feat(KB): v0.8.0 — bulk upload, AI classifier, labels, tool kind

v0.8.0 ships the four-feature bundle from the v0.8.0 plan plus
mid-flight fixes discovered when batch-testing 5 circulars.

New capabilities:
  * Multi-file batch upload. AI classifier (Sonnet via ai-gateway)
    reads the first 3 pages of each PDF and proposes kind / title /
    identifier / labels / dates / summary. User reviews + edits per
    card before committing. Two-phase ingest: classify →
    awaiting_review → embed.
  * 'tool' kind for academic assessment instruments (GMFCS, MACS).
  * Labels (kb_label / kb_source_label) for sub-topic navigation;
    classifier proposes shared label slugs so the graph builds
    itself.
  * 'תוויות' admin sub-section for merge/delete of unused labels.
  * NEW 'ממתינים לאישור' panel: lists batches still in
    awaiting_review with status counters so a hard-refreshed user
    can resume their review — closes the RAM-only _activeBatch gap.

Mid-flight fixes folded in:
  * classifier timeout 45s→90s, concurrency 5→2. ai-gateway
    serializes through a single Claude OAuth session; with conc=5,
    jobs 4-5 of a 5-file batch consistently timed out.
  * labels schema array-of-string → comma-separated string. Claude
    via ai-gateway returned [{}, {}, {}] for items:{type:"string"}.
    _normalize accepts both shapes defensively.

Backfilled ai_classified_at + description + 1-3 labels for the 10
sources ingested in earlier sessions so the filter UX is uniform.

Backend (shira-hermes) in commits 0d678da (Phase 6) + bb23b8a
(this session's fixes).

Refs Task Master #20

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 20:18:34 +00:00
parent 30a9c397e8
commit 8a4f16a50f
7 changed files with 1020 additions and 66 deletions
@@ -17,6 +17,9 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
let _pollIntervalId = null; // setInterval handle so remounts don't double-poll
let _adminSourcesByTopic = {}; // {topicId: [...sources]} cache for the manage tab
let _adminTopics = null; // [...topics admin view] cache (incl. is_active=false)
let _adminLabels = null; // labels admin cache
let _activeBatch = null; // {batchId, kind, cards:[{jobId, filename, status, ai, edits, labels}]}
let _batchPollIntervalId = null;
const SS_ASK = 'kb-last-ask';
const SS_SEARCH = 'kb-last-search';
@@ -190,6 +193,49 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
const id = parseInt($(e.currentTarget).data('id'), 10);
if (id) this._deleteTopic(id);
},
// ── Phase 6 (v0.8.0): batch upload + labels admin ──────────────
'click [data-action="pickBatchFiles"]': function (e) {
e.preventDefault();
this.$el.find('#kb-batch-files').trigger('click');
},
'change #kb-batch-files': function (e) {
const files = Array.from(e.target.files || []);
if (files.length) this._uploadBatch(files);
e.target.value = '';
},
'click [data-action="discardCard"]': function (e) {
e.preventDefault();
const jobId = parseInt($(e.currentTarget).data('jobid'), 10);
if (jobId) this._discardCard(jobId);
},
'click [data-action="commitAllBatch"]': function (e) {
e.preventDefault();
this._commitAllBatch();
},
'click [data-action="discardAllBatch"]': function (e) {
e.preventDefault();
this._discardAllBatch();
},
'click [data-action="refreshLabels"]': function (e) {
e.preventDefault();
this._loadAdminLabels(/*force*/ true);
},
'click [data-action="deleteLabel"]': function (e) {
e.preventDefault();
const id = parseInt($(e.currentTarget).data('id'), 10);
if (id) this._deleteLabel(id);
},
'click [data-action="refreshPending"]': function (e) {
e.preventDefault();
this._loadPendingBatches();
},
'click [data-action="openBatch"]': function (e) {
e.preventDefault();
const batchId = $(e.currentTarget).data('batchid');
const topicAttr = $(e.currentTarget).data('topicid');
const topicId = (topicAttr === '' || topicAttr == null) ? null : parseInt(topicAttr, 10);
if (batchId) this._openBatch(String(batchId), topicId);
},
},
// Cancel any in-flight ask/search, drop the cached last-result for
@@ -285,6 +331,17 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
this._loadAdminSources();
// Phase 4: load the topics admin table (firm-wide, not topic-scoped).
this._loadAdminTopics();
// Phase 6: load the labels admin table.
this._loadAdminLabels();
// Phase 6: load batches that still need review (RAM-only
// _activeBatch is wiped by hard refresh; this lets the user
// resume any pending batch from the panel).
this._loadPendingBatches();
// Phase 6: redraw any in-flight batch upload (survives remount).
if (_activeBatch) {
this._renderBatchCards();
if (this._batchHasClassifying()) this._startBatchPolling();
}
},
_loadJobsList: function () {
@@ -317,7 +374,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
done: '<span class="label label-success">הושלם</span>',
failed: '<span class="label label-danger">נכשל</span>',
};
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזר', caselaw: 'פסיקה'};
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזר', caselaw: 'פסיקה', tool: 'כלי הערכה'};
const rows = items.map(j => {
const when = j.completed_at || j.started_at || j.created_at || '';
const whenShort = String(when).slice(0, 19).replace('T', ' ');
@@ -618,7 +675,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
return;
}
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזר', caselaw: 'פסיקה'};
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזר', caselaw: 'פסיקה', tool: 'כלי הערכה'};
const rows = items.map(s => {
const li = s.last_ingest || {};
const failedFlag = li.status === 'failed'
@@ -772,6 +829,506 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
});
},
// ── Phase 6 (v0.8.0): labels admin ─────────────────────────────────
_loadAdminLabels: function (force) {
const $table = this.$el.find('.kb-labels-table');
if (!$table.length) return;
if (!force && _adminLabels) {
this._renderLabelsTable();
return;
}
const self = this;
Espo.Ajax.getRequest('KnowledgeBase/action/labels', {
topicId: this.topicId,
limit: 200,
}).then(res => {
_adminLabels = res && res.items || [];
self._renderLabelsTable();
}).catch(err => {
console.error('KB: load labels failed', err);
$table.html('<div class="text-muted">שגיאה בטעינת תוויות.</div>');
});
},
_renderLabelsTable: function () {
const $table = this.$el.find('.kb-labels-table');
if (!$table.length) return;
const items = _adminLabels || [];
if (!items.length) {
$table.html('<div class="text-muted">אין תוויות עדיין. הן ייווצרו אוטומטית מההצעות של שירה בעת ההעלאה.</div>');
return;
}
const rows = items.map(l => `<tr data-id="${l.id}">
<td><span style="font-weight:500;">${this.escape(l.name)}</span></td>
<td><code class="text-muted small" style="direction:ltr;">${this.escape(l.slug)}</code></td>
<td class="text-center">${l.usage_count || 0}</td>
<td style="text-align:left;">
${(l.usage_count || 0) === 0
? `<button class="btn btn-link btn-sm" data-action="deleteLabel" data-id="${l.id}" title="מחק" style="color:#d9534f;"><span class="glyphicon glyphicon-trash"></span></button>`
: '<span class="text-muted small">בשימוש</span>'}
</td>
</tr>`).join('');
$table.html(`
<table class="table table-condensed table-hover" style="margin:0;">
<thead>
<tr><th>שם תווית</th><th style="width:200px;">slug</th><th style="width:80px;text-align:center;">שימושים</th><th style="width:80px;"></th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
`);
},
_deleteLabel: function (id) {
const lbl = (_adminLabels || []).find(l => l.id === id);
if (!lbl) return;
if (!confirm(`למחוק את התווית "${lbl.name}" (${lbl.slug})?`)) return;
const self = this;
Espo.Ajax.postRequest('KnowledgeBase/action/deleteLabel', {id})
.then(() => {
_adminLabels = (_adminLabels || []).filter(l => l.id !== id);
self._renderLabelsTable();
})
.catch(err => {
let msg = 'שגיאה לא ידועה';
try { const j = JSON.parse(err && err.responseText || '{}'); msg = j.message || j.detail || msg; } catch (e) {}
alert('מחיקה נכשלה: ' + String(msg).slice(0, 200));
});
},
// ── Phase 6: batch upload + AI review ──────────────────────────────
// Pending-review panel: resumes any batch the user uploaded
// earlier and didn't commit. _activeBatch is RAM-only, so a hard
// refresh wipes the review screen — without this list the only
// recovery path was the DB.
_loadPendingBatches: function () {
const self = this;
const $list = this.$el.find('.kb-pending-list');
const $count = this.$el.find('.kb-pending-count');
if (!$list.length) return;
Espo.Ajax.getRequest('KnowledgeBase/action/pendingBatches', {limit: 50})
.then(res => {
const items = (res && res.items) || [];
self._renderPendingBatches(items);
$count.text(items.length ? ` (${items.length})` : '');
})
.catch(err => {
console.error('KB: failed to load pending batches', err);
$list.html('<div class="text-muted">שגיאה בטעינת ממתינים לאישור.</div>');
});
},
_renderPendingBatches: function (items) {
const $list = this.$el.find('.kb-pending-list');
if (!$list.length) return;
if (!items.length) {
$list.html('<div class="text-muted">אין באצ׳ים ממתינים לאישור.</div>');
return;
}
const kindHe = {
law: 'חוק', regulation: 'תקנות', circular: 'חוזרים',
caselaw: 'פסיקה', tool: 'כלי הערכה',
};
const escape = this.escape.bind(this);
const html = items.map(b => {
const kinds = (b.kinds || []).map(k => kindHe[k] || k).join(', ');
const time = b.created_at ? new Date(b.created_at).toLocaleString('he-IL') : '';
const counters = [];
if (b.awaiting_review) counters.push(`<span class="label label-success">${b.awaiting_review} מוכנים</span>`);
if (b.processing) counters.push(`<span class="label label-info">${b.processing} בתהליך</span>`);
if (b.queued) counters.push(`<span class="label label-default">${b.queued} בתור</span>`);
if (b.failed) counters.push(`<span class="label label-danger">${b.failed} נכשלו</span>`);
if (b.done) counters.push(`<span class="label label-success">${b.done} הושלמו</span>`);
const isCurrent = _activeBatch && _activeBatch.batchId === b.batch_id;
const more = b.total > 1 ? ` <span class="text-muted">+ ${b.total - 1} נוספים</span>` : '';
return `<div class="kb-pending-row" style="display:flex;justify-content:space-between;align-items:center;gap:10px;padding:8px;border-bottom:1px solid #eee;">
<div style="flex:1;min-width:0;">
<div style="font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">📦 ${escape(b.first_filename || '(ללא שם)')}${more}</div>
<div class="text-muted small">${escape(kinds)} · ${escape(time)} · ${counters.join(' ')}</div>
</div>
<button type="button" class="btn btn-${isCurrent ? 'default' : 'primary'} btn-sm" data-action="openBatch" data-batchid="${escape(b.batch_id)}" data-topicid="${b.topic_id || ''}"${isCurrent ? ' disabled title="פתוח כרגע"' : ''}>${isCurrent ? 'פתוח' : 'פתח'}</button>
</div>`;
}).join('');
$list.html(html);
},
_openBatch: function (batchId, topicId) {
const self = this;
if (!batchId) return;
if (_activeBatch && _activeBatch.batchId === batchId) {
this._scrollToBatch();
return;
}
// If the batch belongs to a different topic, switch — labels
// and source views are topic-scoped, the user should see the
// matching context.
if (topicId && this.topicId !== topicId) {
this.switchTopic(topicId);
}
this._stopBatchPolling();
Espo.Ajax.getRequest('KnowledgeBase/action/batch', {batchId})
.then(res => {
const items = (res && res.items) || [];
if (!items.length) {
alert('הבאצ׳ ריק או נמחק.');
return;
}
const cards = items.map(it => {
let ai = it.ai_suggestions;
if (typeof ai === 'string') {
try { ai = JSON.parse(ai); } catch (e) { ai = {}; }
}
let s = it.status;
if (s === 'processing' && it.processing_stage === 'classifying') s = 'classifying';
else if (s === 'processing' && it.processing_stage === 'embedding') s = 'committing';
return {
jobId: it.id,
filename: it.original_filename,
status: s,
ai: (ai && Object.keys(ai).length) ? ai : null,
edits: null,
error: it.error_message || null,
};
});
_activeBatch = {
batchId: batchId,
kind: (items[0] && items[0].kind) || 'circular',
topicId: topicId || self.topicId,
cards,
};
self._renderBatchCards();
// Repaint pending list so the opened row shows "פתוח" instead of "פתח".
self._loadPendingBatches();
self._scrollToBatch();
if (self._batchHasClassifying() || self._batchHasCommitting()) {
self._startBatchPolling();
}
})
.catch(err => {
console.error('KB: failed to open batch', err);
alert('פתיחת הבאצ׳ נכשלה.');
});
},
_scrollToBatch: function () {
const $batch = this.$el.find('.kb-batch-panel');
if ($batch.length && $batch[0].scrollIntoView) {
$batch[0].scrollIntoView({behavior: 'smooth', block: 'start'});
}
},
_uploadBatch: function (fileList) {
const $kindSel = this.$el.find('#kb-batch-kind');
const kind = $kindSel.val() || 'circular';
const topicId = this.topicId;
if (topicId == null) {
alert('בחר נושא לפני העלאה.');
return;
}
// Filter on size + extension client-side; server enforces too.
const ok = [];
for (const f of fileList) {
if (!/\.(pdf|docx|txt)$/i.test(f.name)) continue;
if (f.size > 50 * 1024 * 1024) {
alert(`הקובץ "${f.name}" גדול מ-50MB ולא יועלה.`);
continue;
}
ok.push(f);
}
if (!ok.length) return;
const fd = new FormData();
fd.append('kind', kind);
fd.append('topicId', String(topicId));
// PHP-side $_FILES['files'] requires the bracket suffix to
// recognize repeated fields as a multi-file array. The
// EspoCRM controller un-array's into a plain `files` field
// before forwarding to shira-hermes (FastAPI standard).
for (const f of ok) fd.append('files[]', f, f.name);
// Initialize cards in placeholder state. They get the real
// job_id from the server response.
_activeBatch = {
batchId: null,
kind,
topicId,
cards: ok.map(f => ({
jobId: null,
filename: f.name,
status: 'uploading',
ai: null,
edits: null,
})),
};
this._renderBatchCards();
const self = this;
$.ajax({
url: 'api/v1/KnowledgeBase/action/uploadBatch',
method: 'POST',
data: fd,
contentType: false,
processData: false,
cache: false,
timeout: 300000,
}).then(res => {
if (!_activeBatch) return;
_activeBatch.batchId = res.batch_id;
// Match returned jobs to local cards by filename order.
(res.jobs || []).forEach((j, i) => {
if (_activeBatch.cards[i]) {
_activeBatch.cards[i].jobId = j.job_id || null;
_activeBatch.cards[i].status = j.error ? 'failed' : 'queued';
if (j.error) _activeBatch.cards[i].error = j.error;
}
});
self._renderBatchCards();
self._startBatchPolling();
}).fail(xhr => {
let msg = 'שגיאה לא ידועה';
try {
const j = JSON.parse(xhr.responseText || '{}');
msg = j.message || j.detail || xhr.responseText || msg;
} catch (e) { msg = xhr.responseText || msg; }
alert('העלאה נכשלה: ' + String(msg).slice(0, 300));
_activeBatch = null;
self._renderBatchCards();
});
},
_renderBatchCards: function () {
const $cards = this.$el.find('.kb-batch-cards');
const $actions = this.$el.find('.kb-batch-actions');
if (!$cards.length) return;
if (!_activeBatch || !_activeBatch.cards.length) {
$cards.empty();
$actions.hide();
return;
}
const statusLabel = (s) => ({
uploading: '<span class="label label-default">מעלה…</span>',
queued: '<span class="label label-default">בתור</span>',
classifying: '<span class="label label-info">מנתח מטא-דאטה…</span>',
awaiting_review: '<span class="label label-success">[✓ מוכן]</span>',
committing: '<span class="label label-info">מטמיע…</span>',
done: '<span class="label label-success">[✓ הושלם]</span>',
failed: '<span class="label label-danger">[⚠️ נכשל]</span>',
}[s] || `<span class="label label-default">${this.escape(s)}</span>`);
const kindOptions = (selected) => {
const opts = [
['law', 'חוק'],
['regulation', 'תקנות'],
['circular', 'חוזר'],
['caselaw', 'פסיקה'],
['tool', 'כלי הערכה'],
];
return opts.map(([v, l]) =>
`<option value="${v}"${v === selected ? ' selected' : ''}>${l}</option>`
).join('');
};
let readyCount = 0;
const html = _activeBatch.cards.map((c, idx) => {
if (c.status === 'awaiting_review') readyCount++;
const ai = c.ai || {};
const e = c.edits || {};
const val = (k) => this.escape(e[k] != null ? e[k] : (ai[k] || ''));
const labelsStr = e.labelsStr != null ? e.labelsStr : ((ai.labels || []).join(', '));
let body = '';
if (c.status === 'failed') {
body = `<div class="text-danger small">${this.escape(c.error || 'שגיאה')}</div>`;
} else if (c.status === 'awaiting_review' || c.status === 'committing' || c.status === 'done') {
const disabled = c.status !== 'awaiting_review' ? ' disabled' : '';
body = `
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<select class="form-control" data-card="${idx}" data-edit="kind" style="width:auto;"${disabled}>${kindOptions(e.kind || ai.kind || _activeBatch.kind)}</select>
<input type="text" class="form-control" data-card="${idx}" data-edit="title" value="${val('title')}" placeholder="כותרת" style="flex:1 1 240px;"${disabled} />
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<input type="text" class="form-control" data-card="${idx}" data-edit="identifier" value="${val('identifier')}" placeholder="מזהה (אופציונלי)" style="flex:1 1 200px;"${disabled} />
<input type="date" class="form-control" data-card="${idx}" data-edit="published_at" value="${this.escape((e.published_at != null ? e.published_at : (ai.published_at || '')).slice(0,10))}" style="width:160px;"${disabled} />
</div>
<input type="text" class="form-control" data-card="${idx}" data-edit="labelsStr" value="${this.escape(labelsStr)}" placeholder="תוויות (מופרדות בפסיק)" style="width:100%;"${disabled} />
<textarea class="form-control" rows="2" data-card="${idx}" data-edit="summary" placeholder="סיכום קצר" style="width:100%;"${disabled}>${this.escape(e.summary != null ? e.summary : (ai.summary || ''))}</textarea>
`;
} else {
body = '<div class="text-muted small">ממתין לניתוח…</div>';
}
return `<div class="kb-batch-card panel" data-card="${idx}" style="padding:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px;">
<div style="font-weight:500;">📄 ${this.escape(c.filename)}</div>
<div style="display:flex;gap:8px;align-items:center;">
${statusLabel(c.status)}
${c.status !== 'done' && c.status !== 'committing' ? `<button class="btn btn-link btn-sm" data-action="discardCard" data-jobid="${c.jobId || 0}" title="הסר" style="color:#d9534f;"><span class="glyphicon glyphicon-remove"></span></button>` : ''}
</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px;">${body}</div>
</div>`;
}).join('');
$cards.html(html);
$actions.toggle(_activeBatch.cards.length > 0);
this.$el.find('.kb-batch-commit-count').text(readyCount > 0 ? `(${readyCount})` : '');
this.$el.find('[data-action="commitAllBatch"]').prop('disabled', readyCount === 0);
// Wire input change → save into card.edits
const self = this;
$cards.find('[data-edit]').off('input.batch change.batch').on('input.batch change.batch', function () {
const idx = parseInt($(this).data('card'), 10);
const k = $(this).data('edit');
if (_activeBatch && _activeBatch.cards[idx]) {
if (!_activeBatch.cards[idx].edits) _activeBatch.cards[idx].edits = {};
_activeBatch.cards[idx].edits[k] = $(this).val();
}
});
},
_batchHasClassifying: function () {
return _activeBatch && _activeBatch.cards.some(c =>
c.status === 'queued' || c.status === 'classifying' || c.status === 'uploading'
);
},
_startBatchPolling: function () {
this._stopBatchPolling();
const self = this;
// Refresh the pending list once at start so a freshly-uploaded
// batch appears there right away (without waiting for the
// batch to quiesce).
this._loadPendingBatches();
const tick = () => {
if (!_activeBatch || !_activeBatch.batchId) return;
Espo.Ajax.getRequest('KnowledgeBase/action/batch', {batchId: _activeBatch.batchId})
.then(res => {
if (!_activeBatch) return;
const byId = {};
for (const it of res.items || []) byId[it.id] = it;
for (const card of _activeBatch.cards) {
if (!card.jobId) continue;
const it = byId[card.jobId];
if (!it) continue;
// Map server status → card status
let s = it.status;
if (s === 'processing' && it.processing_stage === 'classifying') s = 'classifying';
else if (s === 'processing' && it.processing_stage === 'embedding') s = 'committing';
card.status = s;
card.error = it.error_message || null;
// Parse ai_suggestions if string
let ai = it.ai_suggestions;
if (typeof ai === 'string') {
try { ai = JSON.parse(ai); } catch (e) { ai = {}; }
}
if (ai && Object.keys(ai).length) card.ai = ai;
}
self._renderBatchCards();
if (!self._batchHasClassifying() && !self._batchHasCommitting()) {
self._stopBatchPolling();
// Refresh sources/jobs/labels list since new ones may have landed.
if (_activeBatch.cards.some(c => c.status === 'done')) {
self._loadJobsList();
self._loadAdminSources(true);
self._loadAdminLabels(true);
}
// Pending list reflects awaiting_review counters
// — refresh on every batch quiescence so commits
// and discards drop the row out cleanly.
self._loadPendingBatches();
}
})
.catch(err => console.error('KB: batch poll failed', err));
};
tick();
_batchPollIntervalId = setInterval(tick, 3000);
},
_stopBatchPolling: function () {
if (_batchPollIntervalId) {
clearInterval(_batchPollIntervalId);
_batchPollIntervalId = null;
}
},
_batchHasCommitting: function () {
return _activeBatch && _activeBatch.cards.some(c => c.status === 'committing');
},
_commitAllBatch: function () {
if (!_activeBatch) return;
const self = this;
const ready = _activeBatch.cards.filter(c => c.status === 'awaiting_review' && c.jobId);
if (!ready.length) return;
for (const card of ready) {
const ai = card.ai || {};
const e = card.edits || {};
const labelsStr = e.labelsStr != null ? e.labelsStr : ((ai.labels || []).join(', '));
const newLabels = labelsStr
.split(',')
.map(s => s.trim())
.filter(s => s.length);
const payload = {
id: card.jobId,
kind: e.kind || ai.kind || _activeBatch.kind,
title: (e.title != null ? e.title : (ai.title || card.filename)).trim() || card.filename,
identifier: (e.identifier != null ? e.identifier : (ai.identifier || '')).trim() || null,
published_at: (e.published_at != null ? e.published_at : (ai.published_at || '')) || null,
effective_at: (e.effective_at != null ? e.effective_at : (ai.effective_at || '')) || null,
source_url: (e.source_url != null ? e.source_url : (ai.source_url || '')) || null,
summary: (e.summary != null ? e.summary : (ai.summary || '')) || null,
label_slugs: [],
new_labels: newLabels,
};
card.status = 'committing';
Espo.Ajax.postRequest('KnowledgeBase/action/commitJob', payload)
.catch(err => {
console.error('KB: commit failed for job', card.jobId, err);
card.status = 'failed';
card.error = (err && err.responseText) || 'commit failed';
self._renderBatchCards();
});
}
self._renderBatchCards();
self._startBatchPolling();
},
_discardCard: function (jobId) {
if (!_activeBatch) return;
const idx = _activeBatch.cards.findIndex(c => c.jobId === jobId);
if (idx < 0) return;
// If never queued (server failed before assigning jobId), just drop locally.
if (!jobId) {
_activeBatch.cards.splice(idx, 1);
if (!_activeBatch.cards.length) _activeBatch = null;
this._renderBatchCards();
return;
}
const self = this;
Espo.Ajax.postRequest('KnowledgeBase/action/discardJob', {id: jobId})
.finally(() => {
_activeBatch.cards.splice(idx, 1);
if (!_activeBatch.cards.length) _activeBatch = null;
self._renderBatchCards();
});
},
_discardAllBatch: function () {
if (!_activeBatch) return;
if (!confirm(`לבטל את כל ${_activeBatch.cards.length} הקבצים בקבוצה?`)) return;
const self = this;
const promises = _activeBatch.cards
.filter(c => c.jobId && c.status !== 'done')
.map(c => Espo.Ajax.postRequest('KnowledgeBase/action/discardJob', {id: c.jobId})
.catch(() => null));
Promise.all(promises).finally(() => {
_activeBatch = null;
self._stopBatchPolling();
self._renderBatchCards();
self._loadJobsList();
});
},
// Live status panel rendered above the upload form during processing.
_renderJobStatus: function (job) {
const $form = this.$el.find('.kb-upload-form');
@@ -1319,7 +1876,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
return;
}
const initialIdx = (typeof selectedIdx === 'number' && hits[selectedIdx]) ? selectedIdx : 0;
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר', caselaw: 'פסיקה'};
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר', caselaw: 'פסיקה', tool: 'כלי'};
const self = this;
const isPdfBacked = h => h && (h.kind === 'regulation' || h.kind === 'circular');
@@ -1618,7 +2175,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
answerHtml = '<div style="white-space:pre-wrap;">' + this.escape(text || '') + '</div>';
}
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר', caselaw: 'פסיקה'};
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר', caselaw: 'פסיקה', tool: 'כלי'};
// Dedup sources by source_id (first occurrence = most relevant),
// but remember every page that came up per source so users can
@@ -1758,8 +2315,8 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
$list.html('<div class="text-muted">אין מקורות.</div>');
return;
}
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזרים', caselaw: 'פסיקה'};
const grouped = {law: [], regulation: [], circular: [], caselaw: []};
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזרים', caselaw: 'פסיקה', tool: 'כלי הערכה'};
const grouped = {law: [], regulation: [], circular: [], caselaw: [], tool: []};
this._sources.forEach(s => {
if (grouped[s.kind]) grouped[s.kind].push(s);
});