feat: Phase 3 — sources management table in 'ניהול' tab
Adds full CRUD for KB sources from the EspoCRM UI: a sortable table at
the top of the 'ניהול' tab with kind / title-identifier-updated / chunk
count / actions columns, a kind filter (חוק / תקנות / חוזרים / פסיקה),
and three per-row actions:
✏ Edit — inline form with title, identifier, dates, source URL
↻ Reingest — re-parses the original file in place, replacing chunks
while keeping the source row + hand-edited metadata
🗑 Delete — confirm dialog with chunk count; double confirm above
100 chunks; also deletes the MinIO object
Browser polls the standard /KnowledgeBase/action/job endpoint for
re-ingest progress (same banner as upload). On terminal status
(done|failed) the sources table auto-refreshes so chunk_count and
last_ingest reflect the new state. Topic switch invalidates the
per-topic admin-sources cache so a stale list doesn't bleed across
topics.
Backend: depends on shira-hermes commit 17f93b5
(GET/PUT/DELETE /admin/kb/sources + POST /reingest).
Refs Task Master #14
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,26 @@
|
||||
|
||||
{{#ifEqual mode 'manage'}}
|
||||
<div class="kb-manage" style="display:flex;flex-direction:column;gap:16px;">
|
||||
<div class="panel panel-default kb-sources-panel" style="padding:12px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
|
||||
<h4 style="margin:0;">מקורות בנושא<span class="kb-topic-name-suffix"></span></h4>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<select class="form-control kb-sources-kind-filter" style="width:auto;">
|
||||
<option value="">כל הסוגים</option>
|
||||
<option value="law">חוק</option>
|
||||
<option value="regulation">תקנות</option>
|
||||
<option value="circular">חוזרים</option>
|
||||
<option value="caselaw">פסיקה</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="refreshSources" title="רענן">
|
||||
<span class="glyphicon glyphicon-refresh"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kb-sources-table">
|
||||
<div class="text-muted">טוען מקורות…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default" style="padding:12px;">
|
||||
<h4 style="margin-top:0;">העלאת מסמך חדש</h4>
|
||||
<form class="kb-upload-form" enctype="multipart/form-data"
|
||||
|
||||
@@ -15,6 +15,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
let _activeJobId = null; // currently-polled ingest job id (across remounts)
|
||||
let _activeJobTopicId = null; // topic this job belongs to (for cross-topic guard)
|
||||
let _pollIntervalId = null; // setInterval handle so remounts don't double-poll
|
||||
let _adminSourcesByTopic = {}; // {topicId: [...sources]} cache for the manage tab
|
||||
|
||||
const SS_ASK = 'kb-last-ask';
|
||||
const SS_SEARCH = 'kb-last-search';
|
||||
@@ -124,6 +125,37 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
e.preventDefault();
|
||||
this.runUpload();
|
||||
},
|
||||
'click [data-action="refreshSources"]': function (e) {
|
||||
e.preventDefault();
|
||||
this._loadAdminSources(/* force */ true);
|
||||
},
|
||||
'change .kb-sources-kind-filter': function () {
|
||||
this._renderAdminSourcesTable();
|
||||
},
|
||||
'click [data-action="editSource"]': function (e) {
|
||||
e.preventDefault();
|
||||
const id = parseInt($(e.currentTarget).data('id'), 10);
|
||||
if (id) this._openSourceEditForm(id);
|
||||
},
|
||||
'click [data-action="cancelEdit"]': function (e) {
|
||||
e.preventDefault();
|
||||
this._renderAdminSourcesTable();
|
||||
},
|
||||
'click [data-action="saveEdit"]': function (e) {
|
||||
e.preventDefault();
|
||||
const id = parseInt($(e.currentTarget).data('id'), 10);
|
||||
if (id) this._saveSourceEdit(id);
|
||||
},
|
||||
'click [data-action="deleteSource"]': function (e) {
|
||||
e.preventDefault();
|
||||
const id = parseInt($(e.currentTarget).data('id'), 10);
|
||||
if (id) this._deleteSource(id);
|
||||
},
|
||||
'click [data-action="reingestSource"]': function (e) {
|
||||
e.preventDefault();
|
||||
const id = parseInt($(e.currentTarget).data('id'), 10);
|
||||
if (id) this._reingestSource(id);
|
||||
},
|
||||
},
|
||||
|
||||
// Cancel any in-flight ask/search, drop the cached last-result for
|
||||
@@ -215,6 +247,8 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
|
||||
// Always refresh the recent-jobs list on mount.
|
||||
this._loadJobsList();
|
||||
// Phase 3: load the sources table when the manage tab is open.
|
||||
this._loadAdminSources();
|
||||
},
|
||||
|
||||
_loadJobsList: function () {
|
||||
@@ -273,6 +307,200 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
$list.html(rows);
|
||||
},
|
||||
|
||||
// ── Phase 3: sources table (Task #14) ──────────────────────────────
|
||||
|
||||
_loadAdminSources: function (force) {
|
||||
const self = this;
|
||||
const $table = this.$el.find('.kb-sources-table');
|
||||
if (!$table.length || this.topicId == null) return;
|
||||
const topicAtFetch = this.topicId;
|
||||
|
||||
// Use cache unless caller asked for a fresh fetch.
|
||||
if (!force && _adminSourcesByTopic[topicAtFetch]) {
|
||||
this._renderAdminSourcesTable();
|
||||
return;
|
||||
}
|
||||
|
||||
$table.html('<div class="text-muted">טוען מקורות…</div>');
|
||||
Espo.Ajax.getRequest('KnowledgeBase/action/adminSources', {
|
||||
topicId: topicAtFetch,
|
||||
limit: 200,
|
||||
}).then(res => {
|
||||
if (self.topicId !== topicAtFetch) return;
|
||||
_adminSourcesByTopic[topicAtFetch] = res && res.items || [];
|
||||
self._renderAdminSourcesTable();
|
||||
}).catch(err => {
|
||||
console.error('KB: failed to load admin sources', err);
|
||||
$table.html('<div class="text-muted">שגיאה בטעינת מקורות.</div>');
|
||||
});
|
||||
},
|
||||
|
||||
_renderAdminSourcesTable: function () {
|
||||
const $table = this.$el.find('.kb-sources-table');
|
||||
if (!$table.length || this.topicId == null) return;
|
||||
const all = _adminSourcesByTopic[this.topicId] || [];
|
||||
const filterKind = (this.$el.find('.kb-sources-kind-filter').val() || '').trim();
|
||||
const items = filterKind ? all.filter(s => s.kind === filterKind) : all;
|
||||
|
||||
if (!items.length) {
|
||||
$table.html('<div class="text-muted">' + (all.length ? 'אין מקורות בסוג זה.' : 'אין מקורות בנושא הזה.') + '</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזר', caselaw: 'פסיקה'};
|
||||
const rows = items.map(s => {
|
||||
const li = s.last_ingest || {};
|
||||
const failedFlag = li.status === 'failed'
|
||||
? `<span class="label label-danger" title="${this.escape(li.error_message || '')}">כשל בקריאה אחרונה</span>`
|
||||
: '';
|
||||
const updated = String(s.updated_at || s.created_at || '').slice(0, 10);
|
||||
const ident = s.identifier ? this.escape(s.identifier) : '<span class="text-muted">—</span>';
|
||||
return `<tr data-id="${s.id}">
|
||||
<td><span class="label label-default">${kindHe[s.kind] || s.kind}</span></td>
|
||||
<td>
|
||||
<div class="kb-src-title" style="font-weight:500;">${this.escape(s.title || '—')}</div>
|
||||
<div class="text-muted small">${ident} · ${this.escape(updated)} ${failedFlag}</div>
|
||||
</td>
|
||||
<td class="text-center">${s.chunk_count}</td>
|
||||
<td style="white-space:nowrap;text-align:left;">
|
||||
<button class="btn btn-link btn-sm" data-action="editSource" data-id="${s.id}" title="ערוך"><span class="glyphicon glyphicon-pencil"></span></button>
|
||||
<button class="btn btn-link btn-sm" data-action="reingestSource" data-id="${s.id}" title="עיבוד מחדש"><span class="glyphicon glyphicon-refresh"></span></button>
|
||||
<button class="btn btn-link btn-sm" data-action="deleteSource" data-id="${s.id}" title="מחק" style="color:#d9534f;"><span class="glyphicon glyphicon-trash"></span></button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
$table.html(`
|
||||
<table class="table table-condensed table-hover" style="margin:0;">
|
||||
<thead>
|
||||
<tr><th style="width:80px;">סוג</th><th>כותרת / מזהה / עודכן</th><th style="width:60px;text-align:center;">קטעים</th><th style="width:140px;"></th></tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`);
|
||||
},
|
||||
|
||||
_findSourceById: function (id) {
|
||||
const arr = _adminSourcesByTopic[this.topicId] || [];
|
||||
return arr.find(s => s.id === id);
|
||||
},
|
||||
|
||||
_openSourceEditForm: function (id) {
|
||||
const src = this._findSourceById(id);
|
||||
if (!src) return;
|
||||
const $row = this.$el.find(`.kb-sources-table tr[data-id="${id}"]`);
|
||||
if (!$row.length) return;
|
||||
const safe = (v) => this.escape(v == null ? '' : String(v));
|
||||
const dateOnly = (v) => v ? String(v).slice(0, 10) : '';
|
||||
$row.html(`
|
||||
<td colspan="4">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;padding:8px;background:#fafafa;border-radius:4px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">כותרת:</label>
|
||||
<input type="text" class="form-control" data-edit="title" value="${safe(src.title)}" style="flex:1 1 240px;" />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">מזהה:</label>
|
||||
<input type="text" class="form-control" data-edit="identifier" value="${safe(src.identifier)}" style="flex:1 1 240px;" />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">פורסם:</label>
|
||||
<input type="date" class="form-control" data-edit="published_at" value="${safe(dateOnly(src.published_at))}" style="width:170px;" />
|
||||
<label class="text-muted" style="margin:0;font-weight:normal;">תוקף מ:</label>
|
||||
<input type="date" class="form-control" data-edit="effective_at" value="${safe(dateOnly(src.effective_at))}" style="width:170px;" />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">קישור:</label>
|
||||
<input type="url" class="form-control" data-edit="source_url" value="${safe(src.source_url)}" style="flex:1 1 240px;" />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:4px;">
|
||||
<button class="btn btn-default btn-sm" data-action="cancelEdit">ביטול</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="saveEdit" data-id="${id}">שמור</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
`);
|
||||
$row.find('input[data-edit="title"]').trigger('focus');
|
||||
},
|
||||
|
||||
_saveSourceEdit: function (id) {
|
||||
const self = this;
|
||||
const $row = this.$el.find(`.kb-sources-table tr[data-id="${id}"]`);
|
||||
if (!$row.length) return;
|
||||
const payload = {id};
|
||||
$row.find('input[data-edit]').each(function () {
|
||||
const k = $(this).data('edit');
|
||||
payload[k] = ($(this).val() || '').trim();
|
||||
});
|
||||
const $btn = $row.find('[data-action="saveEdit"]');
|
||||
$btn.prop('disabled', true).text('שומר…');
|
||||
Espo.Ajax.postRequest('KnowledgeBase/action/updateSource', payload)
|
||||
.then(updated => {
|
||||
// Replace cached row in place so the table re-renders with new metadata.
|
||||
const arr = _adminSourcesByTopic[self.topicId] || [];
|
||||
const idx = arr.findIndex(s => s.id === id);
|
||||
if (idx >= 0) {
|
||||
arr[idx] = Object.assign({}, arr[idx], updated);
|
||||
}
|
||||
self._renderAdminSourcesTable();
|
||||
})
|
||||
.catch(err => {
|
||||
$btn.prop('disabled', false).text('שמור');
|
||||
const msg = (err && err.responseText) || 'שגיאה לא ידועה';
|
||||
alert('שמירה נכשלה: ' + msg.slice(0, 200));
|
||||
});
|
||||
},
|
||||
|
||||
_deleteSource: function (id) {
|
||||
const src = this._findSourceById(id);
|
||||
if (!src) return;
|
||||
const cn = src.chunk_count || 0;
|
||||
const titleShort = (src.title || '').slice(0, 60);
|
||||
let warn = `למחוק את "${titleShort}"?\n\n` +
|
||||
`מקור עם ${cn} קטעים יימחק לצמיתות מחיפוש ה-KB.\n` +
|
||||
`הקובץ ב-MinIO גם יימחק.`;
|
||||
if (!confirm(warn)) return;
|
||||
if (cn > 100) {
|
||||
if (!confirm(`למקור הזה יש מעל 100 קטעים (${cn}). אישור סופי?`)) return;
|
||||
}
|
||||
const self = this;
|
||||
Espo.Ajax.postRequest('KnowledgeBase/action/deleteSource', {id})
|
||||
.then(res => {
|
||||
// Drop from cache and re-render.
|
||||
const arr = _adminSourcesByTopic[self.topicId] || [];
|
||||
_adminSourcesByTopic[self.topicId] = arr.filter(s => s.id !== id);
|
||||
self._renderAdminSourcesTable();
|
||||
})
|
||||
.catch(err => {
|
||||
const msg = (err && err.responseText) || 'שגיאה לא ידועה';
|
||||
alert('מחיקה נכשלה: ' + msg.slice(0, 200));
|
||||
});
|
||||
},
|
||||
|
||||
_reingestSource: function (id) {
|
||||
const src = this._findSourceById(id);
|
||||
if (!src) return;
|
||||
const titleShort = (src.title || '').slice(0, 60);
|
||||
if (!confirm(`לעבד מחדש את "${titleShort}"?\n\nהקטעים הקיימים יימחקו והקובץ יעובר parse + chunk + embed מחדש.\nהמטא-דאטה (כותרת, מזהה, תאריכים) נשמרים.`)) return;
|
||||
const self = this;
|
||||
Espo.Ajax.postRequest('KnowledgeBase/action/reingestSource', {id})
|
||||
.then(res => {
|
||||
if (res && res.job_id) {
|
||||
// Surface in the upload-status banner + jobs list, like an upload would.
|
||||
self._renderJobStatus({
|
||||
original_filename: src.title || ('source #' + id),
|
||||
status: res.status || 'queued',
|
||||
});
|
||||
self._startJobPolling(res.job_id);
|
||||
// Refresh sources after the polling reports done.
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
const msg = (err && err.responseText) || 'שגיאה לא ידועה';
|
||||
alert('עיבוד מחדש נכשל: ' + msg.slice(0, 200));
|
||||
});
|
||||
},
|
||||
|
||||
// Live status panel rendered above the upload form during processing.
|
||||
_renderJobStatus: function (job) {
|
||||
const $form = this.$el.find('.kb-upload-form');
|
||||
@@ -316,8 +544,12 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
self._stopJobPolling();
|
||||
_activeJobId = null;
|
||||
_activeJobTopicId = null;
|
||||
// Refresh the recent-jobs list to surface the new row.
|
||||
// Refresh the recent-jobs list to surface the new row,
|
||||
// and the admin sources table — re-ingest changed
|
||||
// chunk_count + last_ingest fields, and a fresh
|
||||
// upload added a new row.
|
||||
self._loadJobsList();
|
||||
self._loadAdminSources(/* force */ true);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -475,6 +707,9 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
this._stopJobPolling();
|
||||
_activeJobId = null;
|
||||
_activeJobTopicId = null;
|
||||
// Phase 3: a topic change invalidates the admin sources view
|
||||
// (different scope) — drop the cache so afterRender re-fetches.
|
||||
_adminSourcesByTopic = {};
|
||||
this.stopAskProgress();
|
||||
this.setLoading(false);
|
||||
this.$el.find('.kb-results').empty();
|
||||
|
||||
Reference in New Issue
Block a user