1 Commits

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:08:23 +00:00
142 changed files with 1541 additions and 15266 deletions

View File

@@ -34,17 +34,6 @@
--- ---
## שער 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 שלנו:
@@ -234,15 +223,12 @@ 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,8 +17,6 @@ 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

@@ -1,119 +0,0 @@
# שטן מליץ (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,8 +35,6 @@ 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/` שלהלן משלימים — ספ-התחום קודם.
## לפני שאתה מתחיל — קרא ## לפני שאתה מתחיל — קרא
@@ -312,7 +310,16 @@ 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 — שלוש איטרציות מיותרות). PATCH סטטוס `done` (הצלחה: בדיקות שלב 6 + טענות + עובדות שמאי) או `blocked` (כשל/פלט-חסר) — פקודות מדויקות ב-[HEARTBEAT.md](HEARTBEAT.md) §4ב. **אסור** `done` עם פלט חסר. 4. **סגור את ה-issue של עצמך — חובה!** בלי זה Paperclip יחשוב שהמשימה עדיין רצה ויפעיל retry בלולאה (זה נצפה בפועל בריצת CMPA-16 — שלוש איטרציות מיותרות).
**אם הכל עבר בהצלחה (בדיקות שלב 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
@@ -322,9 +329,20 @@ 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
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`). ~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** מוגדר אוטומטית ע"י Paperclip; ב-double-quotes bash מרחיב לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID. "{\"source\":\"automation\",\"triggerDetail\":\"system\",\"reason\":\"מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_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`.
**⚠️ `$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
@@ -484,7 +502,18 @@ X שאלות עומדות להכרעה:
"העמקת ניתוח הושלמה — ערר {case_number}" \ "העמקת ניתוח הושלמה — ערר {case_number}" \
"סיכום: X פסקי דין אומתו, Y דורשים אימות חיצוני. ממצאים עובדתיים הועשרו." "סיכום: X פסקי דין אומתו, Y דורשים אימות חיצוני. ממצאים עובדתיים הועשרו."
``` ```
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 של חברה אחרת. 6. **העֵר את ה-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,8 +51,6 @@ 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` (מפת תפקיד→ספ).
## שפה ## שפה
@@ -141,17 +139,6 @@ 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,8 +28,6 @@ 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` (מפת תפקיד→ספ).
## שפה ## שפה
@@ -124,11 +122,31 @@ tools:
- ממצאי הבדיקה הסופית (אם היו הערות) - ממצאי הבדיקה הסופית (אם היו הערות)
- גודל הקובץ - גודל הקובץ
### סגור את ה-issue של עצמך + העֵר CEO — חובה! ### סגור את ה-issue של עצמך — חובה!
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית). בלי זה Paperclip יזהה "issue 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,8 +20,6 @@ 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` (מפת תפקיד→ספ).
## שפה ## שפה
@@ -92,9 +90,29 @@ tools:
"סיכום: X מסמכים הוגהו, Y החלפות, Z תיקונים. נדרשת ביקורתך." "סיכום: X מסמכים הוגהו, Y החלפות, Z תיקונים. נדרשת ביקורתך."
``` ```
### סגור את ה-issue של עצמך + העֵר CEO — חובה! ### סגור את ה-issue של עצמך — חובה!
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית). בלי זה Paperclip יזהה "issue 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,8 +27,6 @@ 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` (מפת תפקיד→ספ).
## שפה ## שפה
@@ -237,8 +235,28 @@ new → proofread → documents_ready → analyst_verified → research_complete
- האם מותר לייצא (כל הקריטיים pass?) - האם מותר לייצא (כל הקריטיים pass?)
- עדכן סטטוס ל-qa_review (אם נכשל) או drafted (אם עבר) - עדכן סטטוס ל-qa_review (אם נכשל) או drafted (אם עבר)
### סגור את ה-issue של עצמך + העֵר CEO — חובה! ### סגור את ה-issue של עצמך — חובה!
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית). בלי זה Paperclip יזהה "issue 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,9 +21,6 @@ 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
@@ -48,8 +45,6 @@ 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` (מפת תפקיד→ספ).
## שפה ## שפה
@@ -198,26 +193,6 @@ 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`) — חובה
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים: לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
@@ -335,10 +310,6 @@ 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: מיפוי תכנית
@@ -392,11 +363,31 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
- **מדיניות**: אילו שיקולים תכנוניים עולים מהחומר - **מדיניות**: אילו שיקולים תכנוניים עולים מהחומר
- קישור למיקום הקובץ: `{case_dir}/documents/research/precedent-research.md` - קישור למיקום הקובץ: `{case_dir}/documents/research/precedent-research.md`
### סגור את ה-issue של עצמך + העֵר CEO — חובה! ### סגור את ה-issue של עצמך — חובה!
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית). בלי זה Paperclip יזהה "issue 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,8 +35,6 @@ 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` (מפת תפקיד→ספ).
## שפה ## שפה
@@ -214,11 +212,31 @@ case_update(case_number, status="drafted")
- ספירת מילים לכל בלוק - ספירת מילים לכל בלוק
- יחסי משקל (% מהמסמך) - יחסי משקל (% מהמסמך)
### סגור את ה-issue של עצמך + העֵר CEO — חובה! ### סגור את ה-issue של עצמך — חובה!
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית). בלי זה Paperclip יזהה "issue 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 (G1G12). כל PR מצהיר אילו invariants הוא נוגע בהם / מקיים. ראה docs/spec/00-constitution.md (G1G11).
מלא את הסעיפים; מחק את ההערות בסוגריים <!-- -->. מלא את הסעיפים; מחק את ההערות בסוגריים <!-- -->.
--> -->
@@ -11,9 +11,8 @@
## Invariants — הצהרה (חובה) ## Invariants — הצהרה (חובה)
<!-- <!--
אילו invariants הנדסיים (G1G10, G12) או INV-* מקבצי-תחום ה-PR נוגע בהם או מקיים? אילו invariants הנדסיים (G1G10) או INV-* מקבצי-תחום ה-PR נוגע בהם או מקיים?
דוגמה: "G2 (מקור-אמת יחיד) — איחדתי 2 לקוחות Paperclip למסלול קנוני אחד; INV-INT4." דוגמה: "G2 (מקור-אמת יחיד) — איחדתי 2 לקוחות Paperclip למסלול קנוני אחד; INV-INT4."
דוגמה: "G12 (שער-הפלטפורמה) — מגע-Paperclip חדש נוסף רק ב-agent_platform_port.py, לא ב-mcp-server."
תוכן משפטי → G11. תוכן משפטי → G11.
--> -->
@@ -23,7 +22,6 @@
- [ ] קראתי את `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

@@ -1,22 +0,0 @@
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,7 +6,6 @@ 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__/

251
CLAUDE.md
View File

@@ -1,11 +1,10 @@
# עוזר משפטי — Legal Decision Assistant # עוזר משפטי — Legal Decision Assistant
> **אינדקס דק.** הכללים הקריטיים נמצאים כאן; העומק התפעולי (Deploy, Paperclip-ops, adapters, מבנה-תיקיות, Chair-Feedback, TaskMaster מלא) הוצא ל-[`docs/operations-runbook.md`](docs/operations-runbook.md) כדי לרזות את ההקשר הנטען בכל סשן.
## רקע הפרויקט ## רקע הפרויקט
מערכת AI לסיוע בכתיבת החלטות של **ועדת ערר לתכנון ובניה, מחוז ירושלים**, בראשות **עו"ד דפנה תמיר**. מערכת AI לסיוע בכתיבת החלטות של **ועדת ערר לתכנון ובניה, מחוז ירושלים**, בראשות **עו"ד דפנה תמיר**.
### מה עושה ועדת ערר?
ועדת ערר היא גוף מעין-שיפוטי שדן בעררים על החלטות ועדות מקומיות לתכנון ובניה. הוועדה מקבלת חומרי מקור (כתבי ערר, תגובות, פרוטוקולים, תכניות), דנה בטענות הצדדים, ומוציאה **החלטה כתובה מנומקת** — מסמך משפטי פורמלי שניתן לביקורת שיפוטית בבית משפט לעניינים מנהליים. ועדת ערר היא גוף מעין-שיפוטי שדן בעררים על החלטות ועדות מקומיות לתכנון ובניה. הוועדה מקבלת חומרי מקור (כתבי ערר, תגובות, פרוטוקולים, תכניות), דנה בטענות הצדדים, ומוציאה **החלטה כתובה מנומקת** — מסמך משפטי פורמלי שניתן לביקורת שיפוטית בבית משפט לעניינים מנהליים.
### שלושה סוגי עררים ### שלושה סוגי עררים
@@ -16,7 +15,12 @@
| פיצויים (ס' 197) | 9xxx | קר ומקצועי | דומה להיטל השבחה | | פיצויים (ס' 197) | 9xxx | קר ומקצועי | דומה להיטל השבחה |
### מטרת המערכת ### מטרת המערכת
כלי עבודה שמסייע ליו"ר הוועדה: **ניהול תיקים** (ייבוא, סיווג, מעקב סטטוס) · **בסיס ידע** (פסיקה, ביטויי מעבר, לקחים, חקיקה) · **חיפוש סמנטי (RAG)** · **סיוע בכתיבה** (טיוטות לפי 12 בלוקים בסגנון דפנה) · **ייצוא DOCX**. לבנות כלי עבודה שמסייע ליו"ר הוועדה לנסח החלטות:
1. **ניהול תיקים** — ייבוא חומרי מקור, סיווג מסמכים, מעקב סטטוס
2. **בסיס ידע** — פסיקה, ביטויי מעבר, לקחים מהחלטות קודמות, חקיקה
3. **חיפוש סמנטי (RAG)** — מציאת תקדימים רלוונטיים ופסקאות דומות
4. **סיוע בכתיבה** — ייצור טיוטות לפי ארכיטקטורת 12 בלוקים בסגנון דפנה
5. **ייצוא DOCX** — מסמך מעוצב מוכן להגשה
### ⭐ יעד-העל: רכישת-הסגנון של דפנה (Style Acquisition) ### ⭐ יעד-העל: רכישת-הסגנון של דפנה (Style Acquisition)
**היעד הראשי של המערכת הוא שהסוכנים יכתבו וינתחו עררים בדיוק כמו עו"ד דפנה תמיר** — לא רק לייצר טיוטה תקנית, אלא להפנים את **הקול והשיטה** שלה. זה מחייב **הפרדה מובהקת בין שתי תת-מערכות**: **היעד הראשי של המערכת הוא שהסוכנים יכתבו וינתחו עררים בדיוק כמו עו"ד דפנה תמיר** — לא רק לייצר טיוטה תקנית, אלא להפנים את **הקול והשיטה** שלה. זה מחייב **הפרדה מובהקת בין שתי תת-מערכות**:
@@ -26,9 +30,19 @@
**הגישה (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:** שכבת-ידע-הקול לא תכיל מהות ספציפית — רק סגנון ושיטה. ספ מלא: [`docs/spec/07-learning.md`](docs/spec/07-learning.md) §0. ארכיטקטורה ומשימות: תוכנית `style-acquisition-subsystem`. **כלל-העל — INV-LRN4:** כל החלטה אינה "סגורה" עד שהושוותה מול הגרסה הסופית של דפנה; כל סופי מנותח מול הטיוטה. כך לומדים מכל החלטה. **INV-LRN5:** שכבת-ידע-הקול לא תכיל מהות ספציפית — רק סגנון ושיטה.
> **Legacy:** המערכת הקודמת היתה Obsidian vault עם Claude Code skills. הידע שהופק ממנה (ניתוח סגנון, 12 בלוקים מבוססי CREAC/DITA/Akoma-Ntoso/FJC, כללי כתיבה, לקחים, ייצוא DOCX) הוטמע בפרויקט הנוכחי (`docs/`, `data/training/`). ה-vault נמחק; כעת PostgreSQL + pgvector. ספ מלא: [`docs/spec/07-learning.md`](docs/spec/07-learning.md) §0. ארכיטקטורה ומשימות: תוכנית `style-acquisition-subsystem`.
### מה היה קודם (Legacy)
המערכת הקודמת היתה **Obsidian vault** עם Claude Code skills על שרת אחר. פותחו:
- ניתוח סגנון של 3 החלטות (הכט — דחייה, בית הכרם — קבלה חלקית, אריאלי — השוואה)
- ארכיטקטורת 12 בלוקים מבוססת CREAC / DITA / Akoma Ntoso / Federal Judicial Center
- כללי כתיבה (רקע ניטרלי, ללא כפילות, טענות מקוריות בלבד)
- לקחים מהשוואת טיוטות לגרסאות סופיות
- סקריפט ייצוא DOCX
הידע שהופק מה-vault הוטמע במערכת הנוכחית — מסמכי ייחוס (`docs/`), קורפוס אימון (`data/training/`), ומבנה 12 בלוקים. ה-vault המקורי נמחק; הפרויקט הנוכחי עובד עם PostgreSQL + pgvector.
--- ---
@@ -44,7 +58,6 @@
| [`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 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
@@ -60,8 +73,6 @@
| [`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 |
--- ---
@@ -74,14 +85,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 הגלובליים G1G12, וכללי-ההנדסה (§6). אינדקס-הספ ב-§7. 1. **קרא** [`docs/spec/00-constitution.md`](docs/spec/00-constitution.md) — ייעוד, ה-invariants הגלובליים G1G11, וכללי-ההנדסה (§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)/[`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). 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).
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, G12)** — הסעיף הזה + `docs/spec/`. סמכות: ≥3 מקורות חיצוניים. > - **הנדסה (G1G10)** — הסעיף הזה + `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 על נתיב-קוד.
@@ -94,13 +105,17 @@
**לכן — כל סשן שעומד לכתוב/לשנות קוד או תיעוד חייב לעבוד ב-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>`, ומקבל **אוטומטית**: בסיס נקי מ-`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`). נוצר תחת `.claude/worktrees/<slug>/` על ענף `worktree-<slug>`, ומקבל **אוטומטית**:
- **בסיס נקי מ-`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/`** כדי שההגדרות יחולו).
@@ -111,43 +126,202 @@ 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`. כלומר **כל סוכני 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` (קיימת רק ב-fork ה-deepseek שלנו). כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. הניתוח המלא והדרכים שנשקלו: TaskMaster `legal-ai` #104 (נסגר כ-cancelled — "לתעד, לא לבדד").
--- ---
## Deploy — תמצית קריטית ## שרת Nautilus (158.178.131.193)
שלושה מודלי-הרצה דרים יחד; ערבוב = הטעות הנפוצה. **פירוט מלא, 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` |
- **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`. ### ⚠️ ארכיטקטורת Deploy — חובה לקרוא
- **Paperclip** = **pm2 מקומי** (`localhost:3100`). שינוי → `pm2 restart paperclip`. **אין** Docker/Coolify.
- **legal-chat-service** = **pm2 מקומי** (`127.0.0.1:8770`), גשר claude CLI לטאב הצ'אט ב-/training. שינוי → `pm2 restart legal-chat-service`. **עוזר משפטי (Legal-AI)** — רץ כ-**Docker container דרך Coolify**:
- 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/
> **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. ├── CLAUDE.md ← הקובץ הזה
├── Dockerfile ← Docker build
- **Wakeup תמיד דרך API**: `POST /api/agents/{agent-id}/wakeup` עם `payload.issueId`. **אסור** `INSERT INTO agent_wakeup_requests` ישיר — הסוכן לא יתעורר לעולם (אין `heartbeat_run`). ├── docs/ ← תיעוד + לקחים
- **ניתוב comments דרך CEO**: תגובת-משתמש → פלאגין מעיר CEO → CEO מנתב ויוצר issue. סוכנים קוראים comments אחרונים לפני עבודה (HEARTBEAT 2b-2c). │ ├── architecture.md ארכיטקטורה
- **קריאות API דרך helper בלבד**: bash → `scripts/pc.sh`; Python → `pc_request()` מ-`web/paperclip_api.py`. **אסור** `curl` ישיר ל-Paperclip או `httpx.AsyncClient` ישיר. │ ├── block-schema.md 12 בלוקים (המסמך החשוב ביותר)
- **Cross-company sync**: 14 סוכנים = 7 × 2 חברות (CMP=1xxx master, CMPA=8xxx mirror). אחרי כל שינוי הגדרות/skills של סוכן — להריץ `scripts/sync_agents_across_companies.py --apply`. **מדלג** על סוכנים עם `adapter_type` שונה בין החברות (למשל `deepseek_local`) — להחיל ידנית בשתיהן. │ ├── 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/ ← סקריפטים שהושלמו (לא להריץ)
```
--- ---
## כלל: עדכון `scripts/SCRIPTS.md` ## כלל: עדכון `scripts/SCRIPTS.md`
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/`**חובה לעדכן את `scripts/SCRIPTS.md`** (תפקיד, סטטוס, החלפה).
## ניהול משימות — TaskMaster AI בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/`**חובה לעדכן את `scripts/SCRIPTS.md`** בהתאם.
**תמיד** TaskMaster (לא TASKS.md ידני). קובץ קנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (tags: `master`, `legal-ai`). פקודות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`. הקובץ מתעד את התפקיד, הסטטוס, וההחלפה (אם יש) של כל סקריפט.
> **⚠️ מלכוד cwd ב-CLI:** `--tag` בוחר קבוצה *בתוך* הקובץ — לא לאיזה קובץ לכתוב (ה-CLI מאתר לפי cwd). תמיד `cd ~/legal-ai` לפני כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את הקובץ ישירות. פירוט: [`docs/operations-runbook.md`](docs/operations-runbook.md).
--- ---
## עקרונות כתיבה קריטיים (G11) ## ניהול משימות — TaskMaster AI
הפרויקט משתמש ב-**TaskMaster AI** (MCP server) לניהול משימות מובנה:
- **תמיד** להשתמש ב-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 — כללי אינטגרציה קריטיים
### 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. **"רקע ניטרלי"** — בלוק ו = עובדות בלבד. אין ציטוטים מצדדים, אין מילות שיפוט
@@ -156,7 +330,14 @@ cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד
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`, API `/api/feedback`, MCP `record_chair_feedback`/`list_chair_feedback`, UI `/feedback`. פירוט: [`docs/operations-runbook.md`](docs/operations-runbook.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
## יו"ר: עו"ד דפנה תמיר ## יו"ר: עו"ד דפנה תמיר
מדריך סגנון מלא: [`skills/decision/SKILL.md`](skills/decision/SKILL.md). - מדריך סגנון מלא: `skills/decision/SKILL.md`

View File

@@ -1,62 +0,0 @@
# שער 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.

View File

@@ -1,70 +0,0 @@
# מפת הקורפוס — גרף ציטוטים אינטראקטיבי (`/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).

View File

@@ -1,203 +0,0 @@
# 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

@@ -78,14 +78,13 @@
אלה החוקים החוצים את כל המערכת — לב החוקה. הם נחלקים לשני סוגים לפי **מקור-הסמכות**: אלה החוקים החוצים את כל המערכת — לב החוקה. הם נחלקים לשני סוגים לפי **מקור-הסמכות**:
- **G1G10, G12 — invariants הנדסיים** (תכנון/בניית האפליקציה): כל אחד מגובה ב-**≥3 סמכויות - **G1G10 — invariants הנדסיים** (תכנון/בניית האפליקציה): כל אחד מגובה ב-**≥3 סמכויות
טכניות מוכרות** (נספח §8). ביחד הם מייבשים את כשל-השורש החוזר: מסלולים/קורפוסים טכניות מוכרות** (נספח §8). ביחד הם מייבשים את כשל-השורש החוזר: מסלולים/קורפוסים
מקבילים שמתפצלים (drift) בלי שכבה שמגדירה ואוכפת "תקין". (G12 — שער-הפלטפורמה — מוסף מקבילים שמתפצלים (drift) בלי שכבה שמגדירה ואוכפת "תקין".
במחזור-3; ראה [X15](X15-agent-platform-port.md).)
- **G11 — invariant תוכן-משפטי:** הסמכות עליו היא **היו"ר (דפנה) + מסמכי-הפרויקט**, לא - **G11 — invariant תוכן-משפטי:** הסמכות עליו היא **היו"ר (דפנה) + מסמכי-הפרויקט**, לא
מקורות חיצוניים, ואינו כפוף לפרוטוקול ≥3-המקורות. מקורות חיצוניים, ואינו כפוף לפרוטוקול ≥3-המקורות.
### 5א. Invariants הנדסיים (G1G10, G12) ### 5א. Invariants הנדסיים (G1G10)
### INV-G1: מזהה קנוני מנורמל בכתיבה ### INV-G1: מזהה קנוני מנורמל בכתיבה
**כלל:** לכל ישות יש מזהה קנוני יחיד, **מנורמל בנקודת-הכתיבה** (לא תיקון-סלחני בקריאה **כלל:** לכל ישות יש מזהה קנוני יחיד, **מנורמל בנקודת-הכתיבה** (לא תיקון-סלחני בקריאה
@@ -197,22 +196,6 @@ 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: תוכן החלטה מנומקת
@@ -244,11 +227,11 @@ Martin — *Clean Architecture* (The Dependency Rule) · Eric Evans — *Domain-
## 7. אינדקס הספ ## 7. אינדקס הספ
> הערה: כל קבצי הספ (00, 0107, X1X16) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה. > הערה: כל קבצי הספ (00, 0107, X1X10) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
| קובץ | תפקיד | אוכף invariants | | קובץ | תפקיד | אוכף invariants |
|------|--------|-----------------| |------|--------|-----------------|
| [00-constitution.md](00-constitution.md) | חוקה — ייעוד, invariants גלובליים, כללי-הנדסה, אינדקס | G1G12 | | [00-constitution.md](00-constitution.md) | חוקה — ייעוד, invariants גלובליים, כללי-הנדסה, אינדקס | G1G11 |
| [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 |
@@ -267,11 +250,7 @@ Martin — *Clean Architecture* (The Dependency Rule) · Eric Evans — *Domain-
| [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 | | [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,14 +155,6 @@ 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,13 +35,6 @@
(`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
@@ -183,4 +176,3 @@ 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,18 +52,6 @@
**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,11 +3,10 @@
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md). זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר. כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
מבנה: 00 חוקה · 0107 מחזור-חיים · X1X16 חוצי-שלבים. ראה אינדקס מלא בחוקה. מבנה: 00 חוקה · 0107 מחזור-חיים · X1X13 חוצי-שלבים. ראה אינדקס מלא בחוקה.
- X1X5: מזהים · רב-חברתי · אינטגרציה+deploy · סוכנים · audit. - X1X5: מזהים · רב-חברתי · אינטגרציה+deploy · סוכנים · audit.
- X6X10 (מחזור-2, 8 משטחי-האפליקציה): חוזה UI↔API · לקוח-Paperclip · מילוי-שדות · חוזה כלי-MCP · deploy/env/secrets. - X6X10 (מחזור-2, 8 משטחי-האפליקציה): חוזה UI↔API · לקוח-Paperclip · מילוי-שדות · חוזה כלי-MCP · deploy/env/secrets.
- X11X14 (הרחבות-תחום): citator פנימי (תיקוף-הלכות) · יומונים כשכבת-גילוי (radar) · אחזור-פסיקה אוטומטי מנט המשפט (שירות) · אחסון-אובייקטים (MinIO/S3, הגירת `data/`). - X11 · X13: citator תיקוף-הלכות · אחזור-פסיקה אוטומטי מנט המשפט (שירות).
- 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

@@ -1,185 +0,0 @@
# 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

@@ -17,52 +17,29 @@
**הבחנת-מקור קריטית:** רק **פסקי-דין של בתי-משפט** ניתנים לאחזור ציבורי. **החלטות ועדת-ערר** **הבחנת-מקור קריטית:** רק **פסקי-דין של בתי-משפט** ניתנים לאחזור ציבורי. **החלטות ועדת-ערר**
אינן זמינות ציבורית (נדרש נבו) — מסומנות כפער ולא נשלחות לאחזור. אינן זמינות ציבורית (נדרש נבו) — מסומנות כפער ולא נשלחות לאחזור.
**דרכי-מקור ציבוריות (ניתוב לפי זמינות-פורמט-נט, לא לפי ערכאה):** **שתי דרכי-מקור ציבוריות:**
- **נט המשפט** (מציג-התיקים) משרת **כל הערכאות** — מחוזי/שלום *וגם עליון* — כל עוד יש מספר - **עליון** (עע"מ/בג"ץ/ע"א/רע"א/בר"מ/דנ"א) → `supremedecisions.court.gov.il` — הורדה ישירה (httpx), ללא CAPTCHA.
בפורמט תיק-חודש-שנה. ASP.NET WebForms (`__doPostBack`/VIEWSTATE), anti-bot של F5, מסמכים - **מנהלי/מחוזי/שלום** (עת"מ/עמ"נ/...) → מציג-התיקים של **נט המשפט** — ASP.NET WebForms
בצופה-עמודים (turn.js). מחייב **דפדפן-אמת** (host-side) → שירות-מארח ב-pm2 (כדפוס (`__doPostBack`/VIEWSTATE), anti-bot של F5, reCAPTCHA על החיפוש הציבורי, מסמכים כ-S3 cleared URLs.
`legal-chat-service`). **זהו המסלול הראשי המאומת.** מחייב **דפדפן-אמת** (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) ## 1. ארכיטקטורה — שלוש שכבות (tiered)
``` ```
underlying_citation → [classifier] → {tier, האם יש פורמט-נט (תיק-חודש-שנה)} underlying_citation → [classifier] → tier ∈ {supreme, admin, skip}
skip(ערר/בל"מ) → missing_precedent (נבו ידני) — לא אחזור skip(ערר/בל"מ) → missing_precedent (נבו ידני) — לא אחזור
── ניתוב לפי זמינות-פורמט-נט, לא לפי קידומת (נט המשפט משרת כל הערכאות) ── supreme → Tier 0: httpx בקונטיינר → supremedecisions — אוטונומי מלא
פורמט-נט קיים (עמ"נ/עת"מ/עליון-בפורמט-נט כמו בר"מ 72182-06-25) admin → Tier 1: legal-court-fetch-service (host/pm2) — אוטונומי-first
→ Tier 1: legal-court-fetch-service (host/pm2 + Xvfb) — אוטונומי, מאומת → Camoufox stealth browser → external-search → reCAPTCHA(audio/Whisper)
→ Camoufox(python) → external-search → CaseDetails → פסקי דין → download cleared PDF
→ NGCSViewerPage → GetImages(X-Requested-With) → PNGs → PDF → Tier 2 fallback: VNC ידני / missing_precedent + התראה — שער-אנושי
עליון סדרתי-בלבד (בג"ץ/בר"מ NNNN/YY, בלי חודש)
→ Tier 0: httpx → supremedecisions (SearchVerdicts+Download) — מפוענח ומאומת
כשל אוטונומי → Tier 2: missing_precedent + התראה (VNC עתידי) — שער-אנושי
(כל ה-tiers) → precedent_library_upload(source_type=court_ruling) → ingest_precedent (כל ה-tiers) → precedent_library_upload(source_type=court_ruling) → ingest_precedent
→ chunks+embeddings+halachot(pending) → relink digest / close gap → chunks+embeddings+halachot(pending) → relink digest / close gap
``` ```
מצב-העבודה מנוהל בטבלת-תור `court_fetch_jobs` (idempotent, נצפה, retryable). הניקוז מצב-העבודה מנוהל בטבלת-תור `court_fetch_jobs` (idempotent, נצפה, retryable).
האוטומטי: `legal-court-fetch-drain` (pm2 cron שעתי) → `orchestrator.drain_pending`.
--- ---
@@ -82,7 +59,7 @@ underlying_citation → [classifier] → {tier, האם יש פורמט-נט (ת
לא נזרק בשקט. `except: pass` אסור. לא נזרק בשקט. `except: pass` אסור.
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G4](00-constitution.md#inv-g4) וכלל-ההנדסה "אין בליעה שקטה" (§6). **מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G4](00-constitution.md#inv-g4) וכלל-ההנדסה "אין בליעה שקטה" (§6).
**אכיפה:** טבלת `court_fetch_jobs` (status+error+attempts) + לוג-warning בכל כישלון + Tier-2 gate. **אכיפה:** טבלת `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` מנקז (סדרתי) ומקשר את היומון חזרה בהצלחה. **הפרה ידועה:** הפער הקיים ב-X12 — `try_autolink` שנכשל מחזיר `None` בשקט (יתוקן ע"י טריגר זה).
### INV-CF3: אוטונומי-first, שער-אנושי חובה ב-fallback ### INV-CF3: אוטונומי-first, שער-אנושי חובה ב-fallback
**כלל:** האחזור מנסה אוטונומית; אך כש-N נסיונות נכשלים, **שער-אנושי** (VNC לפתרון-CAPTCHA **כלל:** האחזור מנסה אוטונומית; אך כש-N נסיונות נכשלים, **שער-אנושי** (VNC לפתרון-CAPTCHA
@@ -161,8 +138,8 @@ Service / responsible automation) | סטטוס: verified
| proxy בקונטיינר | `web/court_fetch_proxy.py` | שכפול `web/chat_proxy.py` | | proxy בקונטיינר | `web/court_fetch_proxy.py` | שכפול `web/chat_proxy.py` |
| pm2 | `scripts/legal-court-fetch-service.config.cjs` | שכפול `legal-chat-service.config.cjs` | | pm2 | `scripts/legal-court-fetch-service.config.cjs` | שכפול `legal-chat-service.config.cjs` |
| אורקסטרטור+תור | `services/court_fetch_orchestrator.py` + `db.py` (SCHEMA_Vxx) | דפוס-תור קיים | | אורקסטרטור+תור | `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) | | כלי-MCP | `tools/court_fetch.py` (`court_verdict_fetch`) | חוזה-envelope [X9](X9-mcp-tool-contract.md) |
| טריגר אוטומטי | `services/digest_library.py` (`try_autolink` fail`_enqueue_court_fetch`) → drain ע"י `orchestrator.drain_pending` | X12 | | טריגר | `services/digest_library.py` (`try_autolink` fail-path) | X12 |
| סוד | `COURT_FETCH_SHARED_SECRET` (Infisical + Coolify) | דפוס `LEGAL_CHAT_SHARED_SECRET`, [X10](X10-deploy-env-secrets.md) | | סוד | `COURT_FETCH_SHARED_SECRET` (Infisical + Coolify) | דפוס `LEGAL_CHAT_SHARED_SECRET`, [X10](X10-deploy-env-secrets.md) |
--- ---
@@ -172,9 +149,3 @@ Service / responsible automation) | סטטוס: verified
- F5/anti-bot עלול לחסום IP → politeness סדרתי + Camoufox (INV-CF4). - F5/anti-bot עלול לחסום IP → politeness סדרתי + Camoufox (INV-CF4).
- שבירות מול שינויי-אתר → ריכוז selectors במקום אחד + בדיקות-עשן תקופתיות. - שבירות מול שינויי-אתר → ריכוז selectors במקום אחד + בדיקות-עשן תקופתיות.
- גבול-ToS על אתר .gov → INV-CF7 + שיקול-יו"ר. - גבול-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

@@ -1,146 +0,0 @@
# 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

@@ -1,148 +0,0 @@
# 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

@@ -1,96 +0,0 @@
# 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,14 +92,12 @@ 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: חילוץ אסינכרוני, מתור, צד-מארח (לא מהקונטיינר) ### INV-FP5: חילוץ אסינכרוני דרך claude_session מקומי
**כלל:** חילוץ-LLM (מטא, הלכות) רץ **אסינכרוני, מתור, מצד-המארח** — לא חוסם את ה-web ולא קורא ל-LLM **כלל:** חילוץ-LLM (מטא, הלכות) רץ **אסינכרוני, מתור**, דרך `claude_session` **מקומי בלבד** — לא חוסם את
מהקונטיינר. **בחירת-מנוע לפי אופי-המשימה (לא מסלול מקביל):** חילוץ-מטא הוא משימה *תחומה* (טקסט→JSON) ה-web, ולא קורא ל-LLM מהקונטיינר. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
ולכן רץ על **Gemini Flash** (`gemini_session`, structured JSON) — ה-claude CLI ה-agentic פגע ב- (מסלול-LLM קנוני יחיד). **פרויקטלי-תפעולי.** תואם זיכרון `feedback_claude_session_local_only`.
`error_max_turns`; חילוץ-הלכות (רגיש-קול/agentic) נשאר על **`claude_session`** (CLI מקומי, מנוי דפנה). **מקור-סמכות:** [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).
שני המנועים מתנקזים לתור-החילוץ הקנוני היחיד ([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)). **פרויקטלי-תפעולי.** **אכיפה:** queue + `precedent_process_pending`; קריאות-LLM רק מ-MCP מקומי.
**מקור-סמכות:** [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,29 +21,6 @@ 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,10 +54,6 @@ 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;
@@ -202,32 +198,6 @@ 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

@@ -1,314 +1,148 @@
"""Camoufox driver for נט המשפט — calibrated, proven flow (X13, Tier 1). """Camoufox-browser client + נט-המשפט navigation flow (X13, Tier 1).
Open-source, zero-API-cost: drives a **Camoufox** stealth browser (a Firefox Open-source, zero-API-cost stealth browsing: a self-hosted ``camofox-browser``
fork with C++ fingerprint spoofing) via its official Python package REST server (``jo-inc/camofox-browser``, wrapping Camoufox — a Firefox fork
(``camoufox.async_api``) — in-process, no separate Node server. The full flow with C++ fingerprint spoofing) drives a real browser. We talk to it over the
was reverse-engineered and validated end-to-end against עת"מ 46111-12-22 same REST surface the Hermes agent uses (``~/.hermes/.../browser_camofox.py``):
(2026-06-07): a 34-page verdict PDF retrieved with **no smart-card and no
CAPTCHA-solving**.
The proven path: POST /tabs → {tab_id}
1. homepage → DOM-click ``btnExternalSearchCases`` ("תיקים לפי מס' תיק מקור"). POST /tabs/{tab}/navigate {url}
2. Fill the visible header case-locator: ``BamaCaseNumberTextBoxH`` = case GET /tabs/{tab}/snapshot → accessibility tree w/ element refs
number, ``BamaMonthYearTextBoxHT`` = "MM-YY"; click ``SearchHeaderCaseButton``. POST /tabs/{tab}/click {ref}
→ lands on ``FolderCaseDetails/CaseDetails.aspx`` for the case. POST /tabs/{tab}/type {ref,text}
3. Click the "פסקי דין" sidebar tab → ``Decisions/DecisionList.aspx``. GET /tabs/{tab}/screenshot
4. Click the document → popup ``Viewer/NGCSViewerPage.aspx?DocumentNumber=…``. DELETE /sessions/{user}
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): Set ``CAMOFOX_URL`` (e.g. ``http://127.0.0.1:9377``) to enable. The server's
* a virtual display — Camoufox/Firefox crashes headless on this server ``/health`` exposes a VNC URL — that's the human-fallback surface (INV-CF3):
without one. Set ``DISPLAY`` to a running Xvfb (e.g. ``:99``). when the autonomous reCAPTCHA solve fails, the chair opens the VNC and solves
* RAM — a Firefox content process loading the heavy ASP.NET pages needs it live, and this flow continues.
~0.51 GB; keep the box from swapping.
reCAPTCHA note: ``recaptcha_audio`` (local Whisper) remains as a fallback for ⚠ CALIBRATION: the נט-המשפט external-case-search is an ASP.NET WebForms app
the explicit-PDF-download path, but the primary image-API path needs no behind an F5 WAF + reCAPTCHA. The element selectors and step sequence below
solving, so it is normally unused. are the *documented plan* of the flow; they must be calibrated against the
live snapshot on first run (the site rate-limited static probing during
development). Every step that can't find its target **raises** a clear Hebrew
reason (INV-CF2 — no silent success-with-garbage) so the orchestrator escalates
to the Tier-2 human fallback rather than returning an empty/wrong file.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import base64
import io
import json
import logging import logging
import os import os
import re
import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# נט המשפט public entry points (discovered from the homepage __doPostBack menu).
NGCS_HOME = "https://www.court.gov.il/ngcs.web.site/homepage.aspx" NGCS_HOME = "https://www.court.gov.il/ngcs.web.site/homepage.aspx"
# Headless Camoufox needs a virtual display on this server. CAMOFOX_URL = os.environ.get("CAMOFOX_URL", "").rstrip("/")
_DISPLAY = os.environ.get("DISPLAY", "") _TIMEOUT = float(os.environ.get("COURT_FETCH_BROWSER_TIMEOUT_S", "60"))
_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): class CamofoxUnavailable(RuntimeError):
"""Camoufox (or its virtual display) isn't available.""" """camofox-browser isn't configured/reachable."""
class NgcsFlowError(RuntimeError): class NgcsFlowError(RuntimeError):
"""A step in the נט-המשפט flow failed (navigation / not found / blocked).""" """A step in the נט-המשפט flow failed (selector/CAPTCHA/navigation)."""
def is_enabled() -> bool: def is_enabled() -> bool:
"""True if the Camoufox package imports (browser binary present).""" return bool(CAMOFOX_URL)
try:
import camoufox.async_api # noqa: F401
return True
except Exception:
return False
async def health() -> dict: async def health() -> dict:
return {"camoufox_import": is_enabled(), "display": _DISPLAY or "(none)"} """Probe camofox-browser; surfaces the VNC URL for the human fallback."""
if not CAMOFOX_URL:
raise CamofoxUnavailable("CAMOFOX_URL is not set")
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{CAMOFOX_URL}/health")
r.raise_for_status()
return r.json()
async def _fill_visible(page, id_substr: str, value: str) -> bool: class _Browser:
for el in await page.locator(f"input[id*='{id_substr}']").all(): """Thin async wrapper over the camofox-browser REST surface."""
try:
if await el.is_visible() and await el.is_editable():
await el.fill(value)
return True
except Exception:
continue
return False
def __init__(self, client: httpx.AsyncClient, tab_id: str, user_id: str):
self._c = client
self.tab = tab_id
self.user = user_id
async def _reach_viewer(page, *, case_number: str, month_year: str): @classmethod
"""Drive home → search → case → פסקי דין → viewer popup. Returns the popup page.""" async def open(cls, client: httpx.AsyncClient) -> "_Browser":
await page.goto(NGCS_HOME, wait_until="domcontentloaded", timeout=_NAV_TIMEOUT_MS) r = await client.post(f"{CAMOFOX_URL}/tabs", json={})
await page.wait_for_timeout(2500) r.raise_for_status()
await page.eval_on_selector( data = r.json()
"#Header1_UpperMenu1_btnExternalSearchCases", "el => el.click()" return cls(client, data["tab_id"], data.get("user_id", data["tab_id"]))
async def navigate(self, url: str) -> None:
r = await self._c.post(f"{CAMOFOX_URL}/tabs/{self.tab}/navigate", json={"url": url})
r.raise_for_status()
async def snapshot(self) -> dict:
r = await self._c.get(f"{CAMOFOX_URL}/tabs/{self.tab}/snapshot")
r.raise_for_status()
return r.json()
async def click(self, ref: str) -> dict:
r = await self._c.post(f"{CAMOFOX_URL}/tabs/{self.tab}/click", json={"ref": ref})
r.raise_for_status()
return r.json()
async def type(self, ref: str, text: str) -> None:
r = await self._c.post(
f"{CAMOFOX_URL}/tabs/{self.tab}/type", json={"ref": ref, "text": text}
) )
r.raise_for_status()
async def close(self) -> None:
try: try:
await page.wait_for_load_state("domcontentloaded", timeout=_NAV_TIMEOUT_MS) await self._c.delete(f"{CAMOFOX_URL}/sessions/{self.user}")
except Exception: except httpx.HTTPError:
pass 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( async def fetch_admin_verdict(
*, file_number: str, month: str, year: str, case_number: str, court: str *, file_number: str, month: str, year: str, case_number: str, court: str
) -> dict: ) -> dict:
"""Fetch an admin/district court verdict as a PDF. Returns """Drive נט המשפט to download an admin/district verdict PDF.
``{content: bytes, filename, source_url, court}``; raises on failure.
``file_number``/``month``/``year`` are the נט-המשפט triple (e.g. 46111/12/22). Returns ``{content: bytes, filename: str, source_url: str, court: str}``.
Raises ``CamofoxUnavailable`` / ``NgcsFlowError`` on failure.
The flow (to be calibrated against the live snapshot):
1. Open the homepage; trigger "חיפוש תיקים חיצוני" (btnExternalSearchCases).
2. Fill the case-number / month / year fields.
3. Solve the reCAPTCHA via the audio challenge (recaptcha_audio); on
repeated failure, surface the VNC URL for a human solve (INV-CF3).
4. Submit; open the matched case; locate the verdict ("פסק דין") document.
5. Download the cleared PDF (served via S3 pre-signed URL) and return bytes.
""" """
try: if not CAMOFOX_URL:
from camoufox.async_api import AsyncCamoufox
except Exception as e:
raise CamofoxUnavailable( raise CamofoxUnavailable(
"חבילת camoufox אינה מותקנת/זמינה. הרץ `pip install camoufox` ו-" "שירות-הדפדפן (camofox-browser) אינו מוגדר — הגדר CAMOFOX_URL "
"`python -m camoufox fetch`. ראה docs/spec/X13-court-fetch.md." "והפעל את jo-inc/camofox-browser. ראה 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:]}" async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
br = await _Browser.open(client)
# 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: try:
doc_num["v"] = json.loads(resp.request.post_data).get("documentNumber") await br.navigate(NGCS_HOME)
except Exception: snap = await br.snapshot()
pass _ = snap # calibration anchor: locate btnExternalSearchCases here.
async with AsyncCamoufox( # The concrete selector/CAPTCHA/download steps require live
headless=True, geoip=False, humanize=True, locale="he-IL" # calibration with camofox running. Until calibrated we fail
) as browser: # loudly so the orchestrator escalates to the human fallback
page = await browser.new_page() # (INV-CF2/CF3) rather than pretending success.
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( raise NgcsFlowError(
f"אחזור עבר את מגבלת-הזמן ({_FETCH_HARD_TIMEOUT_S:.0f}ש') ובוטל" "זרימת נט-המשפט (Tier 1) ממתינה לכיול מול snapshot חי של "
"camofox-browser — בקשת-אחזור מוסלמת ל-fallback אנושי (VNC/ידני)."
) )
finally: finally:
_reap_orphan_browsers() await br.close()

View File

@@ -9,9 +9,6 @@ Endpoints:
{ok, content_b64, filename, source_url, court, reason} {ok, content_b64, filename, source_url, court, reason}
REQUIRES Authorization: Bearer <COURT_FETCH_SHARED_SECRET>. REQUIRES Authorization: Bearer <COURT_FETCH_SHARED_SECRET>.
GET /health liveness (no auth); reports camofox + VNC URL if available. 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: Run with pm2:
pm2 start scripts/legal-court-fetch-service.config.cjs pm2 start scripts/legal-court-fetch-service.config.cjs
@@ -58,131 +55,6 @@ async def health(request: web.Request) -> web.Response:
return web.json_response(info) 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: def _check_bearer(request: web.Request) -> web.Response | None:
auth = request.headers.get("Authorization", "") auth = request.headers.get("Authorization", "")
expected = "Bearer " + _SHARED_SECRET expected = "Bearer " + _SHARED_SECRET
@@ -234,8 +106,6 @@ async def fetch(request: web.Request) -> web.Response:
def build_app() -> web.Application: def build_app() -> web.Application:
app = web.Application(client_max_size=64 * 1024 * 1024) app = web.Application(client_max_size=64 * 1024 * 1024)
app.router.add_get("/health", health) 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) app.router.add_post("/fetch", fetch)
return app return app

View File

@@ -58,7 +58,6 @@ 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, court_fetch as cf_tools,
) )
@@ -342,81 +341,6 @@ 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,
@@ -988,12 +912,6 @@ async def court_fetch_status(case_number: str = "", status_filter: str = "") ->
return await cf_tools.court_fetch_status(case_number, status_filter) 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,7 +21,6 @@ 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
@@ -35,7 +34,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, storage from legal_mcp.services import db, research_md
def _mark_run_rtl(run: Run) -> None: def _mark_run_rtl(run: Run) -> None:
@@ -495,19 +494,10 @@ async def build_analysis_docx(case_number: str) -> Path:
continue continue
_emit_content_line(doc, raw) _emit_content_line(doc, raw)
# Save versioned through the storage layer (INV-STG1). export_dir.mkdir + # Save versioned
# 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"
buf = io.BytesIO() doc.save(str(out_path))
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,30 +335,18 @@ async def get_legal_arguments(
case_id, case_id,
) )
# Pull supporting claims (id + full text) for each argument in one # Pull supporting claim ids for each argument in one round-trip.
# 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 lap.argument_id, lap.claim_id, """SELECT argument_id, claim_id
c.claim_text, c.source_document, c.claim_index FROM legal_argument_propositions
FROM legal_argument_propositions lap WHERE argument_id = ANY($1::uuid[])""",
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:
@@ -366,6 +354,5 @@ 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

@@ -1,121 +0,0 @@
"""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

@@ -1,147 +0,0 @@
"""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,7 +82,6 @@ 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.
@@ -105,12 +104,6 @@ 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.
@@ -133,8 +126,6 @@ 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"
@@ -213,15 +204,13 @@ 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``/``tools`` are forwarded to :func:`query` (see its docstring). ``model``/``effort`` 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, tools=tools) raw = await query(prompt, timeout=timeout, system=system, model=model, effort=effort)
return parse_llm_json(raw) return parse_llm_json(raw)

View File

@@ -157,23 +157,15 @@ def classify(citation: str) -> CourtCitation:
case_number_norm=normalize_case_number(raw), case_number_norm=normalize_case_number(raw),
) )
# 2. Supreme Court prefix → Tier 0. Still parse a נט-format triple when the # 2. Supreme Court prefix → Tier 0.
# 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) m = _SUPREME_RX.search(text)
if m: if m:
raw = m.group(2) raw = m.group(2)
norm = normalize_case_number(raw)
filed = _split_filed(norm)
return CourtCitation( return CourtCitation(
tier="supreme", tier="supreme",
court_prefix=m.group(1), court_prefix=m.group(1),
case_number_raw=raw, case_number_raw=raw,
case_number_norm=norm, case_number_norm=normalize_case_number(raw),
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. # 3. District / admin prefix → Tier 1.

View File

@@ -41,12 +41,11 @@ logger = logging.getLogger(__name__)
# human (INV-CF3). Kept low — the .gov site shouldn't be hammered (INV-CF4). # 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")) 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 # The host-side Tier-1 browser service (pm2). The MCP server runs on the host,
# gateway (10.0.1.1) — same as legal-chat-service — so both the host MCP server # so it reaches the service over loopback directly (the container bridge in
# and containers can reach it; the host reaches 10.0.1.1 as a local interface. # web/court_fetch_proxy.py is a separate, optional entry point).
# Override with COURT_FETCH_SERVICE_URL.
COURT_FETCH_SERVICE_URL = os.environ.get( COURT_FETCH_SERVICE_URL = os.environ.get(
"COURT_FETCH_SERVICE_URL", "http://10.0.1.1:8771" "COURT_FETCH_SERVICE_URL", "http://127.0.0.1:8771"
) )
_SHARED_SECRET = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip() _SHARED_SECRET = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
_TIER1_TIMEOUT_S = float(os.environ.get("COURT_FETCH_TIER1_TIMEOUT_S", "300")) _TIER1_TIMEOUT_S = float(os.environ.get("COURT_FETCH_TIER1_TIMEOUT_S", "300"))
@@ -170,15 +169,14 @@ async def fetch_and_ingest(
await db.court_fetch_job_update(job_id, status="running", bump_attempts=True) await db.court_fetch_job_update(job_id, status="running", bump_attempts=True)
# ── fetch ── # ── 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: try:
if has_net_format: if cit.tier == "supreme":
fetched = await fetch_supreme_verdict(
citation=citation, case_number_norm=cit.case_number_norm
)
content, filename = fetched.content, fetched.filename
source_url, court = fetched.source_url, fetched.court
else: # admin → Tier 1
res = await _fetch_tier1_admin(cit) res = await _fetch_tier1_admin(cit)
if not res.get("ok"): if not res.get("ok"):
raise RuntimeError(res.get("reason") or "אחזור נכשל") raise RuntimeError(res.get("reason") or "אחזור נכשל")
@@ -187,20 +185,7 @@ async def fetch_and_ingest(
filename = res.get("filename") or f"{cit.case_number_norm}.pdf" filename = res.get("filename") or f"{cit.case_number_norm}.pdf"
source_url = res.get("source_url", "") source_url = res.get("source_url", "")
court = res.get("court") or cit.court_prefix court = res.get("court") or cit.court_prefix
elif cit.tier == "supreme": except (_Tier1Unavailable, SupremeFetchError, RuntimeError) as e:
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)) return await _record_failure(job_id, cit, citation, str(e))
# ── ingest into the canonical pipeline (INV-CF1) ── # ── ingest into the canonical pipeline (INV-CF1) ──
@@ -219,77 +204,10 @@ async def fetch_and_ingest(
case_law_id=UUID(str(case_law_id)) if case_law_id else None, case_law_id=UUID(str(case_law_id)) if case_law_id else None,
source_url=source_url, error="", 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, return {"status": "done", "tier": cit.tier, "case_law_id": case_law_id,
"citation": citation, "source_url": source_url, "ingest": result} "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( async def _record_failure(
job_id: UUID, cit: court_citation.CourtCitation, citation: str, err: str job_id: UUID, cit: court_citation.CourtCitation, citation: str, err: str
) -> dict: ) -> dict:

View File

@@ -1,38 +1,37 @@
"""Tier 0 — Supreme Court verdict fetcher (X13), via supremedecisions.court.gov.il. """Tier 0 — Supreme Court verdict fetcher (X13).
Pulls a published Supreme Court verdict PDF from the **public** decisions portal Pulls a published Supreme Court verdict PDF from the **public** decisions
— no smart-card, no CAPTCHA, no browser (pure httpx). Used for serial-format portal ``supremedecisions.court.gov.il`` — no smart-card, no CAPTCHA. The
citations (בג"ץ/בר"מ/עע"מ NNNN/YY) that have no נט-format triple and so can't go portal is an AngularJS SPA backed by a small JSON API (reverse-engineered
through the Tier-1 נט-המשפט flow. from ``/Scripts/app/config.js`` + the search/results controllers):
The portal is an AngularJS SPA over a small ASP.NET JSON API, reverse-engineered POST Home/SearchVerdicts body {"document": <query>, "lan": 1} → result list
and validated live (2026-06-08 on בג"ץ 3483/05 → 75 KB PDF). The flow: GET Home/GetCasesYearNum ?... (year + number lookup) → case + docs
GET Home/Download?path=<path>&fileName=<file>&type=4 → the PDF bytes
POST Home/SearchVerdicts Two things matter for getting a 200 instead of an F5 connection-reset
body: {"document": {"Year": "YYYY", "CaseNum": "NNNN", "Month": {}, (verified empirically 2026-06-07):
"dateType": 1, "publishDate": 8, * a **complete** browser header set — UA + Accept + Accept-Language. A bare
"SearchText": [<empty clause>], UA alone gets reset.
"OldMainNumFormat": true}, "lan": 1} * **politeness** (INV-CF4): one request at a time, a cooldown between them,
{"data": [{Path, FileName, CaseName, Type, Pages, VerdictDt, ...}, ...]} a Referer of the portal root. We never parallelise or hammer.
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): Honesty / scope: the *result→download* field mapping (where ``path`` and
* the **X-Requested-With: XMLHttpRequest** header on every AJAX call; ``fileName`` live in the SearchVerdicts JSON) is derived from the client code,
* a **complete** browser header set (UA + Accept + Accept-Language). not yet confirmed against a live JSON response (the live site rate-limited
probing during development). ``fetch_supreme_verdict`` therefore validates the
A case can have many documents (interim החלטות + the final פסק דין). We pick the response shape and **raises** on anything unexpected (INV-CF2 — no silent
verdict: prefer a record whose Type contains "פסק דין", else the most-paginated / swallow) so the orchestrator can record the failure and fall back, rather than
latest one. Politeness (INV-CF4): serial, with a cooldown. returning a wrong/empty file. The first live run is the validation pass; see
the X13 verification section.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import datetime as _dt
import logging import logging
import os import os
import re from dataclasses import dataclass
import urllib.parse
import httpx import httpx
@@ -40,6 +39,8 @@ logger = logging.getLogger(__name__)
_BASE = "https://supremedecisions.court.gov.il" _BASE = "https://supremedecisions.court.gov.il"
# A complete, browser-like header set. Empirically required to pass the F5
# WAF (a bare User-Agent gets a TCP reset).
_HEADERS = { _HEADERS = {
"User-Agent": ( "User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
@@ -47,151 +48,134 @@ _HEADERS = {
), ),
"Accept": "application/json, text/plain, */*", "Accept": "application/json, text/plain, */*",
"Accept-Language": "he-IL,he;q=0.9,en;q=0.8", "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 + "/", "Referer": _BASE + "/",
} }
# Politeness knobs (INV-CF4). Serial only — never run these concurrently.
_REQUEST_TIMEOUT_S = float(os.environ.get("COURT_FETCH_HTTP_TIMEOUT_S", "30")) _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")) _INTER_REQUEST_COOLDOWN_S = float(os.environ.get("COURT_FETCH_COOLDOWN_S", "2"))
# type=4 → PDF in the portal's Download endpoint (from resultsControler.js).
_DOC_TYPE_PDF = "4" _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,
}
@dataclass
class FetchedVerdict: class FetchedVerdict:
"""A downloaded verdict file held in memory, ready for ingest.""" """A downloaded verdict file held in memory, ready for ingest."""
def __init__(self, content: bytes, filename: str, source_url: str, content: bytes
court: str = "בית המשפט העליון", case_name: str = ""): filename: str
self.content = content source_url: str
self.filename = filename court: str = "בית המשפט העליון"
self.source_url = source_url
self.court = court
self.case_name = case_name
class SupremeFetchError(RuntimeError): class SupremeFetchError(RuntimeError):
"""The public portal returned an unexpected shape / no document. Carries a """Raised when the public portal returns an unexpected shape / no document.
Hebrew reason for the job row (INV-CF2)."""
Carries a human-readable Hebrew reason so the orchestrator can persist it
def _four_digit_year(yy: str) -> str: on the job row (INV-CF2) and decide on fallback.
"""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: async def _get(client: httpx.AsyncClient, path: str, **kwargs) -> httpx.Response:
m = re.search(r"/Date\((\d+)", str(r.get("VerdictDt") or "")) await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S)
return int(m.group(1)) if m else 0 resp = await client.get(f"{_BASE}/{path.lstrip('/')}", **kwargs)
resp.raise_for_status()
return resp
def _rank_candidates(records: list[dict]) -> list[dict]: async def _post(client: httpx.AsyncClient, path: str, json: dict) -> httpx.Response:
"""Order a case's documents by how good a corpus target each is, best first. await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S)
resp = await client.post(f"{_BASE}/{path.lstrip('/')}", json=json)
resp.raise_for_status()
return resp
Preference: the reasoned ruling (Type contains 'פסק') over interim החלטות;
then more pages (substantive over one-liners); then most recent. We return def _extract_doc_ref(results: object) -> tuple[str, str] | None:
a *ranked list*, not one pick, because the formally-labeled פסק-דין is """Pull (path, fileName) of the first verdict document from a results blob.
sometimes a published-report ('s'-prefix) file that the free Download
endpoint blocks (WAF) — the caller tries each until one downloads as a PDF. The SearchVerdicts/GetCasesYearNum responses nest documents under varying
Records without a Path/FileName are dropped. keys across the portal's endpoints. We probe the known shapes defensively
and return the first (path, fileName) pair found; ``None`` if none.
""" """
usable = [r for r in records if r.get("Path") and r.get("FileName")] def walk(node):
if isinstance(node, dict):
# A document node carries both a path and a file name.
path = node.get("Path") or node.get("path")
fname = node.get("FileName") or node.get("fileName") or node.get("Filename")
if path and fname:
yield (str(path), str(fname))
for v in node.values():
yield from walk(v)
elif isinstance(node, list):
for v in node:
yield from walk(v)
def _score(r: dict) -> tuple: for pair in walk(results):
is_verdict = 1 if "פסק" in str(r.get("Type") or "") else 0 return pair
return (is_verdict, int(r.get("Pages") or 0), _dt_key(r)) return None
return sorted(usable, key=_score, reverse=True)
async def fetch_supreme_verdict( async def fetch_supreme_verdict(
*, citation: str, case_number_norm: str *, citation: str, case_number_norm: str
) -> FetchedVerdict: ) -> FetchedVerdict:
"""Fetch a Supreme Court verdict PDF by serial citation. Raises on failure.""" """Fetch a Supreme Court verdict PDF by citation. Raises on failure.
case_num, yyyy = _parse_serial(case_number_norm, citation)
Flow: full-text search for the citation → locate the verdict document's
(path, fileName) → download the PDF. Serial + cooled-down throughout.
"""
async with httpx.AsyncClient( async with httpx.AsyncClient(
http2=False, headers=_HEADERS, timeout=_REQUEST_TIMEOUT_S, http2=True,
headers=_HEADERS,
timeout=_REQUEST_TIMEOUT_S,
follow_redirects=True, follow_redirects=True,
) as client: ) as client:
document = { # 1. Search. The portal's quick-search posts {document, lan}; lan=1=Hebrew.
"Year": yyyy, "CaseNum": case_num, "Month": {},
"dateType": 1, "publishDate": 8, "SearchText": [dict(_EMPTY_CLAUSE)],
"OldMainNumFormat": True,
}
try: try:
await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S) search = await _post(
resp = await client.post( client, "Home/SearchVerdicts",
f"{_BASE}/Home/SearchVerdicts", json={"document": document, "lan": 1} json={"document": citation, "lan": 1},
) )
resp.raise_for_status() results = search.json()
payload = resp.json()
except httpx.HTTPError as e: except httpx.HTTPError as e:
raise SupremeFetchError(f"חיפוש בפורטל העליון נכשל עבור {citation}: {e}") from e raise SupremeFetchError(
except ValueError as e: f"חיפוש בפורטל העליון נכשל עבור {citation}: {e}"
raise SupremeFetchError(f"תשובת-חיפוש לא-JSON מהפורטל עבור {citation}") from e ) from e
except ValueError as e: # non-JSON body
raise SupremeFetchError(
f"תשובת-חיפוש לא-JSON מהפורטל עבור {citation}"
) from e
records = payload.get("data") if isinstance(payload, dict) else None ref = _extract_doc_ref(results)
candidates = _rank_candidates(records or []) if not ref:
if not candidates:
raise SupremeFetchError( raise SupremeFetchError(
f"לא נמצא מסמך-פסק עבור {citation} בפורטל העליון " f"לא נמצא מסמך-פסק עבור {citation} בפורטל העליון "
f"(תיק {case_num}/{yyyy[-2:]}; ייתכן שאינו פורסם או טרם דיגיטציה)." f"(ייתכן שאינו פורסם או שמבנה-התשובה השתנה)."
) )
path, fname = ref
# Try documents best-first until one downloads as a real PDF. The # 2. Download the PDF.
# 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: try:
await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S) dl = await _get(
dl = await client.get(f"{_BASE}/Home/Download?{qs}") client, "Home/Download",
dl.raise_for_status() params={"path": path, "fileName": fname, "type": _DOC_TYPE_PDF},
)
except httpx.HTTPError as e: 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( raise SupremeFetchError(
f"אף מסמך של {citation} לא ירד כ-PDF ({len(candidates)} מועמדים) — {last_reason}" f"הורדת PDF נכשלה עבור {citation} (path={path}): {e}"
) from e
content = dl.content
ctype = dl.headers.get("content-type", "")
if not content or ("pdf" not in ctype.lower() and not content[:4] == b"%PDF"):
raise SupremeFetchError(
f"הקובץ שהתקבל עבור {citation} אינו PDF תקין (content-type={ctype})."
)
source_url = (
f"{_BASE}/Home/Download?path={path}&fileName={fname}&type={_DOC_TYPE_PDF}"
)
safe_name = fname if fname.lower().endswith(".pdf") else f"{case_number_norm}.pdf"
return FetchedVerdict(
content=content, filename=safe_name, source_url=source_url,
) )

View File

@@ -664,10 +664,8 @@ CREATE TABLE IF NOT EXISTS halachot (
case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE, case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
halacha_index INTEGER NOT NULL, halacha_index INTEGER NOT NULL,
rule_statement TEXT NOT NULL, rule_statement TEXT NOT NULL,
rule_type TEXT DEFAULT 'interpretive', rule_type TEXT DEFAULT 'binding',
-- rule ROLE only (INV-DM7): holding | interpretive | procedural | -- binding | interpretive | procedural | obiter
-- application | obiter. authority (binding/persuasive) is DERIVED
-- from case_law.precedent_level, never stored here.
reasoning_summary TEXT DEFAULT '', reasoning_summary TEXT DEFAULT '',
supporting_quote TEXT NOT NULL, supporting_quote TEXT NOT NULL,
page_reference TEXT DEFAULT '', page_reference TEXT DEFAULT '',
@@ -1289,71 +1287,6 @@ ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_generated_at TIMESTAMPTZ
""" """
SCHEMA_V30_SQL = """
-- digests (X12): Ofer Toister daily "כל יום" one-pagers. A SECONDARY,
-- discovery-layer ("radar") source — NOT authoritative law. Kept in its OWN
-- table (never case_law) so it cannot pollute the precedent corpus, never
-- enters the halacha pipeline (INV-DIG2), and is never cited directly in a
-- decision (INV-DIG1). Its only job is to point the researcher at the
-- UNDERLYING ruling, which is ingested separately into case_law and cited from
-- there. linked_case_law_id is the bridge (INV-DIG3): filled once the
-- underlying ruling is in the library; NULL = an open knowledge gap.
CREATE TABLE IF NOT EXISTS digests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
yomon_number TEXT NOT NULL DEFAULT '', -- "5163"
digest_date DATE, -- date of the yomon ISSUE
publication TEXT NOT NULL DEFAULT 'כל יום',
source_firm TEXT NOT NULL DEFAULT 'עפר טויסטר, עורכי דין',
concept_tag TEXT NOT NULL DEFAULT '', -- "שיקול הדעת המצומצם"
headline_holding TEXT NOT NULL DEFAULT '', -- bold subtitle = the holding
analysis_text TEXT NOT NULL DEFAULT '', -- the 1-2 page body (raw text)
summary TEXT NOT NULL DEFAULT '', -- 2-3 sentence LLM summary
underlying_citation TEXT NOT NULL DEFAULT '', -- 'עת"מ 46111-12-22 יכין-אפק...'
underlying_court TEXT NOT NULL DEFAULT '',
underlying_date DATE, -- date the RULING was given (≠ digest_date)
underlying_judge TEXT NOT NULL DEFAULT '',
practice_area TEXT NOT NULL DEFAULT '', -- rishuy_uvniya/betterment_levy/compensation_197
appeal_subtype TEXT NOT NULL DEFAULT '',
subject_tags TEXT[] NOT NULL DEFAULT '{}',
linked_case_law_id UUID REFERENCES case_law(id) ON DELETE SET NULL,
embedding vector(1024), -- single vector of concept+headline+summary+analysis
source_document_path TEXT NOT NULL DEFAULT '', -- staged PDF path (rel to DATA_DIR)
content_hash TEXT NOT NULL DEFAULT '', -- sha256 of extracted text — idempotent upload
extraction_status TEXT NOT NULL DEFAULT 'pending', -- pending/processing/completed/failed
content_tsv tsvector GENERATED ALWAYS AS (
to_tsvector('simple',
coalesce(concept_tag,'') || ' ' || coalesce(headline_holding,'') || ' ' ||
coalesce(summary,'') || ' ' || coalesce(analysis_text,''))
) STORED,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Idempotent re-upload (INV-G3): same yomon number = same digest. yomon_number
-- can be '' transiently (before extraction), so the unique index is partial.
CREATE UNIQUE INDEX IF NOT EXISTS uq_digests_yomon_number
ON digests(yomon_number) WHERE yomon_number <> '';
-- Secondary dedup key when yomon_number couldn't be parsed.
CREATE UNIQUE INDEX IF NOT EXISTS uq_digests_content_hash
ON digests(content_hash) WHERE content_hash <> '';
-- HNSW (not ivfflat): the digests radar is a small, slowly-growing corpus
-- (~1/day). ivfflat trains `lists` centroids and probes a subset at query time,
-- so on a small table a single probe can hit an empty list and return 0 rows
-- (recall cliff). HNSW has no list-training/probe step — correct recall from
-- the first row — so it is the right index for a corpus that starts ~empty.
DROP INDEX IF EXISTS idx_digests_embedding; -- drop any pre-existing ivfflat
CREATE INDEX IF NOT EXISTS idx_digests_embedding_hnsw
ON digests USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS idx_digests_linked ON digests(linked_case_law_id);
CREATE INDEX IF NOT EXISTS idx_digests_practice_area ON digests(practice_area);
CREATE INDEX IF NOT EXISTS idx_digests_concept_tag ON digests(concept_tag);
CREATE INDEX IF NOT EXISTS idx_digests_subject_tags ON digests USING gin(subject_tags);
-- Lexical half of a future hybrid (Phase-1 search is semantic-only; index is ready).
CREATE INDEX IF NOT EXISTS idx_digests_content_tsv ON digests USING gin(content_tsv);
"""
# ── X13 — Court Verdict Fetch queue ────────────────────────────────────── # ── X13 — Court Verdict Fetch queue ──────────────────────────────────────
# A lightweight, observable, idempotent job queue for the auto-fetch # A lightweight, observable, idempotent job queue for the auto-fetch
# subsystem (docs/spec/X13-court-fetch.md). One row per court verdict we try # subsystem (docs/spec/X13-court-fetch.md). One row per court verdict we try
@@ -1361,8 +1294,11 @@ CREATE INDEX IF NOT EXISTS idx_digests_content_tsv ON digests USING gin(content_
# is always explicit (INV-CF2 — no silent drop), the canonical case number is # is always explicit (INV-CF2 — no silent drop), the canonical case number is
# the idempotency key (INV-CF5), and ``attempts`` drives the human-fallback # the idempotency key (INV-CF5), and ``attempts`` drives the human-fallback
# gate (INV-CF3 — flip to 'manual' after N autonomous failures). # gate (INV-CF3 — flip to 'manual' after N autonomous failures).
# V31 — digests (X12) took V30 when it merged first. #
SCHEMA_V31_SQL = """ # NOTE (merge): main is at V29; the digests-radar worktree adds its own V30.
# If digests-radar lands first, renumber this block to V31 and update the
# apply loop. Kept as V30 here so the branch is self-consistent on main.
SCHEMA_V30_SQL = """
CREATE TABLE IF NOT EXISTS court_fetch_jobs ( CREATE TABLE IF NOT EXISTS court_fetch_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_number_norm TEXT NOT NULL UNIQUE, -- idempotency key (INV-CF5) case_number_norm TEXT NOT NULL UNIQUE, -- idempotency key (INV-CF5)
@@ -1383,39 +1319,6 @@ CREATE INDEX IF NOT EXISTS idx_court_fetch_jobs_digest ON court_fetch_jobs(diges
WHERE digest_id IS NOT NULL; WHERE digest_id IS NOT NULL;
""" """
SCHEMA_V32_SQL = """
-- digest_kind (X12): classify each "כל יום" issue. Most are decision-summaries
-- (point at a ruling → underlying_citation set), but some are non-decision
-- ANNOUNCEMENTS (legislative/planning updates, new-year notices) that legitimately
-- have no ruling. Classifying explicitly lets enrich treat an announcement as a
-- SUCCESS (concept+summary, no citation) instead of a both-empty "failure" that
-- the drain self-heal would retry forever. '' = not yet classified (= a genuine
-- extraction failure once enriched).
ALTER TABLE digests ADD COLUMN IF NOT EXISTS digest_kind TEXT NOT NULL DEFAULT '';
-- Backfill legacy rows cheaply (no LLM): a row with a citation is a decision,
-- otherwise an announcement. MUST run before the new self-heal keys on
-- digest_kind='' (else it would reset every legacy row). Idempotent.
UPDATE digests SET digest_kind =
CASE WHEN coalesce(underlying_citation,'') <> '' THEN 'decision' ELSE 'announcement' END
WHERE coalesce(digest_kind,'') = '' AND extraction_status = 'completed';
CREATE INDEX IF NOT EXISTS idx_digests_kind ON digests(digest_kind);
"""
SCHEMA_V33_SQL = """
-- drain_controls: a per-drain "startup type" switch for the /operations
-- dashboard's process-management panel. pm2 cron_restart resurrects a stopped
-- cron job at the next tick, so `pm2 stop` is NOT a durable "disable" for the
-- drains. Instead each drain checks this flag at startup and no-ops when
-- disabled (like a Windows service set to Disabled). The container writes it
-- directly (no host roundtrip); the drains read it. name = the pm2 process
-- name (e.g. 'legal-metadata-drain').
CREATE TABLE IF NOT EXISTS drain_controls (
name TEXT PRIMARY KEY,
disabled BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
"""
async def _run_schema_migrations(pool: asyncpg.Pool) -> None: async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
async with pool.acquire() as conn: async with pool.acquire() as conn:
@@ -1450,10 +1353,7 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
await conn.execute(SCHEMA_V28_SQL) await conn.execute(SCHEMA_V28_SQL)
await conn.execute(SCHEMA_V29_SQL) await conn.execute(SCHEMA_V29_SQL)
await conn.execute(SCHEMA_V30_SQL) await conn.execute(SCHEMA_V30_SQL)
await conn.execute(SCHEMA_V31_SQL) logger.info("Database schema initialized (v1-v30)")
await conn.execute(SCHEMA_V32_SQL)
await conn.execute(SCHEMA_V33_SQL)
logger.info("Database schema initialized (v1-v33)")
async def init_schema() -> None: async def init_schema() -> None:
@@ -3628,332 +3528,6 @@ async def delete_case_law(case_law_id: UUID) -> bool:
return result == "DELETE 1" return result == "DELETE 1"
# ── Digests (X12 — radar layer; separate table, INV-DIG1/2/3) ────────
_DIGEST_COLS = (
"id, yomon_number, digest_date, publication, source_firm, concept_tag, "
"headline_holding, analysis_text, summary, underlying_citation, "
"underlying_court, underlying_date, underlying_judge, practice_area, "
"appeal_subtype, subject_tags, linked_case_law_id, source_document_path, "
"content_hash, extraction_status, digest_kind, created_at, updated_at"
)
_DIGEST_UPDATE_ALLOWED = {
"yomon_number", "digest_date", "publication", "source_firm", "concept_tag",
"headline_holding", "analysis_text", "summary", "underlying_citation",
"underlying_court", "underlying_date", "underlying_judge", "practice_area",
"appeal_subtype", "subject_tags", "source_document_path", "content_hash",
"extraction_status", "digest_kind",
}
def _row_to_digest(row: asyncpg.Record | dict | None) -> dict | None:
"""Normalize a digests row: ISO-format dates, ensure subject_tags is a list."""
if row is None:
return None
d = dict(row)
for k in ("digest_date", "underlying_date", "created_at", "updated_at"):
if d.get(k) is not None and hasattr(d[k], "isoformat"):
d[k] = d[k].isoformat()
if d.get("subject_tags") is None:
d["subject_tags"] = []
if d.get("id") is not None:
d["id"] = str(d["id"])
if d.get("linked_case_law_id") is not None:
d["linked_case_law_id"] = str(d["linked_case_law_id"])
return d
async def create_digest(
*,
analysis_text: str,
yomon_number: str = "",
digest_date: date | None = None,
publication: str = "כל יום",
source_firm: str = "עפר טויסטר, עורכי דין",
concept_tag: str = "",
headline_holding: str = "",
summary: str = "",
underlying_citation: str = "",
underlying_court: str = "",
underlying_date: date | None = None,
underlying_judge: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
source_document_path: str = "",
extraction_status: str = "processing",
digest_kind: str = "",
) -> dict:
"""Upsert a digest (X12). Idempotent on yomon_number (INV-G3): a repeat
upload of the same yomon updates in place. content_hash is the secondary
dedup key for digests whose number couldn't be parsed (and the primary key
for bulletin items, which carry no yomon_number — see uq_digests_content_hash)."""
pool = await get_pool()
content_hash = _content_hash(analysis_text)
async with pool.acquire() as conn:
# Upsert on the partial unique index uq_digests_yomon_number
# (yomon_number WHERE yomon_number <> ''). Predicate repeated in
# ON CONFLICT as required for partial indexes.
row = await conn.fetchrow(
f"""
INSERT INTO digests (
yomon_number, digest_date, publication, source_firm, concept_tag,
headline_holding, analysis_text, summary, underlying_citation,
underlying_court, underlying_date, underlying_judge, practice_area,
appeal_subtype, subject_tags, source_document_path,
content_hash, extraction_status, digest_kind
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19
)
ON CONFLICT (yomon_number) WHERE yomon_number <> ''
DO UPDATE SET
digest_date = COALESCE(EXCLUDED.digest_date, digests.digest_date),
publication = EXCLUDED.publication,
source_firm = EXCLUDED.source_firm,
concept_tag = EXCLUDED.concept_tag,
headline_holding = EXCLUDED.headline_holding,
analysis_text = EXCLUDED.analysis_text,
summary = EXCLUDED.summary,
underlying_citation = EXCLUDED.underlying_citation,
underlying_court = EXCLUDED.underlying_court,
underlying_date = COALESCE(EXCLUDED.underlying_date, digests.underlying_date),
underlying_judge = EXCLUDED.underlying_judge,
practice_area = EXCLUDED.practice_area,
appeal_subtype = EXCLUDED.appeal_subtype,
subject_tags = EXCLUDED.subject_tags,
source_document_path = COALESCE(NULLIF(EXCLUDED.source_document_path, ''), digests.source_document_path),
content_hash = EXCLUDED.content_hash,
extraction_status = EXCLUDED.extraction_status,
digest_kind = COALESCE(NULLIF(EXCLUDED.digest_kind, ''), digests.digest_kind),
updated_at = now()
RETURNING {_DIGEST_COLS}
""",
yomon_number, digest_date, publication, source_firm, concept_tag,
headline_holding, analysis_text, summary, underlying_citation,
underlying_court, underlying_date, underlying_judge, practice_area,
appeal_subtype, list(subject_tags or []), source_document_path,
content_hash, extraction_status, digest_kind,
)
return _row_to_digest(row)
async def get_digest(digest_id: UUID | str) -> dict | None:
pool = await get_pool()
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
row = await pool.fetchrow(
f"SELECT {_DIGEST_COLS} FROM digests WHERE id = $1", cid,
)
return _row_to_digest(row)
async def get_digest_by_content_hash(content_hash: str) -> dict | None:
if not content_hash:
return None
pool = await get_pool()
row = await pool.fetchrow(
f"SELECT {_DIGEST_COLS} FROM digests WHERE content_hash = $1", content_hash,
)
return _row_to_digest(row)
async def update_digest(digest_id: UUID | str, **fields) -> dict | None:
"""Patch metadata fields on a digest row. Whitelist via _DIGEST_UPDATE_ALLOWED."""
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
updates = {k: v for k, v in fields.items() if k in _DIGEST_UPDATE_ALLOWED}
if not updates:
return await get_digest(cid)
pool = await get_pool()
set_parts = []
params: list = [cid]
for i, (k, v) in enumerate(updates.items(), start=2):
if k == "subject_tags":
v = list(v or [])
set_parts.append(f"{k} = ${i}")
params.append(v)
set_parts.append("updated_at = now()")
sql = f"UPDATE digests SET {', '.join(set_parts)} WHERE id = $1 RETURNING {_DIGEST_COLS}"
row = await pool.fetchrow(sql, *params)
return _row_to_digest(row)
async def store_digest_embedding(digest_id: UUID | str, vector: list[float]) -> None:
pool = await get_pool()
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
await pool.execute(
"UPDATE digests SET embedding = $2, updated_at = now() WHERE id = $1",
cid, vector,
)
async def link_digest_to_case_law(
digest_id: UUID | str, case_law_id: UUID | str | None,
) -> dict | None:
"""Set (or clear, with None) the bridge to the underlying ruling (INV-DIG3)."""
pool = await get_pool()
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
clid = None
if case_law_id is not None:
clid = case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
row = await pool.fetchrow(
f"UPDATE digests SET linked_case_law_id = $2, updated_at = now() "
f"WHERE id = $1 RETURNING {_DIGEST_COLS}",
cid, clid,
)
return _row_to_digest(row)
async def delete_digest(digest_id: UUID | str) -> bool:
pool = await get_pool()
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
result = await pool.execute("DELETE FROM digests WHERE id = $1", cid)
return result == "DELETE 1"
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]:
"""List digests with simple filters. linked=True/False filters on whether
the underlying ruling is in the library yet (INV-DIG3 gap surfacing).
publication filters the source ('כל יום' daily vs 'עו"ד על נדל"ן' monthly)."""
pool = await get_pool()
conditions: list[str] = []
params: list = []
idx = 1
if practice_area:
conditions.append(f"practice_area = ${idx}")
params.append(practice_area)
idx += 1
if publication:
conditions.append(f"publication = ${idx}")
params.append(publication)
idx += 1
if concept_tag:
conditions.append(f"concept_tag ILIKE ${idx}")
params.append(f"%{concept_tag}%")
idx += 1
if linked is True:
conditions.append("linked_case_law_id IS NOT NULL")
elif linked is False:
conditions.append("linked_case_law_id IS NULL")
if search:
conditions.append(
f"(yomon_number ILIKE ${idx} OR concept_tag ILIKE ${idx} "
f"OR headline_holding ILIKE ${idx} OR underlying_citation ILIKE ${idx} "
f"OR summary ILIKE ${idx})"
)
params.append(f"%{search}%")
idx += 1
where_sql = (" WHERE " + " AND ".join(conditions)) if conditions else ""
params.extend([limit, offset])
sql = (
f"SELECT {_DIGEST_COLS} FROM digests{where_sql} "
f"ORDER BY digest_date DESC NULLS LAST, created_at DESC "
f"LIMIT ${idx} OFFSET ${idx + 1}"
)
rows = await pool.fetch(sql, *params)
return [_row_to_digest(r) for r in rows]
async def list_pending_digests(limit: int = 20) -> list[dict]:
"""Digests awaiting local LLM enrichment (web-upload queue, X12). The
drainer (digest_library.process_pending_digests) picks these up."""
pool = await get_pool()
rows = await pool.fetch(
f"SELECT {_DIGEST_COLS} FROM digests WHERE extraction_status = 'pending' "
f"ORDER BY created_at LIMIT $1",
limit,
)
return [_row_to_digest(r) for r in rows]
async def search_digests_semantic(
query_embedding: list[float],
practice_area: str = "",
subject_tag: str = "",
concept_tag: str = "",
limit: int = 10,
) -> list[dict]:
"""Pure-semantic search over the digests radar (X12). Single vector per row
(no chunks/halachot), so no RRF here — see X12 §6. Joins the linked ruling's
citation when present so the researcher sees the pointer target directly."""
pool = await get_pool()
conditions = ["d.embedding IS NOT NULL"]
params: list = [query_embedding, limit]
idx = 3
if practice_area:
conditions.append(f"d.practice_area = ${idx}")
params.append(practice_area)
idx += 1
if subject_tag:
conditions.append(f"${idx} = ANY(d.subject_tags)")
params.append(subject_tag)
idx += 1
if concept_tag:
conditions.append(f"d.concept_tag ILIKE ${idx}")
params.append(f"%{concept_tag}%")
idx += 1
sql = f"""
SELECT {', '.join('d.' + c for c in _DIGEST_COLS.split(', '))},
cl.case_number AS linked_case_number,
cl.case_name AS linked_case_name,
cl.searchable AS linked_searchable,
1 - (d.embedding <=> $1) AS score
FROM digests d
LEFT JOIN case_law cl ON cl.id = d.linked_case_law_id
WHERE {' AND '.join(conditions)}
ORDER BY d.embedding <=> $1
LIMIT $2
"""
rows = await pool.fetch(sql, *params)
out = []
for r in rows:
d = _row_to_digest(r)
d["linked_case_number"] = r["linked_case_number"]
d["linked_case_name"] = r["linked_case_name"]
d["linked_searchable"] = r["linked_searchable"]
d["score"] = float(r["score"])
d["type"] = "digest"
out.append(d)
return out
async def find_case_law_by_citation_fuzzy(citation: str) -> dict | None:
"""Best-effort match of a digest's underlying_citation to a case_law row,
for autolink (INV-DIG3). Tries: (1) exact case_number; (2) canonical docket
substring (e.g. '46111-12-22') contained in a case_law.case_number. Returns
the first match or None — never raises, never mutates."""
citation = (citation or "").strip()
if not citation:
return None
pool = await get_pool()
row = await pool.fetchrow(
"SELECT * FROM case_law WHERE case_number = $1 LIMIT 1",
citation,
)
if row:
return _row_to_case_law(row)
# Extract a docket-like token: digits with '-' or '/' separators, e.g.
# 46111-12-22 or 3975/22. Match it as a substring of case_number.
m = re.search(r"\d+[-/]\d+(?:[-/]\d+)?", citation)
if not m:
return None
docket = m.group(0)
row = await pool.fetchrow(
"SELECT * FROM case_law "
"WHERE case_number ILIKE $1 ORDER BY created_at LIMIT 1",
f"%{docket}%",
)
return _row_to_case_law(row) if row else None
async def store_precedent_chunks( async def store_precedent_chunks(
case_law_id: UUID, chunks: list[dict], case_law_id: UUID, chunks: list[dict],
) -> int: ) -> int:
@@ -4141,7 +3715,7 @@ async def store_halachot(case_law_id: UUID, halachot: list[dict]) -> int:
case_law_id, case_law_id,
i, i,
h["rule_statement"], h["rule_statement"],
h.get("rule_type", "interpretive"), h.get("rule_type", "binding"),
h.get("reasoning_summary", ""), h.get("reasoning_summary", ""),
h["supporting_quote"], h["supporting_quote"],
h.get("page_reference", ""), h.get("page_reference", ""),
@@ -4157,44 +3731,17 @@ async def store_halachot(case_law_id: UUID, halachot: list[dict]) -> int:
return len(halachot) return len(halachot)
async def reset_halacha_extraction(case_law_id: UUID) -> dict: async def reset_halacha_extraction(case_law_id: UUID) -> None:
"""Prepare a clean re-extraction WITHOUT destroying chair-approved work. """Force a clean re-extraction: wipe halachot + clear per-chunk checkpoints
so every chunk is re-processed (used by explicit re-extract, not resume)."""
Deletes only un-reviewed halachot (``review_status NOT IN ('approved',
'published')``) and clears per-chunk checkpoints so every chunk is
re-processed. Chair-approved / published halachot are PRESERVED — INV-G10:
a human approval is never silently deleted by a re-extraction. The
re-extractor's dedup-on-insert (:func:`store_halachot_for_chunk`) skips any
freshly extracted halacha that duplicates a preserved one, so approvals
survive without producing duplicates.
History: this once wiped ALL halachot first, then re-extracted — a crash
between the wipe and the first chunk's store lost every approval and left
the row stuck ``status='processing'`` with 0 rows (the 2026-06-08 amiel
incident, TaskMaster #108). Durable resume of the whole pipeline is X16/#114.
Returns ``{"deleted": N, "preserved": M}``.
"""
pool = await get_pool() pool = await get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
async with conn.transaction(): async with conn.transaction():
preserved = await conn.fetchval( await conn.execute("DELETE FROM halachot WHERE case_law_id = $1", case_law_id)
"SELECT COUNT(*) FROM halachot WHERE case_law_id = $1 "
"AND review_status IN ('approved', 'published')", case_law_id,
)
tag = await conn.execute(
"DELETE FROM halachot WHERE case_law_id = $1 "
"AND review_status NOT IN ('approved', 'published')", case_law_id,
)
await conn.execute( await conn.execute(
"UPDATE precedent_chunks SET halacha_extracted_at = NULL " "UPDATE precedent_chunks SET halacha_extracted_at = NULL "
"WHERE case_law_id = $1", case_law_id, "WHERE case_law_id = $1", case_law_id,
) )
try:
deleted = int(str(tag).split()[-1])
except (ValueError, IndexError):
deleted = 0
return {"deleted": deleted, "preserved": int(preserved or 0)}
async def mark_all_chunks_extracted(case_law_id: UUID) -> int: async def mark_all_chunks_extracted(case_law_id: UUID) -> int:
@@ -4309,7 +3856,7 @@ async def store_halachot_for_chunk(
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, {reviewed_at_clause})""", $12, $13, $14, $15, $16, {reviewed_at_clause})""",
case_law_id, base + inserted, h["rule_statement"], case_law_id, base + inserted, h["rule_statement"],
h.get("rule_type", "interpretive"), h.get("reasoning_summary", ""), h.get("rule_type", "binding"), h.get("reasoning_summary", ""),
h["supporting_quote"], h.get("page_reference", ""), h["supporting_quote"], h.get("page_reference", ""),
h.get("practice_areas", []), h.get("subject_tags", []), h.get("practice_areas", []), h.get("subject_tags", []),
h.get("cites", []), confidence, h.get("quote_verified", False), h.get("cites", []), confidence, h.get("quote_verified", False),
@@ -4415,8 +3962,6 @@ async def list_halachot(
d = dict(r) d = dict(r)
if d.get("decision_date") is not None: if d.get("decision_date") is not None:
d["decision_date"] = d["decision_date"].isoformat() d["decision_date"] = d["decision_date"].isoformat()
# authority is DERIVED from the source, never stored (INV-DM7)
d["authority"] = halacha_quality.derive_authority(d.get("precedent_level"))
out.append(d) out.append(d)
if cluster and out: if cluster and out:
await _annotate_clusters(pool, out) await _annotate_clusters(pool, out)
@@ -4839,7 +4384,7 @@ async def goldset_list(batch: str = "default") -> list[dict]:
" g.ai_is_holding, g.ai_correct_type, g.ai_rationale, g.ai_generated_at, " " g.ai_is_holding, g.ai_correct_type, g.ai_rationale, g.ai_generated_at, "
" h.rule_statement, h.supporting_quote, h.reasoning_summary, " " h.rule_statement, h.supporting_quote, h.reasoning_summary, "
" h.rule_type, h.confidence, h.quality_flags, h.review_status, " " h.rule_type, h.confidence, h.quality_flags, h.review_status, "
" cl.case_number, cl.case_name, cl.source_type, cl.precedent_level " " cl.case_number, cl.case_name, cl.source_type "
"FROM halacha_goldset g JOIN halachot h ON h.id = g.halacha_id " "FROM halacha_goldset g JOIN halachot h ON h.id = g.halacha_id "
"LEFT JOIN case_law cl ON cl.id = h.case_law_id " "LEFT JOIN case_law cl ON cl.id = h.case_law_id "
"WHERE g.batch = $1 ORDER BY g.created_at, g.id", batch, "WHERE g.batch = $1 ORDER BY g.created_at, g.id", batch,
@@ -4853,8 +4398,6 @@ async def goldset_list(batch: str = "default") -> list[dict]:
d["ai_generated_at"] = d["ai_generated_at"].isoformat() d["ai_generated_at"] = d["ai_generated_at"].isoformat()
if d.get("confidence") is not None: if d.get("confidence") is not None:
d["confidence"] = float(d["confidence"]) d["confidence"] = float(d["confidence"])
# authority is DERIVED from the source, never stored (INV-DM7)
d["authority"] = halacha_quality.derive_authority(d.get("precedent_level"))
out.append(d) out.append(d)
return out return out
@@ -4912,7 +4455,7 @@ async def goldset_score(batch: str = "default") -> dict:
for r in labeled: for r in labeled:
rule = r.get("rule_statement") or "" rule = r.get("rule_statement") or ""
quote = r.get("supporting_quote") or "" quote = r.get("supporting_quote") or ""
rtype = r.get("rule_type") or "interpretive" rtype = r.get("rule_type") or "binding"
qc = r["quote_complete"] if r["quote_complete"] is not None else True qc = r["quote_complete"] if r["quote_complete"] is not None else True
truly_bad = r["is_holding"] is False truly_bad = r["is_holding"] is False
flags = halacha_quality.compute_quality_flags(rule, quote, "", qc, rtype) flags = halacha_quality.compute_quality_flags(rule, quote, "", qc, rtype)
@@ -5110,8 +4653,6 @@ async def search_precedent_library_semantic(
_conf = float(d.get("confidence") or 0.0) _conf = float(d.get("confidence") or 0.0)
d["score"] = float(d["score"]) + max(_conf * 0.06, 0.0) d["score"] = float(d["score"]) + max(_conf * 0.06, 0.0)
d["type"] = "halacha" d["type"] = "halacha"
# authority is DERIVED from the source, never stored (INV-DM7)
d["authority"] = halacha_quality.derive_authority(d.get("precedent_level"))
results.append(d) results.append(d)
rows = await pool.fetch(chunk_sql, *c_params) rows = await pool.fetch(chunk_sql, *c_params)
@@ -5492,34 +5033,6 @@ async def list_pending_extraction_requests(
return out return out
async def requeue_stale_processing_extractions(kind: str = "halacha") -> int:
"""Re-stamp orphaned 'processing' rows so they re-drain. Returns count healed.
A drain that died mid-extraction can leave a row ``status='processing'`` with
its ``requested_at`` already cleared — orphaned: the queue selects on
``requested_at IS NOT NULL`` so it would never be picked again. We re-stamp
those (only when requested_at IS NULL, i.e. not an actively-processing row in
a concurrent run) so the next drain resumes them.
"""
status_col = (
"metadata_extraction_status" if kind == "metadata"
else "halacha_extraction_status"
)
req_col = (
"metadata_extraction_requested_at" if kind == "metadata"
else "halacha_extraction_requested_at"
)
pool = await get_pool()
tag = await pool.execute(
f"UPDATE case_law SET {req_col} = now(), {status_col} = 'pending' "
f"WHERE {status_col} = 'processing' AND {req_col} IS NULL"
)
try:
return int(str(tag).split()[-1])
except (ValueError, IndexError):
return 0
async def extraction_queue_status() -> dict: async def extraction_queue_status() -> dict:
"""Pending-extraction queue depth per kind (INV-TOOL4 visibility / GAP-45). """Pending-extraction queue depth per kind (INV-TOOL4 visibility / GAP-45).
@@ -6187,34 +5700,3 @@ async def court_fetch_job_list(status: str | None = None, limit: int = 100) -> l
limit, limit,
) )
return [_row_to_court_fetch_job(r) for r in rows] return [_row_to_court_fetch_job(r) for r in rows]
# ── Drain controls (/operations process-management panel) ──────────────────
async def is_drain_disabled(name: str) -> bool:
"""True if the named drain is switched off (drains check this at startup)."""
pool = await get_pool()
async with pool.acquire() as conn:
val = await conn.fetchval(
"SELECT disabled FROM drain_controls WHERE name = $1", name
)
return bool(val)
async def set_drain_disabled(name: str, disabled: bool) -> None:
"""Switch a drain on/off (upsert). name = pm2 process name."""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute(
"INSERT INTO drain_controls (name, disabled, updated_at) "
"VALUES ($1, $2, now()) "
"ON CONFLICT (name) DO UPDATE SET disabled = $2, updated_at = now()",
name, disabled,
)
async def get_drain_controls() -> dict[str, bool]:
"""Map of drain name → disabled flag (only rows that were ever toggled)."""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT name, disabled FROM drain_controls")
return {r["name"]: bool(r["disabled"]) for r in rows}

View File

@@ -1,422 +0,0 @@
"""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

@@ -1,151 +0,0 @@
"""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,7 +5,6 @@
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
@@ -18,7 +17,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, storage from legal_mcp.services import db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -475,19 +474,8 @@ 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)
Path(output_path).write_bytes(data) doc.save(output_path)
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,9 +14,6 @@ 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
@@ -307,16 +304,9 @@ 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 — through the storage layer (INV-STG1). # Backup if overwriting in place
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,9 +13,6 @@ 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
@@ -101,22 +98,6 @@ 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,
@@ -132,11 +113,12 @@ 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)
_persist_docx_sync(output_path, buffer.getvalue()) output_path.write_bytes(buffer.getvalue())
def _ensure_track_revisions(settings_tree: etree._Element) -> None: def _ensure_track_revisions(settings_tree: etree._Element) -> None:
@@ -529,11 +511,4 @@ 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)."""
out = Path(output_path) shutil.copy2(str(source_path), str(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,7 +23,6 @@ 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
@@ -346,18 +345,6 @@ 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

@@ -1,97 +0,0 @@
"""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,10 +6,8 @@ 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, force=True)`` All extraction is idempotent — calling ``extract(case_law_id)`` twice
twice drops the precedent's un-reviewed rows and re-extracts. Chair-approved / deletes prior rows for that precedent first.
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
@@ -78,12 +76,8 @@ 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 prompt branches on is_binding only to choose the EXTRACTION STRATEGY # The schema's rule_type field accepts six values:
# (what to pull, how to phrase) — NOT the rule_type. rule_type is the rule # binding | interpretive | procedural | obiter | application | persuasive
# 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 לחוק התכנון והבניה). תפקידך: לחלץ הלכות מחייבות מתוך פסק דין/החלטה משפטית של ערכאה עליונה (עליון / מנהלי).
@@ -107,12 +101,10 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
הלכה אחת יכולה לחול על כמה תחומים — practice_areas הוא array ולא string יחיד. הלכה אחת יכולה לחול על כמה תחומים — practice_areas הוא array ולא string יחיד.
## סוג הכלל (rule_type) — מהות הכלל בלבד, לא סמכות-המקור ## סוגי הלכה (rule_type)
**אל תסווג "מחייב/משכנע"** — דרגת-המחייבות נגזרת אוטומטית מזהות הערכאה. כאן בחר רק את **סוג הכלל**: - binding — הלכה מחייבת שהוחלה על התיק.
- holding — עיקרון מהותי שהיה הכרחי להכרעה (ה-ratio; מבחן Wambaugh: שלילתו הייתה משנה את התוצאה). - interpretive — פרשנות סעיף חוק/תכנית שאומצה.
- interpretive — פרשנות הוראת-חוק/מונח/תכנית שאומצה. - procedural — כלל פרוצדורלי (סמכות, מועדים, הליכי שמיעה).
- procedural — כלל סדר-דין (סמכות, מועדים, זכות-עמידה, מיצוי הליכים, נטל).
- application — החלת כלל על עובדות התיק (תלוי-עובדות; לרוב לא-הלכה בת-הכללה).
- obiter — אמרת אגב חשובה (חלץ רק אם משמעותית; סמן confidence נמוך). - obiter — אמרת אגב חשובה (חלץ רק אם משמעותית; סמן confidence נמוך).
## פלט נדרש ## פלט נדרש
@@ -120,7 +112,7 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
[ [
{ {
"rule_statement": "ניסוח הכלל בלשון משפטית מדויקת בגוף שלישי, 1-3 משפטים.", "rule_statement": "ניסוח הכלל בלשון משפטית מדויקת בגוף שלישי, 1-3 משפטים.",
"rule_type": "holding", "rule_type": "binding",
"reasoning_summary": "תמצית ההיגיון: למה בית המשפט הגיע לכלל הזה (1-2 משפטים).", "reasoning_summary": "תמצית ההיגיון: למה בית המשפט הגיע לכלל הזה (1-2 משפטים).",
"supporting_quote": "ציטוט מילולי מדויק מהפסק התומך בכלל. חייב להופיע מילה במילה בטקסט הקלט.", "supporting_quote": "ציטוט מילולי מדויק מהפסק התומך בכלל. חייב להופיע מילה במילה בטקסט הקלט.",
"page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות מהקלט.", "page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות מהקלט.",
@@ -147,11 +139,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=`holding`) — מסקנה עקרונית שלמה של הפנל בסוגיה, עם ההיגיון התומך, בת-הכללה ובת-הסתמכות. - **מסקנה מנומקת ומשכנעת** (rule_type=`persuasive`) — מסקנה שלמה של הפנל בסוגיה, עם ההיגיון התומך, ניתנת לציטוט כאסמכתא משכנעת.
**אין לחלץ:** **אין לחלץ:**
- ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים קונקרטיים) — חלץ את העיקרון/היישום בניסוח בר-הכללה בלבד. - ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים קונקרטיים) — חלץ את העיקרון/היישום בניסוח בר-הכללה בלבד.
@@ -183,7 +175,7 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
## כללי איכות ## כללי איכות
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה. 1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה.
2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אל תמתח את הרשימה. אם אין מה לחלץ — החזר []. 2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אל תמתח את הרשימה. אם אין מה לחלץ — החזר [].
3. **rule_type מדויק (סוג הכלל בלבד)** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. holding = מסקנה מהותית עקרונית. **לא** binding/persuasive (סמכות נגזרת אוטומטית). 3. **rule_type מדויק** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. persuasive = מסקנה כללית בעלת ערך כאסמכתא.
4. **לא לפצל יתר על המידה — קריטי** — כל פריט = שאלה משפטית מובחנת אחת. פנים שונים של אותה שאלה = פריט אחד (בחר את הניסוח הכללי ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים. 4. **לא לפצל יתר על המידה — קריטי** — כל פריט = שאלה משפטית מובחנת אחת. פנים שונים של אותה שאלה = פריט אחד (בחר את הניסוח הכללי ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים.
5. **שפה** — עברית משפטית מקצועית, גוף שלישי. 5. **שפה** — עברית משפטית מקצועית, גוף שלישי.
6. **subject_tags** — 2-5 תגיות בעברית, snake_case. 6. **subject_tags** — 2-5 תגיות בעברית, snake_case.
@@ -192,15 +184,10 @@ 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 = {
"holding", "interpretive", "procedural", "application", "obiter", "binding", "interpretive", "procedural", "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:
@@ -240,14 +227,13 @@ def _verify_quote(supporting_quote: str, full_text: str) -> bool:
return False return False
def _coerce_halacha(raw: dict) -> dict | None: def _coerce_halacha(raw: dict, is_binding: bool = True) -> 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. ``rule_type`` is Returns ``None`` if the entry is missing required fields. ``is_binding``
the rule ROLE only (INV-DM7) — it is NEVER defaulted from the source's only affects the default rule_type when the LLM returned an unknown
bindingness (that was the source-conflation this split removed). Legacy value — for binding sources we default to ``binding``, otherwise to
authority values fold to the nearest role; unknown defaults to ``persuasive`` (never pretend an appeals committee created halacha).
``interpretive`` (the most common role).
""" """
if not isinstance(raw, dict): if not isinstance(raw, dict):
return None return None
@@ -256,10 +242,13 @@ def _coerce_halacha(raw: dict) -> dict | None:
if not rule_statement or not supporting_quote: if not rule_statement or not supporting_quote:
return None return None
rule_type = (raw.get("rule_type") or "").strip().lower() default_rule_type = "binding" if is_binding else "persuasive"
rule_type = _LEGACY_RULE_TYPE_FOLD.get(rule_type, rule_type) rule_type = (raw.get("rule_type") or default_rule_type).strip().lower()
if rule_type not in _VALID_RULE_TYPES: if rule_type not in _VALID_RULE_TYPES:
rule_type = "interpretive" rule_type = default_rule_type
# 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):
@@ -532,20 +521,8 @@ 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:
reset = await db.reset_halacha_extraction(case_law_id) 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
@@ -603,7 +580,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) coerced = _coerce_halacha(raw, is_binding=is_binding)
if coerced is None: if coerced is None:
continue continue
coerced["quote_verified"] = _verify_quote( coerced["quote_verified"] = _verify_quote(
@@ -620,10 +597,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 holding-labeled rule that reads as a case-application is # #81.4 — a binding-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"] == "holding"): and coerced["rule_type"] == "binding"):
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).
@@ -700,6 +677,5 @@ 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,37 +18,6 @@ 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 = "\"'׳״‘’“”«»„′″"
@@ -368,7 +337,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 = "interpretive", rule_type: str = "binding",
) -> 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, storage from legal_mcp.services import chunker, db, embeddings, extractor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -66,20 +66,11 @@ 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]}"
async def _stage_file(src_path: Path, root: Path, subdir: str) -> Path: def _stage_file(src_path: Path, root: Path, subdir: str) -> Path:
"""Stage an intake file through the unified storage layer (INV-STG1). dest_dir = root / (subdir or "other")
dest_dir.mkdir(parents=True, exist_ok=True)
Returns the DATA_DIR path the rest of the pipeline reads from — under the dest = dest_dir / f"{uuid4().hex[:8]}_{_safe_filename(src_path.name)}"
filesystem/dual backends the bytes are on disk and the key is the shutil.copy2(src_path, dest)
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
@@ -160,7 +151,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 = await _stage_file(src, spec.staging_root, spec.staging_subdir(inputs)) staged = _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,7 +15,6 @@ 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
@@ -138,10 +137,6 @@ 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``.
@@ -161,10 +156,9 @@ 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): drop un-reviewed halachot + # Explicit re-extraction = clean slate (force): wipe prior halachot +
# clear per-chunk checkpoints and redo all, but PRESERVE chair-approved / # per-chunk checkpoints and redo all. (Queue draining / resume uses the
# published rows (INV-G10; dedup-on-insert avoids duplicating them). (Queue # default force=False so an interrupted run continues where it stopped.)
# 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)
@@ -185,9 +179,6 @@ 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.
@@ -221,16 +212,6 @@ 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": []}
@@ -245,14 +226,11 @@ 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(cooldown) await asyncio.sleep(INTER_PRECEDENT_COOLDOWN_SEC)
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 db, gemini_session from legal_mcp.services import claude_session, db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -150,10 +150,7 @@ async def extract_metadata(case_law_id: UUID | str) -> dict:
) )
try: try:
# Bounded structured extraction → Gemini Flash (JSON mode). The agentic result = await claude_session.query_json(
# 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,9 +8,7 @@ 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 ( from legal_mcp.services import chunker, db, embeddings, extractor, references_extractor
chunker, db, embeddings, extractor, references_extractor, storage,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -42,17 +40,13 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
page_count=page_count, page_count=page_count,
) )
# Save extracted text (a DERIVED artifact — the DB column holds the # Save extracted text to documents/extracted/ directory
# 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"])
txt_path = original_path.parent.parent / "extracted" / (original_path.stem + ".txt") extracted_dir = original_path.parent.parent / "extracted"
extracted_dir.mkdir(parents=True, exist_ok=True)
txt_path = extracted_dir / (original_path.stem + ".txt")
try: try:
await storage.put_bytes( txt_path.write_text(text, encoding="utf-8")
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

@@ -1,513 +0,0 @@
"""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

@@ -54,13 +54,3 @@ async def court_fetch_status(case_number: str = "", status_filter: str = "") ->
return _ok({"job": job}) return _ok({"job": job})
jobs = await db.court_fetch_job_list(status=status_filter.strip() or None) jobs = await db.court_fetch_job_list(status=status_filter.strip() or None)
return _ok({"jobs": jobs, "count": len(jobs)}) 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

@@ -1,172 +0,0 @@
"""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 mimetypes import shutil
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, storage from legal_mcp.services import audit, db, git_sync, processor
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,14 +50,11 @@ async def document_upload(
"idempotent_existing": True, "idempotent_existing": True,
}, message=f"הקובץ כבר הועלה לתיק {case_number} (זהה ב-hash) — מוחזר הקיים, ללא עיבוד מחדש.") }, message=f"הקובץ כבר הועלה לתיק {case_number} (זהה ב-hash) — מוחזר הקיים, ללא עיבוד מחדש.")
# Stage the original through the unified storage layer (INV-STG1). # Copy file to case directory
dest = config.find_case_dir(case_number) / "documents" / "originals" / source.name case_dir = config.find_case_dir(case_number) / "documents" / "originals"
await storage.put_file( case_dir.mkdir(parents=True, exist_ok=True)
source, dest.relative_to(config.DATA_DIR).as_posix(), dest = case_dir / source.name
bucket=storage.Bucket.DOCUMENTS, shutil.copy2(str(source), str(dest))
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"
@@ -159,14 +156,10 @@ 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():
await storage.put_file( shutil.copy2(str(source), str(dest))
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

@@ -78,14 +78,3 @@ def test_empty_and_garbage():
def test_normalize_case_number(): def test_normalize_case_number():
assert normalize_case_number('עת"מ 46111/12/22') == "46111-12-22" assert normalize_case_number('עת"מ 46111/12/22') == "46111-12-22"
assert normalize_case_number("1110/20") == "1110-20" 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

@@ -1,46 +0,0 @@
"""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,40 +211,23 @@ 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_holding(): def test_application_flag_from_deixis_even_if_binding():
flags = hq.compute_quality_flags( flags = hq.compute_quality_flags(
"במקרה דנן נדחה הערר", "כפי שקבענו במקרה דנן נדחה הערר", "במקרה דנן נדחה הערר", "כפי שקבענו במקרה דנן נדחה הערר",
rule_type="holding", rule_type="binding",
) )
assert hq.FLAG_APPLICATION in flags assert hq.FLAG_APPLICATION in flags
def test_clean_holding_rule_has_no_flags(): def test_clean_binding_rule_has_no_flags():
flags = hq.compute_quality_flags( flags = hq.compute_quality_flags(
"ועדת הערר מוסמכת לדון בטענות חוקתיות הנוגעות לתכנית", "ועדת הערר מוסמכת לדון בטענות חוקתיות הנוגעות לתכנית",
"הוועדה מוסמכת לדון אף בטענות מסוג זה, ככל שהן נוגעות לתכנית שבנדון.", "הוועדה מוסמכת לדון אף בטענות מסוג זה, ככל שהן נוגעות לתכנית שבנדון.",
rule_type="holding", rule_type="binding",
) )
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

@@ -1,115 +0,0 @@
"""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

@@ -1,80 +0,0 @@
"""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

@@ -1,54 +0,0 @@
"""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

@@ -1,198 +0,0 @@
"""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

@@ -1,77 +0,0 @@
"""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,8 +9,7 @@
| 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`+ספ-התחום ולוודא קיום 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) | | `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) |
| `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). | ידני אחרי כל שינוי |
@@ -20,14 +19,7 @@
| `fu2c_reconcile_external_case_numbers.py` | python | **FU-2c (GAP-08, #68) — תיאום `case_number` של פסיקה חיצונית** (`source_kind <> internal_committee`) מציטוט-מלא לצורה קנונית **מציין-הליך + docket** (החלטת-יו"ר 2026-05-31, Option A: `/` נשמר, *לא* `-`; תואם db.py:369 ו-INV-ID2). דטרמיניסטי (designator+docket; 0/>1 docket → flag). `--dry-run` (ברירת-מחדל) מפיק `data/audit/fu2c-reconciliation-*.{csv,md}` עם flags (MISMATCH / NO_CITATION / CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK). `--apply --approved <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) | | `fu2c_reconcile_external_case_numbers.py` | python | **FU-2c (GAP-08, #68) — תיאום `case_number` של פסיקה חיצונית** (`source_kind <> internal_committee`) מציטוט-מלא לצורה קנונית **מציין-הליך + docket** (החלטת-יו"ר 2026-05-31, Option A: `/` נשמר, *לא* `-`; תואם db.py:369 ו-INV-ID2). דטרמיניסטי (designator+docket; 0/>1 docket → flag). `--dry-run` (ברירת-מחדל) מפיק `data/audit/fu2c-reconciliation-*.{csv,md}` עם flags (MISMATCH / NO_CITATION / CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK). `--apply --approved <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) |
| `eval_gold_bootstrap.py` | python | **FU-5 (GAP-11) — bootstrap ל-gold-set** של הערכת-אחזור ל-`data/eval/gold-set.jsonl`. שני מקורות: `--source citations` (cited==relevant מ-`search_relevance_feedback`; ריק עד שייצברו ציטוטים) ו-`--source known_item` (query=שם-תיק → relevant=עצמו; אות אמיתי היום). Idempotent — שומר שורות `source=chair`, מחדש `bootstrap_*`. דורש POSTGRES. | לפני eval; חוזר כשנצבר ground-truth | | `eval_gold_bootstrap.py` | python | **FU-5 (GAP-11) — bootstrap ל-gold-set** של הערכת-אחזור ל-`data/eval/gold-set.jsonl`. שני מקורות: `--source citations` (cited==relevant מ-`search_relevance_feedback`; ריק עד שייצברו ציטוטים) ו-`--source known_item` (query=שם-תיק → relevant=עצמו; אות אמיתי היום). Idempotent — שומר שורות `source=chair`, מחדש `bootstrap_*`. דורש POSTGRES. | לפני eval; חוזר כשנצבר ground-truth |
| `eval_retrieval.py` | python | **FU-5 (GAP-11, INV-RET4/G8) — harness הערכת-אחזור** — מריץ את מסלול-האחזור בייצור (`search_library`/`search_internal`) על ה-gold-set, מחשב precision@k/recall@k/MRR/nDCG@k (k=5,10), מצרף overall+per-corpus+per-PA ל-`data/eval/eval-report-<ts>.{json,md}` + delta מול `data/eval/baseline.json` (מתעד retrieval_config). `--self-test` בודק את המטריקות offline; `--update-baseline` מאמץ snapshot. **שער-CI במשמעת:** הרץ לפני/אחרי כל שינוי בשכבת-האחזור באותו קונפיג. דורש POSTGRES+VOYAGE_API_KEY. | לפני/אחרי שינוי RRF/k/embedder/rerank | | `eval_retrieval.py` | python | **FU-5 (GAP-11, INV-RET4/G8) — harness הערכת-אחזור** — מריץ את מסלול-האחזור בייצור (`search_library`/`search_internal`) על ה-gold-set, מחשב precision@k/recall@k/MRR/nDCG@k (k=5,10), מצרף overall+per-corpus+per-PA ל-`data/eval/eval-report-<ts>.{json,md}` + delta מול `data/eval/baseline.json` (מתעד retrieval_config). `--self-test` בודק את המטריקות offline; `--update-baseline` מאמץ snapshot. **שער-CI במשמעת:** הרץ לפני/אחרי כל שינוי בשכבת-האחזור באותו קונפיג. דורש POSTGRES+VOYAGE_API_KEY. | לפני/אחרי שינוי RRF/k/embedder/rerank |
| `legal-court-fetch-service.config.cjs` | pm2/js | **שירות-מארח Tier-1 לאחזור פסקי-דין מנט המשפט (X13)**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) | | `legal-court-fetch-service.config.cjs` | pm2/js | **שירות-מארח Tier-1 לאחזור פסקי-דין מנט המשפט (X13)**מריץ `python -m legal_mcp.court_fetch_service.server` ב-pm2, bound ל-`10.0.1.1:8771`, Bearer-auth (`COURT_FETCH_SHARED_SECRET` מ-`~/.legal-court-fetch-service.env`). מריץ דפדפן Camoufox (open-source) כי הקונטיינר לא יכול. תלות לאחזור-בפועל: `camofox-browser` רץ (`CAMOFOX_URL`) + `faster-whisper` ל-reCAPTCHA אודיו; אחרת מחזיר ok:false וה-orchestrator מסלים ל-fallback אנושי. מראָה לדפוס `legal-chat-service.config.cjs`. ספ: `docs/spec/X13-court-fetch.md`. התקנה: `pm2 start scripts/legal-court-fetch-service.config.cjs && pm2 save`. בריאות: `curl http://10.0.1.1:8771/health`. | pm2 (host-side) |
| `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) | ידני |
@@ -49,16 +41,6 @@
| `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. **הערה:** קיים גם דף-תיוג אינטראקטיבי DB-backed (`/goldset`) — זה ה-CSV-fallback | ידני — 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 |
| `goldset_ai_recommend.py` | python | **#81.7 QA** — מייצר **חוות-דעת-AI שנייה** (claude מקומי, אפס עלות) לכל פריט ב-`halacha_goldset`: `is_holding`+`type`+נימוק, נשמר ב-`ai_*` ומוצג בדף לצד התיוג האנושי לזיהוי אי-הסכמות. **עצמאי** מהוולידטורים שנמדדים (אין מעגליות) ו**לא** מוחל אוטומטית. `--force` (חידוש)/`--limit N`. **חובה מקומי** (claude_session). | ידני — לאחר יצירת/הרחבת batch | | `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` לקישור | | `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) |
@@ -101,11 +83,7 @@
| `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 סבבים ריקים). חילוץ-הלכות נשאר על claude_session (לא Gemini). self-heal ל-orphaned `processing`. ההלכות נוחתות `pending_review` (שער-יו"ר). | דרך `legal-halacha-drain.config.cjs` (pm2 cron) / ידני | | `drain_halacha_queue.py` | python | ריקון תור חילוץ ההלכות (`process_pending_extractions kind='halacha'`) ב-batches של 4 עד שהתור ריק (2 סבבים ריקים). משמש אחרי `ingest_incoming_batch.py`. | ידני אחרי batch (חלופה ל-MCP `precedent_process_pending`) |
| `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

@@ -1,130 +0,0 @@
"""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

@@ -1,56 +0,0 @@
"""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

@@ -1,107 +0,0 @@
#!/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

@@ -1,49 +0,0 @@
"""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()))

View File

@@ -1,118 +0,0 @@
"""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

@@ -1,47 +0,0 @@
"""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

@@ -1,54 +0,0 @@
"""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

@@ -1,168 +0,0 @@
#!/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

@@ -1,179 +0,0 @@
#!/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

@@ -25,21 +25,20 @@ from uuid import UUID
from legal_mcp.services import claude_session, db from legal_mcp.services import claude_session, db
VALID_TYPES = {"holding", "interpretive", "procedural", "application", "obiter"} VALID_TYPES = {"binding", "interpretive", "obiter", "application", "procedural", "persuasive"}
SYSTEM = ( SYSTEM = (
"אתה בוחן-איכות משפטי המסווג 'הלכות' שחולצו מהחלטות ועדת-ערר ומפסקי-דין. " "אתה בוחן-איכות משפטי המסווג 'הלכות' שחולצו מהחלטות ועדת-ערר ומפסקי-דין. "
"לכל פריט הכרע שתי שאלות, באופן עצמאי ולפי המהות:\n" "לכל פריט הכרע שתי שאלות, באופן עצמאי ולפי המהות:\n"
"1) is_holding — האם זו הלכה אמיתית בת-הכללה ובת-הסתמכות (true), או שזו יישום " "1) is_holding — האם זו הלכה אמיתית בת-הכללה ובת-הסתמכות (true), או שזו יישום "
"תלוי-עובדות / אמרת-אגב / ציטוט-עובדה ולא כלל בר-הכללה (false).\n" "תלוי-עובדות / אמרת-אגב / ציטוט-עובדה ולא כלל בר-הכללה (false).\n"
"2) type — **סוג הכלל בלבד** (אל תסווג מחייב/משכנע — דרגת-המחייבות נגזרת אוטומטית " "2) type — הסוג הנכון: 'binding' (עיקרון הכרחי להכרעה), 'interpretive' (פרשנות "
"מזהות הערכאה): 'holding' (עיקרון מהותי שהיה הכרחי להכרעה — ratio), 'interpretive' " "חוק/מונח/תכנית), 'procedural' (סדר-דין: מועדים/סמכות/מיצוי/נטל), 'persuasive' "
"(פרשנות חוק/מונח/תכנית), 'procedural' (סדר-דין: מועדים/סמכות/מיצוי/נטל), " "(אסמכתה לא-מחייבת), 'application' (החלה על עובדות התיק — לרוב לא-הלכה), "
"'application' (החלה על עובדות התיק — לרוב לא-הלכה), 'obiter' (אמרת-אגב שלא " "'obiter' (אמרת-אגב שלא הוכרעה — לא-הלכה).\n"
"הוכרעה — לא-הלכה).\n" "עקביות: is_holding=true → binding/interpretive/procedural/persuasive; "
"עקביות: is_holding=true → holding/interpretive/procedural; "
"is_holding=false → application/obiter.\n" "is_holding=false → application/obiter.\n"
'החזר JSON בלבד: {"is_holding": true/false, "type": "<אחד מהחמישה>", ' 'החזר JSON בלבד: {"is_holding": true/false, "type": "<אחד מהשישה>", '
'"rationale": "<משפט אחד קצר בעברית>"}. ללא markdown.' '"rationale": "<משפט אחד קצר בעברית>"}. ללא markdown.'
) )

View File

@@ -1,166 +0,0 @@
#!/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

@@ -1,340 +0,0 @@
#!/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

@@ -1,93 +0,0 @@
#!/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

@@ -1,117 +0,0 @@
#!/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

@@ -1,151 +0,0 @@
#!/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

@@ -1,56 +0,0 @@
"""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

@@ -1,138 +0,0 @@
"""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:]))

View File

@@ -1,172 +0,0 @@
#!/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,8 +37,7 @@ 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.
// SoT in Infisical: nautilus:/legal-ai/LEGAL_CHAT_SHARED_SECRET (migrated // Migrate to Infisical (/_GUIDELINES) once the MCP server is back.
// 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

@@ -1,40 +0,0 @@
/**
* 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

@@ -29,16 +29,12 @@
*/ */
const fs = require("fs"); 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_FILE = "/home/chaim/.legal-court-fetch-service.env";
const env = { const env = {
HOME: "/home/chaim", HOME: "/home/chaim",
PATH: "/home/chaim/.local/bin:/usr/local/bin:/usr/bin:/bin", PATH: "/home/chaim/.local/bin:/usr/local/bin:/usr/bin:/bin",
PYTHONUNBUFFERED: "1", PYTHONUNBUFFERED: "1",
// Camoufox (headless Firefox) crashes on this server without a virtual // CAMOFOX_URL: "http://127.0.0.1:9377", // set when camofox-browser is up
// display, so the service points at the Xvfb companion app below (:99).
DISPLAY: ":99",
}; };
try { try {
const text = fs.readFileSync(ENV_FILE, "utf8"); const text = fs.readFileSync(ENV_FILE, "utf8");
@@ -54,16 +50,6 @@ try {
module.exports = { module.exports = {
apps: [ 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", name: "legal-court-fetch-service",
cwd: "/home/chaim/legal-ai/mcp-server", cwd: "/home/chaim/legal-ai/mcp-server",
@@ -73,9 +59,7 @@ module.exports = {
restart_delay: 5000, restart_delay: 5000,
max_restarts: 10, max_restarts: 10,
autorestart: true, autorestart: true,
// A Firefox content process loading the heavy ASP.NET pages can spike; max_memory_restart: "1G",
// give headroom but cap so a leak can't threaten Postgres.
max_memory_restart: "1500M",
}, },
], ],
}; };

View File

@@ -1,37 +0,0 @@
/**
* 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

@@ -1,39 +0,0 @@
/**
* 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

@@ -1,34 +0,0 @@
/**
* 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

@@ -1,35 +0,0 @@
/**
* 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

@@ -1,119 +0,0 @@
#!/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())

View File

@@ -35,39 +35,19 @@ case "$file_path" in
*) exit 0 ;; *) exit 0 ;;
esac esac
# ── G12 leak-guard (INV-G12 / docs/spec/X15) — warn at write-time ────────────── # Dedup לכל session — מזכיר פעם אחת בלבד
# PreToolUse fires BEFORE the edit, so we inspect the CONTENT being written
# (new_string / content), not the file on disk — this catches a Paperclip symbol
# being introduced into the intelligence layer right now. NOT session-deduped:
# every such write should warn. The hard gate is the CI fitness-test
# (mcp-server/tests/test_platform_port_leak_guard.py via scripts/leak_guard.py).
leak_warn=""
case "$file_path" in
*/mcp-server/src/*)
new_content="$(printf '%s' "$input" | jq -r '.tool_input.new_string // .tool_input.content // empty' 2>/dev/null || true)"
if printf '%s' "$new_content" | grep -qE 'paperclip|Paperclip|PAPERCLIP|wakeup|heartbeat|HEARTBEAT|pc_request|pc\.sh|X-Paperclip|agent_wakeup|heartbeat_run|ctx\.agents|issueId'; then
leak_warn="⚠️ G12 (שער-הפלטפורמה) — התוכן שאתה כותב ל-${file_path} (שכבת-האינטליגנציה) מכיל מונח ספציפי-Paperclip. אסור (INV-G12): נתב מגע-פלטפורמה דרך web/agent_platform_port.py. אם זו הערת-מקור בלבד — הוסף רשומה מנומקת ל-allowlist ב-scripts/leak_guard.py, אחרת ה-CI (test_platform_port_leak_guard) ייכשל. ראה docs/spec/X15-agent-platform-port.md.
"
fi
;;
esac
# Dedup לכל session — תזכורת-הספ מופיעה פעם אחת בלבד (אזהרת-leak אינה deduped)
session_id="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null || echo nosession)" session_id="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null || echo nosession)"
marker="${TMPDIR:-/tmp}/.spec-guard-${session_id}" marker="${TMPDIR:-/tmp}/.spec-guard-${session_id}"
spec_ctx="" [ -f "$marker" ] && exit 0
if [ ! -f "$marker" ]; then
: > "$marker" 2>/dev/null || true : > "$marker" 2>/dev/null || true
spec_ctx="פרוטוקול כתיבת-קוד (CLAUDE.md §פרוטוקול כתיבת-קוד) — נוגעים בקובץ-קוד: ${file_path}
ctx="פרוטוקול כתיבת-קוד (CLAUDE.md §פרוטוקול כתיבת-קוד) — נוגעים בקובץ-קוד: ${file_path}
לפני השינוי ודא: לפני השינוי ודא:
• קראת את docs/spec/00-constitution.md (ייעוד, G1G12, אינדקס §7) + ספ-התחום הרלוונטי. • קראת את docs/spec/00-constitution.md (ייעוד, G1G11, אינדקס §7) + ספ-התחום הרלוונטי.
• השינוי מקיים את ה-invariants: אין מסלול מקביל ליכולת קיימת (G2), נרמול-במקור ולא תיקון-בקריאה (G1), שער-הפלטפורמה (G12 — Paperclip רק דרך agent_platform_port.py), אין בליעה שקטה של שגיאות (§6). • השינוי מקיים את ה-invariants: אין מסלול מקביל ליכולת קיימת (G2), נרמול-במקור ולא תיקון-בקריאה (G1), אין בליעה שקטה של שגיאות (§6).
• בדקת מול docs/spec/gap-audit.md אם נוגעים ב-GAP/FU שכבר ממופה — להתאים, לא לפתור מחדש. • בדקת מול docs/spec/gap-audit.md אם נוגעים ב-GAP/FU שכבר ממופה — להתאים, לא לפתור מחדש.
• ה-PR יצהיר אילו invariants (G*/INV-*) נגעת בהם / מקיים (ראה .gitea/PULL_REQUEST_TEMPLATE.md). • ה-PR יצהיר אילו invariants (G*/INV-*) נגעת בהם / מקיים (ראה .gitea/PULL_REQUEST_TEMPLATE.md).
(תזכורת זו מופיעה פעם אחת בסשן.)" (תזכורת זו מופיעה פעם אחת בסשן.)"
fi
ctx="${leak_warn}${spec_ctx}"
[ -z "$ctx" ] && exit 0
jq -n --arg ctx "$ctx" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}' jq -n --arg ctx "$ctx" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}'
exit 0 exit 0

View File

@@ -1,354 +0,0 @@
#!/usr/bin/env python3
"""Two-judge panel to vet distilled STYLE lessons — "double learning" (DeepSeek + Gemini).
The voice-learning loop (ingest_final_version → learning_loop.analyze_changes) uses
Opus to distill, from a draft→final diff, lessons on HOW דפנה writes — stored as a
PROPOSAL in draft_final_pairs.analysis.changes[]. Per INV-LRN1/G10 those are NOT
auto-committed to the writer-consumed knowledge (SKILL.md / legal-decision-lessons.md).
This panel adds a SECOND independent layer on top of Opus's distillation — two judges
of different lineage vote per lesson on the coarse, reliable question: "is this an
ABSTRACT, generalizable STYLE/METHOD lesson (INV-LRN5 — voice, not legal substance)?"
- deepseek (api.deepseek.com) [DeepSeek — same family as the Hermes curator]
- gemini (gemini-2.5-flash) [Google — #1 on LegalBench]
(No Claude judge here: Opus already produced the proposal; the panel's job is an
independent cross-check, so we use the two NON-Opus lineages = "double learning".)
Agreement policy (mirrors halacha_panel_approve.py, reversible + CSV-backed):
- 2/2 keep → create a decision_lesson row (source='panel:deepseek+gemini')
- 2/2 drop → recorded as panel-rejected, NOT written
- split / incomplete / substance → ESCALATE to chair (printed + in the JSON report)
Distilled lessons tagged domain='substance' are skipped outright (INV-LRN5 — legal
substance must never enter the voice knowledge layer).
Final fold into SKILL.md / legal-decision-lessons.md stays a MANUAL chair gate (G10);
this panel only creates decision_lesson *proposals* attached to the case's style_corpus row.
Local-only (DeepSeek/Gemini keys live on the host, like the halacha panel).
cd ~/legal-ai/mcp-server
.venv/bin/python ../scripts/style_lesson_panel.py --case 8126-03-25 # dry-run
.venv/bin/python ../scripts/style_lesson_panel.py --case 8126-03-25 --apply # write proposals
.venv/bin/python ../scripts/style_lesson_panel.py --pair-id <uuid> --apply
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import json
import os
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID
import httpx
from legal_mcp.services import db
# ── keys (local files, same pattern as halacha_panel_approve.py) ──
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")
GEMINI_KEY = _env_key("GOOGLE_GEMINI_API_KEY", "~/.env") or _env_key("GEMINI_API_KEY", "~/.env")
# ── the coarse question (the reliable axis — generalizable style, not substance) ──
KEEP_SYSTEM = (
"אתה עורך-לשון משפטי בכיר המנתח את סגנון-הכתיבה של יו\"ר ועדת ערר. בהינתן לקח-סגנון "
"שחולץ מהשוואת טיוטה לגרסה הסופית, הכרע אם הוא ראוי להישמר כהנחיית-סגנון בת-הכללה "
"לתיקים עתידיים. ראוי (keep=true) = תובנה מופשטת על קול/טון/מבנה/מקצב/ביטויי-מעבר/ניסוח "
"שניתן להחילה על תיקים אחרים. לא-ראוי (keep=false) = מהות משפטית ספציפית (הלכה, עובדה, "
"הכרעה תלוית-תיק), פרט חד-פעמי, או חזרה על תוכן ההחלטה ללא הפשטה סגנונית. "
'החזר JSON בלבד: {"keep": true/false, "reason": "<משפט קצר>"}. ללא markdown.'
)
def _keep_user(change: dict) -> str:
desc = change.get("description") or ""
lesson = change.get("lesson") or ""
block = change.get("block_id") or ""
ctype = change.get("type") or ""
return (
f"סוג השינוי: {ctype}\n"
f"בלוק: {block}\n\n"
f"תיאור השינוי:\n{desc}\n\n"
f"הלקח שהוצע:\n{lesson}"
)
def _lesson_text(change: dict) -> str:
"""The text we would persist as a decision_lesson — prefer the distilled lesson."""
return (change.get("lesson") or change.get("description") or "").strip()
def _category(change: dict) -> str:
"""Map a distilled change to a decision_lessons.category (style/structure/lexicon/...)."""
t = (change.get("type") or "").lower()
block = (change.get("block_id") or "").lower()
if "transition" in t or "ביטוי" in t or "מעבר" in t:
return "lexicon"
if "structure" in t or "מבנה" in t or "order" in t:
return "structure"
if "table" in t or "טבל" in t:
return "tabular"
return "style"
# ── two judges, one signature: (system, user) -> dict|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": 160,
"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 the two judges; return per-judge bools + the verdict."""
ds, gm = await asyncio.gather(
judge_deepseek(client, system, user),
judge_gemini(client, system, user),
)
votes = {"deepseek": _bool(ds, key), "gemini": _bool(gm, key)}
valid = [v for v in votes.values() if v is not None]
agree_yes = len(valid) == 2 and all(valid)
agree_no = len(valid) == 2 and not any(valid)
votes["_verdict"] = ("agree_yes" if agree_yes else
"agree_no" if agree_no else
"split" if len(valid) == 2 else "incomplete")
return votes
# ── inputs: pair (analysis.changes) + the case's style_corpus row ──
def _as_dict(v):
"""analysis/diff_stats come back from asyncpg jsonb as str — normalize to dict."""
if isinstance(v, str):
try:
return json.loads(v)
except Exception:
return {}
return v or {}
async def _resolve_corpus_id(decision_number: str) -> str | None:
"""The case's final must be in style_corpus for lessons to attach (FK)."""
pool = await db.get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id FROM style_corpus WHERE decision_number = $1 "
"ORDER BY created_at DESC LIMIT 1",
decision_number,
)
return str(row["id"]) if row else None
def _norm(text: str) -> str:
"""Normalize a lesson for dedup — collapse whitespace, strip."""
return " ".join((text or "").split())
async def _existing_lesson_texts(corpus_id: str) -> set[str]:
"""Normalized lesson_texts already attached to this corpus (any source) —
so re-running --apply is idempotent and never duplicates a lesson."""
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT lesson_text FROM decision_lessons WHERE style_corpus_id = $1",
UUID(corpus_id),
)
return {_norm(r["lesson_text"]) for r in rows}
async def _load_pair(args) -> dict | None:
if args.pair_id:
return await db.get_draft_final_pair(UUID(args.pair_id))
# by case → latest analyzed pair
pairs = await db.list_draft_final_pairs(limit=500)
matches = [p for p in pairs if p.get("case_number") == args.case]
if not matches:
return None
# list_draft_final_pairs has no analysis; re-fetch the full row
return await db.get_draft_final_pair(matches[0]["id"])
async def main(args: argparse.Namespace) -> int:
print(f"judges available — deepseek:{bool(DEEPSEEK_KEY)} gemini:{bool(GEMINI_KEY)}\n",
flush=True)
if not (DEEPSEEK_KEY and GEMINI_KEY):
print("⚠ both DeepSeek and Gemini keys are required for the 2/2 panel.", flush=True)
pair = await _load_pair(args)
if not pair:
print(f"no draft_final_pair found for {args.pair_id or args.case}", flush=True)
return 1
if pair.get("status") != "analyzed" or not pair.get("analysis"):
print(f"pair {pair['id']} not analyzed yet (status={pair.get('status')}). "
f"Run ingest_final_version first.", flush=True)
return 1
analysis = _as_dict(pair["analysis"])
changes = analysis.get("changes") or []
case_number = pair.get("case_number") or args.case or ""
if args.limit:
changes = changes[: args.limit]
# INV-LRN5: substance never enters the voice layer.
substance = [c for c in changes if (c.get("domain") or "").lower() == "substance"]
style_changes = [c for c in changes if (c.get("domain") or "").lower() != "substance"]
print(f"pair {pair['id']} ({case_number}): {len(changes)} changes "
f"{len(style_changes)} style / {len(substance)} substance(skipped)\n", flush=True)
sem = asyncio.Semaphore(args.concurrency)
results: list[dict] = []
async with httpx.AsyncClient() as client:
async def run(ch):
async with sem:
v = await panel_vote(client, KEEP_SYSTEM, _keep_user(ch), "keep")
v["_change"] = ch
results.append(v)
tasks = [run(c) for c in style_changes]
for i in range(0, len(tasks), args.concurrency):
await asyncio.gather(*tasks[i : i + args.concurrency])
print(f"{len(results)}/{len(tasks)} judged", flush=True)
cc = Counter(r["_verdict"] for r in results)
print("\n" + "=" * 60)
print(f"STYLE-LESSON PANEL {'(APPLY)' if args.apply else '(DRY-RUN — no DB writes)'}")
print("=" * 60)
print(f" ✓ keep (2/2): {cc['agree_yes']}")
print(f" ✗ drop (2/2): {cc['agree_no']}")
print(f" → CHAIR (split): {cc['split']}")
print(f" ? incomplete: {cc['incomplete']}")
print(f" ⊘ substance skipped: {len(substance)}")
report = [{"verdict": r["_verdict"], "deepseek": r["deepseek"], "gemini": r["gemini"],
"category": _category(r["_change"]), "lesson": _lesson_text(r["_change"])[:200]}
for r in results]
Path("/tmp/style_lesson_panel.json").write_text(
json.dumps(report, ensure_ascii=False, indent=1))
print("\nper-lesson verdicts → /tmp/style_lesson_panel.json")
if not args.apply:
print("\n(dry-run — pass --apply to write keep-lessons as decision_lesson proposals)")
return 0
# ── apply: write 2/2-keep as decision_lesson proposals (reversible) ──
corpus_id = await _resolve_corpus_id(case_number)
if not corpus_id:
print(f"\n✗ no style_corpus row for decision_number={case_number!r}; cannot attach "
f"lessons. Add the final to the style corpus first (document_upload_training).")
return 1
keeps = [r for r in results if r["_verdict"] == "agree_yes" and _lesson_text(r["_change"])]
# Idempotency / dedup — skip keeps already attached to the corpus (any source),
# and collapse duplicates WITHIN this run. Re-running --apply writes nothing new.
existing = await _existing_lesson_texts(corpus_id)
fresh, seen = [], set(existing)
for r in keeps:
n = _norm(_lesson_text(r["_change"]))
if n in seen:
continue
seen.add(n)
fresh.append(r)
skipped_dup = len(keeps) - len(fresh)
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"style-panel-apply-{case_number}-{ts}.csv"
with backup.open("w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["corpus_id", "category", "source", "lesson_text"])
for r in fresh:
w.writerow([corpus_id, _category(r["_change"]), "panel:deepseek+gemini",
_lesson_text(r["_change"])])
written = 0
for r in fresh:
await db.add_decision_lesson(
UUID(corpus_id),
lesson_text=_lesson_text(r["_change"]),
category=_category(r["_change"]),
source="panel:deepseek+gemini",
created_by="panel",
)
written += 1
chair = cc["split"] + cc["incomplete"]
print(f"\nAPPLIED (reversible): wrote {written} decision_lesson proposals "
f"(source=panel:deepseek+gemini) · {skipped_dup} כפילויות דולגו · "
f"{chair} escalated to chair · {len(substance)} substance skipped")
print(f"backup → {backup}")
print("NB: fold into SKILL.md / legal-decision-lessons.md stays a manual chair gate (INV-G10).")
return 0
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--case", help="case_number — uses its latest analyzed draft_final_pair")
g.add_argument("--pair-id", dest="pair_id", help="explicit draft_final_pairs.id")
ap.add_argument("--apply", action="store_true", help="write keep-lessons (default: dry-run)")
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--concurrency", type=int, default=4)
raise SystemExit(asyncio.run(main(ap.parse_args())))

View File

@@ -1,53 +1,20 @@
#!/bin/sh #!/bin/sh
# Start FastAPI backend (:8000) + Next.js frontend (:3000) in one container, # Start FastAPI backend + Next.js frontend in the same container.
# each under a respawn supervisor. # Both processes log to stdout/stderr so Docker captures everything.
#
# Why a supervisor: a transient failure of either process — e.g. Postgres
# (:5433) not yet reachable in the seconds after a host reboot — must self-heal
# via capped-backoff restart, instead of leaving the container half-alive
# (Next.js up, FastAPI dead → /api/health returns 503, which is exactly the
# outage that followed the kernel update + reboot on 2026-06-10). Both processes
# log to stdout/stderr so Docker captures everything.
set -u set -e
# Run "$@" forever, restarting it when it exits. Backoff doubles on a fast crash echo "[start.sh] Starting FastAPI backend on :8000 ..."
# (cap 30s) and resets once the process has stayed up for >=30s, so a flapping uvicorn web.app:app --host 127.0.0.1 --port 8000 --workers 1 2>&1 &
# dependency cannot spin the CPU while a genuinely-recovered process restarts UVICORN_PID=$!
# promptly.
supervise() { # Give uvicorn a moment to start (or crash)
name="$1" sleep 2
shift
backoff=1 if ! kill -0 $UVICORN_PID 2>/dev/null; then
while true; do echo "[start.sh] ERROR: uvicorn failed to start!"
echo "[start.sh] starting ${name} ..." # Don't exit — let Node.js run so the UI is accessible for debugging
start=$(date +%s)
"$@"
code=$?
if [ $(( $(date +%s) - start )) -ge 30 ]; then
backoff=1
fi fi
echo "[start.sh] ${name} exited (code=${code}); restarting in ${backoff}s ..."
sleep "$backoff"
backoff=$(( backoff * 2 ))
[ "$backoff" -gt 30 ] && backoff=30
done
}
supervise "FastAPI (uvicorn :8000)" \ echo "[start.sh] Starting Next.js frontend on :3000 ..."
uvicorn web.app:app --host 127.0.0.1 --port 8000 --workers 1 & node server.js
API_PID=$!
supervise "Next.js (node :3000)" \
node server.js &
WEB_PID=$!
# Clean shutdown on redeploy/stop: forward the signal to both supervisors and
# let Docker tear the container down (children are reaped with the pid namespace).
trap 'echo "[start.sh] terminating ..."; kill "$API_PID" "$WEB_PID" 2>/dev/null; exit 0' TERM INT
# Both supervisors loop forever; returning from wait means one died unexpectedly,
# so exit non-zero and let Docker restart the whole container.
wait "$API_PID" "$WEB_PID"
echo "[start.sh] a supervisor exited unexpectedly; stopping container"
exit 1

387
web-ui/package-lock.json generated
View File

@@ -20,7 +20,6 @@
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-dropzone": "^15.0.0", "react-dropzone": "^15.0.0",
"react-force-graph-2d": "^1.29.1",
"react-hook-form": "^7.72.1", "react-hook-form": "^7.72.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
@@ -3904,12 +3903,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/@tweenjs/tween.js": {
"version": "25.0.0",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -4613,15 +4606,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/accessor-fn": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
"integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -5038,16 +5022,6 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bezier-js": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz",
"integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -5229,18 +5203,6 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/canvas-color-tracker": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz",
"integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==",
"license": "MIT",
"dependencies": {
"tinycolor2": "^1.6.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/ccount": { "node_modules/ccount": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
@@ -5646,222 +5608,6 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-binarytree": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force-3d": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
"integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
"license": "MIT",
"dependencies": {
"d3-binarytree": "1",
"d3-dispatch": "1 - 3",
"d3-octree": "1",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-octree": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
"license": "MIT"
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -7203,20 +6949,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/float-tooltip": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz",
"integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==",
"license": "MIT",
"dependencies": {
"d3-selection": "2 - 3",
"kapsule": "^1.16",
"preact": "10"
},
"engines": {
"node": ">=12"
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -7233,32 +6965,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/force-graph": {
"version": "1.51.4",
"resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz",
"integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==",
"license": "MIT",
"dependencies": {
"@tweenjs/tween.js": "18 - 25",
"accessor-fn": "1",
"bezier-js": "3 - 6",
"canvas-color-tracker": "^1.3",
"d3-array": "1 - 3",
"d3-drag": "2 - 3",
"d3-force-3d": "2 - 3",
"d3-scale": "1 - 4",
"d3-scale-chromatic": "1 - 3",
"d3-selection": "2 - 3",
"d3-zoom": "2 - 3",
"float-tooltip": "^1.7",
"index-array-by": "1",
"kapsule": "^1.16",
"lodash-es": "4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/formdata-polyfill": { "node_modules/formdata-polyfill": {
"version": "4.0.10", "version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -7831,15 +7537,6 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/index-array-by": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz",
"integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/index-to-position": { "node_modules/index-to-position": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
@@ -7880,15 +7577,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -8553,15 +8241,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/jerrypick": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz",
"integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -8694,18 +8373,6 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/kapsule": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz",
"integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==",
"license": "MIT",
"dependencies": {
"lodash-es": "4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -9042,12 +8709,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -10988,16 +10649,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/preact": {
"version": "10.29.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
"integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -11263,23 +10914,6 @@
"react": ">= 16.8 || 18.0.0" "react": ">= 16.8 || 18.0.0"
} }
}, },
"node_modules/react-force-graph-2d": {
"version": "1.29.1",
"resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz",
"integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==",
"license": "MIT",
"dependencies": {
"force-graph": "^1.51",
"prop-types": "15",
"react-kapsule": "^2.5"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.72.1", "version": "7.72.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz",
@@ -11302,21 +10936,6 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-kapsule": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.5.7.tgz",
"integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==",
"license": "MIT",
"dependencies": {
"jerrypick": "^1.1.1"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-markdown": { "node_modules/react-markdown": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -12552,12 +12171,6 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",

View File

@@ -22,7 +22,6 @@
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-dropzone": "^15.0.0", "react-dropzone": "^15.0.0",
"react-force-graph-2d": "^1.29.1",
"react-hook-form": "^7.72.1", "react-hook-form": "^7.72.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",

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