3 Commits

Author SHA1 Message Date
chaim db7b30e3c0 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>
2026-04-25 17:46:51 +00:00
chaim d051b268dd 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>
2026-04-25 17:27:45 +00:00
chaim e7b21919c2 feat: add 'caselaw' (פסיקה) kind alongside חוק/תקנות/חוזרים
Court rulings get their own kind so users can filter search results
to "פסיקה" specifically and upload case-law PDFs from the ניהול tab.
PHP validators in Controller + Service accept the new kind; the search
dropdown and upload kind dropdown gain a "פסיקה" option; kindHe in
index.js maps caselaw → "פסיקה" so jobs list, search results, and
browse view all label it consistently.

Backend: depends on shira-hermes commit 16eeeff (kind validators +
chunker fallback) and migration 003_caselaw_kind.sql (already applied
on dev — alters CHECK constraints on kb_source.kind and
kb_ingest_job.kind to include 'caselaw').

Refs Task Master #18

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 17:01:12 +00:00
8 changed files with 880 additions and 23 deletions
+1 -1
View File
@@ -2,5 +2,5 @@
"currentTag": "master",
"lastSwitched": "2026-04-24T15:47:33.139Z",
"branchTagMapping": {},
"migrationNoticeShown": false
"migrationNoticeShown": true
}
+35 -9
View File
@@ -150,7 +150,7 @@
"id": "13",
"title": "Phase 2 — Document upload from the KB UI (file picker + async ingestion + job tracking)",
"description": "Today documents enter the KB by manually putting them in the MinIO inbox/ folder and waiting for the n8n cron to call /admin/kb/scan-inbox. End users have no path to upload via the UI. Add a file-upload form in the management panel: user picks a PDF/DOCX/TXT, picks a kind (law/regulation/circular), confirms the topic, optionally fills metadata (title, identifier, dates, source_url), and clicks upload. The file lands in MinIO under inbox/<topic_slug>/<kind>/, a kb_ingest_job row is created, and a background asyncio task processes it (parse → chunk → embed → upsert kb_source). The UI polls the job status and shows progress live; on done it links to the new source in the management table.",
"status": "in-progress",
"status": "done",
"priority": "high",
"details": "DB:\n- New table kb_ingest_job: id PK, source_id INTEGER NULL FK kb_source(id) ON DELETE SET NULL, original_filename TEXT, s3_key TEXT, kind TEXT, topic_id INTEGER NOT NULL, metadata_json JSONB, status TEXT NOT NULL CHECK (status IN ('queued','processing','done','failed')), error_message TEXT, chunks_created INTEGER, created_at TIMESTAMPTZ DEFAULT now(), started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, requested_by_user TEXT.\n- Index: (status, created_at DESC) for the queued list.\n\nshira-hermes endpoints:\n- POST /admin/kb/upload (multipart): receives file, kind, topic_id, optional title/identifier/published_at/effective_at/source_url. Validates topic exists. Writes file to s3 inbox/<topic_slug>/<kind>/<uuid-prefixed filename>. Inserts kb_ingest_job with status=queued. Schedules asyncio.create_task(_process_ingest_job(job_id)) so processing starts immediately, not on the next n8n cron tick. Returns {job_id, status:'queued'}.\n- GET /admin/kb/jobs?topic_id=&status=&limit=: list jobs, newest first.\n- GET /admin/kb/jobs/{id}: single job detail.\n- _process_ingest_job(job_id): UPDATE status='processing', started_at=now. Calls existing kb_ingest.ingest_source. On success: UPDATE status='done', source_id=<new>, chunks_created=<n>, completed_at=now. On failure: UPDATE status='failed', error_message=<exception text>. The upload doesn't block the HTTP request — the user gets the job_id immediately and polls.\n- Reuses scan-inbox advisory lock pattern so a manually-uploaded file plus an n8n cron run don't double-process.\n\nKnowledgeBase extension:\n- EspoCRM PHP proxy for multipart upload — Controllers can read $request->getParsedBody() but multipart needs special handling. Look at how other extensions do it (LegalAssistance/DigitalSignature might have examples).\n- New tab 'ניהול' (visible only to non-portal users; can also gate to admin role later). The tab opens a panel with: (a) Upload form, (b) Sources table for current topic [implemented in Phase 3], (c) Recent jobs.\n- Upload form fields: file picker, kind dropdown, topic dropdown (defaulting to currently-selected topic), optional title/identifier/dates/source_url collapse. Submit → POST KnowledgeBase/action/upload → returns job_id → switch to job-status view that polls every 2s.\n- Job status view: shows the job's status badge (queued/processing/done/failed), elapsed time, and on done: link to the source in the management table (Phase 3).\n\nFile size: enforce 50MB limit at the FastAPI route + server-side. PDFs of עשרות-שעות could approach this.",
"testStrategy": "1) Upload a small test PDF (~1MB). Job goes queued → processing → done in <30s. UI shows progress live. 2) Upload a corrupt file. Job ends in failed with a useful error_message. 3) Upload while another large ingest is running — job shows 'processing' but doesn't deadlock. 4) Refresh the page mid-upload — job status restored from sessionStorage + a fresh poll. 5) /admin/kb/jobs returns the upload's row.",
@@ -159,13 +159,13 @@
"12"
],
"createdAt": "2026-04-25T13:30:00Z",
"updatedAt": "2026-04-25T16:20:11.931Z"
"updatedAt": "2026-04-25T16:47:46.599Z"
},
{
"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": "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.",
@@ -173,13 +173,14 @@
"dependencies": [
"13"
],
"createdAt": "2026-04-25T13:30:00Z"
"createdAt": "2026-04-25T13:30:00Z",
"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.",
@@ -187,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",
@@ -215,13 +217,37 @@
"15"
],
"createdAt": "2026-04-25T13:30:00Z"
},
{
"id": "18",
"title": "feat(kb): add 'caselaw' (פסיקה) as a fourth kind",
"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": "done",
"dependencies": [],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-04-25T17:02:14.241Z"
},
{
"id": "19",
"title": "feat(kb): tag/label sub-topics within a topic (e.g. 'ילד נכה')",
"description": "Group multiple sources within a topic by sub-subject so users can find e.g. all 'ילד נכה' circulars together. Recommendation: many-to-many kb_label + kb_source_label so a source can carry multiple tags reflecting that legal docs commonly touch several subjects.",
"status": "pending",
"priority": "medium",
"details": "Three approaches considered:\n\n1. Free-text 'subject' column on kb_source (~1d): cheapest, typo-prone (ילד נכה / ילדים נכים → separate buckets).\n\n2. Many-to-many labels (~3d, RECOMMENDED): kb_label(id, topic_id, slug UNIQUE per topic, name) + kb_source_label(source_id, label_id, PK both). UI shows label chips on sources; click to filter. Naturally handles cross-cutting subjects (a circular tagged both 'ילד נכה' AND 'אזרח ותיק'). Needs admin UI to manage labels.\n\n3. Hierarchical sub-topics under kb_topic (~5d): clean tree, but a source can only live in one branch — wrong fit for the legal world. Also clashes with planned Phase 4 topic-CRUD UI.\n\nPragmatic path: ship #1 first (subject TEXT field on kb_source + dropdown of seen values + browse-view grouping); upgrade to #2 once we see how labels are used in practice.",
"testStrategy": "Upload 3 circulars all in 'ילד נכה' subject; verify they appear grouped together in browse view; verify search results show the subject chip; verify topic switch resets the subject filter.",
"subtasks": [],
"dependencies": [],
"createdAt": "2026-04-25T16:54:54.478470Z"
}
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-04-25T16:20:11.932Z",
"taskCount": 17,
"completedCount": 9,
"lastModified": "2026-04-25T17:30:11.759Z",
"taskCount": 19,
"completedCount": 12,
"tags": [
"master"
]
@@ -36,6 +36,7 @@
<option value="law">חוק</option>
<option value="regulation">תקנות</option>
<option value="circular">חוזרים</option>
<option value="caselaw">פסיקה</option>
</select>
<button type="button" class="btn btn-primary" data-action="submit">חפש</button>
<button type="button" class="btn btn-default" data-action="clearResults"
@@ -71,6 +72,43 @@
{{#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>
<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"
@@ -88,6 +126,7 @@
<option value="law">חוק</option>
<option value="regulation">תקנות</option>
<option value="circular">חוזר</option>
<option value="caselaw">פסיקה</option>
</select>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
@@ -15,6 +15,8 @@ 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
let _adminTopics = null; // [...topics admin view] cache (incl. is_active=false)
const SS_ASK = 'kb-last-ask';
const SS_SEARCH = 'kb-last-search';
@@ -124,6 +126,70 @@ 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);
},
'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
@@ -215,6 +281,10 @@ 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();
// Phase 4: load the topics admin table (firm-wide, not topic-scoped).
this._loadAdminTopics();
},
_loadJobsList: function () {
@@ -247,7 +317,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: 'חוזר'};
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזר', caselaw: 'פסיקה'};
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', ' ');
@@ -273,6 +343,435 @@ 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) {
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 +815,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 +978,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();
@@ -813,7 +1319,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: 'חוזר'};
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר', caselaw: 'פסיקה'};
const self = this;
const isPdfBacked = h => h && (h.kind === 'regulation' || h.kind === 'circular');
@@ -1112,7 +1618,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: 'חוזר'};
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר', caselaw: 'פסיקה'};
// Dedup sources by source_id (first occurrence = most relevant),
// but remember every page that came up per source so users can
@@ -1252,8 +1758,8 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
$list.html('<div class="text-muted">אין מקורות.</div>');
return;
}
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזרים'};
const grouped = {law: [], regulation: [], circular: []};
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזרים', caselaw: 'פסיקה'};
const grouped = {law: [], regulation: [], circular: [], caselaw: []};
this._sources.forEach(s => {
if (grouped[s.kind]) grouped[s.kind].push(s);
});
@@ -145,8 +145,8 @@ class KnowledgeBase
// For multipart/form-data EspoCRM's getParsedBody() returns an empty
// stdClass; the form fields land in $_POST as PHP parses them.
$kind = $_POST['kind'] ?? null;
if (!in_array($kind, ['law', 'regulation', 'circular'], true)) {
throw new BadRequest('kind must be one of: law, regulation, circular.');
if (!in_array($kind, ['law', 'regulation', 'circular', 'caselaw'], true)) {
throw new BadRequest('kind must be one of: law, regulation, circular, caselaw.');
}
$topicId = $this->coerceTopicId($_POST['topicId'] ?? $_POST['topic_id'] ?? null);
if ($topicId === null) {
@@ -191,4 +191,120 @@ 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')
);
}
// ── 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);
}
}
@@ -62,5 +62,69 @@
"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"
}
},
{
"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"
}
}
]
@@ -37,7 +37,7 @@ class KnowledgeBaseService
public function search(string $query, string $kind, int $topK, ?int $topicId = null): array
{
$topK = max(1, min(15, $topK));
if (!in_array($kind, ['any', 'law', 'regulation', 'circular'], true)) {
if (!in_array($kind, ['any', 'law', 'regulation', 'circular', 'caselaw'], true)) {
$kind = 'any';
}
@@ -88,8 +88,8 @@ class KnowledgeBaseService
if (!is_readable($tmpPath)) {
throw new Error('Uploaded file is not readable on disk.');
}
if (!in_array($kind, ['law', 'regulation', 'circular'], true)) {
throw new BadRequest('kind must be one of: law, regulation, circular.');
if (!in_array($kind, ['law', 'regulation', 'circular', 'caselaw'], true)) {
throw new BadRequest('kind must be one of: law, regulation, circular, caselaw.');
}
$url = $this->getBaseUrl() . '/admin/kb/upload';
@@ -173,6 +173,105 @@ 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
);
}
// ── 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;
$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);
@@ -414,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
View File
@@ -1,7 +1,7 @@
{
"name": "KnowledgeBase",
"module": "KnowledgeBase",
"version": "0.4.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": "מאגר ידע — חוק הביטוח הלאומי, תקנות וחוזרים"
}