28 Commits

Author SHA1 Message Date
36d10b6a70 feat(X13): auto-fetch court verdicts from נט המשפט → corpus (Tier 0 + scaffold)
תת-מערכת אחזור-פסיקה אוטומטי: כשיומון מצביע על פס"ד בית-משפט, מסווגים את
הערכאה, מורידים מהמקור הציבורי המתאים, וקולטים דרך צינור-הקליטה הקנוני.

- spec-first: docs/spec/X13-court-fetch.md (INV-CF1..CF7) + אינדקס
- מסווג court_citation.py (supreme/admin/skip) + 10 בדיקות (עת"מ 46111-12-22 → admin)
- Tier 0: court_fetch_supreme.py — supremedecisions API (reverse-engineered), httpx
  + browser-headers (אומת 200) + politeness
- תור court_fetch_jobs (SCHEMA_V30) + DB helpers + court_fetch_orchestrator.py
- Tier 1 scaffold: legal-court-fetch-service (aiohttp+Bearer, מראת legal-chat-service)
  + camofox_client (Camoufox open-source) + recaptcha_audio (Whisper מקומי) + pm2
- Tier 2 fallback חינני: manual + missing_precedent (INV-CF2/CF3 — אין drop שקט)
- כלי-MCP court_verdict_fetch / court_fetch_status; SCRIPTS.md

Invariants: מקיים G2 (מסלול-קליטה יחיד, INV-CF1) · G3/G1 (idempotent+נרמול, INV-CF5)
· G4/§6 (אין בליעה שקטה, INV-CF2) · G10 (שער-אנושי, INV-CF3) · G5 (source_type,
INV-CF6) · G9 (provenance+audit, INV-CF7). מקורות INV-CF4: RFC 9309 · Google
crawler · OWASP OAT.

Follow-ups (טרם אומתו חי): live Tier-0 validation · התקנת camofox-browser+whisper
· כיול selectors Tier-1 · COURT_FETCH_SHARED_SECRET (Infisical+Coolify) · טריגר
מ-digest try_autolink (worktree-digests-radar). V30 עלול להתנגש עם digests-radar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:08:23 +00:00
9eaabffba4 Merge pull request 'fix(goldset): single view-mode filter (can't get stuck hiding untagged)' (#108) from worktree-goldset-filter-fix into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 44s
2026-06-07 14:48:16 +00:00
90f3c472b5 fix(goldset): single view-mode filter — can't get stuck hiding untagged
The old independent toggles had a trap: clicking "אי-הסכמות AI" set a filter,
and once all disagreements were resolved the toggle button disappeared
(rendered only when count>0) while the filter stayed ON — so the list showed
zero items and the untagged ones were unreachable.

Replaced hideTagged + disagreeOnly with one mutually-exclusive segmented
control: הכל / לא תויגו / תויגו / ⚠ אי-הסכמות, each with a live count and always
visible. No stuck state; "לא תויגו" makes the remaining work obvious.

Verified: tsc --noEmit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:47:53 +00:00
638a542cf4 Merge pull request 'feat(goldset): AI second-opinion per item (QA aid)' (#107) from worktree-goldset-ai-recommendation into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m35s
2026-06-07 14:25:06 +00:00
0e35060d3d feat(goldset): AI second-opinion per item (QA aid) — compare vs human tag
The chair wanted an independent recommendation beside each tag, to reconsider
his own judgments. Adds a NON-ground-truth AI second-opinion:

- schema: halacha_goldset.ai_is_holding / ai_correct_type / ai_rationale /
  ai_generated_at (additive).
- db.goldset_set_ai_recommendation + goldset_list now returns the ai_* fields.
- scripts/goldset_ai_recommend.py — local claude_session judges is_holding +
  type + a one-line rationale per item, INDEPENDENTLY (own legal rubric).
  Independent of the rule-based validators #81.8 measures → no circularity.
  Never auto-applied; QA aid only.
- web-ui: each card shows "🤖 המלצת AI: הלכה/לא · type" + rationale and an
  agreement/disagreement chip vs the human tag (amber on disagree); a
  "⚠ אי-הסכמות AI (N)" filter to review only the conflicts.

Methodology note kept explicit: the human stays the ground truth; the AI is a
prompt to reconsider, not to copy.

Verified: tsc --noEmit 0; generator stores recs and flags disagreements with
existing human tags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:24:35 +00:00
a0c1b74c55 Merge pull request 'fix(goldset): score panel open by default + sparse-negatives hint' (#106) from worktree-goldset-score-open into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 38s
2026-06-07 14:12:08 +00:00
7e7de485a4 fix(goldset): score panel open by default + sparse-negatives hint
The validator score panel was collapsed by default, so taggers thought nothing
was happening. Now open by default, with a caption explaining the metrics
measure "not-a-holding" detection and become meaningful as more "לא הלכה" items
are tagged (showing the current negative count while it's small).

Verified: tsc --noEmit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:11:49 +00:00
e62f39aabf Merge pull request 'feat(goldset): separate court rulings from committee decisions' (#105) from worktree-goldset-source-split into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
2026-06-07 13:55:27 +00:00
632fe73857 feat(goldset): separate court rulings from committee decisions in tagging
Tagging is easier one source-type at a time. goldset_list now returns
case_law.source_type; the page adds:
- a filter (הכל / פסקי דין / ועדת ערר) with live counts,
- a group-sort so even in "הכל" all court rulings come first, then all
  committee decisions,
- a per-card source badge (פסק-דין / ועדת ערר).

Verified: tsc --noEmit 0; source_type splits the live batch 58 court / 92 committee.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:55:06 +00:00
f60fdc2c6d Merge pull request 'fix(goldset): order help table to match the type buttons' (#104) from worktree-goldset-help-order into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 37s
2026-06-07 13:45:45 +00:00
a07622659c fix(goldset): order rule-type help table to match the buttons
TYPE_HELP popover now follows the same order as the type buttons:
מחייבת · פרשני · יישום · אמרת-אגב · פרוצדורלי · משכנע.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:45:30 +00:00
a1f491e9cc Merge pull request 'feat(goldset): soft consistency warning between is_holding and type' (#103) from worktree-goldset-consistency-warn into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 37s
2026-06-07 13:40:28 +00:00
5aa3d4ed99 feat(goldset): soft consistency warning between is_holding and type
"לא הלכה" + "מחייבת" (or any holding-type) is a logical contradiction — binding
means it IS the holding. Likewise "הלכה" + application/obiter. The three controls
are independent, so the combo was clickable with no signal.

Adds a non-blocking amber warning under the type buttons when is_holding and
correct_type contradict (holding ↔ binding/interpretive/procedural/persuasive;
not-holding ↔ application/obiter). Soft by design — flags the inconsistency for
the tagger to fix without forcing, leaving room for genuine edge cases.

Verified: tsc --noEmit exits 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:40:05 +00:00
b107654ee4 Merge pull request 'fix(goldset): "tagged" = all 3 answers + rule-type help popover' (#102) from worktree-goldset-tagged-fix into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 38s
2026-06-07 13:27:19 +00:00
27911c5beb fix(goldset): "tagged" = all 3 answers set + add rule-type help popover
Two UX fixes on the gold-set tagging page:

1. isTagged now requires is_holding AND correct_type AND quote_complete — not
   just is_holding. Previously, in "hide tagged" mode the card vanished the
   instant is_holding was clicked, so the type and quote-complete answers could
   never be set. The progress counter / "תויג" badge now reflect full tagging.

2. An info (ℹ) icon next to "הסוג הנכון" opens a popover explaining the six
   rule types (definition + the deciding test + an example each), so the tagger
   has the criteria in front of them while tagging.

Verified: tsc --noEmit exits 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:26:52 +00:00
1a1757f29d Merge pull request 'feat(goldset): interactive gold-set tagging page (#81.7/#81.8)' (#101) from worktree-goldset-tagging-ui into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m25s
2026-06-06 21:52:41 +00:00
ac279220c4 feat(goldset): interactive gold-set tagging page (#81.7/#81.8)
Replaces the CSV-edit workflow with an in-app tagging page so the chair/Dafna
can label the extraction-quality gold-set by clicking, and see validator
precision/recall live.

Schema (V29): halacha_goldset — a stratified, human-tagged evaluation batch
(is_holding / correct_type / quote_complete, NULL until tagged).

db.py:
- goldset_create_sample (stratified round-robin over case×rule_type, idempotent),
- goldset_list (items + halacha content + the machine's own labels),
- goldset_tag (partial — one field at a time for keyboard tagging),
- goldset_score (ports the script's P/R/F1: each validator scored as a
  not-a-holding detector against the human tags — the #81.8 input).

API: GET /api/goldset, POST /api/goldset/sample, GET /api/goldset/score,
PATCH /api/goldset/{id}.

web-ui:
- lib/api/goldset.ts (hooks),
- components/goldset/goldset-panel.tsx — card-per-item, keyboard-first
  (J/K nav, H/N holding, C/X quote), progress bar, hide-tagged toggle, and a
  collapsible live score table,
- app/goldset/page.tsx + nav link "מדגם-זהב" under ידע ולמידה.

Methodology guard kept explicit in UI + docstrings: tags are HUMAN ground truth,
no AI pre-fill (circular bias). Populated a 150-item stratified batch.

Verified: backend create/list/tag/score against the live DB; tsc --noEmit 0;
py_compile ok. (Local Turbopack build blocked by worktree symlink — CI builds clean.)

Invariants: G1 (eval set modeled at source in its own table); G2 (reuses the same
halacha_quality validators the extractor runs — no parallel scoring logic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:52:05 +00:00
9bd247c421 Merge pull request 'feat(halacha): equivalent-halacha (parallel-authority) links across precedents' (#100) from worktree-equivalent-halachot into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m24s
2026-06-06 21:30:21 +00:00
b7b44f4453 feat(halacha): equivalent-halacha (parallel-authority) links across precedents
Cross-precedent recurrence of a principle is real but is NOT citation
corroboration (X11) — the 5 candidate pairs have ZERO citations between their
precedents. Recording them in halacha_citation_corroboration would fabricate
citation data and inflate corroboration_count. This adds a proper, separate
halacha-level link for parallel authority.

Schema (V28): equivalent_halachot — symmetric (halacha_a < halacha_b, CHECK +
UNIQUE), non-citation, cross-precedent-only. ON DELETE CASCADE.

db.py:
- link_equivalent_halachot (idempotent; rejects same-id and SAME-precedent pairs
  — parallel authority is cross-precedent by definition), unlink, and
  list_equivalent_for_halacha.
- list_halachot gains include_equivalents → _annotate_equivalents attaches an
  `equivalents` list (both directions) per row.

API: include_equivalents on GET /api/halachot; GET/POST/DELETE
/api/halachot/{id}/equivalents for the chair to view/link/unlink manually.

scripts/halacha_batch_reconcile.py: --link records found cross-precedent pairs
as equivalent_halachot (non-destructive, idempotent).

web-ui: Halacha.equivalents type; the clean review queue fetches
include_equivalents; the review card shows a gold "עיקרון מקביל ב-N" badge + an
expandable list (case + rule + similarity) labeled "אסמכתה מקבילה — לא ציטוט".

Populated the 5 reviewed pairs (chair decision: keep all + link as parallel
authority). Verified: 5 rows; the 1023-20 hub annotates 3 of its halachot with
equivalents; tsc --noEmit exits 0.

Invariants: G1 (model recurrence at source in its own table, not by abusing the
citator); G2 (no parallel path — extends list_halachot); citator integrity
preserved (corroboration stays citation-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:29:46 +00:00
ab99cfa1d3 Merge pull request 'docs(paperclip-quirks): §5 — pruned npx cache → 500/crash-loop + fix' (#99) from worktree-pc-quirks-doc into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
2026-06-06 21:24:42 +00:00
e239915fd3 docs(paperclip-quirks): §5 — pruned npx cache → 500/crash-loop + fix
Document the failure mode hit on 06/06/26: a pruned npx cache makes the
running paperclip serve GET / → 500 (deleted ui-dist) and, on restart,
crash-loop because the server's startup assertCloudDatabaseContract()
out-races the post-exec patch loop.

Records the synchronous pre-extract+patch gate now in start-paperclip.sh
(paperclip-config c824e0f), the `--help` clean-extract trick, the three
bugs found while building the fix (ui-dist vs dist marker, set -e on patch
failure, pkill -f self-match), the manual recovery runbook, and the e2e
verification.

Invariants: docs-only; touches no G*/INV-* code paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:24:12 +00:00
86f5797dbd chore(tasks): mark style-acquisition T0-T15 + #85/#87/#88 done (initiative complete)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 25s
2026-06-06 21:03:27 +00:00
25e0662ead Merge pull request 'feat(halacha-triage UI): wire gating + near-duplicate cluster cards (#84.2)' (#98) from worktree-task84.2-ui-clustering into main
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
2026-06-06 21:02:09 +00:00
6dbc9130b0 Merge pull request 'feat(#99 / T10): get_style_guide — יחסי-זהב נמדדים מהקורפוס' (#97) from worktree-style-acquisition-mvp into main
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
2026-06-06 21:02:03 +00:00
12313774a1 feat(halacha-triage UI): wire gating + near-duplicate cluster cards (#84.2)
Completes #84 — surfaces the backend gating/prioritization (#84.1/#84.3, PR
#93) in the chair's review UI and adds near-duplicate clustering (#84.2).

Backend
- db.list_halachot gains `cluster` (#84.2): annotates each row with cluster_id +
  cluster_size by unioning same-precedent halachot within HALACHA_CLUSTER_COSINE
  (0.90, new config). Display-only — never merges/deletes. Pairwise is confined
  to the returned set (cheap).
- GET /api/halachot exposes the `cluster` query param (default off).

Frontend (web-ui)
- Halacha type gains optional cluster_id / cluster_size (hand-written module; no
  api:types regen needed — halachot aren't typed off the generated schema).
- useHalachotPending(opts): the default "clean" queue now fetches
  exclude_low_quality + order_by_priority + cluster; needsFix:true returns the
  flagged 'needs extraction fix' bucket (filtered client-side).
- HalachaReviewPanel: a "תור נקי / דורש תיקון-חילוץ" toggle (#84.1); near-dup
  clusters collapse into ONE card showing "+N וריאנטים" with an expandable list,
  and approve/reject/defer on a clustered card applies to all variants via the
  batch endpoint (#84.2 + #84.4). Counts show true halacha totals (pendingTotal).
  New flag labels added (application / near_duplicate / nevo_preamble_leak).

Verified:
- backend: list_halachot(cluster=True) on the live queue — algorithm correct
  (groups related same-precedent rules at 0.78; none at the production 0.90
  because dedup #82 already removed near-dups — the desired state).
- frontend: `tsc --noEmit` exits 0 (type-clean); no new lint errors (the one
  lint error is pre-existing in training/learning-panel.tsx from #94). Local
  Turbopack build can't run on the worktree node_modules symlink — CI builds in
  a clean checkout.

Invariants: G1 (gate/cluster at source in SQL, not post-hoc); G2 (same
list_halachot path); §6 (flagged items routed to a visible bucket, not dropped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:01:30 +00:00
7d97ca25a2 Merge pull request 'fix(#88+#87): סנכרון DB↔file אוטומטי + claims_coverage מבחין כתב-ערר מתכתובת' (#96) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m25s
2026-06-06 20:54:52 +00:00
c7933b9de3 Merge pull request 'chore(style-acq T11): regen API types (learning + methodology)' (#95) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 49s
2026-06-06 20:45:00 +00:00
161d0d6ed6 Merge pull request 'fix(#85): claude_session retry על כשלים חולפים של claude -p' (#94) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m24s
2026-06-06 20:09:09 +00:00
28 changed files with 3239 additions and 215 deletions

View File

@@ -978,7 +978,7 @@
"legal-ai": { "legal-ai": {
"tasks": [ "tasks": [
{ {
"id": 1, "id": "1",
"title": "V7 schema: precedent library + halachot tables", "title": "V7 schema: precedent library + halachot tables",
"description": "Add SCHEMA_V7_SQL to db.py: extend case_law with source_kind/document_id/extraction_status/halacha_extraction_status/practice_area (CHECK constraint for 3 areas)/appeal_subtype/headnote. Create precedent_chunks table with vector(1024). Create halachot table with vector(1024), review_status, practice_areas array. Add IVFFlat indexes. Register V7 in init_schema().", "description": "Add SCHEMA_V7_SQL to db.py: extend case_law with source_kind/document_id/extraction_status/halacha_extraction_status/practice_area (CHECK constraint for 3 areas)/appeal_subtype/headnote. Create precedent_chunks table with vector(1024). Create halachot table with vector(1024), review_status, practice_areas array. Add IVFFlat indexes. Register V7 in init_schema().",
"details": "", "details": "",
@@ -990,7 +990,7 @@
"updatedAt": "2026-05-03T08:17:59.928Z" "updatedAt": "2026-05-03T08:17:59.928Z"
}, },
{ {
"id": 2, "id": "2",
"title": "Chunker: add court ruling section patterns", "title": "Chunker: add court ruling section patterns",
"description": "Extend services/chunker.py SECTION_PATTERNS with 4 patterns for external court rulings: פסק דין→ruling, נימוקים→legal_analysis, סוף דבר→conclusion, העובדות הצריכות לעניין→facts", "description": "Extend services/chunker.py SECTION_PATTERNS with 4 patterns for external court rulings: פסק דין→ruling, נימוקים→legal_analysis, סוף דבר→conclusion, העובדות הצריכות לעניין→facts",
"details": "", "details": "",
@@ -1004,7 +1004,7 @@
"updatedAt": "2026-05-03T08:18:33.239Z" "updatedAt": "2026-05-03T08:18:33.239Z"
}, },
{ {
"id": 3, "id": "3",
"title": "Service: halacha_extractor.py", "title": "Service: halacha_extractor.py",
"description": "New service that runs claude_session.query_json() over chunks where section_type IN (legal_analysis, ruling, conclusion). Concurrency=3, retry=1. Validates supporting_quote with substring check after Hebrew normalization. All halachot inserted with review_status=pending_review (no auto-publish). Embeds rule_statement+reasoning_summary via Voyage. Uses Hebrew prompt from plan appendix א. Idempotent on case_law_id.", "description": "New service that runs claude_session.query_json() over chunks where section_type IN (legal_analysis, ruling, conclusion). Concurrency=3, retry=1. Validates supporting_quote with substring check after Hebrew normalization. All halachot inserted with review_status=pending_review (no auto-publish). Embeds rule_statement+reasoning_summary via Voyage. Uses Hebrew prompt from plan appendix א. Idempotent on case_law_id.",
"details": "", "details": "",
@@ -1019,7 +1019,7 @@
"updatedAt": "2026-05-03T08:22:12.392Z" "updatedAt": "2026-05-03T08:22:12.392Z"
}, },
{ {
"id": 4, "id": "4",
"title": "Service: precedent_library.py orchestrator", "title": "Service: precedent_library.py orchestrator",
"description": "New service with ingest_precedent(file_path, citation, court, decision_date, source_type, precedent_level, practice_area, appeal_subtype, subject_tags, case_name, task_id) that orchestrates: extract_text → proofread → INSERT case_law (source_kind=external_upload) → chunk → embed → store precedent_chunks → halacha_extractor.extract → embed halachot → publish progress. Plus delete_precedent (cascading), list_precedents(filters), get_precedent(id), search_library(query, filters, limit) merging chunks+approved-halachot ranked.", "description": "New service with ingest_precedent(file_path, citation, court, decision_date, source_type, precedent_level, practice_area, appeal_subtype, subject_tags, case_name, task_id) that orchestrates: extract_text → proofread → INSERT case_law (source_kind=external_upload) → chunk → embed → store precedent_chunks → halacha_extractor.extract → embed halachot → publish progress. Plus delete_precedent (cascading), list_precedents(filters), get_precedent(id), search_library(query, filters, limit) merging chunks+approved-halachot ranked.",
"details": "", "details": "",
@@ -1035,7 +1035,7 @@
"updatedAt": "2026-05-03T08:23:33.235Z" "updatedAt": "2026-05-03T08:23:33.235Z"
}, },
{ {
"id": 5, "id": "5",
"title": "MCP tools: precedent_library + halacha_review", "title": "MCP tools: precedent_library + halacha_review",
"description": "Create mcp-server/src/legal_mcp/tools/precedent_library.py with tools: precedent_library_upload, precedent_library_list, precedent_library_get, precedent_library_delete, precedent_extract_halachot, search_precedent_library (semantic, returns merged halachot+chunks), halacha_review (approve/reject). Register all in server.py. Do NOT modify existing precedent_search_library or search_decisions.", "description": "Create mcp-server/src/legal_mcp/tools/precedent_library.py with tools: precedent_library_upload, precedent_library_list, precedent_library_get, precedent_library_delete, precedent_extract_halachot, search_precedent_library (semantic, returns merged halachot+chunks), halacha_review (approve/reject). Register all in server.py. Do NOT modify existing precedent_search_library or search_decisions.",
"details": "", "details": "",
@@ -1049,7 +1049,7 @@
"updatedAt": "2026-05-03T08:25:07.439Z" "updatedAt": "2026-05-03T08:25:07.439Z"
}, },
{ {
"id": 6, "id": "6",
"title": "FastAPI endpoints under /api/precedent-library", "title": "FastAPI endpoints under /api/precedent-library",
"description": "Add to web/app.py: POST /api/precedent-library/upload (multipart), GET /api/precedent-library (filters), GET /api/precedent-library/{id}, PATCH /api/precedent-library/{id}, DELETE /api/precedent-library/{id}, POST /api/precedent-library/{id}/extract-halachot, GET /api/precedent-library/search, GET /api/halachot?status=pending_review, PATCH /api/halachot/{id}, GET /api/precedent-library/stats. Reuse existing /api/progress/{task_id} SSE.", "description": "Add to web/app.py: POST /api/precedent-library/upload (multipart), GET /api/precedent-library (filters), GET /api/precedent-library/{id}, PATCH /api/precedent-library/{id}, DELETE /api/precedent-library/{id}, POST /api/precedent-library/{id}/extract-halachot, GET /api/precedent-library/search, GET /api/halachot?status=pending_review, PATCH /api/halachot/{id}, GET /api/precedent-library/stats. Reuse existing /api/progress/{task_id} SSE.",
"details": "", "details": "",
@@ -1063,7 +1063,7 @@
"updatedAt": "2026-05-03T08:26:21.860Z" "updatedAt": "2026-05-03T08:26:21.860Z"
}, },
{ {
"id": 7, "id": "7",
"title": "UI: /precedents page with 4 tabs", "title": "UI: /precedents page with 4 tabs",
"description": "New web-ui/src/app/precedents/page.tsx with tabs: Library (table+filters+upload), Semantic Search, Pending Review (PRIMARY - bulk approval UX with J/K nav, A/R/E shortcuts, side-by-side rule_statement vs supporting_quote, badge count), Stats. New components in web-ui/src/components/precedents/: precedent-upload-sheet, precedent-list-table, precedent-search-panel, precedent-detail-panel, halacha-review-card. New hooks in web-ui/src/lib/api/precedent-library.ts. Add nav link in app-shell.tsx.", "description": "New web-ui/src/app/precedents/page.tsx with tabs: Library (table+filters+upload), Semantic Search, Pending Review (PRIMARY - bulk approval UX with J/K nav, A/R/E shortcuts, side-by-side rule_statement vs supporting_quote, badge count), Stats. New components in web-ui/src/components/precedents/: precedent-upload-sheet, precedent-list-table, precedent-search-panel, precedent-detail-panel, halacha-review-card. New hooks in web-ui/src/lib/api/precedent-library.ts. Add nav link in app-shell.tsx.",
"details": "", "details": "",
@@ -1077,7 +1077,7 @@
"updatedAt": "2026-05-03T08:34:00.548Z" "updatedAt": "2026-05-03T08:34:00.548Z"
}, },
{ {
"id": 8, "id": "8",
"title": "Agent integration: legal-writer + 3 others", "title": "Agent integration: legal-writer + 3 others",
"description": "Update .claude/agents/legal-writer.md (PRIMARY) — add mcp__legal-ai__search_precedent_library to tools and prompt section explaining when to use it for CREAC rule+explanation in block י. Update legal-researcher.md, legal-analyst.md, legal-ceo.md, legal-qa.md to add the tool. Update skills/decision/SKILL.md with section explaining the 3 corpora (style_corpus, case_precedents, precedent_library).", "description": "Update .claude/agents/legal-writer.md (PRIMARY) — add mcp__legal-ai__search_precedent_library to tools and prompt section explaining when to use it for CREAC rule+explanation in block י. Update legal-researcher.md, legal-analyst.md, legal-ceo.md, legal-qa.md to add the tool. Update skills/decision/SKILL.md with section explaining the 3 corpora (style_corpus, case_precedents, precedent_library).",
"details": "", "details": "",
@@ -1091,7 +1091,7 @@
"updatedAt": "2026-05-03T08:36:24.711Z" "updatedAt": "2026-05-03T08:36:24.711Z"
}, },
{ {
"id": 9, "id": "9",
"title": "Service: precedent_metadata_extractor.py", "title": "Service: precedent_metadata_extractor.py",
"description": "LLM-based extractor that auto-fills empty metadata fields after upload: short case_name (e.g. 'אהרון ברק' from long citation), summary (2-3 sentences), headnote, key_quote, subject_tags array, appeal_subtype. Reuses claude_session.query_json. Returns dict; caller decides which empty fields to merge (never overrides user values).", "description": "LLM-based extractor that auto-fills empty metadata fields after upload: short case_name (e.g. 'אהרון ברק' from long citation), summary (2-3 sentences), headnote, key_quote, subject_tags array, appeal_subtype. Reuses claude_session.query_json. Returns dict; caller decides which empty fields to merge (never overrides user values).",
"details": "", "details": "",
@@ -1103,7 +1103,7 @@
"updatedAt": "2026-05-03T10:19:15.105Z" "updatedAt": "2026-05-03T10:19:15.105Z"
}, },
{ {
"id": 10, "id": "10",
"title": "Halacha extractor: dual mode (binding vs persuasive)", "title": "Halacha extractor: dual mode (binding vs persuasive)",
"description": "Update halacha_extractor.py prompt to branch on is_binding: binding=true → strict halacha extraction (current). binding=false → extract reasoning principles, applications of established halachot, persuasive conclusions. New rule_types: 'application' (applying known rule to facts), 'persuasive' (committee's reasoning citable as authority). Schema unchanged (rule_type already TEXT).", "description": "Update halacha_extractor.py prompt to branch on is_binding: binding=true → strict halacha extraction (current). binding=false → extract reasoning principles, applications of established halachot, persuasive conclusions. New rule_types: 'application' (applying known rule to facts), 'persuasive' (committee's reasoning citable as authority). Schema unchanged (rule_type already TEXT).",
"details": "", "details": "",
@@ -1115,7 +1115,7 @@
"updatedAt": "2026-05-03T10:19:15.117Z" "updatedAt": "2026-05-03T10:19:15.117Z"
}, },
{ {
"id": 11, "id": "11",
"title": "Ingest pipeline: add metadata extraction stage", "title": "Ingest pipeline: add metadata extraction stage",
"description": "In services/precedent_library.py:ingest_precedent, after halacha extraction, run metadata_extractor and PATCH the case_law row with auto-filled fields (only those left empty by user). Publish progress 'extracting_metadata'.", "description": "In services/precedent_library.py:ingest_precedent, after halacha extraction, run metadata_extractor and PATCH the case_law row with auto-filled fields (only those left empty by user). Publish progress 'extracting_metadata'.",
"details": "", "details": "",
@@ -1129,7 +1129,7 @@
"updatedAt": "2026-05-03T10:19:15.128Z" "updatedAt": "2026-05-03T10:19:15.128Z"
}, },
{ {
"id": 12, "id": "12",
"title": "UI: precedent edit sheet", "title": "UI: precedent edit sheet",
"description": "Add edit button to library-list-panel rows that opens a Sheet with all editable fields (case_name, citation, court, date, practice_area, appeal_subtype, subject_tags, summary, headnote, key_quote, source_type, precedent_level, is_binding). Pre-populated from current values. Submit calls PATCH /api/precedent-library/{id} via useUpdatePrecedent. After save, invalidate library list query.", "description": "Add edit button to library-list-panel rows that opens a Sheet with all editable fields (case_name, citation, court, date, practice_area, appeal_subtype, subject_tags, summary, headnote, key_quote, source_type, precedent_level, is_binding). Pre-populated from current values. Submit calls PATCH /api/precedent-library/{id} via useUpdatePrecedent. After save, invalidate library list query.",
"details": "", "details": "",
@@ -1141,7 +1141,7 @@
"updatedAt": "2026-05-03T10:19:15.134Z" "updatedAt": "2026-05-03T10:19:15.134Z"
}, },
{ {
"id": 13, "id": "13",
"title": "Test on 403-17: fix metadata + re-extract", "title": "Test on 403-17: fix metadata + re-extract",
"description": "After deploy: PATCH 403-17 to set case_name='ערר 403/17', then trigger precedent_extract_halachot to test the dual-mode extraction on a non-binding committee decision.", "description": "After deploy: PATCH 403-17 to set case_name='ערר 403/17', then trigger precedent_extract_halachot to test the dual-mode extraction on a non-binding committee decision.",
"details": "", "details": "",
@@ -1158,7 +1158,7 @@
"updatedAt": "2026-05-26T10:38:07.071897Z" "updatedAt": "2026-05-26T10:38:07.071897Z"
}, },
{ {
"id": 14, "id": "14",
"title": "Upgrade: speed up halacha+metadata extraction", "title": "Upgrade: speed up halacha+metadata extraction",
"description": "Halacha extraction on long rulings is slow (5-15 min for typical court ruling, 30-50 min for a 207-chunk appeals committee decision). Root cause: each chunk spawns a separate `claude -p` subprocess (5-10 sec startup overhead each), Hebrew prompts on cold cache run 30-90 sec, and there's no prompt-cache sharing between chunks. Acceleration options to evaluate later when speed becomes a real blocker.\n\nOptions (each can be combined):\n\n1. Concurrency 3 -> 6 in halacha_extractor.CHUNK_CONCURRENCY. ~2x faster wall-clock. Cost: 6x ~300MB RSS = 1.8GB peak — verify on Nautilus headroom.\n\n2. Larger chunks 12K -> 18-25K chars (CHUNK_TARGET_CHARS in claims_extractor.py / halacha_extractor.py). Fewer waves. Risk: timeout on cold cache (currently 1800s ceiling), and may degrade extraction precision for very long sections.\n\n3. Anthropic SDK direct with 5-min ephemeral prompt caching on the static instruction prefix (already wired the parameter as system= in claude_session.query). Estimated 5-10x faster because cache reads are ~10% of cold cost. Costs ~$0.30-2 per long ruling on Sonnet 4.6. Chair previously rejected this path for ALL traffic ('we work only with claude session'). Compromise: SDK only for the precedent-library corpus build (static, one-time), claude session for live decision drafting (interactive, frequent).\n\n4. Two-tier prompt: a short 'classification' pass with claude -p deciding which chunks contain halachot, then deep extraction only on positive chunks. Could cut total LLM time by 40-60% on rulings with lots of factual chapters.\n\n5. Already implemented (Apr 3, 2026): skip non-extractable sections — only run on chunks where section_type IN (legal_analysis, ruling, conclusion); fallback to all chunks when chunker labels nothing. So that win is already banked.\n\nRe-evaluate when: a chair drops a 200K+ char ruling into the queue and the wait becomes painful, OR when the precedent-library has 50+ pending entries and bulk processing matters.", "description": "Halacha extraction on long rulings is slow (5-15 min for typical court ruling, 30-50 min for a 207-chunk appeals committee decision). Root cause: each chunk spawns a separate `claude -p` subprocess (5-10 sec startup overhead each), Hebrew prompts on cold cache run 30-90 sec, and there's no prompt-cache sharing between chunks. Acceleration options to evaluate later when speed becomes a real blocker.\n\nOptions (each can be combined):\n\n1. Concurrency 3 -> 6 in halacha_extractor.CHUNK_CONCURRENCY. ~2x faster wall-clock. Cost: 6x ~300MB RSS = 1.8GB peak — verify on Nautilus headroom.\n\n2. Larger chunks 12K -> 18-25K chars (CHUNK_TARGET_CHARS in claims_extractor.py / halacha_extractor.py). Fewer waves. Risk: timeout on cold cache (currently 1800s ceiling), and may degrade extraction precision for very long sections.\n\n3. Anthropic SDK direct with 5-min ephemeral prompt caching on the static instruction prefix (already wired the parameter as system= in claude_session.query). Estimated 5-10x faster because cache reads are ~10% of cold cost. Costs ~$0.30-2 per long ruling on Sonnet 4.6. Chair previously rejected this path for ALL traffic ('we work only with claude session'). Compromise: SDK only for the precedent-library corpus build (static, one-time), claude session for live decision drafting (interactive, frequent).\n\n4. Two-tier prompt: a short 'classification' pass with claude -p deciding which chunks contain halachot, then deep extraction only on positive chunks. Could cut total LLM time by 40-60% on rulings with lots of factual chapters.\n\n5. Already implemented (Apr 3, 2026): skip non-extractable sections — only run on chunks where section_type IN (legal_analysis, ruling, conclusion); fallback to all chunks when chunker labels nothing. So that win is already banked.\n\nRe-evaluate when: a chair drops a 200K+ char ruling into the queue and the wait becomes painful, OR when the precedent-library has 50+ pending entries and bulk processing matters.",
"details": "נסקר 2026-06-03 — אין blocker נוכחי. הרצתי reindex ל-73 תקדימים + חילוצים מרובים בלי שמהירות הייתה כאב. YAGNI: לא מבצעים אופטימיזציה מוקדמת. נשאר deferred עם trigger ברור: פסק-דין 200K+ תווים שתוקע את התור, או 50+ פריטים ממתינים. ה-win הזול (concurrency 3→6) דורש אימות headroom של 1.8GB RSS ב-Nautilus לפני — לא עכשיו.", "details": "נסקר 2026-06-03 — אין blocker נוכחי. הרצתי reindex ל-73 תקדימים + חילוצים מרובים בלי שמהירות הייתה כאב. YAGNI: לא מבצעים אופטימיזציה מוקדמת. נשאר deferred עם trigger ברור: פסק-דין 200K+ תווים שתוקע את התור, או 50+ פריטים ממתינים. ה-win הזול (concurrency 3→6) דורש אימות headroom של 1.8GB RSS ב-Nautilus לפני — לא עכשיו.",
@@ -1170,7 +1170,7 @@
"updatedAt": "2026-06-03T00:00:00.000Z" "updatedAt": "2026-06-03T00:00:00.000Z"
}, },
{ {
"id": 15, "id": "15",
"title": "Multimodal — כיוונון MULTIMODAL_TEXT_WEIGHT (A/B) + הכרעה על backfill", "title": "Multimodal — כיוונון MULTIMODAL_TEXT_WEIGHT (A/B) + הכרעה על backfill",
"description": "נסגר 2026-06-03 לאחר A/B אובייקטיבי על gold-set (86 שאילתות, eval_retrieval.py). הנחת היסוד התיישנה: multimodal כבר ברירת-מחדל בייצור (110 מסמכים מוטמעים אוטומטית בהעלאה), לא רק 2 תיקי A/B. ממצא: ה-weight 0.5 (ברירת-מחדל) היה mis-tuned — צד-התמונה כבד מדי וחתך recall של precedent_library (0.971→0.885). sweep 0.5→0.75: במשקל 0.65 multimodal מנצח את text-only בכל מדד ובכל corpus (R@5 0.994 מול 0.989; nDCG@5 0.960 מול 0.944; MRR 0.954 מול 0.936; precedent_library R@5 0.983, internal_decisions nDCG 0.978). כיסוי: 28/79 מסמכי gold-set מוטמעים multimodal (35% — אות אמיתי). דפנה אישרה.", "description": "נסגר 2026-06-03 לאחר A/B אובייקטיבי על gold-set (86 שאילתות, eval_retrieval.py). הנחת היסוד התיישנה: multimodal כבר ברירת-מחדל בייצור (110 מסמכים מוטמעים אוטומטית בהעלאה), לא רק 2 תיקי A/B. ממצא: ה-weight 0.5 (ברירת-מחדל) היה mis-tuned — צד-התמונה כבד מדי וחתך recall של precedent_library (0.971→0.885). sweep 0.5→0.75: במשקל 0.65 multimodal מנצח את text-only בכל מדד ובכל corpus (R@5 0.994 מול 0.989; nDCG@5 0.960 מול 0.944; MRR 0.954 מול 0.936; precedent_library R@5 0.983, internal_decisions nDCG 0.978). כיסוי: 28/79 מסמכי gold-set מוטמעים multimodal (35% — אות אמיתי). דפנה אישרה.",
"details": "בוצע: MULTIMODAL_TEXT_WEIGHT=0.65 הוגדר ב-Coolify env של legal-ai (runtime) + redeploy; baseline (data/eval/baseline.json) עודכן לקונפיג 0.65. ה-backfill היקר ל-140 התיקים ה-legacy *לא* בוצע — אין הצדקת-אחזור לשאלות טקסט, וערך ה-image-answer לא נבדק. מומר ל-#80 (מותנה). ראיות: data/eval/eval-report-20260603T08*.md, project_multimodal_stage_c.", "details": "בוצע: MULTIMODAL_TEXT_WEIGHT=0.65 הוגדר ב-Coolify env של legal-ai (runtime) + redeploy; baseline (data/eval/baseline.json) עודכן לקונפיג 0.65. ה-backfill היקר ל-140 התיקים ה-legacy *לא* בוצע — אין הצדקת-אחזור לשאלות טקסט, וערך ה-image-answer לא נבדק. מומר ל-#80 (מותנה). ראיות: data/eval/eval-report-20260603T08*.md, project_multimodal_stage_c.",
@@ -1182,7 +1182,7 @@
"updatedAt": "2026-06-03T00:00:00.000Z" "updatedAt": "2026-06-03T00:00:00.000Z"
}, },
{ {
"id": 16, "id": "16",
"title": "[Paperclip Gap 1] runtime_config ריק — חסרים graceSec/cooldownSec/maxConcurrentRuns", "title": "[Paperclip Gap 1] runtime_config ריק — חסרים graceSec/cooldownSec/maxConcurrentRuns",
"description": "runtime_config = '{}' לכל 14 הסוכנים. מסתבר שעיקר ההגדרות החשובות (timeoutSec=3600, maxTurnsPerRun=500) יושבות ב-adapter_config ולא ב-runtime_config — אז המצב פחות חמור. אבל graceSec/cooldownSec/maxConcurrentRuns עדיין חסרים.", "description": "runtime_config = '{}' לכל 14 הסוכנים. מסתבר שעיקר ההגדרות החשובות (timeoutSec=3600, maxTurnsPerRun=500) יושבות ב-adapter_config ולא ב-runtime_config — אז המצב פחות חמור. אבל graceSec/cooldownSec/maxConcurrentRuns עדיין חסרים.",
"details": "תיקון לניתוח המקורי שגוי בעקבות בדיקה ב-DB:\n\nמה שכן יש לנו (ב-adapter_config, לא runtime_config):\n- timeoutSec: 3600 (לכל הסוכנים)\n- maxTurnsPerRun: 500 (לכל הסוכנים)\n- model + effort=high (לכל הסוכנים)\n- paperclipSkillSync.desiredSkills (5/7 סוכנים — חסר אצל הגהת מסמכים ומנתח משפטי)\n\nמה שבאמת חסר ב-runtime_config:\n- heartbeat.graceSec — זמן grace לפני SIGKILL אחרי timeout. מהקוד: Math.max(1, graceSec)*1000. אם לא מוגדר → 1ms grace. בעיה אם הסוכן נחתך באמצע commit ל-DB.\n- heartbeat.cooldownSec — default ביצירה חדשה: 10. אצלנו לא מוגדר.\n- heartbeat.maxConcurrentRuns — default מ-AGENT_DEFAULT_MAX_CONCURRENT_RUNS (כנראה 1).\n- heartbeat.wakeOnDemand — default=true בקוד. אצלנו לא מוגדר אבל בפועל true.\n- heartbeat.enabled — default=false (timer off). זה הרצוי אצלנו.\n\nפעולה (Phase 1):\n1. עדכון runtime_config של כל סוכן: { heartbeat: { graceSec: 60, cooldownSec: 10, maxConcurrentRuns: 1, wakeOnDemand: true } }\n2. בעיקר graceSec — בלעדיו commit באמצע יכול להיכשל\n3. cooldownSec=10 (זהה לdefault ב-UI ליצירת agent חדש)\n\nהשפעה: minimal — רוב המקרים עובדים עם defaults. graceSec הוא העיקר.", "details": "תיקון לניתוח המקורי שגוי בעקבות בדיקה ב-DB:\n\nמה שכן יש לנו (ב-adapter_config, לא runtime_config):\n- timeoutSec: 3600 (לכל הסוכנים)\n- maxTurnsPerRun: 500 (לכל הסוכנים)\n- model + effort=high (לכל הסוכנים)\n- paperclipSkillSync.desiredSkills (5/7 סוכנים — חסר אצל הגהת מסמכים ומנתח משפטי)\n\nמה שבאמת חסר ב-runtime_config:\n- heartbeat.graceSec — זמן grace לפני SIGKILL אחרי timeout. מהקוד: Math.max(1, graceSec)*1000. אם לא מוגדר → 1ms grace. בעיה אם הסוכן נחתך באמצע commit ל-DB.\n- heartbeat.cooldownSec — default ביצירה חדשה: 10. אצלנו לא מוגדר.\n- heartbeat.maxConcurrentRuns — default מ-AGENT_DEFAULT_MAX_CONCURRENT_RUNS (כנראה 1).\n- heartbeat.wakeOnDemand — default=true בקוד. אצלנו לא מוגדר אבל בפועל true.\n- heartbeat.enabled — default=false (timer off). זה הרצוי אצלנו.\n\nפעולה (Phase 1):\n1. עדכון runtime_config של כל סוכן: { heartbeat: { graceSec: 60, cooldownSec: 10, maxConcurrentRuns: 1, wakeOnDemand: true } }\n2. בעיקר graceSec — בלעדיו commit באמצע יכול להיכשל\n3. cooldownSec=10 (זהה לdefault ב-UI ליצירת agent חדש)\n\nהשפעה: minimal — רוב המקרים עובדים עם defaults. graceSec הוא העיקר.",
@@ -1194,7 +1194,7 @@
"updatedAt": "2026-05-04T07:47:02.008Z" "updatedAt": "2026-05-04T07:47:02.008Z"
}, },
{ {
"id": 17, "id": "17",
"title": "[Paperclip Gap 2] תקציבים = 0 לכל הסוכנים — אין budget enforcement", "title": "[Paperclip Gap 2] תקציבים = 0 לכל הסוכנים — אין budget enforcement",
"description": "budget_monthly_cents = 0 ו-spent_monthly_cents = 0 לכל 14 הסוכנים. Paperclip מציע cost control מובנה — אנחנו מתעלמים.", "description": "budget_monthly_cents = 0 ו-spent_monthly_cents = 0 לכל 14 הסוכנים. Paperclip מציע cost control מובנה — אנחנו מתעלמים.",
"details": "ממצא: SELECT name, budget_monthly_cents, spent_monthly_cents FROM agents → הכל אפס.\n\nסיכון: לולאה חבויה יכולה לשרוף מאות $. אין auto-pause ב-80% spend (דפוס ש-CEO HEARTBEAT הרשמי מצפה לו).\n\nפעולה (Phase 3):\n1. מדידה: כמה כל סוכן באמת מוציא בחודש כיום (דרך לוגי claude-code, או Anthropic dashboard).\n2. הגדרת budget_monthly_cents סביר לכל סוכן (כותב Opus ≫ מנתח Sonnet).\n3. בדיקה שהמנגנון מפסיק כשמגיעים ל-100%.\n\nשאלה לחיים לפני ביצוע: באיזו רזולוציה למדוד? לפי Anthropic invoice, או לפי טוקנים בלוגים של claude_session?", "details": "ממצא: SELECT name, budget_monthly_cents, spent_monthly_cents FROM agents → הכל אפס.\n\nסיכון: לולאה חבויה יכולה לשרוף מאות $. אין auto-pause ב-80% spend (דפוס ש-CEO HEARTBEAT הרשמי מצפה לו).\n\nפעולה (Phase 3):\n1. מדידה: כמה כל סוכן באמת מוציא בחודש כיום (דרך לוגי claude-code, או Anthropic dashboard).\n2. הגדרת budget_monthly_cents סביר לכל סוכן (כותב Opus ≫ מנתח Sonnet).\n3. בדיקה שהמנגנון מפסיק כשמגיעים ל-100%.\n\nשאלה לחיים לפני ביצוע: באיזו רזולוציה למדוד? לפי Anthropic invoice, או לפי טוקנים בלוגים של claude_session?",
@@ -1206,7 +1206,7 @@
"updatedAt": "2026-05-04T10:18:08.046Z" "updatedAt": "2026-05-04T10:18:08.046Z"
}, },
{ {
"id": 18, "id": "18",
"title": "[Paperclip Gap 3] חסר X-Paperclip-Run-Id header בקריאות API", "title": "[Paperclip Gap 3] חסר X-Paperclip-Run-Id header בקריאות API",
"description": "ה-skill הרשמי קובע: 'You MUST include -H X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on ALL API requests that modify issues'. ב-HEARTBEAT.md שלנו אין זכר לכך.", "description": "ה-skill הרשמי קובע: 'You MUST include -H X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on ALL API requests that modify issues'. ב-HEARTBEAT.md שלנו אין זכר לכך.",
"details": "ממצא: grep -n 'X-Paperclip-Run-Id' .claude/agents/ → 0 hits. כל curl ב-checkout/comments/PATCH issues — בלי הheader.\n\nסיכון: audit trail שבור. שאלה 'איזו ריצה שינתה את ה-issue X?' אין לה תשובה ב-DB.\n\nפעולה (Phase 1):\n1. עדכון .claude/agents/HEARTBEAT.md — דוגמאות ה-curl יכללו את הheader\n2. עדכון 6 קבצי הסוכנים (legal-ceo.md, legal-analyst.md, legal-researcher.md, legal-writer.md, legal-qa.md, legal-exporter.md) — כל מקום שיש curl POST/PATCH\n3. בדיקה שיש env var $PAPERCLIP_RUN_ID זמין בכל heartbeat", "details": "ממצא: grep -n 'X-Paperclip-Run-Id' .claude/agents/ → 0 hits. כל curl ב-checkout/comments/PATCH issues — בלי הheader.\n\nסיכון: audit trail שבור. שאלה 'איזו ריצה שינתה את ה-issue X?' אין לה תשובה ב-DB.\n\nפעולה (Phase 1):\n1. עדכון .claude/agents/HEARTBEAT.md — דוגמאות ה-curl יכללו את הheader\n2. עדכון 6 קבצי הסוכנים (legal-ceo.md, legal-analyst.md, legal-researcher.md, legal-writer.md, legal-qa.md, legal-exporter.md) — כל מקום שיש curl POST/PATCH\n3. בדיקה שיש env var $PAPERCLIP_RUN_ID זמין בכל heartbeat",
@@ -1218,7 +1218,7 @@
"updatedAt": "2026-05-04T08:49:44.646Z" "updatedAt": "2026-05-04T08:49:44.646Z"
}, },
{ {
"id": 19, "id": "19",
"title": "[Paperclip Gap 4] לא משתמשים ב-/api/issues/{id}/interactions לאישורים", "title": "[Paperclip Gap 4] לא משתמשים ב-/api/issues/{id}/interactions לאישורים",
"description": "Paperclip מציע API מובנה לאישור/שאלות (request_confirmation, ask_user_questions, suggest_tasks) עם idempotency keys ו-auto-wake. אנחנו עדיין כותבים 'חיים, מה לעשות?' כ-comment חופשי.", "description": "Paperclip מציע API מובנה לאישור/שאלות (request_confirmation, ask_user_questions, suggest_tasks) עם idempotency keys ו-auto-wake. אנחנו עדיין כותבים 'חיים, מה לעשות?' כ-comment חופשי.",
"details": "סוגי interaction:\n- ask_user_questions — שאלות מובנות\n- request_confirmation — yes/no עם idempotency key (confirmation:{issueId}:plan:{revisionId})\n- suggest_tasks — הצעת עץ משימות\n- continuationPolicy: wake_assignee — wake אוטומטי על מענה\n- supersedeOnUserComment: true — בטל אם חיים עונה\n\nסיכון: אין UI מובנה לחיים (כפתורים), רק טקסט. אם הסוכן מתעורר פעמיים — שתי שאלות זהות.\n\nפעולה (Phase 2):\n1. בlegal-ceo.md — להחליף 'אם חיים לא הגדיר outcome: שאל בcomment' ב-request_confirmation\n2. בbrainstorm_directions — suggest_tasks במקום רשימת bullet\n3. בlegal-qa.md — request_confirmation לאישור export\n\nשאלה לחיים: האם תרצה לראות UI חדש או להישאר ב-Markdown comments?", "details": "סוגי interaction:\n- ask_user_questions — שאלות מובנות\n- request_confirmation — yes/no עם idempotency key (confirmation:{issueId}:plan:{revisionId})\n- suggest_tasks — הצעת עץ משימות\n- continuationPolicy: wake_assignee — wake אוטומטי על מענה\n- supersedeOnUserComment: true — בטל אם חיים עונה\n\nסיכון: אין UI מובנה לחיים (כפתורים), רק טקסט. אם הסוכן מתעורר פעמיים — שתי שאלות זהות.\n\nפעולה (Phase 2):\n1. בlegal-ceo.md — להחליף 'אם חיים לא הגדיר outcome: שאל בcomment' ב-request_confirmation\n2. בbrainstorm_directions — suggest_tasks במקום רשימת bullet\n3. בlegal-qa.md — request_confirmation לאישור export\n\nשאלה לחיים: האם תרצה לראות UI חדש או להישאר ב-Markdown comments?",
@@ -1234,7 +1234,7 @@
"updatedAt": "2026-05-04T11:18:59.050Z" "updatedAt": "2026-05-04T11:18:59.050Z"
}, },
{ {
"id": 20, "id": "20",
"title": "[Paperclip Gap 5] לא משתמשים ב-PAPERCLIP_WAKE_PAYLOAD_JSON fast-path", "title": "[Paperclip Gap 5] לא משתמשים ב-PAPERCLIP_WAKE_PAYLOAD_JSON fast-path",
"description": "בwake שמכוון ל-issue ספציפי, ה-env var מכיל כבר issue summary + comments חדשים דחוסים. ה-skill הרשמי אומר 'skip Steps 1-4 entirely'. שלנו תמיד fetcher גם ה-API.", "description": "בwake שמכוון ל-issue ספציפי, ה-env var מכיל כבר issue summary + comments חדשים דחוסים. ה-skill הרשמי אומר 'skip Steps 1-4 entirely'. שלנו תמיד fetcher גם ה-API.",
"details": "ממצא: HEARTBEAT.md סעיפים 2-2c תמיד פונים ל-API גם אם ה-payload כבר מכיל את הכל.\n\nתועלת: חיסכון 3-4 קריאות API לכל ריצה. בwakeups תכופים (CEO על comments) — חיסכון ניכר.\n\nפעולה (Phase 2):\n1. הוספה ל-HEARTBEAT.md בראש הסעיפים: 'אם $PAPERCLIP_WAKE_PAYLOAD_JSON קיים — קרא אותו ראשון. רק אם fallbackFetchNeeded:true או חסר הקשר רחב — fetch'.\n2. דוגמה לפענוח JSON: jq עם key paths\n3. בדיקה איזה wake reasons בכלל מקבלים payload (כנראה comment-driven בלבד)", "details": "ממצא: HEARTBEAT.md סעיפים 2-2c תמיד פונים ל-API גם אם ה-payload כבר מכיל את הכל.\n\nתועלת: חיסכון 3-4 קריאות API לכל ריצה. בwakeups תכופים (CEO על comments) — חיסכון ניכר.\n\nפעולה (Phase 2):\n1. הוספה ל-HEARTBEAT.md בראש הסעיפים: 'אם $PAPERCLIP_WAKE_PAYLOAD_JSON קיים — קרא אותו ראשון. רק אם fallbackFetchNeeded:true או חסר הקשר רחב — fetch'.\n2. דוגמה לפענוח JSON: jq עם key paths\n3. בדיקה איזה wake reasons בכלל מקבלים payload (כנראה comment-driven בלבד)",
@@ -1248,7 +1248,7 @@
"updatedAt": "2026-05-04T09:15:46.339Z" "updatedAt": "2026-05-04T09:15:46.339Z"
}, },
{ {
"id": 21, "id": "21",
"title": "[Paperclip Gap 6] שאילתות psql ישירות ל-issue_attachments — שובר אבסטרקציה", "title": "[Paperclip Gap 6] שאילתות psql ישירות ל-issue_attachments — שובר אבסטרקציה",
"description": "HEARTBEAT.md סעיף 2c משתמש ב-psql ישיר ל-issue_attachments + assets. אם schema ישתנה (כפי שצפוי בעדכוני Paperclip) — כל הסוכנים נשברים.", "description": "HEARTBEAT.md סעיף 2c משתמש ב-psql ישיר ל-issue_attachments + assets. אם schema ישתנה (כפי שצפוי בעדכוני Paperclip) — כל הסוכנים נשברים.",
"details": "ממצא: 6 קבצי סוכן + HEARTBEAT.md מכילים PGPASSWORD=paperclip psql ... FROM issue_attachments ia JOIN assets a.\n\nסיכון: breakage בעדכון Paperclip. כפילות לוגיקה (copy-paste בכל סוכן).\n\nפעולה (Phase 2):\n1. בדיקה אם קיים endpoint רשמי /api/issues/{id}/attachments (curl + grep ב-server/src/routes)\n2. אם כן — להחליף את כל ה-psql\n3. אם לא — להעביר את ה-psql למקום יחיד: helper ב-mcp-server (mcp__legal-ai__list_issue_attachments tool)\n4. אופציה ג: לפתוח issue ב-paperclipai/paperclip לבקש endpoint\n\nתלוי במחקר API.", "details": "ממצא: 6 קבצי סוכן + HEARTBEAT.md מכילים PGPASSWORD=paperclip psql ... FROM issue_attachments ia JOIN assets a.\n\nסיכון: breakage בעדכון Paperclip. כפילות לוגיקה (copy-paste בכל סוכן).\n\nפעולה (Phase 2):\n1. בדיקה אם קיים endpoint רשמי /api/issues/{id}/attachments (curl + grep ב-server/src/routes)\n2. אם כן — להחליף את כל ה-psql\n3. אם לא — להעביר את ה-psql למקום יחיד: helper ב-mcp-server (mcp__legal-ai__list_issue_attachments tool)\n4. אופציה ג: לפתוח issue ב-paperclipai/paperclip לבקש endpoint\n\nתלוי במחקר API.",
@@ -1262,7 +1262,7 @@
"updatedAt": "2026-05-04T09:28:18.058Z" "updatedAt": "2026-05-04T09:28:18.058Z"
}, },
{ {
"id": 22, "id": "22",
"title": "[Paperclip Gap 7] לא משתמשים ב-/api/issues/{id}/heartbeat-context", "title": "[Paperclip Gap 7] לא משתמשים ב-/api/issues/{id}/heartbeat-context",
"description": "Endpoint רשמי שמחזיר issue + ancestors + goal/project + comment cursor בקריאה אחת. אנחנו עושים 3 קריאות נפרדות.", "description": "Endpoint רשמי שמחזיר issue + ancestors + goal/project + comment cursor בקריאה אחת. אנחנו עושים 3 קריאות נפרדות.",
"details": "ה-skill הרשמי: 'Prefer GET /api/issues/{issueId}/heartbeat-context first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.'\n\nשלנו: HEARTBEAT.md סעיפים 2 + 2b → שלוש קריאות (inbox-lite, issue, comments).\n\nפעולה (Phase 2):\n1. הוספת endpoint כצעד 6 ב-HEARTBEAT.md לפני 'Do the work'\n2. הסרת קריאות מיותרות שכבר ב-context\n3. שמירת comment cursor (after={last-seen-id}) לקריאות עוקבות", "details": "ה-skill הרשמי: 'Prefer GET /api/issues/{issueId}/heartbeat-context first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.'\n\nשלנו: HEARTBEAT.md סעיפים 2 + 2b → שלוש קריאות (inbox-lite, issue, comments).\n\nפעולה (Phase 2):\n1. הוספת endpoint כצעד 6 ב-HEARTBEAT.md לפני 'Do the work'\n2. הסרת קריאות מיותרות שכבר ב-context\n3. שמירת comment cursor (after={last-seen-id}) לקריאות עוקבות",
@@ -1276,7 +1276,7 @@
"updatedAt": "2026-05-04T09:28:14.247Z" "updatedAt": "2026-05-04T09:28:14.247Z"
}, },
{ {
"id": 23, "id": "23",
"title": "[Paperclip Gap 8+11] HEARTBEAT.md ארוך + אין שימוש ב-skills של Paperclip", "title": "[Paperclip Gap 8+11] HEARTBEAT.md ארוך + אין שימוש ב-skills של Paperclip",
"description": "HEARTBEAT.md שלנו 220 שורות (vs upstream 85). Paperclip מציע 8 skills מוכנים (paperclip, paperclip-create-agent, וכו') שאנחנו לא משתמשים באף אחד.", "description": "HEARTBEAT.md שלנו 220 שורות (vs upstream 85). Paperclip מציע 8 skills מוכנים (paperclip, paperclip-create-agent, וכו') שאנחנו לא משתמשים באף אחד.",
"details": "תיקון לניתוח: מסתבר ש-CEO + 4 סוכנים אחרים כן משתמשים ב-paperclipSkillSync עם 4 paperclip skills (paperclip, paperclip-create-agent, paperclip-create-plugin, para-memory-files). חסר אצל: הגהת מסמכים ומנתח משפטי (skills_count=0).\n\nממצא: ls skills/ ב-paperclip repo → 8 skills. שלנו: 0 skills של Paperclip בשימוש.\n\nרלוונטיים לנו:\n- paperclip — API patterns + heartbeat checklist (יכול להחליף חלק מ-HEARTBEAT.md)\n- paperclip-create-agent — אם נוסיף סוכן\n- paperclip-create-plugin — לעדכוני plugin-legal-ai\n- paperclip-converting-plans-to-tasks — יכול להחליף brainstorm_directions\n- diagnose-why-work-stopped — לתחזוקה\n\nפעולה (Phase 3):\n1. קריאת skills/paperclip/SKILL.md מלא\n2. הזרקת skill לסביבת הסוכנים (כנראה דרך CLI: paperclipai agent local-cli)\n3. שכתוב HEARTBEAT.md לפי הדפוס: project-specific only, delegation לskill הרשמי לכלל ה-API\n4. יעד: ~120 שורות ב-HEARTBEAT.md שלנו\n\nשאלה לחיים: האם להזריק skills כסימלינקים ל-symlinks קיימים, או דרך paperclipai CLI?", "details": "תיקון לניתוח: מסתבר ש-CEO + 4 סוכנים אחרים כן משתמשים ב-paperclipSkillSync עם 4 paperclip skills (paperclip, paperclip-create-agent, paperclip-create-plugin, para-memory-files). חסר אצל: הגהת מסמכים ומנתח משפטי (skills_count=0).\n\nממצא: ls skills/ ב-paperclip repo → 8 skills. שלנו: 0 skills של Paperclip בשימוש.\n\nרלוונטיים לנו:\n- paperclip — API patterns + heartbeat checklist (יכול להחליף חלק מ-HEARTBEAT.md)\n- paperclip-create-agent — אם נוסיף סוכן\n- paperclip-create-plugin — לעדכוני plugin-legal-ai\n- paperclip-converting-plans-to-tasks — יכול להחליף brainstorm_directions\n- diagnose-why-work-stopped — לתחזוקה\n\nפעולה (Phase 3):\n1. קריאת skills/paperclip/SKILL.md מלא\n2. הזרקת skill לסביבת הסוכנים (כנראה דרך CLI: paperclipai agent local-cli)\n3. שכתוב HEARTBEAT.md לפי הדפוס: project-specific only, delegation לskill הרשמי לכלל ה-API\n4. יעד: ~120 שורות ב-HEARTBEAT.md שלנו\n\nשאלה לחיים: האם להזריק skills כסימלינקים ל-symlinks קיימים, או דרך paperclipai CLI?",
@@ -1296,7 +1296,7 @@
"updatedAt": "2026-05-04T16:44:27.553Z" "updatedAt": "2026-05-04T16:44:27.553Z"
}, },
{ {
"id": 24, "id": "24",
"title": "[Paperclip Gap 9] לבדוק bootstrapPromptTemplate deprecated באף סוכן", "title": "[Paperclip Gap 9] לבדוק bootstrapPromptTemplate deprecated באף סוכן",
"description": "מ-docs/agents-runtime.md: 'bootstrapPromptTemplate is deprecated... should be migrated to the managed instructions bundle system.' לבדוק האם adapter_config שלנו משתמש בזה.", "description": "מ-docs/agents-runtime.md: 'bootstrapPromptTemplate is deprecated... should be migrated to the managed instructions bundle system.' לבדוק האם adapter_config שלנו משתמש בזה.",
"details": "פעולה (Phase 1):\n1. SELECT name, adapter_config->'promptTemplate' as pt, adapter_config->'bootstrapPromptTemplate' as bpt FROM agents WHERE adapter_type = 'claude_local';\n2. אם בשימוש אצל סוכן כלשהו — מיגרציה למבנה החדש\n3. ייעוד: לבדוק תיעוד managed instructions bundle ב-paperclip docs\n\nהערה: זה כנראה לא ישפיע אצלנו (אנחנו משתמשים ב-symlinks ל-AGENTS.md/HEARTBEAT.md ישירות) — אבל חובה לוודא.", "details": "פעולה (Phase 1):\n1. SELECT name, adapter_config->'promptTemplate' as pt, adapter_config->'bootstrapPromptTemplate' as bpt FROM agents WHERE adapter_type = 'claude_local';\n2. אם בשימוש אצל סוכן כלשהו — מיגרציה למבנה החדש\n3. ייעוד: לבדוק תיעוד managed instructions bundle ב-paperclip docs\n\nהערה: זה כנראה לא ישפיע אצלנו (אנחנו משתמשים ב-symlinks ל-AGENTS.md/HEARTBEAT.md ישירות) — אבל חובה לוודא.",
@@ -1308,7 +1308,7 @@
"updatedAt": "2026-05-04T08:19:27.766Z" "updatedAt": "2026-05-04T08:19:27.766Z"
}, },
{ {
"id": 25, "id": "25",
"title": "[Paperclip Gap 10] סוכנים מוכפלים בין 2 חברות — אין סנכרון", "title": "[Paperclip Gap 10] סוכנים מוכפלים בין 2 חברות — אין סנכרון",
"description": "14 שורות = 7 סוכנים × 2 חברות (1xxx, 8xxx). כל שינוי בהגדרות הסוכן צריך להיעשות פעמיים. אין מנגנון סנכרון או הורשה.", "description": "14 שורות = 7 סוכנים × 2 חברות (1xxx, 8xxx). כל שינוי בהגדרות הסוכן צריך להיעשות פעמיים. אין מנגנון סנכרון או הורשה.",
"details": "ממצא: SELECT name, COUNT(*) FROM agents GROUP BY name → 2 לכל אחד.\n\nסיכון: drift בין החברות. שינוי runtime_config ל-CEO של 1xxx יכול לפספס את CEO של 8xxx.\n\nפעולה (Phase 3):\n1. בדיקה: האם Paperclip תומך ב-shared agents או chainOfCommand? (לקרוא docs/companies/)\n2. אם כן — מיגרציה למבנה משותף\n3. אם לא — סקריפט סנכרון: scripts/sync_agents_across_companies.py שמעתיק כל שינוי מחברה לחברה\n\nשאלה לחיים: בעתיד אם יהיו עוד סוגי ערר (10xxx?) — להוסיף עוד חברה או להשאיר 2?", "details": "ממצא: SELECT name, COUNT(*) FROM agents GROUP BY name → 2 לכל אחד.\n\nסיכון: drift בין החברות. שינוי runtime_config ל-CEO של 1xxx יכול לפספס את CEO של 8xxx.\n\nפעולה (Phase 3):\n1. בדיקה: האם Paperclip תומך ב-shared agents או chainOfCommand? (לקרוא docs/companies/)\n2. אם כן — מיגרציה למבנה משותף\n3. אם לא — סקריפט סנכרון: scripts/sync_agents_across_companies.py שמעתיק כל שינוי מחברה לחברה\n\nשאלה לחיים: בעתיד אם יהיו עוד סוגי ערר (10xxx?) — להוסיף עוד חברה או להשאיר 2?",
@@ -1322,7 +1322,7 @@
"updatedAt": "2026-05-04T09:52:14.263Z" "updatedAt": "2026-05-04T09:52:14.263Z"
}, },
{ {
"id": 26, "id": "26",
"title": "[Paperclip Gap 12] עדכון @paperclipai/plugin-sdk + capabilities חדשות", "title": "[Paperclip Gap 12] עדכון @paperclipai/plugin-sdk + capabilities חדשות",
"description": "ה-plugin שלנו: @paperclipai/plugin-sdk@^2026.325.0, apiVersion: 1, minimumHostVersion: 2026.325.0. ה-host: 2026.428.0. ייתכן capabilities חדשות (issue.interactions.create, וכו').", "description": "ה-plugin שלנו: @paperclipai/plugin-sdk@^2026.325.0, apiVersion: 1, minimumHostVersion: 2026.325.0. ה-host: 2026.428.0. ייתכן capabilities חדשות (issue.interactions.create, וכו').",
"details": "פעולה (Phase 4 — אחרי שדרוג Paperclip stable):\n1. cd /home/chaim/plugin-legal-ai && npm view @paperclipai/plugin-sdk version\n2. אם חדשה: npm install @paperclipai/plugin-sdk@latest\n3. קריאת adapter-plugin.md המעודכן ב-paperclip repo\n4. בדיקה אם apiVersion: 2 קיים\n5. הוספת capabilities חדשות אם רלוונטי (בעיקר issue.interactions.create אחרי gap #4)\n6. npm run build && reinstall plugin\n\nתלוי בgap #19 (interactions API) — אם אנחנו רוצים שהplugin יוכל ליצור interactions, חייב capability חדש.", "details": "פעולה (Phase 4 — אחרי שדרוג Paperclip stable):\n1. cd /home/chaim/plugin-legal-ai && npm view @paperclipai/plugin-sdk version\n2. אם חדשה: npm install @paperclipai/plugin-sdk@latest\n3. קריאת adapter-plugin.md המעודכן ב-paperclip repo\n4. בדיקה אם apiVersion: 2 קיים\n5. הוספת capabilities חדשות אם רלוונטי (בעיקר issue.interactions.create אחרי gap #4)\n6. npm run build && reinstall plugin\n\nתלוי בgap #19 (interactions API) — אם אנחנו רוצים שהplugin יוכל ליצור interactions, חייב capability חדש.",
@@ -1337,7 +1337,7 @@
"updatedAt": "2026-05-26T12:19:16.180163Z" "updatedAt": "2026-05-26T12:19:16.180163Z"
}, },
{ {
"id": 27, "id": "27",
"title": "[Paperclip Phase 4] שדרוג Paperclip לגרסה stable הבאה (לא 2026.428.0)", "title": "[Paperclip Phase 4] שדרוג Paperclip לגרסה stable הבאה (לא 2026.428.0)",
"description": "כרגע אנחנו על 2026.428.0 — הגרסה היציבה האחרונה. כשיופיע stable חדש (כנראה 2026.5xx.x), לבצע שדרוג מבוקר.", "description": "כרגע אנחנו על 2026.428.0 — הגרסה היציבה האחרונה. כשיופיע stable חדש (כנראה 2026.5xx.x), לבצע שדרוג מבוקר.",
"details": "טריגר: npm view paperclipai dist-tags.latest מחזיר משהו ≠ 2026.428.0.\n\nפעולה:\n1. קריאת releases/v2026.5xx.x.md ב-GitHub\n2. בדיקת שינויים שעלולים להשפיע (CUSTOMIZATIONS.md סעיפים: hebrew, RTL, plugin driver, heartbeat)\n3. גיבוי: pg_dump של paperclip DB + cp -r ~/.npm/_npx/43414d9b790239bb /tmp/\n4. pm2 stop paperclip\n5. rm -rf ~/.npm/_npx/43414d9b790239bb\n6. npx paperclipai@latest run (יוריד גרסה חדשה)\n7. הרצה מחדש: ~/.paperclip/hebrew/apply-hebrew.sh && ~/.paperclip/issue-link-fix/apply-issue-link-fix.sh\n8. pm2 restart paperclip\n9. בדיקה ב-pc.nautilus.marcusgroup.org: עברית + plugin פעיל + סוכן מתעורר על comment\n\nתלוי בלי dependencies (יכול להיות מבוצע בכל עת אחרי שיש stable חדש).", "details": "טריגר: npm view paperclipai dist-tags.latest מחזיר משהו ≠ 2026.428.0.\n\nפעולה:\n1. קריאת releases/v2026.5xx.x.md ב-GitHub\n2. בדיקת שינויים שעלולים להשפיע (CUSTOMIZATIONS.md סעיפים: hebrew, RTL, plugin driver, heartbeat)\n3. גיבוי: pg_dump של paperclip DB + cp -r ~/.npm/_npx/43414d9b790239bb /tmp/\n4. pm2 stop paperclip\n5. rm -rf ~/.npm/_npx/43414d9b790239bb\n6. npx paperclipai@latest run (יוריד גרסה חדשה)\n7. הרצה מחדש: ~/.paperclip/hebrew/apply-hebrew.sh && ~/.paperclip/issue-link-fix/apply-issue-link-fix.sh\n8. pm2 restart paperclip\n9. בדיקה ב-pc.nautilus.marcusgroup.org: עברית + plugin פעיל + סוכן מתעורר על comment\n\nתלוי בלי dependencies (יכול להיות מבוצע בכל עת אחרי שיש stable חדש).",
@@ -1349,7 +1349,7 @@
"updatedAt": "2026-05-26T12:19:16.180163Z" "updatedAt": "2026-05-26T12:19:16.180163Z"
}, },
{ {
"id": 28, "id": "28",
"title": "[Paperclip Auxiliary] להפעיל skill-sync ל-2 סוכנים שפיספסו", "title": "[Paperclip Auxiliary] להפעיל skill-sync ל-2 סוכנים שפיספסו",
"description": "הגהת מסמכים ומנתח משפטי לא קיבלו אף פעם revision מסוג skill-sync (לעומת 5 האחרים שכן). לבצע sync.", "description": "הגהת מסמכים ומנתח משפטי לא קיבלו אף פעם revision מסוג skill-sync (לעומת 5 האחרים שכן). לבצע sync.",
"details": "ממצא: בדיקה ב-agent_config_revisions:\n- עוזר משפטי: 3 skill-sync revisions (יש 7 skills)\n- חוקר תקדימים: 3 (יש 5)\n- מייצא טיוטה: 5 (יש 5)\n- בודק איכות: 1 (יש 5)\n- כותב החלטה: 1 (יש 5)\n- הגהת מסמכים: 0 (יש 0) ❌\n- מנתח משפטי: 0 (יש 0) ❌\n\nאופציות:\n1. UI: agent settings → 'sync skills'\n2. API: POST /api/agents/{id}/skills-sync (לאתר)\n3. CLI: paperclipai agent skill-sync (לבדוק אם קיים)\n4. SQL ידני (לא מומלץ — דורף revision tracking)\n\nSkills להעתקה (לפי בודק איכות):\n- paperclipai/paperclip/paperclip\n- paperclipai/paperclip/paperclip-create-agent\n- paperclipai/paperclip/paperclip-create-plugin\n- paperclipai/paperclip/para-memory-files\n- (אופציונלי) local/eba6210d5a/legal-decision", "details": "ממצא: בדיקה ב-agent_config_revisions:\n- עוזר משפטי: 3 skill-sync revisions (יש 7 skills)\n- חוקר תקדימים: 3 (יש 5)\n- מייצא טיוטה: 5 (יש 5)\n- בודק איכות: 1 (יש 5)\n- כותב החלטה: 1 (יש 5)\n- הגהת מסמכים: 0 (יש 0) ❌\n- מנתח משפטי: 0 (יש 0) ❌\n\nאופציות:\n1. UI: agent settings → 'sync skills'\n2. API: POST /api/agents/{id}/skills-sync (לאתר)\n3. CLI: paperclipai agent skill-sync (לבדוק אם קיים)\n4. SQL ידני (לא מומלץ — דורף revision tracking)\n\nSkills להעתקה (לפי בודק איכות):\n- paperclipai/paperclip/paperclip\n- paperclipai/paperclip/paperclip-create-agent\n- paperclipai/paperclip/paperclip-create-plugin\n- paperclipai/paperclip/para-memory-files\n- (אופציונלי) local/eba6210d5a/legal-decision",
@@ -1361,7 +1361,7 @@
"updatedAt": "2026-05-04T09:46:32.092Z" "updatedAt": "2026-05-04T09:46:32.092Z"
}, },
{ {
"id": 29, "id": "29",
"title": "[legal-ai UI] מסך הגדרות סוכנים — הצגה + עריכה + שמירה", "title": "[legal-ai UI] מסך הגדרות סוכנים — הצגה + עריכה + שמירה",
"description": "מסך אדמין ב-legal-ai UI שמציג את כל הגדרות הסוכנים (model, timeout, runtime_config, skills, budget) ומאפשר עריכה ושמירה. מונע SQL ישיר.", "description": "מסך אדמין ב-legal-ai UI שמציג את כל הגדרות הסוכנים (model, timeout, runtime_config, skills, budget) ומאפשר עריכה ושמירה. מונע SQL ישיר.",
"details": "מטרה: ממשק אדמין מרכזי במקום שעריכה תהיה רק ב-UI של Paperclip + SQL ישיר + CUSTOMIZATIONS.md.\n\nשדות (לכל סוכן × 2 חברות):\n1. adapter_config: model, effort, timeoutSec, maxTurnsPerRun, extraArgs[], paperclipSkillSync.desiredSkills[]\n2. runtime_config.heartbeat: graceSec, cooldownSec, wakeOnDemand, maxConcurrentRuns, enabled, intervalSec\n3. budget_monthly_cents (לקראת gap #2)\n4. status / pause_reason (קריאה + כפתור pause/resume)\n\nאופציות מימוש:\nA. עמוד חדש ב-legal-ai/web-ui (Next.js 16) — קורא Paperclip DB דרך FastAPI endpoint חדש (/api/admin/paperclip-agents)\nB. קריאה ל-Paperclip API (/api/companies/{id}/agents) — REST טהור, פחות שדות זמינים\nC. iframe ל-Paperclip UI — שטחי\n\nהמלצה: A. שולט מלא + ולידציה משפטית (timeoutSec >= 1800 כי OCR).\n\nתלוי ב: gap #25 (סוכנים מוכפלים) — אם נעבור לshared, המסך יתאים.\n\nשאלות פתוחות לחיים:\n- auth: מי יכול לגשת? (כיום אין auth ב-legal-ai)\n- bulk edit ל-2 חברות יחד או נפרד?\n- חשיפת skill marketplace (להוסיף/להוריד skills) או רק קריאה?", "details": "מטרה: ממשק אדמין מרכזי במקום שעריכה תהיה רק ב-UI של Paperclip + SQL ישיר + CUSTOMIZATIONS.md.\n\nשדות (לכל סוכן × 2 חברות):\n1. adapter_config: model, effort, timeoutSec, maxTurnsPerRun, extraArgs[], paperclipSkillSync.desiredSkills[]\n2. runtime_config.heartbeat: graceSec, cooldownSec, wakeOnDemand, maxConcurrentRuns, enabled, intervalSec\n3. budget_monthly_cents (לקראת gap #2)\n4. status / pause_reason (קריאה + כפתור pause/resume)\n\nאופציות מימוש:\nA. עמוד חדש ב-legal-ai/web-ui (Next.js 16) — קורא Paperclip DB דרך FastAPI endpoint חדש (/api/admin/paperclip-agents)\nB. קריאה ל-Paperclip API (/api/companies/{id}/agents) — REST טהור, פחות שדות זמינים\nC. iframe ל-Paperclip UI — שטחי\n\nהמלצה: A. שולט מלא + ולידציה משפטית (timeoutSec >= 1800 כי OCR).\n\nתלוי ב: gap #25 (סוכנים מוכפלים) — אם נעבור לshared, המסך יתאים.\n\nשאלות פתוחות לחיים:\n- auth: מי יכול לגשת? (כיום אין auth ב-legal-ai)\n- bulk edit ל-2 חברות יחד או נפרד?\n- חשיפת skill marketplace (להוסיף/להוריד skills) או רק קריאה?",
@@ -1377,7 +1377,7 @@
"updatedAt": "2026-05-04T17:29:25.686Z" "updatedAt": "2026-05-04T17:29:25.686Z"
}, },
{ {
"id": 30, "id": "30",
"title": "תיקון 3 baגים בקטלוג (practice_area + source_kind + upload route)", "title": "תיקון 3 baגים בקטלוג (practice_area + source_kind + upload route)",
"description": "CRITICAL: 3 sub-bugs. (א) יצירת תיקים מתייגת practice_area='appeals_committee' במקום rishuy_uvniya/betterment_levy/compensation_197 לפי קידומת מספר התיק (1xxx/8xxx/9xxx) — audit + migration לכל התיקים הקיימים + תיקון נתיב היצירה. (ב) כל החלטה של ועדת ערר שהועלתה ל-case_law מסומנת כ-source_kind='external_upload' במקום 'internal_committee' — audit ל-case_law עם case_number שמתחיל ב'ערר' → reclassify + מילוי chair_name + district רטרואקטיבית. (ג) POST /api/precedent-library/upload לא מבחין — תיקון: ניתוב לפי תחילית הציטוט (ערר/ועדות ערר → internal_committee, אחרת → external_upload).", "description": "CRITICAL: 3 sub-bugs. (א) יצירת תיקים מתייגת practice_area='appeals_committee' במקום rishuy_uvniya/betterment_levy/compensation_197 לפי קידומת מספר התיק (1xxx/8xxx/9xxx) — audit + migration לכל התיקים הקיימים + תיקון נתיב היצירה. (ב) כל החלטה של ועדת ערר שהועלתה ל-case_law מסומנת כ-source_kind='external_upload' במקום 'internal_committee' — audit ל-case_law עם case_number שמתחיל ב'ערר' → reclassify + מילוי chair_name + district רטרואקטיבית. (ג) POST /api/precedent-library/upload לא מבחין — תיקון: ניתוב לפי תחילית הציטוט (ערר/ועדות ערר → internal_committee, אחרת → external_upload).",
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #1. Pre-requirement: השתמש במחיקה+rerun של halachot אחרי שינוי source_kind. השתמש בpattern של internal_decisions.py (dry_run+log_action).", "details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #1. Pre-requirement: השתמש במחיקה+rerun של halachot אחרי שינוי source_kind. השתמש בpattern של internal_decisions.py (dry_run+log_action).",
@@ -1447,7 +1447,7 @@
"updatedAt": "2026-05-26T08:35:22.762800Z" "updatedAt": "2026-05-26T08:35:22.762800Z"
}, },
{ {
"id": 31, "id": "31",
"title": "מיצוי chair_name + district בהעלאת ועדת ערר", "title": "מיצוי chair_name + district בהעלאת ועדת ערר",
"description": "תוספת לטופס + חילוץ אוטומטי מהציטוט/text הפסיקה. רטרואקטיבי לכל הרשומות הקיימות עם source_kind='internal_committee' שהשדות בהן ריקים.", "description": "תוספת לטופס + חילוץ אוטומטי מהציטוט/text הפסיקה. רטרואקטיבי לכל הרשומות הקיימות עם source_kind='internal_committee' שהשדות בהן ריקים.",
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #2. תלוי במשימה #30 (sub-bug ב).", "details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #2. תלוי במשימה #30 (sub-bug ב).",
@@ -1483,7 +1483,7 @@
"updatedAt": "2026-05-26T08:35:22.762800Z" "updatedAt": "2026-05-26T08:35:22.762800Z"
}, },
{ {
"id": 32, "id": "32",
"title": "UI — דף עריכת פסיקה ייפתח רחב-במרכז (לא צר-בצד)", "title": "UI — דף עריכת פסיקה ייפתח רחב-במרכז (לא צר-בצד)",
"description": "חוסר נוחות בעריכה. שינוי ה-Dialog/Sheet ל-Modal רחב מרכזי. רלוונטי גם להוספת שדות chair_name + district מהמשימה #31.", "description": "חוסר נוחות בעריכה. שינוי ה-Dialog/Sheet ל-Modal רחב מרכזי. רלוונטי גם להוספת שדות chair_name + district מהמשימה #31.",
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #3.", "details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #3.",
@@ -1495,7 +1495,7 @@
"updatedAt": "2026-05-26T10:38:07.071897Z" "updatedAt": "2026-05-26T10:38:07.071897Z"
}, },
{ {
"id": 33, "id": "33",
"title": "UI — הסתרת עמודת 'שם' (case_name) בדף רשימת פסיקה", "title": "UI — הסתרת עמודת 'שם' (case_name) בדף רשימת פסיקה",
"description": "רוב הערכים זהים למספר התיק. להסתיר את העמודה ב-UI, לשמור עמודה ב-DB לשימוש עתידי.", "description": "רוב הערכים זהים למספר התיק. להסתיר את העמודה ב-UI, לשמור עמודה ב-DB לשימוש עתידי.",
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #4.", "details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #4.",
@@ -1507,7 +1507,7 @@
"updatedAt": "2026-05-26T11:27:09.039154Z" "updatedAt": "2026-05-26T11:27:09.039154Z"
}, },
{ {
"id": 34, "id": "34",
"title": "חילוץ ציטוטי-פנים מהחלטות דפנה (internal_committee + ירושלים)", "title": "חילוץ ציטוטי-פנים מהחלטות דפנה (internal_committee + ירושלים)",
"description": "פאטרן: 'ונפנה להחלטות של ועדת ערר זו...', 'כפי שקבעתי בערר X', 'בדומה לעמדתי בהחלטה Y'. חילוץ אוטומטי + שמירה ב-precedent_internal_citations table שיאפשר ל-search_internal_decisions להחזיר גם את הפסיקה המוזכרת.", "description": "פאטרן: 'ונפנה להחלטות של ועדת ערר זו...', 'כפי שקבעתי בערר X', 'בדומה לעמדתי בהחלטה Y'. חילוץ אוטומטי + שמירה ב-precedent_internal_citations table שיאפשר ל-search_internal_decisions להחזיר גם את הפסיקה המוזכרת.",
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #5. תלוי במשימה #30 (sub-bug ב) ובמשימה #31.", "details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #5. תלוי במשימה #30 (sub-bug ב) ובמשימה #31.",
@@ -1522,7 +1522,7 @@
"updatedAt": "2026-05-26T10:38:07.071897Z" "updatedAt": "2026-05-26T10:38:07.071897Z"
}, },
{ {
"id": 35, "id": "35",
"title": "דף/דוח 'פסיקה חסרה בקורפוס' — פיצ'ר מלא", "title": "דף/דוח 'פסיקה חסרה בקורפוס' — פיצ'ר מלא",
"description": "טבלת DB missing_precedents (id, citation, case_name, cited_in_case_id, cited_in_document_id, cited_by_party, legal_topic, legal_issue, claim_quote, status, linked_case_law_id, closed_at, created_at), API endpoints (POST/GET/upload/PATCH), MCP tools (missing_precedent_create/list/close), דף Next.js /missing-precedents, הוק אוטומטי במחקר ע\"י legal-researcher.", "description": "טבלת DB missing_precedents (id, citation, case_name, cited_in_case_id, cited_in_document_id, cited_by_party, legal_topic, legal_issue, claim_quote, status, linked_case_law_id, closed_at, created_at), API endpoints (POST/GET/upload/PATCH), MCP tools (missing_precedent_create/list/close), דף Next.js /missing-precedents, הוק אוטומטי במחקר ע\"י legal-researcher.",
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #6.", "details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #6.",
@@ -1571,7 +1571,7 @@
"updatedAt": "2026-05-26T08:35:22.762800Z" "updatedAt": "2026-05-26T08:35:22.762800Z"
}, },
{ {
"id": 36, "id": "36",
"title": "כינוס פרופוזיציות לטיעונים משפטיים אמיתיים (de-dup/aggregation)", "title": "כינוס פרופוזיציות לטיעונים משפטיים אמיתיים (de-dup/aggregation)",
"description": "extract_claims מחזיר ~60 פרופוזיציות לתיק, צריך לאגד ל-~10 טיעונים משפטיים אמיתיים. טבלה חדשה legal_arguments + טבלת קישור legal_argument_propositions (M:M ל-case_claims). LLM aggregation job (Hermes/DeepSeek). API + MCP + UI display שמציג 'X טיעונים משפטיים' במקום 'Y טענות'.", "description": "extract_claims מחזיר ~60 פרופוזיציות לתיק, צריך לאגד ל-~10 טיעונים משפטיים אמיתיים. טבלה חדשה legal_arguments + טבלת קישור legal_argument_propositions (M:M ל-case_claims). LLM aggregation job (Hermes/DeepSeek). API + MCP + UI display שמציג 'X טיעונים משפטיים' במקום 'Y טענות'.",
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #7.", "details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #7.",
@@ -1614,7 +1614,7 @@
"updatedAt": "2026-05-26T08:35:22.762800Z" "updatedAt": "2026-05-26T08:35:22.762800Z"
}, },
{ {
"id": 37, "id": "37",
"title": "הפרדת תתי-סוגי בל\"מ לפי practice_area", "title": "הפרדת תתי-סוגי בל\"מ לפי practice_area",
"description": "3 ערכי appeal_subtype חדשים: extension_request_building_permit (1xxx, ס'152 - 30 ימים), extension_request_betterment_levy (8xxx, ס'14 לתוספת ג' - 45 ימים), extension_request_compensation (9xxx, ס'198(ד) - 30 ימים). 3 templates מתודולוגיים נפרדים. אוטו-זיהוי מהsubject. UI badge + filter.", "description": "3 ערכי appeal_subtype חדשים: extension_request_building_permit (1xxx, ס'152 - 30 ימים), extension_request_betterment_levy (8xxx, ס'14 לתוספת ג' - 45 ימים), extension_request_compensation (9xxx, ס'198(ד) - 30 ימים). 3 templates מתודולוגיים נפרדים. אוטו-זיהוי מהsubject. UI badge + filter.",
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #8. Pre-requirement: עדכון mcp-server/src/legal_mcp/services/practice_area.py APPEALS_COMMITTEE_SUBTYPES + עדכון web/paperclip_client.py mapping appeal_subtype → company.", "details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #8. Pre-requirement: עדכון mcp-server/src/legal_mcp/services/practice_area.py APPEALS_COMMITTEE_SUBTYPES + עדכון web/paperclip_client.py mapping appeal_subtype → company.",
@@ -1657,7 +1657,7 @@
"updatedAt": "2026-05-26T08:35:22.762800Z" "updatedAt": "2026-05-26T08:35:22.762800Z"
}, },
{ {
"id": 38, "id": "38",
"title": "שדרוג סוכני Paperclip להכרת השינויים מ-#30-#37", "title": "שדרוג סוכני Paperclip להכרת השינויים מ-#30-#37",
"description": "עדכון 7 הגדרות סוכן (CEO/analyst/researcher/writer/QA/proofreader/exporter) + HEARTBEAT.md לזיהוי המבנים החדשים. בלי זה כל הפיצ'רים נשארים זמינים אבל הסוכנים לא יודעים להשתמש בהם. כולל הוספת research_complete כ-valid case_status.", "description": "עדכון 7 הגדרות סוכן (CEO/analyst/researcher/writer/QA/proofreader/exporter) + HEARTBEAT.md לזיהוי המבנים החדשים. בלי זה כל הפיצ'רים נשארים זמינים אבל הסוכנים לא יודעים להשתמש בהם. כולל הוספת research_complete כ-valid case_status.",
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #9. תלוי במשימות #30-#37.", "details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #9. תלוי במשימות #30-#37.",
@@ -1740,7 +1740,7 @@
"updatedAt": "2026-05-26T07:41:47.880478Z" "updatedAt": "2026-05-26T07:41:47.880478Z"
}, },
{ {
"id": 39, "id": "39",
"title": "[ROOT CAUSE] MCP tool חדש: internal_decision_upload", "title": "[ROOT CAUSE] MCP tool חדש: internal_decision_upload",
"description": "הוספת @mcp.tool() עם chair_name+district חובה ו-source_kind='internal_committee' אוטומטי. סוגר את ה-root cause של Bug (ב) ב-#30. בלעדיו 44 רשומות חדשות יחזרו כ-external_upload תוך חודש.", "description": "הוספת @mcp.tool() עם chair_name+district חובה ו-source_kind='internal_committee' אוטומטי. סוגר את ה-root cause של Bug (ב) ב-#30. בלעדיו 44 רשומות חדשות יחזרו כ-external_upload תוך חודש.",
"details": "מיקום: mcp-server/src/legal_mcp/tools/internal_decisions.py (אם לא קיים — ליצור). רישום ב-server.py סביב שורה 169 (ליד precedent_library_upload). הקריאה מנותבת ל-int_decisions_service.ingest_internal_decision (קיים ב-internal_decisions.py).", "details": "מיקום: mcp-server/src/legal_mcp/tools/internal_decisions.py (אם לא קיים — ליצור). רישום ב-server.py סביב שורה 169 (ליד precedent_library_upload). הקריאה מנותבת ל-int_decisions_service.ingest_internal_decision (קיים ב-internal_decisions.py).",
@@ -1754,7 +1754,7 @@
"updatedAt": "2026-05-26T07:41:37.260868Z" "updatedAt": "2026-05-26T07:41:37.260868Z"
}, },
{ {
"id": 40, "id": "40",
"title": "[שלב B - ROI מיידי] הפעלת VOYAGE_RERANK_ENABLED=true ב-Coolify", "title": "[שלב B - ROI מיידי] הפעלת VOYAGE_RERANK_ENABLED=true ב-Coolify",
"description": "Cross-encoder rerank-2 ממומש ב-mcp-server/src/legal_mcp/services/rerank.py אבל כבוי בייצור (default=false). POC הוכיח +4.5% mean@3 ו-+11.6% practical queries (latency +702ms acceptable לזרימה האסינכרונית). 5 דקות עבודה — env change ב-Coolify.", "description": "Cross-encoder rerank-2 ממומש ב-mcp-server/src/legal_mcp/services/rerank.py אבל כבוי בייצור (default=false). POC הוכיח +4.5% mean@3 ו-+11.6% practical queries (latency +702ms acceptable לזרימה האסינכרונית). 5 דקות עבודה — env change ב-Coolify.",
"details": "mcp__coolify__env_vars set VOYAGE_RERANK_ENABLED=true. ראה web/mcp_env_catalog.py:71-72 לdescription. אופציה: rampup רק על search_precedent_library (לא על find_similar_cases — latency-sensitive).", "details": "mcp__coolify__env_vars set VOYAGE_RERANK_ENABLED=true. ראה web/mcp_env_catalog.py:71-72 לdescription. אופציה: rampup רק על search_precedent_library (לא על find_similar_cases — latency-sensitive).",
@@ -1766,7 +1766,7 @@
"updatedAt": "2026-05-26T08:08:27.953285Z" "updatedAt": "2026-05-26T08:08:27.953285Z"
}, },
{ {
"id": 41, "id": "41",
"title": "[שלב B] BM25/tsvector hybrid retrieval על precedent_chunks + halachot", "title": "[שלב B] BM25/tsvector hybrid retrieval על precedent_chunks + halachot",
"description": "כיום כל החיפוש הוא 100% dense (cosine). ציטוטים מספריים ('עע\"מ 1461/20') נכשלים כי semantic לא מצליח בהם. הוספת tsvector GIN + RRF merge dense+lexical = +15-25% recall על ציטוטים — קריטי לאימות פסיקה ב-3-glimmering-oasis שלב 3.", "description": "כיום כל החיפוש הוא 100% dense (cosine). ציטוטים מספריים ('עע\"מ 1461/20') נכשלים כי semantic לא מצליח בהם. הוספת tsvector GIN + RRF merge dense+lexical = +15-25% recall על ציטוטים — קריטי לאימות פסיקה ב-3-glimmering-oasis שלב 3.",
"details": "ALTER TABLE precedent_chunks ADD COLUMN content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED; CREATE INDEX ... USING gin (content_tsv). באותו אופן על halachot.rule_statement. ב-db.py:2357 (search_precedent_library_semantic) — להוסיף שאילתה מקבילה של websearch_to_tsquery → RRF merge עם cosine. אזהרה: postgres אינו תומך ב-'hebrew' config — simple config יעבוד אבל בלי stemming.", "details": "ALTER TABLE precedent_chunks ADD COLUMN content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED; CREATE INDEX ... USING gin (content_tsv). באותו אופן על halachot.rule_statement. ב-db.py:2357 (search_precedent_library_semantic) — להוסיף שאילתה מקבילה של websearch_to_tsquery → RRF merge עם cosine. אזהרה: postgres אינו תומך ב-'hebrew' config — simple config יעבוד אבל בלי stemming.",
@@ -1780,7 +1780,7 @@
"updatedAt": "2026-05-26T08:08:27.953285Z" "updatedAt": "2026-05-26T08:08:27.953285Z"
}, },
{ {
"id": 42, "id": "42",
"title": "[שלב B] Query expansion via Claude Haiku — 2-3 variants per query", "title": "[שלב B] Query expansion via Claude Haiku — 2-3 variants per query",
"description": "שאילתות עם abbreviations משפטיות ('בל\"מ'/'בקשה להארכת מועד') חוטפות recall. LLM expansion: שאילתה → 2-3 variants → union retrieval. +10-15% recall.", "description": "שאילתות עם abbreviations משפטיות ('בל\"מ'/'בקשה להארכת מועד') חוטפות recall. LLM expansion: שאילתה → 2-3 variants → union retrieval. +10-15% recall.",
"details": "בוטל 2026-06-03 — obviated. BM25_HYBRID_ENABLED=true כבר פעיל ותופס קיצורים לקסיקלית (בל\"מ כ-token). ב-gold-set (86 שאילתות) 0 שאילתות-קיצורים, ו-recall כללי ≈0.99 — אין gap נמדד. Query-expansion דרך LLM מוסיף latency+עלות לכל שאילתה ללא צורך מוכח (YAGNI). re-open trigger: אם eval ייעודי על שאילתות-קיצורים יראה recall<0.9.", "details": "בוטל 2026-06-03 — obviated. BM25_HYBRID_ENABLED=true כבר פעיל ותופס קיצורים לקסיקלית (בל\"מ כ-token). ב-gold-set (86 שאילתות) 0 שאילתות-קיצורים, ו-recall כללי ≈0.99 — אין gap נמדד. Query-expansion דרך LLM מוסיף latency+עלות לכל שאילתה ללא צורך מוכח (YAGNI). re-open trigger: אם eval ייעודי על שאילתות-קיצורים יראה recall<0.9.",
@@ -1794,7 +1794,7 @@
"updatedAt": "2026-06-03T00:00:00.000Z" "updatedAt": "2026-06-03T00:00:00.000Z"
}, },
{ {
"id": 43, "id": "43",
"title": "[שלב B] MMR / diversity penalty — limit 2 chunks per case_law_id", "title": "[שלב B] MMR / diversity penalty — limit 2 chunks per case_law_id",
"description": "תוצאות חיפוש דומות מאוד זו לזו (אותה פסיקה, chunks סמוכים) — פסיקות חוזרות תופסות slots → diversity@10 נמוך. הוספת cap per case_law_id (2-3 max) או MMR אמיתי.", "description": "תוצאות חיפוש דומות מאוד זו לזו (אותה פסיקה, chunks סמוכים) — פסיקות חוזרות תופסות slots → diversity@10 נמוך. הוספת cap per case_law_id (2-3 max) או MMR אמיתי.",
"details": "פתרון קל: SQL DISTINCT ON (case_law_id) + 2 בpost-processing. פתרון איכותי: MMR — לכל candidate, score = λ*relevance - (1-λ)*max_similarity_to_selected. λ=0.7 דיפולט.", "details": "פתרון קל: SQL DISTINCT ON (case_law_id) + 2 בpost-processing. פתרון איכותי: MMR — לכל candidate, score = λ*relevance - (1-λ)*max_similarity_to_selected. λ=0.7 דיפולט.",
@@ -1808,7 +1808,7 @@
"updatedAt": "2026-05-26T08:08:27.953285Z" "updatedAt": "2026-05-26T08:08:27.953285Z"
}, },
{ {
"id": 44, "id": "44",
"title": "[שלב B] HNSW migration (or lists=68 IVFFlat) + REINDEX", "title": "[שלב B] HNSW migration (or lists=68 IVFFlat) + REINDEX",
"description": "IVFFlat lists=50 עם 4,595 vectors — sub-optimal. sqrt(4595)≈68. HNSW עדיף ל-recall (אבל יותר זיכרון). שיפור +3-5% recall@10.", "description": "IVFFlat lists=50 עם 4,595 vectors — sub-optimal. sqrt(4595)≈68. HNSW עדיף ל-recall (אבל יותר זיכרון). שיפור +3-5% recall@10.",
"details": "אופציה 1: REINDEX עם lists=68 (פשוט, idempotent). אופציה 2: DROP+CREATE עם HNSW (m=16, ef_construction=64) — דורש pgvector ≥0.5 ובדיקת זמן build. בדוק SELECT extversion FROM pg_extension WHERE extname='vector'.", "details": "אופציה 1: REINDEX עם lists=68 (פשוט, idempotent). אופציה 2: DROP+CREATE עם HNSW (m=16, ef_construction=64) — דורש pgvector ≥0.5 ובדיקת זמן build. בדוק SELECT extversion FROM pg_extension WHERE extname='vector'.",
@@ -1820,7 +1820,7 @@
"updatedAt": "2026-05-26T08:08:27.953285Z" "updatedAt": "2026-05-26T08:08:27.953285Z"
}, },
{ {
"id": 45, "id": "45",
"title": "[שלב B] Halacha auto-approve sweep — בדיקת 219 pending + הורדת סף ל-0.78", "title": "[שלב B] Halacha auto-approve sweep — בדיקת 219 pending + הורדת סף ל-0.78",
"description": "219 halachot pending review (17%) חסומות מ-search. אם dafna לא מסקר ידנית — הם מתבזבזים. dashboard batch + הורדת auto-approve threshold.", "description": "219 halachot pending review (17%) חסומות מ-search. אם dafna לא מסקר ידנית — הם מתבזבזים. dashboard batch + הורדת auto-approve threshold.",
"details": "1. בדוק 20 דגימות אקראיות של pending — אם רובן ראויות לאישור, הורד HALACHA_AUTO_APPROVE_THRESHOLD מ-0.80 ל-0.78. 2. הוסף UI batch approval ב-/halachot עם filter pending+confidence>0.75. 3. one-shot SQL לאישור 200 halachot שעמדו בקריטריונים החדשים.", "details": "1. בדוק 20 דגימות אקראיות של pending — אם רובן ראויות לאישור, הורד HALACHA_AUTO_APPROVE_THRESHOLD מ-0.80 ל-0.78. 2. הוסף UI batch approval ב-/halachot עם filter pending+confidence>0.75. 3. one-shot SQL לאישור 200 halachot שעמדו בקריטריונים החדשים.",
@@ -1832,7 +1832,7 @@
"updatedAt": "2026-05-26T08:08:27.953285Z" "updatedAt": "2026-05-26T08:08:27.953285Z"
}, },
{ {
"id": 46, "id": "46",
"title": "[שלב B] Dynamic halacha boost — לפי query-rule similarity", "title": "[שלב B] Dynamic halacha boost — לפי query-rule similarity",
"description": "כיום halacha boost = +0.05 קבוע. דינמי לפי query similarity ירוץ דייקנות (5% precision על שאילתות ספציפיות).", "description": "כיום halacha boost = +0.05 קבוע. דינמי לפי query similarity ירוץ דייקנות (5% precision על שאילתות ספציפיות).",
"details": "ב-db.py:2479 — score = float(d['score']) + 0.05. החלף ב-boost = 0.10 * d['score'] (proportional). או — אם rerank ON, השתמש בrerank score כbaseline (אין צורך ב-boost כלל).", "details": "ב-db.py:2479 — score = float(d['score']) + 0.05. החלף ב-boost = 0.10 * d['score'] (proportional). או — אם rerank ON, השתמש בrerank score כbaseline (אין צורך ב-boost כלל).",
@@ -1846,7 +1846,7 @@
"updatedAt": "2026-05-26T08:08:27.953285Z" "updatedAt": "2026-05-26T08:08:27.953285Z"
}, },
{ {
"id": 47, "id": "47",
"title": "[שלב C - prevention] Audit script periodic: detect new external_upload עם case_number של ערר", "title": "[שלב C - prevention] Audit script periodic: detect new external_upload עם case_number של ערר",
"description": "Drift detection: שגיאה דומה ל-Bug (ב) יכולה לחזור בעתיד. periodic check (יומי?) + alert ל-Slack/comment.", "description": "Drift detection: שגיאה דומה ל-Bug (ב) יכולה לחזור בעתיד. periodic check (יומי?) + alert ל-Slack/comment.",
"details": "scripts/audit_corpus_consistency.py — בודק: 1. case_law WHERE source_kind='external_upload' AND case_number ~ '^ערר|^ARAR'. 2. case_law WHERE source_kind='internal_committee' AND chair_name IS NULL. הרצה דרך cron או scheduled task ב-Paperclip.", "details": "scripts/audit_corpus_consistency.py — בודק: 1. case_law WHERE source_kind='external_upload' AND case_number ~ '^ערר|^ARAR'. 2. case_law WHERE source_kind='internal_committee' AND chair_name IS NULL. הרצה דרך cron או scheduled task ב-Paperclip.",
@@ -1861,7 +1861,7 @@
"updatedAt": "2026-05-26T11:27:09.039154Z" "updatedAt": "2026-05-26T11:27:09.039154Z"
}, },
{ {
"id": 48, "id": "48",
"title": "[שלב C] Parent-doc retrieval (child=300, parent=1500 tokens)", "title": "[שלב C] Parent-doc retrieval (child=300, parent=1500 tokens)",
"description": "chunk_size=600 חותך חלק מהלכות ארוכות. parent-doc: חיפוש על child קטן (300 tokens), החזרת parent גדול (1500 tokens) ל-LLM context.", "description": "chunk_size=600 חותך חלק מהלכות ארוכות. parent-doc: חיפוש על child קטן (300 tokens), החזרת parent גדול (1500 tokens) ל-LLM context.",
"details": "מיגרציה DB: precedent_chunks.parent_chunk_id (FK self). chunking pipeline משתנה ל-2 רמות. retrieval: SELECT distinct parent_chunk WHERE child_chunk matches.", "details": "מיגרציה DB: precedent_chunks.parent_chunk_id (FK self). chunking pipeline משתנה ל-2 רמות. retrieval: SELECT distinct parent_chunk WHERE child_chunk matches.",
@@ -1875,7 +1875,7 @@
"updatedAt": "2026-05-26T11:27:09.039154Z" "updatedAt": "2026-05-26T11:27:09.039154Z"
}, },
{ {
"id": 49, "id": "49",
"title": "[שלב C] Multimodal backfill ל-77 רשומות שנותרו", "title": "[שלב C] Multimodal backfill ל-77 רשומות שנותרו",
"description": "כיום 40/117 precedent_image_embeddings (34%). 77 רשומות נותרו ללא image embeddings. ערך נמוך כשהמסמכים digital-native, אבל קריטי לscanned PDFs.", "description": "כיום 40/117 precedent_image_embeddings (34%). 77 רשומות נותרו ללא image embeddings. ערך נמוך כשהמסמכים digital-native, אבל קריטי לscanned PDFs.",
"details": "scripts/multimodal_backfill.py כבר קיים. להריץ עם batch size 10 כדי לא לדפוק את Voyage rate limits. אומדן: 77×~10K tokens = ~770K tokens ($10-15).", "details": "scripts/multimodal_backfill.py כבר קיים. להריץ עם batch size 10 כדי לא לדפוק את Voyage rate limits. אומדן: 77×~10K tokens = ~770K tokens ($10-15).",
@@ -1887,7 +1887,7 @@
"updatedAt": "2026-05-26T11:27:09.039154Z" "updatedAt": "2026-05-26T11:27:09.039154Z"
}, },
{ {
"id": 50, "id": "50",
"title": "[שלב C] Closed-loop retrieval feedback + ndcg dashboard", "title": "[שלב C] Closed-loop retrieval feedback + ndcg dashboard",
"description": "אין tracking של 'what was retrieved → what writer cited'. בלי זה — אי אפשר לעדכן את ה-RAG בצורה מדודה לאורך זמן.", "description": "אין tracking של 'what was retrieved → what writer cited'. בלי זה — אי אפשר לעדכן את ה-RAG בצורה מדודה לאורך זמן.",
"details": "טבלה חדשה retrieval_feedback (query, candidates_retrieved JSONB, cited_in_final_decision UUID[], created_at). hooks ב-writer לדווח. dashboard חודשי עם ndcg@10.", "details": "טבלה חדשה retrieval_feedback (query, candidates_retrieved JSONB, cited_in_final_decision UUID[], created_at). hooks ב-writer לדווח. dashboard חודשי עם ndcg@10.",
@@ -1899,7 +1899,7 @@
"updatedAt": "2026-05-26T11:27:09.039154Z" "updatedAt": "2026-05-26T11:27:09.039154Z"
}, },
{ {
"id": 51, "id": "51",
"title": "[שלב C] Halacha quality monitoring — confidence drift, alert", "title": "[שלב C] Halacha quality monitoring — confidence drift, alert",
"description": "אם prompt או model משתנה — confidence distribution יכול לזוז. בלי monitoring — דרדור איכות עובר תחת הראדר.", "description": "אם prompt או model משתנה — confidence distribution יכול לזוז. בלי monitoring — דרדור איכות עובר תחת הראדר.",
"details": "scheduled job: weekly mean confidence per practice_area. אם זז ביותר מ-0.05 — alert. dashboard ב-/halachot עם histogram.", "details": "scheduled job: weekly mean confidence per practice_area. אם זז ביותר מ-0.05 — alert. dashboard ב-/halachot עם histogram.",
@@ -1911,7 +1911,7 @@
"updatedAt": "2026-05-26T11:27:09.039154Z" "updatedAt": "2026-05-26T11:27:09.039154Z"
}, },
{ {
"id": 52, "id": "52",
"title": "[Retrieval RC-A] הוספת case_name + case_number ל-tsvector הלקסיקלי", "title": "[Retrieval RC-A] הוספת case_name + case_number ל-tsvector הלקסיקלי",
"description": "השורש האמיתי לכך שסוכן לא מאתר החלטה לפי שם (אגסי). ה-tsvector הלקסיקלי (SCHEMA_V12_SQL ב-db.py) בנוי רק מ-precedent_chunks.content ומ-halachot rule/quote/reasoning — לא משם התיק/הצד או ממספר התיק. לכן שאילתת-שם מחזירה את מי שמצטט את ההחלטה, לא את ההחלטה עצמה. לשלב את case_law.case_name + case_number באינדקס הלקסיקלי (tsvector ייעודי על case_law או setweight) כך שחיפוש לפי שם יפגע ברשומה עצמה.", "description": "השורש האמיתי לכך שסוכן לא מאתר החלטה לפי שם (אגסי). ה-tsvector הלקסיקלי (SCHEMA_V12_SQL ב-db.py) בנוי רק מ-precedent_chunks.content ומ-halachot rule/quote/reasoning — לא משם התיק/הצד או ממספר התיק. לכן שאילתת-שם מחזירה את מי שמצטט את ההחלטה, לא את ההחלטה עצמה. לשלב את case_law.case_name + case_number באינדקס הלקסיקלי (tsvector ייעודי על case_law או setweight) כך שחיפוש לפי שם יפגע ברשומה עצמה.",
"status": "done", "status": "done",
@@ -1923,7 +1923,7 @@
"updatedAt": "2026-05-30T11:05:36.307Z" "updatedAt": "2026-05-30T11:05:36.307Z"
}, },
{ {
"id": 53, "id": "53",
"title": "[Retrieval RC-B] חיפוש/רשימה מאוחדים — לא לחתוך internal_committee", "title": "[Retrieval RC-B] חיפוש/רשימה מאוחדים — לא לחתוך internal_committee",
"description": "החלטות ערר/בל\"מ שמועלות נשמרות source_kind='internal_committee'. precedent_library_list ברירת מחדל external_upload ומסתיר אותן; כלי ה-MCP precedent_library_list אפילו לא חושף פרמטר source_kind, כך שסוכן לעולם לא יכול לדפדף בהן. לחשוף source_kind/all_committees בכלי ה-MCP ובמידת הצורך לאחד את שכבת ה-list/search.", "description": "החלטות ערר/בל\"מ שמועלות נשמרות source_kind='internal_committee'. precedent_library_list ברירת מחדל external_upload ומסתיר אותן; כלי ה-MCP precedent_library_list אפילו לא חושף פרמטר source_kind, כך שסוכן לעולם לא יכול לדפדף בהן. לחשוף source_kind/all_committees בכלי ה-MCP ובמידת הצורך לאחד את שכבת ה-list/search.",
"status": "done", "status": "done",
@@ -1937,7 +1937,7 @@
"updatedAt": "2026-05-30T11:09:44.511Z" "updatedAt": "2026-05-30T11:09:44.511Z"
}, },
{ {
"id": 54, "id": "54",
"title": "[Retrieval RC-3] הנחיית סוכנים — איתור לפי שם + שני קורפוסים", "title": "[Retrieval RC-3] הנחיית סוכנים — איתור לפי שם + שני קורפוסים",
"description": "לעדכן הנחיות legal-analyst/researcher/writer: לאיתור החלטה ספציפית לפי שם להוסיף מונחי תוכן או מספר תיק, ולחפש בשני הקורפוסים (search_internal_decisions + search_precedent_library) לפני שמסיקים 'לא קיים בקורפוס'. כולל יצירת missing_precedent רק אחרי חיפוש כפול.", "description": "לעדכן הנחיות legal-analyst/researcher/writer: לאיתור החלטה ספציפית לפי שם להוסיף מונחי תוכן או מספר תיק, ולחפש בשני הקורפוסים (search_internal_decisions + search_precedent_library) לפני שמסיקים 'לא קיים בקורפוס'. כולל יצירת missing_precedent רק אחרי חיפוש כפול.",
"status": "done", "status": "done",
@@ -1951,7 +1951,7 @@
"updatedAt": "2026-05-30T11:12:44.727Z" "updatedAt": "2026-05-30T11:12:44.727Z"
}, },
{ {
"id": 55, "id": "55",
"title": "[Retrieval RC-4] תיקון chunking — פרגמנטים זעירים", "title": "[Retrieval RC-4] תיקון chunking — פרגמנטים זעירים",
"description": "בתוצאות החיפוש מופיעים chunks של מילה-שתיים ('דיון','דיון וב','סיכום ו') כתוצאות מובילות. מציפים תוצאות ומורידים דירוג תוכן אמיתי. לחקור את chunker.py (פיצול לפי כותרת-סעיף שיוצר chunks ריקים) ולתקן: מינימום אורך chunk / מיזוג כותרת לגוף.", "description": "בתוצאות החיפוש מופיעים chunks של מילה-שתיים ('דיון','דיון וב','סיכום ו') כתוצאות מובילות. מציפים תוצאות ומורידים דירוג תוכן אמיתי. לחקור את chunker.py (פיצול לפי כותרת-סעיף שיוצר chunks ריקים) ולתקן: מינימום אורך chunk / מיזוג כותרת לגוף.",
"status": "done", "status": "done",
@@ -1965,7 +1965,7 @@
"updatedAt": "2026-05-30T11:19:23.923Z" "updatedAt": "2026-05-30T11:19:23.923Z"
}, },
{ {
"id": 56, "id": "56",
"title": "[Retrieval finding] halacha_filters לא מסננים source_kind — דליפה חוצת-קורפוסים", "title": "[Retrieval finding] halacha_filters לא מסננים source_kind — דליפה חוצת-קורפוסים",
"description": "התגלה תוך כדי משימה 53. ב-search_precedent_library_semantic וב-search_precedent_library_lexical (db.py): chunk_filters כוללים cl.source_kind=$sk אבל halacha_filters כוללים רק review_status. תוצאה: search_precedent_library(external) מחזיר גם הלכות internal_committee, ו-search_internal_decisions(internal) מחזיר גם הלכות external. אי-עקביות: chunks מסוננים, halachot לא. כרגע זה דווקא מסייע למציאוּת (לכן לא רגרסיה), אבל לא עקבי. דורש החלטת מדיניות: או לסנן halachot גם לפי source_kind (עקבי, אך 'מסתיר' שכבות), או להשאיר מאוחד במכוון + לתעד. אם משאירים מאוחד — לעדכן docstrings של שני הכלים שזה לא 'corpus נפרד'.", "description": "התגלה תוך כדי משימה 53. ב-search_precedent_library_semantic וב-search_precedent_library_lexical (db.py): chunk_filters כוללים cl.source_kind=$sk אבל halacha_filters כוללים רק review_status. תוצאה: search_precedent_library(external) מחזיר גם הלכות internal_committee, ו-search_internal_decisions(internal) מחזיר גם הלכות external. אי-עקביות: chunks מסוננים, halachot לא. כרגע זה דווקא מסייע למציאוּת (לכן לא רגרסיה), אבל לא עקבי. דורש החלטת מדיניות: או לסנן halachot גם לפי source_kind (עקבי, אך 'מסתיר' שכבות), או להשאיר מאוחד במכוון + לתעד. אם משאירים מאוחד — לעדכן docstrings של שני הכלים שזה לא 'corpus נפרד'.",
"status": "cancelled", "status": "cancelled",
@@ -1977,7 +1977,7 @@
"updatedAt": "2026-05-30T11:09:30.257989+00:00" "updatedAt": "2026-05-30T11:09:30.257989+00:00"
}, },
{ {
"id": 57, "id": "57",
"title": "[Retrieval #55 follow-up] re-chunk+re-embed של פסיקה שהוטמעה לפני תיקון ה-chunker", "title": "[Retrieval #55 follow-up] re-chunk+re-embed של פסיקה שהוטמעה לפני תיקון ה-chunker",
"description": "משימה 55 תיקנה את ה-chunker (עיגון כותרות + מיזוג) ומסננת את 484 הפרגמנטים בזמן query. הרמדיאציה המלאה: re-chunk מ-full_text השמור (ללא re-OCR — תואם feedback_no_reocr_retrofit) + re-embed, כדי שהתוכן יהיה נכון ולא רק מוסתר. נדחה כי זו מיגרציית-נתונים עם עלות Voyage API על ~13+ תיקים — דורש אישור עלות מ-chaim לפני הרצה. לבדוק כמה תיקים מושפעים (יש להם chunk<50) ולהריץ בקבוצות.", "description": "משימה 55 תיקנה את ה-chunker (עיגון כותרות + מיזוג) ומסננת את 484 הפרגמנטים בזמן query. הרמדיאציה המלאה: re-chunk מ-full_text השמור (ללא re-OCR — תואם feedback_no_reocr_retrofit) + re-embed, כדי שהתוכן יהיה נכון ולא רק מוסתר. נדחה כי זו מיגרציית-נתונים עם עלות Voyage API על ~13+ תיקים — דורש אישור עלות מ-chaim לפני הרצה. לבדוק כמה תיקים מושפעים (יש להם chunk<50) ולהריץ בקבוצות.",
"status": "done", "status": "done",
@@ -1991,7 +1991,7 @@
"updatedAt": "2026-06-03T07:56:21.688Z" "updatedAt": "2026-06-03T07:56:21.688Z"
}, },
{ {
"id": 58, "id": "58",
"title": "[Case access] get_case_by_number שביר לפורמט — סוכן 'עיוור' למסמכי תיק", "title": "[Case access] get_case_by_number שביר לפורמט — סוכן 'עיוור' למסמכי תיק",
"description": "דווח ע\"י chaim: סוכן כתב שחסרים מסמכי תיק כי document_list החזיר ריק, אך המסמכים קיימים. שורש: get_case_by_number (db.py) עושה 'WHERE case_number=$1' התאמה מדויקת בלבד. אומת — 8137-24 מחזיר 9 מסמכים, אבל 8137/24 / 'ערר 8137-24' / רווחים / zero-pad → 'תיק לא נמצא'. הסוכן מקבל את המספר בפורמט שונה (כותרת issue, לוכסן, תחילית ערר/בל\"מ) → התאמה נכשלת → 'אין מסמכים'. משפיע על כל הכלים מבוססי case_number (document_list, extract_references, search_case_documents, get_claims, draft, וכו'). תיקון: נורמליזציה (strip prefix לתחילת ספרה, trim, '/'→'-') + fallback בשאילתה. תיקון נקודה-אחת מתקן את כל הכלים.", "description": "דווח ע\"י chaim: סוכן כתב שחסרים מסמכי תיק כי document_list החזיר ריק, אך המסמכים קיימים. שורש: get_case_by_number (db.py) עושה 'WHERE case_number=$1' התאמה מדויקת בלבד. אומת — 8137-24 מחזיר 9 מסמכים, אבל 8137/24 / 'ערר 8137-24' / רווחים / zero-pad → 'תיק לא נמצא'. הסוכן מקבל את המספר בפורמט שונה (כותרת issue, לוכסן, תחילית ערר/בל\"מ) → התאמה נכשלת → 'אין מסמכים'. משפיע על כל הכלים מבוססי case_number (document_list, extract_references, search_case_documents, get_claims, draft, וכו'). תיקון: נורמליזציה (strip prefix לתחילת ספרה, trim, '/'→'-') + fallback בשאילתה. תיקון נקודה-אחת מתקן את כל הכלים.",
"status": "done", "status": "done",
@@ -2003,7 +2003,7 @@
"updatedAt": "2026-05-30T11:54:34.291Z" "updatedAt": "2026-05-30T11:54:34.291Z"
}, },
{ {
"id": 59, "id": "59",
"title": "[FU-1] איחוד מסלול ה-ingest למסלול קנוני אחד", "title": "[FU-1] איחוד מסלול ה-ingest למסלול קנוני אחד",
"description": "מאחד את ingest_precedent ו-ingest_internal_decision למסלול קנוני יחיד; מבטל את האסימטריות.", "description": "מאחד את ingest_precedent ו-ingest_internal_decision למסלול קנוני יחיד; מבטל את האסימטריות.",
"details": "מכסה GAP-01,02,04,05. מספק INV-ING1/ING3/G2/G4. severity: Critical. סוג: קוד. יסוד — FU-2/FU-3/FU-7 תלויים בו. מקור: docs/spec/gap-audit.md + 01-ingest.md.", "details": "מכסה GAP-01,02,04,05. מספק INV-ING1/ING3/G2/G4. severity: Critical. סוג: קוד. יסוד — FU-2/FU-3/FU-7 תלויים בו. מקור: docs/spec/gap-audit.md + 01-ingest.md.",
@@ -2056,7 +2056,7 @@
"updatedAt": "2026-05-30T17:37:34.741136+00:00" "updatedAt": "2026-05-30T17:37:34.741136+00:00"
}, },
{ {
"id": 60, "id": "60",
"title": "[FU-2a] ingest idempotent + נרמול-בכתיבה + searchable (pure-code)", "title": "[FU-2a] ingest idempotent + נרמול-בכתיבה + searchable (pure-code)",
"description": "upsert ON CONFLICT על מפתח קנוני + נרמול case_number בכתיבה (type-aware) + דגל searchable מפורש. אפס מיגרציית-נתונים.", "description": "upsert ON CONFLICT על מפתח קנוני + נרמול case_number בכתיבה (type-aware) + דגל searchable מפורש. אפס מיגרציית-נתונים.",
"details": "מכסה GAP-03,06,13. מספק INV-ING2/G3/G1/ID1/DM1. severity: Critical. סוג: pure-code (schema-additive). תלוי ב-FU-1 (#59). FU-2b (#67) מטפל ב-GAP-07/08 בנפרד.", "details": "מכסה GAP-03,06,13. מספק INV-ING2/G3/G1/ID1/DM1. severity: Critical. סוג: pure-code (schema-additive). תלוי ב-FU-1 (#59). FU-2b (#67) מטפל ב-GAP-07/08 בנפרד.",
@@ -2101,7 +2101,7 @@
"updatedAt": "2026-05-30T17:37:34.741136+00:00" "updatedAt": "2026-05-30T17:37:34.741136+00:00"
}, },
{ {
"id": 61, "id": "61",
"title": "[FU-3] re-index בשינוי תוכן", "title": "[FU-3] re-index בשינוי תוכן",
"description": "embedding מתעדכן אוטומטית בשינוי תוכן (כיום trigger-dependent, לא GENERATED).", "description": "embedding מתעדכן אוטומטית בשינוי תוכן (כיום trigger-dependent, לא GENERATED).",
"details": "מכסה GAP-09. מספק INV-DM3/G6. severity: High. סוג: קוד + מיגרציה (re-embed). תלוי ב-FU-1.", "details": "מכסה GAP-09. מספק INV-DM3/G6. severity: High. סוג: קוד + מיגרציה (re-embed). תלוי ב-FU-1.",
@@ -2138,7 +2138,7 @@
"updatedAt": "2026-05-30T17:37:34.741136+00:00" "updatedAt": "2026-05-30T17:37:34.741136+00:00"
}, },
{ {
"id": 62, "id": "62",
"title": "[FU-4] בידוד-קורפוס בכל מסלול query", "title": "[FU-4] בידוד-קורפוס בכל מסלול query",
"description": "אכיפת source_kind בכל פילטר (כולל halacha_filters); חסימת חיפוש ללא תחום.", "description": "אכיפת source_kind בכל פילטר (כולל halacha_filters); חסימת חיפוש ללא תחום.",
"details": "מכסה GAP-10,12. מספק INV-RET1/G5. severity: Critical. סוג: קוד. ללא תלות — דחוף (דליפה פעילה).", "details": "מכסה GAP-10,12. מספק INV-RET1/G5. severity: Critical. סוג: קוד. ללא תלות — דחוף (דליפה פעילה).",
@@ -2173,7 +2173,7 @@
"updatedAt": "2026-05-30T18:30:11.503Z" "updatedAt": "2026-05-30T18:30:11.503Z"
}, },
{ {
"id": 63, "id": "63",
"title": "[FU-5] eval-harness + נראות backlog", "title": "[FU-5] eval-harness + נראות backlog",
"description": "מדידת precision/recall על gold-set + חשיפת backlog הלכות בבדיקת-בריאות.", "description": "מדידת precision/recall על gold-set + חשיפת backlog הלכות בבדיקת-בריאות.",
"details": "מכסה GAP-11,14. מספק INV-RET4/G8/QA1/G10. severity: High. סוג: קוד + החלטת-יו\"ר (בניית gold-set). תלוי ב-FU-2. | DONE 2026-05-31: Unit B (GAP-14) — halacha_backlog נחשף ב-metrics.get_dashboard + /api/system/diagnostics (גילה 178 pending_review מתוך 1552, הישן 3.5.26). Unit A (GAP-11) — scripts/eval_gold_bootstrap.py (citations+known_item) + scripts/eval_retrieval.py (P/R/MRR/nDCG@5,10, self-test, baseline+config). gold-set=77 known-item queries (citation-source ריק: 0 ציטוטים בהחלטות). baseline בייצור: R@10=0.987 MRR=0.837; ממצא: MULTIMODAL=true מוריד known-item recall קלות (relevant ל-#15). gold-set=provisional עד סקירת דפנה (chair-gate; הדומיין). spec: docs/superpowers/specs/2026-05-31-fu5-eval-harness-design.md", "details": "מכסה GAP-11,14. מספק INV-RET4/G8/QA1/G10. severity: High. סוג: קוד + החלטת-יו\"ר (בניית gold-set). תלוי ב-FU-2. | DONE 2026-05-31: Unit B (GAP-14) — halacha_backlog נחשף ב-metrics.get_dashboard + /api/system/diagnostics (גילה 178 pending_review מתוך 1552, הישן 3.5.26). Unit A (GAP-11) — scripts/eval_gold_bootstrap.py (citations+known_item) + scripts/eval_retrieval.py (P/R/MRR/nDCG@5,10, self-test, baseline+config). gold-set=77 known-item queries (citation-source ריק: 0 ציטוטים בהחלטות). baseline בייצור: R@10=0.987 MRR=0.837; ממצא: MULTIMODAL=true מוריד known-item recall קלות (relevant ל-#15). gold-set=provisional עד סקירת דפנה (chair-gate; הדומיין). spec: docs/superpowers/specs/2026-05-31-fu5-eval-harness-design.md",
@@ -2210,7 +2210,7 @@
"updatedAt": "2026-05-31T14:55:38.295Z" "updatedAt": "2026-05-31T14:55:38.295Z"
}, },
{ {
"id": 64, "id": "64",
"title": "[FU-6] שערי-QA נאכפים בקוד", "title": "[FU-6] שערי-QA נאכפים בקוד",
"description": "export חוסם בקוד על כשל-QA קריטי; תיקון neutral_background critical-but-passes.", "description": "export חוסם בקוד על כשל-QA קריטי; תיקון neutral_background critical-but-passes.",
"details": "מכסה GAP-15,16. מספק INV-QA3/EX3/G10. severity: Critical. סוג: קוד. ללא תלות — מהיר.", "details": "מכסה GAP-15,16. מספק INV-QA3/EX3/G10. severity: Critical. סוג: קוד. ללא תלות — מהיר.",
@@ -2245,7 +2245,7 @@
"updatedAt": "2026-05-30T18:30:11.521Z" "updatedAt": "2026-05-30T18:30:11.521Z"
}, },
{ {
"id": 65, "id": "65",
"title": "[FU-7] audit-trail + provenance", "title": "[FU-7] audit-trail + provenance",
"description": "כתיבת audit_log בכל פעולה; קישור בלוק→קטעי-מקור; סנכרון DB אחרי עריכה; אימות citation→corpus.", "description": "כתיבת audit_log בכל פעולה; קישור בלוק→קטעי-מקור; סנכרון DB אחרי עריכה; אימות citation→corpus.",
"details": "מכסה GAP-17,18,19,20. מספק INV-AUD1/2/3/EX1/G9. severity: High. סוג: קוד + backfill קל. תלוי ב-FU-1. (זרע לתת-פרויקט 3/audit-provenance.)", "details": "מכסה GAP-17,18,19,20. מספק INV-AUD1/2/3/EX1/G9. severity: High. סוג: קוד + backfill קל. תלוי ב-FU-1. (זרע לתת-פרויקט 3/audit-provenance.)",
@@ -2300,7 +2300,7 @@
"updatedAt": "2026-05-30T17:37:34.741136+00:00" "updatedAt": "2026-05-30T17:37:34.741136+00:00"
}, },
{ {
"id": 66, "id": "66",
"title": "[FU-8a] מחסומי-תהליך→קוד: enforce sync + Paperclip-access guard (pure-code)", "title": "[FU-8a] מחסומי-תהליך→קוד: enforce sync + Paperclip-access guard (pure-code)",
"description": "אכיפת cross-company sync (--verify יוצא non-zero על drift; adapter_type mismatch = drift לא silent skip) + fitness-function שחוסם גישת-Paperclip לא-מאושרת (raw http / INSERT agent_wakeup_requests).", "description": "אכיפת cross-company sync (--verify יוצא non-zero על drift; adapter_type mismatch = drift לא silent skip) + fitness-function שחוסם גישת-Paperclip לא-מאושרת (raw http / INSERT agent_wakeup_requests).",
"details": "מכסה GAP-21,22. מספק INV-MC1/INT1/INT3. severity: High. סוג: pure-code. GAP-23 (חיווט ספ→סוכנים) הופרד ל-#69 (משנה התנהגות-ייצור). | DONE 2026-05-31 PR#16: --verify drift-gate (exit≠0) + Paperclip-access fitness function. GAP-23→#69.", "details": "מכסה GAP-21,22. מספק INV-MC1/INT1/INT3. severity: High. סוג: pure-code. GAP-23 (חיווט ספ→סוכנים) הופרד ל-#69 (משנה התנהגות-ייצור). | DONE 2026-05-31 PR#16: --verify drift-gate (exit≠0) + Paperclip-access fitness function. GAP-23→#69.",
@@ -2333,7 +2333,7 @@
"updatedAt": "2026-05-30T17:37:34.741136+00:00" "updatedAt": "2026-05-30T17:37:34.741136+00:00"
}, },
{ {
"id": 67, "id": "67",
"title": "[FU-2b] תיאום מזהים קנוניים + ניקוי ציטוט-כמזהה (data-migration, chair)", "title": "[FU-2b] תיאום מזהים קנוניים + ניקוי ציטוט-כמזהה (data-migration, chair)",
"description": "מיגרציה חד-פעמית של ~52+ רשומות case_law עם ציטוט-מלא ב-case_number → מספר-בסיס מנורמל; dedup (למשל 8047-23 כפול); הכרעת צורה קנונית per-record.", "description": "מיגרציה חד-פעמית של ~52+ רשומות case_law עם ציטוט-מלא ב-case_number → מספר-בסיס מנורמל; dedup (למשל 8047-23 כפול); הכרעת צורה קנונית per-record.",
"details": "מכסה GAP-07,08. מספק INV-ID1/ID2/DM2. severity: High. סוג: DATA-MIGRATION + chair-decision (מספר רשמי per-record, with-month canonical). דורש: גיבוי, dry-run, סקירת-יו\"ר, reversibility. תלוי ב-FU-2a (#60, לצורך פונקציית הנרמול). מקור: בדיקת DB 2026-05-30 — internal_committee ~52/56 ציטוט-מלא, ≥1 dup (8047-23), 1 בלתי-פתיר (ערר אדלר/cited_only). | APPLIED 2026-05-31: 55 internal rows normalized to bare case_number; corrupted 8047 dup (מטודלה) deleted; חלוואני 1028-20 proc→בל\"מ. Backups in data/audit/fu2b-*. external→#68.", "details": "מכסה GAP-07,08. מספק INV-ID1/ID2/DM2. severity: High. סוג: DATA-MIGRATION + chair-decision (מספר רשמי per-record, with-month canonical). דורש: גיבוי, dry-run, סקירת-יו\"ר, reversibility. תלוי ב-FU-2a (#60, לצורך פונקציית הנרמול). מקור: בדיקת DB 2026-05-30 — internal_committee ~52/56 ציטוט-מלא, ≥1 dup (8047-23), 1 בלתי-פתיר (ערר אדלר/cited_only). | APPLIED 2026-05-31: 55 internal rows normalized to bare case_number; corrupted 8047 dup (מטודלה) deleted; חלוואני 1028-20 proc→בל\"מ. Backups in data/audit/fu2b-*. external→#68.",
@@ -2367,7 +2367,7 @@
] ]
}, },
{ {
"id": 68, "id": "68",
"title": "[FU-2c] תיאום מזהי external_upload (case_number↔citation_formatted)", "title": "[FU-2c] תיאום מזהי external_upload (case_number↔citation_formatted)",
"description": "פסיקה חיצונית: case_number מחזיק ציטוט מלא; citation_formatted לא תמיד תואם (נמצאה סתירה 25226-04-25 מול 1975/24). דורש קודם תיקון סתירות citation_formatted↔case_number, ואז הכרעה אם docket מחולץ הופך ל-case_number או שהציטוט נשאר המזהה.", "description": "פסיקה חיצונית: case_number מחזיק ציטוט מלא; citation_formatted לא תמיד תואם (נמצאה סתירה 25226-04-25 מול 1975/24). דורש קודם תיקון סתירות citation_formatted↔case_number, ואז הכרעה אם docket מחולץ הופך ל-case_number או שהציטוט נשאר המזהה.",
"details": "מקור: בדיקת DB 2026-05-31 (FU-2b scoping). 22/24 external עם ציטוט ב-case_number; citation_formatted נוצר בנפרד (LLM) ולא אמין כ-ground truth. שונה מ-internal (שם 0 סתירות). דורש סקירת-יו\"ר פר-רשומה. severity: Medium. סוג: data-migration + chair. תלוי בהחלטה: האם זהות external = ציטוט (FU-1) או docket מנורמל (INV-ID2). מופרד מ-FU-2b לפי החלטת chaim 2026-05-31. | APPLIED 2026-05-31: chair decision Option A (designator+docket, '/' kept). 21 external_upload case_number normalized + 3 citation_formatted fixed (D=לויתן/קלמנוביץ consolidated→25226-04-25; 2×C empty-citation composed). אהוד שפר עע\"מ 317/10 deferred (cross-source dup w/ cited_only → #70). collision-guard: 0. Backups data/audit/fu2c-backup-20260531T140943Z.csv. cited_only(49)→#70.", "details": "מקור: בדיקת DB 2026-05-31 (FU-2b scoping). 22/24 external עם ציטוט ב-case_number; citation_formatted נוצר בנפרד (LLM) ולא אמין כ-ground truth. שונה מ-internal (שם 0 סתירות). דורש סקירת-יו\"ר פר-רשומה. severity: Medium. סוג: data-migration + chair. תלוי בהחלטה: האם זהות external = ציטוט (FU-1) או docket מנורמל (INV-ID2). מופרד מ-FU-2b לפי החלטת chaim 2026-05-31. | APPLIED 2026-05-31: chair decision Option A (designator+docket, '/' kept). 21 external_upload case_number normalized + 3 citation_formatted fixed (D=לויתן/קלמנוביץ consolidated→25226-04-25; 2×C empty-citation composed). אהוד שפר עע\"מ 317/10 deferred (cross-source dup w/ cited_only → #70). collision-guard: 0. Backups data/audit/fu2c-backup-20260531T140943Z.csv. cited_only(49)→#70.",
@@ -2381,7 +2381,7 @@
"updatedAt": "2026-05-31T14:11:37.689Z" "updatedAt": "2026-05-31T14:11:37.689Z"
}, },
{ {
"id": 69, "id": "69",
"title": "[FU-8b] חיווט הספ לסוכני-Paperclip (GAP-23)", "title": "[FU-8b] חיווט הספ לסוכני-Paperclip (GAP-23)",
"description": "HEARTBEAT/agent docs דורשים קריאת 00-constitution + ספ-תחום רלוונטי לפני פעולה. משנה התנהגות-סוכן בייצור; prereq לתת-פרויקט 5.", "description": "HEARTBEAT/agent docs דורשים קריאת 00-constitution + ספ-תחום רלוונטי לפני פעולה. משנה התנהגות-סוכן בייצור; prereq לתת-פרויקט 5.",
"details": "מכסה GAP-23. מספק INV-AG1. severity: High. סוג: docs+chair-decision. דורש ספ יציב (קיים) + החלטה על שילוב בזרימת-הסוכנים. הופרד מ-FU-8a לפי החלטת chaim 2026-05-31 (GAP-21/22 = pure-code עכשיו).", "details": "מכסה GAP-23. מספק INV-AG1. severity: High. סוג: docs+chair-decision. דורש ספ יציב (קיים) + החלטה על שילוב בזרימת-הסוכנים. הופרד מ-FU-8a לפי החלטת chaim 2026-05-31 (GAP-21/22 = pure-code עכשיו).",
@@ -2407,7 +2407,7 @@
"updatedAt": "2026-05-31T16:01:42.032Z" "updatedAt": "2026-05-31T16:01:42.032Z"
}, },
{ {
"id": 70, "id": "70",
"title": "[FU-2c-b] תיאום + dedup של cited_only (49 רשומות) + אהוד שפר cross-source", "title": "[FU-2c-b] תיאום + dedup של cited_only (49 רשומות) + אהוד שפר cross-source",
"description": "המשך ל-FU-2c (#68). ה-dry-run של תיאום-המזהים החיצוני חשף 49 רשומות source_kind='cited_only' (הפניות-ציטוט שחולצו מהחלטות) שלא היו בהיקף #68. דורשות נרמול נפרד: צורות-ועדה כמו 'ערר 1093-19' (NNNN-NN) שה-extractor הנוכחי לא תופס (NO_DOCKET), 'בש\"א 2487-14', dups, ו-'ערר אדלר' בלתי-פתיר (ללא מספר). בנוסף: dedup חוצה-source של אהוד שפר — external_upload 'עע\"מ 317/10 אהוד שפר' מול cited_only קיים 'עע\"מ 317/10' (אותו תיק; ה-collision-guard מנע התנגשות ב-uq_case_law_external_number, ה-external_upload נשאר עם case_number מנופח עד הכרעה).", "description": "המשך ל-FU-2c (#68). ה-dry-run של תיאום-המזהים החיצוני חשף 49 רשומות source_kind='cited_only' (הפניות-ציטוט שחולצו מהחלטות) שלא היו בהיקף #68. דורשות נרמול נפרד: צורות-ועדה כמו 'ערר 1093-19' (NNNN-NN) שה-extractor הנוכחי לא תופס (NO_DOCKET), 'בש\"א 2487-14', dups, ו-'ערר אדלר' בלתי-פתיר (ללא מספר). בנוסף: dedup חוצה-source של אהוד שפר — external_upload 'עע\"מ 317/10 אהוד שפר' מול cited_only קיים 'עע\"מ 317/10' (אותו תיק; ה-collision-guard מנע התנגשות ב-uq_case_law_external_number, ה-external_upload נשאר עם case_number מנופח עד הכרעה).",
"details": "[2026-06-03] נרמול מבוסס-מחקר (4 מקורות: ECLI work-level id, Akoma Ntoso FRBR Work/Manifestation, ELI canonical+alias, OpenCitations OMID + Christen data-matching). מדיניות: צורה קנונית אחת + alias; cited_only stub = אותו Work כמו ה-doc → merge על התאמה-מדויקת בלבד; un-resolvable = display+flag, לא למחוק; merge = re-point edges + dedup, שמרני (false-merge בגרף-ציטוט יקר). בוצע: 46 רשומות cited_only סווגו; 3 תיקונים מכניים-דטרמיניסטיים הוחלו (ערר \\n316/10→ערר 316/10; עע\"מ65/13→עע\"מ 65/13; עע\"מ9057/09→עע\"מ 9057/09). 0 malformed (whitespace/no-space) נותרו. **נותר לשיקול יו\"ר (לא ננחש, לפי המשמר)**: (1) 2 garbled — 'ערר 1078/0724' (4a38c202), 'ערר 1083/0724' (6682f9cb); (2) 'ערר אדלר' (863a7bf8) ללא docket → keep+flag; (3) combined 'ערר (ירושלים) 1078+1083/24' (e7f6fd06) → פיצול ל-1078/24+1083/24 מתנגש עם stub קיים 'ערר 1083/24' → entity-resolution ידני. תוספת קוד עתידית: טיפול '+' ב-citation_extractor. הדדאפ הקודם (shafer + stub cleanup) כבר הושלם. אלה chair-domain — לא הכרעת-מהנדס. [2026-06-03 סגירה]: בדיקת-קשתות חשפה ש-4 ה'דו-משמעיים' (+11 נוספים) הם stubs **יתומים מתים** — 0 קשתות בכל 5 מנגנוני-הציטוט, 0 full_text, 0 הלכות, 0 chunks/embeddings. כלומר ניקוי טכני, לא שיפוט-יו\"ר (OpenCitations שומר ישות חסרת-מזהה רק אם מצוטטת — אלה לא). נמחקו 15 יתומים (cited_only 46→31), גיבוי data/audit/fu2b-orphan-stub-cleanup-20260603T093741Z.json. 0 malformed/יתומים נותרו; כל 31 הנותרים מצוטטים. forward-edge ידוע (לא חוסם, ללא משימה): טיפול '+' בציטוט-משולב ב-citation_extractor אם יחזור בחילוץ עתידי. #70 done.", "details": "[2026-06-03] נרמול מבוסס-מחקר (4 מקורות: ECLI work-level id, Akoma Ntoso FRBR Work/Manifestation, ELI canonical+alias, OpenCitations OMID + Christen data-matching). מדיניות: צורה קנונית אחת + alias; cited_only stub = אותו Work כמו ה-doc → merge על התאמה-מדויקת בלבד; un-resolvable = display+flag, לא למחוק; merge = re-point edges + dedup, שמרני (false-merge בגרף-ציטוט יקר). בוצע: 46 רשומות cited_only סווגו; 3 תיקונים מכניים-דטרמיניסטיים הוחלו (ערר \\n316/10→ערר 316/10; עע\"מ65/13→עע\"מ 65/13; עע\"מ9057/09→עע\"מ 9057/09). 0 malformed (whitespace/no-space) נותרו. **נותר לשיקול יו\"ר (לא ננחש, לפי המשמר)**: (1) 2 garbled — 'ערר 1078/0724' (4a38c202), 'ערר 1083/0724' (6682f9cb); (2) 'ערר אדלר' (863a7bf8) ללא docket → keep+flag; (3) combined 'ערר (ירושלים) 1078+1083/24' (e7f6fd06) → פיצול ל-1078/24+1083/24 מתנגש עם stub קיים 'ערר 1083/24' → entity-resolution ידני. תוספת קוד עתידית: טיפול '+' ב-citation_extractor. הדדאפ הקודם (shafer + stub cleanup) כבר הושלם. אלה chair-domain — לא הכרעת-מהנדס. [2026-06-03 סגירה]: בדיקת-קשתות חשפה ש-4 ה'דו-משמעיים' (+11 נוספים) הם stubs **יתומים מתים** — 0 קשתות בכל 5 מנגנוני-הציטוט, 0 full_text, 0 הלכות, 0 chunks/embeddings. כלומר ניקוי טכני, לא שיפוט-יו\"ר (OpenCitations שומר ישות חסרת-מזהה רק אם מצוטטת — אלה לא). נמחקו 15 יתומים (cited_only 46→31), גיבוי data/audit/fu2b-orphan-stub-cleanup-20260603T093741Z.json. 0 malformed/יתומים נותרו; כל 31 הנותרים מצוטטים. forward-edge ידוע (לא חוסם, ללא משימה): טיפול '+' בציטוט-משולב ב-citation_extractor אם יחזור בחילוץ עתידי. #70 done.",
@@ -2421,7 +2421,7 @@
"updatedAt": "2026-06-03T00:00:00.000Z" "updatedAt": "2026-06-03T00:00:00.000Z"
}, },
{ {
"id": 71, "id": "71",
"title": "[FU-5 follow-up] כוונון עומק-אחזור/rerank — recall רב-תקדימי לסוגיות רחבות", "title": "[FU-5 follow-up] כוונון עומק-אחזור/rerank — recall רב-תקדימי לסוגיות רחבות",
"description": "נפתר ע\"י תיקון ה-weight של #15 (multimodal 0.5→0.65). מדידה 2026-06-03: כל 11 השאילתות הרב-תקדים/יו\"ר מחזירות את כל התקדימים הרלוונטיים ב-top-10 (רובם top-6; גרוע ביותר rank 9). השאילתות החלשות מהבייסליין (S2 הבית-שמעוני@16, S4 ב.דייניש@15, S7@15, S8) כולן תוקנו. recall@10≈1.0.", "description": "נפתר ע\"י תיקון ה-weight של #15 (multimodal 0.5→0.65). מדידה 2026-06-03: כל 11 השאילתות הרב-תקדים/יו\"ר מחזירות את כל התקדימים הרלוונטיים ב-top-10 (רובם top-6; גרוע ביותר rank 9). השאילתות החלשות מהבייסליין (S2 הבית-שמעוני@16, S4 ב.דייניש@15, S7@15, S8) כולן תוקנו. recall@10≈1.0.",
"details": "החלטה מבוססת-מדידה+מחקר (6 מקורות: Cormack RRF, Drowning-in-Documents 2411.11767, ReFIT, MMR, Elastic, Pinecone). המחקר המליץ להפעיל rerank (fetch_k=50,return_k=10); בדקתי אמפירית — VOYAGE_RERANK_ENABLED=true דווקא הזיק: nDCG@5 0.879 מול 0.960, MRR 0.867 מול 0.954, R@5 0.966 מול 0.994 (כל המדדים שליליים). הסיבה: recall כבר רווי, וה-cross-encoder הכללי מוריד את ההתאמה המדויקת ב-known-item. **המדידה גוברת על התיאוריה — לא מפעילים rerank, לא מעלים limit, RRF_K=60 נשאר.** אין שינוי-קוד נדרש.", "details": "החלטה מבוססת-מדידה+מחקר (6 מקורות: Cormack RRF, Drowning-in-Documents 2411.11767, ReFIT, MMR, Elastic, Pinecone). המחקר המליץ להפעיל rerank (fetch_k=50,return_k=10); בדקתי אמפירית — VOYAGE_RERANK_ENABLED=true דווקא הזיק: nDCG@5 0.879 מול 0.960, MRR 0.867 מול 0.954, R@5 0.966 מול 0.994 (כל המדדים שליליים). הסיבה: recall כבר רווי, וה-cross-encoder הכללי מוריד את ההתאמה המדויקת ב-known-item. **המדידה גוברת על התיאוריה — לא מפעילים rerank, לא מעלים limit, RRF_K=60 נשאר.** אין שינוי-קוד נדרש.",
@@ -2435,7 +2435,7 @@
"updatedAt": "2026-06-03T00:00:00.000Z" "updatedAt": "2026-06-03T00:00:00.000Z"
}, },
{ {
"id": 72, "id": "72",
"title": "[ops] MCP 'No such tool' תחת עומס חילוץ opus-4-8@xhigh — timeout ב-handshake", "title": "[ops] MCP 'No such tool' תחת עומס חילוץ opus-4-8@xhigh — timeout ב-handshake",
"description": "בריצת CMPA-71 (חילוץ הלכות 9002-24, סוכן עוזר משפטי) שרת ה-legal-ai MCP לא נטען — כל קריאות mcp__legal-ai__* החזירו 'No such tool available' אחרי 3 ניסיונות+המתנות; הסוכן עשה fallback ל-.venv ישיר (לפי legal-ceo.md) והחילוץ הצליח על claude-opus-4-8@xhigh.", "description": "בריצת CMPA-71 (חילוץ הלכות 9002-24, סוכן עוזר משפטי) שרת ה-legal-ai MCP לא נטען — כל קריאות mcp__legal-ai__* החזירו 'No such tool available' אחרי 3 ניסיונות+המתנות; הסוכן עשה fallback ל-.venv ישיר (לפי legal-ceo.md) והחילוץ הצליח על claude-opus-4-8@xhigh.",
"status": "done", "status": "done",
@@ -2446,7 +2446,7 @@
"subtasks": [] "subtasks": []
}, },
{ {
"id": 73, "id": "73",
"title": "החלטת ועדת ערר: ברירת מחדל is_binding=false (יישור דוקטרינרי)", "title": "החלטת ועדת ערר: ברירת מחדל is_binding=false (יישור דוקטרינרי)",
"description": "כשמעלים החלטת ועדת ערר דרך מסך העלאת הפסיקה (precedent-upload-sheet, isCommittee=true), הצ'קבוקס 'הלכה מחייבת' (is_binding) כברירת מחדל הוא true — כך שההלכות שמחולצות מהחלטה לא-מחייבת מתויגות rule_type='binding'. זה סותר את ההגדרה הדוקטרינרית שלנו (ועדת ערר = persuasive בלבד, לא binding כמו עליון/מנהלי). התיקון: כש-isCommittee=true ב-precedent-upload-sheet.tsx, להפוך את is_binding ל-false כברירת מחדל (או לנעול/להסתיר את הצ'קבוקס ולתייג אוטומטית persuasive). הערה חשובה: זהו תיקון יישור-דוקטרינרי בלבד — אין השפעה downstream על ranking/injection (rule_type הוא תווית תצוגה; השער הפונקציונלי האמיתי הוא review_status שדפנה שולטת בו ידנית). קבצים: web-ui/src/components/precedents/precedent-upload-sheet.tsx (useState isBinding שורה 47, isCommittee שורה 53); guard clause קיים ב-mcp-server/src/legal_mcp/services/halacha_extractor.py:229-235 שמוריד binding→persuasive רק כאשר is_binding=false.", "description": "כשמעלים החלטת ועדת ערר דרך מסך העלאת הפסיקה (precedent-upload-sheet, isCommittee=true), הצ'קבוקס 'הלכה מחייבת' (is_binding) כברירת מחדל הוא true — כך שההלכות שמחולצות מהחלטה לא-מחייבת מתויגות rule_type='binding'. זה סותר את ההגדרה הדוקטרינרית שלנו (ועדת ערר = persuasive בלבד, לא binding כמו עליון/מנהלי). התיקון: כש-isCommittee=true ב-precedent-upload-sheet.tsx, להפוך את is_binding ל-false כברירת מחדל (או לנעול/להסתיר את הצ'קבוקס ולתייג אוטומטית persuasive). הערה חשובה: זהו תיקון יישור-דוקטרינרי בלבד — אין השפעה downstream על ranking/injection (rule_type הוא תווית תצוגה; השער הפונקציונלי האמיתי הוא review_status שדפנה שולטת בו ידנית). קבצים: web-ui/src/components/precedents/precedent-upload-sheet.tsx (useState isBinding שורה 47, isCommittee שורה 53); guard clause קיים ב-mcp-server/src/legal_mcp/services/halacha_extractor.py:229-235 שמוריד binding→persuasive רק כאשר is_binding=false.",
"details": "", "details": "",
@@ -2458,7 +2458,7 @@
"updatedAt": "2026-05-31T20:41:04.160Z" "updatedAt": "2026-05-31T20:41:04.160Z"
}, },
{ {
"id": 74, "id": "74",
"title": "ניקוי רטרואקטיבי: rule_type binding→persuasive להלכות ממקור ועדת ערר", "title": "ניקוי רטרואקטיבי: rule_type binding→persuasive להלכות ממקור ועדת ערר",
"description": "המשך משימה #73 (PR #29 מנע binding חדש לועדת ערר מכאן והלאה). יש 82 הלכות קיימות ב-DB עם rule_type='binding' שמקורן (case_law) בהחלטת ועדת ערר — בסתירה לדוקטרינה (ועדת ערר = persuasive). פילוח: 75 approved + 7 pending_review. גישה #2 (שמרנית): לתקן רק את ה-binding ל-persuasive, ולהשאיר interpretive/procedural/application/obiter כמות שהם (תקינים גם לועדת ערר). הגדרת 'מקור ועדת ערר': case_law WHERE source_type='appeals_committee' OR precedent_level LIKE 'ועדת%' OR court LIKE '%ועדת%ערר%' OR court LIKE '%ועדות ערר%'. שאילתה: UPDATE halachot SET rule_type='persuasive' WHERE rule_type='binding' AND case_law_id IN (<committee case_law ids>). הערה: rule_type הוא תווית תצוגה בלבד — אין השפעה על ranking/injection (השער הפונקציונלי הוא review_status). DB: legal_ai על Postgres pgvector קונטיינר t84kegpjm5qrttd6nw7bgoxe (פורט 5433). ביצוע דרך docker exec עם trust מקומי. לגבות/לספור לפני ואחרי לאימות (צפוי: 82 שורות מושפעות, 0 binding ממקור ועדת ערר אחרי).", "description": "המשך משימה #73 (PR #29 מנע binding חדש לועדת ערר מכאן והלאה). יש 82 הלכות קיימות ב-DB עם rule_type='binding' שמקורן (case_law) בהחלטת ועדת ערר — בסתירה לדוקטרינה (ועדת ערר = persuasive). פילוח: 75 approved + 7 pending_review. גישה #2 (שמרנית): לתקן רק את ה-binding ל-persuasive, ולהשאיר interpretive/procedural/application/obiter כמות שהם (תקינים גם לועדת ערר). הגדרת 'מקור ועדת ערר': case_law WHERE source_type='appeals_committee' OR precedent_level LIKE 'ועדת%' OR court LIKE '%ועדת%ערר%' OR court LIKE '%ועדות ערר%'. שאילתה: UPDATE halachot SET rule_type='persuasive' WHERE rule_type='binding' AND case_law_id IN (<committee case_law ids>). הערה: rule_type הוא תווית תצוגה בלבד — אין השפעה על ranking/injection (השער הפונקציונלי הוא review_status). DB: legal_ai על Postgres pgvector קונטיינר t84kegpjm5qrttd6nw7bgoxe (פורט 5433). ביצוע דרך docker exec עם trust מקומי. לגבות/לספור לפני ואחרי לאימות (צפוי: 82 שורות מושפעות, 0 binding ממקור ועדת ערר אחרי).",
"details": "", "details": "",
@@ -2472,7 +2472,7 @@
"updatedAt": "2026-05-31T20:49:28.894Z" "updatedAt": "2026-05-31T20:49:28.894Z"
}, },
{ {
"id": 75, "id": "75",
"title": "[X11 Phase 2] חיווט אוטו-אישור מבוסס-ציטוט + backfill", "title": "[X11 Phase 2] חיווט אוטו-אישור מבוסס-ציטוט + backfill",
"description": "Phase 2 של citation-corroboration (X11). Phase 1 (האות) מוזג ב-PR #27. דפנה אימתה את האות ואישרה הפעלה (2026-06-01). Phase 2: (1) חיווט אוטו-אישור — הלכה corroborated (≥2 ציטוטים חיוביים בלתי-תלויים, 0 שליליים) עוברת ל-review_status='approved' עם reviewer='corroborated (…judicial citations)' (INV-COR4/G10); (2) הדחת overruled — הלכה approved שקיבלה טיפול overruled בציטוט מאוחר חוזרת לשער-היו\"ר (INV-COR2); (3) backfill על 12 התקדימים (halachot+ציטוטים-נכנסים); (4) כלי-MCP write להרצת rebuild.", "description": "Phase 2 של citation-corroboration (X11). Phase 1 (האות) מוזג ב-PR #27. דפנה אימתה את האות ואישרה הפעלה (2026-06-01). Phase 2: (1) חיווט אוטו-אישור — הלכה corroborated (≥2 ציטוטים חיוביים בלתי-תלויים, 0 שליליים) עוברת ל-review_status='approved' עם reviewer='corroborated (…judicial citations)' (INV-COR4/G10); (2) הדחת overruled — הלכה approved שקיבלה טיפול overruled בציטוט מאוחר חוזרת לשער-היו\"ר (INV-COR2); (3) backfill על 12 התקדימים (halachot+ציטוטים-נכנסים); (4) כלי-MCP write להרצת rebuild.",
"details": "דגל: HALACHA_CORROBORATION_AUTO_APPROVE (default true, env-tunable). פונקציית-הכרעה טהורה approval_action(agg, has_overruled)→'approve'/'demote'/None (unit-tested, INV-COR2/COR4). DB: approve_halacha_by_corroboration (רק על pending_review), demote_halacha_overruled (רק על approved→pending_review), list_corroboration_grouped, precedents_with_halachot_and_incoming_citations. שירות: reconcile_approvals מופעל בסוף build_for_precedent; build_all driver. backfill target=12 תקדימים (אומת 2026-06-01). נדחה ל-backlog (proposal-only, מסוכן-תוכן): enrichment של rule_statement, treatment-backfill ל-case_law_citations.citation_type. תוכנית: docs/superpowers/plans/2026-06-01-x11-citation-corroboration-phase2.md. spec: docs/spec/X11-citation-corroboration.md §4-6.", "details": "דגל: HALACHA_CORROBORATION_AUTO_APPROVE (default true, env-tunable). פונקציית-הכרעה טהורה approval_action(agg, has_overruled)→'approve'/'demote'/None (unit-tested, INV-COR2/COR4). DB: approve_halacha_by_corroboration (רק על pending_review), demote_halacha_overruled (רק על approved→pending_review), list_corroboration_grouped, precedents_with_halachot_and_incoming_citations. שירות: reconcile_approvals מופעל בסוף build_for_precedent; build_all driver. backfill target=12 תקדימים (אומת 2026-06-01). נדחה ל-backlog (proposal-only, מסוכן-תוכן): enrichment של rule_statement, treatment-backfill ל-case_law_citations.citation_type. תוכנית: docs/superpowers/plans/2026-06-01-x11-citation-corroboration-phase2.md. spec: docs/spec/X11-citation-corroboration.md §4-6.",
@@ -2484,7 +2484,7 @@
"updatedAt": "2026-06-01T04:43:40.474Z" "updatedAt": "2026-06-01T04:43:40.474Z"
}, },
{ {
"id": 76, "id": "76",
"title": "תיקון כפתור \"צור משימה\" ב-Paperclip — מאופשר אך submit חוזר בשקט", "title": "תיקון כפתור \"צור משימה\" ב-Paperclip — מאופשר אך submit חוזר בשקט",
"description": "בוטל 2026-06-03 — באג upstream של Paperclip, לא ניתן לתיקון בטוח אצלנו (ee=companyId; הכפתור מאופשר לפי כותרת בלבד אך submit דורש חברה שלא אותחלה). אומת ע\"י chaim שעובד מהקשרי-חברה רגילים. Workaround: לבחור חברה במודאל / לפתוח מתוך לוח. מסלול אמין: pc.sh POST /companies/{id}/issues. תיקון יסודי = upstream. #78 מסיר את הצורך בזרימת הפסיקה.", "description": "בוטל 2026-06-03 — באג upstream של Paperclip, לא ניתן לתיקון בטוח אצלנו (ee=companyId; הכפתור מאופשר לפי כותרת בלבד אך submit דורש חברה שלא אותחלה). אומת ע\"י chaim שעובד מהקשרי-חברה רגילים. Workaround: לבחור חברה במודאל / לפתוח מתוך לוח. מסלול אמין: pc.sh POST /companies/{id}/issues. תיקון יסודי = upstream. #78 מסיר את הצורך בזרימת הפסיקה.",
"details": "אבחנה סופית מתוך הבאנדל (index-BWGhimVr.js): ה-submit הוא `function xi(){const je=m.current.trim();if(!ee||!je||He.isPending)return;...He.mutate({...companyId:ee...})}`. `je`=כותרת (קיים), `He`=mutation. ה-guard שנכשל הוא **`ee`**, ש-`ee` משמש כ-`projects.list(ee)` וכ-`companyId:ee` במוטציה — כלומר **`ee` = מזהה החברה**. השורש: הכפתור מאופשר לפי הכותרת בלבד (`disabled:!b`, b=כותרת), אבל ה-submit דורש גם חברה (`!ee`). כשהמודאל נפתח בהקשר שבו החברה לא אותחלה, המשתמש לוחץ כפתור 'מאופשר' וה-handler חוזר בשקט — בלי POST, בלי שגיאה. בחירת הסוכן (callback Ro) לא מגדירה את החברה — היא נקבעת רק דרך בורר חברה נפרד (pr/oe). ההזרקה שלנו (translate-he.js) זוכתה: reverseComments נוגע רק ב-[id^='comment-'], לא במודאל; isUserContent מדלג על contentEditable. **לא ניתן לתקן בבטחה דרך injection**: אי-אפשר לכתוב ל-state של React מבחוץ; shim שמגרד DOM ויוצר issue דרך API הוא שביר (צריך IDs מה-DOM) ועלול ליצור משימות פגומות — גרוע מהבאג. **Workaround**: לוודא שהחברה נבחרה במודאל (בורר החברה) לפני לחיצה על 'צור משימה'; או לפתוח 'משימה חדשה' מתוך הקשר חברה/לוח. מסלול אמין תמיד: API ישיר `pc.sh POST /companies/{id}/issues`. **תיקון יסודי = upstream Paperclip** (הכפתור צריך להיות disabled כשאין חברה, או החברה צריכה להיגזר מהלוח/סוכן הנבחר). הערה: #78 (חילוץ פסיקה אוטומטי) מסיר את הצורך במודאל הזה בזרימת חילוץ-הפסיקה; הזרימה הרגילה מניעה סוכנים דרך תגובות (CEO מנתב).", "details": "אבחנה סופית מתוך הבאנדל (index-BWGhimVr.js): ה-submit הוא `function xi(){const je=m.current.trim();if(!ee||!je||He.isPending)return;...He.mutate({...companyId:ee...})}`. `je`=כותרת (קיים), `He`=mutation. ה-guard שנכשל הוא **`ee`**, ש-`ee` משמש כ-`projects.list(ee)` וכ-`companyId:ee` במוטציה — כלומר **`ee` = מזהה החברה**. השורש: הכפתור מאופשר לפי הכותרת בלבד (`disabled:!b`, b=כותרת), אבל ה-submit דורש גם חברה (`!ee`). כשהמודאל נפתח בהקשר שבו החברה לא אותחלה, המשתמש לוחץ כפתור 'מאופשר' וה-handler חוזר בשקט — בלי POST, בלי שגיאה. בחירת הסוכן (callback Ro) לא מגדירה את החברה — היא נקבעת רק דרך בורר חברה נפרד (pr/oe). ההזרקה שלנו (translate-he.js) זוכתה: reverseComments נוגע רק ב-[id^='comment-'], לא במודאל; isUserContent מדלג על contentEditable. **לא ניתן לתקן בבטחה דרך injection**: אי-אפשר לכתוב ל-state של React מבחוץ; shim שמגרד DOM ויוצר issue דרך API הוא שביר (צריך IDs מה-DOM) ועלול ליצור משימות פגומות — גרוע מהבאג. **Workaround**: לוודא שהחברה נבחרה במודאל (בורר החברה) לפני לחיצה על 'צור משימה'; או לפתוח 'משימה חדשה' מתוך הקשר חברה/לוח. מסלול אמין תמיד: API ישיר `pc.sh POST /companies/{id}/issues`. **תיקון יסודי = upstream Paperclip** (הכפתור צריך להיות disabled כשאין חברה, או החברה צריכה להיגזר מהלוח/סוכן הנבחר). הערה: #78 (חילוץ פסיקה אוטומטי) מסיר את הצורך במודאל הזה בזרימת חילוץ-הפסיקה; הזרימה הרגילה מניעה סוכנים דרך תגובות (CEO מנתב).",
@@ -2496,7 +2496,7 @@
"updatedAt": "2026-06-03T00:00:00.000Z" "updatedAt": "2026-06-03T00:00:00.000Z"
}, },
{ {
"id": 77, "id": "77",
"title": "תיקון מיפוי שדות בהעלאת פסיקת ועדת-ערר — מראה-מקום נדחס ל-case_number; case_number לא ניתן לעריכה", "title": "תיקון מיפוי שדות בהעלאת פסיקת ועדת-ערר — מראה-מקום נדחס ל-case_number; case_number לא ניתן לעריכה",
"description": "בטופס העלאת פסיקה (precedent-upload-sheet), עבור החלטות ועדת-ערר ה-frontend ממפה את שדה \"מראה המקום\" אל case_number (`case_number: citation.trim()`), כך שהמזהה הייחודי מקבל את המראה-מקום הארוך במקום מספר תיק נקי (למשל '8027-25'). בנוסף case_number כלל לא קיים ב-PrecedentUpdateRequest — אז מסך העריכה לא יכול לתקן אותו בדיעבד. citation_formatted נשאר ריק בהעלאה (מתמלא רק בחילוץ מטא). תוצאה ב-8027-25: case_number=מראה-מקום, case_name=מראה-מקום, מראה-מקום ריק עד החילוץ.", "description": "בטופס העלאת פסיקה (precedent-upload-sheet), עבור החלטות ועדת-ערר ה-frontend ממפה את שדה \"מראה המקום\" אל case_number (`case_number: citation.trim()`), כך שהמזהה הייחודי מקבל את המראה-מקום הארוך במקום מספר תיק נקי (למשל '8027-25'). בנוסף case_number כלל לא קיים ב-PrecedentUpdateRequest — אז מסך העריכה לא יכול לתקן אותו בדיעבד. citation_formatted נשאר ריק בהעלאה (מתמלא רק בחילוץ מטא). תוצאה ב-8027-25: case_number=מראה-מקום, case_name=מראה-מקום, מראה-מקום ריק עד החילוץ.",
"details": "קבצים: web-ui/src/components/precedents/precedent-upload-sheet.tsx:121-123 (committee path ממפה citation→case_number); web/app.py:5147-5163 (PrecedentUpdateRequest חסר case_number); mcp-server/.../internal_decisions.py (id_field=case_number, display_name_fallback=case_number); precedent_metadata_extractor.py:247-253 (guard: case_name מתוקן רק אם ריק או ==case_number, לכן לא תיקן). תיקון מוצע: (1) בטופס committee — שדה נפרד \"מספר תיק (מזהה ייחודי)\" שממפה ל-case_number, ולמפות \"מראה המקום\" ל-citation (→citation_formatted), במקום לדחוס הכל ל-case_number; (2) להוסיף case_number ל-PrecedentUpdateRequest כדי שהעריכה תוכל לתקן בדיעבד (update_case_law כבר מתיר אותו); (3) להריץ `npm run api:types`. ראה כללי השם שהוגדרו: מזהה ייחודי = שם הקובץ/מספר תיק; מראה-מקום בשדה שלו; שם קצר = שם הצד.", "details": "קבצים: web-ui/src/components/precedents/precedent-upload-sheet.tsx:121-123 (committee path ממפה citation→case_number); web/app.py:5147-5163 (PrecedentUpdateRequest חסר case_number); mcp-server/.../internal_decisions.py (id_field=case_number, display_name_fallback=case_number); precedent_metadata_extractor.py:247-253 (guard: case_name מתוקן רק אם ריק או ==case_number, לכן לא תיקן). תיקון מוצע: (1) בטופס committee — שדה נפרד \"מספר תיק (מזהה ייחודי)\" שממפה ל-case_number, ולמפות \"מראה המקום\" ל-citation (→citation_formatted), במקום לדחוס הכל ל-case_number; (2) להוסיף case_number ל-PrecedentUpdateRequest כדי שהעריכה תוכל לתקן בדיעבד (update_case_law כבר מתיר אותו); (3) להריץ `npm run api:types`. ראה כללי השם שהוגדרו: מזהה ייחודי = שם הקובץ/מספר תיק; מראה-מקום בשדה שלו; שם קצר = שם הצד.",
@@ -2508,7 +2508,7 @@
"updatedAt": "2026-06-02T12:17:44.302Z" "updatedAt": "2026-06-02T12:17:44.302Z"
}, },
{ {
"id": 78, "id": "78",
"title": "כשל שקט ב-wakeup לחילוץ פסיקה — חילוץ אוטומטי לא רץ והתיק נתקע ב-pending", "title": "כשל שקט ב-wakeup לחילוץ פסיקה — חילוץ אוטומטי לא רץ והתיק נתקע ב-pending",
"description": "אחרי העלאת פסיקה, הבקאנד מסמן metadata+halacha כ-pending וקורא ל-pc_wake_for_precedent_extraction להעיר את ה-CEO. הקריאה נבלעת בשקט (try/except 'non-fatal') — אם PAPERCLIP_BOARD_API_KEY חסר מחזיר {ok:false,skipped:no_api_key}, או שה-wakeup API נכשל — והתוצאה: ה-CEO לא מתעורר, לא רץ חילוץ מטא ולא חילוץ הלכות, וה-UI מציג 'ממתין לחילוץ' לנצח. קרה ב-8027-25 (תוקן ידנית עם precedent_process_pending). זה גם הסיבה ששדות המטא (תקציר/headnote/תגיות/citation) היו ריקים.", "description": "אחרי העלאת פסיקה, הבקאנד מסמן metadata+halacha כ-pending וקורא ל-pc_wake_for_precedent_extraction להעיר את ה-CEO. הקריאה נבלעת בשקט (try/except 'non-fatal') — אם PAPERCLIP_BOARD_API_KEY חסר מחזיר {ok:false,skipped:no_api_key}, או שה-wakeup API נכשל — והתוצאה: ה-CEO לא מתעורר, לא רץ חילוץ מטא ולא חילוץ הלכות, וה-UI מציג 'ממתין לחילוץ' לנצח. קרה ב-8027-25 (תוקן ידנית עם precedent_process_pending). זה גם הסיבה ששדות המטא (תקציר/headnote/תגיות/citation) היו ריקים.",
"details": "קבצים: web/app.py:5250-5262 (wakeup best-effort, exception נבלעת); web/paperclip_client.py:816-820 (skip שקט כש-no api key) ו-~905-907 (כשל API נבלע). תיקון מוצע: (1) להציף את הכשל למשתמש — סטטוס מובחן (extraction_wakeup_failed / 'ממתין-לעיבוד-ידני') ב-UI במקום 'pending' אילם; (2) fallback אוטומטי — אם ה-wakeup נכשל, או job מתוזמן (כמו sync-case-status) שמנקז את התור עם precedent_process_pending, או retry; (3) לאמת אם PAPERCLIP_BOARD_API_KEY מוגדר בקונטיינר (Coolify env) — אם לא, להוסיף. עיין reference: project_precedent_auto_extraction. לא לבלוע exceptions בשקט (feedback_silent_swallow).", "details": "קבצים: web/app.py:5250-5262 (wakeup best-effort, exception נבלעת); web/paperclip_client.py:816-820 (skip שקט כש-no api key) ו-~905-907 (כשל API נבלע). תיקון מוצע: (1) להציף את הכשל למשתמש — סטטוס מובחן (extraction_wakeup_failed / 'ממתין-לעיבוד-ידני') ב-UI במקום 'pending' אילם; (2) fallback אוטומטי — אם ה-wakeup נכשל, או job מתוזמן (כמו sync-case-status) שמנקז את התור עם precedent_process_pending, או retry; (3) לאמת אם PAPERCLIP_BOARD_API_KEY מוגדר בקונטיינר (Coolify env) — אם לא, להוסיף. עיין reference: project_precedent_auto_extraction. לא לבלוע exceptions בשקט (feedback_silent_swallow).",
@@ -2520,7 +2520,7 @@
"updatedAt": "2026-06-02T12:07:22.194Z" "updatedAt": "2026-06-02T12:07:22.194Z"
}, },
{ {
"id": 79, "id": "79",
"title": "[#55 follow-up] chunker — כותרות-סעיף מבודדות נשארות chunks זעירים (<50)", "title": "[#55 follow-up] chunker — כותרות-סעיף מבודדות נשארות chunks זעירים (<50)",
"description": "ה-chunker ההיררכי הנוכחי (אחרי תיקון #55) עדיין פולט מדי פעם chunk זעיר שהוא כותרת-סעיף בודדת שלא מוזגה קדימה לתוכן שאחריה. התגלה בסגירת #57 (re-chunk legacy): מתוך 73 תקדימים שעברו reindex, נשארו 4 chunks זעירים — כולם כותרות מבודדות: 'דיון' (ע\"א 5138/04, len=4), 'טענות המשיבים' (בג\"ץ 6525/15, len=13), 'העובדות וההליכים' (בר\"מ 2340/02, len=16), ושבר-ציטוט 'כלל התושבים\". (ע' 13 להחלטה)' (403-17, len=32). לא שאריות legacy — אלה פלט דטרמיניסטי של ה-chunker הנוכחי.", "description": "ה-chunker ההיררכי הנוכחי (אחרי תיקון #55) עדיין פולט מדי פעם chunk זעיר שהוא כותרת-סעיף בודדת שלא מוזגה קדימה לתוכן שאחריה. התגלה בסגירת #57 (re-chunk legacy): מתוך 73 תקדימים שעברו reindex, נשארו 4 chunks זעירים — כולם כותרות מבודדות: 'דיון' (ע\"א 5138/04, len=4), 'טענות המשיבים' (בג\"ץ 6525/15, len=13), 'העובדות וההליכים' (בר\"מ 2340/02, len=16), ושבר-ציטוט 'כלל התושבים\". (ע' 13 להחלטה)' (403-17, len=32). לא שאריות legacy — אלה פלט דטרמיניסטי של ה-chunker הנוכחי.",
"details": "השפעה: נמוכה — chunks אלה כבר מסוננים ב-query-time (פילטר length>=50 שנוסף ב-#55), אז החיפוש לא מושפע; זו בעיית-איכות-chunking ולא בעיית-אחזור. מיקום: chunker ההיררכי ב-mcp-server (chunk_document_hierarchical / מדיניות מיזוג הכותרות שב-#55). התיקון הצפוי: כשכותרת/heading קצרה (<50, או section_type שמתחיל סעיף) נותרת כ-chunk עצמאי ללא גוף — למזג אותה קדימה אל ה-chunk הבא (anchor-forward), או אחורה אם אין הבא. לשים לב ל-section boundaries: 'דיון'/'טענות המשיבים' הן תחילת סעיף — המיזוג צריך לצרף את הכותרת לראש הסעיף שאחריה, לא לזנב הקודם. אימות: להריץ scripts/rechunk_legacy_precedents.py אחרי התיקון — אמור להגיע ל-0 chunks<50 (או רק שברי-ציטוט לגיטימיים נדירים). תלוי ב-#55. ראה גם feedback_no_reocr_retrofit (re-chunk מ-full_text בלבד).", "details": "השפעה: נמוכה — chunks אלה כבר מסוננים ב-query-time (פילטר length>=50 שנוסף ב-#55), אז החיפוש לא מושפע; זו בעיית-איכות-chunking ולא בעיית-אחזור. מיקום: chunker ההיררכי ב-mcp-server (chunk_document_hierarchical / מדיניות מיזוג הכותרות שב-#55). התיקון הצפוי: כשכותרת/heading קצרה (<50, או section_type שמתחיל סעיף) נותרת כ-chunk עצמאי ללא גוף — למזג אותה קדימה אל ה-chunk הבא (anchor-forward), או אחורה אם אין הבא. לשים לב ל-section boundaries: 'דיון'/'טענות המשיבים' הן תחילת סעיף — המיזוג צריך לצרף את הכותרת לראש הסעיף שאחריה, לא לזנב הקודם. אימות: להריץ scripts/rechunk_legacy_precedents.py אחרי התיקון — אמור להגיע ל-0 chunks<50 (או רק שברי-ציטוט לגיטימיים נדירים). תלוי ב-#55. ראה גם feedback_no_reocr_retrofit (re-chunk מ-full_text בלבד).",
@@ -2534,7 +2534,7 @@
"updatedAt": "2026-06-03T08:10:57.844Z" "updatedAt": "2026-06-03T08:10:57.844Z"
}, },
{ {
"id": 80, "id": "80",
"title": "[#15 follow-up] בדיקת ערך image-answer ל-multimodal → הכרעה על backfill 140 legacy", "title": "[#15 follow-up] בדיקת ערך image-answer ל-multimodal → הכרעה על backfill 140 legacy",
"description": "נסגר 2026-06-03 — ההנחה התבררה שגויה בכל מרכיב (full check). לא '140 מסמכים / 17,700 עמ' / שעתיים / אישור-עלות chaim + תיוג דפנה', אלא: מתוך 140 חסרי-image רק 65 PDF (השאר MD/DOCX — ה-pipeline מרנדר PDF בלבד), ובסך 704 עמ'. תיקי-השמאות (כל ערך ה-multimodal) כבר היו 8/12 מוטמעים — הפער היחיד היה תיק 8070-25 (4 מסמכי שמאות).", "description": "נסגר 2026-06-03 — ההנחה התבררה שגויה בכל מרכיב (full check). לא '140 מסמכים / 17,700 עמ' / שעתיים / אישור-עלות chaim + תיוג דפנה', אלא: מתוך 140 חסרי-image רק 65 PDF (השאר MD/DOCX — ה-pipeline מרנדר PDF בלבד), ובסך 704 עמ'. תיקי-השמאות (כל ערך ה-multimodal) כבר היו 8/12 מוטמעים — הפער היחיד היה תיק 8070-25 (4 מסמכי שמאות).",
"details": "בוצע: backfill מקומי (multimodal_backfill.py 8070-25, voyage-multimodal-3, ~30 שניות) → כל 14 מסמכי 8070-25 הוטמעו. **כיסוי שמאות עכשיו 12/12 (100%)**. נותרו 51 PDF/649 עמ' ללא multimodal — כולם טקסטואליים (reference/response/appeal), ו-#15 הוכיח ש-multimodal לא עוזר (אף מדלל) על מסמכים טקסטואליים → **מושארים בכוונה** text-only; זו לא חוסר-עקביות אלא הקונפיג הנכון. אין צורך ב-gold-set/דפנה/אישור-עלות — העלות הייתה סנטים והערך הוכח ב-#15 לתיקי ועדה/שמאות. #80 done (טכני, לא human-gated).", "details": "בוצע: backfill מקומי (multimodal_backfill.py 8070-25, voyage-multimodal-3, ~30 שניות) → כל 14 מסמכי 8070-25 הוטמעו. **כיסוי שמאות עכשיו 12/12 (100%)**. נותרו 51 PDF/649 עמ' ללא multimodal — כולם טקסטואליים (reference/response/appeal), ו-#15 הוכיח ש-multimodal לא עוזר (אף מדלל) על מסמכים טקסטואליים → **מושארים בכוונה** text-only; זו לא חוסר-עקביות אלא הקונפיג הנכון. אין צורך ב-gold-set/דפנה/אישור-עלות — העלות הייתה סנטים והערך הוכח ב-#15 לתיקי ועדה/שמאות. #80 done (טכני, לא human-gated).",
@@ -2548,7 +2548,7 @@
"updatedAt": "2026-06-03T00:00:00.000Z" "updatedAt": "2026-06-03T00:00:00.000Z"
}, },
{ {
"id": 81, "id": "81",
"title": "איכות חילוץ הלכות — לוודא שמה שמחולץ הוא הלכה אמיתית ולא ציטוט/אמרת-אגב", "title": "איכות חילוץ הלכות — לוודא שמה שמחולץ הוא הלכה אמיתית ולא ציטוט/אמרת-אגב",
"description": "מנוע חילוץ ההלכות מפיק כיום פריטים שאינם 'הלכות' במובן המהותי: אמרות-אגב שהערכאה לא הכריעה בהן, יישומים ספציפיים-לתיק (rule_type=application), ציטוטים חתוכים, ופירוק-יתר (עד 351 'הלכות' מפסק אחד). המשימה: לחדד את ה-prompt ולהוסיף ולידטורים אוטומטיים כך שרק עיקרון משפטי בר-הכללה ובר-הסתמכות ייכנס למאגר. מבוססת מחקר מקצועי (ratio decidendi מול obiter dictum, holding-extraction בספרות legal-NLP, קריטריונים לאיכות rule_statement). תתי-המשימות יוגדרו לאחר המחקר.", "description": "מנוע חילוץ ההלכות מפיק כיום פריטים שאינם 'הלכות' במובן המהותי: אמרות-אגב שהערכאה לא הכריעה בהן, יישומים ספציפיים-לתיק (rule_type=application), ציטוטים חתוכים, ופירוק-יתר (עד 351 'הלכות' מפסק אחד). המשימה: לחדד את ה-prompt ולהוסיף ולידטורים אוטומטיים כך שרק עיקרון משפטי בר-הכללה ובר-הסתמכות ייכנס למאגר. מבוססת מחקר מקצועי (ratio decidendi מול obiter dictum, holding-extraction בספרות legal-NLP, קריטריונים לאיכות rule_statement). תתי-המשימות יוגדרו לאחר המחקר.",
"details": "רקע מבצעי (ניקוי 2026-06-03): נמחקו 196 רשומות מתוך 1650 (165 כפילויות תוכן + 31 Tier B/C). גיבוי: data/audit/halacha-cleanup-backup-20260603T101747Z.sql ; מניפסט: data/audit/halacha-cleanup-manifest-20260603T101747Z.csv . קוד רלוונטי: mcp-server/src/legal_mcp/services/halacha_extractor.py (prompts BINDING/PERSUASIVE, _coerce_halacha, אימות ציטוט). תחומי בדיקה ידועים: (א) חסימת quote_verified=false מהתור; (ב) הוצאת rule_type=application מהגדרת 'הלכה'; (ג) שמירה על דחיית dicta שלא הוכרעו; (ד) תקרת כמות/גרנולריות לפסק; (ה) הגנה מפני ציטוט חתוך (truncation guard). מפרט מאומת: docs/halacha-strict-rubric.md (הרובריקה האגרסיבית שהנחיתה ניקוי קורפוס 1454→534 ב-2026-06-03, שפר 51→22). להטמיע אותה במחלץ + dedup-on-insert (#82).", "details": "רקע מבצעי (ניקוי 2026-06-03): נמחקו 196 רשומות מתוך 1650 (165 כפילויות תוכן + 31 Tier B/C). גיבוי: data/audit/halacha-cleanup-backup-20260603T101747Z.sql ; מניפסט: data/audit/halacha-cleanup-manifest-20260603T101747Z.csv . קוד רלוונטי: mcp-server/src/legal_mcp/services/halacha_extractor.py (prompts BINDING/PERSUASIVE, _coerce_halacha, אימות ציטוט). תחומי בדיקה ידועים: (א) חסימת quote_verified=false מהתור; (ב) הוצאת rule_type=application מהגדרת 'הלכה'; (ג) שמירה על דחיית dicta שלא הוכרעו; (ד) תקרת כמות/גרנולריות לפסק; (ה) הגנה מפני ציטוט חתוך (truncation guard). מפרט מאומת: docs/halacha-strict-rubric.md (הרובריקה האגרסיבית שהנחיתה ניקוי קורפוס 1454→534 ב-2026-06-03, שפר 51→22). להטמיע אותה במחלץ + dedup-on-insert (#82).",
@@ -2648,7 +2648,7 @@
"updatedAt": "2026-06-03T16:27:24.755Z" "updatedAt": "2026-06-03T16:27:24.755Z"
}, },
{ {
"id": 82, "id": "82",
"title": "Dedup בזמן הכנסה — מניעת הלכות כמעט-זהות בכתיבה (semantic dedup on insert)", "title": "Dedup בזמן הכנסה — מניעת הלכות כמעט-זהות בכתיבה (semantic dedup on insert)",
"description": "store_halachot_for_chunk מבצע INSERT עיוור ללא בדיקת כפילות, ולכן נצברו עשרות הלכות 'אותו עיקרון במילים אחרות'. המשימה: לפני שמירה, לבדוק דמיון סמנטי (embedding) מול הלכות קיימות באותו פסק ולדלג/למזג near-duplicates, וכן לזהות ציטוט-תומך זהה. מבוססת מחקר (ספי near-duplicate ב-embedding-IR, MinHash/LSH מול cosine, בחירת צורה קנונית, merge מול skip). תתי-המשימות יוגדרו לאחר המחקר.", "description": "store_halachot_for_chunk מבצע INSERT עיוור ללא בדיקת כפילות, ולכן נצברו עשרות הלכות 'אותו עיקרון במילים אחרות'. המשימה: לפני שמירה, לבדוק דמיון סמנטי (embedding) מול הלכות קיימות באותו פסק ולדלג/למזג near-duplicates, וכן לזהות ציטוט-תומך זהה. מבוססת מחקר (ספי near-duplicate ב-embedding-IR, MinHash/LSH מול cosine, בחירת צורה קנונית, merge מול skip). תתי-המשימות יוגדרו לאחר המחקר.",
"details": "ממצא מהניקוי: סף cosine ≥0.90 תוך-פסק זיהה כפילויות אמיתיות בוודאות גבוהה (הרצועה 0.90-0.95 הייתה כמעט כולה 'אותו עיקרון בניסוח שונה'); ≥0.95 = ודאי. ציטוט-תומך זהה = איתות ודאי. להחליט: סף מבצעי, התנהגות (skip/merge/flag-for-review), ובחירת השורד (approved>pending, ביטחון גבוה, quote_verified). קוד: db.store_halachot_for_chunk; קיים idx_halachot_vec (pgvector).", "details": "ממצא מהניקוי: סף cosine ≥0.90 תוך-פסק זיהה כפילויות אמיתיות בוודאות גבוהה (הרצועה 0.90-0.95 הייתה כמעט כולה 'אותו עיקרון בניסוח שונה'); ≥0.95 = ודאי. ציטוט-תומך זהה = איתות ודאי. להחליט: סף מבצעי, התנהגות (skip/merge/flag-for-review), ובחירת השורד (approved>pending, ביטחון גבוה, quote_verified). קוד: db.store_halachot_for_chunk; קיים idx_halachot_vec (pgvector).",
@@ -2736,7 +2736,7 @@
"updatedAt": "2026-06-03T12:32:19.721Z" "updatedAt": "2026-06-03T12:32:19.721Z"
}, },
{ {
"id": 83, "id": "83",
"title": "חוסן pipeline החילוץ — re-run אידמפוטנטי + אינדוקס תקין (תיקון באגים)", "title": "חוסן pipeline החילוץ — re-run אידמפוטנטי + אינדוקס תקין (תיקון באגים)",
"description": "התגלו שני באגים: (1) halacha_index מוקצה per-chunk ולכן אינו ייחודי לפסק — שני עקרונות שונים מקבלים אותו מספר (לא כפילות, אך שובר dedup/מיון מבוסס-אינדקס); (2) חילוץ רץ פי-2/3 על אותו פסק (למשל 85026-17 שלוש ריצות תוך דקתיים) ומוסיף append במקום להחליף — ה-advisory lock לא מנע. המשימה: אינדוקס ייחודי לפסק, force=True שמוחק לפני re-extract, וחיזוק ה-lock/אידמפוטנטיות. מחקר קצר: דפוסי idempotency/exactly-once ב-pipelines.", "description": "התגלו שני באגים: (1) halacha_index מוקצה per-chunk ולכן אינו ייחודי לפסק — שני עקרונות שונים מקבלים אותו מספר (לא כפילות, אך שובר dedup/מיון מבוסס-אינדקס); (2) חילוץ רץ פי-2/3 על אותו פסק (למשל 85026-17 שלוש ריצות תוך דקתיים) ומוסיף append במקום להחליף — ה-advisory lock לא מנע. המשימה: אינדוקס ייחודי לפסק, force=True שמוחק לפני re-extract, וחיזוק ה-lock/אידמפוטנטיות. מחקר קצר: דפוסי idempotency/exactly-once ב-pipelines.",
"details": "קוד: halacha_extractor.py (global advisory lock, per-chunk checkpoints ב-precedent_chunks.halacha_extracted_at, force flag), db.store_halachot_for_chunk (הקצאת halacha_index). לשקול unique constraint (case_law_id, halacha_index) אחרי תיקון ההקצאה.", "details": "קוד: halacha_extractor.py (global advisory lock, per-chunk checkpoints ב-precedent_chunks.halacha_extracted_at, force flag), db.store_halachot_for_chunk (הקצאת halacha_index). לשקול unique constraint (case_law_id, halacha_index) אחרי תיקון ההקצאה.",
@@ -2822,7 +2822,7 @@
"updatedAt": "2026-06-03T13:08:10.793Z" "updatedAt": "2026-06-03T13:08:10.793Z"
}, },
{ {
"id": 84, "id": "84",
"title": "טריאז' תור אישור ההלכות — אישור יעיל ולא מתיש", "title": "טריאז' תור אישור ההלכות — אישור יעיל ולא מתיש",
"description": "אישור ההלכות ידני ומתיש: קריאת עקרונות כמעט-זהים שוב ושוב, ללא תיעדוף או קיבוץ. המשימה: לייעל את חוויית האישור — מיון לפי ביטחון/corroboration, קיבוץ near-duplicates יחד, auto-defer/הסתרה של פריטים באיכות נמוכה, ופעולות batch (אישור/דחייה מרובים). מבוססת מחקר (human-in-the-loop review UX, active-learning prioritization, triage queues). תתי-המשימות לאחר המחקר.", "description": "אישור ההלכות ידני ומתיש: קריאת עקרונות כמעט-זהים שוב ושוב, ללא תיעדוף או קיבוץ. המשימה: לייעל את חוויית האישור — מיון לפי ביטחון/corroboration, קיבוץ near-duplicates יחד, auto-defer/הסתרה של פריטים באיכות נמוכה, ופעולות batch (אישור/דחייה מרובים). מבוססת מחקר (human-in-the-loop review UX, active-learning prioritization, triage queues). תתי-המשימות לאחר המחקר.",
"details": "הקשר: הדחייה כמעט לא בשימוש (1/1650) — התור הוא 'אשר-או-השאר-תלוי'. כלים קיימים: halachot_pending, halacha_review (MCP), דף ביקורת ב-UI. לשלב עם פלט #81 (איכות) ו-#82 (dedup) כדי שהתור יציג רק מועמדים אמיתיים ומקובצים.", "details": "הקשר: הדחייה כמעט לא בשימוש (1/1650) — התור הוא 'אשר-או-השאר-תלוי'. כלים קיימים: halachot_pending, halacha_review (MCP), דף ביקורת ב-UI. לשלב עם פלט #81 (איכות) ו-#82 (dedup) כדי שהתור יציג רק מועמדים אמיתיים ומקובצים.",
@@ -2918,18 +2918,19 @@
"updatedAt": "2026-06-03T13:43:18.488Z" "updatedAt": "2026-06-03T13:43:18.488Z"
}, },
{ {
"id": 85, "id": "85",
"title": "CEO MCP instance: nested claude -p exits 1 in write_interim_draft", "title": "CEO MCP instance: nested claude -p exits 1 in write_interim_draft",
"description": "write_interim_draft נכשל לכל 5 הבלוקים מתוך session ה-CEO עם 'Claude CLI failed (exit 1): unknown error'. אומת: claude CLI תקין מ-bash (exit 0), PATH+HOME של תהליך ה-MCP תקינים (/home/chaim/.local/bin/claude), אין ANTHROPIC_API_KEY ב-.env, הבלוקים נכתבים סדרתית (לא concurrency). סוכני משנה (proofreader/analyst CMPA-73..76) הריצו claude -p בהצלחה באותו יום. ⇒ כשל ספציפי ל-MCP server instance של ה-CEO. עוקף: האצלה ל-writer agent. דרוש: לבדוק מדוע nested claude -p נכשל מ-instance זה (אולי session lock / env stale); שקול restart ל-CEO agent session.", "description": "write_interim_draft נכשל לכל 5 הבלוקים מתוך session ה-CEO עם 'Claude CLI failed (exit 1): unknown error'. אומת: claude CLI תקין מ-bash (exit 0), PATH+HOME של תהליך ה-MCP תקינים (/home/chaim/.local/bin/claude), אין ANTHROPIC_API_KEY ב-.env, הבלוקים נכתבים סדרתית (לא concurrency). סוכני משנה (proofreader/analyst CMPA-73..76) הריצו claude -p בהצלחה באותו יום. ⇒ כשל ספציפי ל-MCP server instance של ה-CEO. עוקף: האצלה ל-writer agent. דרוש: לבדוק מדוע nested claude -p נכשל מ-instance זה (אולי session lock / env stale); שקול restart ל-CEO agent session.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "high", "priority": "high",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:47.925Z"
}, },
{ {
"id": 86, "id": "86",
"title": "טיפול ב-preamble/רציו של נבו — anti-contamination + gold-set מהרציו", "title": "טיפול ב-preamble/רציו של נבו — anti-contamination + gold-set מהרציו",
"description": "התגלה (2026-06-03) ש-`strip_nevo_preamble` קיים ומחווט ל-ingest, אבל ה-regex `_DECISION_START` מזהה רק פתיחות של ועדת ערר (בפנינו/הערר שבנדון/ועדת הערר לתכנון/רקע עובדתי/עסקינן) — ולא פסקי-דין שנפתחים ב'פסק-דין' (כמו בג\"ץ 1764/05). לכן בפסקי-דין מנבו — בדיוק אלה שיש להם מיני-רציו — ה-preamble/רציו **אינו נחתך**, דולף לצ'אנקים, ועלול לזהם את חילוץ ההלכות (המחלץ קורא את התשובון של נבו) ואת הקורפוס. במקביל — הרציו של נבו הוא gold-set אנושי-מקצועי חינמי לאמידת איכות החילוץ.", "description": "התגלה (2026-06-03) ש-`strip_nevo_preamble` קיים ומחווט ל-ingest, אבל ה-regex `_DECISION_START` מזהה רק פתיחות של ועדת ערר (בפנינו/הערר שבנדון/ועדת הערר לתכנון/רקע עובדתי/עסקינן) — ולא פסקי-דין שנפתחים ב'פסק-דין' (כמו בג\"ץ 1764/05). לכן בפסקי-דין מנבו — בדיוק אלה שיש להם מיני-רציו — ה-preamble/רציו **אינו נחתך**, דולף לצ'אנקים, ועלול לזהם את חילוץ ההלכות (המחלץ קורא את התשובון של נבו) ואת הקורפוס. במקביל — הרציו של נבו הוא gold-set אנושי-מקצועי חינמי לאמידת איכות החילוץ.",
"details": "קוד: mcp-server/src/legal_mcp/services/extractor.py — `strip_nevo_preamble` (~367), `_NEVO_MARKERS` (ספרות:/חקיקה שאוזכרה:/מיני-רציו:/...), `_DECISION_START` (~361). מחווט ב-ingest.py:161 ו-documents.py:152. הוכחה: ב-1764/05 המיני-רציו שרד כ-chunk מסוג intro (לא נחתך) ורק במזל לא חולץ (intro לא ב-EXTRACTABLE_SECTIONS). השוואת benchmark שבוצעה ידנית על 1764/05: 14 הלכות שלנו כיסו 100% מ-4 הלכות-הרציו של נבו + 2 נוספות, בגרנולריות פי ~3.5 (קשור ל-#81.5).", "details": "קוד: mcp-server/src/legal_mcp/services/extractor.py — `strip_nevo_preamble` (~367), `_NEVO_MARKERS` (ספרות:/חקיקה שאוזכרה:/מיני-רציו:/...), `_DECISION_START` (~361). מחווט ב-ingest.py:161 ו-documents.py:152. הוכחה: ב-1764/05 המיני-רציו שרד כ-chunk מסוג intro (לא נחתך) ורק במזל לא חולץ (intro לא ב-EXTRACTABLE_SECTIONS). השוואת benchmark שבוצעה ידנית על 1764/05: 14 הלכות שלנו כיסו 100% מ-4 הלכות-הרציו של נבו + 2 נוספות, בגרנולריות פי ~3.5 (קשור ל-#81.5).",
@@ -2977,204 +2978,218 @@
"updatedAt": "2026-06-03T16:56:13.158Z" "updatedAt": "2026-06-03T16:56:13.158Z"
}, },
{ {
"id": 87, "id": "87",
"title": "claims_coverage: להבחין בין טענות כתב-ערר לטענות תכתובת", "title": "claims_coverage: להבחין בין טענות כתב-ערר לטענות תכתובת",
"description": "בדיקת claims_coverage מסמנת false-positives: טענות שעלו רק בתכתובות/תגובות בין הצדדים (לא בכתב הערר) מסומנות כ'לא נענו'. יש להבחין בין טענות מכתב הערר (חובה מענה) לטענות מתכתובת (לא חייבות מענה עצמאי, במיוחד כשהערר מתקבל במלואו). מקור: chair_feedback תיק 1033-25.", "description": "בדיקת claims_coverage מסמנת false-positives: טענות שעלו רק בתכתובות/תגובות בין הצדדים (לא בכתב הערר) מסומנות כ'לא נענו'. יש להבחין בין טענות מכתב הערר (חובה מענה) לטענות מתכתובת (לא חייבות מענה עצמאי, במיוחד כשהערר מתקבל במלואו). מקור: chair_feedback תיק 1033-25.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "medium", "priority": "medium",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:47.933Z"
}, },
{ {
"id": 88, "id": "88",
"title": "פער DB↔file: decision_blocks (DB) מול drafts/decision.md (disk)", "title": "פער DB↔file: decision_blocks (DB) מול drafts/decision.md (disk)",
"description": "legal-writer מעדכן decision_blocks ב-DB אבל legal-qa קורא drafts/decision.md מהדיסק — שני מקורות אמת לא מסונכרנים גורמים ל-QA להיכשל פעמיים על אותה בעיה. נדרש: או כתיבה אטומית ל-DB+file יחד, או hook אוטומטי regenerate-draft אחרי כל עדכון בלוק. מקור: chair_feedback תיקים 8126-03-25, CMPA-62. ראה legal-decision-lessons.md #35.", "description": "legal-writer מעדכן decision_blocks ב-DB אבל legal-qa קורא drafts/decision.md מהדיסק — שני מקורות אמת לא מסונכרנים גורמים ל-QA להיכשל פעמיים על אותה בעיה. נדרש: או כתיבה אטומית ל-DB+file יחד, או hook אוטומטי regenerate-draft אחרי כל עדכון בלוק. מקור: chair_feedback תיקים 8126-03-25, CMPA-62. ראה legal-decision-lessons.md #35.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "high", "priority": "high",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:47.943Z"
}, },
{ {
"id": 89, "id": "89",
"title": "[רכישת-סגנון T0] הזרקת הפרופיל-המופשט ל-block_writer + מדיניות-העתקה", "title": "[רכישת-סגנון T0] הזרקת הפרופיל-המופשט ל-block_writer + מדיניות-העתקה",
"description": "הלוֹבר הראשי. block_writer.py יטען voice-fingerprint+author-features+Copy-Paste Templates ל-{style_context} בכל בלוק. הוראת-מדיניות לפי סוג-תוכן: נוסחה/בוילרפלייט→מותר להעתיק, ניתוח ספציפי→הכלל והתאם, מהות מתיק אחר→אסור. פיצול {precedents_context} ל-{daphna_style_exemplars} (סגנון) ו-{case_law_citations} (פסיקה). קבצים: block_writer.py:205-260,710,795-815. MVP.", "description": "הלוֹבר הראשי. block_writer.py יטען voice-fingerprint+author-features+Copy-Paste Templates ל-{style_context} בכל בלוק. הוראת-מדיניות לפי סוג-תוכן: נוסחה/בוילרפלייט→מותר להעתיק, ניתוח ספציפי→הכלל והתאם, מהות מתיק אחר→אסור. פיצול {precedents_context} ל-{daphna_style_exemplars} (סגנון) ו-{case_law_citations} (פסיקה). קבצים: block_writer.py:205-260,710,795-815. MVP.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "high", "priority": "high",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:47.951Z"
}, },
{ {
"id": 90, "id": "90",
"title": "[רכישת-סגנון T1] Backfill decision_paragraphs+paragraph_embeddings מ-style_corpus", "title": "[רכישת-סגנון T1] Backfill decision_paragraphs+paragraph_embeddings מ-style_corpus",
"description": "אכלוס כל 48 ההחלטות עם author='daphna' כדי שאחזור-הבלוק (search_similar_paragraphs) יחזיר פסקאות אמיתיות של דפנה. documents.py:186-215 + סקריפט חד-פעמי. תלוי: אין. MVP-enabler.", "description": "אכלוס כל 48 ההחלטות עם author='daphna' כדי שאחזור-הבלוק (search_similar_paragraphs) יחזיר פסקאות אמיתיות של דפנה. documents.py:186-215 + סקריפט חד-פעמי. תלוי: אין. MVP-enabler.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "high", "priority": "high",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:47.959Z"
}, },
{ {
"id": 91, "id": "91",
"title": "[רכישת-סגנון T2] הרחבת search_similar_paragraphs — סינון outcome+practice_area+block_type", "title": "[רכישת-סגנון T2] הרחבת search_similar_paragraphs — סינון outcome+practice_area+block_type",
"description": "db.py:2243 — להוסיף סינון, להחזיר פסקה מלאה, להרחיב לבלוקים ז/ח (לא רק י). block_writer.py:710 מעביר outcome, 4→6 exemplars. תלוי: T1.", "description": "db.py:2243 — להוסיף סינון, להחזיר פסקה מלאה, להרחיב לבלוקים ז/ח (לא רק י). block_writer.py:710 מעביר outcome, 4→6 exemplars. תלוי: T1.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "high", "priority": "high",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:47.965Z"
}, },
{ {
"id": 92, "id": "92",
"title": "[רכישת-סגנון T3] דוגמאות contrastive + תיוג 'תבנית-קול בלבד'", "title": "[רכישת-סגנון T3] דוגמאות contrastive + תיוג 'תבנית-קול בלבד'",
"description": "להחזיר גם 'במה דפנה שונה' לא רק דומה (author-features+contrastive, arxiv 2504.08745). תלוי: T2,T0.", "description": "להחזיר גם 'במה דפנה שונה' לא רק דומה (author-features+contrastive, arxiv 2504.08745). תלוי: T2,T0.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "medium", "priority": "medium",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:47.973Z"
}, },
{ {
"id": 93, "id": "93",
"title": "[רכישת-סגנון T4] חיבור learning_loop ל-mark-final דרך ה-curator", "title": "[רכישת-סגנון T4] חיבור learning_loop ל-mark-final דרך ה-curator",
"description": "mark-final מסמן+מעיר; curator מריץ ingest_final_version (claude_session לא בקונטיינר). app.py:3217-3283, paperclip_client.py, hermes-curator.md. תלוי: אין. MVP.", "description": "mark-final מסמן+מעיר; curator מריץ ingest_final_version (claude_session לא בקונטיינר). app.py:3217-3283, paperclip_client.py, hermes-curator.md. תלוי: אין. MVP.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "high", "priority": "high",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:47.982Z"
}, },
{ {
"id": 94, "id": "94",
"title": "[רכישת-סגנון T5] פנקס-התאמה draft_final_pairs + snapshot ב-mark-final (INV-LRN4)", "title": "[רכישת-סגנון T5] פנקס-התאמה draft_final_pairs + snapshot ב-mark-final (INV-LRN4)",
"description": "טבלה draft_final_pairs(case_id,draft_text,final_text,diff_stats,status,created_at). snapshot של הטיוטה ברגע mark-final (אחרת diff מזוהם). זו 'רשימת ההחלטות' של כלל-העל + ground-truth ל-T7. תלוי: T4. MVP.", "description": "טבלה draft_final_pairs(case_id,draft_text,final_text,diff_stats,status,created_at). snapshot של הטיוטה ברגע mark-final (אחרת diff מזוהם). זו 'רשימת ההחלטות' של כלל-העל + ground-truth ל-T7. תלוי: T4. MVP.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "high", "priority": "high",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:47.991Z"
}, },
{ {
"id": 95, "id": "95",
"title": "[רכישת-סגנון T6] פנקס-התאמה ב-UI + קטגוריה במרכז-אישורים", "title": "[רכישת-סגנון T6] פנקס-התאמה ב-UI + קטגוריה במרכז-אישורים",
"description": "רשימת כל ההחלטות + סטטוס (draft_done/final_received/analyzed/lessons_folded). מרכז-אישורים: קטגוריה 'ממתינות להשוואה מול סופי'. תלוי: T5.", "description": "רשימת כל ההחלטות + סטטוס (draft_done/final_received/analyzed/lessons_folded). מרכז-אישורים: קטגוריה 'ממתינות להשוואה מול סופי'. תלוי: T5.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "medium", "priority": "medium",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:47.998Z"
}, },
{ {
"id": 96, "id": "96",
"title": "[רכישת-סגנון T7] מדד מרחק-סגנון (style_distance.py)", "title": "[רכישת-סגנון T7] מדד מרחק-סגנון (style_distance.py)",
"description": "golden_ratio_adherence + anti_pattern_hits + draft_to_final_diff (ללא LLM). lessons.py יקבל ANTI_PATTERNS. חשיפה דרך get_metrics/tool. תלוי: T5. MVP.", "description": "golden_ratio_adherence + anti_pattern_hits + draft_to_final_diff (ללא LLM). lessons.py יקבל ANTI_PATTERNS. חשיפה דרך get_metrics/tool. תלוי: T5. MVP.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "high", "priority": "high",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:48.007Z"
}, },
{ {
"id": 97, "id": "97",
"title": "[רכישת-סגנון T8] הסרת LIMIT 20 ב-style_analyzer (כיסוי 48/48)", "title": "[רכישת-סגנון T8] הסרת LIMIT 20 ב-style_analyzer (כיסוי 48/48)",
"description": "style_analyzer.py:124 — LIMIT 20→פרמטר/הסרה. מזין author-features של T0. תלוי: אין.", "description": "style_analyzer.py:124 — LIMIT 20→פרמטר/הסרה. מזין author-features של T0. תלוי: אין.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "medium", "priority": "medium",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:48.015Z"
}, },
{ {
"id": 98, "id": "98",
"title": "[רכישת-סגנון T9] תיקון-המספור: ביטול אנטי-דפוס + מספור-אוטומטי בייצוא", "title": "[רכישת-סגנון T9] תיקון-המספור: ביטול אנטי-דפוס + מספור-אוטומטי בייצוא",
"description": "ביטול 'אסור רשימה ממוספרת' (voice-fingerprint 3.1 — שגוי, ההחלטה ממוספרת תמיד). ייצוא DOCX יחיל מספור-אוטומטי של Word (skills/dafna-decision-template); הכותב יפסיק להזריק מספרים ידניים. בדיקה: האם הייצוא כבר ממספר אוטומטית. תלוי: אין.", "description": "ביטול 'אסור רשימה ממוספרת' (voice-fingerprint 3.1 — שגוי, ההחלטה ממוספרת תמיד). ייצוא DOCX יחיל מספור-אוטומטי של Word (skills/dafna-decision-template); הכותב יפסיק להזריק מספרים ידניים. בדיקה: האם הייצוא כבר ממספר אוטומטית. תלוי: אין.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "medium", "priority": "medium",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:48.022Z"
}, },
{ {
"id": 99, "id": "99",
"title": "[רכישת-סגנון T10] get_style_guide דינמי — golden-ratios נמדדים מקורפוס", "title": "[רכישת-סגנון T10] get_style_guide דינמי — golden-ratios נמדדים מקורפוס",
"description": "drafting.py:68 — golden-ratios נמדדים מהקורפוס לצד הקבועים, סימון פער. תלוי: T1,T8.", "description": "drafting.py:68 — golden-ratios נמדדים מהקורפוס לצד הקבועים, סימון פער. תלוי: T1,T8.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "low", "priority": "low",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:48.028Z"
}, },
{ {
"id": 100, "id": "100",
"title": "[רכישת-סגנון T11] regen API types + deploy", "title": "[רכישת-סגנון T11] regen API types + deploy",
"description": "npm run api:types ב-web-ui אם נוסף tool/endpoint; commit+push+Coolify deploy; MCP restart מקומי. תלוי: כל משימה שמשנה endpoint/tool.", "description": "npm run api:types ב-web-ui אם נוסף tool/endpoint; commit+push+Coolify deploy; MCP restart מקומי. תלוי: כל משימה שמשנה endpoint/tool.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "medium", "priority": "medium",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:48.038Z"
}, },
{ {
"id": 101, "id": "101",
"title": "[רכישת-סגנון T12] /methodology — קטגוריות profile חדשות", "title": "[רכישת-סגנון T12] /methodology — קטגוריות profile חדשות",
"description": "להוסיף ל-CRUD הגנרי (/api/methodology/{category}) קטגוריות: transition_phrases, anti_patterns, voice_invariants (קבועי voice-fingerprint). + טאבים ב-web-ui/src/app/methodology. תלוי: אין.", "description": "להוסיף ל-CRUD הגנרי (/api/methodology/{category}) קטגוריות: transition_phrases, anti_patterns, voice_invariants (קבועי voice-fingerprint). + טאבים ב-web-ui/src/app/methodology. תלוי: אין.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "medium", "priority": "medium",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:48.044Z"
}, },
{ {
"id": 102, "id": "102",
"title": "[רכישת-סגנון T13] /training — טאבי learning חדשים", "title": "[רכישת-סגנון T13] /training — טאבי learning חדשים",
"description": "טאב מדד-מרחק (מגמת T7), טאב פנקס-התאמה (T6), חיווט 'השוואה' ל-draft_final_pairs, פורטרט 'נמדד מול יעד'. תלוי: T5,T7.", "description": "טאב מדד-מרחק (מגמת T7), טאב פנקס-התאמה (T6), חיווט 'השוואה' ל-draft_final_pairs, פורטרט 'נמדד מול יעד'. תלוי: T5,T7.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "medium", "priority": "medium",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:48.051Z"
}, },
{ {
"id": 103, "id": "103",
"title": "[רכישת-סגנון T14] אישור הצעות-distillation של ה-curator → כתיבה לפרופיל", "title": "[רכישת-סגנון T14] אישור הצעות-distillation של ה-curator → כתיבה לפרופיל",
"description": "משטח אישור הצעות ה-curator (שער INV-G10) שכותב ל-methodology/voice-fingerprint. /training טאב הסוכן. תלוי: T4.", "description": "משטח אישור הצעות ה-curator (שער INV-G10) שכותב ל-methodology/voice-fingerprint. /training טאב הסוכן. תלוי: T4.",
"details": "", "details": "",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "medium", "priority": "medium",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-06-06T21:02:48.060Z"
} }
], ],
"metadata": { "metadata": {
"version": "1.0.0", "version": "1.0.0",
"lastModified": "2026-06-03T16:56:13.158Z", "lastModified": "2026-06-06T21:02:48.060Z",
"taskCount": 86, "taskCount": 103,
"completedCount": 77, "completedCount": 95,
"tags": [ "tags": [
"legal-ai" "legal-ai"
], ]
"created": "2026-06-06T12:53:14.496Z",
"description": "Tasks for legal-ai context",
"updated": "2026-06-06T15:58:42.555Z"
} }
} }
} }

View File

@@ -155,3 +155,78 @@ CEO צריך להעביר את ה-issue ל-`in_review` (לא `in_progress`) כש
### סטטוס ### סטטוס
- **תיקון בכל הסקייל** (CLAUDE.md זיכרון: `reference_paperclip_wakeup.md`) - **תיקון בכל הסקייל** (CLAUDE.md זיכרון: `reference_paperclip_wakeup.md`)
---
## 5. מחיקת npx cache → crash-loop בהפעלה (השרת מנצח את הפאטצ')
### מה קורה
Paperclip מופעל דרך `exec npx -y paperclipai@<version> run` ב-[start-paperclip.sh](../../.paperclip/scripts/start-paperclip.sh). npx **עושה reuse** ל-cache שכבר חולץ (`~/.npm/_npx/<hash>/node_modules/@paperclipai/server/`) — הוא **לא** מחלץ מחדש בכל הפעלה. כל עוד ה-cache קיים, הפאטצ'ים שהוחלו עליו פעם אחת נשמרים על פני ריסטארטים.
הבעיה מתחילה כש-ה-cache **נמחק** (`npm cache clean`, prune, או ניקוי ידני) בזמן שהתהליך רץ. אז נוצרות שתי תקלות נפרדות:
1. **התהליך הישן ממשיך "online" אבל שבור** — המודולים של node כבר טעונים בזיכרון, אז `/api/health` עדיין מחזיר 200, אבל `GET /` קורא את `ui-dist/index.html` **מהדיסק בכל בקשה** (`readFileSync`) → `ENOENT` → **HTTP 500** (`{"error":"Internal server error"}`). גם ה-URL הציבורי `pc.nautilus...` מחזיר 500.
2. **בריסטארט נכנסים ל-crash-loop** — npx מחלץ עותק **טרי ולא-מתוקן**. השרת מריץ `assertCloudDatabaseContract()` (ראה patch §4 ב-start script) שמסרב ל-embedded PG במצב authenticated/public → **קורס מיד**, לפני שלולאת-הרקע (5/20/60ש') מספיקה להחיל את פאטץ' ה-bypass. כל ריסטארט מחלץ-וקורס מחדש ⇒ עשרות ריסטארטים, שום דבר לא מאזין על 3100.
### ראיה אמפירית — 06/06/26
```
# התהליך הישן: online 5D אבל GET / נכשל
GET / 500 — ENOENT: no such file or directory,
open '.../@paperclipai/server/ui-dist/index.html'
/api/health → 200 # שורד כי לא קורא קבצים
# אחרי restart: crash-loop
pm2 describe paperclip → status: "waiting restart", restarts: 36, nothing on :3100
ERROR log → "Paperclip server failed to start.
authenticated public deployments require DATABASE_URL ...;
refusing embedded PostgreSQL fallback"
```
הורדת החבילה איטית (~30ש', native builds) — מה שמחמיר את ה-loop: `min_uptime` של PM2 קוטע את ה-npx **באמצע ההורדה** לפני שהוא מסיים לחלץ, כך שה-cache לעולם לא מתמלא.
### ההשפעה על הצינור שלנו
Paperclip מושבת לגמרי — ה-UI לא עולה לאף משתמש, וכל סוכני Paperclip (14 הסוכנים) לא יכולים לרוץ כי הם חולקים את התהליך הזה.
### תיקון — שער סינכרוני לפני הפעלת השרת
**שורש הבעיה:** פאטץ' ה-cloud-db-bypass חייב להיות על הדיסק **לפני** שהשרת רץ; לולאת-הרקע מאוחרת מדי. ב-[start-paperclip.sh](../../.paperclip/scripts/start-paperclip.sh) נוספה `ensure_patched_before_run()` (06/06/26) שרצה סינכרונית לפני `exec`:
1. בודקת אם `@paperclipai/server/ui-dist/index.html` קיים ב-cache (ראה "מלכודות בדרך" — זה הסמן הנכון, לא `dist/index.js`).
2. אם לא — מריצה `npx -y paperclipai@<version> --help`. זה מאלץ את npx **לחלץ את כל החבילה** (כולל `ui-dist/`) כדי להריץ את ה-CLI, שמדפיס help ו**יוצא לבד ב-exit 0** — **לא** מפעיל שרת ולא תופס את 3100 (אומת). אין תהליך-רקע, אין שרת לא-מתוקן מוקדם, ואין מה להרוג.
3. מחילה את **כל** הפאטצ'ים (כולל bypass) על ה-cache המחולץ — עם guard שלא מפיל את ה-wrapper אם patch נכשל.
4. רק אז `exec npx ... run` — npx עושה reuse ל-cache המתוקן והשרת עולה נקי.
לולאת-הרקע (post-exec) נשמרה כרשת-ביטחון idempotent.
**אומת מקצה-לקצה (06/06/26):** מחיקת ה-cache בכוונה + `pm2 restart` → השער חילץ אוטומטית דרך `--help` (~64ש'), תיקן, והשרת עלה ל-200 ב-~72ש'. מונה הריסטארטים של PM2 **לא זז** (אפס crash-loop).
> **מלכודות שהתגלו בדרך (גרסה ראשונה של הפיקס נכשלה):**
> 1. **סמן חילוץ שגוי** — `dist/index.js` נכתב ~שניות **לפני** `ui-dist/`. שער שממתין ל-`dist` ומריץ מיד → ui-dist עדיין חסר → 500. הסמן הנכון הוא `ui-dist/index.html` (הקובץ האחרון, וגם זה שגרם ל-500 המקורי).
> 2. **`set -e` + patch כושל** — אם `apply-hebrew.sh` רץ בלי ui-dist הוא מחזיר שגיאה, ותחת `set -e` ה-wrapper מת → crash-loop חדש. הפתרון: `apply_all_patches || echo WARNING`.
> 3. **`pkill -f "paperclipai@..."` תופס את עצמו** — מחרוזת הדפוס מופיעה ב-command line של ה-shell שמריץ את ה-pkill, אז הוא הורג את עצמו (exit 144). זו הסיבה שגישת spawn-`run`-then-`pkill` ננטשה לטובת `--help` שיוצא לבד. אם בכל זאת צריך להרוג — לפי PID (`kill $PID; pkill -P $PID`), לא לפי `-f`.
**שחזור** — עם הפיקס פרוס, מספיק `pm2 restart paperclip` וה-`ensure_patched_before_run()` מתאושש לבד. אם צריך לעשות זאת ידנית (fix אחר, דיבוג):
```bash
pm2 stop paperclip # לעצור loop אם קיים
export PATH=/home/chaim/.nvm/versions/node/v24.14.0/bin:$PATH
npx -y paperclipai@2026.529.0 --help >/dev/null 2>&1 # חילוץ נקי שיוצא לבד (לא מפעיל שרת)
find ~/.npm/_npx -path "*@paperclipai/server/ui-dist/index.html" -type f # לאמת חילוץ מלא
# להחיל פאטצ'ים על ה-cache, ובמיוחד ה-bypass:
bash ~/.paperclip/hermes-patches/apply-cloud-db-bypass.sh
bash ~/.paperclip/hebrew/apply-hebrew.sh
bash ~/.paperclip/hermes-patches/apply-hermes-fixes.sh
bash ~/.paperclip/hermes-patches/apply-deepseek-reaper-fix.sh
grep -q HEBREW_PATCH_BYPASS_CLOUD_DB \
~/.npm/_npx/*/node_modules/@paperclipai/server/dist/index.js && echo "BYPASS OK"
pm2 start paperclip && pm2 save # reuse ל-cache המתוקן
```
> אל תשתמש ב-`pkill -f "paperclipai@..."` / `-f "@paperclipai/server"` — הדפוס תופס את ה-shell של עצמך (exit 144). אם חייבים להרוג תהליך — לפי PID.
### סטטוס
- **תוקן ב-start script** ע"י `ensure_patched_before_run()` (06/06/26) — שער סינכרוני שמחלץ+מתקן לפני exec.
- **הערה מטעה תוקנה**: ההערה הישנה בראש ה-script טענה ש-`npx run` מחלץ-מחדש בכל הפעלה (לכן הסתמכו על לולאת-הרקע בלבד) — זה לא נכון, npx עושה reuse ל-cache תקין; הסכנה היא cache **מחוק**.
- **לקח כללי**: כל patch שה-target שלו הוא assert בזמן-startup חייב להיות מוחל לפני `exec`, לא בלולאת-רקע.

View File

@@ -250,6 +250,7 @@ Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוט
| [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) | חוזה 71 כלי-ה-MCP: envelope · שמות · idempotency · extract/get-symmetry · שלמות-הרשאות | G2, G3, G10 | | [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) | חוזה 71 כלי-ה-MCP: envelope · שמות · idempotency · extract/get-symmetry · שלמות-הרשאות | G2, G3, G10 |
| [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) | env-catalog SSoT · מקור-config יחיד (Coolify) · ללא hardcode · secrets · drift | G2, G4, G9 | | [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) | env-catalog SSoT · מקור-config יחיד (Coolify) · ללא hardcode · secrets · drift | G2, G4, G9 |
| [X11-citation-corroboration.md](X11-citation-corroboration.md) | citator פנימי — תיקוף הלכות בטיפול-שיפוטי מצטבר · תיקון-G10 מבוקר · סף-corroboration · התאמה-להלכה | G9, G10 | | [X11-citation-corroboration.md](X11-citation-corroboration.md) | citator פנימי — תיקוף הלכות בטיפול-שיפוטי מצטבר · תיקון-G10 מבוקר · סף-corroboration · התאמה-להלכה | G9, G10 |
| [X13-court-fetch.md](X13-court-fetch.md) | אחזור-פסיקה אוטומטי מנט המשפט — 3 שכבות (עליון/מנהלי/skip) · שירות-מארח · reCAPTCHA · שער-אנושי | G2, G3, G4, G5, G9, G10 |
> **X6X10 (מחזור-2):** מכסים את 8 משטחי-האפליקציה שמחוץ לצינור-הליבה (אינטגרציה, web-ui, מילוי-שדות, > **X6X10 (מחזור-2):** מכסים את 8 משטחי-האפליקציה שמחוץ לצינור-הליבה (אינטגרציה, web-ui, מילוי-שדות,
> אחסון-ניתוחים, כלי-MCP, deploy/env). הממצאים ב-[gap-audit.md](gap-audit.md) (GAP-24..62 → FU-9..15) > אחסון-ניתוחים, כלי-MCP, deploy/env). הממצאים ב-[gap-audit.md](gap-audit.md) (GAP-24..62 → FU-9..15)

View File

@@ -3,9 +3,10 @@
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md). זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר. כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
מבנה: 00 חוקה · 0107 מחזור-חיים · X1X10 חוצי-שלבים. ראה אינדקס מלא בחוקה. מבנה: 00 חוקה · 0107 מחזור-חיים · X1X13 חוצי-שלבים. ראה אינדקס מלא בחוקה.
- X1X5: מזהים · רב-חברתי · אינטגרציה+deploy · סוכנים · audit. - X1X5: מזהים · רב-חברתי · אינטגרציה+deploy · סוכנים · audit.
- X6X10 (מחזור-2, 8 משטחי-האפליקציה): חוזה UI↔API · לקוח-Paperclip · מילוי-שדות · חוזה כלי-MCP · deploy/env/secrets. - X6X10 (מחזור-2, 8 משטחי-האפליקציה): חוזה UI↔API · לקוח-Paperclip · מילוי-שדות · חוזה כלי-MCP · deploy/env/secrets.
- X11 · X13: citator תיקוף-הלכות · אחזור-פסיקה אוטומטי מנט המשפט (שירות).
מפות-ממצאים: [gap-audit.md](gap-audit.md) (GAP-01..62 → FU-1..15; מחזור-1 ✅ הושלם, מחזור-2 פתוח) · [ui-audit.md](ui-audit.md) (ביקורת 13 דפי-UI). מפות-ממצאים: [gap-audit.md](gap-audit.md) (GAP-01..62 → FU-1..15; מחזור-1 ✅ הושלם, מחזור-2 פתוח) · [ui-audit.md](ui-audit.md) (ביקורת 13 דפי-UI).
בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.md בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.md

View File

@@ -0,0 +1,151 @@
# X13 — אחזור-פסיקה אוטומטי מנט המשפט (Court Verdict Fetch)
> כפוף ל-[חוקת המערכת](00-constitution.md). תת-מערכת **שירות** (לא קורפוס) שמורידה פסקי-דין
> ציבוריים של בתי-משפט ומזרימה אותם ל**צינור-הקליטה הקנוני** של ספריית-הפסיקה. אחות-מושגית
> ל-[X12 — Digests Radar](X12-digests-radar.md) (הטריגר העיקרי) ול-[01-ingest](01-ingest.md)
> (היעד). אינה קורפוס רביעי ואינה מסלול-ingest מקביל.
---
## 0. ייעוד והקשר
יומון (digest) מצביע על פסק-דין נושא (`underlying_citation`, למשל `עת"מ 46111-12-22`). כשהפסק
אינו בקורפוס, המערכת **מאחזרת אותו אוטומטית** ממקור ציבורי, מחלצת טקסט, וקולטת אותו דרך
`precedent_library_upload``ingest_precedent`. כך הופך פסק-דין מ"מצוטט-בלבד" ל"שמיש לחיפוש
וחילוץ-הלכות".
**הבחנת-מקור קריטית:** רק **פסקי-דין של בתי-משפט** ניתנים לאחזור ציבורי. **החלטות ועדת-ערר**
אינן זמינות ציבורית (נדרש נבו) — מסומנות כפער ולא נשלחות לאחזור.
**שתי דרכי-מקור ציבוריות:**
- **עליון** (עע"מ/בג"ץ/ע"א/רע"א/בר"מ/דנ"א) → `supremedecisions.court.gov.il` — הורדה ישירה (httpx), ללא CAPTCHA.
- **מנהלי/מחוזי/שלום** (עת"מ/עמ"נ/...) → מציג-התיקים של **נט המשפט** — ASP.NET WebForms
(`__doPostBack`/VIEWSTATE), anti-bot של F5, reCAPTCHA על החיפוש הציבורי, מסמכים כ-S3 cleared URLs.
מחייב **דפדפן-אמת** (host-side), ולכן שירות-מארח ב-pm2 (כדפוס `legal-chat-service`).
---
## 1. ארכיטקטורה — שלוש שכבות (tiered)
```
underlying_citation → [classifier] → tier ∈ {supreme, admin, skip}
skip(ערר/בל"מ) → missing_precedent (נבו ידני) — לא אחזור
supreme → Tier 0: httpx בקונטיינר → supremedecisions — אוטונומי מלא
admin → Tier 1: legal-court-fetch-service (host/pm2) — אוטונומי-first
→ Camoufox stealth browser → external-search → reCAPTCHA(audio/Whisper)
→ download cleared PDF
→ Tier 2 fallback: VNC ידני / missing_precedent + התראה — שער-אנושי
(כל ה-tiers) → precedent_library_upload(source_type=court_ruling) → ingest_precedent
→ chunks+embeddings+halachot(pending) → relink digest / close gap
```
מצב-העבודה מנוהל בטבלת-תור `court_fetch_jobs` (idempotent, נצפה, retryable).
---
## 2. Invariants
### INV-CF1: מסלול-קליטה יחיד — אין ingest מקביל
**כלל:** כל ה-tiers מתנקזים ל**צינור-הקליטה הקנוני היחיד** (`precedent_library_upload`
`ingest_precedent`). המאחזר מספק קובץ+מטא בלבד; אסור לו לכתוב `case_law`/`precedent_chunks`/
`halachot` ישירות או לשכפל לוגיקת-chunking/embedding.
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G2](00-constitution.md#inv-g2) (מקור-אמת יחיד, אין מסלול מקביל) על תת-מערכת זו.
**אכיפה:** האורקסטרטור קורא רק ל-API/שירות-הקליטה הקיים; ביקורת-ארכיטקטורה ב-PR.
**הפרה ידועה:**
### INV-CF2: אין בליעה שקטה — כל אחזור נצפה
**כלל:** לכל פסק-דין שזוהה לאחזור יש רשומת-job עם סטטוס סופי מפורש
(`done`/`failed`/`manual`). כישלון-אחזור **לעולם אינו נבלע** — הוא מסומן ומועלה (Tier 2),
לא נזרק בשקט. `except: pass` אסור.
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G4](00-constitution.md#inv-g4) וכלל-ההנדסה "אין בליעה שקטה" (§6).
**אכיפה:** טבלת `court_fetch_jobs` (status+error+attempts) + לוג-warning בכל כישלון + Tier-2 gate.
**הפרה ידועה:** הפער הקיים ב-X12 — `try_autolink` שנכשל מחזיר `None` בשקט (יתוקן ע"י טריגר זה).
### INV-CF3: אוטונומי-first, שער-אנושי חובה ב-fallback
**כלל:** האחזור מנסה אוטונומית; אך כש-N נסיונות נכשלים, **שער-אנושי** (VNC לפתרון-CAPTCHA
חי / סימון missing_precedent + התראה) הוא **חובה, לא רשות**. המערכת אינה "מוותרת" ואינה
"מסתירה" — היא מסלימה לאדם.
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G10](00-constitution.md#inv-g10) (המערכת מסייעת; שערים אנושיים = invariant).
**אכיפה:** מונה-נסיונות בטבלת-התור + מעבר אוטומטי ל-status=`manual` עם נתיב-פעולה ל-chaim.
**הפרה ידועה:**
### INV-CF4: אחזור-אחראי (politeness) — סדרתי, מרווח, חתימה-אמיתית
**כלל:** האחזור מאתר-ממשלתי הוא **אחראי**: סדרתי (לא מקבילי), עם cooldown בין בקשות,
כיבוד-`robots`/תנאי-שימוש, ו-rate מתון. אסור flooding/parallel-hammering שעלול לחסום IP
או להעמיס על שירות ציבורי.
**מקורות:** RFC 9309 (*Robots Exclusion Protocol*, IETF 2022) · Google Search Central —
*Crawler / crawl-rate guidance* · OWASP — *Automated Threat Handbook* (OAT-021 Denial of
Service / responsible automation) | סטטוס: verified
**אכיפה:** האורקסטרטור והשירות אוכפים serial + `INTER_FETCH_COOLDOWN_SEC`; Camoufox מספק
חתימת-דפדפן אמיתית (לא spoof-חמדני). מראה לדפוס-התור ב-[`precedent_library.py`](../../mcp-server/src/legal_mcp/services/precedent_library.py).
**הפרה ידועה:**
### INV-CF5: אחזור idempotent
**כלל:** אחזור הוא **idempotent** — מפתח-job דטרמיניסטי לפי `case_number` מנורמל. אחזור
חוזר של אותו תיק אינו יוצר job כפול ואינו קולט פסק-דין פעמיים (upsert על המפתח הקנוני).
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G3](00-constitution.md#inv-g3) (ingest idempotent) ו-[G1](00-constitution.md#inv-g1) (מזהה מנורמל בכתיבה).
**אכיפה:** אילוץ-ייחודיות על `court_fetch_jobs.case_number_norm`; הקליטה עצמה idempotent דרך `ingest_precedent`.
**הפרה ידועה:**
### INV-CF6: שער-סיווג מקור — רק פסקי-דין של בתי-משפט
**כלל:** רק ציטוט שסווג כ**פסק-דין של בית-משפט** נשלח לאחזור. **ועדת-ערר (ערר/בל"מ) לעולם
אינה נשלחת לאחזור-ציבורי** (נדרש נבו) — היא מסומנת `missing_precedent` בלבד. הפריט הנקלט
נושא `source_type=court_ruling`, `source_kind=external_upload`, `precedent_level` לפי הערכאה.
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G5](00-constitution.md#inv-g5) (metadata מלא + הפרדת-קורפוס)
ותואם את הבחנת-המקור ב-[01-ingest](01-ingest.md) (`court_ruling` מול `appeals_committee`).
**אכיפה:** המסווג מחזיר `tier=skip` ל-ערר/בל"מ; הקליטה אוכפת `source_type`.
**הפרה ידועה:**
### INV-CF7: עקיבוּת-מקור + גבול-ToS
**כלל:** כל אחזור נושם **provenance** מלא (`source_url`, tier, זמן, מזהה-job) ב-audit-trail.
האחזור מוגבל ל**מסמכים ציבוריים** הזמינים ללא הזדהות (smart-card); אופי המערכת הוא
**הורדה-בסיוע** (עם שער-אנושי), לא בוט-סמוי לעקיפת בקרת-גישה.
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G9](00-constitution.md#inv-g9) (עקיבוּת + audit-trail);
גבול-ה-ToS מועלה ליו"ר (חיים) כשיקול-מדיניות (עיקרון-עבודה 4: המשתמש הוא הסמכות).
**אכיפה:** `source_url`+tier נשמרים על `case_law`/`court_fetch_jobs`; שער-אנושי שומר על אופי בסיוע.
**הפרה ידועה:**
---
## 3. מודל-נתונים — `court_fetch_jobs`
| עמודה | טיפוס | תפקיד |
|--------|-------|-------|
| `id` | UUID PK | מזהה-job |
| `case_number_norm` | TEXT UNIQUE | מפתח-idempotency קנוני (INV-CF5) |
| `citation_raw` | TEXT | הציטוט המקורי כפי שזוהה |
| `tier` | TEXT | `supreme` \| `admin` \| `skip` |
| `court` | TEXT | ערכאה שזוהתה |
| `status` | TEXT | `pending` \| `running` \| `done` \| `failed` \| `manual` |
| `attempts` | INT | מונה-נסיונות (ל-Tier 2 gate, INV-CF3) |
| `error` | TEXT | הודעת-כישלון אחרונה (INV-CF2) |
| `case_law_id` | UUID FK | הפסק שנקלט (NULL עד done) |
| `digest_id` | UUID FK | היומון-מקור (NULL לאד-הוק) |
| `source_url` | TEXT | provenance (INV-CF7) |
| `created_at` / `updated_at` | TIMESTAMPTZ | |
---
## 4. רכיבי-מימוש (מיפוי לקוד)
| רכיב | קובץ | מקור-תבנית / שימוש-חוזר |
|------|------|------------------------|
| מסווג | `mcp-server/.../services/court_citation.py` | regex מ-`citation_extractor.py:67-132` |
| Tier 0 | `services/court_fetch_supreme.py` | httpx; דפוס-cooldown מ-`precedent_library.py:176-186` |
| Tier 1 שירות | `mcp-server/.../court_fetch_service/server.py` | שכפול `chat_service/server.py` (aiohttp+Bearer+bind 10.0.1.1) |
| Camoufox client | `court_fetch_service/camofox_client.py` | חיקוי `~/.hermes/.../browser_camofox.py` |
| reCAPTCHA audio | `court_fetch_service/recaptcha_audio.py` | faster-whisper מקומי |
| proxy בקונטיינר | `web/court_fetch_proxy.py` | שכפול `web/chat_proxy.py` |
| pm2 | `scripts/legal-court-fetch-service.config.cjs` | שכפול `legal-chat-service.config.cjs` |
| אורקסטרטור+תור | `services/court_fetch_orchestrator.py` + `db.py` (SCHEMA_Vxx) | דפוס-תור קיים |
| כלי-MCP | `tools/court_fetch.py` (`court_verdict_fetch`) | חוזה-envelope [X9](X9-mcp-tool-contract.md) |
| טריגר | `services/digest_library.py` (`try_autolink` fail-path) | X12 |
| סוד | `COURT_FETCH_SHARED_SECRET` (Infisical + Coolify) | דפוס `LEGAL_CHAT_SHARED_SECRET`, [X10](X10-deploy-env-secrets.md) |
---
## 5. סיכונים (R&D — לעקוב)
- reCAPTCHA נלחם פעיל בפותרי-אודיו → שיעור-כישלון אפשרי גבוה → Tier 2 הוא קו-ההגנה (INV-CF3).
- F5/anti-bot עלול לחסום IP → politeness סדרתי + Camoufox (INV-CF4).
- שבירות מול שינויי-אתר → ריכוז selectors במקום אחד + בדיקות-עשן תקופתיות.
- גבול-ToS על אתר .gov → INV-CF7 + שיקול-יו"ר.

View File

@@ -162,6 +162,13 @@ HALACHA_DEDUP_COSINE = float(os.environ.get("HALACHA_DEDUP_COSINE", "0.93"))
# dropping a possibly-distinct principle unreviewed. 0.83 from the same cleanup. # dropping a possibly-distinct principle unreviewed. 0.83 from the same cleanup.
HALACHA_DEDUP_BAND_COSINE = float(os.environ.get("HALACHA_DEDUP_BAND_COSINE", "0.83")) HALACHA_DEDUP_BAND_COSINE = float(os.environ.get("HALACHA_DEDUP_BAND_COSINE", "0.83"))
# Halacha review-queue clustering (#84.2) — when the review queue is requested
# with cluster=true, halachot of the SAME precedent whose rule-embeddings are
# within this cosine are grouped into ONE review card (canonical + variants), so
# the chair judges near-identical principles once instead of repeatedly. Display
# only — never merges/deletes. 0.90 = "same principle, reworded".
HALACHA_CLUSTER_COSINE = float(os.environ.get("HALACHA_CLUSTER_COSINE", "0.90"))
# Halacha NLI entailment validator (#81.3) — after extraction, a claude_session # Halacha NLI entailment validator (#81.3) — after extraction, a claude_session
# judge checks each halacha's rule_statement is entailed by its supporting_quote. # judge checks each halacha's rule_statement is entailed by its supporting_quote.
# Non-entailed (neutral/contradiction) → quality flag 'nli_unsupported' that # Non-entailed (neutral/contradiction) → quality flag 'nli_unsupported' that

View File

@@ -0,0 +1,7 @@
"""Host-side Tier-1 verdict fetch service (X13).
Runs on the host under pm2 (it needs a real browser, which the legal-ai
container can't run). Drives a Camoufox stealth browser against נט המשפט to
download administrative/district-court verdicts the Supreme portal (Tier 0)
doesn't carry. See docs/spec/X13-court-fetch.md.
"""

View File

@@ -0,0 +1,148 @@
"""Camoufox-browser client + נט-המשפט navigation flow (X13, Tier 1).
Open-source, zero-API-cost stealth browsing: a self-hosted ``camofox-browser``
REST server (``jo-inc/camofox-browser``, wrapping Camoufox — a Firefox fork
with C++ fingerprint spoofing) drives a real browser. We talk to it over the
same REST surface the Hermes agent uses (``~/.hermes/.../browser_camofox.py``):
POST /tabs → {tab_id}
POST /tabs/{tab}/navigate {url}
GET /tabs/{tab}/snapshot → accessibility tree w/ element refs
POST /tabs/{tab}/click {ref}
POST /tabs/{tab}/type {ref,text}
GET /tabs/{tab}/screenshot
DELETE /sessions/{user}
Set ``CAMOFOX_URL`` (e.g. ``http://127.0.0.1:9377``) to enable. The server's
``/health`` exposes a VNC URL — that's the human-fallback surface (INV-CF3):
when the autonomous reCAPTCHA solve fails, the chair opens the VNC and solves
it live, and this flow continues.
⚠ CALIBRATION: the נט-המשפט external-case-search is an ASP.NET WebForms app
behind an F5 WAF + reCAPTCHA. The element selectors and step sequence below
are the *documented plan* of the flow; they must be calibrated against the
live snapshot on first run (the site rate-limited static probing during
development). Every step that can't find its target **raises** a clear Hebrew
reason (INV-CF2 — no silent success-with-garbage) so the orchestrator escalates
to the Tier-2 human fallback rather than returning an empty/wrong file.
"""
from __future__ import annotations
import logging
import os
import httpx
logger = logging.getLogger(__name__)
# נט המשפט public entry points (discovered from the homepage __doPostBack menu).
NGCS_HOME = "https://www.court.gov.il/ngcs.web.site/homepage.aspx"
CAMOFOX_URL = os.environ.get("CAMOFOX_URL", "").rstrip("/")
_TIMEOUT = float(os.environ.get("COURT_FETCH_BROWSER_TIMEOUT_S", "60"))
class CamofoxUnavailable(RuntimeError):
"""camofox-browser isn't configured/reachable."""
class NgcsFlowError(RuntimeError):
"""A step in the נט-המשפט flow failed (selector/CAPTCHA/navigation)."""
def is_enabled() -> bool:
return bool(CAMOFOX_URL)
async def health() -> dict:
"""Probe camofox-browser; surfaces the VNC URL for the human fallback."""
if not CAMOFOX_URL:
raise CamofoxUnavailable("CAMOFOX_URL is not set")
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{CAMOFOX_URL}/health")
r.raise_for_status()
return r.json()
class _Browser:
"""Thin async wrapper over the camofox-browser REST surface."""
def __init__(self, client: httpx.AsyncClient, tab_id: str, user_id: str):
self._c = client
self.tab = tab_id
self.user = user_id
@classmethod
async def open(cls, client: httpx.AsyncClient) -> "_Browser":
r = await client.post(f"{CAMOFOX_URL}/tabs", json={})
r.raise_for_status()
data = r.json()
return cls(client, data["tab_id"], data.get("user_id", data["tab_id"]))
async def navigate(self, url: str) -> None:
r = await self._c.post(f"{CAMOFOX_URL}/tabs/{self.tab}/navigate", json={"url": url})
r.raise_for_status()
async def snapshot(self) -> dict:
r = await self._c.get(f"{CAMOFOX_URL}/tabs/{self.tab}/snapshot")
r.raise_for_status()
return r.json()
async def click(self, ref: str) -> dict:
r = await self._c.post(f"{CAMOFOX_URL}/tabs/{self.tab}/click", json={"ref": ref})
r.raise_for_status()
return r.json()
async def type(self, ref: str, text: str) -> None:
r = await self._c.post(
f"{CAMOFOX_URL}/tabs/{self.tab}/type", json={"ref": ref, "text": text}
)
r.raise_for_status()
async def close(self) -> None:
try:
await self._c.delete(f"{CAMOFOX_URL}/sessions/{self.user}")
except httpx.HTTPError:
pass
async def fetch_admin_verdict(
*, file_number: str, month: str, year: str, case_number: str, court: str
) -> dict:
"""Drive נט המשפט to download an admin/district verdict PDF.
Returns ``{content: bytes, filename: str, source_url: str, court: str}``.
Raises ``CamofoxUnavailable`` / ``NgcsFlowError`` on failure.
The flow (to be calibrated against the live snapshot):
1. Open the homepage; trigger "חיפוש תיקים חיצוני" (btnExternalSearchCases).
2. Fill the case-number / month / year fields.
3. Solve the reCAPTCHA via the audio challenge (recaptcha_audio); on
repeated failure, surface the VNC URL for a human solve (INV-CF3).
4. Submit; open the matched case; locate the verdict ("פסק דין") document.
5. Download the cleared PDF (served via S3 pre-signed URL) and return bytes.
"""
if not CAMOFOX_URL:
raise CamofoxUnavailable(
"שירות-הדפדפן (camofox-browser) אינו מוגדר — הגדר CAMOFOX_URL "
"והפעל את jo-inc/camofox-browser. ראה docs/spec/X13-court-fetch.md."
)
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
br = await _Browser.open(client)
try:
await br.navigate(NGCS_HOME)
snap = await br.snapshot()
_ = snap # calibration anchor: locate btnExternalSearchCases here.
# The concrete selector/CAPTCHA/download steps require live
# calibration with camofox running. Until calibrated we fail
# loudly so the orchestrator escalates to the human fallback
# (INV-CF2/CF3) rather than pretending success.
raise NgcsFlowError(
"זרימת נט-המשפט (Tier 1) ממתינה לכיול מול snapshot חי של "
"camofox-browser — בקשת-אחזור מוסלמת ל-fallback אנושי (VNC/ידני)."
)
finally:
await br.close()

View File

@@ -0,0 +1,80 @@
"""Open-source reCAPTCHA v2 audio-challenge solver (X13, Tier 1).
Pure open-source, zero-API-cost: switch the reCAPTCHA widget to its **audio**
challenge, download the mp3, transcribe it with a **local Whisper** model
(``faster-whisper``), and submit the transcript. This is the well-known
"Buster"-style technique. It is intentionally a *best-effort* solver —
reCAPTCHA actively fights audio solving, so a non-trivial failure rate is
expected and handled by the Tier-2 human fallback (INV-CF3), never hidden.
Model is loaded lazily and cached; ``WHISPER_MODEL`` (default ``small``) and
``WHISPER_DEVICE`` (default ``cpu``) tune it. The dependency is optional — if
``faster-whisper`` isn't installed, ``transcribe_audio`` raises a clear error
so the caller falls back to a human solve rather than crashing the service.
"""
from __future__ import annotations
import logging
import os
import tempfile
import httpx
logger = logging.getLogger(__name__)
_WHISPER_MODEL_NAME = os.environ.get("WHISPER_MODEL", "small")
_WHISPER_DEVICE = os.environ.get("WHISPER_DEVICE", "cpu")
_model = None
class AudioSolveUnavailable(RuntimeError):
"""faster-whisper isn't installed — cannot solve audio locally."""
def _get_model():
global _model
if _model is not None:
return _model
try:
from faster_whisper import WhisperModel # type: ignore
except ImportError as e:
raise AudioSolveUnavailable(
"faster-whisper אינו מותקן — לא ניתן לפתור reCAPTCHA אודיו מקומית. "
"התקן `pip install faster-whisper` או הסתמך על fallback אנושי (VNC)."
) from e
logger.info("loading whisper model %s on %s", _WHISPER_MODEL_NAME, _WHISPER_DEVICE)
_model = WhisperModel(
_WHISPER_MODEL_NAME, device=_WHISPER_DEVICE, compute_type="int8"
)
return _model
async def download_audio(audio_url: str) -> bytes:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as c:
r = await c.get(audio_url)
r.raise_for_status()
return r.content
def transcribe_audio(mp3_bytes: bytes) -> str:
"""Transcribe a reCAPTCHA audio clip to its (English) digit/word phrase.
Raises ``AudioSolveUnavailable`` if the local model isn't installed.
"""
model = _get_model()
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=True) as f:
f.write(mp3_bytes)
f.flush()
# reCAPTCHA audio is English regardless of page locale.
segments, _info = model.transcribe(f.name, language="en")
text = " ".join(seg.text for seg in segments).strip()
# Normalise: reCAPTCHA expects the bare phrase, lower-case, no punctuation.
cleaned = "".join(ch for ch in text.lower() if ch.isalnum() or ch.isspace())
return " ".join(cleaned.split())
async def solve_from_audio_url(audio_url: str) -> str:
"""Convenience: download + transcribe an audio-challenge URL."""
mp3 = await download_audio(audio_url)
return transcribe_audio(mp3)

View File

@@ -0,0 +1,145 @@
"""Host-side HTTP bridge for Tier-1 verdict fetching (X13).
Mirrors ``legal_mcp.chat_service.server`` — the proven host-side pattern: an
aiohttp app, bound to the docker bridge gateway, Bearer-auth, that does the one
thing the container can't (here: drive a real browser against נט המשפט).
Endpoints:
POST /fetch body {file_number, month, year, case_number, court}
{ok, content_b64, filename, source_url, court, reason}
REQUIRES Authorization: Bearer <COURT_FETCH_SHARED_SECRET>.
GET /health liveness (no auth); reports camofox + VNC URL if available.
Run with pm2:
pm2 start scripts/legal-court-fetch-service.config.cjs
Security posture (identical rationale to legal-chat-service):
1. Bind defaults to ``10.0.1.1`` (docker0 bridge gateway) — reachable from
the host + containers on docker bridges, invisible to outside networks.
2. ``/fetch`` requires a Bearer token (constant-time compare); the service
refuses to start without ``COURT_FETCH_SHARED_SECRET`` set.
3. ``/health`` is unauthenticated and spawns nothing.
"""
from __future__ import annotations
import argparse
import base64
import hmac
import json
import logging
import os
import sys
from aiohttp import web
_pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
if _pkg_root not in sys.path:
sys.path.insert(0, _pkg_root)
from legal_mcp.court_fetch_service import camofox_client # noqa: E402
logger = logging.getLogger("legal_court_fetch_service")
_SHARED_SECRET: str = ""
async def health(request: web.Request) -> web.Response:
info = {"ok": True, "service": "legal-court-fetch-service",
"camofox_enabled": camofox_client.is_enabled()}
if camofox_client.is_enabled():
try:
info["camofox"] = await camofox_client.health()
except Exception as e: # health must never throw
info["camofox_error"] = str(e)
return web.json_response(info)
def _check_bearer(request: web.Request) -> web.Response | None:
auth = request.headers.get("Authorization", "")
expected = "Bearer " + _SHARED_SECRET
if not auth or not hmac.compare_digest(auth, expected):
return web.json_response(
{"error": "unauthorized: missing or invalid Bearer token"}, status=401
)
return None
async def fetch(request: web.Request) -> web.Response:
unauth = _check_bearer(request)
if unauth is not None:
return unauth
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"error": "invalid JSON body"}, status=400)
required = ("file_number", "month", "year")
if not all(body.get(k) for k in required):
return web.json_response(
{"ok": False, "reason": f"missing one of {required}"}, status=400
)
try:
result = await camofox_client.fetch_admin_verdict(
file_number=str(body["file_number"]),
month=str(body["month"]),
year=str(body["year"]),
case_number=str(body.get("case_number", "")),
court=str(body.get("court", "")),
)
return web.json_response({
"ok": True,
"content_b64": base64.b64encode(result["content"]).decode("ascii"),
"filename": result.get("filename", ""),
"source_url": result.get("source_url", ""),
"court": result.get("court", ""),
})
except (camofox_client.CamofoxUnavailable, camofox_client.NgcsFlowError) as e:
# Expected, recoverable failure → orchestrator escalates (INV-CF3).
return web.json_response({"ok": False, "reason": str(e)}, status=200)
except Exception as e: # noqa: BLE001
logger.exception("fetch failed")
return web.json_response({"ok": False, "reason": f"unexpected: {e}"}, status=200)
def build_app() -> web.Application:
app = web.Application(client_max_size=64 * 1024 * 1024)
app.router.add_get("/health", health)
app.router.add_post("/fetch", fetch)
return app
def main() -> int:
parser = argparse.ArgumentParser(description="legal-court-fetch-service")
parser.add_argument("--port", type=int, default=8771)
parser.add_argument("--host", default="10.0.1.1",
help="bind address; default = docker0 bridge gateway")
parser.add_argument("--log-level", default="INFO")
args = parser.parse_args()
logging.basicConfig(level=args.log_level.upper(),
format="%(asctime)s %(name)s %(levelname)s %(message)s")
secret = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
if not secret:
logger.error(
"COURT_FETCH_SHARED_SECRET is empty; refusing to start. Set it in "
"/home/chaim/.legal-court-fetch-service.env (loaded by pm2) and "
"mirror it as a Coolify env var on the legal-ai app."
)
return 2
if len(secret) < 24:
logger.error("COURT_FETCH_SHARED_SECRET too short (>=32 chars expected).")
return 2
global _SHARED_SECRET
_SHARED_SECRET = secret
app = build_app()
logger.info("legal-court-fetch-service listening on %s:%d", args.host, args.port)
web.run_app(app, host=args.host, port=args.port, print=lambda _m: None)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -58,6 +58,7 @@ from legal_mcp.tools import ( # noqa: E402
missing_precedents as mp_tools, missing_precedents as mp_tools,
citations as cit_tools, citations as cit_tools,
training_enrichment as train_tools, training_enrichment as train_tools,
court_fetch as cf_tools,
) )
@@ -895,6 +896,22 @@ async def missing_precedent_close(
) )
# ── Court verdict auto-fetch (X13) ────────────────────────────────
@mcp.tool()
async def court_verdict_fetch(citation: str) -> str:
"""אחזור אוטומטי של פסק-דין בית-משפט מנט המשפט/פורטל-העליון וקליטה לקורפוס.
מסווג את הציטוט (עליון→Tier0 / מנהלי→Tier1 / ערר→skip), מוריד וקולט דרך
צינור-הקליטה הקנוני. דוגמה: 'עת"מ 46111-12-22'. כלי מקומי בלבד."""
return await cf_tools.court_verdict_fetch(citation)
@mcp.tool()
async def court_fetch_status(case_number: str = "", status_filter: str = "") -> str:
"""סטטוס תור-אחזור הפסיקה. case_number לפריט יחיד, או status_filter (pending/failed/manual/done)."""
return await cf_tools.court_fetch_status(case_number, status_filter)
# ── Internal citations graph (TaskMaster #34) ───────────────────── # ── Internal citations graph (TaskMaster #34) ─────────────────────

View File

@@ -0,0 +1,204 @@
"""Court-citation classifier for the auto-fetch subsystem (X13).
Given a raw citation string (typically a digest's ``underlying_citation``,
e.g. ``עת"מ 46111-12-22 יכין-אפק נ' הוועדה המחוזית``), decide:
* **which tier** can fetch it (``supreme`` | ``admin`` | ``skip``), and
* the **canonical case number** plus, for נט המשפט, the
(file, month, year) triple the public case-search form needs.
Tier mapping (INV-CF6 — only court rulings are auto-fetched; ועדת-ערר is
never sent to a public fetch, it needs Nevo):
* ``supreme`` — Supreme Court prefixes (עע"מ/בג"ץ/ע"א/רע"א/דנ"א/בר"מ/בש"א).
Fetched directly from ``supremedecisions.court.gov.il`` (Tier 0, no CAPTCHA).
* ``admin`` — district / administrative-court prefixes (עת"מ/עמ"נ/…) and
the bare נט-המשפט "filed" format ``NNNNN-MM-YY``. Fetched via the
host-side stealth browser against נט המשפט (Tier 1).
* ``skip`` — ועדת-ערר (ערר/בל"מ). Not publicly fetchable → missing_precedent.
Regex families intentionally mirror ``citation_extractor.py`` (the canonical
prefix/number patterns) so the two stay in sync — we reuse ``_NUM_RX`` shape
and ``_normalize_case_number`` semantics rather than inventing a parallel
parser (INV-CF1 / engineering "symmetry" rule).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# Canonical number core, identical shape to citation_extractor._NUM_RX:
# 3-5 digits, optional separator + 2-4 digits, optional third group
# (the NNNNN-MM-YY "filed" format — 46111-12-22 = file 46111, month 12, yr 22).
_NUM_RX = r"\d{1,5}(?:[-/]\d{1,4}(?:[-/]\d{2,4})?)?"
# Hebrew gershayim: straight (") or curly (״).
_Q = r"[\"״]"
# Optional leading one-letter Hebrew preposition/conjunction (ב/ל/ה/ו/כ/מ/ש)
# attached to the prefix — e.g. "בערר", "וערר", "כפי שקבעתי בערר". Anchored by
# a lookbehind that forbids a *preceding* Hebrew letter, so we don't match a
# prefix buried inside a longer word. Regex backtracking lets the preposition
# match empty when the prefix itself starts with one of these letters (בג"ץ).
_LEAD = r"(?<![א-ת])(?:[בלהוכמש])?"
# Supreme Court prefixes → Tier 0 (supremedecisions public download API).
_SUPREME_PREFIXES = [
rf"עע{_Q}מ", # ערעור מנהלי (לעליון)
rf"בג{_Q}ץ", # בג"ץ
rf"בג{_Q}צ", # variant spelling
rf"דנג{_Q}ץ", # דיון נוסף בג"ץ
rf"ע{_Q}א", # ערעור אזרחי
rf"רע{_Q}א", # רשות ערעור אזרחי
rf"דנ{_Q}א", # דיון נוסף אזרחי
rf"בר{_Q}מ", # בקשת רשות ערעור מנהלי (עליון)
rf"בש{_Q}א", # בקשת רשות … (עליון)
]
# District / administrative-court prefixes → Tier 1 (נט המשפט case viewer).
_ADMIN_PREFIXES = [
rf"עת{_Q}מ", # עתירה מנהלית (בימ"ש לעניינים מנהליים)
rf"עמ{_Q}נ", # ערעור מנהלי (מחוזי)
rf"ת{_Q}א", # תביעה אזרחית (מחוזי/שלום)
rf"ה{_Q}פ", # המרצת פתיחה
]
# Appeals-committee → skip (needs Nevo; never auto-fetched).
_SKIP_PREFIXES = [
rf"ערר",
rf"בל{_Q}מ",
]
_SUPREME_RX = re.compile(
_LEAD + r"(" + "|".join(_SUPREME_PREFIXES) + r")\s*(" + _NUM_RX + r")",
re.UNICODE,
)
_ADMIN_RX = re.compile(
_LEAD + r"(" + "|".join(_ADMIN_PREFIXES) + r")\s*(" + _NUM_RX + r")",
re.UNICODE,
)
_SKIP_RX = re.compile(
_LEAD + r"(" + "|".join(_SKIP_PREFIXES) + r")" + r"(?:\s*\([^)\n]{0,80}\))?\s*(" + _NUM_RX + r")",
re.UNICODE,
)
# Bare נט-המשפט filed format with no prefix: 46111-12-22 (5/4-digit file,
# 1-2 digit month, 2-4 digit year). Used when a digest gives just the number.
_BARE_FILED_RX = re.compile(r"(?<!\d)(\d{1,5})-(\d{1,2})-(\d{2,4})(?!\d)", re.UNICODE)
@dataclass
class CourtCitation:
"""Result of classifying a citation for auto-fetch routing."""
tier: str # "supreme" | "admin" | "skip" | "unknown"
court_prefix: str # e.g. 'עת"מ', or "" for bare/unknown
case_number_raw: str # the matched number as written, e.g. "46111-12-22"
case_number_norm: str # canonical: slashes→dashes, digits/sep only
# נט-המשפט form fields (only when the filed format NNNNN-MM-YY is present):
file_number: str | None = None
month: str | None = None
year: str | None = None
@property
def fetchable(self) -> bool:
return self.tier in ("supreme", "admin")
def normalize_case_number(raw: str) -> str:
"""Canonicalize a case number for idempotency keys / matching.
Mirrors ``citation_extractor._normalize_case_number``: strip everything
but digits and separators, unify ``/`` → ``-``. Display value is never
derived from this.
"""
cleaned = re.sub(r"[^\d/\-]", "", raw or "")
return cleaned.replace("/", "-").strip("-")
def _split_filed(num_norm: str) -> tuple[str, str, str] | None:
"""Split a normalized NNNNN-MM-YY number into (file, month, year).
Only the three-group "filed" format yields a נט-המשפט triple; two-group
formats (1234-22 / 1234/22) are Supreme-style serials and return None.
"""
m = _BARE_FILED_RX.fullmatch(num_norm)
if not m:
return None
file_no, month, year = m.group(1), m.group(2), m.group(3)
# Plausibility: month 1-12, year 2-4 digits. Reject implausible months
# (avoids mis-reading a 2-group serial that slipped through).
if not (1 <= int(month) <= 12):
return None
return file_no, month, year
def classify(citation: str) -> CourtCitation:
"""Classify a raw citation string into a fetch tier + parsed number.
Resolution order: ועדת-ערר (skip) is checked FIRST so an "ערר" prefix is
never mis-routed to a court tier; then Supreme prefixes; then admin
prefixes; then a bare filed number defaults to ``admin`` (נט המשפט is the
only public source for prefix-less district/שלום numbers).
"""
text = (citation or "").strip()
if not text:
return CourtCitation("unknown", "", "", "")
# 1. ועדת-ערר → skip (must win over any court match).
m = _SKIP_RX.search(text)
if m:
raw = m.group(2)
return CourtCitation(
tier="skip",
court_prefix=m.group(1),
case_number_raw=raw,
case_number_norm=normalize_case_number(raw),
)
# 2. Supreme Court prefix → Tier 0.
m = _SUPREME_RX.search(text)
if m:
raw = m.group(2)
return CourtCitation(
tier="supreme",
court_prefix=m.group(1),
case_number_raw=raw,
case_number_norm=normalize_case_number(raw),
)
# 3. District / admin prefix → Tier 1.
m = _ADMIN_RX.search(text)
if m:
raw = m.group(2)
norm = normalize_case_number(raw)
filed = _split_filed(norm)
return CourtCitation(
tier="admin",
court_prefix=m.group(1),
case_number_raw=raw,
case_number_norm=norm,
file_number=filed[0] if filed else None,
month=filed[1] if filed else None,
year=filed[2] if filed else None,
)
# 4. Bare filed number (no prefix) → default admin (נט המשפט).
m = _BARE_FILED_RX.search(text)
if m:
raw = m.group(0)
norm = normalize_case_number(raw)
filed = _split_filed(norm)
if filed:
return CourtCitation(
tier="admin",
court_prefix="",
case_number_raw=raw,
case_number_norm=norm,
file_number=filed[0],
month=filed[1],
year=filed[2],
)
return CourtCitation("unknown", "", "", "")

View File

@@ -0,0 +1,241 @@
"""X13 orchestrator — classify → fetch → ingest → record.
The single entry point (`fetch_and_ingest`) wires the three tiers to the
**canonical** precedent-ingest pipeline (INV-CF1 — no parallel ingest path)
and keeps the `court_fetch_jobs` row honest at every step (INV-CF2 — a job
always ends in an explicit terminal state, never a silent drop).
Tier routing (from `court_citation.classify`):
* ``skip`` — ועדת-ערר → never fetched; logged as a missing_precedent gap.
* ``supreme`` — Tier 0, in-process httpx (`court_fetch_supreme`).
* ``admin`` — Tier 1, the host-side stealth-browser service over loopback.
Fallback (INV-CF3): after ``MAX_AUTONOMOUS_ATTEMPTS`` autonomous failures the
job flips to ``manual`` and a missing_precedent row is opened so the chair
sees the gap and can solve the CAPTCHA live (VNC) or drop the file manually.
This module runs **in the local MCP server only** — `ingest_precedent` drives
halacha extraction via the local ``claude`` CLI (see `claude_session.py`). It
is invoked from the `court_verdict_fetch` MCP tool, not from the container.
"""
from __future__ import annotations
import logging
import os
import tempfile
from pathlib import Path
from uuid import UUID
import httpx
from legal_mcp.services import court_citation, db
from legal_mcp.services.court_fetch_supreme import (
SupremeFetchError,
fetch_supreme_verdict,
)
logger = logging.getLogger(__name__)
# After this many autonomous failures, stop auto-retrying and escalate to a
# human (INV-CF3). Kept low — the .gov site shouldn't be hammered (INV-CF4).
MAX_AUTONOMOUS_ATTEMPTS = int(os.environ.get("COURT_FETCH_MAX_ATTEMPTS", "2"))
# The host-side Tier-1 browser service (pm2). The MCP server runs on the host,
# so it reaches the service over loopback directly (the container bridge in
# web/court_fetch_proxy.py is a separate, optional entry point).
COURT_FETCH_SERVICE_URL = os.environ.get(
"COURT_FETCH_SERVICE_URL", "http://127.0.0.1:8771"
)
_SHARED_SECRET = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
_TIER1_TIMEOUT_S = float(os.environ.get("COURT_FETCH_TIER1_TIMEOUT_S", "300"))
# Provenance level by tier — Supreme rulings are binding; admin-court verdicts
# are administrative (set is_binding conservatively True, chair can downgrade).
_LEVEL_BY_TIER = {"supreme": "עליון", "admin": "מנהלי"}
class _Tier1Unavailable(RuntimeError):
"""The host browser service is not reachable / not configured."""
async def _ingest_bytes(
*, content: bytes, filename: str, citation: str, tier: str,
court: str, source_url: str,
) -> dict:
"""Stage bytes to a temp file and run the canonical ingest (INV-CF1)."""
from legal_mcp.services import precedent_library
suffix = Path(filename).suffix or ".pdf"
tmp = tempfile.NamedTemporaryFile(
prefix="court_fetch_", suffix=suffix, delete=False
)
try:
tmp.write(content)
tmp.flush()
tmp.close()
result = await precedent_library.ingest_precedent(
file_path=tmp.name,
citation=citation,
court=court,
source_type="court_ruling", # INV-CF6
precedent_level=_LEVEL_BY_TIER.get(tier, ""),
is_binding=True,
)
# Stamp provenance on the new case_law row (INV-CF7).
case_law_id = result.get("case_law_id")
if case_law_id and source_url:
try:
await db.update_case_law(
UUID(str(case_law_id)), source_url=source_url
)
except Exception: # provenance is best-effort, never blocks ingest
logger.warning("could not stamp source_url on %s", case_law_id)
return result
finally:
try:
os.unlink(tmp.name)
except OSError:
pass
async def _fetch_tier1_admin(cit: court_citation.CourtCitation) -> dict:
"""Call the host-side browser service to fetch an admin-court verdict.
Returns the service's JSON: ``{ok, content_b64, filename, source_url,
court, reason}``. Raises ``_Tier1Unavailable`` if the service can't be
reached, ``SupremeFetchError``-style RuntimeError on a fetch failure the
service reports.
"""
if not (cit.file_number and cit.month and cit.year):
raise RuntimeError(
f"מספר-תיק {cit.case_number_norm} אינו בפורמט נט-המשפט (תיק-חודש-שנה)"
)
headers = {"Authorization": f"Bearer {_SHARED_SECRET}"} if _SHARED_SECRET else {}
payload = {
"file_number": cit.file_number,
"month": cit.month,
"year": cit.year,
"case_number": cit.case_number_norm,
"court": cit.court_prefix,
}
try:
async with httpx.AsyncClient(timeout=_TIER1_TIMEOUT_S) as client:
resp = await client.post(
f"{COURT_FETCH_SERVICE_URL}/fetch", json=payload, headers=headers
)
except httpx.ConnectError as e:
raise _Tier1Unavailable(
f"שירות-האחזור (legal-court-fetch-service) אינו זמין ב-"
f"{COURT_FETCH_SERVICE_URL}: {e}"
) from e
if resp.status_code != 200:
raise RuntimeError(f"שירות-האחזור החזיר {resp.status_code}: {resp.text[:200]}")
return resp.json()
async def fetch_and_ingest(
citation: str, *, digest_id: UUID | None = None
) -> dict:
"""Classify a citation, fetch the verdict, ingest it, and record the job.
Idempotent on the canonical case number (INV-CF5): a case already fetched
(job ``done``) is returned without re-fetching.
"""
cit = court_citation.classify(citation)
# ── skip: ועדת-ערר — never auto-fetched (INV-CF6). Surface as a gap. ──
if cit.tier == "skip":
await _open_gap(citation, reason="ועדת-ערר — לא ניתן לאחזור ציבורי (נדרש נבו)")
return {"status": "skipped", "tier": "skip", "citation": citation,
"reason": "appeals_committee — needs Nevo"}
if cit.tier == "unknown" or not cit.case_number_norm:
return {"status": "unrecognized", "citation": citation}
# ── idempotent job row ──
job = await db.court_fetch_job_upsert(
case_number_norm=cit.case_number_norm,
citation_raw=citation,
tier=cit.tier,
court=cit.court_prefix,
digest_id=digest_id,
)
if job.get("status") == "done":
return {"status": "already_done", "job": job}
if job.get("status") == "manual":
return {"status": "awaiting_manual", "job": job}
job_id = UUID(str(job["id"]))
await db.court_fetch_job_update(job_id, status="running", bump_attempts=True)
# ── fetch ──
try:
if cit.tier == "supreme":
fetched = await fetch_supreme_verdict(
citation=citation, case_number_norm=cit.case_number_norm
)
content, filename = fetched.content, fetched.filename
source_url, court = fetched.source_url, fetched.court
else: # admin → Tier 1
res = await _fetch_tier1_admin(cit)
if not res.get("ok"):
raise RuntimeError(res.get("reason") or "אחזור נכשל")
import base64
content = base64.b64decode(res["content_b64"])
filename = res.get("filename") or f"{cit.case_number_norm}.pdf"
source_url = res.get("source_url", "")
court = res.get("court") or cit.court_prefix
except (_Tier1Unavailable, SupremeFetchError, RuntimeError) as e:
return await _record_failure(job_id, cit, citation, str(e))
# ── ingest into the canonical pipeline (INV-CF1) ──
try:
result = await _ingest_bytes(
content=content, filename=filename, citation=citation,
tier=cit.tier, court=court, source_url=source_url,
)
except Exception as e: # noqa: BLE001 — recorded, never swallowed (INV-CF2)
logger.exception("ingest failed for %s", cit.case_number_norm)
return await _record_failure(job_id, cit, citation, f"קליטה נכשלה: {e}")
case_law_id = result.get("case_law_id")
await db.court_fetch_job_update(
job_id, status="done",
case_law_id=UUID(str(case_law_id)) if case_law_id else None,
source_url=source_url, error="",
)
return {"status": "done", "tier": cit.tier, "case_law_id": case_law_id,
"citation": citation, "source_url": source_url, "ingest": result}
async def _record_failure(
job_id: UUID, cit: court_citation.CourtCitation, citation: str, err: str
) -> dict:
"""Record a fetch/ingest failure; escalate to manual after N attempts (INV-CF3)."""
job = await db.court_fetch_job_get(cit.case_number_norm)
attempts = (job or {}).get("attempts", 1)
if attempts >= MAX_AUTONOMOUS_ATTEMPTS:
await db.court_fetch_job_update(job_id, status="manual", error=err)
await _open_gap(
citation,
reason=f"אחזור אוטונומי נכשל ({attempts} נסיונות) — נדרשת הורדה ידנית. {err}",
)
logger.warning("court fetch escalated to manual: %s%s", citation, err)
return {"status": "manual", "citation": citation, "error": err,
"attempts": attempts}
await db.court_fetch_job_update(job_id, status="failed", error=err)
logger.warning("court fetch failed (will retry): %s%s", citation, err)
return {"status": "failed", "citation": citation, "error": err,
"attempts": attempts}
async def _open_gap(citation: str, *, reason: str) -> None:
"""Open a missing_precedent gap so the chair sees it (INV-CF2/CF3).
Best-effort + de-duplicated by the missing_precedents layer; a failure
here is logged, never raised (it must not mask the original outcome).
"""
try:
await db.create_missing_precedent(citation=citation, notes=reason)
except Exception:
logger.warning("could not open missing_precedent for %s", citation)

View File

@@ -0,0 +1,181 @@
"""Tier 0 — Supreme Court verdict fetcher (X13).
Pulls a published Supreme Court verdict PDF from the **public** decisions
portal ``supremedecisions.court.gov.il`` — no smart-card, no CAPTCHA. The
portal is an AngularJS SPA backed by a small JSON API (reverse-engineered
from ``/Scripts/app/config.js`` + the search/results controllers):
POST Home/SearchVerdicts body {"document": <query>, "lan": 1} → result list
GET Home/GetCasesYearNum ?... (year + number lookup) → case + docs
GET Home/Download?path=<path>&fileName=<file>&type=4 → the PDF bytes
Two things matter for getting a 200 instead of an F5 connection-reset
(verified empirically 2026-06-07):
* a **complete** browser header set — UA + Accept + Accept-Language. A bare
UA alone gets reset.
* **politeness** (INV-CF4): one request at a time, a cooldown between them,
a Referer of the portal root. We never parallelise or hammer.
Honesty / scope: the *result→download* field mapping (where ``path`` and
``fileName`` live in the SearchVerdicts JSON) is derived from the client code,
not yet confirmed against a live JSON response (the live site rate-limited
probing during development). ``fetch_supreme_verdict`` therefore validates the
response shape and **raises** on anything unexpected (INV-CF2 — no silent
swallow) so the orchestrator can record the failure and fall back, rather than
returning a wrong/empty file. The first live run is the validation pass; see
the X13 verification section.
"""
from __future__ import annotations
import asyncio
import logging
import os
from dataclasses import dataclass
import httpx
logger = logging.getLogger(__name__)
_BASE = "https://supremedecisions.court.gov.il"
# A complete, browser-like header set. Empirically required to pass the F5
# WAF (a bare User-Agent gets a TCP reset).
_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Accept-Language": "he-IL,he;q=0.9,en;q=0.8",
"Referer": _BASE + "/",
}
# Politeness knobs (INV-CF4). Serial only — never run these concurrently.
_REQUEST_TIMEOUT_S = float(os.environ.get("COURT_FETCH_HTTP_TIMEOUT_S", "30"))
_INTER_REQUEST_COOLDOWN_S = float(os.environ.get("COURT_FETCH_COOLDOWN_S", "2"))
# type=4 → PDF in the portal's Download endpoint (from resultsControler.js).
_DOC_TYPE_PDF = "4"
@dataclass
class FetchedVerdict:
"""A downloaded verdict file held in memory, ready for ingest."""
content: bytes
filename: str
source_url: str
court: str = "בית המשפט העליון"
class SupremeFetchError(RuntimeError):
"""Raised when the public portal returns an unexpected shape / no document.
Carries a human-readable Hebrew reason so the orchestrator can persist it
on the job row (INV-CF2) and decide on fallback.
"""
async def _get(client: httpx.AsyncClient, path: str, **kwargs) -> httpx.Response:
await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S)
resp = await client.get(f"{_BASE}/{path.lstrip('/')}", **kwargs)
resp.raise_for_status()
return resp
async def _post(client: httpx.AsyncClient, path: str, json: dict) -> httpx.Response:
await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S)
resp = await client.post(f"{_BASE}/{path.lstrip('/')}", json=json)
resp.raise_for_status()
return resp
def _extract_doc_ref(results: object) -> tuple[str, str] | None:
"""Pull (path, fileName) of the first verdict document from a results blob.
The SearchVerdicts/GetCasesYearNum responses nest documents under varying
keys across the portal's endpoints. We probe the known shapes defensively
and return the first (path, fileName) pair found; ``None`` if none.
"""
def walk(node):
if isinstance(node, dict):
# A document node carries both a path and a file name.
path = node.get("Path") or node.get("path")
fname = node.get("FileName") or node.get("fileName") or node.get("Filename")
if path and fname:
yield (str(path), str(fname))
for v in node.values():
yield from walk(v)
elif isinstance(node, list):
for v in node:
yield from walk(v)
for pair in walk(results):
return pair
return None
async def fetch_supreme_verdict(
*, citation: str, case_number_norm: str
) -> FetchedVerdict:
"""Fetch a Supreme Court verdict PDF by citation. Raises on failure.
Flow: full-text search for the citation → locate the verdict document's
(path, fileName) → download the PDF. Serial + cooled-down throughout.
"""
async with httpx.AsyncClient(
http2=True,
headers=_HEADERS,
timeout=_REQUEST_TIMEOUT_S,
follow_redirects=True,
) as client:
# 1. Search. The portal's quick-search posts {document, lan}; lan=1=Hebrew.
try:
search = await _post(
client, "Home/SearchVerdicts",
json={"document": citation, "lan": 1},
)
results = search.json()
except httpx.HTTPError as e:
raise SupremeFetchError(
f"חיפוש בפורטל העליון נכשל עבור {citation}: {e}"
) from e
except ValueError as e: # non-JSON body
raise SupremeFetchError(
f"תשובת-חיפוש לא-JSON מהפורטל עבור {citation}"
) from e
ref = _extract_doc_ref(results)
if not ref:
raise SupremeFetchError(
f"לא נמצא מסמך-פסק עבור {citation} בפורטל העליון "
f"(ייתכן שאינו פורסם או שמבנה-התשובה השתנה)."
)
path, fname = ref
# 2. Download the PDF.
try:
dl = await _get(
client, "Home/Download",
params={"path": path, "fileName": fname, "type": _DOC_TYPE_PDF},
)
except httpx.HTTPError as e:
raise SupremeFetchError(
f"הורדת PDF נכשלה עבור {citation} (path={path}): {e}"
) from e
content = dl.content
ctype = dl.headers.get("content-type", "")
if not content or ("pdf" not in ctype.lower() and not content[:4] == b"%PDF"):
raise SupremeFetchError(
f"הקובץ שהתקבל עבור {citation} אינו PDF תקין (content-type={ctype})."
)
source_url = (
f"{_BASE}/Home/Download?path={path}&fileName={fname}&type={_DOC_TYPE_PDF}"
)
safe_name = fname if fname.lower().endswith(".pdf") else f"{case_number_norm}.pdf"
return FetchedVerdict(
content=content, filename=safe_name, source_url=source_url,
)

View File

@@ -1232,6 +1232,93 @@ CREATE INDEX IF NOT EXISTS idx_style_exemplars_section ON style_exemplars(sectio
CREATE INDEX IF NOT EXISTS idx_style_exemplars_decision ON style_exemplars(decision_number, source); CREATE INDEX IF NOT EXISTS idx_style_exemplars_decision ON style_exemplars(decision_number, source);
""" """
SCHEMA_V28_SQL = """
-- equivalent_halachot (#84.2 follow-up): halacha-level PARALLEL-AUTHORITY links.
-- Distinct from halacha_citation_corroboration (X11): that records an actual
-- citation of a halacha by a later decision; this records that two halachot of
-- DIFFERENT precedents state the same legal principle INDEPENDENTLY (no citation
-- between them). Symmetric and non-directional — stored with halacha_a < halacha_b
-- so each pair is unique and self-links are impossible. Never merges/deletes the
-- halachot; it only relates them so the chair sees a principle recurs across
-- committees (a real-but-non-citation signal the citator must not fabricate).
CREATE TABLE IF NOT EXISTS equivalent_halachot (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
halacha_a UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
halacha_b UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
cosine NUMERIC(4,3) DEFAULT 0,
note TEXT DEFAULT '',
created_by TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now(),
CHECK (halacha_a < halacha_b),
UNIQUE (halacha_a, halacha_b)
);
CREATE INDEX IF NOT EXISTS idx_equiv_halacha_a ON equivalent_halachot(halacha_a);
CREATE INDEX IF NOT EXISTS idx_equiv_halacha_b ON equivalent_halachot(halacha_b);
"""
SCHEMA_V29_SQL = """
-- halacha_goldset (#81.7/#81.8): a human-tagged evaluation set. A stratified
-- sample of halachot the chair/Dafna labels (is_holding / correct_type /
-- quote_complete) so we can measure the extraction validators' precision/recall
-- and recalibrate the auto-approve threshold. The tags are the ground truth —
-- they MUST be human (no AI pre-fill) to avoid circular bias.
CREATE TABLE IF NOT EXISTS halacha_goldset (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
halacha_id UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
batch TEXT NOT NULL DEFAULT 'default',
is_holding BOOLEAN, -- NULL until tagged
correct_type TEXT DEFAULT '', -- binding | interpretive | obiter | application | ''
quote_complete BOOLEAN,
tagged_by TEXT DEFAULT '',
tagged_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (halacha_id, batch)
);
CREATE INDEX IF NOT EXISTS idx_goldset_batch ON halacha_goldset(batch);
-- AI second-opinion (a QA aid, NOT ground truth): an INDEPENDENT local-LLM
-- judgment shown beside the human tag so the chair can spot disagreements and
-- reconsider. Independent of the rule-based validators that #81.8 measures, so
-- no circularity. Generated locally (claude_session); never auto-applied.
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_is_holding BOOLEAN;
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_correct_type TEXT DEFAULT '';
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_rationale TEXT DEFAULT '';
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_generated_at TIMESTAMPTZ;
"""
# ── X13 — Court Verdict Fetch queue ──────────────────────────────────────
# A lightweight, observable, idempotent job queue for the auto-fetch
# subsystem (docs/spec/X13-court-fetch.md). One row per court verdict we try
# to pull from a public source. Mirrors the extraction-queue pattern: status
# is always explicit (INV-CF2 — no silent drop), the canonical case number is
# the idempotency key (INV-CF5), and ``attempts`` drives the human-fallback
# gate (INV-CF3 — flip to 'manual' after N autonomous failures).
#
# NOTE (merge): main is at V29; the digests-radar worktree adds its own V30.
# If digests-radar lands first, renumber this block to V31 and update the
# apply loop. Kept as V30 here so the branch is self-consistent on main.
SCHEMA_V30_SQL = """
CREATE TABLE IF NOT EXISTS court_fetch_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_number_norm TEXT NOT NULL UNIQUE, -- idempotency key (INV-CF5)
citation_raw TEXT NOT NULL DEFAULT '',
tier TEXT NOT NULL DEFAULT '', -- supreme | admin | skip
court TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending', -- pending|running|done|failed|manual
attempts INT NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
case_law_id UUID REFERENCES case_law(id) ON DELETE SET NULL,
digest_id UUID, -- source digest (X12), nullable for ad-hoc
source_url TEXT NOT NULL DEFAULT '', -- provenance (INV-CF7)
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_court_fetch_jobs_status ON court_fetch_jobs(status);
CREATE INDEX IF NOT EXISTS idx_court_fetch_jobs_digest ON court_fetch_jobs(digest_id)
WHERE digest_id IS NOT NULL;
"""
async def _run_schema_migrations(pool: asyncpg.Pool) -> None: async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
async with pool.acquire() as conn: async with pool.acquire() as conn:
@@ -1263,7 +1350,10 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
await conn.execute(SCHEMA_V25_SQL) await conn.execute(SCHEMA_V25_SQL)
await conn.execute(SCHEMA_V26_SQL) await conn.execute(SCHEMA_V26_SQL)
await conn.execute(SCHEMA_V27_SQL) await conn.execute(SCHEMA_V27_SQL)
logger.info("Database schema initialized (v1-v27)") await conn.execute(SCHEMA_V28_SQL)
await conn.execute(SCHEMA_V29_SQL)
await conn.execute(SCHEMA_V30_SQL)
logger.info("Database schema initialized (v1-v30)")
async def init_schema() -> None: async def init_schema() -> None:
@@ -3794,6 +3884,8 @@ async def list_halachot(
offset: int = 0, offset: int = 0,
exclude_low_quality: bool = False, exclude_low_quality: bool = False,
order_by_priority: bool = False, order_by_priority: bool = False,
cluster: bool = False,
include_equivalents: bool = False,
) -> list[dict]: ) -> list[dict]:
"""List halachot with optional triage controls (#84). """List halachot with optional triage controls (#84).
@@ -3804,6 +3896,9 @@ async def list_halachot(
order_by_priority — replace FIFO with an active-learning order (#84.3): order_by_priority — replace FIFO with an active-learning order (#84.3):
negatively-treated first, then most-uncertain (lowest confidence), then negatively-treated first, then most-uncertain (lowest confidence), then
oldest — so the chair sees the highest-value decisions first. oldest — so the chair sees the highest-value decisions first.
cluster — annotate each row with ``cluster_id`` + ``cluster_size`` (#84.2):
same-precedent halachot within HALACHA_CLUSTER_COSINE form one group so
the UI can collapse near-identical principles into a single review card.
""" """
pool = await get_pool() pool = await get_pool()
conditions = [] conditions = []
@@ -3868,9 +3963,49 @@ async def list_halachot(
if d.get("decision_date") is not None: if d.get("decision_date") is not None:
d["decision_date"] = d["decision_date"].isoformat() d["decision_date"] = d["decision_date"].isoformat()
out.append(d) out.append(d)
if cluster and out:
await _annotate_clusters(pool, out)
if include_equivalents and out:
await _annotate_equivalents(pool, out)
return out return out
async def _annotate_clusters(pool, out: list[dict]) -> None:
"""Add cluster_id + cluster_size to each row (#84.2), display-only.
Same-precedent halachot within HALACHA_CLUSTER_COSINE are unioned into one
group. Singletons get their own id as cluster_id and size 1. Pairwise is
confined to the returned set (cheap; the queue is ~hundreds of rows)."""
ids = [d["id"] for d in out]
max_dist = 1.0 - config.HALACHA_CLUSTER_COSINE
pairs = await pool.fetch(
"SELECT a.id AS a, b.id AS b FROM halachot a JOIN halachot b "
"ON a.case_law_id = b.case_law_id AND a.id < b.id "
"AND a.embedding IS NOT NULL AND b.embedding IS NOT NULL "
"AND (a.embedding <=> b.embedding) <= $2 "
"WHERE a.id = ANY($1::uuid[]) AND b.id = ANY($1::uuid[])",
ids, max_dist,
)
parent = {str(i): str(i) for i in ids}
def find(x: str) -> str:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for p in pairs:
ra, rb = find(str(p["a"])), find(str(p["b"]))
if ra != rb:
parent[ra] = rb
from collections import Counter
sizes = Counter(find(str(i)) for i in ids)
for d in out:
root = find(str(d["id"]))
d["cluster_id"] = root
d["cluster_size"] = sizes[root]
async def update_halacha( async def update_halacha(
halacha_id: UUID, halacha_id: UUID,
review_status: str | None = None, review_status: str | None = None,
@@ -4093,6 +4228,255 @@ async def store_corroboration(
) )
# ── Parallel-authority (equivalent halachot) — #84.2 follow-up ───────────────
#
# A NON-citation, symmetric link between halachot of different precedents that
# state the same principle. Kept entirely separate from the citation corroboration
# above so the citator's counts never include non-citation recurrences.
def _equiv_order(a: UUID, b: UUID) -> tuple[UUID, UUID]:
"""Canonical ordering (halacha_a < halacha_b) so the pair is symmetric+unique."""
return (a, b) if str(a) < str(b) else (b, a)
async def link_equivalent_halachot(
a: UUID, b: UUID, *, cosine: float = 0.0, note: str = "", created_by: str = "",
) -> bool:
"""Record that two halachot (different precedents) state the same principle.
Idempotent (symmetric UNIQUE). Returns False and does nothing if a == b or
the two belong to the SAME precedent (parallel authority is cross-precedent
by definition; within-precedent sameness is the dedup/cluster concern)."""
if a == b:
return False
pool = await get_pool()
same = await pool.fetchval(
"SELECT (SELECT case_law_id FROM halachot WHERE id=$1) "
" = (SELECT case_law_id FROM halachot WHERE id=$2)", a, b,
)
if same:
return False
lo, hi = _equiv_order(a, b)
await pool.execute(
"INSERT INTO equivalent_halachot (halacha_a, halacha_b, cosine, note, created_by) "
"VALUES ($1,$2,$3,$4,$5) ON CONFLICT (halacha_a, halacha_b) DO UPDATE SET "
"cosine=GREATEST(equivalent_halachot.cosine, EXCLUDED.cosine), "
"note=COALESCE(NULLIF(EXCLUDED.note,''), equivalent_halachot.note)",
lo, hi, round(float(cosine), 3), note, created_by,
)
return True
async def unlink_equivalent_halachot(a: UUID, b: UUID) -> bool:
pool = await get_pool()
lo, hi = _equiv_order(a, b)
res = await pool.execute(
"DELETE FROM equivalent_halachot WHERE halacha_a=$1 AND halacha_b=$2", lo, hi,
)
return res.endswith(" 1")
async def list_equivalent_for_halacha(halacha_id: UUID) -> list[dict]:
"""The other halachot linked as parallel authority to this one (both sides)."""
pool = await get_pool()
rows = await pool.fetch(
"SELECT e.cosine, h.id::text AS halacha_id, h.rule_statement, "
" cl.case_number, cl.case_name "
"FROM equivalent_halachot e "
"JOIN halachot h ON h.id = CASE WHEN e.halacha_a=$1 THEN e.halacha_b ELSE e.halacha_a END "
"JOIN case_law cl ON cl.id = h.case_law_id "
"WHERE e.halacha_a=$1 OR e.halacha_b=$1 "
"ORDER BY e.cosine DESC", halacha_id,
)
return [
{
"halacha_id": r["halacha_id"],
"rule_statement": r["rule_statement"],
"case_number": r["case_number"],
"case_name": r["case_name"],
"cosine": float(r["cosine"]) if r["cosine"] is not None else None,
}
for r in rows
]
async def _annotate_equivalents(pool, out: list[dict]) -> None:
"""Attach an `equivalents` list to each row (#84.2) — parallel-authority links.
Adds both directions, so when both halachot of a pair are on the same page
each one lists the other."""
ids = [d["id"] for d in out]
rows = await pool.fetch(
"SELECT e.halacha_a, e.halacha_b, e.cosine, "
" ha.rule_statement AS a_rule, cla.case_number AS a_case, "
" hb.rule_statement AS b_rule, clb.case_number AS b_case "
"FROM equivalent_halachot e "
"JOIN halachot ha ON ha.id = e.halacha_a "
"JOIN case_law cla ON cla.id = ha.case_law_id "
"JOIN halachot hb ON hb.id = e.halacha_b "
"JOIN case_law clb ON clb.id = hb.case_law_id "
"WHERE e.halacha_a = ANY($1::uuid[]) OR e.halacha_b = ANY($1::uuid[])",
ids,
)
idset = {str(i) for i in ids}
by_src: dict[str, list[dict]] = {}
for r in rows:
a, b = str(r["halacha_a"]), str(r["halacha_b"])
cos = float(r["cosine"]) if r["cosine"] is not None else None
if a in idset:
by_src.setdefault(a, []).append({
"halacha_id": b, "case_number": r["b_case"],
"rule_statement": r["b_rule"], "cosine": cos})
if b in idset:
by_src.setdefault(b, []).append({
"halacha_id": a, "case_number": r["a_case"],
"rule_statement": r["a_rule"], "cosine": cos})
for d in out:
d["equivalents"] = by_src.get(str(d["id"]), [])
# ── Gold-set evaluation (#81.7 / #81.8) ──────────────────────────────────────
async def goldset_create_sample(
n: int = 150, batch: str = "default", reset: bool = False,
) -> dict:
"""Stratified sample of halachot (round-robin over case×rule_type) into a
tagging batch. Idempotent (ON CONFLICT); ``reset`` clears the batch first."""
pool = await get_pool()
if reset:
await pool.execute("DELETE FROM halacha_goldset WHERE batch = $1", batch)
rows = await pool.fetch(
"SELECT id, case_law_id, rule_type FROM halachot WHERE rule_statement <> ''"
)
from collections import defaultdict
buckets: dict = defaultdict(list)
for r in rows:
buckets[(r["case_law_id"], r["rule_type"])].append(r["id"])
keys = list(buckets.values())
sample: list = []
i = 0
while len(sample) < n and any(keys):
b = keys[i % len(keys)]
if b:
sample.append(b.pop())
i += 1
if i > n * 50:
break
inserted = 0
for hid in sample:
res = await pool.execute(
"INSERT INTO halacha_goldset (halacha_id, batch) VALUES ($1, $2) "
"ON CONFLICT (halacha_id, batch) DO NOTHING", hid, batch,
)
if res.endswith(" 1"):
inserted += 1
total = await pool.fetchval(
"SELECT count(*) FROM halacha_goldset WHERE batch = $1", batch)
return {"batch": batch, "inserted": inserted, "total": total}
async def goldset_list(batch: str = "default") -> list[dict]:
"""Gold-set items joined with the halacha content + the machine's labels."""
pool = await get_pool()
rows = await pool.fetch(
"SELECT g.id, g.halacha_id::text AS halacha_id, g.is_holding, "
" g.correct_type, g.quote_complete, g.tagged_by, g.tagged_at, "
" g.ai_is_holding, g.ai_correct_type, g.ai_rationale, g.ai_generated_at, "
" h.rule_statement, h.supporting_quote, h.reasoning_summary, "
" h.rule_type, h.confidence, h.quality_flags, h.review_status, "
" cl.case_number, cl.case_name, cl.source_type "
"FROM halacha_goldset g JOIN halachot h ON h.id = g.halacha_id "
"LEFT JOIN case_law cl ON cl.id = h.case_law_id "
"WHERE g.batch = $1 ORDER BY g.created_at, g.id", batch,
)
out = []
for r in rows:
d = dict(r)
if d.get("tagged_at") is not None:
d["tagged_at"] = d["tagged_at"].isoformat()
if d.get("ai_generated_at") is not None:
d["ai_generated_at"] = d["ai_generated_at"].isoformat()
if d.get("confidence") is not None:
d["confidence"] = float(d["confidence"])
out.append(d)
return out
async def goldset_set_ai_recommendation(
goldset_id: UUID, *, ai_is_holding: bool | None,
ai_correct_type: str = "", ai_rationale: str = "",
) -> None:
"""Store the independent AI second-opinion for a gold-set item (QA aid)."""
pool = await get_pool()
await pool.execute(
"UPDATE halacha_goldset SET ai_is_holding = $2, ai_correct_type = $3, "
"ai_rationale = $4, ai_generated_at = now() WHERE id = $1",
goldset_id, ai_is_holding, ai_correct_type, ai_rationale,
)
async def goldset_tag(
goldset_id: UUID, *, is_holding: bool | None = None,
correct_type: str | None = None, quote_complete: bool | None = None,
tagged_by: str = "chair",
) -> dict | None:
"""Save one human tag (partial — only provided fields change)."""
pool = await get_pool()
sets = ["tagged_by = $2", "tagged_at = now()"]
params: list = [goldset_id, tagged_by]
i = 3
if is_holding is not None:
sets.append(f"is_holding = ${i}"); params.append(is_holding); i += 1
if correct_type is not None:
sets.append(f"correct_type = ${i}"); params.append(correct_type); i += 1
if quote_complete is not None:
sets.append(f"quote_complete = ${i}"); params.append(quote_complete); i += 1
row = await pool.fetchrow(
f"UPDATE halacha_goldset SET {', '.join(sets)} WHERE id = $1 RETURNING *", *params,
)
return dict(row) if row else None
async def goldset_score(batch: str = "default") -> dict:
"""Measure each extraction validator against the human tags (#81.8).
A validator flag predicts "NOT a clean holding"; ground truth is
is_holding == false. truncated_quote is scored against quote_complete."""
items = await goldset_list(batch)
labeled = [r for r in items if r.get("is_holding") is not None]
from collections import defaultdict
counters: dict = defaultdict(lambda: {"tp": 0, "fp": 0, "fn": 0, "tn": 0})
def tally(name: str, predicted_bad: bool, truly_bad: bool) -> None:
c = counters[name]
key = ("tp" if truly_bad else "fp") if predicted_bad else ("fn" if truly_bad else "tn")
c[key] += 1
for r in labeled:
rule = r.get("rule_statement") or ""
quote = r.get("supporting_quote") or ""
rtype = r.get("rule_type") or "binding"
qc = r["quote_complete"] if r["quote_complete"] is not None else True
truly_bad = r["is_holding"] is False
flags = halacha_quality.compute_quality_flags(rule, quote, "", qc, rtype)
tally("any_flag", bool(flags), truly_bad)
tally("application", halacha_quality.FLAG_APPLICATION in flags, truly_bad)
tally("non_decision", halacha_quality.FLAG_NON_DECISION in flags, truly_bad)
tally("thin_restatement", halacha_quality.FLAG_THIN_RESTATEMENT in flags, truly_bad)
tally("truncated_quote", halacha_quality.is_quote_truncated(quote), qc is False)
def prf(c: dict) -> dict:
p = c["tp"] / (c["tp"] + c["fp"]) if (c["tp"] + c["fp"]) else 0.0
rec = c["tp"] / (c["tp"] + c["fn"]) if (c["tp"] + c["fn"]) else 0.0
f1 = 2 * p * rec / (p + rec) if (p + rec) else 0.0
return {"precision": round(p, 3), "recall": round(rec, 3), "f1": round(f1, 3), **c}
return {
"batch": batch, "total": len(items), "labeled": len(labeled),
"validators": {name: prf(c) for name, c in counters.items()},
}
async def list_corroboration_for_halacha(halacha_id: UUID) -> list[dict]: async def list_corroboration_for_halacha(halacha_id: UUID) -> list[dict]:
"""Return all corroboration rows for one halacha, ordered by match_score DESC.""" """Return all corroboration rows for one halacha, ordered by match_score DESC."""
pool = await get_pool() pool = await get_pool()
@@ -5209,3 +5593,110 @@ async def find_missing_precedent_by_citation(
citation.strip(), citation.strip(),
) )
return _row_to_missing_precedent(row) if row else None return _row_to_missing_precedent(row) if row else None
# ── X13 — Court Verdict Fetch jobs ───────────────────────────────────────
# CRUD for the auto-fetch queue (docs/spec/X13-court-fetch.md). Status is
# always explicit; failures are recorded, never swallowed (INV-CF2). Upsert
# is keyed on the canonical case number (INV-CF5).
def _row_to_court_fetch_job(row) -> dict:
return dict(row) if row else None
async def court_fetch_job_upsert(
case_number_norm: str,
citation_raw: str = "",
tier: str = "",
court: str = "",
digest_id: UUID | None = None,
) -> dict:
"""Idempotent create-or-get of a fetch job by canonical case number.
Re-requesting the same case number returns the existing row (with a
``_existing`` flag) rather than creating a duplicate — the canonical
number is a UNIQUE key. A job that already reached a terminal state is
returned as-is so callers can decide whether to retry.
"""
if not (case_number_norm or "").strip():
raise ValueError("case_number_norm is required")
pool = await get_pool()
async with pool.acquire() as conn:
existing = await conn.fetchrow(
"SELECT * FROM court_fetch_jobs WHERE case_number_norm = $1",
case_number_norm,
)
if existing:
out = _row_to_court_fetch_job(existing)
out["_existing"] = True
return out
row = await conn.fetchrow(
"""INSERT INTO court_fetch_jobs
(case_number_norm, citation_raw, tier, court, digest_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING *""",
case_number_norm, citation_raw, tier, court, digest_id,
)
out = _row_to_court_fetch_job(row)
out["_existing"] = False
return out
async def court_fetch_job_update(
job_id: UUID,
*,
status: str | None = None,
error: str | None = None,
case_law_id: UUID | None = None,
source_url: str | None = None,
bump_attempts: bool = False,
) -> dict:
"""Patch a job row. Only provided fields change; ``updated_at`` always does."""
sets = ["updated_at = now()"]
args: list = []
if status is not None:
args.append(status); sets.append(f"status = ${len(args)}")
if error is not None:
args.append(error); sets.append(f"error = ${len(args)}")
if case_law_id is not None:
args.append(case_law_id); sets.append(f"case_law_id = ${len(args)}")
if source_url is not None:
args.append(source_url); sets.append(f"source_url = ${len(args)}")
if bump_attempts:
sets.append("attempts = attempts + 1")
args.append(job_id)
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
f"UPDATE court_fetch_jobs SET {', '.join(sets)} "
f"WHERE id = ${len(args)} RETURNING *",
*args,
)
return _row_to_court_fetch_job(row)
async def court_fetch_job_get(case_number_norm: str) -> dict | None:
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM court_fetch_jobs WHERE case_number_norm = $1",
case_number_norm,
)
return _row_to_court_fetch_job(row) if row else None
async def court_fetch_job_list(status: str | None = None, limit: int = 100) -> list[dict]:
pool = await get_pool()
async with pool.acquire() as conn:
if status:
rows = await conn.fetch(
"SELECT * FROM court_fetch_jobs WHERE status = $1 "
"ORDER BY created_at DESC LIMIT $2",
status, limit,
)
else:
rows = await conn.fetch(
"SELECT * FROM court_fetch_jobs ORDER BY created_at DESC LIMIT $1",
limit,
)
return [_row_to_court_fetch_job(r) for r in rows]

View File

@@ -0,0 +1,56 @@
"""MCP tools for the X13 court-verdict auto-fetch subsystem.
- ``court_verdict_fetch`` — classify a citation, fetch the verdict from the
matching public source (Supreme portal / נט המשפט), and ingest it into the
precedent library via the canonical pipeline. The standalone entry point
(also driven automatically from digest auto-link, see X12/X13).
- ``court_fetch_status`` — inspect the fetch-job queue (pending/failed/manual).
Local-only: ``court_verdict_fetch`` runs the ingest pipeline, which drives
halacha extraction via the local ``claude`` CLI — same constraint as
``precedent_process_pending``. Invoking it from the container will fail.
"""
from __future__ import annotations
from legal_mcp.services import court_fetch_orchestrator as orch
from legal_mcp.services import db
from legal_mcp.tools.envelope import err as _err, ok as _ok
async def court_verdict_fetch(citation: str) -> str:
"""אחזור אוטומטי של פסק-דין בית-משפט וקליטה לקורפוס.
מקבל ציטוט (למשל 'עת"מ 46111-12-22' או 'עע"מ 1234/22'), מסווג את הערכאה,
מוריד את הפסק מהמקור הציבורי המתאים, וקולט אותו דרך צינור-הקליטה הקנוני.
ערר/בל"מ (ועדת-ערר) אינם ניתנים לאחזור ציבורי ויסומנו כפער.
"""
if not (citation or "").strip():
return _err("citation is required")
try:
result = await orch.fetch_and_ingest(citation.strip())
except Exception as e: # noqa: BLE001 — surfaced, not swallowed (INV-CF2)
return _err(f"אחזור נכשל: {e}")
status = result.get("status")
if status in ("done", "already_done"):
return _ok(result, message="הפסק נקלט לקורפוס")
if status == "skipped":
return _ok(result, message="ועדת-ערר — לא ניתן לאחזור ציבורי (סומן כפער)")
if status in ("manual", "awaiting_manual"):
return _ok(result, message="האחזור האוטונומי נכשל — הוסלם להורדה ידנית")
if status == "unrecognized":
return _err("הציטוט לא זוהה כמספר-תיק תקין")
return _ok(result, message=f"סטטוס: {status}")
async def court_fetch_status(case_number: str = "", status_filter: str = "") -> str:
"""סטטוס תור-האחזור. case_number לפריט יחיד, או status_filter לסינון רשימה."""
if case_number.strip():
from legal_mcp.services.court_citation import normalize_case_number
job = await db.court_fetch_job_get(normalize_case_number(case_number))
if not job:
return _ok({"job": None}, message="אין job עבור תיק זה")
return _ok({"job": job})
jobs = await db.court_fetch_job_list(status=status_filter.strip() or None)
return _ok({"jobs": jobs, "count": len(jobs)})

View File

@@ -0,0 +1,80 @@
"""Unit tests for the X13 court-citation classifier."""
from __future__ import annotations
from legal_mcp.services.court_citation import classify, normalize_case_number
def test_admin_filed_format_the_example():
"""The plan's example: עת"מ 46111-12-22 → admin, parsed into (46111,12,22)."""
c = classify('עת"מ 46111-12-22 יכין-אפק בע"מ נ\' הוועדה המחוזית')
assert c.tier == "admin"
assert c.court_prefix in ('עת"מ', "עת״מ")
assert c.case_number_raw == "46111-12-22"
assert c.case_number_norm == "46111-12-22"
assert (c.file_number, c.month, c.year) == ("46111", "12", "22")
assert c.fetchable is True
def test_bare_filed_number_defaults_admin():
c = classify("46111-12-22")
assert c.tier == "admin"
assert (c.file_number, c.month, c.year) == ("46111", "12", "22")
def test_supreme_prefixes():
for cit, pref in [
('עע"מ 1234/22', "supreme"),
('בג"ץ 5678/21', "supreme"),
('ע"א 999/20', "supreme"),
('רע"א 4/19', "supreme"),
('בר"מ 8126/24', "supreme"),
]:
c = classify(cit)
assert c.tier == pref, f"{cit} -> {c.tier}"
assert c.fetchable is True
def test_appeals_committee_is_skip():
"""ערר / בל"מ must never be auto-fetched (needs Nevo) — INV-CF6."""
for cit in ['ערר 1110/20', 'בל"מ 8048/24', "ערר 1015-01-24 ירושלים שקופה"]:
c = classify(cit)
assert c.tier == "skip", f"{cit} -> {c.tier}"
assert c.fetchable is False
def test_skip_wins_over_court_match():
"""An 'ערר' citation that also contains court-like digits stays skip."""
c = classify("ראה החלטתי בערר 1041/24 ובהמשך")
assert c.tier == "skip"
def test_admin_amn_prefix():
c = classify('עמ"נ 12345-06-23')
assert c.tier == "admin"
assert (c.file_number, c.month, c.year) == ("12345", "06", "23")
def test_two_group_serial_has_no_filed_triple():
"""Supreme serial 1234/22 normalizes but yields no (file,month,year)."""
c = classify('עע"מ 1234/22')
assert c.case_number_norm == "1234-22"
assert c.file_number is None
def test_implausible_month_not_parsed_as_filed():
# 1234-22-05 has month=22 → not a valid filed triple.
assert classify("1234-22-05").tier in ("unknown", "admin")
c = classify("1234-22-05")
if c.tier == "admin":
assert c.month is None
def test_empty_and_garbage():
assert classify("").tier == "unknown"
assert classify("שלום עולם בלי ציטוט").tier == "unknown"
def test_normalize_case_number():
assert normalize_case_number('עת"מ 46111/12/22') == "46111-12-22"
assert normalize_case_number("1110/20") == "1110-20"

View File

@@ -19,6 +19,7 @@
| `fu2c_reconcile_external_case_numbers.py` | python | **FU-2c (GAP-08, #68) — תיאום `case_number` של פסיקה חיצונית** (`source_kind <> internal_committee`) מציטוט-מלא לצורה קנונית **מציין-הליך + docket** (החלטת-יו"ר 2026-05-31, Option A: `/` נשמר, *לא* `-`; תואם db.py:369 ו-INV-ID2). דטרמיניסטי (designator+docket; 0/>1 docket → flag). `--dry-run` (ברירת-מחדל) מפיק `data/audit/fu2c-reconciliation-*.{csv,md}` עם flags (MISMATCH / NO_CITATION / CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK). `--apply --approved <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) | | `fu2c_reconcile_external_case_numbers.py` | python | **FU-2c (GAP-08, #68) — תיאום `case_number` של פסיקה חיצונית** (`source_kind <> internal_committee`) מציטוט-מלא לצורה קנונית **מציין-הליך + docket** (החלטת-יו"ר 2026-05-31, Option A: `/` נשמר, *לא* `-`; תואם db.py:369 ו-INV-ID2). דטרמיניסטי (designator+docket; 0/>1 docket → flag). `--dry-run` (ברירת-מחדל) מפיק `data/audit/fu2c-reconciliation-*.{csv,md}` עם flags (MISMATCH / NO_CITATION / CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK). `--apply --approved <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) |
| `eval_gold_bootstrap.py` | python | **FU-5 (GAP-11) — bootstrap ל-gold-set** של הערכת-אחזור ל-`data/eval/gold-set.jsonl`. שני מקורות: `--source citations` (cited==relevant מ-`search_relevance_feedback`; ריק עד שייצברו ציטוטים) ו-`--source known_item` (query=שם-תיק → relevant=עצמו; אות אמיתי היום). Idempotent — שומר שורות `source=chair`, מחדש `bootstrap_*`. דורש POSTGRES. | לפני eval; חוזר כשנצבר ground-truth | | `eval_gold_bootstrap.py` | python | **FU-5 (GAP-11) — bootstrap ל-gold-set** של הערכת-אחזור ל-`data/eval/gold-set.jsonl`. שני מקורות: `--source citations` (cited==relevant מ-`search_relevance_feedback`; ריק עד שייצברו ציטוטים) ו-`--source known_item` (query=שם-תיק → relevant=עצמו; אות אמיתי היום). Idempotent — שומר שורות `source=chair`, מחדש `bootstrap_*`. דורש POSTGRES. | לפני eval; חוזר כשנצבר ground-truth |
| `eval_retrieval.py` | python | **FU-5 (GAP-11, INV-RET4/G8) — harness הערכת-אחזור** — מריץ את מסלול-האחזור בייצור (`search_library`/`search_internal`) על ה-gold-set, מחשב precision@k/recall@k/MRR/nDCG@k (k=5,10), מצרף overall+per-corpus+per-PA ל-`data/eval/eval-report-<ts>.{json,md}` + delta מול `data/eval/baseline.json` (מתעד retrieval_config). `--self-test` בודק את המטריקות offline; `--update-baseline` מאמץ snapshot. **שער-CI במשמעת:** הרץ לפני/אחרי כל שינוי בשכבת-האחזור באותו קונפיג. דורש POSTGRES+VOYAGE_API_KEY. | לפני/אחרי שינוי RRF/k/embedder/rerank | | `eval_retrieval.py` | python | **FU-5 (GAP-11, INV-RET4/G8) — harness הערכת-אחזור** — מריץ את מסלול-האחזור בייצור (`search_library`/`search_internal`) על ה-gold-set, מחשב precision@k/recall@k/MRR/nDCG@k (k=5,10), מצרף overall+per-corpus+per-PA ל-`data/eval/eval-report-<ts>.{json,md}` + delta מול `data/eval/baseline.json` (מתעד retrieval_config). `--self-test` בודק את המטריקות offline; `--update-baseline` מאמץ snapshot. **שער-CI במשמעת:** הרץ לפני/אחרי כל שינוי בשכבת-האחזור באותו קונפיג. דורש POSTGRES+VOYAGE_API_KEY. | לפני/אחרי שינוי RRF/k/embedder/rerank |
| `legal-court-fetch-service.config.cjs` | pm2/js | **שירות-מארח Tier-1 לאחזור פסקי-דין מנט המשפט (X13)** — מריץ `python -m legal_mcp.court_fetch_service.server` ב-pm2, bound ל-`10.0.1.1:8771`, Bearer-auth (`COURT_FETCH_SHARED_SECRET` מ-`~/.legal-court-fetch-service.env`). מריץ דפדפן Camoufox (open-source) כי הקונטיינר לא יכול. תלות לאחזור-בפועל: `camofox-browser` רץ (`CAMOFOX_URL`) + `faster-whisper` ל-reCAPTCHA אודיו; אחרת מחזיר ok:false וה-orchestrator מסלים ל-fallback אנושי. מראָה לדפוס `legal-chat-service.config.cjs`. ספ: `docs/spec/X13-court-fetch.md`. התקנה: `pm2 start scripts/legal-court-fetch-service.config.cjs && pm2 save`. בריאות: `curl http://10.0.1.1:8771/health`. | pm2 (host-side) |
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) | | `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` | | `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני | | `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
@@ -38,8 +39,9 @@
| `rechunk_legacy_precedents.py` | python | **#57** — re-chunk + re-embed פסיקה שהוטמעה לפני תיקון ה-chunker (#55). בוחר כל `case_law` עם chunk זעיר (`length(trim(content))<50` — טביעת-האצבע של ה-chunker הישן) ומריץ `ingest.reindex_case_law` (re-chunk+re-embed מ-`full_text` שמור בלבד — ללא re-OCR/LLM, feedback_no_reocr_retrofit; idempotent DELETE-then-INSERT). idempotent ברמת-הבאטץ' (שואב מחדש את הסט המושפע בכל ריצה). דגל `--limit N`. רץ עם venv של mcp-server (`cd mcp-server && .venv/bin/python ../scripts/rechunk_legacy_precedents.py`) | חד-פעמי — מיגרציית-נתונים של פסיקה legacy (תוקן 2026-06-03) | | `rechunk_legacy_precedents.py` | python | **#57** — re-chunk + re-embed פסיקה שהוטמעה לפני תיקון ה-chunker (#55). בוחר כל `case_law` עם chunk זעיר (`length(trim(content))<50` — טביעת-האצבע של ה-chunker הישן) ומריץ `ingest.reindex_case_law` (re-chunk+re-embed מ-`full_text` שמור בלבד — ללא re-OCR/LLM, feedback_no_reocr_retrofit; idempotent DELETE-then-INSERT). idempotent ברמת-הבאטץ' (שואב מחדש את הסט המושפע בכל ריצה). דגל `--limit N`. רץ עם venv של mcp-server (`cd mcp-server && .venv/bin/python ../scripts/rechunk_legacy_precedents.py`) | חד-פעמי — מיגרציית-נתונים של פסיקה legacy (תוקן 2026-06-03) |
| `backfill_nevo_preamble.py` | python | **#86.2** — מיגרציית-נתונים: חיתוך preamble/רציו של נבו שדלף לפסיקה שהוטמעה לפני תיקון #86.1. מאתר כל `case_law` ש-`strip_nevo_preamble(full_text)` עדיין מקצר (דליפה היסטורית), ומבצע: (1) לכידת ה-מיני-רציו ל-`case_law.nevo_ratio` (gold-set ל-#86.3); (2) שכתוב `full_text` החתוך + חישוב-מחדש של `content_hash`; (3) `reindex_case_law` (re-chunk+embed, ללא re-OCR/LLM); (4) **סימון (לא מחיקה)** הלכות ש-`supporting_quote` שלהן בתוך ה-preamble שהוסר → `pending_review` + quality_flag `nevo_preamble_leak`. **שומר-בטיחות:** שורות עם keep%<`--min-keep` (ברירת-מחדל 60) מוחרגות מ-`--apply` כחשד over-strip (אלא אם `--include-suspicious`). **dry-run כברירת-מחדל**; `--apply` כותב backup JSON + manifest CSV ל-`data/audit/` תחילה. idempotent. רץ עם venv של mcp-server. **chair-gated** (לאמת manifest לפני apply) | מיגרציית-נתונים — dry-run בוצע (19 פסקים, 27 הלכות מזוהמות); apply ממתין לאישור | | `backfill_nevo_preamble.py` | python | **#86.2** — מיגרציית-נתונים: חיתוך preamble/רציו של נבו שדלף לפסיקה שהוטמעה לפני תיקון #86.1. מאתר כל `case_law` ש-`strip_nevo_preamble(full_text)` עדיין מקצר (דליפה היסטורית), ומבצע: (1) לכידת ה-מיני-רציו ל-`case_law.nevo_ratio` (gold-set ל-#86.3); (2) שכתוב `full_text` החתוך + חישוב-מחדש של `content_hash`; (3) `reindex_case_law` (re-chunk+embed, ללא re-OCR/LLM); (4) **סימון (לא מחיקה)** הלכות ש-`supporting_quote` שלהן בתוך ה-preamble שהוסר → `pending_review` + quality_flag `nevo_preamble_leak`. **שומר-בטיחות:** שורות עם keep%<`--min-keep` (ברירת-מחדל 60) מוחרגות מ-`--apply` כחשד over-strip (אלא אם `--include-suspicious`). **dry-run כברירת-מחדל**; `--apply` כותב backup JSON + manifest CSV ל-`data/audit/` תחילה. idempotent. רץ עם venv של mcp-server. **chair-gated** (לאמת manifest לפני apply) | מיגרציית-נתונים — dry-run בוצע (19 פסקים, 27 הלכות מזוהמות); apply ממתין לאישור |
| `nevo_ratio_benchmark.py` | python | **#86.3** — מדידת איכות חילוץ-הלכות מול ה-מיני-רציו של נבו (gold-set מקצועי חינמי). לכל פסק עם `nevo_ratio` (או נגזר מ-`full_text` אם טרם בוצע backfill): LLM-judge מקומי (`claude_session`, אפס עלות) ממפה סמנטית את הלכות-המערכת מול הלכות-נבו ומפיק **recall** (כיסוי הלכות-נבו), **precision** (אחוז הלכותינו הממופות), **granularity** (יחס פירוק — איתות over-extraction ל-#81.5). `--case <num>` / `--all [--limit N]` / `--model` / `--out`. כותב CSV ל-`data/audit/`. רץ עם venv של mcp-server (דורש Claude CLI מקומי). אומת על בג"ץ 1764/05: recall 0.875, precision 1.0, granularity 1.75x | ידני — מדידת-איכות (CI/ad-hoc) | | `nevo_ratio_benchmark.py` | python | **#86.3** — מדידת איכות חילוץ-הלכות מול ה-מיני-רציו של נבו (gold-set מקצועי חינמי). לכל פסק עם `nevo_ratio` (או נגזר מ-`full_text` אם טרם בוצע backfill): LLM-judge מקומי (`claude_session`, אפס עלות) ממפה סמנטית את הלכות-המערכת מול הלכות-נבו ומפיק **recall** (כיסוי הלכות-נבו), **precision** (אחוז הלכותינו הממופות), **granularity** (יחס פירוק — איתות over-extraction ל-#81.5). `--case <num>` / `--all [--limit N]` / `--model` / `--out`. כותב CSV ל-`data/audit/`. רץ עם venv של mcp-server (דורש Claude CLI מקומי). אומת על בג"ץ 1764/05: recall 0.875, precision 1.0, granularity 1.75x | ידני — מדידת-איכות (CI/ad-hoc) |
| `halacha_goldset.py` | python | **#81.7** — הארנס gold-set לאיכות חילוץ-הלכות. `export --n N` מייצא מדגם מרובד (לפי precedent×rule_type) ל-CSV עם עמודות-תיוג ריקות (`is_holding`/`correct_type`/`quote_complete`) לתיוג ידני (חיים/דפנה). `score --in <csv>` קורא את ה-CSV המתויג ומודד כל ולידטור (`compute_quality_flags`/`is_fact_dependent`/`is_quote_truncated`/`is_thin_restatement`) מול אמת-המידה האנושית: P/R/F1 + confusion. בסיס ל-#81.8 (כיול סף האישור). מייבא את אותם ולידטורים שה-extractor מריץ. רץ עם venv של mcp-server | ידני — export→תיוג→score | | `halacha_goldset.py` | python | **#81.7** — הארנס gold-set לאיכות חילוץ-הלכות. `export --n N` מייצא מדגם מרובד (לפי precedent×rule_type) ל-CSV עם עמודות-תיוג ריקות (`is_holding`/`correct_type`/`quote_complete`) לתיוג ידני (חיים/דפנה). `score --in <csv>` קורא את ה-CSV המתויג ומודד כל ולידטור (`compute_quality_flags`/`is_fact_dependent`/`is_quote_truncated`/`is_thin_restatement`) מול אמת-המידה האנושית: P/R/F1 + confusion. בסיס ל-#81.8 (כיול סף האישור). מייבא את אותם ולידטורים שה-extractor מריץ. רץ עם venv של mcp-server. **הערה:** קיים גם דף-תיוג אינטראקטיבי DB-backed (`/goldset`) — זה ה-CSV-fallback | ידני — export→תיוג→score |
| `halacha_batch_reconcile.py` | python | **#82.7** — dedup חוצה-פסקים offline (שמרני, **dry-run בלבד**). dedup-on-insert משווה רק תוך-פסק; כאן סף מחמיר (cosine ≥0.95, `--cosine`) ולא-הרסני: מאתר זוגות הלכות near-duplicate בין פסקים שונים (pgvector `<=>` exact) עם איתות לקסיקלי (Jaccard/Levenshtein) ומדווח ל-CSV ב-`data/audit/` לסקירת היו"ר. לא מדלג/ממזג/מוחק. `--include-pending`. רץ עם venv של mcp-server. אומת: 819 הלכות → 5 זוגות מועמדים | ידני — דוח-סקירה | | `goldset_ai_recommend.py` | python | **#81.7 QA** — מייצר **חוות-דעת-AI שנייה** (claude מקומי, אפס עלות) לכל פריט ב-`halacha_goldset`: `is_holding`+`type`+נימוק, נשמר ב-`ai_*` ומוצג בדף לצד התיוג האנושי לזיהוי אי-הסכמות. **עצמאי** מהוולידטורים שנמדדים (אין מעגליות) ו**לא** מוחל אוטומטית. `--force` (חידוש)/`--limit N`. **חובה מקומי** (claude_session). | ידני — לאחר יצירת/הרחבת batch |
| `halacha_batch_reconcile.py` | python | **#82.7** — dedup חוצה-פסקים offline (שמרני, **dry-run בלבד**). dedup-on-insert משווה רק תוך-פסק; כאן סף מחמיר (cosine ≥0.95, `--cosine`) ולא-הרסני: מאתר זוגות הלכות near-duplicate בין פסקים שונים (pgvector `<=>` exact) עם איתות לקסיקלי (Jaccard/Levenshtein) ומדווח ל-CSV ב-`data/audit/` לסקירת היו"ר. לא מדלג/ממזג/מוחק. `--include-pending`. **`--link`** רושם את הזוגות שנמצאו כ-`equivalent_halachot` (parallel authority, #84.2 — קישור-מקביל ברמת-הלכה, **לא** ציטוט; idempotent, לא-הרסני). רץ עם venv של mcp-server. אומת: 800 הלכות → 5 זוגות (קושרו). | ידני — דוח-סקירה / `--link` לקישור |
| `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest <path>`. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) | | `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest <path>`. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) |
| `audit_corpus_integrity.py` | python | בדיקה תקופתית של עקביות הקורפוס — 3 בדיקות SQL read-only על `case_law` ו-`cases`: (A) `external_upload` עם prefix פנימי `ערר`/`בל"מ`; (B) `internal_committee` חסר `chair_name`/`district`; (C) `cases.practice_area` מחוץ ל-{`rishuy_uvniya`, `betterment_levy`, `compensation_197`, `''`}. כותב log מצטבר ל-`data/logs/corpus_integrity_audit.log` ובמצב הפרות שולח wakeup ל-CEO ב-Paperclip (best-effort, רק אם `PAPERCLIP_API_URL`+`PAPERCLIP_API_KEY` מוגדרים). דגל: `--no-notify`. Idempotent, יוצא 0. **Cron יומי 07:00**: `0 7 * * * /home/chaim/legal-ai/mcp-server/.venv/bin/python /home/chaim/legal-ai/scripts/audit_corpus_integrity.py` | `0 7 * * *` (cron) | | `audit_corpus_integrity.py` | python | בדיקה תקופתית של עקביות הקורפוס — 3 בדיקות SQL read-only על `case_law` ו-`cases`: (A) `external_upload` עם prefix פנימי `ערר`/`בל"מ`; (B) `internal_committee` חסר `chair_name`/`district`; (C) `cases.practice_area` מחוץ ל-{`rishuy_uvniya`, `betterment_levy`, `compensation_197`, `''`}. כותב log מצטבר ל-`data/logs/corpus_integrity_audit.log` ובמצב הפרות שולח wakeup ל-CEO ב-Paperclip (best-effort, רק אם `PAPERCLIP_API_URL`+`PAPERCLIP_API_KEY` מוגדרים). דגל: `--no-notify`. Idempotent, יוצא 0. **Cron יומי 07:00**: `0 7 * * * /home/chaim/legal-ai/mcp-server/.venv/bin/python /home/chaim/legal-ai/scripts/audit_corpus_integrity.py` | `0 7 * * *` (cron) |
| `backfill_legal_arguments.py` | python | Backfill `legal_arguments` לתיקים עם `claims` קיימים (TaskMaster #36). מקבץ פרופוזיציות גולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד) דרך `argument_aggregator.aggregate_claims_to_arguments` (Claude CLI). תומך `--dry-run`/`--apply`/`--force`/`--case <num>...`. **חייב לרוץ מהמכונה המקומית** (לא קונטיינר) — `claude_session` דורש Claude CLI | ידני per-case (`python scripts/backfill_legal_arguments.py --apply --case 1017-03-26`) | | `backfill_legal_arguments.py` | python | Backfill `legal_arguments` לתיקים עם `claims` קיימים (TaskMaster #36). מקבץ פרופוזיציות גולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד) דרך `argument_aggregator.aggregate_claims_to_arguments` (Claude CLI). תומך `--dry-run`/`--apply`/`--force`/`--case <num>...`. **חייב לרוץ מהמכונה המקומית** (לא קונטיינר) — `claude_session` דורש Claude CLI | ידני per-case (`python scripts/backfill_legal_arguments.py --apply --case 1017-03-26`) |

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Generate the AI second-opinion for gold-set items (#81.7 QA aid).
For each gold-set halacha, an INDEPENDENT local-LLM (claude_session, zero cost)
judges: is it a real generalizable holding, what is its correct rule_type, and a
one-line rationale. Stored in halacha_goldset.ai_* and shown beside the human
tag so the chair can spot disagreements and reconsider.
This is a QA aid, NOT ground truth and NOT auto-applied. It is also independent
of the rule-based validators that #81.8 measures, so it doesn't bias that score.
Must run locally (claude_session needs the local CLI — not the container):
cd ~/legal-ai/mcp-server
.venv/bin/python ../scripts/goldset_ai_recommend.py # missing only
.venv/bin/python ../scripts/goldset_ai_recommend.py --force # regenerate all
.venv/bin/python ../scripts/goldset_ai_recommend.py --limit 10 # smoke
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from uuid import UUID
from legal_mcp.services import claude_session, db
VALID_TYPES = {"binding", "interpretive", "obiter", "application", "procedural", "persuasive"}
SYSTEM = (
"אתה בוחן-איכות משפטי המסווג 'הלכות' שחולצו מהחלטות ועדת-ערר ומפסקי-דין. "
"לכל פריט הכרע שתי שאלות, באופן עצמאי ולפי המהות:\n"
"1) is_holding — האם זו הלכה אמיתית בת-הכללה ובת-הסתמכות (true), או שזו יישום "
"תלוי-עובדות / אמרת-אגב / ציטוט-עובדה ולא כלל בר-הכללה (false).\n"
"2) type — הסוג הנכון: 'binding' (עיקרון הכרחי להכרעה), 'interpretive' (פרשנות "
"חוק/מונח/תכנית), 'procedural' (סדר-דין: מועדים/סמכות/מיצוי/נטל), 'persuasive' "
"(אסמכתה לא-מחייבת), 'application' (החלה על עובדות התיק — לרוב לא-הלכה), "
"'obiter' (אמרת-אגב שלא הוכרעה — לא-הלכה).\n"
"עקביות: is_holding=true → binding/interpretive/procedural/persuasive; "
"is_holding=false → application/obiter.\n"
'החזר JSON בלבד: {"is_holding": true/false, "type": "<אחד מהשישה>", '
'"rationale": "<משפט אחד קצר בעברית>"}. ללא markdown.'
)
def _prompt(item: dict) -> str:
src = "פסק-דין" if item.get("source_type") == "court_ruling" else "החלטת ועדת-ערר"
return (
f"מקור: {src} ({item.get('case_number') or ''}).\n"
f"סוג שהמכונה נתנה: {item.get('rule_type')}.\n\n"
f"ניסוח הכלל:\n{item.get('rule_statement') or ''}\n\n"
f"ציטוט תומך:\n{item.get('supporting_quote') or ''}"
)
async def main(args: argparse.Namespace) -> int:
items = await db.goldset_list(args.batch)
todo = [it for it in items if args.force or not it.get("ai_generated_at")]
if args.limit:
todo = todo[: args.limit]
print(f"gold-set {args.batch}: {len(items)} items, {len(todo)} to recommend", flush=True)
ok, fail, disagree = 0, 0, 0
for i, it in enumerate(todo, 1):
try:
v = await claude_session.query_json(_prompt(it), system=SYSTEM, effort="low")
except Exception as e: # noqa: BLE001
fail += 1
print(f"[{i}/{len(todo)}] {it['case_number']}: FAIL {e}", flush=True)
continue
if not isinstance(v, dict):
fail += 1
continue
ai_hold = bool(v.get("is_holding"))
ai_type = str(v.get("type") or "").strip()
if ai_type not in VALID_TYPES:
ai_type = ""
await db.goldset_set_ai_recommendation(
UUID(str(it["id"])), ai_is_holding=ai_hold, ai_correct_type=ai_type,
ai_rationale=str(v.get("rationale") or "")[:300],
)
ok += 1
# note disagreements with the human tag (if tagged)
flag = ""
if it.get("is_holding") is not None and it["is_holding"] != ai_hold:
disagree += 1
flag = " ⚠ DISAGREE is_holding"
print(f"[{i}/{len(todo)}] {it['case_number']}: ai={ai_hold}/{ai_type}{flag}", flush=True)
print(f"\nDONE — {ok} stored, {fail} failed, {disagree} disagree with existing human tag",
flush=True)
return 0
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--batch", default="default")
ap.add_argument("--force", action="store_true", help="regenerate even if present")
ap.add_argument("--limit", type=int, default=None)
sys.exit(asyncio.run(main(ap.parse_args())))

View File

@@ -91,7 +91,22 @@ async def main(args: argparse.Namespace) -> int:
w = csv.DictWriter(f, fieldnames=list(pairs[0].keys())) w = csv.DictWriter(f, fieldnames=list(pairs[0].keys()))
w.writeheader() w.writeheader()
w.writerows(pairs) w.writerows(pairs)
print(f"\nreport: {out} (review-only — nothing changed)", flush=True) print(f"\nreport: {out}", flush=True)
if args.link and pairs:
# #84.2 — record each pair as parallel authority (equivalent_halachot).
# Non-destructive: links only, never merges/deletes. Idempotent.
linked = 0
for p in pairs:
if await db.link_equivalent_halachot(
p["id_a"], p["id_b"], cosine=p["cosine"],
note="cross-precedent parallel authority (halacha_batch_reconcile)",
created_by="batch_reconcile",
):
linked += 1
print(f"linked {linked}/{len(pairs)} pairs as equivalent_halachot", flush=True)
elif pairs:
print("(review-only — pass --link to record them as equivalent_halachot)", flush=True)
return 0 return 0
@@ -102,5 +117,7 @@ if __name__ == "__main__":
help="min cosine for a cross-precedent candidate (default 0.95)") help="min cosine for a cross-precedent candidate (default 0.95)")
ap.add_argument("--include-pending", action="store_true", ap.add_argument("--include-pending", action="store_true",
help="also scan pending_review halachot (default: approved/published only)") help="also scan pending_review halachot (default: approved/published only)")
ap.add_argument("--link", action="store_true",
help="record found pairs as equivalent_halachot (parallel authority, #84.2)")
args = ap.parse_args() args = ap.parse_args()
sys.exit(asyncio.run(main(args))) sys.exit(asyncio.run(main(args)))

View File

@@ -0,0 +1,65 @@
/**
* pm2 ecosystem entry for legal-court-fetch-service — the host-side Tier-1
* verdict fetcher (X13). It drives a Camoufox stealth browser against
* נט המשפט to download administrative/district-court verdicts the Supreme
* portal (Tier 0) doesn't carry. Lives on the host because the legal-ai
* container can't run a browser. See docs/spec/X13-court-fetch.md.
*
* Mirrors legal-chat-service.config.cjs (same security model):
* 1. Bind to 10.0.1.1 (docker0 bridge gateway) — host + docker-bridge
* containers only; nothing from outside the host.
* 2. Bearer token auth — COURT_FETCH_SHARED_SECRET loaded from
* /home/chaim/.legal-court-fetch-service.env (chmod 600) and mirrored in
* Coolify so the FastAPI proxy sends a matching Authorization header.
* The service refuses to start without the secret.
*
* Prereqs for Tier-1 to actually fetch (otherwise it returns ok:false and the
* orchestrator escalates to the human fallback — INV-CF3):
* - camofox-browser running, CAMOFOX_URL set (e.g. http://127.0.0.1:9377).
* git clone https://github.com/jo-inc/camofox-browser && npm i && npm start
* - faster-whisper installed in the venv for the reCAPTCHA audio solver.
*
* Install (once):
* pm2 start /home/chaim/legal-ai/scripts/legal-court-fetch-service.config.cjs
* pm2 save
* Smoke test:
* curl http://10.0.1.1:8771/health
* Update:
* pm2 restart legal-court-fetch-service --update-env
*/
const fs = require("fs");
const ENV_FILE = "/home/chaim/.legal-court-fetch-service.env";
const env = {
HOME: "/home/chaim",
PATH: "/home/chaim/.local/bin:/usr/local/bin:/usr/bin:/bin",
PYTHONUNBUFFERED: "1",
// CAMOFOX_URL: "http://127.0.0.1:9377", // set when camofox-browser is up
};
try {
const text = fs.readFileSync(ENV_FILE, "utf8");
for (const line of text.split("\n")) {
if (!line || line.trim().startsWith("#")) continue;
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
if (m) env[m[1]] = m[2];
}
} catch (e) {
console.error(`legal-court-fetch-service: failed to load ${ENV_FILE}: ${e.message}`);
console.error("Service will refuse to start without COURT_FETCH_SHARED_SECRET.");
}
module.exports = {
apps: [
{
name: "legal-court-fetch-service",
cwd: "/home/chaim/legal-ai/mcp-server",
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
args: "-m legal_mcp.court_fetch_service.server --port 8771 --host 10.0.1.1",
env,
restart_delay: 5000,
max_restarts: 10,
autorestart: true,
max_memory_restart: "1G",
},
],
};

View File

@@ -0,0 +1,41 @@
"use client";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { GoldsetPanel } from "@/components/goldset/goldset-panel";
/**
* Gold-set tagging page (#81.7 / #81.8).
*
* Interactive review of a stratified halacha sample. The chair/Dafna labels each
* item (is_holding / correct_type / quote_complete); those human labels are the
* ground truth that measures the extraction validators and recalibrates the
* auto-approve threshold. Tags MUST be human — no AI pre-fill (circular bias).
*/
export default function GoldsetPage() {
return (
<AppShell>
<section className="space-y-6">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<span className="text-navy">מדגם-זהב לתיוג</span>
</nav>
<h1 className="text-navy mb-0">מדגם-זהב לתיוג איכות</h1>
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
מדגם מרובד של הלכות שחולצו. לכל הלכה הכריעו שלוש שאלות
<strong> האם זו הלכה אמיתית</strong>, <strong>מה הסוג הנכון</strong>,
ו<strong>האם הציטוט שלם</strong>. ההכרעות שלכם הן אמת-המידה שמודדת את
דיוק המחלץ ומכיילת את סף-האישור האוטומטי. שיפוט משפטי אנושי בלבד
לא תיוג-AI (כדי למנוע הטיה מעגלית).
</p>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
<GoldsetPanel />
</section>
</AppShell>
);
}

View File

@@ -51,6 +51,7 @@ const NAV_GROUPS: NavGroup[] = [
items: [ items: [
{ href: "/precedents", label: "ספריית פסיקה" }, { href: "/precedents", label: "ספריית פסיקה" },
{ href: "/missing-precedents", label: "פסיקה חסרה" }, { href: "/missing-precedents", label: "פסיקה חסרה" },
{ href: "/goldset", label: "מדגם-זהב" },
{ href: "/training", label: "אימון סגנון" }, { href: "/training", label: "אימון סגנון" },
{ href: "/methodology", label: "מתודולוגיה" }, { href: "/methodology", label: "מתודולוגיה" },
], ],

View File

@@ -0,0 +1,501 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Check, X, ChevronDown, ChevronLeft, Info, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
useGoldset, useGoldsetScore, useTagGoldset, useCreateGoldsetSample,
type GoldsetItem,
} from "@/lib/api/goldset";
const TYPES: { value: string; label: string }[] = [
{ value: "binding", label: "מחייבת" },
{ value: "interpretive", label: "פרשני" },
{ value: "application", label: "יישום" },
{ value: "obiter", label: "אמרת-אגב" },
{ value: "procedural", label: "פרוצדורלי" },
{ value: "persuasive", label: "משכנע" },
];
// Consistency between is_holding and the type (#81.7): a real holding is
// binding/interpretive/procedural/persuasive; a NON-holding is its reason —
// application (fact-bound) or obiter (not decided). Other pairings contradict.
const HOLDING_TYPES = new Set(["binding", "interpretive", "procedural", "persuasive"]);
const NON_HOLDING_TYPES = new Set(["application", "obiter"]);
function inconsistentTag(it: GoldsetItem): string | null {
if (it.is_holding === null || !it.correct_type) return null;
if (it.is_holding === true && NON_HOLDING_TYPES.has(it.correct_type)) {
return "סימנת \"הלכה\" אך הסוג הוא יישום/אמרת-אגב — אלה דווקא הסיבות שמשהו אינו הלכה.";
}
if (it.is_holding === false && HOLDING_TYPES.has(it.correct_type)) {
return "סימנת \"לא הלכה\" אך הסוג מציין הלכה (מחייבת/פרשני/…); ל\"לא\" מתאים יישום או אמרת-אגב.";
}
return null;
}
const FLAG_LABELS: Record<string, string> = {
non_decision: "אי-הכרעה", truncated_quote: "ציטוט קטוע", thin_restatement: "ניסוח דק",
quote_unverified: "ציטוט לא מאומת", nli_unsupported: "כלל לא נגזר", application: "יישום",
near_duplicate: "כפילות-קרובה", nevo_preamble_leak: "דליפת רציו",
};
function cleanCitation(s: string | null | undefined): string {
if (!s) return "—";
return s.replace(/[--]/g, "").trim();
}
// Source separation (פסקי-דין מול החלטות ועדת-ערר) for convenient tagging.
function sourceLabel(s: string | null): string {
return s === "court_ruling" ? "פסק-דין"
: s === "appeals_committee" ? "ועדת ערר" : "אחר";
}
const SOURCE_FILTERS: { value: "all" | "court_ruling" | "appeals_committee"; label: string }[] = [
{ value: "all", label: "הכל" },
{ value: "court_ruling", label: "פסקי דין" },
{ value: "appeals_committee", label: "ועדת ערר" },
];
function isTagged(it: GoldsetItem): boolean {
// Fully tagged only when ALL THREE answers are set — otherwise, in
// "hide tagged" mode, a card would vanish the moment is_holding is clicked,
// before correct_type / quote_complete can be set.
return it.is_holding !== null && it.quote_complete !== null && !!it.correct_type;
}
// The AI second-opinion disagrees with the human tag (on is_holding or type).
function aiDisagrees(it: GoldsetItem): boolean {
if (!it.ai_generated_at) return false;
const holdDiff = it.is_holding !== null && it.ai_is_holding !== null
&& it.is_holding !== it.ai_is_holding;
const typeDiff = !!it.correct_type && !!it.ai_correct_type
&& it.correct_type !== it.ai_correct_type;
return holdDiff || typeDiff;
}
// ─── Score panel ──────────────────────────────────────────────────────────────
function ScorePanel({ batch }: { batch: string }) {
const { data } = useGoldsetScore(batch);
const [open, setOpen] = useState(true);
if (!data || data.labeled === 0) return null;
const rows = Object.entries(data.validators);
// negatives so far (truly "not a holding") = tp+fn of any validator.
const af = data.validators.any_flag;
const negatives = af ? af.tp + af.fn : 0;
return (
<div className="rounded-lg border border-rule bg-surface">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-gold-wash/30"
aria-expanded={open}
>
{open ? <ChevronDown className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
<span className="font-semibold text-navy">ציון הוולידטורים מול התיוג</span>
<span className="text-ink-muted">({data.labeled} מתויגות)</span>
</button>
{open && (
<div className="px-4 pb-3 overflow-x-auto">
<p className="text-[0.72rem] text-ink-muted mb-2">
המדדים מודדים זיהוי <strong>"לא-הלכה"</strong> (יישום / ציטוט-קטוע / אי-הכרעה...).
{negatives < 10
? ` עד כה תויגו רק ${negatives} פריטי "לא הלכה" — המספרים יהפכו משמעותיים ככל שיצטברו עוד (במיוחד מבקט המסומנים).`
: " precision גבוה = מעט אזעקות-שווא; recall גבוה = תופס את רוב ה'לא-הלכה'."}
</p>
<table className="w-full text-sm tabular-nums">
<thead>
<tr className="text-ink-muted text-[0.72rem] border-b border-rule">
<th className="text-start py-1 ps-1">ולידטור</th>
<th className="text-start">Precision</th>
<th className="text-start">Recall</th>
<th className="text-start">F1</th>
<th className="text-start">tp/fp/fn/tn</th>
</tr>
</thead>
<tbody>
{rows.map(([name, v]) => (
<tr key={name} className="border-b border-rule-soft last:border-0">
<td className="py-1 ps-1 text-navy">{name}</td>
<td>{v.precision.toFixed(2)}</td>
<td>{v.recall.toFixed(2)}</td>
<td className="font-semibold">{v.f1.toFixed(2)}</td>
<td className="text-ink-muted text-[0.72rem]">
{v.tp}/{v.fp}/{v.fn}/{v.tn}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ─── Rule-type help (info popover) ────────────────────────────────────────────
const TYPE_HELP: { label: string; def: string; test: string; example: string }[] = [
{
label: "מחייבת",
def: "העיקרון שהיה הכרחי להכרעה — ה-holding האמיתי. בר-הסתמכות מלא.",
test: "מבחן וומבו: הפוך את הכלל — אם התוצאה הייתה משתנה → מחייבת.",
example: "נטל ההוכחה בהיטל השבחה מוטל על הוועדה המקומית.",
},
{
label: "פרשני",
def: "קביעה שמפרשת הוראת-חוק / מונח / תכנית (מה המשמעות של סעיף X).",
test: "עונה ל'מה פירוש הנורמה?' ולא ל'מה הדין?'.",
example: "תכלית הפטור לפי ס' 19(ב)(4) היא לעודד פעילות ציבורית.",
},
{
label: "יישום",
def: "החלת כלל על עובדות התיק הספציפי — תלוי-עובדות, לא בר-הכללה (לרוב 'לא הלכה').",
test: "מכיל 'במקרה דנן', שמות-צדדים, סכומים, המבנה הקונקרטי.",
example: "במקרה דנן ההיתר בטל כי השומה שגתה ב-12,000 ₪.",
},
{
label: "אמרת-אגב",
def: "נאמר אגב אורחא, לא הכרחי להכרעה; הערכאה לא הכריעה בו. לא מחייב.",
test: "מבחן וומבו הפוך: היפוך הכלל לא משנה את התוצאה. דגלים: 'למעלה מן הצורך', 'מבלי לקבוע מסמרות'.",
example: "אף שאיננו נדרשים להכריע, נעיר כי ייתכן ש...",
},
{
label: "פרוצדורלי",
def: "כלל סדר-דין: מועדים, סמכות, זכות-עמידה, מיצוי הליכים, נטל.",
test: "עוסק ב'איך' מתנהל ההליך, לא במהות התכנונית.",
example: "המועד להגשת ערר הוא 30 יום.",
},
{
label: "משכנע",
def: "אסמכתה לא-מחייבת את הערכאה — שכנוע בלבד.",
test: "מקור שאינו כובל: ועדת-ערר אחרת, דעת-מיעוט, ספרות.",
example: "ועדת ערר ירושלים מסתמכת על החלטת ועדת ערר ממחוז אחר.",
},
];
function RuleTypeHelp() {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center text-ink-muted hover:text-gold-deep"
aria-label="הסבר על סוגי ההלכה"
title="הסבר על הסוגים"
>
<Info className="w-3.5 h-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[min(92vw,560px)] max-h-[70vh] overflow-y-auto p-0">
<div className="p-3 border-b border-rule">
<p className="font-semibold text-navy text-sm">סוגי ההלכה במה הם נבדלים</p>
<p className="text-[0.72rem] text-ink-muted mt-0.5">
כלל-אצבע: סימנת "הלכה" לרוב מחייבת / פרשני / פרוצדורלי / משכנע. סימנת "לא" לרוב יישום / אמרת-אגב.
</p>
</div>
<ul className="divide-y divide-rule-soft">
{TYPE_HELP.map((t) => (
<li key={t.label} className="p-3 space-y-1" dir="rtl">
<div className="font-semibold text-navy text-sm">{t.label}</div>
<div className="text-[0.78rem] text-ink-soft leading-relaxed">{t.def}</div>
<div className="text-[0.72rem] text-ink-muted"><span className="font-medium">מבחן:</span> {t.test}</div>
<div className="text-[0.72rem] text-ink-muted"><span className="font-medium">דוגמה:</span> {t.example}</div>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
// ─── Tag card ─────────────────────────────────────────────────────────────────
function TagCard({
it, focused, onTag,
}: {
it: GoldsetItem;
focused: boolean;
onTag: (tag: { is_holding?: boolean; correct_type?: string; quote_complete?: boolean }) => void;
}) {
const tagged = isTagged(it);
return (
<div
data-goldset-id={it.id}
className={`rounded-lg border bg-surface p-4 space-y-3 transition-colors
${focused ? "border-gold ring-2 ring-gold/40 shadow-md" : tagged ? "border-rule-soft opacity-70" : "border-rule"}`}
>
<div className="flex items-center gap-2 text-[0.72rem] text-ink-muted flex-wrap">
<span className="font-semibold text-navy">{cleanCitation(it.case_number)}</span>
<Badge variant="outline"
className={`text-[0.65rem] ${it.source_type === "court_ruling"
? "bg-navy-soft/30 text-navy border-navy/30"
: "bg-gold-wash text-gold-deep border-gold/40"}`}>
{sourceLabel(it.source_type)}
</Badge>
<Badge variant="outline" className="text-[0.65rem]">מכונה: {it.rule_type}</Badge>
{it.confidence != null && (
<Badge variant="outline" className="text-[0.65rem] tabular-nums">ביטחון {it.confidence.toFixed(2)}</Badge>
)}
{(it.quality_flags ?? []).map((f) => (
<Badge key={f} variant="outline" className="text-[0.65rem] bg-danger-bg text-danger border-danger/40">
{FLAG_LABELS[f] ?? f}
</Badge>
))}
{tagged && (
<Badge variant="outline" className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40 ms-auto">
<Check className="w-3 h-3 me-1" /> תויג
</Badge>
)}
</div>
<p className="text-navy font-medium leading-relaxed" dir="rtl">{it.rule_statement}</p>
<blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3" dir="rtl">
&ldquo;{it.supporting_quote}&rdquo;
</blockquote>
{it.ai_generated_at && (() => {
const aiType = TYPES.find((t) => t.value === it.ai_correct_type)?.label ?? it.ai_correct_type;
const holdDisagree = it.is_holding !== null && it.ai_is_holding !== null
&& it.is_holding !== it.ai_is_holding;
const typeDisagree = !!it.correct_type && !!it.ai_correct_type
&& it.correct_type !== it.ai_correct_type;
const anyTag = it.is_holding !== null || !!it.correct_type;
return (
<div className={`rounded-md border p-2.5 text-[0.78rem] space-y-1
${holdDisagree ? "border-amber-400 bg-amber-50" : "border-rule bg-rule-soft/20"}`} dir="rtl">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-navy">🤖 המלצת AI:</span>
<span>{it.ai_is_holding ? "הלכה" : "לא הלכה"}</span>
{aiType && <span className="text-ink-muted">· {aiType}</span>}
{anyTag && (
<span className={`ms-auto text-[0.7rem] px-1.5 py-0.5 rounded
${holdDisagree || typeDisagree
? "bg-amber-100 text-amber-800"
: "bg-emerald-50 text-emerald-700"}`}>
{holdDisagree ? "⚠ חולק על 'הלכה/לא'"
: typeDisagree ? "⚠ חולק על הסוג"
: "✓ מסכים איתך"}
</span>
)}
</div>
{it.ai_rationale && <div className="text-ink-soft leading-relaxed">{it.ai_rationale}</div>}
</div>
);
})()}
<div className="grid gap-3 sm:grid-cols-3 pt-1 border-t border-rule-soft">
{/* is_holding */}
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">האם זו הלכה אמיתית?</div>
<div className="flex gap-1">
<Button size="sm" variant={it.is_holding === true ? "default" : "ghost"}
className={it.is_holding === true ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => onTag({ is_holding: true })}>הלכה (H)</Button>
<Button size="sm" variant={it.is_holding === false ? "default" : "ghost"}
className={it.is_holding === false ? "bg-danger text-parchment hover:bg-danger" : "text-danger"}
onClick={() => onTag({ is_holding: false })}>לא (N)</Button>
</div>
</div>
{/* correct_type */}
<div>
<div className="text-[0.7rem] text-ink-muted mb-1 flex items-center gap-1">
הסוג הנכון
<RuleTypeHelp />
</div>
<div className="flex gap-1 flex-wrap">
{TYPES.map((t) => (
<Button key={t.value} size="sm"
variant={it.correct_type === t.value ? "default" : "ghost"}
className={`text-[0.7rem] px-2 ${it.correct_type === t.value ? "bg-navy text-parchment hover:bg-navy-soft" : ""}`}
onClick={() => onTag({ correct_type: t.value })}>{t.label}</Button>
))}
</div>
{inconsistentTag(it) && (
<p className="mt-1 text-[0.68rem] text-amber-700 flex items-start gap-1">
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
{inconsistentTag(it)}
</p>
)}
</div>
{/* quote_complete */}
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">הציטוט שלם?</div>
<div className="flex gap-1">
<Button size="sm" variant={it.quote_complete === true ? "default" : "ghost"}
className={it.quote_complete === true ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => onTag({ quote_complete: true })}>שלם (C)</Button>
<Button size="sm" variant={it.quote_complete === false ? "default" : "ghost"}
className={it.quote_complete === false ? "bg-danger text-parchment hover:bg-danger" : "text-danger"}
onClick={() => onTag({ quote_complete: false })}>קטוע (X)</Button>
</div>
</div>
</div>
</div>
);
}
// ─── Main panel ───────────────────────────────────────────────────────────────
export function GoldsetPanel() {
const batch = "default";
const { data, isPending, error } = useGoldset(batch);
const tag = useTagGoldset(batch);
const createSample = useCreateGoldsetSample(batch);
const [focusedId, setFocusedId] = useState<string | null>(null);
// Single mutually-exclusive view mode — can't get "stuck" like the old
// independent toggles (where the disagree filter hid the untagged items).
const [viewMode, setViewMode] =
useState<"all" | "untagged" | "tagged" | "disagree">("all");
const [sourceFilter, setSourceFilter] =
useState<"all" | "court_ruling" | "appeals_committee">("all");
const items = useMemo(() => data?.items ?? [], [data]);
const taggedCount = items.filter(isTagged).length;
const untaggedCount = items.length - taggedCount;
const disagreeCount = items.filter(aiDisagrees).length;
const sourceCounts = useMemo(() => ({
court_ruling: items.filter((i) => i.source_type === "court_ruling").length,
appeals_committee: items.filter((i) => i.source_type === "appeals_committee").length,
}), [items]);
const visible = useMemo(() => {
let v = items;
if (sourceFilter !== "all") v = v.filter((i) => i.source_type === sourceFilter);
if (viewMode === "untagged") v = v.filter((i) => !isTagged(i));
else if (viewMode === "tagged") v = v.filter(isTagged);
else if (viewMode === "disagree") v = v.filter(aiDisagrees);
// group-sort: כל פסקי-הדין יחד, ואז כל החלטות ועדת-הערר (הפרדה ברורה).
const order = (s: string | null) =>
s === "court_ruling" ? 0 : s === "appeals_committee" ? 1 : 2;
return [...v].sort((a, b) => order(a.source_type) - order(b.source_type));
}, [items, viewMode, sourceFilter]);
const focused = focusedId ? visible.find((i) => i.id === focusedId) ?? null : null;
useEffect(() => {
if (focusedId && visible.some((i) => i.id === focusedId)) return;
setFocusedId(visible[0]?.id ?? null);
}, [focusedId, visible]);
useEffect(() => {
if (!focusedId) return;
document.querySelector(`[data-goldset-id="${focusedId}"]`)
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [focusedId]);
const move = (delta: 1 | -1) => {
if (!visible.length) return;
const idx = focusedId ? visible.findIndex((i) => i.id === focusedId) : -1;
const next = idx < 0 ? (delta > 0 ? 0 : visible.length - 1)
: Math.max(0, Math.min(visible.length - 1, idx + delta));
setFocusedId(visible[next].id);
};
const doTag = async (
it: GoldsetItem,
t: { is_holding?: boolean; correct_type?: string; quote_complete?: boolean },
) => {
try {
await tag.mutateAsync({ id: it.id, tag: t });
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tagName = (e.target as HTMLElement)?.tagName?.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") return;
if (e.key === "j") { e.preventDefault(); move(1); }
else if (e.key === "k") { e.preventDefault(); move(-1); }
else if (focused && (e.key === "h" || e.key === "H")) { e.preventDefault(); doTag(focused, { is_holding: true }); }
else if (focused && (e.key === "n" || e.key === "N")) { e.preventDefault(); doTag(focused, { is_holding: false }); }
else if (focused && (e.key === "c" || e.key === "C")) { e.preventDefault(); doTag(focused, { quote_complete: true }); }
else if (focused && (e.key === "x" || e.key === "X")) { e.preventDefault(); doTag(focused, { quote_complete: false }); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focused, visible]);
if (error) {
return <div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">{error.message}</div>;
}
if (isPending) {
return <div className="space-y-3">{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-40 w-full" />)}</div>;
}
if (!items.length) {
return (
<div className="text-center text-ink-muted py-16 space-y-4">
<p className="text-lg">אין מדגם-זהב עדיין.</p>
<Button disabled={createSample.isPending}
onClick={() => createSample.mutate(150)}
className="bg-gold text-navy hover:bg-gold-deep">
צור מדגם של 150 הלכות לתיוג
</Button>
</div>
);
}
const pct = items.length ? Math.round((taggedCount / items.length) * 100) : 0;
return (
<div className="space-y-4">
<ScorePanel batch={batch} />
{/* source separation — פסקי-דין מול החלטות ועדת-ערר */}
<div className="flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30 w-fit">
{SOURCE_FILTERS.map((s) => (
<Button key={s.value} size="sm"
variant={sourceFilter === s.value ? "default" : "ghost"}
className={sourceFilter === s.value ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => setSourceFilter(s.value)}>
{s.label}
{s.value === "court_ruling" && ` (${sourceCounts.court_ruling})`}
{s.value === "appeals_committee" && ` (${sourceCounts.appeals_committee})`}
</Button>
))}
</div>
<div className="flex items-center gap-3 flex-wrap text-sm">
<span className="text-navy font-semibold tabular-nums">{taggedCount}/{items.length} תויגו</span>
<div className="h-2 w-40 rounded-full bg-rule-soft overflow-hidden">
<div className="h-full bg-gold" style={{ width: `${pct}%` }} />
</div>
<span className="text-ink-muted text-[0.72rem]">
מקלדת: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd>
{" "}· הלכה <kbd className="bg-rule-soft px-1.5 rounded">H</kbd> / לא <kbd className="bg-rule-soft px-1.5 rounded">N</kbd>
{" "}· ציטוט שלם <kbd className="bg-rule-soft px-1.5 rounded">C</kbd> / קטוע <kbd className="bg-rule-soft px-1.5 rounded">X</kbd>
</span>
<div className="ms-auto flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30">
{([
{ v: "all", label: `הכל (${items.length})` },
{ v: "untagged", label: `לא תויגו (${untaggedCount})` },
{ v: "tagged", label: `תויגו (${taggedCount})` },
{ v: "disagree", label: `⚠ אי-הסכמות (${disagreeCount})` },
] as const).map((m) => (
<Button key={m.v} size="sm"
variant={viewMode === m.v ? "default" : "ghost"}
className={viewMode === m.v
? (m.v === "disagree" ? "bg-amber-500 text-white hover:bg-amber-600" : "bg-gold text-navy hover:bg-gold-deep")
: (m.v === "disagree" ? "text-amber-700" : "")}
onClick={() => setViewMode(m.v)}>
{m.label}
</Button>
))}
</div>
</div>
<div className="space-y-3">
{visible.map((it) => (
<TagCard key={it.id} it={it} focused={it.id === focusedId}
onTag={(t) => doTag(it, t)} />
))}
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw } from "lucide-react"; import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -20,6 +20,9 @@ const QUALITY_FLAG_LABELS: Record<string, string> = {
thin_restatement: "ניסוח דק", thin_restatement: "ניסוח דק",
quote_unverified: "ציטוט לא מאומת", quote_unverified: "ציטוט לא מאומת",
nli_unsupported: "כלל לא נגזר מהציטוט", nli_unsupported: "כלל לא נגזר מהציטוט",
application: "יישום תלוי-עובדות",
near_duplicate: "כפילות-קרובה",
nevo_preamble_leak: "דליפת רציו נבו",
}; };
function formatDate(iso: string | null | undefined) { function formatDate(iso: string | null | undefined) {
@@ -57,13 +60,17 @@ type EditState = { rule_statement: string; reasoning_summary: string };
function HalachaCard({ function HalachaCard({
h, focused, onApprove, onReject, onDefer, onSave, h, focused, onApprove, onReject, onDefer, onSave,
}: { }: {
h: Halacha; h: Halacha & { variants?: Halacha[] };
focused: boolean; focused: boolean;
onApprove: () => void; onApprove: () => void;
onReject: () => void; onReject: () => void;
onDefer: () => void; onDefer: () => void;
onSave: (patch: Partial<EditState>) => Promise<void>; onSave: (patch: Partial<EditState>) => Promise<void>;
}) { }) {
const variants = h.variants ?? [];
const equivalents = h.equivalents ?? [];
const [showVariants, setShowVariants] = useState(false);
const [showEquiv, setShowEquiv] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<EditState>({ const [draft, setDraft] = useState<EditState>({
rule_statement: h.rule_statement, rule_statement: h.rule_statement,
@@ -111,6 +118,18 @@ function HalachaCard({
<Badge variant="outline" className="text-[0.65rem]"> <Badge variant="outline" className="text-[0.65rem]">
{ruleTypeLabel(h.rule_type)} {ruleTypeLabel(h.rule_type)}
</Badge> </Badge>
{variants.length > 0 && (
<Badge variant="outline"
className="text-[0.65rem] bg-navy-soft/30 text-navy border-navy/30">
+{variants.length} וריאנטים
</Badge>
)}
{equivalents.length > 0 && (
<Badge variant="outline"
className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40">
עיקרון מקביל ב-{equivalents.length}
</Badge>
)}
<CorroborationBadge halacha={h} /> <CorroborationBadge halacha={h} />
</span> </span>
</div> </div>
@@ -180,6 +199,67 @@ function HalachaCard({
))} ))}
</div> </div>
{variants.length > 0 && (
<div className="rounded-md border border-navy/20 bg-navy-soft/10">
<button
type="button"
onClick={() => setShowVariants((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-[0.72rem] text-navy hover:bg-navy-soft/20 transition-colors"
aria-expanded={showVariants}
>
{showVariants ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
<span className="font-medium">
{variants.length} ניסוחים כמעט-זהים של אותו עיקרון
</span>
<span className="me-auto text-ink-muted">החלטה תחול על כולם</span>
</button>
{showVariants && (
<ul className="px-4 pb-3 pt-1 space-y-2">
{variants.map((v) => (
<li key={v.id} className="text-[0.78rem] text-ink-soft leading-relaxed border-r-2 border-navy/20 pr-3" dir="rtl">
{v.rule_statement}
<span className="text-[0.65rem] text-ink-muted tabular-nums ms-2">
(ביטחון {v.confidence.toFixed(2)})
</span>
</li>
))}
</ul>
)}
</div>
)}
{equivalents.length > 0 && (
<div className="rounded-md border border-gold/30 bg-gold-wash/40">
<button
type="button"
onClick={() => setShowEquiv((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-[0.72rem] text-gold-deep hover:bg-gold-wash/70 transition-colors"
aria-expanded={showEquiv}
>
{showEquiv ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
<span className="font-medium">
עיקרון מקביל ב-{equivalents.length} החלטות אחרות (אסמכתה מקבילה)
</span>
<span className="me-auto text-ink-muted">לא ציטוט הישנות עצמאית</span>
</button>
{showEquiv && (
<ul className="px-4 pb-3 pt-1 space-y-2">
{equivalents.map((e) => (
<li key={e.halacha_id} className="text-[0.78rem] text-ink-soft leading-relaxed border-r-2 border-gold/30 pr-3" dir="rtl">
<span className="font-semibold text-navy">{cleanCitation(e.case_number)}</span>
{" — "}{e.rule_statement}
{e.cosine != null && (
<span className="text-[0.65rem] text-ink-muted tabular-nums ms-2">
(דמיון {e.cosine.toFixed(2)})
</span>
)}
</li>
))}
</ul>
)}
</div>
)}
<div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft"> <div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
{editing ? ( {editing ? (
<> <>
@@ -292,37 +372,64 @@ function HalachaRestoreCard({
// ─── Shared group type ──────────────────────────────────────────────────────── // ─── Shared group type ────────────────────────────────────────────────────────
/** #84.2 — a review card: the canonical halacha plus its near-duplicate
* variants (empty for singletons). Acting on the card acts on the whole set. */
type ReviewItem = Halacha & { variants: Halacha[] };
type Group = { type Group = {
caseLawId: string; caseLawId: string;
caseNumber: string; caseNumber: string;
court: string; court: string;
decisionDate: string | null; decisionDate: string | null;
precedentLevel: string; precedentLevel: string;
items: Halacha[]; items: ReviewItem[]; // canonicals (clusters collapsed)
pendingTotal: number; // total halachot incl. variants (for the count badge)
}; };
/** All halacha ids represented by a review item (canonical + its variants). */
function itemIds(it: ReviewItem): string[] {
return [it.id, ...it.variants.map((v) => v.id)];
}
function buildGroups(items: Halacha[]): Group[] { function buildGroups(items: Halacha[]): Group[] {
const map = new Map<string, Group>(); const byCase = new Map<string, Halacha[]>();
for (const h of items) { for (const h of items) {
const k = h.case_law_id; const arr = byCase.get(h.case_law_id) ?? [];
let g = map.get(k); arr.push(h);
if (!g) { byCase.set(h.case_law_id, arr);
g = {
caseLawId: k,
caseNumber: h.case_number ?? "",
court: h.court ?? "",
decisionDate: h.decision_date ?? null,
precedentLevel: h.precedent_level ?? "",
items: [],
};
map.set(k, g);
} }
g.items.push(h); const groups: Group[] = [];
for (const [caseLawId, members] of byCase) {
// collapse near-duplicate clusters (#84.2): one card per cluster_id.
const clusters = new Map<string, Halacha[]>();
for (const h of members) {
const key = h.cluster_id ?? h.id;
const arr = clusters.get(key) ?? [];
arr.push(h);
clusters.set(key, arr);
} }
for (const g of map.values()) { const reviewItems: ReviewItem[] = [];
g.items.sort((a, b) => a.confidence - b.confidence); for (const mem of clusters.values()) {
// canonical = strongest phrasing (highest confidence, verified wins ties)
mem.sort((a, b) =>
b.confidence - a.confidence
|| Number(b.quote_verified) - Number(a.quote_verified));
reviewItems.push({ ...mem[0], variants: mem.slice(1) });
} }
return Array.from(map.values()).sort((a, b) => b.items.length - a.items.length); // active-learning order within the case: most-uncertain first
reviewItems.sort((a, b) => a.confidence - b.confidence);
const first = members[0];
groups.push({
caseLawId,
caseNumber: first.case_number ?? "",
court: first.court ?? "",
decisionDate: first.decision_date ?? null,
precedentLevel: first.precedent_level ?? "",
items: reviewItems,
pendingTotal: members.length,
});
}
return groups.sort((a, b) => b.pendingTotal - a.pendingTotal);
} }
// ─── Restore panel (used for "rejected" and "approved" tabs) ────────────────── // ─── Restore panel (used for "rejected" and "approved" tabs) ──────────────────
@@ -422,7 +529,7 @@ function RestorePanel({
</div> </div>
</div> </div>
<Badge variant="outline" className="tabular-nums"> <Badge variant="outline" className="tabular-nums">
{g.items.length} {g.pendingTotal}
</Badge> </Badge>
</button> </button>
@@ -450,7 +557,12 @@ function RestorePanel({
// ─── Pending queue panel (main review flow) ─────────────────────────────────── // ─── Pending queue panel (main review flow) ───────────────────────────────────
function PendingPanel() { function PendingPanel() {
const { data, isPending, error } = useHalachotPending(500); // #84.1 — "clean" = quality-gated + prioritized + clustered review queue;
// "needsfix" = the flagged 'needs extraction fix' bucket.
const [view, setView] = useState<"clean" | "needsfix">("clean");
const { data, isPending, error } = useHalachotPending({
limit: 500, needsFix: view === "needsfix",
});
const update = useUpdateHalacha(); const update = useUpdateHalacha();
const batch = useBatchReviewHalachot(); const batch = useBatchReviewHalachot();
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
@@ -463,8 +575,8 @@ function PendingPanel() {
const totalCount = data?.items.length ?? 0; const totalCount = data?.items.length ?? 0;
const visibleItems = useMemo<Halacha[]>(() => { const visibleItems = useMemo<ReviewItem[]>(() => {
const out: Halacha[] = []; const out: ReviewItem[] = [];
for (const g of groups) { for (const g of groups) {
if (expandedIds.has(g.caseLawId)) out.push(...g.items); if (expandedIds.has(g.caseLawId)) out.push(...g.items);
} }
@@ -483,7 +595,7 @@ function PendingPanel() {
el?.scrollIntoView({ block: "nearest", behavior: "smooth" }); el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [focusedId]); }, [focusedId]);
const focused = focusedId const focused: ReviewItem | null = focusedId
? visibleItems.find((h) => h.id === focusedId) ?? null ? visibleItems.find((h) => h.id === focusedId) ?? null
: null; : null;
@@ -503,16 +615,26 @@ function PendingPanel() {
}; };
const review = async ( const review = async (
h: Halacha, it: ReviewItem,
status: "approved" | "rejected" | "deferred", status: "approved" | "rejected" | "deferred",
extra?: Partial<EditState>, extra?: Partial<EditState>,
) => { ) => {
const ids = itemIds(it);
try { try {
// #84.2 — a cluster card applies the decision to all its variants at once
// (an edit, which is canonical-specific, stays single).
if (ids.length > 1 && !extra) {
await batch.mutateAsync({ ids, status });
} else {
await update.mutateAsync({ await update.mutateAsync({
id: h.id, id: it.id,
patch: { review_status: status, ...extra }, patch: { review_status: status, ...extra },
}); });
toast.success(REVIEW_TOAST[status] ?? "עודכן"); }
toast.success(
(REVIEW_TOAST[status] ?? "עודכן")
+ (ids.length > 1 ? ` (${ids.length} וריאנטים)` : ""),
);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה"); toast.error(e instanceof Error ? e.message : "שגיאה");
} }
@@ -521,7 +643,7 @@ function PendingPanel() {
const reviewGroup = async (g: Group, status: "approved" | "rejected") => { const reviewGroup = async (g: Group, status: "approved" | "rejected") => {
try { try {
const res = await batch.mutateAsync({ const res = await batch.mutateAsync({
ids: g.items.map((h) => h.id), status, ids: g.items.flatMap(itemIds), status,
}); });
toast.success( toast.success(
`${status === "approved" ? "אושרו" : "נדחו"} ${res.updated} הלכות`, `${status === "approved" ? "אושרו" : "נדחו"} ${res.updated} הלכות`,
@@ -573,37 +695,63 @@ function PendingPanel() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [focused, visibleItems]); }, [focused, visibleItems]);
const viewToggle = (
<div className="flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30 w-fit">
<Button
size="sm"
variant={view === "clean" ? "default" : "ghost"}
className={view === "clean" ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => setView("clean")}
>
תור נקי
</Button>
<Button
size="sm"
variant={view === "needsfix" ? "default" : "ghost"}
className={view === "needsfix" ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => setView("needsfix")}
>
דורש תיקון-חילוץ
</Button>
</div>
);
let body: ReactNode;
if (error) { if (error) {
return ( body = (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center"> <div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
{error.message} {error.message}
</div> </div>
); );
} } else if (isPending) {
body = (
if (isPending) {
return (
<div className="space-y-3"> <div className="space-y-3">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)} {[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div> </div>
); );
} } else if (!groups.length) {
body = (
if (!groups.length) {
return (
<div className="text-center text-ink-muted py-16"> <div className="text-center text-ink-muted py-16">
<p className="text-lg">אין הלכות הממתינות לאישור.</p> <p className="text-lg">
<p className="text-sm mt-2">העלה פסיקה חדשה ההלכות שיחולצו ממנה יופיעו כאן.</p> {view === "needsfix"
? "בקט התיקון ריק — אין הלכות מסומנות-איכות."
: "אין הלכות נקיות הממתינות לאישור."}
</p>
<p className="text-sm mt-2">
{view === "needsfix"
? "פריטים שסומנו (ציטוט לא-מאומת, יישום, כפילות-קרובה וכו') יופיעו כאן."
: "העלה פסיקה חדשה — ההלכות שיחולצו ממנה יופיעו כאן."}
</p>
</div> </div>
); );
} } else {
body = (
return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3 text-sm text-ink-muted flex-wrap"> <div className="flex items-center gap-3 text-sm text-ink-muted flex-wrap">
<span> <span>
<span className="text-navy font-semibold">{totalCount}</span> ממתינות <span className="text-navy font-semibold">{totalCount}</span>
ב-<span className="text-navy font-semibold">{groups.length}</span> פסיקות {view === "needsfix" ? " מסומנות" : " ממתינות"}
{" "}ב-<span className="text-navy font-semibold">{groups.length}</span> פסיקות
</span> </span>
<span className="me-auto text-[0.72rem]"> <span className="me-auto text-[0.72rem]">
ניווט: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd> ניווט: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd>
@@ -650,7 +798,8 @@ function PendingPanel() {
</div> </div>
</div> </div>
<Badge variant="outline" className="bg-gold-wash text-gold-deep border-gold/40 tabular-nums"> <Badge variant="outline" className="bg-gold-wash text-gold-deep border-gold/40 tabular-nums">
{g.items.length} ממתינות {g.pendingTotal} ממתינות
{g.pendingTotal !== g.items.length && ` · ${g.items.length} כרטיסים`}
</Badge> </Badge>
</button> </button>
@@ -658,12 +807,12 @@ function PendingPanel() {
<div className="p-4 space-y-3 bg-rule-soft/20"> <div className="p-4 space-y-3 bg-rule-soft/20">
<div className="flex items-center gap-2 justify-end pb-1"> <div className="flex items-center gap-2 justify-end pb-1">
<span className="me-auto text-[0.72rem] text-ink-muted"> <span className="me-auto text-[0.72rem] text-ink-muted">
פעולה קבוצתית על {g.items.length} ההלכות: פעולה קבוצתית על {g.pendingTotal} ההלכות:
</span> </span>
<Button <Button
size="sm" variant="ghost" disabled={batch.isPending} size="sm" variant="ghost" disabled={batch.isPending}
onClick={() => { onClick={() => {
if (window.confirm(`לדחות את כל ${g.items.length} ההלכות בפסק זה?`)) if (window.confirm(`לדחות את כל ${g.pendingTotal} ההלכות בפסק זה?`))
reviewGroup(g, "rejected"); reviewGroup(g, "rejected");
}} }}
className="text-danger hover:text-danger hover:bg-danger-bg" className="text-danger hover:text-danger hover:bg-danger-bg"
@@ -673,7 +822,7 @@ function PendingPanel() {
<Button <Button
size="sm" disabled={batch.isPending} size="sm" disabled={batch.isPending}
onClick={() => { onClick={() => {
if (window.confirm(`לאשר את כל ${g.items.length} ההלכות בפסק זה?`)) if (window.confirm(`לאשר את כל ${g.pendingTotal} ההלכות בפסק זה?`))
reviewGroup(g, "approved"); reviewGroup(g, "approved");
}} }}
className="bg-gold text-navy hover:bg-gold-deep" className="bg-gold text-navy hover:bg-gold-deep"
@@ -711,6 +860,14 @@ function PendingPanel() {
); );
} }
return (
<div className="space-y-4">
{viewToggle}
{body}
</div>
);
}
// ─── Count badge for tabs ───────────────────────────────────────────────────── // ─── Count badge for tabs ─────────────────────────────────────────────────────
function useHalachaCount(status: string) { function useHalachaCount(status: string) {
@@ -730,7 +887,7 @@ const TAB_LABELS: Record<Tab, string> = {
export function HalachaReviewPanel() { export function HalachaReviewPanel() {
const [tab, setTab] = useState<Tab>("pending"); const [tab, setTab] = useState<Tab>("pending");
const { data: pendingData } = useHalachotPending(500); const { data: pendingData } = useHalachotPending({ limit: 500 });
const rejectedCount = useHalachaCount("rejected"); const rejectedCount = useHalachaCount("rejected");
const approvedCount = useHalachaCount("approved"); const approvedCount = useHalachaCount("approved");

View File

@@ -0,0 +1,111 @@
/**
* Gold-set tagging API (#81.7 / #81.8).
*
* The chair/Dafna manually labels a stratified sample of halachot
* (is_holding / correct_type / quote_complete). Those human labels are the
* ground truth used to measure the extraction validators and recalibrate the
* auto-approve threshold. Endpoints under /api/goldset.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
export type GoldsetItem = {
id: string;
halacha_id: string;
// human tags (null until tagged)
is_holding: boolean | null;
correct_type: string;
quote_complete: boolean | null;
tagged_by: string;
tagged_at: string | null;
// halacha content + the machine's own labels
rule_statement: string;
supporting_quote: string;
reasoning_summary: string;
rule_type: string;
confidence: number | null;
quality_flags?: string[];
review_status: string;
case_number: string | null;
case_name: string | null;
source_type: string | null; // 'court_ruling' | 'appeals_committee' | ''
// AI second-opinion (QA aid — independent, not ground truth, not auto-applied)
ai_is_holding: boolean | null;
ai_correct_type: string;
ai_rationale: string;
ai_generated_at: string | null;
};
export type GoldsetScore = {
batch: string;
total: number;
labeled: number;
validators: Record<
string,
{ precision: number; recall: number; f1: number; tp: number; fp: number; fn: number; tn: number }
>;
};
export type GoldsetTag = {
is_holding?: boolean | null;
correct_type?: string;
quote_complete?: boolean | null;
};
const keys = {
all: ["goldset"] as const,
list: (batch: string) => ["goldset", "list", batch] as const,
score: (batch: string) => ["goldset", "score", batch] as const,
};
export function useGoldset(batch = "default") {
return useQuery({
queryKey: keys.list(batch),
queryFn: ({ signal }) =>
apiRequest<{ items: GoldsetItem[]; batch: string }>(
`/api/goldset?batch=${encodeURIComponent(batch)}`,
{ signal },
),
staleTime: 5_000,
refetchOnMount: "always",
});
}
export function useGoldsetScore(batch = "default") {
return useQuery({
queryKey: keys.score(batch),
queryFn: ({ signal }) =>
apiRequest<GoldsetScore>(
`/api/goldset/score?batch=${encodeURIComponent(batch)}`,
{ signal },
),
staleTime: 5_000,
});
}
export function useTagGoldset(batch = "default") {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, tag }: { id: string; tag: GoldsetTag }) =>
apiRequest<{ ok: boolean }>(`/api/goldset/${encodeURIComponent(id)}`, {
method: "PATCH",
body: { ...tag, tagged_by: "chair" },
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.list(batch) });
qc.invalidateQueries({ queryKey: keys.score(batch) });
},
});
}
export function useCreateGoldsetSample(batch = "default") {
const qc = useQueryClient();
return useMutation({
mutationFn: (n: number) =>
apiRequest<{ batch: string; inserted: number; total: number }>(
"/api/goldset/sample",
{ method: "POST", body: { n, batch } },
),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.list(batch) }),
});
}

View File

@@ -92,6 +92,20 @@ export type Halacha = {
* negatively (distinguished/criticized/overruled). */ * negatively (distinguished/criticized/overruled). */
corroboration_count?: number; corroboration_count?: number;
corroboration_negative?: boolean; corroboration_negative?: boolean;
/* #84.2 near-duplicate clustering (present only when fetched with cluster=true):
* same-precedent halachot within the cluster cosine share a cluster_id, so the
* UI collapses them into one review card. cluster_size === 1 → singleton. */
cluster_id?: string;
cluster_size?: number;
/* #84.2 parallel authority (present only when fetched with include_equivalents):
* the SAME principle stated independently in OTHER precedents — recurrence, not
* citation (distinct from corroboration_count). */
equivalents?: {
halacha_id: string;
case_number: string;
rule_statement: string;
cosine: number | null;
}[];
}; };
export type RelatedCase = { export type RelatedCase = {
@@ -566,14 +580,32 @@ export function useRequestHalachotExtraction() {
}); });
} }
export function useHalachotPending(limit = 200) { /** #84.1/#84.2/#84.3 — the chair review queue.
*
* Default ("clean") view: quality-gated (flagged items hidden), priority-ordered
* (most-uncertain/negatively-treated first), and near-duplicate-clustered into
* one card. Pass `needsFix: true` for the 'needs extraction fix' bucket — every
* pending item carrying a quality flag (filtered client-side). */
export function useHalachotPending(
opts: { limit?: number; needsFix?: boolean } = {},
) {
const { limit = 200, needsFix = false } = opts;
const qs = needsFix
? `review_status=pending_review&exclude_low_quality=false&limit=${limit}`
: `review_status=pending_review&exclude_low_quality=true`
+ `&order_by_priority=true&cluster=true&include_equivalents=true&limit=${limit}`;
return useQuery({ return useQuery({
queryKey: libraryKeys.halachotPending(), queryKey: [...libraryKeys.halachotPending(), needsFix ? "needsfix" : "clean"],
queryFn: ({ signal }) => queryFn: async ({ signal }) => {
apiRequest<{ items: Halacha[]; count: number }>( const res = await apiRequest<{ items: Halacha[]; count: number }>(
`/api/halachot?review_status=pending_review&limit=${limit}`, `/api/halachot?${qs}`,
{ signal }, { signal },
), );
if (!needsFix) return res;
// needs-fix bucket = pending items that carry a quality flag
const items = res.items.filter((h) => (h.quality_flags?.length ?? 0) > 0);
return { items, count: items.length };
},
staleTime: 5_000, staleTime: 5_000,
refetchOnMount: "always", refetchOnMount: "always",
}); });

View File

@@ -6033,11 +6033,14 @@ async def halachot_list(
offset: int = 0, offset: int = 0,
exclude_low_quality: bool = False, exclude_low_quality: bool = False,
order_by_priority: bool = False, order_by_priority: bool = False,
cluster: bool = False,
include_equivalents: bool = False,
): ):
"""List halachot. ``exclude_low_quality`` hides flagged items (#84.1) and """List halachot. ``exclude_low_quality`` hides flagged items (#84.1),
``order_by_priority`` switches to the active-learning order (#84.3). Both ``order_by_priority`` switches to the active-learning order (#84.3),
default off so existing callers are unaffected; the review-queue view opts ``cluster`` annotates near-duplicate groups for one-card review (#84.2), and
in.""" ``include_equivalents`` attaches cross-precedent parallel-authority links. All
default off so existing callers are unaffected; the review queue opts in."""
cid: UUID | None = None cid: UUID | None = None
if case_law_id: if case_law_id:
try: try:
@@ -6051,10 +6054,104 @@ async def halachot_list(
limit=limit, offset=offset, limit=limit, offset=offset,
exclude_low_quality=exclude_low_quality, exclude_low_quality=exclude_low_quality,
order_by_priority=order_by_priority, order_by_priority=order_by_priority,
cluster=cluster,
include_equivalents=include_equivalents,
) )
return {"items": rows, "count": len(rows)} return {"items": rows, "count": len(rows)}
class EquivalentLinkRequest(BaseModel):
other_id: str
note: str = ""
@app.get("/api/halachot/{halacha_id}/equivalents")
async def halacha_equivalents_list(halacha_id: str):
"""Cross-precedent parallel-authority links for a halacha (#84.2)."""
try:
hid = UUID(halacha_id)
except ValueError:
raise HTTPException(400, "halacha_id לא תקין")
return {"items": await db.list_equivalent_for_halacha(hid)}
@app.post("/api/halachot/{halacha_id}/equivalents")
async def halacha_equivalents_link(halacha_id: str, req: EquivalentLinkRequest):
"""Chair links two halachot as the same principle across precedents (#84.2)."""
try:
hid = UUID(halacha_id)
oid = UUID(req.other_id)
except ValueError:
raise HTTPException(400, "מזהה הלכה לא תקין")
ok = await db.link_equivalent_halachot(hid, oid, note=req.note, created_by="chair")
if not ok:
raise HTTPException(
400, "לא ניתן לקשר — אותה הלכה או שתי הלכות מאותו פסק (קישור-מקביל הוא חוצה-פסקים)")
return {"ok": True}
@app.delete("/api/halachot/{halacha_id}/equivalents/{other_id}")
async def halacha_equivalents_unlink(halacha_id: str, other_id: str):
try:
hid, oid = UUID(halacha_id), UUID(other_id)
except ValueError:
raise HTTPException(400, "מזהה הלכה לא תקין")
return {"ok": await db.unlink_equivalent_halachot(hid, oid)}
# ── Gold-set tagging (#81.7 / #81.8) ─────────────────────────────────────────
class GoldsetSampleRequest(BaseModel):
n: int = 150
batch: str = "default"
reset: bool = False
class GoldsetTagRequest(BaseModel):
is_holding: bool | None = None
correct_type: str | None = None
quote_complete: bool | None = None
tagged_by: str = "chair"
@app.get("/api/goldset")
async def goldset_list_ep(batch: str = "default"):
"""The gold-set tagging queue (halacha content + machine labels + human tags)."""
return {"items": await db.goldset_list(batch), "batch": batch}
@app.post("/api/goldset/sample")
async def goldset_sample_ep(req: GoldsetSampleRequest):
"""Create/extend a stratified gold-set batch for tagging (#81.7)."""
return await db.goldset_create_sample(n=req.n, batch=req.batch, reset=req.reset)
@app.get("/api/goldset/score")
async def goldset_score_ep(batch: str = "default"):
"""Measure the extraction validators against the human tags (#81.8)."""
return await db.goldset_score(batch)
@app.patch("/api/goldset/{goldset_id}")
async def goldset_tag_ep(goldset_id: str, req: GoldsetTagRequest):
"""Save one human tag on a gold-set item."""
try:
gid = UUID(goldset_id)
except ValueError:
raise HTTPException(400, "מזהה לא תקין")
if req.correct_type and req.correct_type not in (
"binding", "interpretive", "obiter", "application", "procedural", "persuasive",
):
raise HTTPException(400, "correct_type לא תקין")
row = await db.goldset_tag(
gid, is_holding=req.is_holding, correct_type=req.correct_type,
quote_complete=req.quote_complete, tagged_by=req.tagged_by,
)
if not row:
raise HTTPException(404, "פריט לא נמצא")
return {"ok": True}
@app.patch("/api/halachot/{halacha_id}") @app.patch("/api/halachot/{halacha_id}")
async def halacha_update(halacha_id: str, req: HalachaUpdateRequest): async def halacha_update(halacha_id: str, req: HalachaUpdateRequest):
"""Approve / reject / edit a halacha. Used by the chair review queue.""" """Approve / reject / edit a halacha. Used by the chair review queue."""