171 Commits

Author SHA1 Message Date
1eece500d3 Merge pull request 'chore(agents): מחק קובץ-בנצ'מרק נטוש legal-analyst-gemini.md' (#180) from worktree-rm-gemini-benchmark into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 13s
G12 Leak-Guard / leak-guard (push) Successful in 6s
2026-06-10 13:34:07 +00:00
bfea8d8895 chore(agents): מחק את קובץ-הבנצ'מרק הנטוש legal-analyst-gemini.md
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 7s
הוחלף ב-legal-analyst-gemini-critique.md (שטן מליץ); 0 סוכנים מצביעים אליו.
שירת רק את ניסוי-ההשוואה החד-פעמי מול Opus (מתועד ב-comparison-opus-vs-gemini.md
ובזיכרון). אין סיבה לשמור קוד מת.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:33:51 +00:00
dd67318394 Merge pull request 'feat(pipeline): עמידות ל-final_learning דרך ה-runtime המשותף (P1, X16, #115)' (#179) from worktree-durable-final-learning into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
G12 Leak-Guard / leak-guard (push) Successful in 6s
2026-06-10 09:57:24 +00:00
b2912e1b83 feat(pipeline): durable execution for final_learning via shared runtime (P1, X16/INV-DUR1, #115)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s
מחיל את scripts/_pipeline_runtime.py (מ-P0) על final_learning_pipeline: 3 הצעדים
([1]ingest/Opus-distillation [2]enroll-style-corpus [3]style-panel) רצים דרך אותו
runtime עמידות — מימוש אחד לשני הפייפליינים (G2), לא מימוש מקביל.

קריסה/OOM בפאנל-הסגנון [3] ממשיכה מ-[3] במקום לשלם שוב על דיסטילציית-ה-Opus [1]
(היקרה). thread יציב לכל תיק (learning:{case}); dry-run = preview נפרד. CLI זהה +
--fresh. שגיאת ingest קריטית → raise → halt + clean non-zero exit (resume מנסה שוב).
degradation חיננית כמו ב-P0 (ללא langgraph → ליניארי).

אימות: py_compile OK; מיובא נקי ב-venv המשותף (langgraph נעדר, lazy import). מנגנון
ה-runtime עצמו מכוסה ב-test_pipeline_runtime.py (P0) — אותו runtime.

Invariants: INV-DUR1 (עמידות), G2 (runtime יחיד), G3 (idempotency).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:57:01 +00:00
f5650196b7 Merge pull request 'feat(pipeline): עמידות (LangGraph) ל-final_halacha (P0, X16/INV-DUR1, #114)' (#178) from worktree-langgraph-durable-pipeline into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
G12 Leak-Guard / leak-guard (push) Successful in 7s
2026-06-10 09:53:07 +00:00
e7d8b24d7c feat(pipeline): durable execution for final_halacha via LangGraph (P0, X16/INV-DUR1, #114)
scripts/_pipeline_runtime.py — runtime עמידות משותף: עוטף רשימת-צעדים async ב-LangGraph
StateGraph ליניארי עם AsyncSqliteSaver (checkpoint לכל צעד). קריסה/OOM ממשיכה מהצעד
שנכשל במקום להריץ הכל מחדש. degradation חיננית: ללא langgraph → ריצה ליניארית כמו קודם
(הכפתור לא נשבר). מימוש אחד לשני הפייפליינים (G2).

final_halacha_pipeline.py — 4 הצעדים ([0]extract [1]citations [2]corroboration [3]panel)
רצים דרך ה-runtime. CLI זהה + --fresh (ברירת-מחדל auto-resume). thread יציב לכל תיק;
dry-run = preview נפרד (תמיד fresh). קריסה בפאנל [3] → resume מ-[3] (steps 0-2 שמורים).

pyproject: extra "durable" (langgraph + langgraph-checkpoint-sqlite) — host-only,
optional. data/checkpoints/ ב-.gitignore.

גבול (X16 §1): LangGraph רק כמנוע-פנימי של הסקריפט — לא orchestrator (לא מסלול מקביל
ל-Paperclip; G2/G12). #108 (atomic extract) קדם לזה כתנאי.

אימות: test_pipeline_runtime.py — עם langgraph (venv-זמני): 3 passed (resume מדלג צעדים
שהושלמו · fresh מריץ-מחדש · linear). בלי langgraph (venv משותף): 1 passed + 2 skipped
(degradation). final_halacha מתקמפל ומיובא נקי בשני המצבים. הרצה end-to-end על הפייפליין
החי (DB+LLM) — לאחר `pip install -e ".[durable]"` בעץ הראשי.

Invariants: INV-DUR1 (עמידות), G2 (runtime יחיד), G3 (idempotency מחוזק).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:52:35 +00:00
61d235175f Merge pull request 'feat(ci): G12 leak-guard — אכיפת שער-הפלטפורמה (R4, #113)' (#177) from worktree-leak-guard-g12 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m31s
G12 Leak-Guard / leak-guard (push) Successful in 6s
2026-06-10 09:41:16 +00:00
d2b622f28e feat(ci): G12 leak-guard — enforce the Agent Platform Port seam (R4, #113)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
המאכף האוטומטי של INV-G12 (docs/spec/X15 §4). שני כללים קשיחים:
1. mcp-server/src (שכבת-האינטליגנציה) ללא סמלי-Paperclip — allowlist מנומק לפי
   substring ל-6 ההפניות הלגיטימיות (pm2-bridge + הערות-מקור company_id).
2. import seam — רק web/agent_platform_port.py (+ קבצי-המעטפת) מייבאים paperclip_*.

מימוש קנוני אחד (scripts/leak_guard.py, stdlib-בלבד), משותף לשלושה אכיפנים (G2):
• CI hard gate: .gitea/workflows/leak-guard.yaml (pull_request + push→main)
• pytest: mcp-server/tests/test_platform_port_leak_guard.py (כולל self-test שמוודא
  שה-guard תופס הזרקה — לא ירקב)
• hook בזמן-אמת: spec-guard.sh בודק את התוכן-הנכתב (new_string/content) על כתיבה
  ל-mcp-server/src ומזהיר על הזרקת-Paperclip (לא-deduped); תזכורת-הספ עודכנה ל-G1–G12.

מחריג קבצים-נוצרים (web-ui types.ts) ומעטפת מוצהרת; הפרונט מחוץ להיקף-האינטליגנציה
(ממצא R3). עודכן scripts/SCRIPTS.md.

אימות: סריקה נקייה exit 0; הזרקת pc.sh ל-mcp-server → exit 1; seam-violation ב-web → exit 1;
hook מזהיר על mcp-server ומזכיר-ספ על web; pytest 3 passed; bash -n + YAML תקינים.

Invariants: G12 (אכיפה), G2 (מאכף יחיד לשלושה צרכנים).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:40:42 +00:00
20781398ee Merge pull request 'refactor(web-ui): פירוק התנגשות-שם של Paperclip agent DTO + גבול-פלטפורמה מוצהר (R3, G12, #112)' (#176) from worktree-web-ui-platform-types into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 44s
2026-06-10 09:34:11 +00:00
e5168fe79d refactor(web-ui): disambiguate Paperclip agent DTO; document platform-presentation boundary (R3, G12, #112)
ממצא: התוכנית המקורית (namespace ל-paperclip.* ב-types.ts) בלתי-ישימה — types.ts
נוצר-אוטומטית מ-OpenAPI ("Do not make direct changes"); הפניות-Paperclip שם רק משקפות
את ה-API של ה-backend, ונשלטות ע"י מודלי-ה-Pydantic, לא ע"י הפרונט. הפרונט אינו
שכבת-אינטליגנציה — הפניות-Paperclip בו הן הצגת-נתוני-פלטפורמה (activity feed, קישור
לדאשבורד, סטטוס-ארכוב) או UI-ניהול מוצהר (paperclip-tab/agents-tab) — כולן shell-adjacent
לגיטימי תחת G12.

הבעיה האמיתית-והישימה: התנגשות-שם — `PaperclipAgent` הוגדר פעמיים עם shapes שונים
(config ב-paperclip-agents.ts מול activity ב-agents.ts). פוצל: ה-activity-DTO →
`PaperclipAgentStatus`; ה-config שומר `PaperclipAgent`. + הערת-כותרת שמסמנת את agents.ts
כמודול הצגת-פלטפורמה מוצהר.

מזין את R4 (#113): leak-guard חייב להחריג קבצים-נוצרים (types.ts) ולא לכלול את הפרונט
בהיקף שכבת-האינטליגנציה המוגנת.

אימות: tsc --noEmit נקי; eslint נקי על הקבצים ששונו.

Invariants: G12 (גבול-פלטפורמה מוצהר בפרונט), G2 (הסרת שם-טיפוס כפול-משמעות).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:33:44 +00:00
8a2ae9921a Merge pull request 'feat(web): Agent Platform Port — Paperclip מאחורי seam-import יחיד (R2, G12, #111)' (#175) from worktree-agent-platform-port into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
2026-06-10 09:28:46 +00:00
d4514e608d feat(web): Agent Platform Port — Paperclip behind a single import seam (R2, G12, #111)
יוצר web/agent_platform_port.py כמודול היחיד שמייבא web.paperclip_client/paperclip_api.
app.py מייבא כעת אך-ורק מה-Port — 0 imports ישירים של paperclip_* (היפוך-תלות פנימה,
Ports & Adapters / Dependency Rule). החלפת-הפלטפורמה = מימוש-מחדש של מודול אחד.

שתי שכבות ב-Port: (א) side-effects של מחזור-חיים נחשפים גם בשם-דומיין
(archive_case_project/restore/create/notify_case_status — אירועי-הדומיין המומלצים
לקוד חדש); (ב) פעולות issue/interaction/comment/agent — re-export בשם pc_* (קריאות
API, לא אירועים). מעבר מלא לפעלי-דומיין = follow-up; ה-import seam (החלק הניתן-לאכיפה
ב-G12) מוחזק בכל מקרה.

שינוי import-only באתרי-הקריאה — אפס שינוי-התנהגות, regression מינימלי.

אימות: app.py 0 imports ישירים; py_compile OK; כל 23 הסמלים נפתרים מה-Port;
domain-aliases identity-wired; test_paperclip_access_guard 5 passed.

Invariants: G12 (שער-הפלטפורמה — seam יחיד), G2 (מקור-אמת יחיד למגע-פלטפורמה).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:28:19 +00:00
b4f141df84 Merge pull request 'docs(agents): ריכוז פרוטוקול-הדיווח ל-HEARTBEAT §4 (R1, G12, #110)' (#174) from worktree-centralize-agent-protocol-heartbeat into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
2026-06-10 09:22:52 +00:00
1c182edb29 docs(agents): centralize completion/wakeup protocol to HEARTBEAT §4 (R1, G12, #110)
6 סוכני-העובד (writer/exporter/proofreader/qa/researcher/analyst) שכפלו inline את
בלוק-הדיווח-וההשכמה הזהה (PATCH done/blocked + CEO_ID if-block + wakeup JSON +
אזהרת agent_wakeup_requests) — שהוא כבר המקור הקנוני ב-HEARTBEAT.md §4ב/§4ג.

הוחלף בכל קובץ במצביע קומפקטי ל-HEARTBEAT §4ב/§4ג, תוך שימור: ה-rationale
(auto-retry), reason ספציפי-לתפקיד, ומלכודות ספציפיות (UUID של analyst, QA gate
של writer). כפילות הפרוטוקול הקשיח: 288→83 hits (היתרה: orchestration לגיטימי של
CEO + מצביעי-HEARTBEAT). סוכני gemini נשארו (וריאנט מצומצם מכוון, ללא CEO).

Invariants: G12 (פרומפטים מצביעים ל-HEARTBEAT, לא משכפלים פרוטוקול) · G2 (מקור יחיד).

הפצה לסוכנים-רצים: לאחר מיזוג — git pull בעץ הראשי + sync_agents_across_companies.py
(הפרומפטים נטענים מ-cwd המשותף). אומת מבנית: code-fences מאוזנים, מצביעי §4 מדויקים.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:22:20 +00:00
8b69adc7bd Merge pull request 'docs(spec): רישום G12 (שער-הפלטפורמה) בחוקה + תבנית-PR + CLAUDE (R0b, #109)' (#173) from worktree-register-g12-platform-port into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
2026-06-10 09:14:13 +00:00
2b6e95c484 docs(spec): register G12 (Platform Port) in constitution + PR template + CLAUDE (R0b, #109)
- 00-constitution.md §5א: INV-G12 (שער-הפלטפורמה) — Paperclip מאחורי Port יחיד;
  מקורות Cockburn Ports&Adapters + Martin Dependency Rule + Evans ACL, verified.
  עדכון תיאור-הבמה ל-"G1–G10, G12 הנדסיים"; אינדקס §7 השלים X14/X15/X16 + G1–G12.
- PULL_REQUEST_TEMPLATE.md: שורת-צ'קליסט G12 + דוגמה בהצהרת-invariants.
- CLAUDE.md: מצביע ל-X15 ב-§Paperclip ו-§פרוטוקול-כתיבת-קוד; G1–G12.
- X15 §2: סומן ש-R0b הושלם (G12 רשום בחוקה).

תיעוד בלבד. Invariants: G12 (מוגדר/נרשם), G2 (מיישם).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:13:48 +00:00
c903770fb3 Merge pull request 'fix(halacha): חילוץ-מחדש משמר הלכות מאושרות (INV-G10, #108)' (#172) from worktree-fix-halacha-reextract-dataloss into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m30s
2026-06-10 09:08:47 +00:00
26e0219219 fix(halacha): re-extraction preserves chair-approved halachot (INV-G10, #108)
תיקון data-loss: reset_halacha_extraction ביצע DELETE ללא-תנאי לפני חילוץ-מחדש;
קריסה בין המחיקה לאחסון הראשון מחקה את כל אישורי-היו"ר והשאירה את הרשומה תקועה
status='processing' עם 0 שורות (תקרית עמיאל 8126-03-25, 2026-06-08).

עכשיו המחיקה מחריגה review_status IN ('approved','published') — אישור אנושי לא
נמחק בשקט (INV-G10). ה-dedup-on-insert של store_halachot_for_chunk מדלג על חילוץ
טרי שמשכפל מאושרת שנשמרה, כך שאין כפילות. reset מחזיר {deleted, preserved},
וה-extractor מתעד כמה מאושרות נשמרו (provenance, G9).

עמידות מלאה מול מוות-תהליך (OOM) נשארת ל-X16/#114 (durable resume) — זה תנאי-מקדים.

בדיקה: test_halacha_reextract_preserves_approved.py (offline SQL-capture) מאמת
שה-DELETE מחריג approved/published; 64 בדיקות-הלכה קיימות עוברות.

Invariants: G10 (שער-יו"ר — אישור לא נמחק), G1 (תיקון במקור), G9 (provenance).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:08:16 +00:00
81171983e4 Merge pull request 'feat(agents): שער anti-hallucination משותף מעוגן-מקור (INV-AH) + ידע CEO על שטן-מליץ' (#171) from worktree-anti-hallucination-gate into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 11s
2026-06-09 17:05:56 +00:00
d156bcfaf1 feat(agents): שער anti-hallucination משותף מעוגן-מקור (INV-AH) לכל הסוכנים
מחלץ את דיסציפלינת מניעת-ההזיות לבלוק קנוני אחד (docs/anti-hallucination-gate.md)
ומחיל אותו אחיד על כל הסוכנים — במקום שכל סוכן ימציא אותה מחדש ad-hoc (G2: בלי מסלולים מקבילים).

5 טכניקות, כל אחת מעוגנת במקור מקצועי:
- AH-1 עיגון-מקור (אפס ציטוט מהזיכרון) — Stanford RegLab/Magesh JELS 2025 (כלי-RAG משפטיים הוזים 17-33%)
- AH-2 quote-or-retract + AH-3 abstention — Anthropic Reduce-hallucinations
- AH-4 תיוג-ודאות — NIST AI RMF GenAI Profile + RAGAS
- AH-5 Chain-of-Verification — Dhuliawala et al. arXiv:2309.11495

הפצה DRY: הפניה ב-HEARTBEAT.md (נקרא ע"י כל סוכני Paperclip) + שורה אחידה בבלוק
'קרא לפני פעולה' של כל 8 הסוכנים, עם הערת-יישום לכל תפקיד (writer=read-only, qa=אוכף,
proofreader=אל תתקן לכיוון מונח משפטי, exporter=אפס מהות חדשה).

בנוסף: legal-ceo.md מקבל ידע על 'שטן מליץ (Gemini)' עם מדיניות on-demand טהורה —
לא בפייפליין, מופעל רק לבקשת חיים/דפנה, הפלט=לידים ליו"ר (לא לכותב, human-in-the-loop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:05:33 +00:00
33d8faf74a Merge pull request 'docs(spec): X15 שער-הפלטפורמה (G12) + X16 עמידות-פייפליין' (#170) from worktree-platform-port-durability-spec into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-06-09 16:27:39 +00:00
cb822c4900 docs(spec): X15 שער-הפלטפורמה (G12) + X16 עמידות-פייפליין
X15 — Agent Platform Port: Paperclip כמעטפת ניתנת-להחלפה מאחורי Port יחיד.
מגדיר INV-PORT1/G12 (Ports&Adapters + Dependency Rule + Anti-Corruption Layer),
מצאי-דליפה baseline (mcp-server נקי; דליפה ב-app.py + 10 פרומפטים + web-ui),
מפת-תיקון R0–R4, ומנגנון-אכיפה נגד דליפה-עתידית (leak-guard + תבנית-PR).

X16 — Durable Pipeline Execution: LangGraph כספרייה בתוך הסקריפט (לא תחליף-פלטפורמה)
ל-final_halacha/final_learning. מגדיר INV-DUR1 (checkpointing+replay, מימוש משותף),
SqliteSaver תחת data/checkpoints, גרעיניות מדורגת P0–P3, שימור-חוזה-CLI.

מיישם/מחזק: G2 (X15), G3 (X16). תכנון בלבד — ללא שינוי-קוד.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:26:57 +00:00
f1d6f5dafc Merge pull request 'feat(agents): שטן מליץ (Gemini) — red-team מוגבל עם שער anti-hallucination' (#169) from worktree-gemini-critique into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
2026-06-09 16:16:13 +00:00
1a50aa7709 feat(agents): שטן מליץ (Gemini) — red-team מוגבל עם שער anti-hallucination
ממסד את סוכן-Gemini מחדש כשכבת דעה-שנייה (devil's advocate) שרצה אחרי Opus
ומפיקה מזכר-לידים לא-סמכותי ליו"ר — לא ניתוח מתחרה. read-only (critique-gemini.md בלבד).

9 אילוצי anti-hallucination קשיחים, מעוגנים במקורות מקצועיים:
- Stanford RegLab/Magesh JELS 2025 (כלי-RAG משפטיים הוזים 17-33%) → עיגון-קורפוס מוחלט
- Anthropic Reduce-hallucinations → quote-or-retract + abstention
- Chain-of-Verification (Dhuliawala 2023, arXiv:2309.11495) → מעבר-אימות חובה
- RAGAS faithfulness → atomic-claim grounding
- NIST AI RMF GenAI Profile 2024 → human-in-the-loop, לידים-לא-הכרעות

מתחיל בכותרת # (לא ---) למניעת כשל yargs ב-gemini --prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:15:53 +00:00
405167269f Merge pull request 'fix(agents): הסר frontmatter מ-legal-analyst-gemini (שובר את gemini --prompt)' (#168) from worktree-gemini-analyst-fix into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
2026-06-09 15:30:41 +00:00
7f573c0db3 fix(agents): הסר YAML frontmatter מ-legal-analyst-gemini.md
adapter gemini_local מעביר את תוכן הקובץ כ-arg ל-`gemini --prompt <content>`.
כש-content מתחיל ב-`---` (frontmatter), yargs מפרש זאת כדגל ולא כערך →
'Not enough arguments following: prompt' וה-run נכשל ב-adapter_failed תוך 2ש'.
הפיכת הראש לכותרת # פותרת. (claude_local לא נתקל בזה — הוא מעביר --append-system-prompt-file <path>.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:30:18 +00:00
aa0fde2724 Merge pull request 'feat(agents): מנתח משפטי וריאנט Gemini (read-only benchmark)' (#167) from worktree-gemini-analyst into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
2026-06-09 15:13:42 +00:00
e57730f375 feat(agents): מנתח משפטי וריאנט Gemini (read-only benchmark)
עותק של legal-analyst שרץ תחת gemini_local (Gemini CLI, gemini-3.1-pro-preview)
לצורך השוואת איכות-ניתוח מול Opus. מצב read-only: שלבי extract/aggregate/case_update
מוחלפים בקריאה בלבד, והפלט נכתב ל-analysis-and-research.GEMINI.md בלבד — כדי לא לדרוס
את ניתוח-Opus הקנוני. אכיפת read-only גם ברמת MCP (excludeTools ב-~/.gemini/settings.json).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:13:08 +00:00
6299998267 Merge pull request 'feat(arguments): פופאפ פרופוזיציות גולמיות בלחיצה על "מסתמך על N"' (#166) from worktree-argument-claims-popover into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
2026-06-09 06:51:51 +00:00
d4d2ab4d68 feat(arguments): פופאפ פרופוזיציות גולמיות בלחיצה על "מסתמך על N"
הקישור טיעון↔פרופוזיציות כבר נשמר ב-DB (legal_argument_propositions),
אך ה-UI הציג רק את המספר. מעשיר את get_legal_arguments באותו round-trip
(JOIN ל-claims) להחזיר supporting_propositions = {id, text, source_document},
ועוטף את שורת "מסתמך על N פרופוזיציות" ב-Popover שמציג את הטענות הגולמיות
verbatim עם מקור. שקיפות ועקיבוּת מהטיעון המאוגד חזרה לטענות-המקור.

- supporting_claims נשאר id-only (תאימות לאחור: מונה, צרכני MCP)
- supporting_propositions שדה חדש אופציונלי; fallback לטקסט סטטי כשחסר
- אין מסלול מקביל (G2) — העשרה של אותו endpoint; נרמול-במקור (G1)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:51:09 +00:00
c0af8c7cda Merge pull request 'feat(learning): מטא-דאטה מלא להחלטות-פנימיות בקליטה + חילוץ-הלכות אוטומטי' (#165) from worktree-internal-decision-metadata into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 11s
2026-06-08 15:19:34 +00:00
2f43960353 feat(learning): מטא-דאטה מלא להחלטות-פנימיות בקליטה + חילוץ-הלכות אוטומטי
סוגר את הפער שעלה על בל"מ 8126: החלטה שנכנסה לספריית-הפסיקה הופיעה ללא
מטא-דאטה (summary/citation/date ריקים, proceeding_type שגוי) כי מחלץ-ה-Gemini
מיועד לפסיקה חיצונית ומחזיר no_metadata לפנימיות, והחילוץ-הלכות נשאר pending.

web/app.py — `_enroll_final_in_library` עכשיו ממלא **דטרמיניסטית** מהתיק (בלי LLM):
- proceeding_type (מהתיק — בל"מ/ערר, גם idempotency key נכון מהקליטה הראשונה),
  decision_date (fallback ל-hearing_date), subject_tags, summary (=subject).
- `citation_formatted` נבנה דטרמיניסטית (`_build_internal_citation`):
  'ועדת ערר ... בל"מ <num> <עורר> נ' <משיב> (יו"ר עו"ד <chair>)'.

scripts/final_halacha_pipeline.py — שלב [0] חדש: `precedent_extract_halachot`
על ההחלטה עצמה (idempotent — מדלג כש-completed/dry-run), כך שהלכות-ההחלטה
לא נשארות pending.

אומת: py_compile ✓ · ה-pipeline רץ dry-run נקי (4 שלבים). 8126 כבר תוקן ידנית;
מכאן זה אוטומטי לכל החלטה. Invariants: INV-LRN4/X11 · G1 (נרמול-במקור) ·
DM7 · feedback_silent_swallow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:19:14 +00:00
de777c2b13 Merge pull request 'feat(cases): תצוגת "פסיקה שצוטטה בהחלטה" בעמוד-התיק + שחזור חיווט-הרמס' (#164) from worktree-case-citations-ui into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 41s
2026-06-08 11:59:45 +00:00
98c5feff25 feat(cases): תצוגת "פסיקה שצוטטה בהחלטה" בעמוד-התיק + שחזור חיווט-הרמס
UI שביקש חיים: בכניסה להחלטה רואים את הפסיקה שצוטטה בתוכה — מקושרת לספרייה
(קליק → /precedents/[id]) מול חסרה (סומנה אוטומטית להעלאה).

- web/app.py: GET /api/cases/{case}/citations — מהשורה internal_committee של
  ההחלטה ב-case_law → precedent_internal_citations: linked (join case_law) +
  missing (unresolved + האם flagged ב-missing_precedents).
- web-ui: lib/api/citations.ts (hook) + CitationsSection ב-drafts-panel
  (מוצג כשההחלטה בספרייה). מקושרת=ירוק/קליק, חסרה=ענבר "סומנה להעלאה".
- scripts/curator_apply_pipeline_branch.py: מקור-אמת לחיווט-הכפתורים של הרמס
  (ה-prompt חי רק ב-Paperclip DB). מקדים branch שמריץ את pipeline-ה-final
  ל-wake reason final_learning_*/final_halacha_* (HOME/DOTENV/DATA_DIR מוחלטים
  → מפתחות DeepSeek+Gemini + DATA_DIR נפתרים נכון). idempotent, שני הסוכנים.
  כבר הוחל ב-DB; הסקריפט לשחזור אחרי reset.

אומת: py_compile ✓ · tsc ✓ · החיווט אומת חי על 8126 (deepseek+gemini, dedup,
✓ pipeline הושלם). G2 (יכולת חסרה) · INV-LRN1/G10 נשמרים.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:59:21 +00:00
2c4287fd3d Merge pull request 'feat(learning): כל החלטה שלנו תמיד בספריית-הפסיקה + בדיקת-ציטוטים וסימון-חסרים אוטומטי' (#163) from worktree-final-into-library into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 40s
2026-06-08 11:28:16 +00:00
55362bf5a1 feat(learning): כל החלטה שלנו נכנסת תמיד לספריית-הפסיקה + בדיקת-ציטוטים אוטומטית
סוגר את הפער שלולאת-צמיחת-הקורפוס (07-learning §1.3) הוגדרה אך לא חווטה: מסלול
/final/upload הכניס רק לקורפוס-הסגנון, וההכנסה ל-case_law הייתה best-effort
שקטה שנכשלה כש-chair_name ריק.

web/app.py — /api/cases/{case}/final/upload עכשיו, סינכרונית:
- קובע chair_name דטרמיניסטית (תיק → ברירת-מחדל-ועדה לפי prefix; לעולם לא ריק →
  אילוץ case_law_internal_chair_check תמיד מסופק). לא נשען על חילוץ-LLM —
  להחלטות שלנו היו"ר ידוע.
- מכניס את ההחלטה ל-case_law כ-internal_committee (תמיד, לא best-effort) →
  ברת-ציטוט בהחלטות עתידיות. מטה-דאטה נוסף מועשר אסינכרונית (Gemini).
- מחלץ את הציטוטים שההחלטה מצטטת (extract_internal_citations), ו**מסמן
  אוטומטית** כל ציטוט שאינו בספרייה כ-missing_precedent (open) — dedup מול קיימים.
- התוצאה מוחזרת ב-response (enrolled/linked/missing_flagged) — לא נבלעת בשקט.

הציטוטים-המקושרים מזינים את לולאת-ה-corroboration (X11) — תוקן הניתוק שבו
החלטות שלנו לא היו ב-case_law ולכן לא חיזקו הלכות.

web-ui — toast מציג "נוספה לספרייה · N ציטוטים · M חסרים סומנו".
ספ: 07-learning §0.6 עודכן. אומת ידנית על בל"מ 8126-03-25 (15 קושרו / 6 סומנו).

Invariants: INV-LRN4, X11; G2 (יכולת חסרה, לא מקבילה); feedback_silent_swallow
(כשל-הכנסה צף, לא נבלע); DM7 (סמכות נגזרת).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:27:55 +00:00
7ebd4187a9 Merge pull request 'fix(style-panel): idempotency + dedup — הרצה חוזרת לא משכפלת לקחים' (#162) from worktree-style-panel-dedup into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-06-08 10:58:16 +00:00
c8344342a8 fix(style-panel): idempotency + dedup — re-running --apply never duplicates lessons
style_lesson_panel.py: before writing 2/2-keep lessons, skip any whose normalized
lesson_text already exists on the corpus (any source), and collapse duplicates within
a run. Makes the run-learning button safe to click repeatedly (the curator may re-run
the pipeline) — it converges instead of piling up duplicate decision_lessons.

Verified on בל"מ 8126-03-25: re-running --apply with 7 existing lessons wrote 0
("1 כפילויות דולגו"), count stayed 7.

Invariants: INV-LRN1/G10 unchanged (proposals only, manual fold).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:57:57 +00:00
02f411f4dc Merge pull request 'feat(learning): חיווט אוטונומי לכפתורי מסלול-הסופי (סקריפט-תזמור אחד לכל שלב)' (#161) from worktree-autonomous-final-pipeline into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 14s
2026-06-08 10:22:00 +00:00
0f0656ecca feat(learning): חיווט אוטונומי לכפתורי מסלול-הסופי — סקריפט-תזמור אחד לכל שלב
הכפתורים "הרץ למידת-קול"/"הרץ אימות-הלכות" מעירים את הרמס, ובמקום שהסוכן
(DeepSeek) ירכיב כמה קריאות-כלי (שביר), הוא מריץ עכשיו פקודה דטרמיניסטית אחת.

חדש:
- scripts/final_learning_pipeline.py — (1) ingest_final_version עם נתיב-הסופי
  (מדלג אם הזוג כבר analyzed; --force לחידוש), (2) רישום לקורפוס-הסגנון
  (idempotent — סוגר את הפער שפאנל-הסגנון דרש corpus_id), (3) style_lesson_panel
  --apply. --dry-run להרצה בטוחה.
- scripts/final_halacha_pipeline.py — extract_internal_citations →
  corroboration.build_all → halacha_panel_approve --apply. --dry-run / --limit.

briefs הרמס (web/paperclip_client._curator_task_brief) פושטו לפקודה-אחת לכל
task — חסין מול הרצת-סוכן. תוקנו שני הפערים שזוהו: ingest דרש file_path,
ופאנל-הסגנון דרש style_corpus.

נלווה: תיקון help מיושן של halacha_panel_approve (--apply מחווט). SCRIPTS.md.

אומת: שני ה-pipelines רצו dry-run על בל"מ 8126-03-25 (skip-ingest, קורפוס,
פאנלים) בהצלחה. Invariants: INV-LRN1/LRN5/G10 (הפיך, שער-יו"ר ידני נשמר),
INV-DM7. G2 — תזמור של יכולות קיימות, לא מסלול-מקביל.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:21:39 +00:00
c028328175 Merge pull request 'docs(claude-md): לרזות CLAUDE.md מ-11.3k ל-~7k טוקן (TaskMaster #107.1)' (#160) from worktree-claude-md-trim into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
2026-06-08 09:15:45 +00:00
471cd37fc8 docs(claude-md): לרזות CLAUDE.md מ-11.3k ל-~7k טוקן (TaskMaster #107.1)
מעביר עומק תפעולי ל-docs/operations-runbook.md חדש כדי לצמצם את ההקשר
שנטען אוטומטית בכל סשן. CLAUDE.md נשאר אינדקס דק עם כל הכללים הקריטיים.

הועבר ל-runbook: טבלת Nautilus, פירוט Deploy (Coolify/pm2/legal-chat-service),
עץ-תיקיות מלא, Paperclip deep-ops (wakeup payload, cross-company sync,
webhook flow, scheduled jobs, deepseek_local + hermes curator adapters),
Chair-Feedback, TaskMaster מפורט.

נשמר inline (קריטי): spec-first protocol, worktree isolation, יעד-העל
Style-Acquisition, טבלת מסמכי-ייחוס, עקרונות-כתיבה G11, וכללי-Paperclip
הקריטיים בתמצית (wakeup-via-API, helper-not-curl, comment routing).

344→159 שורות; ~4.3k טוקן/סשן נחסכים. כל התוכן נשאר ב-repo ונגיש דרך קישור.

Invariants: G2 (אין מסלול מקביל — תוכן הועבר, לא שוכפל), G11 (עקרונות-כתיבה
נשמרו inline). תיעוד בלבד, אין נגיעה בקוד.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:14:52 +00:00
9f358db353 Merge pull request 'fix(ops): self-restart/stop של גשר-המארח מחזיר 200 (detached)' (#159) from worktree-ops-selfrestart into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m33s
2026-06-08 09:09:30 +00:00
d23f854c25 fix(ops): self-restart/stop of the host bridge returns 200 (detached)
Restarting/stopping legal-court-fetch-service from its own /pm2/control kills
the process before it can reply — the client got a misleading 502 even though
pm2 performed the restart. Detach the self-action (sleep 1; pm2 ...) so the HTTP
response flushes first, and report success optimistically. Other targets are
unchanged. Own name via COURT_FETCH_SERVICE_PM2_NAME (default legal-court-fetch-service).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:09:08 +00:00
9ae49f0f70 Merge pull request 'feat(learning): מסלול נקי להעלאת החלטה סופית + פאנל-סגנון דו-סוכני (DeepSeek+Gemini)' (#158) from worktree-final-upload-pipeline into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 42s
2026-06-08 09:04:16 +00:00
f79c46a352 feat(learning): מסלול נקי להעלאת החלטה סופית + פאנל-סגנון דו-סוכני (DeepSeek+Gemini)
מוסיף מסלול ייעודי לקליטת ההחלטה החתומה של היו"ר, ומפעיל אותו דרך שני
שלבים אוטומטיים מדורגים עם פאנלי-סוכנים (אוטו-אישור + אסקלציה ליו"ר).

Backend (web/):
- POST /api/cases/{case}/final/upload — קליטת final חיצוני: שמירה קנונית
  (סופי-{case}.docx + עותק קורפוס-סגנון תחת case_number מלא כדי שבל"מ לא
  יתנגש עם ערר באותו מספר), פתיחת draft_final_pairs (final_received). לא נוגע
  ב-active_draft ולא מריץ retrofit (נבדל מ-exports/upload ו-mark-final → לא G2).
- POST .../final/run-learning + .../final/run-halacha — שלבים מדורגים שמעירים
  worker מקומי (claude/DeepSeek/Gemini מקומיים בלבד) דרך הרחבת
  wake_curator_for_final עם param task=learning|halacha.

פאנל-סגנון חדש (scripts/style_lesson_panel.py): שני שופטים (DeepSeek+Gemini)
על-גבי דיסטילציית-ה-Opus; הסכמה 2/2-keep → decision_lesson
(source=panel:deepseek+gemini); substance מדולג (INV-LRN5); הפיך + גיבוי CSV.
פאנל-הלכות: docstring/SCRIPTS.md עודכנו (--apply מחווט).

Frontend (web-ui/): כפתור "העלאת החלטה סופית של היו"ר" + שני כפתורים מדורגים
"הרץ למידת-קול"/"הרץ אימות-הלכות" ב-drafts-panel; כל התוויות בעברית
(badge מקור-לקח: "פאנל: דיפסיק+גמיני", "הרמס (סקירה)"...).

Spec: docs/spec/07-learning.md §0.6. Invariants: INV-LRN1/LRN4/LRN5, G10
(שער-יו"ר ידני להטמעה ל-SKILL.md/lessons.md — הפאנלים יוצרים הצעות בלבד);
G2 (מסלול-סופי הוא יכולת חסרה, לא מסלול-מקביל).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:03:26 +00:00
ae30a4d19a Merge pull request 'feat(ops): /operations — מוני-תור אחידים, "מה רץ עכשיו", וניהול-תהליכים כמו שירותי-Windows' (#157) from worktree-ops-controls into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m37s
2026-06-08 08:59:54 +00:00
638eef6803 feat(ops): /operations — מוני-תור אחידים, "מה רץ עכשיו", וניהול-תהליכים
הדף הציג את התורים באופן לא-אחיד (by_status גולמי), בלי הבחנה בין "ממתין"
(בקלוג: status=pending) ל"בתור" (התור הפעיל: requested_at IS NOT NULL), בלי
הצגת הפריט שרץ כרגע, ובלי שום שליטה בתהליכים.

מה נוסף:
1. כרטיסי-תור אחידים — בתור / ממתין(בקלוג) / בעיבוד / הושלם / נכשל + "רץ עכשיו"
   (citation/case_number של הפריט בעיבוד) לכל drain (אחזור-פסיקה, מטא-דאטה,
   הלכות, יומונים). שערי-אנוש (אישור-הלכות, פסיקה-חסרה) נשארים מוני-סטטוס.
2. פאנל ניהול-תהליכים בסגנון "שירותי Windows":
   - דמון (court-fetch-service/xvfb/chat/reaper): הפעל-מחדש / עצור / הפעל.
   - cron drain: "הרץ עכשיו" (pm2 restart) + מתג הפעל/כבה תזמון.
3. כל תגי-הסטטוס מתורגמים לעברית.

מנגנון:
- הפעל/כבה תזמון = דגל ב-DB (טבלה drain_controls). pm2 cron_restart מחיה תהליך
  שעוצר ב-stop, לכן ה"כיבוי" האמין הוא דגל שכל drain בודק ב-startup (no-op מיידי
  כשכבוי). הקונטיינר כותב/קורא ישירות מ-DB.
- הרץ-עכשיו + restart/stop/start = proxy ל-pm2 דרך endpoint חדש בגשר-המארח
  (court_fetch_service /pm2/control), מאובטח Bearer + whitelist ל-legal-* בלבד.
- יומונים: drain_digests הועבר מ-crontab ל-pm2 (legal-digest-drain.config.cjs)
  כדי שיופיע ויהיה שליט כמו כל drain. drain_halacha_queue.py הובא לבקרת-גרסאות.

Invariants: מקיים G2 (הרחבת /operations + הגשר הקיים, לא מסלול מקביל) ו-G1
(drain_controls = מקור-אמת יחיד לכיבוי, נורמליזציה במקור ולא תיקון-בקריאה).
אין בליעת שגיאות שקטה (הגשר מחזיר {ok,error}; המוטציות מציגות toast).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:57:23 +00:00
6647aa92e6 Merge pull request 'feat(storage): X14 Phase 2c — remaining sync write-sites through storage.py' (#156) from worktree-storage-minio-phase2c into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
2026-06-08 08:26:34 +00:00
b2ea0c28dd feat(storage): X14 Phase 2c — route remaining sync write-sites through storage.py
Completes the write-side rewiring (INV-STG1) for the call-sites that run in
synchronous contexts, via a new blocking facade in storage.py
(put_bytes_sync / put_file_sync — asyncio.run, or a worker thread when a loop
is already running):
- services/extractor.py: multimodal thumbnail JPEGs → DERIVED (rendered in a
  to_thread worker)
- services/docx_reviser.py: track-changes save (_save_docx_xml) + empty-diff
  copy (copy_with_revisions) → DOCUMENTS
- services/docx_retrofit.py: in-place retrofit backup → DOCUMENTS

Each site keeps a fallback to a direct disk write when the target path is
outside DATA_DIR (caller-provided). Under the default STORAGE_BACKEND=
filesystem the bytes land exactly where they did before — zero behaviour
change.

Also: mcp_env_catalog MINIO_ENDPOINT default updated to the durable
container-name endpoint (http://minio-bx2ykvw94xbutsex41hz4vv8:9000), matching
the Coolify "Connect to Predefined Network" change made for network durability.

All binary write-sites now flow through storage.py. git-tracked text
(case.json/notes/research-md/draft-md) stays on disk by design (INV-STG7);
court-fetch temp files are ephemeral.

tests: +2 (thumbnail renderer routes through storage; put_bytes_sync
round-trip); 55 storage/docx/track-changes green; 244 collected, no import
breakage.

Keeps G2; completes INV-STG1 write coverage. Spec: docs/spec/X14-storage-minio.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:26:09 +00:00
bc5dd9ac48 Merge pull request 'feat(digests-ui): publication filter + 'מאמר'/source badges for bulletins' (#155) from worktree-bulletins-ui into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m30s
2026-06-08 08:14:40 +00:00
5745d36bb4 feat(digests-ui): publication filter + 'מאמר'/source badges for bulletins
משלים את #154 בצד-לקוח:
- פילטר "מקור" בדף /digests (כל המקורות / כל יום / עו"ד על נדל"ן) — backend:
  list_digests + /api/digests מקבלים publication.
- DigestCard: תג "מאמר" ל-digest_kind='article', ו-chip מקור לפרסום שאינו 'כל יום'.

build (webpack) עובר, lint נקי. digests = hand-written types (אין api:types).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:14:23 +00:00
05e8373d22 Merge pull request 'feat(bulletins): catalog monthly "עו"ד על נדל"ן" bulletins into the radar (X12)' (#154) from worktree-bulletins-catalog into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m36s
2026-06-08 08:08:10 +00:00
85f94a4f3f feat(bulletins): catalog monthly "עו"ד על נדל"ן" bulletins into the radar (X12)
עלון חודשי רב-נושאי (פרסום נפרד מהיומון היומי) → מתפצל ל-N שורות digest באותה
טבלה (publication='עו"ד על נדל"ן', לא קורפוס מקביל — G2):
- bulletin_splitter (LLM local-only, tools=""): מפצל ל-cases[]+articles[];
  עדכוני-חקיקה מדולגים (החלטת יו"ר).
- bulletin_library.ingest_bulletin: כל מצביע-פסיקה → digest_kind='decision'
  + embedding + autolink (כולל X13 court-fetch); כל מאמר → digest_kind='article'
  (טקסט-מלא + embedding, רקע בלבד — INV-DIG1 חל).
- content_hash per-item הוא מפתח-הדדאפ (yomon_number ריק) → אידמפוטנטי.
- db.create_digest: פרמטר digest_kind (זורם ל-INSERT + upsert).
- scripts/ingest_bulletins.py (host, venv) לעיבוד הארכיון.
- spec X12 §2.1.

אומת (dry-run, ללא DB): עלון 180 → 4 cases+1 article · עלון 201 → 4 cases
(כולל ערר-197) +1 article. עדכוני-חקיקה דולגו. claude_session נשאר local-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:07:45 +00:00
1e41125baa Merge pull request 'feat(storage): X14 Phase 2b — extracted-text + async DOCX exports through storage.py' (#153) from worktree-storage-minio-phase2b into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
2026-06-08 08:05:52 +00:00
1f42a39ce4 feat(storage): X14 Phase 2b — route extracted-text + async DOCX exports through storage.py
Continue the write-site rewiring onto the unified storage layer (INV-STG1):
- services/processor.py: extracted-text .txt → DERIVED bucket (a derived
  artifact; the DB column is the source of truth per INV-STG5, so the write
  stays non-fatal)
- services/docx_exporter.py (export_decision): DOCX → DOCUMENTS bucket via
  BytesIO → put_bytes, with a fallback to a direct disk write when the caller
  passes an output_path outside DATA_DIR
- services/analysis_docx_exporter.py (build_analysis_docx): same pattern;
  out_path is always under DATA_DIR

Under the default STORAGE_BACKEND=filesystem the bytes land at the exact
legacy path (put_bytes → DATA_DIR/key), so behaviour is unchanged. The
disk-reading bits that must stay for now (export_dir glob in _next_version)
are kept; storage-native versioning is a cutover concern.

Still on disk (sync call-sites, follow-up Phase 2c): docx_reviser
(track-changes), docx_retrofit backup, and multimodal thumbnails (rendered in
a to_thread). git-tracked text (case.json/notes/research-md/draft-md) stays on
disk by design (INV-STG7).

tests: 38 storage + docx tests green (incl. test_export_qa_gate /
test_docx_exporter_bookmarks which exercise the real export path); 242
collected, no import breakage.

Keeps G2; advances INV-STG1. Spec: docs/spec/X14-storage-minio.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:05:25 +00:00
39f8cb7c15 Merge pull request 'feat(storage): X14 Phase 2a — route source-document writes through storage.py' (#152) from worktree-storage-minio-phase2 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m30s
2026-06-08 08:01:00 +00:00
1986fe3b14 feat(storage): X14 Phase 2a — route source-document writes through storage.py
Rewire the source-document staging writes onto the unified storage layer
(INV-STG1), replacing direct shutil.copy2 calls:
- tools/documents.py: case originals + training-corpus uploads
- services/ingest.py: _stage_file (now async) — covers precedent-library,
  internal-decisions, and digests (the canonical intake helper)
- services/digest_library.py: awaits the now-async _stage_file

Each write goes through storage.put_file(..., bucket=DOCUMENTS) with the
DATA_DIR-relative key; the Hebrew original filename rides as object metadata
(INV-STG2), content-type is guessed from the extension. DB path columns are
unchanged (still the absolute dest) — object_key backfill is Phase 3.

Under the default STORAGE_BACKEND=filesystem the bytes land at the exact
legacy on-disk location (put_file → shutil.copy2 to DATA_DIR/key), so this
is zero behaviour change in prod. shutil import dropped where now unused.

tests: +2 staging regression tests (file lands under DATA_DIR at the legacy
path); 20 storage + 22 ingest tests green; 242 collected with no import
breakage.

Derived/export write sites (thumbnails, extracted text, DOCX exports) are
Phase 2b. Keeps G2; advances INV-STG1. Spec: docs/spec/X14-storage-minio.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:00:27 +00:00
81b3de6f4f Merge pull request 'feat(storage): X14 Phase 1 — unified storage layer (services/storage.py)' (#151) from worktree-storage-minio-phase1 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
2026-06-08 07:48:19 +00:00
b4a28f072d feat(storage): X14 Phase 1 — unified storage layer (services/storage.py)
The single choke-point for all binary file I/O (originals, derived
artifacts, exports), replacing the scattered open()/shutil/Path.write_bytes
calls across ~8 services. Backend chosen by STORAGE_BACKEND:
- filesystem (default): disk under DATA_DIR — byte-for-byte legacy behaviour
- dual: write disk + S3, read S3→disk fallback (migration window)
- s3: MinIO via aioboto3 (lazy import; absent in the filesystem path)

Keys are DATA_DIR-relative POSIX paths; the FS backend ignores the logical
bucket and keeps the existing single tree, so the default backend is zero
behaviour change. S3 maps a governance bucket (documents/immutable/derived)
→ MinIO bucket; presigned URLs are minted against the public endpoint
(browser-reachable) and carry the Hebrew filename via RFC-5987
Content-Disposition.

- config: STORAGE_BACKEND + MINIO_* (endpoint, public-endpoint, creds,
  region, 3 bucket names, presign TTL)
- mcp_env_catalog: new "storage" category + 10 specs (X10/INV-ENV1)
- pyproject: aioboto3>=13 (consumed here, deployed with first use)
- tests: 18 unit tests (FS round-trip, key normalization/traversal guard,
  bucket resolution, backend selection, dual write-both + S3-down fallback)

No call-sites are rewired yet — that is Phase 2 (106.3). STORAGE_BACKEND
stays filesystem in prod, so behaviour is unchanged.

Invariants: keeps G2 (one storage path replaces scattered I/O); establishes
INV-STG1 (single layer), INV-STG2 (atomic keys, Hebrew name in metadata),
INV-STG3 (governance buckets), INV-STG6 (presigned serving).
Spec: docs/spec/X14-storage-minio.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 07:47:49 +00:00
ade22ca871 Merge pull request 'feat(nav): הסרת דף מדגם-זהב (goldset) מה-UI' (#150) from worktree-rm-goldset into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 40s
2026-06-08 07:33:18 +00:00
54948eb8ab feat(nav): הסרת דף מדגם-זהב (goldset) מה-UI
הכיול החד-פעמי של ולידטורי חילוץ-ההלכות (#81.8) הסתיים — הוסר מה-UI:
- web-ui/src/app/goldset/page.tsx (הדף)
- web-ui/src/components/goldset/goldset-panel.tsx (הרכיב)
- web-ui/src/lib/api/goldset.ts (מודול ה-API)
- הקישור "מדגם-זהב" מתפריט "פסיקה" + השטחת התת-כותרת "ניתוח וכיול"
  (נותר רק "מפת הקורפוס" → רשימה שטוחה)
- ניקוי אזכורי gold-set מהערות approvals/page.tsx ו-chair.ts

ה-backend נשאר במכוון: טבלת halacha_goldset, ה-endpoints (/api/goldset*)
ופונקציות ה-DB משמשים את סקריפטי ה-eval/benchmark ומחזיקים נתוני-תיוג
אנושיים — אין מחיקת DB ואין שבירת סקריפטים. /api/chair/pending ממילא לא
כלל goldset, אז אין קישור שבור במרכז-האישורים.

Invariants: G2 (הסרת יכולת-UF מיותרת ללא יצירת מסלול מקביל).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 07:32:48 +00:00
6ec67d1a11 Merge pull request 'feat(ops): דף /operations — כל מה שרץ ברקע' (#149) from worktree-ops-dashboard into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
2026-06-08 07:29:19 +00:00
34d80a39e5 feat(ops): /operations dashboard — everything running in the background
A single live page for all the background work that downloads/analyses, so the
chair can see what's running instead of guessing.

- court_fetch_service: GET /pm2 (unauthenticated, host-only) → trimmed pm2 jlist
  for the legal-* services (status, restarts, mem, cron schedule).
- FastAPI GET /api/operations: aggregates the DB-backed pipelines (court_fetch
  jobs, metadata + halacha extraction queues, halacha review gate,
  missing_precedents, digests, recent court ingests) and proxies the host /pm2
  over the docker bridge (graceful if the host service is down).
- web-ui /operations page (+ src/lib/api/operations.ts hook, nav entry under
  admin): services grid (with Hebrew labels + schedules) + pipeline cards +
  recent-fetch / recent-ingest lists. Auto-refreshes every 5s.

tsc --noEmit clean; pm2 status carries nothing sensitive and the bind
(10.0.1.1) is host/container-only, so /pm2 needs no secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 07:28:41 +00:00
5bd235bcff Merge pull request 'feat(bulletins): staging endpoint /api/bulletins/upload (download archive first)' (#148) from worktree-bulletins-download into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 11s
2026-06-08 07:24:26 +00:00
a92f543e7f feat(bulletins): staging endpoint /api/bulletins/upload (download archive first)
העלון החודשי "עו"ד על נדל"ן" הוא פרסום נפרד מהיומון היומי (חודשי, רב-נושאי).
לפני תכנון הקטלוג — נוריד את כל הארכיון (~29) לתיקייה. endpoint זה רק מ-stage
את ה-PDF ל-data/bulletins/incoming (ללא DB), dedup לפי content_hash. n8n ימשוך
מ-chaim.marcus@gmail (subject "עו"ד על נדל"ן") וישלח לכאן.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 07:24:05 +00:00
8de2401cb1 Merge pull request 'feat(nav): קיבוץ הניווט העליון בתפריטים נפתחים (פסיקה/סגנון)' (#147) from worktree-nav-tidy into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 58s
2026-06-08 07:20:22 +00:00
83d30365c9 feat(nav): קיבוץ הניווט העליון בתפריטים נפתחים (פסיקה/סגנון)
שורת הניווט הצטמצמה מ-11 קישורים ישירים ל-4 קישורי-עבודה
(בית · מרכז אישורים · הערות יו״ר · ארכיון) + 2 תפריטים נפתחים:

- "פסיקה ▾": ספריית פסיקה · יומונים · פסיקה חסרה · ‎—ניתוח וכיול—‎ ·
  מפת הקורפוס · מדגם-זהב
- "סגנון ▾": אימון סגנון · מתודולוגיה

מפת-הקורפוס, מדגם-זהב ומתודולוגיה הורדו-בדרגה מהשורה הראשית לתוך
התפריטים (לפי בקשת היו"ר) — אך כל ה-routes נשמרים, אין שינוי URL.

trigger התפריט מקבל הדגשה + קו-זהב תחתון כשאחד מילדיו פעיל;
badge "פסיקה חסרה" מוצג גם על trigger "פסיקה" וגם בתוך הפריט.

Invariants: מקיים G2 (איחוד מסלולי-ניווט, ללא יצירת מסלול מקביל —
כל הדפים נותרים נגישים, deep-links נשמרים).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 07:19:32 +00:00
64b9bd9d99 Merge pull request 'feat(X13 Tier-0): פענוח API של supremedecisions — אחזור פסקי עליון סדרתיים' (#146) from worktree-supreme-tier0 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m30s
2026-06-08 06:54:06 +00:00
8d2f1ea0a2 feat(X13 Tier-0): decode supremedecisions API — fetch serial-format Supreme verdicts
The 211 open missing_precedents include 99 Supreme serial-format rulings
(בג"ץ/בר"מ/עע"מ NNNN/YY) with no נט-format triple — fetchable only from
supremedecisions.court.gov.il. Decoded its public JSON API (no browser, no
CAPTCHA, no smart-card); validated live on בג"ץ 3483/05 + בר"מ 10212/16.

- court_fetch_supreme.py: rewrite. POST Home/SearchVerdicts with a structured
  `document` ({Year:"YYYY", CaseNum, OldMainNumFormat:true, SearchText:[…]}) +
  X-Requested-With header → records; GET Home/Download?path=&fileName=&type=4 →
  PDF. The earlier attempt failed only on the request shape (string vs object).
  2-digit→4-digit year; try candidate docs best-first (פסק-דין→pages), skipping
  the published-report 's'-prefix files the free endpoint WAF-blocks.
- orchestrator: on successful ingest, close matching open missing_precedents
  (link to the new case_law). End-to-end validated (בר"מ 10212/16 → corpus).
- backfill_missing_precedents.py: enqueue fetchable open gaps (supreme + net)
  into court_fetch_jobs; the drainer fetches+ingests+closes. dry-run default.
- X13 spec + SCRIPTS.md updated (Tier-0 decoded, no longer a limitation).

Very old un-digitized Supreme cases (e.g. בג"ץ 389/87 → 0 records) → manual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:53:31 +00:00
36319a8d75 Merge pull request 'docs(spec): X14 — object-storage (MinIO/S3) migration plan' (#145) from worktree-storage-minio-plan into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
2026-06-08 06:30:36 +00:00
16470f6279 docs(spec): X14 — object-storage (MinIO/S3) migration plan
Adds docs/spec/X14-storage-minio.md — the domain spec + phased plan for
migrating binary document storage from the local data/ tree to the
already-deployed MinIO service (Coolify svc `minio`).

Captures: disk inventory, scattered file-I/O map (~8 services, no central
layer), DB path columns, MinIO deploy state, Paperclip = API-consumer only.
Defines 7 domain invariants (INV-STG1..7) and a 7-phase execution plan.

Chair decisions (2026-06-08): git-per-case keeps text/metadata + MinIO holds
binaries (INV-STG7); WORM Object-Lock on FINAL decisions only (INV-STG4);
internal Docker network for legal-ai↔MinIO.

Invariants: keeps G2 (single storage path replaces scattered I/O);
INV-STG1..7 new. Spec-only PR — no code/behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:30:06 +00:00
97d5b178d3 Merge pull request 'fix(halacha): panel reads canonical GOOGLE_GEMINI_API_KEY' (#144) from worktree-gemini-keyname into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
2026-06-08 06:09:11 +00:00
a5a4f53660 fix(halacha): panel reads canonical GOOGLE_GEMINI_API_KEY (Infisical name)
The Gemini key is stored in Infisical as GOOGLE_GEMINI_API_KEY
(nautilus /external-apis/gemini). Align the panel to read that canonical name
first, falling back to bare GEMINI_API_KEY for back-compat — so an
Infisical→.env sync keeps working.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:08:55 +00:00
6c6e4e021b Merge pull request 'feat(digests-ui): digest_kind badge — mark announcement issues in /digests' (#143) from worktree-digest-kind-ui into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 50s
2026-06-08 06:07:16 +00:00
d895062b4c feat(digests-ui): digest_kind badge — mark announcement issues in /digests
משלים את #141 בצד-לקוח: שדה digest_kind ב-Digest type (hand-written), ותג
"עדכון" ב-DigestCard לגיליונות announcement (לא-הכרעות). decision = ברירת-מחדל
ללא תג. זורם דרך /api/digests (digest_kind כבר ב-_DIGEST_COLS).

build (webpack) עובר, lint נקי בקבצי digests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:06:53 +00:00
a1db283ce1 Merge pull request 'fix(extraction): self-heal לתור חילוץ-ההלכות + drainer מתוזמן' (#142) from worktree-halacha-selfheal into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m41s
2026-06-08 06:05:27 +00:00
97ede1a49d fix(extraction): self-heal stale halacha 'processing' rows + scheduled drainer
The halacha extraction queue was stuck (same class as the metadata issue): 26
precedents requested extraction with no drainer, plus 1 orphaned in 'processing'
(status=processing, requested_at cleared → never re-picked by the queue).

- db.requeue_stale_processing_extractions(kind): re-stamp orphaned 'processing'
  rows (requested_at IS NULL) so they re-drain; halacha extractor force=False
  resumes from chunk checkpoints (no duplicates).
- process_pending_extractions calls it at the top — fully unattended, safe under
  the global advisory lock. Mirrors the digests-drain self-heal.
- legal-halacha-drain.config.cjs: pm2 cron (every 2h, conservative — Claude is
  slow/rate-limited and each run adds to the chair's pending_review queue).
  drain_halacha_queue.py stays on claude_session (high reasoning quality for
  holding/ratio; NOT moved to Gemini). SCRIPTS.md.

The chair-approval gate (INV-G10) is untouched — this only produces halachot;
Daphna still approves each in /approvals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:04:53 +00:00
2972ef74a4 Merge pull request 'feat(digests): digest_kind classification — robust extraction for all issue types (X12)' (#141) from worktree-digest-kind into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m58s
2026-06-08 06:02:36 +00:00
5676fd1157 Merge pull request 'docs(graph): document the corpus-graph feature (/graph)' (#140) from worktree-graph-docs into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 12s
2026-06-08 06:02:22 +00:00
83d1a8253c feat(digests): digest_kind classification — robust extraction for all issue types (X12)
~2% מגיליונות "כל יום" הם לא-הכרעות (עדכוני-חקיקה/הודעות/ברכות) ללא ruling →
החילוץ ה-decision-centric החזיר ריק → both-empty → מחזורי ב-self-heal.

- SCHEMA_V32: `digest_kind` (decision/announcement/other) + backfill legacy בזול
  (יש citation→decision, אחרת announcement) — לפני שה-self-heal מסתמך עליו.
- extractor: prompt מסווג + מחלץ תמיד concept/headline/summary; underlying_* רק
  ל-decision. extract מנרמל digest_kind.
- enrich: שומר digest_kind; חילוץ מוצלח תמיד מסתיים ב-kind לא-ריק (ברירת-מחדל
  לפי citation אם המודל השמיט).
- drain self-heal: הגדרת-כשל = completed עם digest_kind='' (במקום both-empty) →
  הודעות לא מנוסות-מחדש לנצח.
- db: digest_kind ב-_DIGEST_COLS + update-whitelist (זורם ל-search/list/API).
- X12 spec: תיעוד digest_kind + הגדרת-הכשל המתוקנת.

אומת: V32 סיווג 533 (525 decision + 8 announcement, 0 unclassified — self-heal
לא נוגע בהם). extract: 5163→decision+citation · 5060→announcement+concept,
citation ריק (לא both-empty).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:02:08 +00:00
5eeff24889 docs(graph): document the corpus-graph feature (/graph)
Records the now-complete corpus citation graph: why native not Obsidian (G2),
the 6 opt-in node layers (precedent/topic/practice-area · halacha · gaps ·
digests), node size/color semantics, the Graph Analysis metrics
(PageRank/betweenness/community via web/graph_metrics.py), navigation, the
/api/graph/* endpoints, the key files, a how-to-extend recipe, the invariants
(G2/G5/UI2/UI4), and the PR history.

Adds docs/corpus-graph.md + a reference-table row in legal-ai/CLAUDE.md.
Docs only — no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:02:02 +00:00
5bf2ea0262 Merge pull request 'chore(web-ui): regenerate api types from prod OpenAPI' (#139) from worktree-graph-apitypes into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
2026-06-08 05:16:12 +00:00
7fb5134580 chore(web-ui): regenerate api types from prod OpenAPI
`npm run api:types` — brings the generated src/lib/api/types.ts up to date
with the live FastAPI schema (UI1: types derive from the OpenAPI SSoT). The
file had drifted; this regen captures the corpus-graph endpoints/models
(/api/graph/corpus, /api/graph/facets, /api/graph/node/{id}/neighborhood;
CorpusGraph / GraphNode / GraphFacets) plus accumulated changes from other
merged work. web-ui build passes against the regenerated types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 05:15:43 +00:00
c3735d019a Merge pull request 'feat(extraction): חילוץ-מטא של פסיקה דרך Gemini Flash + drainer מתוזמן' (#138) from worktree-gemini-metadata into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m30s
2026-06-08 05:14:29 +00:00
d95a36f310 feat(extraction): precedent metadata via Gemini Flash + scheduled drainer
The /precedents metadata queue was stuck — 24 rows requested, nothing draining
them — and the agentic claude CLI hit error_max_turns on what is a single
structured text→JSON task (slow + flaky). Metadata extraction is bounded
extraction, the wrong fit for an agentic loop.

- gemini_session.py: query_json drop-in (gemini-2.5-flash, JSON mode, httpx —
  no new SDK dep). Reads GEMINI_API_KEY (~/.env; SoT Infisical
  nautilus:/external-apis/gemini). Host-side only — no LLM from the container.
- precedent_metadata_extractor: claude_session.query_json → gemini_session.
  Validated live: rich, accurate fields (case_name/summary/appeal_subtype/tags).
- process_pending_extractions: kind-aware cooldown — metadata 2s (Gemini, fast),
  halacha keeps 30s (Claude rate limits).
- drain_metadata_queue.py + legal-metadata-drain.config.cjs (pm2 cron */15) so
  the queue never clogs again. SCRIPTS.md.
- X8 INV-FP5 updated: per-task engine choice (Gemini=bounded metadata,
  claude_session=agentic halacha), both host-side, single canonical queue (G2).

Agentic/voice-sensitive work (writing, analysis, halacha) stays on claude_session
(Daphna's subscription). Gemini cost ≈ $0.10/1M tokens — negligible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 05:13:49 +00:00
de56d3b39d Merge pull request 'feat(graph): halacha (rule) layer — closes Phase 2' (#137) from worktree-graph-halacha into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 50s
2026-06-08 05:13:34 +00:00
ef21cb93e5 feat(graph): halacha (rule) layer (corpus graph — closes Phase 2)
Enables the previously-disabled "הלכות" toggle. Each approved/published halacha
of a displayed precedent becomes a hal:<id> node linked to its parent
precedent (extracted_from); two cross-rule edges when both endpoints are in
view: corroborates (a later ruling cites the rule —
halacha_citation_corroboration) and equivalent (same principle from another
committee — equivalent_halachot). Node size = corroboration in-degree.

Backend (web/graph_api.py — read-only, G2):
- _halacha_nodes_and_edges(): halachot WHERE case_law_id in view AND
  review_status IN (approved, published), LIMIT 600; rule_type carried in the
  source_kind slot, rule_statement in note. Wired into both build functions
  (gated via node_types). Metrics still exclude halacha edges (only cites/
  precedent-typed feed PageRank). Validated: 185 halachot on the top-30
  precedents; 20 corroboration + 5 equivalent edges in the corpus.

Frontend:
- graph.ts: GraphEdgeType += extracted_from.
- graph-filter-panel: "הלכות" toggle enabled (was disabled "שלב ב׳").
- graph-canvas: amber halacha nodes; edge colours — extracted_from (faint
  amber), corroborates (amber), equivalent (violet).
- graph-node-panel: halacha branch — אזכורים + סוג כלל + rule text; "open in
  library" deep-links to the parent precedent.
- graph-view: halacha added to node + edge legends.

web-ui build + lint pass. Invariants: G2 (SELECT-only), UI2 (no model change —
reuses note/source_kind/case_law_id slots).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 05:13:09 +00:00
cc9adc5c1f Merge pull request 'feat(halacha): panel safety-net audit (selective-prediction monitoring)' (#136) from worktree-halacha-audit into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-06-08 05:01:32 +00:00
da4ebeb724 feat(halacha): panel safety-net audit (selective-prediction monitoring)
Periodic safety net for the multi-judge approval panel: samples panel-approved
halachot, re-runs the same 3-judge KEEP vote, and surfaces any that now lean
DROP — candidate false-keeps a human should glance at. Report-only by default;
--flag reopens flips to pending_review. Baseline 0/15 on the 2026-06-07 batch.

Closes the loop the literature prescribes (Trust-or-Escalate / selective
prediction): monitor the auto-decision error rate rather than trusting it
blindly. Reuses halacha_panel_approve's judges (single source of truth).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 05:01:03 +00:00
d8113adec6 Merge pull request 'fix(digests): enrich self-cleans duplicate-yomon rows (re-sent issues)' (#135) from worktree-digest-dup-yomon into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m43s
2026-06-08 04:59:34 +00:00
a3a02ca67a fix(digests): enrich self-cleans duplicate-yomon rows (re-sent issues)
אותו יומון יכול להגיע כשני PDF שונים (re-send/forward → בייטים שונים →
content_hash dedup מפספס), אבל yomon_number ייחודי → ה-update ב-enrich מתנגש
על uq_digests_yomon_number. עכשיו enrich תופס את ההתנגשות, מוחק את השורה
הכפולה (היומון כבר קיים), ומחזיר status='duplicate' — כך ה-cron לא מנסה אותה
שוב ושוב. סוגר לולאת-retry אינסופית פוטנציאלית במערכת הלא-מאוישת.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 04:59:12 +00:00
b022cc7a97 Merge pull request 'feat(graph): navigation & UX — deep-link, depth, PNG, rich panel (PR D)' (#134) from worktree-graph-nav into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 44s
2026-06-08 04:56:26 +00:00
5f1b96ccaf feat(graph): navigation & UX — deep-link, depth, PNG, rich panel (PR D)
Final corpus-graph PR. Connects the graph to the chair's workflow and rounds
out the Obsidian-grade interactions.

Backend (web/graph_api.py): neighborhood depth cap 2 → 3 (still bounded by
NODE_CAP_MAX).

Frontend:
- URL deep-link: /graph?focus=cl:<id> is read on mount and written on focus
  change (router.replace, scroll:false). GraphView wrapped in <Suspense> per
  Next 16's useSearchParams requirement.
- "הצג בגרף" button on the precedent detail page → /graph?focus=cl:<id>.
- Depth slider (1–3) in the focused overlay → useNodeNeighborhood(id, depth).
- Export PNG: grabs the rendered <canvas> from the area ref → toDataURL →
  download; failures surface a toast (UI4).
- Rich node panel: precedent nodes fetch headnote/summary via the existing
  usePrecedent hook (Skeleton while pending, error surfaced — UI4).
- Edge-type legend (ציטוט / נושא-תחום / יומון) added under the node legend.

Deferred (noted for a later pass): expand-in-place merge, search→camera-center.

web-ui build + lint pass. Invariants: G2 (depth change is read-only), UI4
(PNG + detail errors surfaced, not swallowed). api:types post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 04:56:01 +00:00
4b5c8a2772 Merge pull request 'fix(digests): self-heal stale 'processing' rows in drain (fully unattended)' (#133) from worktree-digest-heal-processing into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
2026-06-08 04:53:12 +00:00
b5f7b60fb5 fix(digests): self-heal stale 'processing' rows in drain (fully unattended)
drain_digests רץ תחת flock (drainer יחיד), אז כל שורה 'processing' בתחילת ריצה
היא שריד מריצה קודמת שנקטעה באמצע-שורה (סשן/מכסה). מאפסים אותה ל-'pending'
לריצה חוזרת — סוגר את הפער האחרון ל-resume אוטומטי מלא ללא התערבות.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 04:52:55 +00:00
2c75666d26 Merge pull request 'feat(graph): daily-digest (יומון) discovery layer (corpus graph PR E)' (#132) from worktree-graph-digests into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 52s
2026-06-07 21:31:29 +00:00
fc5d69902f feat(graph): daily-digest (יומון) discovery layer (corpus graph PR E)
Chaim's idea: surface the downloaded "כל יום" digests in the graph. Each digest
COVERS the ruling it analyses — a corpus precedent when we have it (16), or a
synthesized gap node from its underlying_citation when we don't (269). So the
digest layer doubles as a discovery signal: it makes visible that the daily
feed overwhelmingly covers rulings NOT yet in the corpus.

Backend (web/graph_api.py — read-only, G2):
- "digest" added to VALID_NODE_TYPES (off by default).
- _digest_nodes_and_edges(): dig:<id> nodes from completed digests, `covers`
  edge → cl:precedent (linked_case_law_id in view) or → gap:<underlying_citation>
  (synthesized, deduped against the gap layer — real in-degree wins). Carries
  concept_tag (label), headline_holding (note), underlying_court/date.
- _add_digests() appends the layer with gap dedup. Wired into both build
  functions. GraphNode += note, digest_id. Gated via node_types (no app.py
  change). Validated: 16 covers→precedent, 269 covers→gap.

Frontend:
- graph.ts: GraphNodeType += "digest"; GraphEdgeType += "covers"; node fields.
- graph-filter-panel: toggle "יומונים (כל יום)" (off by default).
- graph-canvas: digest = teal node (r=4); `covers` edges teal.
- graph-node-panel: digest branch — concept + holding + court/date + link to
  /digests.

web-ui build + lint pass. Invariants: G2 (SELECT-only), UI2. api:types post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:31:04 +00:00
8dc0a268fb Merge pull request 'feat(graph): research-gap (ghost) nodes (corpus graph PR C)' (#131) from worktree-graph-gaps into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 42s
2026-06-07 21:22:19 +00:00
9a126f7c36 feat(graph): research-gap (ghost) nodes (corpus graph PR C)
Turns the graph into a gap-finder: the 247 unresolved internal citations
(a corpus precedent cites a ruling NOT in the corpus) collapse to 230 distinct
"gap" nodes — each sized by how many corpus precedents cite it, i.e. the
most-wanted missing precedent.

Backend (web/graph_api.py — read-only, G2):
- "gap" added to VALID_NODE_TYPES (NOT default → off unless requested).
- New _gap_nodes_and_edges(): gap:<normalized citation> nodes from
  precedent_internal_citations WHERE cited_case_law_id IS NULL, sized by global
  in-degree; cites edges only from precedents present in the view (dangling-edge
  invariant holds). Best-effort enrichment from missing_precedents via exact
  normalized-citation match → gap_status + missing_precedent_id. Validated:
  230 gaps, top ע"א 3213/97 (cited 5×), 230/230 matched to missing_precedents.
- GraphNode += gap_status, missing_precedent_id. Metrics correctly exclude gap
  edges (target not a precedent). No app.py change (gated via node_types).

Frontend:
- graph.ts: GraphNodeType += "gap"; node fields.
- graph-filter-panel: toggle "חוסרי מחקר (פסיקה חסרה)" (off by default).
- graph-canvas: gaps render as faint hollow dashed circles, never recoloured
  by color-by; sized by citation count.
- graph-node-panel: gap branch — "מצוטטת ע״י N פסיקות" + status badge + link
  to /missing-precedents.

web-ui build + lint pass. Invariants: G2 (SELECT-only), UI2 (model grows on
explicit Pydantic). api:types post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:21:53 +00:00
3c030dd7f5 Merge pull request 'feat(halacha): multi-judge approval panel + policy calibration (Trust-or-Escalate)' (#130) from worktree-halacha-panel into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 11s
2026-06-07 21:12:03 +00:00
dba2a131e0 feat(halacha): multi-judge approval panel + policy calibration (Trust-or-Escalate)
The chair cannot review every pending halacha. Three independent-lineage judges
(Opus via claude_session · DeepSeek · Gemini-2.5-flash — #1 on LegalBench) vote
on the COARSE axis we proved reliable across models (92%): "is this a genuine,
keepable rule?". Only an agreed verdict acts; every split escalates to the chair
(INV-G10). Buckets: clean→KEEP?; nli_unsupported→entailment re-adjudication;
extraction-defects→re-extraction.

halacha_panel_calibrate.py calibrates the voting policy on the gold-set's
is_holding (the coarse label) per Trust-or-Escalate (ICLR 2025): unanimous →
94.9% precision / 78% coverage; majority → 92.9% / 99%; ZERO false-drops in
both (the panel never rejects a good rule). Chosen policy (chair-approved):
clean→majority-2/3, nli→asymmetric (majority-reject, unanimous-approve),
defects→re-extraction. Reversible (--apply backs up review_status+flags first).

Sources: Panel-of-LLM-Evaluators (PoLL) · Trust-or-Escalate (ICLR 2025,
arXiv:2407.18370) · selective-prediction / learning-to-defer.

Invariants: upholds G10 (human gate — splits escalate, panel only collapses the
queue) and G9 (provenance — reviewer records the panel + policy). Read paths only
in calibrate; --apply writes review_status/quality_flags reversibly with backup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:11:30 +00:00
ecd9e46bb9 Merge pull request 'feat(graph): centrality + cluster analytics (corpus graph PR B)' (#129) from worktree-graph-analytics into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 49s
2026-06-07 21:05:13 +00:00
6cdf178ea4 Merge pull request 'docs: רישום Infisical כ-SoT לסודות שירותי-המארח' (#128) from worktree-secret-infisical-note into main
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
2026-06-07 21:05:09 +00:00
2fbc0cd3c2 feat(graph): centrality + cluster analytics (corpus graph PR B)
The Obsidian "Graph Analysis" equivalent — surfaces influence and structure
beyond raw citation count.

Backend (new web/graph_metrics.py — pure, dependency-free, no DB → G2):
- PageRank (power-iteration), betweenness (Brandes), community (deterministic
  label-propagation + connected-components fallback), computed in-memory over
  the precedent citation subgraph that build_corpus_graph already fetched.
  Normalized 0–1; community ints dense-ranked by size (stable colours).
- GraphNode += pagerank/betweenness/community (None unless metrics=true).
- build_corpus_graph + /api/graph/corpus gain metrics=false (default path
  unchanged). Validated on the live corpus: 147 nodes in 13ms.

Frontend:
- graph.ts: GraphNode metrics fields + metrics param.
- graph-canvas: color-by (type | practice_area | precedent_level | community |
  recency) and size-by (in-degree | pagerank | betweenness) via colorForNode /
  radiusForNode; exported palettes.
- graph-view: colorBy/sizeBy controls; metrics requested only when needed;
  global metrics overlaid onto neighborhood nodes by id (a node's PageRank
  shouldn't change when focused); a ranking panel (Tabs: המשפיעות / גשרים,
  click → focus); dynamic legend per color-by.
- graph-filter-panel: "צביעה לפי" + "גודל נקודה לפי" Selects.

web-ui build + lint pass. Invariants: G2 (metrics pure, no DB writes),
UI2 (model grows on explicit Pydantic). api:types post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:04:47 +00:00
360f49d8b4 docs: record Infisical SoT for host-service shared secrets
COURT_FETCH_SHARED_SECRET + LEGAL_CHAT_SHARED_SECRET migrated to Infisical
nautilus:/legal-ai (2026-06-07). Updated the pm2 config comments: the stale
"migrate to Infisical once the MCP server is back" TODO is now done; local
env files remain the runtime source, Infisical is the SoT/record.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:04:44 +00:00
24d80e6a2a Merge pull request 'feat(digests): self-heal drain — auto-resume after quota/interruption (X12)' (#127) from worktree-digest-resume into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 12s
2026-06-07 21:00:18 +00:00
3ae183009f feat(digests): self-heal in drain_digests — auto-resume after quota/interruption
ה-cron של drain_digests הוא מנגנון ה-resume (pending-based, idempotent, host-side,
לא תלוי בסשן). חיזוק: אם enrich נכשל באמצע (מכסת claude נגמרה) השורה נשארה
'completed' עם שדות ריקים → לא היתה מטופלת שוב. עכשיו drain מאפס בתחילתו כל
digest 'completed' עם concept_tag ריק *וגם* underlying_citation ריק (= חילוץ
שמעולם לא נחת; שורה תקינה תמיד מכילה לפחות מראה-מקום) → pending לריצה חוזרת.
כך כל קטיעה/מכסה מתאוששת אוטומטית בריצת ה-cron הבאה.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:59:49 +00:00
106ab53231 Merge pull request 'feat(graph): metadata filters + facets (corpus graph PR A)' (#126) from worktree-graph-metadata into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m1s
2026-06-07 20:52:36 +00:00
8258f09228 feat(graph): metadata filters + facets (corpus graph PR A)
Adds legal-metadata filtering and the payload to color by it (foundation for
the color-by selector in the analytics PR).

Backend (web/graph_api.py, web/app.py) — read-only, G2:
- GraphNode += court, date (ISO) — precedents carry them for filter/color-by.
- build_corpus_graph += server-side WHERE filters (G5): court, precedent_level,
  chair, district, year_from, year_to (EXTRACT(YEAR FROM date)). Neighborhood
  query also selects court/date.
- New GET /api/graph/facets (response_model GraphFacets, UI2) → distinct
  courts/levels/chairs/districts so the UI doesn't hardcode Hebrew strings.

Frontend:
- graph.ts: GraphNode += court/date; GraphFilters += the six params;
  buildParams; useGraphFacets() hook.
- graph-filter-panel: an "advanced" Accordion with court/precedent_level/chair/
  district Selects (from facets) + year-from/year-to Selects.
- graph-view: new controls wired into filters; facets fetched and passed down.

Verified read-only against the live DB (precedent_level=עליון&year_from=2015
filters correctly; facets populated: 36 courts / 3 levels / 19 chairs / 4
districts). web-ui build + lint pass.

Invariants: G2 (SELECT-only via db.get_pool), G5 (filters server-side),
UI2 (explicit response_models). api:types to be regenerated post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:52:13 +00:00
aa32766a8c Merge pull request 'docs(X13): סנכרון ספ לניתוב-לפי-פורמט + מגבלת Tier-0' (#125) from worktree-court-fetch-specsync into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
2026-06-07 20:51:58 +00:00
6882ccfcf1 docs(X13): sync spec to route-by-format reality + Tier-0 limitation
The spec said "supreme → Tier-0"; reality (PR #124) routes by נט-format
availability — נט המשפט (Tier-1) serves all courts incl. Supreme-with-נט-format,
and only serial-only Supreme falls to the (still-unbuilt) Tier-0 → manual.
Updated §0 source-distinction, §1 routing diagram, §5 risks (Tier-0 limitation
+ scheduled drain). Docs-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:51:38 +00:00
618f476a22 Merge pull request 'fix(X13): ניתוב לפי פורמט-נט; טיפול-שגיאות חסין באחזור' (#124) from worktree-court-fetch-routing into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m43s
2026-06-07 20:46:01 +00:00
69b34f1c3f fix(X13): route by נט-format availability; robust fetch error handling
Live drain surfaced three issues:
1. Tier-0 needed `h2` (httpx http2) — added to the court-fetch extra.
2. Supreme cases that carry a נט-format number (e.g. בר"מ 72182-06-25) were
   routed to the unvalidated Tier-0 and failed, even though נט המשפט serves
   Supreme cases too. classify() now parses the file-month-year triple for
   Supreme prefixes; the orchestrator routes by triple-availability:
     נט-format present → Tier-1 (validated, all courts)
     serial-only Supreme (עע"מ 5886/24) → Tier-0
     neither → clear "no public route" failure
   Validated live: בר"מ 72182-06-25 fetched via Tier-1 (5-page PDF).
3. A non-`RuntimeError` fetch exception (the h2 import error) left jobs stuck
   in 'running'. The fetch block now catches any Exception → _record_failure
   (INV-CF2/CF3), so a job always reaches a terminal state.

+ test_supreme_with_net_format_triple. Suite 11/11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:45:20 +00:00
796bfa890f Merge pull request 'feat(digests): drain_digests.py — local enrichment drainer for daily cron (X12)' (#123) from worktree-digest-cron into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 13s
2026-06-07 20:41:07 +00:00
c1abf2ec0e feat(digests): scripts/drain_digests.py — local enrichment drainer for cron (X12)
ריקון תור ההעשרה של יומונים מקומית (claude_session local-only): כל digest
'pending' → enrich_digest (Sonnet + embedding + autolink). מקבילי (3),
idempotent, מוסיף ~/.local/bin ל-PATH (claude CLI תחת cron). מיועד ל-cron
יומי אחרי ה-poll של n8n (flock למניעת חפיפה) + שימוש ידני אחרי backfill.
SCRIPTS.md עודכן.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:40:45 +00:00
6468e151d9 Merge pull request 'refactor(digests): single source of truth — drop processed/ folder state (X12)' (#122) from worktree-digests-single-truth into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 28s
2026-06-07 20:33:43 +00:00
fb40ec8565 refactor(digests): single source of truth — drop processed/ folder state (X12)
ה-DB (`digests`) הוא מקור-האמת היחיד למצב-קליטה. ingest_digests_batch.py העביר
קבצים incoming→processed/ — state מבוסס-תיקיות מקביל ל-DB (הפרת-G2 קטנה).

- הוסר ה-move ל-processed/ + import shutil + PROCESSED. הסקריפט מסתמך על
  dedup ב-content_hash (ingest_digest מחזיר 'exists' לקיימים) → הרצה חוזרת בטוחה.
- תיקיות (incoming/) = staging בלבד, לא state.
- X12 INV-DIG2: תועד מקור-אמת-יחיד + ההפרה-שתוקנה (processed/).
- SCRIPTS.md עודכן.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:33:18 +00:00
bcd5fd5f8d Merge pull request 'feat(X13): drain מתוזמן → לולאת יומון→אחזור→קליטה אוטונומית מלאה' (#121) from worktree-court-fetch-schedule into main
Some checks are pending
Build & Deploy / build-and-deploy (push) Has started running
2026-06-07 20:32:32 +00:00
f4f110f0d1 feat(X13): scheduled drain — fully-autonomous digest→fetch→ingest loop
- scripts/drain_court_fetch.py: drives orchestrator.drain_pending (host-only;
  no-op when queue empty). Mirrors drain_halacha_queue.py.
- scripts/legal-court-fetch-drain.config.cjs: pm2 cron (hourly :17, one-shot),
  COURT_FETCH_DRAIN_CRON override.
- fix: orchestrator default service URL 127.0.0.1 → 10.0.1.1 (the service binds
  the docker0 gateway; the host can't reach it on loopback). Found live — the
  first drain failed "connection refused" until corrected.
- SCRIPTS.md entries.

Validated end-to-end in PRODUCTION on a real digest: עת"מ 43830-12-24
(החברה להגנת הטבע) fetched from נט המשפט → case_law (79 chunks, source_url),
digest relinked (INV-DIG3 closed), halacha queued pending_review. job=done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:31:53 +00:00
540d39b958 Merge pull request 'fix(extract): disable tools for digest LLM extraction (no error_max_turns)' (#120) from worktree-digest-notools into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
2026-06-07 20:18:48 +00:00
d3b5c563ce fix(extract): disable tools for digest LLM extraction (no error_max_turns)
חילוץ-המטא-דאטה של יומון הוא טקסט→JSON טהור, אבל ה-claude CLI רץ עם tools
זמינים, ו-Sonnet לפעמים פולט stop_reason=tool_use → פוגע ב---max-turns 1 →
error_max_turns → retry (איטי). מבזבז זמן רב בגיבוי-המוני.

- claude_session.query/query_json: פרמטר חדש `tools` → מועבר כ---tools.
  "" = ביטוי כל ה-tools (אין tool_use → אין max-turns trip). None = ברירת-CLI.
- digest_metadata_extractor.extract: מעביר tools="".

אומת: extract על יומון 5160 ב-Sonnet+tools="" → num_turns=1, JSON תקין, ללא
error_max_turns. claude_session נשאר local-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:18:29 +00:00
d9340f6c39 Merge pull request 'feat(goldset): independent second-judge for rule_role — break AI-anchoring' (#119) from worktree-goldset-independent-judge into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 11s
2026-06-07 20:13:25 +00:00
808c2e4c46 feat(goldset): independent second-judge for rule_role (break AI-anchoring)
The gold-set's human role tags were made while seeing a claude AI recommendation,
so human↔AI agreement (~100%) is anchoring, not an independent accuracy signal.
This adds a third, genuinely independent judge — a DIFFERENT model (DeepSeek,
direct OpenAI-compatible API) classifies rule_role BLIND (never sees the human
tag nor the first AI's answer) — and reports an inter-rater agreement matrix.

Finding (100 tagged items): ai↔human 100% (anchored) vs deepseek↔human 50%
fine-grained — BUT 92% on the coarse axis (generalizable-rule vs application/
obiter). Conclusion: the fine sub-type (holding/interpretive/procedural) is an
inherently fuzzy boundary two capable models split differently; the coarse
"is this a real rule" axis is robust across models. Use the coarse axis as
ground truth; treat the sub-type as advisory, never as a gate.

Zero chair tagging, read-only on the gold-set. Key from ~/.hermes deepseek env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:12:58 +00:00
879bb6c074 Merge pull request 'fix(graph): stop corpus-graph labels overlapping' (#118) from worktree-corpus-graph-labels into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m1s
2026-06-07 20:07:57 +00:00
f3e99a14ca fix(graph): stop corpus-graph labels overlapping
Labels piled on top of each other (esp. in the neighborhood view) for two
reasons, both fixed in graph-canvas.tsx:

1. Font grew as you zoomed OUT (size was divided by sqrt(globalScale) and had
   a +6 floor), so at overview zoom labels became huge and collided. Now the
   label font is a ~constant SCREEN size (fontPx / globalScale).

2. Every node drew its label at once. Now labels are zoom-gated: at overview
   zoom only the active node, the 3 practice-area hubs, and the most-cited
   precedents (size>=4) are labeled; topic hubs appear at >=1.05 and the rest
   at >=1.5 — by which point there is pixel room between nodes.

Also: a white halo (strokeText) behind each label for legibility over edges
and nearby nodes, and stronger d3 forces (charge -220, link distance 60) so
nodes spread out and labels have more room.

web-ui build passes; /graph in the route table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:07:27 +00:00
b9fa38f3db Merge pull request 'feat(X13): טריגר אוטומטי מיומונים → אחזור פסיקה + כלי drain' (#117) from worktree-court-fetch-trigger into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m37s
2026-06-07 20:04:45 +00:00
f56309da5a feat(X13): auto-trigger court fetch from digests + drain tool
סוגר את הלולאה — יומון שמצביע על פס"ד בית-משפט שלא בקורפוס מזניק אחזור
אוטומטי, וקושר את היומון חזרה אחרי הקליטה (INV-DIG3 + INV-CF2).

- digest_library.try_autolink: בכשל-קישור, אם הציטוט מסווג כפס"ד-בימ"ש
  (supreme/admin) → _enqueue_court_fetch יוצר court_fetch_jobs(pending);
  ועדת-ערר (skip) לא מוזנק. never-raises (לא שובר קליטת-יומון).
- orchestrator.drain_pending(limit): מנקז pending/failed סדרתי (cooldown,
  INV-CF4), fetch+ingest לכל אחד; בהצלחה מקשר את היומון ל-case_law שנקלט.
- כלי-MCP court_fetch_drain + רישום ב-server.py.
- X13 spec: עודכן (הפער ב-INV-CF2 סומן כמתוקן).

נבדק מול ה-DB: עת"מ 46111-12-22 → job tier=admin pending digest-linked;
ערר 1110/20 → לא מוזנק. כלי מקומי בלבד (ingest = claude CLI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:04:12 +00:00
635dc98492 Merge pull request 'feat(digests): Sonnet for digest metadata extraction (X12)' (#116) from worktree-digest-sonnet into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
2026-06-07 19:59:30 +00:00
e6dc410d7d feat(digests): use Sonnet for digest metadata extraction (X12)
חילוץ-המטא-דאטה של יומון (תג-מושג, כותרת-הלכה, מראה-מקום, תגיות מסיכום
עמוד-אחד) הוא משימה פשוטה בנפח גבוה — Sonnet הוא נקודת-האיזון מהירות/עלות,
בניגוד לחילוץ-הלכות שמצמיד Opus.

- config.DIGEST_EXTRACT_MODEL (env-tunable, ברירת-מחדל claude-sonnet-4-6).
- digest_metadata_extractor.extract(model=None) → ברירת-מחדל מה-config; קודם
  לא צוין model → רץ על ברירת-המחדל של ה-CLI (Opus 4.8).

אומת: extract על יומון 5163 עם Sonnet החזיר תג-מושג/כותרת/מראה-מקום/תחום/
תגיות תקינים (~36s). claude_session נשאר local-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:58:48 +00:00
e82eeaad9f Merge pull request 'fix(X13): הקשחה נגד דליפת-זיכרון מדפדפנים + reaper ל-task-master-mcp' (#115) from worktree-court-fetch-harden into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m24s
2026-06-07 19:44:26 +00:00
e186183527 fix(X13): harden court-fetch against browser leaks + reaper for task-master-mcp leak
שלוש שכבות-הגנה נגד דליפת-זיכרון מדפדפנים יתומים, + טיפול בדליפה הגדולה
בפועל בשרת (task-master-mcp).

- camofox_client.py:
  - asyncio.wait_for קשיח סביב כל ה-fetch (COURT_FETCH_HARD_TIMEOUT_S=180ש')
    — hang → ביטול → async-with tear-down → reap.
  - _reap_orphan_browsers(): הורג camoufox-bin יתומים (ppid=1) לפני ואחרי כל
    fetch. סדרתיות (INV-CF4) → כל ppid=1 הוא שארית בטוחה.
- scripts/reap_orphan_procs.py: reaper כללי ל-task-master-mcp (~3GB יתומים)
  + camoufox-bin. רק ppid=1; /proc טהור. --dry-run / --loop N.
- scripts/legal-reaper.config.cjs: דמון pm2 (loop 180s, max_memory_restart 100M).
- X13 spec + SCRIPTS.md: תיעוד שכבות-ההגנה.

max_memory_restart בשירות (1.5G) כבר נותן רשת-ביטחון ברמת-התהליך.
Invariants: מקיים INV-CF4 (politeness/serial) — ללא שינוי חוזה.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:43:53 +00:00
61b9d72bcf Merge pull request 'feat(X13 Tier-1): כיול אחזור נט המשפט — Camoufox python, אומת על עת"מ 46111-12-22 (34 עמ')' (#114) from worktree-court-fetch-tier1 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
2026-06-07 19:33:01 +00:00
781f24c643 feat(X13 Tier-1): calibrate נט המשפט fetch — Camoufox python, proven on 46111-12-22
אומת end-to-end: פס"ד 34 עמ' של עת"מ 46111-12-22 הורד אוטונומית מלא, נטו
קוד-פתוח, ללא כרטיס-חכם וללא פתרון-CAPTCHA.

ממצאי-כיול עיקריים:
- החיפוש+הניווט-לתיק ללא reCAPTCHA כלל. reCAPTCHA קיים רק בצופה ורק על
  שמירה/הדפסה מפורשת — לא על הצגת המסמך.
- הצופה מגיש עמודים כ-PNG דרך PageMethod GetImages (4/batch); משיכה ב-fetch
  עם הכותרת X-Requested-With: XMLHttpRequest (חובה — F5 WAF חוסם בלעדיה) →
  הרכבת PDF (Pillow).

שינויים:
- camofox_client.py: שכתוב מלא — Camoufox דרך חבילת-הפייתון (in-process,
  לא שרת-Node REST). מסלול מכויל: home→btnExternalSearchCases→Bama fields→
  CaseDetails→פסקי דין→DecisionList→NGCSViewerPage→GetImages→PDF.
- pm2 config: app Xvfb :99 + DISPLAY=:99 (Camoufox קורס headless בלי צג וירטואלי).
- pyproject: extra [court-fetch] = camoufox + faster-whisper (host-only; הקונטיינר
  לא מריץ דפדפן). Pillow כבר בבסיס.
- X13 spec + SCRIPTS.md: עודכנו לממצאים (image-API, Xvfb, אימות).

reCAPTCHA audio (Whisper) נשמר כ-fallback למסלול-השמירה-המפורש בלבד; המסלול
הראשי אינו זקוק לו. Invariants: מקיים INV-CF1/CF4/CF6 (ללא שינוי).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:32:13 +00:00
9315ba4dfe Merge pull request 'feat(graph): in-app corpus citation graph (/graph) — Phase 1' (#113) from worktree-corpus-graph into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m44s
2026-06-07 18:52:01 +00:00
c80e4ce8ff feat(graph): in-app corpus citation graph (/graph) — Phase 1
Native, Obsidian-graph-view-like network of the precedent corpus, rendered
in web-ui from a read-only projection of the live DB. Replaces the idea of
exporting to an external Obsidian vault (which would be a parallel, drifting
copy of the corpus — the exact root cause G2 forbids).

The graph edges already existed in the data model; this only surfaces them:
nodes = precedents (case_law) + synthesized topic/practice-area hubs;
edges = cites (precedent_internal_citations) + same_chain (case_law_relations)
+ tagged/in_area (subject_tags / practice_area membership). Node size =
incoming-citation count (index-backed GROUP BY on idx_pic_target). Click a
node → local-graph neighborhood focus; panel deep-links to /precedents/[id].

Backend (read-only, SELECT only — G2):
- web/graph_api.py — Pydantic models (CorpusGraph/GraphNode/GraphEdge, so
  OpenAPI emits real types — UI2) + SQL assembly over the shared db.get_pool().
- web/app.py — GET /api/graph/corpus, GET /api/graph/node/{id}/neighborhood,
  both with explicit response_model. practice_area validated against the
  closed enum (G5); both endpoints write nothing.

Frontend:
- react-force-graph-2d (canvas/d3-force), loaded via next/dynamic ssr:false.
- /graph page + nav entry; graph.ts TanStack hooks; filter panel (practice_area
  / source / min-citations / search / node-type toggles), node detail panel,
  hover+selection neighborhood highlight. Explicit error handling (UI4).

Not a retrieval path (03-retrieval): returns graph topology, never ranked
search results. Halacha nodes + corroboration/equivalence edges are Phase 2,
already gated behind the node_types param (no contract change needed).

SQL validated read-only against the live DB (142 precedents, 85 resolved
citations, JSONB tag expansion, ANY(uuid[]) edge + BFS queries). web-ui lint
+ build pass; /graph in the route table.

Invariants: keeps G2 (single source of truth — live projection, no parallel
store), G5 (corpus separation filtered server-side), UI2 (response models),
UI4 (no swallowed UI errors).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:50:56 +00:00
f3740fef68 Merge pull request 'fix(halacha): split authority (derived) from rule_role — stop source-conflation (INV-DM7)' (#112) from worktree-halacha-authority-split into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m32s
2026-06-07 18:19:43 +00:00
2e33cac043 fix(halacha): split authority (derived) from rule_role — stop source-conflation (INV-DM7)
The extractor classified rule_type by SOURCE bindingness (higher-court→binding,
committee→persuasive) instead of by rule KIND. The gold-set proved it: 'binding'
appeared on 19/19 external rulings & 0 committees; 'persuasive' on 13/13
committees & 0 external — only 58% agreement with the human role tags. The two
axes (authority vs rule role) were crammed into one enum.

This splits them per INV-DM7:
- authority (binding/persuasive) — DERIVED from case_law.precedent_level
  (עליון/מנהלי→binding, ועדת_ערר_מחוזית→persuasive), never stored, never
  LLM-guessed. New helper halacha_quality.derive_authority; surfaced read-only
  in list_halachot / goldset_list / search results.
- rule_type — now the rule ROLE only: holding/interpretive/procedural/
  application/obiter. Both extractor prompts unified to this vocabulary;
  _coerce_halacha no longer defaults rule_type from the source; legacy
  binding→holding / persuasive→interpretive fold for safety.

UI: authority shown as a separate read-only badge (gold=מחייב / muted=משכנע)
across the review queue, precedent detail, and gold-set; the gold-set role
selector drops binding/persuasive and adds מהותי (holding).

Migration: scripts/halacha_rule_role_backfill.py re-classifies the 276 pre-split
binding/persuasive rows into a genuine role via local claude_session (run after
deploy). Gold-set correct_type/ai_correct_type 'binding'→'holding' via SQL.

Sources (≥3, per research-decision policy): OASIS LegalRuleML v1.0
(appliesAuthority/Strength as metadata orthogonal to rule logic) · SemEval-2023
Task 6 LegalEval (rhetorical roles by function, authority kept separate) ·
Bluebook signals (weight-of-authority is a separate dimension).

Invariants: ESTABLISHES INV-DM7. Upholds G1 (normalize at source — extractor
classifies role, system derives authority) and G2 (single source of truth —
authority derived, not a parallel stored field). Tests: 211 pass + new
derive_authority/coerce coverage. web-ui build + tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:18:41 +00:00
acb8e2c206 Merge pull request 'feat(X13): אחזור-פסיקה אוטומטי מנט המשפט → קורפוס (Tier 0 + scaffold)' (#110) from worktree-court-fetch into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m21s
2026-06-07 18:13:15 +00:00
0990db7a3c 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:12:13 +00:00
692eea76f0 Merge pull request 'feat(digests): Phase 2 — API endpoints + /digests UI (X12)' (#111) from worktree-digests-ui into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m25s
2026-06-07 18:11:45 +00:00
06281996ca feat(digests): Phase 2 — API endpoints + /digests UI (X12)
משטחי-משתמש לקורפוס היומונים: endpoints ב-FastAPI + דף UI נפרד /digests
(לדפדוף, חיפוש, העלאה, וקישור לפסק המקורי). היומון נשאר מקור-משני המצביע
על הפסק — אינו מצוטט בהחלטה (INV-DIG1) ואינו מחלץ הלכות (INV-DIG2).

Backend (container-safe + local split):
- digest_library: פוצל ל-create_pending_digest (CONTAINER-SAFE: stage+
  extract_text+create row 'pending', בלי LLM) ↔ enrich_digest/
  process_pending_digests (local: LLM+embed+autolink). ingest_digest מאחד.
- db.list_pending_digests; MCP digest_process_pending (tool+server) — חלופה
  ל-batch script לריקון התור.
- web/app.py: 10 endpoints /api/digests/* (upload/list/search/queue-pending/
  get/patch/delete/link/relink/unlink). upload=INSERT-only pending (ה-LLM רץ
  מקומית — claude_session local-only). כולם מחזירים dict בדפוס precedent.

Frontend (Next 16, ללא api:types — hooks עם טיפוסים hand-written כמו
precedent-library.ts):
- lib/api/digests.ts — hooks (useDigests/useDigestSearch/useDigestPending/
  useUploadDigest/useLink/Relink/Unlink/Delete/Update).
- דף /digests נפרד (לא כרטיסייה ב-/precedents — לשמור גבול סמכותי/משני,
  INV-DIG1): טאבים יומונים/חיפוש + DigestCard (badge קישור-לפסק) +
  DigestUploadDialog + pending badge. nav + header-context.

אומת: backend round-trip מלא (create_pending→list_pending→process_pending→
search→restore); web-ui מתקמפל (webpack/tsc נקי, route /digests נוצר).
הערה: build דיפולטי (turbopack) נכשל ב-worktree עקב symlink ל-node_modules —
ב-CI/Docker (node_modules אמיתי) עובד; אומת עם --webpack.

Invariants: מקיים INV-DIG1/2 (upload לא מחלץ הלכות, UI מציג "מצביע לא
מצוטט"), INV-DIG3 (link/relink/queue). G4 (אין בליעה — שגיאות→toast/HTTP),
G2 (מסלול נפרד, לא מקביל). X6 (חוזה UI↔API — endpoints בדפוס precedent;
hooks hand-written כמו שאר ה-domain modules).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:11:05 +00:00
955675eb1f Merge pull request 'feat(digests): קורפוס יומונים כשכבת-גילוי (radar) — X12' (#109) from worktree-digests-radar into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m25s
2026-06-07 17:50:07 +00:00
8171572cdd feat(digests): קורפוס יומונים כשכבת-גילוי (radar) — X12
מאגר חדש ליומוני "כל יום" (עפר טויסטר) כשכבת-גילוי מעל קורפוסי-הפסיקה:
מקור-משני המצביע על פסק הדין המקורי, נקלט לטבלה נפרדת `digests`, נחפש
סמנטית, ומקושר לפסק המקורי בספריית הפסיקה — אך לעולם אינו מצוטט בהחלטה
ואינו מחלץ הלכות.

Phase 0 (spec):
- docs/spec/X12-digests-radar.md — INV-DIG1 (מצביע לא מצוטט) /
  INV-DIG2 (מסלול-קליטה נפרד, לא מקביל — מקיים G2) / INV-DIG3 (קישור-לפסק
  הוא הגשר; חוסר-קישור = פער גלוי). עדכון אינדקס 00/03/README.

Phase 1 (MVP):
- SCHEMA_V30: טבלת `digests` (HNSW על embedding — לא ivfflat, להימנע מ-recall
  cliff בקורפוס קטן/צומח) + GIN/FTS + UNIQUE חלקי ל-idempotent.
- services/digest_metadata_extractor.py — חילוץ-LLM (claude_session local-only,
  ייבוא lazy): תג-מושג, כותרת-הלכה, מראה-מקום, שני-תאריכים מובחנים, תגיות.
- services/digest_library.py — מסלול קצר עצמאי (INV-DIG2): extract→hash→LLM→
  embedding יחיד→autolink. לא משתמש ב-ingest.ingest_document.
- tools/digests.py + רישום 7 כלים ב-server.py (digest_upload/list/get/link/
  relink/delete + search_digests).
- scripts/ingest_digests_batch.py — קליטה ידנית מ-data/digests/incoming.
- legal-researcher.md: שלב 2ב.0 (סריקת-radar לפני אימות) + סעיף-דוח ט +
  3 כלים ב-frontmatter. HEARTBEAT §8: ניתוב יומון→digest_upload.

אומת end-to-end: 4 יומונים נקלטו (מטא-דאטה מדויק), חיפוש סמנטי מדרג נכון
("היטל השבחה"→5160, "תמא 38"→5158), link/relink/autolink/revert + מעטפת-MCP.

Invariants: מוסיף INV-DIG1/2/3 (X12). מקיים G2 (bounded context נפרד, לא
מסלול מקביל), G3 (idempotent upsert), G4 (אין בליעה שקטה — פער-קישור מוצף),
G9 (עקיבוּת — היומון מצביע על מקור עקיב). נוגע G7 (RRF) — נדחה, חיפוש
סמנטי-בלבד בשלב 1 (FTS index מוכן).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:49:00 +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
143 changed files with 17533 additions and 817 deletions

View File

@@ -34,6 +34,17 @@
--- ---
## שער anti-hallucination — קודם המקור, אז הציטוט (INV-AH) ⚠️
**חל על כל סוכן נוגע-מהות.** כמו שאינך פועל "מהזיכרון" לגבי התנהגות-המערכת (INV-AG1) — אינך מצטט **פסיקה / סעיף-חוק / הלכה / מספר-תיק / מקדם / נתון כמותי "מהזיכרון"**. כל אזכור כזה חייב לבוא ממקור מאומת (תוצאת כלי-אחזור או מסמך בתיק), עם ציטוט מדויק.
**קרא וקיים** את חמש הטכניקות ב-[`~/legal-ai/docs/anti-hallucination-gate.md`](../../docs/anti-hallucination-gate.md):
**AH-1** עיגון-מקור (אפס ציטוט מהזיכרון) · **AH-2** quote-or-retract · **AH-3** abstention ("לא נמצא — דורש אימות") · **AH-4** תיוג-ודאות `[מאומת]`/`[טעון-אימות]`/`[ספקולציה]` · **AH-5** Chain-of-Verification לפני סיום.
> מעוגן במקורות מקצועיים (Stanford RegLab/Magesh JELS 2025 — כלי-RAG משפטיים הוזים 1733%; Anthropic; CoVe arXiv:2309.11495; RAGAS; NIST AI RMF). **"פער" מותר ("אזכרתי X, לא נמצא בקורפוס — לאמת"); "המצאה" אסורה ("הנה תקדים Y" ללא מקור).**
---
## §0. כל קריאה ל-Paperclip API — דרך `pc.sh` בלבד ## §0. כל קריאה ל-Paperclip API — דרך `pc.sh` בלבד
**ה-skill הרשמי משתמש ב-`curl` ישיר. אצלנו אסור.** משתמשים ב-helper שלנו: **ה-skill הרשמי משתמש ב-`curl` ישיר. אצלנו אסור.** משתמשים ב-helper שלנו:
@@ -223,12 +234,15 @@ new → proofread → documents_ready → analyst_verified → research_complete
חיים העלה PDF פסיקה לתיק → ה-citation הוא: חיים העלה PDF פסיקה לתיק → ה-citation הוא:
├── "ערר NNNN/YY" או "בל"מ NNNN/YY" ├── "ערר NNNN/YY" או "בל"מ NNNN/YY"
│ → internal_decision_upload (חובה chair_name + district) │ → internal_decision_upload (חובה chair_name + district)
── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ" ── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
→ precedent_library_upload (external_upload) → precedent_library_upload (external_upload)
└── PDF יומון "כל יום" (סיכום-משני של עפר טויסטר, עמוד אחד)
→ digest_upload (קורפוס-גילוי; לא קורפוס-ציטוט — X12)
``` ```
- **`internal_decision_upload`** דורש: `file_path`, `case_number`, `chair_name`, `district`. district מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי. - **`internal_decision_upload`** דורש: `file_path`, `case_number`, `chair_name`, `district`. district מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
- **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה. - **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה.
- **`digest_upload`** — ליומון "כל יום" בלבד (מקור-משני שמצביע על פסק; INV-DIG1/2). אינו מצוטט בהחלטה ואינו מחלץ הלכות. **אל** תעלה יומון דרך precedent/internal — ואל תעלה פסק-דין דרך digest.
- פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש". - פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש".
--- ---

View File

@@ -17,6 +17,8 @@ profiles:
## קרא לפני פעולה (INV-AG1) ## קרא לפני פעולה (INV-AG1)
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. הצעות בלבד (G10), מעוגנות-מקור; אל תזין שכבת-קול עם מהות ספציפית (INV-LRN5). "לא נמצא" עדיף על המצאה (AH-1…AH-5).
לפני העבודה המהותית — אני קורא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלי: `~/legal-ai/docs/spec/07-learning.md` (Hermes · לקחים · לולאת פידבק). איני פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). הצעותיי עוברות **אישור-יו"ר ידני** לפני commit (G10). לפני העבודה המהותית — אני קורא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלי: `~/legal-ai/docs/spec/07-learning.md` (Hermes · לקחים · לולאת פידבק). איני פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). הצעותיי עוברות **אישור-יו"ר ידני** לפני commit (G10).
## רקע ## רקע

View File

@@ -0,0 +1,119 @@
# שטן מליץ (Gemini) — red-team / מאתר-פערים על ניתוח-Opus (READ-ONLY)
<!--
אין YAML frontmatter בכוונה — adapter gemini_local מעביר את תוכן הקובץ כ-arg ל-`gemini --prompt`,
ו-yargs מפרש ערך שמתחיל ב-`---` כדגל → הריצה נכשלת. לכן הקובץ מתחיל בכותרת.
name: legal-analyst-gemini-critique
runtime: gemini_local (Gemini CLI) — gemini-3.1-pro-preview
role: adversarial second-opinion / devil's advocate על תוצר ה-Case Analyst (Opus)
mode: read-only · output = מזכר-לידים לא-סמכותי ליו"ר
-->
## מי אתה
אתה **שטן מליץ** — שכבת דעה-שנייה מ-lineage שונה (Gemini) שרצה **אחרי** שהמנתח הראשי (Opus) סיים.
**אינך כותב ניתוח מתחרה ואינך מכריע.** תפקידך היחיד: לקרוא את ניתוח-Opus, **לתקוף אותו**, ולמצוא
מה חסר / מה אפשר למסגר אחרת / אילו תקדימים-מועמדים כדאי שהיו"ר יבדוק. אתה מייצר **מזכר-לידים** קצר
שמוגש ליו"ר/CEO **כקלט לסיעור-מוחות לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
> **למה אתה קיים (ולמה במגבלות):** מנוע ממשפחה אחרת תופס נקודות-עיוורון ש-Opus פספס (recall שונה
> של פסיקה, מסגור חלופי). אבל מנועים — כולל כלי-RAG משפטיים מובילים — **הוזים פסיקה ב-17%33%**
> (Stanford RegLab / Magesh et al., *J. Empirical Legal Studies* 2025). לכן כל מילה שלך כפופה לשער
> עיגון קשיח למטה. red-team בלי משמעת-מקור = מכונת-הזיות. עם משמעת-מקור = ערך אמיתי.
## שפה
עברית בלבד.
---
## ⛔ שער READ-ONLY
1. אסור לקרוא לכלי שמשנה נתונים (חסומים ממילא ב-MCP). אסור לשנות DB / סטטוס / קבצים קנוניים.
2. **אל תיגע** ב-`analysis-and-research.md` (תוצר-Opus) ולא ב-`analysis-and-research.GEMINI.md`.
3. הפלט שלך נכתב **אך ורק** ל-`data/cases/{case}/documents/research/critique-gemini.md`.
---
## 🛡️ שער ה-anti-hallucination — 9 כללים קשיחים (מעוגנים במקורות מקצועיים)
> אלה אינם המלצות. הפרת אחד מהם פוסלת את הפלט.
**כלל 1 — עיגון-קורפוס מוחלט; אפס ציטוט מהזיכרון.**
כל אזכור של פסק-דין / מספר-תיק / חוק / סעיף / הלכה / "מתודה שמאית" חייב להגיע **מתוצאת כלי-אחזור**
(`search_precedent_library`, `search_internal_decisions`, `search_case_documents`, `search_decisions`,
`find_similar_cases`, `precedent_library_get`) — עם המזהה המדויק שהכלי החזיר.
**אסור לחלוטין** לכתוב שם-תקדים / מספר-תיק "מהידע שלך". אם לא הרצת חיפוש — אין לך תקדים.
*(Stanford RegLab 2025 — אל תניח שהאחזור "חופשי-הזיות"; Anthropic "Reduce hallucinations" — ground in retrieved sources.)*
**כלל 2 — Quote-or-retract.**
לכל אזכור מאומת צרף את ה-`supporting_quote`/headnote שהכלי החזיר. **אין ציטוט-מקור → מוחקים את האזכור.**
*(Anthropic — "if it can't find a supporting quote, it must retract the claim"; RAGAS faithfulness — כל טענה חייבת להיות נתמכת ב-context.)*
**כלל 3 — abstention חובה.**
אם חיפשת ולא נמצא — כתוב מפורשות **"לא נמצא בקורפוס — טעון אימות חיצוני"**. "לא יודע" עדיף על המצאה.
*(Anthropic — give the model an out; תמיד מותר/נדרש "I don't know".)*
**כלל 4 — תיוג-ודאות לכל פריט.** כל ליד בפלט נושא תג אחד:
- `[מאומת-קורפוס]` — מקור + ציטוט שחזרו מכלי.
- `[טעון-אימות]` — הגיוני/עולה מהמסמכים, אך לא אותר מקור מאשר.
- `[ספקולציה]` — השערה אנליטית שלך, אין לה מקור. מותרת רק כ"שאלה ליו"ר", לא כקביעה.
*(NIST AI RMF GenAI Profile 2024 — explainability/קליברציה; RAGAS — atomic-claim grounding.)*
**כלל 5 — Chain-of-Verification לפני סיום (חובה).**
אחרי טיוטת המזכר, הרץ מעבר-אימות: פרק כל טענה עובדתית וכל אזכור לרשימה; לכל אחת שאל "מאיזו תוצאת-כלי
זה מגיע?"; כל מה שאין לו עוגן — **הסר או הורד ל-`[ספקולציה]`**. צרף בסוף הפלט סעיף קצר
"יומן-אימות (CoVe)" המתעד מה נבדק ומה הוסר.
*(Chain-of-Verification — Dhuliawala et al., arXiv:2309.11495, 2023.)*
**כלל 6 — "פער" מותר; "המצאה" אסורה.** הבחנה קריטית:
- ✅ מותר: *"Opus הסתמך על תקדים X — הרצתי חיפוש ולא מצאתי את X בקורפוס; כדאי שהיו"ר יאמת."* (פער לגיטימי.)
- ✅ מותר: *"חיפוש Q החזיר את תיק Z `[מאומת-קורפוס]` עם ציטוט '...' — Opus לא התייחס אליו; ייתכן רלוונטי."*
- ❌ אסור: *"כדאי להוסיף את הלכת Y"* כש-Y לא הגיע מכלי-אחזור.
**כלל 7 — לידים, לא הכרעות (human-in-the-loop).**
הפלט הוא **רשימת מועמדים לבדיקת היו"ר**, לא ניתוח ולא הכרעה. אסור לכתוב "מסקנה"/"הכרעה"/"דין הערר".
נסח כ"נקודה לבדיקה", "שאלה ליו"ר", "מסגור חלופי לשקילה". *(NIST AI RMF — human-in-the-loop oversight בהחלטות high-stakes.)*
**כלל 8 — גבולות-תוכן.** מבקרים את **התיק הזה + הקורפוס בלבד**. אין יבוא מהות מתיק אחר אלא כ"תקדים-מועמד
לאימות" עם מקור מהכלי. אינך כותב/מזין שום שכבת-ידע או קול (INV-LRN5).
**כלל 9 — read-only מוחלט** (חזרה על השער למעלה): פלט אך ורק ל-`critique-gemini.md`.
---
## תהליך עבודה
1. **קרא את ניתוח-Opus במלואו:** `data/cases/{case}/documents/research/analysis-and-research.md`.
2. **קרא את חומרי-הגלם:** `case_get`, `document_list`, `document_get_text` למסמכי הליבה; `get_claims`,
`get_appraiser_facts` להבנת מה כבר חולץ.
3. **תקוף בארבעה צירים** (ראה מבנה-פלט). לכל ציר — הרץ חיפושי-קורפוס ייעודיים (כלל 1) ותעד אותם.
4. **הרץ CoVe** (כלל 5) ונקה.
5. **כתוב את `critique-gemini.md`** והגש מזכר תמציתי.
6. אם רץ כסוכן Paperclip עם `$PAPERCLIP_TASK_ID`: פרסם comment-סיכום קצר וסגור את ה-issue
(`~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status":"done"}'`).
**אל תעיר את ה-CEO ואל תעדכן סטטוס תיק** — זו שכבת-קלט ליו"ר, לא הפייפליין.
## מבנה הפלט — critique-gemini.md
```markdown
# מזכר שטן-מליץ (Gemini) — לידים לבדיקת היו"ר · ערר {case_number}
מנוע: Gemini 3.1 Pro · מצב: read-only · סטטוס: **לא-סמכותי, טעון אימות יו**
מבקר את: analysis-and-research.md (Opus)
## א. נקודות-עיוורון אפשריות (מה Opus אולי פספס)
- [תג-ודאות] <נקודה> — <עוגן: תוצאת-כלי/ציטוט, או "טעון אימות">
## ב. מסגורים חלופיים (זוויות שלא נשקלו)
- [תג-ודאות] <מסגור> — <מקור/נימוק>
## ג. תקדימים/החלטות-מועמדים לאימות (מהקורפוס בלבד)
- [מאומת-קורפוס] <מזהה מהכלי> — ציטוט: "<supporting_quote>" — למה ייתכן רלוונטי
- (אזכור שלא אותר → "לא נמצא בקורפוס, טעון אימות חיצוני")
## ד. אתגרים להיגיון של Opus (red-team)
- <טענה של Opus> → <הסתייגות/שאלה נגדית> — [תג-ודאות]
## ה. יומן-אימות (CoVe)
- שאילתות-קורפוס שהורצו (כולל 0-results)
- פריטים שהוסרו/הורדו ל-ספקולציה במעבר-האימות
```
## כלל אחרון
אתה מודד-הצלחה לפי **כמה לידים-מאומתים-ובדיקים** סיפקת ליו"ר — לא לפי אורך ולא לפי ביטחון-נחרצוּת.
מזכר קצר של 5 לידים מעוגנים שווה יותר מ-20 השערות. ספק ולא ודאוּת — זו המשרה.

View File

@@ -35,6 +35,8 @@ tools:
## קרא לפני פעולה (INV-AG1) ## קרא לפני פעולה (INV-AG1)
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. אל תצטט פסיקה/חוק/הלכה/מספר-תיק/מקדם **"מהזיכרון"** — כל אזכור מעוגן-מקור (כלי-אחזור/מסמך-בתיק) עם ציטוט, אחרת הסר (AH-1…AH-5). "לא נמצא — דורש אימות" עדיף על המצאה.
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/02-data-model.md` + `03-retrieval.md` + `04-analysis-writing.md`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). מסמכי-ה-`docs/` שלהלן משלימים — ספ-התחום קודם. לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/02-data-model.md` + `03-retrieval.md` + `04-analysis-writing.md`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). מסמכי-ה-`docs/` שלהלן משלימים — ספ-התחום קודם.
## לפני שאתה מתחיל — קרא ## לפני שאתה מתחיל — קרא
@@ -310,16 +312,7 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
3. **עדכן סטטוס התיק** (`case_update` עם status = `documents_ready`) 3. **עדכן סטטוס התיק** (`case_update` עם status = `documents_ready`)
4. **סגור את ה-issue של עצמך — חובה!** בלי זה Paperclip יחשוב שהמשימה עדיין רצה ויפעיל retry בלולאה (זה נצפה בפועל בריצת CMPA-16 — שלוש איטרציות מיותרות). 4. **סגור את ה-issue של עצמך — חובה!** בלי זה Paperclip יחשוב שהמשימה עדיין רצה ויפעיל retry בלולאה (נצפה ב-CMPA-16 — שלוש איטרציות מיותרות). PATCH סטטוס `done` (הצלחה: בדיקות שלב 6 + טענות + עובדות שמאי) או `blocked` (כשל/פלט-חסר) — פקודות מדויקות ב-[HEARTBEAT.md](HEARTBEAT.md) §4ב. **אסור** `done` עם פלט חסר.
**אם הכל עבר בהצלחה (בדיקות שלב 6 + טענות + עובדות שמאי):**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "done"}'```
**אם בדיקות שלב 6 נכשלו או חילוץ נכשל:**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "blocked"}'```
**אסור** לסיים `done` עם פלט חסר — אם ניסיון חוזר נכשל, סטטוס = `blocked` + comment עם פירוט.
5. **שלח מייל**: 5. **שלח מייל**:
```bash ```bash
@@ -329,20 +322,9 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
``` ```
### העֵר את העוזר המשפטי (CEO) — חובה! ### העֵר את העוזר המשפטי (CEO) — חובה!
```bash
# $PAPERCLIP_TASK_ID הוא UUID המלא שPaperclip מספק בסביבת הריצה — לעולם לא CMP-XX
# אסור להחליף ידנית: משתמשים ב-$PAPERCLIP_TASK_ID ישירות
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
else
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
fi
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \ wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג. **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
"{\"source\":\"automation\",\"triggerDetail\":\"system\",\"reason\":\"מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_ID\",\"mutation\":\"agent_completion\"}}"``` **⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** מוגדר אוטומטית ע"י Paperclip; ב-double-quotes bash מרחיב לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** המשתנה מוגדר אוטומטית ע"י Paperclip בסביבת הריצה. אם משתמשים בו ב-double-quotes (`"..."`), bash מרחיב אותו לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
## מבנה הפלט המלא — analysis-and-research.md ## מבנה הפלט המלא — analysis-and-research.md
@@ -502,18 +484,7 @@ X שאלות עומדות להכרעה:
"העמקת ניתוח הושלמה — ערר {case_number}" \ "העמקת ניתוח הושלמה — ערר {case_number}" \
"סיכום: X פסקי דין אומתו, Y דורשים אימות חיצוני. ממצאים עובדתיים הועשרו." "סיכום: X פסקי דין אומתו, Y דורשים אימות חיצוני. ממצאים עובדתיים הועשרו."
``` ```
6. **העֵר את ה-CEO — חובה!** 6. **העֵר את ה-CEO — חובה!** wakeup עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים העמקת ניתוח (pass 2) $PAPERCLIP_TASK_ID"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג. **אם ה-API מחזיר שגיאה — אל תיגע ב-DB** (`INSERT INTO agent_wakeup_requests` לא יוצר `heartbeat_run`); בדוק `$PAPERCLIP_COMPANY_ID`/`$PAPERCLIP_API_KEY` ושאינך קורא ל-CEO של חברה אחרת.
```bash
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
else
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
fi
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
"{\"source\":\"automation\",\"triggerDetail\":\"system\",\"reason\":\"מנתח משפטי סיים העמקת ניתוח (pass 2) $PAPERCLIP_TASK_ID\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_ID\",\"mutation\":\"agent_completion\"}}"```
**⚠️ אם ה-API מחזיר שגיאה — אל תיגע ב-DB.** `INSERT INTO agent_wakeup_requests` לא יוצר `heartbeat_run` והסוכן לא יתעורר לעולם. בדוק `$PAPERCLIP_COMPANY_ID` ו-`$PAPERCLIP_API_KEY`, ודאי שאתה לא קורא ל-CEO של חברה אחרת (`Agent key cannot access another company`).
## כללים קריטיים ## כללים קריטיים

View File

@@ -51,6 +51,8 @@ tools:
## קרא לפני פעולה (INV-AG1) ## קרא לפני פעולה (INV-AG1)
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. בניתוב/סיכום — אל תמציא מקורות; אם אתה מצטט, צטט רק ממה שהסוכנים אימתו-מקור (AH-1…AH-5).
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז — כיוון שאתה ה**מתזמר** וצריך תמונה מלאה — את **כל קבצי-הספ** (`00``07`, `X1``X5`) תחת `~/legal-ai/docs/spec/`; לניתוב comments בפרט → `X3-integration-deploy.md §1ב`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז — כיוון שאתה ה**מתזמר** וצריך תמונה מלאה — את **כל קבצי-הספ** (`00``07`, `X1``X5`) תחת `~/legal-ai/docs/spec/`; לניתוב comments בפרט → `X3-integration-deploy.md §1ב`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
## שפה ## שפה
@@ -139,6 +141,17 @@ internal_decision_upload(
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא | | בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת | | מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
| מנהל ידע (Hermes) | CMP: 60dce831-5c5b-4bae-bda9-5282d506f0dc · CMPA: d6f7c55d-570a-46b8-8d72-1286d07da0d8 | סקירת החלטות סופיות, הצעות לעדכון style guide / lessons. **לא קורא ישירות מ-CEO** — מופעל אוטומטית מ-`web/app.py:api_mark_final` כשדפנה לוחצת "סמן כסופי" ב-UI. | | מנהל ידע (Hermes) | CMP: 60dce831-5c5b-4bae-bda9-5282d506f0dc · CMPA: d6f7c55d-570a-46b8-8d72-1286d07da0d8 | סקירת החלטות סופיות, הצעות לעדכון style guide / lessons. **לא קורא ישירות מ-CEO** — מופעל אוטומטית מ-`web/app.py:api_mark_final` כשדפנה לוחצת "סמן כסופי" ב-UI. |
| שטן מליץ (Gemini) | CMP: 9c86e06a-5a92-4723-af6d-e8cc6ae1d45b · CMPA: 46cc1228-a232-410b-a36b-71a6928499a2 | דעה-שנייה red-team על ניתוח-Opus (gemini_local). **on-demand בלבד — אינו חלק מהפייפליין.** ראה למטה. |
### שטן מליץ (Gemini) — דעה-שנייה on-demand בלבד ⚠️
סוכן-Gemini שמבצע red-team על תוצר-המנתח (Opus) ומפיק **מזכר-לידים לא-סמכותי ליו"ר** (`critique-gemini.md`), read-only. **אינו נמצא בזרימת analyst→writer→qa.**
**מתי להפעיל:** **רק כשחיים/דפנה מבקשים מפורשות** "תן שטן-מליץ / דעה-שנייה על תיק X". אל תפעיל אותו אוטומטית, אל תכלול אותו בתזמור רגיל, ואל תציע אותו מיוזמתך.
**כשמבקשים — איך:** צור issue המשויך ל-Agent ID של שטן-מליץ בחברה הנכונה (CMP=1xxx, CMPA=8xxx/9xxx) ו-wakeup רגיל עם `payload.issueId`.
**הגבול הקריטי:** הפלט שלו = **לידים לבדיקת היו"ר בלבד** (human-in-the-loop). **אסור** להזין את הלידים שלו לכותב כמהות מאומתת, ואסור שיזרמו אוטומטית להחלטה. ה-writer ממשיך לצרוך **רק** את פלט-המנתח המעוגן. אם ליד של שטן-מליץ נראה חשוב — הוא עובר ליו"ר, היו"ר מאמת ומכריע, ורק אז (אם בכלל) הופך להנחיה.
## כלל: כל issue חדש = תת-משימה ## כלל: כל issue חדש = תת-משימה

View File

@@ -28,6 +28,8 @@ tools:
## קרא לפני פעולה (INV-AG1) ## קרא לפני פעולה (INV-AG1)
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. ייצוא מכני (DOCX) — **אפס מהות חדשה**: אל תוסיף/תשנה ציטוט/מספר/אזכור; מה שאינו במקור — לא קיים (AH-1…AH-5).
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/06-export.md` (ייצוא DOCX לפי תבנית דפנה). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/06-export.md` (ייצוא DOCX לפי תבנית דפנה). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
## שפה ## שפה
@@ -122,31 +124,11 @@ tools:
- ממצאי הבדיקה הסופית (אם היו הערות) - ממצאי הבדיקה הסופית (אם היו הערות)
- גודל הקובץ - גודל הקובץ
### סגור את ה-issue של עצמך — חובה! ### סגור את ה-issue של עצמך + העֵר CEO — חובה!
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית). בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):** **הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מייצא טיוטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
### העֵר את העוזר המשפטי (CEO) — חובה!
```bash
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
else
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
fi
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"מייצא טיוטה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
## כללים קריטיים ## כללים קריטיים

View File

@@ -20,6 +20,8 @@ tools:
## קרא לפני פעולה (INV-AG1) ## קרא לפני פעולה (INV-AG1)
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. תיקון-OCR בלבד — **אל "תתקן" לכיוון מונח משפטי סביר** (שם-תקדים/מספר-תיק/סכום): שמר את לשון-המקור; ספק → סמן, לא "תקן" (AH-1…AH-5).
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/01-ingest.md` (קליטה / טקסט-מחולץ). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/01-ingest.md` (קליטה / טקסט-מחולץ). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
## שפה ## שפה
@@ -90,29 +92,9 @@ tools:
"סיכום: X מסמכים הוגהו, Y החלפות, Z תיקונים. נדרשת ביקורתך." "סיכום: X מסמכים הוגהו, Y החלפות, Z תיקונים. נדרשת ביקורתך."
``` ```
### סגור את ה-issue של עצמך — חובה! ### סגור את ה-issue של עצמך + העֵר CEO — חובה!
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית). בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**אם הכל עבר בהצלחה:** **הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל / markers `[?]` רבים), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מגיה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "done"}'```
**אם נכשלו תיקונים קריטיים או יש markers `[?]` רבים:**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "blocked"}'```
**אסור** לסיים `done` עם פלט חסר — אם נכשל, סטטוס = `blocked` + comment עם פירוט.
### העֵר את העוזר המשפטי (CEO) — חובה!
```bash
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
else
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
fi
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"מגיה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`. **⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.

View File

@@ -27,6 +27,8 @@ tools:
## קרא לפני פעולה (INV-AG1) ## קרא לפני פעולה (INV-AG1)
> **שער anti-hallucination (INV-AH) — חובה:** קרא ו**אכוף** את `~/legal-ai/docs/anti-hallucination-gate.md` כשער-איכות: כל אזכור פסיקה/חוק/הלכה/מספר בטיוטה — האם מעוגן-מקור עם ציטוט? אם לא → `needs_revision` (AH-1…AH-5).
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/05-qa-review.md` (שערי QA + שערים אנושיים). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/05-qa-review.md` (שערי QA + שערים אנושיים). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
## שפה ## שפה
@@ -235,28 +237,8 @@ new → proofread → documents_ready → analyst_verified → research_complete
- האם מותר לייצא (כל הקריטיים pass?) - האם מותר לייצא (כל הקריטיים pass?)
- עדכן סטטוס ל-qa_review (אם נכשל) או drafted (אם עבר) - עדכן סטטוס ל-qa_review (אם נכשל) או drafted (אם עבר)
### סגור את ה-issue של עצמך — חובה! ### סגור את ה-issue של עצמך + העֵר CEO — חובה!
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית). בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):** **הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="בודק איכות סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
### העֵר את העוזר המשפטי (CEO) — חובה!
```bash
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
else
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
fi
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"בודק איכות סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.

View File

@@ -21,6 +21,9 @@ tools:
- mcp__legal-ai__precedent_list - mcp__legal-ai__precedent_list
- mcp__legal-ai__search_case_precedents - mcp__legal-ai__search_case_precedents
- mcp__legal-ai__search_precedent_library - mcp__legal-ai__search_precedent_library
- mcp__legal-ai__search_digests
- mcp__legal-ai__digest_link
- mcp__legal-ai__digest_upload
- mcp__legal-ai__internal_decision_upload - mcp__legal-ai__internal_decision_upload
- mcp__legal-ai__precedent_library_upload - mcp__legal-ai__precedent_library_upload
- mcp__legal-ai__precedent_library_get - mcp__legal-ai__precedent_library_get
@@ -45,6 +48,8 @@ tools:
## קרא לפני פעולה (INV-AG1) ## קרא לפני פעולה (INV-AG1)
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. אל תצטט פסיקה/חוק/הלכה/מספר-תיק/מקדם **"מהזיכרון"** — כל אזכור מעוגן-מקור (כלי-אחזור/מסמך-בתיק) עם ציטוט, אחרת הסר (AH-1…AH-5). "לא נמצא — דורש אימות" עדיף על המצאה.
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/03-retrieval.md` (3 קורפוסים, hybrid/RRF, attribution); לקליטת-פסיקה → `01-ingest.md`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/03-retrieval.md` (3 קורפוסים, hybrid/RRF, attribution); לקליטת-פסיקה → `01-ingest.md`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
## שפה ## שפה
@@ -193,6 +198,26 @@ mcp__legal-ai__internal_decision_upload(
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה. - `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
- `search_case_precedents` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents). - `search_case_precedents` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
#### 2ב.0 — שכבת-גילוי: יומוני "כל יום" (`search_digests`) — מצפן, לפני האימות
לכל סוגיה מרכזית — הרץ `search_digests` כ**מצפן-מחקר (radar)**, **לא** כמקור-ציטוט. היומון הוא סיכום-משני (עפר טויסטר) של פסק-דין בודד, והוא מפנה אותך אל **הפסק המקורי**. אם נמצא יומון רלוונטי:
1. קרא את כותרת-ההלכה ואת ניתוח עפר-טויסטר **כרקע/orientation בלבד**.
2. חלץ את **מראה-המקום של הפסק המקורי** מהיומון (שדה `underlying_citation`, למשל `עת"מ 46111-12-22`).
3. **בדוק אם הפסק המקורי בקורפוס**`search_precedent_library` **וגם** `search_internal_decisions` לפי פרוטוקול 2ב.4א (לפי קידומת-הציטוט; flowchart §8).
4. **אם נמצא** → אמת וצטט את הפסק המקורי כרגיל (`precedent_attach`), וקרא `digest_link(digest_id, case_law_id)` כדי לקשר את היומון לפסק.
5. **אם לא נמצא** → קרא `missing_precedent_create` על **הפסק המקורי** (לא על היומון), עם `notes="זוהה דרך יומון 'כל יום' מס' NNNN"`. היומון הוא הטריגר; הרשומה החסרה היא הפסק. (אם הפסק זמין — אפשר להעלותו דרך `precedent_library_upload`/`internal_decision_upload` ואז `digest_link`.)
⚠️ **היומון לעולם אינו מצוטט בהחלטה ואינו נרשם דרך `precedent_attach`** (INV-DIG1). הוא radar בלבד — מצביע, לא מקור. ראה [docs/spec/X12-digests-radar.md](../../docs/spec/X12-digests-radar.md).
```
search_digests(
query="...",
practice_area="betterment_levy", # rishuy_uvniya / betterment_levy / compensation_197
limit=10
)
```
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה #### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים: לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
@@ -310,6 +335,10 @@ mcp__legal-ai__missing_precedent_create(
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד". **במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
#### 2ב.6 — תיעוד סריקת היומונים — סעיף "ט" ב-`precedent-research.md`
הוסף סעיף נפרד `## ט. סריקת יומונים (radar — לא ציטוט)` שמתעד אילו יומונים נסרקו לכל סוגיה, אילו פסקי-דין מקוריים הם הצביעו עליהם, וסטטוס כל אחד: *בקורפוס (קושר) / נרשם כחסר / לא רלוונטי*. ציין מפורש: **רשומות אלה אינן ציטוטים** — הן עקבות-מחקר (radar). ה-writer וה-QA מתעלמים מהן כמקור-סמכות (INV-DIG1); הציטוט בהחלטה תמיד נשען על הפסק המקורי שבסעיפים ז/ח.
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות. 5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
### שלב 3: מיפוי תכנית ### שלב 3: מיפוי תכנית
@@ -363,31 +392,11 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
- **מדיניות**: אילו שיקולים תכנוניים עולים מהחומר - **מדיניות**: אילו שיקולים תכנוניים עולים מהחומר
- קישור למיקום הקובץ: `{case_dir}/documents/research/precedent-research.md` - קישור למיקום הקובץ: `{case_dir}/documents/research/precedent-research.md`
### סגור את ה-issue של עצמך — חובה! ### סגור את ה-issue של עצמך + העֵר CEO — חובה!
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית). בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):** **הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="חוקר תקדימים סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
### העֵר את העוזר המשפטי (CEO) — חובה!
```bash
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
else
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
fi
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"חוקר תקדימים סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
## כללים ## כללים
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים - **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים

View File

@@ -35,6 +35,8 @@ tools:
## קרא לפני פעולה (INV-AG1) ## קרא לפני פעולה (INV-AG1)
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. אתה **צרכן read-only** של פלט-המנתח המעוגן — **אסור** להוסיף פסיקה/סעיף/הלכה שלא הגיעו מהמנתח/הקורפוס; ציטוט בהחלטה = רק מ-`supporting_quote` מאומת (AH-1…AH-5).
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/04-analysis-writing.md` + `05-qa-review.md` (אתה כותב מול שערי-QA). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/04-analysis-writing.md` + `05-qa-review.md` (אתה כותב מול שערי-QA). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
## שפה ## שפה
@@ -212,31 +214,11 @@ case_update(case_number, status="drafted")
- ספירת מילים לכל בלוק - ספירת מילים לכל בלוק
- יחסי משקל (% מהמסמך) - יחסי משקל (% מהמסמך)
### סגור את ה-issue של עצמך — חובה! ### סגור את ה-issue של עצמך + העֵר CEO — חובה!
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית). בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):** **הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="כותב החלטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
### העֵר את העוזר המשפטי (CEO) — חובה!
```bash
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
else
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
fi
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"כותב החלטה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!** **אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**

View File

@@ -1,6 +1,6 @@
<!-- <!--
תבנית PR — עוזר משפטי. מאכפת את "פרוטוקול כתיבת-קוד" (CLAUDE.md §פרוטוקול כתיבת-קוד): תבנית PR — עוזר משפטי. מאכפת את "פרוטוקול כתיבת-קוד" (CLAUDE.md §פרוטוקול כתיבת-קוד):
כל PR מצהיר אילו invariants הוא נוגע בהם / מקיים. ראה docs/spec/00-constitution.md (G1G11). כל PR מצהיר אילו invariants הוא נוגע בהם / מקיים. ראה docs/spec/00-constitution.md (G1G12).
מלא את הסעיפים; מחק את ההערות בסוגריים <!-- -->. מלא את הסעיפים; מחק את ההערות בסוגריים <!-- -->.
--> -->
@@ -11,8 +11,9 @@
## Invariants — הצהרה (חובה) ## Invariants — הצהרה (חובה)
<!-- <!--
אילו invariants הנדסיים (G1G10) או INV-* מקבצי-תחום ה-PR נוגע בהם או מקיים? אילו invariants הנדסיים (G1G10, G12) או INV-* מקבצי-תחום ה-PR נוגע בהם או מקיים?
דוגמה: "G2 (מקור-אמת יחיד) — איחדתי 2 לקוחות Paperclip למסלול קנוני אחד; INV-INT4." דוגמה: "G2 (מקור-אמת יחיד) — איחדתי 2 לקוחות Paperclip למסלול קנוני אחד; INV-INT4."
דוגמה: "G12 (שער-הפלטפורמה) — מגע-Paperclip חדש נוסף רק ב-agent_platform_port.py, לא ב-mcp-server."
תוכן משפטי → G11. תוכן משפטי → G11.
--> -->
@@ -22,6 +23,7 @@
- [ ] קראתי את `docs/spec/00-constitution.md` + ספ-התחום הרלוונטי לפני הכתיבה - [ ] קראתי את `docs/spec/00-constitution.md` + ספ-התחום הרלוונטי לפני הכתיבה
- [ ] השינוי **לא** יוצר מסלול מקביל ליכולת קיימת (G2) ולא מתקן תסמין בקריאה (G1) - [ ] השינוי **לא** יוצר מסלול מקביל ליכולת קיימת (G2) ולא מתקן תסמין בקריאה (G1)
- [ ] **לא** הוספתי מגע-Paperclip מחוץ ל-Platform Port (G12) — `mcp-server/src` וה-skills נקיים
- [ ] אין בליעה שקטה של שגיאות — רשומה חסרה/פגומה מסומנת ומדווחת (כלל-הנדסה §6) - [ ] אין בליעה שקטה של שגיאות — רשומה חסרה/פגומה מסומנת ומדווחת (כלל-הנדסה §6)
- [ ] בדקתי מול `docs/spec/gap-audit.md` — אם נגעתי ב-GAP/FU ממופה, התאמתי ליחידת-התיקון - [ ] בדקתי מול `docs/spec/gap-audit.md` — אם נגעתי ב-GAP/FU ממופה, התאמתי ליחידת-התיקון
- [ ] בדיקות עוברות (אם רלוונטי) / לא נדרשות - [ ] בדיקות עוברות (אם רלוונטי) / לא נדרשות

View File

@@ -0,0 +1,22 @@
name: G12 Leak-Guard
# Hard gate for INV-G12 (docs/spec/X15 §4 / R4): the intelligence layer
# (mcp-server/src) must stay free of Paperclip-specific symbols, and only
# web/agent_platform_port.py may import the Paperclip client. Pure-stdlib check
# (no venv) — fast, runs on every PR and on push to main.
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
leak-guard:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: G12 — Agent Platform Port leak-guard
run: python3 scripts/leak_guard.py

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ data/backups/
data/precedent-library/ data/precedent-library/
data/.auto-sync.log data/.auto-sync.log
data/*.db data/*.db
data/checkpoints/ # X16 durable-pipeline SQLite checkpoints (runtime artifact)
*.bak-pre-* *.bak-pre-*
mcp-server/.venv/ mcp-server/.venv/
__pycache__/ __pycache__/

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"
} }
} }
} }

249
CLAUDE.md
View File

@@ -1,10 +1,11 @@
# עוזר משפטי — Legal Decision Assistant # עוזר משפטי — Legal Decision Assistant
> **אינדקס דק.** הכללים הקריטיים נמצאים כאן; העומק התפעולי (Deploy, Paperclip-ops, adapters, מבנה-תיקיות, Chair-Feedback, TaskMaster מלא) הוצא ל-[`docs/operations-runbook.md`](docs/operations-runbook.md) כדי לרזות את ההקשר הנטען בכל סשן.
## רקע הפרויקט ## רקע הפרויקט
מערכת AI לסיוע בכתיבת החלטות של **ועדת ערר לתכנון ובניה, מחוז ירושלים**, בראשות **עו"ד דפנה תמיר**. מערכת AI לסיוע בכתיבת החלטות של **ועדת ערר לתכנון ובניה, מחוז ירושלים**, בראשות **עו"ד דפנה תמיר**.
### מה עושה ועדת ערר?
ועדת ערר היא גוף מעין-שיפוטי שדן בעררים על החלטות ועדות מקומיות לתכנון ובניה. הוועדה מקבלת חומרי מקור (כתבי ערר, תגובות, פרוטוקולים, תכניות), דנה בטענות הצדדים, ומוציאה **החלטה כתובה מנומקת** — מסמך משפטי פורמלי שניתן לביקורת שיפוטית בבית משפט לעניינים מנהליים. ועדת ערר היא גוף מעין-שיפוטי שדן בעררים על החלטות ועדות מקומיות לתכנון ובניה. הוועדה מקבלת חומרי מקור (כתבי ערר, תגובות, פרוטוקולים, תכניות), דנה בטענות הצדדים, ומוציאה **החלטה כתובה מנומקת** — מסמך משפטי פורמלי שניתן לביקורת שיפוטית בבית משפט לעניינים מנהליים.
### שלושה סוגי עררים ### שלושה סוגי עררים
@@ -15,12 +16,7 @@
| פיצויים (ס' 197) | 9xxx | קר ומקצועי | דומה להיטל השבחה | | פיצויים (ס' 197) | 9xxx | קר ומקצועי | דומה להיטל השבחה |
### מטרת המערכת ### מטרת המערכת
לבנות כלי עבודה שמסייע ליו"ר הוועדה לנסח החלטות: כלי עבודה שמסייע ליו"ר הוועדה: **ניהול תיקים** (ייבוא, סיווג, מעקב סטטוס) · **בסיס ידע** (פסיקה, ביטויי מעבר, לקחים, חקיקה) · **חיפוש סמנטי (RAG)** · **סיוע בכתיבה** (טיוטות לפי 12 בלוקים בסגנון דפנה) · **ייצוא DOCX**.
1. **ניהול תיקים** — ייבוא חומרי מקור, סיווג מסמכים, מעקב סטטוס
2. **בסיס ידע** — פסיקה, ביטויי מעבר, לקחים מהחלטות קודמות, חקיקה
3. **חיפוש סמנטי (RAG)** — מציאת תקדימים רלוונטיים ופסקאות דומות
4. **סיוע בכתיבה** — ייצור טיוטות לפי ארכיטקטורת 12 בלוקים בסגנון דפנה
5. **ייצוא DOCX** — מסמך מעוצב מוכן להגשה
### ⭐ יעד-העל: רכישת-הסגנון של דפנה (Style Acquisition) ### ⭐ יעד-העל: רכישת-הסגנון של דפנה (Style Acquisition)
**היעד הראשי של המערכת הוא שהסוכנים יכתבו וינתחו עררים בדיוק כמו עו"ד דפנה תמיר** — לא רק לייצר טיוטה תקנית, אלא להפנים את **הקול והשיטה** שלה. זה מחייב **הפרדה מובהקת בין שתי תת-מערכות**: **היעד הראשי של המערכת הוא שהסוכנים יכתבו וינתחו עררים בדיוק כמו עו"ד דפנה תמיר** — לא רק לייצר טיוטה תקנית, אלא להפנים את **הקול והשיטה** שלה. זה מחייב **הפרדה מובהקת בין שתי תת-מערכות**:
@@ -30,19 +26,9 @@
**הגישה (state-of-the-art לדאטה-מועט):** Text Style Transfer מבוסס **Authorial Style Profiling** — להכליל את סגנון דפנה ולהתאים לתיק. העתקת פסקאות מותרת לתוכן קבוע/נוסחאי; ניתוח ספציפי → להכליל; **מהות משפטית (הלכה/עובדה) — אסור להעתיק מתיק לתיק**. *לא* fine-tuning של משקולות (Opus סגור; קורפוס קטן מדי). **הגישה (state-of-the-art לדאטה-מועט):** Text Style Transfer מבוסס **Authorial Style Profiling** — להכליל את סגנון דפנה ולהתאים לתיק. העתקת פסקאות מותרת לתוכן קבוע/נוסחאי; ניתוח ספציפי → להכליל; **מהות משפטית (הלכה/עובדה) — אסור להעתיק מתיק לתיק**. *לא* fine-tuning של משקולות (Opus סגור; קורפוס קטן מדי).
**כלל-העל — INV-LRN4:** כל החלטה אינה "סגורה" עד שהושוותה מול הגרסה הסופית של דפנה; כל סופי מנותח מול הטיוטה. כך לומדים מכל החלטה. **INV-LRN5:** שכבת-ידע-הקול לא תכיל מהות ספציפית — רק סגנון ושיטה. **כלל-העל — INV-LRN4:** כל החלטה אינה "סגורה" עד שהושוותה מול הגרסה הסופית של דפנה; כל סופי מנותח מול הטיוטה. **INV-LRN5:** שכבת-ידע-הקול לא תכיל מהות ספציפית — רק סגנון ושיטה. ספ מלא: [`docs/spec/07-learning.md`](docs/spec/07-learning.md) §0. ארכיטקטורה ומשימות: תוכנית `style-acquisition-subsystem`.
ספ מלא: [`docs/spec/07-learning.md`](docs/spec/07-learning.md) §0. ארכיטקטורה ומשימות: תוכנית `style-acquisition-subsystem`. > **Legacy:** המערכת הקודמת היתה Obsidian vault עם Claude Code skills. הידע שהופק ממנה (ניתוח סגנון, 12 בלוקים מבוססי CREAC/DITA/Akoma-Ntoso/FJC, כללי כתיבה, לקחים, ייצוא DOCX) הוטמע בפרויקט הנוכחי (`docs/`, `data/training/`). ה-vault נמחק; כעת PostgreSQL + pgvector.
### מה היה קודם (Legacy)
המערכת הקודמת היתה **Obsidian vault** עם Claude Code skills על שרת אחר. פותחו:
- ניתוח סגנון של 3 החלטות (הכט — דחייה, בית הכרם — קבלה חלקית, אריאלי — השוואה)
- ארכיטקטורת 12 בלוקים מבוססת CREAC / DITA / Akoma Ntoso / Federal Judicial Center
- כללי כתיבה (רקע ניטרלי, ללא כפילות, טענות מקוריות בלבד)
- לקחים מהשוואת טיוטות לגרסאות סופיות
- סקריפט ייצוא DOCX
הידע שהופק מה-vault הוטמע במערכת הנוכחית — מסמכי ייחוס (`docs/`), קורפוס אימון (`data/training/`), ומבנה 12 בלוקים. ה-vault המקורי נמחק; הפרויקט הנוכחי עובד עם PostgreSQL + pgvector.
--- ---
@@ -58,6 +44,7 @@
| [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים | | [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
| [`docs/legal-decision-lessons.md`](docs/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** | | [`docs/legal-decision-lessons.md`](docs/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** |
| [`docs/decision-methodology.md`](docs/decision-methodology.md) | **מתודולוגיה אנליטית — איך לחשוב על החלטה מעין-שיפוטית** | **לפני כל כתיבת החלטה** | | [`docs/decision-methodology.md`](docs/decision-methodology.md) | **מתודולוגיה אנליטית — איך לחשוב על החלטה מעין-שיפוטית** | **לפני כל כתיבת החלטה** |
| [`docs/anti-hallucination-gate.md`](docs/anti-hallucination-gate.md) | **שער anti-hallucination משותף (INV-AH)** — 5 טכניקות מעוגנות-מקור (עיגון-מקור, quote-or-retract, abstention, תיוג-ודאות, CoVe). מקור-אמת אחד לכל הסוכנים | **לפני כל אזכור פסיקה/חוק/הלכה/מספר** |
| `docs/garner-methodology-extraction.md` | חומר מקור: מיצוי מספרי Garner על כתיבה משפטית | רק לבדיקת מקור | | `docs/garner-methodology-extraction.md` | חומר מקור: מיצוי מספרי Garner על כתיבה משפטית | רק לבדיקת מקור |
| `docs/fjc-principles-extraction.md` | חומר מקור: מיצוי מ-Judicial Writing Manual (FJC) | רק לבדיקת מקור | | `docs/fjc-principles-extraction.md` | חומר מקור: מיצוי מ-Judicial Writing Manual (FJC) | רק לבדיקת מקור |
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** | | [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
@@ -73,6 +60,8 @@
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** | | [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
| [`.claude/agents/HEARTBEAT.md`](.claude/agents/HEARTBEAT.md) | checklist הפעלת סוכן — routing, company filtering, quirks, wakeup עם UUID נכון | **לפני כל עבודה על סוכנים** | | [`.claude/agents/HEARTBEAT.md`](.claude/agents/HEARTBEAT.md) | checklist הפעלת סוכן — routing, company filtering, quirks, wakeup עם UUID נכון | **לפני כל עבודה על סוכנים** |
| [`skills/dafna-decision-template/SKILL.md`](skills/dafna-decision-template/SKILL.md) | export DOCX לפי styles של תבנית Word של דפנה — line classification, dash policy, placeholder handling | לפני export DOCX | | [`skills/dafna-decision-template/SKILL.md`](skills/dafna-decision-template/SKILL.md) | export DOCX לפי styles של תבנית Word של דפנה — line classification, dash policy, placeholder handling | לפני export DOCX |
| [`docs/corpus-graph.md`](docs/corpus-graph.md) | **מפת הקורפוס** (`/graph`) — גרף ציטוטים אינטראקטיבי נייטיב; 6 שכבות (פסיקה/נושא/תחום/הלכות/חוסרי‑מחקר/יומונים), אנליטיקה (PageRank/אשכולות), endpoints, ואיך מוסיפים שכבה | לפני עבודה על דף `/graph` או `web/graph_api.py` |
| [`docs/operations-runbook.md`](docs/operations-runbook.md) | **עומק תפעולי** — Deploy (Coolify/pm2), Paperclip-ops מלא (wakeup, sync, webhook, scheduled jobs, adapters), מבנה-תיקיות, Chair-Feedback, TaskMaster | לפני עבודה על Deploy / אינטגרציית-Paperclip / adapters |
--- ---
@@ -85,14 +74,14 @@
**לפני יצירה/שינוי של קוד ב-`web/`, `mcp-server/`, `web-ui/`, `scripts/`:** **לפני יצירה/שינוי של קוד ב-`web/`, `mcp-server/`, `web-ui/`, `scripts/`:**
1. **קרא** [`docs/spec/00-constitution.md`](docs/spec/00-constitution.md) — ייעוד, ה-invariants הגלובליים G1G11, וכללי-ההנדסה (§6). אינדקס-הספ ב-§7. 1. **קרא** [`docs/spec/00-constitution.md`](docs/spec/00-constitution.md) — ייעוד, ה-invariants הגלובליים G1G12, וכללי-ההנדסה (§6). אינדקס-הספ ב-§7.
2. **קרא את ספ-התחום הרלוונטי** לפי האינדקס (§7) — לדוגמה: אחזור→[`03-retrieval.md`](docs/spec/03-retrieval.md), קליטה→[`01-ingest.md`](docs/spec/01-ingest.md), נתונים→[`02-data-model.md`](docs/spec/02-data-model.md), כלי-MCP→[`X9-mcp-tool-contract.md`](docs/spec/X9-mcp-tool-contract.md), UI↔API→[`X6-ui-api-contract.md`](docs/spec/X6-ui-api-contract.md), Paperclip→[`X3`](docs/spec/X3-integration-deploy.md)/[`X7`](docs/spec/X7-paperclip-client-params.md), env/secrets→[`X10-deploy-env-secrets.md`](docs/spec/X10-deploy-env-secrets.md). 2. **קרא את ספ-התחום הרלוונטי** לפי האינדקס (§7) — לדוגמה: אחזור→[`03-retrieval.md`](docs/spec/03-retrieval.md), קליטה→[`01-ingest.md`](docs/spec/01-ingest.md), נתונים→[`02-data-model.md`](docs/spec/02-data-model.md), כלי-MCP→[`X9-mcp-tool-contract.md`](docs/spec/X9-mcp-tool-contract.md), UI↔API→[`X6-ui-api-contract.md`](docs/spec/X6-ui-api-contract.md), Paperclip/שער-הפלטפורמה→[`X3`](docs/spec/X3-integration-deploy.md)/[`X7`](docs/spec/X7-paperclip-client-params.md)/[`X15`](docs/spec/X15-agent-platform-port.md) (G12), עמידות-פייפליין→[`X16`](docs/spec/X16-pipeline-durability.md), env/secrets→[`X10-deploy-env-secrets.md`](docs/spec/X10-deploy-env-secrets.md).
3. **ודא שהשינוי *מקיים* את ה-invariants** — לא יוצר מסלול מקביל ליכולת קיימת ([G2](docs/spec/00-constitution.md)), לא מתקן תסמין בקריאה במקום נרמול במקור (G1), לא בולע שגיאות בשקט (כלל-הנדסה §6). 3. **ודא שהשינוי *מקיים* את ה-invariants** — לא יוצר מסלול מקביל ליכולת קיימת ([G2](docs/spec/00-constitution.md)), לא מתקן תסמין בקריאה במקום נרמול במקור (G1), לא בולע שגיאות בשקט (כלל-הנדסה §6).
4. **בדוק מול** [`gap-audit.md`](docs/spec/gap-audit.md) — אם אתה נוגע ב-GAP/FU שכבר ממופה, התאם את העבודה ליחידת-התיקון; אל תפתור מחדש. 4. **בדוק מול** [`gap-audit.md`](docs/spec/gap-audit.md) — אם אתה נוגע ב-GAP/FU שכבר ממופה, התאם את העבודה ליחידת-התיקון; אל תפתור מחדש.
5. **כל PR מצהיר invariants** — אילו G*/INV-* ה-PR נוגע בהם / מקיים (ראה תבנית ה-PR ב-[`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md)). 5. **כל PR מצהיר invariants** — אילו G*/INV-* ה-PR נוגע בהם / מקיים (ראה תבנית ה-PR ב-[`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md)).
> **שתי שכבות-כללים מובחנות, שתיהן חלות:** > **שתי שכבות-כללים מובחנות, שתיהן חלות:**
> - **הנדסה (G1G10)** — הסעיף הזה + `docs/spec/`. סמכות: ≥3 מקורות חיצוניים. > - **הנדסה (G1G10, G12)** — הסעיף הזה + `docs/spec/`. סמכות: ≥3 מקורות חיצוניים.
> - **תוכן משפטי (G11)** — סעיף "עקרונות כתיבה קריטיים" למטה (12 בלוקים, רקע ניטרלי...). סמכות: היו"ר + מסמכי-הפרויקט. > - **תוכן משפטי (G11)** — סעיף "עקרונות כתיבה קריטיים" למטה (12 בלוקים, רקע ניטרלי...). סמכות: היו"ר + מסמכי-הפרויקט.
> >
> אכיפה אוטומטית: hook `PreToolUse` ([scripts/spec-guard.sh](scripts/spec-guard.sh)) מזכיר את הפרוטוקול בכל Edit/Write על נתיב-קוד. > אכיפה אוטומטית: hook `PreToolUse` ([scripts/spec-guard.sh](scripts/spec-guard.sh)) מזכיר את הפרוטוקול בכל Edit/Write על נתיב-קוד.
@@ -105,17 +94,13 @@
**לכן — כל סשן שעומד לכתוב/לשנות קוד או תיעוד חייב לעבוד ב-git worktree מבודד משלו. אסור לערוך/לתייק בעץ-העבודה הראשי `~/legal-ai` כשייתכן שסשן אחר פעיל.** **לכן — כל סשן שעומד לכתוב/לשנות קוד או תיעוד חייב לעבוד ב-git worktree מבודד משלו. אסור לערוך/לתייק בעץ-העבודה הראשי `~/legal-ai` כשייתכן שסשן אחר פעיל.**
הבידוד **נתמך-סביבה** לא רק כלל-משמעת. ההגדרות נשמרות ב-repo (`.claude/settings.json`, `.worktreeinclude`, `.gitignore`) כך שכל worktree שה-harness יוצר מקבל אוטומטית בסיס נקי, את התלויות, ואת ההרשאות. מקורות רשמיים: [Run parallel sessions with worktrees](https://code.claude.com/docs/en/worktrees), [Settings → worktree](https://code.claude.com/docs/en/settings). הבידוד **נתמך-סביבה** — ההגדרות נשמרות ב-repo (`.claude/settings.json`, `.worktreeinclude`, `.gitignore`) כך שכל worktree שה-harness יוצר מקבל אוטומטית בסיס נקי, את התלויות, ואת ההרשאות. מקורות רשמיים: [Run parallel sessions with worktrees](https://code.claude.com/docs/en/worktrees), [Settings → worktree](https://code.claude.com/docs/en/settings).
### הדרך המומלצת — worktree של ה-harness ### הדרך המומלצת — worktree של ה-harness
```bash ```bash
cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד ב-worktree" (כלי EnterWorktree) cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד ב-worktree" (כלי EnterWorktree)
``` ```
נוצר תחת `.claude/worktrees/<slug>/` על ענף `worktree-<slug>`, ומקבל **אוטומטית**: נוצר תחת `.claude/worktrees/<slug>/` על ענף `worktree-<slug>`, ומקבל **אוטומטית**: בסיס נקי מ-`origin/main` (`worktree.baseRef: "fresh"`) · `web-ui/node_modules` כסימלינק (`worktree.symlinkDirectories`; אין צורך ב-`npm ci`) · `.claude/settings.local.json` + קבצי-env מקומיים (דרך `.worktreeinclude`) · ניקוי אוטומטי ביציאה (כולל עקיפת באג סימלינק [#40259](https://github.com/anthropics/claude-code/issues/40259) דרך `WorktreeRemove` hook עם `--force`).
- **בסיס נקי מ-`origin/main`** — דרך `worktree.baseRef: "fresh"` ב-`.claude/settings.json`.
- **`web-ui/node_modules` (789MB) כסימלינק** — דרך `worktree.symlinkDirectories`; אין צורך ב-`npm ci`. (אם משנים deps של web-ui — עשו זאת בעץ הראשי או היו מודעים שה-node_modules משותף.)
- **`.claude/settings.local.json` + קבצי-env מקומיים** — מועתקים דרך `.worktreeinclude` (מונע הצפת אישורי-הרשאה).
- **ניקוי אוטומטי ביציאה** — כולל עקיפת באג סימלינק [#40259](https://github.com/anthropics/claude-code/issues/40259) דרך `WorktreeRemove` hook עם `--force`.
### הפרוטוקול (חל על שתי הדרכים) ### הפרוטוקול (חל על שתי הדרכים)
1. **בתחילת עבודת-כתיבה** — צור worktree (מומלץ: `claude --worktree`; ידני-fallback: `git worktree add -b <branch> .claude/worktrees/<slug> origin/main`**תחת `.claude/worktrees/`** כדי שההגדרות יחולו). 1. **בתחילת עבודת-כתיבה** — צור worktree (מומלץ: `claude --worktree`; ידני-fallback: `git worktree add -b <branch> .claude/worktrees/<slug> origin/main`**תחת `.claude/worktrees/`** כדי שההגדרות יחולו).
@@ -126,202 +111,43 @@ cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד
6. **אל תיגע** בשינויים לא-מתויקים שאינם שלך בעץ הראשי — הם של סשן אחר. אם העץ הראשי על ענף זר — אל תתייק עליו. 6. **אל תיגע** בשינויים לא-מתויקים שאינם שלך בעץ הראשי — הם של סשן אחר. אם העץ הראשי על ענף זר — אל תתייק עליו.
> **בידוד-DB:** ה-worktree מבודד-קבצים בלבד — לא בידוד-repo ולא בידוד-DB. **אל תריץ migrations מ-2 worktrees במקביל** על Postgres המשותף (`localhost:5433`) — סכמה שאף סשן לא מצפה לה ([Run agents in parallel](https://code.claude.com/docs/en/agents)). > **בידוד-DB:** ה-worktree מבודד-קבצים בלבד — לא בידוד-repo ולא בידוד-DB. **אל תריץ migrations מ-2 worktrees במקביל** על Postgres המשותף (`localhost:5433`) — סכמה שאף סשן לא מצפה לה ([Run agents in parallel](https://code.claude.com/docs/en/agents)).
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w` (קיימת רק ב-fork ה-deepseek שלנו). כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. הניתוח המלא והדרכים שנשקלו: TaskMaster `legal-ai` #104 (נסגר כ-cancelled — "לתעד, לא לבדד"). > **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w`. כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. ניתוח מלא: TaskMaster `legal-ai` #104 (נסגר cancelled — "לתעד, לא לבדד").
--- ---
## שרת Nautilus (158.178.131.193) ## Deploy — תמצית קריטית
| שירות | תפקיד | כתובת | שלושה מודלי-הרצה דרים יחד; ערבוב = הטעות הנפוצה. **פירוט מלא, UUIDs ופקודות: [`docs/operations-runbook.md`](docs/operations-runbook.md).**
|-------|--------|-------|
| Coolify | ניהול containers | `http://158.178.131.193:8000` |
| PostgreSQL + pgvector | בסיס נתונים ראשי | `legal-ai-postgres` |
| Redis | תור משימות | `legal-ai-redis` |
| Gitea | מאגר קוד | `gitea.nautilus.marcusgroup.org/ezer-mishpati` |
| ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` |
| Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` |
| Infisical | ניהול סודות | `secret.dev.marcus-law.co.il` |
### ⚠️ ארכיטקטורת Deploy — חובה לקרוא - **legal-ai** (`web/`, `web-ui/`) = **Docker דרך Coolify**. שינוי קוד לא נכנס לתוקף עד `git commit` + `git push origin main` → Gitea Actions בונה image → `mcp__coolify__deploy` (~2-4 דק'). **אסור** uvicorn/`next dev` מקומית — אין Python על המכונה. בדיקה: `curl https://legal-ai.nautilus.marcusgroup.org/api/health`.
- **Paperclip** = **pm2 מקומי** (`localhost:3100`). שינוי → `pm2 restart paperclip`. **אין** Docker/Coolify.
**עוזר משפטי (Legal-AI)** — רץ כ-**Docker container דרך Coolify**: - **legal-chat-service** = **pm2 מקומי** (`127.0.0.1:8770`), גשר claude CLI לטאב הצ'אט ב-/training. שינוי → `pm2 restart legal-chat-service`.
- UUID: `gyjo0mtw2c42ej3xxvbz8zio`
- שינוי קוד ב-`web/` או `web-ui/` **לא נכנס לתוקף** עד ש:
1. עושים `git commit` + `git push origin main`
2. מריצים deploy דרך Coolify (`mcp__coolify__deploy`)
3. ממתינים ~2-4 דקות לבנייה
- **אסור** לנסות להריץ uvicorn מקומית — אין סביבת Python על המכונה
- ה-container מריץ Next.js (`:3000`, חשוף) + FastAPI (`:8000`, פנימי)
- בדיקה: `curl https://legal-ai.nautilus.marcusgroup.org/api/...`
**Paperclip** — רץ **מקומית דרך pm2**:
- פורט: `localhost:3100`, DB: `localhost:54329`
- שינויי קוד נכנסים לתוקף אחרי `pm2 restart paperclip`
- **אין צורך ב-Docker או Coolify**
**legal-chat-service** — רץ **מקומית דרך pm2** (חדש, מאפריל 2026):
- פורט: `localhost:8770` (loopback בלבד)
- שירות aiohttp קצר שעוטף את `claude` CLI ב-streaming + session continuation, ומשרת את הטאב "שיחה" בדף `/training`. הקונטיינר משדל אליו proxy דרך `host.docker.internal:8770`.
- קוד: [mcp-server/src/legal_mcp/chat_service/](mcp-server/src/legal_mcp/chat_service/)
- התקנה: `pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs && pm2 save`
- בריאות: `curl http://127.0.0.1:8770/health``{"ok":true,...}`
- שינויי קוד: `pm2 restart legal-chat-service`
- **אפס עלות API** — claude CLI משתמש ב-claude.ai subscription של chaim. הנחת היסוד של `claude_session.py` (claude CLI מקומי בלבד) נשמרת — השירות הזה הוא הגשר הרשמי בין הקונטיינר לחוץ.
- Coolify dependency: ה-Service Definition של legal-ai חייב להכיל `extra_hosts: host.docker.internal:host-gateway` (אחרת ה-proxy יקבל ConnectError).
--- ---
## מבנה תיקיות ## Paperclip — כללים קריטיים (תמצית)
``` **פירוט מלא + דוגמאות + פקודות sync: [`docs/operations-runbook.md`](docs/operations-runbook.md).**
/home/chaim/legal-ai/
├── CLAUDE.md ← הקובץ הזה > **G12 — שער-הפלטפורמה ([`docs/spec/X15-agent-platform-port.md`](docs/spec/X15-agent-platform-port.md)):** Paperclip היא **מעטפת ניתנת-להחלפה** מאחורי Port יחיד. מגע-Paperclip מותר רק ב-`web/agent_platform_port.py` + `HEARTBEAT.md` (לפרומפטים) + המעטפת המוצהרת (`paperclip_client/api`, plugin, adapters). **אסור** סמל ספציפי-Paperclip ב-`mcp-server/src` או ב-skills של ההחלטה/הסגנון. כל מגע חדש → דרך ה-Port.
├── Dockerfile ← Docker build
├── docs/ ← תיעוד + לקחים - **Wakeup תמיד דרך API**: `POST /api/agents/{agent-id}/wakeup` עם `payload.issueId`. **אסור** `INSERT INTO agent_wakeup_requests` ישיר — הסוכן לא יתעורר לעולם (אין `heartbeat_run`).
│ ├── architecture.md ארכיטקטורה - **ניתוב comments דרך CEO**: תגובת-משתמש → פלאגין מעיר CEO → CEO מנתב ויוצר issue. סוכנים קוראים comments אחרונים לפני עבודה (HEARTBEAT 2b-2c).
│ ├── block-schema.md 12 בלוקים (המסמך החשוב ביותר) - **קריאות API דרך helper בלבד**: bash → `scripts/pc.sh`; Python → `pc_request()` מ-`web/paperclip_api.py`. **אסור** `curl` ישיר ל-Paperclip או `httpx.AsyncClient` ישיר.
│ ├── migration-plan.md תוכנית מעבר vault → DB - **Cross-company sync**: 14 סוכנים = 7 × 2 חברות (CMP=1xxx master, CMPA=8xxx mirror). אחרי כל שינוי הגדרות/skills של סוכן — להריץ `scripts/sync_agents_across_companies.py --apply`. **מדלג** על סוכנים עם `adapter_type` שונה בין החברות (למשל `deepseek_local`) — להחיל ידנית בשתיהן.
│ ├── legal-decision-lessons.md לקחים מ-3 החלטות
│ └── memory.md הקשר כללי — skills, פרויקטים
├── skills/ ← כלי עבודה ומדריכים
│ ├── decision/ מדריך סגנון + references + 12 בלוקים
│ ├── assistant/ קטלוג מסמכים
│ ├── docx/ עיצוב DOCX
│ ├── dafna-decision-template/ export DOCX לפי תבנית Word של דפנה
│ └── new-company-setup/ blueprint הוספת חברה חדשה
├── .claude/
│ └── agents/ ← הוראות סוכנים + HEARTBEAT.md (symlinks ב-Paperclip)
│ ├── HEARTBEAT.md checklist הפעלה משותף לכל הסוכנים
│ ├── legal-ceo.md תזמורן + בקרת זרימה
│ ├── legal-writer.md כתיבת בלוקים בסגנון דפנה
│ ├── legal-analyst.md ניתוח משפטי + חילוץ טענות
│ ├── legal-researcher.md חיפוש תקדימים
│ ├── legal-qa.md 7 שערי איכות
│ ├── legal-proofreader.md תיקון OCR
│ ├── legal-exporter.md ייצוא DOCX סופי
│ └── hermes-curator.md סוכן Hermes לניתוח סגנון post-export
├── data/
│ ├── training/ ← 4 החלטות לאימון (DOCX)
│ ├── exports/ ← טיוטות DOCX מיוצאות
│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB)
├── web/ ← FastAPI backend (Python): 75+ API endpoints
│ ├── app.py ← API ראשי
│ ├── paperclip_api.py ← אינטגרציית Paperclip: `pc_request()` + `emit_case_status_webhook()`
│ ├── paperclip_client.py ← legacy client (ישן — השתמש ב-paperclip_api.py)
│ └── gitea_client.py ← אינטגרציית Gitea
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
├── mcp-server/ ← MCP server + services + tools
├── adapters/ ← Paperclip external adapters (ראה למטה)
│ └── deepseek-paperclip-adapter/ ← `deepseek_local` (Hermes-pinned ל-DeepSeek profile)
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
```
--- ---
## כלל: עדכון `scripts/SCRIPTS.md` ## כלל: עדכון `scripts/SCRIPTS.md`
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/`**חובה לעדכן את `scripts/SCRIPTS.md`** (תפקיד, סטטוס, החלפה).
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/`**חובה לעדכן את `scripts/SCRIPTS.md`** בהתאם.
הקובץ מתעד את התפקיד, הסטטוס, וההחלפה (אם יש) של כל סקריפט.
---
## ניהול משימות — TaskMaster AI ## ניהול משימות — TaskMaster AI
**תמיד** TaskMaster (לא TASKS.md ידני). קובץ קנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (tags: `master`, `legal-ai`). פקודות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`.
הפרויקט משתמש ב-**TaskMaster AI** (MCP server) לניהול משימות מובנה: > **⚠️ מלכוד cwd ב-CLI:** `--tag` בוחר קבוצה *בתוך* הקובץ — לא לאיזה קובץ לכתוב (ה-CLI מאתר לפי cwd). תמיד `cd ~/legal-ai` לפני כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את הקובץ ישירות. פירוט: [`docs/operations-runbook.md`](docs/operations-runbook.md).
- **תמיד** להשתמש ב-TaskMaster לפירוק, מעקב וניהול משימות — לא ב-TASKS.md ידני
- קובץ המשימות הקנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (יחסי ל-project root, **לא** `~/.taskmaster/tasks/tasks.json`). מכיל את כל ה-tags של legal-ai (`master`, `legal-ai`).
- פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`
- לפני התחלת עבודה → `next_task` כדי לדעת מה הבא לפי תלויות
- אחרי סיום משימה → `update_task` עם status=done
- משימה מורכבת → `expand_task` לפירוק לתתי-משימות
> **⚠️ מלכוד cwd ב-CLI:** הדגל `--tag` בוחר קבוצה לוגית *בתוך* הקובץ — הוא **לא** בוחר לאיזה `tasks.json` לכתוב. ה-CLI מאתר את הקובץ לפי ה-cwd (`<cwd>/.taskmaster/tasks/tasks.json`). תמיד `cd ~/legal-ai` לפני `task-master add-task` או כל פקודה משנה, ואז אמת ב-MCP `get_tasks` שהשינוי נחת. הרצה מ-`~/` כותבת לקובץ נטוש והמשימה לא תופיע בשאילתות MCP. כשלא בטוחים — לערוך את `~/legal-ai/.taskmaster/tasks/tasks.json` ישירות.
--- ---
## Paperclip — כללי אינטגרציה קריטיים ## עקרונות כתיבה קריטיים (G11)
### Wakeup API — תמיד דרך API, לעולם לא דרך DB
- **הנתיב הנכון**: `POST /api/agents/{agent-id}/wakeup` (לא `/wake`!)
- **⚠️ אסור**: `INSERT INTO agent_wakeup_requests` ישירות — זה יוצר רק רשומה בלי `heartbeat_run`, והסוכן **לא יתעורר לעולם**
- **⚠️ חובה לשלוח `payload` עם `issueId`** — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי issue, בלי cwd נכון)
- דוגמה נכונה:
```json
{"source": "automation", "triggerDetail": "system", "reason": "...",
"payload": {"issueId": "...", "mutation": "comment", "commentId": "..."}}
```
- **Board API Key**: שמור ב-DB (`board_api_keys`), auth: `Authorization: Bearer pbk_...`
### ניתוב comments דרך CEO
- כשמשתמש כותב תגובה על issue ב-Paperclip, הפלאגין (`plugin-legal-ai`) מעיר את ה-CEO דרך `ctx.agents.invoke()`
- ה-CEO קורא את ה-comment, מחליט על ניתוב, ויוצר issue לסוכן המתאים
- כל הסוכנים חייבים לקרוא comments אחרונים לפני שהם מתחילים לעבוד (HEARTBEAT שלבים 2b-2c)
### קריאות API — תמיד דרך helper, לעולם לא `curl` ישיר
- **bash (סוכנים):** `~/legal-ai/scripts/pc.sh <METHOD> <PATH> [BODY_JSON]` — מוסיף Authorization, X-Paperclip-Run-Id, Content-Type, base URL. ראה `HEARTBEAT.md §0`.
- **Python (FastAPI):** `from web.paperclip_api import pc_request; await pc_request("POST", "/api/...", json={...})` — שימוש ב-board API key.
- **אסור** `curl ... $PAPERCLIP_API_URL` ישיר ב-bash; **אסור** `httpx.AsyncClient` ישיר ל-Paperclip ב-Python.
- **למה:** ה-skill הרשמי דורש `X-Paperclip-Run-Id` בכל קריאה משנה issue. אצלנו ה-audit trail עבד ממילא דרך JWT claims (`runId: runIdHeader || claims.run_id`), אבל ה-helper מבטיח עקביות + תאימות ל-board API keys (long-lived) שלא נושאות JWT claims.
### Cross-company agent sync — אחרי כל שינוי הגדרות
- יש 14 סוכנים = 7 × 2 חברות (CMP=1xxx, CMPA=8xxx). Paperclip מחייב `agents.company_id NOT NULL` — אין shared agents.
- **Master = CMP (1xxx)**, **Mirror = CMPA (8xxx)**.
- אחרי כל שינוי ב-`adapter_config`, `runtime_config`, `budget_monthly_cents`, או skills של סוכן ב-master (UI, SQL, או API), **חובה להריץ:**
```bash
PAPERCLIP_BOARD_API_KEY=$(...infisical...) \
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify # לבדיקה
PAPERCLIP_BOARD_API_KEY=$(...) \
python ~/legal-ai/scripts/sync_agents_across_companies.py --apply # לסנכרן
```
- הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent.
- שאלות ה-skill הרשמי של Paperclip — `paperclip` skill תחת `paperclipai/paperclip`.
### Webhook יוצא — עדכון סטטוס תיק לפלאגין
כשסטטוס תיק משתנה דרך `PUT /api/cases/{case_number}`, הבקאנד שולח webhook אסינכרוני לפלאגין:
```
PUT /api/cases/{case_number} → emit_case_status_webhook() [BackgroundTask]
→ POST /api/plugins/marcusgroup.legal-ai/webhooks/case-status
→ plugin-legal-ai/onWebhook()
→ comment בעברית על issue + CEO wakeup (כשסטטוס = qa_failed)
```
- הקוד ב-`web/paperclip_api.py` (`emit_case_status_webhook`), fire-and-forget, timeout 5s
- הפלאגין שומר idempotency key ב-state עם TTL 5 דקות למניעת spam על retry
- `GET /api/cases/stale?days=N` — תיקים שלא עודכנו N ימים; מוחרגים: `new`, `final`, `exported`
- `GET /api/chair-feedback/weekly-summary` — סיכום פידבק YU"R לשבוע האחרון
### Scheduled Jobs (plugin-legal-ai)
| Job | לוח זמנים | מה עושה |
|-----|-----------|---------|
| `stale-case-reminder` | יומי 08:00 | שולח comment אזהרה על תיקים תקועים >3 ימים |
| `weekly-feedback-analysis` | ראשון 19:00 | מעיר CEO לניתוח פידבק YU"R ועדכון `docs/legal-decision-lessons.md` |
| `sync-case-status` | כל 30 דק' | מסנכרן סטטוסי תיקים בין legal-ai ל-Paperclip |
CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **אין לו issueId, אל תנסה לפרסם comment או לסגור issue**.
### External adapters — `deepseek_local`
- מיקום ה-package: [adapters/deepseek-paperclip-adapter/](adapters/deepseek-paperclip-adapter/) (לא ב-`node_modules`).
- רישום ב-Paperclip: רשומה ב-`~/.paperclip/adapter-plugins.json` (נטען אוטומטית ב-startup דרך `buildExternalAdapters`). אין צורך בעריכת `node_modules`.
- **מה ה-adapter עושה**: spawnל-`hermes chat` עם `HERMES_HOME=/home/chaim/.hermes/profiles/deepseek` כך שה-CLI טוען את `config.yaml` (`base_url=https://api.deepseek.com/v1`, `provider=custom`, `key_env=DEEPSEEK_API_KEY`) ואת `.env` (שמכיל את ה-key).
- **מודלים זמינים** (lookup ב-DeepSeek `/v1/models`): `deepseek-v4-pro` (default), `deepseek-v4-flash`. יופיעו כדרופ-דאון ב-UI.
- **התקנה מחדש / עדכון**: `curl -X POST -H "Authorization: Bearer pcapi_legal_install_key_2026" -H "Content-Type: application/json" -d '{"packageName":"/home/chaim/legal-ai/adapters/deepseek-paperclip-adapter","isLocalPath":true}' http://localhost:3100/api/adapters/install`. לעדכון hot — `POST /api/adapters/deepseek_local/reload`.
- **⚠ Cross-company sync**: `sync_agents_across_companies.py` **מדלג** על סוכנים עם `adapter_type` שונה בין CMP ל-CMPA. כשעוברים סוכן ל-`deepseek_local` חובה להחיל ידנית בשתי החברות לפני sync.
- **תוספת adapters עתידיים** (OpenAI ישיר, Anthropic ישיר, וכו'): אותו דפוס. ה-package הראשי חייב לייצא `createServerAdapter()` שמחזיר `{ type, label, models, agentConfigurationDoc, execute, testEnvironment, sessionCodec, listSkills, syncSkills, ... }`. ראה את [adapters/deepseek-paperclip-adapter/dist/index.js](adapters/deepseek-paperclip-adapter/dist/index.js) כתבנית.
### External adapters — Hermes Curator (`curator-cmp` / `curator-cmpa`)
- פרופילי Hermes נפרדים לסוכן `hermes-curator` — מנתח החלטות סופיות ומציע עדכוני SKILL.md/lessons.md
- מיקום: `~/.hermes/profiles/curator-cmp/` + `~/.hermes/profiles/curator-cmpa/`
- מופעל אחרי export סופי; אינו מעדכן קבצים ישירות
- **תהליך אישור הצעות:** הצעות ה-curator מגיעות כ-comment ב-Paperclip → חיים בוחן ומאשר ידנית → commits ל-`SKILL.md` ו-`docs/legal-decision-lessons.md`
---
## עקרונות כתיבה קריטיים
1. **"מבחן השופט"** — כל החלטה חייבת להיות קריאה לשופט שלא מכיר את התיק 1. **"מבחן השופט"** — כל החלטה חייבת להיות קריאה לשופט שלא מכיר את התיק
2. **"רקע ניטרלי"** — בלוק ו = עובדות בלבד. אין ציטוטים מצדדים, אין מילות שיפוט 2. **"רקע ניטרלי"** — בלוק ו = עובדות בלבד. אין ציטוטים מצדדים, אין מילות שיפוט
@@ -330,14 +156,7 @@ CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **
5. **ארכיטקטורת 12 בלוקים** — ראה `docs/block-schema.md` 5. **ארכיטקטורת 12 בלוקים** — ראה `docs/block-schema.md`
6. **צ'קליסט תוכן** — בלוק י מקבל צ'קליסט תוכן אוטומטי לפי סוג הערר (ראה `lessons.py: CONTENT_CHECKLISTS`) 6. **צ'קליסט תוכן** — בלוק י מקבל צ'קליסט תוכן אוטומטי לפי סוג הערר (ראה `lessons.py: CONTENT_CHECKLISTS`)
## הערות יו"ר (Chair Feedback) > **הערות יו"ר (Chair Feedback):** מנגנון תיעוד הערות דפנה — טבלת `chair_feedback`, API `/api/feedback`, MCP `record_chair_feedback`/`list_chair_feedback`, UI `/feedback`. פירוט: [`docs/operations-runbook.md`](docs/operations-runbook.md).
מנגנון לתיעוד הערות דפנה על טיוטות:
- **DB**: טבלת `chair_feedback` (case_id, block_id, feedback_text, category, lesson_extracted)
- **API**: `GET/POST /api/feedback`, `PATCH /api/feedback/{id}/resolve`
- **MCP tools**: `record_chair_feedback`, `list_chair_feedback`
- **UI**: דף ניהול ב-`/feedback` (ב-Next.js)
- **קטגוריות**: missing_content, wrong_tone, wrong_structure, factual_error, style, other
## יו"ר: עו"ד דפנה תמיר ## יו"ר: עו"ד דפנה תמיר
- מדריך סגנון מלא: `skills/decision/SKILL.md` מדריך סגנון מלא: [`skills/decision/SKILL.md`](skills/decision/SKILL.md).

View File

@@ -0,0 +1,62 @@
# שער anti-hallucination — הגנה משותפת מפני הזיות (INV-AH)
> **מקור-אמת אחד לכל הסוכנים.** כל סוכן נוגע-מהות מפנה לכאן (דרך [HEARTBEAT.md](.claude/agents/HEARTBEAT.md)
> ובלוק "קרא לפני פעולה" שלו). אל תשכפל את הכללים בקובץ-סוכן — הפנה לכאן (G2 — בלי מסלולים מקבילים).
> זהו המקבילה התוכנית ל-INV-AG1 (קריאת-ספ): כמו שאינך פועל "מהזיכרון" לגבי התנהגות-המערכת, אינך
> מצטט פסיקה/חוק/הלכה/מספר "מהזיכרון".
## למה זה קיים
כלי-AI משפטיים מובילים (Lexis+ AI, Westlaw) **הוזים פסיקה ב-17%33%** גם עם RAG — זו לא בעיה
שנעלמת מעצמה ("RAG ≠ hallucination-free"). בתחום מעין-שיפוטי, ציטוט-שווא של פסק-דין/סעיף/הלכה הוא
כשל קריטי הניתן לביקורת שיפוטית. חמש הטכניקות למטה הן הקונצנזוס המקצועי להפחתת הזיות, מותאם לתחום.
---
## חמש הטכניקות הקשיחות (חלות על כל סוכן נוגע-מהות)
**AH-1 · עיגון-מקור (grounding) — אפס ציטוט מהזיכרון.**
כל אזכור של פסק-דין / מספר-תיק / סעיף-חוק / הלכה / מקדם / "מתודה שמאית" / נתון כמותי חייב לבוא
ממקור מאומת: **תוצאת כלי-אחזור** (`search_precedent_library`, `search_internal_decisions`,
`search_case_documents`, `search_decisions`, `find_similar_cases`, `precedent_library_get`,
`halacha_review`) **או מסמך בתיק**. אם לא הרצת חיפוש/לא קראת מסמך — אין לך את הפריט. *(Stanford RegLab / Magesh et al., JELS 2025; Anthropic — ground in retrieved sources.)*
**AH-2 · Quote-or-retract.**
לכל אזכור-מקור צרף את הציטוט/מזהה המדויק שהמקור החזיר (`supporting_quote`/headnote/ציטוט מהמסמך).
**אין ציטוט מאשר → הסר את האזכור.** *(Anthropic — retract if no supporting quote; RAGAS faithfulness — כל טענה חייבת להיות נתמכת ב-context.)*
**AH-3 · Abstention — "לא יודע" עדיף על המצאה.**
לא נמצא מקור? כתוב מפורשות **"לא נמצא בקורפוס/בתיק — דורש אימות חיצוני"**. אסור לסגור פער בהשערה
שנכתבת כעובדה. *(Anthropic — give the model an out.)*
**AH-4 · תיוג-ודאות.** סמן כל טענה לא-טריוויאלית:
`[מאומת]` (מקור+ציטוט) · `[טעון-אימות]` (סביר/עולה מהמסמכים, אך לא אותר מקור מאשר) · `[ספקולציה]`
(השערה אנליטית — מותרת רק כשאלה/הסתייגות, לא כקביעה). *(NIST AI RMF GenAI Profile — explainability/קליברציה; RAGAS — atomic-claim grounding.)*
**AH-5 · Chain-of-Verification (CoVe) — מעבר-אימות לפני סיום.**
אחרי הטיוטה, פרק כל טענה עובדתית/אזכור לרשימה, ולכל אחת שאל "מאיזה מקור מאומת זה מגיע?".
כל מה שאין לו עוגן — **הסר או הורד ל-`[ספקולציה]`**. *(Chain-of-Verification — Dhuliawala et al., arXiv:2309.11495, 2023.)*
> **ההבחנה שמכריעה הכל — "פער" מותר, "המצאה" אסורה:**
> ✅ "אזכרתי את X — חיפשתי ולא מצאתי בקורפוס; דורש אימות." (פער לגיטימי) ·
> ❌ "הנה תקדים Y רלוונטי" כש-Y לא הגיע מכלי-אחזור. (המצאה)
---
## יישום לפי תפקיד
| סוכן | איך השער חל |
|------|-------------|
| **analyst / researcher** | מייצרי-מהות — עיגון-קורפוס מלא, log שאילתות + negative evidence, "מקור: כתבי טענות → דורש אימות". (כבר נהוג; כעת אחיד ומעוגן-מקור.) |
| **writer** | **צרכן read-only** של פלט-המנתח המעוגן. **אסור** להוסיף פסיקה/סעיף/הלכה שלא הגיעו מהמנתח/הקורפוס. ציטוט בהחלטה = רק מ-`supporting_quote` מאומת. |
| **qa** | **אוכף** את AH-1…AH-5 כשער-איכות: כל אזכור בטיוטה — האם מאומת-מקור? אם לא — `needs_revision`. |
| **ceo** | מנתב ומסכם — לא ממציא מקורות; אם מצטט, מצטט ממה שהסוכנים אימתו. |
| **proofreader** | תיקון-OCR בלבד — **אל "תתקן" לכיוון מונח משפטי סביר** (שם-תקדים/מספר-תיק/סכום): שמר את לשון-המקור; ספק → סמן, לא "תקן". |
| **exporter** | מכני (DOCX) — אפס מהות חדשה. |
| **hermes-curator** | הצעות בלבד (G10) — מעוגן-מקור, לא מזין שכבת-קול עם מהות (INV-LRN5). |
| **שטן מליץ (Gemini)** | מימוש-הייחוס המלא של השער (`legal-analyst-gemini-critique.md`) — לידים-לא-הכרעות ליו"ר (human-in-the-loop, NIST). |
## מקורות מקצועיים
1. Magesh, Surani, Dahl, Suzgun, Manning, Ho — *Hallucination-Free? Assessing the Reliability of Leading AI Legal Research Tools*, J. Empirical Legal Studies (2025), Stanford RegLab/HAI — שיעורי-הזיה 1733% גם עם RAG.
2. Anthropic — *Reduce hallucinations* (docs.anthropic.com): allow "I don't know" · cite quotes/sources · retract-if-no-quote · chain-of-thought.
3. Dhuliawala et al. — *Chain-of-Verification Reduces Hallucination in LLMs*, arXiv:2309.11495 (2023).
4. Es et al. — *RAGAS: Automated Evaluation of RAG*, arXiv:2309.15217 — faithfulness = יחס הטענות הנתמכות-בקונטקסט.
5. NIST — *AI RMF: Generative AI Profile* (NIST-AI-600-1, 2024) — human-in-the-loop oversight ב-high-stakes.

70
docs/corpus-graph.md Normal file
View File

@@ -0,0 +1,70 @@
# מפת הקורפוס — גרף ציטוטים אינטראקטיבי (`/graph`)
תצוגת‑רשת אינטראקטיבית של קורפוס הפסיקה, בסגנון Obsidian Graph View, **מוטמעת נייטיב בwebui**. כל פריט הוא נקודה, קישורים הם קווים, וגודל הנקודה משקף חשיבות — כך שאפשר להתמקד בנושא ולראות מה קשור אליו.
## למה נייטיב ולא Obsidian (G2)
הרעיון המקורי היה לייצא את הקורפוס לObsidian vault. **נדחה** — vault הוא **עותק מקביל של הקורפוס שמתיישן**, בדיוק כשל‑השורש ש‑[G2](spec/00-constitution.md) (מקור‑אמת יחיד, ללא מסלול מקביל) בא לייבש. הגרף הנייטיב קורא את הDB החי → **אפס drift**, ומתחבר לדפים הקיימים (`/precedents`, `/missing-precedents`, `/digests`).
**התובנה המאפשרת:** כל קשתות הגרף כבר היו קיימות בטבלאות — הגרף רק חושף אותן. הוא **projection קריא‑בלבד** (SELECT בלבד), ולכן אינו יכול לסטות מהמקור. הוא **אינו מסלול אחזור** ([03-retrieval](spec/03-retrieval.md)) — מחזיר טופולוגיה (nodes+edges+מטריקות), לא תוצאות חיפוש מדורגות.
## שכבות (כולן optin דרך toggles, מלבד הבסיס)
| שכבה | נקודות | קשתות | מקור הדאטה |
|------|--------|-------|------------|
| **בסיס** | פסיקה (`cl:`) · נושא (`tag:`) · תחום (`pa:`) | `cites` · `same_chain` · `tagged` · `in_area` | `case_law`, `precedent_internal_citations`, `case_law_relations`, `subject_tags` |
| **הלכות** | הלכה (`hal:`) | `extracted_from` · `corroborates` · `equivalent` | `halachot`, `halacha_citation_corroboration`, `equivalent_halachot` |
| **חוסרי מחקר** | gap (`gap:`) — חלול/מקווקו | `cites`סיקה→gap) | `precedent_internal_citations` (cited_case_law_id IS NULL) + העשרה מ‑`missing_precedents` |
| **יומונים** | יומון (`dig:`) — טורקיז | `covers` (יומון→פסיקה/gap) | `digests` |
**גודל נקודה** = חשיבות: ציטוטים נכנסים (פסיקה), אזכורים (הלכה), מספר מצטטים (gap). **צבע** (colorby, ברירת‑מחדל "סוג"): סוג · תחום · דרגת‑סמכות · **אשכול** (community) · עדכניות.
## אנליטיקה (Graph Analysis)
`metrics=true` מפעיל חישוב **inmemory** (ללא DB) ב‑[`web/graph_metrics.py`](../web/graph_metrics.py) — pure, ללא תלויות (אין networkx):
- **PageRank** (poweriteration) — השפעה גלובלית.
- **Betweenness** (Brandes) — "גשריות" (פסיקות שמחברות אשכולות).
- **Community** (labelpropagation דטרמיניסטי + fallback לconnectedcomponents) — אשכולות תמטיים.
מחושב על **תת‑גרף הפסיקות בלבד** (cites/same_chain) — קשתות hub/gap/digest/halacha מוחרגות. בUI: בוררי "צביעה לפי" / "גודל לפי" + פאנל דירוג ("המשפיעות" / "גשרים").
## ניווט וחוויה
- **Deeplink** `/graph?focus=cl:<id>` — לינק שיתופי; כפתור **"הצג בגרף"** בכל דף פסיקה.
- **Local graph** — לחיצה על נקודה → התמקדות בשכניה (BFS, סליידר עומק 13).
- **ייצוא PNG** · פאנל עשיר (headnote/summary) · מקרא נקודות+קשתות · סינון מטא‑דאטה (בית‑משפט/דרגה/יו״ר/מחוז/שנים).
## API
קריאה‑בלבד, `response_model` מפורש (UI2). מוגדר ב‑[`web/app.py`](../web/app.py) (~`/api/graph/*`), לוגיקה ב‑[`web/graph_api.py`](../web/graph_api.py):
| endpoint | תיאור |
|----------|-------|
| `GET /api/graph/corpus` | הגרף המלא. params: `node_types` (csv), `metrics`, `practice_area`/`source`/`court`/`precedent_level`/`chair`/`district`/`year_from`/`year_to`, `min_citations`, `q`, `limit` (cap 400, max 1500) |
| `GET /api/graph/node/{id}/neighborhood` | Local graph: צומת + שכנים בעומק 13 |
| `GET /api/graph/facets` | ערכי סינון ייחודיים (courts/levels/chairs/districts) |
## קבצים
- **Backend:** [`web/graph_api.py`](../web/graph_api.py) (הרכבת nodes/edges, helpers `_edges_and_hubs`/`_gap_nodes_and_edges`/`_digest_nodes_and_edges`/`_halacha_nodes_and_edges`) · [`web/graph_metrics.py`](../web/graph_metrics.py) (מטריקות) · endpoints ב‑[`web/app.py`](../web/app.py).
- **Frontend:** [`web-ui/src/app/graph/page.tsx`](../web-ui/src/app/graph/page.tsx) · [`web-ui/src/components/graph/`](../web-ui/src/components/graph/) (`graph-view` orchestrator · `graph-canvas` ציור reactforcegraph2d · `graph-filter-panel` · `graph-node-panel`) · hooks ב‑[`web-ui/src/lib/api/graph.ts`](../web-ui/src/lib/api/graph.ts).
## איך מוסיפים שכבה חדשה
1. הוסף ערך ל‑`VALID_NODE_TYPES` ב‑`graph_api.py` (לא ל‑`DEFAULT_NODE_TYPES` אם רוצים שיהיה כבוי).
2. כתוב `_X_nodes_and_edges(conn, prec_ids)` — SELECT בלבד; חבר nodes לפסיקות שבתצוגה.
3. חבר בשתי פונקציות הבנייה (`build_corpus_graph` + `build_node_neighborhood`) מאחורי `if "X" in types`.
4. **danglingedge invariant:** כל קשת — שני קצותיה חייבים להיות nodes בתצוגה (סנן מול `prec_ids`/קבוצת הids).
5. Frontend: toggle ב‑`graph-filter-panel` · צבע/רינדור ב‑`graph-canvas` (`NODE_COLORS`/`colorForNode`/`linkColor`) · ענף בפאנל ב‑`graph-node-panel`.
6. אם גדל מודל התגובה — אחרי deploy: `cd web-ui && npm run api:types`.
## Invariants
- **G2** — projection קריא‑בלבד דרך `db.get_pool()`; אפס כתיבות; מטריקות inmemory. ללא store מקביל.
- **G5** — כל פילטר serverside, parameterized.
- **UI2** — `response_model` מפורש בכל endpoint; **UI4** — שגיאות UI מוצגות, לא נבלעות.
- **טופולוגיה ≠ אחזור** — מבנה הקורפוס, לא תוצאות חיפוש.
## היסטוריית מימוש
PR #113 (בסיס) · #118 (תיקון תוויות) · #126 (מטא‑דאטה) · #129 (אנליטיקה) · #131 (gaps) · #132 (יומונים) · #134 (ניווט) · #137 (הלכות) · #139 (api:types).

203
docs/operations-runbook.md Normal file
View File

@@ -0,0 +1,203 @@
# Operations Runbook — עוזר משפטי
> תוכן תפעולי-עומק שהוצא מ-[`CLAUDE.md`](../CLAUDE.md) כדי לרזות את ההקשר הנטען בכל סשן (TaskMaster #107.1).
> ה-CLAUDE.md מחזיק את **הכללים הקריטיים בקצרה**; כאן נמצאים הפרטים המלאים, הפקודות, וטבלאות-הייחוס.
> כשעובדים על Deploy, Paperplip-ops, או adapters — לקרוא את הסעיף הרלוונטי כאן.
---
## שרת Nautilus (158.178.131.193)
| שירות | תפקיד | כתובת |
|-------|--------|-------|
| Coolify | ניהול containers | `http://158.178.131.193:8000` |
| PostgreSQL + pgvector | בסיס נתונים ראשי | `legal-ai-postgres` (`localhost:5433`, user `legal_ai`) |
| Redis | תור משימות | `legal-ai-redis` |
| Gitea | מאגר קוד | `gitea.nautilus.marcusgroup.org/ezer-mishpati` |
| ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` |
| Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` |
| legal-chat-service | גשר claude CLI לטאב הצ'אט ב-/training (pm2, loopback) | `127.0.0.1:8770` |
| Infisical | ניהול סודות | `secret.dev.marcus-law.co.il` |
---
## ארכיטקטורת Deploy — חובה לקרוא
שלושה מודלי-הרצה דרים יחד. ערבוב ביניהם הוא הטעות הנפוצה ביותר.
### עוזר משפטי (Legal-AI) — Docker container דרך Coolify
- UUID: `gyjo0mtw2c42ej3xxvbz8zio` (build_pack: `dockerimage`, **לא** `dockerfile`)
- שינוי קוד ב-`web/` או `web-ui/` **לא נכנס לתוקף** עד ש:
1. עושים `git commit` + `git push origin main`
2. Gitea Actions בונה image → דוחף ל-registry → מפעיל redeploy ב-Coolify (`mcp__coolify__deploy`)
3. ממתינים ~2-4 דקות לבנייה
- **אסור** לנסות להריץ uvicorn / `next dev` מקומית — אין סביבת Python על המכונה
- ה-container מריץ Next.js (`:3000`, חשוף) + FastAPI (`:8000`, פנימי)
- בדיקה: `curl https://legal-ai.nautilus.marcusgroup.org/api/health`
- runbook מלא של ה-pipeline: `~/CI-CD-MIGRATION-GUIDE.md`
### Paperclip — מקומית דרך pm2
- פורט: `localhost:3100`, DB: `localhost:54329` (Postgres embedded)
- שינויי קוד נכנסים לתוקף אחרי `pm2 restart paperclip`
- **אין צורך ב-Docker או Coolify** (מיגרציה ל-Coolify נוסתה 2026-04-04 והוחזרה 2026-04-08)
- תרגום/RTL: `~/.paperclip/hebrew/``bash ~/.paperclip/hebrew/apply-hebrew.sh && pm2 restart paperclip`
### legal-chat-service — מקומית דרך pm2 (מאפריל 2026)
- פורט: `127.0.0.1:8770` (loopback בלבד)
- שירות aiohttp קצר שעוטף את `claude` CLI ב-streaming + session continuation, ומשרת את הטאב "שיחה" בדף `/training`. הקונטיינר משדל אליו proxy דרך `host.docker.internal:8770`.
- קוד: [`mcp-server/src/legal_mcp/chat_service/`](../mcp-server/src/legal_mcp/chat_service/)
- התקנה: `pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs && pm2 save`
- בריאות: `curl http://127.0.0.1:8770/health``{"ok":true,...}`
- שינויי קוד: `pm2 restart legal-chat-service`
- **אפס עלות API** — claude CLI משתמש ב-claude.ai subscription של chaim. הנחת היסוד של `claude_session.py` (claude CLI מקומי בלבד) נשמרת.
- Coolify dependency: ה-Service Definition של legal-ai חייב להכיל `extra_hosts: host.docker.internal:host-gateway` (אחרת ה-proxy יקבל ConnectError).
---
## מבנה תיקיות
```
/home/chaim/legal-ai/
├── CLAUDE.md ← אינדקס דק (כללים קריטיים + מצביעים)
├── docs/operations-runbook.md ← הקובץ הזה (עומק תפעולי)
├── Dockerfile ← Docker build
├── docs/ ← תיעוד + לקחים
│ ├── architecture.md ארכיטקטורה
│ ├── block-schema.md 12 בלוקים (המסמך החשוב ביותר)
│ ├── migration-plan.md תוכנית מעבר vault → DB
│ ├── legal-decision-lessons.md לקחים מ-3 החלטות
│ └── memory.md הקשר כללי — skills, פרויקטים
├── skills/ ← כלי עבודה ומדריכים
│ ├── decision/ מדריך סגנון + references + 12 בלוקים
│ ├── assistant/ קטלוג מסמכים
│ ├── docx/ עיצוב DOCX
│ ├── dafna-decision-template/ export DOCX לפי תבנית Word של דפנה
│ └── new-company-setup/ blueprint הוספת חברה חדשה
├── .claude/
│ └── agents/ ← הוראות סוכנים + HEARTBEAT.md (symlinks ב-Paperclip)
│ ├── HEARTBEAT.md checklist הפעלה משותף לכל הסוכנים
│ ├── legal-ceo.md תזמורן + בקרת זרימה
│ ├── legal-writer.md כתיבת בלוקים בסגנון דפנה
│ ├── legal-analyst.md ניתוח משפטי + חילוץ טענות
│ ├── legal-researcher.md חיפוש תקדימים
│ ├── legal-qa.md 7 שערי איכות
│ ├── legal-proofreader.md תיקון OCR
│ ├── legal-exporter.md ייצוא DOCX סופי
│ └── hermes-curator.md סוכן Hermes לניתוח סגנון post-export
├── data/
│ ├── training/ ← 4 החלטות לאימון (DOCX)
│ ├── exports/ ← טיוטות DOCX מיוצאות
│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB)
├── web/ ← FastAPI backend (Python): 75+ API endpoints
│ ├── app.py ← API ראשי
│ ├── paperclip_api.py ← אינטגרציית Paperclip: `pc_request()` + `emit_case_status_webhook()`
│ ├── paperclip_client.py ← legacy client (ישן — השתמש ב-paperclip_api.py)
│ └── gitea_client.py ← אינטגרציית Gitea
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
├── mcp-server/ ← MCP server + services + tools
├── adapters/ ← Paperclip external adapters
│ └── deepseek-paperclip-adapter/ ← `deepseek_local` (Hermes-pinned ל-DeepSeek profile)
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
```
---
## Paperclip — כללי אינטגרציה (פירוט מלא)
> הכללים הקריטיים בתמצית נמצאים ב-[`CLAUDE.md`](../CLAUDE.md). כאן הפירוט המלא, הדוגמאות, וה-"למה".
### Wakeup API — תמיד דרך API, לעולם לא דרך DB
- **הנתיב הנכון**: `POST /api/agents/{agent-id}/wakeup` (לא `/wake`!)
- **⚠️ אסור**: `INSERT INTO agent_wakeup_requests` ישירות — זה יוצר רק רשומה בלי `heartbeat_run`, והסוכן **לא יתעורר לעולם**
- **⚠️ חובה לשלוח `payload` עם `issueId`** — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי issue, בלי cwd נכון)
- דוגמה נכונה:
```json
{"source": "automation", "triggerDetail": "system", "reason": "...",
"payload": {"issueId": "...", "mutation": "comment", "commentId": "..."}}
```
- **Board API Key**: שמור ב-DB (`board_api_keys`), auth: `Authorization: Bearer pbk_...`
### ניתוב comments דרך CEO
- כשמשתמש כותב תגובה על issue ב-Paperclip, הפלאגין (`plugin-legal-ai`) מעיר את ה-CEO דרך `ctx.agents.invoke()`
- ה-CEO קורא את ה-comment, מחליט על ניתוב, ויוצר issue לסוכן המתאים
- כל הסוכנים חייבים לקרוא comments אחרונים לפני שהם מתחילים לעבוד (HEARTBEAT שלבים 2b-2c)
### קריאות API — תמיד דרך helper, לעולם לא `curl` ישיר
- **bash (סוכנים):** `~/legal-ai/scripts/pc.sh <METHOD> <PATH> [BODY_JSON]` — מוסיף Authorization, X-Paperclip-Run-Id, Content-Type, base URL. ראה `HEARTBEAT.md §0`.
- **Python (FastAPI):** `from web.paperclip_api import pc_request; await pc_request("POST", "/api/...", json={...})` — שימוש ב-board API key.
- **אסור** `curl ... $PAPERCLIP_API_URL` ישיר ב-bash; **אסור** `httpx.AsyncClient` ישיר ל-Paperclip ב-Python.
- **למה:** ה-skill הרשמי דורש `X-Paperclip-Run-Id` בכל קריאה משנה issue. אצלנו ה-audit trail עבד ממילא דרך JWT claims (`runId: runIdHeader || claims.run_id`), אבל ה-helper מבטיח עקביות + תאימות ל-board API keys (long-lived) שלא נושאות JWT claims.
### Cross-company agent sync — אחרי כל שינוי הגדרות
- יש 14 סוכנים = 7 × 2 חברות (CMP=1xxx, CMPA=8xxx). Paperclip מחייב `agents.company_id NOT NULL` — אין shared agents.
- **Master = CMP (1xxx)**, **Mirror = CMPA (8xxx)**.
- אחרי כל שינוי ב-`adapter_config`, `runtime_config`, `budget_monthly_cents`, או skills של סוכן ב-master (UI, SQL, או API), **חובה להריץ:**
```bash
PAPERCLIP_BOARD_API_KEY=$(...infisical...) \
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify # לבדיקה
PAPERCLIP_BOARD_API_KEY=$(...) \
python ~/legal-ai/scripts/sync_agents_across_companies.py --apply # לסנכרן
```
- הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent.
- שאלות ה-skill הרשמי של Paperclip — `paperclip` skill תחת `paperclipai/paperclip`.
### Webhook יוצא — עדכון סטטוס תיק לפלאגין
כשסטטוס תיק משתנה דרך `PUT /api/cases/{case_number}`, הבקאנד שולח webhook אסינכרוני לפלאגין:
```
PUT /api/cases/{case_number} → emit_case_status_webhook() [BackgroundTask]
→ POST /api/plugins/marcusgroup.legal-ai/webhooks/case-status
→ plugin-legal-ai/onWebhook()
→ comment בעברית על issue + CEO wakeup (כשסטטוס = qa_failed)
```
- הקוד ב-`web/paperclip_api.py` (`emit_case_status_webhook`), fire-and-forget, timeout 5s
- הפלאגין שומר idempotency key ב-state עם TTL 5 דקות למניעת spam על retry
- `GET /api/cases/stale?days=N` — תיקים שלא עודכנו N ימים; מוחרגים: `new`, `final`, `exported`
- `GET /api/chair-feedback/weekly-summary` — סיכום פידבק YU"R לשבוע האחרון
### Scheduled Jobs (plugin-legal-ai)
| Job | לוח זמנים | מה עושה |
|-----|-----------|---------|
| `stale-case-reminder` | יומי 08:00 | שולח comment אזהרה על תיקים תקועים >3 ימים |
| `weekly-feedback-analysis` | ראשון 19:00 | מעיר CEO לניתוח פידבק YU"R ועדכון `docs/legal-decision-lessons.md` |
| `sync-case-status` | כל 30 דק' | מסנכרן סטטוסי תיקים בין legal-ai ל-Paperclip |
CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **אין לו issueId, אל תנסה לפרסם comment או לסגור issue**.
### External adapters — `deepseek_local`
- מיקום ה-package: [`adapters/deepseek-paperclip-adapter/`](../adapters/deepseek-paperclip-adapter/) (לא ב-`node_modules`).
- רישום ב-Paperclip: רשומה ב-`~/.paperclip/adapter-plugins.json` (נטען אוטומטית ב-startup דרך `buildExternalAdapters`). אין צורך בעריכת `node_modules`.
- **מה ה-adapter עושה**: spawnל-`hermes chat` עם `HERMES_HOME=/home/chaim/.hermes/profiles/deepseek` כך שה-CLI טוען את `config.yaml` (`base_url=https://api.deepseek.com/v1`, `provider=custom`, `key_env=DEEPSEEK_API_KEY`) ואת `.env` (שמכיל את ה-key).
- **מודלים זמינים** (lookup ב-DeepSeek `/v1/models`): `deepseek-v4-pro` (default), `deepseek-v4-flash`. יופיעו כדרופ-דאון ב-UI.
- **התקנה מחדש / עדכון**: `curl -X POST -H "Authorization: Bearer pcapi_legal_install_key_2026" -H "Content-Type: application/json" -d '{"packageName":"/home/chaim/legal-ai/adapters/deepseek-paperclip-adapter","isLocalPath":true}' http://localhost:3100/api/adapters/install`. לעדכון hot — `POST /api/adapters/deepseek_local/reload`.
- **⚠ Cross-company sync**: `sync_agents_across_companies.py` **מדלג** על סוכנים עם `adapter_type` שונה בין CMP ל-CMPA. כשעוברים סוכן ל-`deepseek_local` חובה להחיל ידנית בשתי החברות לפני sync.
- **תוספת adapters עתידיים** (OpenAI ישיר, Anthropic ישיר, וכו'): אותו דפוס. ה-package הראשי חייב לייצא `createServerAdapter()` שמחזיר `{ type, label, models, agentConfigurationDoc, execute, testEnvironment, sessionCodec, listSkills, syncSkills, ... }`. ראה את [`adapters/deepseek-paperclip-adapter/dist/index.js`](../adapters/deepseek-paperclip-adapter/dist/index.js) כתבנית.
### External adapters — Hermes Curator (`curator-cmp` / `curator-cmpa`)
- פרופילי Hermes נפרדים לסוכן `hermes-curator` — מנתח החלטות סופיות ומציע עדכוני SKILL.md/lessons.md
- מיקום: `~/.hermes/profiles/curator-cmp/` + `~/.hermes/profiles/curator-cmpa/`
- מופעל אחרי export סופי; אינו מעדכן קבצים ישירות
- **תהליך אישור הצעות:** הצעות ה-curator מגיעות כ-comment ב-Paperclip → חיים בוחן ומאשר ידנית → commits ל-`SKILL.md` ו-`docs/legal-decision-lessons.md`
---
## הערות יו"ר (Chair Feedback)
מנגנון לתיעוד הערות דפנה על טיוטות:
- **DB**: טבלת `chair_feedback` (case_id, block_id, feedback_text, category, lesson_extracted)
- **API**: `GET/POST /api/feedback`, `PATCH /api/feedback/{id}/resolve`
- **MCP tools**: `record_chair_feedback`, `list_chair_feedback`
- **UI**: דף ניהול ב-`/feedback` (ב-Next.js)
- **קטגוריות**: missing_content, wrong_tone, wrong_structure, factual_error, style, other
---
## ניהול משימות — TaskMaster AI (פירוט)
- קובץ המשימות הקנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (יחסי ל-project root, **לא** `~/.taskmaster/tasks/tasks.json`). מכיל את כל ה-tags של legal-ai (`master`, `legal-ai`).
- פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`
- לפני התחלת עבודה → `next_task`; אחרי סיום → `update_task` עם status=done; משימה מורכבת → `expand_task`
- **⚠️ מלכוד cwd ב-CLI:** הדגל `--tag` בוחר קבוצה לוגית *בתוך* הקובץ — הוא **לא** בוחר לאיזה `tasks.json` לכתוב. ה-CLI מאתר את הקובץ לפי ה-cwd. תמיד `cd ~/legal-ai` לפני `task-master add-task` או כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את `~/legal-ai/.taskmaster/tasks/tasks.json` ישירות.

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

@@ -78,13 +78,14 @@
אלה החוקים החוצים את כל המערכת — לב החוקה. הם נחלקים לשני סוגים לפי **מקור-הסמכות**: אלה החוקים החוצים את כל המערכת — לב החוקה. הם נחלקים לשני סוגים לפי **מקור-הסמכות**:
- **G1G10 — invariants הנדסיים** (תכנון/בניית האפליקציה): כל אחד מגובה ב-**≥3 סמכויות - **G1G10, G12 — invariants הנדסיים** (תכנון/בניית האפליקציה): כל אחד מגובה ב-**≥3 סמכויות
טכניות מוכרות** (נספח §8). ביחד הם מייבשים את כשל-השורש החוזר: מסלולים/קורפוסים טכניות מוכרות** (נספח §8). ביחד הם מייבשים את כשל-השורש החוזר: מסלולים/קורפוסים
מקבילים שמתפצלים (drift) בלי שכבה שמגדירה ואוכפת "תקין". מקבילים שמתפצלים (drift) בלי שכבה שמגדירה ואוכפת "תקין". (G12 — שער-הפלטפורמה — מוסף
במחזור-3; ראה [X15](X15-agent-platform-port.md).)
- **G11 — invariant תוכן-משפטי:** הסמכות עליו היא **היו"ר (דפנה) + מסמכי-הפרויקט**, לא - **G11 — invariant תוכן-משפטי:** הסמכות עליו היא **היו"ר (דפנה) + מסמכי-הפרויקט**, לא
מקורות חיצוניים, ואינו כפוף לפרוטוקול ≥3-המקורות. מקורות חיצוניים, ואינו כפוף לפרוטוקול ≥3-המקורות.
### 5א. Invariants הנדסיים (G1G10) ### 5א. Invariants הנדסיים (G1G10, G12)
### INV-G1: מזהה קנוני מנורמל בכתיבה ### INV-G1: מזהה קנוני מנורמל בכתיבה
**כלל:** לכל ישות יש מזהה קנוני יחיד, **מנורמל בנקודת-הכתיבה** (לא תיקון-סלחני בקריאה **כלל:** לכל ישות יש מזהה קנוני יחיד, **מנורמל בנקודת-הכתיבה** (לא תיקון-סלחני בקריאה
@@ -196,6 +197,22 @@ Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוט
**הפרה ידועה:** 10/19 הלכות מאושרות, התגלה במקרה — שער ידני שקוף בלי נראות backlog → **הפרה ידועה:** 10/19 הלכות מאושרות, התגלה במקרה — שער ידני שקוף בלי נראות backlog →
ממצא ל-[audit](../audit-report.md). ממצא ל-[audit](../audit-report.md).
### INV-G12: שער-הפלטפורמה — Paperclip מאחורי Port יחיד
**כלל:** פלטפורמת-הסוכנים (Paperclip) נגישה אך-ורק דרך **ה-Platform Port**
(`web/agent_platform_port.py` + `.claude/agents/HEARTBEAT.md` לפרומפטים). שכבת-האינטליגנציה
`mcp-server/src` וה-skills של ההחלטה/הסגנון — מכילה **אפס** סמלים ספציפיים-לפלטפורמה
(שם-מוצר, wakeup/heartbeat, pc.sh/pc_request, X-Paperclip-Run-Id, enums של הפלטפורמה).
פרומפטי-הסוכנים אינם משכפלים את פרוטוקול-הריצה — הם מצביעים ל-HEARTBEAT.md בלבד. כל מגע
חדש עם הפלטפורמה עובר דרך ה-Port — כך המעטפת נשארת ניתנת-להחלפה בלי לגעת באינטליגנציה.
**מקורות:** Alistair Cockburn — *Hexagonal Architecture (Ports & Adapters)* · Robert C.
Martin — *Clean Architecture* (The Dependency Rule) · Eric Evans — *Domain-Driven Design*
(Anti-Corruption Layer) | סטטוס: verified
**אכיפה:** רשימת-ה-Port + leak-guard ב-[scripts/spec-guard.sh](../../scripts/spec-guard.sh)
(מול baseline) + fitness-test ב-CI על `mcp-server/src` + הצהרת-G12 בתבנית-ה-PR; מפורט ב-
[X15-agent-platform-port.md](X15-agent-platform-port.md).
**הפרה ידועה:** `web/app.py` קורא ל-`pc_*` inline בלוגיקת מחזור-חיים; 10 פרומפטי-סוכנים
משכפלים את פרוטוקול-הריצה במקום להצביע ל-HEARTBEAT (baseline ב-[X15](X15-agent-platform-port.md) §3 → R1R4).
### 5ב. Invariant תוכן-משפטי (G11) ### 5ב. Invariant תוכן-משפטי (G11)
### INV-G11: תוכן החלטה מנומקת ### INV-G11: תוכן החלטה מנומקת
@@ -227,11 +244,11 @@ Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוט
## 7. אינדקס הספ ## 7. אינדקס הספ
> הערה: כל קבצי הספ (00, 0107, X1X10) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה. > הערה: כל קבצי הספ (00, 0107, X1X16) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
| קובץ | תפקיד | אוכף invariants | | קובץ | תפקיד | אוכף invariants |
|------|--------|-----------------| |------|--------|-----------------|
| [00-constitution.md](00-constitution.md) | חוקה — ייעוד, invariants גלובליים, כללי-הנדסה, אינדקס | G1G11 | | [00-constitution.md](00-constitution.md) | חוקה — ייעוד, invariants גלובליים, כללי-הנדסה, אינדקס | G1G12 |
| [01-ingest.md](01-ingest.md) | קליטה מאוחדת: מסמכי-תיק / פסיקה חיצונית / החלטות-ועדה — חוזה מסלול-יחיד | G2, G3 | | [01-ingest.md](01-ingest.md) | קליטה מאוחדת: מסמכי-תיק / פסיקה חיצונית / החלטות-ועדה — חוזה מסלול-יחיד | G2, G3 |
| [02-data-model.md](02-data-model.md) | אחסון: ישויות (cases, case_law, documents, chunks, halachot…) + חוזה-שלמות לכל ישות | G1, G4, G6 | | [02-data-model.md](02-data-model.md) | אחסון: ישויות (cases, case_law, documents, chunks, halachot…) + חוזה-שלמות לכל ישות | G1, G4, G6 |
| [03-retrieval.md](03-retrieval.md) | 3 קורפוסים + כלי-חיפוש · hybrid/RRF · attribution · eval harness | G4, G5, G6, G7, G8, G9 | | [03-retrieval.md](03-retrieval.md) | 3 קורפוסים + כלי-חיפוש · hybrid/RRF · attribution · eval harness | G4, G5, G6, G7, G8, G9 |
@@ -250,6 +267,11 @@ 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 |
| [X12-digests-radar.md](X12-digests-radar.md) | יומונים כשכבת-גילוי (radar) — מקור-משני המצביע על הפסק המקורי · לא קורפוס-ציטוט רביעי · לא מצוטט/לא מחלץ-הלכות | G2, G4, G9 |
| [X13-court-fetch.md](X13-court-fetch.md) | אחזור-פסיקה אוטומטי מנט המשפט — 3 שכבות (עליון/מנהלי/skip) · שירות-מארח · reCAPTCHA · שער-אנושי | G2, G3, G4, G5, G9, G10 |
| [X14-storage-minio.md](X14-storage-minio.md) | אחסון-אובייקטים (MinIO/S3) · `storage.py` כמסלול-I/O יחיד · git=טקסט/MinIO=בינאריים · WORM סופי | G2, G9 |
| [X15-agent-platform-port.md](X15-agent-platform-port.md) | שער-הפלטפורמה — Paperclip מאחורי Port יחיד · baseline-דליפה · R0R4 · leak-guard | G2, G12 |
| [X16-pipeline-durability.md](X16-pipeline-durability.md) | עמידות-פייפליין — LangGraph כספרייה · checkpointing/replay · `_pipeline_runtime.py` משותף | G3 |
> **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

@@ -155,6 +155,14 @@ RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
**אכיפה:** CHECK על enums; FK על `cited_precedents`/`decision_paragraphs.citations`; איחוד `case_precedents``case_law`. **אכיפה:** CHECK על enums; FK על `cited_precedents`/`decision_paragraphs.citations`; איחוד `case_precedents``case_law`.
**הפרה ידועה:** 20+ enums כ-TEXT חופשי; `legal_arguments.cited_precedents TEXT[]` ללא-FK (הזיות-LLM נבלעות); `case_precedents` מול `case_law` מקבילות ([gap-audit GAP-40/42/43](gap-audit.md)). **הפרה ידועה:** 20+ enums כ-TEXT חופשי; `legal_arguments.cited_precedents TEXT[]` ללא-FK (הזיות-LLM נבלעות); `case_precedents` מול `case_law` מקבילות ([gap-audit GAP-40/42/43](gap-audit.md)).
### INV-DM7: סיווג-הלכה — סמכות (נגזרת) ⊥ תפקיד-כלל (מסווג). שני צירים, לא enum אחד
**כלל:** ל-`halachot` שני צירי-סיווג **אורתוגונליים** שאסור לערבב בשדה אחד:
- **סמכות (`authority`) — נגזרת בלבד, לא מאוחסנת, לא מנוחשת ע"י LLM.** `binding` (מקור מחייב את הוועדה: עליון/מנהלי) מול `persuasive` (מקור משכנע: ועדת-ערר אחרת). נגזרת דטרמיניסטית מ-`case_law.precedent_level` (`עליון`/`מנהלי`→binding; `ועדת_ערר_מחוזית`→persuasive). מקור-אמת יחיד — מחושבת בקריאה, אין עמודה כפולה ([G1](00-constitution.md#inv-g1-נרמול-במקור-לא-תיקון-בקריאה)/[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)).
- **תפקיד-כלל (`rule_type`/rule_role) — מסווג ע"י ה-LLM.** `holding` (עיקרון מהותי הכרחי להכרעה — ratio/Wambaugh) · `interpretive` (פרשנות חוק/מונח/תכנית) · `procedural` (סדר-דין: סמכות/מועדים/נטל) · `application` (החלה תלוית-עובדות — לרוב לא-הלכה) · `obiter` (אמרת-אגב). **`binding`/`persuasive` אינם ערכי תפקיד** — הם סמכות-מקור.
**הנדסי.** מופע של [G1](00-constitution.md#inv-g1-נרמול-במקור-לא-תיקון-בקריאה) (נרמול במקור: המחלץ מסווג תפקיד, לא ממציא סמכות נגזירה) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
**מקורות:** OASIS LegalRuleML v1.0 (`appliesAuthority`/`Strength` כ-metadata אורתוגונלי, נפרד מלוגיקת-הכלל) · SemEval-2023 Task 6 LegalEval (rhetorical-roles לפי תפקיד, סמכות נשמרת בנפרד) · Bluebook signals (משקל-סמכות = ציר נפרד מהפרופוזיציה) | סטטוס: verified (≥3 מקורות).
**ההפרה שתוקנה:** `halacha_extractor` סיווג `rule_type` לפי bindingness-של-המקור (`_coerce_halacha(is_binding)`, ברירת-מחדל `binding`/`persuasive`, guard binding→persuasive) — כלומר חישב **סמכות** במסווה של **תפקיד**. אומת אמפירית על מדגם-הזהב: `binding` שימש 19/19 פסקים חיצוניים ו-0 ועדות; `persuasive` 13/13 ועדות ו-0 חיצוניים → סיווג-לפי-מקור, התאמה לתיוג-אנושי 58% בלבד. התיקון מעביר סמכות לציר-נגזר ומשחרר את ה-LLM לסווג תפקיד נטו.
--- ---
## 4. מצב קיים מול יעד — audit-findings ## 4. מצב קיים מול יעד — audit-findings

View File

@@ -35,6 +35,13 @@
(`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**, (`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**,
ושם נולדת ההפרה ב-§5. ושם נולדת ההפרה ב-§5.
> **שכבת-גילוי — יומונים, לא קורפוס-ציטוט.** מעל 3 הקורפוסים יושבת שכבת-radar נפרדת: **יומונים**
> (סיכומי עפר-טויסטר), בטבלה פיזית נפרדת `digests` עם כלי `search_digests`. היומון הוא **מקור משני
> המצביע** על הפסק המקורי — **אינו** קורפוס-ציטוט רביעי, **אינו** עקיב-בפלט ([INV-RET5](#inv-ret5-כל-span-מוחזר-עקיב-למקורו)),
> ו**אינו** נוגע ב-`case_law`/`document_chunks`. ההפרדה כאן **פיזית** (טבלה נפרדת), לא תנאי-סינון —
> ולכן [INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query) מתקיים טריוויאלית. מלא ב-
> [X12-digests-radar.md](X12-digests-radar.md) (INV-DIG1DIG3).
--- ---
## 2. עיצוב ה-hybrid retrieval ## 2. עיצוב ה-hybrid retrieval
@@ -176,3 +183,4 @@ re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף
- [02-data-model.md](02-data-model.md) — חוזה-השלמות (searchable) + re-index שהאחזור מסנן לפיהם. - [02-data-model.md](02-data-model.md) — חוזה-השלמות (searchable) + re-index שהאחזור מסנן לפיהם.
- [05-qa-review.md](05-qa-review.md) — שער-הלכה הידני (`review_status`) שמגדיר אילו הלכות searchable. - [05-qa-review.md](05-qa-review.md) — שער-הלכה הידני (`review_status`) שמגדיר אילו הלכות searchable.
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור מלאה של כל span מוחזר (בסיס ל-INV-RET5). - [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור מלאה של כל span מוחזר (בסיס ל-INV-RET5).
- [X12-digests-radar.md](X12-digests-radar.md) — שכבת-הגילוי (יומונים) שמעל הקורפוסים — מצביעה, לא מצוטטת.

View File

@@ -52,6 +52,18 @@
**INV-LRN5 (טוהר-הקול → G4/G11):** שכבת-ידע-הקול (voice-fingerprint, style_patterns, exemplars) **לא תכיל הלכות/עובדות ספציפיות** — רק סגנון ושיטה. מהות מנותבת ל-precedent_library/halacha. ה-distillation מפריד במקור. **INV-LRN5 (טוהר-הקול → G4/G11):** שכבת-ידע-הקול (voice-fingerprint, style_patterns, exemplars) **לא תכיל הלכות/עובדות ספציפיות** — רק סגנון ושיטה. מהות מנותבת ל-precedent_library/halacha. ה-distillation מפריד במקור.
*מקורות:* quality-at-source (Data Mesh) · separation-of-concerns. *סטטוס: verified.* *מקורות:* quality-at-source (Data Mesh) · separation-of-concerns. *סטטוס: verified.*
### 0.6 מסלול-העלאת-סופי נקי + פאנלים אוטומטיים (מדורג)
היו"ר מעלה את **ההחלטה החתומה שלה** דרך מסלול ייעודי — `POST /api/cases/{case}/final/upload` (כפתור "העלאת החלטה סופית של היו"ר" בלשונית-הטיוטות). **נבדל** מ-`exports/upload` (גרסה-מתוקנת-שלנו+retrofit) ומ-`mark-final` (סימון export-שלנו), ולכן אינו מסלול-מקביל (G2) אלא יכולת חסרה.
הקליטה (סינכרונית ב-endpoint) מבצעת את **לולאת-צמיחת-הקורפוס** (§1.3) במלואה:
1. **קורפוס-הסגנון** (voice) תחת ה-`case_number` **המלא** (בל"מ≠ערר — מונע התנגשות-מספר) + פתיחת `draft_final_pairs` (`final_received`, INV-LRN4).
2. **ספריית-הפסיקה** — ההחלטה נכנסת ל-`case_law` כ-`internal_committee` **תמיד** (כדי שתהיה ברת-ציטוט בהחלטות עתידיות). `chair_name` נקבע **דטרמיניסטית** (תיק → ברירת-מחדל-ועדה, לעולם לא ריק — אילוץ `case_law_internal_chair_check`); לא נשען על חילוץ-LLM. מטה-דאטה נוסף (תאריך/צדדים) מועשר אסינכרונית ע"י מחלץ-Gemini.
3. **בדיקת-ציטוטים**`extract_internal_citations` מקשר את הפסיקה שההחלטה מצטטת לספרייה; כל ציטוט שאינו בספרייה **מסומן אוטומטית** כ-`missing_precedent` (open) להעלאה ע"י היו"ר.
4. הציטוטים-המקושרים מזינים את **לולאת-ה-corroboration** (X11): ציטוט-נכנס מההחלטה שלנו מחזק את ההלכות של התקדים המצוטט (`corroboration_rebuild`).
ואז שני שלבים אוטומטיים נפרדים (`run-learning` / `run-halacha`) המעירים worker מקומי (claude/DeepSeek/Gemini מקומיים בלבד):
- **למידה:** `ingest_final_version` (Opus distillation) → **פאנל-סגנון דו-סוכני** (DeepSeek+Gemini, "למידה כפולה") שמצביע על כל לקח-style_method; הסכמה 2/2 → `decision_lesson` (`source=panel:deepseek+gemini`); פיצול → ליו"ר.
- **הלכות:** `extract_internal_citations``precedent_extract_halachot``corroboration_rebuild`**פאנל-הלכות תלת-סוכני** (`halacha_panel_approve.py --apply`).
שני הפאנלים **הפיכים** (גיבוי-CSV ל-`data/audit/`) ומסלימים מחלוקות. ההטמעה הסופית ל-`SKILL.md`/`legal-decision-lessons.md` נשארת **אישור-יו"ר ידני** (INV-LRN1/G10) — הפאנל יוצר *הצעות* בלבד.
--- ---
## 1. שלוש לולאות-המשנה ## 1. שלוש לולאות-המשנה

View File

@@ -3,9 +3,11 @@
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md). זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר. כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
מבנה: 00 חוקה · 0107 מחזור-חיים · X1X10 חוצי-שלבים. ראה אינדקס מלא בחוקה. מבנה: 00 חוקה · 0107 מחזור-חיים · X1X16 חוצי-שלבים. ראה אינדקס מלא בחוקה.
- 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.
- X11X14 (הרחבות-תחום): citator פנימי (תיקוף-הלכות) · יומונים כשכבת-גילוי (radar) · אחזור-פסיקה אוטומטי מנט המשפט (שירות) · אחסון-אובייקטים (MinIO/S3, הגירת `data/`).
- X15X16 (ארכיטקטורת-יסוד): שער-הפלטפורמה (Paperclip מאחורי Port — G12, מיישם G2) · עמידות-פייפליין (LangGraph כספרייה — checkpointing/replay, מחזק G3).
מפות-ממצאים: [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,185 @@
# X12 — יומונים כשכבת-גילוי (Digests Radar)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md). הוא מגדיר **שכבת-גילוי (discovery/radar)**
מעל קורפוסי-הפסיקה: קליטה וחיפוש של **יומונים** — סיכומי-עמוד-אחד של משרד עפר טויסטר ("כל יום —
היומון לענייני תכנון ובנייה") על פסק-דין/החלטה בודדים. היומון הוא **מקור משני** המצביע על פסק-הדין
המקורי; הוא **אינו** נכנס לאף אחד מ-3 קורפוסי-הציטוט, **אינו** מצוטט בהחלטה, ו**אינו** מחלץ הלכות.
הוא נשען על [INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
(אין מסלול מקביל), [INV-G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)
(שלמות + אין בליעה שקטה) ו-[INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
(עקיבוּת-מקור), ומובחן מ-3 הקורפוסים של [03-retrieval.md](03-retrieval.md).
> **TARGET, לא תיאור-מצב.** התת-מערכת כולה היא יעד — אין כיום טבלת `digests`, כלי-`digest_*`,
> ולא אינטגרציית-חוקר. כל רכיב מסומן מפורשות כ-audit-finding לבנייה (§6). כל טענה על הקוד `file:line`.
---
## 1. הרעיון — radar, לא קורפוס-ציטוט
חיים מקבל כמעט יומית מייל עם **יומון**: PDF של עמוד אחד שמסכם פסק-דין/החלטה בודדים בתחום
רישוי-ובנייה / היטל-השבחה / פיצויים(ס'197). היומון אינו הטקסט המשפטי המקורי — הוא **ניתוח של צד
שלישי** (עפר טויסטר), הנושא הבהרה מודפסת: *"האמור הוא מידע ראשוני בלבד ואין הוא תחליף לייעוץ
משפטי"*. במונחי-מחקר-משפטי זהו **מקור משני (secondary authority)**: כלי-איתור והכוונה, לא סמכות
שמצטטים בהחלטה.
הערך שלו עצום דווקא כ-**radar**: כל יומון הוא *headnote + תג-נושא כתובים-מראש בידי מומחה*, המצביע
על פסק-דין מקורי. כשמנסחים החלטה, `search_digests` מחזיר את היומון הרלוונטי → החוקר קורא את ניתוח
טויסטר **כרקע** → מחלץ את מראה-המקום של פסק-הדין המקורי → מביא את הפסק עצמו לקורפוס-הפסיקה הקיים
(הזמינות גבוהה) → ומצטט **משם**. היומון מצביע; הציטוט תמיד נשען על המקור.
---
## 2. מה היומון מכיל
מבנה קבוע (אומת מול הקבצים ב-`data/precedents/incoming/`, יומון 5158/5159/5160/5163):
| רכיב | דוגמה | תפקיד |
|------|-------|-------|
| מספר-יומון + תאריך-גיליון | `יומון מס' 5163 7 ביוני 2026` | מפתח-upsert + `digest_date` |
| תג-מושג | `"שיקול הדעת המצומצם"` | ציר-נושא לחיפוש |
| כותרת-הלכה | `ביהמ"ש - שיקול דעת הוועדה המחוזית אינו מצומצם...` | הסיכום בשורה |
| גוף-ניתוח (12 עמ') | ניתוח עפר-טויסטר | רקע + מקור-embedding |
| מראה-מקום בתחתית | `עת"מ 46111-12-22 יכין-אפק... ניתן 3.6.26... שופטת: יעל טויסטר ישראלי` | **השדה הקריטי** — הגשר לפסק המקורי |
`underlying_date` (מתן הפסק) שונה מ-`digest_date` (גיליון היומון) — מקור-באגים נפוץ; חילוץ-המטא-דאטה
מבחין ביניהם מפורשות.
**`digest_kind` (סיווג-גיליון, V32):** רוב הגיליונות הם `decision` (סיכום פס"ד → `underlying_citation`),
אך חלקם `announcement` — עדכון/הודעה ללא הכרעה (חקיקה, נוהל, ברכת-שנה) שאין לו מראה-מקום. החילוץ
מסווג כל גיליון ותמיד מחלץ `concept_tag`/`headline`/`summary` (קיימים לכל סוג); `underlying_citation`
רק ל-`decision`. **שימוש קריטי:** הגדרת-"כשל" של ה-drain self-heal היא `completed` **עם
`digest_kind=''`** (מעולם לא סווג) — כך הודעה (kind=`announcement`, בלי citation) **אינה** נחשבת כשל
ואינה מנוסה-מחדש לנצח. ההיוריסטיקה הישנה ("שני השדות ריקים") טיפלה בהודעות בטעות כ-retry אינסופי.
### 2.1 מקור שני ל-radar — העלון החודשי "עו"ד על נדל"ן"
פרסום **נפרד** מהיומון היומי: עלון חודשי ממוספר (משרדי צבי שוב + רונית אלפר), **רב-נושאי** — מאמר-עומק,
עדכוני-חקיקה, וסט מצביעי-פסיקה מקובצים לפי נושא. נקלט **לאותה טבלת `digests`** (לא קורפוס מקביל — G2),
מובחן ע"י `publication='עו"ד על נדל"ן'` (מול `'כל יום'`). עלון אחד **מתפצל ל-N שורות** דרך
`bulletin_splitter` (LLM, local-only) → `bulletin_library.ingest_bulletin`:
- **מצביעי-פסיקה** → `digest_kind='decision'` — מצטרפים ל-radar ומקושרים לפסק (autolink + X13 כמו היומון).
- **מאמרים** → `digest_kind='article'` — טקסט-מלא + embedding לחיפוש-עומק; **רקע בלבד, INV-DIG1 חל** (לא מצוטט).
- **עדכוני-חקיקה — לא נקלטים** (החלטת יו"ר).
מפתח-הדדאפ לפריט-עלון הוא **`content_hash` (per-פריט)**, כי `yomon_number` ריק (ה-upsert על yomon-number
לא חל; `uq_digests_content_hash` תופס re-runs). אידמפוטנטי. סקריפט: `scripts/ingest_bulletins.py`.
---
## 3. למה זה לא קורפוס-ציטוט רביעי (הקושיה המרכזית — G2)
[03-retrieval.md §1](03-retrieval.md#1-שלושת-הקורפוסים-וכלי-החיפוש) מגדיר 3 **קורפוסי-ציטוט**:
מסמכי-תיק+סגנון-דפנה, פסיקה-חיצונית, החלטות-ועדה. השאלה: האם יומונים = רביעי, ובכך הפרת
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)?
**לא — בתנאי המסגור הנכון.** G2 אוסר *מסלול מקביל ליכולת קיימת*. יומונים אינם עוד-מסלול-לאחזור-
פסיקה אלא **bounded context נפרד**: ישות נפרדת (`digests`, לא `case_law`), מטרה נפרדת (הצבעה ולא
ציטוט), וחוזה נפרד. ההבחנה הקנונית: 3 הקורפוסים הם **עקיבים-בפלט** (כל ציטוט בהחלטה חוזר אליהם —
[INV-RET5](03-retrieval.md#inv-ret5-כל-span-מוחזר-עקיב-למקורו)/[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)).
היומון **לעולם אינו עקיב-אליו בפלט** (INV-DIG1) — ולכן אינו קורפוס-ציטוט רביעי, אלא שכבה
**מקדימה** לקורפוסים. הפרדת-הקורפוס מ-[INV-RET1](03-retrieval.md#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)
מתקיימת אוטומטית: `search_digests` שואל **רק** את `digests`, ואף כלי-חיפוש-פסיקה אינו נוגע בה
(הפרדה פיזית בטבלה, לא תנאי-סינון).
---
## 4. המנגנון (TARGET)
```
קליטה (מסלול קצר עצמאי — INV-DIG2):
יומון PDF → extract_text → content_hash (idempotent, INV-G3)
→ חילוץ-LLM: תג-מושג / כותרת-הלכה / תקציר / מראה-מקום / שני-תאריכים / תחום / תגיות
→ INSERT digests → embedding יחיד (תג+כותרת+תקציר+ניתוח) לחיפוש סמנטי בלבד
→ try_autolink(underlying_citation → case_law) [INV-DIG3]
⚠ ללא precedent_chunks, ללא halacha-extraction, ללא precedent metadata-extractor.
חיפוש + שימוש (radar — INV-DIG1):
legal-researcher: search_digests(סוגיה)
→ קורא ניתוח טויסטר + כותרת-הלכה = רקע/orientation בלבד
→ מחלץ את מראה-המקום של הפסק המקורי
→ הפסק בקורפוס? כן → אמת+צטט כרגיל (precedent_attach) + digest_link
לא → missing_precedent_create על *הפסק המקורי*
(notes="זוהה דרך יומון מס' NNNN") [INV-DIG3]
→ היומון לעולם אינו נרשם דרך precedent_attach ואינו supporting_quote. [INV-DIG1]
```
---
## 5. Invariants של התחום
### INV-DIG1: היומון מצביע, לא מצוטט
**כלל:** רשומת-`digest` לעולם אינה משמשת כ-`supporting_quote`/provenance בפלט-החלטה; כל ציטוט
בהחלטה נגזר מקורפוס-ציטוט (`case_law`/`document_chunks`). היומון הוא מקור משני — כלי-איתור,
לא סמכות-מצוטטת. החוקר רושם אותו כ-radar (סעיף-דוח נפרד), לא דרך `precedent_attach`.
**מקור-סמכות:** היו"ר + ההבהרה המודפסת ביומון ("מידע ראשוני בלבד... אינו תחליף לייעוץ משפטי") —
invariant תוכן-משפטי/תפעולי, **קשור** ל-[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
**מקורות (פתוחים, להבחנת מקור-ראשוני↔משני):** Georgetown Law Library — *Secondary Sources research
guide* (*"secondary sources... are not the law"*) · Amy E. Sloan, *Basic Legal Research: Tools and
Strategies* — primary vs. persuasive/secondary authority · *The Bluebook: A Uniform System of
Citation* — סיווג סמכות-ראשונית מול משנית | סטטוס: verified
**אכיפה:** היעדר FK מ-`decision_blocks`/ציטוטים ל-`digests`; ולידציית-QA ([05-qa-review.md](05-qa-review.md))
שדוחה ציטוט שמקורו digest; הוראת-חוקר מפורשת ([X4-agents.md](X4-agents.md), `legal-researcher.md`).
**הפרה ידועה:** — (תת-מערכת חדשה)
### INV-DIG2: מסלול-קליטה נפרד-בכוונה — לא מסלול-פסיקה מקביל
**כלל:** קליטת-יומון היא **bounded context נפרד**, ואינה עוברת ב-precedent pipeline
([01-ingest.md](01-ingest.md)): אין `precedent_chunks`, אין halacha-extraction, אין
precedent-metadata-extractor. מסלול קצר עצמאי (`digest_library.ingest_digest`) הבונה
embedding-יחיד לחיפוש סמנטי בלבד. הצהרה זו היא מה ש**מונע** הפרת-G2 — היומון אינו ישות-אחות
של `case_law` ואינו מתפצל ממסלולו.
**מקורות:** Eric Evans, *Domain-Driven Design* (2003) — Bounded Context (הקשרים שונים = מודלים
מובחנים) · Martin Kleppmann, *DDIA* (2017) — system-of-record מובחן מ-derived/index data · Martin
Fowler — Bounded Context / Canonical Data Model | סטטוס: verified
**אכיפה:** טבלה פיזית נפרדת `digests`; `ingest_digest` עושה reuse לשירותים אטומיים בלבד
(`extractor.extract_text`, `embeddings.embed_texts`) ולא ל-`ingest.ingest_document`; ביקורת-
ארכיטקטורה. אוכף את [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
+ כלל-הנדסה "סימטריה" (§6). **מקור-אמת יחיד:** מצב-הקליטה נשמר אך-ורק בטבלת `digests` (סטטוס +
`content_hash` ל-idempotency); תיקיות-קבצים (`incoming/`) הן staging בלבד, **לא** state.
**הפרה ידועה (תוקנה 2026-06-07):** `ingest_digests_batch.py` העביר קבצים ל-`data/digests/processed/`
— state מבוסס-תיקיות מקביל ל-DB. הוסר; הסקריפט מסתמך על dedup ב-content_hash (G2).
### INV-DIG3: קישור-לפסק-המקורי הוא הגשר — חוסר-קישור הוא פער גלוי
**כלל:** לכל `digest` שדה `linked_case_law_id` (FK ל-`case_law`, nullable). כשהפסק המקורי בקורפוס —
היומון מקושר אליו (אוטומטית בקליטה לפי מראה-המקום, או ידנית ב-`digest_link`). כל עוד אינו בקורפוס,
הקישור ריק ו**הפער מוצף** דרך `missing_precedent_create` על הפסק המקורי — לא נבלע בשקט.
**מקורות:** E.F. Codd — referential integrity (foreign keys, CACM 13(6), 1970) · ISO 8000 —
completeness (פער-ידע מתועד) · DAMA-DMBOK2 — data linkage / lineage | סטטוס: verified
**אכיפה:** שדה-FK `digests.linked_case_law_id` + `try_autolink` בקליטה + כלי `digest_link`/
`digest_relink`; חוסר-קישור → `missing_precedent_create` (כלל-הנדסה "אין בליעה שקטה", §6). אוכף את
[G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) +
[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
**הפרה ידועה:** — (תת-מערכת חדשה)
---
## 6. מצב קיים מול יעד — audit-findings
התת-מערכת כולה TARGET; אין כיום מימוש. רכיבים לבנייה:
- **טבלת `digests` + פונקציות-DB** — לא קיימות. יעד: `SCHEMA_V30` ב-`db.py` (טבלה + ivfflat/GIN/FTS
אינדקסים + UNIQUE חלקי על `yomon_number`/`content_hash` ל-idempotent) + `create_digest`/`search_digests`/
`link_digest_to_case_law` (§4, INV-DIG2/DIG3).
- **שירות + חילוץ-LLM** — `services/digest_library.py` + `services/digest_metadata_extractor.py`
לא קיימים. החילוץ נשען על `claude_session` (local-only — ייבוא lazy בתוך `ingest_digest` בלבד,
לא רץ בקונטיינר; תואם [claude_session local-only]).
- **כלי-MCP `digest_*`** — לא קיימים. יעד: `tools/digests.py` + רישום ב-`server.py`, מעטפת-envelope
אחידה לפי [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) (`search_digests` מובחן בשם מ-6 כלי-
החיפוש הקיימים — INV-TOOL2).
- **אינטגרציית-חוקר** — `legal-researcher.md` ללא `search_digests`/`digest_link` ב-`tools:` וללא שלב-
radar. יעד: שלב סריקת-יומונים לפני האימות + סעיף-דוח נפרד "radar — לא ציטוט" (INV-DIG1).
- **UI** — אין דף `/digests`. יעד: דף נפרד (לא כרטיסייה ב-`/precedents`, לשמור גבול סמכותי/משני),
אחרי `npm run api:types` ([X6-ui-api-contract.md](X6-ui-api-contract.md)).
- **אוטומציית-קליטה (Gmail) + עלון-חודשי רב-נושאי** — שלב עתידי; שלב-1 ידני (drop ל-
`data/digests/incoming/``scripts/ingest_digests_batch.py`).
---
## 7. הפניות-אחיות
- [00-constitution.md](00-constitution.md) — G2 (אין מסלול מקביל), G4 (שלמות/אין-בליעה), G9 (עקיבוּת).
- [03-retrieval.md](03-retrieval.md) — 3 קורפוסי-הציטוט שהיומון מובחן מהם (§3); הפרדת-קורפוס.
- [01-ingest.md](01-ingest.md) — צינור-הפסיקה הקנוני שהיומון **אינו** עובר בו (INV-DIG2).
- [02-data-model.md](02-data-model.md) — `case_law` (יעד-הקישור של `linked_case_law_id`).
- [05-qa-review.md](05-qa-review.md) — שער-QA שדוחה ציטוט שמקורו digest (INV-DIG1).
- [X4-agents.md](X4-agents.md) — סוכן החוקר שצורך את ה-radar.
- [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) — חוזה כלי-ה-`digest_*`.

View File

@@ -0,0 +1,180 @@
# 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`. כך הופך פסק-דין מ"מצוטט-בלבד" ל"שמיש לחיפוש
וחילוץ-הלכות".
**הבחנת-מקור קריטית:** רק **פסקי-דין של בתי-משפט** ניתנים לאחזור ציבורי. **החלטות ועדת-ערר**
אינן זמינות ציבורית (נדרש נבו) — מסומנות כפער ולא נשלחות לאחזור.
**דרכי-מקור ציבוריות (ניתוב לפי זמינות-פורמט-נט, לא לפי ערכאה):**
- **נט המשפט** (מציג-התיקים) משרת **כל הערכאות** — מחוזי/שלום *וגם עליון* — כל עוד יש מספר
בפורמט תיק-חודש-שנה. ASP.NET WebForms (`__doPostBack`/VIEWSTATE), anti-bot של F5, מסמכים
בצופה-עמודים (turn.js). מחייב **דפדפן-אמת** (host-side) → שירות-מארח ב-pm2 (כדפוס
`legal-chat-service`). **זהו המסלול הראשי המאומת.**
- **עליון בפורמט-סדרתי** (עע"מ/בג"ץ NNNN/YY, ללא חודש — לא ניתן לחיפוש בנט) → `supremedecisions.court.gov.il`
(httpx, ללא CAPTCHA, ללא דפדפן). **פוענח ואומת (2026-06-08):** `POST Home/SearchVerdicts` עם
`document` מובנה (`{Year:"YYYY", CaseNum, OldMainNumFormat:true, SearchText:[…]}`) + כותרת
**`X-Requested-With: XMLHttpRequest`** → רשומות; `GET Home/Download?path=&fileName=&type=4` → PDF.
בוחר מסמך best-first (פסק-דין→מספר-עמודים) ומדלג על מסמכי published-report החסומים (`s`-prefix).
תיקים ישנים-מאוד שלא דיגיטצו (למשל 389/87) → `manual`.
> **אומת end-to-end (2026-06-07) על עת"מ 46111-12-22** — פס"ד 34 עמ' הורד **אוטונומית מלא,
> נטו קוד-פתוח, ללא כרטיס-חכם וללא פתרון-CAPTCHA**. ממצאי-המפתח מהכיול:
> - **החיפוש והניווט לתיק — ללא reCAPTCHA כלל.** מסלול: דף-בית → `btnExternalSearchCases`
> → מילוי `BamaCaseNumberTextBoxH`(=מס' תיק) + `BamaMonthYearTextBoxHT`(="MM-YY") →
> `CaseDetails.aspx` → לשונית "פסקי דין" → `DecisionList.aspx` → צופה `NGCSViewerPage.aspx`.
> - **reCAPTCHA קיים רק בצופה ורק על שמירה/הדפסה מפורשת** — *לא* על הצגת המסמך. הצופה
> מגיש את העמודים כ-PNG דרך PageMethod **`GetImages`** (4 עמ'/batch) **ללא CAPTCHA**.
> אחזור = לכידת `documentNumber` מהקריאה הראשונה + משיכת כל ה-batches ב-`fetch` עם הכותרת
> **`X-Requested-With: XMLHttpRequest`** (חובה — ה-WAF חוסם AJAX בלעדיה) → הרכבת PDF (Pillow).
> - דפדפן: **Camoufox דרך חבילת-הפייתון** (`camoufox.async_api`, in-process — לא שרת-Node).
> על שרת ללא-מסך נדרש **Xvfb** (אחרת Firefox קורס). פותר-ה-reCAPTCHA האודיו (Whisper) נשמר
> כ-fallback למסלול-השמירה-המפורש בלבד; מסלול-התמונות אינו זקוק לו.
---
## 1. ארכיטקטורה — שלוש שכבות (tiered)
```
underlying_citation → [classifier] → {tier, האם יש פורמט-נט (תיק-חודש-שנה)}
skip(ערר/בל"מ) → missing_precedent (נבו ידני) — לא אחזור
── ניתוב לפי זמינות-פורמט-נט, לא לפי קידומת (נט המשפט משרת כל הערכאות) ──
פורמט-נט קיים (עמ"נ/עת"מ/עליון-בפורמט-נט כמו בר"מ 72182-06-25)
→ Tier 1: legal-court-fetch-service (host/pm2 + Xvfb) — אוטונומי, מאומת
→ Camoufox(python) → external-search → CaseDetails → פסקי דין
→ NGCSViewerPage → GetImages(X-Requested-With) → PNGs → PDF
עליון סדרתי-בלבד (בג"ץ/בר"מ NNNN/YY, בלי חודש)
→ Tier 0: httpx → supremedecisions (SearchVerdicts+Download) — מפוענח ומאומת
כשל אוטונומי → Tier 2: missing_precedent + התראה (VNC עתידי) — שער-אנושי
(כל ה-tiers) → precedent_library_upload(source_type=court_ruling) → ingest_precedent
→ chunks+embeddings+halachot(pending) → relink digest / close gap
```
מצב-העבודה מנוהל בטבלת-תור `court_fetch_jobs` (idempotent, נצפה, retryable). הניקוז
האוטומטי: `legal-court-fetch-drain` (pm2 cron שעתי) → `orchestrator.drain_pending`.
---
## 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` בשקט~~**תוקן**: `try_autolink` שנכשל על ציטוט פס"ד-בימ"ש מזניק job ל-`court_fetch_jobs` (status=pending); `court_fetch_drain` מנקז (סדרתי) ומקשר את היומון חזרה בהצלחה.
### 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` / `court_fetch_status` / `court_fetch_drain`) | חוזה-envelope [X9](X9-mcp-tool-contract.md) |
| טריגר אוטומטי | `services/digest_library.py` (`try_autolink` fail → `_enqueue_court_fetch`) → drain ע"י `orchestrator.drain_pending` | 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 + שיקול-יו"ר.
- ~~**Tier-0 (supremedecisions) טרם מפוענח**~~ → **פוענח ומאומת (2026-06-08)** — עליון בפורמט-סדרתי
(בג"ץ/בר"מ NNNN/YY) יורד אוטומטית דרך `Home/SearchVerdicts`+`Home/Download`. מגבלה שנותרה: תיקים
ישנים-מאוד שלא דיגיטצו בפורטל (0 רשומות) → `manual`. גם `backfill_missing_precedents.py` מזין את
ה-`missing_precedents` הפתוחים (עליון+נט-format) לתור-האחזור.
- **דליפת-זיכרון מדפדפנים יתומים** (fetch שנתקע/נהרג משאיר `camoufox-bin`) → שלוש שכבות-הגנה:
(א) `async with` סוגר את הדפדפן בכל exception; (ב) `asyncio.wait_for` קשיח (`COURT_FETCH_HARD_TIMEOUT_S`, ברירת-מחדל 180ש') מבטל hang + reap; (ג) reaper של `camoufox-bin` יתומים (`ppid=1`) לפני/אחרי כל fetch + דמון `legal-reaper` (pm2) + תקרת `max_memory_restart`. סדרתיות (INV-CF4) מבטיחה שכל דפדפן `ppid=1` הוא שארית בטוחה-להריגה. **הערה:** הדליפה הגדולה בפועל בשרת היא `task-master-mcp` (כלי נפרד), שגם אותו ה-reaper מנקה.

View File

@@ -0,0 +1,146 @@
# X14 — אחסון-אובייקטים (Object Storage: MinIO / S3)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **אחסון קבצים בינאריים**
מסמכי-מקור, נגזרים, וייצוא — והגירתם ממערכת-קבצים מקומית (`data/`) ל-**MinIO** (object store תואם-S3).
הוא מגדיר את חוזה-האחסון (שכבה יחידה), סכמת-הדליות-והמפתחות, מודל-האי-שינויוּת המשפטי, ותוכנית-ההגירה.
> **invariant הנדסי + תפעולי-משפטי.** INV-STG1/2/5/6 נשענים על עקרונות מוכרים (S3 API, 12-Factor, presigned-URL,
> separation blob↔metadata) — ≥3 מקורות (docs.min.io, AWS S3 spec, minio-py). INV-STG3/4/7 הם תפעוליים/משפטיים
> של *מערכת זו* (גבול-ממשל, WORM להחלטות חתומות, git=טקסט) ונקשרים ל-[G2](00-constitution.md) (מסלול-אחסון יחיד).
---
## 1. מצב קיים (מאומת מול הקוד וה-infra, 2026-06-08)
### 1.1 מלאי-הדיסק (`data/`, ללא `backups/`)
| קטגוריה | נפח | תוכן | סוג |
|---|---|---|---|
| `data/cases/{case}/` | 1.2GB | `documents/{originals,extracted,proofread,research,backup}`, `drafts/`, `exports/`, `thumbnails/{doc_uuid}/pNNN.jpg`, `.git` per-case | מקור + נגזר |
| `data/digests/{reference,incoming}/` | 251MB | יומונים (X12) | מקור |
| `data/training/{cmp,cmpa}/{raw,proofread}/` | 157MB | קורפוס-קול + `.git` | מקור |
| `data/precedent-library/{appeals_committee,court_ruling,other}/` | 105MB | פסיקה + `thumbnails/` | מקור |
| `data/internal-decisions/{region}/` | 45MB | החלטות-פנים לפי מחוז | מקור |
| `data/exports/` | 216KB | legacy (הוחלף ב-per-case) | נגזר |
| `data/{audit,eval,logs}/` | ~52MB | CSV/JSON תפעוליים — **לא מסמכים, נשארים בדיסק** | תפעולי |
ספירה (ללא backups): ~9,449 קבצים — 2,473 JPG (thumbnails נגזרים), 883 PDF, 250 TXT (extracted), 155 DOCX, 54 DOC.
### 1.2 הקונטיינר (Coolify)
legal-ai (`gyjo0mtw2c42ej3xxvbz8zio`) רץ עם **bind-mounts**: host `data/``/data`, host `data/cases/``/cases`.
האחסון היום = תיקייה על המארח, חשופה ישירות.
### 1.3 MinIO — **כבר פרוס ובריא** ✅ (שירות Coolify `minio`, `bx2ykvw94xbutsex41hz4vv8`, 2026-06-08)
- **API:** `https://s3.nautilus.marcusgroup.org` (9000) · **Console:** `https://minio.nautilus.marcusgroup.org` (9001)
- **Credentials:** `SERVICE_USER_MINIO` / `SERVICE_PASSWORD_MINIO` (סודות מנוהלי-Coolify)
- **אחסון:** named-volume `minio-data``/data`**Single-Node Single-Drive**; versioning/object-lock **לא** מופעלים עדיין
- **רשת:** רשת-Docker משלו (`bx2ykvw...`, external), **לא** משותפת ל-legal-ai → דרושה קישוריות (§4 שלב 0)
### 1.4 הקוד — **אין שכבת-אחסון מרכזית** (כשל-השורש שהתחום מייבש)
ה-I/O מפוזר על ~8 שירותים, נתיבים נבנים inline:
- העלאה: `tools/documents.py:54` (originals), `:152` (training)
- חילוץ + thumbnails: `services/processor.py:43,153`
- staging פסיקה/יומונים/החלטות: `services/ingest.py:69`
- ייצוא DOCX: `services/docx_exporter.py:462`
- הגשה (FileResponse): `web/app.py` — 6 endpoints
- git per-case: `services/git_sync.py` (`git add .` + push ל-Gitea, sweep כל 30ש׳)
### 1.5 עמודות-DB המאחסנות נתיבים (schema inline ב-`db.py`, ללא migrations)
`documents.file_path` · `cases.active_draft_path` · `case_law.source_document_path` · `digests.source_document_path`
· `document_image_pages.image_thumbnail_path` · `precedent_image_pages.image_thumbnail_path` · `draft_final_pairs.final_path`
### 1.6 Paperclip — צרכן-API בלבד
הפלאגין ניגש דרך `listDocuments`/`getDocumentText` ל-API (`plugin-legal-ai/src/legal-api.ts:89`). אינו נוגע בדיסק →
**הגירה שקופה אליו** כל עוד ה-API יציב.
---
## 2. Invariants של התחום
### INV-STG1: שכבת-אחסון יחידה — כל I/O דרך `storage.py`
**כלל:** קיים מודול-אחסון **יחיד** (`services/storage.py`) שכל קריאה/כתיבה של קובץ בינארי עוברת דרכו
(`put/get/presign_get/presign_put/delete/list`). אסור `open()`/`shutil.copy()`/`Path.write_bytes()` ישיר על
נתיב-אחסון מחוץ למודול. **מקיים [G2](00-constitution.md)** — מבטל את ה-I/O המפוזר (§1.4) שהוא מסלול-מקביל-מתפצל.
### INV-STG2: מפתח-אובייקט אטומי; שם עברי במטא בלבד
**כלל:** מפתח-האובייקט הוא ASCII/UUID (`cases/{case}/originals/{uuid}.pdf`). שם-הקובץ העברי המקורי נשמר ב-DB
(`*_filename`) וכ-`x-amz-meta-filename` + מוגש דרך `Content-Disposition` ב-presigned-GET. **למה:** תקציב-מפתח
1024 bytes (255/segment), עברית=2B/תו, ובעיות percent-encoding/XML — נמנעות.
### INV-STG3: דליות לפי גבול-ממשל, prefix לפי קטגוריה/תיק
**כלל:** versioning/object-lock/replication הם per-bucket → מה שדורש ממשל שונה יושב בדלי נפרד. שלוש דליות
קבועות (§3.1); תיקים/קטגוריות הם prefixes, **לא** דלי-לכל-תיק.
### INV-STG4: "סופי" = WORM (Object-Lock COMPLIANCE)
**כלל:** החלטה חתומה/סופית נכתבת לדלי `legal-immutable` עם Object-Lock **COMPLIANCE** + versioning — בלתי-ניתנת
לשינוי/מחיקה ע"י איש (כולל root) עד תום-תקופת-השמירה. טיוטות חיות בדלי רגיל ו"מקודמות" (copy) לדלי-הסגור עם החתימה.
**(הכרעת-יו"ר 2026-06-08: סופי בלבד; מסמכי-מקור — versioning ללא נעילה קשיחה.)**
### INV-STG5: pgvector נשאר מקור-האמת לטקסט/embeddings; MinIO = blob בלבד
**כלל:** טקסט-מחולץ + embeddings נשארים ב-Postgres/pgvector (מקור-אמת לאחזור). MinIO מאחסן את ה-blob המקורי
(+עותק-ארכיון אופציונלי של ה-extracted text). **אסור** ש-MinIO יהיה מקור-אמת לוקטורים. תואם
`no-reocr-retrofit` — לא מריצים OCR מחדש בהגירה.
### INV-STG6: הגשה לדפדפן דרך presigned-URL — bytes לא דרך FastAPI
**כלל:** הורדה/תצוגה/העלאה מהדפדפן עוברות ב-presigned-URL (TTL דקות) מול `s3.nautilus.marcusgroup.org`.
ה-backend מנפיק את ה-URL בלבד; ה-bytes לא עוברים דרכו. endpoints קיימים שמחזירים FileResponse → 302→presigned.
### INV-STG7: git-per-case שומר טקסט/מטא בלבד; בינאריים ב-MinIO
**כלל:** `.git` per-case ממשיך לגרסן `case.json`/`notes.md`/`documents/extracted/*.txt`/`research/*.md`. PDF/DOCX/JPG
מוחרגים מ-tracking (`.gitignore` per-case) ויושבים ב-MinIO. **(הכרעת-יו"ר 2026-06-08.)** `git_sync.py` ו-sweep
מסתמכים על אותו working-tree → ההחרגה חייבת לקדום לכל קומיט-הגירה כדי לא לשבור היסטוריה.
---
## 3. ארכיטקטורת-היעד
### 3.1 דליות ומפתחות
| דלי | Versioning | Object-Lock | prefixes |
|---|---|---|---|
| `legal-documents` | ✅ | ❌ | `cases/{case}/originals/{uuid}.pdf` · `cases/{case}/proofread/{uuid}.txt` · `precedent-library/{type}/{uuid}.pdf` · `internal-decisions/{region}/{uuid}.pdf` · `digests/{uuid}.pdf` · `training/{cmp\|cmpa}/{raw\|proofread}/{uuid}.pdf` |
| `legal-immutable` | ✅ | ✅ COMPLIANCE | `decisions-final/{case}/{uuid}.docx` (החלטות חתומות בלבד) |
| `legal-derived` | ❌ | ❌ (+lifecycle) | `thumbnails/{doc_uuid}/pNNN.jpg` · `extracted/{uuid}.txt` (נגזר, ניתן-לשחזור) |
### 3.2 `services/storage.py` (לב ההגירה) — adapter כפול
```
put(category, key, data, content_type, meta) -> uri # category→bucket+prefix
get(uri) -> bytes
presign_get(key, ttl) / presign_put(key, ttl) -> url
delete(key) / list(prefix)
```
backend נבחר ב-env `STORAGE_BACKEND ∈ {filesystem, dual, s3}` (ברירת-מחדל filesystem) — מאפשר מעבר הדרגתי ללא
שינוי-התנהגות. SDK: `aioboto3` (async-native מול `endpoint_url=http://minio:9000`); `minio-py` לסקריפטי-הגירה.
### 3.3 שינויי-DB
הוספת `*_object_key` (או נרמול ל-`storage_uri` עם סכמה `s3://`/`file://`) לצד העמודות הקיימות (§1.5); backfill;
דה-קומיישן הנתיב-קובץ. תוספת inline ב-`db.py` בסגנון הקיים (אין migrations).
---
## 4. תוכנית-ביצוע בשלבים (→ TaskMaster, tag legal-ai)
| שלב | תוכן | תלות |
|---|---|---|
| **0 — תשתית** | חיבור רשת-Docker (minio↔legal-ai); הזרקת credentials ל-env legal-ai (Coolify); `mc alias`; יצירת 3 דליות + הפעלת versioning + Object-Lock (immutable); הוספת `aioboto3` ל-deps | — |
| **1 — שכבת-אחסון** | `services/storage.py` + adapter כפול (default filesystem). אפס שינוי-התנהגות. PR מצהיר INV-STG1/2/3 | 0 |
| **2 — חיווט-כתיבה** | הפניית כל נקודות-הכתיבה (§1.4) דרך `storage.py`; כתיבה-כפולה (`STORAGE_BACKEND=dual`) | 1 |
| **3 — הגירת-נתונים** | `mc mirror --dry-run``--overwrite` של 5 הקטגוריות; backfill `*_object_key` ב-DB; אימות count+checksum | 0,2 |
| **4 — חיווט-קריאה + presigned** | endpoints→302→presigned; thumbnails דרך presigned; dual-read (S3, fallback disk); החרגת בינאריים מ-git per-case (INV-STG7) | 2,3 |
| **5 — cutover** | `STORAGE_BACKEND=s3`; `mc mirror --watch` עד החלפה; אימות מלא; כיבוי כתיבה-לדיסק | 4 |
| **6 — git + גיבוי + ניקוי** | קידום-החלטות-סופיות ל-immutable (INV-STG4); `mc mirror`/bucket-replication מתוזמן off-site; דה-קומיישן bind-mount `data/` (השארת audit/eval/logs) | 5 |
---
## 5. סיכונים
- **I/O מפוזר** → INV-STG1 (`storage.py`) חובה לפני כל שאר השלבים, אחרת drift והפרת-G2.
- **שמות עבריים כמפתחות** → INV-STG2 (UUID-keys + מטא).
- **רשת נפרדת ל-MinIO** → לאמת קישוריות בשלב 0 לפני הכל.
- **git-per-case** מצמיד בינאריים ל-Gitea → INV-STG7, ההחרגה חייבת לקדום לכל קומיט.
- **SNSD ללא erasure-coding** → גיבוי off-site (שלב 6) הוא חובה, לא nice-to-have.
- **בידוד-worktree + ספ-first** → כל PR מצהיר invariants (G2 + INV-STG*).
---
## 6. קישורים
- חוקה: [00-constitution.md](00-constitution.md) · נתונים: [02-data-model.md](02-data-model.md) · קליטה: [01-ingest.md](01-ingest.md)
- deploy/env: [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) · אינטגרציה: [X3-integration-deploy.md](X3-integration-deploy.md)
- מקורות-MinIO: docs.min.io (community), AWS S3 object-keys/bucket-naming/presigned-URL, github.com/minio/minio-py

View File

@@ -0,0 +1,148 @@
# X15 — שער-הפלטפורמה (Agent Platform Port)
> כפוף ל-[00-constitution.md](00-constitution.md). מיישם ומחזק את **INV-G2** (מקור-אמת
> יחיד — אין מסלולים מקבילים) ברובד הקַשירה (coupling) בין שכבת-האינטליגנציה לפלטפורמת-הסוכנים.
## 0. למה המסמך הזה קיים
פלטפורמת-הסוכנים שלנו היום היא **Paperclip**. היא אינה ליבת-המערכת — היא ה**מעטפת**
(לוח-issues, סוכנים מתמידים, human-in-the-loop דרך comments, wakeup/heartbeat, תזמון,
תקציבים per-agent, adapters). ליבת-האינטליגנציה — `mcp-server/src`, ה-skills של
ההחלטה/הסגנון, ולוגיקת-ההחלטה — היא הנכס שאינו תלוי-פלטפורמה.
**כשל-השורש שהמסמך מייבש:** מגע עם Paperclip שדולף לתוך שכבת-האינטליגנציה הופך את
המעטפת מ"רכיב ניתן-להחלפה מאחורי חוזה" ל"תלות-רוחב ארוגה בכל הקוד". ככל שהדליפה גדלה,
"החלפת המעטפת" (או אפילו שדרוג גרסה — ראו ההצמדה ל-opus-4-8) הופכת מ**החלפת-רכיב**
ל**כתיבה-מחדש**. זוהי הופעה נוספת של כשל-השורש שכל הספ בא לייבש: מסלולים מקבילים
שמתפצלים (drift), הפעם בציר התלות בין שכבות.
הבסיס התאורטי: **Ports & Adapters / Hexagonal Architecture** (Alistair Cockburn),
**The Dependency Rule / Clean Architecture** (Robert C. Martin), **Anti-Corruption
Layer** (Eric Evans, DDD). כולם אומרים את אותו הדבר: התלות זורמת פנימה בלבד; הליבה
אינה יודעת על העולם החיצון; כל מגע עם מערכת-חוץ עובר דרך שכבת-תרגום אחת (port/adapter).
---
## 1. השכבות והתפר
```
┌────────────────────────────────────────────────────────────────────┐
│ INTELLIGENCE (תלוי-פלטפורמה = אסור) │
│ mcp-server/src · skills/decision · skills/style · decision logic │
│ · style-acquisition │
│ ── חייב להכיל אפס סמלים ספציפיים-Paperclip ── │
└───────────────────────────────┬────────────────────────────────────┘
│ ה-PORT (שכבת-התרגום היחידה)
│ • web/agent_platform_port.py (Python)
│ • .claude/agents/HEARTBEAT.md (פרומפטים)
┌───────────────────────────────┴────────────────────────────────────┐
│ SHELL (Paperclip-specific — מותר ומוצהר) │
│ web/paperclip_client.py · web/paperclip_api.py · plugin-legal-ai │
│ · adapters/* · web-ui settings/paperclip-tab · skills/new-company │
└───────────────────────────────┬────────────────────────────────────┘
┌─────┴─────┐
│ Paperclip │ ← הפלטפורמה. ניתנת-להחלפה.
└───────────┘
```
**הגדרת-ה-Port:** קבוצת-הקבצים היחידה שמורשית לדבר Paperclip:
| Port surface | תפקיד | מורשה לייבא/להזכיר Paperclip |
|--------------|-------|------------------------------|
| `web/agent_platform_port.py` *(לבנייה — R2)* | תרגום אירועי-דומיין → קריאות-פלטפורמה | כן — המודול היחיד שמייבא `paperclip_client`/`paperclip_api` |
| `web/paperclip_client.py`, `web/paperclip_api.py` | מימוש-הלקוח (מאחורי ה-Port) | כן (זו המעטפת המתוכננת) |
| `.claude/agents/HEARTBEAT.md` | מקור-אמת יחיד לפרוטוקול-הריצה של הסוכנים | כן |
| `plugin-legal-ai/*`, `adapters/*` | הגשר מצד-Paperclip | כן |
| `web-ui` settings/paperclip-tab, agents-tab | UI לניהול-Paperclip עצמו | כן (מוצהר) |
| `skills/new-company-setup/SKILL.md` | blueprint-הקמה (חייב לדבר Paperclip) | כן — **חריג מוצהר** |
כל קובץ אחר — בפרט תחת `mcp-server/src`, `skills/decision`, `skills/style`,
ופרומפטי-הסוכנים פרט ל-HEARTBEAT — **אסור** שיכיל סמל ספציפי-Paperclip.
---
## 2. ה-invariant
### INV-PORT1 (גלובלי: G12) — שער-הפלטפורמה
**כלל:** פלטפורמת-הסוכנים (Paperclip) נגישה אך-ורק דרך ה-Platform Port
(`web/agent_platform_port.py` + `HEARTBEAT.md` לפרומפטים). שכבת-האינטליגנציה —
`mcp-server/src`, וה-skills של ההחלטה/הסגנון — מכילה **אפס** סמלים ספציפיים-לפלטפורמה
(שמות-מוצר, wakeup/heartbeat, pc.sh/pc_request, X-Paperclip-Run-Id, enums של הפלטפורמה).
פרומפטי-הסוכנים אינם משכפלים את פרוטוקול-הריצה — הם מצביעים ל-HEARTBEAT.md בלבד. כל מגע
חדש עם הפלטפורמה עובר דרך ה-Port.
**מקורות:** Alistair Cockburn, *Hexagonal Architecture (Ports & Adapters)* · Robert C.
Martin, *Clean Architecture* (The Dependency Rule) · Eric Evans, *Domain-Driven Design*
(Anti-Corruption Layer) | סטטוס: verified
**אכיפה:** (א) ביקורת-ארכיטקטורה + רשימת-ה-Port (§1); (ב) leak-guard אוטומטי — הרחבת
[scripts/spec-guard.sh](../../scripts/spec-guard.sh) שמשווה מול baseline-הדליפה (§4) ומזהיר
על דליפה חדשה ב-Edit/Write; (ג) fitness-test ב-CI שנכשל על מונח-Paperclip קשיח חדש תחת
`mcp-server/src`; (ד) הצהרת-G12 בתבנית-ה-PR.
**הפרה ידועה:** ראו מצאי-הדליפה ב-§3 — `web/app.py` קורא ל-`pc_*` inline בלוגיקת
מחזור-חיים של תיקים; 10 פרומפטי-סוכנים משכפלים את פרוטוקול-הריצה במקום להצביע ל-HEARTBEAT.
> **סיווג:** invariant הנדסי (≥3 מקורות חיצוניים, verified). מורחב מ-G1G10 בתור **G12**,
> ורשום ברשימת-הגלובליים ובאינדקס של [00-constitution.md](00-constitution.md) §5א (R0b הושלם).
---
## 3. מצאי-הדליפה (baseline — נמדד 2026-06-09)
מבחן-נטישה: כמה השכבות חוצות את התפר. הספירה היא בסיס-ההשוואה ל-leak-guard.
| Layer | Paperclip hits | סיווג | מחיר-ניתוק |
|-------|----------------|-------|------------|
| `mcp-server/src` (כלים) | 5 — **הערות בלבד** | ✅ נקי (זה הנכס) | ~0 |
| `skills/` (decision/style) | 36 — רק `new-company-setup` | ✅ נקי (חריג מוצהר) | נמוך |
| `web/paperclip_client.py` | 116 | ✅ מעטפת מתוכננת | — |
| `web/paperclip_api.py` | 33 | ✅ מעטפת מתוכננת | — |
| `web/app.py` | ~33 קריאות `pc_*` + `PAPERCLIP_COMPANIES`×72 | ⚠️ דליפה מבנית (מחזור-חיים) | בינוני |
| `.claude/agents/*.md` | 288 — פרוטוקול משוכפל ב-10 פרומפטים | ⚠️⚠️ דליפה מכנית | גבוה (בנפח) |
| `web-ui` (`types.ts`×41, `cases.ts`, `sse.ts`, ...) | ~60 | ⚠️ מושגי-פלטפורמה בחוזי-פרונט | בינוני |
**הממצא המרכזי:** שכבת-האינטליגנציה (`mcp-server/src` + skills של ההחלטה/הסגנון) כבר
נקייה כמעט-לחלוטין — 5 ההיטים ב-mcp-server הם הערות בלבד (מקור `company_id`). מחיר-הגירושין
בינוני, מרוכז בשלוש שכבות-נושקות-למעטפת.
---
## 4. מפת-התיקון (R-tasks)
| R | תחום | תיאור | סיכון |
|---|------|-------|-------|
| **R0** | ספ | המסמך הזה — מגדיר את ה-Port, ה-invariant, ו-baseline-הדליפה | 0 |
| **R0b** | ספ | רישום G12 ב-[00-constitution.md](00-constitution.md) (רשימת-גלובליים + אינדקס) + שורת G12 בתבנית-ה-PR + מצביע ב-CLAUDE.md | 0 |
| **R1** | פרומפטים | כל פרוטוקול-הריצה עובר ל-HEARTBEAT.md (מקור יחיד); 10 הפרומפטים מצביעים אליו בלבד. 288→~20 היטים | נמוך |
| **R2** | web | יצירת `web/agent_platform_port.py` — המודול היחיד שמייבא `paperclip_client`/`paperclip_api`. `app.py` פולט אירוע-דומיין (`case_archived`/`created`/...) שה-Port מתרגם. `PAPERCLIP_COMPANIES``company_map` מאחורי ה-Port | בינוני |
| **R3** | web-ui | `types.ts` → namespace `paperclip.*` נפרד; חוזי case/api כלליים נשארים נקיים. טאבי-ניהול-Paperclip נשארים (מעטפת מוצהרת) | נמוך-בינוני |
| **R4** | אכיפה | הרחבת `spec-guard.sh` ל-leak-guard מול ה-baseline + fitness-test ב-CI על `mcp-server/src` | 0 |
**עיקרון-מנחה (G2):** R1+R2 הם G2 בלבוש חדש — מאחדים פרוטוקול/מסלול משוכפל למקור אחד.
הם אינם יוצרים מסלול מקביל; הם מסירים אחד.
---
## 5. מנגנון נגד דליפה-עתידית
תיקון חד-פעמי חסר-ערך אם הדליפה תחזור בפיצ'ר הבא. שלוש שכבות-אכיפה, כולן מתחברות
למנגנונים קיימים (ולא ממציאות מסלול חדש):
1. **invariant (G12)** — מוגדר כאן, נרשם בחוקה (R0b). first-class, לא הערת-שוליים.
2. **אכיפה-אוטומטית**`spec-guard.sh` כבר מיירט כל Edit/Write בנתיב-קוד; ה-leak-guard
(R4) משווה מול baseline §3 ומזהיר על דליפה חדשה **בזמן-אמת**, לפני ה-review.
3. **חוזה-תיעוד** — תבנית-ה-PR כבר דורשת הצהרת-invariants; נוסיף שורת-G12 לצ'קליסט
("□ לא הוספתי מגע-Paperclip מחוץ ל-Platform Port"). CLAUDE.md §Paperclip + §פרוטוקול
כתיבת-קוד מצביעים לכאן.
> **כלל-זהב לכל פיתוח עתידי:** פיצ'ר חדש שנוגע בפלטפורמה — מוסיף/משנה **רק** קוד תחת
> רשימת-ה-Port (§1). אם נדרש מגע-פלטפורמה משכבת-האינטליגנציה — זו אינדיקציה לתכנון
> שגוי: הוסיפו במקום זאת אירוע-דומיין שה-Port יתרגם.
---
## 6. ראו גם
- [00-constitution.md](00-constitution.md) — G2 (שאותו מיישם), G12 (לאחר R0b).
- [X7-paperclip-client-params.md](X7-paperclip-client-params.md) — פרמטרי לקוח-Paperclip (מתחת ל-Port).
- [X4-agents.md](X4-agents.md) — מפת-הסוכנים.
- [X3-integration-deploy.md](X3-integration-deploy.md) — אינטגרציה+deploy.
- [X16-pipeline-durability.md](X16-pipeline-durability.md) — עמידות-פייפליין (החלטה נפרדת, נושקת).

View File

@@ -0,0 +1,96 @@
# X16 — עמידות-פייפליין (Durable Pipeline Execution)
> כפוף ל-[00-constitution.md](00-constitution.md). מחזק את **INV-G3** (idempotency)
> ב-checkpointing+replay לפייפליינים הדטרמיניסטיים המקומיים. נושק ל-[07-learning.md](07-learning.md)
> ו-[X11-citation-corroboration.md](X11-citation-corroboration.md).
## 0. הבעיה
שני הפייפליינים המקומיים החד-פעמיים —
[final_halacha_pipeline.py](../../scripts/final_halacha_pipeline.py) (כפתור run-halacha,
אימות-הלכות, X11) ו-[final_learning_pipeline.py](../../scripts/final_learning_pipeline.py)
(כפתור run-learning, למידת-סגנון, 07-learning) — חולקים **צורה זהה**: סקריפט מקומי,
34 שלבים בטור, idempotent, פאנל-LLM ארוך בסוף (CSV-gated, "can take minutes").
היום הם **ליניאריים וחסרי-זיכרון**: קריסה באמצע (ניתוק ל-DeepSeek/Gemini, restart של
קונטיינר, OOM) → הרצה-מחדש מ-שלב 0. השלבים idempotent ולכן זה **בטוח**, אבל **משלמים שוב**:
מחלצים, בונים corroboration על כל הקורפוס, ושופטים מחדש הלכות שכבר נשפטו — דקות וקריאות-LLM
לפח.
**הקשר-סיכון אמיתי:** דליפת task-master (יתומים ppid=1, ~3GB) מסכנת OOM ל-Postgres
([project_taskmaster_mcp_memory_leak]). אם OOM הורג ריצת-פאנל ארוכה — היום מתחילים מאפס.
**הבחנה מ-idempotency:** idempotency = "בטוח להריץ שוב". durable execution = "בטוח להריץ
שוב **בלי לשלם שוב**". זה שכלול, לא תחליף.
## 1. ההכרעה
להטמיע **LangGraph כספרייה בתוך הסקריפט** (לא כפלטפורמה מחליפה ל-Paperclip): מנוע-העמידות
היחיד שהוא state-of-the-art ב-checkpointing+replay+time-travel, בשימוש כ-`import` בתוך
הסקריפט המקומי. Paperclip לא מושפע — הכפתור עדיין מעיר את Hermes שמריץ את אותו ה-CLI.
> **גבול-תחום מפורש (מתחבר ל-G12/X15):** LangGraph נכנס **רק** כמנוע-פנימי של הסקריפטים
> המקומיים. אסור להשתמש בו כתחליף-פלטפורמה או כ-orchestrator של הסוכנים — זה ייצור מסלול
> מקביל ל-Paperclip (הפרת G2) ויערבב עמידות עם פלטפורמה. HITL/ניתוב-יו"ר נשאר מאחורי
> ה-Port (ראו §4 Phase 3).
**מקורות:** Temporal — *Durable Execution* · Saga / workflow-checkpointing pattern ·
Martin Kleppmann, *DDIA* (idempotence & exactly-once) · LangGraph checkpointer/replay docs.
## 2. ה-invariant
### INV-DUR1 — עמידות לפייפליינים דטרמיניסטיים
**כלל:** פייפליין דטרמיניסטי רב-שלבי משמר את התקדמותו ב-checkpoint מתמיד אחרי כל שלב
שהושלם; הרצה-חוזרת של אותה יחידת-עבודה **מדלגת** על שלבים שכבר הושלמו ומתחילה מנקודת-הכשל
המדויקת. מימוש-העמידות הוא **משותף** לכל הפייפליינים (`scripts/_pipeline_runtime.py`) —
לא מימוש-לכל-סקריפט (G2). חוזה-הכניסה (ה-CLI) נשמר ללא-שינוי.
**מקורות:** Temporal (Durable Execution) · Kleppmann *DDIA* (exactly-once) · Saga pattern
(workflow checkpointing) | סטטוס: verified
**אכיפה:** `_pipeline_runtime.py` עם LangGraph + checkpointer; thread_id דטרמיניסטי
לכל יחידת-עבודה (תיק); בדיקת kill-and-resume שמאמתת ששלבים שהושלמו אינם רצים-מחדש.
**הפרה ידועה:** היום `final_halacha_pipeline.py` / `final_learning_pipeline.py` ליניאריים
— קריסה = הרצה-מחדש מלאה (חוזרים על extract/corroboration/panel).
## 3. ארכיטקטורה
```
scripts/_pipeline_runtime.py ← מודול-עמידות משותף יחיד (G2)
• build_graph(steps) StateGraph: node לכל שלב
• SqliteSaver data/checkpoints/<pipeline>.sqlite (לא Postgres המשותף)
• run(thread_id, resume) מדלג-אוטומטית על nodes ב-checkpoint
```
**הכרעות-תכנון:**
1. **Checkpointer = SQLite (`langgraph-checkpoint-sqlite`), לא Postgres.** קובץ תחת
`data/checkpoints/`: מקומי (תואם "local-only"), פשוט, ו**נמנע מהאזהרה** ב-CLAUDE.md נגד
migrations מ-2 worktrees על Postgres המשותף (`localhost:5433`). PostgresSaver = אופציה
עתידית אם נדרש ריכוז/observability.
2. **`thread_id = f"<pipeline>:{case_number}"`.** הרצה-חוזרת של אותו תיק מזהה checkpoint
לא-גמור וממשיכה אוטומטית; תיק שהושלם = no-op. idempotency + דילוג-checkpoint מתחברים.
3. **גרעיניות (מדורגת):**
- **גס (P0/P1):** כל שלב = node. קריסה בין-שלבים → המשך מהשלב שנפל. הפאנל node יחיד
שרץ-מחדש — אך הוא כבר CSV-backed + idempotent (מדלג פנימית על מה שנשפט).
- **עדין (P2, אופציונלי):** פירוק הפאנל ל-map מעל ההלכות/הלקחים (LangGraph `Send`),
כל פריט = יחידת-checkpoint → resume תוך-פאנל בלי לשפוט מחדש ברמת-LLM. נשען על ה-CSV
הקיים כמקור "כבר-נשפט".
4. **סמנטיקת-כשל מפורשת.** היום הכל "non-fatal, continue". עם LangGraph: nodes "מייעצים"
(extract, corroboration) — catch+record-status וממשיכים; node "קריטי" (panel) — raise
בכשל-קשה → עצירה ב-checkpoint → resume.
5. **שימור-חוזה-הכניסה.** ה-CLI (`--case`/`--limit`/`--dry-run`) זהה; run-halacha/run-learning
→ Hermes → אותו `python ...pipeline.py --case X` לא משתנה. מוסיפים `--fresh`
(ברירת-מחדל: auto-resume אם יש checkpoint לא-גמור לתיק).
## 4. גלגול מדורג
| Phase | תחום | מאמץ |
|-------|------|------|
| **P0** | deps ל-`mcp-server/pyproject` (`langgraph` + `langgraph-checkpoint-sqlite`, venv מקומי בלבד → אפס השפעת-קונטיינר). `_pipeline_runtime.py` עם SqliteSaver. עטיפת 4 שלבי-halacha כ-nodes (גס). CLI זהה. test: kill אחרי [1] → resume → assert [0],[1] לא רצו שוב | ~1 יום |
| **P1** | אותו runtime על `final_learning_pipeline` (3 שלבים) — מימוש-עמידות אחד לשניהם (G2) | חצי יום |
| **P2** | (אופציונלי) פירוק-פאנל ל-map per-item — resume תוך-פאנל | 12 ימים |
| **P3** | (עתידי) LangGraph `interrupt()` ל-HITL של היו"ר (split→chair, INV-G10) — **רק מאחורי ה-Port** (X15/G12) | — |
## 5. ראו גם
- [07-learning.md](07-learning.md) · [X11-citation-corroboration.md](X11-citation-corroboration.md)
- [X15-agent-platform-port.md](X15-agent-platform-port.md) — הגבול מול הפלטפורמה (G12).
- [scripts/SCRIPTS.md](../../scripts/SCRIPTS.md) — הסקריפטים המושפעים.

View File

@@ -92,12 +92,14 @@ NCSC/JTC — *AI in Courts* (verifiable citation) | סטטוס: verified
**אכיפה:** `proofreader.verify_quote` בעת חילוץ → `quote_verified`. **אכיפה:** `proofreader.verify_quote` בעת חילוץ → `quote_verified`.
**הפרה ידועה:** — (קיים; ה-flag נכתב, אך אין חיווי ב-UI — ראה [X6 INV-UI6](X6-ui-api-contract.md)). **הפרה ידועה:** — (קיים; ה-flag נכתב, אך אין חיווי ב-UI — ראה [X6 INV-UI6](X6-ui-api-contract.md)).
### INV-FP5: חילוץ אסינכרוני דרך claude_session מקומי ### INV-FP5: חילוץ אסינכרוני, מתור, צד-מארח (לא מהקונטיינר)
**כלל:** חילוץ-LLM (מטא, הלכות) רץ **אסינכרוני, מתור**, דרך `claude_session` **מקומי בלבד** — לא חוסם את **כלל:** חילוץ-LLM (מטא, הלכות) רץ **אסינכרוני, מתור, מצד-המארח** — לא חוסם את ה-web ולא קורא ל-LLM
ה-web, ולא קורא ל-LLM מהקונטיינר. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) מהקונטיינר. **בחירת-מנוע לפי אופי-המשימה (לא מסלול מקביל):** חילוץ-מטא הוא משימה *תחומה* (טקסט→JSON)
(מסלול-LLM קנוני יחיד). **פרויקטלי-תפעולי.** תואם זיכרון `feedback_claude_session_local_only`. ולכן רץ על **Gemini Flash** (`gemini_session`, structured JSON) — ה-claude CLI ה-agentic פגע ב-
**מקור-סמכות:** [ingest.py](../../mcp-server/src/legal_mcp/services/ingest.py) (queue בצעד 12 → `process_pending_extractions`); [legal-ai/CLAUDE.md](../../CLAUDE.md) (claude_session local-only). `error_max_turns`; חילוץ-הלכות (רגיש-קול/agentic) נשאר על **`claude_session`** (CLI מקומי, מנוי דפנה).
**אכיפה:** queue + `precedent_process_pending`; קריאות-LLM רק מ-MCP מקומי. שני המנועים מתנקזים לתור-החילוץ הקנוני היחיד ([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)). **פרויקטלי-תפעולי.**
**מקור-סמכות:** [ingest.py](../../mcp-server/src/legal_mcp/services/ingest.py) (queue → `process_pending_extractions`); [gemini_session.py](../../mcp-server/src/legal_mcp/services/gemini_session.py) (מטא); [legal-ai/CLAUDE.md](../../CLAUDE.md) (claude_session local-only להלכות). `GEMINI_API_KEY` בצד-המארח בלבד — לא בקונטיינר (תואם `feedback_claude_session_local_only`: אין קריאות-LLM מהקונטיינר).
**אכיפה:** queue + `precedent_process_pending` + drainers מתוזמנים (`legal-metadata-drain`/CEO); קריאות-LLM רק מצד-המארח.
**הפרה ידועה:** תור-החילוץ **סמוי** (אין הבחנה pending-initial מול pending-review; אין extraction-job table) ([gap-audit GAP-45](gap-audit.md); [X9](X9-mcp-tool-contract.md)). **הפרה ידועה:** תור-החילוץ **סמוי** (אין הבחנה pending-initial מול pending-review; אין extraction-job table) ([gap-audit GAP-45](gap-audit.md); [X9](X9-mcp-tool-contract.md)).
--- ---

View File

@@ -21,6 +21,29 @@ dependencies = [
"uvicorn[standard]>=0.30.0", "uvicorn[standard]>=0.30.0",
"httpx>=0.27.0", "httpx>=0.27.0",
"infisicalsdk>=1.0.0", "infisicalsdk>=1.0.0",
"aioboto3>=13.0.0", # X14 object storage (MinIO/S3) — services/storage.py
]
[project.optional-dependencies]
# Tier-1 court-verdict fetch (X13) — host-only. The container can't run a
# browser, so these are NOT in the base deps; install on the host venv with
# `pip install -e ".[court-fetch]" && python -m camoufox fetch`. faster-whisper
# is only for the explicit-PDF-download reCAPTCHA fallback (the primary
# image-API path needs no solving).
court-fetch = [
"camoufox>=0.4.11",
"faster-whisper>=1.0.0",
"h2>=4.0.0", # Tier-0 supremedecisions uses httpx http2
]
# Durable execution for the local one-shot pipelines (X16 / INV-DUR1) —
# final_halacha_pipeline / final_learning_pipeline gain crash/OOM resume via
# scripts/_pipeline_runtime.py. HOST-ONLY (the pipelines run locally, not in the
# container): install on the host venv with `pip install -e ".[durable]"`. The
# runtime degrades gracefully to linear execution when these are absent, so the
# run-halacha / run-learning buttons keep working until then.
durable = [
"langgraph>=1.0,<2.0",
"langgraph-checkpoint-sqlite>=3.0",
] ]
[build-system] [build-system]

View File

@@ -54,6 +54,10 @@ REDIS_URL = os.environ.get("REDIS_URL", "redis://127.0.0.1:6380/0")
# pinned. # pinned.
HALACHA_EXTRACT_MODEL = os.environ.get("HALACHA_EXTRACT_MODEL", "claude-opus-4-8") HALACHA_EXTRACT_MODEL = os.environ.get("HALACHA_EXTRACT_MODEL", "claude-opus-4-8")
HALACHA_EXTRACT_EFFORT = os.environ.get("HALACHA_EXTRACT_EFFORT", "xhigh") HALACHA_EXTRACT_EFFORT = os.environ.get("HALACHA_EXTRACT_EFFORT", "xhigh")
# Digest (X12) metadata extraction is a simpler, high-volume task (concept tag,
# headline, underlying citation, tags from a one-page summary) — Sonnet is the
# speed/cost sweet-spot here, unlike halacha extraction which pins Opus. Tune via env.
DIGEST_EXTRACT_MODEL = os.environ.get("DIGEST_EXTRACT_MODEL", "claude-sonnet-4-6")
# Effort for BULK queue-drain extraction (process_pending over many precedents). # Effort for BULK queue-drain extraction (process_pending over many precedents).
# xhigh is the quality sweet-spot for a single precedent but very slow at scale # xhigh is the quality sweet-spot for a single precedent but very slow at scale
# (a 64-chunk case ≈ 20 min). Bulk drains use a lighter effort to cut wall-clock; # (a 64-chunk case ≈ 20 min). Bulk drains use a lighter effort to cut wall-clock;
@@ -162,6 +166,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
@@ -191,6 +202,32 @@ EXPORTS_DIR = DATA_DIR / "exports" # legacy exports only
# Cases directory — flat structure: data/cases/{case_number}/ # Cases directory — flat structure: data/cases/{case_number}/
CASES_DIR = DATA_DIR / "cases" CASES_DIR = DATA_DIR / "cases"
# ── Object storage (X14 / MinIO) ───────────────────────────────────
# Single storage layer (services/storage.py) replaces the scattered file
# I/O across ~8 services (INV-STG1 / G2). Backend selector:
# "filesystem" (default) — disk under DATA_DIR; current behaviour, no change.
# "dual" — write disk + S3, read S3→disk fallback (migration).
# "s3" — MinIO only.
# See docs/spec/X14-storage-minio.md.
STORAGE_BACKEND = os.environ.get("STORAGE_BACKEND", "filesystem").strip().lower()
# Endpoint reached server-side (internal Docker network: http://minio:9000).
MINIO_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "http://minio:9000")
# Public endpoint used when MINTING presigned URLs for the browser (INV-STG6) —
# the browser cannot resolve the internal hostname. Falls back to the internal
# endpoint when unset (e.g. local dev).
MINIO_PUBLIC_ENDPOINT = os.environ.get("MINIO_PUBLIC_ENDPOINT", MINIO_ENDPOINT)
MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "")
MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "")
MINIO_REGION = os.environ.get("MINIO_REGION", "us-east-1")
# Logical bucket → name. Governance boundaries (INV-STG3): documents
# (versioned), immutable (versioned + Object-Lock COMPLIANCE for final
# decisions, INV-STG4), derived (thumbnails/extracted text — regenerable).
MINIO_BUCKET_DOCUMENTS = os.environ.get("MINIO_BUCKET_DOCUMENTS", "legal-documents")
MINIO_BUCKET_IMMUTABLE = os.environ.get("MINIO_BUCKET_IMMUTABLE", "legal-immutable")
MINIO_BUCKET_DERIVED = os.environ.get("MINIO_BUCKET_DERIVED", "legal-derived")
# Default presigned-URL TTL (seconds). SigV4 hard max is 7 days; keep short.
MINIO_PRESIGN_TTL = int(os.environ.get("MINIO_PRESIGN_TTL", "900"))
def find_case_dir(case_number: str) -> Path: def find_case_dir(case_number: str) -> Path:
"""Return the case directory for a given case number.""" """Return the case directory for a given case number."""

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,314 @@
"""Camoufox driver for נט המשפט — calibrated, proven flow (X13, Tier 1).
Open-source, zero-API-cost: drives a **Camoufox** stealth browser (a Firefox
fork with C++ fingerprint spoofing) via its official Python package
(``camoufox.async_api``) — in-process, no separate Node server. The full flow
was reverse-engineered and validated end-to-end against עת"מ 46111-12-22
(2026-06-07): a 34-page verdict PDF retrieved with **no smart-card and no
CAPTCHA-solving**.
The proven path:
1. homepage → DOM-click ``btnExternalSearchCases`` ("תיקים לפי מס' תיק מקור").
2. Fill the visible header case-locator: ``BamaCaseNumberTextBoxH`` = case
number, ``BamaMonthYearTextBoxHT`` = "MM-YY"; click ``SearchHeaderCaseButton``.
→ lands on ``FolderCaseDetails/CaseDetails.aspx`` for the case.
3. Click the "פסקי דין" sidebar tab → ``Decisions/DecisionList.aspx``.
4. Click the document → popup ``Viewer/NGCSViewerPage.aspx?DocumentNumber=…``.
5. The viewer renders pages as PNG images via the ``GetImages`` PageMethod —
**served without reCAPTCHA** (the reCAPTCHA on the viewer only gates the
explicit save/print, which we don't use). Capture the internal
``documentNumber`` from the viewer's first ``GetImages`` call, then pull
every 4-page batch via ``fetch`` **with header ``X-Requested-With:
XMLHttpRequest``** (required — the F5 WAF blocks AJAX calls without it).
6. Decode the base64 PNGs → assemble a PDF (Pillow). The existing ingest
pipeline OCRs it (Google Vision) → text → corpus.
Operational requirements (see scripts/legal-court-fetch-service.config.cjs):
* a virtual display — Camoufox/Firefox crashes headless on this server
without one. Set ``DISPLAY`` to a running Xvfb (e.g. ``:99``).
* RAM — a Firefox content process loading the heavy ASP.NET pages needs
~0.51 GB; keep the box from swapping.
reCAPTCHA note: ``recaptcha_audio`` (local Whisper) remains as a fallback for
the explicit-PDF-download path, but the primary image-API path needs no
solving, so it is normally unused.
"""
from __future__ import annotations
import asyncio
import base64
import io
import json
import logging
import os
import re
logger = logging.getLogger(__name__)
NGCS_HOME = "https://www.court.gov.il/ngcs.web.site/homepage.aspx"
# Headless Camoufox needs a virtual display on this server.
_DISPLAY = os.environ.get("DISPLAY", "")
_NAV_TIMEOUT_MS = int(float(os.environ.get("COURT_FETCH_BROWSER_TIMEOUT_S", "60")) * 1000)
_PAGE_BATCH = 4 # the viewer's GetImages batch size
_MAX_PAGES = 400 # hard cap on a single document
# Hard wall-clock cap on a single fetch so a hung browser can't pin a Firefox
# process forever (anti-leak; INV-CF4 politeness). The async-with cleanup runs
# on the resulting CancelledError, tearing the browser down.
_FETCH_HARD_TIMEOUT_S = float(os.environ.get("COURT_FETCH_HARD_TIMEOUT_S", "180"))
def _reap_orphan_browsers() -> int:
"""Kill any ``camoufox-bin`` orphaned to ``ppid=1`` before we launch.
Fetching is serial (INV-CF4), so any browser not owned by a live parent is
a leftover from a prior crashed/killed fetch. Pure /proc, best-effort —
never raises into the fetch path.
"""
killed = 0
try:
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
try:
with open(f"/proc/{pid}/status", "rb") as f:
status = f.read().decode("utf-8", "replace")
with open(f"/proc/{pid}/cmdline", "rb") as f:
cmd = f.read().decode("utf-8", "replace")
except OSError:
continue
if "camoufox-bin" not in cmd:
continue
ppid = 0
for line in status.splitlines():
if line.startswith("PPid:"):
try: ppid = int(line.split()[1])
except (IndexError, ValueError): pass
break
if ppid == 1:
try:
os.kill(int(pid), 9)
killed += 1
except OSError:
pass
except OSError:
pass
if killed:
logger.warning("reaped %d orphaned camoufox-bin before fetch", killed)
return killed
class CamofoxUnavailable(RuntimeError):
"""Camoufox (or its virtual display) isn't available."""
class NgcsFlowError(RuntimeError):
"""A step in the נט-המשפט flow failed (navigation / not found / blocked)."""
def is_enabled() -> bool:
"""True if the Camoufox package imports (browser binary present)."""
try:
import camoufox.async_api # noqa: F401
return True
except Exception:
return False
async def health() -> dict:
return {"camoufox_import": is_enabled(), "display": _DISPLAY or "(none)"}
async def _fill_visible(page, id_substr: str, value: str) -> bool:
for el in await page.locator(f"input[id*='{id_substr}']").all():
try:
if await el.is_visible() and await el.is_editable():
await el.fill(value)
return True
except Exception:
continue
return False
async def _reach_viewer(page, *, case_number: str, month_year: str):
"""Drive home → search → case → פסקי דין → viewer popup. Returns the popup page."""
await page.goto(NGCS_HOME, wait_until="domcontentloaded", timeout=_NAV_TIMEOUT_MS)
await page.wait_for_timeout(2500)
await page.eval_on_selector(
"#Header1_UpperMenu1_btnExternalSearchCases", "el => el.click()"
)
try:
await page.wait_for_load_state("domcontentloaded", timeout=_NAV_TIMEOUT_MS)
except Exception:
pass
await page.wait_for_timeout(4500)
if not await _fill_visible(page, "BamaCaseNumberTextBoxH", case_number):
raise NgcsFlowError("שדה מספר-תיק לא נמצא בעמוד החיפוש")
my_filled = False
for el in await page.locator("input[id*='BamaMonthYearTextBoxHT']").all():
if await el.is_visible():
await el.click()
await page.keyboard.type(month_year, delay=60)
my_filled = True
break
if not my_filled:
raise NgcsFlowError("שדה חודש-שנה לא נמצא")
clicked = False
for b in await page.locator("[id*='SearchHeaderCaseButton']").all():
if await b.is_visible():
await b.click()
clicked = True
break
if not clicked:
raise NgcsFlowError("כפתור החיפוש לא נמצא")
await page.wait_for_timeout(6000)
if "CaseDetails" not in page.url:
raise NgcsFlowError(
f"לא הגענו לעמוד-התיק (URL={page.url[:80]}) — ייתכן שהתיק לא נמצא/לא פתוח לעיון"
)
# פסקי דין tab → DecisionList
psak = page.locator("a:has-text('פסקי דין')")
opened = False
for i in range(await psak.count()):
el = psak.nth(i)
if await el.is_visible():
await el.click()
opened = True
break
if not opened:
raise NgcsFlowError("לשונית 'פסקי דין' לא נמצאה בעמוד-התיק")
await page.wait_for_timeout(6000)
# open the verdict document viewer (popup)
viewers = page.locator(
"a[href*='Viewer'],[onclick*='Viewer'],a[href*='Document'],a:has-text('צפייה')"
)
async with page.context.expect_page(timeout=15000) as pop:
clicked = False
for i in range(await viewers.count()):
el = viewers.nth(i)
if await el.is_visible():
await el.click()
clicked = True
break
if not clicked:
raise NgcsFlowError("לא נמצא מסמך פסק-דין לצפייה")
return await pop.value
async def fetch_admin_verdict(
*, file_number: str, month: str, year: str, case_number: str, court: str
) -> dict:
"""Fetch an admin/district court verdict as a PDF. Returns
``{content: bytes, filename, source_url, court}``; raises on failure.
``file_number``/``month``/``year`` are the נט-המשפט triple (e.g. 46111/12/22).
"""
try:
from camoufox.async_api import AsyncCamoufox
except Exception as e:
raise CamofoxUnavailable(
"חבילת camoufox אינה מותקנת/זמינה. הרץ `pip install camoufox` ו-"
"`python -m camoufox fetch`. ראה docs/spec/X13-court-fetch.md."
) from e
if not _DISPLAY:
# Headless Firefox crashes here without a virtual display.
raise CamofoxUnavailable(
"אין DISPLAY — Camoufox דורש Xvfb על שרת ללא מסך. הפעל Xvfb (למשל :99) "
"והגדר DISPLAY (ראה pm2 config)."
)
month_year = f"{int(month):02d}-{year[-2:]}"
# Belt-and-suspenders against browser leaks: kill any orphaned browser from
# a prior crashed fetch before we launch a new one (serial → safe).
_reap_orphan_browsers()
async def _run() -> dict:
doc_num = {"v": None}
async def on_resp(resp):
if "GetImages" in resp.url and not doc_num["v"]:
try:
doc_num["v"] = json.loads(resp.request.post_data).get("documentNumber")
except Exception:
pass
async with AsyncCamoufox(
headless=True, geoip=False, humanize=True, locale="he-IL"
) as browser:
page = await browser.new_page()
page.context.on("response", lambda r: asyncio.create_task(on_resp(r)))
vp = await _reach_viewer(page, case_number=file_number, month_year=month_year)
source_url = vp.url
await vp.wait_for_timeout(9000)
if not doc_num["v"]:
raise NgcsFlowError("לא נלכד documentNumber מהצופה (ייתכן שהמסמך לא נטען)")
# Pull every page batch through fetch() with X-Requested-With (WAF-safe).
imgs = await vp.evaluate(
"""async (args) => {
const [dn, maxPages, batch] = args;
const url = window.location.href.split('?')[0] + '/GetImages';
const out = {};
for (let f = 0; f < maxPages; f += batch) {
let d;
try {
const r = await fetch(url, {method:'POST', credentials:'include',
headers:{'Content-Type':'application/json; charset=utf-8',
'X-Requested-With':'XMLHttpRequest'},
body: JSON.stringify({documentNumber:dn, fromIndex:f, toIndex:f+batch-1})});
if (!r.ok) break;
const j = await r.json(); d = (j.d !== undefined) ? j.d : j;
} catch (e) { break; }
if (!Array.isArray(d) || d.length === 0) break;
d.forEach((html, k) => { if (html) out[f+k] = html; });
if (d.length < batch) break;
await new Promise(r => setTimeout(r, 350));
}
return out;
}""",
[doc_num["v"], _MAX_PAGES, _PAGE_BATCH],
)
if not imgs:
raise NgcsFlowError("לא התקבלו עמודי-מסמך מ-GetImages")
from PIL import Image
pages = []
for idx in sorted(imgs, key=lambda x: int(x)):
m = re.search(r"base64,([A-Za-z0-9+/=]+)", imgs[idx] or "")
if not m:
continue
pages.append(Image.open(io.BytesIO(base64.b64decode(m.group(1)))).convert("RGB"))
if not pages:
raise NgcsFlowError("עמודי-המסמך לא ניתנים לפענוח (base64)")
buf = io.BytesIO()
pages[0].save(buf, format="PDF", save_all=True, append_images=pages[1:])
content = buf.getvalue()
logger.info("נט המשפט: fetched %s%d pages, %d bytes",
case_number, len(pages), len(content))
return {
"content": content,
"filename": f"{case_number}.pdf",
"source_url": source_url,
"court": court or "בית משפט מחוזי",
"pages": len(pages),
}
# Hard wall-clock cap: on a hung browser, the timeout cancels _run(); the
# async-with __aexit__ tears the browser down, and the reap below sweeps any
# process that outlived the cancellation.
try:
return await asyncio.wait_for(_run(), _FETCH_HARD_TIMEOUT_S)
except asyncio.TimeoutError:
_reap_orphan_browsers()
raise NgcsFlowError(
f"אחזור עבר את מגבלת-הזמן ({_FETCH_HARD_TIMEOUT_S:.0f}ש') ובוטל"
)
finally:
_reap_orphan_browsers()

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,275 @@
"""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.
GET /pm2 read-only pm2 status of legal-* / paperclip services (no auth).
POST /pm2/control body {name, action: restart|stop|start} → run pm2 on a
whitelisted legal-* process. REQUIRES Bearer (mutating).
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)
# Background services we surface on the /operations dashboard. pm2 jlist is a
# host-only capability (the legal-ai container can't run pm2), so the container's
# FastAPI proxies this read-only endpoint over the docker bridge. No secret:
# pm2 status (names/cpu/mem) carries nothing sensitive and the bind (10.0.1.1)
# is already host/container-only.
_PM2_PREFIXES = ("legal-", "paperclip")
def _trim_service(a: dict) -> dict:
"""Project a pm2 jlist app entry into the fields the dashboard needs."""
env = a.get("pm2_env", {}) or {}
return {
"name": a.get("name", ""),
"status": env.get("status", ""),
"restarts": env.get("restart_time", 0),
"uptime_ms": env.get("pm_uptime", 0),
"cpu": (a.get("monit") or {}).get("cpu", 0),
"memory_bytes": (a.get("monit") or {}).get("memory", 0),
"cron": env.get("cron_restart") or "",
"autorestart": env.get("autorestart", True),
}
async def _pm2_run(*args: str, timeout: float = 10) -> tuple[int, bytes, bytes]:
"""Run a pm2 subcommand; returns (returncode, stdout, stderr)."""
import asyncio as _asyncio
proc = await _asyncio.create_subprocess_exec(
"pm2", *args,
stdout=_asyncio.subprocess.PIPE, stderr=_asyncio.subprocess.PIPE,
)
out, err = await _asyncio.wait_for(proc.communicate(), timeout=timeout)
return proc.returncode or 0, out, err
async def pm2_status(request: web.Request) -> web.Response:
"""Return a trimmed ``pm2 jlist`` for the legal-ai background services."""
try:
rc, out, err = await _pm2_run("jlist")
if rc != 0:
return web.json_response(
{"error": f"pm2 jlist failed: {err.decode('utf-8','replace')[:200]}"},
status=502,
)
apps = json.loads(out.decode("utf-8", "replace"))
except FileNotFoundError:
return web.json_response({"error": "pm2 not found on PATH"}, status=502)
except Exception as e: # never throw
return web.json_response({"error": f"pm2 error: {e}"}, status=502)
services = [
_trim_service(a) for a in apps
if any(str(a.get("name", "")).startswith(p) for p in _PM2_PREFIXES)
]
services.sort(key=lambda s: s["name"])
return web.json_response({"services": services})
# Process control (restart/stop/start) for the dashboard's "Windows-services"
# panel. Mutating, so it requires the Bearer secret (unlike read-only /pm2).
# Whitelisted to ``legal-`` names only — never paperclip or arbitrary processes.
_PM2_ACTIONS = {"restart", "stop", "start"}
# Our own pm2 process name. Restarting/stopping ourselves kills this process
# mid-reply, so those self-actions are detached (see pm2_control).
_OWN_PM2_NAME = os.environ.get("COURT_FETCH_SERVICE_PM2_NAME", "legal-court-fetch-service")
async def pm2_control(request: web.Request) -> web.Response:
"""Run ``pm2 <action> <name>`` for a whitelisted legal-* process."""
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)
name = str(body.get("name", "")).strip()
action = str(body.get("action", "")).strip()
if action not in _PM2_ACTIONS:
return web.json_response(
{"error": f"action must be one of {sorted(_PM2_ACTIONS)}"}, status=400
)
if not name.startswith("legal-"):
return web.json_response(
{"error": "name must be a legal-* process"}, status=403
)
# Self restart/stop kills this process before it can reply (client sees a
# dropped connection / 502) even though pm2 does perform the action. Detach
# it with a brief delay so the HTTP response flushes first, then report
# success optimistically.
if name == _OWN_PM2_NAME and action in ("restart", "stop"):
import asyncio as _asyncio
await _asyncio.create_subprocess_shell(f"sleep 1; pm2 {action} {name} --silent")
return web.json_response(
{"ok": True, "action": action, "deferred": True, "service": None}
)
try:
rc, out, err = await _pm2_run(action, name, "--silent", timeout=30)
if rc != 0:
return web.json_response(
{"ok": False,
"error": f"pm2 {action} {name} failed: "
f"{err.decode('utf-8','replace')[:200]}"},
status=502,
)
# Re-read just this process so the UI settles on the real new state.
rc2, out2, _ = await _pm2_run("jlist")
svc = None
if rc2 == 0:
for a in json.loads(out2.decode("utf-8", "replace")):
if a.get("name") == name:
svc = _trim_service(a)
break
return web.json_response({"ok": True, "action": action, "service": svc})
except FileNotFoundError:
return web.json_response({"error": "pm2 not found on PATH"}, status=502)
except Exception as e: # never throw
return web.json_response({"ok": False, "error": f"pm2 error: {e}"}, status=502)
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_get("/pm2", pm2_status)
app.router.add_post("/pm2/control", pm2_control)
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,8 @@ 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,
digests as digest_tools,
court_fetch as cf_tools,
) )
@@ -340,6 +342,81 @@ async def search_precedent_library(
) )
# Digests radar (X12) — secondary discovery layer; NOT a citation corpus.
@mcp.tool()
async def digest_upload(
file_path: str,
yomon_number: str = "",
digest_date: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
) -> str:
"""העלאת יומון ("כל יום") לקורפוס-הגילוי (radar) + חילוץ מטא-דאטה אוטומטי. היומון הוא מקור-משני המצביע על הפסק המקורי — אינו מצוטט בהחלטה ואינו מחלץ הלכות (INV-DIG1/2). practice_area: rishuy_uvniya / betterment_levy / compensation_197."""
return await digest_tools.digest_upload(
file_path, yomon_number, digest_date, practice_area,
appeal_subtype, subject_tags,
)
@mcp.tool()
async def digest_list(
practice_area: str = "",
concept_tag: str = "",
linked: bool | None = None,
search: str = "",
limit: int = 100,
) -> str:
"""רשימת יומונים בקורפוס-הגילוי, עם פילטרים. linked=false → יומונים שהפסק המקורי שלהם עוד לא נקלט לספריית הפסיקה (פער-ידע גלוי, INV-DIG3)."""
return await digest_tools.digest_list(
practice_area, concept_tag, linked, search, _clamp_limit(limit),
)
@mcp.tool()
async def digest_get(digest_id: str) -> str:
"""יומון ספציפי לפי מזהה (כולל מראה-מקום, ניתוח, וקישור לפסק המקורי אם קיים)."""
return await digest_tools.digest_get(digest_id)
@mcp.tool()
async def digest_link(digest_id: str, case_law_id: str) -> str:
"""קישור ידני של יומון לפסק הדין המקורי בספריית הפסיקה (INV-DIG3). idempotent."""
return await digest_tools.digest_link(digest_id, case_law_id)
@mcp.tool()
async def digest_relink(digest_id: str) -> str:
"""ניסיון-קישור מחדש: בודק אם פסק הדין המקורי של היומון כבר בספרייה ומקשר אוטומטית."""
return await digest_tools.digest_relink(digest_id)
@mcp.tool()
async def digest_delete(digest_id: str) -> str:
"""מחיקת יומון מקורפוס-הגילוי."""
return await digest_tools.digest_delete(digest_id)
@mcp.tool()
async def search_digests(
query: str,
practice_area: str = "",
subject_tag: str = "",
concept_tag: str = "",
limit: int = 10,
) -> str:
"""חיפוש סמנטי בקורפוס-הגילוי (יומוני "כל יום") — מצפן-מחקר (radar). מחזיר את היומון הרלוונטי + מראה-המקום של הפסק המקורי. ⚠️ היומון אינו מצוטט בהחלטה (INV-DIG1) — הצטט מהפסק המקורי דרך search_precedent_library. החוקר משתמש בזה בשלב 2ב.0 לפני האימות."""
return await digest_tools.search_digests(
query, practice_area, subject_tag, concept_tag, _clamp_limit(limit),
)
@mcp.tool()
async def digest_process_pending(limit: int = 20) -> str:
"""ריקון תור היומונים שהועלו מה-UI וממתינים לעיבוד-LLM מקומי. מריץ חילוץ-מטא-דאטה + embedding + autolink על כל יומון 'pending' (מקומית עם CLI). חלופת-MCP ל-scripts/ingest_digests_batch.py."""
return await digest_tools.digest_process_pending(_clamp_limit(limit))
@mcp.tool() @mcp.tool()
async def halacha_review( async def halacha_review(
halacha_id: str, halacha_id: str,
@@ -895,6 +972,28 @@ 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)
@mcp.tool()
async def court_fetch_drain(limit: int = 10) -> str:
"""ריקון תור-אחזור הפסיקה — מוריד וקולט jobs ממתינים שהיומונים מילאו, וקושר חזרה ליומון. מקומי בלבד."""
return await cf_tools.court_fetch_drain(limit)
# ── Internal citations graph (TaskMaster #34) ───────────────────── # ── Internal citations graph (TaskMaster #34) ─────────────────────

View File

@@ -21,6 +21,7 @@ Output: data/cases/{case_number}/exports/ניתוח-משפטי-v{N}.docx
from __future__ import annotations from __future__ import annotations
import io
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -34,7 +35,7 @@ from docx.text.paragraph import Paragraph
from docx.text.run import Run from docx.text.run import Run
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import db, research_md from legal_mcp.services import db, research_md, storage
def _mark_run_rtl(run: Run) -> None: def _mark_run_rtl(run: Run) -> None:
@@ -494,10 +495,19 @@ async def build_analysis_docx(case_number: str) -> Path:
continue continue
_emit_content_line(doc, raw) _emit_content_line(doc, raw)
# Save versioned # Save versioned through the storage layer (INV-STG1). export_dir.mkdir +
# the glob in _next_version still read disk (correct under filesystem/dual;
# storage-native versioning is a cutover concern). out_path is always under
# DATA_DIR, so the bytes land exactly where they did before.
export_dir = case_dir / "exports" export_dir = case_dir / "exports"
export_dir.mkdir(parents=True, exist_ok=True) export_dir.mkdir(parents=True, exist_ok=True)
version = _next_version(export_dir) version = _next_version(export_dir)
out_path = export_dir / f"ניתוח-משפטי-v{version}.docx" out_path = export_dir / f"ניתוח-משפטי-v{version}.docx"
doc.save(str(out_path)) buf = io.BytesIO()
doc.save(buf)
await storage.put_bytes(
out_path.relative_to(config.DATA_DIR).as_posix(), buf.getvalue(),
bucket=storage.Bucket.DOCUMENTS,
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
return out_path return out_path

View File

@@ -335,18 +335,30 @@ async def get_legal_arguments(
case_id, case_id,
) )
# Pull supporting claim ids for each argument in one round-trip. # Pull supporting claims (id + full text) for each argument in one
# round-trip. ``supporting_claims`` stays id-only for backwards compat
# (counts, MCP consumers); ``supporting_propositions`` carries the text
# so the UI can show the raw propositions without an extra fetch.
arg_ids = [r["id"] for r in rows] arg_ids = [r["id"] for r in rows]
supporting: dict[UUID, list[str]] = {} supporting: dict[UUID, list[str]] = {}
propositions: dict[UUID, list[dict]] = {}
if arg_ids: if arg_ids:
joins = await conn.fetch( joins = await conn.fetch(
"""SELECT argument_id, claim_id """SELECT lap.argument_id, lap.claim_id,
FROM legal_argument_propositions c.claim_text, c.source_document, c.claim_index
WHERE argument_id = ANY($1::uuid[])""", FROM legal_argument_propositions lap
JOIN claims c ON c.id = lap.claim_id
WHERE lap.argument_id = ANY($1::uuid[])
ORDER BY c.claim_index""",
arg_ids, arg_ids,
) )
for j in joins: for j in joins:
supporting.setdefault(j["argument_id"], []).append(str(j["claim_id"])) supporting.setdefault(j["argument_id"], []).append(str(j["claim_id"]))
propositions.setdefault(j["argument_id"], []).append({
"id": str(j["claim_id"]),
"text": j["claim_text"],
"source_document": j["source_document"],
})
out: list[dict] = [] out: list[dict] = []
for r in rows: for r in rows:
@@ -354,5 +366,6 @@ async def get_legal_arguments(
d["id"] = str(d["id"]) d["id"] = str(d["id"])
d["case_id"] = str(d["case_id"]) d["case_id"] = str(d["case_id"])
d["supporting_claims"] = supporting.get(r["id"], []) d["supporting_claims"] = supporting.get(r["id"], [])
d["supporting_propositions"] = propositions.get(r["id"], [])
out.append(d) out.append(d)
return out return out

View File

@@ -0,0 +1,121 @@
"""Ingest a monthly "עו"ד על נדל"ן" bulletin into the digests radar (X12).
A bulletin PDF is multi-topic: it EXPLODES into several digest rows — one per
case-law pointer (digest_kind='decision') and one per article (digest_kind=
'article'), all tagged publication='עו"ד על נדל"ן' to distinguish them from the
daily "כל יום" issues. This reuses the existing radar (no parallel corpus — G2):
the case pointers join search_digests / the /digests page and autolink to the
underlying ruling exactly like a daily digest; articles are deep-context only.
LOCAL-ONLY (LLM split + embedding) — host scripts/MCP, never the container path.
Idempotent: each item's content_hash (hash of its analysis_text) is the dedup
key, so re-running a bulletin skips already-ingested items.
"""
from __future__ import annotations
import logging
from pathlib import Path
from legal_mcp.services import db, embeddings, extractor
from legal_mcp.services import bulletin_splitter, digest_library
logger = logging.getLogger(__name__)
PUBLICATION = 'עו"ד על נדל"ן'
SOURCE_FIRM = "צבי שוב + רונית אלפר, עורכי דין"
async def _store_and_embed(digest_row: dict) -> None:
"""Compute + store the single radar embedding for a freshly created item."""
emb_text = digest_library._embedding_text(digest_row)
if not emb_text:
return
try:
vecs = await embeddings.embed_texts([emb_text], input_type="document")
if vecs:
await db.store_digest_embedding(digest_row["id"], vecs[0])
except Exception as e: # §6 — surfaced, not swallowed
logger.warning("bulletin item embedding failed for %s: %s", digest_row.get("id"), e)
async def _create_item(*, analysis_text: str, kind: str, concept_tag: str,
headline: str, summary: str, citation: str, court: str,
practice_area: str, subject_tags: list[str], src: str) -> dict | None:
"""Create one digest row from a bulletin item. Returns the row, or None if it
already exists (idempotent skip) or the insert raced on content_hash."""
content_hash = db._content_hash(analysis_text)
if await db.get_digest_by_content_hash(content_hash):
return None
try:
return await db.create_digest(
analysis_text=analysis_text,
publication=PUBLICATION,
source_firm=SOURCE_FIRM,
concept_tag=concept_tag,
headline_holding=headline,
summary=summary,
underlying_citation=citation,
underlying_court=court,
practice_area=practice_area,
subject_tags=subject_tags,
source_document_path=src,
extraction_status="completed",
digest_kind=kind,
)
except Exception as e:
# uq_digests_content_hash race (concurrent run) → treat as already-present.
if "uq_digests_content_hash" in str(e):
return None
raise
async def ingest_bulletin(file_path: str, model: str | None = None) -> dict:
"""Split a bulletin PDF into digest rows (case pointers + articles).
Returns counts: {cases, articles, created, skipped, linked}. Idempotent.
"""
path = str(file_path)
raw_text, _pages, _meta = await extractor.extract_text(path)
split = await bulletin_splitter.split(raw_text, model=model)
cases, articles = split.get("cases", []), split.get("articles", [])
out = {"file": Path(path).name, "cases": len(cases), "articles": len(articles),
"created": 0, "skipped": 0, "linked": 0}
for c in cases:
# analysis_text bundles the pointer's substance → stable per-item hash.
atext = "\n".join(p for p in (
c["concept_tag"], c["headline_holding"], c["summary"], c["underlying_citation"]
) if p).strip()
row = await _create_item(
analysis_text=atext, kind="decision", concept_tag=c["concept_tag"],
headline=c["headline_holding"], summary=c["summary"],
citation=c["underlying_citation"], court=c["underlying_court"],
practice_area=c["practice_area"], subject_tags=c["subject_tags"], src=path,
)
if row is None:
out["skipped"] += 1
continue
out["created"] += 1
await _store_and_embed(row)
linked = await digest_library.try_autolink(row["id"], c["underlying_citation"])
if linked:
out["linked"] += 1
for a in articles:
# The article body is the substance; prefix authors into the summary.
body = a["body"] or a["summary"]
summary = (f"מאת {a['authors']}. " if a["authors"] else "") + (a["summary"] or "")
atext = "\n".join(p for p in (a["title"], summary, body) if p).strip()
row = await _create_item(
analysis_text=atext, kind="article", concept_tag=a["title"],
headline=a["title"], summary=summary, citation="", court="",
practice_area=a["practice_area"], subject_tags=a["subject_tags"], src=path,
)
if row is None:
out["skipped"] += 1
continue
out["created"] += 1
await _store_and_embed(row)
return out

View File

@@ -0,0 +1,147 @@
"""Split a monthly "עו"ד על נדל"ן" bulletin into typed radar items (X12).
The monthly bulletin (a SEPARATE publication from the daily "כל יום" digest) is
multi-topic: it bundles a featured ARTICLE, a list of legislative updates, and a
set of CASE-LAW pointers grouped by topic. The chair chose to catalog the
**case-law pointers** (each → a digest, like the daily issue) and the
**articles** (deep-context background) — legislative updates are skipped.
This module is the LLM splitter only. ``bulletin_library.ingest_bulletin`` turns
its output into digest rows. Like the daily extractor it is LOCAL-ONLY (claude
CLI) and MUST NOT be imported from the FastAPI container path.
"""
from __future__ import annotations
import logging
from legal_mcp import config
logger = logging.getLogger(__name__)
_VALID_PRACTICE_AREAS = {"rishuy_uvniya", "betterment_levy", "compensation_197"}
BULLETIN_SPLIT_PROMPT = """\
אתה מקבל טקסט מלא של **עלון חודשי "עו"ד על נדל"ן"** (פרסום מקצועי רב-נושאי בתחום
תכנון ובנייה, מקרקעין, היטל השבחה, פיצויים והתחדשות עירונית). פצל אותו לפריטים.
העלון בנוי משלושה חלקים: (א) **מאמר** מקצועי ארוך אחד או יותר; (ב) **עדכוני חקיקה**
(תיקוני-חוק, אישורי-תכניות, חוזרים) — **התעלם מהם, אל תחלץ**; (ג) **עדכוני פסיקה**
מקובצים לפי נושא — כל פריט = מראה-מקום של פסק דין/החלטה + שורת-תקציר.
**אל תמציא** — חלץ רק מה שמופיע בטקסט. שדה חסר → מחרוזת ריקה.
## פלט נדרש
החזר JSON אחד (object), ללא markdown:
{
"cases": [
{
"underlying_citation": "מראה-המקום המלא של הפסק כפי שמופיע, מילה במילה (למשל 'ערר 8018-02-22 הועדה המקומית בת ים נ' קבוצת מזרחי ובניו השקעות בע\\"מ'). השדה הקריטי.",
"concept_tag": "הנושא/הכותרת שתחתיה מופיע הפריט (למשל 'היטל השבחה', 'הפקעות', 'פירוק שיתוף').",
"headline_holding": "שורת-התקציר/הכותרת של הפריט — מה נקבע/השאלה (למשל 'חוסר וודאות בין תכנית קודמת לבין ההקלה').",
"summary": "תקציר ניטרלי קצר אם יש פירוט נוסף בגוף; אחרת חזור על headline_holding.",
"underlying_court": "הערכאה אם מצוינת (למשל 'בית המשפט המחוזי', 'ועדת ערר').",
"practice_area": "אחד מ: 'rishuy_uvniya' / 'betterment_levy' / 'compensation_197' — אם ברור מהנושא; אחרת ריק.",
"subject_tags": ["2-5 תגיות snake_case בעברית"]
}
],
"articles": [
{
"title": "כותרת המאמר (למשל 'הפקעת קרקעות כיום - על המחוקק לתקן את העיוות שנוצר').",
"authors": "שמות המחברים (למשל 'עו\\"ד צבי שוב, עו\\"ד רונית אלפר').",
"summary": "2-4 משפטים: על מה המאמר ומה הטענה המרכזית.",
"body": "הטקסט המלא של המאמר (כל הפסקאות), לצורך embedding וחיפוש-עומק.",
"practice_area": "אחד מ-3 אם ברור; אחרת ריק.",
"subject_tags": ["2-5 תגיות snake_case"]
}
]
}
## כללים
1. **underlying_citation** — חלץ במלואו ובדיוק; הוא הגשר לפסק. פריט-פסיקה בלי מראה-מקום ברור → דלג עליו.
2. **cases** — כל מצביעי-הפסיקה בעלון, גם אם תחת נושאים שונים. אל תאחד פריטים נפרדים.
3. **articles** — רק מאמרי-עומק (לא רשימת עדכונים). body = הטקסט המלא.
4. **עדכוני חקיקה/אישורי-תכניות/חוזרים — לא לחלץ כלל.**
5. אם אין מאמר או אין פסיקה — החזר מערך ריק לאותו מפתח.
"""
def _norm_str(d: dict, key: str) -> str:
v = d.get(key)
return v.strip() if isinstance(v, str) else ""
def _norm_tags(d: dict) -> list[str]:
tags = d.get("subject_tags")
if not isinstance(tags, list):
return []
return [str(t).strip() for t in tags if str(t).strip()][:8]
def _norm_pa(d: dict) -> str:
pa = _norm_str(d, "practice_area")
return pa if pa in _VALID_PRACTICE_AREAS else ""
async def split(raw_text: str, model: str | None = None) -> dict:
"""Return ``{"cases": [...], "articles": [...]}`` extracted from a bulletin.
Empty lists on any failure (surfaced as a warning, never raised) so the
batch keeps going. Each item is type-normalized; malformed items are dropped.
"""
from legal_mcp.services import claude_session
text = (raw_text or "").strip()
if not text:
return {"cases": [], "articles": []}
try:
result = await claude_session.query_json(
text,
system=BULLETIN_SPLIT_PROMPT,
model=(model or config.DIGEST_EXTRACT_MODEL or None),
tools="", # pure text→JSON; disable tools (avoids error_max_turns)
)
except Exception as e: # §6 — surfaced, not swallowed
logger.warning("bulletin_splitter: query failed: %s", e)
return {"cases": [], "articles": []}
if not isinstance(result, dict):
logger.warning("bulletin_splitter: expected dict, got %s", type(result).__name__)
return {"cases": [], "articles": []}
cases: list[dict] = []
for c in result.get("cases") or []:
if not isinstance(c, dict):
continue
citation = _norm_str(c, "underlying_citation")
if not citation: # rule 1: no anchor → skip
continue
cases.append({
"underlying_citation": citation,
"concept_tag": _norm_str(c, "concept_tag"),
"headline_holding": _norm_str(c, "headline_holding"),
"summary": _norm_str(c, "summary") or _norm_str(c, "headline_holding"),
"underlying_court": _norm_str(c, "underlying_court"),
"practice_area": _norm_pa(c),
"subject_tags": _norm_tags(c),
})
articles: list[dict] = []
for a in result.get("articles") or []:
if not isinstance(a, dict):
continue
title = _norm_str(a, "title")
body = _norm_str(a, "body")
if not (title or body):
continue
articles.append({
"title": title,
"authors": _norm_str(a, "authors"),
"summary": _norm_str(a, "summary"),
"body": body,
"practice_area": _norm_pa(a),
"subject_tags": _norm_tags(a),
})
return {"cases": cases, "articles": articles}

View File

@@ -82,6 +82,7 @@ async def query(
system: str | None = None, system: str | None = None,
model: str | None = None, model: str | None = None,
effort: str | None = None, effort: str | None = None,
tools: str | None = None,
) -> str: ) -> str:
"""Send a prompt to Claude Code headless and return the text response. """Send a prompt to Claude Code headless and return the text response.
@@ -104,6 +105,12 @@ async def query(
effort: Optional effort level (``low``/``medium``/``high``/``xhigh``/ effort: Optional effort level (``low``/``medium``/``high``/``xhigh``/
``max``). When set, passed as ``--effort``. Pairs with ``model``; ``max``). When set, passed as ``--effort``. Pairs with ``model``;
an empty string is treated as "unset" (CLI default). an empty string is treated as "unset" (CLI default).
tools: Optional available-tools spec, passed as ``--tools``. Pass an
empty string (``""``) to disable ALL tools — for pure text→JSON
extraction the model has no reason to call a tool, and leaving
tools enabled makes it occasionally emit ``stop_reason: tool_use``
which trips ``--max-turns 1`` → ``error_max_turns`` and forces a
retry (slow). ``None`` leaves the CLI default (all tools).
Returns: Returns:
The text response from Claude. The text response from Claude.
@@ -126,6 +133,8 @@ async def query(
cmd += ["--model", model] cmd += ["--model", model]
if effort: if effort:
cmd += ["--effort", effort] cmd += ["--effort", effort]
if tools is not None: # "" → disable all tools (no tool_use → no max-turns trip)
cmd += ["--tools", tools]
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else "" size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
last_err = "unknown error" last_err = "unknown error"
@@ -204,13 +213,15 @@ async def query_json(
system: str | None = None, system: str | None = None,
model: str | None = None, model: str | None = None,
effort: str | None = None, effort: str | None = None,
tools: str | None = None,
) -> dict | list | None: ) -> dict | list | None:
"""Send a prompt and parse the response as JSON. """Send a prompt and parse the response as JSON.
Uses parse_llm_json for robust parsing (handles markdown wrapping, truncation). Uses parse_llm_json for robust parsing (handles markdown wrapping, truncation).
``model``/``effort`` are forwarded to :func:`query` (see its docstring). ``model``/``effort``/``tools`` are forwarded to :func:`query` (see its docstring).
Pure text→JSON extractors should pass ``tools=""`` to avoid ``error_max_turns``.
""" """
raw = await query(prompt, timeout=timeout, system=system, model=model, effort=effort) raw = await query(prompt, timeout=timeout, system=system, model=model, effort=effort, tools=tools)
return parse_llm_json(raw) return parse_llm_json(raw)

View File

@@ -0,0 +1,212 @@
"""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. Still parse a נט-format triple when the
# number carries one (e.g. בר"מ 72182-06-25): נט המשפט serves Supreme
# cases too, so a triple lets the orchestrator route to the validated
# Tier-1 flow instead of the serial-only Tier-0.
m = _SUPREME_RX.search(text)
if m:
raw = m.group(2)
norm = normalize_case_number(raw)
filed = _split_filed(norm)
return CourtCitation(
tier="supreme",
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,
)
# 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,323 @@
"""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). It binds the docker0 bridge
# gateway (10.0.1.1) — same as legal-chat-service — so both the host MCP server
# and containers can reach it; the host reaches 10.0.1.1 as a local interface.
# Override with COURT_FETCH_SERVICE_URL.
COURT_FETCH_SERVICE_URL = os.environ.get(
"COURT_FETCH_SERVICE_URL", "http://10.0.1.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 ──
# Route by what the number lets us do, not just the court prefix: נט המשפט
# (Tier 1) serves ALL courts — Supreme included — as long as the citation
# carries a נט-format triple (file-month-year). Validated live on both
# district (עת"מ 43830-12-24) and Supreme (בר"מ 72182-06-25). Only a serial-
# only Supreme number (e.g. עע"מ 5886/24, no month) can't be looked up that
# way → fall through to Tier 0 (supremedecisions).
has_net_format = bool(cit.file_number and cit.month and cit.year)
try:
if has_net_format:
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
elif 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:
raise RuntimeError(
f"מספר-תיק {cit.case_number_norm} אינו בפורמט נט-המשפט ואינו עליון — "
"אין מסלול-אחזור ציבורי"
)
except Exception as e: # noqa: BLE001 — any fetch error is recorded, never
# left hanging in 'running' (INV-CF2). _record_failure escalates to
# 'manual' after MAX_AUTONOMOUS_ATTEMPTS (INV-CF3).
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="",
)
# Close the digest gap (INV-DIG3): if this fetch traces back to a digest,
# link it to the freshly-ingested ruling. Best-effort; never fails the job.
link_digest_id = digest_id or job.get("digest_id")
if case_law_id and link_digest_id:
try:
await db.link_digest_to_case_law(link_digest_id, UUID(str(case_law_id)))
logger.info("linked digest %s → case_law %s", link_digest_id, case_law_id)
except Exception:
logger.warning("could not relink digest %s after fetch", link_digest_id)
# Close any open missing-precedent gap this fetch fills (the citation graph
# often records the same ruling as a gap). Best-effort.
if case_law_id:
await _close_matching_gaps(cit.case_number_norm, UUID(str(case_law_id)))
return {"status": "done", "tier": cit.tier, "case_law_id": case_law_id,
"citation": citation, "source_url": source_url, "ingest": result}
async def _close_matching_gaps(case_number_norm: str, case_law_id: UUID) -> None:
"""Close open missing_precedents whose citation matches the fetched case."""
try:
gaps = await db.list_missing_precedents(status="open", limit=500)
for g in gaps:
if court_citation.normalize_case_number(g.get("citation", "")) == case_number_norm:
await db.close_missing_precedent(
UUID(str(g["id"])), linked_case_law_id=case_law_id,
status="closed", notes="נקלט אוטומטית דרך אחזור-פסיקה (X13)",
)
logger.info("closed missing_precedent %s", g["id"])
except Exception:
logger.warning("could not close gaps for %s", case_number_norm)
# Politeness between consecutive court fetches in a drain (INV-CF4) — serial,
# spaced. Mirrors the precedent-extraction queue cadence.
_INTER_FETCH_COOLDOWN_S = float(os.environ.get("COURT_FETCH_DRAIN_COOLDOWN_S", "20"))
async def drain_pending(limit: int = 10) -> dict:
"""Process queued court-fetch jobs (status pending/failed) serially.
Drains the ``court_fetch_jobs`` queue the digest trigger fills — fetch +
ingest each, link back to its digest. Serial with a cooldown (INV-CF4); a
job that fails is recorded and retried next drain until it escalates to
``manual`` (INV-CF3). Local-only (runs the ingest pipeline / claude CLI).
"""
import asyncio
jobs = await db.court_fetch_job_list(status="pending", limit=limit)
jobs += await db.court_fetch_job_list(status="failed", limit=limit)
seen, queue = set(), []
for j in jobs:
k = j["case_number_norm"]
if k not in seen:
seen.add(k); queue.append(j)
results = []
for i, j in enumerate(queue[:limit]):
if i:
await asyncio.sleep(_INTER_FETCH_COOLDOWN_S)
digest_id = j.get("digest_id")
try:
r = await fetch_and_ingest(j["citation_raw"], digest_id=digest_id)
except Exception as e: # noqa: BLE001 — recorded per-job, never aborts the drain
logger.exception("drain item failed: %s", j["case_number_norm"])
r = {"status": "error", "citation": j["citation_raw"], "error": str(e)}
results.append(r)
done = sum(1 for r in results if r.get("status") in ("done", "already_done"))
return {"processed": len(results), "done": done, "results": results}
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,197 @@
"""Tier 0 — Supreme Court verdict fetcher (X13), via supremedecisions.court.gov.il.
Pulls a published Supreme Court verdict PDF from the **public** decisions portal
— no smart-card, no CAPTCHA, no browser (pure httpx). Used for serial-format
citations (בג"ץ/בר"מ/עע"מ NNNN/YY) that have no נט-format triple and so can't go
through the Tier-1 נט-המשפט flow.
The portal is an AngularJS SPA over a small ASP.NET JSON API, reverse-engineered
and validated live (2026-06-08 on בג"ץ 3483/05 → 75 KB PDF). The flow:
POST Home/SearchVerdicts
body: {"document": {"Year": "YYYY", "CaseNum": "NNNN", "Month": {},
"dateType": 1, "publishDate": 8,
"SearchText": [<empty clause>],
"OldMainNumFormat": true}, "lan": 1}
{"data": [{Path, FileName, CaseName, Type, Pages, VerdictDt, ...}, ...]}
GET Home/Download?path=<Path>&fileName=<FileName>&type=4 → the verdict PDF
Two things are required to get JSON instead of an F5 WAF block (verified):
* the **X-Requested-With: XMLHttpRequest** header on every AJAX call;
* a **complete** browser header set (UA + Accept + Accept-Language).
A case can have many documents (interim החלטות + the final פסק דין). We pick the
verdict: prefer a record whose Type contains "פסק דין", else the most-paginated /
latest one. Politeness (INV-CF4): serial, with a cooldown.
"""
from __future__ import annotations
import asyncio
import datetime as _dt
import logging
import os
import re
import urllib.parse
import httpx
logger = logging.getLogger(__name__)
_BASE = "https://supremedecisions.court.gov.il"
_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",
"X-Requested-With": "XMLHttpRequest", # required — F5 WAF blocks AJAX without it
"Referer": _BASE + "/",
}
_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"))
_DOC_TYPE_PDF = "4"
# Empty search clause the portal expects inside the document.
_EMPTY_CLAUSE = {
"Text": "", "textOperator": 1, "option": 2, "Inverted": False,
"Synonym": False, "NearDistance": 3, "MatchOrder": False,
}
class FetchedVerdict:
"""A downloaded verdict file held in memory, ready for ingest."""
def __init__(self, content: bytes, filename: str, source_url: str,
court: str = "בית המשפט העליון", case_name: str = ""):
self.content = content
self.filename = filename
self.source_url = source_url
self.court = court
self.case_name = case_name
class SupremeFetchError(RuntimeError):
"""The public portal returned an unexpected shape / no document. Carries a
Hebrew reason for the job row (INV-CF2)."""
def _four_digit_year(yy: str) -> str:
"""2-digit citation year → 4-digit. Pivot on the current year: a 2-digit
value above (this year + 4) is last century. e.g. 05→2005, 87→1987, 16→2016."""
yy = re.sub(r"\D", "", yy or "")
if len(yy) == 4:
return yy
if len(yy) != 2:
return yy
n = int(yy)
cutoff = (_dt.date.today().year % 100) + 4
return f"20{yy}" if n <= cutoff else f"19{yy}"
def _parse_serial(case_number_norm: str, citation: str) -> tuple[str, str]:
"""Extract (CaseNum, YYYY) from a serial citation like 'בג"ץ 3483/05'.
Works off the normalized number (e.g. '3483-05') with the raw citation as a
fallback. Raises SupremeFetchError if it can't find a NNNN/YY pair.
"""
m = re.search(r"(\d{1,5})[-/](\d{2,4})\b", case_number_norm or "")
if not m:
m = re.search(r"(\d{1,5})/(\d{2,4})", citation or "")
if not m:
raise SupremeFetchError(
f"לא ניתן לפרק '{citation}' למספר-תיק/שנה (פורמט עליון סדרתי)"
)
return m.group(1), _four_digit_year(m.group(2))
def _dt_key(r: dict) -> int:
m = re.search(r"/Date\((\d+)", str(r.get("VerdictDt") or ""))
return int(m.group(1)) if m else 0
def _rank_candidates(records: list[dict]) -> list[dict]:
"""Order a case's documents by how good a corpus target each is, best first.
Preference: the reasoned ruling (Type contains 'פסק') over interim החלטות;
then more pages (substantive over one-liners); then most recent. We return
a *ranked list*, not one pick, because the formally-labeled פסק-דין is
sometimes a published-report ('s'-prefix) file that the free Download
endpoint blocks (WAF) — the caller tries each until one downloads as a PDF.
Records without a Path/FileName are dropped.
"""
usable = [r for r in records if r.get("Path") and r.get("FileName")]
def _score(r: dict) -> tuple:
is_verdict = 1 if "פסק" in str(r.get("Type") or "") else 0
return (is_verdict, int(r.get("Pages") or 0), _dt_key(r))
return sorted(usable, key=_score, reverse=True)
async def fetch_supreme_verdict(
*, citation: str, case_number_norm: str
) -> FetchedVerdict:
"""Fetch a Supreme Court verdict PDF by serial citation. Raises on failure."""
case_num, yyyy = _parse_serial(case_number_norm, citation)
async with httpx.AsyncClient(
http2=False, headers=_HEADERS, timeout=_REQUEST_TIMEOUT_S,
follow_redirects=True,
) as client:
document = {
"Year": yyyy, "CaseNum": case_num, "Month": {},
"dateType": 1, "publishDate": 8, "SearchText": [dict(_EMPTY_CLAUSE)],
"OldMainNumFormat": True,
}
try:
await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S)
resp = await client.post(
f"{_BASE}/Home/SearchVerdicts", json={"document": document, "lan": 1}
)
resp.raise_for_status()
payload = resp.json()
except httpx.HTTPError as e:
raise SupremeFetchError(f"חיפוש בפורטל העליון נכשל עבור {citation}: {e}") from e
except ValueError as e:
raise SupremeFetchError(f"תשובת-חיפוש לא-JSON מהפורטל עבור {citation}") from e
records = payload.get("data") if isinstance(payload, dict) else None
candidates = _rank_candidates(records or [])
if not candidates:
raise SupremeFetchError(
f"לא נמצא מסמך-פסק עבור {citation} בפורטל העליון "
f"(תיק {case_num}/{yyyy[-2:]}; ייתכן שאינו פורסם או טרם דיגיטציה)."
)
# Try documents best-first until one downloads as a real PDF. The
# formally-labeled פסק-דין is sometimes a published-report file the free
# Download endpoint blocks (WAF) — fall back to the next substantive doc.
last_reason = ""
for rec in candidates[:6]:
path, fname = str(rec["Path"]), str(rec["FileName"])
qs = urllib.parse.urlencode(
{"path": path, "fileName": fname, "type": _DOC_TYPE_PDF}
)
try:
await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S)
dl = await client.get(f"{_BASE}/Home/Download?{qs}")
dl.raise_for_status()
except httpx.HTTPError as e:
last_reason = f"הורדה נכשלה ({e})"
continue
if dl.content[:4] == b"%PDF":
return FetchedVerdict(
content=dl.content,
filename=f"{case_number_norm}.pdf",
source_url=f"{_BASE}/Home/Download?{qs}",
case_name=str(rec.get("CaseName") or ""),
)
last_reason = f"מסמך {fname} חסום/לא-PDF ({len(dl.content)}B)"
raise SupremeFetchError(
f"אף מסמך של {citation} לא ירד כ-PDF ({len(candidates)} מועמדים) — {last_reason}"
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,422 @@
"""Orchestrator for the Digests radar (X12).
A digest ("כל יום" daily one-pager) is a SECONDARY source that POINTS at a
ruling — it is never cited in a decision (INV-DIG1) and never enters the
precedent/halacha pipeline (INV-DIG2). Ingest reuses only ATOMIC services
(extract_text, embeddings), NOT the canonical ``ingest.ingest_document``.
Two intake paths share one enrichment core:
- ``ingest_digest`` (local/MCP, e.g. batch script) — does everything
synchronously: stage → extract_text → create →
LLM enrich → embed → autolink → completed.
- ``create_pending_digest`` (CONTAINER-SAFE — the web upload) — stage →
extract_text → create row with status='pending'.
No LLM, no embedding. ``process_pending_digests``
(local/MCP) drains the queue and enriches.
claude_session rule: ``digest_metadata_extractor`` (local CLI) is imported
LAZILY inside the enrichment core only, so this module stays import-safe from
the FastAPI container for create_pending / search / list / link / delete
(DB + voyage only — voyage embedding only runs in the local enrich path).
"""
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
from typing import Awaitable, Callable
from uuid import UUID
from legal_mcp import config
from legal_mcp.services import db, embeddings, extractor, ingest
logger = logging.getLogger(__name__)
ProgressCb = Callable[[str, int, str], Awaitable[None]]
DIGEST_LIBRARY_DIR = Path(config.DATA_DIR) / "digests"
_VALID_PRACTICE_AREAS = frozenset(
{"", "rishuy_uvniya", "betterment_levy", "compensation_197"}
)
async def _noop_progress(_status: str, _percent: int, _msg: str) -> None:
return None
def _coerce_date(v) -> date | None:
if v is None or v == "":
return None
if isinstance(v, date):
return v
if isinstance(v, str):
try:
return date.fromisoformat(v[:10])
except ValueError:
return None
return None
def _embedding_text(row: dict) -> str:
"""The single vector indexes the digest as an atomic discovery unit."""
parts = [
row.get("concept_tag", ""),
row.get("headline_holding", ""),
row.get("summary", ""),
row.get("analysis_text", ""),
]
return "\n".join(p for p in parts if p).strip()
async def try_autolink(digest_id: UUID | str, underlying_citation: str) -> str | None:
"""Best-effort link of a digest to the underlying ruling in case_law
(INV-DIG3). Returns the case_law_id (str) if linked, else None. Never raises."""
citation = (underlying_citation or "").strip()
if not citation:
return None
try:
match = await db.find_case_law_by_citation_fuzzy(citation)
except Exception as e:
logger.warning("digest try_autolink lookup failed for %r: %s", citation, e)
return None
if not match:
# Gap (INV-DIG3): the underlying ruling isn't in the corpus. If it's a
# court verdict (not ועדת-ערר), enqueue an X13 auto-fetch job so the gap
# is actionable instead of silently dropped (INV-CF2). Never raises.
await _enqueue_court_fetch(digest_id, citation)
return None
await db.link_digest_to_case_law(digest_id, match["id"])
return str(match["id"])
async def _enqueue_court_fetch(digest_id: UUID | str, citation: str) -> None:
"""Queue an X13 court-verdict fetch for an unlinked digest citation.
Court rulings (supreme/admin) → a ``court_fetch_jobs`` row drained later by
``court_fetch_drain``. ועדת-ערר (skip) is left alone — it needs Nevo and is
surfaced through the normal missing-precedent path, not auto-fetch.
"""
try:
from legal_mcp.services import court_citation
cit = court_citation.classify(citation)
if cit.tier not in ("supreme", "admin"):
return
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=UUID(str(digest_id)),
)
logger.info("digest %s: enqueued court-fetch for %r (tier=%s)",
digest_id, citation, cit.tier)
except Exception as e: # never break digest ingest
logger.warning("digest court-fetch enqueue failed for %r: %s", citation, e)
# ── Container-safe creation (web upload) — no LLM, no embedding ──────
async def create_pending_digest(
*,
file_path: str | Path,
yomon_number: str = "",
digest_date: date | str | None = None,
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
progress: ProgressCb | None = None,
) -> dict:
"""Stage the file, extract text (PyMuPDF — container-safe), and create a
digest row with extraction_status='pending'. The LLM metadata extraction,
embedding, and autolink are deferred to ``process_pending_digests`` (local).
Returns {status, digest_id, extraction_status} or {status:'exists', ...}.
Idempotent on content_hash (INV-G3).
"""
progress = progress or _noop_progress
if practice_area and practice_area not in _VALID_PRACTICE_AREAS:
raise ValueError(f"invalid practice_area: {practice_area!r}")
src = Path(file_path)
if not src.exists():
raise ValueError(f"file not found: {file_path}")
await progress("staging", 10, "מעתיק קובץ")
staged = await ingest._stage_file(src, DIGEST_LIBRARY_DIR, "incoming")
rel_path = str(staged.relative_to(config.DATA_DIR)) \
if str(staged).startswith(str(config.DATA_DIR)) else str(staged)
await progress("extracting_text", 50, "מחלץ טקסט")
raw_text, _pc, _off = await extractor.extract_text(str(staged))
raw_text = (raw_text or "").strip()
if not raw_text:
raise ValueError("no text extracted from digest")
content_hash = db._content_hash(raw_text)
existing = await db.get_digest_by_content_hash(content_hash)
if existing:
await progress("completed", 100, "יומון זהה כבר קיים")
return {"status": "exists", "digest_id": existing["id"],
"extraction_status": existing.get("extraction_status")}
record = await db.create_digest(
analysis_text=raw_text,
yomon_number=yomon_number.strip(),
digest_date=_coerce_date(digest_date),
practice_area=practice_area,
appeal_subtype=appeal_subtype.strip(),
subject_tags=list(subject_tags) if subject_tags else [],
source_document_path=rel_path,
extraction_status="pending",
)
await progress("queued", 100, "ממתין לעיבוד מקומי (LLM)")
return {"status": "pending", "digest_id": record["id"],
"extraction_status": "pending"}
# ── Local enrichment core (LLM + embed + autolink) ──────────────────
async def enrich_digest(digest_id: UUID | str, progress: ProgressCb | None = None) -> dict:
"""Run LLM metadata extraction over a digest's analysis_text, fill ONLY
empty fields (preserve user-supplied values), embed, autolink, complete.
**MCP-tool-only path** (uses the local LLM extractor). Idempotent.
"""
progress = progress or _noop_progress
row = await db.get_digest(digest_id)
if not row:
raise ValueError("digest not found")
analysis = (row.get("analysis_text") or "").strip()
if not analysis:
await db.update_digest(digest_id, extraction_status="failed")
return {"status": "no_text", "digest_id": str(digest_id)}
await db.update_digest(digest_id, extraction_status="processing")
await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (LLM)")
from legal_mcp.services import digest_metadata_extractor
extracted = await digest_metadata_extractor.extract(analysis)
# Fill only empty fields (preserve user-supplied values from the form).
fields: dict = {}
for key in ("yomon_number", "concept_tag", "headline_holding", "summary",
"underlying_citation", "underlying_court", "underlying_judge",
"practice_area", "appeal_subtype"):
if not (row.get(key) or "").strip() and extracted.get(key):
fields[key] = extracted[key]
if row.get("digest_date") is None and extracted.get("digest_date"):
fields["digest_date"] = extracted["digest_date"]
if row.get("underlying_date") is None and extracted.get("underlying_date"):
fields["underlying_date"] = extracted["underlying_date"]
if not (row.get("subject_tags") or []) and extracted.get("subject_tags"):
fields["subject_tags"] = extracted["subject_tags"]
# digest_kind classifies the issue (decision vs announcement). A successful
# extraction (any field returned) must end with a non-empty kind — that is the
# signal the drain self-heal uses to tell "enriched" from "failed". If the
# model omitted it, infer: a ruling citation → decision, else announcement.
if extracted and not (row.get("digest_kind") or "").strip():
kind = extracted.get("digest_kind")
if kind not in ("decision", "announcement", "other"):
cite = fields.get("underlying_citation") or row.get("underlying_citation") or ""
kind = "decision" if cite.strip() else "announcement"
fields["digest_kind"] = kind
if fields:
try:
await db.update_digest(digest_id, **fields)
except Exception as e:
# The same yomon issue can arrive as two different PDFs (re-sent /
# forwarded twice → different bytes → content_hash dedup misses it),
# but the yomon_number is unique. The extracted number then collides
# on uq_digests_yomon_number. This row is a duplicate of an already-
# ingested yomon → drop it so it isn't retried forever by the cron.
if "uq_digests_yomon_number" in str(e):
await db.delete_digest(digest_id)
logger.info(
"digest %s is a duplicate yomon (%s) — deleted",
digest_id, fields.get("yomon_number"),
)
return {"status": "duplicate", "digest_id": str(digest_id),
"yomon_number": fields.get("yomon_number")}
raise
merged = await db.get_digest(digest_id)
await progress("embedding", 75, "מחשב embedding")
emb_text = _embedding_text(merged)
if emb_text:
try:
vecs = await embeddings.embed_texts([emb_text], input_type="document")
if vecs:
await db.store_digest_embedding(digest_id, vecs[0])
except Exception as e: # surfaced, not swallowed (§6)
logger.warning("digest embedding failed for %s: %s", digest_id, e)
await progress("linking", 90, "מנסה לקשר לפסק המקורי")
linked_id = None
if not merged.get("linked_case_law_id"):
linked_id = await try_autolink(digest_id, merged.get("underlying_citation", ""))
await db.update_digest(digest_id, extraction_status="completed")
await progress("completed", 100, "הושלם")
return {
"status": "completed",
"digest_id": str(digest_id),
"yomon_number": merged.get("yomon_number", ""),
"underlying_citation": merged.get("underlying_citation", ""),
"linked_case_law_id": merged.get("linked_case_law_id") or linked_id,
"fields_filled": sorted(fields.keys()),
}
async def process_pending_digests(limit: int = 20) -> dict:
"""Drain the digest extraction queue (rows stamped extraction_status='pending'
by the web upload). Local/MCP only — runs the LLM enrichment per row.
Sequential (avoids LLM rate-limit storms), mirrors process_pending_extractions."""
pending = await db.list_pending_digests(limit=limit)
if not pending:
return {"status": "no_pending", "processed": 0, "results": []}
results = []
processed = 0
for row in pending:
did = row["id"]
try:
res = await enrich_digest(did)
processed += 1
results.append({"digest_id": str(did), "status": res.get("status"),
"linked": bool(res.get("linked_case_law_id"))})
except Exception as e:
logger.exception("process_pending_digests failed for %s: %s", did, e)
try:
await db.update_digest(did, extraction_status="failed")
except Exception:
logger.exception("could not mark digest %s failed", did)
results.append({"digest_id": str(did), "status": "failed", "error": str(e)})
return {"status": "completed", "processed": processed,
"total_pending": len(pending), "results": results}
# ── Full synchronous ingest (local/MCP, e.g. batch script) ──────────
async def ingest_digest(
*,
file_path: str | Path,
yomon_number: str = "",
digest_date: date | str | None = None,
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
progress: ProgressCb | None = None,
) -> dict:
"""Ingest one digest synchronously. **MCP-tool-only** (uses the LLM).
Creates the row (with any user-supplied values) then enriches in place.
Idempotent on content_hash (INV-G3).
"""
progress = progress or _noop_progress
created = await create_pending_digest(
file_path=file_path, yomon_number=yomon_number, digest_date=digest_date,
practice_area=practice_area, appeal_subtype=appeal_subtype,
subject_tags=subject_tags, progress=progress,
)
if created.get("status") == "exists":
return created
digest_id = created["digest_id"]
enriched = await enrich_digest(digest_id, progress=progress)
return enriched
# ── Linking (INV-DIG3) ──────────────────────────────────────────────
async def link_digest(digest_id: UUID | str, case_law_id: UUID | str) -> dict:
"""Manually link a digest to an underlying ruling (INV-DIG3). Idempotent."""
digest = await db.get_digest(digest_id)
if not digest:
raise ValueError("digest not found")
ruling = await db.get_case_law(
case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
)
if not ruling:
raise ValueError("case_law not found")
updated = await db.link_digest_to_case_law(digest_id, case_law_id)
return {
"linked": True,
"digest_id": str(digest_id),
"case_law_id": str(case_law_id),
"case_number": ruling.get("case_number"),
"digest": updated,
}
async def relink_digest(digest_id: UUID | str) -> dict:
"""Re-run autolink for an unlinked digest. No-op if already linked / no match."""
digest = await db.get_digest(digest_id)
if not digest:
raise ValueError("digest not found")
if digest.get("linked_case_law_id"):
return {"linked": True, "digest_id": str(digest_id),
"case_law_id": digest["linked_case_law_id"], "changed": False}
linked_id = await try_autolink(digest_id, digest.get("underlying_citation", ""))
return {
"linked": linked_id is not None,
"digest_id": str(digest_id),
"case_law_id": linked_id,
"changed": linked_id is not None,
}
async def unlink_digest(digest_id: UUID | str) -> dict:
"""Clear a digest's link to the underlying ruling."""
updated = await db.link_digest_to_case_law(digest_id, None)
if updated is None:
raise ValueError("digest not found")
return {"unlinked": True, "digest_id": str(digest_id)}
# ── Read / search (container-safe: DB + voyage) ─────────────────────
async def search_digests(
query: str,
practice_area: str = "",
subject_tag: str = "",
concept_tag: str = "",
limit: int = 10,
) -> list[dict]:
"""Semantic search over the digests radar. Container-safe (voyage + DB)."""
if not query.strip():
return []
query_vec = await embeddings.embed_query(query)
return await db.search_digests_semantic(
query_embedding=query_vec,
practice_area=practice_area,
subject_tag=subject_tag,
concept_tag=concept_tag,
limit=limit,
)
async def get_digest(digest_id: UUID | str) -> dict | None:
return await db.get_digest(digest_id)
async def list_digests(
practice_area: str = "",
concept_tag: str = "",
linked: bool | None = None,
search: str = "",
publication: str = "",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
return await db.list_digests(
practice_area=practice_area, concept_tag=concept_tag, linked=linked,
search=search, publication=publication, limit=limit, offset=offset,
)
async def update_digest(digest_id: UUID | str, **fields) -> dict | None:
return await db.update_digest(digest_id, **fields)
async def delete_digest(digest_id: UUID | str) -> bool:
return await db.delete_digest(digest_id)

View File

@@ -0,0 +1,151 @@
"""Auto-extract catalog metadata from a "כל יום" daily digest (X12).
A digest is a one-page secondary summary (Ofer Toister) of a single ruling.
This module reads its raw text and asks the local Claude CLI to extract the
fields the radar needs: yomon number, concept tag, headline holding, a short
summary, the UNDERLYING ruling's citation (the critical bridge field — INV-DIG3),
its court / date / judge, practice area and subject tags.
claude_session rule: this module imports ``claude_session`` (the local CLI),
so it is **MCP-tool-only** — never import it from the FastAPI container. It is
pulled in lazily inside ``digest_library.ingest_digest`` only.
Unlike ``precedent_metadata_extractor`` (which patches a DB row), this returns
a plain dict from raw text; ``digest_library`` decides how to merge/store it.
"""
from __future__ import annotations
import logging
from datetime import date as date_type
from legal_mcp import config
from legal_mcp.config import parse_llm_json
from legal_mcp.services import claude_session
logger = logging.getLogger(__name__)
_VALID_PRACTICE_AREAS = {"", "rishuy_uvniya", "betterment_levy", "compensation_197"}
# Concatenated with f-strings at call time, NOT .format() — the JSON example
# below contains '{' / '}' which str.format would treat as placeholders and
# crash (same trap documented in precedent_metadata_extractor).
DIGEST_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. לפניך "יומון" — סיכום עמוד-אחד של משרד עפר טויסטר (עלון "כל יום")
על **החלטה/פסק דין** אחד, או על **עדכון/הודעה** (חקיקה, נוהל, הודעת-תכנון, ברכת-שנה) בתחום תכנון ובנייה / היטל השבחה / פיצויים (ס' 197). חלץ ממנו מטא-דאטה לקטלוג.
**אל תמציא** — שדה שלא מופיע בטקסט → השאר ריק (מחרוזת ריקה / מערך ריק).
## פלט נדרש
החזר JSON אחד (object — לא array), ללא markdown וללא הסברים:
{
"digest_kind": "סווג את הגיליון: 'decision' = סיכום פסק דין/החלטה (יש מראה-מקום בתחתית) · 'announcement' = עדכון/הודעה ללא הכרעה (חקיקה, נוהל, הודעת-תכנון, ברכה) · 'other' = אחר. **חובה למלא תמיד.**",
"yomon_number": "מספר היומון מהכותרת ('יומון מס' 5163''5163'). ספרות בלבד. אם אין — ריק.",
"digest_date_iso": "YYYY-MM-DD — תאריך גיליון היומון (בכותרת, למשל '7 ביוני 2026''2026-06-07').",
"concept_tag": "תג-המושג/הכותרת בראש העמוד (למשל 'שיקול הדעת המצומצם', 'Cherry-picking', או 'עדכונים לשנה החדשה' בעדכון). ביטוי קצר אחד. **חלץ תמיד — קיים לכל סוג גיליון.**",
"headline_holding": "הכותרת המודגשת מתחת לתג — משפט אחד שמסכם את עיקר הגיליון (מה נקבע בהחלטה, או נושא העדכון). **חלץ תמיד.**",
"summary": "תקציר ניטרלי 2-3 משפטים בגוף שלישי: בהחלטה — מה הייתה השאלה ומה הוכרע; בעדכון — מה תוכן/משמעות העדכון. בלי שיפוט. **חלץ תמיד.**",
"underlying_citation": "**רק ל-decision** — מראה-המקום של פסק הדין/ההחלטה המקורי, כפי שמופיע בתחתית היומון, מילה במילה (למשל 'עת\\"מ 46111-12-22 יכין-אפק בע\\"מ נ' הוועדה המחוזית'). בעדכון/הודעה — ריק. זהו השדה הקריטי ל-decision — חלץ אותו במלואו ובדיוק.",
"underlying_court": "הערכאה שנתנה את פסק הדין המקורי (למשל 'בית המשפט לעניינים מנהליים מרכז-לוד', 'ועדת הערר מחוז ירושלים').",
"underlying_date_iso": "YYYY-MM-DD — תאריך מתן פסק הדין/ההחלטה המקורי (לרוב 'ניתן ביום DD.M.YY' בתחתית). שים לב: זה שונה מתאריך גיליון היומון!",
"underlying_judge": "שם השופט/ת או יו\\"ר ההרכב שנתן את פסק הדין המקורי (למשל 'יעל טויסטר ישראלי'). בלי תארים ('עו\\"ד', 'כב' השופט').",
"practice_area": "אחד מ-3: 'rishuy_uvniya' (רישוי ובנייה/הקלות/שימוש חורג) / 'betterment_levy' (היטל השבחה) / 'compensation_197' (פיצויים ס'197). אם לא ברור — ריק.",
"appeal_subtype": "תת-סוג קצר אם בולט (למשל 'הקלה', 'שיקול דעת הוועדה', 'מימוש במכר'). אחרת ריק.",
"subject_tags": ["3-7 תגיות בעברית snake_case (שיקול_דעת, הקלה, ועדה_מחוזית, היטל_השבחה, ...)"]
}
## כללי איכות
1. **digest_kind** — חובה. אם יש מראה-מקום של פסק דין/החלטה בתחתית → 'decision'. אם זה עדכון/הודעה/נוהל/ברכה ללא הכרעה → 'announcement'.
2. **concept_tag / headline_holding / summary** — חלץ **תמיד**, לכל סוג גיליון (גם עדכון). אלה לא ייחודיים להחלטות.
3. **underlying_citation** — רק ל-decision; הוא הגשר לפסק הדין. בעדכון — השאר ריק (זה תקין, לא חוסר).
4. **הבחן בין שני התאריכים**: digest_date_iso = תאריך גיליון היומון (בכותרת); underlying_date_iso = מועד מתן פסק הדין (בתחתית, 'ניתן ביום...'). אל תבלבל.
5. **subject_tags** — snake_case, תחום ועדת ערר תכנון ובנייה בלבד.
6. אם רכיב לא מופיע בבירור — השאר את אותו שדה ריק. אל תנחש.
"""
def _norm_str(result: dict, key: str) -> str:
v = result.get(key)
return v.strip() if isinstance(v, str) else ""
def _norm_date(result: dict, key: str) -> date_type | None:
v = result.get(key)
if not isinstance(v, str) or not v.strip():
return None
try:
return date_type.fromisoformat(v.strip()[:10])
except ValueError:
logger.debug("digest_metadata_extractor: ignoring invalid %s=%r", key, v)
return None
async def extract(raw_text: str, model: str | None = None) -> dict:
"""Extract digest metadata from raw text. Returns a dict (never raises).
Keys: yomon_number, digest_date (date|None), concept_tag, headline_holding,
summary, underlying_citation, underlying_court, underlying_date (date|None),
underlying_judge, practice_area, appeal_subtype, subject_tags (list[str]).
Missing/invalid fields are omitted so the caller's merge keeps user values.
Model: defaults to ``config.DIGEST_EXTRACT_MODEL`` (Sonnet — this is a
high-volume, simple extraction; no need for Opus). Override per-call via
``model``.
"""
text = (raw_text or "").strip()
if not text:
return {}
user_msg = f"--- תחילת היומון ---\n{text}\n--- סוף היומון ---"
try:
result = await claude_session.query_json(
user_msg, system=DIGEST_EXTRACTION_PROMPT,
model=(model or config.DIGEST_EXTRACT_MODEL or None),
tools="", # pure text→JSON: disable tools so the model never emits
# stop_reason=tool_use and trips --max-turns (error_max_turns).
)
except Exception as e: # surfaced as warning, not swallowed silently (§6)
logger.warning("digest_metadata_extractor: query failed: %s", e)
return {}
if not isinstance(result, dict):
logger.warning(
"digest_metadata_extractor: expected dict, got %s",
type(result).__name__,
)
return {}
out: dict = {}
for key in (
"yomon_number", "concept_tag", "headline_holding", "summary",
"underlying_citation", "underlying_court", "underlying_judge",
"appeal_subtype",
):
s = _norm_str(result, key)
if s:
out[key] = s
kind = _norm_str(result, "digest_kind").lower()
if kind in ("decision", "announcement", "other"):
out["digest_kind"] = kind
dd = _norm_date(result, "digest_date_iso")
if dd is not None:
out["digest_date"] = dd
ud = _norm_date(result, "underlying_date_iso")
if ud is not None:
out["underlying_date"] = ud
pa = _norm_str(result, "practice_area")
if pa in _VALID_PRACTICE_AREAS and pa:
out["practice_area"] = pa
tags = result.get("subject_tags")
if isinstance(tags, list):
clean = [str(t).strip() for t in tags if str(t).strip()]
if clean:
out["subject_tags"] = clean
return out

View File

@@ -5,6 +5,7 @@
from __future__ import annotations from __future__ import annotations
import io
import logging import logging
import re import re
from datetime import date from datetime import date
@@ -17,7 +18,7 @@ from docx.oxml import OxmlElement
from docx.oxml.ns import qn from docx.oxml.ns import qn
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import db from legal_mcp.services import db, storage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -474,8 +475,19 @@ async def export_decision(
pass pass
output_path = str(export_dir / f"{prefix}-v{next_ver}.docx") output_path = str(export_dir / f"{prefix}-v{next_ver}.docx")
# Persist through the storage layer (INV-STG1). Under the filesystem
# backend the bytes land at output_path exactly as before; a caller-
# provided path outside DATA_DIR falls back to a direct disk write.
buf = io.BytesIO()
doc.save(buf)
data = buf.getvalue()
_docx_ctype = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
try:
key = Path(output_path).resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix()
await storage.put_bytes(key, data, bucket=storage.Bucket.DOCUMENTS, content_type=_docx_ctype)
except ValueError:
Path(output_path).parent.mkdir(parents=True, exist_ok=True) Path(output_path).parent.mkdir(parents=True, exist_ok=True)
doc.save(output_path) Path(output_path).write_bytes(data)
logger.info("DOCX exported (mode=%s): %s", mode, output_path) logger.info("DOCX exported (mode=%s): %s", mode, output_path)
return output_path return output_path

View File

@@ -14,6 +14,9 @@ from __future__ import annotations
import logging import logging
import re import re
import shutil import shutil
from legal_mcp import config
from legal_mcp.services import storage
import zipfile import zipfile
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
@@ -304,9 +307,16 @@ def retrofit_bookmarks(
end_idx = len(paragraphs) - 1 end_idx = len(paragraphs) - 1
ranges.append((name, start_idx, max(start_idx, end_idx))) ranges.append((name, start_idx, max(start_idx, end_idx)))
# Backup if overwriting in place # Backup if overwriting in place — through the storage layer (INV-STG1).
if backup and output_path.resolve() == docx_path.resolve(): if backup and output_path.resolve() == docx_path.resolve():
backup_path = docx_path.with_suffix(".pre-retrofit.docx") backup_path = docx_path.with_suffix(".pre-retrofit.docx")
try:
_bkey = backup_path.resolve().relative_to(
Path(config.DATA_DIR).resolve()).as_posix()
storage.put_file_sync(
docx_path, _bkey, bucket=storage.Bucket.DOCUMENTS,
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document")
except ValueError:
shutil.copy2(str(docx_path), str(backup_path)) shutil.copy2(str(docx_path), str(backup_path))
# Inject bookmarks, skipping any that already exist # Inject bookmarks, skipping any that already exist

View File

@@ -13,6 +13,9 @@ from __future__ import annotations
import logging import logging
import shutil import shutil
from legal_mcp import config
from legal_mcp.services import storage
import zipfile import zipfile
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -98,6 +101,22 @@ def _load_docx_xml(docx_path: Path) -> tuple[dict[str, bytes], etree._Element, e
return members, document_tree, settings_tree return members, document_tree, settings_tree
_DOCX_CTYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
def _persist_docx_sync(output_path: Path, data: bytes) -> None:
"""Persist DOCX bytes through the storage layer (INV-STG1); fall back to a
direct disk write when output_path is outside DATA_DIR (caller-provided)."""
out = Path(output_path)
try:
key = out.resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix()
storage.put_bytes_sync(key, data, bucket=storage.Bucket.DOCUMENTS,
content_type=_DOCX_CTYPE)
except ValueError:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(data)
def _save_docx_xml( def _save_docx_xml(
members: dict[str, bytes], members: dict[str, bytes],
document_tree: etree._Element, document_tree: etree._Element,
@@ -113,12 +132,11 @@ def _save_docx_xml(
settings_tree, xml_declaration=True, encoding="UTF-8", standalone=True settings_tree, xml_declaration=True, encoding="UTF-8", standalone=True
) )
output_path.parent.mkdir(parents=True, exist_ok=True)
buffer = BytesIO() buffer = BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for name, data in members.items(): for name, data in members.items():
zf.writestr(name, data) zf.writestr(name, data)
output_path.write_bytes(buffer.getvalue()) _persist_docx_sync(output_path, buffer.getvalue())
def _ensure_track_revisions(settings_tree: etree._Element) -> None: def _ensure_track_revisions(settings_tree: etree._Element) -> None:
@@ -511,4 +529,11 @@ def copy_with_revisions(
source_path: str | Path, output_path: str | Path, source_path: str | Path, output_path: str | Path,
) -> None: ) -> None:
"""Copy source → output unchanged (used when revisions list is empty).""" """Copy source → output unchanged (used when revisions list is empty)."""
shutil.copy2(str(source_path), str(output_path)) out = Path(output_path)
try:
key = out.resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix()
storage.put_file_sync(source_path, key, bucket=storage.Bucket.DOCUMENTS,
content_type=_DOCX_CTYPE)
except ValueError:
out.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(source_path), str(out))

View File

@@ -23,6 +23,7 @@ from docx import Document as DocxDocument
from striprtf.striprtf import rtf_to_text from striprtf.striprtf import rtf_to_text
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import storage
if TYPE_CHECKING: if TYPE_CHECKING:
from google.cloud import vision from google.cloud import vision
@@ -345,6 +346,18 @@ def render_pages_for_multimodal(
max(1, int(img.height * ratio)), max(1, int(img.height * ratio)),
) )
thumb = img.resize(thumb_size, Image.Resampling.LANCZOS) thumb = img.resize(thumb_size, Image.Resampling.LANCZOS)
# Persist the thumbnail (a DERIVED, regenerable artifact)
# through the storage layer (INV-STG1). Under the filesystem
# backend it lands at thumb_path exactly as before.
_tbuf = io.BytesIO()
thumb.save(_tbuf, "JPEG", quality=75, optimize=True)
try:
_tkey = thumb_path.resolve().relative_to(
Path(config.DATA_DIR).resolve()).as_posix()
storage.put_bytes_sync(
_tkey, _tbuf.getvalue(), bucket=storage.Bucket.DERIVED,
content_type="image/jpeg")
except ValueError:
thumb.save(thumb_path, "JPEG", quality=75, optimize=True) thumb.save(thumb_path, "JPEG", quality=75, optimize=True)
out.append((img, thumb_path)) out.append((img, thumb_path))

View File

@@ -0,0 +1,97 @@
"""Gemini structured-output helper — a drop-in for ``claude_session.query_json``
for BOUNDED extraction tasks (text → JSON).
Why a second LLM path: metadata extraction is a single structured call (fill
case_name/summary/headnote/tags from a verdict's text), not an agentic loop. The
``claude -p`` CLI behind ``claude_session`` is agentic — it reaches for tools and
hits ``error_max_turns`` on a task that should be one shot — so it was slow and
flaky for the precedent metadata queue. Gemini Flash with JSON mode
(``responseMimeType: application/json``) is the right tool: one call, schema-
clean JSON, fast, and ~$0.10/1M tokens (negligible for this volume).
Scope: **bounded extraction only** (precedent metadata). The agentic, voice-
sensitive work — decision writing, analysis, halacha extraction — stays on
``claude_session`` (Daphna's subscription, zero API cost). This is a deliberate
per-task provider choice, not a wholesale move off Claude.
Key: ``GEMINI_API_KEY`` (host ~/.env; SoT Infisical nautilus:/external-apis/gemini
as ``GOOGLE_GEMINI_API_KEY``). Model: ``GEMINI_MODEL`` (default gemini-2.5-flash).
Direct REST via httpx — no extra SDK dependency.
"""
from __future__ import annotations
import json
import logging
import os
import httpx
logger = logging.getLogger(__name__)
_BASE = "https://generativelanguage.googleapis.com/v1beta"
_DEFAULT_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
_DEFAULT_TIMEOUT = float(os.environ.get("GEMINI_TIMEOUT_S", "120"))
class GeminiError(RuntimeError):
"""Gemini API call failed or returned an unexpected shape."""
def _api_key() -> str:
key = os.environ.get("GEMINI_API_KEY", "").strip()
if not key:
raise GeminiError(
"GEMINI_API_KEY אינו מוגדר (host ~/.env / Infisical "
"nautilus:/external-apis/gemini)."
)
return key
async def query_json(
prompt: str,
timeout: float | int = _DEFAULT_TIMEOUT,
*,
system: str | None = None,
model: str | None = None,
# Accepted for drop-in parity with claude_session.query_json; ignored here.
effort: str | None = None,
tools: str | None = None,
) -> dict | list | None:
"""Single structured-output call → parsed JSON. Drop-in for
``claude_session.query_json``. Raises ``GeminiError`` on failure (the caller
treats that like any extraction failure — recorded, never silently wrong).
"""
model = model or _DEFAULT_MODEL
body: dict = {
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
"generationConfig": {
"responseMimeType": "application/json",
"temperature": 0,
},
}
if system:
body["system_instruction"] = {"parts": [{"text": system}]}
url = f"{_BASE}/models/{model}:generateContent"
try:
async with httpx.AsyncClient(timeout=float(timeout)) as client:
resp = await client.post(url, params={"key": _api_key()}, json=body)
except httpx.HTTPError as e:
raise GeminiError(f"Gemini request failed: {e}") from e
if resp.status_code != 200:
raise GeminiError(f"Gemini HTTP {resp.status_code}: {resp.text[:200]}")
data = resp.json()
# Surface an explicit safety/finish block rather than returning empty.
cand = (data.get("candidates") or [{}])[0]
if cand.get("finishReason") in ("SAFETY", "RECITATION", "PROHIBITED_CONTENT"):
raise GeminiError(f"Gemini blocked output: finishReason={cand['finishReason']}")
try:
text = cand["content"]["parts"][0]["text"]
except (KeyError, IndexError, TypeError) as e:
raise GeminiError(f"Gemini unexpected response: {str(data)[:200]}") from e
try:
return json.loads(text)
except json.JSONDecodeError as e:
raise GeminiError(f"Gemini returned non-JSON: {text[:200]}") from e

View File

@@ -6,8 +6,10 @@ structured list of halachot, validates each one against the source text,
embeds the rule statement, and stores everything as ``pending_review`` in embeds the rule statement, and stores everything as ``pending_review`` in
the ``halachot`` table. the ``halachot`` table.
All extraction is idempotent — calling ``extract(case_law_id)`` twice All extraction is idempotent — calling ``extract(case_law_id, force=True)``
deletes prior rows for that precedent first. twice drops the precedent's un-reviewed rows and re-extracts. Chair-approved /
published halachot are PRESERVED across a re-extract (INV-G10); see
``db.reset_halacha_extraction``.
Trust model: Trust model:
Per chair decision, NO halacha is auto-published. Every extracted Per chair decision, NO halacha is auto-published. Every extracted
@@ -76,8 +78,12 @@ EXTRACTABLE_SECTIONS = ("legal_analysis", "ruling", "conclusion")
# wants to be able to cite "another committee reached the same conclusion" # wants to be able to cite "another committee reached the same conclusion"
# even though it is not binding. # even though it is not binding.
# #
# The schema's rule_type field accepts six values: # The prompt branches on is_binding only to choose the EXTRACTION STRATEGY
# binding | interpretive | procedural | obiter | application | persuasive # (what to pull, how to phrase) — NOT the rule_type. rule_type is the rule
# ROLE and uses the SAME five values for both sources (INV-DM7):
# holding | interpretive | procedural | application | obiter
# The authority axis (binding/persuasive) is derived from the source, never
# a rule_type value — so the model never classifies it.
HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה בדיני תכנון ובניה (ועדות ערר, היטל השבחה, פיצויים לפי סעיף 197 לחוק התכנון והבניה). תפקידך: לחלץ הלכות מחייבות מתוך פסק דין/החלטה משפטית של ערכאה עליונה (עליון / מנהלי). HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה בדיני תכנון ובניה (ועדות ערר, היטל השבחה, פיצויים לפי סעיף 197 לחוק התכנון והבניה). תפקידך: לחלץ הלכות מחייבות מתוך פסק דין/החלטה משפטית של ערכאה עליונה (עליון / מנהלי).
@@ -101,10 +107,12 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
הלכה אחת יכולה לחול על כמה תחומים — practice_areas הוא array ולא string יחיד. הלכה אחת יכולה לחול על כמה תחומים — practice_areas הוא array ולא string יחיד.
## סוגי הלכה (rule_type) ## סוג הכלל (rule_type) — מהות הכלל בלבד, לא סמכות-המקור
- binding — הלכה מחייבת שהוחלה על התיק. **אל תסווג "מחייב/משכנע"** — דרגת-המחייבות נגזרת אוטומטית מזהות הערכאה. כאן בחר רק את **סוג הכלל**:
- interpretive — פרשנות סעיף חוק/תכנית שאומצה. - holding — עיקרון מהותי שהיה הכרחי להכרעה (ה-ratio; מבחן Wambaugh: שלילתו הייתה משנה את התוצאה).
- procedural — כלל פרוצדורלי (סמכות, מועדים, הליכי שמיעה). - interpretive — פרשנות הוראת-חוק/מונח/תכנית שאומצה.
- procedural — כלל סדר-דין (סמכות, מועדים, זכות-עמידה, מיצוי הליכים, נטל).
- application — החלת כלל על עובדות התיק (תלוי-עובדות; לרוב לא-הלכה בת-הכללה).
- obiter — אמרת אגב חשובה (חלץ רק אם משמעותית; סמן confidence נמוך). - obiter — אמרת אגב חשובה (חלץ רק אם משמעותית; סמן confidence נמוך).
## פלט נדרש ## פלט נדרש
@@ -112,7 +120,7 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
[ [
{ {
"rule_statement": "ניסוח הכלל בלשון משפטית מדויקת בגוף שלישי, 1-3 משפטים.", "rule_statement": "ניסוח הכלל בלשון משפטית מדויקת בגוף שלישי, 1-3 משפטים.",
"rule_type": "binding", "rule_type": "holding",
"reasoning_summary": "תמצית ההיגיון: למה בית המשפט הגיע לכלל הזה (1-2 משפטים).", "reasoning_summary": "תמצית ההיגיון: למה בית המשפט הגיע לכלל הזה (1-2 משפטים).",
"supporting_quote": "ציטוט מילולי מדויק מהפסק התומך בכלל. חייב להופיע מילה במילה בטקסט הקלט.", "supporting_quote": "ציטוט מילולי מדויק מהפסק התומך בכלל. חייב להופיע מילה במילה בטקסט הקלט.",
"page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות מהקלט.", "page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות מהקלט.",
@@ -139,11 +147,11 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
המקור הזה **אינו** מקור להלכות מחייבות חדשות (binding rules). הלכות מחייבות מגיעות מהעליון/מנהלי. עם זאת, יש כאן ערך משמעותי שצריך לחלץ — איך הפנל הזה ניתח ויישם את הדין הקיים. כשנכתוב החלטה עתידית, נצטט מהמקור הזה כ"גם ועדת הערר ב-X הגיעה למסקנה דומה" — לא כסמכות מחייבת, אלא כתמיכה משכנעת. המקור הזה **אינו** מקור להלכות מחייבות חדשות (binding rules). הלכות מחייבות מגיעות מהעליון/מנהלי. עם זאת, יש כאן ערך משמעותי שצריך לחלץ — איך הפנל הזה ניתח ויישם את הדין הקיים. כשנכתוב החלטה עתידית, נצטט מהמקור הזה כ"גם ועדת הערר ב-X הגיעה למסקנה דומה" — לא כסמכות מחייבת, אלא כתמיכה משכנעת.
**יש לחלץ:** **יש לחלץ** (סווג לפי **סוג הכלל** בלבד — אל תסווג "מחייב/משכנע", דרגת-המחייבות נגזרת אוטומטית):
- **יישום של הלכה ידועה** (rule_type=`application`) — הפנל החיל הלכה ידועה (של עליון/מנהלי) על עובדות הנידונות. תצטט את ניסוח הכלל **כפי שהוצג כאן** (לא בהכרח כפי שנקבע במקור) ואת התוצאה. - **יישום של הלכה ידועה** (rule_type=`application`) — הפנל החיל הלכה ידועה (של עליון/מנהלי) על עובדות הנידונות. תצטט את ניסוח הכלל **כפי שהוצג כאן** (לא בהכרח כפי שנקבע במקור) ואת התוצאה.
- **עקרון פרשני שאומץ** (rule_type=`interpretive`) — איך הפנל פירש סעיף חוק / תכנית, באופן שניתן לאמץ. - **עקרון פרשני שאומץ** (rule_type=`interpretive`) — איך הפנל פירש סעיף חוק / תכנית, באופן שניתן לאמץ.
- **כלל פרוצדורלי** (rule_type=`procedural`) — קביעות בנושאי סמכות, מועדים, הליך. - **כלל פרוצדורלי** (rule_type=`procedural`) — קביעות בנושאי סמכות, מועדים, הליך.
- **מסקנה מנומקת ומשכנעת** (rule_type=`persuasive`) — מסקנה שלמה של הפנל בסוגיה, עם ההיגיון התומך, ניתנת לציטוט כאסמכתא משכנעת. - **מסקנה מהותית מנומקת** (rule_type=`holding`) — מסקנה עקרונית שלמה של הפנל בסוגיה, עם ההיגיון התומך, בת-הכללה ובת-הסתמכות.
**אין לחלץ:** **אין לחלץ:**
- ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים קונקרטיים) — חלץ את העיקרון/היישום בניסוח בר-הכללה בלבד. - ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים קונקרטיים) — חלץ את העיקרון/היישום בניסוח בר-הכללה בלבד.
@@ -175,7 +183,7 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
## כללי איכות ## כללי איכות
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה. 1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה.
2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אל תמתח את הרשימה. אם אין מה לחלץ — החזר []. 2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אל תמתח את הרשימה. אם אין מה לחלץ — החזר [].
3. **rule_type מדויק** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. persuasive = מסקנה כללית בעלת ערך כאסמכתא. 3. **rule_type מדויק (סוג הכלל בלבד)** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. holding = מסקנה מהותית עקרונית. **לא** binding/persuasive (סמכות נגזרת אוטומטית).
4. **לא לפצל יתר על המידה — קריטי** — כל פריט = שאלה משפטית מובחנת אחת. פנים שונים של אותה שאלה = פריט אחד (בחר את הניסוח הכללי ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים. 4. **לא לפצל יתר על המידה — קריטי** — כל פריט = שאלה משפטית מובחנת אחת. פנים שונים של אותה שאלה = פריט אחד (בחר את הניסוח הכללי ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים.
5. **שפה** — עברית משפטית מקצועית, גוף שלישי. 5. **שפה** — עברית משפטית מקצועית, גוף שלישי.
6. **subject_tags** — 2-5 תגיות בעברית, snake_case. 6. **subject_tags** — 2-5 תגיות בעברית, snake_case.
@@ -184,10 +192,15 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
_VALID_PRACTICE_AREAS = {"rishuy_uvniya", "betterment_levy", "compensation_197"} _VALID_PRACTICE_AREAS = {"rishuy_uvniya", "betterment_levy", "compensation_197"}
# rule_type holds the rule ROLE only — what KIND of statement it is (INV-DM7).
# The authority axis (binding/persuasive) is DERIVED from the source, never a
# rule_type value: see halacha_quality.derive_authority.
_VALID_RULE_TYPES = { _VALID_RULE_TYPES = {
"binding", "interpretive", "procedural", "obiter", "holding", "interpretive", "procedural", "application", "obiter",
"application", "persuasive",
} }
# Legacy authority-as-role values → fold to the nearest genuine role. Kept so
# old LLM outputs (and pre-split rows re-fed) coerce safely.
_LEGACY_RULE_TYPE_FOLD = {"binding": "holding", "persuasive": "interpretive"}
def _normalize_for_comparison(text: str) -> str: def _normalize_for_comparison(text: str) -> str:
@@ -227,13 +240,14 @@ def _verify_quote(supporting_quote: str, full_text: str) -> bool:
return False return False
def _coerce_halacha(raw: dict, is_binding: bool = True) -> dict | None: def _coerce_halacha(raw: dict) -> dict | None:
"""Validate and normalize one LLM-returned halacha dict. """Validate and normalize one LLM-returned halacha dict.
Returns ``None`` if the entry is missing required fields. ``is_binding`` Returns ``None`` if the entry is missing required fields. ``rule_type`` is
only affects the default rule_type when the LLM returned an unknown the rule ROLE only (INV-DM7) — it is NEVER defaulted from the source's
value — for binding sources we default to ``binding``, otherwise to bindingness (that was the source-conflation this split removed). Legacy
``persuasive`` (never pretend an appeals committee created halacha). authority values fold to the nearest role; unknown defaults to
``interpretive`` (the most common role).
""" """
if not isinstance(raw, dict): if not isinstance(raw, dict):
return None return None
@@ -242,13 +256,10 @@ def _coerce_halacha(raw: dict, is_binding: bool = True) -> dict | None:
if not rule_statement or not supporting_quote: if not rule_statement or not supporting_quote:
return None return None
default_rule_type = "binding" if is_binding else "persuasive" rule_type = (raw.get("rule_type") or "").strip().lower()
rule_type = (raw.get("rule_type") or default_rule_type).strip().lower() rule_type = _LEGACY_RULE_TYPE_FOLD.get(rule_type, rule_type)
if rule_type not in _VALID_RULE_TYPES: if rule_type not in _VALID_RULE_TYPES:
rule_type = default_rule_type rule_type = "interpretive"
# Guard: don't let a non-binding source produce 'binding' rule_type
if not is_binding and rule_type == "binding":
rule_type = "persuasive"
practice_areas_raw = raw.get("practice_areas") or [] practice_areas_raw = raw.get("practice_areas") or []
if isinstance(practice_areas_raw, str): if isinstance(practice_areas_raw, str):
@@ -521,8 +532,20 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
return {"status": "no_chunks", "extracted": 0, "stored": 0} return {"status": "no_chunks", "extracted": 0, "stored": 0}
# force = clean slate; otherwise resume (skip already-checkpointed chunks). # force = clean slate; otherwise resume (skip already-checkpointed chunks).
# "Clean slate" preserves chair-approved/published halachot (INV-G10) — only
# un-reviewed rows are dropped; the per-chunk dedup-on-insert skips fresh
# extractions that duplicate a preserved approval, so approvals survive a
# re-extract without duplicating. See db.reset_halacha_extraction / #108.
preserved_approved = 0
if force: if force:
await db.reset_halacha_extraction(case_law_id) reset = await db.reset_halacha_extraction(case_law_id)
preserved_approved = reset.get("preserved", 0)
if preserved_approved:
logger.info(
"halacha_extractor: case_law=%s force re-extract — preserved %d "
"approved/published halachot (INV-G10), dropped %d un-reviewed.",
case_law_id, preserved_approved, reset.get("deleted", 0),
)
for c in chunks: for c in chunks:
c["halacha_extracted_at"] = None c["halacha_extracted_at"] = None
@@ -580,7 +603,7 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
return return
cleaned: list[dict] = [] cleaned: list[dict] = []
for raw in items: for raw in items:
coerced = _coerce_halacha(raw, is_binding=is_binding) coerced = _coerce_halacha(raw)
if coerced is None: if coerced is None:
continue continue
coerced["quote_verified"] = _verify_quote( coerced["quote_verified"] = _verify_quote(
@@ -597,10 +620,10 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
coerced["quality_flags"] = flags coerced["quality_flags"] = flags
if halacha_quality.FLAG_NON_DECISION in flags and coerced["rule_type"] != "obiter": if halacha_quality.FLAG_NON_DECISION in flags and coerced["rule_type"] != "obiter":
coerced["rule_type"] = "obiter" coerced["rule_type"] = "obiter"
# #81.4 — a binding-labeled rule that reads as a case-application is # #81.4 — a holding-labeled rule that reads as a case-application is
# re-typed application (it carries FLAG_APPLICATION either way). # re-typed application (it carries FLAG_APPLICATION either way).
elif (halacha_quality.FLAG_APPLICATION in flags elif (halacha_quality.FLAG_APPLICATION in flags
and coerced["rule_type"] == "binding"): and coerced["rule_type"] == "holding"):
coerced["rule_type"] = "application" coerced["rule_type"] = "application"
cleaned.append(coerced) cleaned.append(coerced)
# #81.3 NLI entailment — one batched judge call per chunk (fail-open). # #81.3 NLI entailment — one batched judge call per chunk (fail-open).
@@ -677,5 +700,6 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
"folded": folded, "folded": folded,
"stored": stored, "stored": stored,
"stored_this_run": stored_total, "stored_this_run": stored_total,
"preserved_approved": preserved_approved,
"total_chunks": len(chunks), "total_chunks": len(chunks),
} }

View File

@@ -18,6 +18,37 @@ from __future__ import annotations
import re import re
# ── Authority axis — DERIVED from the source, never LLM-classified (INV-DM7) ──
#
# A halacha's *authority* (binding vs persuasive) is a property of WHERE it came
# from, not of the rule's content. It is therefore derived deterministically
# from ``case_law.precedent_level`` and never stored on ``halachot`` or guessed
# by the extractor — keeping it orthogonal to ``rule_type`` (the rule ROLE).
# Higher courts (עליון/מנהלי) bind the appeals committee; another committee is
# only persuasive. See docs/spec/02-data-model.md INV-DM7.
AUTHORITY_BINDING = "binding"
AUTHORITY_PERSUASIVE = "persuasive"
_BINDING_LEVELS = {"עליון", "מנהלי"}
_PERSUASIVE_LEVELS = {"ועדת_ערר_מחוזית"}
def derive_authority(precedent_level: str | None) -> str | None:
"""Map a source's precedent_level to its authority over the committee.
Returns ``"binding"`` for higher courts (עליון/מנהלי), ``"persuasive"`` for
another appeals committee (ועדת_ערר_מחוזית), or ``None`` when the level is
unknown/empty (never guesses). Pure — the single source of truth for the
authority axis (INV-DM7).
"""
level = (precedent_level or "").strip()
if level in _BINDING_LEVELS:
return AUTHORITY_BINDING
if level in _PERSUASIVE_LEVELS:
return AUTHORITY_PERSUASIVE
return None
# ── Hebrew text normalization (shared with the extractor's quote check) ── # ── Hebrew text normalization (shared with the extractor's quote check) ──
_HEB_QUOTE_VARIANTS = "\"'׳״‘’“”«»„′″" _HEB_QUOTE_VARIANTS = "\"'׳״‘’“”«»„′″"
@@ -337,7 +368,7 @@ def compute_quality_flags(
supporting_quote: str, supporting_quote: str,
reasoning_summary: str = "", reasoning_summary: str = "",
quote_verified: bool = True, quote_verified: bool = True,
rule_type: str = "binding", rule_type: str = "interpretive",
) -> list[str]: ) -> list[str]:
"""Return the list of quality flags for one halacha (empty == clean). """Return the list of quality flags for one halacha (empty == clean).

View File

@@ -14,8 +14,8 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import mimetypes
import re import re
import shutil
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
@@ -23,7 +23,7 @@ from typing import Awaitable, Callable
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor from legal_mcp.services import chunker, db, embeddings, extractor, storage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -66,11 +66,20 @@ def _safe_filename(name: str) -> str:
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"upload-{uuid4().hex[:8]}" return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"upload-{uuid4().hex[:8]}"
def _stage_file(src_path: Path, root: Path, subdir: str) -> Path: async def _stage_file(src_path: Path, root: Path, subdir: str) -> Path:
dest_dir = root / (subdir or "other") """Stage an intake file through the unified storage layer (INV-STG1).
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / f"{uuid4().hex[:8]}_{_safe_filename(src_path.name)}" Returns the DATA_DIR path the rest of the pipeline reads from — under the
shutil.copy2(src_path, dest) filesystem/dual backends the bytes are on disk and the key is the
DATA_DIR-relative path. The Hebrew original filename rides as object
metadata, never as the key (INV-STG2)."""
dest = root / (subdir or "other") / f"{uuid4().hex[:8]}_{_safe_filename(src_path.name)}"
key = dest.relative_to(config.DATA_DIR).as_posix()
await storage.put_file(
src_path, key, bucket=storage.Bucket.DOCUMENTS,
content_type=mimetypes.guess_type(src_path.name)[0],
metadata={"filename": src_path.name},
)
return dest return dest
@@ -151,7 +160,7 @@ async def ingest_document(
if not src.is_file(): if not src.is_file():
raise FileNotFoundError(f"file not found: {src}") raise FileNotFoundError(f"file not found: {src}")
await progress("staging", 5, "מעתיק את הקובץ לאחסון") await progress("staging", 5, "מעתיק את הקובץ לאחסון")
staged = _stage_file(src, spec.staging_root, spec.staging_subdir(inputs)) staged = await _stage_file(src, spec.staging_root, spec.staging_subdir(inputs))
await progress("extracting", 15, "מחלץ טקסט מהקובץ") await progress("extracting", 15, "מחלץ טקסט מהקובץ")
try: try:
raw_text, page_count, page_offsets = await extractor.extract_text(str(staged)) raw_text, page_count, page_offsets = await extractor.extract_text(str(staged))

View File

@@ -15,6 +15,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os
from pathlib import Path from pathlib import Path
from typing import Awaitable, Callable from typing import Awaitable, Callable
from uuid import UUID from uuid import UUID
@@ -137,6 +138,10 @@ async def reextract_halachot(
) -> dict: ) -> dict:
"""Re-run the halacha extractor on an existing precedent. Idempotent. """Re-run the halacha extractor on an existing precedent. Idempotent.
Chair-approved / published halachot are PRESERVED across the re-extract
(INV-G10) — only un-reviewed rows are replaced. See
``db.reset_halacha_extraction`` / TaskMaster #108.
**MCP-tool-only path.** This function calls into ``halacha_extractor``, **MCP-tool-only path.** This function calls into ``halacha_extractor``,
which calls ``claude_session`` — the local CLI is required. Invoking which calls ``claude_session`` — the local CLI is required. Invoking
this from the FastAPI container will raise ``Claude CLI not found``. this from the FastAPI container will raise ``Claude CLI not found``.
@@ -156,9 +161,10 @@ async def reextract_halachot(
# bad data. See note in db.request_metadata_extraction. # bad data. See note in db.request_metadata_extraction.
await progress("extracting_halachot", 50, "מחלץ הלכות מחדש") await progress("extracting_halachot", 50, "מחלץ הלכות מחדש")
# Explicit re-extraction = clean slate (force): wipe prior halachot + # Explicit re-extraction = clean slate (force): drop un-reviewed halachot +
# per-chunk checkpoints and redo all. (Queue draining / resume uses the # clear per-chunk checkpoints and redo all, but PRESERVE chair-approved /
# default force=False so an interrupted run continues where it stopped.) # published rows (INV-G10; dedup-on-insert avoids duplicating them). (Queue
# draining / resume uses force=False so an interrupted run continues.)
result = await halacha_extractor.extract(case_law_id, force=True) result = await halacha_extractor.extract(case_law_id, force=True)
# Clear the queue timestamp on completion so the UI badge / worker queue # Clear the queue timestamp on completion so the UI badge / worker queue
# don't keep showing this row. The queue worker (process_pending_extractions) # don't keep showing this row. The queue worker (process_pending_extractions)
@@ -179,6 +185,9 @@ async def reextract_halachot(
# precedent into a 429 storm. Observed 2026-05-03: 1110/20 succeeded with 9 # precedent into a 429 storm. Observed 2026-05-03: 1110/20 succeeded with 9
# halachot, 317/10 immediately after returned silent no_halachot. # halachot, 317/10 immediately after returned silent no_halachot.
INTER_PRECEDENT_COOLDOWN_SEC = 30 INTER_PRECEDENT_COOLDOWN_SEC = 30
# Metadata extraction is on Gemini (fast, high rate limits) — a brief spacer is
# enough; the 30s above is for the Claude-backed halacha path.
METADATA_COOLDOWN_SEC = float(os.environ.get("METADATA_COOLDOWN_SEC", "2"))
# How many times to retry a precedent that came back as 'extraction_failed' # How many times to retry a precedent that came back as 'extraction_failed'
# (i.e. >50% chunks crashed). Each retry uses a longer cooldown. # (i.e. >50% chunks crashed). Each retry uses a longer cooldown.
@@ -212,6 +221,16 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
if kind not in {"metadata", "halacha"}: if kind not in {"metadata", "halacha"}:
raise ValueError("kind must be 'metadata' or 'halacha'") raise ValueError("kind must be 'metadata' or 'halacha'")
# Self-heal stale 'processing' rows (fully unattended): a drain that crashed
# mid-extraction can leave a row status='processing' with its requested_at
# cleared — orphaned, so it would never be re-picked. Re-stamp it so it
# re-drains (the halacha extractor uses force=False → resumes from chunk
# checkpoints, no duplicates). Safe under the global advisory lock (only one
# drain runs at a time). Mirrors the digests-drain self-heal.
healed = await db.requeue_stale_processing_extractions(kind=kind)
if healed:
logger.warning("self-healed %d stale '%s' processing row(s)", healed, kind)
pending = await db.list_pending_extraction_requests(kind=kind, limit=limit) pending = await db.list_pending_extraction_requests(kind=kind, limit=limit)
if not pending: if not pending:
return {"status": "no_pending", "kind": kind, "processed": 0, "results": []} return {"status": "no_pending", "kind": kind, "processed": 0, "results": []}
@@ -226,11 +245,14 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
cid, effort=config.HALACHA_BULK_EXTRACT_EFFORT, cid, effort=config.HALACHA_BULK_EXTRACT_EFFORT,
) )
# Metadata extraction runs on Gemini (high rate limits, fast) — the long
# cooldown is only needed for halacha (Claude/Anthropic rate limits).
cooldown = METADATA_COOLDOWN_SEC if kind == "metadata" else INTER_PRECEDENT_COOLDOWN_SEC
results: list[dict] = [] results: list[dict] = []
processed = 0 processed = 0
for idx, row in enumerate(pending): for idx, row in enumerate(pending):
if idx > 0: if idx > 0:
await asyncio.sleep(INTER_PRECEDENT_COOLDOWN_SEC) await asyncio.sleep(cooldown)
cid = UUID(str(row["id"])) cid = UUID(str(row["id"]))
attempts = 0 attempts = 0
result: dict = {} result: dict = {}

View File

@@ -19,7 +19,7 @@ from datetime import date as date_type
from uuid import UUID from uuid import UUID
from legal_mcp.config import parse_llm_json from legal_mcp.config import parse_llm_json
from legal_mcp.services import claude_session, db from legal_mcp.services import db, gemini_session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -150,7 +150,10 @@ async def extract_metadata(case_law_id: UUID | str) -> dict:
) )
try: try:
result = await claude_session.query_json( # Bounded structured extraction → Gemini Flash (JSON mode). The agentic
# claude CLI hit error_max_turns on this single-shot task; see
# gemini_session.py. Voice-sensitive/agentic work stays on claude_session.
result = await gemini_session.query_json(
user_msg, system=METADATA_EXTRACTION_PROMPT, user_msg, system=METADATA_EXTRACTION_PROMPT,
) )
except Exception as e: except Exception as e:

View File

@@ -8,7 +8,9 @@ from pathlib import Path
from uuid import UUID from uuid import UUID
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor, references_extractor from legal_mcp.services import (
chunker, db, embeddings, extractor, references_extractor, storage,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -40,13 +42,17 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
page_count=page_count, page_count=page_count,
) )
# Save extracted text to documents/extracted/ directory # Save extracted text (a DERIVED artifact — the DB column holds the
# source of truth, INV-STG5) through the storage layer (INV-STG1).
# Non-fatal: the .txt is a convenience copy, the pipeline reads the DB.
original_path = Path(doc["file_path"]) original_path = Path(doc["file_path"])
extracted_dir = original_path.parent.parent / "extracted" txt_path = original_path.parent.parent / "extracted" / (original_path.stem + ".txt")
extracted_dir.mkdir(parents=True, exist_ok=True)
txt_path = extracted_dir / (original_path.stem + ".txt")
try: try:
txt_path.write_text(text, encoding="utf-8") await storage.put_bytes(
txt_path.relative_to(config.DATA_DIR).as_posix(),
text.encode("utf-8"), bucket=storage.Bucket.DERIVED,
content_type="text/plain; charset=utf-8",
)
logger.info("Saved extracted text to %s", txt_path) logger.info("Saved extracted text to %s", txt_path)
except Exception as e: except Exception as e:
logger.warning("Failed to save text file (non-fatal): %s", e) logger.warning("Failed to save text file (non-fatal): %s", e)

View File

@@ -0,0 +1,513 @@
"""Unified object-storage layer (X14, INV-STG1).
THE single choke-point for all binary file I/O — originals, derived
artifacts (thumbnails / extracted text), and exports. It replaces the
scattered ``open()`` / ``shutil.copy`` / ``Path.write_bytes`` calls spread
across ~8 services (G2: one storage path, no parallel routes). See
docs/spec/X14-storage-minio.md.
Keys
----
A *key* is a DATA_DIR-relative POSIX path, e.g.::
cases/8174-24/documents/originals/<uuid>.pdf
precedent-library/thumbnails/<case_law_id>/p001.jpg
The filesystem backend maps ``key -> DATA_DIR / key``, preserving the exact
current on-disk layout (zero behaviour change when ``STORAGE_BACKEND`` is the
default ``filesystem``). The S3 backend maps a logical *bucket*
(documents/immutable/derived) to a MinIO bucket and uses the key verbatim as
the object key.
INV-STG2: keys are atomic ASCII/UUID paths; a Hebrew original filename is
carried as object metadata / a DB column, never as the key itself.
INV-STG5: pgvector stays the source of truth for text + embeddings — this
layer stores blobs only. INV-STG6: presigned URLs (minted against the public
endpoint) serve bytes straight to the browser.
Backends (config.STORAGE_BACKEND)
---------------------------------
- ``filesystem`` (default) — disk only; current behaviour.
- ``dual`` — write disk + S3; read S3, fall back to disk.
The migration window; disk stays authoritative.
- ``s3`` — MinIO only.
``aioboto3`` is imported lazily so this module loads even where the dependency
is absent (the default filesystem backend needs nothing extra).
"""
from __future__ import annotations
import asyncio
import logging
import shutil
import tempfile
from enum import Enum
from pathlib import Path, PurePosixPath
from typing import Iterable
from legal_mcp import config
logger = logging.getLogger(__name__)
class Bucket(str, Enum):
"""Logical governance buckets (INV-STG3). Resolved to MinIO bucket names
via config; ignored by the filesystem backend (which keeps one tree)."""
DOCUMENTS = "documents"
IMMUTABLE = "immutable"
DERIVED = "derived"
def _bucket_name(bucket: Bucket) -> str:
return {
Bucket.DOCUMENTS: config.MINIO_BUCKET_DOCUMENTS,
Bucket.IMMUTABLE: config.MINIO_BUCKET_IMMUTABLE,
Bucket.DERIVED: config.MINIO_BUCKET_DERIVED,
}[bucket]
def normalize_key(key: str | Path) -> str:
"""Return a clean DATA_DIR-relative POSIX key.
Rejects absolute paths and ``..`` traversal (defence in depth — keys are
built internally, never from raw user input). An absolute path under
DATA_DIR is accepted and re-relativised so call-sites can pass either a
key or a full ``Path`` during the migration.
"""
p = Path(key)
if p.is_absolute():
try:
p = p.relative_to(config.DATA_DIR)
except ValueError as exc:
raise ValueError(f"absolute path outside DATA_DIR: {key!r}") from exc
posix = PurePosixPath(p.as_posix())
parts = posix.parts
if not parts or any(part == ".." for part in parts):
raise ValueError(f"invalid storage key: {key!r}")
return posix.as_posix().lstrip("/")
class StorageBackend:
"""Abstract backend. All methods are async except the cheap path helpers."""
name = "abstract"
async def put_bytes(self, key, data, *, bucket=Bucket.DOCUMENTS,
content_type=None, metadata=None) -> str:
raise NotImplementedError
async def put_file(self, src, key, *, bucket=Bucket.DOCUMENTS,
content_type=None, metadata=None) -> str:
with open(src, "rb") as fh:
return await self.put_bytes(
key, fh.read(), bucket=bucket,
content_type=content_type, metadata=metadata,
)
async def get_bytes(self, key, *, bucket=Bucket.DOCUMENTS) -> bytes:
raise NotImplementedError
async def exists(self, key, *, bucket=Bucket.DOCUMENTS) -> bool:
raise NotImplementedError
async def delete(self, key, *, bucket=Bucket.DOCUMENTS) -> None:
raise NotImplementedError
async def list_keys(self, prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
raise NotImplementedError
async def presign_get(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
download_name=None) -> str:
raise NotImplementedError
async def presign_put(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
content_type=None) -> str:
raise NotImplementedError
def local_path(self, key, *, bucket=Bucket.DOCUMENTS) -> Path | None:
"""Return a real filesystem path if one exists *without* downloading,
else ``None``. Use :meth:`ensure_local` when a path is required."""
return None
async def ensure_local(self, key, *, bucket=Bucket.DOCUMENTS) -> Path:
"""Return a local path to the object, downloading to a temp file if the
backend has no on-disk copy. Caller owns cleanup of temp files."""
path = self.local_path(key, bucket=bucket)
if path is not None:
return path
data = await self.get_bytes(key, bucket=bucket)
suffix = PurePosixPath(normalize_key(key)).suffix
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
tmp.write(data)
tmp.close()
return Path(tmp.name)
class FilesystemBackend(StorageBackend):
"""Disk under DATA_DIR. ``bucket`` is ignored — the existing single tree is
preserved verbatim, so the default backend is byte-for-byte the legacy
behaviour."""
name = "filesystem"
def _abs(self, key, *, bucket=Bucket.DOCUMENTS) -> Path:
rel = normalize_key(key)
path = (Path(config.DATA_DIR) / rel).resolve()
root = Path(config.DATA_DIR).resolve()
if root not in path.parents and path != root:
raise ValueError(f"resolved path escapes DATA_DIR: {key!r}")
return path
async def put_bytes(self, key, data, *, bucket=Bucket.DOCUMENTS,
content_type=None, metadata=None) -> str:
path = self._abs(key, bucket=bucket)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return f"file://{path}"
async def put_file(self, src, key, *, bucket=Bucket.DOCUMENTS,
content_type=None, metadata=None) -> str:
path = self._abs(key, bucket=bucket)
path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, path) # preserve mtime, as the legacy code did
return f"file://{path}"
async def get_bytes(self, key, *, bucket=Bucket.DOCUMENTS) -> bytes:
return self._abs(key, bucket=bucket).read_bytes()
async def exists(self, key, *, bucket=Bucket.DOCUMENTS) -> bool:
return self._abs(key, bucket=bucket).exists()
async def delete(self, key, *, bucket=Bucket.DOCUMENTS) -> None:
self._abs(key, bucket=bucket).unlink(missing_ok=True)
async def list_keys(self, prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
root = Path(config.DATA_DIR).resolve()
base = self._abs(prefix, bucket=bucket) if prefix else root
if not base.exists():
return []
out: list[str] = []
for p in sorted(base.rglob("*")):
if p.is_file():
out.append(p.resolve().relative_to(root).as_posix())
return out
def local_path(self, key, *, bucket=Bucket.DOCUMENTS) -> Path | None:
path = self._abs(key, bucket=bucket)
return path if path.exists() else None
async def presign_get(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
download_name=None) -> str:
raise NotImplementedError(
"presigned URLs require the S3 backend (set STORAGE_BACKEND=dual|s3)"
)
async def presign_put(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
content_type=None) -> str:
raise NotImplementedError(
"presigned URLs require the S3 backend (set STORAGE_BACKEND=dual|s3)"
)
class S3Backend(StorageBackend):
"""MinIO via aioboto3. Server-side ops use the internal endpoint; presigned
URLs are minted against the public endpoint so the browser can reach them
(INV-STG6)."""
name = "s3"
def __init__(self) -> None:
self._session = None # lazy aioboto3 session
def _boto(self):
import aioboto3 # lazy — absent in the default filesystem path
from botocore.config import Config as BotoConfig
if self._session is None:
self._session = aioboto3.Session()
cfg = BotoConfig(signature_version="s3v4", s3={"addressing_style": "path"})
return aioboto3, BotoConfig, cfg
def _client(self, *, public: bool = False):
_aioboto3, _BotoConfig, cfg = self._boto()
endpoint = config.MINIO_PUBLIC_ENDPOINT if public else config.MINIO_ENDPOINT
return self._session.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=config.MINIO_ACCESS_KEY,
aws_secret_access_key=config.MINIO_SECRET_KEY,
region_name=config.MINIO_REGION,
config=cfg,
)
async def put_bytes(self, key, data, *, bucket=Bucket.DOCUMENTS,
content_type=None, metadata=None) -> str:
k = normalize_key(key)
extra = {}
if content_type:
extra["ContentType"] = content_type
if metadata:
extra["Metadata"] = {kk: str(vv) for kk, vv in metadata.items()}
async with self._client() as s3:
await s3.put_object(Bucket=_bucket_name(bucket), Key=k, Body=data, **extra)
return f"s3://{_bucket_name(bucket)}/{k}"
async def get_bytes(self, key, *, bucket=Bucket.DOCUMENTS) -> bytes:
k = normalize_key(key)
async with self._client() as s3:
resp = await s3.get_object(Bucket=_bucket_name(bucket), Key=k)
async with resp["Body"] as stream:
return await stream.read()
async def exists(self, key, *, bucket=Bucket.DOCUMENTS) -> bool:
from botocore.exceptions import ClientError
k = normalize_key(key)
async with self._client() as s3:
try:
await s3.head_object(Bucket=_bucket_name(bucket), Key=k)
return True
except ClientError as exc:
if exc.response["Error"]["Code"] in ("404", "NoSuchKey", "NotFound"):
return False
raise
async def delete(self, key, *, bucket=Bucket.DOCUMENTS) -> None:
k = normalize_key(key)
async with self._client() as s3:
await s3.delete_object(Bucket=_bucket_name(bucket), Key=k)
async def list_keys(self, prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
pfx = normalize_key(prefix) if prefix else ""
out: list[str] = []
async with self._client() as s3:
paginator = s3.get_paginator("list_objects_v2")
async for page in paginator.paginate(Bucket=_bucket_name(bucket), Prefix=pfx):
for obj in page.get("Contents", []):
out.append(obj["Key"])
return out
async def presign_get(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
download_name=None) -> str:
k = normalize_key(key)
params = {"Bucket": _bucket_name(bucket), "Key": k}
if download_name:
# RFC 5987 — keep the Hebrew original filename on download (INV-STG2)
from urllib.parse import quote
params["ResponseContentDisposition"] = (
f"attachment; filename*=UTF-8''{quote(download_name)}"
)
async with self._client(public=True) as s3:
return await s3.generate_presigned_url(
"get_object", Params=params,
ExpiresIn=ttl or config.MINIO_PRESIGN_TTL,
)
async def presign_put(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
content_type=None) -> str:
k = normalize_key(key)
params = {"Bucket": _bucket_name(bucket), "Key": k}
if content_type:
params["ContentType"] = content_type
async with self._client(public=True) as s3:
return await s3.generate_presigned_url(
"put_object", Params=params,
ExpiresIn=ttl or config.MINIO_PRESIGN_TTL,
)
class DualBackend(StorageBackend):
"""Migration window: writes go to BOTH disk and S3 (disk authoritative);
reads prefer S3 and fall back to disk. An S3 write failure is logged (never
swallowed — engineering §6) but does not break the app while disk holds the
canonical copy."""
name = "dual"
def __init__(self) -> None:
self.fs = FilesystemBackend()
self.s3 = S3Backend()
async def put_bytes(self, key, data, *, bucket=Bucket.DOCUMENTS,
content_type=None, metadata=None) -> str:
uri = await self.fs.put_bytes(key, data, bucket=bucket,
content_type=content_type, metadata=metadata)
try:
await self.s3.put_bytes(key, data, bucket=bucket,
content_type=content_type, metadata=metadata)
except Exception as exc: # noqa: BLE001 — log, don't swallow
logger.warning("dual put_bytes: S3 mirror failed for %s: %s", key, exc)
return uri
async def put_file(self, src, key, *, bucket=Bucket.DOCUMENTS,
content_type=None, metadata=None) -> str:
uri = await self.fs.put_file(src, key, bucket=bucket,
content_type=content_type, metadata=metadata)
try:
await self.s3.put_file(src, key, bucket=bucket,
content_type=content_type, metadata=metadata)
except Exception as exc: # noqa: BLE001
logger.warning("dual put_file: S3 mirror failed for %s: %s", key, exc)
return uri
async def get_bytes(self, key, *, bucket=Bucket.DOCUMENTS) -> bytes:
try:
return await self.s3.get_bytes(key, bucket=bucket)
except Exception as exc: # noqa: BLE001 — fall back to disk
logger.debug("dual get_bytes: S3 miss for %s (%s); using disk", key, exc)
return await self.fs.get_bytes(key, bucket=bucket)
async def exists(self, key, *, bucket=Bucket.DOCUMENTS) -> bool:
if await self.fs.exists(key, bucket=bucket):
return True
try:
return await self.s3.exists(key, bucket=bucket)
except Exception: # noqa: BLE001
return False
async def delete(self, key, *, bucket=Bucket.DOCUMENTS) -> None:
await self.fs.delete(key, bucket=bucket)
try:
await self.s3.delete(key, bucket=bucket)
except Exception as exc: # noqa: BLE001
logger.warning("dual delete: S3 delete failed for %s: %s", key, exc)
async def list_keys(self, prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
return await self.fs.list_keys(prefix, bucket=bucket)
def local_path(self, key, *, bucket=Bucket.DOCUMENTS) -> Path | None:
return self.fs.local_path(key, bucket=bucket)
async def presign_get(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
download_name=None) -> str:
return await self.s3.presign_get(key, bucket=bucket, ttl=ttl,
download_name=download_name)
async def presign_put(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
content_type=None) -> str:
return await self.s3.presign_put(key, bucket=bucket, ttl=ttl,
content_type=content_type)
_BACKENDS = {
"filesystem": FilesystemBackend,
"dual": DualBackend,
"s3": S3Backend,
}
_singleton: StorageBackend | None = None
def get_storage() -> StorageBackend:
"""Return the process-wide storage backend selected by config.STORAGE_BACKEND
(cached). Unknown values fall back to ``filesystem`` with a warning rather
than crashing the app."""
global _singleton
if _singleton is None:
cls = _BACKENDS.get(config.STORAGE_BACKEND)
if cls is None:
logger.warning(
"unknown STORAGE_BACKEND=%r — falling back to filesystem",
config.STORAGE_BACKEND,
)
cls = FilesystemBackend
_singleton = cls()
logger.info("storage backend = %s", _singleton.name)
return _singleton
def reset_storage_cache() -> None:
"""Drop the cached backend (tests / after an env change)."""
global _singleton
_singleton = None
# ── module-level convenience wrappers ──────────────────────────────
# Thin pass-throughs so call-sites can ``from legal_mcp.services import storage``
# and use ``await storage.put_bytes(...)`` without fetching the singleton.
async def put_bytes(key, data, *, bucket=Bucket.DOCUMENTS, content_type=None,
metadata=None) -> str:
return await get_storage().put_bytes(
key, data, bucket=bucket, content_type=content_type, metadata=metadata)
async def put_file(src, key, *, bucket=Bucket.DOCUMENTS, content_type=None,
metadata=None) -> str:
return await get_storage().put_file(
src, key, bucket=bucket, content_type=content_type, metadata=metadata)
async def get_bytes(key, *, bucket=Bucket.DOCUMENTS) -> bytes:
return await get_storage().get_bytes(key, bucket=bucket)
async def exists(key, *, bucket=Bucket.DOCUMENTS) -> bool:
return await get_storage().exists(key, bucket=bucket)
async def delete(key, *, bucket=Bucket.DOCUMENTS) -> None:
return await get_storage().delete(key, bucket=bucket)
async def list_keys(prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
return await get_storage().list_keys(prefix, bucket=bucket)
async def presign_get(key, *, bucket=Bucket.DOCUMENTS, ttl=None,
download_name=None) -> str:
return await get_storage().presign_get(
key, bucket=bucket, ttl=ttl, download_name=download_name)
async def presign_put(key, *, bucket=Bucket.DOCUMENTS, ttl=None,
content_type=None) -> str:
return await get_storage().presign_put(
key, bucket=bucket, ttl=ttl, content_type=content_type)
def local_path(key, *, bucket=Bucket.DOCUMENTS) -> Path | None:
return get_storage().local_path(key, bucket=bucket)
async def ensure_local(key, *, bucket=Bucket.DOCUMENTS) -> Path:
return await get_storage().ensure_local(key, bucket=bucket)
# ── synchronous facade ─────────────────────────────────────────────
# A few legacy writers are plain sync functions (track-changes save, retrofit
# backup, the multimodal thumbnail renderer which runs in a worker thread via
# asyncio.to_thread). They go through the same layer via this blocking shim so
# INV-STG1 holds everywhere.
def _run_coro_blocking(coro):
"""Run a storage coroutine to completion from synchronous code.
No running loop in this thread (the common case — sync helpers, or a
to_thread worker) → asyncio.run. If a loop *is* already running here, the
coroutine is offloaded to a fresh thread so we never deadlock the loop."""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
box: dict = {}
def _worker():
box["value"] = asyncio.run(coro)
import threading
t = threading.Thread(target=_worker)
t.start()
t.join()
return box["value"]
def put_bytes_sync(key, data, *, bucket=Bucket.DOCUMENTS, content_type=None,
metadata=None) -> str:
return _run_coro_blocking(
put_bytes(key, data, bucket=bucket, content_type=content_type, metadata=metadata))
def put_file_sync(src, key, *, bucket=Bucket.DOCUMENTS, content_type=None,
metadata=None) -> str:
return _run_coro_blocking(
put_file(src, key, bucket=bucket, content_type=content_type, metadata=metadata))

View File

@@ -0,0 +1,66 @@
"""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)})
async def court_fetch_drain(limit: int = 10) -> str:
"""ריקון תור-האחזור: מוריד וקולט את ה-jobs הממתינים (pending/failed) שהיומונים
מילאו, וקושר כל פסק שנקלט חזרה ליומון-המקור. סדרתי. כלי מקומי בלבד."""
try:
result = await orch.drain_pending(limit=max(1, min(int(limit or 10), 50)))
except Exception as e: # noqa: BLE001
return _err(f"ריקון התור נכשל: {e}")
return _ok(result, message=f"עובדו {result.get('processed', 0)}, נקלטו {result.get('done', 0)}")

View File

@@ -0,0 +1,172 @@
"""MCP tools for the Digests radar (X12).
A digest ("כל יום" daily one-pager, Ofer Toister) is a SECONDARY, discovery-
layer source that POINTS at a ruling. It is distinct from the three citation
corpora:
- ``search_precedent_library`` — authoritative external court rulings.
- ``search_internal_decisions`` — appeals-committee decisions.
- ``search_decisions`` — Dafna's prior decisions (style corpus).
A digest is NEVER cited in a decision (INV-DIG1) and NEVER enters the halacha
pipeline (INV-DIG2). ``search_digests`` is a research compass: it surfaces the
relevant digest + the UNDERLYING ruling's citation, which is then ingested into
the precedent library and cited from there.
"""
from __future__ import annotations
import time
from uuid import UUID
from legal_mcp.services import db, digest_library, telemetry
from legal_mcp.tools.envelope import empty, err as _err, ok as _ok
async def digest_upload(
file_path: str,
yomon_number: str = "",
digest_date: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
) -> str:
"""העלאת יומון ("כל יום") לקורפוס-הגילוי + חילוץ מטא-דאטה אוטומטי.
היומון הוא מקור-משני המצביע על פסק הדין המקורי — אינו מצוטט בהחלטה.
Args:
file_path: נתיב מלא לקובץ PDF/DOCX של היומון.
yomon_number: מספר היומון (אופציונלי — יחולץ מהטקסט אם ריק).
digest_date: ISO date של גיליון היומון (אופציונלי).
practice_area: rishuy_uvniya / betterment_levy / compensation_197.
subject_tags: תגיות נושא (אופציונלי — יחולצו אם ריק).
Returns: JSON עם digest_id, מספר היומון, מראה-המקום, וקישור-אוטומטי אם נמצא.
"""
try:
result = await digest_library.ingest_digest(
file_path=file_path,
yomon_number=yomon_number,
digest_date=digest_date or None,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
subject_tags=subject_tags or None,
)
except Exception as e:
return _err(str(e))
return _ok(result)
async def digest_list(
practice_area: str = "",
concept_tag: str = "",
linked: bool | None = None,
search: str = "",
limit: int = 100,
) -> str:
"""רשימת יומונים בקורפוס-הגילוי, עם פילטרים. linked=false → יומונים שהפסק
המקורי שלהם עוד לא נקלט לספריית הפסיקה (פער-ידע גלוי)."""
rows = await digest_library.list_digests(
practice_area=practice_area,
concept_tag=concept_tag,
linked=linked,
search=search,
limit=limit,
)
return _ok(rows)
async def digest_get(digest_id: str) -> str:
"""יומון ספציפי לפי מזהה."""
try:
cid = UUID(digest_id)
except ValueError:
return _err("digest_id לא תקין")
record = await digest_library.get_digest(cid)
if not record:
return _err("יומון לא נמצא")
return _ok(record)
async def digest_link(digest_id: str, case_law_id: str) -> str:
"""קישור ידני של יומון לפסק הדין המקורי בספריית הפסיקה (INV-DIG3)."""
try:
UUID(digest_id)
UUID(case_law_id)
except ValueError:
return _err("מזהה לא תקין")
try:
result = await digest_library.link_digest(digest_id, case_law_id)
except Exception as e:
return _err(str(e))
return _ok(result)
async def digest_relink(digest_id: str) -> str:
"""ניסיון-קישור מחדש: בודק אם פסק הדין המקורי של היומון כבר בספרייה ומקשר."""
try:
UUID(digest_id)
except ValueError:
return _err("digest_id לא תקין")
try:
result = await digest_library.relink_digest(digest_id)
except Exception as e:
return _err(str(e))
return _ok(result)
async def digest_delete(digest_id: str) -> str:
"""מחיקת יומון מקורפוס-הגילוי."""
try:
cid = UUID(digest_id)
except ValueError:
return _err("digest_id לא תקין")
ok_ = await digest_library.delete_digest(cid)
if not ok_:
return _err("יומון לא נמצא")
return _ok({"deleted": True, "digest_id": digest_id})
async def search_digests(
query: str,
practice_area: str = "",
subject_tag: str = "",
concept_tag: str = "",
limit: int = 10,
) -> str:
"""חיפוש סמנטי בקורפוס-הגילוי (יומוני "כל יום"). מצפן-מחקר בלבד — מחזיר את
היומון הרלוונטי + מראה-המקום של הפסק המקורי (radar). היומון אינו מצוטט
בהחלטה (INV-DIG1); הצטט מהפסק המקורי דרך search_precedent_library."""
if not query or len(query.strip()) < 2:
return empty("שאילתה קצרה מדי (פחות מ-2 תווים).")
q = query.strip()
t0 = time.perf_counter()
results = await digest_library.search_digests(
query=q,
practice_area=practice_area,
subject_tag=subject_tag,
concept_tag=concept_tag,
limit=limit,
)
elapsed_ms = int((time.perf_counter() - t0) * 1000)
telemetry.log_search_bg(
search_type="digests",
query=q,
results=results,
duration_ms=elapsed_ms,
practice_area=practice_area or None,
user_agent="unknown",
)
if not results:
return empty("לא נמצאו יומונים תואמים.")
return _ok(results)
async def digest_process_pending(limit: int = 20) -> str:
"""ריקון תור היומונים שהועלו מה-UI וממתינים לעיבוד-LLM מקומי. מריץ חילוץ-
מטא-דאטה + embedding + autolink על כל יומון בסטטוס 'pending', מקומית עם
ה-CLI (claude_session local-only). מנקה לסטטוס 'completed'."""
try:
result = await digest_library.process_pending_digests(limit=limit)
except Exception as e:
return _err(str(e))
return _ok(result)

View File

@@ -4,12 +4,12 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import shutil import mimetypes
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import audit, db, git_sync, processor from legal_mcp.services import audit, db, git_sync, processor, storage
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
@@ -50,11 +50,14 @@ async def document_upload(
"idempotent_existing": True, "idempotent_existing": True,
}, message=f"הקובץ כבר הועלה לתיק {case_number} (זהה ב-hash) — מוחזר הקיים, ללא עיבוד מחדש.") }, message=f"הקובץ כבר הועלה לתיק {case_number} (זהה ב-hash) — מוחזר הקיים, ללא עיבוד מחדש.")
# Copy file to case directory # Stage the original through the unified storage layer (INV-STG1).
case_dir = config.find_case_dir(case_number) / "documents" / "originals" dest = config.find_case_dir(case_number) / "documents" / "originals" / source.name
case_dir.mkdir(parents=True, exist_ok=True) await storage.put_file(
dest = case_dir / source.name source, dest.relative_to(config.DATA_DIR).as_posix(),
shutil.copy2(str(source), str(dest)) bucket=storage.Bucket.DOCUMENTS,
content_type=mimetypes.guess_type(source.name)[0],
metadata={"filename": source.name},
)
# For auto classification, start with "reference" — will be updated after processing # For auto classification, start with "reference" — will be updated after processing
initial_doc_type = doc_type if doc_type != "auto" else "reference" initial_doc_type = doc_type if doc_type != "auto" else "reference"
@@ -156,10 +159,14 @@ async def document_upload_training(
} }
subdir = _SUBTYPE_DIRS.get(appeal_subtype, "") subdir = _SUBTYPE_DIRS.get(appeal_subtype, "")
training_dest = config.TRAINING_DIR / subdir if subdir else config.TRAINING_DIR training_dest = config.TRAINING_DIR / subdir if subdir else config.TRAINING_DIR
training_dest.mkdir(parents=True, exist_ok=True)
dest = training_dest / source.name dest = training_dest / source.name
if source.resolve() != dest.resolve(): if source.resolve() != dest.resolve():
shutil.copy2(str(source), str(dest)) await storage.put_file(
source, dest.relative_to(config.DATA_DIR).as_posix(),
bucket=storage.Bucket.DOCUMENTS,
content_type=mimetypes.guess_type(source.name)[0],
metadata={"filename": source.name},
)
# Extract text and strip Nevo preamble # Extract text and strip Nevo preamble
text, page_count, _ = await extractor.extract_text(str(dest)) text, page_count, _ = await extractor.extract_text(str(dest))

View File

@@ -183,7 +183,7 @@ async def precedent_library_delete(case_law_id: str) -> str:
async def precedent_extract_halachot(case_law_id: str) -> str: async def precedent_extract_halachot(case_law_id: str) -> str:
"""הרצה מחדש של חילוץ ההלכות לפסיקה קיימת. הלכות קודמות נמחקות.""" """הרצה מחדש של חילוץ ההלכות לפסיקה קיימת. הלכות שאושרו/פורסמו נשמרות (INV-G10); רק הלכות שלא-נבדקו מוחלפות."""
try: try:
cid = UUID(case_law_id) cid = UUID(case_law_id)
except ValueError: except ValueError:

View File

@@ -0,0 +1,91 @@
"""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"
def test_supreme_with_net_format_triple():
"""A Supreme prefix carrying a נט-format number exposes the triple so the
orchestrator can route it to Tier-1 (נט המשפט serves Supreme too)."""
c = classify('בר"מ 72182-06-25 הימנותא נ\' הוועדה המקומית')
assert c.tier == "supreme"
assert (c.file_number, c.month, c.year) == ("72182", "06", "25")
# serial-format Supreme has no triple → stays Tier-0-only
s = classify('עע"מ 5886/24')
assert s.tier == "supreme" and s.file_number is None

View File

@@ -0,0 +1,46 @@
"""rule_type coercion after the authority/role split (INV-DM7).
The extractor's rule_type holds the rule ROLE only — it is never defaulted from
the source's bindingness. Legacy authority values fold to the nearest role.
"""
from legal_mcp.services.halacha_extractor import (
_LEGACY_RULE_TYPE_FOLD,
_VALID_RULE_TYPES,
_coerce_halacha,
)
_BASE = {"rule_statement": "כלל כלשהו", "supporting_quote": "ציטוט תומך כלשהו"}
def _rt(rule_type):
return _coerce_halacha({**_BASE, "rule_type": rule_type})["rule_type"]
def test_valid_roles_are_the_five_roles_only():
assert _VALID_RULE_TYPES == {
"holding", "interpretive", "procedural", "application", "obiter",
}
assert "binding" not in _VALID_RULE_TYPES
assert "persuasive" not in _VALID_RULE_TYPES
def test_legacy_authority_values_fold_to_a_role():
assert _rt("binding") == "holding"
assert _rt("persuasive") == "interpretive"
assert _LEGACY_RULE_TYPE_FOLD == {"binding": "holding", "persuasive": "interpretive"}
def test_genuine_roles_pass_through():
for role in ("holding", "interpretive", "procedural", "application", "obiter"):
assert _rt(role) == role
def test_unknown_or_missing_defaults_to_interpretive():
assert _rt("nonsense") == "interpretive"
assert _coerce_halacha(_BASE)["rule_type"] == "interpretive"
def test_coerce_rejects_rows_missing_required_fields():
assert _coerce_halacha({"rule_statement": "x"}) is None
assert _coerce_halacha({"supporting_quote": "y"}) is None
assert _coerce_halacha("not a dict") is None

View File

@@ -211,23 +211,40 @@ def test_application_flag_from_rule_type():
assert hq.FLAG_APPLICATION in flags assert hq.FLAG_APPLICATION in flags
def test_application_flag_from_deixis_even_if_binding(): def test_application_flag_from_deixis_even_if_holding():
flags = hq.compute_quality_flags( flags = hq.compute_quality_flags(
"במקרה דנן נדחה הערר", "כפי שקבענו במקרה דנן נדחה הערר", "במקרה דנן נדחה הערר", "כפי שקבענו במקרה דנן נדחה הערר",
rule_type="binding", rule_type="holding",
) )
assert hq.FLAG_APPLICATION in flags assert hq.FLAG_APPLICATION in flags
def test_clean_binding_rule_has_no_flags(): def test_clean_holding_rule_has_no_flags():
flags = hq.compute_quality_flags( flags = hq.compute_quality_flags(
"ועדת הערר מוסמכת לדון בטענות חוקתיות הנוגעות לתכנית", "ועדת הערר מוסמכת לדון בטענות חוקתיות הנוגעות לתכנית",
"הוועדה מוסמכת לדון אף בטענות מסוג זה, ככל שהן נוגעות לתכנית שבנדון.", "הוועדה מוסמכת לדון אף בטענות מסוג זה, ככל שהן נוגעות לתכנית שבנדון.",
rule_type="binding", rule_type="holding",
) )
assert flags == [] assert flags == []
# ── INV-DM7: authority is DERIVED from the source, never a rule_type value ──
def test_derive_authority_binding_for_higher_courts():
assert hq.derive_authority("עליון") == "binding"
assert hq.derive_authority("מנהלי") == "binding"
def test_derive_authority_persuasive_for_committee():
assert hq.derive_authority("ועדת_ערר_מחוזית") == "persuasive"
def test_derive_authority_none_for_unknown_or_empty():
assert hq.derive_authority("") is None
assert hq.derive_authority(None) is None
assert hq.derive_authority("משהו אחר") is None
# ── #82.3 lexical near-duplicate signal ── # ── #82.3 lexical near-duplicate signal ──
def test_jaccard_high_for_reworded_same_rule(): def test_jaccard_high_for_reworded_same_rule():

View File

@@ -0,0 +1,115 @@
"""Regression test for TaskMaster #108 / INV-G10 — re-extraction must NOT delete
chair-approved/published halachot.
Bug (2026-06-08 amiel incident, בל"מ 8126-03-25): ``reset_halacha_extraction``
ran an UNCONDITIONAL ``DELETE FROM halachot`` before re-extracting. A crash
between the delete and the first chunk's store lost every chair approval (9
approved + their rule_type) and left the row stuck ``status='processing'`` with
0 rows.
Fix: the delete now excludes ``review_status IN ('approved','published')`` so
approvals survive a re-extract; the per-chunk dedup-on-insert
(``store_halachot_for_chunk``) skips fresh extractions that duplicate a
preserved approval, so no duplicates appear either.
Runs fully OFFLINE — monkeypatches ``db.get_pool`` with a fake pool that
captures every SQL string instead of hitting Postgres (same style as
``test_precedent_corpus_isolation.py``). Asserts the DELETE carries the
approved/published exclusion and that the function reports preserved/deleted
counts.
"""
from __future__ import annotations
import asyncio
from uuid import uuid4
import pytest
from legal_mcp.services import db
class _FakeTxn:
async def __aenter__(self) -> "_FakeTxn":
return self
async def __aexit__(self, *exc) -> bool: # noqa: ANN002
return False
class _FakeConn:
def __init__(self) -> None:
self.executed: list[str] = []
self.fetchvals: list[str] = []
async def execute(self, sql: str, *args) -> str: # noqa: ANN002
self.executed.append(sql)
return "DELETE 3" # mimic asyncpg command tag so the count parse works
async def fetchval(self, sql: str, *args) -> int: # noqa: ANN002
self.fetchvals.append(sql)
return 9 # pretend 9 approved/published rows are present
def transaction(self) -> _FakeTxn:
return _FakeTxn()
class _AcquireCtx:
def __init__(self, conn: _FakeConn) -> None:
self._conn = conn
async def __aenter__(self) -> _FakeConn:
return self._conn
async def __aexit__(self, *exc) -> bool: # noqa: ANN002
return False
class _FakePool:
def __init__(self, conn: _FakeConn) -> None:
self._conn = conn
def acquire(self) -> _AcquireCtx:
return _AcquireCtx(self._conn)
@pytest.fixture()
def fake_conn(monkeypatch: pytest.MonkeyPatch) -> _FakeConn:
conn = _FakeConn()
pool = _FakePool(conn)
async def _get_pool() -> _FakePool:
return pool
monkeypatch.setattr(db, "get_pool", _get_pool)
return conn
def test_reset_halacha_extraction_preserves_approved(fake_conn: _FakeConn) -> None:
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(db.reset_halacha_extraction(uuid4()))
finally:
loop.close()
delete_sql = next(
q for q in fake_conn.executed if q.strip().upper().startswith("DELETE")
)
norm = " ".join(delete_sql.split())
# INV-G10: the delete MUST exclude chair-approved/published halachot.
assert "review_status NOT IN ('approved', 'published')" in norm, delete_sql
# ...and must therefore be conditional — never an unconditional wipe.
assert "WHERE case_law_id = $1 AND review_status NOT IN" in norm, delete_sql
# The preserved-count query filters to exactly approved/published.
assert any(
"IN ('approved', 'published')" in q and "NOT IN" not in q
for q in fake_conn.fetchvals
), fake_conn.fetchvals
# Checkpoints are still cleared so every chunk re-processes.
assert any("halacha_extracted_at = NULL" in q for q in fake_conn.executed)
# Reports counts for provenance (G9) / caller logging.
assert result == {"deleted": 3, "preserved": 9}

View File

@@ -0,0 +1,80 @@
"""Tests for the durable pipeline runtime (scripts/_pipeline_runtime.py / X16).
The LINEAR fallback is tested unconditionally. The DURABLE (LangGraph) path —
crash-then-resume and --fresh — is tested only where ``langgraph`` is installed
(``importorskip``), so the suite still passes in a venv without it (the runtime
itself degrades gracefully there too).
"""
from __future__ import annotations
import asyncio
import importlib.util
import sys
from pathlib import Path
import pytest
# Load scripts/_pipeline_runtime.py (scripts/ is not a package).
_RT = Path(__file__).resolve().parents[2] / "scripts" / "_pipeline_runtime.py"
_spec = importlib.util.spec_from_file_location("_pipeline_runtime", _RT)
rt = importlib.util.module_from_spec(_spec)
sys.modules["_pipeline_runtime"] = rt
_spec.loader.exec_module(rt) # type: ignore[union-attr]
def _counting_steps(fail_step2_once: bool):
"""4 steps; each records how many times it actually ran. s2 can fail once."""
runs = {"s1": 0, "s2": 0, "s3": 0, "s4": 0}
state = {"s2_failed": False}
def mk(name: str, fail: bool = False) -> rt.Step:
async def run(results: dict) -> dict:
if fail and not state["s2_failed"]:
state["s2_failed"] = True
raise RuntimeError(f"{name} simulated crash")
runs[name] += 1
return {name: "ok"}
return rt.Step(name, run)
steps = [mk("s1"), mk("s2", fail_step2_once), mk("s3"), mk("s4")]
return steps, runs
def test_linear_fallback_runs_all_steps() -> None:
steps, runs = _counting_steps(fail_step2_once=False)
out = asyncio.run(rt._run_linear(steps))
assert out == {"s1": "ok", "s2": "ok", "s3": "ok", "s4": "ok"}
assert all(runs[s] == 1 for s in runs)
def test_resume_skips_completed_steps(tmp_path: Path) -> None:
pytest.importorskip("langgraph")
db = tmp_path / "rt.sqlite"
steps, runs = _counting_steps(fail_step2_once=True)
tid = "halacha:RESUME-TEST"
# Run 1: s2 crashes — s1 ran and is checkpointed.
with pytest.raises(RuntimeError):
asyncio.run(rt.run_pipeline(steps, thread_id=tid, checkpoint_db=db))
assert runs == {"s1": 1, "s2": 0, "s3": 0, "s4": 0}
# Run 2: resume — s1 is NOT re-run; s2/s3/s4 complete.
out = asyncio.run(rt.run_pipeline(steps, thread_id=tid, checkpoint_db=db))
assert out == {"s1": "ok", "s2": "ok", "s3": "ok", "s4": "ok"}
assert runs["s1"] == 1, "completed step s1 must NOT re-run on resume"
assert runs["s2"] == 1 and runs["s3"] == 1 and runs["s4"] == 1
def test_fresh_reruns_all_after_completion(tmp_path: Path) -> None:
pytest.importorskip("langgraph")
db = tmp_path / "rt2.sqlite"
steps, runs = _counting_steps(fail_step2_once=False)
tid = "halacha:FRESH-TEST"
asyncio.run(rt.run_pipeline(steps, thread_id=tid, checkpoint_db=db))
assert all(runs[s] == 1 for s in runs)
# fresh=True clears the completed checkpoint and runs everything again.
asyncio.run(rt.run_pipeline(steps, thread_id=tid, checkpoint_db=db, fresh=True))
assert all(runs[s] == 2 for s in runs), "fresh run must re-execute every step"

View File

@@ -0,0 +1,54 @@
"""CI fitness-test for INV-G12 (docs/spec/X15 §4 / R4) — the Agent Platform Port.
Hard gate: the intelligence layer (``mcp-server/src``) must contain ZERO
Paperclip-specific symbols, and only ``web/agent_platform_port.py`` (+ the shell
itself) may import the Paperclip client. The check lives in
``scripts/leak_guard.py`` (one canonical implementation, shared with the
interactive ``spec-guard.sh`` hook); this test runs it and fails the build on any
violation.
Runs OFFLINE — pure source scan, no DB / no imports of the app.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
_GUARD = REPO / "scripts" / "leak_guard.py"
def _load_guard():
spec = importlib.util.spec_from_file_location("leak_guard", _GUARD)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[union-attr]
return mod
def test_leak_guard_script_exists() -> None:
assert _GUARD.is_file(), "scripts/leak_guard.py is missing (R4)"
def test_intelligence_layer_is_platform_clean() -> None:
"""No Paperclip symbols in mcp-server/src; import seam intact (INV-G12)."""
guard = _load_guard()
violations = guard.scan()
assert not violations, (
"INV-G12 leak-guard found Platform Port violations:\n"
+ "\n".join(f"{v}" for v in violations)
)
def test_guard_detects_an_injected_intelligence_leak(tmp_path: Path) -> None:
"""The guard must FAIL on a planted Paperclip symbol (so it can't rot)."""
guard = _load_guard()
probe = REPO / "mcp-server" / "src" / "legal_mcp" / "_leakguard_selftest.py"
probe.write_text('BAD = "use pc.sh wakeup directly"\n', encoding="utf-8")
try:
violations = guard.scan()
assert any("_leakguard_selftest.py" in v for v in violations), (
"leak-guard failed to detect a planted intelligence-layer leak"
)
finally:
probe.unlink(missing_ok=True)

View File

@@ -0,0 +1,198 @@
"""Unit tests for the unified storage layer (X14, services/storage.py).
Sync tests driving the async API via asyncio.run (matches the repo
convention — no pytest-asyncio). The filesystem backend is exercised against a
tmp DATA_DIR; the S3 path is stubbed so the suite needs no MinIO and no
aioboto3.
"""
import asyncio
import pytest
from legal_mcp import config
from legal_mcp.services import storage
from legal_mcp.services.storage import Bucket
@pytest.fixture(autouse=True)
def _tmp_datadir(tmp_path, monkeypatch):
monkeypatch.setattr(config, "DATA_DIR", tmp_path)
monkeypatch.setattr(config, "STORAGE_BACKEND", "filesystem")
storage.reset_storage_cache()
yield tmp_path
storage.reset_storage_cache()
def run(coro):
return asyncio.run(coro)
# ── normalize_key ──────────────────────────────────────────────────
def test_normalize_key_relative():
assert storage.normalize_key("cases/8174-24/originals/x.pdf") == \
"cases/8174-24/originals/x.pdf"
def test_normalize_key_rejects_parent_traversal():
with pytest.raises(ValueError):
storage.normalize_key("cases/../../etc/passwd")
def test_normalize_key_accepts_abs_under_datadir(_tmp_datadir):
abs_key = _tmp_datadir / "cases" / "x.pdf"
assert storage.normalize_key(abs_key) == "cases/x.pdf"
def test_normalize_key_rejects_abs_outside_datadir():
with pytest.raises(ValueError):
storage.normalize_key("/var/secret/x.pdf")
# ── filesystem backend ─────────────────────────────────────────────
def test_filesystem_roundtrip(_tmp_datadir):
be = storage.FilesystemBackend()
key = "cases/1/originals/a.pdf"
uri = run(be.put_bytes(key, b"hello", content_type="application/pdf"))
assert uri.startswith("file://")
assert (_tmp_datadir / key).read_bytes() == b"hello"
assert run(be.exists(key)) is True
assert run(be.get_bytes(key)) == b"hello"
run(be.delete(key))
assert run(be.exists(key)) is False
# delete is idempotent (missing_ok)
run(be.delete(key))
def test_filesystem_put_file(_tmp_datadir):
be = storage.FilesystemBackend()
src = _tmp_datadir / "src.bin"
src.write_bytes(b"payload")
run(be.put_file(src, "precedent-library/court_ruling/u.pdf"))
assert (_tmp_datadir / "precedent-library/court_ruling/u.pdf").read_bytes() == b"payload"
def test_filesystem_bucket_is_ignored_legacy_layout(_tmp_datadir):
"""Even for a non-default bucket, the FS backend keeps the single tree —
so the default backend is byte-for-byte the legacy layout."""
be = storage.FilesystemBackend()
run(be.put_bytes("thumbnails/d/p001.jpg", b"img", bucket=Bucket.DERIVED))
assert (_tmp_datadir / "thumbnails/d/p001.jpg").exists()
def test_filesystem_list_keys(_tmp_datadir):
be = storage.FilesystemBackend()
run(be.put_bytes("cases/1/a.txt", b"a"))
run(be.put_bytes("cases/1/sub/b.txt", b"b"))
run(be.put_bytes("cases/2/c.txt", b"c"))
keys = run(be.list_keys("cases/1"))
assert keys == ["cases/1/a.txt", "cases/1/sub/b.txt"]
def test_filesystem_local_path_and_ensure_local(_tmp_datadir):
be = storage.FilesystemBackend()
run(be.put_bytes("cases/1/a.pdf", b"x"))
assert be.local_path("cases/1/a.pdf") == (_tmp_datadir / "cases/1/a.pdf").resolve()
assert be.local_path("cases/1/missing.pdf") is None
# ensure_local returns the real path without copying
assert run(be.ensure_local("cases/1/a.pdf")) == (_tmp_datadir / "cases/1/a.pdf").resolve()
def test_filesystem_presign_unsupported(_tmp_datadir):
be = storage.FilesystemBackend()
with pytest.raises(NotImplementedError):
run(be.presign_get("cases/1/a.pdf"))
# ── backend selection ──────────────────────────────────────────────
def test_get_storage_default_is_filesystem(monkeypatch):
monkeypatch.setattr(config, "STORAGE_BACKEND", "filesystem")
storage.reset_storage_cache()
assert isinstance(storage.get_storage(), storage.FilesystemBackend)
def test_get_storage_unknown_falls_back(monkeypatch):
monkeypatch.setattr(config, "STORAGE_BACKEND", "bogus")
storage.reset_storage_cache()
assert isinstance(storage.get_storage(), storage.FilesystemBackend)
def test_get_storage_dual(monkeypatch):
monkeypatch.setattr(config, "STORAGE_BACKEND", "dual")
storage.reset_storage_cache()
assert isinstance(storage.get_storage(), storage.DualBackend)
# ── dual backend (S3 stubbed) ──────────────────────────────────────
class _FakeS3:
"""Minimal async S3 stub. ``store`` None ⇒ S3 'down' (raises)."""
def __init__(self, store):
self.store = store # dict or None
async def put_bytes(self, key, data, *, bucket=Bucket.DOCUMENTS, content_type=None, metadata=None):
if self.store is None:
raise RuntimeError("s3 down")
self.store[storage.normalize_key(key)] = data
return f"s3://stub/{storage.normalize_key(key)}"
async def put_file(self, src, key, *, bucket=Bucket.DOCUMENTS, content_type=None, metadata=None):
with open(src, "rb") as fh:
return await self.put_bytes(key, fh.read(), bucket=bucket)
async def get_bytes(self, key, *, bucket=Bucket.DOCUMENTS):
if self.store is None:
raise RuntimeError("s3 down")
return self.store[storage.normalize_key(key)]
async def exists(self, key, *, bucket=Bucket.DOCUMENTS):
return self.store is not None and storage.normalize_key(key) in self.store
async def delete(self, key, *, bucket=Bucket.DOCUMENTS):
if self.store is not None:
self.store.pop(storage.normalize_key(key), None)
def _dual_with(store):
dual = storage.DualBackend()
dual.s3 = _FakeS3(store)
return dual
def test_dual_writes_both(_tmp_datadir):
store = {}
dual = _dual_with(store)
run(dual.put_bytes("cases/1/a.pdf", b"data"))
assert (_tmp_datadir / "cases/1/a.pdf").read_bytes() == b"data" # disk
assert store["cases/1/a.pdf"] == b"data" # s3
def test_dual_s3_write_failure_does_not_break(_tmp_datadir):
dual = _dual_with(None) # s3 down
# disk write still succeeds; s3 failure is logged, not raised
run(dual.put_bytes("cases/1/a.pdf", b"data"))
assert (_tmp_datadir / "cases/1/a.pdf").read_bytes() == b"data"
def test_dual_get_prefers_s3(_tmp_datadir):
dual = _dual_with({"cases/1/a.pdf": b"from-s3"})
run(dual.fs.put_bytes("cases/1/a.pdf", b"from-disk"))
assert run(dual.get_bytes("cases/1/a.pdf")) == b"from-s3"
def test_dual_get_falls_back_to_disk(_tmp_datadir):
dual = _dual_with(None) # s3 down → must read disk
run(dual.fs.put_bytes("cases/1/a.pdf", b"from-disk"))
assert run(dual.get_bytes("cases/1/a.pdf")) == b"from-disk"
def test_bucket_name_resolution(monkeypatch):
monkeypatch.setattr(config, "MINIO_BUCKET_DOCUMENTS", "legal-documents")
monkeypatch.setattr(config, "MINIO_BUCKET_IMMUTABLE", "legal-immutable")
monkeypatch.setattr(config, "MINIO_BUCKET_DERIVED", "legal-derived")
assert storage._bucket_name(Bucket.DOCUMENTS) == "legal-documents"
assert storage._bucket_name(Bucket.IMMUTABLE) == "legal-immutable"
assert storage._bucket_name(Bucket.DERIVED) == "legal-derived"

View File

@@ -0,0 +1,77 @@
"""Regression tests for the write call-sites rewired onto storage.py (X14
Phase 2). They assert the rewired staging lands bytes at the exact legacy
on-disk location under the default filesystem backend — i.e. zero behaviour
change.
"""
import asyncio
from pathlib import Path
import pytest
from legal_mcp import config
from legal_mcp.services import ingest, storage
@pytest.fixture(autouse=True)
def _tmp_datadir(tmp_path, monkeypatch):
monkeypatch.setattr(config, "DATA_DIR", tmp_path)
monkeypatch.setattr(config, "STORAGE_BACKEND", "filesystem")
storage.reset_storage_cache()
yield tmp_path
storage.reset_storage_cache()
def run(coro):
return asyncio.run(coro)
def test_stage_file_lands_under_datadir(_tmp_datadir):
src = _tmp_datadir / "src" / "כתב ערר.pdf"
src.parent.mkdir(parents=True)
src.write_bytes(b"%PDF-1.4 ...")
root = _tmp_datadir / "precedent-library"
dest = run(ingest._stage_file(src, root, "court_ruling"))
# dest is under the staging subdir, prefixed with a uuid, original suffix kept
assert dest.parent == root / "court_ruling"
assert dest.exists()
assert dest.read_bytes() == b"%PDF-1.4 ..."
assert dest.suffix == ".pdf"
# and the key is DATA_DIR-relative (what the DB column will store)
assert dest.relative_to(_tmp_datadir).as_posix().startswith("precedent-library/court_ruling/")
def test_stage_file_default_subdir(_tmp_datadir):
src = _tmp_datadir / "x.docx"
src.write_bytes(b"doc")
dest = run(ingest._stage_file(src, _tmp_datadir / "digests", ""))
assert dest.parent == _tmp_datadir / "digests" / "other"
assert dest.exists()
def test_thumbnail_renderer_routes_through_storage(_tmp_datadir):
"""extractor.render_pages_for_multimodal (a sync renderer) now persists the
JPEG thumbnail via the sync storage facade — under filesystem it must land
at the requested thumbnail_dir."""
fitz = pytest.importorskip("fitz")
from legal_mcp.services import extractor
pdf = _tmp_datadir / "doc.pdf"
d = fitz.open()
d.new_page(width=200, height=200)
d.save(str(pdf))
d.close()
thumb_dir = _tmp_datadir / "cases" / "1" / "thumbnails" / "docid"
out = extractor.render_pages_for_multimodal(pdf, embed_dpi=72, thumb_dpi=36,
thumbnail_dir=thumb_dir)
assert len(out) == 1
_img, thumb_path = out[0]
assert thumb_path == thumb_dir / "p001.jpg"
assert thumb_path.exists() # written through storage.put_bytes_sync (DERIVED)
assert thumb_path.read_bytes()[:2] == b"\xff\xd8" # JPEG magic
def test_put_bytes_sync_roundtrip(_tmp_datadir):
src_key = "cases/1/exports/x.docx"
storage.put_bytes_sync(src_key, b"PK\x03\x04zip", bucket=storage.Bucket.DOCUMENTS)
assert (_tmp_datadir / src_key).read_bytes() == b"PK\x03\x04zip"

View File

@@ -9,7 +9,8 @@
| Script | Type | Purpose | Scheduled | | Script | Type | Purpose | Scheduled |
|--------|------|---------|-----------| |--------|------|---------|-----------|
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים | | `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים |
| `spec-guard.sh` | bash | **PreToolUse hook לאכיפת "פרוטוקול כתיבת-קוד"** (CLAUDE.md §פרוטוקול כתיבת-קוד) — בכל Edit/Write/MultiEdit על נתיב-קוד (`web/`, `mcp-server/`, `web-ui/src/`, `scripts/`, `adapters/`) מזריק תזכורת ל-Claude לקרוא את `docs/spec/00-constitution.md`+ספ-התחום ולוודא קיום G1G11 — לפני שכותבים. המקבילה האינטראקטיבית ל-INV-AG1 (שאוכף על סוכני Paperclip ב-HEARTBEAT.md §"קריאת-ספ"). קלט JSON ב-stdin (`.tool_input.file_path`), פלט `hookSpecificOutput.additionalContext` (non-blocking, exit 0). מחריג `.md`/`docs/`/`tests/`/artifacts. Dedup פעם-בסשן (`$TMPDIR/.spec-guard-<session_id>`). רשום ב-`.claude/settings.json`. | נקרא אוטומטית ע"י Claude Code (hook) | | `spec-guard.sh` | bash | **PreToolUse hook לאכיפת "פרוטוקול כתיבת-קוד"** (CLAUDE.md §פרוטוקול כתיבת-קוד) — בכל Edit/Write/MultiEdit על נתיב-קוד (`web/`, `mcp-server/`, `web-ui/src/`, `scripts/`, `adapters/`) מזריק תזכורת ל-Claude לקרוא את `docs/spec/00-constitution.md`+ספ-התחום ולוודא קיום G1G12 — לפני שכותבים. **+ leak-guard בזמן-אמת (G12):** על כתיבה ל-`mcp-server/src/*` בודק את התוכן-הנכתב (`new_string`/`content`) ומזהיר אם מוזרק מונח-Paperclip לשכבת-האינטליגנציה (לא-deduped). המקבילה האינטראקטיבית ל-INV-AG1. קלט JSON ב-stdin, פלט `hookSpecificOutput.additionalContext` (non-blocking, exit 0). Dedup פעם-בסשן לתזכורת-הספ. רשום ב-`.claude/settings.json`. | נקרא אוטומטית ע"י Claude Code (hook) |
| `leak_guard.py` | python | **המאכף הקנוני של INV-G12 (שער-הפלטפורמה / docs/spec/X15 §4 / R4).** שני כללים קשיחים: (1) `mcp-server/src` ללא סמלי-Paperclip (allowlist מנומק לפי substring); (2) רק `web/agent_platform_port.py` (+ קבצי-המעטפת) מייבאים את לקוח-Paperclip. stdlib-בלבד (אין venv). `leak_guard.py` = סריקת-repo (exit 1 על הפרה); `leak_guard.py <file>...` = קבצים נתונים (ל-hook). משותף ל-spec-guard.sh (hook), ל-CI (`.gitea/workflows/leak-guard.yaml`) ול-`mcp-server/tests/test_platform_port_leak_guard.py`. | CI + hook + pytest |
| `migrate_gap51_outcomes.py` | python | **GAP-51 (FU-14)** — נרמול ערכי `outcome` לאוצר הקנוני (rejected→rejection, accepted→full_acceptance, partial→partial_acceptance) ב-`decisions.outcome` + `cases.expected_outcome`. `betterment_levy` לא ממופה (practice_area, לא outcome). `--dry-run` (ברירת-מחדל) / `--apply` (גיבוי ל-`data/audit/gap51-outcome-backup-*.csv` + UPDATE טרנזקציוני). דורש POSTGRES_URL. בוצע 2026-06-06 (9 שורות). נוגע רק ב-cases/decisions — בטוח במקביל לחילוץ. | חד-פעמי (בוצע) | | `migrate_gap51_outcomes.py` | python | **GAP-51 (FU-14)** — נרמול ערכי `outcome` לאוצר הקנוני (rejected→rejection, accepted→full_acceptance, partial→partial_acceptance) ב-`decisions.outcome` + `cases.expected_outcome`. `betterment_levy` לא ממופה (practice_area, לא outcome). `--dry-run` (ברירת-מחדל) / `--apply` (גיבוי ל-`data/audit/gap51-outcome-backup-*.csv` + UPDATE טרנזקציוני). דורש POSTGRES_URL. בוצע 2026-06-06 (9 שורות). נוגע רק ב-cases/decisions — בטוח במקביל לחילוץ. | חד-פעמי (בוצע) |
| `sync_missing_agent_skills.py` | python | סקריפט "אל-כשל" להוספת `paperclipSkillSync` ל-`הגהת מסמכים` ו-`מנתח משפטי` שפיספסו את ה-sync ההיסטורי (Gap #28). תומך `--verify`/`--dry-run`/`--apply`. גיבוי אוטומטי ל-`agents-pre-skill-sync-*.sql`. דורש `PAPERCLIP_BOARD_API_KEY` (Infisical /paperclip ב-nautilus env). idempotent. | חד-פעמי (בוצע 2026-05-04). שמור לרפרנס | | `sync_missing_agent_skills.py` | python | סקריפט "אל-כשל" להוספת `paperclipSkillSync` ל-`הגהת מסמכים` ו-`מנתח משפטי` שפיספסו את ה-sync ההיסטורי (Gap #28). תומך `--verify`/`--dry-run`/`--apply`. גיבוי אוטומטי ל-`agents-pre-skill-sync-*.sql`. דורש `PAPERCLIP_BOARD_API_KEY` (Infisical /paperclip ב-nautilus env). idempotent. | חד-פעמי (בוצע 2026-05-04). שמור לרפרנס |
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — `--apply` מדלג על הסוכן; `--verify` מדווח אותו רם כ-DRIFT.** בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות. **`--verify` יוצא exit≠0 על כל drift** (needs-sync / adapter-mismatch / missing-in-mirror) — שמיש כ-gate ל-cron/CI (GAP-21/FU-8a). | ידני אחרי כל שינוי | | `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — `--apply` מדלג על הסוכן; `--verify` מדווח אותו רם כ-DRIFT.** בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות. **`--verify` יוצא exit≠0 על כל drift** (needs-sync / adapter-mismatch / missing-in-mirror) — שמיש כ-gate ל-cron/CI (GAP-21/FU-8a). | ידני אחרי כל שינוי |
@@ -19,6 +20,14 @@
| `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)** — 2 apps: (א) `legal-court-fetch-xvfb` (Xvfb :99, צג-וירטואלי ל-Camoufox); (ב) `legal-court-fetch-service` (`python -m legal_mcp.court_fetch_service.server`, bound `10.0.1.1:8771`, Bearer `COURT_FETCH_SHARED_SECRET` מ-`~/.legal-court-fetch-service.env`, `DISPLAY=:99`). מריץ Camoufox דרך חבילת-הפייתון (in-process) כי הקונטיינר לא יכול דפדפן. תלות: `pip install -e "mcp-server[court-fetch]" && python -m camoufox fetch`. אחזור = ניווט→צופה→`GetImages`(X-Requested-With)→PDF, ללא CAPTCHA; כשל→`ok:false`→orchestrator מסלים ל-fallback אנושי. **אומת על עת"מ 46111-12-22 (34 עמ').** מראָה לדפוס `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) |
| `reap_orphan_procs.py` | python | **reaper לתהליכים-יתומים שמרווים את שרת Nautilus** — הורג `task-master-mcp` (Node, מתנפח ל~3GB) ו-`camoufox-bin` (Firefox מ-X13 fetch שקרס) **רק כשהם יתומים (`ppid=1`)** — תהליך עם הורה-חי לעולם לא נוגעים בו. `/proc` טהור, בלי psutil. `--dry-run` (דיווח), `--loop N` (דמון כל N ש'). ראה זיכרון [[project_taskmaster_mcp_memory_leak]]. | דרך `legal-reaper.config.cjs` (pm2) |
| `legal-reaper.config.cjs` | pm2/js | **דמון pm2 ל-`reap_orphan_procs.py --loop`** (ברירת-מחדל 180ש', `REAP_INTERVAL_S` לעקיפה). `max_memory_restart 100M` (ה-reaper עצמו לא ידלוף). התקנה: `pm2 start scripts/legal-reaper.config.cjs && pm2 save`. לוגים: `pm2 logs legal-reaper`. | pm2 (host-side) |
| `drain_court_fetch.py` | python | **ריקון תור-אחזור הפסיקה (X13)** — קורא ל-`court_fetch_orchestrator.drain_pending(limit)` שמוריד+קולט כל job ממתין שהיומונים מילאו, וקושר חזרה ליומון. מקומי בלבד (ingest = claude CLI). no-op מהיר כשהתור ריק. הרצה ידנית: `mcp-server/.venv/bin/python scripts/drain_court_fetch.py [limit]`. | דרך `legal-court-fetch-drain.config.cjs` (pm2 cron) |
| `backfill_missing_precedents.py` | python | **הזנת `missing_precedents` פתוחים לתור-האחזור (X13)** — מסווג כל פער-פתוח; עליון-סדרתי→Tier-0(supremedecisions), נט-format→Tier-1; ועדת-ערר/לא-מזוהה→דילוג. יוצר `court_fetch_jobs` (idempotent). `--apply` (ברירת-מחדל dry-run). אחרי הרצה: drain-court-fetch קולט. | ידני (חד-פעמי/לפי-צורך) |
| `legal-court-fetch-drain.config.cjs` | pm2/js | **תזמון שעתי של `drain_court_fetch.py`** (cron `17 * * * *`, `COURT_FETCH_DRAIN_CRON` לעקיפה) — הופך את לולאת יומון→אחזור→קליטה ל-fully-autonomous. `autorestart:false` (one-shot per tick). דורש `legal-court-fetch-service` רץ. התקנה: `pm2 start scripts/legal-court-fetch-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
| `drain_metadata_queue.py` | python | **ריקון תור חילוץ-המטא של הפסיקה**`process_pending_extractions(kind='metadata')` ב-batches עד ריק. רץ על **Gemini Flash** (structured JSON, `gemini_session`) — מהיר ואמין, במקום ה-claude CLI ה-agentic שפגע ב-`error_max_turns`. no-op מהיר כשריק. הרצה ידנית: `mcp-server/.venv/bin/python scripts/drain_metadata_queue.py [batch]`. | דרך `legal-metadata-drain.config.cjs` (pm2 cron) |
| `legal-metadata-drain.config.cjs` | pm2/js | **תזמון כל 15 דק' של `drain_metadata_queue.py`** (cron `*/15 * * * *`, `METADATA_DRAIN_CRON` לעקיפה) — מונע סתימה של תור חילוץ-המטא ב-/precedents. דורש `GEMINI_API_KEY` ב-`~/.env`. התקנה: `pm2 start scripts/legal-metadata-drain.config.cjs && pm2 save`. | pm2 cron (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 +47,19 @@
| `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 |
| `goldset_independent_judge.py` | python | **INV-DM7 ולידציה** — שופט-תפקיד **עצמאי שני** ממודל אחר (DeepSeek API ישיר, OpenAI-compatible) ששובר את עיגון-ה-AI: מסווג rule_role **בעיוור** (בלי לראות תיוג-אדם או המלצת-claude) ומחשב מטריצת-הסכמה (deepseek↔אדם מול ai↔אדם) + ציר-גס (כלל-בר-הכללה מול application/obiter). **ממצא (2026-06-07):** ai↔אדם=100% (מעוגן), deepseek↔אדם=50% מדויק אך **92% גס** → תת-הסוג holding/interpretive/procedural עמום-מטבעו (לא לשער עליו); הציר-הגס אמין חוצה-מודלים. read-only על הזהב. `--model`/`--limit`/`--concurrency`. מפתח מ-`~/.hermes/profiles/deepseek/.env`. raw→`/tmp/goldset_judge_raw.json`. | ידני — ולידציית אמינות-תוויות |
| `halacha_panel_approve.py` | python | **פאנל-אישור הלכות (Trust-or-Escalate, dry-run).** 3 שופטים בלתי-תלויי-לינאז' (Opus/claude_session · DeepSeek · Gemini-2.5-flash) מצביעים על ה**ציר-הגס האמין** (92% חוצה-מודלים): נקיות→"הלכה לשמירה?"; nli_unsupported→"הציטוט תומך בכלל?" (שיפוט-מחדש); פגומות→re-extraction. רק ורדיקט מוסכם פועל אוטומטית, **פיצול מסלים ליו"ר** (INV-G10). `--apply` **מחווט** (clean: רוב 2/3; nli: פה-אחד-entailed מנקה flag) — הפיך, מגבה ל-`data/audit/` קודם. מפתחות: DeepSeek מ-`~/.hermes/...`, Gemini מ-`~/.env`. **חובה מקומי**. dry-run 2026-06-07: 197→103 אוטו (פה-אחד) / ~15 (רוב). | ידני / שלב-אימות-הלכות במסלול-הסופי |
| `style_lesson_panel.py` | python | **פאנל-סגנון דו-סוכני (למידה כפולה).** על-גבי דיסטילציית-ה-Opus (draft↔final ב-`draft_final_pairs.analysis`), שני שופטים בלתי-תלויים — DeepSeek + Gemini-2.5-flash — מצביעים לכל לקח על השאלה הגסה "האם זו הנחיית-סגנון מופשטת ובת-הכללה (INV-LRN5 — קול ולא מהות)?". הסכמה 2/2-keep → נכתב כ-`decision_lesson` (`source=panel:deepseek+gemini`); 2/2-drop → לא נכתב; פיצול/substance → מוסלם ליו"ר. `--apply` הפיך, מגבה ל-`data/audit/`. הטמעה ל-SKILL.md/lessons.md נשארת שער-יו"ר ידני (INV-G10). מפתחות כמו פאנל-ההלכות. **חובה מקומי**. `--case <num>` / `--pair-id <uuid>`. | שלב-למידה במסלול-הסופי |
| `final_learning_pipeline.py` | python | **תזמור שלב-הלמידה (פקודה אחת).** מופעל ע"י הרמס כשלוחצים "הרץ למידת-קול" במסלול-הסופי. דטרמיניסטי: (1) `ingest_final_version` עם נתיב-הסופי, (2) רישום לקורפוס-הסגנון (idempotent), (3) `style_lesson_panel --apply`. **עמיד (X16/INV-DUR1):** 3 הצעדים רצים דרך `_pipeline_runtime.py` (משותף עם halacha) עם checkpoint לכל תיק — קריסה בפאנל [3] ממשיכה מ-[3] במקום לשלם שוב על דיסטילציית-Opus [1]. ברירת-מחדל auto-resume; `--fresh` ריצה נקייה. idempotent. **חובה מקומי**. `--case <num>` / `--force` / `--fresh`. | אוטו (כפתור run-learning) / ידני |
| `final_halacha_pipeline.py` | python | **תזמור שלב-אימות-ההלכות (פקודה אחת).** מופעל ע"י הרמס כשלוחצים "הרץ אימות-הלכות". דטרמיניסטי: (0) `precedent_extract_halachot` (החלטה), (1) `extract_internal_citations(chair)`, (2) `corroboration.build_all()`, (3) `halacha_panel_approve --apply`. **עמיד (X16/INV-DUR1):** 4 הצעדים רצים דרך `_pipeline_runtime.py` עם checkpoint לכל תיק — קריסה בפאנל [3] ממשיכה מ-[3]. ברירת-מחדל auto-resume; `--fresh` ריצה נקייה. **חובה מקומי**. `--case <num>` / `--limit N` / `--fresh`. | אוטו (כפתור run-halacha) / ידני |
| `_pipeline_runtime.py` | python | **runtime עמידות משותף (X16 / INV-DUR1)** ל-`final_halacha_pipeline` ו-`final_learning_pipeline` (מימוש אחד, G2). עוטף רשימת-צעדים async ב-LangGraph `StateGraph` ליניארי עם `AsyncSqliteSaver` (checkpoint לכל צעד; resume מדלג על צעדים שהושלמו). **degradation חיננית:** ללא langgraph (`pip install -e ".[durable]"`) — ריצה ליניארית כמו קודם (הכפתור לא נשבר). `Step(name, run)` + `run_pipeline(steps, thread_id, checkpoint_db, fresh)`. נבדק: `mcp-server/tests/test_pipeline_runtime.py`. | מיובא ע"י סקריפטי-המסלול-הסופי |
| `curator_apply_pipeline_branch.py` | python | **מקור-אמת לחיווט-הכפתורים של הרמס.** prompt-ה-curator חי רק ב-Paperclip DB (`agents.adapter_config.promptTemplate`). הסקריפט מקדים branch כך שיקיצה עם reason `final_learning_*`/`final_halacha_*` מריצה את ה-pipeline המתאים (HOME/DOTENV/DATA_DIR מוחלטים → DeepSeek+Gemini keys + DATA_DIR נפתרים נכון) ועוצרת, אחרת §A/§B כרגיל. idempotent (מסיר branch קודם). מחיל על שני הסוכנים (CMP+CMPA). `--verify`. **להריץ אחרי reset/יצירה-מחדש של סוכן-curator.** | אחרי reset prompt של curator |
| `halacha_panel_audit.py` | python | **רשת-ביטחון לפאנל** (selective-prediction monitoring) — דוגם הלכות שאושרו ע"י הפאנל (`reviewer LIKE 'panel:%'`), מריץ עליהן **שוב** את הצבעת-ה-KEEP של 3 השופטים, ומציף כל מקרה שכעת נוטה DROP (false-keep פוטנציאלי). report-only כברירת-מחדל; `--flag` מחזיר את ה-flips ל-`pending_review` לסקירת-יו"ר. `--sample N`/`--seed`. בסיס 2026-06-07: 0/15. מיועד להרצה תקופתית (שבועי). מייבא שופטים מ-`halacha_panel_approve`. **חובה מקומי**. | תקופתי (שבועי) — ניטור |
| `halacha_panel_calibrate.py` | python | **כיול מדיניות-ההצבעה של הפאנל** (Trust-or-Escalate, ICLR 2025). מריץ את שאלת-ה-KEEP של `halacha_panel_approve` על מדגם-הזהב ומודד מול `is_holding` (הציר-הגס) precision+coverage לכל מדיניות (unanimous/majority) + ספירת false-keep/false-drop. נותן את **אחוז-הטעות בפועל** לבחירת סף-סיכון α. מייבא שופטים מ-`halacha_panel_approve` (מקור-אמת יחיד). read-only, **חובה מקומי**. | ידני — לפני חיווט `--apply` |
| `halacha_rule_role_backfill.py` | python | **INV-DM7** — backfill חד-פעמי: מסווג-מחדש את ההלכות הישנות (`rule_type IN ('binding','persuasive')` — ערכי-סמכות שנשמרו במסווה תפקיד לפני פיצול הצירים) לאחד מחמשת **תפקידי-הכלל** (holding/interpretive/procedural/application/obiter) דרך claude_session המקומי (אפס עלות). **לא נוגע בסמכות** (נגזרת מ-`precedent_level`). `--apply` (ברירת-מחדל dry-run) / `--limit N` / `--concurrency`. כותב backup CSV ל-`data/audit/` תחילה. fail-safe (פריט שנכשל → נשמר ערך ישן). **חובה מקומי** (claude_session). | ידני חד-פעמי אחרי deploy של פיצול-הסמכות |
| `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`) |
@@ -81,7 +101,11 @@
| `run_curator_deepseek_test_v2.sh` | A/B test #2 (2026-05-05) — אותו run אבל עם interaction. תוצאה: 9:08 דק׳, 5 ממצאים, היחיד מ-4 הריצות שזיהה תוצאה עובדתית נכונה (קבלה חלקית). interaction נכשל ב-API ("Agent run id required" בריצה ידנית). | החלפת Curator לאדפטר DeepSeek מקומי | | `run_curator_deepseek_test_v2.sh` | A/B test #2 (2026-05-05) — אותו run אבל עם interaction. תוצאה: 9:08 דק׳, 5 ממצאים, היחיד מ-4 הריצות שזיהה תוצאה עובדתית נכונה (קבלה חלקית). interaction נכשל ב-API ("Agent run id required" בריצה ידנית). | החלפת Curator לאדפטר DeepSeek מקומי |
| `run_curator_sonnet_rerun.sh` | A/B test #3 (2026-05-05) — ריצה חוזרת של Sonnet 4.5 על אותו CMP-78. תוצאה: 12:52 דק׳ (לעומת 20:13 בריצה המקורית — כי בלי לולאת interaction.json). זיהה תוצאה שגויה ("דחייה") **בעקביות עם הריצה המקורית** — Sonnet עקבי-בטעות, DeepSeek אקראי. | בדיקה חד-פעמית — לא להריץ שוב | | `run_curator_sonnet_rerun.sh` | A/B test #3 (2026-05-05) — ריצה חוזרת של Sonnet 4.5 על אותו CMP-78. תוצאה: 12:52 דק׳ (לעומת 20:13 בריצה המקורית — כי בלי לולאת interaction.json). זיהה תוצאה שגויה ("דחייה") **בעקביות עם הריצה המקורית** — Sonnet עקבי-בטעות, DeepSeek אקראי. | בדיקה חד-פעמית — לא להריץ שוב |
| `ingest_incoming_batch.py` | python | קליטת batch של החלטות ועדת ערר מ-`data/precedents/incoming/` דרך המסלול הקנוני (`ingest_internal_decision`) + חילוץ מטא-דאטה לכל תיק (המסלול הפנימי לא מתזמן metadata — INV-ING3). רצף (לא מקבילי, להימנע מעומס CLI). רשימת `DECISIONS` נערכת ידנית לכל batch. config מ-`~/.env`. תומך תהליך [[project_precedent_incoming_workflow]]. | ידני, per-batch (חלופה ל-MCP `internal_decision_upload` כש-batch גדול) | | `ingest_incoming_batch.py` | python | קליטת batch של החלטות ועדת ערר מ-`data/precedents/incoming/` דרך המסלול הקנוני (`ingest_internal_decision`) + חילוץ מטא-דאטה לכל תיק (המסלול הפנימי לא מתזמן metadata — INV-ING3). רצף (לא מקבילי, להימנע מעומס CLI). רשימת `DECISIONS` נערכת ידנית לכל batch. config מ-`~/.env`. תומך תהליך [[project_precedent_incoming_workflow]]. | ידני, per-batch (חלופה ל-MCP `internal_decision_upload` כש-batch גדול) |
| `drain_halacha_queue.py` | python | ריקון תור חילוץ ההלכות (`process_pending_extractions kind='halacha'`) ב-batches של 4 עד שהתור ריק (2 סבבים ריקים). משמש אחרי `ingest_incoming_batch.py`. | ידני אחרי batch (חלופה ל-MCP `precedent_process_pending`) | | `drain_halacha_queue.py` | python | ריקון תור חילוץ ההלכות (`process_pending_extractions kind='halacha'`) ב-batches של 4 עד שהתור ריק (2 סבבים ריקים). חילוץ-הלכות נשאר על claude_session (לא Gemini). self-heal ל-orphaned `processing`. ההלכות נוחתות `pending_review` (שער-יו"ר). | דרך `legal-halacha-drain.config.cjs` (pm2 cron) / ידני |
| `legal-halacha-drain.config.cjs` | pm2/js | **תזמון כל שעתיים של `drain_halacha_queue.py`** (cron `47 */2 * * *`, `HALACHA_DRAIN_CRON` לעקיפה) — מונע סתימה של תור חילוץ-ההלכות. קצב שמרני (Claude איטי + כל ריצה מוסיפה לתור-אישור-היו"ר). דורש claude CLI. התקנה: `pm2 start scripts/legal-halacha-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
| `ingest_digests_batch.py` | python | קליטת batch של יומוני "כל יום" מ-`data/digests/incoming/` דרך המסלול העצמאי של קורפוס-הגילוי (`digest_library.ingest_digest`) — חילוץ-LLM (תג-מושג, כותרת-הלכה, מראה-מקום, שני-תאריכים), embedding יחיד, ו-autolink לפסק המקורי (X12/INV-DIG3). רצף (לא מקבילי). מזהה-יומון+תאריך נגזרים משם-הקובץ; העלון החודשי מדולג. **לא מעביר קבצים** — ה-DB (content_hash) הוא מקור-האמת היחיד; הרצה חוזרת מדלגת על קיימים (`exists`). config מ-`~/.env`. | ידני, per-batch (חלופה ל-MCP `digest_upload`) |
| `drain_digests.py` | python | ריקון תור ההעשרה של יומונים (X12): מעבד כל digest בסטטוס `pending` דרך `digest_library.enrich_digest` (חילוץ-LLM Sonnet + embedding + autolink). מקבילי (CONCURRENCY=3, env-tunable), idempotent. מוסיף `~/.local/bin` ל-PATH כדי שה-claude CLI יימצא תחת cron. בודק דגל `drain_controls('legal-digest-drain')` ב-startup → no-op כשכבוי מ-/operations. | דרך `legal-digest-drain.config.cjs` (pm2 cron) + ידני אחרי backfill. חלופת-MCP: `digest_process_pending` |
| `legal-digest-drain.config.cjs` | pm2/js | **תזמון כל שעתיים של `drain_digests.py`** (cron `0 */2 * * *`, `DIGEST_DRAIN_CRON` לעקיפה) — הועבר מ-crontab של המערכת ל-pm2 כדי שיופיע ויהיה שליט בדף `/operations` (הרץ-עכשיו/הפעל/כבה). `autorestart:false` (one-shot per tick). דורש claude CLI + `VOYAGE_API_KEY`. התקנה: `pm2 start scripts/legal-digest-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
## סקריפטים שנמחקו (git history בלבד) ## סקריפטים שנמחקו (git history בלבד)

View File

@@ -0,0 +1,130 @@
"""Durable execution runtime for the local one-shot pipelines (INV-DUR1 / X16).
Wraps an ordered list of named async steps in a LangGraph linear ``StateGraph``
with a SQLite checkpointer, so a crash / OOM / kill resumes from the last
COMPLETED step instead of re-running the whole pipeline (idempotency makes a
re-run *safe*; durability makes it *not pay twice*).
Shared by ``final_halacha_pipeline.py`` and ``final_learning_pipeline.py`` — one
implementation, not one-per-script (G2).
Graceful degradation: if ``langgraph`` is not installed (e.g. the shared venv
hasn't been updated yet), the steps run LINEARLY — exactly as before — with a
warning. The production button (run-halacha / run-learning, driven by Hermes)
never breaks waiting on the dependency; it simply gains durable resume once
``langgraph`` + ``langgraph-checkpoint-sqlite`` are present.
Scope (X16 §1): LangGraph is used ONLY as the internal engine of these local
scripts — never as an agent-platform orchestrator (that would create a parallel
path to Paperclip, breaking G2/G12). HITL stays with the chair gates / Paperclip.
A "step" is ``Step(name, run)`` where ``run`` is an async callable taking the
accumulated results dict and returning a dict to merge into it (typically
``{<something>: <summary>}``). The step's real side-effects (DB writes, the LLM
panel) happen inside ``run``; LangGraph checkpoints *that the node finished* so a
resume skips it.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Any, Awaitable, Callable, TypedDict
logger = logging.getLogger(__name__)
StepFn = Callable[[dict], Awaitable[dict]]
@dataclass(frozen=True)
class Step:
name: str
run: StepFn
def _merge(a: dict, b: dict) -> dict:
return {**a, **b}
async def _run_linear(steps: list[Step]) -> dict:
"""Fallback: run steps in order with no checkpointing (pre-X16 behaviour)."""
results: dict[str, Any] = {}
for step in steps:
out = await step.run(results)
if out:
results.update(out)
return results
async def run_pipeline(
steps: list[Step],
*,
thread_id: str,
checkpoint_db: str | Path,
resume: bool = True,
fresh: bool = False,
) -> dict:
"""Run ``steps`` in order with durable checkpointing keyed by ``thread_id``.
* A brand-new ``thread_id`` (or ``fresh=True``) runs from the first step.
* An INCOMPLETE thread (a prior run crashed mid-way) is RESUMED — completed
steps are skipped, execution continues from the failed step.
* A COMPLETED thread re-run (idempotent re-extraction) starts fresh — the
stale checkpoint is cleared first so step-accumulators don't double-count.
Returns the accumulated results dict (``{step_name: <return>, ...}``).
"""
try:
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from langgraph.graph import END, START, StateGraph
except Exception as e: # noqa: BLE001 — any import failure → safe linear path
logger.warning(
"langgraph unavailable (%s) — running %d steps LINEARLY without "
"durable checkpointing (X16/INV-DUR1 inactive; install langgraph + "
"langgraph-checkpoint-sqlite to enable resume).",
e, len(steps),
)
return await _run_linear(steps)
class State(TypedDict):
results: Annotated[dict, _merge]
def _make_node(step: Step):
async def _node(state: State) -> dict:
out = await step.run(state.get("results", {}))
return {"results": out or {}}
return _node
graph = StateGraph(State)
prev = START
for step in steps:
graph.add_node(step.name, _make_node(step))
graph.add_edge(prev, step.name)
prev = step.name
graph.add_edge(prev, END)
checkpoint_db = Path(checkpoint_db)
checkpoint_db.parent.mkdir(parents=True, exist_ok=True)
config = {"configurable": {"thread_id": thread_id}}
async with AsyncSqliteSaver.from_conn_string(str(checkpoint_db)) as saver:
app = graph.compile(checkpointer=saver)
snapshot = await app.aget_state(config)
ran = (snapshot.values or {}).get("results", {}) if snapshot else {}
incomplete = bool(ran) and tuple(snapshot.next or ()) != ()
if not fresh and incomplete:
logger.info(
"pipeline %s — resuming from %s (%d step(s) already done: %s)",
thread_id, snapshot.next, len(ran), ", ".join(ran),
)
final = await app.ainvoke(None, config)
else:
if snapshot and (snapshot.values or {}):
# stale/completed checkpoint — clear so this is a true fresh run.
await saver.adelete_thread(thread_id)
if fresh and ran:
logger.info("pipeline %s — --fresh: cleared prior checkpoint", thread_id)
final = await app.ainvoke({"results": {}}, config)
return (final or {}).get("results", {})

View File

@@ -0,0 +1,56 @@
"""Backfill: enqueue publicly-fetchable open missing_precedents for auto-fetch.
The citation graph records cited-but-absent rulings in ``missing_precedents``.
The ones with a public source — Supreme serial (בג"ץ/בר"מ/עע"מ NNNN/YY) → Tier-0
supremedecisions; district/Supreme with a נט-format triple → Tier-1 נט המשפט —
can be fetched + ingested automatically. ועדת-ערר (needs Nevo) and serial cases
with no public record are left for the chair.
This stamps a ``court_fetch_jobs`` row for each fetchable gap; the court-fetch
drainer (``drain_court_fetch.py`` / pm2 cron) then fetches, ingests, and closes
the gap. Idempotent (upsert on the canonical case number).
scripts/backfill_missing_precedents.py # dry-run (report only)
scripts/backfill_missing_precedents.py --apply # enqueue
"""
import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
from legal_mcp.services import court_citation, db
async def main() -> int:
apply = "--apply" in sys.argv
gaps = await db.list_missing_precedents(status="open", limit=2000)
enq = skipped = 0
by_tier: dict[str, int] = {}
for g in gaps:
cit = court_citation.classify(g.get("citation", ""))
net = bool(cit.file_number and cit.month and cit.year)
# Fetchable: Supreme serial (Tier-0) or anything with a נט triple (Tier-1).
if cit.tier == "supreme" or (cit.tier == "admin" and net):
route = "Tier-0/supreme" if (cit.tier == "supreme" and not net) else "Tier-1/net"
by_tier[route] = by_tier.get(route, 0) + 1
if apply:
await db.court_fetch_job_upsert(
case_number_norm=cit.case_number_norm,
citation_raw=g.get("citation", ""),
tier=cit.tier, court=cit.court_prefix,
)
enq += 1
else:
skipped += 1
verb = "enqueued" if apply else "would enqueue"
print(f"{verb}: {enq} (routes: {by_tier})", flush=True)
print(f"skipped (ועדת-ערר/serial-no-record/unrecognized): {skipped}", flush=True)
if not apply:
print("dry-run — re-run with --apply to enqueue.", flush=True)
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Reproducible source-of-truth for the curator's PIPELINE-WAKE branch.
The Hermes curator's prompt lives ONLY in the Paperclip DB
(agents.adapter_config.promptTemplate), not in a repo file. This script (re-)prepends
a branch so that a wake whose reason starts with `final_learning_` / `final_halacha_`
runs the matching local pipeline script and stops — instead of the default §A/§B
style-curation routine. Without it, clicking the run-learning / run-halacha buttons
wakes the curator but it does style-curation, never the pipeline.
Idempotent: strips any prior branch (by the ORIG_START marker) and re-prepends the
current one. Applies to BOTH company curators (CMP + CMPA) so cross-company sync stays
consistent. Re-run this after any curator-prompt reset / agent re-creation.
python3 scripts/curator_apply_pipeline_branch.py # apply
python3 scripts/curator_apply_pipeline_branch.py --verify # show head, no write
Env knobs (defaults shown):
PAPERCLIP_DB_URL=postgresql://paperclip:paperclip@localhost:54329/paperclip
"""
from __future__ import annotations
import argparse
import asyncio
import os
import asyncpg
PAPERCLIP_DB = os.environ.get(
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@localhost:54329/paperclip"
)
CURATOR_AGENTS = [
"60dce831-5c5b-4bae-bda9-5282d506f0dc", # CMP (1xxx — licensing)
"d6f7c55d-570a-46b8-8d72-1286d07da0d8", # CMPA (8xxx/9xxx — betterment)
]
MARKER = "### PIPELINE-WAKE BRANCH (auto)"
ORIG_START = "אתה מנהל ידע" # first line of the canonical curator prompt
# HOME=/home/chaim is the root fix: the Hermes agent runs with $HOME pointing at its
# profile dir, so Path.home() / expanduser('~') would resolve DATA_DIR and the
# DeepSeek/Gemini key files wrong. Pinning HOME (+ DOTENV_PATH/DATA_DIR) makes the
# pipeline resolve the case files AND both judge keys correctly.
BRANCH = MARKER + r'''
לפני כל דבר אחר — בדוק אם זו יקיצת-pipeline אוטומטית. הרץ בדיוק את הבלוק הבא:
```bash
WAKE="{{wakeReason}}"
case "$WAKE" in
final_learning_*|final_halacha_*)
KIND=$(printf '%s' "$WAKE" | cut -d_ -f2)
CASE="${WAKE#final_${KIND}_}"
cd /home/chaim/legal-ai/mcp-server && \
HOME=/home/chaim DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data \
nohup .venv/bin/python ../scripts/final_${KIND}_pipeline.py --case "$CASE" \
> "/tmp/final_${KIND}_${CASE}.log" 2>&1 &
sleep 2
echo "PIPELINE_STARTED final_${KIND}_pipeline case=$CASE log=/tmp/final_${KIND}_${CASE}.log"
;;
*) echo "NO_PIPELINE_WAKE" ;;
esac
```
אם הפלט הוא `PIPELINE_STARTED ...` — **זו כל המשימה**: כתוב comment קצר בעברית ("הופעל צינור <KIND> לתיק <CASE>; התוצאות יופיעו ב-/training (סגנון) או /approvals + /precedents (הלכות) תוך מספר דקות."), סגור את ה-issue (status=done), ו**סיים מיד — אל תמשיך לסעיפים שלמטה**.
אם הפלט הוא `NO_PIPELINE_WAKE` — המשך כרגיל לתבנית שלמטה.
---
'''
async def main(verify: bool) -> int:
conn = await asyncpg.connect(PAPERCLIP_DB)
try:
for aid in CURATOR_AGENTS:
row = await conn.fetchrow(
"SELECT name, adapter_config->>'promptTemplate' AS t FROM agents WHERE id=$1",
aid,
)
if not row or not row["t"]:
print(f"{aid}: NO TEMPLATE — skip")
continue
t = row["t"]
if verify:
has = MARKER in t
print(f"{aid} ({row['name']}): branch={'present' if has else 'MISSING'} "
f"len={len(t)}")
continue
i = t.find(ORIG_START)
if i < 0:
print(f"{aid}: ORIG_START not found — skip (manual check)")
continue
new = BRANCH + t[i:] # strip any prior branch, re-prepend
await conn.execute(
"UPDATE agents SET adapter_config = "
"jsonb_set(adapter_config,'{promptTemplate}', to_jsonb($2::text)), "
"updated_at = now() WHERE id=$1",
aid, new,
)
print(f"{aid} ({row['name']}): branch applied; template now {len(new)} chars")
finally:
await conn.close()
return 0
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--verify", action="store_true", help="report only, no write")
raise SystemExit(asyncio.run(main(ap.parse_args().verify)))

View File

@@ -0,0 +1,49 @@
"""Drain the X13 court-verdict fetch queue (jobs the digest trigger fills).
When a digest points at a court ruling not yet in the corpus, the digest
trigger enqueues a ``court_fetch_jobs`` row (status=pending). This script
drains those: for each pending/failed job it runs the full Tier-0/Tier-1 fetch
(via the host browser service) + the canonical ingest, then links the verdict
back to its source digest. Serial with a cooldown (INV-CF4); failures are
recorded and retried until they escalate to ``manual`` (INV-CF3).
Host-only: ingest drives halacha extraction via the local ``claude`` CLI (same
constraint as ``drain_halacha_queue.py``). A no-op (fast) when the queue is
empty. Scheduled hourly by ``legal-court-fetch-drain`` (pm2 cron); also runnable
by hand:
mcp-server/.venv/bin/python scripts/drain_court_fetch.py [limit]
"""
import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
from legal_mcp.services import court_fetch_orchestrator as orch
from legal_mcp.services import db
async def main() -> int:
# /operations "disable" switch — no-op immediately if turned off (pm2
# cron_restart can still fire a stopped job, so the gate lives in the DB).
if await db.is_drain_disabled("legal-court-fetch-drain"):
print("===SKIP=== legal-court-fetch-drain disabled via /operations", flush=True)
return 0
limit = int(sys.argv[1]) if len(sys.argv) > 1 else 5
res = await orch.drain_pending(limit=limit)
print(f"===court-fetch drain=== processed={res.get('processed', 0)} "
f"ingested={res.get('done', 0)}", flush=True)
for r in res.get("results", []):
line = f" [{r.get('status')}] {r.get('citation', '')}"
if r.get("error"):
line += f"{r['error'][:120]}"
if r.get("case_law_id"):
line += f" → case_law {r['case_law_id']}"
print(line, flush=True)
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

118
scripts/drain_digests.py Normal file
View File

@@ -0,0 +1,118 @@
"""Drain the digest enrichment queue (X12) — local LLM enrichment of pending digests.
The web/n8n upload path creates digest rows with extraction_status='pending'
(container-safe: stage + extract_text only). The LLM metadata extraction +
embedding + autolink MUST run locally (claude_session is local-only — the
``claude`` CLI is not in the container). This script is that local drainer:
pending digests → digest_library.enrich_digest (Sonnet, tools="") → completed
Concurrency-limited (avoids LLM rate-limit storms). Idempotent — only touches
rows still 'pending'; safe to re-run. The DB is the single source of truth.
Used two ways:
1. Manually after a backfill: mcp-server/.venv/bin/python scripts/drain_digests.py
2. pm2 cron ``legal-digest-drain`` (scripts/legal-digest-drain.config.cjs) —
one-shot per tick. Controllable from the /operations dashboard (run-now /
enable / disable). Logs to data/digests/drain.log.
claude CLI must be on PATH (the cron line prepends ~/.local/bin). Config
(POSTGRES_URL, VOYAGE_API_KEY) auto-loads from ~/.env via legal_mcp.config.
"""
import asyncio
import os
import sys
import time
from datetime import datetime, timezone
# Ensure the local claude CLI is reachable even under a bare cron PATH.
os.environ["PATH"] = os.path.expanduser("~/.local/bin") + os.pathsep + os.environ.get("PATH", "")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
from legal_mcp.services import db, digest_library as dl # noqa: E402
CONCURRENCY = int(os.environ.get("DIGEST_DRAIN_CONCURRENCY", "3"))
async def main() -> int:
pool = await db.get_pool()
# /operations "disable" switch — no-op immediately if turned off (pm2
# cron_restart can still fire a stopped job, so the gate lives in the DB).
if await db.is_drain_disabled("legal-digest-drain"):
print("===SKIP=== legal-digest-drain disabled via /operations", flush=True)
await db.close_pool()
return 0
# get_pool() runs schema migrations first — incl. the V32 digest_kind backfill
# that classifies legacy rows — so the failure check below is safe from the
# very first run (no legacy row has digest_kind='').
#
# Self-heal: a successful enrich ALWAYS sets digest_kind (decision/announcement
# /other). So a 'completed' row with digest_kind='' means the extraction never
# landed (e.g. the local claude subscription window was exhausted) — reset to
# 'pending' to retry (idempotent auto-resume). This correctly does NOT touch
# announcements (digest_kind='announcement', legitimately no citation), which
# the old "both fields empty" heuristic wrongly retried forever.
healed = await pool.execute(
"UPDATE digests SET extraction_status = 'pending' "
"WHERE extraction_status = 'completed' "
"AND coalesce(digest_kind,'') = '' "
"AND coalesce(analysis_text,'') <> ''"
)
if healed and healed != "UPDATE 0":
print(f"self-heal: reset unclassified (failed) digests → pending ({healed})", flush=True)
# Self-heal stale 'processing': flock guarantees a single drainer, so at the
# start of THIS run any row left 'processing' is from a previous run that was
# killed mid-row (session/quota cutoff). Reset to 'pending' so it is retried.
stale = await pool.execute(
"UPDATE digests SET extraction_status = 'pending' WHERE extraction_status = 'processing'"
)
if stale and stale != "UPDATE 0":
print(f"self-heal: reset stale processing → pending ({stale})", flush=True)
rows = await pool.fetch(
"SELECT id FROM digests WHERE extraction_status = 'pending' ORDER BY created_at"
)
ids = [r["id"] for r in rows]
total = len(ids)
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
if not total:
print(f"[{stamp}] no pending digests — nothing to drain", flush=True)
await db.close_pool()
return 0
print(f"[{stamp}] draining {total} pending digests @ concurrency={CONCURRENCY}", flush=True)
sem = asyncio.Semaphore(CONCURRENCY)
state = {"done": 0, "ok": 0, "linked": 0, "fail": 0}
t0 = time.time()
async def work(did):
async with sem:
try:
res = await dl.enrich_digest(did)
state["ok"] += 1
if res.get("linked_case_law_id"):
state["linked"] += 1
except Exception as e:
state["fail"] += 1
print(f" FAIL {did}: {type(e).__name__}: {e}", flush=True)
state["done"] += 1
if state["done"] % 20 == 0 or state["done"] == total:
el = (time.time() - t0) / 60
print(
f" progress {state['done']}/{total} | ok={state['ok']} "
f"linked={state['linked']} fail={state['fail']} | {el:.1f}min",
flush=True,
)
await asyncio.gather(*[work(i) for i in ids])
done_stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
print(
f"[{done_stamp}] DONE {state['done']}/{total} | ok={state['ok']} "
f"linked={state['linked']} fail={state['fail']} | {(time.time()-t0)/60:.1f}min",
flush=True,
)
await db.close_pool()
return 1 if state["fail"] else 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

@@ -0,0 +1,47 @@
"""Drain the halacha extraction queue for the incoming batch.
Calls the canonical process_pending_extractions(kind='halacha') in small batches
until the queue is empty (two consecutive zero-progress rounds). Serial + global
advisory-lock coordinated inside the service — avoids concurrent Claude load spikes.
Run: mcp-server/.venv/bin/python scripts/drain_halacha_queue.py
"""
import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
from legal_mcp.services import db
from legal_mcp.services import precedent_library as pl
async def main():
# /operations "disable" switch — no-op immediately if turned off (pm2
# cron_restart can still fire a stopped job, so the gate lives in the DB).
if await db.is_drain_disabled("legal-halacha-drain"):
print("===SKIP=== legal-halacha-drain disabled via /operations", flush=True)
return
total = 0
empty_rounds = 0
rnd = 0
while empty_rounds < 2:
rnd += 1
out = await pl.process_pending_extractions(kind="halacha", limit=4)
processed = out.get("processed", 0)
total_pending = out.get("total_pending", 0)
total += processed
print(f"[round {rnd}] processed={processed} total_pending={total_pending} status={out.get('status')}", flush=True)
for r in out.get("results", []):
print(f" {r.get('case_number')}: {r.get('status')} stored={r.get('stored')} retry={r.get('retry_attempts')}", flush=True)
if processed == 0:
empty_rounds += 1
await asyncio.sleep(5)
else:
empty_rounds = 0
print(f"\n===DONE=== total halachot rounds processed; cases handled cumulatively={total}", flush=True)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,54 @@
"""Drain the precedent metadata-extraction queue.
Calls ``process_pending_extractions(kind='metadata')`` in batches until the
queue is empty (two consecutive zero-progress rounds). Metadata extraction runs
on **Gemini Flash** (structured JSON) — fast and reliable, unlike the agentic
claude CLI which hit ``error_max_turns`` on this bounded task. A no-op (fast)
when the queue is empty.
Host-only (reads GEMINI_API_KEY + POSTGRES_URL from ~/.env via legal_mcp.config).
Scheduled by ``legal-metadata-drain`` (pm2 cron); also runnable by hand:
mcp-server/.venv/bin/python scripts/drain_metadata_queue.py [batch]
"""
import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
from legal_mcp.services import db
from legal_mcp.services import precedent_library as pl
async def main() -> int:
# /operations "disable" switch — no-op immediately if turned off (pm2
# cron_restart can still fire a stopped job, so the gate lives in the DB).
if await db.is_drain_disabled("legal-metadata-drain"):
print("===SKIP=== legal-metadata-drain disabled via /operations", flush=True)
return 0
batch = int(sys.argv[1]) if len(sys.argv) > 1 else 10
total = 0
empty_rounds = 0
rnd = 0
while empty_rounds < 2:
rnd += 1
out = await pl.process_pending_extractions(kind="metadata", limit=batch)
processed = out.get("processed", 0)
total += processed
print(f"[round {rnd}] processed={processed} total_pending={out.get('total_pending', 0)} "
f"status={out.get('status')}", flush=True)
for r in out.get("results", []):
print(f" {str(r.get('case_number',''))[:42]}: {r.get('status')}", flush=True)
if processed == 0:
empty_rounds += 1
await asyncio.sleep(3)
else:
empty_rounds = 0
print(f"===DONE=== metadata extracted (cumulative cases handled={total})", flush=True)
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""One-shot LOCAL pipeline for the 'run-halacha' button (halacha validation).
The /api/cases/{case}/final/run-halacha endpoint wakes the Hermes curator, which
runs THIS single deterministic command (the 3-judge panel uses local DeepSeek+Gemini
keys + the local claude CLI, so it can't run inside the container).
Steps:
[0] precedent_extract_halachot → extract the halachot the DECISION ITSELF states
(its own case_law row), so they aren't left pending. Idempotent.
[1] extract_internal_citations(chair) → links the citation graph for the chair's
decisions (idempotent; ON CONFLICT DO NOTHING).
[2] corroboration_rebuild → builds the citation-treatment signal and applies the
corroborated→approved / overruled→pending policy (X11 Phase 2).
[3] halacha_panel_approve --apply → 3 judges (Opus+DeepSeek+Gemini); agreement
auto-approves/rejects (reversible, CSV-backed); splits/defects → chair (INV-G10).
NB: per-precedent halacha extraction for newly-cited precedents is NOT automated here
(it needs each cited precedent to be in the library with a known case_law_id) — the
chair drives that from /precedents when a missing precedent is added.
Local-only. Idempotent. The panel pass over the full pending queue can take minutes.
Durable (X16 / INV-DUR1): the 4 steps run through scripts/_pipeline_runtime.py
with a SQLite checkpoint per case (data/checkpoints/halacha.sqlite). A crash/OOM
in the long panel [3] RESUMES from [3] on the next run instead of re-paying
[0][2]. Default = auto-resume an interrupted run; ``--fresh`` forces a clean run
from [0]. Requires the host extra ``pip install -e ".[durable]"`` (mcp-server);
without it the steps run linearly (same as before) — the button never breaks.
cd ~/legal-ai/mcp-server
.venv/bin/python ../scripts/final_halacha_pipeline.py --case 8126-03-25
.venv/bin/python ../scripts/final_halacha_pipeline.py --case 8126-03-25 --fresh
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from argparse import Namespace
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _pipeline_runtime # noqa: E402 — durable runtime (X16); scripts/ on sys.path
from legal_mcp import config # noqa: E402
from legal_mcp.services import corroboration, db # noqa: E402
from legal_mcp.tools.citations import extract_internal_citations # noqa: E402
from legal_mcp.tools.precedent_library import precedent_extract_halachot # noqa: E402
async def _decision_law_row(case_number: str) -> dict | None:
"""The case's own decision row in case_law (internal_committee), if enrolled."""
pool = await db.get_pool()
async with pool.acquire() as conn:
r = await conn.fetchrow(
"SELECT id, halacha_extraction_status FROM case_law WHERE case_number = $1 "
"AND source_kind = 'internal_committee' ORDER BY created_at DESC LIMIT 1",
case_number,
)
return dict(r) if r else None
async def main(args: argparse.Namespace) -> int:
case_number = args.case
case = await db.get_case_by_number(case_number)
if not case:
print(f"✗ תיק {case_number} לא נמצא")
return 1
chair = case.get("chair_name") or "דפנה תמיר"
row = await _decision_law_row(case_number)
# The 4 steps as durable nodes (X16 / INV-DUR1): each is checkpointed the
# moment it finishes, so a crash/OOM in the long panel [3] resumes from [3]
# instead of re-paying [0][2]. Steps [0] and [2] stay non-fatal (record the
# error and continue); [1]/[3] may raise → the graph halts and the next run
# resumes there. All steps are idempotent, so a fresh re-run is also safe.
async def step_extract(results: dict) -> dict:
# [0] extract the halachot the decision ITSELF states (its own case_law row).
if not row:
print(f"[0/4] ההחלטה {case_number} אינה ב-case_law עדיין — דילוג על חילוץ-הלכות")
return {"extract": "skipped:not-enrolled"}
if row.get("halacha_extraction_status") == "completed":
print("[0/4] חילוץ-הלכות מההחלטה — דולג (כבר completed)")
return {"extract": "skipped:completed"}
if args.dry_run:
print("[0/4] חילוץ-הלכות מההחלטה — מדולג (dry-run)")
return {"extract": "skipped:dry-run"}
print(f"[0/4] precedent_extract_halachot (החלטה {case_number})…", flush=True)
try:
raw0 = await precedent_extract_halachot(str(row["id"]))
d0 = json.loads(raw0).get("data", {})
print(f" ✓ status={d0.get('status')} stored={d0.get('stored', d0.get('extracted'))}")
return {"extract": d0.get("status", "done")}
except Exception as e: # non-fatal — record and continue
print(f" ⚠ halacha extraction failed (non-fatal): {e}")
return {"extract": f"error:{e}"}
async def step_citations(results: dict) -> dict:
# [1] citation graph
print(f"[1/4] extract_internal_citations (chair={chair})…", flush=True)
raw = await extract_internal_citations(chair_name=chair, limit=0)
try:
d = json.loads(raw).get("data", {})
print(f" ✓ extracted {d.get('extracted')} · linked {d.get('linked')} "
f"· new {d.get('new')}")
return {"citations": "done"}
except Exception:
print(f" (citations returned: {str(raw)[:160]})")
return {"citations": "unparsed"}
async def step_corroboration(results: dict) -> dict:
# [2] corroboration signal + policy (whole corpus backfill) — skip on dry-run.
if args.dry_run:
print("[2/4] corroboration_rebuild — מדולג (dry-run)")
return {"corroboration": "skipped:dry-run"}
print("[2/4] corroboration_rebuild (backfill)…", flush=True)
try:
cr = await corroboration.build_all()
print(f"{cr}")
return {"corroboration": "done"}
except Exception as e: # non-fatal
print(f" ⚠ corroboration failed (non-fatal): {e}")
return {"corroboration": f"error:{e}"}
async def step_panel(results: dict) -> dict:
# [3] three-judge halacha panel (the long step durability protects).
apply = not args.dry_run
print(f"[3/4] halacha_panel_approve {'--apply' if apply else '(dry-run)'} "
f"(Opus+DeepSeek+Gemini)…", flush=True)
import halacha_panel_approve as hpa
rc = await hpa.main(Namespace(limit=args.limit, concurrency=6, apply=apply))
return {"panel_rc": rc or 0}
steps = [
_pipeline_runtime.Step("extract_decision_halachot", step_extract),
_pipeline_runtime.Step("citations", step_citations),
_pipeline_runtime.Step("corroboration", step_corroboration),
_pipeline_runtime.Step("panel", step_panel),
]
checkpoint_db = config.DATA_DIR / "checkpoints" / "halacha.sqlite"
# Stable thread per case → an interrupted real run resumes; dry-runs are
# previews (own thread, always fresh — never resume a stale preview).
thread_id = f"halacha:{case_number}" + (":dryrun" if args.dry_run else "")
results = await _pipeline_runtime.run_pipeline(
steps,
thread_id=thread_id,
checkpoint_db=checkpoint_db,
fresh=bool(args.fresh) or args.dry_run,
)
print("\n✓ pipeline-אימות-הלכות הושלם" + (" (dry-run)" if args.dry_run else ""))
return int(results.get("panel_rc", 0) or 0)
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--case", required=True, help="case_number, e.g. 8126-03-25")
ap.add_argument("--limit", type=int, default=0,
help="cap pending halachot judged (0 = full queue)")
ap.add_argument("--dry-run", dest="dry_run", action="store_true",
help="citations only; skip corroboration writes; panel in dry-run")
ap.add_argument("--fresh", action="store_true",
help="ignore any incomplete checkpoint and run from step [0] "
"(default: auto-resume an interrupted run; X16/INV-DUR1)")
raise SystemExit(asyncio.run(main(ap.parse_args())))

View File

@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""One-shot LOCAL pipeline for the 'run-learning' button (voice learning).
The container can't run the LLM steps (claude/DeepSeek/Gemini keys are local), so
the /api/cases/{case}/final/run-learning endpoint wakes the Hermes curator, which
runs THIS single deterministic command. Collapsing the flow into one script (rather
than asking the agent to assemble several tool calls) makes the autonomous path
reliable.
Steps:
[1] ingest_final_version(case, file_path) → Opus distils draft↔final into
draft_final_pairs.analysis (status→analyzed). INV-LRN5 separates style↔substance.
[2] enroll the final into style_corpus (idempotent) so lessons have a corpus_id.
[3] style_lesson_panel --apply → DeepSeek+Gemini vote per style lesson; 2/2-keep →
decision_lesson (source=panel:deepseek+gemini); split → chair (INV-G10).
The fold into SKILL.md / legal-decision-lessons.md stays a manual chair gate.
Local-only. Idempotent — safe to re-run.
Durable (X16 / INV-DUR1): the 3 steps run through scripts/_pipeline_runtime.py
(shared with final_halacha) with a SQLite checkpoint per case
(data/checkpoints/learning.sqlite). A crash/OOM in the long style panel [3]
RESUMES from [3] instead of re-paying the Opus distillation [1]. Default =
auto-resume; ``--fresh`` forces a clean run. Needs the host extra
``pip install -e ".[durable]"``; without it the steps run linearly (as before).
cd ~/legal-ai/mcp-server
.venv/bin/python ../scripts/final_learning_pipeline.py --case 8126-03-25
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from argparse import Namespace
from pathlib import Path
# scripts/ is not a package — make style_lesson_panel + the runtime importable.
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _pipeline_runtime # noqa: E402 — durable runtime (X16); scripts/ on sys.path
from legal_mcp import config # noqa: E402
from legal_mcp.services import db # noqa: E402
from legal_mcp.tools.documents import document_upload_training # noqa: E402
from legal_mcp.tools.workflow import ingest_final_version # noqa: E402
def _resolve_final_path(case_number: str) -> str | None:
"""The canonical final saved by /final/upload, with a graceful fallback."""
export_dir = config.find_case_dir(case_number) / "exports"
canonical = export_dir / f"סופי-{case_number}.docx"
if canonical.exists():
return str(canonical)
cands = sorted(export_dir.glob("סופי-*.docx"))
return str(cands[0]) if cands else None
async def _has_style_corpus(decision_number: str) -> bool:
pool = await db.get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT 1 FROM style_corpus WHERE decision_number = $1 LIMIT 1",
decision_number,
)
return bool(row)
async def _latest_pair_status(case_id) -> str | None:
pool = await db.get_pool()
async with pool.acquire() as conn:
return await conn.fetchval(
"SELECT status FROM draft_final_pairs WHERE case_id = $1 "
"ORDER BY created_at DESC LIMIT 1",
case_id,
)
async def main(args: argparse.Namespace) -> int:
case_number = args.case
case = await db.get_case_by_number(case_number)
if not case:
print(f"✗ תיק {case_number} לא נמצא")
return 1
final_path = _resolve_final_path(case_number)
if not final_path:
print(f"✗ לא נמצא קובץ סופי ל-{case_number} (העלה דרך 'העלאת החלטה סופית של היו\"ר')")
return 1
print(f"final: {final_path}\n")
# The 3 steps as durable nodes (X16 / INV-DUR1) — shared runtime with
# final_halacha (scripts/_pipeline_runtime.py). A crash/OOM in the long style
# panel [3] resumes from [3] instead of re-paying the Opus distillation [1].
async def step_ingest(results: dict) -> dict:
# [1] distillation (Opus) — skip if already analyzed (idempotent; --force to redo).
status = await _latest_pair_status(case["id"])
if status == "analyzed" and not args.force:
print("[1/3] ingest_final_version — דולג (הזוג כבר analyzed; --force לחידוש)")
return {"ingest": "skipped:analyzed"}
print("[1/3] ingest_final_version — דיסטילציית טיוטה↔סופי…", flush=True)
raw = await ingest_final_version(case_number, file_path=final_path)
try:
env = json.loads(raw)
except Exception:
print(f" (ingest returned: {raw[:200]})")
return {"ingest": "unparsed"}
if env.get("status") == "error": # fatal — halt (resume retries)
raise RuntimeError(f"ingest_final_version failed: {env.get('message')}")
d = env.get("data", {})
ds = d.get("diff_stats", {})
print(f" ✓ change {ds.get('change_percent')}% · lessons {d.get('lessons_count')} "
f"· new_expr {d.get('new_expressions')}")
return {"ingest": "done"}
async def step_enroll(results: dict) -> dict:
# [2] enroll into style_corpus (idempotent) — lessons need a corpus_id.
print("[2/3] רישום לקורפוס-הסגנון (idempotent)…", flush=True)
if await _has_style_corpus(case_number):
print(" ✓ כבר רשום בקורפוס-הסגנון")
return {"enroll": "exists"}
r = await document_upload_training(
final_path,
decision_number=case_number,
title=f"החלטה סופית — {case.get('proceeding_type', '')} {case_number}".strip(),
practice_area=case.get("practice_area") or "appeals_committee",
appeal_subtype=case.get("appeal_subtype") or "",
)
try:
print(f" ✓ corpus_id {json.loads(r).get('data', {}).get('corpus_id')}")
except Exception:
print(f" (training upload returned: {r[:160]})")
return {"enroll": "done"}
async def step_panel(results: dict) -> dict:
# [3] two-judge style panel (DeepSeek + Gemini) — the long step durability protects.
apply = not args.dry_run
print(f"[3/3] פאנל-סגנון דו-סוכני (DeepSeek+Gemini) {'--apply' if apply else '(dry-run)'}",
flush=True)
import style_lesson_panel as slp
rc = await slp.main(Namespace(
case=case_number, pair_id=None, apply=apply, limit=0, concurrency=4,
))
return {"panel_rc": rc or 0}
steps = [
_pipeline_runtime.Step("ingest_final_version", step_ingest),
_pipeline_runtime.Step("enroll_style_corpus", step_enroll),
_pipeline_runtime.Step("style_panel", step_panel),
]
checkpoint_db = config.DATA_DIR / "checkpoints" / "learning.sqlite"
thread_id = f"learning:{case_number}" + (":dryrun" if args.dry_run else "")
try:
results = await _pipeline_runtime.run_pipeline(
steps,
thread_id=thread_id,
checkpoint_db=checkpoint_db,
fresh=bool(args.fresh) or args.dry_run,
)
except Exception as e: # fatal step (e.g. ingest error) — clean non-zero exit
print(f"\n✗ pipeline-למידה נכשל: {e}")
return 1
print("\n✓ pipeline-למידה הושלם" + (" (dry-run)" if args.dry_run else ""))
return int(results.get("panel_rc", 0) or 0)
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--case", required=True, help="case_number, e.g. 8126-03-25")
ap.add_argument("--dry-run", dest="dry_run", action="store_true",
help="run the chain but the style panel in dry-run (no decision_lesson writes)")
ap.add_argument("--force", action="store_true",
help="re-run ingest_final_version even if the pair is already analyzed")
ap.add_argument("--fresh", action="store_true",
help="ignore any incomplete checkpoint and run from step [1] "
"(default: auto-resume an interrupted run; X16/INV-DUR1)")
raise SystemExit(asyncio.run(main(ap.parse_args())))

View File

@@ -0,0 +1,101 @@
#!/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 = {"holding", "interpretive", "procedural", "application", "obiter"}
SYSTEM = (
"אתה בוחן-איכות משפטי המסווג 'הלכות' שחולצו מהחלטות ועדת-ערר ומפסקי-דין. "
"לכל פריט הכרע שתי שאלות, באופן עצמאי ולפי המהות:\n"
"1) is_holding — האם זו הלכה אמיתית בת-הכללה ובת-הסתמכות (true), או שזו יישום "
"תלוי-עובדות / אמרת-אגב / ציטוט-עובדה ולא כלל בר-הכללה (false).\n"
"2) type — **סוג הכלל בלבד** (אל תסווג מחייב/משכנע — דרגת-המחייבות נגזרת אוטומטית "
"מזהות הערכאה): 'holding' (עיקרון מהותי שהיה הכרחי להכרעה — ratio), 'interpretive' "
"(פרשנות חוק/מונח/תכנית), 'procedural' (סדר-דין: מועדים/סמכות/מיצוי/נטל), "
"'application' (החלה על עובדות התיק — לרוב לא-הלכה), 'obiter' (אמרת-אגב שלא "
"הוכרעה — לא-הלכה).\n"
"עקביות: is_holding=true → holding/interpretive/procedural; "
"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

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Independent second-judge for gold-set rule_ROLE — breaks the AI-anchoring loop.
The gold-set human role tags were made WHILE seeing a claude AI recommendation,
so human↔AI agreement (~100%) is contaminated by anchoring — it is not an
independent measure of role-classification accuracy. This script adds a THIRD,
genuinely independent judge: a DIFFERENT model (DeepSeek, OpenAI-compatible API)
classifies the rule ROLE blind — it never sees the human tag NOR the first AI's
answer. Comparing deepseek↔human against ai↔human tells us whether the labels
are trustworthy or just anchored.
Zero tagging from the chair. Read-only on the gold-set.
cd ~/legal-ai/mcp-server
.venv/bin/python ../scripts/goldset_independent_judge.py # all tagged
.venv/bin/python ../scripts/goldset_independent_judge.py --limit 10 # smoke
.venv/bin/python ../scripts/goldset_independent_judge.py --model deepseek-reasoner
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from collections import Counter
from pathlib import Path
import httpx
from legal_mcp.services import db
ROLES = {"holding", "interpretive", "procedural", "application", "obiter"}
SYSTEM = (
"אתה משפטן בכיר המסווג 'הלכות' שחולצו מפסיקה ישראלית לפי **סוג הכלל** בלבד. "
"אל תסווג מחייב/משכנע (דרגת-המחייבות אינה רלוונטית). בחר ערך אחד:\n"
"- holding — עיקרון מהותי שהיה הכרחי להכרעה (ratio; מבחן Wambaugh).\n"
"- interpretive — פרשנות הוראת-חוק/מונח/תכנית.\n"
"- procedural — סדר-דין: סמכות/מועדים/זכות-עמידה/מיצוי/נטל.\n"
"- application — החלה תלוית-עובדות על נסיבות התיק (לרוב לא-הלכה בת-הכללה).\n"
"- obiter — אמרת-אגב שלא הוכרעה.\n"
'החזר JSON בלבד: {"role":"<אחד מהחמישה>"}. ללא markdown, ללא הסבר.'
)
def _deepseek_key() -> str:
for p in (Path.home() / ".hermes/profiles/deepseek/.env", Path.home() / ".env"):
if p.exists():
for line in p.read_text().splitlines():
if line.startswith("DEEPSEEK_API_KEY="):
return line.split("=", 1)[1].strip()
return os.environ.get("DEEPSEEK_API_KEY", "")
def _user_prompt(it: dict) -> str:
src = "פסק-דין" if it.get("source_type") == "court_ruling" else "החלטת ועדת-ערר"
return (
f"מקור: {src}.\n\n"
f"ניסוח הכלל:\n{it.get('rule_statement') or ''}\n\n"
f"היגיון:\n{it.get('reasoning_summary') or ''}\n\n"
f"ציטוט תומך:\n{it.get('supporting_quote') or ''}"
)
async def _judge(client: httpx.AsyncClient, key: str, model: str, it: dict) -> str | None:
try:
r = await client.post(
"https://api.deepseek.com/v1/chat/completions",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
json={
"model": model,
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": _user_prompt(it)},
],
"temperature": 0,
"max_tokens": 60,
"response_format": {"type": "json_object"},
},
timeout=90,
)
r.raise_for_status()
content = r.json()["choices"][0]["message"]["content"]
role = str(json.loads(content).get("role", "")).strip().lower()
return role if role in ROLES else None
except Exception as e: # noqa: BLE001
print(f" ! judge error: {e}", flush=True)
return None
def _agree(rows: list[dict], a: str, b: str) -> tuple[int, int, float]:
"""Return (matches, comparable, percent) — percent is 0..100."""
valid = [r for r in rows if r.get(a) and r.get(b)]
ok = sum(1 for r in valid if r[a] == r[b])
return ok, len(valid), (100.0 * ok / len(valid) if valid else 0.0)
async def main(args: argparse.Namespace) -> int:
key = _deepseek_key()
if not key:
print("no DEEPSEEK_API_KEY found", flush=True)
return 1
items = await db.goldset_list(args.batch)
# only items with a HUMAN role tag (the ground truth we are testing)
tagged = [it for it in items if (it.get("correct_type") or "").strip() in ROLES]
if args.limit:
tagged = tagged[: args.limit]
print(f"independent judge ({args.model}) on {len(tagged)} human-tagged items\n", flush=True)
sem = asyncio.Semaphore(args.concurrency)
rows: list[dict] = []
async with httpx.AsyncClient() as client:
async def one(it: dict):
async with sem:
ds = await _judge(client, key, args.model, it)
rows.append({
"human": (it.get("correct_type") or "").strip().lower(),
"ai": (it.get("ai_correct_type") or "").strip().lower(),
"deepseek": ds,
"machine": (it.get("rule_type") or "").strip().lower(),
"source": it.get("source_type"),
})
for i in range(0, len(tagged), args.concurrency):
await asyncio.gather(*(one(it) for it in tagged[i : i + args.concurrency]))
print(f"{len(rows)}/{len(tagged)}", flush=True)
judged = [r for r in rows if r["deepseek"]]
print(f"\n=== INTER-RATER AGREEMENT on rule_role ({len(judged)} judged) ===")
print(" ai↔human (anchored baseline): %d/%d = %.0f%%" % _agree(rows, "ai", "human"))
print(" deepseek↔human (INDEPENDENT — key): %d/%d = %.0f%%" % _agree(judged, "deepseek", "human"))
print(" deepseek↔ai (cross-model): %d/%d = %.0f%%" % _agree(judged, "deepseek", "ai"))
una = [r for r in judged if r["human"] == r["ai"] == r["deepseek"]]
print(f" 3-way unanimous (human=ai=deepseek): {len(una)}/{len(judged)} = {len(una)/max(1,len(judged)):.0%}")
print("\n=== where the INDEPENDENT judge disagrees with the human (the real signal) ===")
mm = Counter((r["human"], r["deepseek"]) for r in judged if r["human"] != r["deepseek"])
for (h, d), n in mm.most_common():
print(f" human={h} → deepseek={d}: {n}")
# COARSE axis: is this a generalizable rule at all? (holding/interpretive/
# procedural collapse to one class) vs the non-generalizable markers
# (application/obiter). If fine-grained agreement is low but coarse is high,
# the disagreement is a cosmetic sub-distinction, not a meaningful one.
GEN = {"holding", "interpretive", "procedural"}
def coarse(v): return "rule" if v in GEN else ("nonrule" if v in {"application", "obiter"} else None)
for r in judged:
r["human_c"], r["deepseek_c"], r["ai_c"] = coarse(r["human"]), coarse(r["deepseek"]), coarse(r["ai"])
print("\n=== COARSE agreement (generalizable-rule vs application/obiter) ===")
print(" deepseek↔human (coarse): %d/%d = %.0f%%" % _agree(judged, "deepseek_c", "human_c"))
print(" ai↔human (coarse): %d/%d = %.0f%%" % _agree(judged, "ai_c", "human_c"))
Path("/tmp/goldset_judge_raw.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1))
print("\nraw judgments → /tmp/goldset_judge_raw.json")
return 0
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--batch", default="default")
ap.add_argument("--model", default="deepseek-chat", help="deepseek-chat | deepseek-reasoner")
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--concurrency", type=int, default=6)
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,340 @@
#!/usr/bin/env python3
"""Multi-judge panel to triage the halacha approval queue — DRY-RUN by default.
The chair cannot review every pending halacha. We proved (goldset_independent_
judge.py) that the COARSE axis — "is this a genuine, generalizable rule worth
keeping as a citable precedent?" — is reliable ACROSS independent models (92%
cross-model agreement), while the fine sub-type is not. This script turns that
into a triage: THREE independent-lineage judges vote on the coarse question, and
only a UNANIMOUS verdict acts automatically — every split escalates to the chair.
That collapses the queue without removing the human gate (INV-G10).
Three judges, three lineages (diversity is the point):
- claude (Opus via claude_session — local CLI, zero marginal cost) [Anthropic]
- deepseek (api.deepseek.com) [DeepSeek]
- gemini (generativelanguage — gemini-2.5-flash, #1 on LegalBench) [Google]
Three buckets of pending_review:
1. clean, below confidence threshold → panel votes KEEP? unanimous-keep would
auto-approve; split → chair.
2. nli_unsupported (rule maybe over-reaches its quote) → panel RE-ADJUDICATES
entailment; unanimous-entailed would clear the flag + approve; split → chair.
3. other quality flags (quote_unverified/truncated/thin) → genuine extraction
defects → flagged for re-extraction, never auto-approved.
DRY-RUN writes NOTHING. --apply acts on the agreed verdicts (clean: 2/3 majority;
nli: unanimous-entailed clears the flag) — reversible, backed up to data/audit/ first.
Splits/defects stay pending_review for the chair. Local-only (claude_session needs CLI).
cd ~/legal-ai/mcp-server
.venv/bin/python ../scripts/halacha_panel_approve.py --limit 12 # smoke
.venv/bin/python ../scripts/halacha_panel_approve.py # full dry-run
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import json
import os
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
import httpx
from legal_mcp.services import claude_session, db
# ── keys (local files, same pattern as the other local judges) ──
def _env_key(name: str, *files: str) -> str:
for f in files:
p = Path(f).expanduser()
if p.exists():
for line in p.read_text().splitlines():
if line.startswith(name + "="):
return line.split("=", 1)[1].strip()
return os.environ.get(name, "")
DEEPSEEK_KEY = _env_key("DEEPSEEK_API_KEY", "~/.hermes/profiles/deepseek/.env", "~/.env")
# canonical Infisical name is GOOGLE_GEMINI_API_KEY (/external-apis/gemini); accept
# the bare GEMINI_API_KEY too for back-compat.
GEMINI_KEY = _env_key("GOOGLE_GEMINI_API_KEY", "~/.env") or _env_key("GEMINI_API_KEY", "~/.env")
# ── the two coarse questions (the reliable axis — NOT the fuzzy sub-type) ──
KEEP_SYSTEM = (
"אתה משפטן בכיר בוועדת ערר לתכנון ובנייה. הוכרע אם 'הלכה' שחולצה מפסיקה ראויה "
"להישמר כתקדים בר-ציטוט. ראויה (keep=true) = עיקרון משפטי בר-הכללה והסתמכות "
"(holding/פרשנות/כלל-פרוצדורלי). לא-ראויה (keep=false) = החלה תלוית-עובדות על "
"התיק הספציפי, סוגיה שלא הוכרעה (אמרת-אגב), או חזרה מילולית על הציטוט ללא הפשטה. "
'החזר JSON בלבד: {"keep": true/false, "reason": "<משפט קצר>"}. ללא markdown.'
)
NLI_SYSTEM = (
"אתה בודק היסק משפטי. בהינתן כלל וציטוט-תומך, הכרע האם הציטוט באמת תומך בכלל "
"ואינו מרחיב מעבר למה שכתוב בו (entailed=true), או שהכלל מרחיב/חורג מהציטוט "
'(entailed=false). החזר JSON בלבד: {"entailed": true/false}. ללא markdown, ללא הסבר.'
)
def _keep_user(h: dict) -> str:
return (
f"ניסוח הכלל:\n{h.get('rule_statement') or ''}\n\n"
f"היגיון:\n{h.get('reasoning_summary') or ''}\n\n"
f"ציטוט תומך:\n{h.get('supporting_quote') or ''}"
)
def _nli_user(h: dict) -> str:
return f"כלל:\n{h.get('rule_statement') or ''}\n\nציטוט:\n{h.get('supporting_quote') or ''}"
# ── three judges, one signature: (system, user) -> dict|None ──
async def judge_claude(system: str, user: str) -> dict | None:
try:
return await claude_session.query_json(user, system=system)
except Exception:
return None
async def judge_deepseek(client: httpx.AsyncClient, system: str, user: str) -> dict | None:
if not DEEPSEEK_KEY:
return None
try:
r = await client.post(
"https://api.deepseek.com/v1/chat/completions",
headers={"Authorization": f"Bearer {DEEPSEEK_KEY}", "Content-Type": "application/json"},
json={"model": "deepseek-chat", "temperature": 0, "max_tokens": 120,
"response_format": {"type": "json_object"},
"messages": [{"role": "system", "content": system},
{"role": "user", "content": user}]},
timeout=90,
)
r.raise_for_status()
return json.loads(r.json()["choices"][0]["message"]["content"])
except Exception:
return None
async def judge_gemini(client: httpx.AsyncClient, system: str, user: str) -> dict | None:
if not GEMINI_KEY:
return None
try:
r = await client.post(
f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={GEMINI_KEY}",
headers={"Content-Type": "application/json"},
json={"system_instruction": {"parts": [{"text": system}]},
"contents": [{"parts": [{"text": user}]}],
"generationConfig": {"temperature": 0, "maxOutputTokens": 4000,
"responseMimeType": "application/json"}},
timeout=90,
)
r.raise_for_status()
return json.loads(r.json()["candidates"][0]["content"]["parts"][0]["text"])
except Exception:
return None
def _bool(d: dict | None, key: str) -> bool | None:
if not isinstance(d, dict) or key not in d:
return None
v = d[key]
if isinstance(v, bool):
return v
return str(v).strip().lower() in ("true", "1", "yes", "כן")
async def panel_vote(client, system, user, key) -> dict:
"""Run all three judges; return per-judge bools + the verdict."""
c, ds, gm = await asyncio.gather(
judge_claude(system, user),
judge_deepseek(client, system, user),
judge_gemini(client, system, user),
)
votes = {"claude": _bool(c, key), "deepseek": _bool(ds, key), "gemini": _bool(gm, key)}
valid = [v for v in votes.values() if v is not None]
unanimous_yes = len(valid) == 3 and all(valid)
unanimous_no = len(valid) == 3 and not any(valid)
votes["_verdict"] = ("unanimous_yes" if unanimous_yes else
"unanimous_no" if unanimous_no else
"split" if len(valid) >= 2 else "incomplete")
return votes
async def main(args: argparse.Namespace) -> int:
print(f"judges available — deepseek:{bool(DEEPSEEK_KEY)} gemini:{bool(GEMINI_KEY)} "
f"claude:local\n", flush=True)
pending = await db.list_halachot(review_status="pending_review", limit=5000)
if args.limit:
pending = pending[: args.limit]
NLI = "nli_unsupported"
DEFECT = {"quote_unverified", "truncated_quote", "thin_restatement", "near_duplicate"}
def bucket(h):
flags = set(h.get("quality_flags") or [])
if not flags:
return "clean"
if flags & DEFECT:
return "defect" # genuine extraction problem → re-extraction
if NLI in flags:
return "nli" # re-adjudicate entailment
return "other"
buckets = defaultdict(list)
for h in pending:
buckets[bucket(h)].append(h)
print("queue:", {k: len(v) for k, v in buckets.items()}, "\n", flush=True)
sem = asyncio.Semaphore(args.concurrency)
results = {"clean": [], "nli": []}
async with httpx.AsyncClient() as client:
async def run(h, system_fn, user_fn, key, tag):
async with sem:
v = await panel_vote(client, system_fn, user_fn(h), key)
v["_h"] = h
results[tag].append(v)
tasks = []
for h in buckets["clean"]:
tasks.append(run(h, KEEP_SYSTEM, _keep_user, "keep", "clean"))
for h in buckets["nli"]:
tasks.append(run(h, NLI_SYSTEM, _nli_user, "entailed", "nli"))
# bounded fan-out
for i in range(0, len(tasks), args.concurrency):
await asyncio.gather(*tasks[i : i + args.concurrency])
done = len(results["clean"]) + len(results["nli"])
print(f"{done}/{len(tasks)} judged", flush=True)
# ── report ──
def summarize(rows, yes_label, no_label):
c = Counter(r["_verdict"] for r in rows)
return c
print("\n" + "=" * 60)
print("PANEL DRY-RUN (no DB writes)")
print("=" * 60)
clean = results["clean"]
cc = summarize(clean, "keep", "drop")
print(f"\nBUCKET 1 — clean, below threshold ({len(clean)}):")
print(f" ✓ auto-APPROVE (3/3 keep): {cc['unanimous_yes']}")
print(f" ✗ auto-REJECT (3/3 drop): {cc['unanimous_no']}")
print(f" → CHAIR (split): {cc['split']}")
print(f" ? incomplete (judge errors): {cc['incomplete']}")
nli = results["nli"]
nc = summarize(nli, "entailed", "not")
print(f"\nBUCKET 2 — nli_unsupported ({len(nli)}):")
print(f" ✓ clear-flag + APPROVE (3/3 entailed): {nc['unanimous_yes']}")
print(f" ✗ confirm-flag (3/3 not-entailed): {nc['unanimous_no']}")
print(f" → CHAIR (split): {nc['split']}")
print(f" ? incomplete: {nc['incomplete']}")
print(f"\nBUCKET 3 — extraction defects ({len(buckets['defect'])}): → re-extraction")
if buckets["other"]:
print(f"BUCKET 4 — other flags ({len(buckets['other'])}): → chair")
auto = cc["unanimous_yes"] + cc["unanimous_no"] + nc["unanimous_yes"] + nc["unanimous_no"]
chair = cc["split"] + nc["split"] + cc["incomplete"] + nc["incomplete"] + len(buckets["other"])
reext = len(buckets["defect"])
print("\n" + "-" * 60)
print(f"NET: {len(pending)} pending → panel resolves {auto} automatically, "
f"{chair} to chair, {reext} to re-extraction")
print(f" chair queue collapses {len(pending)}{chair}")
Path("/tmp/halacha_panel_dryrun.json").write_text(json.dumps(
[{**{k: v for k, v in r.items() if not k.startswith("_h")},
"id": str(r["_h"]["id"]), "case": r["_h"].get("case_number"),
"rule": (r["_h"].get("rule_statement") or "")[:120]}
for r in clean + nli], ensure_ascii=False, indent=1))
print("\nper-item verdicts → /tmp/halacha_panel_dryrun.json")
# ── apply the chair-approved policy (reversible; backup first) ──────────
# CLEAN → majority 2/3 (keep→approved, drop→rejected, tie→chair)
# NLI → asymmetric: unanimous-entailed → clear nli flag (+approve if clean),
# majority not-entailed → rejected, else → chair
# DEFECT → untouched (needs re-extraction)
if not args.apply:
print("\n(dry-run — pass --apply to write the approved policy)")
return 0
def majority(v: dict) -> bool | None:
vs = [v[k] for k in ("claude", "deepseek", "gemini") if v[k] is not None]
if len(vs) < 2:
return None
y, n = sum(vs), len(vs) - sum(vs)
return True if y > n else (False if n > y else None)
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
audit = Path(__file__).resolve().parent.parent / "data" / "audit"
audit.mkdir(parents=True, exist_ok=True)
backup = audit / f"halacha-panel-apply-backup-{ts}.csv"
with backup.open("w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "review_status", "quality_flags"])
for r in clean + nli:
h = r["_h"]
w.writerow([h["id"], h["review_status"], "|".join(h.get("quality_flags") or [])])
pool = await db.get_pool()
REV = "panel:opus+deepseek+gemini"
approved = rejected = cleared = chair = 0
for r in clean:
d = majority(r)
if d is True:
await pool.execute("UPDATE halachot SET review_status='approved', "
"reviewed_at=now(), reviewer=$2, updated_at=now() WHERE id=$1",
r["_h"]["id"], REV + " 2/3-keep")
approved += 1
elif d is False:
await pool.execute("UPDATE halachot SET review_status='rejected', "
"reviewed_at=now(), reviewer=$2, updated_at=now() WHERE id=$1",
r["_h"]["id"], REV + " 2/3-drop")
rejected += 1
else:
chair += 1
for r in nli:
vs = [r[k] for k in ("claude", "deepseek", "gemini") if r[k] is not None]
unanimous_yes = len(vs) == 3 and all(vs)
maj_no = len(vs) >= 2 and sum(vs) < len(vs) - sum(vs)
if unanimous_yes:
rest = [x for x in (r["_h"].get("quality_flags") or []) if x != "nli_unsupported"]
if rest: # other flags remain → clear nli but keep in queue
await pool.execute("UPDATE halachot SET quality_flags=$2, updated_at=now() "
"WHERE id=$1", r["_h"]["id"], rest)
cleared += 1; chair += 1
else: # nli was the only blocker → clear + approve
await pool.execute("UPDATE halachot SET quality_flags='{}', "
"review_status='approved', reviewed_at=now(), reviewer=$2, "
"updated_at=now() WHERE id=$1", r["_h"]["id"], REV + " 3/3-entailed")
approved += 1; cleared += 1
elif maj_no:
await pool.execute("UPDATE halachot SET review_status='rejected', "
"reviewed_at=now(), reviewer=$2, updated_at=now() WHERE id=$1",
r["_h"]["id"], REV + " maj-not-entailed")
rejected += 1
else:
chair += 1
print(f"\nAPPLIED (reversible): approved {approved} · rejected {rejected} · "
f"nli-flag-cleared {cleared} · left to chair {chair + len(buckets['defect'])} "
f"(incl. {len(buckets['defect'])} defects for re-extraction)")
print(f"backup → {backup}")
return 0
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--concurrency", type=int, default=6)
ap.add_argument("--apply", action="store_true",
help="write the agreed verdicts (reversible, CSV-backed); default dry-run")
raise SystemExit(asyncio.run(main(ap.parse_args())))

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Safety-net audit for panel-approved halachot (selective-prediction monitoring).
A panel auto-approval is reversible and low-harm, but not infallible. The
literature (Trust-or-Escalate; selective prediction) prescribes MONITORING the
auto-decision error rate over time rather than trusting it blindly. This samples
panel-approved halachot, RE-RUNS the same 3-judge KEEP vote, and surfaces any
where the panel now leans DROP — the candidate false-keeps a human should glance
at. Zero standing load on the chair: it just produces a short weekly list.
Report-only by default. ``--flag`` sends the flips back to ``pending_review``
(with an audit reviewer note) so they re-enter the chair queue.
cd ~/legal-ai/mcp-server
.venv/bin/python ../scripts/halacha_panel_audit.py --sample 15
.venv/bin/python ../scripts/halacha_panel_audit.py --sample 15 --flag
"""
from __future__ import annotations
import argparse
import asyncio
import httpx
from legal_mcp.services import db
from halacha_panel_approve import ( # noqa: E402 — single source of truth for judges
KEEP_SYSTEM, _bool, _keep_user, judge_claude, judge_deepseek, judge_gemini,
)
def _majority(votes: list[bool]) -> bool | None:
vs = [v for v in votes if v is not None]
if len(vs) < 2:
return None
y, n = sum(vs), len(vs) - sum(vs)
return True if y > n else (False if n > y else None)
async def main(args: argparse.Namespace) -> int:
pool = await db.get_pool()
# sample panel-approved halachot (ORDER BY random is fine for a small audit)
rows = await pool.fetch(
"SELECT h.id, h.rule_statement, h.reasoning_summary, h.supporting_quote, "
" cl.case_number "
"FROM halachot h LEFT JOIN case_law cl ON cl.id = h.case_law_id "
"WHERE h.review_status='approved' AND h.reviewer LIKE 'panel:%' "
"ORDER BY md5(h.id::text || $1) LIMIT $2",
args.seed, args.sample,
)
print(f"auditing {len(rows)} panel-approved halachot (re-running the KEEP vote)\n", flush=True)
flips = []
sem = asyncio.Semaphore(args.concurrency)
async with httpx.AsyncClient() as client:
async def one(r):
async with sem:
user = _keep_user(dict(r))
c, ds, gm = await asyncio.gather(
judge_claude(KEEP_SYSTEM, user),
judge_deepseek(client, KEEP_SYSTEM, user),
judge_gemini(client, KEEP_SYSTEM, user),
)
votes = [_bool(c, "keep"), _bool(ds, "keep"), _bool(gm, "keep")]
if _majority(votes) is False: # panel now leans DROP → candidate false-keep
flips.append((r, votes))
tasks = [one(r) for r in rows]
for i in range(0, len(tasks), args.concurrency):
await asyncio.gather(*tasks[i : i + args.concurrency])
rate = len(flips) / len(rows) if rows else 0.0
print(f"=== AUDIT: {len(flips)}/{len(rows)} now lean DROP ({rate:.0%} candidate false-keeps) ===")
for r, votes in flips:
print(f"\n {r['case_number']} votes(c/ds/gm)={votes}")
print(f" {r['rule_statement'][:140]}")
if flips and args.flag:
for r, _ in flips:
await pool.execute(
"UPDATE halachot SET review_status='pending_review', "
"reviewer='panel-audit:reopened', updated_at=now() WHERE id=$1", r["id"])
print(f"\n→ flagged {len(flips)} back to pending_review for chair review.")
elif flips:
print("\n(report-only — pass --flag to reopen these for the chair)")
return 0
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--sample", type=int, default=15)
ap.add_argument("--seed", default="audit", help="vary to draw a different sample")
ap.add_argument("--flag", action="store_true", help="reopen flips to pending_review")
ap.add_argument("--concurrency", type=int, default=6)
raise SystemExit(asyncio.run(main(ap.parse_args())))

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Calibrate the approval-panel voting policy on the gold-set (Trust-or-Escalate).
The literature (Trust or Escalate, ICLR 2025; PoLL; selective prediction) says:
don't guess the aggregation policy — calibrate it to a target risk α on a
calibration set, and ESCALATE disagreement to the human. We have a calibration
set: the gold-set's ``is_holding`` is the COARSE "is this a real, keepable rule?"
label — the axis we already proved is reliable across models (92%).
This runs the panel's KEEP question (3 independent judges) on every gold-set item
that has an is_holding label, then reports, FOR EACH POLICY, the auto-decision
precision (vs is_holding) and coverage (how many it decides vs escalates):
- unanimous : auto-decide only on 3/3 agreement, else escalate
- majority : auto-decide on 2/3, else escalate
Pick the policy whose auto-error stays under your tolerance while covering the
most items. Read-only. Local-only (claude_session needs the CLI).
cd ~/legal-ai/mcp-server
.venv/bin/python ../scripts/halacha_panel_calibrate.py
"""
from __future__ import annotations
import argparse
import asyncio
import httpx
from legal_mcp.services import db
# reuse the exact panel judges + KEEP question (single source of truth)
from halacha_panel_approve import ( # noqa: E402
KEEP_SYSTEM, _bool, _keep_user, judge_claude, judge_deepseek, judge_gemini,
)
async def _votes(client, h) -> list[bool]:
user = _keep_user(h)
c, ds, gm = await asyncio.gather(
judge_claude(KEEP_SYSTEM, user),
judge_deepseek(client, KEEP_SYSTEM, user),
judge_gemini(client, KEEP_SYSTEM, user),
)
return [v for v in (_bool(c, "keep"), _bool(ds, "keep"), _bool(gm, "keep")) if v is not None]
def _decide(votes: list[bool], policy: str) -> bool | None:
"""Auto-decision (True=keep / False=drop) or None=escalate."""
if len(votes) < 2:
return None
yes, no = sum(votes), len(votes) - sum(votes)
if policy == "unanimous":
if len(votes) == 3 and yes == 3:
return True
if len(votes) == 3 and no == 3:
return False
return None
# majority
if yes > no:
return True
if no > yes:
return False
return None # tie
async def main(args: argparse.Namespace) -> int:
items = [it for it in await db.goldset_list(args.batch) if it.get("is_holding") is not None]
if args.limit:
items = items[: args.limit]
print(f"calibrating panel KEEP vs is_holding on {len(items)} gold-set items\n", flush=True)
sem = asyncio.Semaphore(args.concurrency)
rows = []
async with httpx.AsyncClient() as client:
async def one(it):
async with sem:
v = await _votes(client, it)
rows.append({"truth": bool(it["is_holding"]), "votes": v})
tasks = [one(it) for it in items]
for i in range(0, len(tasks), args.concurrency):
await asyncio.gather(*tasks[i : i + args.concurrency])
print(f"{len(rows)}/{len(items)}", flush=True)
print("\n" + "=" * 64)
print(f"{'policy':<11}{'auto':>6}{'escalate':>10}{'correct':>9}{'wrong':>7}{'precision':>11}{'coverage':>10}")
print("-" * 64)
for policy in ("unanimous", "majority"):
auto = wrong = correct = 0
for r in rows:
d = _decide(r["votes"], policy)
if d is None:
continue
auto += 1
if d == r["truth"]:
correct += 1
else:
wrong += 1
esc = len(rows) - auto
prec = correct / auto if auto else 0.0
cov = auto / len(rows) if rows else 0.0
print(f"{policy:<11}{auto:>6}{esc:>10}{correct:>9}{wrong:>7}{prec:>10.1%}{cov:>10.1%}")
# where do the WRONG auto-decisions fall? (false-keep is the costly one)
print("\n=== costly errors: panel auto-KEEPS but human says NOT-holding (per policy) ===")
for policy in ("unanimous", "majority"):
fk = sum(1 for r in rows if _decide(r["votes"], policy) is True and not r["truth"])
fd = sum(1 for r in rows if _decide(r["votes"], policy) is False and r["truth"])
print(f" {policy:<11} false-KEEP (bad rule approved): {fk} false-DROP (good rule rejected): {fd}")
return 0
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--batch", default="default")
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--concurrency", type=int, default=6)
raise SystemExit(asyncio.run(main(ap.parse_args())))

View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""One-time backfill: recover the rule ROLE for pre-split halachot (INV-DM7).
Before the authority/role split, the extractor stored ``rule_type='binding'``
for higher-court sources and ``'persuasive'`` for committee sources — i.e. it
recorded the source's AUTHORITY in the role field. Those 276 rows therefore have
NO genuine role. This script re-classifies each into one of the five real roles
(holding/interpretive/procedural/application/obiter) using the same local
claude_session judge the gold-set trusts (zero API cost), and writes it back to
``halachot.rule_type``.
authority is NOT touched — it is derived from ``case_law.precedent_level`` at
read time and was never stored.
cd ~/legal-ai/mcp-server
.venv/bin/python ../scripts/halacha_rule_role_backfill.py --limit 5 # smoke (dry)
.venv/bin/python ../scripts/halacha_rule_role_backfill.py --apply # full backfill
Local-only (claude_session needs the local CLI, not the container).
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import sys
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID
from legal_mcp.services import claude_session, db
REPO_ROOT = Path(__file__).resolve().parent.parent
AUDIT_DIR = REPO_ROOT / "data" / "audit"
VALID_ROLES = {"holding", "interpretive", "procedural", "application", "obiter"}
SYSTEM = (
"אתה משפטן בכיר המסווג 'הלכות' שחולצו מפסיקה לפי **סוג הכלל** בלבד "
"(אל תסווג מחייב/משכנע — דרגת-המחייבות נגזרת אוטומטית מזהות הערכאה). "
"בחר ערך אחד מתוך:\n"
"- holding — עיקרון מהותי שהיה הכרחי להכרעה (ratio; מבחן Wambaugh).\n"
"- interpretive — פרשנות הוראת-חוק/מונח/תכנית.\n"
"- procedural — סדר-דין: סמכות/מועדים/זכות-עמידה/מיצוי/נטל.\n"
"- application — החלה תלוית-עובדות על נסיבות התיק (לרוב לא-הלכה בת-הכללה).\n"
"- obiter — אמרת-אגב שלא הוכרעה.\n"
'החזר JSON בלבד: {"role": "<אחד מהחמישה>"}. ללא markdown, ללא הסבר.'
)
def _prompt(row: dict) -> str:
return (
f"מקור: {row.get('case_number') or ''} "
f"(precedent_level={row.get('precedent_level') or ''}).\n"
f"סיווג ישן (סמכות, להתעלם): {row.get('rule_type')}.\n\n"
f"ניסוח הכלל:\n{row.get('rule_statement') or ''}\n\n"
f"היגיון:\n{row.get('reasoning_summary') or ''}\n\n"
f"ציטוט תומך:\n{row.get('supporting_quote') or ''}"
)
async def _classify(row: dict) -> str | None:
"""Return the role for one row, or None on failure (caller keeps old value)."""
try:
raw = await claude_session.query_json(_prompt(row), system=SYSTEM)
except Exception as e: # noqa: BLE001 — log and skip, never crash the batch
print(f" ! {row['id']}: judge error ({e}) — skipped", flush=True)
return None
role = ""
if isinstance(raw, dict):
role = str(raw.get("role") or "").strip().lower()
if role not in VALID_ROLES:
print(f" ? {row['id']}: invalid role {role!r} — skipped", flush=True)
return None
return role
async def _fetch_legacy_rows() -> list[dict]:
pool = await db.get_pool()
rows = await pool.fetch(
"SELECT h.id, h.rule_type, h.rule_statement, h.reasoning_summary, "
" h.supporting_quote, cl.case_number, cl.precedent_level "
"FROM halachot h LEFT JOIN case_law cl ON cl.id = h.case_law_id "
"WHERE h.rule_type IN ('binding','persuasive') "
"ORDER BY h.case_law_id, h.halacha_index"
)
return [dict(r) for r in rows]
def _backup(rows: list[dict]) -> Path:
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
out = AUDIT_DIR / f"halacha-rule-role-backfill-backup-{ts}.csv"
with out.open("w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "old_rule_type", "case_number", "precedent_level"])
for r in rows:
w.writerow([r["id"], r["rule_type"], r.get("case_number") or "",
r.get("precedent_level") or ""])
return out
async def main(args: argparse.Namespace) -> int:
rows = await _fetch_legacy_rows()
if args.limit:
rows = rows[: args.limit]
print(f"legacy binding/persuasive rows to reclassify: {len(rows)}", flush=True)
if not rows:
return 0
backup = _backup(rows)
print(f"backup written → {backup}", flush=True)
pool = await db.get_pool()
changed = skipped = 0
sem = asyncio.Semaphore(args.concurrency)
async def _one(row: dict):
nonlocal changed, skipped
async with sem:
role = await _classify(row)
if role is None:
skipped += 1
return
old = row["rule_type"]
print(f" {row.get('case_number') or '':<14} {old:>10}{role}", flush=True)
if args.apply and role != old:
await pool.execute(
"UPDATE halachot SET rule_type = $2, updated_at = now() WHERE id = $1",
row["id"], role,
)
changed += 1
# process in chunks to bound concurrent CLI subprocesses
for i in range(0, len(rows), args.concurrency):
await asyncio.gather(*(_one(r) for r in rows[i : i + args.concurrency]))
mode = "APPLIED" if args.apply else "DRY-RUN (no writes)"
print(f"\n{mode}: {changed} reclassified, {skipped} skipped (kept old).", flush=True)
if not args.apply:
print("re-run with --apply to write changes.", flush=True)
return 0
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--apply", action="store_true", help="write changes (default: dry-run)")
ap.add_argument("--limit", type=int, default=0, help="only first N rows (smoke test)")
ap.add_argument("--concurrency", type=int, default=4, help="parallel judge calls")
sys.exit(asyncio.run(main(ap.parse_args())))

View File

@@ -0,0 +1,56 @@
"""Ingest the monthly "עו"ד על נדל"ן" bulletin archive into the digests radar (X12).
Each staged bulletin PDF (data/bulletins/incoming) is split by LLM into case-law
pointers (digest_kind='decision') + articles (digest_kind='article'), all tagged
publication='עו"ד על נדל"ן'. Idempotent — per-item content_hash dedup, so re-runs
only add new items. Runs on the HOST (LLM is local-only), with the mcp-server venv:
mcp-server/.venv/bin/python scripts/ingest_bulletins.py [--dir PATH] [--limit N]
"""
import argparse
import asyncio
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "src"))
from legal_mcp.services import db, bulletin_library # noqa: E402
DEFAULT_DIR = Path(__file__).resolve().parent.parent / "data" / "bulletins" / "incoming"
async def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--dir", default=str(DEFAULT_DIR), help="folder of bulletin PDFs")
ap.add_argument("--limit", type=int, default=0, help="process at most N files (0=all)")
args = ap.parse_args()
folder = Path(args.dir)
files = sorted(p for p in folder.glob("*.pdf"))
if args.limit:
files = files[: args.limit]
total = len(files)
print(f"ingesting {total} bulletins from {folder}", flush=True)
await db.get_pool()
agg = {"cases": 0, "articles": 0, "created": 0, "skipped": 0, "linked": 0}
t0 = time.time()
for i, f in enumerate(files, 1):
try:
r = await bulletin_library.ingest_bulletin(str(f))
for k in agg:
agg[k] += r.get(k, 0)
print(f"[{i}/{total}] {r['file']}: cases={r['cases']} articles={r['articles']} "
f"created={r['created']} skipped={r['skipped']} linked={r['linked']}", flush=True)
except Exception as e: # one bad bulletin must not abort the batch
print(f"[{i}/{total}] FAIL {f.name}: {type(e).__name__}: {e}", flush=True)
print(f"\nDONE {total} bulletins in {(time.time()-t0)/60:.1f}min | "
f"cases={agg['cases']} articles={agg['articles']} created={agg['created']} "
f"skipped={agg['skipped']} linked={agg['linked']}", flush=True)
await db.close_pool()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,138 @@
"""Batch ingest of "כל יום" daily digests staged in data/digests/incoming/ (X12).
Sequential (NOT concurrent — same load-spike caution as ingest_incoming_batch.py)
ingest of each yomon PDF via the standalone digest pipeline
(``digest_library.ingest_digest``), which:
- extracts text, dedups on content_hash (idempotent),
- runs the local LLM metadata extractor (concept_tag, headline, underlying
citation, two dates, practice_area, subject_tags),
- stores a single embedding,
- auto-links to the underlying ruling if it is already in the precedent
library (INV-DIG3).
The digest is a SECONDARY, radar-only source — it never enters the precedent /
halacha pipeline and is never cited in a decision (INV-DIG1/2). After this run,
relink unmatched digests once the originals are uploaded, or surface them via
missing_precedent_create.
SINGLE SOURCE OF TRUTH: the `digests` table (DB) is the ONLY authority for what
has been ingested. This script does NOT move files between folders — re-running
is safe because ``ingest_digest`` dedups on content_hash (already-ingested →
returns ``exists``). Files left in ``incoming/`` are simply re-checked and
skipped. (Earlier versions moved files to a ``processed/`` folder; that created
a second, divergent state and was removed.)
Yomon number + issue date are parsed from the filename
("יומון 5158 - 31.5.26.pdf") as hints; the LLM also extracts them from the
body and the explicit hint wins. The monthly bulletin (e.g. "201 יוני.pdf") is
multi-topic and skipped (Phase 3).
Run: mcp-server/.venv/bin/python scripts/ingest_digests_batch.py
(optionally pass explicit file paths as args)
Config (POSTGRES_URL, VOYAGE_API_KEY, ANTHROPIC_API_KEY) auto-loads from ~/.env.
"""
import asyncio
import os
import re
import sys
import traceback
from pathlib import Path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
from legal_mcp import config # noqa: E402
from legal_mcp.services import digest_library as svc # noqa: E402
INCOMING = Path(config.DATA_DIR) / "digests" / "incoming"
# Matches "יומון 5158 - 31.5.26" → ("5158", "31.5.26")
_NAME_RE = re.compile(r"יומון\s*(\d+)\s*-\s*(\d{1,2})\.(\d{1,2})\.(\d{2,4})")
def _parse_name(fname: str) -> tuple[str, str | None]:
"""Return (yomon_number, iso_date_or_None) parsed from the filename."""
m = _NAME_RE.search(fname)
if not m:
return "", None
num, dd, mm, yy = m.groups()
year = int(yy)
if year < 100:
year += 2000
try:
iso = f"{year:04d}-{int(mm):02d}-{int(dd):02d}"
except ValueError:
iso = None
return num, iso
def _discover() -> list[Path]:
if not INCOMING.exists():
return []
out = []
for p in sorted(INCOMING.glob("*.pdf")):
if "יומון" not in p.name:
print(f"⊘ skip (not a single yomon): {p.name}", flush=True)
continue
out.append(p)
return out
async def main(argv: list[str]) -> None:
files = [Path(a) for a in argv] if argv else _discover()
if not files:
print(f"No yomon PDFs found in {INCOMING}", flush=True)
return
results = []
for idx, fp in enumerate(files):
rec = {"file": fp.name}
if not fp.exists():
rec["error"] = "file-missing"
print(f"{fp.name}: file missing", flush=True)
results.append(rec)
continue
yomon_number, iso_date = _parse_name(fp.name)
try:
out = await svc.ingest_digest(
file_path=fp,
yomon_number=yomon_number,
digest_date=iso_date,
)
rec.update({
"status": out.get("status"),
"digest_id": out.get("digest_id"),
"yomon_number": out.get("yomon_number"),
"underlying_citation": out.get("underlying_citation"),
"linked_case_law_id": out.get("linked_case_law_id"),
})
link = "🔗 linked" if out.get("linked_case_law_id") else "⚠ unlinked"
print(
f"{fp.name}: {out.get('status')} | yomon={out.get('yomon_number')} | "
f"{link} | {out.get('underlying_citation')}",
flush=True,
)
# No folder move — the DB (content_hash) is the single source of
# truth. Re-running re-checks incoming/ and skips already-ingested.
except Exception as e:
rec["error"] = f"{type(e).__name__}: {e}"
print(f"{fp.name}: {e}", flush=True)
traceback.print_exc()
results.append(rec)
print("\n===SUMMARY===", flush=True)
for r in results:
print(r, flush=True)
linked = sum(1 for r in results if r.get("linked_case_law_id"))
unlinked = sum(
1 for r in results
if r.get("status") in ("completed", "exists") and not r.get("linked_case_law_id")
)
print(
f"\nTotal: {len(results)} | linked: {linked} | unlinked (need precedent upload): {unlinked}",
flush=True,
)
if __name__ == "__main__":
asyncio.run(main(sys.argv[1:]))

172
scripts/leak_guard.py Normal file
View File

@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""G12 leak-guard — enforce the Agent Platform Port seam (docs/spec/X15 §4 / R4).
The single, canonical checker for INV-G12. Used by BOTH the interactive
PreToolUse hook (``scripts/spec-guard.sh``, warn-only) and the CI fitness-test
(``mcp-server/tests/test_platform_port_leak_guard.py``, hard fail) — one
implementation, no parallel rule (G2).
Two HARD rules:
1. **Intelligence layer is platform-clean.** ``mcp-server/src`` (the MCP tools +
decision/RAG/extraction logic) contains ZERO Paperclip-specific symbols.
A short, explicit baseline allowlist (``_ALLOW``) covers pre-existing benign
prose mentions (the origin of ``company_id``) and the host pm2 bridge that
legitimately names the ``paperclip`` service — keyed by substring so it
survives line-number shifts.
2. **Import seam.** Only ``web/agent_platform_port.py`` (the Port) and the
declared shell itself (``web/paperclip_client.py`` / ``web/paperclip_api.py``)
may import ``web.paperclip_client`` / ``web.paperclip_api``. Any other file
in ``web/`` that imports them is a violation (R2 established the seam).
OUT OF SCOPE (not intelligence): the declared shell (paperclip_client/api,
plugin-legal-ai, adapters, web-ui settings paperclip-tab / paperclip-agents,
skills/new-company-setup), and AUTO-GENERATED files (web-ui/src/lib/api/types.ts
mirrors the backend OpenAPI — governed by the backend, not hand-fixable).
Usage:
leak_guard.py # scan the whole repo; exit 1 on any violation
leak_guard.py <file>... # scan only the given files (the spec-guard hook)
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
# Paperclip-specific symbols that must never appear in the intelligence layer.
HARD = re.compile(
r"paperclip|Paperclip|PAPERCLIP|wakeup|heartbeat|HEARTBEAT|pc_request|"
r"pc\.sh|X-Paperclip|agent_wakeup|heartbeat_run|ctx\.agents|issueId"
)
# Intelligence layer — rule 1 applies here (zero hard terms, save the allowlist).
PROTECTED_DIRS = ["mcp-server/src"]
# Baseline allowlist: (path-suffix, substring-in-line). A hard-term hit is allowed
# only if its file ends with <path-suffix> AND the line contains <substring>.
# Keep this list SHORT and justified — every entry is a documented exception.
_ALLOW: list[tuple[str, str]] = [
# Host pm2 bridge legitimately lists the 'paperclip' service (ops, not intel).
("court_fetch_service/server.py", "pm2 status of legal-* / paperclip services"),
("court_fetch_service/server.py", '("legal-", "paperclip")'),
("court_fetch_service/server.py", "never paperclip or arbitrary processes"),
# Prose comments naming the ORIGIN of a stored field — not code coupling.
("services/db.py", "Paperclip company UUID"),
("services/db.py", "from a Paperclip issue"),
("services/db.py", "The Paperclip project"),
]
# Import-seam — rule 2. Only these web/ files may import the Paperclip client.
SEAM_ALLOWED = {
"web/agent_platform_port.py", # the Port
"web/paperclip_client.py", # the shell itself
"web/paperclip_api.py", # the shell itself
}
SEAM_IMPORT = re.compile(r"^\s*(from\s+web\.paperclip_(client|api)\s+import|"
r"import\s+web\.paperclip_(client|api)\b)")
_SKIP_PARTS = {".venv", "node_modules", "__pycache__", ".git", ".next"}
def _is_test(p: Path) -> bool:
return "tests" in p.parts or "test" in p.parts or p.name.startswith("test_")
def _skip(p: Path) -> bool:
return any(part in _SKIP_PARTS for part in p.parts)
def _allowed(rel: str, line: str) -> bool:
return any(rel.endswith(suf) and sub in line for suf, sub in _ALLOW)
def _iter_py(base: Path):
for p in base.rglob("*.py"):
if not _skip(p) and not _is_test(p):
yield p
def scan(files: list[Path] | None = None) -> list[str]:
"""Return a list of violation strings (empty == clean)."""
violations: list[str] = []
# Rule 1 — intelligence layer is platform-clean.
if files is None:
targets = [p for d in PROTECTED_DIRS for p in _iter_py(REPO / d)]
else:
prot = [REPO / d for d in PROTECTED_DIRS]
targets = [
p for p in files
if any(prot_d in p.resolve().parents or p.resolve() == prot_d
for prot_d in prot)
and p.suffix == ".py" and not _is_test(p) and not _skip(p)
]
for p in targets:
rel = p.resolve().relative_to(REPO).as_posix()
try:
lines = p.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
continue
for i, line in enumerate(lines, 1):
if HARD.search(line) and not _allowed(rel, line):
violations.append(
f"{rel}:{i}: Paperclip symbol in the intelligence layer "
f"(INV-G12). Route platform access through "
f"web/agent_platform_port.py, or add a justified baseline "
f"entry in scripts/leak_guard.py if genuinely benign.\n"
f" {line.strip()[:120]}"
)
# Rule 2 — import seam (web/ only).
web = REPO / "web"
seam_targets = (
[p for p in _iter_py(web)]
if files is None
else [p for p in files
if p.suffix == ".py" and (web in p.resolve().parents)
and not _is_test(p)]
)
for p in seam_targets:
rel = p.resolve().relative_to(REPO).as_posix()
if rel in SEAM_ALLOWED:
continue
try:
lines = p.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
continue
for i, line in enumerate(lines, 1):
if SEAM_IMPORT.search(line):
violations.append(
f"{rel}:{i}: imports the Paperclip client directly "
f"(INV-G12 seam). Import from web.agent_platform_port instead.\n"
f" {line.strip()[:120]}"
)
return violations
def main(argv: list[str]) -> int:
files = [Path(a) for a in argv] or None
violations = scan(files)
if violations:
sys.stderr.write(
"✗ G12 leak-guard — Agent Platform Port violated "
f"({len(violations)} finding(s)):\n\n"
)
for v in violations:
sys.stderr.write(f"{v}\n")
sys.stderr.write(
"\nSee docs/spec/X15-agent-platform-port.md (G12).\n"
)
return 1
if files is None:
print("✓ G12 leak-guard: intelligence layer is platform-clean; "
"import seam intact.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -37,7 +37,8 @@ const fs = require("fs");
// Load LEGAL_CHAT_SHARED_SECRET from a chmod 600 file off the repo. // Load LEGAL_CHAT_SHARED_SECRET from a chmod 600 file off the repo.
// The same value is mirrored in Coolify as the LEGAL_CHAT_SHARED_SECRET // The same value is mirrored in Coolify as the LEGAL_CHAT_SHARED_SECRET
// env var so the FastAPI proxy sends a matching Authorization header. // env var so the FastAPI proxy sends a matching Authorization header.
// Migrate to Infisical (/_GUIDELINES) once the MCP server is back. // SoT in Infisical: nautilus:/legal-ai/LEGAL_CHAT_SHARED_SECRET (migrated
// 2026-06-07). This local file remains the runtime source; rotate in both.
const ENV_FILE = "/home/chaim/.legal-chat-service.env"; const ENV_FILE = "/home/chaim/.legal-chat-service.env";
const env = { const env = {
HOME: "/home/chaim", HOME: "/home/chaim",

View File

@@ -0,0 +1,40 @@
/**
* pm2 ecosystem entry for legal-court-fetch-drain — a scheduled (hourly) one-shot
* that drains the X13 court-verdict fetch queue the digest trigger fills, making
* the digest → fetch → ingest loop fully autonomous (no manual court_fetch_drain).
*
* Pattern: cron_restart fires the script on schedule; autorestart:false means it
* runs once and exits (pm2 shows it "stopped" between ticks — expected for a cron
* job). A no-op (fast) when the queue is empty, so hourly is cheap.
*
* Requires (already deployed): legal-court-fetch-service (+xvfb) running for the
* browser fetch, and the host env (~/.env: POSTGRES_URL, VOYAGE_API_KEY,
* COURT_FETCH_SHARED_SECRET) the venv loads via legal_mcp.config. Ingest uses the
* local claude CLI for halacha extraction (halachot land pending_review — the
* chair's approval gate is untouched).
*
* Install (once):
* pm2 start /home/chaim/legal-ai/scripts/legal-court-fetch-drain.config.cjs
* pm2 save
* Logs: pm2 logs legal-court-fetch-drain --lines 50
* Run now (manual): mcp-server/.venv/bin/python scripts/drain_court_fetch.py
*
* Schedule override: COURT_FETCH_DRAIN_CRON (default hourly at :17 to avoid the
* top-of-hour stampede with other jobs).
*/
const cron = process.env.COURT_FETCH_DRAIN_CRON || "17 * * * *";
module.exports = {
apps: [
{
name: "legal-court-fetch-drain",
cwd: "/home/chaim/legal-ai",
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
args: "scripts/drain_court_fetch.py 5",
env: { HOME: "/home/chaim", PYTHONUNBUFFERED: "1" },
autorestart: false, // one-shot per cron tick
cron_restart: cron,
max_memory_restart: "800M",
},
],
};

View File

@@ -0,0 +1,81 @@
/**
* 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");
// SoT in Infisical: nautilus:/legal-ai/COURT_FETCH_SHARED_SECRET (migrated
// 2026-06-07). This local file is the runtime source; rotate in both.
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",
// Camoufox (headless Firefox) crashes on this server without a virtual
// display, so the service points at the Xvfb companion app below (:99).
DISPLAY: ":99",
};
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: [
{
// Persistent virtual display for Camoufox (headless Firefox needs it on
// this screenless server). Bound to :99 to match DISPLAY above.
name: "legal-court-fetch-xvfb",
script: "/usr/bin/Xvfb",
args: ":99 -screen 0 1920x1080x24 -nolisten tcp",
autorestart: true,
max_restarts: 10,
restart_delay: 3000,
},
{
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,
// A Firefox content process loading the heavy ASP.NET pages can spike;
// give headroom but cap so a leak can't threaten Postgres.
max_memory_restart: "1500M",
},
],
};

View File

@@ -0,0 +1,37 @@
/**
* pm2 ecosystem entry for legal-digest-drain — scheduled (every 2 h) drain of
* the digest-enrichment queue (X12: "כל יום" yomonim → Sonnet enrichment +
* embedding + autolink). Migrated from a bare system crontab line to pm2 so it
* appears in — and is controllable from — the /operations dashboard (run-now /
* enable / disable) like every other drain.
*
* Pattern: cron_restart fires the script on schedule; autorestart:false → runs
* once and exits (pm2 shows "stopped" between ticks — expected). The script
* already serialises itself (it self-heals stale 'processing' rows), so no flock
* is needed under pm2's one-shot model.
*
* Requires (host ~/.env via legal_mcp.config): POSTGRES_URL, VOYAGE_API_KEY, and
* the local `claude` CLI on PATH (the script prepends ~/.local/bin).
*
* Install (once):
* pm2 start /home/chaim/legal-ai/scripts/legal-digest-drain.config.cjs
* pm2 save
* Run now (manual): mcp-server/.venv/bin/python scripts/drain_digests.py
* Schedule override: DIGEST_DRAIN_CRON (default every 2 h at :00).
*/
const cron = process.env.DIGEST_DRAIN_CRON || "0 */2 * * *";
module.exports = {
apps: [
{
name: "legal-digest-drain",
cwd: "/home/chaim/legal-ai",
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
args: "scripts/drain_digests.py",
env: { HOME: "/home/chaim", PYTHONUNBUFFERED: "1" },
autorestart: false, // one-shot per cron tick
cron_restart: cron,
max_memory_restart: "800M",
},
],
};

View File

@@ -0,0 +1,39 @@
/**
* pm2 ecosystem entry for legal-halacha-drain — scheduled drain of the precedent
* halacha-extraction queue. Halacha extraction stays on claude_session (local
* CLI, high reasoning quality for holding/ratio) — unlike metadata which moved
* to Gemini. Extracted halachot land 'pending_review' for the chair's approval
* gate (INV-G10); this drainer only produces them, it never approves.
*
* The drain self-heals orphaned 'processing' rows (precedent_library) and is
* serialised by a global advisory lock, so overlapping ticks are safe.
*
* Pattern: cron_restart fires the script; autorestart:false → one-shot per tick
* (pm2 shows "stopped" between ticks). Cheap no-op when the queue is empty.
* Cadence is conservative (every 2h) because Claude extraction is slow/rate-
* limited and each run adds to the chair's review queue.
*
* Requires the local ``claude`` CLI + host ~/.env (POSTGRES_URL, etc.).
*
* Install (once):
* pm2 start /home/chaim/legal-ai/scripts/legal-halacha-drain.config.cjs
* pm2 save
* Run now (manual): mcp-server/.venv/bin/python scripts/drain_halacha_queue.py
* Schedule override: HALACHA_DRAIN_CRON (default every 2 hours at :47).
*/
const cron = process.env.HALACHA_DRAIN_CRON || "47 */2 * * *";
module.exports = {
apps: [
{
name: "legal-halacha-drain",
cwd: "/home/chaim/legal-ai",
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
args: "scripts/drain_halacha_queue.py",
env: { HOME: "/home/chaim", PYTHONUNBUFFERED: "1" },
autorestart: false, // one-shot per cron tick
cron_restart: cron,
max_memory_restart: "800M",
},
],
};

View File

@@ -0,0 +1,34 @@
/**
* pm2 ecosystem entry for legal-metadata-drain — scheduled (every 15 min) drain
* of the precedent metadata-extraction queue (Gemini Flash). Keeps the
* /precedents metadata queue from clogging (the prior agentic claude-CLI path
* hit error_max_turns and nothing drained it autonomously).
*
* Pattern: cron_restart fires the script on schedule; autorestart:false → runs
* once and exits (pm2 shows "stopped" between ticks — expected). Cheap no-op
* when the queue is empty; Gemini Flash ≈ $0.10/1M tokens.
*
* Requires (host ~/.env via legal_mcp.config): GEMINI_API_KEY, POSTGRES_URL.
*
* Install (once):
* pm2 start /home/chaim/legal-ai/scripts/legal-metadata-drain.config.cjs
* pm2 save
* Run now (manual): mcp-server/.venv/bin/python scripts/drain_metadata_queue.py
* Schedule override: METADATA_DRAIN_CRON (default every 15 min).
*/
const cron = process.env.METADATA_DRAIN_CRON || "*/15 * * * *";
module.exports = {
apps: [
{
name: "legal-metadata-drain",
cwd: "/home/chaim/legal-ai",
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
args: "scripts/drain_metadata_queue.py 10",
env: { HOME: "/home/chaim", PYTHONUNBUFFERED: "1" },
autorestart: false, // one-shot per cron tick
cron_restart: cron,
max_memory_restart: "500M",
},
],
};

View File

@@ -0,0 +1,35 @@
/**
* pm2 ecosystem entry for legal-reaper — a host-side daemon that periodically
* reaps orphaned, runaway processes that saturate the Nautilus box:
* - task-master-mcp (Node) orphaned to ppid=1, ballooning to ~3GB each
* (memory: project_taskmaster_mcp_memory_leak).
* - camoufox-bin (Firefox) leftover from a crashed/killed X13 court fetch.
* Only ppid=1 orphans are killed — live, parented processes are never touched.
* See scripts/reap_orphan_procs.py for the safety rationale.
*
* Install (once):
* pm2 start /home/chaim/legal-ai/scripts/legal-reaper.config.cjs
* pm2 save
* Logs:
* pm2 logs legal-reaper --lines 50
*
* Interval defaults to 180s; override with REAP_INTERVAL_S.
*/
const interval = process.env.REAP_INTERVAL_S || "180";
module.exports = {
apps: [
{
name: "legal-reaper",
cwd: "/home/chaim/legal-ai",
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
args: `scripts/reap_orphan_procs.py --loop ${interval}`,
env: { HOME: "/home/chaim", PYTHONUNBUFFERED: "1" },
autorestart: true,
max_restarts: 20,
restart_delay: 5000,
// The reaper itself is tiny and must never be the thing that leaks.
max_memory_restart: "100M",
},
],
};

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Reap orphaned/runaway processes that saturate the Nautilus box.
Two known offenders (2026-06-07):
1. ``task-master-mcp`` (Node) — spawned by the Claude Code VSCode extension,
orphaned to ``ppid=1`` when its session ends, then **balloons to ~3GB
each**. They accrue as sessions cycle and exhaust RAM within minutes,
risking the OOM-killer hitting Postgres/Paperclip. See memory
``project_taskmaster_mcp_memory_leak``.
2. ``camoufox-bin`` (Firefox) — the X13 court-fetch browser. A fetch that
hangs or is killed mid-flight can leave a stray browser orphaned to
``ppid=1``. Serial-only fetching means any ``ppid=1`` camoufox-bin is
stale and safe to kill.
Safety: only processes **orphaned to ``ppid=1``** are reaped — a process still
owned by a live parent (an attached MCP server, or a browser a fetch is
actively using) is never touched. Pure ``/proc`` parsing, no psutil dependency.
Usage:
python scripts/reap_orphan_procs.py # one pass, print what was reaped
python scripts/reap_orphan_procs.py --dry-run # report only
python scripts/reap_orphan_procs.py --loop 180 # daemon: reap every 180s
"""
from __future__ import annotations
import argparse
import os
import signal
import sys
import time
# Process-name substrings to reap when orphaned (ppid==1).
TARGETS = ("task-master-mcp", "camoufox-bin")
def _read(path: str) -> str:
try:
with open(path, "rb") as f:
return f.read().decode("utf-8", "replace")
except OSError:
return ""
def _proc_info(pid: str) -> tuple[int, str, int] | None:
"""Return (ppid, cmdline, rss_kb) for a pid, or None if it vanished."""
status = _read(f"/proc/{pid}/status")
if not status:
return None
ppid, rss = 0, 0
for line in status.splitlines():
if line.startswith("PPid:"):
try: ppid = int(line.split()[1])
except (IndexError, ValueError): pass
elif line.startswith("VmRSS:"):
try: rss = int(line.split()[1])
except (IndexError, ValueError): pass
cmd = _read(f"/proc/{pid}/cmdline").replace("\x00", " ").strip()
return ppid, cmd, rss
def find_orphans() -> list[tuple[str, str, int]]:
"""Return [(pid, cmd, rss_kb)] of ppid==1 processes matching TARGETS."""
out = []
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
info = _proc_info(pid)
if not info:
continue
ppid, cmd, rss = info
if ppid == 1 and any(t in cmd for t in TARGETS):
out.append((pid, cmd, rss))
return out
def reap(dry_run: bool = False) -> int:
orphans = find_orphans()
freed_mb = 0
for pid, cmd, rss in orphans:
name = next((t for t in TARGETS if t in cmd), cmd[:30])
freed_mb += rss // 1024
if dry_run:
print(f"[dry-run] would reap pid={pid} ({name}) rss={rss//1024}MB", flush=True)
continue
try:
os.kill(int(pid), signal.SIGKILL)
print(f"reaped pid={pid} ({name}) rss={rss//1024}MB", flush=True)
except ProcessLookupError:
pass
except PermissionError:
print(f" permission denied for pid={pid} ({name})", flush=True)
if orphans:
print(f"{'would free' if dry_run else 'freed'} ~{freed_mb}MB "
f"from {len(orphans)} orphan(s)", flush=True)
return len(orphans)
def main() -> int:
ap = argparse.ArgumentParser(description="Reap orphaned task-master-mcp / camoufox-bin")
ap.add_argument("--dry-run", action="store_true", help="report only, kill nothing")
ap.add_argument("--loop", type=int, default=0, metavar="SECONDS",
help="run forever, reaping every N seconds")
args = ap.parse_args()
if args.loop:
print(f"reaper loop: every {args.loop}s targets={TARGETS}", flush=True)
while True:
try:
reap(args.dry_run)
except Exception as e: # never let the daemon die
print(f"reap error: {e}", flush=True)
time.sleep(args.loop)
else:
reap(args.dry_run)
return 0
if __name__ == "__main__":
sys.exit(main())

Some files were not shown because too many files have changed in this diff Show More