diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index c5f7fc6..d1e055d 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -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 . 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" ] diff --git a/files/client/custom/modules/knowledge-base/res/templates/kb/index.tpl b/files/client/custom/modules/knowledge-base/res/templates/kb/index.tpl index 619e1d7..f396bf4 100644 --- a/files/client/custom/modules/knowledge-base/res/templates/kb/index.tpl +++ b/files/client/custom/modules/knowledge-base/res/templates/kb/index.tpl @@ -72,6 +72,23 @@ {{#ifEqual mode 'manage'}}
+
+
+

נושאים (תחומי משפט)

+
+ + +
+
+
+
טוען נושאים…
+
+
+

מקורות בנושא

diff --git a/files/client/custom/modules/knowledge-base/src/views/kb/index.js b/files/client/custom/modules/knowledge-base/src/views/kb/index.js index d5c0859..79efd58 100644 --- a/files/client/custom/modules/knowledge-base/src/views/kb/index.js +++ b/files/client/custom/modules/knowledge-base/src/views/kb/index.js @@ -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('
טוען נושאים…
'); + 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('
שגיאה בטעינת נושאים.
'); + }); + }, + + _renderTopicsTable: function () { + const $table = this.$el.find('.kb-topics-table'); + if (!$table.length) return; + const items = _adminTopics || []; + if (!items.length) { + $table.html('
אין נושאים. לחץ "נושא חדש" כדי להוסיף את הראשון.
'); + return; + } + const rows = items.map(t => { + const activeBadge = t.is_active + ? 'פעיל' + : 'מושבת'; + const sources = (t.source_count != null) ? t.source_count : 0; + const desc = t.description ? `
${this.escape(t.description)}
` : ''; + return ` + ${this.escape(t.slug)} + +
${this.escape(t.name)}
+ ${desc} + + ${sources} + ${activeBadge} + + + + + + `; + }).join(''); + $table.html(` + + + + + + + + + + + ${rows} +
slugשם / תיאורמקורותסטטוס
+ `); + }, + + _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 + // for layout simplicity. + const formHtml = ` + + +
+
+ + + ${isNew ? 'אותיות קטנות, מקפים. לא ניתן לשנות לאחר היצירה.' : 'אינו ניתן לשינוי.'} +
+
+ + +
+
+ + +
+
+ + +
+
+ + + מוסתר ממשתמשים אם לא פעיל. +
+
+ + +
+
+ + + `; + 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) { diff --git a/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php b/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php index 78174b4..f1356d2 100644 --- a/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php +++ b/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php @@ -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); + } } diff --git a/files/custom/Espo/Modules/KnowledgeBase/Resources/routes.json b/files/custom/Espo/Modules/KnowledgeBase/Resources/routes.json index e5ff6db..4a10c66 100644 --- a/files/custom/Espo/Modules/KnowledgeBase/Resources/routes.json +++ b/files/custom/Espo/Modules/KnowledgeBase/Resources/routes.json @@ -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" + } } ] diff --git a/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php b/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php index 7d567e0..93eb2af 100644 --- a/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php +++ b/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php @@ -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); diff --git a/manifest.json b/manifest.json index 16c247b..4f6592a 100644 --- a/manifest.json +++ b/manifest.json @@ -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": "מאגר ידע — חוק הביטוח הלאומי, תקנות וחוזרים" } \ No newline at end of file