feat: Phase 4 — topic CRUD UI in 'ניהול' tab
Adds a topics-management panel above the sources table. Admins can
create new legal domains (דיני עבודה, דין פלילי, נדל"ן) without
running SQL — slug + name + description + system prompt addendum
all live in one inline form. Per-row actions:
✏ Edit — opens inline form with all fields except slug
(slug is locked post-create — the S3 layout
depends on it)
👁 Toggle active — soft-disable: hides the topic from the user-facing
<select> while keeping data intact. Eye icon
flips between "השבת" and "הפעל"
🗑 Delete — hard-delete; only allowed when source_count=0.
Otherwise the UI directs the user to soft-disable
instead
Any topic mutation (create / rename / toggle / delete) invalidates
the cached _topics list so the user-facing dropdown re-fetches
immediately — admins don't have to refresh the page to see their
own changes.
Service.request() now distinguishes 4xx from 5xx upstream errors:
4xx is rethrown as BadRequest (so the user sees "slug already exists"
instead of a generic 500), 5xx stays as Error.
Backend: depends on shira-hermes commit 49503ca (admin topic
endpoints). No new migration — kb_topic schema from migration 001
is sufficient.
Description field in manifest was rewritten to Hebrew to match the
in-CRM scopeNames label ("מאגר ידע").
Refs Task Master #15
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": "in-progress",
|
||||
"status": "done",
|
||||
"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.",
|
||||
@@ -174,13 +174,13 @@
|
||||
"13"
|
||||
],
|
||||
"createdAt": "2026-04-25T13:30:00Z",
|
||||
"updatedAt": "2026-04-25T17:03:34.486Z"
|
||||
"updatedAt": "2026-04-25T17:28:47.359Z"
|
||||
},
|
||||
{
|
||||
"id": "15",
|
||||
"title": "Phase 4 — Topic CRUD UI (admins can add/edit/disable topics from the panel)",
|
||||
"description": "Once Phase 1 ships there will be a single seeded topic ('ביטוח לאומי'). For Klear and other firms to actually use the multi-topic capability they need a UI to create new topics — דיני עבודה, דין פלילי, נדל\"ן — without anyone running SQL. Add a topics-management section in the 'ניהול' tab: list active+inactive topics, add new (slug + name + system_prompt_addendum), edit (rename, change prompt addendum), soft-delete (sets is_active=false). Soft-delete keeps the data but hides the topic from the user-facing dropdown.",
|
||||
"status": "pending",
|
||||
"status": "in-progress",
|
||||
"priority": "normal",
|
||||
"details": "shira-hermes endpoints:\n- GET /admin/kb/topics: list including inactive ones (with source counts).\n- POST /admin/kb/topics: create. Validates slug (lowercase, hyphens only, unique). Returns created row.\n- PUT /admin/kb/topics/{id}: update name, description, system_prompt_addendum, is_active.\n- DELETE /admin/kb/topics/{id}: 409 Conflict if topic has sources; otherwise hard-delete. Soft-delete (is_active=false) is the normal path.\n\nEspoCRM client:\n- New section in 'ניהול' tab: 'נושאים' table — slug, name, source count, is_active toggle, edit button.\n- Add new: modal with slug, name, description, prompt-addendum textarea (pre-populated with a generic template like 'You are answering legal questions about <topic>. Cite sections explicitly when possible. Never answer from generic legal training if the KB has a matching section').\n- Edit: same modal pre-filled. Saving updates the topic; if name changed, the topic dropdown refreshes.\n- The 'ניהול' tab itself only shows the topics section if user is admin. Regular users see only sources management for the topics they have access to.\n\nDefault topic on first install of multi-topic version: still id=1 'ביטוח לאומי'. New installs get the same seed.",
|
||||
"testStrategy": "1) Create topic 'דיני עבודה' (slug 'employment-law'). 2) Verify it appears in GET /kb/topics within seconds. 3) The user-facing topic dropdown lists it. 4) Upload a PDF under it (Phase 2 flow), verify search/ask scoped to it works. 5) Edit prompt addendum — /kb/ask answers in context of employment law. 6) Soft-delete the test topic — disappears from user dropdown but kept in admin list.",
|
||||
@@ -188,7 +188,8 @@
|
||||
"dependencies": [
|
||||
"12"
|
||||
],
|
||||
"createdAt": "2026-04-25T13:30:00Z"
|
||||
"createdAt": "2026-04-25T13:30:00Z",
|
||||
"updatedAt": "2026-04-25T17:30:11.758Z"
|
||||
},
|
||||
{
|
||||
"id": "17",
|
||||
@@ -244,9 +245,9 @@
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-04-25T17:03:34.486Z",
|
||||
"lastModified": "2026-04-25T17:30:11.759Z",
|
||||
"taskCount": 19,
|
||||
"completedCount": 11,
|
||||
"completedCount": 12,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
|
||||
@@ -72,6 +72,23 @@
|
||||
|
||||
{{#ifEqual mode 'manage'}}
|
||||
<div class="kb-manage" style="display:flex;flex-direction:column;gap:16px;">
|
||||
<div class="panel panel-default kb-topics-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;">נושאים (תחומי משפט)</h4>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="refreshTopics" title="רענן">
|
||||
<span class="glyphicon glyphicon-refresh"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" data-action="newTopic">
|
||||
<span class="glyphicon glyphicon-plus"></span> נושא חדש
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kb-topics-table">
|
||||
<div class="text-muted">טוען נושאים…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -16,6 +16,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
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
|
||||
let _adminTopics = null; // [...topics admin view] cache (incl. is_active=false)
|
||||
|
||||
const SS_ASK = 'kb-last-ask';
|
||||
const SS_SEARCH = 'kb-last-search';
|
||||
@@ -156,6 +157,39 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
const id = parseInt($(e.currentTarget).data('id'), 10);
|
||||
if (id) this._reingestSource(id);
|
||||
},
|
||||
'click [data-action="refreshTopics"]': function (e) {
|
||||
e.preventDefault();
|
||||
this._loadAdminTopics(/* force */ true);
|
||||
},
|
||||
'click [data-action="newTopic"]': function (e) {
|
||||
e.preventDefault();
|
||||
this._openTopicForm(null);
|
||||
},
|
||||
'click [data-action="editTopic"]': function (e) {
|
||||
e.preventDefault();
|
||||
const id = parseInt($(e.currentTarget).data('id'), 10);
|
||||
if (id) this._openTopicForm(id);
|
||||
},
|
||||
'click [data-action="cancelTopicEdit"]': function (e) {
|
||||
e.preventDefault();
|
||||
this._renderTopicsTable();
|
||||
},
|
||||
'click [data-action="saveTopic"]': function (e) {
|
||||
e.preventDefault();
|
||||
const idAttr = $(e.currentTarget).data('id');
|
||||
const id = (idAttr === '' || idAttr == null) ? null : parseInt(idAttr, 10);
|
||||
this._saveTopic(id);
|
||||
},
|
||||
'click [data-action="toggleTopicActive"]': function (e) {
|
||||
e.preventDefault();
|
||||
const id = parseInt($(e.currentTarget).data('id'), 10);
|
||||
if (id) this._toggleTopicActive(id);
|
||||
},
|
||||
'click [data-action="deleteTopic"]': function (e) {
|
||||
e.preventDefault();
|
||||
const id = parseInt($(e.currentTarget).data('id'), 10);
|
||||
if (id) this._deleteTopic(id);
|
||||
},
|
||||
},
|
||||
|
||||
// Cancel any in-flight ask/search, drop the cached last-result for
|
||||
@@ -249,6 +283,8 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
this._loadJobsList();
|
||||
// Phase 3: load the sources table when the manage tab is open.
|
||||
this._loadAdminSources();
|
||||
// Phase 4: load the topics admin table (firm-wide, not topic-scoped).
|
||||
this._loadAdminTopics();
|
||||
},
|
||||
|
||||
_loadJobsList: function () {
|
||||
@@ -307,6 +343,241 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
$list.html(rows);
|
||||
},
|
||||
|
||||
// ── Phase 4: topics admin (Task #15) ───────────────────────────────
|
||||
|
||||
_loadAdminTopics: function (force) {
|
||||
const self = this;
|
||||
const $table = this.$el.find('.kb-topics-table');
|
||||
if (!$table.length) return;
|
||||
if (!force && _adminTopics) {
|
||||
this._renderTopicsTable();
|
||||
return;
|
||||
}
|
||||
$table.html('<div class="text-muted">טוען נושאים…</div>');
|
||||
Espo.Ajax.getRequest('KnowledgeBase/action/adminTopics')
|
||||
.then(res => {
|
||||
_adminTopics = (res && res.items) || [];
|
||||
self._renderTopicsTable();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('KB: failed to load admin topics', err);
|
||||
$table.html('<div class="text-muted">שגיאה בטעינת נושאים.</div>');
|
||||
});
|
||||
},
|
||||
|
||||
_renderTopicsTable: function () {
|
||||
const $table = this.$el.find('.kb-topics-table');
|
||||
if (!$table.length) return;
|
||||
const items = _adminTopics || [];
|
||||
if (!items.length) {
|
||||
$table.html('<div class="text-muted">אין נושאים. לחץ "נושא חדש" כדי להוסיף את הראשון.</div>');
|
||||
return;
|
||||
}
|
||||
const rows = items.map(t => {
|
||||
const activeBadge = t.is_active
|
||||
? '<span class="label label-success">פעיל</span>'
|
||||
: '<span class="label label-default">מושבת</span>';
|
||||
const sources = (t.source_count != null) ? t.source_count : 0;
|
||||
const desc = t.description ? `<div class="text-muted small">${this.escape(t.description)}</div>` : '';
|
||||
return `<tr data-id="${t.id}">
|
||||
<td><code style="direction:ltr;">${this.escape(t.slug)}</code></td>
|
||||
<td>
|
||||
<div style="font-weight:500;">${this.escape(t.name)}</div>
|
||||
${desc}
|
||||
</td>
|
||||
<td class="text-center">${sources}</td>
|
||||
<td class="text-center">${activeBadge}</td>
|
||||
<td style="white-space:nowrap;text-align:left;">
|
||||
<button class="btn btn-link btn-sm" data-action="editTopic" data-id="${t.id}" title="ערוך"><span class="glyphicon glyphicon-pencil"></span></button>
|
||||
<button class="btn btn-link btn-sm" data-action="toggleTopicActive" data-id="${t.id}" title="${t.is_active ? 'השבת' : 'הפעל'}"><span class="glyphicon glyphicon-${t.is_active ? 'eye-close' : 'eye-open'}"></span></button>
|
||||
<button class="btn btn-link btn-sm" data-action="deleteTopic" data-id="${t.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:160px;">slug</th>
|
||||
<th>שם / תיאור</th>
|
||||
<th style="width:60px;text-align:center;">מקורות</th>
|
||||
<th style="width:80px;text-align:center;">סטטוס</th>
|
||||
<th style="width:120px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`);
|
||||
},
|
||||
|
||||
_findTopicById: function (id) {
|
||||
const arr = _adminTopics || [];
|
||||
return arr.find(t => t.id === id);
|
||||
},
|
||||
|
||||
_openTopicForm: function (id) {
|
||||
const isNew = id == null;
|
||||
const t = isNew ? null : this._findTopicById(id);
|
||||
if (!isNew && !t) return;
|
||||
const $tbody = this.$el.find('.kb-topics-table tbody');
|
||||
if (!$tbody.length) return;
|
||||
const safe = (v) => this.escape(v == null ? '' : String(v));
|
||||
// For new topics, prepend a fresh editable row. For edits, replace
|
||||
// the existing row in place. Either way the form lives inside one
|
||||
// <td colspan="5"> for layout simplicity.
|
||||
const formHtml = `
|
||||
<tr data-id="${isNew ? '' : id}" class="kb-topic-edit-row">
|
||||
<td colspan="5">
|
||||
<div style="display:flex;flex-direction:column;gap:8px;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;">slug:</label>
|
||||
<input type="text" class="form-control" data-edit="slug" value="${safe(t && t.slug)}" ${isNew ? '' : 'disabled'} placeholder="employment-law" style="flex:0 0 240px;direction:ltr;" />
|
||||
<span class="text-muted small">${isNew ? 'אותיות קטנות, מקפים. לא ניתן לשנות לאחר היצירה.' : 'אינו ניתן לשינוי.'}</span>
|
||||
</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="name" value="${safe(t && t.name)}" placeholder="דיני עבודה" style="flex:1 1 240px;" />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap;">
|
||||
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;padding-top:6px;">תיאור:</label>
|
||||
<input type="text" class="form-control" data-edit="description" value="${safe(t && t.description)}" placeholder="תיאור קצר של הנושא (אופציונלי)" style="flex:1 1 240px;" />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap;">
|
||||
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;padding-top:6px;">תוספת לפרומפט:</label>
|
||||
<textarea class="form-control" data-edit="system_prompt_addendum" rows="4" placeholder="הקשר נוסף שיינתן ל-LLM. למשל: 'You are answering legal questions about employment law in Israel. Cite sections explicitly when possible.'" style="flex:1 1 240px;direction:ltr;font-family:monospace;font-size:0.9em;">${safe(t && t.system_prompt_addendum)}</textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">פעיל:</label>
|
||||
<input type="checkbox" data-edit="is_active" ${(!t || t.is_active) ? 'checked' : ''} />
|
||||
<span class="text-muted small">מוסתר ממשתמשים אם לא פעיל.</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:4px;">
|
||||
<button class="btn btn-default btn-sm" data-action="cancelTopicEdit">ביטול</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="saveTopic" data-id="${isNew ? '' : id}">${isNew ? 'צור' : 'שמור'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
if (isNew) {
|
||||
$tbody.prepend(formHtml);
|
||||
this.$el.find('.kb-topic-edit-row input[data-edit="slug"]').trigger('focus');
|
||||
} else {
|
||||
const $row = this.$el.find(`.kb-topics-table tr[data-id="${id}"]`);
|
||||
$row.replaceWith(formHtml);
|
||||
this.$el.find('.kb-topic-edit-row input[data-edit="name"]').trigger('focus');
|
||||
}
|
||||
},
|
||||
|
||||
_saveTopic: function (id) {
|
||||
const self = this;
|
||||
const $row = this.$el.find('.kb-topic-edit-row');
|
||||
if (!$row.length) return;
|
||||
const payload = {};
|
||||
$row.find('[data-edit]').each(function () {
|
||||
const k = $(this).data('edit');
|
||||
if ($(this).is(':checkbox')) {
|
||||
payload[k] = $(this).is(':checked');
|
||||
} else if (!$(this).is('[disabled]')) {
|
||||
payload[k] = ($(this).val() || '').trim();
|
||||
}
|
||||
});
|
||||
const isNew = id == null;
|
||||
const url = isNew
|
||||
? 'KnowledgeBase/action/createTopic'
|
||||
: 'KnowledgeBase/action/updateTopic';
|
||||
if (!isNew) {
|
||||
payload.id = id;
|
||||
// PUT semantics: don't send slug (it's locked) — Service whitelists anyway.
|
||||
delete payload.slug;
|
||||
}
|
||||
const $btn = $row.find('[data-action="saveTopic"]');
|
||||
const oldText = $btn.text();
|
||||
$btn.prop('disabled', true).text('שומר…');
|
||||
|
||||
Espo.Ajax.postRequest(url, payload)
|
||||
.then(saved => {
|
||||
if (isNew) {
|
||||
_adminTopics = (_adminTopics || []).concat([
|
||||
Object.assign({source_count: 0}, saved),
|
||||
]);
|
||||
} else {
|
||||
const idx = (_adminTopics || []).findIndex(t => t.id === id);
|
||||
if (idx >= 0) {
|
||||
_adminTopics[idx] = Object.assign({}, _adminTopics[idx], saved);
|
||||
}
|
||||
}
|
||||
self._renderTopicsTable();
|
||||
// The user-facing topic dropdown might now be stale (rename
|
||||
// / new active topic) — invalidate it so afterRender re-fetches.
|
||||
_topics = null;
|
||||
_topicsPromise = null;
|
||||
self._ensureTopicsLoaded().then(() => self._populateTopicPicker());
|
||||
})
|
||||
.catch(err => {
|
||||
$btn.prop('disabled', false).text(oldText);
|
||||
let msg = '';
|
||||
try {
|
||||
const j = JSON.parse(err && err.responseText || '{}');
|
||||
msg = j.message || j.detail || (err && err.responseText) || 'שגיאה לא ידועה';
|
||||
} catch (e) {
|
||||
msg = (err && err.responseText) || 'שגיאה לא ידועה';
|
||||
}
|
||||
alert('שמירה נכשלה: ' + String(msg).slice(0, 300));
|
||||
});
|
||||
},
|
||||
|
||||
_toggleTopicActive: function (id) {
|
||||
const t = this._findTopicById(id);
|
||||
if (!t) return;
|
||||
const next = !t.is_active;
|
||||
const verb = next ? 'להפעיל' : 'להשבית';
|
||||
if (!confirm(`${verb} את הנושא "${t.name}"?`)) return;
|
||||
const self = this;
|
||||
Espo.Ajax.postRequest('KnowledgeBase/action/updateTopic', {id, is_active: next})
|
||||
.then(updated => {
|
||||
const idx = (_adminTopics || []).findIndex(x => x.id === id);
|
||||
if (idx >= 0) {
|
||||
_adminTopics[idx] = Object.assign({}, _adminTopics[idx], updated);
|
||||
}
|
||||
self._renderTopicsTable();
|
||||
_topics = null;
|
||||
_topicsPromise = null;
|
||||
self._ensureTopicsLoaded().then(() => self._populateTopicPicker());
|
||||
})
|
||||
.catch(err => alert('פעולה נכשלה: ' + ((err && err.responseText) || '').slice(0, 300)));
|
||||
},
|
||||
|
||||
_deleteTopic: function (id) {
|
||||
const t = this._findTopicById(id);
|
||||
if (!t) return;
|
||||
const cn = t.source_count || 0;
|
||||
if (cn > 0) {
|
||||
alert(`לא ניתן למחוק את "${t.name}" כי יש לו ${cn} מקורות.\n\nניתן להשבית את הנושא במקום זאת — הוא ייעלם מהדרופ-דאון אך הנתונים יישמרו.`);
|
||||
return;
|
||||
}
|
||||
if (!confirm(`למחוק לחלוטין את הנושא "${t.name}" (slug=${t.slug})?\n\nאין לו מקורות, אבל הפעולה בלתי הפיכה.`)) return;
|
||||
const self = this;
|
||||
Espo.Ajax.postRequest('KnowledgeBase/action/deleteTopic', {id})
|
||||
.then(() => {
|
||||
_adminTopics = (_adminTopics || []).filter(x => x.id !== id);
|
||||
self._renderTopicsTable();
|
||||
_topics = null;
|
||||
_topicsPromise = null;
|
||||
self._ensureTopicsLoaded().then(() => self._populateTopicPicker());
|
||||
})
|
||||
.catch(err => {
|
||||
let msg = '';
|
||||
try {
|
||||
const j = JSON.parse(err && err.responseText || '{}');
|
||||
msg = j.message || j.detail || 'שגיאה לא ידועה';
|
||||
} catch (e) {
|
||||
msg = (err && err.responseText) || 'שגיאה לא ידועה';
|
||||
}
|
||||
alert('מחיקה נכשלה: ' + String(msg).slice(0, 300));
|
||||
});
|
||||
},
|
||||
|
||||
// ── Phase 3: sources table (Task #14) ──────────────────────────────
|
||||
|
||||
_loadAdminSources: function (force) {
|
||||
|
||||
@@ -252,4 +252,59 @@ class KnowledgeBase
|
||||
(string) $this->user->get('userName')
|
||||
);
|
||||
}
|
||||
|
||||
// ── Phase 4: topic CRUD (Task #15) ──────────────────────────────────────
|
||||
|
||||
public function getActionAdminTopics(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
return $this->getService()->listAdminTopics();
|
||||
}
|
||||
|
||||
public function postActionCreateTopic(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$body = $request->getParsedBody();
|
||||
if (empty($body->slug) || empty($body->name)) {
|
||||
throw new BadRequest('slug and name are required.');
|
||||
}
|
||||
return $this->getService()->createAdminTopic([
|
||||
'slug' => trim((string) $body->slug),
|
||||
'name' => trim((string) $body->name),
|
||||
'description' => isset($body->description) ? trim((string) $body->description) : null,
|
||||
'system_prompt_addendum' => $body->system_prompt_addendum ?? $body->systemPromptAddendum ?? null,
|
||||
'is_active' => isset($body->is_active) ? (bool) $body->is_active : true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function postActionUpdateTopic(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.');
|
||||
}
|
||||
$payload = [];
|
||||
foreach (['name', 'description', 'system_prompt_addendum', 'systemPromptAddendum', 'is_active', 'isActive'] as $k) {
|
||||
if (property_exists($body, $k)) {
|
||||
$canon = $k;
|
||||
if ($k === 'systemPromptAddendum') $canon = 'system_prompt_addendum';
|
||||
if ($k === 'isActive') $canon = 'is_active';
|
||||
$payload[$canon] = $body->$k;
|
||||
}
|
||||
}
|
||||
return $this->getService()->updateAdminTopic((int) $id, $payload);
|
||||
}
|
||||
|
||||
public function postActionDeleteTopic(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()->deleteAdminTopic((int) $id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,5 +94,37 @@
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "reingestSource"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/adminTopics",
|
||||
"method": "get",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "adminTopics"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/createTopic",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "createTopic"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/updateTopic",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "updateTopic"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/deleteTopic",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "deleteTopic"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -205,6 +205,28 @@ class KnowledgeBaseService
|
||||
);
|
||||
}
|
||||
|
||||
// ── Phase 4: topic CRUD (Task #15) ──────────────────────────────────────
|
||||
|
||||
public function listAdminTopics(): array
|
||||
{
|
||||
return $this->get('/admin/kb/topics');
|
||||
}
|
||||
|
||||
public function createAdminTopic(array $payload): array
|
||||
{
|
||||
return $this->request('POST', '/admin/kb/topics', $payload);
|
||||
}
|
||||
|
||||
public function updateAdminTopic(int $topicId, array $fields): array
|
||||
{
|
||||
return $this->request('PUT', '/admin/kb/topics/' . $topicId, $fields);
|
||||
}
|
||||
|
||||
public function deleteAdminTopic(int $topicId): array
|
||||
{
|
||||
return $this->request('DELETE', '/admin/kb/topics/' . $topicId, null);
|
||||
}
|
||||
|
||||
private function postWithUser(string $path, ?array $payload, string $username): array
|
||||
{
|
||||
$url = $this->getBaseUrl() . $path;
|
||||
@@ -491,6 +513,13 @@ class KnowledgeBaseService
|
||||
}
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$this->log->error("KnowledgeBase: HTTP {$httpCode} from {$url}: {$body}");
|
||||
// Surface 4xx error detail to the browser as BadRequest so the
|
||||
// user sees the actual reason (bad slug, duplicate, conflict)
|
||||
// rather than a generic 500.
|
||||
if ($httpCode >= 400 && $httpCode < 500) {
|
||||
$detail = $this->decodeDetail($body) ?: "HTTP {$httpCode}";
|
||||
throw new BadRequest($detail);
|
||||
}
|
||||
throw new Error("Knowledge Base returned HTTP {$httpCode}");
|
||||
}
|
||||
$decoded = json_decode($body, true);
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "KnowledgeBase",
|
||||
"module": "KnowledgeBase",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"acceptableVersions": [
|
||||
">=8.0.0"
|
||||
],
|
||||
@@ -10,5 +10,5 @@
|
||||
],
|
||||
"releaseDate": "2026-04-25",
|
||||
"author": "klear",
|
||||
"description": "Knowledge Base — Israeli National Insurance law, regulations, and circulars. Hybrid search + ask-shira, powered by shira-hermes KB."
|
||||
"description": "מאגר ידע — חוק הביטוח הלאומי, תקנות וחוזרים"
|
||||
}
|
||||
Reference in New Issue
Block a user