5 Commits

Author SHA1 Message Date
chaim 95c48c77d9 fix(kb): prevent double-commit race + collapse admin sections by default
Two unrelated UX issues in the Knowledge Base manage tab:

1. Double-commit 400 error. The polling tick at index.js:1217 was
   blindly overwriting a locally-set 'committing' status with whatever
   the server reported. If the GET /batch poll raced ahead of the POST
   commitJob reaching the DB, the card flipped back to 'awaiting_review',
   the "אשר הכל" button re-enabled, and a second click triggered a duplicate
   commit which the backend rejected with HTTP 400 ("job N is in status
   'done', expected 'awaiting_review'"). Fix: never downgrade
   'committing' → 'awaiting_review' from the polling response.

2. Admin tab scrolling. The "מקורות בנושא", "תוויות תת-נושא" and
   "משימות אחרונות" panels were always expanded on tab entry, pushing
   the actual upload form far down the page. Wrapped each in a
   collapsible body with a chevron toggle, default collapsed. Data
   lazy-loads on first expand.

Refs Task Master #21

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 11:08:29 +00:00
chaim 1b8972c264 chore: update task statuses in .taskmaster
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:19:15 +00:00
chaim 8a4f16a50f feat(KB): v0.8.0 — bulk upload, AI classifier, labels, tool kind
v0.8.0 ships the four-feature bundle from the v0.8.0 plan plus
mid-flight fixes discovered when batch-testing 5 circulars.

New capabilities:
  * Multi-file batch upload. AI classifier (Sonnet via ai-gateway)
    reads the first 3 pages of each PDF and proposes kind / title /
    identifier / labels / dates / summary. User reviews + edits per
    card before committing. Two-phase ingest: classify →
    awaiting_review → embed.
  * 'tool' kind for academic assessment instruments (GMFCS, MACS).
  * Labels (kb_label / kb_source_label) for sub-topic navigation;
    classifier proposes shared label slugs so the graph builds
    itself.
  * 'תוויות' admin sub-section for merge/delete of unused labels.
  * NEW 'ממתינים לאישור' panel: lists batches still in
    awaiting_review with status counters so a hard-refreshed user
    can resume their review — closes the RAM-only _activeBatch gap.

Mid-flight fixes folded in:
  * classifier timeout 45s→90s, concurrency 5→2. ai-gateway
    serializes through a single Claude OAuth session; with conc=5,
    jobs 4-5 of a 5-file batch consistently timed out.
  * labels schema array-of-string → comma-separated string. Claude
    via ai-gateway returned [{}, {}, {}] for items:{type:"string"}.
    _normalize accepts both shapes defensively.

Backfilled ai_classified_at + description + 1-3 labels for the 10
sources ingested in earlier sessions so the filter UX is uniform.

Backend (shira-hermes) in commits 0d678da (Phase 6) + bb23b8a
(this session's fixes).

Refs Task Master #20

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:18:34 +00:00
chaim 30a9c397e8 chore: add manifest.displayLabel for extension-platform catalog
Short Hebrew label shown in the admin "ההרחבות שלנו" panel.
Synced into extensions.description on next sync-registry run
(or on next /publish for this extension).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:07:59 +00:00
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
7 changed files with 1503 additions and 84 deletions
+36 -10
View File
@@ -165,7 +165,7 @@
"id": "14", "id": "14",
"title": "Phase 3 — Management panel UI (sources table, edit metadata, delete, re-ingest)", "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 'ניהול'.", "description": "Today the only way to manage existing sources is via SQL on the shared PG (DELETE FROM kb_source WHERE id=…) plus an mc command on MinIO. End users need a panel where they can: see all sources for the selected topic, with chunk counts and ingestion dates; edit source metadata (title, identifier, dates, source_url); delete a source (with confirm and clear cascade messaging); re-ingest a source (re-fetch its file from MinIO processed/, drop chunks, re-run parse+chunk+embed); see the most recent ingest jobs (success + failure both). The panel sits in the same KB extension under a new tab 'ניהול'.",
"status": "in-progress", "status": "done",
"priority": "high", "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.", "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.", "testStrategy": "1) Open 'ניהול' tab. See the 6 existing sources for ביטוח לאומי. 2) Edit one source's title — refresh → new title sticks. 3) Re-ingest ספר הליקויים. Job appears in the jobs section processing → done. Source still searchable end-to-end after. 4) Delete one source. Confirm modal appears with chunk count. After delete: source gone from /kb/sources, /kb/search no longer surfaces it. 5) Failed re-ingest (e.g. delete the file in MinIO first) — surfaces in failures section with error_message.",
@@ -174,13 +174,13 @@
"13" "13"
], ],
"createdAt": "2026-04-25T13:30:00Z", "createdAt": "2026-04-25T13:30:00Z",
"updatedAt": "2026-04-25T17:03:34.486Z" "updatedAt": "2026-04-25T17:28:47.359Z"
}, },
{ {
"id": "15", "id": "15",
"title": "Phase 4 — Topic CRUD UI (admins can add/edit/disable topics from the panel)", "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.", "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", "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.", "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.", "testStrategy": "1) Create topic 'דיני עבודה' (slug 'employment-law'). 2) Verify it appears in GET /kb/topics within seconds. 3) The user-facing topic dropdown lists it. 4) Upload a PDF under it (Phase 2 flow), verify search/ask scoped to it works. 5) Edit prompt addendum — /kb/ask answers in context of employment law. 6) Soft-delete the test topic — disappears from user dropdown but kept in admin list.",
@@ -188,7 +188,8 @@
"dependencies": [ "dependencies": [
"12" "12"
], ],
"createdAt": "2026-04-25T13:30:00Z" "createdAt": "2026-04-25T13:30:00Z",
"updatedAt": "2026-04-25T17:47:57.615Z"
}, },
{ {
"id": "17", "id": "17",
@@ -206,16 +207,17 @@
"id": "16", "id": "16",
"title": "Phase 5 (optional) — Per-topic ACL by EspoCRM role", "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.", "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", "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.", "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": [], "subtasks": [],
"dependencies": [ "dependencies": [
"12", "12",
"15" "15"
], ],
"createdAt": "2026-04-25T13:30:00Z" "createdAt": "2026-04-25T13:30:00Z",
"updatedAt": "2026-04-25T17:51:07.124Z"
}, },
{ {
"id": "18", "id": "18",
@@ -240,13 +242,37 @@
"subtasks": [], "subtasks": [],
"dependencies": [], "dependencies": [],
"createdAt": "2026-04-25T16:54:54.478470Z" "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": "done",
"dependencies": [],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-04-25T20:19:47.523Z"
},
{
"id": "21",
"title": "fix(kb): prevent double-commit + collapse admin sections",
"description": "Fix UI race in commitAllBatch + collapse Sources/Labels/Recent Jobs panels",
"details": "Fix 1: in polling tick (index.js ~1217), don't downgrade local 'committing' status back to 'awaiting_review' from server. Fix 2: wrap Sources, Labels and Recent Jobs panel bodies in collapsible div with chevron toggle, default collapsed; lazy-load on first expand.",
"testStrategy": "",
"status": "in-progress",
"dependencies": [],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-04-26T11:01:34.264Z"
} }
], ],
"metadata": { "metadata": {
"version": "1.0.0", "version": "1.0.0",
"lastModified": "2026-04-25T17:03:34.486Z", "lastModified": "2026-04-26T11:01:34.265Z",
"taskCount": 19, "taskCount": 21,
"completedCount": 11, "completedCount": 14,
"tags": [ "tags": [
"master" "master"
] ]
@@ -37,6 +37,7 @@
<option value="regulation">תקנות</option> <option value="regulation">תקנות</option>
<option value="circular">חוזרים</option> <option value="circular">חוזרים</option>
<option value="caselaw">פסיקה</option> <option value="caselaw">פסיקה</option>
<option value="tool">כלי הערכה</option>
</select> </select>
<button type="button" class="btn btn-primary" data-action="submit">חפש</button> <button type="button" class="btn btn-primary" data-action="submit">חפש</button>
<button type="button" class="btn btn-default" data-action="clearResults" <button type="button" class="btn btn-default" data-action="clearResults"
@@ -72,9 +73,29 @@
{{#ifEqual mode 'manage'}} {{#ifEqual mode 'manage'}}
<div class="kb-manage" style="display:flex;flex-direction:column;gap:16px;"> <div class="kb-manage" style="display:flex;flex-direction:column;gap:16px;">
<div class="panel panel-default kb-sources-panel" style="padding:12px;"> <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;"> <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> <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 kb-collapsible kb-collapsed" 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;cursor:pointer;user-select:none;" data-action="toggleSection" title="הרחב/כווץ">
<span class="kb-collapse-chevron glyphicon glyphicon-chevron-left" style="font-size:12px;margin-left:6px;"></span>
מקורות בנושא<span class="kb-topic-name-suffix"></span>
</h4>
<div style="display:flex;gap:8px;align-items:center;"> <div style="display:flex;gap:8px;align-items:center;">
<select class="form-control kb-sources-kind-filter" style="width:auto;"> <select class="form-control kb-sources-kind-filter" style="width:auto;">
<option value="">כל הסוגים</option> <option value="">כל הסוגים</option>
@@ -82,79 +103,96 @@
<option value="regulation">תקנות</option> <option value="regulation">תקנות</option>
<option value="circular">חוזרים</option> <option value="circular">חוזרים</option>
<option value="caselaw">פסיקה</option> <option value="caselaw">פסיקה</option>
<option value="tool">כלי הערכה</option>
</select> </select>
<button type="button" class="btn btn-default btn-sm" data-action="refreshSources" title="רענן"> <button type="button" class="btn btn-default btn-sm" data-action="refreshSources" title="רענן">
<span class="glyphicon glyphicon-refresh"></span> <span class="glyphicon glyphicon-refresh"></span>
</button> </button>
</div> </div>
</div> </div>
<div class="kb-sources-table"> <div class="kb-collapsible-body" style="display:none;">
<div class="text-muted">טוען מקורות…</div> <div class="kb-sources-table">
<div class="text-muted">טוען מקורות…</div>
</div>
</div> </div>
</div> </div>
<div class="panel panel-default" style="padding:12px;"> <div class="panel panel-default kb-labels-panel kb-collapsible kb-collapsed" style="padding:12px;">
<h4 style="margin-top:0;">העלאת מסמך חדש</h4> <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
<form class="kb-upload-form" enctype="multipart/form-data" <h4 style="margin:0;cursor:pointer;user-select:none;" data-action="toggleSection" title="הרחב/כווץ">
style="display:flex;flex-direction:column;gap:10px;"> <span class="kb-collapse-chevron glyphicon glyphicon-chevron-left" style="font-size:12px;margin-left:6px;"></span>
<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> </h4>
<input type="file" id="kb-upload-file" name="file" <button type="button" class="btn btn-default btn-sm" data-action="refreshLabels" title="רענן">
accept=".pdf,.docx,.txt" <span class="glyphicon glyphicon-refresh"></span>
style="flex:1 1 280px;" /> </button>
</div>
<div class="kb-collapsible-body" style="display:none;">
<div class="kb-labels-table">
<div class="text-muted">טוען תוויות…</div>
</div> </div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;"> <div class="text-muted small" style="margin-top:6px;">
<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;">
<option value="law">חוק</option>
<option value="regulation">תקנות</option>
<option value="circular">חוזר</option>
<option value="caselaw">פסיקה</option>
</select>
</div> </div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;"> </div>
<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 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>
</div>
</form>
</div> </div>
<div class="panel panel-default" style="padding:12px;"> <div class="panel panel-default kb-pending-panel" style="padding:12px;">
<h4 style="margin-top:0;">משימות אחרונות</h4> <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
<div class="kb-jobs-list"> <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 class="text-muted">טוען…</div>
</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;">
<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>
</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 kb-jobs-panel kb-collapsible kb-collapsed" style="padding:12px;">
<h4 style="margin-top:0;cursor:pointer;user-select:none;" data-action="toggleSection" title="הרחב/כווץ">
<span class="kb-collapse-chevron glyphicon glyphicon-chevron-left" style="font-size:12px;margin-left:6px;"></span>
משימות אחרונות
</h4>
<div class="kb-collapsible-body" style="display:none;">
<div class="kb-jobs-list">
<div class="text-muted">טוען…</div>
</div>
</div>
</div> </div>
</div> </div>
{{/ifEqual}} {{/ifEqual}}
@@ -16,6 +16,10 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
let _activeJobTopicId = null; // topic this job belongs to (for cross-topic guard) let _activeJobTopicId = null; // topic this job belongs to (for cross-topic guard)
let _pollIntervalId = null; // setInterval handle so remounts don't double-poll let _pollIntervalId = null; // setInterval handle so remounts don't double-poll
let _adminSourcesByTopic = {}; // {topicId: [...sources]} cache for the manage tab let _adminSourcesByTopic = {}; // {topicId: [...sources]} cache for the manage tab
let _adminTopics = null; // [...topics admin view] cache (incl. is_active=false)
let _adminLabels = null; // labels admin cache
let _activeBatch = null; // {batchId, kind, cards:[{jobId, filename, status, ai, edits, labels}]}
let _batchPollIntervalId = null;
const SS_ASK = 'kb-last-ask'; const SS_ASK = 'kb-last-ask';
const SS_SEARCH = 'kb-last-search'; const SS_SEARCH = 'kb-last-search';
@@ -156,6 +160,86 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
const id = parseInt($(e.currentTarget).data('id'), 10); const id = parseInt($(e.currentTarget).data('id'), 10);
if (id) this._reingestSource(id); 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);
},
// ── Phase 6 (v0.8.0): batch upload + labels admin ──────────────
'click [data-action="pickBatchFiles"]': function (e) {
e.preventDefault();
this.$el.find('#kb-batch-files').trigger('click');
},
'change #kb-batch-files': function (e) {
const files = Array.from(e.target.files || []);
if (files.length) this._uploadBatch(files);
e.target.value = '';
},
'click [data-action="discardCard"]': function (e) {
e.preventDefault();
const jobId = parseInt($(e.currentTarget).data('jobid'), 10);
if (jobId) this._discardCard(jobId);
},
'click [data-action="commitAllBatch"]': function (e) {
e.preventDefault();
this._commitAllBatch();
},
'click [data-action="discardAllBatch"]': function (e) {
e.preventDefault();
this._discardAllBatch();
},
'click [data-action="refreshLabels"]': function (e) {
e.preventDefault();
this._loadAdminLabels(/*force*/ true);
},
'click [data-action="deleteLabel"]': function (e) {
e.preventDefault();
const id = parseInt($(e.currentTarget).data('id'), 10);
if (id) this._deleteLabel(id);
},
'click [data-action="refreshPending"]': function (e) {
e.preventDefault();
this._loadPendingBatches();
},
'click [data-action="openBatch"]': function (e) {
e.preventDefault();
const batchId = $(e.currentTarget).data('batchid');
const topicAttr = $(e.currentTarget).data('topicid');
const topicId = (topicAttr === '' || topicAttr == null) ? null : parseInt(topicAttr, 10);
if (batchId) this._openBatch(String(batchId), topicId);
},
'click [data-action="toggleSection"]': function (e) {
e.preventDefault();
this._toggleSection($(e.currentTarget).closest('.kb-collapsible'));
},
}, },
// Cancel any in-flight ask/search, drop the cached last-result for // Cancel any in-flight ask/search, drop the cached last-result for
@@ -245,10 +329,43 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
// here. The polling continues silently in the background. // here. The polling continues silently in the background.
} }
// Always refresh the recent-jobs list on mount. // Sources / Labels / Recent-Jobs panels are collapsed by default
this._loadJobsList(); // (they're long lists the user usually doesn't need on mount).
// Phase 3: load the sources table when the manage tab is open. // Their data loads lazily on first expand via _toggleSection.
this._loadAdminSources(); // Phase 4: load the topics admin table (firm-wide, not topic-scoped).
this._loadAdminTopics();
// Phase 6: load batches that still need review (RAM-only
// _activeBatch is wiped by hard refresh; this lets the user
// resume any pending batch from the panel).
this._loadPendingBatches();
// Phase 6: redraw any in-flight batch upload (survives remount).
if (_activeBatch) {
this._renderBatchCards();
if (this._batchHasClassifying()) this._startBatchPolling();
}
},
_toggleSection: function ($panel) {
if (!$panel || !$panel.length) return;
const isCollapsed = $panel.hasClass('kb-collapsed');
const $body = $panel.find('.kb-collapsible-body').first();
const $chev = $panel.find('.kb-collapse-chevron').first();
if (isCollapsed) {
$panel.removeClass('kb-collapsed');
$chev.removeClass('glyphicon-chevron-left').addClass('glyphicon-chevron-down');
$body.slideDown(150);
// Lazy-load on first expand. The individual loaders are
// idempotent — they early-out if the list is in the DOM but
// already populated. Pass force=true so a stale "טוען…"
// placeholder gets replaced even on second expand.
if ($panel.hasClass('kb-sources-panel')) this._loadAdminSources(true);
else if ($panel.hasClass('kb-labels-panel')) this._loadAdminLabels(true);
else if ($panel.hasClass('kb-jobs-panel')) this._loadJobsList();
} else {
$panel.addClass('kb-collapsed');
$chev.removeClass('glyphicon-chevron-down').addClass('glyphicon-chevron-left');
$body.slideUp(150);
}
}, },
_loadJobsList: function () { _loadJobsList: function () {
@@ -281,7 +398,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
done: '<span class="label label-success">הושלם</span>', done: '<span class="label label-success">הושלם</span>',
failed: '<span class="label label-danger">נכשל</span>', failed: '<span class="label label-danger">נכשל</span>',
}; };
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזר', caselaw: 'פסיקה'}; const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזר', caselaw: 'פסיקה', tool: 'כלי הערכה'};
const rows = items.map(j => { const rows = items.map(j => {
const when = j.completed_at || j.started_at || j.created_at || ''; const when = j.completed_at || j.started_at || j.created_at || '';
const whenShort = String(when).slice(0, 19).replace('T', ' '); const whenShort = String(when).slice(0, 19).replace('T', ' ');
@@ -307,6 +424,241 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
$list.html(rows); $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) ────────────────────────────── // ── Phase 3: sources table (Task #14) ──────────────────────────────
_loadAdminSources: function (force) { _loadAdminSources: function (force) {
@@ -347,7 +699,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
return; return;
} }
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזר', caselaw: 'פסיקה'}; const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזר', caselaw: 'פסיקה', tool: 'כלי הערכה'};
const rows = items.map(s => { const rows = items.map(s => {
const li = s.last_ingest || {}; const li = s.last_ingest || {};
const failedFlag = li.status === 'failed' const failedFlag = li.status === 'failed'
@@ -501,6 +853,514 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
}); });
}, },
// ── Phase 6 (v0.8.0): labels admin ─────────────────────────────────
_loadAdminLabels: function (force) {
const $table = this.$el.find('.kb-labels-table');
if (!$table.length) return;
if (!force && _adminLabels) {
this._renderLabelsTable();
return;
}
const self = this;
Espo.Ajax.getRequest('KnowledgeBase/action/labels', {
topicId: this.topicId,
limit: 200,
}).then(res => {
_adminLabels = res && res.items || [];
self._renderLabelsTable();
}).catch(err => {
console.error('KB: load labels failed', err);
$table.html('<div class="text-muted">שגיאה בטעינת תוויות.</div>');
});
},
_renderLabelsTable: function () {
const $table = this.$el.find('.kb-labels-table');
if (!$table.length) return;
const items = _adminLabels || [];
if (!items.length) {
$table.html('<div class="text-muted">אין תוויות עדיין. הן ייווצרו אוטומטית מההצעות של שירה בעת ההעלאה.</div>');
return;
}
const rows = items.map(l => `<tr data-id="${l.id}">
<td><span style="font-weight:500;">${this.escape(l.name)}</span></td>
<td><code class="text-muted small" style="direction:ltr;">${this.escape(l.slug)}</code></td>
<td class="text-center">${l.usage_count || 0}</td>
<td style="text-align:left;">
${(l.usage_count || 0) === 0
? `<button class="btn btn-link btn-sm" data-action="deleteLabel" data-id="${l.id}" title="מחק" style="color:#d9534f;"><span class="glyphicon glyphicon-trash"></span></button>`
: '<span class="text-muted small">בשימוש</span>'}
</td>
</tr>`).join('');
$table.html(`
<table class="table table-condensed table-hover" style="margin:0;">
<thead>
<tr><th>שם תווית</th><th style="width:200px;">slug</th><th style="width:80px;text-align:center;">שימושים</th><th style="width:80px;"></th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
`);
},
_deleteLabel: function (id) {
const lbl = (_adminLabels || []).find(l => l.id === id);
if (!lbl) return;
if (!confirm(`למחוק את התווית "${lbl.name}" (${lbl.slug})?`)) return;
const self = this;
Espo.Ajax.postRequest('KnowledgeBase/action/deleteLabel', {id})
.then(() => {
_adminLabels = (_adminLabels || []).filter(l => l.id !== id);
self._renderLabelsTable();
})
.catch(err => {
let msg = 'שגיאה לא ידועה';
try { const j = JSON.parse(err && err.responseText || '{}'); msg = j.message || j.detail || msg; } catch (e) {}
alert('מחיקה נכשלה: ' + String(msg).slice(0, 200));
});
},
// ── Phase 6: batch upload + AI review ──────────────────────────────
// Pending-review panel: resumes any batch the user uploaded
// earlier and didn't commit. _activeBatch is RAM-only, so a hard
// refresh wipes the review screen — without this list the only
// recovery path was the DB.
_loadPendingBatches: function () {
const self = this;
const $list = this.$el.find('.kb-pending-list');
const $count = this.$el.find('.kb-pending-count');
if (!$list.length) return;
Espo.Ajax.getRequest('KnowledgeBase/action/pendingBatches', {limit: 50})
.then(res => {
const items = (res && res.items) || [];
self._renderPendingBatches(items);
$count.text(items.length ? ` (${items.length})` : '');
})
.catch(err => {
console.error('KB: failed to load pending batches', err);
$list.html('<div class="text-muted">שגיאה בטעינת ממתינים לאישור.</div>');
});
},
_renderPendingBatches: function (items) {
const $list = this.$el.find('.kb-pending-list');
if (!$list.length) return;
if (!items.length) {
$list.html('<div class="text-muted">אין באצ׳ים ממתינים לאישור.</div>');
return;
}
const kindHe = {
law: 'חוק', regulation: 'תקנות', circular: 'חוזרים',
caselaw: 'פסיקה', tool: 'כלי הערכה',
};
const escape = this.escape.bind(this);
const html = items.map(b => {
const kinds = (b.kinds || []).map(k => kindHe[k] || k).join(', ');
const time = b.created_at ? new Date(b.created_at).toLocaleString('he-IL') : '';
const counters = [];
if (b.awaiting_review) counters.push(`<span class="label label-success">${b.awaiting_review} מוכנים</span>`);
if (b.processing) counters.push(`<span class="label label-info">${b.processing} בתהליך</span>`);
if (b.queued) counters.push(`<span class="label label-default">${b.queued} בתור</span>`);
if (b.failed) counters.push(`<span class="label label-danger">${b.failed} נכשלו</span>`);
if (b.done) counters.push(`<span class="label label-success">${b.done} הושלמו</span>`);
const isCurrent = _activeBatch && _activeBatch.batchId === b.batch_id;
const more = b.total > 1 ? ` <span class="text-muted">+ ${b.total - 1} נוספים</span>` : '';
return `<div class="kb-pending-row" style="display:flex;justify-content:space-between;align-items:center;gap:10px;padding:8px;border-bottom:1px solid #eee;">
<div style="flex:1;min-width:0;">
<div style="font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">📦 ${escape(b.first_filename || '(ללא שם)')}${more}</div>
<div class="text-muted small">${escape(kinds)} · ${escape(time)} · ${counters.join(' ')}</div>
</div>
<button type="button" class="btn btn-${isCurrent ? 'default' : 'primary'} btn-sm" data-action="openBatch" data-batchid="${escape(b.batch_id)}" data-topicid="${b.topic_id || ''}"${isCurrent ? ' disabled title="פתוח כרגע"' : ''}>${isCurrent ? 'פתוח' : 'פתח'}</button>
</div>`;
}).join('');
$list.html(html);
},
_openBatch: function (batchId, topicId) {
const self = this;
if (!batchId) return;
if (_activeBatch && _activeBatch.batchId === batchId) {
this._scrollToBatch();
return;
}
// If the batch belongs to a different topic, switch — labels
// and source views are topic-scoped, the user should see the
// matching context.
if (topicId && this.topicId !== topicId) {
this.switchTopic(topicId);
}
this._stopBatchPolling();
Espo.Ajax.getRequest('KnowledgeBase/action/batch', {batchId})
.then(res => {
const items = (res && res.items) || [];
if (!items.length) {
alert('הבאצ׳ ריק או נמחק.');
return;
}
const cards = items.map(it => {
let ai = it.ai_suggestions;
if (typeof ai === 'string') {
try { ai = JSON.parse(ai); } catch (e) { ai = {}; }
}
let s = it.status;
if (s === 'processing' && it.processing_stage === 'classifying') s = 'classifying';
else if (s === 'processing' && it.processing_stage === 'embedding') s = 'committing';
return {
jobId: it.id,
filename: it.original_filename,
status: s,
ai: (ai && Object.keys(ai).length) ? ai : null,
edits: null,
error: it.error_message || null,
};
});
_activeBatch = {
batchId: batchId,
kind: (items[0] && items[0].kind) || 'circular',
topicId: topicId || self.topicId,
cards,
};
self._renderBatchCards();
// Repaint pending list so the opened row shows "פתוח" instead of "פתח".
self._loadPendingBatches();
self._scrollToBatch();
if (self._batchHasClassifying() || self._batchHasCommitting()) {
self._startBatchPolling();
}
})
.catch(err => {
console.error('KB: failed to open batch', err);
alert('פתיחת הבאצ׳ נכשלה.');
});
},
_scrollToBatch: function () {
const $batch = this.$el.find('.kb-batch-panel');
if ($batch.length && $batch[0].scrollIntoView) {
$batch[0].scrollIntoView({behavior: 'smooth', block: 'start'});
}
},
_uploadBatch: function (fileList) {
const $kindSel = this.$el.find('#kb-batch-kind');
const kind = $kindSel.val() || 'circular';
const topicId = this.topicId;
if (topicId == null) {
alert('בחר נושא לפני העלאה.');
return;
}
// Filter on size + extension client-side; server enforces too.
const ok = [];
for (const f of fileList) {
if (!/\.(pdf|docx|txt)$/i.test(f.name)) continue;
if (f.size > 50 * 1024 * 1024) {
alert(`הקובץ "${f.name}" גדול מ-50MB ולא יועלה.`);
continue;
}
ok.push(f);
}
if (!ok.length) return;
const fd = new FormData();
fd.append('kind', kind);
fd.append('topicId', String(topicId));
// PHP-side $_FILES['files'] requires the bracket suffix to
// recognize repeated fields as a multi-file array. The
// EspoCRM controller un-array's into a plain `files` field
// before forwarding to shira-hermes (FastAPI standard).
for (const f of ok) fd.append('files[]', f, f.name);
// Initialize cards in placeholder state. They get the real
// job_id from the server response.
_activeBatch = {
batchId: null,
kind,
topicId,
cards: ok.map(f => ({
jobId: null,
filename: f.name,
status: 'uploading',
ai: null,
edits: null,
})),
};
this._renderBatchCards();
const self = this;
$.ajax({
url: 'api/v1/KnowledgeBase/action/uploadBatch',
method: 'POST',
data: fd,
contentType: false,
processData: false,
cache: false,
timeout: 300000,
}).then(res => {
if (!_activeBatch) return;
_activeBatch.batchId = res.batch_id;
// Match returned jobs to local cards by filename order.
(res.jobs || []).forEach((j, i) => {
if (_activeBatch.cards[i]) {
_activeBatch.cards[i].jobId = j.job_id || null;
_activeBatch.cards[i].status = j.error ? 'failed' : 'queued';
if (j.error) _activeBatch.cards[i].error = j.error;
}
});
self._renderBatchCards();
self._startBatchPolling();
}).fail(xhr => {
let msg = 'שגיאה לא ידועה';
try {
const j = JSON.parse(xhr.responseText || '{}');
msg = j.message || j.detail || xhr.responseText || msg;
} catch (e) { msg = xhr.responseText || msg; }
alert('העלאה נכשלה: ' + String(msg).slice(0, 300));
_activeBatch = null;
self._renderBatchCards();
});
},
_renderBatchCards: function () {
const $cards = this.$el.find('.kb-batch-cards');
const $actions = this.$el.find('.kb-batch-actions');
if (!$cards.length) return;
if (!_activeBatch || !_activeBatch.cards.length) {
$cards.empty();
$actions.hide();
return;
}
const statusLabel = (s) => ({
uploading: '<span class="label label-default">מעלה…</span>',
queued: '<span class="label label-default">בתור</span>',
classifying: '<span class="label label-info">מנתח מטא-דאטה…</span>',
awaiting_review: '<span class="label label-success">[✓ מוכן]</span>',
committing: '<span class="label label-info">מטמיע…</span>',
done: '<span class="label label-success">[✓ הושלם]</span>',
failed: '<span class="label label-danger">[⚠️ נכשל]</span>',
}[s] || `<span class="label label-default">${this.escape(s)}</span>`);
const kindOptions = (selected) => {
const opts = [
['law', 'חוק'],
['regulation', 'תקנות'],
['circular', 'חוזר'],
['caselaw', 'פסיקה'],
['tool', 'כלי הערכה'],
];
return opts.map(([v, l]) =>
`<option value="${v}"${v === selected ? ' selected' : ''}>${l}</option>`
).join('');
};
let readyCount = 0;
const html = _activeBatch.cards.map((c, idx) => {
if (c.status === 'awaiting_review') readyCount++;
const ai = c.ai || {};
const e = c.edits || {};
const val = (k) => this.escape(e[k] != null ? e[k] : (ai[k] || ''));
const labelsStr = e.labelsStr != null ? e.labelsStr : ((ai.labels || []).join(', '));
let body = '';
if (c.status === 'failed') {
body = `<div class="text-danger small">${this.escape(c.error || 'שגיאה')}</div>`;
} else if (c.status === 'awaiting_review' || c.status === 'committing' || c.status === 'done') {
const disabled = c.status !== 'awaiting_review' ? ' disabled' : '';
body = `
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<select class="form-control" data-card="${idx}" data-edit="kind" style="width:auto;"${disabled}>${kindOptions(e.kind || ai.kind || _activeBatch.kind)}</select>
<input type="text" class="form-control" data-card="${idx}" data-edit="title" value="${val('title')}" placeholder="כותרת" style="flex:1 1 240px;"${disabled} />
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<input type="text" class="form-control" data-card="${idx}" data-edit="identifier" value="${val('identifier')}" placeholder="מזהה (אופציונלי)" style="flex:1 1 200px;"${disabled} />
<input type="date" class="form-control" data-card="${idx}" data-edit="published_at" value="${this.escape((e.published_at != null ? e.published_at : (ai.published_at || '')).slice(0,10))}" style="width:160px;"${disabled} />
</div>
<input type="text" class="form-control" data-card="${idx}" data-edit="labelsStr" value="${this.escape(labelsStr)}" placeholder="תוויות (מופרדות בפסיק)" style="width:100%;"${disabled} />
<textarea class="form-control" rows="2" data-card="${idx}" data-edit="summary" placeholder="סיכום קצר" style="width:100%;"${disabled}>${this.escape(e.summary != null ? e.summary : (ai.summary || ''))}</textarea>
`;
} else {
body = '<div class="text-muted small">ממתין לניתוח…</div>';
}
return `<div class="kb-batch-card panel" data-card="${idx}" style="padding:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px;">
<div style="font-weight:500;">📄 ${this.escape(c.filename)}</div>
<div style="display:flex;gap:8px;align-items:center;">
${statusLabel(c.status)}
${c.status !== 'done' && c.status !== 'committing' ? `<button class="btn btn-link btn-sm" data-action="discardCard" data-jobid="${c.jobId || 0}" title="הסר" style="color:#d9534f;"><span class="glyphicon glyphicon-remove"></span></button>` : ''}
</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px;">${body}</div>
</div>`;
}).join('');
$cards.html(html);
$actions.toggle(_activeBatch.cards.length > 0);
this.$el.find('.kb-batch-commit-count').text(readyCount > 0 ? `(${readyCount})` : '');
this.$el.find('[data-action="commitAllBatch"]').prop('disabled', readyCount === 0);
// Wire input change → save into card.edits
const self = this;
$cards.find('[data-edit]').off('input.batch change.batch').on('input.batch change.batch', function () {
const idx = parseInt($(this).data('card'), 10);
const k = $(this).data('edit');
if (_activeBatch && _activeBatch.cards[idx]) {
if (!_activeBatch.cards[idx].edits) _activeBatch.cards[idx].edits = {};
_activeBatch.cards[idx].edits[k] = $(this).val();
}
});
},
_batchHasClassifying: function () {
return _activeBatch && _activeBatch.cards.some(c =>
c.status === 'queued' || c.status === 'classifying' || c.status === 'uploading'
);
},
_startBatchPolling: function () {
this._stopBatchPolling();
const self = this;
// Refresh the pending list once at start so a freshly-uploaded
// batch appears there right away (without waiting for the
// batch to quiesce).
this._loadPendingBatches();
const tick = () => {
if (!_activeBatch || !_activeBatch.batchId) return;
Espo.Ajax.getRequest('KnowledgeBase/action/batch', {batchId: _activeBatch.batchId})
.then(res => {
if (!_activeBatch) return;
const byId = {};
for (const it of res.items || []) byId[it.id] = it;
for (const card of _activeBatch.cards) {
if (!card.jobId) continue;
const it = byId[card.jobId];
if (!it) continue;
// Map server status → card status
let s = it.status;
if (s === 'processing' && it.processing_stage === 'classifying') s = 'classifying';
else if (s === 'processing' && it.processing_stage === 'embedding') s = 'committing';
// Don't downgrade a locally-set 'committing' back to
// 'awaiting_review' just because the POST commit
// hasn't reached the DB yet — that re-enables the
// button and lets the user re-submit, which then
// explodes with HTTP 400 once the first commit
// races to 'done'.
if (!(card.status === 'committing' && s === 'awaiting_review')) {
card.status = s;
}
card.error = it.error_message || null;
// Parse ai_suggestions if string
let ai = it.ai_suggestions;
if (typeof ai === 'string') {
try { ai = JSON.parse(ai); } catch (e) { ai = {}; }
}
if (ai && Object.keys(ai).length) card.ai = ai;
}
self._renderBatchCards();
if (!self._batchHasClassifying() && !self._batchHasCommitting()) {
self._stopBatchPolling();
// Refresh sources/jobs/labels list since new ones may have landed.
if (_activeBatch.cards.some(c => c.status === 'done')) {
self._loadJobsList();
self._loadAdminSources(true);
self._loadAdminLabels(true);
}
// Pending list reflects awaiting_review counters
// — refresh on every batch quiescence so commits
// and discards drop the row out cleanly.
self._loadPendingBatches();
}
})
.catch(err => console.error('KB: batch poll failed', err));
};
tick();
_batchPollIntervalId = setInterval(tick, 3000);
},
_stopBatchPolling: function () {
if (_batchPollIntervalId) {
clearInterval(_batchPollIntervalId);
_batchPollIntervalId = null;
}
},
_batchHasCommitting: function () {
return _activeBatch && _activeBatch.cards.some(c => c.status === 'committing');
},
_commitAllBatch: function () {
if (!_activeBatch) return;
const self = this;
const ready = _activeBatch.cards.filter(c => c.status === 'awaiting_review' && c.jobId);
if (!ready.length) return;
for (const card of ready) {
const ai = card.ai || {};
const e = card.edits || {};
const labelsStr = e.labelsStr != null ? e.labelsStr : ((ai.labels || []).join(', '));
const newLabels = labelsStr
.split(',')
.map(s => s.trim())
.filter(s => s.length);
const payload = {
id: card.jobId,
kind: e.kind || ai.kind || _activeBatch.kind,
title: (e.title != null ? e.title : (ai.title || card.filename)).trim() || card.filename,
identifier: (e.identifier != null ? e.identifier : (ai.identifier || '')).trim() || null,
published_at: (e.published_at != null ? e.published_at : (ai.published_at || '')) || null,
effective_at: (e.effective_at != null ? e.effective_at : (ai.effective_at || '')) || null,
source_url: (e.source_url != null ? e.source_url : (ai.source_url || '')) || null,
summary: (e.summary != null ? e.summary : (ai.summary || '')) || null,
label_slugs: [],
new_labels: newLabels,
};
card.status = 'committing';
Espo.Ajax.postRequest('KnowledgeBase/action/commitJob', payload)
.catch(err => {
console.error('KB: commit failed for job', card.jobId, err);
card.status = 'failed';
card.error = (err && err.responseText) || 'commit failed';
self._renderBatchCards();
});
}
self._renderBatchCards();
self._startBatchPolling();
},
_discardCard: function (jobId) {
if (!_activeBatch) return;
const idx = _activeBatch.cards.findIndex(c => c.jobId === jobId);
if (idx < 0) return;
// If never queued (server failed before assigning jobId), just drop locally.
if (!jobId) {
_activeBatch.cards.splice(idx, 1);
if (!_activeBatch.cards.length) _activeBatch = null;
this._renderBatchCards();
return;
}
const self = this;
Espo.Ajax.postRequest('KnowledgeBase/action/discardJob', {id: jobId})
.finally(() => {
_activeBatch.cards.splice(idx, 1);
if (!_activeBatch.cards.length) _activeBatch = null;
self._renderBatchCards();
});
},
_discardAllBatch: function () {
if (!_activeBatch) return;
if (!confirm(`לבטל את כל ${_activeBatch.cards.length} הקבצים בקבוצה?`)) return;
const self = this;
const promises = _activeBatch.cards
.filter(c => c.jobId && c.status !== 'done')
.map(c => Espo.Ajax.postRequest('KnowledgeBase/action/discardJob', {id: c.jobId})
.catch(() => null));
Promise.all(promises).finally(() => {
_activeBatch = null;
self._stopBatchPolling();
self._renderBatchCards();
self._loadJobsList();
});
},
// Live status panel rendered above the upload form during processing. // Live status panel rendered above the upload form during processing.
_renderJobStatus: function (job) { _renderJobStatus: function (job) {
const $form = this.$el.find('.kb-upload-form'); const $form = this.$el.find('.kb-upload-form');
@@ -1048,7 +1908,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
return; return;
} }
const initialIdx = (typeof selectedIdx === 'number' && hits[selectedIdx]) ? selectedIdx : 0; const initialIdx = (typeof selectedIdx === 'number' && hits[selectedIdx]) ? selectedIdx : 0;
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר', caselaw: 'פסיקה'}; const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר', caselaw: 'פסיקה', tool: 'כלי'};
const self = this; const self = this;
const isPdfBacked = h => h && (h.kind === 'regulation' || h.kind === 'circular'); const isPdfBacked = h => h && (h.kind === 'regulation' || h.kind === 'circular');
@@ -1347,7 +2207,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
answerHtml = '<div style="white-space:pre-wrap;">' + this.escape(text || '') + '</div>'; answerHtml = '<div style="white-space:pre-wrap;">' + this.escape(text || '') + '</div>';
} }
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר', caselaw: 'פסיקה'}; const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר', caselaw: 'פסיקה', tool: 'כלי'};
// Dedup sources by source_id (first occurrence = most relevant), // Dedup sources by source_id (first occurrence = most relevant),
// but remember every page that came up per source so users can // but remember every page that came up per source so users can
@@ -1487,8 +2347,8 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
$list.html('<div class="text-muted">אין מקורות.</div>'); $list.html('<div class="text-muted">אין מקורות.</div>');
return; return;
} }
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזרים', caselaw: 'פסיקה'}; const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזרים', caselaw: 'פסיקה', tool: 'כלי הערכה'};
const grouped = {law: [], regulation: [], circular: [], caselaw: []}; const grouped = {law: [], regulation: [], circular: [], caselaw: [], tool: []};
this._sources.forEach(s => { this._sources.forEach(s => {
if (grouped[s.kind]) grouped[s.kind].push(s); if (grouped[s.kind]) grouped[s.kind].push(s);
}); });
@@ -252,4 +252,216 @@ class KnowledgeBase
(string) $this->user->get('userName') (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);
}
} }
@@ -94,5 +94,109 @@
"controller": "KnowledgeBase", "controller": "KnowledgeBase",
"action": "reingestSource" "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"
}
} }
] ]
@@ -205,6 +205,177 @@ class KnowledgeBaseService
); );
} }
// ── Phase 4: topic CRUD (Task #15) ──────────────────────────────────────
public function listAdminTopics(): array
{
return $this->get('/admin/kb/topics');
}
public function createAdminTopic(array $payload): array
{
return $this->request('POST', '/admin/kb/topics', $payload);
}
public function updateAdminTopic(int $topicId, array $fields): array
{
return $this->request('PUT', '/admin/kb/topics/' . $topicId, $fields);
}
public function deleteAdminTopic(int $topicId): array
{
return $this->request('DELETE', '/admin/kb/topics/' . $topicId, null);
}
// ── 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 private function postWithUser(string $path, ?array $payload, string $username): array
{ {
$url = $this->getBaseUrl() . $path; $url = $this->getBaseUrl() . $path;
@@ -491,6 +662,13 @@ class KnowledgeBaseService
} }
if ($httpCode < 200 || $httpCode >= 300) { if ($httpCode < 200 || $httpCode >= 300) {
$this->log->error("KnowledgeBase: HTTP {$httpCode} from {$url}: {$body}"); $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}"); throw new Error("Knowledge Base returned HTTP {$httpCode}");
} }
$decoded = json_decode($body, true); $decoded = json_decode($body, true);
+4 -3
View File
@@ -1,14 +1,15 @@
{ {
"name": "KnowledgeBase", "name": "KnowledgeBase",
"module": "KnowledgeBase", "module": "KnowledgeBase",
"version": "0.6.0", "version": "0.8.1",
"acceptableVersions": [ "acceptableVersions": [
">=8.0.0" ">=8.0.0"
], ],
"php": [ "php": [
">=8.1" ">=8.1"
], ],
"releaseDate": "2026-04-25", "releaseDate": "2026-04-26",
"author": "klear", "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": "מאגר ידע"
} }