Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a4f16a50f | |||
| 30a9c397e8 | |||
| db7b30e3c0 | |||
| d051b268dd |
@@ -165,7 +165,7 @@
|
||||
"id": "14",
|
||||
"title": "Phase 3 — Management panel UI (sources table, edit metadata, delete, re-ingest)",
|
||||
"description": "Today the only way to manage existing sources is via SQL on the shared PG (DELETE FROM kb_source WHERE id=…) plus an mc command on MinIO. End users need a panel where they can: see all sources for the selected topic, with chunk counts and ingestion dates; edit source metadata (title, identifier, dates, source_url); delete a source (with confirm and clear cascade messaging); re-ingest a source (re-fetch its file from MinIO processed/, drop chunks, re-run parse+chunk+embed); see the most recent ingest jobs (success + failure both). The panel sits in the same KB extension under a new tab 'ניהול'.",
|
||||
"status": "pending",
|
||||
"status": "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": "done",
|
||||
"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:47:57.615Z"
|
||||
},
|
||||
{
|
||||
"id": "17",
|
||||
@@ -205,16 +207,17 @@
|
||||
"id": "16",
|
||||
"title": "Phase 5 (optional) — Per-topic ACL by EspoCRM role",
|
||||
"description": "Not every user in a multi-topic firm should see every topic. Example: only the criminal-law department needs the criminal KB; the family-law team shouldn't. Wire EspoCRM's existing role/team model to the KB topics: each topic can be restricted to specific roles, with two permission levels — 'view' (use search/ask) and 'manage' (upload/edit/delete). Without role mapping, all non-portal users see all topics (current behavior). With mapping, the topic dropdown filters to topics the user has at least 'view' on, and the management UI gates 'manage' actions to users with the manage permission for that topic.",
|
||||
"status": "pending",
|
||||
"status": "deferred",
|
||||
"priority": "low",
|
||||
"details": "DB: new table kb_topic_acl: topic_id FK, role_name TEXT, permission TEXT CHECK in ('view', 'manage'), PRIMARY KEY (topic_id, role_name, permission).\n\nshira-hermes endpoints:\n- /kb/topics: filter by user's roles. The user's role list comes from the EspoCRM proxy (sent in a header or JWT claim — needs design).\n- /admin/kb/* actions: enforce 'manage' permission per source's topic_id.\n\nEspoCRM client:\n- Topic dropdown: only shows topics the user has 'view' on.\n- Management tab: only visible if user has 'manage' on at least one topic.\n- Edit/delete buttons in the sources table: visible only when user has 'manage' on that source's topic.\n\nThis is the most-likely-to-skip phase if the user's firm is small and everyone needs everything. Worth scoping out depth before committing.",
|
||||
"details": "DB: new table kb_topic_acl: topic_id FK, role_name TEXT, permission TEXT CHECK in ('view', 'manage'), PRIMARY KEY (topic_id, role_name, permission).\n\nshira-hermes endpoints:\n- /kb/topics: filter by user's roles. The user's role list comes from the EspoCRM proxy (sent in a header or JWT claim — needs design).\n- /admin/kb/* actions: enforce 'manage' permission per source's topic_id.\n\nEspoCRM client:\n- Topic dropdown: only shows topics the user has 'view' on.\n- Management tab: only visible if user has 'manage' on at least one topic.\n- Edit/delete buttons in the sources table: visible only when user has 'manage' on that source's topic.\n\nThis is the most-likely-to-skip phase if the user's firm is small and everyone needs everything. Worth scoping out depth before committing.\n\n---\nDEFERRED 2026-04-25: small firm, all users need access to all topics. Re-open if the firm grows past ~3 lawyers AND topics start to genuinely need siloing (e.g. confidential criminal cases the partners shouldn't browse). Until then, the only access control is `User::isPortal()` already enforced in `Controller::checkAccess()`.",
|
||||
"testStrategy": "Set up two test users: lawyer_a with role 'criminal-law', lawyer_b with role 'employment-law'. Topics: 'דין פלילי' restricted to 'criminal-law' role only, 'דיני עבודה' restricted to 'employment-law'. lawyer_a sees only the criminal topic; lawyer_b only employment. Cross-user attempts return 403.",
|
||||
"subtasks": [],
|
||||
"dependencies": [
|
||||
"12",
|
||||
"15"
|
||||
],
|
||||
"createdAt": "2026-04-25T13:30:00Z"
|
||||
"createdAt": "2026-04-25T13:30:00Z",
|
||||
"updatedAt": "2026-04-25T17:51:07.124Z"
|
||||
},
|
||||
{
|
||||
"id": "18",
|
||||
@@ -222,11 +225,11 @@
|
||||
"description": "Add 'caselaw' alongside law/regulation/circular across the whole stack. Touches: DB CHECK constraints (migration 003), shira-hermes Python (Literal types, _KINDS, kind validation, chunker section detection), EspoCRM Controller/Service validators, template dropdowns, JS kindHe mapping.",
|
||||
"details": "DB: migration 003_caselaw_kind.sql alters CHECK on kb_source.kind and kb_ingest_job.kind to include 'caselaw'. shira-hermes: update Literal types in ingest.py, admin_kb.py, kb_public.py SearchRequest; add 'caselaw' to s3.py _KINDS; chunker.py — fall back to generic section detector (regex for 'פסק דין', 'תיק' / case number, paragraph numbers). EspoCRM: update validators in Controller postActionUpload + Service search/uploadFile; add 'caselaw' option to kind <select> in index.tpl (search box + upload form); update kindHe in index.js.",
|
||||
"testStrategy": "",
|
||||
"status": "in-progress",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-25T16:49:30.816Z"
|
||||
"updatedAt": "2026-04-25T17:02:14.241Z"
|
||||
},
|
||||
{
|
||||
"id": "19",
|
||||
@@ -239,13 +242,25 @@
|
||||
"subtasks": [],
|
||||
"dependencies": [],
|
||||
"createdAt": "2026-04-25T16:54:54.478470Z"
|
||||
},
|
||||
{
|
||||
"id": "20",
|
||||
"title": "v0.8.0 — kind 'tool' + labels + bulk upload with AI classification",
|
||||
"description": "Phase 6. Adds 'tool' kind for academic assessment instruments (GMFCS, MACS, etc), label-based sub-topic grouping (kb_label many-to-many), multi-file drag-drop upload, and AI-extracted metadata via Claude/ai-gateway. Two-phase ingest: classify → await_review → embed. Replaces single-file upload with batch-review screen. Also creates 'תוויות' admin tab. Subsumes task #19 (labels). Plan: ~/.claude/plans/hidden-tickling-ullman.md",
|
||||
"details": "See /home/chaim/.claude/plans/hidden-tickling-ullman.md for the full architecture, migration scripts, API surface, classifier prompt, UX, and file-level breakdown. ~7 days estimated.",
|
||||
"testStrategy": "",
|
||||
"status": "in-progress",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-25T18:21:44.731Z"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-04-25T16:49:30.816Z",
|
||||
"taskCount": 18,
|
||||
"completedCount": 10,
|
||||
"lastModified": "2026-04-25T18:21:44.731Z",
|
||||
"taskCount": 20,
|
||||
"completedCount": 13,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<option value="regulation">תקנות</option>
|
||||
<option value="circular">חוזרים</option>
|
||||
<option value="caselaw">פסיקה</option>
|
||||
<option value="tool">כלי הערכה</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-primary" data-action="submit">חפש</button>
|
||||
<button type="button" class="btn btn-default" data-action="clearResults"
|
||||
@@ -72,62 +73,104 @@
|
||||
|
||||
{{#ifEqual mode 'manage'}}
|
||||
<div class="kb-manage" style="display:flex;flex-direction:column;gap:16px;">
|
||||
<div class="panel panel-default" style="padding:12px;">
|
||||
<h4 style="margin-top:0;">העלאת מסמך חדש</h4>
|
||||
<form class="kb-upload-form" enctype="multipart/form-data"
|
||||
style="display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<label for="kb-upload-file" class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">קובץ:</label>
|
||||
<input type="file" id="kb-upload-file" name="file"
|
||||
accept=".pdf,.docx,.txt"
|
||||
style="flex:1 1 280px;" />
|
||||
<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 style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<label for="kb-upload-kind" class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">סוג:</label>
|
||||
<select id="kb-upload-kind" class="form-control" name="kind"
|
||||
style="flex:0 0 auto;width:200px;">
|
||||
</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="circular">חוזרים</option>
|
||||
<option value="caselaw">פסיקה</option>
|
||||
<option value="tool">כלי הערכה</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="refreshSources" title="רענן">
|
||||
<span class="glyphicon glyphicon-refresh"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<label for="kb-upload-title" class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">כותרת:</label>
|
||||
<input type="text" id="kb-upload-title" class="form-control" name="title"
|
||||
placeholder="ברירת מחדל: שם הקובץ"
|
||||
style="flex:1 1 280px;" />
|
||||
</div>
|
||||
<details>
|
||||
<summary class="text-muted" style="cursor:pointer;">מטא-דאטה נוסף (אופציונלי)</summary>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;margin-top:8px;padding-right:12px;">
|
||||
<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" name="identifier"
|
||||
placeholder="למשל: ח'(353) 14.1.2018"
|
||||
style="flex:1 1 280px;" />
|
||||
</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" name="published_at"
|
||||
style="flex:0 0 auto;width:200px;" />
|
||||
<label class="text-muted" style="margin:0;font-weight:normal;">תוקף מ:</label>
|
||||
<input type="date" class="form-control" name="effective_at"
|
||||
style="flex:0 0 auto;width:200px;" />
|
||||
</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" name="source_url"
|
||||
placeholder="https://…"
|
||||
style="flex:1 1 280px;" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="kb-sources-table">
|
||||
<div class="text-muted">טוען מקורות…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default kb-labels-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>
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="refreshLabels" title="רענן">
|
||||
<span class="glyphicon glyphicon-refresh"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="kb-labels-table">
|
||||
<div class="text-muted">טוען תוויות…</div>
|
||||
</div>
|
||||
<div class="text-muted small" style="margin-top:6px;">
|
||||
תוויות נוצרות אוטומטית כשמעלים מסמך חדש ושירה מציעה תת-נושא. כאן ניתן למחוק תוויות שאינן בשימוש.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default kb-pending-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-pending-count text-muted" style="font-weight:normal;margin-right:6px;"></span></h4>
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="refreshPending" title="רענן">
|
||||
<span class="glyphicon glyphicon-refresh"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="kb-pending-list">
|
||||
<div class="text-muted">טוען…</div>
|
||||
</div>
|
||||
<div class="text-muted small" style="margin-top:6px;">
|
||||
באצ׳ים שעלו וממתינים לעריכה ואישור. לחץ "פתח" כדי לחזור למסך הסקירה.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default kb-batch-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;align-items:center;">
|
||||
<button type="button" class="btn btn-primary" data-action="submitUpload">העלה</button>
|
||||
<span class="kb-upload-hint text-muted small">מקסימום 50MB. PDF / DOCX / TXT.</span>
|
||||
<select id="kb-batch-kind" class="form-control" style="width:auto;">
|
||||
<option value="circular" selected>חוזר</option>
|
||||
<option value="law">חוק</option>
|
||||
<option value="regulation">תקנות</option>
|
||||
<option value="caselaw">פסיקה</option>
|
||||
<option value="tool">כלי הערכה</option>
|
||||
</select>
|
||||
<input type="file" id="kb-batch-files" multiple
|
||||
accept=".pdf,.docx,.txt" style="display:none;" />
|
||||
<button type="button" class="btn btn-primary btn-sm" data-action="pickBatchFiles">
|
||||
<span class="glyphicon glyphicon-cloud-upload"></span> בחר קבצים…
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="kb-batch-hint text-muted small" style="margin-bottom:8px;">
|
||||
ניתן לבחור מספר קבצים בו-זמנית. שירה תנתח כל קובץ ותציע מטא-דאטה (סוג / כותרת / תוויות / סיכום) — תוכל לערוך לפני אישור הטמעה.
|
||||
מקסימום 50MB לקובץ, עד 50 קבצים בקבוצה.
|
||||
</div>
|
||||
<div class="kb-batch-cards" style="display:flex;flex-direction:column;gap:10px;"></div>
|
||||
<div class="kb-batch-actions" style="display:none;justify-content:space-between;align-items:center;margin-top:12px;padding-top:12px;border-top:1px solid #eee;">
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="discardAllBatch">בטל הכל</button>
|
||||
<button type="button" class="btn btn-primary" data-action="commitAllBatch">
|
||||
אישור והטמעה
|
||||
<span class="kb-batch-commit-count"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default" style="padding:12px;">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -191,4 +191,277 @@ 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);
|
||||
}
|
||||
|
||||
// ── Phase 6 (v0.8.0): bulk upload + AI classifier + labels ──────────────
|
||||
|
||||
public function postActionUploadBatch(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
|
||||
// Multi-file: $_FILES['files'] is an array-of-arrays in PHP when
|
||||
// the form submits files[] repeated. Normalize per-file.
|
||||
$rawFiles = $_FILES['files'] ?? null;
|
||||
if (!$rawFiles || !is_array($rawFiles['tmp_name'] ?? null)) {
|
||||
throw new BadRequest('No files uploaded (form field must be `files[]`).');
|
||||
}
|
||||
$files = [];
|
||||
$count = count($rawFiles['tmp_name']);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$err = $rawFiles['error'][$i] ?? UPLOAD_ERR_NO_FILE;
|
||||
$tmp = $rawFiles['tmp_name'][$i] ?? '';
|
||||
if ($err !== UPLOAD_ERR_OK || !$tmp || !is_uploaded_file($tmp)) {
|
||||
continue; // server-side filter; client filters too
|
||||
}
|
||||
$files[] = [
|
||||
'tmp_name' => $tmp,
|
||||
'name' => (string) ($rawFiles['name'][$i] ?? 'upload'),
|
||||
'type' => (string) ($rawFiles['type'][$i] ?? 'application/octet-stream'),
|
||||
'size' => (int) ($rawFiles['size'][$i] ?? 0),
|
||||
];
|
||||
}
|
||||
if (!$files) {
|
||||
throw new BadRequest('No valid files in upload.');
|
||||
}
|
||||
|
||||
$kind = $_POST['kind'] ?? null;
|
||||
if (!in_array($kind, ['law', 'regulation', 'circular', 'caselaw', 'tool'], true)) {
|
||||
throw new BadRequest('kind must be one of: law, regulation, circular, caselaw, tool.');
|
||||
}
|
||||
$topicId = $this->coerceTopicId($_POST['topicId'] ?? $_POST['topic_id'] ?? null);
|
||||
if ($topicId === null) {
|
||||
throw new BadRequest('topicId is required.');
|
||||
}
|
||||
|
||||
return $this->getService()->uploadBatch(
|
||||
$files,
|
||||
(string) $kind,
|
||||
$topicId,
|
||||
(string) $this->user->get('userName'),
|
||||
);
|
||||
}
|
||||
|
||||
public function getActionBatch(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$batchId = $request->getQueryParam('batchId');
|
||||
if (!$batchId) {
|
||||
throw new BadRequest('batchId is required.');
|
||||
}
|
||||
return $this->getService()->getBatch((string) $batchId);
|
||||
}
|
||||
|
||||
public function getActionPendingBatches(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$limit = (int) ($request->getQueryParam('limit') ?? 50);
|
||||
if ($limit < 1) $limit = 1;
|
||||
if ($limit > 200) $limit = 200;
|
||||
return $this->getService()->listPendingBatches($limit);
|
||||
}
|
||||
|
||||
public function postActionCommitJob(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.');
|
||||
}
|
||||
$kind = $body->kind ?? null;
|
||||
if (!in_array($kind, ['law', 'regulation', 'circular', 'caselaw', 'tool'], true)) {
|
||||
throw new BadRequest('kind must be one of: law, regulation, circular, caselaw, tool.');
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'kind' => $kind,
|
||||
'title' => isset($body->title) ? trim((string) $body->title) : '',
|
||||
'identifier' => isset($body->identifier) ? trim((string) $body->identifier) : null,
|
||||
'source_url' => $body->source_url ?? $body->sourceUrl ?? null,
|
||||
'published_at' => $body->published_at ?? $body->publishedAt ?? null,
|
||||
'effective_at' => $body->effective_at ?? $body->effectiveAt ?? null,
|
||||
'summary' => isset($body->summary) ? trim((string) $body->summary) : null,
|
||||
'label_slugs' => is_array($body->label_slugs ?? null) ? $body->label_slugs : [],
|
||||
'new_labels' => is_array($body->new_labels ?? null) ? $body->new_labels : [],
|
||||
];
|
||||
|
||||
return $this->getService()->commitJob(
|
||||
(int) $id, $payload, (string) $this->user->get('userName')
|
||||
);
|
||||
}
|
||||
|
||||
public function postActionDiscardJob(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()->discardJob(
|
||||
(int) $id, (string) $this->user->get('userName')
|
||||
);
|
||||
}
|
||||
|
||||
public function getActionLabels(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$topicId = $this->coerceTopicId($request->getQueryParam('topicId'));
|
||||
$q = $request->getQueryParam('q');
|
||||
$limit = $request->getQueryParam('limit');
|
||||
$limitInt = ($limit !== null && is_numeric($limit)) ? (int) $limit : 50;
|
||||
return $this->getService()->listLabels($topicId, $q, $limitInt);
|
||||
}
|
||||
|
||||
public function postActionCreateLabel(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()->createLabel([
|
||||
'slug' => trim((string) $body->slug),
|
||||
'name' => trim((string) $body->name),
|
||||
'topic_id' => isset($body->topic_id) ? (int) $body->topic_id : null,
|
||||
], (string) $this->user->get('userName'));
|
||||
}
|
||||
|
||||
public function postActionMergeLabels(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$body = $request->getParsedBody();
|
||||
$id = $body->id ?? null;
|
||||
$intoId = $body->into_label_id ?? $body->intoLabelId ?? null;
|
||||
if (!$id || !is_numeric($id) || !$intoId || !is_numeric($intoId)) {
|
||||
throw new BadRequest('id and into_label_id (numeric) are required.');
|
||||
}
|
||||
return $this->getService()->mergeLabels((int) $id, (int) $intoId);
|
||||
}
|
||||
|
||||
public function postActionDeleteLabel(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()->deleteLabel((int) $id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,5 +62,141 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/uploadBatch",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "uploadBatch"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/batch",
|
||||
"method": "get",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "batch"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/pendingBatches",
|
||||
"method": "get",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "pendingBatches"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/commitJob",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "commitJob"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/discardJob",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "discardJob"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/labels",
|
||||
"method": "get",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "labels"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/createLabel",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "createLabel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/mergeLabels",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "mergeLabels"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/deleteLabel",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "deleteLabel"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -173,6 +173,254 @@ 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);
|
||||
}
|
||||
|
||||
// ── Phase 6 (v0.8.0): bulk upload + AI classifier + labels ──────────────
|
||||
|
||||
/**
|
||||
* Multi-file upload. Each file is sent to /admin/kb/upload-batch in
|
||||
* one multipart POST that repeats the `files` field. shira-hermes
|
||||
* assigns the batch_id and fires per-file classify tasks.
|
||||
*
|
||||
* @param array<int,array{tmp_name:string,name:string,type:string,size:int}> $files
|
||||
* @return array{batch_id:string, jobs:array<int,mixed>}
|
||||
*/
|
||||
public function uploadBatch(array $files, string $kind, int $topicId, string $username): array
|
||||
{
|
||||
if (!$files) {
|
||||
throw new BadRequest('No files in batch.');
|
||||
}
|
||||
$url = $this->getBaseUrl() . '/admin/kb/upload-batch';
|
||||
$apiKey = $this->getApiKey();
|
||||
if (!$apiKey) {
|
||||
throw new Error('SmartAssistant API key is not configured.');
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'kind' => $kind,
|
||||
'topic_id' => (string) $topicId,
|
||||
];
|
||||
// PHP cURL accepts repeated form fields by giving the array under
|
||||
// a special "[]" suffix syntax — but actually CURLOPT_POSTFIELDS
|
||||
// only sees the LAST value when the key repeats. Workaround:
|
||||
// build the multipart body manually so we can repeat `files`.
|
||||
$boundary = '----shira-hermes-' . bin2hex(random_bytes(8));
|
||||
$body = '';
|
||||
foreach ($fields as $k => $v) {
|
||||
$body .= "--{$boundary}\r\n";
|
||||
$body .= "Content-Disposition: form-data; name=\"{$k}\"\r\n\r\n";
|
||||
$body .= $v . "\r\n";
|
||||
}
|
||||
foreach ($files as $f) {
|
||||
$contents = @file_get_contents($f['tmp_name']);
|
||||
if ($contents === false) {
|
||||
throw new Error("Failed to read uploaded file: " . $f['name']);
|
||||
}
|
||||
$name = addslashes($f['name']);
|
||||
$type = $f['type'] ?: $this->guessMime($f['name']);
|
||||
$body .= "--{$boundary}\r\n";
|
||||
$body .= "Content-Disposition: form-data; name=\"files\"; filename=\"{$name}\"\r\n";
|
||||
$body .= "Content-Type: {$type}\r\n\r\n";
|
||||
$body .= $contents . "\r\n";
|
||||
}
|
||||
$body .= "--{$boundary}--\r\n";
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'Content-Type: multipart/form-data; boundary=' . $boundary,
|
||||
'X-Api-Key: ' . $apiKey,
|
||||
'X-User-Name: ' . $username,
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
// Batch upload of e.g. 10×10MB files is dominated by PHP→Python
|
||||
// network transfer; bump from the single-file 120s.
|
||||
CURLOPT_TIMEOUT => 300,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($err) {
|
||||
$this->log->error("KnowledgeBase: batch upload transport error: {$err}");
|
||||
throw new Error("Failed to reach Knowledge Base: {$err}");
|
||||
}
|
||||
if ($httpCode === 413) {
|
||||
throw new BadRequest('Total upload too large.');
|
||||
}
|
||||
if ($httpCode >= 400 && $httpCode < 500) {
|
||||
$detail = $this->decodeDetail($resp) ?: 'Bad request';
|
||||
throw new BadRequest($detail);
|
||||
}
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$this->log->error("KnowledgeBase: batch upload HTTP {$httpCode}: {$resp}");
|
||||
throw new Error("Knowledge Base returned HTTP {$httpCode}");
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $resp, true);
|
||||
if (!is_array($decoded) || !isset($decoded['batch_id'])) {
|
||||
throw new Error('Invalid response from Knowledge Base.');
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
public function getBatch(string $batchId): array
|
||||
{
|
||||
return $this->get('/admin/kb/batch/' . rawurlencode($batchId));
|
||||
}
|
||||
|
||||
public function listPendingBatches(int $limit): array
|
||||
{
|
||||
return $this->get('/admin/kb/batches?' . http_build_query(['limit' => $limit]));
|
||||
}
|
||||
|
||||
public function commitJob(int $jobId, array $payload, string $username): array
|
||||
{
|
||||
return $this->postWithUser(
|
||||
'/admin/kb/jobs/' . $jobId . '/commit',
|
||||
$payload,
|
||||
$username,
|
||||
);
|
||||
}
|
||||
|
||||
public function discardJob(int $jobId, string $username): array
|
||||
{
|
||||
return $this->postWithUser(
|
||||
'/admin/kb/jobs/' . $jobId . '/discard',
|
||||
null,
|
||||
$username,
|
||||
);
|
||||
}
|
||||
|
||||
public function listLabels(?int $topicId, ?string $q, int $limit): array
|
||||
{
|
||||
$qs = ['limit' => $limit];
|
||||
if ($topicId !== null) $qs['topic_id'] = $topicId;
|
||||
if ($q !== null && $q !== '') $qs['q'] = $q;
|
||||
return $this->get('/admin/kb/labels?' . http_build_query($qs));
|
||||
}
|
||||
|
||||
public function createLabel(array $payload, string $username): array
|
||||
{
|
||||
return $this->postWithUser('/admin/kb/labels', $payload, $username);
|
||||
}
|
||||
|
||||
public function mergeLabels(int $labelId, int $intoLabelId): array
|
||||
{
|
||||
return $this->request(
|
||||
'POST',
|
||||
'/admin/kb/labels/' . $labelId . '/merge',
|
||||
['into_label_id' => $intoLabelId],
|
||||
);
|
||||
}
|
||||
|
||||
public function deleteLabel(int $labelId): array
|
||||
{
|
||||
return $this->request('DELETE', '/admin/kb/labels/' . $labelId, 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 +662,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);
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "KnowledgeBase",
|
||||
"module": "KnowledgeBase",
|
||||
"version": "0.5.0",
|
||||
"version": "0.8.0",
|
||||
"acceptableVersions": [
|
||||
">=8.0.0"
|
||||
],
|
||||
@@ -10,5 +10,6 @@
|
||||
],
|
||||
"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": "Knowledge Base — Israeli National Insurance law, regulations, and circulars. Hybrid search + ask-shira, powered by shira-hermes KB.",
|
||||
"displayLabel": "מאגר ידע"
|
||||
}
|
||||
Reference in New Issue
Block a user