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:
@@ -165,7 +165,7 @@
|
||||
"id": "14",
|
||||
"title": "Phase 3 — Management panel UI (sources table, edit metadata, delete, re-ingest)",
|
||||
"description": "Today the only way to manage existing sources is via SQL on the shared PG (DELETE FROM kb_source WHERE id=…) plus an mc command on MinIO. End users need a panel where they can: see all sources for the selected topic, with chunk counts and ingestion dates; edit source metadata (title, identifier, dates, source_url); delete a source (with confirm and clear cascade messaging); re-ingest a source (re-fetch its file from MinIO processed/, drop chunks, re-run parse+chunk+embed); see the most recent ingest jobs (success + failure both). The panel sits in the same KB extension under a new tab 'ניהול'.",
|
||||
"status": "pending",
|
||||
"status": "in-progress",
|
||||
"priority": "high",
|
||||
"details": "shira-hermes endpoints:\n- GET /admin/kb/sources?topic_id=&kind=&limit=: list with id, kind, title, identifier, chunk_count, ingestion_date, last_ingest_status (joining kb_ingest_job).\n- PUT /admin/kb/sources/{id}: update editable fields (title, identifier, source_url, published_at, effective_at).\n- DELETE /admin/kb/sources/{id}: hard delete (cascades chunks via FK, drops processed/ files in S3 too).\n- POST /admin/kb/sources/{id}/reingest: requires source_id; finds the original file at original_path s3:// URI; creates a new kb_ingest_job row with status=queued and the existing source_id; background task replaces chunks+embeddings (does NOT change source_id, so all v0.1.11 chunk_index references remain valid IF the chunker output is stable; otherwise old _lastSearch sessionStorage will point at chunk_indexes that no longer exist — flag for testing). Should optionally just delete + re-create the source — simpler.\n\nEspoCRM client:\n- 'ניהול' tab. Top: header showing topic name + a count badge ('X מסמכים בנושא'). Below: 3 collapsed sections — uploaded jobs (collapsed if all done), sources table, recent failures.\n- Sources table: id, kind label, title, identifier, chunk_count, ingestion_date. Rightmost column: action buttons (✏ edit, 🗑 delete, ↻ re-ingest, 👁 view in browse mode).\n- Edit: opens a modal with the editable fields. PUT on save.\n- Delete: confirm modal that shows chunk_count + 'this will also remove the PDF from the search results'. Two-step confirmation if source has >100 chunks.\n- Re-ingest: confirms; submits POST /reingest; surfaces in the jobs section.\n- Use jQuery + Espo.Ui.dialog patterns consistent with the rest of the extension.\n\nACL gate: this tab visible only to users with role 'KB Admin' (define in EspoCRM if not present) or fall back to !isPortal for now.",
|
||||
"testStrategy": "1) Open 'ניהול' tab. See the 6 existing sources for ביטוח לאומי. 2) Edit one source's title — refresh → new title sticks. 3) Re-ingest ספר הליקויים. Job appears in the jobs section processing → done. Source still searchable end-to-end after. 4) Delete one source. Confirm modal appears with chunk count. After delete: source gone from /kb/sources, /kb/search no longer surfaces it. 5) Failed re-ingest (e.g. delete the file in MinIO first) — surfaces in failures section with error_message.",
|
||||
@@ -173,7 +173,8 @@
|
||||
"dependencies": [
|
||||
"13"
|
||||
],
|
||||
"createdAt": "2026-04-25T13:30:00Z"
|
||||
"createdAt": "2026-04-25T13:30:00Z",
|
||||
"updatedAt": "2026-04-25T17:03:34.486Z"
|
||||
},
|
||||
{
|
||||
"id": "15",
|
||||
@@ -222,11 +223,11 @@
|
||||
"description": "Add 'caselaw' alongside law/regulation/circular across the whole stack. Touches: DB CHECK constraints (migration 003), shira-hermes Python (Literal types, _KINDS, kind validation, chunker section detection), EspoCRM Controller/Service validators, template dropdowns, JS kindHe mapping.",
|
||||
"details": "DB: migration 003_caselaw_kind.sql alters CHECK on kb_source.kind and kb_ingest_job.kind to include 'caselaw'. shira-hermes: update Literal types in ingest.py, admin_kb.py, kb_public.py SearchRequest; add 'caselaw' to s3.py _KINDS; chunker.py — fall back to generic section detector (regex for 'פסק דין', 'תיק' / case number, paragraph numbers). EspoCRM: update validators in Controller postActionUpload + Service search/uploadFile; add 'caselaw' option to kind <select> in index.tpl (search box + upload form); update kindHe in index.js.",
|
||||
"testStrategy": "",
|
||||
"status": "in-progress",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-25T16:49:30.816Z"
|
||||
"updatedAt": "2026-04-25T17:02:14.241Z"
|
||||
},
|
||||
{
|
||||
"id": "19",
|
||||
@@ -243,9 +244,9 @@
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-04-25T16:49:30.816Z",
|
||||
"taskCount": 18,
|
||||
"completedCount": 10,
|
||||
"lastModified": "2026-04-25T17:03:34.486Z",
|
||||
"taskCount": 19,
|
||||
"completedCount": 11,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -191,4 +191,65 @@ class KnowledgeBase
|
||||
}
|
||||
return $this->getService()->getJob((int) $jobId);
|
||||
}
|
||||
|
||||
// ── Phase 3: source management (Task #14) ───────────────────────────────
|
||||
|
||||
public function getActionAdminSources(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$topicId = $this->coerceTopicId($request->getQueryParam('topicId'));
|
||||
$kind = $request->getQueryParam('kind');
|
||||
$limit = $request->getQueryParam('limit');
|
||||
$limitInt = ($limit !== null && is_numeric($limit)) ? (int) $limit : 200;
|
||||
return $this->getService()->listAdminSources($topicId, $kind, $limitInt);
|
||||
}
|
||||
|
||||
public function postActionUpdateSource(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$body = $request->getParsedBody();
|
||||
$id = $body->id ?? null;
|
||||
if (!$id || !is_numeric($id)) {
|
||||
throw new BadRequest('id (numeric) is required.');
|
||||
}
|
||||
// Whitelist what we forward — never let arbitrary body fields hit
|
||||
// upstream's PUT body. Empty strings are explicitly preserved as
|
||||
// null clearing (the user wiping a date or identifier).
|
||||
$payload = [];
|
||||
foreach (['title', 'identifier', 'source_url', 'sourceUrl', 'published_at', 'publishedAt', 'effective_at', 'effectiveAt'] as $k) {
|
||||
if (property_exists($body, $k)) {
|
||||
$canon = $k;
|
||||
if ($k === 'sourceUrl') $canon = 'source_url';
|
||||
if ($k === 'publishedAt') $canon = 'published_at';
|
||||
if ($k === 'effectiveAt') $canon = 'effective_at';
|
||||
$payload[$canon] = $body->$k;
|
||||
}
|
||||
}
|
||||
return $this->getService()->updateAdminSource((int) $id, $payload);
|
||||
}
|
||||
|
||||
public function postActionDeleteSource(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$body = $request->getParsedBody();
|
||||
$id = $body->id ?? null;
|
||||
if (!$id || !is_numeric($id)) {
|
||||
throw new BadRequest('id (numeric) is required.');
|
||||
}
|
||||
return $this->getService()->deleteAdminSource((int) $id);
|
||||
}
|
||||
|
||||
public function postActionReingestSource(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$body = $request->getParsedBody();
|
||||
$id = $body->id ?? null;
|
||||
if (!$id || !is_numeric($id)) {
|
||||
throw new BadRequest('id (numeric) is required.');
|
||||
}
|
||||
return $this->getService()->reingestAdminSource(
|
||||
(int) $id,
|
||||
(string) $this->user->get('userName')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,5 +62,37 @@
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "job"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/adminSources",
|
||||
"method": "get",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "adminSources"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/updateSource",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "updateSource"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/deleteSource",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "deleteSource"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/reingestSource",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "reingestSource"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -173,6 +173,83 @@ class KnowledgeBaseService
|
||||
return $this->get('/admin/kb/jobs/' . $jobId);
|
||||
}
|
||||
|
||||
// ── Phase 3: admin source management (Task #14) ─────────────────────────
|
||||
|
||||
public function listAdminSources(?int $topicId, ?string $kind, int $limit): array
|
||||
{
|
||||
$limit = max(1, min(500, $limit));
|
||||
$qs = ['limit' => $limit];
|
||||
if ($topicId !== null) $qs['topic_id'] = $topicId;
|
||||
if ($kind !== null && $kind !== '') $qs['kind'] = $kind;
|
||||
return $this->get('/admin/kb/sources?' . http_build_query($qs));
|
||||
}
|
||||
|
||||
public function updateAdminSource(int $sourceId, array $fields): array
|
||||
{
|
||||
return $this->request('PUT', '/admin/kb/sources/' . $sourceId, $fields);
|
||||
}
|
||||
|
||||
public function deleteAdminSource(int $sourceId): array
|
||||
{
|
||||
return $this->request('DELETE', '/admin/kb/sources/' . $sourceId, null);
|
||||
}
|
||||
|
||||
public function reingestAdminSource(int $sourceId, string $username): array
|
||||
{
|
||||
// Re-ingest needs the user identity for audit; piggyback on the
|
||||
// X-User-Name header that the upload route already understands.
|
||||
return $this->postWithUser(
|
||||
'/admin/kb/sources/' . $sourceId . '/reingest',
|
||||
null,
|
||||
$username
|
||||
);
|
||||
}
|
||||
|
||||
private function postWithUser(string $path, ?array $payload, string $username): array
|
||||
{
|
||||
$url = $this->getBaseUrl() . $path;
|
||||
$apiKey = $this->getApiKey();
|
||||
|
||||
$headers = ['Accept: application/json', 'X-User-Name: ' . $username];
|
||||
if ($apiKey) $headers[] = 'X-Api-Key: ' . $apiKey;
|
||||
|
||||
$ch = curl_init($url);
|
||||
$opts = [
|
||||
CURLOPT_CUSTOMREQUEST => 'POST',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 180,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
];
|
||||
if ($payload !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
$opts[CURLOPT_POSTFIELDS] = json_encode($payload);
|
||||
}
|
||||
$opts[CURLOPT_HTTPHEADER] = $headers;
|
||||
curl_setopt_array($ch, $opts);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
$this->log->error("KnowledgeBase: HTTP error calling {$url}: {$error}");
|
||||
throw new Error("Failed to reach Knowledge Base: {$error}");
|
||||
}
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$detail = $this->decodeDetail($body) ?: "HTTP {$httpCode}";
|
||||
if ($httpCode >= 400 && $httpCode < 500) {
|
||||
throw new BadRequest($detail);
|
||||
}
|
||||
throw new Error("Knowledge Base returned HTTP {$httpCode}");
|
||||
}
|
||||
$decoded = json_decode($body, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new Error('Invalid JSON from Knowledge Base.');
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function guessMime(string $filename): string
|
||||
{
|
||||
$lower = strtolower($filename);
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "KnowledgeBase",
|
||||
"module": "KnowledgeBase",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"acceptableVersions": [
|
||||
">=8.0.0"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user