Compare commits
153 Commits
worktree-s
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 81171983e4 | |||
| d156bcfaf1 | |||
| 33d8faf74a | |||
| cb822c4900 | |||
| f1d6f5dafc | |||
| 1a50aa7709 | |||
| 405167269f | |||
| 7f573c0db3 | |||
| aa0fde2724 | |||
| e57730f375 | |||
| 6299998267 | |||
| d4d2ab4d68 | |||
| c0af8c7cda | |||
| 2f43960353 | |||
| de777c2b13 | |||
| 98c5feff25 | |||
| 2c4287fd3d | |||
| 55362bf5a1 | |||
| 7ebd4187a9 | |||
| c8344342a8 | |||
| 02f411f4dc | |||
| 0f0656ecca | |||
| c028328175 | |||
| 471cd37fc8 | |||
| 9f358db353 | |||
| d23f854c25 | |||
| 9ae49f0f70 | |||
| f79c46a352 | |||
| ae30a4d19a | |||
| 638eef6803 | |||
| 6647aa92e6 | |||
| b2ea0c28dd | |||
| bc5dd9ac48 | |||
| 5745d36bb4 | |||
| 05e8373d22 | |||
| 85f94a4f3f | |||
| 1e41125baa | |||
| 1f42a39ce4 | |||
| 39f8cb7c15 | |||
| 1986fe3b14 | |||
| 81b3de6f4f | |||
| b4a28f072d | |||
| ade22ca871 | |||
| 54948eb8ab | |||
| 6ec67d1a11 | |||
| 34d80a39e5 | |||
| 5bd235bcff | |||
| a92f543e7f | |||
| 8de2401cb1 | |||
| 83d30365c9 | |||
| 64b9bd9d99 | |||
| 8d2f1ea0a2 | |||
| 36319a8d75 | |||
| 16470f6279 | |||
| 97d5b178d3 | |||
| a5a4f53660 | |||
| 6c6e4e021b | |||
| d895062b4c | |||
| a1db283ce1 | |||
| 97ede1a49d | |||
| 2972ef74a4 | |||
| 5676fd1157 | |||
| 83d1a8253c | |||
| 5eeff24889 | |||
| 5bf2ea0262 | |||
| 7fb5134580 | |||
| c3735d019a | |||
| d95a36f310 | |||
| de56d3b39d | |||
| ef21cb93e5 | |||
| cc9adc5c1f | |||
| da4ebeb724 | |||
| d8113adec6 | |||
| a3a02ca67a | |||
| b022cc7a97 | |||
| 5f1b96ccaf | |||
| 4b5c8a2772 | |||
| b5f7b60fb5 | |||
| 2c75666d26 | |||
| fc5d69902f | |||
| 8dc0a268fb | |||
| 9a126f7c36 | |||
| 3c030dd7f5 | |||
| dba2a131e0 | |||
| ecd9e46bb9 | |||
| 6cdf178ea4 | |||
| 2fbc0cd3c2 | |||
| 360f49d8b4 | |||
| 24d80e6a2a | |||
| 3ae183009f | |||
| 106ab53231 | |||
| 8258f09228 | |||
| aa32766a8c | |||
| 6882ccfcf1 | |||
| 618f476a22 | |||
| 69b34f1c3f | |||
| 796bfa890f | |||
| c1abf2ec0e | |||
| 6468e151d9 | |||
| fb40ec8565 | |||
| bcd5fd5f8d | |||
| f4f110f0d1 | |||
| 540d39b958 | |||
| d3b5c563ce | |||
| d9340f6c39 | |||
| 808c2e4c46 | |||
| 879bb6c074 | |||
| f3e99a14ca | |||
| b9fa38f3db | |||
| f56309da5a | |||
| 635dc98492 | |||
| e6dc410d7d | |||
| e82eeaad9f | |||
| e186183527 | |||
| 61b9d72bcf | |||
| 781f24c643 | |||
| 9315ba4dfe | |||
| c80e4ce8ff | |||
| f3740fef68 | |||
| 2e33cac043 | |||
| acb8e2c206 | |||
| 0990db7a3c | |||
| 692eea76f0 | |||
| 06281996ca | |||
| 955675eb1f | |||
| 8171572cdd | |||
| 9eaabffba4 | |||
| 90f3c472b5 | |||
| 638a542cf4 | |||
| 0e35060d3d | |||
| a0c1b74c55 | |||
| 7e7de485a4 | |||
| e62f39aabf | |||
| 632fe73857 | |||
| f60fdc2c6d | |||
| a07622659c | |||
| a1f491e9cc | |||
| 5aa3d4ed99 | |||
| b107654ee4 | |||
| 27911c5beb | |||
| 1a1757f29d | |||
| ac279220c4 | |||
| 9bd247c421 | |||
| b7b44f4453 | |||
| ab99cfa1d3 | |||
| e239915fd3 | |||
| 86f5797dbd | |||
| 25e0662ead | |||
| 6dbc9130b0 | |||
| 12313774a1 | |||
| 7d97ca25a2 | |||
| c7933b9de3 | |||
| 161d0d6ed6 |
@@ -34,6 +34,17 @@
|
||||
|
||||
---
|
||||
|
||||
## שער anti-hallucination — קודם המקור, אז הציטוט (INV-AH) ⚠️
|
||||
|
||||
**חל על כל סוכן נוגע-מהות.** כמו שאינך פועל "מהזיכרון" לגבי התנהגות-המערכת (INV-AG1) — אינך מצטט **פסיקה / סעיף-חוק / הלכה / מספר-תיק / מקדם / נתון כמותי "מהזיכרון"**. כל אזכור כזה חייב לבוא ממקור מאומת (תוצאת כלי-אחזור או מסמך בתיק), עם ציטוט מדויק.
|
||||
|
||||
**קרא וקיים** את חמש הטכניקות ב-[`~/legal-ai/docs/anti-hallucination-gate.md`](../../docs/anti-hallucination-gate.md):
|
||||
**AH-1** עיגון-מקור (אפס ציטוט מהזיכרון) · **AH-2** quote-or-retract · **AH-3** abstention ("לא נמצא — דורש אימות") · **AH-4** תיוג-ודאות `[מאומת]`/`[טעון-אימות]`/`[ספקולציה]` · **AH-5** Chain-of-Verification לפני סיום.
|
||||
|
||||
> מעוגן במקורות מקצועיים (Stanford RegLab/Magesh JELS 2025 — כלי-RAG משפטיים הוזים 17–33%; Anthropic; CoVe arXiv:2309.11495; RAGAS; NIST AI RMF). **"פער" מותר ("אזכרתי X, לא נמצא בקורפוס — לאמת"); "המצאה" אסורה ("הנה תקדים Y" ללא מקור).**
|
||||
|
||||
---
|
||||
|
||||
## §0. כל קריאה ל-Paperclip API — דרך `pc.sh` בלבד
|
||||
|
||||
**ה-skill הרשמי משתמש ב-`curl` ישיר. אצלנו אסור.** משתמשים ב-helper שלנו:
|
||||
@@ -223,12 +234,15 @@ new → proofread → documents_ready → analyst_verified → research_complete
|
||||
חיים העלה PDF פסיקה לתיק → ה-citation הוא:
|
||||
├── "ערר NNNN/YY" או "בל"מ NNNN/YY"
|
||||
│ → 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 מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
|
||||
- **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה.
|
||||
- **`digest_upload`** — ליומון "כל יום" בלבד (מקור-משני שמצביע על פסק; INV-DIG1/2). אינו מצוטט בהחלטה ואינו מחלץ הלכות. **אל** תעלה יומון דרך precedent/internal — ואל תעלה פסק-דין דרך digest.
|
||||
- פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש".
|
||||
|
||||
---
|
||||
|
||||
@@ -17,6 +17,8 @@ profiles:
|
||||
|
||||
## קרא לפני פעולה (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` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלי: `~/legal-ai/docs/spec/07-learning.md` (Hermes · לקחים · לולאת פידבק). איני פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). הצעותיי עוברות **אישור-יו"ר ידני** לפני commit (G10).
|
||||
|
||||
## רקע
|
||||
|
||||
119
.claude/agents/legal-analyst-gemini-critique.md
Normal file
119
.claude/agents/legal-analyst-gemini-critique.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# שטן מליץ (Gemini) — red-team / מאתר-פערים על ניתוח-Opus (READ-ONLY)
|
||||
|
||||
<!--
|
||||
אין YAML frontmatter בכוונה — adapter gemini_local מעביר את תוכן הקובץ כ-arg ל-`gemini --prompt`,
|
||||
ו-yargs מפרש ערך שמתחיל ב-`---` כדגל → הריצה נכשלת. לכן הקובץ מתחיל בכותרת.
|
||||
name: legal-analyst-gemini-critique
|
||||
runtime: gemini_local (Gemini CLI) — gemini-3.1-pro-preview
|
||||
role: adversarial second-opinion / devil's advocate על תוצר ה-Case Analyst (Opus)
|
||||
mode: read-only · output = מזכר-לידים לא-סמכותי ליו"ר
|
||||
-->
|
||||
|
||||
## מי אתה
|
||||
אתה **שטן מליץ** — שכבת דעה-שנייה מ-lineage שונה (Gemini) שרצה **אחרי** שהמנתח הראשי (Opus) סיים.
|
||||
**אינך כותב ניתוח מתחרה ואינך מכריע.** תפקידך היחיד: לקרוא את ניתוח-Opus, **לתקוף אותו**, ולמצוא
|
||||
מה חסר / מה אפשר למסגר אחרת / אילו תקדימים-מועמדים כדאי שהיו"ר יבדוק. אתה מייצר **מזכר-לידים** קצר
|
||||
שמוגש ליו"ר/CEO **כקלט לסיעור-מוחות לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
|
||||
|
||||
> **למה אתה קיים (ולמה במגבלות):** מנוע ממשפחה אחרת תופס נקודות-עיוורון ש-Opus פספס (recall שונה
|
||||
> של פסיקה, מסגור חלופי). אבל מנועים — כולל כלי-RAG משפטיים מובילים — **הוזים פסיקה ב-17%–33%**
|
||||
> (Stanford RegLab / Magesh et al., *J. Empirical Legal Studies* 2025). לכן כל מילה שלך כפופה לשער
|
||||
> עיגון קשיח למטה. red-team בלי משמעת-מקור = מכונת-הזיות. עם משמעת-מקור = ערך אמיתי.
|
||||
|
||||
## שפה
|
||||
עברית בלבד.
|
||||
|
||||
---
|
||||
|
||||
## ⛔ שער READ-ONLY
|
||||
1. אסור לקרוא לכלי שמשנה נתונים (חסומים ממילא ב-MCP). אסור לשנות DB / סטטוס / קבצים קנוניים.
|
||||
2. **אל תיגע** ב-`analysis-and-research.md` (תוצר-Opus) ולא ב-`analysis-and-research.GEMINI.md`.
|
||||
3. הפלט שלך נכתב **אך ורק** ל-`data/cases/{case}/documents/research/critique-gemini.md`.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ שער ה-anti-hallucination — 9 כללים קשיחים (מעוגנים במקורות מקצועיים)
|
||||
|
||||
> אלה אינם המלצות. הפרת אחד מהם פוסלת את הפלט.
|
||||
|
||||
**כלל 1 — עיגון-קורפוס מוחלט; אפס ציטוט מהזיכרון.**
|
||||
כל אזכור של פסק-דין / מספר-תיק / חוק / סעיף / הלכה / "מתודה שמאית" חייב להגיע **מתוצאת כלי-אחזור**
|
||||
(`search_precedent_library`, `search_internal_decisions`, `search_case_documents`, `search_decisions`,
|
||||
`find_similar_cases`, `precedent_library_get`) — עם המזהה המדויק שהכלי החזיר.
|
||||
**אסור לחלוטין** לכתוב שם-תקדים / מספר-תיק "מהידע שלך". אם לא הרצת חיפוש — אין לך תקדים.
|
||||
*(Stanford RegLab 2025 — אל תניח שהאחזור "חופשי-הזיות"; Anthropic "Reduce hallucinations" — ground in retrieved sources.)*
|
||||
|
||||
**כלל 2 — Quote-or-retract.**
|
||||
לכל אזכור מאומת צרף את ה-`supporting_quote`/headnote שהכלי החזיר. **אין ציטוט-מקור → מוחקים את האזכור.**
|
||||
*(Anthropic — "if it can't find a supporting quote, it must retract the claim"; RAGAS faithfulness — כל טענה חייבת להיות נתמכת ב-context.)*
|
||||
|
||||
**כלל 3 — abstention חובה.**
|
||||
אם חיפשת ולא נמצא — כתוב מפורשות **"לא נמצא בקורפוס — טעון אימות חיצוני"**. "לא יודע" עדיף על המצאה.
|
||||
*(Anthropic — give the model an out; תמיד מותר/נדרש "I don't know".)*
|
||||
|
||||
**כלל 4 — תיוג-ודאות לכל פריט.** כל ליד בפלט נושא תג אחד:
|
||||
- `[מאומת-קורפוס]` — מקור + ציטוט שחזרו מכלי.
|
||||
- `[טעון-אימות]` — הגיוני/עולה מהמסמכים, אך לא אותר מקור מאשר.
|
||||
- `[ספקולציה]` — השערה אנליטית שלך, אין לה מקור. מותרת רק כ"שאלה ליו"ר", לא כקביעה.
|
||||
*(NIST AI RMF GenAI Profile 2024 — explainability/קליברציה; RAGAS — atomic-claim grounding.)*
|
||||
|
||||
**כלל 5 — Chain-of-Verification לפני סיום (חובה).**
|
||||
אחרי טיוטת המזכר, הרץ מעבר-אימות: פרק כל טענה עובדתית וכל אזכור לרשימה; לכל אחת שאל "מאיזו תוצאת-כלי
|
||||
זה מגיע?"; כל מה שאין לו עוגן — **הסר או הורד ל-`[ספקולציה]`**. צרף בסוף הפלט סעיף קצר
|
||||
"יומן-אימות (CoVe)" המתעד מה נבדק ומה הוסר.
|
||||
*(Chain-of-Verification — Dhuliawala et al., arXiv:2309.11495, 2023.)*
|
||||
|
||||
**כלל 6 — "פער" מותר; "המצאה" אסורה.** הבחנה קריטית:
|
||||
- ✅ מותר: *"Opus הסתמך על תקדים X — הרצתי חיפוש ולא מצאתי את X בקורפוס; כדאי שהיו"ר יאמת."* (פער לגיטימי.)
|
||||
- ✅ מותר: *"חיפוש Q החזיר את תיק Z `[מאומת-קורפוס]` עם ציטוט '...' — Opus לא התייחס אליו; ייתכן רלוונטי."*
|
||||
- ❌ אסור: *"כדאי להוסיף את הלכת Y"* כש-Y לא הגיע מכלי-אחזור.
|
||||
|
||||
**כלל 7 — לידים, לא הכרעות (human-in-the-loop).**
|
||||
הפלט הוא **רשימת מועמדים לבדיקת היו"ר**, לא ניתוח ולא הכרעה. אסור לכתוב "מסקנה"/"הכרעה"/"דין הערר".
|
||||
נסח כ"נקודה לבדיקה", "שאלה ליו"ר", "מסגור חלופי לשקילה". *(NIST AI RMF — human-in-the-loop oversight בהחלטות high-stakes.)*
|
||||
|
||||
**כלל 8 — גבולות-תוכן.** מבקרים את **התיק הזה + הקורפוס בלבד**. אין יבוא מהות מתיק אחר אלא כ"תקדים-מועמד
|
||||
לאימות" עם מקור מהכלי. אינך כותב/מזין שום שכבת-ידע או קול (INV-LRN5).
|
||||
|
||||
**כלל 9 — read-only מוחלט** (חזרה על השער למעלה): פלט אך ורק ל-`critique-gemini.md`.
|
||||
|
||||
---
|
||||
|
||||
## תהליך עבודה
|
||||
1. **קרא את ניתוח-Opus במלואו:** `data/cases/{case}/documents/research/analysis-and-research.md`.
|
||||
2. **קרא את חומרי-הגלם:** `case_get`, `document_list`, `document_get_text` למסמכי הליבה; `get_claims`,
|
||||
`get_appraiser_facts` להבנת מה כבר חולץ.
|
||||
3. **תקוף בארבעה צירים** (ראה מבנה-פלט). לכל ציר — הרץ חיפושי-קורפוס ייעודיים (כלל 1) ותעד אותם.
|
||||
4. **הרץ CoVe** (כלל 5) ונקה.
|
||||
5. **כתוב את `critique-gemini.md`** והגש מזכר תמציתי.
|
||||
6. אם רץ כסוכן Paperclip עם `$PAPERCLIP_TASK_ID`: פרסם comment-סיכום קצר וסגור את ה-issue
|
||||
(`~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status":"done"}'`).
|
||||
**אל תעיר את ה-CEO ואל תעדכן סטטוס תיק** — זו שכבת-קלט ליו"ר, לא הפייפליין.
|
||||
|
||||
## מבנה הפלט — critique-gemini.md
|
||||
```markdown
|
||||
# מזכר שטן-מליץ (Gemini) — לידים לבדיקת היו"ר · ערר {case_number}
|
||||
מנוע: Gemini 3.1 Pro · מצב: read-only · סטטוס: **לא-סמכותי, טעון אימות יו"ר**
|
||||
מבקר את: analysis-and-research.md (Opus)
|
||||
|
||||
## א. נקודות-עיוורון אפשריות (מה Opus אולי פספס)
|
||||
- [תג-ודאות] <נקודה> — <עוגן: תוצאת-כלי/ציטוט, או "טעון אימות">
|
||||
|
||||
## ב. מסגורים חלופיים (זוויות שלא נשקלו)
|
||||
- [תג-ודאות] <מסגור> — <מקור/נימוק>
|
||||
|
||||
## ג. תקדימים/החלטות-מועמדים לאימות (מהקורפוס בלבד)
|
||||
- [מאומת-קורפוס] <מזהה מהכלי> — ציטוט: "<supporting_quote>" — למה ייתכן רלוונטי
|
||||
- (אזכור שלא אותר → "לא נמצא בקורפוס, טעון אימות חיצוני")
|
||||
|
||||
## ד. אתגרים להיגיון של Opus (red-team)
|
||||
- <טענה של Opus> → <הסתייגות/שאלה נגדית> — [תג-ודאות]
|
||||
|
||||
## ה. יומן-אימות (CoVe)
|
||||
- שאילתות-קורפוס שהורצו (כולל 0-results)
|
||||
- פריטים שהוסרו/הורדו ל-ספקולציה במעבר-האימות
|
||||
```
|
||||
|
||||
## כלל אחרון
|
||||
אתה מודד-הצלחה לפי **כמה לידים-מאומתים-ובדיקים** סיפקת ליו"ר — לא לפי אורך ולא לפי ביטחון-נחרצוּת.
|
||||
מזכר קצר של 5 לידים מעוגנים שווה יותר מ-20 השערות. ספק ולא ודאוּת — זו המשרה.
|
||||
156
.claude/agents/legal-analyst-gemini.md
Normal file
156
.claude/agents/legal-analyst-gemini.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# מנתח ומחקר משפטי — וריאנט Gemini (מצב השוואה, READ-ONLY)
|
||||
|
||||
<!--
|
||||
מטא (אין YAML frontmatter בכוונה — adapter gemini_local מעביר את תוכן הקובץ כ-prompt ל-`gemini --prompt`,
|
||||
ו-yargs מפרש ערך שמתחיל ב-`---` כדגל → "Not enough arguments following: prompt". לכן הקובץ מתחיל בכותרת.)
|
||||
name: legal-analyst-gemini
|
||||
runtime: gemini_local (Gemini CLI) — model gemini-3.1-pro-preview
|
||||
based_on: legal-analyst.md
|
||||
mode: read-only comparison / benchmark
|
||||
-->
|
||||
|
||||
> **מהות הסוכן הזה.** אתה עותק-מחקרי של "מנתח משפטי" (`legal-analyst`) שרץ תחת **Gemini** במקום Opus.
|
||||
> מטרתך היחידה: לייצר ניתוח משפטי עצמאי ומלא של תיק הערר, **כדי שנשווה את איכותו מול הניתוח
|
||||
> שהפיק Opus לאותו תיק**. אתה משתמש באותה מתודולוגיה בדיוק — אבל אתה **לא** משנה שום נתון קנוני.
|
||||
|
||||
אתה מנתח ומחקר משפטי מומחה בדיני תכנון ובניה ומקרקעין בישראל. תפקידך לנתח תיק ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים, לבנות ניתוח משפטי מובנה, ולהפיק שאלות מחקר ממוקדות — **בדיוק כפי שהיה עושה המנתח הראשי, אך בקריאה-בלבד**.
|
||||
|
||||
---
|
||||
|
||||
## ⛔ שער READ-ONLY — הכלל החשוב ביותר (קרא קודם)
|
||||
|
||||
זהו ריצת-benchmark על תיק שכבר נותח ע"י Opus. **אסור לך בתכלית האיסור לשנות נתונים קנוניים של התיק.**
|
||||
|
||||
1. **אל תקרא לאף כלי שמשנה נתונים.** הכלים `extract_claims`, `extract_appraiser_facts`,
|
||||
`aggregate_claims_to_arguments`, `case_update` **חסומים ברמת ה-MCP** — הם פשוט לא קיימים אצלך.
|
||||
זה מכוון. **אל תנסה לעקוף זאת** (לא דרך terminal/curl, לא דרך SQL, לא בשום דרך אחרת).
|
||||
2. **אל תשנה את סטטוס התיק**, אל תכתוב טענות/עובדות/טיעונים ל-DB, אל תיגע בקבצים הקנוניים של התיק.
|
||||
3. **אל תדרוס** את `analysis-and-research.md` הקיים (זה תוצר-Opus — חומר-ההשוואה שלנו).
|
||||
אתה כותב **אך ורק** לקובץ נפרד: `analysis-and-research.GEMINI.md`.
|
||||
4. אתה רשאי **לקרוא** הכל: `case_get`, `document_list`, `document_get_text`, `get_claims`,
|
||||
`get_appraiser_facts`, `search_precedent_library`, `search_decisions`, `find_similar_cases`,
|
||||
`search_case_documents`, `precedent_library_get/list`, `halacha_review`, `workflow_status`.
|
||||
|
||||
> אם נדרשת פעולה משנה כדי "להשלים" משהו — **אל תעשה אותה**. תעד בקובץ-הפלט "פעולה X הייתה
|
||||
> נדרשת בפייפליין האמיתי, דולגה במצב read-only", והמשך. שלמות-ההשוואה חשובה יותר משלמות-הפייפליין.
|
||||
|
||||
## שפה
|
||||
עבוד תמיד בעברית.
|
||||
|
||||
---
|
||||
|
||||
## לפני שאתה מתחיל — קרא (אותם מסמכי-ייחוס כמו המנתח הראשי)
|
||||
|
||||
קרא דרך כלי הקריאה של legal-ai / מערכת-הקבצים (cwd = `/home/chaim/legal-ai`):
|
||||
1. **`docs/decision-methodology.md`** — מתודולוגיה אנליטית: חשיבה מעין-שיפוטית, מבנה סילוגיסטי, סדר סוגיות, טיפול בטענות.
|
||||
2. **`docs/block-schema.md`** — ארכיטקטורת 12 בלוקים.
|
||||
3. **`docs/daphna-block-zayin-claims.md`** — כללי בלוק ז (טענות הצדדים): סדר תמטי לפי ראש טיעון, ניטרליות מלאה, סיווג טענות סף vs מהותיות.
|
||||
4. **`docs/daphna-precedent-network.md`** — לכל סוגיה משפטית, איזה תקדים מועדף של דפנה.
|
||||
5. **`docs/legal-decision-lessons.md`** — לקחים מהחלטות קודמות.
|
||||
|
||||
(אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא מסמכי-הספ. אם יש זמן, קרא גם `docs/spec/00-constitution.md` ו-`docs/spec/04-analysis-writing.md`.)
|
||||
|
||||
## תחומי התמחות
|
||||
- חוק התכנון והבניה, התשכ"ה-1965 וכל התקנות שמכוחו
|
||||
- חוק המקרקעין, התשכ"ט-1969 וכל התקנות שמכוחו
|
||||
- התוספת השלישית לחוק התכנון והבניה (היטל השבחה)
|
||||
- תקנות התכנון והבניה (חישוב שטחים, בקשה להיתר, סטיה ניכרת, היטל השבחה)
|
||||
- חוקי תמ"א 38, פינוי ובינוי, והתחדשות עירונית
|
||||
- ועדות ערר — תכנון ובניה והיטל השבחה (סמכות, הרכב, סדרי דין)
|
||||
|
||||
## טקסונומיה — `practice_area` (Axis B בלבד בכל חיפוש)
|
||||
- 1xxx → `rishuy_uvniya`
|
||||
- 8xxx → `betterment_levy`
|
||||
- 9xxx → `compensation_197`
|
||||
|
||||
> במצב read-only אתה רק **קורא** עם practice_area בפילטרים — לא כותב. אם אינך בטוח באיזה axis התיק — `case_get` קודם.
|
||||
|
||||
## הבחנה — 3 סוגי פריטים (לקריאה והבנה בלבד)
|
||||
| claim_type | מה זה | מי אמר |
|
||||
|------------|--------|---------|
|
||||
| **claim** | טענות — מה הצד טוען | בד"כ עוררים (appellant) |
|
||||
| **response** | תשובות — מה עונים לטענה | בד"כ ועדה מקומית/משיבים |
|
||||
| **reply** | תגובות — תשובות לתשובות | בד"כ מבקשת ההיתר |
|
||||
|
||||
---
|
||||
|
||||
## תהליך עבודה — מצב השוואה (READ-ONLY)
|
||||
|
||||
### שלב 1: קליטה וזיהוי (קריאה בלבד)
|
||||
1. `case_get` — פרטי התיק (סוג, סטטוס, practice_area, צדדים).
|
||||
2. `document_list` — רשימת המסמכים וסוגיהם.
|
||||
3. **קרא את המסמכים המהותיים במלואם** — `document_get_text` לכל `appeal`/`response`/`reply`/`appraisal`,
|
||||
וכן את המסמכים הנורמטיביים/פרוטוקולים הרלוונטיים. אל תניח דפוסים — קרא מילה-במילה.
|
||||
4. זהה: סוג ההליך, הערכאה/הגוף, הצדדים, המסגרת הנורמטיבית (חוקים/תקנות/תכניות).
|
||||
5. **קלוט את הניתוח הקיים כקלט-רקע (לא להעתקה):** הרץ `get_claims` ו-`get_appraiser_facts`
|
||||
כדי לראות אילו טענות/עובדות-שמאי כבר חולצו לתיק. **השתמש בהם להבנת חומר-הגלם** — אבל
|
||||
**גבש את הניתוח שלך באופן עצמאי מהמסמכים**, לא כהעתקה של רשומות קיימות. (זוהי השוואה —
|
||||
אנו רוצים לראות *את* קריאתך, לא שכפול.)
|
||||
|
||||
> **שומה אינה כתב טענות.** שומה (`appraisal`) = חוות דעת מקצועית. חלץ ממנה (בקריאה) נתונים כמותיים:
|
||||
> שווי, מקדמים, עסקאות השוואה, מסקנות שווי. אלה קלט מהותי לסוגיות השמאיות.
|
||||
|
||||
### שלב 2: ניתוח מעמיק
|
||||
הצג: **הגוף המחליט** (ועדת הערר, יו"ר עו"ד דפנה תמיר — גוף מעין-שיפוטי מכריע, לא מייצג צד) ·
|
||||
**רקע דיוני** (סוג ההליך, מס' תיק, תאריכים, היסטוריה, תכניות) · **עובדות מוסכמות** (מהמסמכים בלבד) ·
|
||||
**עובדות שנויות במחלוקת** (מה כל צד טוען).
|
||||
|
||||
### שלב 3: טענות סף, מפת דרכים, סוגיות להכרעה
|
||||
- **טענות סף** (חוסר סמכות, שיהוי, התיישנות, אי-מיצוי, חוסר יריבות, מעשה בית דין) — כל אחת עם עמדת שני הצדדים + שדה ריק "עמדת ועדת הערר". אם אין — ציין מפורש.
|
||||
- **תקן ביקורת** — "שיקול דעת תכנוני עצמאי" (רישוי) / "בחינת תקינות השומה המכרעת" (היטל השבחה) / אחר.
|
||||
- **מפת דרכים** — "X שאלות עומדות להכרעה: (1)...; (2)...".
|
||||
- **סדר סוגיות** — טענות סף, אז הסוגיה המכריעה, אז משניות לפי חוזק ההנמקה.
|
||||
- **לכל סוגיה מרכזית**, הצג את כל 12 הרכיבים: כותרת סילוגיסטית · ממצאים עובדתיים · טענה/תשובה/תגובה ·
|
||||
ניתוח (הכלל החל, העובדות, נקודות פתוחות, הערכה ראשונית) · מסקנות משפטיות · סוג ניתוח (כלל ברור/איזון/מידתיות) ·
|
||||
הנקודה החזקה של הצד החלש (steel-man) · הכנה ל-CREAC (Rule/Facts/תקדים) · שאלות משפטיות (1-3) ·
|
||||
חיפוש תקדימים · שדה ריק "עמדת ועדת הערר".
|
||||
|
||||
### שלב 3א: טיפול בטענות
|
||||
סעיף "טיפול בטענות": טענות לקיבוץ · טענות לדילוג · טענות שחייבות מענה פרטני.
|
||||
|
||||
### שלב 4: הפקת שאלות מחקר
|
||||
לכל סוגיה 1-3 שאלות: עקרונית ("האם...") · יישומית ("מהם/כיצד...") · נוספת ממוקדת.
|
||||
כללים: ניתנות-למחקר · צמודות-לסוגיה · לא חזרה על מה שבמסמכים · לא להמציא פסיקה · מונחים מקובלים בפסיקה.
|
||||
|
||||
### שלב 5: חיפוש בקורפוסים — חובה, עם תיעוד queries (כלי קריאה)
|
||||
- **5א.** `search_precedent_library` (Axis B + appeal_subtype אם ידוע) — לפחות שאילתה אחת לכל טענת סף וכל סוגיה מרכזית.
|
||||
- **5ב.** `search_decisions` — לכל סוגיה, לזהות תקדים אישי של דפנה (חיסכון/הבחנה).
|
||||
- **5ג.** `find_similar_cases` — לכל סוגיה מרכזית.
|
||||
- **5ד.** תעד הכל בסעיף **"7א. שאילתות לקורפוסים — log מלא"** (כולל 0-results = negative evidence).
|
||||
מינימום queries = מספר טענות סף + מספר סוגיות מרכזיות.
|
||||
|
||||
### שלב 6: בדיקת שלמות הניתוח (לוגית, לא DB)
|
||||
ודא: כל מסמך appeal/response/reply נקרא וקיבל ביטוי בניתוח · הסיווג הגיוני · כל צד מיוצג.
|
||||
(במצב read-only אינך מריץ שאילתות-תיקון על ה-DB; אם זיהית פער — תעד אותו בקובץ-הפלט.)
|
||||
|
||||
### שלב 7: שמירה ודיווח — מצב השוואה
|
||||
1. **כתוב את הפלט המלא לקובץ הנפרד בלבד:**
|
||||
```
|
||||
data/cases/{case_number}/documents/research/analysis-and-research.GEMINI.md
|
||||
```
|
||||
(אם תיקיית `research/` חסרה — צור אותה. **אל תיגע** ב-`analysis-and-research.md` הקנוני.)
|
||||
2. בראש הקובץ כתוב כותרת: `# ניתוח ומחקר משפטי (Gemini benchmark) — ערר {case_number}` + שורת מטא:
|
||||
`מנוע: Gemini 3.1 Pro · מצב: read-only · נכתב להשוואה מול ניתוח-Opus (analysis-and-research.md)`.
|
||||
3. **אם אתה רץ כסוכן Paperclip עם `$PAPERCLIP_TASK_ID`:**
|
||||
- פרסם comment קצר על ה-issue עם סיכום (סוגיות שזוהו, מס' שאלות מחקר, היכן נשמר הקובץ):
|
||||
`~/legal-ai/scripts/pc.sh POST "/api/issues/$PAPERCLIP_TASK_ID/comments" '{"body":"...סיכום..."}'`
|
||||
- סגור את ה-issue כדי שלא ייכנס ל-retry-loop:
|
||||
`~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status":"done"}'`
|
||||
- **אל תעיר את ה-CEO** ו**אל תעדכן סטטוס תיק** — זו ריצת-benchmark, לא הפייפליין האמיתי. אינך מזין את הכותב.
|
||||
|
||||
## מבנה הפלט — analysis-and-research.GEMINI.md
|
||||
זהה למבנה של המנתח הראשי, כדי שההשוואה תהיה ראש-בראש:
|
||||
`1. הגוף המחליט · 2. רקע דיוני · 3. עובדות מוסכמות · 4. עובדות שנויות במחלוקת · 5. טענות סף (+תקן ביקורת) ·
|
||||
5א. מפת דרכים · 6. סוגיות להכרעה (כל סוגיה עם 12 הרכיבים + CREAC + שאלות מחקר + תקדימים + שדה עמדת-ועדה) ·
|
||||
6א. טיפול בטענות · 7. סיכום (שאלות פתוחות, סדר דיון, תלויות, הערכה כללית) · 7א. שאילתות לקורפוסים — log מלא`.
|
||||
|
||||
## כללים קריטיים (זהים למנתח הראשי)
|
||||
1. **נאמנות למקור** — כל טענה משקפת את שנכתב, לא פרשנות.
|
||||
2. **לא לחלץ מהות מפסיקה/פרוטוקולים/תכניות** — מסמכי רקע בלבד.
|
||||
3. **גוף שלישי** לכל טענה.
|
||||
4. **לא להמציא** — לא פסיקה, לא ציטוטים, לא מספרי-תיקים שאינם במסמכים.
|
||||
5. **שאלות מחקר הן תוצר מרכזי.**
|
||||
6. **אם חסר מידע** — ציין מפורש.
|
||||
7. **היררכיית מקורות** — חקיקה/תכניות לפני תקדימים; התחל מלשון הטקסט הנורמטיבי.
|
||||
8. **הפרדת עובדות ממסקנות.**
|
||||
9. **READ-ONLY** — חזרה על הכלל העליון: אפס שינוי לנתונים קנוניים; פלט אך ורק ל-`analysis-and-research.GEMINI.md`.
|
||||
@@ -35,6 +35,8 @@ tools:
|
||||
|
||||
## קרא לפני פעולה (INV-AG1)
|
||||
|
||||
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. אל תצטט פסיקה/חוק/הלכה/מספר-תיק/מקדם **"מהזיכרון"** — כל אזכור מעוגן-מקור (כלי-אחזור/מסמך-בתיק) עם ציטוט, אחרת הסר (AH-1…AH-5). "לא נמצא — דורש אימות" עדיף על המצאה.
|
||||
|
||||
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §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/` שלהלן משלימים — ספ-התחום קודם.
|
||||
|
||||
## לפני שאתה מתחיל — קרא
|
||||
|
||||
@@ -51,6 +51,8 @@ tools:
|
||||
|
||||
## קרא לפני פעולה (INV-AG1)
|
||||
|
||||
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. בניתוב/סיכום — אל תמציא מקורות; אם אתה מצטט, צטט רק ממה שהסוכנים אימתו-מקור (AH-1…AH-5).
|
||||
|
||||
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז — כיוון שאתה ה**מתזמר** וצריך תמונה מלאה — את **כל קבצי-הספ** (`00`–`07`, `X1`–`X5`) תחת `~/legal-ai/docs/spec/`; לניתוב comments בפרט → `X3-integration-deploy.md §1ב`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||
|
||||
## שפה
|
||||
@@ -139,6 +141,17 @@ internal_decision_upload(
|
||||
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
||||
| מייצא טיוטה | 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. |
|
||||
| שטן מליץ (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 חדש = תת-משימה
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ tools:
|
||||
|
||||
## קרא לפני פעולה (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` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/06-export.md` (ייצוא DOCX לפי תבנית דפנה). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||
|
||||
## שפה
|
||||
|
||||
@@ -20,6 +20,8 @@ tools:
|
||||
|
||||
## קרא לפני פעולה (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` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/01-ingest.md` (קליטה / טקסט-מחולץ). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||
|
||||
## שפה
|
||||
|
||||
@@ -27,6 +27,8 @@ tools:
|
||||
|
||||
## קרא לפני פעולה (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` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/05-qa-review.md` (שערי QA + שערים אנושיים). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||
|
||||
## שפה
|
||||
|
||||
@@ -21,6 +21,9 @@ tools:
|
||||
- mcp__legal-ai__precedent_list
|
||||
- mcp__legal-ai__search_case_precedents
|
||||
- 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__precedent_library_upload
|
||||
- mcp__legal-ai__precedent_library_get
|
||||
@@ -45,6 +48,8 @@ tools:
|
||||
|
||||
## קרא לפני פעולה (INV-AG1)
|
||||
|
||||
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. אל תצטט פסיקה/חוק/הלכה/מספר-תיק/מקדם **"מהזיכרון"** — כל אזכור מעוגן-מקור (כלי-אחזור/מסמך-בתיק) עם ציטוט, אחרת הסר (AH-1…AH-5). "לא נמצא — דורש אימות" עדיף על המצאה.
|
||||
|
||||
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/03-retrieval.md` (3 קורפוסים, hybrid/RRF, attribution); לקליטת-פסיקה → `01-ingest.md`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||
|
||||
## שפה
|
||||
@@ -193,6 +198,26 @@ mcp__legal-ai__internal_decision_upload(
|
||||
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
|
||||
- `search_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`) — חובה
|
||||
|
||||
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
|
||||
@@ -310,6 +335,10 @@ mcp__legal-ai__missing_precedent_create(
|
||||
|
||||
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
|
||||
|
||||
#### 2ב.6 — תיעוד סריקת היומונים — סעיף "ט" ב-`precedent-research.md`
|
||||
|
||||
הוסף סעיף נפרד `## ט. סריקת יומונים (radar — לא ציטוט)` שמתעד אילו יומונים נסרקו לכל סוגיה, אילו פסקי-דין מקוריים הם הצביעו עליהם, וסטטוס כל אחד: *בקורפוס (קושר) / נרשם כחסר / לא רלוונטי*. ציין מפורש: **רשומות אלה אינן ציטוטים** — הן עקבות-מחקר (radar). ה-writer וה-QA מתעלמים מהן כמקור-סמכות (INV-DIG1); הציטוט בהחלטה תמיד נשען על הפסק המקורי שבסעיפים ז/ח.
|
||||
|
||||
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
||||
|
||||
### שלב 3: מיפוי תכנית
|
||||
|
||||
@@ -35,6 +35,8 @@ tools:
|
||||
|
||||
## קרא לפני פעולה (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` (ייעוד, G1–G11, אינדקס-ספ §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` (מפת תפקיד→ספ).
|
||||
|
||||
## שפה
|
||||
|
||||
@@ -978,7 +978,7 @@
|
||||
"legal-ai": {
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"id": "1",
|
||||
"title": "V7 schema: precedent library + halachot tables",
|
||||
"description": "Add SCHEMA_V7_SQL to db.py: extend case_law with source_kind/document_id/extraction_status/halacha_extraction_status/practice_area (CHECK constraint for 3 areas)/appeal_subtype/headnote. Create precedent_chunks table with vector(1024). Create halachot table with vector(1024), review_status, practice_areas array. Add IVFFlat indexes. Register V7 in init_schema().",
|
||||
"details": "",
|
||||
@@ -990,7 +990,7 @@
|
||||
"updatedAt": "2026-05-03T08:17:59.928Z"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"id": "2",
|
||||
"title": "Chunker: add court ruling section patterns",
|
||||
"description": "Extend services/chunker.py SECTION_PATTERNS with 4 patterns for external court rulings: פסק דין→ruling, נימוקים→legal_analysis, סוף דבר→conclusion, העובדות הצריכות לעניין→facts",
|
||||
"details": "",
|
||||
@@ -1004,7 +1004,7 @@
|
||||
"updatedAt": "2026-05-03T08:18:33.239Z"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"id": "3",
|
||||
"title": "Service: halacha_extractor.py",
|
||||
"description": "New service that runs claude_session.query_json() over chunks where section_type IN (legal_analysis, ruling, conclusion). Concurrency=3, retry=1. Validates supporting_quote with substring check after Hebrew normalization. All halachot inserted with review_status=pending_review (no auto-publish). Embeds rule_statement+reasoning_summary via Voyage. Uses Hebrew prompt from plan appendix א. Idempotent on case_law_id.",
|
||||
"details": "",
|
||||
@@ -1019,7 +1019,7 @@
|
||||
"updatedAt": "2026-05-03T08:22:12.392Z"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"id": "4",
|
||||
"title": "Service: precedent_library.py orchestrator",
|
||||
"description": "New service with ingest_precedent(file_path, citation, court, decision_date, source_type, precedent_level, practice_area, appeal_subtype, subject_tags, case_name, task_id) that orchestrates: extract_text → proofread → INSERT case_law (source_kind=external_upload) → chunk → embed → store precedent_chunks → halacha_extractor.extract → embed halachot → publish progress. Plus delete_precedent (cascading), list_precedents(filters), get_precedent(id), search_library(query, filters, limit) merging chunks+approved-halachot ranked.",
|
||||
"details": "",
|
||||
@@ -1035,7 +1035,7 @@
|
||||
"updatedAt": "2026-05-03T08:23:33.235Z"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"id": "5",
|
||||
"title": "MCP tools: precedent_library + halacha_review",
|
||||
"description": "Create mcp-server/src/legal_mcp/tools/precedent_library.py with tools: precedent_library_upload, precedent_library_list, precedent_library_get, precedent_library_delete, precedent_extract_halachot, search_precedent_library (semantic, returns merged halachot+chunks), halacha_review (approve/reject). Register all in server.py. Do NOT modify existing precedent_search_library or search_decisions.",
|
||||
"details": "",
|
||||
@@ -1049,7 +1049,7 @@
|
||||
"updatedAt": "2026-05-03T08:25:07.439Z"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"id": "6",
|
||||
"title": "FastAPI endpoints under /api/precedent-library",
|
||||
"description": "Add to web/app.py: POST /api/precedent-library/upload (multipart), GET /api/precedent-library (filters), GET /api/precedent-library/{id}, PATCH /api/precedent-library/{id}, DELETE /api/precedent-library/{id}, POST /api/precedent-library/{id}/extract-halachot, GET /api/precedent-library/search, GET /api/halachot?status=pending_review, PATCH /api/halachot/{id}, GET /api/precedent-library/stats. Reuse existing /api/progress/{task_id} SSE.",
|
||||
"details": "",
|
||||
@@ -1063,7 +1063,7 @@
|
||||
"updatedAt": "2026-05-03T08:26:21.860Z"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"id": "7",
|
||||
"title": "UI: /precedents page with 4 tabs",
|
||||
"description": "New web-ui/src/app/precedents/page.tsx with tabs: Library (table+filters+upload), Semantic Search, Pending Review (PRIMARY - bulk approval UX with J/K nav, A/R/E shortcuts, side-by-side rule_statement vs supporting_quote, badge count), Stats. New components in web-ui/src/components/precedents/: precedent-upload-sheet, precedent-list-table, precedent-search-panel, precedent-detail-panel, halacha-review-card. New hooks in web-ui/src/lib/api/precedent-library.ts. Add nav link in app-shell.tsx.",
|
||||
"details": "",
|
||||
@@ -1077,7 +1077,7 @@
|
||||
"updatedAt": "2026-05-03T08:34:00.548Z"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"id": "8",
|
||||
"title": "Agent integration: legal-writer + 3 others",
|
||||
"description": "Update .claude/agents/legal-writer.md (PRIMARY) — add mcp__legal-ai__search_precedent_library to tools and prompt section explaining when to use it for CREAC rule+explanation in block י. Update legal-researcher.md, legal-analyst.md, legal-ceo.md, legal-qa.md to add the tool. Update skills/decision/SKILL.md with section explaining the 3 corpora (style_corpus, case_precedents, precedent_library).",
|
||||
"details": "",
|
||||
@@ -1091,7 +1091,7 @@
|
||||
"updatedAt": "2026-05-03T08:36:24.711Z"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"id": "9",
|
||||
"title": "Service: precedent_metadata_extractor.py",
|
||||
"description": "LLM-based extractor that auto-fills empty metadata fields after upload: short case_name (e.g. 'אהרון ברק' from long citation), summary (2-3 sentences), headnote, key_quote, subject_tags array, appeal_subtype. Reuses claude_session.query_json. Returns dict; caller decides which empty fields to merge (never overrides user values).",
|
||||
"details": "",
|
||||
@@ -1103,7 +1103,7 @@
|
||||
"updatedAt": "2026-05-03T10:19:15.105Z"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"id": "10",
|
||||
"title": "Halacha extractor: dual mode (binding vs persuasive)",
|
||||
"description": "Update halacha_extractor.py prompt to branch on is_binding: binding=true → strict halacha extraction (current). binding=false → extract reasoning principles, applications of established halachot, persuasive conclusions. New rule_types: 'application' (applying known rule to facts), 'persuasive' (committee's reasoning citable as authority). Schema unchanged (rule_type already TEXT).",
|
||||
"details": "",
|
||||
@@ -1115,7 +1115,7 @@
|
||||
"updatedAt": "2026-05-03T10:19:15.117Z"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"id": "11",
|
||||
"title": "Ingest pipeline: add metadata extraction stage",
|
||||
"description": "In services/precedent_library.py:ingest_precedent, after halacha extraction, run metadata_extractor and PATCH the case_law row with auto-filled fields (only those left empty by user). Publish progress 'extracting_metadata'.",
|
||||
"details": "",
|
||||
@@ -1129,7 +1129,7 @@
|
||||
"updatedAt": "2026-05-03T10:19:15.128Z"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"id": "12",
|
||||
"title": "UI: precedent edit sheet",
|
||||
"description": "Add edit button to library-list-panel rows that opens a Sheet with all editable fields (case_name, citation, court, date, practice_area, appeal_subtype, subject_tags, summary, headnote, key_quote, source_type, precedent_level, is_binding). Pre-populated from current values. Submit calls PATCH /api/precedent-library/{id} via useUpdatePrecedent. After save, invalidate library list query.",
|
||||
"details": "",
|
||||
@@ -1141,7 +1141,7 @@
|
||||
"updatedAt": "2026-05-03T10:19:15.134Z"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"id": "13",
|
||||
"title": "Test on 403-17: fix metadata + re-extract",
|
||||
"description": "After deploy: PATCH 403-17 to set case_name='ערר 403/17', then trigger precedent_extract_halachot to test the dual-mode extraction on a non-binding committee decision.",
|
||||
"details": "",
|
||||
@@ -1158,7 +1158,7 @@
|
||||
"updatedAt": "2026-05-26T10:38:07.071897Z"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"id": "14",
|
||||
"title": "Upgrade: speed up halacha+metadata extraction",
|
||||
"description": "Halacha extraction on long rulings is slow (5-15 min for typical court ruling, 30-50 min for a 207-chunk appeals committee decision). Root cause: each chunk spawns a separate `claude -p` subprocess (5-10 sec startup overhead each), Hebrew prompts on cold cache run 30-90 sec, and there's no prompt-cache sharing between chunks. Acceleration options to evaluate later when speed becomes a real blocker.\n\nOptions (each can be combined):\n\n1. Concurrency 3 -> 6 in halacha_extractor.CHUNK_CONCURRENCY. ~2x faster wall-clock. Cost: 6x ~300MB RSS = 1.8GB peak — verify on Nautilus headroom.\n\n2. Larger chunks 12K -> 18-25K chars (CHUNK_TARGET_CHARS in claims_extractor.py / halacha_extractor.py). Fewer waves. Risk: timeout on cold cache (currently 1800s ceiling), and may degrade extraction precision for very long sections.\n\n3. Anthropic SDK direct with 5-min ephemeral prompt caching on the static instruction prefix (already wired the parameter as system= in claude_session.query). Estimated 5-10x faster because cache reads are ~10% of cold cost. Costs ~$0.30-2 per long ruling on Sonnet 4.6. Chair previously rejected this path for ALL traffic ('we work only with claude session'). Compromise: SDK only for the precedent-library corpus build (static, one-time), claude session for live decision drafting (interactive, frequent).\n\n4. Two-tier prompt: a short 'classification' pass with claude -p deciding which chunks contain halachot, then deep extraction only on positive chunks. Could cut total LLM time by 40-60% on rulings with lots of factual chapters.\n\n5. Already implemented (Apr 3, 2026): skip non-extractable sections — only run on chunks where section_type IN (legal_analysis, ruling, conclusion); fallback to all chunks when chunker labels nothing. So that win is already banked.\n\nRe-evaluate when: a chair drops a 200K+ char ruling into the queue and the wait becomes painful, OR when the precedent-library has 50+ pending entries and bulk processing matters.",
|
||||
"details": "נסקר 2026-06-03 — אין blocker נוכחי. הרצתי reindex ל-73 תקדימים + חילוצים מרובים בלי שמהירות הייתה כאב. YAGNI: לא מבצעים אופטימיזציה מוקדמת. נשאר deferred עם trigger ברור: פסק-דין 200K+ תווים שתוקע את התור, או 50+ פריטים ממתינים. ה-win הזול (concurrency 3→6) דורש אימות headroom של 1.8GB RSS ב-Nautilus לפני — לא עכשיו.",
|
||||
@@ -1170,7 +1170,7 @@
|
||||
"updatedAt": "2026-06-03T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"id": "15",
|
||||
"title": "Multimodal — כיוונון MULTIMODAL_TEXT_WEIGHT (A/B) + הכרעה על backfill",
|
||||
"description": "נסגר 2026-06-03 לאחר A/B אובייקטיבי על gold-set (86 שאילתות, eval_retrieval.py). הנחת היסוד התיישנה: multimodal כבר ברירת-מחדל בייצור (110 מסמכים מוטמעים אוטומטית בהעלאה), לא רק 2 תיקי A/B. ממצא: ה-weight 0.5 (ברירת-מחדל) היה mis-tuned — צד-התמונה כבד מדי וחתך recall של precedent_library (0.971→0.885). sweep 0.5→0.75: במשקל 0.65 multimodal מנצח את text-only בכל מדד ובכל corpus (R@5 0.994 מול 0.989; nDCG@5 0.960 מול 0.944; MRR 0.954 מול 0.936; precedent_library R@5 0.983, internal_decisions nDCG 0.978). כיסוי: 28/79 מסמכי gold-set מוטמעים multimodal (35% — אות אמיתי). דפנה אישרה.",
|
||||
"details": "בוצע: MULTIMODAL_TEXT_WEIGHT=0.65 הוגדר ב-Coolify env של legal-ai (runtime) + redeploy; baseline (data/eval/baseline.json) עודכן לקונפיג 0.65. ה-backfill היקר ל-140 התיקים ה-legacy *לא* בוצע — אין הצדקת-אחזור לשאלות טקסט, וערך ה-image-answer לא נבדק. מומר ל-#80 (מותנה). ראיות: data/eval/eval-report-20260603T08*.md, project_multimodal_stage_c.",
|
||||
@@ -1182,7 +1182,7 @@
|
||||
"updatedAt": "2026-06-03T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"id": "16",
|
||||
"title": "[Paperclip Gap 1] runtime_config ריק — חסרים graceSec/cooldownSec/maxConcurrentRuns",
|
||||
"description": "runtime_config = '{}' לכל 14 הסוכנים. מסתבר שעיקר ההגדרות החשובות (timeoutSec=3600, maxTurnsPerRun=500) יושבות ב-adapter_config ולא ב-runtime_config — אז המצב פחות חמור. אבל graceSec/cooldownSec/maxConcurrentRuns עדיין חסרים.",
|
||||
"details": "תיקון לניתוח המקורי שגוי בעקבות בדיקה ב-DB:\n\nמה שכן יש לנו (ב-adapter_config, לא runtime_config):\n- timeoutSec: 3600 (לכל הסוכנים)\n- maxTurnsPerRun: 500 (לכל הסוכנים)\n- model + effort=high (לכל הסוכנים)\n- paperclipSkillSync.desiredSkills (5/7 סוכנים — חסר אצל הגהת מסמכים ומנתח משפטי)\n\nמה שבאמת חסר ב-runtime_config:\n- heartbeat.graceSec — זמן grace לפני SIGKILL אחרי timeout. מהקוד: Math.max(1, graceSec)*1000. אם לא מוגדר → 1ms grace. בעיה אם הסוכן נחתך באמצע commit ל-DB.\n- heartbeat.cooldownSec — default ביצירה חדשה: 10. אצלנו לא מוגדר.\n- heartbeat.maxConcurrentRuns — default מ-AGENT_DEFAULT_MAX_CONCURRENT_RUNS (כנראה 1).\n- heartbeat.wakeOnDemand — default=true בקוד. אצלנו לא מוגדר אבל בפועל true.\n- heartbeat.enabled — default=false (timer off). זה הרצוי אצלנו.\n\nפעולה (Phase 1):\n1. עדכון runtime_config של כל סוכן: { heartbeat: { graceSec: 60, cooldownSec: 10, maxConcurrentRuns: 1, wakeOnDemand: true } }\n2. בעיקר graceSec — בלעדיו commit באמצע יכול להיכשל\n3. cooldownSec=10 (זהה לdefault ב-UI ליצירת agent חדש)\n\nהשפעה: minimal — רוב המקרים עובדים עם defaults. graceSec הוא העיקר.",
|
||||
@@ -1194,7 +1194,7 @@
|
||||
"updatedAt": "2026-05-04T07:47:02.008Z"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"id": "17",
|
||||
"title": "[Paperclip Gap 2] תקציבים = 0 לכל הסוכנים — אין budget enforcement",
|
||||
"description": "budget_monthly_cents = 0 ו-spent_monthly_cents = 0 לכל 14 הסוכנים. Paperclip מציע cost control מובנה — אנחנו מתעלמים.",
|
||||
"details": "ממצא: SELECT name, budget_monthly_cents, spent_monthly_cents FROM agents → הכל אפס.\n\nסיכון: לולאה חבויה יכולה לשרוף מאות $. אין auto-pause ב-80% spend (דפוס ש-CEO HEARTBEAT הרשמי מצפה לו).\n\nפעולה (Phase 3):\n1. מדידה: כמה כל סוכן באמת מוציא בחודש כיום (דרך לוגי claude-code, או Anthropic dashboard).\n2. הגדרת budget_monthly_cents סביר לכל סוכן (כותב Opus ≫ מנתח Sonnet).\n3. בדיקה שהמנגנון מפסיק כשמגיעים ל-100%.\n\nשאלה לחיים לפני ביצוע: באיזו רזולוציה למדוד? לפי Anthropic invoice, או לפי טוקנים בלוגים של claude_session?",
|
||||
@@ -1206,7 +1206,7 @@
|
||||
"updatedAt": "2026-05-04T10:18:08.046Z"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"id": "18",
|
||||
"title": "[Paperclip Gap 3] חסר X-Paperclip-Run-Id header בקריאות API",
|
||||
"description": "ה-skill הרשמי קובע: 'You MUST include -H X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on ALL API requests that modify issues'. ב-HEARTBEAT.md שלנו אין זכר לכך.",
|
||||
"details": "ממצא: grep -n 'X-Paperclip-Run-Id' .claude/agents/ → 0 hits. כל curl ב-checkout/comments/PATCH issues — בלי הheader.\n\nסיכון: audit trail שבור. שאלה 'איזו ריצה שינתה את ה-issue X?' אין לה תשובה ב-DB.\n\nפעולה (Phase 1):\n1. עדכון .claude/agents/HEARTBEAT.md — דוגמאות ה-curl יכללו את הheader\n2. עדכון 6 קבצי הסוכנים (legal-ceo.md, legal-analyst.md, legal-researcher.md, legal-writer.md, legal-qa.md, legal-exporter.md) — כל מקום שיש curl POST/PATCH\n3. בדיקה שיש env var $PAPERCLIP_RUN_ID זמין בכל heartbeat",
|
||||
@@ -1218,7 +1218,7 @@
|
||||
"updatedAt": "2026-05-04T08:49:44.646Z"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"id": "19",
|
||||
"title": "[Paperclip Gap 4] לא משתמשים ב-/api/issues/{id}/interactions לאישורים",
|
||||
"description": "Paperclip מציע API מובנה לאישור/שאלות (request_confirmation, ask_user_questions, suggest_tasks) עם idempotency keys ו-auto-wake. אנחנו עדיין כותבים 'חיים, מה לעשות?' כ-comment חופשי.",
|
||||
"details": "סוגי interaction:\n- ask_user_questions — שאלות מובנות\n- request_confirmation — yes/no עם idempotency key (confirmation:{issueId}:plan:{revisionId})\n- suggest_tasks — הצעת עץ משימות\n- continuationPolicy: wake_assignee — wake אוטומטי על מענה\n- supersedeOnUserComment: true — בטל אם חיים עונה\n\nסיכון: אין UI מובנה לחיים (כפתורים), רק טקסט. אם הסוכן מתעורר פעמיים — שתי שאלות זהות.\n\nפעולה (Phase 2):\n1. בlegal-ceo.md — להחליף 'אם חיים לא הגדיר outcome: שאל בcomment' ב-request_confirmation\n2. בbrainstorm_directions — suggest_tasks במקום רשימת bullet\n3. בlegal-qa.md — request_confirmation לאישור export\n\nשאלה לחיים: האם תרצה לראות UI חדש או להישאר ב-Markdown comments?",
|
||||
@@ -1234,7 +1234,7 @@
|
||||
"updatedAt": "2026-05-04T11:18:59.050Z"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"id": "20",
|
||||
"title": "[Paperclip Gap 5] לא משתמשים ב-PAPERCLIP_WAKE_PAYLOAD_JSON fast-path",
|
||||
"description": "בwake שמכוון ל-issue ספציפי, ה-env var מכיל כבר issue summary + comments חדשים דחוסים. ה-skill הרשמי אומר 'skip Steps 1-4 entirely'. שלנו תמיד fetcher גם ה-API.",
|
||||
"details": "ממצא: HEARTBEAT.md סעיפים 2-2c תמיד פונים ל-API גם אם ה-payload כבר מכיל את הכל.\n\nתועלת: חיסכון 3-4 קריאות API לכל ריצה. בwakeups תכופים (CEO על comments) — חיסכון ניכר.\n\nפעולה (Phase 2):\n1. הוספה ל-HEARTBEAT.md בראש הסעיפים: 'אם $PAPERCLIP_WAKE_PAYLOAD_JSON קיים — קרא אותו ראשון. רק אם fallbackFetchNeeded:true או חסר הקשר רחב — fetch'.\n2. דוגמה לפענוח JSON: jq עם key paths\n3. בדיקה איזה wake reasons בכלל מקבלים payload (כנראה comment-driven בלבד)",
|
||||
@@ -1248,7 +1248,7 @@
|
||||
"updatedAt": "2026-05-04T09:15:46.339Z"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"id": "21",
|
||||
"title": "[Paperclip Gap 6] שאילתות psql ישירות ל-issue_attachments — שובר אבסטרקציה",
|
||||
"description": "HEARTBEAT.md סעיף 2c משתמש ב-psql ישיר ל-issue_attachments + assets. אם schema ישתנה (כפי שצפוי בעדכוני Paperclip) — כל הסוכנים נשברים.",
|
||||
"details": "ממצא: 6 קבצי סוכן + HEARTBEAT.md מכילים PGPASSWORD=paperclip psql ... FROM issue_attachments ia JOIN assets a.\n\nסיכון: breakage בעדכון Paperclip. כפילות לוגיקה (copy-paste בכל סוכן).\n\nפעולה (Phase 2):\n1. בדיקה אם קיים endpoint רשמי /api/issues/{id}/attachments (curl + grep ב-server/src/routes)\n2. אם כן — להחליף את כל ה-psql\n3. אם לא — להעביר את ה-psql למקום יחיד: helper ב-mcp-server (mcp__legal-ai__list_issue_attachments tool)\n4. אופציה ג: לפתוח issue ב-paperclipai/paperclip לבקש endpoint\n\nתלוי במחקר API.",
|
||||
@@ -1262,7 +1262,7 @@
|
||||
"updatedAt": "2026-05-04T09:28:18.058Z"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"id": "22",
|
||||
"title": "[Paperclip Gap 7] לא משתמשים ב-/api/issues/{id}/heartbeat-context",
|
||||
"description": "Endpoint רשמי שמחזיר issue + ancestors + goal/project + comment cursor בקריאה אחת. אנחנו עושים 3 קריאות נפרדות.",
|
||||
"details": "ה-skill הרשמי: 'Prefer GET /api/issues/{issueId}/heartbeat-context first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.'\n\nשלנו: HEARTBEAT.md סעיפים 2 + 2b → שלוש קריאות (inbox-lite, issue, comments).\n\nפעולה (Phase 2):\n1. הוספת endpoint כצעד 6 ב-HEARTBEAT.md לפני 'Do the work'\n2. הסרת קריאות מיותרות שכבר ב-context\n3. שמירת comment cursor (after={last-seen-id}) לקריאות עוקבות",
|
||||
@@ -1276,7 +1276,7 @@
|
||||
"updatedAt": "2026-05-04T09:28:14.247Z"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"id": "23",
|
||||
"title": "[Paperclip Gap 8+11] HEARTBEAT.md ארוך + אין שימוש ב-skills של Paperclip",
|
||||
"description": "HEARTBEAT.md שלנו 220 שורות (vs upstream 85). Paperclip מציע 8 skills מוכנים (paperclip, paperclip-create-agent, וכו') שאנחנו לא משתמשים באף אחד.",
|
||||
"details": "תיקון לניתוח: מסתבר ש-CEO + 4 סוכנים אחרים כן משתמשים ב-paperclipSkillSync עם 4 paperclip skills (paperclip, paperclip-create-agent, paperclip-create-plugin, para-memory-files). חסר אצל: הגהת מסמכים ומנתח משפטי (skills_count=0).\n\nממצא: ls skills/ ב-paperclip repo → 8 skills. שלנו: 0 skills של Paperclip בשימוש.\n\nרלוונטיים לנו:\n- paperclip — API patterns + heartbeat checklist (יכול להחליף חלק מ-HEARTBEAT.md)\n- paperclip-create-agent — אם נוסיף סוכן\n- paperclip-create-plugin — לעדכוני plugin-legal-ai\n- paperclip-converting-plans-to-tasks — יכול להחליף brainstorm_directions\n- diagnose-why-work-stopped — לתחזוקה\n\nפעולה (Phase 3):\n1. קריאת skills/paperclip/SKILL.md מלא\n2. הזרקת skill לסביבת הסוכנים (כנראה דרך CLI: paperclipai agent local-cli)\n3. שכתוב HEARTBEAT.md לפי הדפוס: project-specific only, delegation לskill הרשמי לכלל ה-API\n4. יעד: ~120 שורות ב-HEARTBEAT.md שלנו\n\nשאלה לחיים: האם להזריק skills כסימלינקים ל-symlinks קיימים, או דרך paperclipai CLI?",
|
||||
@@ -1296,7 +1296,7 @@
|
||||
"updatedAt": "2026-05-04T16:44:27.553Z"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"id": "24",
|
||||
"title": "[Paperclip Gap 9] לבדוק bootstrapPromptTemplate deprecated באף סוכן",
|
||||
"description": "מ-docs/agents-runtime.md: 'bootstrapPromptTemplate is deprecated... should be migrated to the managed instructions bundle system.' לבדוק האם adapter_config שלנו משתמש בזה.",
|
||||
"details": "פעולה (Phase 1):\n1. SELECT name, adapter_config->'promptTemplate' as pt, adapter_config->'bootstrapPromptTemplate' as bpt FROM agents WHERE adapter_type = 'claude_local';\n2. אם בשימוש אצל סוכן כלשהו — מיגרציה למבנה החדש\n3. ייעוד: לבדוק תיעוד managed instructions bundle ב-paperclip docs\n\nהערה: זה כנראה לא ישפיע אצלנו (אנחנו משתמשים ב-symlinks ל-AGENTS.md/HEARTBEAT.md ישירות) — אבל חובה לוודא.",
|
||||
@@ -1308,7 +1308,7 @@
|
||||
"updatedAt": "2026-05-04T08:19:27.766Z"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"id": "25",
|
||||
"title": "[Paperclip Gap 10] סוכנים מוכפלים בין 2 חברות — אין סנכרון",
|
||||
"description": "14 שורות = 7 סוכנים × 2 חברות (1xxx, 8xxx). כל שינוי בהגדרות הסוכן צריך להיעשות פעמיים. אין מנגנון סנכרון או הורשה.",
|
||||
"details": "ממצא: SELECT name, COUNT(*) FROM agents GROUP BY name → 2 לכל אחד.\n\nסיכון: drift בין החברות. שינוי runtime_config ל-CEO של 1xxx יכול לפספס את CEO של 8xxx.\n\nפעולה (Phase 3):\n1. בדיקה: האם Paperclip תומך ב-shared agents או chainOfCommand? (לקרוא docs/companies/)\n2. אם כן — מיגרציה למבנה משותף\n3. אם לא — סקריפט סנכרון: scripts/sync_agents_across_companies.py שמעתיק כל שינוי מחברה לחברה\n\nשאלה לחיים: בעתיד אם יהיו עוד סוגי ערר (10xxx?) — להוסיף עוד חברה או להשאיר 2?",
|
||||
@@ -1322,7 +1322,7 @@
|
||||
"updatedAt": "2026-05-04T09:52:14.263Z"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"id": "26",
|
||||
"title": "[Paperclip Gap 12] עדכון @paperclipai/plugin-sdk + capabilities חדשות",
|
||||
"description": "ה-plugin שלנו: @paperclipai/plugin-sdk@^2026.325.0, apiVersion: 1, minimumHostVersion: 2026.325.0. ה-host: 2026.428.0. ייתכן capabilities חדשות (issue.interactions.create, וכו').",
|
||||
"details": "פעולה (Phase 4 — אחרי שדרוג Paperclip stable):\n1. cd /home/chaim/plugin-legal-ai && npm view @paperclipai/plugin-sdk version\n2. אם חדשה: npm install @paperclipai/plugin-sdk@latest\n3. קריאת adapter-plugin.md המעודכן ב-paperclip repo\n4. בדיקה אם apiVersion: 2 קיים\n5. הוספת capabilities חדשות אם רלוונטי (בעיקר issue.interactions.create אחרי gap #4)\n6. npm run build && reinstall plugin\n\nתלוי בgap #19 (interactions API) — אם אנחנו רוצים שהplugin יוכל ליצור interactions, חייב capability חדש.",
|
||||
@@ -1337,7 +1337,7 @@
|
||||
"updatedAt": "2026-05-26T12:19:16.180163Z"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"id": "27",
|
||||
"title": "[Paperclip Phase 4] שדרוג Paperclip לגרסה stable הבאה (לא 2026.428.0)",
|
||||
"description": "כרגע אנחנו על 2026.428.0 — הגרסה היציבה האחרונה. כשיופיע stable חדש (כנראה 2026.5xx.x), לבצע שדרוג מבוקר.",
|
||||
"details": "טריגר: npm view paperclipai dist-tags.latest מחזיר משהו ≠ 2026.428.0.\n\nפעולה:\n1. קריאת releases/v2026.5xx.x.md ב-GitHub\n2. בדיקת שינויים שעלולים להשפיע (CUSTOMIZATIONS.md סעיפים: hebrew, RTL, plugin driver, heartbeat)\n3. גיבוי: pg_dump של paperclip DB + cp -r ~/.npm/_npx/43414d9b790239bb /tmp/\n4. pm2 stop paperclip\n5. rm -rf ~/.npm/_npx/43414d9b790239bb\n6. npx paperclipai@latest run (יוריד גרסה חדשה)\n7. הרצה מחדש: ~/.paperclip/hebrew/apply-hebrew.sh && ~/.paperclip/issue-link-fix/apply-issue-link-fix.sh\n8. pm2 restart paperclip\n9. בדיקה ב-pc.nautilus.marcusgroup.org: עברית + plugin פעיל + סוכן מתעורר על comment\n\nתלוי בלי dependencies (יכול להיות מבוצע בכל עת אחרי שיש stable חדש).",
|
||||
@@ -1349,7 +1349,7 @@
|
||||
"updatedAt": "2026-05-26T12:19:16.180163Z"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"id": "28",
|
||||
"title": "[Paperclip Auxiliary] להפעיל skill-sync ל-2 סוכנים שפיספסו",
|
||||
"description": "הגהת מסמכים ומנתח משפטי לא קיבלו אף פעם revision מסוג skill-sync (לעומת 5 האחרים שכן). לבצע sync.",
|
||||
"details": "ממצא: בדיקה ב-agent_config_revisions:\n- עוזר משפטי: 3 skill-sync revisions (יש 7 skills)\n- חוקר תקדימים: 3 (יש 5)\n- מייצא טיוטה: 5 (יש 5)\n- בודק איכות: 1 (יש 5)\n- כותב החלטה: 1 (יש 5)\n- הגהת מסמכים: 0 (יש 0) ❌\n- מנתח משפטי: 0 (יש 0) ❌\n\nאופציות:\n1. UI: agent settings → 'sync skills'\n2. API: POST /api/agents/{id}/skills-sync (לאתר)\n3. CLI: paperclipai agent skill-sync (לבדוק אם קיים)\n4. SQL ידני (לא מומלץ — דורף revision tracking)\n\nSkills להעתקה (לפי בודק איכות):\n- paperclipai/paperclip/paperclip\n- paperclipai/paperclip/paperclip-create-agent\n- paperclipai/paperclip/paperclip-create-plugin\n- paperclipai/paperclip/para-memory-files\n- (אופציונלי) local/eba6210d5a/legal-decision",
|
||||
@@ -1361,7 +1361,7 @@
|
||||
"updatedAt": "2026-05-04T09:46:32.092Z"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"id": "29",
|
||||
"title": "[legal-ai UI] מסך הגדרות סוכנים — הצגה + עריכה + שמירה",
|
||||
"description": "מסך אדמין ב-legal-ai UI שמציג את כל הגדרות הסוכנים (model, timeout, runtime_config, skills, budget) ומאפשר עריכה ושמירה. מונע SQL ישיר.",
|
||||
"details": "מטרה: ממשק אדמין מרכזי במקום שעריכה תהיה רק ב-UI של Paperclip + SQL ישיר + CUSTOMIZATIONS.md.\n\nשדות (לכל סוכן × 2 חברות):\n1. adapter_config: model, effort, timeoutSec, maxTurnsPerRun, extraArgs[], paperclipSkillSync.desiredSkills[]\n2. runtime_config.heartbeat: graceSec, cooldownSec, wakeOnDemand, maxConcurrentRuns, enabled, intervalSec\n3. budget_monthly_cents (לקראת gap #2)\n4. status / pause_reason (קריאה + כפתור pause/resume)\n\nאופציות מימוש:\nA. עמוד חדש ב-legal-ai/web-ui (Next.js 16) — קורא Paperclip DB דרך FastAPI endpoint חדש (/api/admin/paperclip-agents)\nB. קריאה ל-Paperclip API (/api/companies/{id}/agents) — REST טהור, פחות שדות זמינים\nC. iframe ל-Paperclip UI — שטחי\n\nהמלצה: A. שולט מלא + ולידציה משפטית (timeoutSec >= 1800 כי OCR).\n\nתלוי ב: gap #25 (סוכנים מוכפלים) — אם נעבור לshared, המסך יתאים.\n\nשאלות פתוחות לחיים:\n- auth: מי יכול לגשת? (כיום אין auth ב-legal-ai)\n- bulk edit ל-2 חברות יחד או נפרד?\n- חשיפת skill marketplace (להוסיף/להוריד skills) או רק קריאה?",
|
||||
@@ -1377,7 +1377,7 @@
|
||||
"updatedAt": "2026-05-04T17:29:25.686Z"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"id": "30",
|
||||
"title": "תיקון 3 baגים בקטלוג (practice_area + source_kind + upload route)",
|
||||
"description": "CRITICAL: 3 sub-bugs. (א) יצירת תיקים מתייגת practice_area='appeals_committee' במקום rishuy_uvniya/betterment_levy/compensation_197 לפי קידומת מספר התיק (1xxx/8xxx/9xxx) — audit + migration לכל התיקים הקיימים + תיקון נתיב היצירה. (ב) כל החלטה של ועדת ערר שהועלתה ל-case_law מסומנת כ-source_kind='external_upload' במקום 'internal_committee' — audit ל-case_law עם case_number שמתחיל ב'ערר' → reclassify + מילוי chair_name + district רטרואקטיבית. (ג) POST /api/precedent-library/upload לא מבחין — תיקון: ניתוב לפי תחילית הציטוט (ערר/ועדות ערר → internal_committee, אחרת → external_upload).",
|
||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #1. Pre-requirement: השתמש במחיקה+rerun של halachot אחרי שינוי source_kind. השתמש בpattern של internal_decisions.py (dry_run+log_action).",
|
||||
@@ -1447,7 +1447,7 @@
|
||||
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"id": "31",
|
||||
"title": "מיצוי chair_name + district בהעלאת ועדת ערר",
|
||||
"description": "תוספת לטופס + חילוץ אוטומטי מהציטוט/text הפסיקה. רטרואקטיבי לכל הרשומות הקיימות עם source_kind='internal_committee' שהשדות בהן ריקים.",
|
||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #2. תלוי במשימה #30 (sub-bug ב).",
|
||||
@@ -1483,7 +1483,7 @@
|
||||
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"id": "32",
|
||||
"title": "UI — דף עריכת פסיקה ייפתח רחב-במרכז (לא צר-בצד)",
|
||||
"description": "חוסר נוחות בעריכה. שינוי ה-Dialog/Sheet ל-Modal רחב מרכזי. רלוונטי גם להוספת שדות chair_name + district מהמשימה #31.",
|
||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #3.",
|
||||
@@ -1495,7 +1495,7 @@
|
||||
"updatedAt": "2026-05-26T10:38:07.071897Z"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"id": "33",
|
||||
"title": "UI — הסתרת עמודת 'שם' (case_name) בדף רשימת פסיקה",
|
||||
"description": "רוב הערכים זהים למספר התיק. להסתיר את העמודה ב-UI, לשמור עמודה ב-DB לשימוש עתידי.",
|
||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #4.",
|
||||
@@ -1507,7 +1507,7 @@
|
||||
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"id": "34",
|
||||
"title": "חילוץ ציטוטי-פנים מהחלטות דפנה (internal_committee + ירושלים)",
|
||||
"description": "פאטרן: 'ונפנה להחלטות של ועדת ערר זו...', 'כפי שקבעתי בערר X', 'בדומה לעמדתי בהחלטה Y'. חילוץ אוטומטי + שמירה ב-precedent_internal_citations table שיאפשר ל-search_internal_decisions להחזיר גם את הפסיקה המוזכרת.",
|
||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #5. תלוי במשימה #30 (sub-bug ב) ובמשימה #31.",
|
||||
@@ -1522,7 +1522,7 @@
|
||||
"updatedAt": "2026-05-26T10:38:07.071897Z"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"id": "35",
|
||||
"title": "דף/דוח 'פסיקה חסרה בקורפוס' — פיצ'ר מלא",
|
||||
"description": "טבלת DB missing_precedents (id, citation, case_name, cited_in_case_id, cited_in_document_id, cited_by_party, legal_topic, legal_issue, claim_quote, status, linked_case_law_id, closed_at, created_at), API endpoints (POST/GET/upload/PATCH), MCP tools (missing_precedent_create/list/close), דף Next.js /missing-precedents, הוק אוטומטי במחקר ע\"י legal-researcher.",
|
||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #6.",
|
||||
@@ -1571,7 +1571,7 @@
|
||||
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"id": "36",
|
||||
"title": "כינוס פרופוזיציות לטיעונים משפטיים אמיתיים (de-dup/aggregation)",
|
||||
"description": "extract_claims מחזיר ~60 פרופוזיציות לתיק, צריך לאגד ל-~10 טיעונים משפטיים אמיתיים. טבלה חדשה legal_arguments + טבלת קישור legal_argument_propositions (M:M ל-case_claims). LLM aggregation job (Hermes/DeepSeek). API + MCP + UI display שמציג 'X טיעונים משפטיים' במקום 'Y טענות'.",
|
||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #7.",
|
||||
@@ -1614,7 +1614,7 @@
|
||||
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"id": "37",
|
||||
"title": "הפרדת תתי-סוגי בל\"מ לפי practice_area",
|
||||
"description": "3 ערכי appeal_subtype חדשים: extension_request_building_permit (1xxx, ס'152 - 30 ימים), extension_request_betterment_levy (8xxx, ס'14 לתוספת ג' - 45 ימים), extension_request_compensation (9xxx, ס'198(ד) - 30 ימים). 3 templates מתודולוגיים נפרדים. אוטו-זיהוי מהsubject. UI badge + filter.",
|
||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #8. Pre-requirement: עדכון mcp-server/src/legal_mcp/services/practice_area.py APPEALS_COMMITTEE_SUBTYPES + עדכון web/paperclip_client.py mapping appeal_subtype → company.",
|
||||
@@ -1657,7 +1657,7 @@
|
||||
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"id": "38",
|
||||
"title": "שדרוג סוכני Paperclip להכרת השינויים מ-#30-#37",
|
||||
"description": "עדכון 7 הגדרות סוכן (CEO/analyst/researcher/writer/QA/proofreader/exporter) + HEARTBEAT.md לזיהוי המבנים החדשים. בלי זה כל הפיצ'רים נשארים זמינים אבל הסוכנים לא יודעים להשתמש בהם. כולל הוספת research_complete כ-valid case_status.",
|
||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #9. תלוי במשימות #30-#37.",
|
||||
@@ -1740,7 +1740,7 @@
|
||||
"updatedAt": "2026-05-26T07:41:47.880478Z"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"id": "39",
|
||||
"title": "[ROOT CAUSE] MCP tool חדש: internal_decision_upload",
|
||||
"description": "הוספת @mcp.tool() עם chair_name+district חובה ו-source_kind='internal_committee' אוטומטי. סוגר את ה-root cause של Bug (ב) ב-#30. בלעדיו 44 רשומות חדשות יחזרו כ-external_upload תוך חודש.",
|
||||
"details": "מיקום: mcp-server/src/legal_mcp/tools/internal_decisions.py (אם לא קיים — ליצור). רישום ב-server.py סביב שורה 169 (ליד precedent_library_upload). הקריאה מנותבת ל-int_decisions_service.ingest_internal_decision (קיים ב-internal_decisions.py).",
|
||||
@@ -1754,7 +1754,7 @@
|
||||
"updatedAt": "2026-05-26T07:41:37.260868Z"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"id": "40",
|
||||
"title": "[שלב B - ROI מיידי] הפעלת VOYAGE_RERANK_ENABLED=true ב-Coolify",
|
||||
"description": "Cross-encoder rerank-2 ממומש ב-mcp-server/src/legal_mcp/services/rerank.py אבל כבוי בייצור (default=false). POC הוכיח +4.5% mean@3 ו-+11.6% practical queries (latency +702ms acceptable לזרימה האסינכרונית). 5 דקות עבודה — env change ב-Coolify.",
|
||||
"details": "mcp__coolify__env_vars set VOYAGE_RERANK_ENABLED=true. ראה web/mcp_env_catalog.py:71-72 לdescription. אופציה: rampup רק על search_precedent_library (לא על find_similar_cases — latency-sensitive).",
|
||||
@@ -1766,7 +1766,7 @@
|
||||
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"id": "41",
|
||||
"title": "[שלב B] BM25/tsvector hybrid retrieval על precedent_chunks + halachot",
|
||||
"description": "כיום כל החיפוש הוא 100% dense (cosine). ציטוטים מספריים ('עע\"מ 1461/20') נכשלים כי semantic לא מצליח בהם. הוספת tsvector GIN + RRF merge dense+lexical = +15-25% recall על ציטוטים — קריטי לאימות פסיקה ב-3-glimmering-oasis שלב 3.",
|
||||
"details": "ALTER TABLE precedent_chunks ADD COLUMN content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED; CREATE INDEX ... USING gin (content_tsv). באותו אופן על halachot.rule_statement. ב-db.py:2357 (search_precedent_library_semantic) — להוסיף שאילתה מקבילה של websearch_to_tsquery → RRF merge עם cosine. אזהרה: postgres אינו תומך ב-'hebrew' config — simple config יעבוד אבל בלי stemming.",
|
||||
@@ -1780,7 +1780,7 @@
|
||||
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"id": "42",
|
||||
"title": "[שלב B] Query expansion via Claude Haiku — 2-3 variants per query",
|
||||
"description": "שאילתות עם abbreviations משפטיות ('בל\"מ'/'בקשה להארכת מועד') חוטפות recall. LLM expansion: שאילתה → 2-3 variants → union retrieval. +10-15% recall.",
|
||||
"details": "בוטל 2026-06-03 — obviated. BM25_HYBRID_ENABLED=true כבר פעיל ותופס קיצורים לקסיקלית (בל\"מ כ-token). ב-gold-set (86 שאילתות) 0 שאילתות-קיצורים, ו-recall כללי ≈0.99 — אין gap נמדד. Query-expansion דרך LLM מוסיף latency+עלות לכל שאילתה ללא צורך מוכח (YAGNI). re-open trigger: אם eval ייעודי על שאילתות-קיצורים יראה recall<0.9.",
|
||||
@@ -1794,7 +1794,7 @@
|
||||
"updatedAt": "2026-06-03T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"id": "43",
|
||||
"title": "[שלב B] MMR / diversity penalty — limit 2 chunks per case_law_id",
|
||||
"description": "תוצאות חיפוש דומות מאוד זו לזו (אותה פסיקה, chunks סמוכים) — פסיקות חוזרות תופסות slots → diversity@10 נמוך. הוספת cap per case_law_id (2-3 max) או MMR אמיתי.",
|
||||
"details": "פתרון קל: SQL DISTINCT ON (case_law_id) + 2 בpost-processing. פתרון איכותי: MMR — לכל candidate, score = λ*relevance - (1-λ)*max_similarity_to_selected. λ=0.7 דיפולט.",
|
||||
@@ -1808,7 +1808,7 @@
|
||||
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"id": "44",
|
||||
"title": "[שלב B] HNSW migration (or lists=68 IVFFlat) + REINDEX",
|
||||
"description": "IVFFlat lists=50 עם 4,595 vectors — sub-optimal. sqrt(4595)≈68. HNSW עדיף ל-recall (אבל יותר זיכרון). שיפור +3-5% recall@10.",
|
||||
"details": "אופציה 1: REINDEX עם lists=68 (פשוט, idempotent). אופציה 2: DROP+CREATE עם HNSW (m=16, ef_construction=64) — דורש pgvector ≥0.5 ובדיקת זמן build. בדוק SELECT extversion FROM pg_extension WHERE extname='vector'.",
|
||||
@@ -1820,7 +1820,7 @@
|
||||
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"id": "45",
|
||||
"title": "[שלב B] Halacha auto-approve sweep — בדיקת 219 pending + הורדת סף ל-0.78",
|
||||
"description": "219 halachot pending review (17%) חסומות מ-search. אם dafna לא מסקר ידנית — הם מתבזבזים. dashboard batch + הורדת auto-approve threshold.",
|
||||
"details": "1. בדוק 20 דגימות אקראיות של pending — אם רובן ראויות לאישור, הורד HALACHA_AUTO_APPROVE_THRESHOLD מ-0.80 ל-0.78. 2. הוסף UI batch approval ב-/halachot עם filter pending+confidence>0.75. 3. one-shot SQL לאישור 200 halachot שעמדו בקריטריונים החדשים.",
|
||||
@@ -1832,7 +1832,7 @@
|
||||
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"id": "46",
|
||||
"title": "[שלב B] Dynamic halacha boost — לפי query-rule similarity",
|
||||
"description": "כיום halacha boost = +0.05 קבוע. דינמי לפי query similarity ירוץ דייקנות (5% precision על שאילתות ספציפיות).",
|
||||
"details": "ב-db.py:2479 — score = float(d['score']) + 0.05. החלף ב-boost = 0.10 * d['score'] (proportional). או — אם rerank ON, השתמש בrerank score כbaseline (אין צורך ב-boost כלל).",
|
||||
@@ -1846,7 +1846,7 @@
|
||||
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"id": "47",
|
||||
"title": "[שלב C - prevention] Audit script periodic: detect new external_upload עם case_number של ערר",
|
||||
"description": "Drift detection: שגיאה דומה ל-Bug (ב) יכולה לחזור בעתיד. periodic check (יומי?) + alert ל-Slack/comment.",
|
||||
"details": "scripts/audit_corpus_consistency.py — בודק: 1. case_law WHERE source_kind='external_upload' AND case_number ~ '^ערר|^ARAR'. 2. case_law WHERE source_kind='internal_committee' AND chair_name IS NULL. הרצה דרך cron או scheduled task ב-Paperclip.",
|
||||
@@ -1861,7 +1861,7 @@
|
||||
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"id": "48",
|
||||
"title": "[שלב C] Parent-doc retrieval (child=300, parent=1500 tokens)",
|
||||
"description": "chunk_size=600 חותך חלק מהלכות ארוכות. parent-doc: חיפוש על child קטן (300 tokens), החזרת parent גדול (1500 tokens) ל-LLM context.",
|
||||
"details": "מיגרציה DB: precedent_chunks.parent_chunk_id (FK self). chunking pipeline משתנה ל-2 רמות. retrieval: SELECT distinct parent_chunk WHERE child_chunk matches.",
|
||||
@@ -1875,7 +1875,7 @@
|
||||
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"id": "49",
|
||||
"title": "[שלב C] Multimodal backfill ל-77 רשומות שנותרו",
|
||||
"description": "כיום 40/117 precedent_image_embeddings (34%). 77 רשומות נותרו ללא image embeddings. ערך נמוך כשהמסמכים digital-native, אבל קריטי לscanned PDFs.",
|
||||
"details": "scripts/multimodal_backfill.py כבר קיים. להריץ עם batch size 10 כדי לא לדפוק את Voyage rate limits. אומדן: 77×~10K tokens = ~770K tokens ($10-15).",
|
||||
@@ -1887,7 +1887,7 @@
|
||||
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"id": "50",
|
||||
"title": "[שלב C] Closed-loop retrieval feedback + ndcg dashboard",
|
||||
"description": "אין tracking של 'what was retrieved → what writer cited'. בלי זה — אי אפשר לעדכן את ה-RAG בצורה מדודה לאורך זמן.",
|
||||
"details": "טבלה חדשה retrieval_feedback (query, candidates_retrieved JSONB, cited_in_final_decision UUID[], created_at). hooks ב-writer לדווח. dashboard חודשי עם ndcg@10.",
|
||||
@@ -1899,7 +1899,7 @@
|
||||
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"id": "51",
|
||||
"title": "[שלב C] Halacha quality monitoring — confidence drift, alert",
|
||||
"description": "אם prompt או model משתנה — confidence distribution יכול לזוז. בלי monitoring — דרדור איכות עובר תחת הראדר.",
|
||||
"details": "scheduled job: weekly mean confidence per practice_area. אם זז ביותר מ-0.05 — alert. dashboard ב-/halachot עם histogram.",
|
||||
@@ -1911,7 +1911,7 @@
|
||||
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"id": "52",
|
||||
"title": "[Retrieval RC-A] הוספת case_name + case_number ל-tsvector הלקסיקלי",
|
||||
"description": "השורש האמיתי לכך שסוכן לא מאתר החלטה לפי שם (אגסי). ה-tsvector הלקסיקלי (SCHEMA_V12_SQL ב-db.py) בנוי רק מ-precedent_chunks.content ומ-halachot rule/quote/reasoning — לא משם התיק/הצד או ממספר התיק. לכן שאילתת-שם מחזירה את מי שמצטט את ההחלטה, לא את ההחלטה עצמה. לשלב את case_law.case_name + case_number באינדקס הלקסיקלי (tsvector ייעודי על case_law או setweight) כך שחיפוש לפי שם יפגע ברשומה עצמה.",
|
||||
"status": "done",
|
||||
@@ -1923,7 +1923,7 @@
|
||||
"updatedAt": "2026-05-30T11:05:36.307Z"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"id": "53",
|
||||
"title": "[Retrieval RC-B] חיפוש/רשימה מאוחדים — לא לחתוך internal_committee",
|
||||
"description": "החלטות ערר/בל\"מ שמועלות נשמרות source_kind='internal_committee'. precedent_library_list ברירת מחדל external_upload ומסתיר אותן; כלי ה-MCP precedent_library_list אפילו לא חושף פרמטר source_kind, כך שסוכן לעולם לא יכול לדפדף בהן. לחשוף source_kind/all_committees בכלי ה-MCP ובמידת הצורך לאחד את שכבת ה-list/search.",
|
||||
"status": "done",
|
||||
@@ -1937,7 +1937,7 @@
|
||||
"updatedAt": "2026-05-30T11:09:44.511Z"
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"id": "54",
|
||||
"title": "[Retrieval RC-3] הנחיית סוכנים — איתור לפי שם + שני קורפוסים",
|
||||
"description": "לעדכן הנחיות legal-analyst/researcher/writer: לאיתור החלטה ספציפית לפי שם להוסיף מונחי תוכן או מספר תיק, ולחפש בשני הקורפוסים (search_internal_decisions + search_precedent_library) לפני שמסיקים 'לא קיים בקורפוס'. כולל יצירת missing_precedent רק אחרי חיפוש כפול.",
|
||||
"status": "done",
|
||||
@@ -1951,7 +1951,7 @@
|
||||
"updatedAt": "2026-05-30T11:12:44.727Z"
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"id": "55",
|
||||
"title": "[Retrieval RC-4] תיקון chunking — פרגמנטים זעירים",
|
||||
"description": "בתוצאות החיפוש מופיעים chunks של מילה-שתיים ('דיון','דיון וב','סיכום ו') כתוצאות מובילות. מציפים תוצאות ומורידים דירוג תוכן אמיתי. לחקור את chunker.py (פיצול לפי כותרת-סעיף שיוצר chunks ריקים) ולתקן: מינימום אורך chunk / מיזוג כותרת לגוף.",
|
||||
"status": "done",
|
||||
@@ -1965,7 +1965,7 @@
|
||||
"updatedAt": "2026-05-30T11:19:23.923Z"
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"id": "56",
|
||||
"title": "[Retrieval finding] halacha_filters לא מסננים source_kind — דליפה חוצת-קורפוסים",
|
||||
"description": "התגלה תוך כדי משימה 53. ב-search_precedent_library_semantic וב-search_precedent_library_lexical (db.py): chunk_filters כוללים cl.source_kind=$sk אבל halacha_filters כוללים רק review_status. תוצאה: search_precedent_library(external) מחזיר גם הלכות internal_committee, ו-search_internal_decisions(internal) מחזיר גם הלכות external. אי-עקביות: chunks מסוננים, halachot לא. כרגע זה דווקא מסייע למציאוּת (לכן לא רגרסיה), אבל לא עקבי. דורש החלטת מדיניות: או לסנן halachot גם לפי source_kind (עקבי, אך 'מסתיר' שכבות), או להשאיר מאוחד במכוון + לתעד. אם משאירים מאוחד — לעדכן docstrings של שני הכלים שזה לא 'corpus נפרד'.",
|
||||
"status": "cancelled",
|
||||
@@ -1977,7 +1977,7 @@
|
||||
"updatedAt": "2026-05-30T11:09:30.257989+00:00"
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"id": "57",
|
||||
"title": "[Retrieval #55 follow-up] re-chunk+re-embed של פסיקה שהוטמעה לפני תיקון ה-chunker",
|
||||
"description": "משימה 55 תיקנה את ה-chunker (עיגון כותרות + מיזוג) ומסננת את 484 הפרגמנטים בזמן query. הרמדיאציה המלאה: re-chunk מ-full_text השמור (ללא re-OCR — תואם feedback_no_reocr_retrofit) + re-embed, כדי שהתוכן יהיה נכון ולא רק מוסתר. נדחה כי זו מיגרציית-נתונים עם עלות Voyage API על ~13+ תיקים — דורש אישור עלות מ-chaim לפני הרצה. לבדוק כמה תיקים מושפעים (יש להם chunk<50) ולהריץ בקבוצות.",
|
||||
"status": "done",
|
||||
@@ -1991,7 +1991,7 @@
|
||||
"updatedAt": "2026-06-03T07:56:21.688Z"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"id": "58",
|
||||
"title": "[Case access] get_case_by_number שביר לפורמט — סוכן 'עיוור' למסמכי תיק",
|
||||
"description": "דווח ע\"י chaim: סוכן כתב שחסרים מסמכי תיק כי document_list החזיר ריק, אך המסמכים קיימים. שורש: get_case_by_number (db.py) עושה 'WHERE case_number=$1' התאמה מדויקת בלבד. אומת — 8137-24 מחזיר 9 מסמכים, אבל 8137/24 / 'ערר 8137-24' / רווחים / zero-pad → 'תיק לא נמצא'. הסוכן מקבל את המספר בפורמט שונה (כותרת issue, לוכסן, תחילית ערר/בל\"מ) → התאמה נכשלת → 'אין מסמכים'. משפיע על כל הכלים מבוססי case_number (document_list, extract_references, search_case_documents, get_claims, draft, וכו'). תיקון: נורמליזציה (strip prefix לתחילת ספרה, trim, '/'→'-') + fallback בשאילתה. תיקון נקודה-אחת מתקן את כל הכלים.",
|
||||
"status": "done",
|
||||
@@ -2003,7 +2003,7 @@
|
||||
"updatedAt": "2026-05-30T11:54:34.291Z"
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"id": "59",
|
||||
"title": "[FU-1] איחוד מסלול ה-ingest למסלול קנוני אחד",
|
||||
"description": "מאחד את ingest_precedent ו-ingest_internal_decision למסלול קנוני יחיד; מבטל את האסימטריות.",
|
||||
"details": "מכסה GAP-01,02,04,05. מספק INV-ING1/ING3/G2/G4. severity: Critical. סוג: קוד. יסוד — FU-2/FU-3/FU-7 תלויים בו. מקור: docs/spec/gap-audit.md + 01-ingest.md.",
|
||||
@@ -2056,7 +2056,7 @@
|
||||
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"id": "60",
|
||||
"title": "[FU-2a] ingest idempotent + נרמול-בכתיבה + searchable (pure-code)",
|
||||
"description": "upsert ON CONFLICT על מפתח קנוני + נרמול case_number בכתיבה (type-aware) + דגל searchable מפורש. אפס מיגרציית-נתונים.",
|
||||
"details": "מכסה GAP-03,06,13. מספק INV-ING2/G3/G1/ID1/DM1. severity: Critical. סוג: pure-code (schema-additive). תלוי ב-FU-1 (#59). FU-2b (#67) מטפל ב-GAP-07/08 בנפרד.",
|
||||
@@ -2101,7 +2101,7 @@
|
||||
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"id": "61",
|
||||
"title": "[FU-3] re-index בשינוי תוכן",
|
||||
"description": "embedding מתעדכן אוטומטית בשינוי תוכן (כיום trigger-dependent, לא GENERATED).",
|
||||
"details": "מכסה GAP-09. מספק INV-DM3/G6. severity: High. סוג: קוד + מיגרציה (re-embed). תלוי ב-FU-1.",
|
||||
@@ -2138,7 +2138,7 @@
|
||||
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"id": "62",
|
||||
"title": "[FU-4] בידוד-קורפוס בכל מסלול query",
|
||||
"description": "אכיפת source_kind בכל פילטר (כולל halacha_filters); חסימת חיפוש ללא תחום.",
|
||||
"details": "מכסה GAP-10,12. מספק INV-RET1/G5. severity: Critical. סוג: קוד. ללא תלות — דחוף (דליפה פעילה).",
|
||||
@@ -2173,7 +2173,7 @@
|
||||
"updatedAt": "2026-05-30T18:30:11.503Z"
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"id": "63",
|
||||
"title": "[FU-5] eval-harness + נראות backlog",
|
||||
"description": "מדידת precision/recall על gold-set + חשיפת backlog הלכות בבדיקת-בריאות.",
|
||||
"details": "מכסה GAP-11,14. מספק INV-RET4/G8/QA1/G10. severity: High. סוג: קוד + החלטת-יו\"ר (בניית gold-set). תלוי ב-FU-2. | DONE 2026-05-31: Unit B (GAP-14) — halacha_backlog נחשף ב-metrics.get_dashboard + /api/system/diagnostics (גילה 178 pending_review מתוך 1552, הישן 3.5.26). Unit A (GAP-11) — scripts/eval_gold_bootstrap.py (citations+known_item) + scripts/eval_retrieval.py (P/R/MRR/nDCG@5,10, self-test, baseline+config). gold-set=77 known-item queries (citation-source ריק: 0 ציטוטים בהחלטות). baseline בייצור: R@10=0.987 MRR=0.837; ממצא: MULTIMODAL=true מוריד known-item recall קלות (relevant ל-#15). gold-set=provisional עד סקירת דפנה (chair-gate; הדומיין). spec: docs/superpowers/specs/2026-05-31-fu5-eval-harness-design.md",
|
||||
@@ -2210,7 +2210,7 @@
|
||||
"updatedAt": "2026-05-31T14:55:38.295Z"
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"id": "64",
|
||||
"title": "[FU-6] שערי-QA נאכפים בקוד",
|
||||
"description": "export חוסם בקוד על כשל-QA קריטי; תיקון neutral_background critical-but-passes.",
|
||||
"details": "מכסה GAP-15,16. מספק INV-QA3/EX3/G10. severity: Critical. סוג: קוד. ללא תלות — מהיר.",
|
||||
@@ -2245,7 +2245,7 @@
|
||||
"updatedAt": "2026-05-30T18:30:11.521Z"
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"id": "65",
|
||||
"title": "[FU-7] audit-trail + provenance",
|
||||
"description": "כתיבת audit_log בכל פעולה; קישור בלוק→קטעי-מקור; סנכרון DB אחרי עריכה; אימות citation→corpus.",
|
||||
"details": "מכסה GAP-17,18,19,20. מספק INV-AUD1/2/3/EX1/G9. severity: High. סוג: קוד + backfill קל. תלוי ב-FU-1. (זרע לתת-פרויקט 3/audit-provenance.)",
|
||||
@@ -2300,7 +2300,7 @@
|
||||
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"id": "66",
|
||||
"title": "[FU-8a] מחסומי-תהליך→קוד: enforce sync + Paperclip-access guard (pure-code)",
|
||||
"description": "אכיפת cross-company sync (--verify יוצא non-zero על drift; adapter_type mismatch = drift לא silent skip) + fitness-function שחוסם גישת-Paperclip לא-מאושרת (raw http / INSERT agent_wakeup_requests).",
|
||||
"details": "מכסה GAP-21,22. מספק INV-MC1/INT1/INT3. severity: High. סוג: pure-code. GAP-23 (חיווט ספ→סוכנים) הופרד ל-#69 (משנה התנהגות-ייצור). | DONE 2026-05-31 PR#16: --verify drift-gate (exit≠0) + Paperclip-access fitness function. GAP-23→#69.",
|
||||
@@ -2333,7 +2333,7 @@
|
||||
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"id": "67",
|
||||
"title": "[FU-2b] תיאום מזהים קנוניים + ניקוי ציטוט-כמזהה (data-migration, chair)",
|
||||
"description": "מיגרציה חד-פעמית של ~52+ רשומות case_law עם ציטוט-מלא ב-case_number → מספר-בסיס מנורמל; dedup (למשל 8047-23 כפול); הכרעת צורה קנונית per-record.",
|
||||
"details": "מכסה GAP-07,08. מספק INV-ID1/ID2/DM2. severity: High. סוג: DATA-MIGRATION + chair-decision (מספר רשמי per-record, with-month canonical). דורש: גיבוי, dry-run, סקירת-יו\"ר, reversibility. תלוי ב-FU-2a (#60, לצורך פונקציית הנרמול). מקור: בדיקת DB 2026-05-30 — internal_committee ~52/56 ציטוט-מלא, ≥1 dup (8047-23), 1 בלתי-פתיר (ערר אדלר/cited_only). | APPLIED 2026-05-31: 55 internal rows normalized to bare case_number; corrupted 8047 dup (מטודלה) deleted; חלוואני 1028-20 proc→בל\"מ. Backups in data/audit/fu2b-*. external→#68.",
|
||||
@@ -2367,7 +2367,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"id": "68",
|
||||
"title": "[FU-2c] תיאום מזהי external_upload (case_number↔citation_formatted)",
|
||||
"description": "פסיקה חיצונית: case_number מחזיק ציטוט מלא; citation_formatted לא תמיד תואם (נמצאה סתירה 25226-04-25 מול 1975/24). דורש קודם תיקון סתירות citation_formatted↔case_number, ואז הכרעה אם docket מחולץ הופך ל-case_number או שהציטוט נשאר המזהה.",
|
||||
"details": "מקור: בדיקת DB 2026-05-31 (FU-2b scoping). 22/24 external עם ציטוט ב-case_number; citation_formatted נוצר בנפרד (LLM) ולא אמין כ-ground truth. שונה מ-internal (שם 0 סתירות). דורש סקירת-יו\"ר פר-רשומה. severity: Medium. סוג: data-migration + chair. תלוי בהחלטה: האם זהות external = ציטוט (FU-1) או docket מנורמל (INV-ID2). מופרד מ-FU-2b לפי החלטת chaim 2026-05-31. | APPLIED 2026-05-31: chair decision Option A (designator+docket, '/' kept). 21 external_upload case_number normalized + 3 citation_formatted fixed (D=לויתן/קלמנוביץ consolidated→25226-04-25; 2×C empty-citation composed). אהוד שפר עע\"מ 317/10 deferred (cross-source dup w/ cited_only → #70). collision-guard: 0. Backups data/audit/fu2c-backup-20260531T140943Z.csv. cited_only(49)→#70.",
|
||||
@@ -2381,7 +2381,7 @@
|
||||
"updatedAt": "2026-05-31T14:11:37.689Z"
|
||||
},
|
||||
{
|
||||
"id": 69,
|
||||
"id": "69",
|
||||
"title": "[FU-8b] חיווט הספ לסוכני-Paperclip (GAP-23)",
|
||||
"description": "HEARTBEAT/agent docs דורשים קריאת 00-constitution + ספ-תחום רלוונטי לפני פעולה. משנה התנהגות-סוכן בייצור; prereq לתת-פרויקט 5.",
|
||||
"details": "מכסה GAP-23. מספק INV-AG1. severity: High. סוג: docs+chair-decision. דורש ספ יציב (קיים) + החלטה על שילוב בזרימת-הסוכנים. הופרד מ-FU-8a לפי החלטת chaim 2026-05-31 (GAP-21/22 = pure-code עכשיו).",
|
||||
@@ -2407,7 +2407,7 @@
|
||||
"updatedAt": "2026-05-31T16:01:42.032Z"
|
||||
},
|
||||
{
|
||||
"id": 70,
|
||||
"id": "70",
|
||||
"title": "[FU-2c-b] תיאום + dedup של cited_only (49 רשומות) + אהוד שפר cross-source",
|
||||
"description": "המשך ל-FU-2c (#68). ה-dry-run של תיאום-המזהים החיצוני חשף 49 רשומות source_kind='cited_only' (הפניות-ציטוט שחולצו מהחלטות) שלא היו בהיקף #68. דורשות נרמול נפרד: צורות-ועדה כמו 'ערר 1093-19' (NNNN-NN) שה-extractor הנוכחי לא תופס (NO_DOCKET), 'בש\"א 2487-14', dups, ו-'ערר אדלר' בלתי-פתיר (ללא מספר). בנוסף: dedup חוצה-source של אהוד שפר — external_upload 'עע\"מ 317/10 אהוד שפר' מול cited_only קיים 'עע\"מ 317/10' (אותו תיק; ה-collision-guard מנע התנגשות ב-uq_case_law_external_number, ה-external_upload נשאר עם case_number מנופח עד הכרעה).",
|
||||
"details": "[2026-06-03] נרמול מבוסס-מחקר (4 מקורות: ECLI work-level id, Akoma Ntoso FRBR Work/Manifestation, ELI canonical+alias, OpenCitations OMID + Christen data-matching). מדיניות: צורה קנונית אחת + alias; cited_only stub = אותו Work כמו ה-doc → merge על התאמה-מדויקת בלבד; un-resolvable = display+flag, לא למחוק; merge = re-point edges + dedup, שמרני (false-merge בגרף-ציטוט יקר). בוצע: 46 רשומות cited_only סווגו; 3 תיקונים מכניים-דטרמיניסטיים הוחלו (ערר \\n316/10→ערר 316/10; עע\"מ65/13→עע\"מ 65/13; עע\"מ9057/09→עע\"מ 9057/09). 0 malformed (whitespace/no-space) נותרו. **נותר לשיקול יו\"ר (לא ננחש, לפי המשמר)**: (1) 2 garbled — 'ערר 1078/0724' (4a38c202), 'ערר 1083/0724' (6682f9cb); (2) 'ערר אדלר' (863a7bf8) ללא docket → keep+flag; (3) combined 'ערר (ירושלים) 1078+1083/24' (e7f6fd06) → פיצול ל-1078/24+1083/24 מתנגש עם stub קיים 'ערר 1083/24' → entity-resolution ידני. תוספת קוד עתידית: טיפול '+' ב-citation_extractor. הדדאפ הקודם (shafer + stub cleanup) כבר הושלם. אלה chair-domain — לא הכרעת-מהנדס. [2026-06-03 סגירה]: בדיקת-קשתות חשפה ש-4 ה'דו-משמעיים' (+11 נוספים) הם stubs **יתומים מתים** — 0 קשתות בכל 5 מנגנוני-הציטוט, 0 full_text, 0 הלכות, 0 chunks/embeddings. כלומר ניקוי טכני, לא שיפוט-יו\"ר (OpenCitations שומר ישות חסרת-מזהה רק אם מצוטטת — אלה לא). נמחקו 15 יתומים (cited_only 46→31), גיבוי data/audit/fu2b-orphan-stub-cleanup-20260603T093741Z.json. 0 malformed/יתומים נותרו; כל 31 הנותרים מצוטטים. forward-edge ידוע (לא חוסם, ללא משימה): טיפול '+' בציטוט-משולב ב-citation_extractor אם יחזור בחילוץ עתידי. #70 done.",
|
||||
@@ -2421,7 +2421,7 @@
|
||||
"updatedAt": "2026-06-03T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": 71,
|
||||
"id": "71",
|
||||
"title": "[FU-5 follow-up] כוונון עומק-אחזור/rerank — recall רב-תקדימי לסוגיות רחבות",
|
||||
"description": "נפתר ע\"י תיקון ה-weight של #15 (multimodal 0.5→0.65). מדידה 2026-06-03: כל 11 השאילתות הרב-תקדים/יו\"ר מחזירות את כל התקדימים הרלוונטיים ב-top-10 (רובם top-6; גרוע ביותר rank 9). השאילתות החלשות מהבייסליין (S2 הבית-שמעוני@16, S4 ב.דייניש@15, S7@15, S8) כולן תוקנו. recall@10≈1.0.",
|
||||
"details": "החלטה מבוססת-מדידה+מחקר (6 מקורות: Cormack RRF, Drowning-in-Documents 2411.11767, ReFIT, MMR, Elastic, Pinecone). המחקר המליץ להפעיל rerank (fetch_k=50,return_k=10); בדקתי אמפירית — VOYAGE_RERANK_ENABLED=true דווקא הזיק: nDCG@5 0.879 מול 0.960, MRR 0.867 מול 0.954, R@5 0.966 מול 0.994 (כל המדדים שליליים). הסיבה: recall כבר רווי, וה-cross-encoder הכללי מוריד את ההתאמה המדויקת ב-known-item. **המדידה גוברת על התיאוריה — לא מפעילים rerank, לא מעלים limit, RRF_K=60 נשאר.** אין שינוי-קוד נדרש.",
|
||||
@@ -2435,7 +2435,7 @@
|
||||
"updatedAt": "2026-06-03T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": 72,
|
||||
"id": "72",
|
||||
"title": "[ops] MCP 'No such tool' תחת עומס חילוץ opus-4-8@xhigh — timeout ב-handshake",
|
||||
"description": "בריצת CMPA-71 (חילוץ הלכות 9002-24, סוכן עוזר משפטי) שרת ה-legal-ai MCP לא נטען — כל קריאות mcp__legal-ai__* החזירו 'No such tool available' אחרי 3 ניסיונות+המתנות; הסוכן עשה fallback ל-.venv ישיר (לפי legal-ceo.md) והחילוץ הצליח על claude-opus-4-8@xhigh.",
|
||||
"status": "done",
|
||||
@@ -2446,7 +2446,7 @@
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": 73,
|
||||
"id": "73",
|
||||
"title": "החלטת ועדת ערר: ברירת מחדל is_binding=false (יישור דוקטרינרי)",
|
||||
"description": "כשמעלים החלטת ועדת ערר דרך מסך העלאת הפסיקה (precedent-upload-sheet, isCommittee=true), הצ'קבוקס 'הלכה מחייבת' (is_binding) כברירת מחדל הוא true — כך שההלכות שמחולצות מהחלטה לא-מחייבת מתויגות rule_type='binding'. זה סותר את ההגדרה הדוקטרינרית שלנו (ועדת ערר = persuasive בלבד, לא binding כמו עליון/מנהלי). התיקון: כש-isCommittee=true ב-precedent-upload-sheet.tsx, להפוך את is_binding ל-false כברירת מחדל (או לנעול/להסתיר את הצ'קבוקס ולתייג אוטומטית persuasive). הערה חשובה: זהו תיקון יישור-דוקטרינרי בלבד — אין השפעה downstream על ranking/injection (rule_type הוא תווית תצוגה; השער הפונקציונלי האמיתי הוא review_status שדפנה שולטת בו ידנית). קבצים: web-ui/src/components/precedents/precedent-upload-sheet.tsx (useState isBinding שורה 47, isCommittee שורה 53); guard clause קיים ב-mcp-server/src/legal_mcp/services/halacha_extractor.py:229-235 שמוריד binding→persuasive רק כאשר is_binding=false.",
|
||||
"details": "",
|
||||
@@ -2458,7 +2458,7 @@
|
||||
"updatedAt": "2026-05-31T20:41:04.160Z"
|
||||
},
|
||||
{
|
||||
"id": 74,
|
||||
"id": "74",
|
||||
"title": "ניקוי רטרואקטיבי: rule_type binding→persuasive להלכות ממקור ועדת ערר",
|
||||
"description": "המשך משימה #73 (PR #29 מנע binding חדש לועדת ערר מכאן והלאה). יש 82 הלכות קיימות ב-DB עם rule_type='binding' שמקורן (case_law) בהחלטת ועדת ערר — בסתירה לדוקטרינה (ועדת ערר = persuasive). פילוח: 75 approved + 7 pending_review. גישה #2 (שמרנית): לתקן רק את ה-binding ל-persuasive, ולהשאיר interpretive/procedural/application/obiter כמות שהם (תקינים גם לועדת ערר). הגדרת 'מקור ועדת ערר': case_law WHERE source_type='appeals_committee' OR precedent_level LIKE 'ועדת%' OR court LIKE '%ועדת%ערר%' OR court LIKE '%ועדות ערר%'. שאילתה: UPDATE halachot SET rule_type='persuasive' WHERE rule_type='binding' AND case_law_id IN (<committee case_law ids>). הערה: rule_type הוא תווית תצוגה בלבד — אין השפעה על ranking/injection (השער הפונקציונלי הוא review_status). DB: legal_ai על Postgres pgvector קונטיינר t84kegpjm5qrttd6nw7bgoxe (פורט 5433). ביצוע דרך docker exec עם trust מקומי. לגבות/לספור לפני ואחרי לאימות (צפוי: 82 שורות מושפעות, 0 binding ממקור ועדת ערר אחרי).",
|
||||
"details": "",
|
||||
@@ -2472,7 +2472,7 @@
|
||||
"updatedAt": "2026-05-31T20:49:28.894Z"
|
||||
},
|
||||
{
|
||||
"id": 75,
|
||||
"id": "75",
|
||||
"title": "[X11 Phase 2] חיווט אוטו-אישור מבוסס-ציטוט + backfill",
|
||||
"description": "Phase 2 של citation-corroboration (X11). Phase 1 (האות) מוזג ב-PR #27. דפנה אימתה את האות ואישרה הפעלה (2026-06-01). Phase 2: (1) חיווט אוטו-אישור — הלכה corroborated (≥2 ציטוטים חיוביים בלתי-תלויים, 0 שליליים) עוברת ל-review_status='approved' עם reviewer='corroborated (…judicial citations)' (INV-COR4/G10); (2) הדחת overruled — הלכה approved שקיבלה טיפול overruled בציטוט מאוחר חוזרת לשער-היו\"ר (INV-COR2); (3) backfill על 12 התקדימים (halachot+ציטוטים-נכנסים); (4) כלי-MCP write להרצת rebuild.",
|
||||
"details": "דגל: HALACHA_CORROBORATION_AUTO_APPROVE (default true, env-tunable). פונקציית-הכרעה טהורה approval_action(agg, has_overruled)→'approve'/'demote'/None (unit-tested, INV-COR2/COR4). DB: approve_halacha_by_corroboration (רק על pending_review), demote_halacha_overruled (רק על approved→pending_review), list_corroboration_grouped, precedents_with_halachot_and_incoming_citations. שירות: reconcile_approvals מופעל בסוף build_for_precedent; build_all driver. backfill target=12 תקדימים (אומת 2026-06-01). נדחה ל-backlog (proposal-only, מסוכן-תוכן): enrichment של rule_statement, treatment-backfill ל-case_law_citations.citation_type. תוכנית: docs/superpowers/plans/2026-06-01-x11-citation-corroboration-phase2.md. spec: docs/spec/X11-citation-corroboration.md §4-6.",
|
||||
@@ -2484,7 +2484,7 @@
|
||||
"updatedAt": "2026-06-01T04:43:40.474Z"
|
||||
},
|
||||
{
|
||||
"id": 76,
|
||||
"id": "76",
|
||||
"title": "תיקון כפתור \"צור משימה\" ב-Paperclip — מאופשר אך submit חוזר בשקט",
|
||||
"description": "בוטל 2026-06-03 — באג upstream של Paperclip, לא ניתן לתיקון בטוח אצלנו (ee=companyId; הכפתור מאופשר לפי כותרת בלבד אך submit דורש חברה שלא אותחלה). אומת ע\"י chaim שעובד מהקשרי-חברה רגילים. Workaround: לבחור חברה במודאל / לפתוח מתוך לוח. מסלול אמין: pc.sh POST /companies/{id}/issues. תיקון יסודי = upstream. #78 מסיר את הצורך בזרימת הפסיקה.",
|
||||
"details": "אבחנה סופית מתוך הבאנדל (index-BWGhimVr.js): ה-submit הוא `function xi(){const je=m.current.trim();if(!ee||!je||He.isPending)return;...He.mutate({...companyId:ee...})}`. `je`=כותרת (קיים), `He`=mutation. ה-guard שנכשל הוא **`ee`**, ש-`ee` משמש כ-`projects.list(ee)` וכ-`companyId:ee` במוטציה — כלומר **`ee` = מזהה החברה**. השורש: הכפתור מאופשר לפי הכותרת בלבד (`disabled:!b`, b=כותרת), אבל ה-submit דורש גם חברה (`!ee`). כשהמודאל נפתח בהקשר שבו החברה לא אותחלה, המשתמש לוחץ כפתור 'מאופשר' וה-handler חוזר בשקט — בלי POST, בלי שגיאה. בחירת הסוכן (callback Ro) לא מגדירה את החברה — היא נקבעת רק דרך בורר חברה נפרד (pr/oe). ההזרקה שלנו (translate-he.js) זוכתה: reverseComments נוגע רק ב-[id^='comment-'], לא במודאל; isUserContent מדלג על contentEditable. **לא ניתן לתקן בבטחה דרך injection**: אי-אפשר לכתוב ל-state של React מבחוץ; shim שמגרד DOM ויוצר issue דרך API הוא שביר (צריך IDs מה-DOM) ועלול ליצור משימות פגומות — גרוע מהבאג. **Workaround**: לוודא שהחברה נבחרה במודאל (בורר החברה) לפני לחיצה על 'צור משימה'; או לפתוח 'משימה חדשה' מתוך הקשר חברה/לוח. מסלול אמין תמיד: API ישיר `pc.sh POST /companies/{id}/issues`. **תיקון יסודי = upstream Paperclip** (הכפתור צריך להיות disabled כשאין חברה, או החברה צריכה להיגזר מהלוח/סוכן הנבחר). הערה: #78 (חילוץ פסיקה אוטומטי) מסיר את הצורך במודאל הזה בזרימת חילוץ-הפסיקה; הזרימה הרגילה מניעה סוכנים דרך תגובות (CEO מנתב).",
|
||||
@@ -2496,7 +2496,7 @@
|
||||
"updatedAt": "2026-06-03T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": 77,
|
||||
"id": "77",
|
||||
"title": "תיקון מיפוי שדות בהעלאת פסיקת ועדת-ערר — מראה-מקום נדחס ל-case_number; case_number לא ניתן לעריכה",
|
||||
"description": "בטופס העלאת פסיקה (precedent-upload-sheet), עבור החלטות ועדת-ערר ה-frontend ממפה את שדה \"מראה המקום\" אל case_number (`case_number: citation.trim()`), כך שהמזהה הייחודי מקבל את המראה-מקום הארוך במקום מספר תיק נקי (למשל '8027-25'). בנוסף case_number כלל לא קיים ב-PrecedentUpdateRequest — אז מסך העריכה לא יכול לתקן אותו בדיעבד. citation_formatted נשאר ריק בהעלאה (מתמלא רק בחילוץ מטא). תוצאה ב-8027-25: case_number=מראה-מקום, case_name=מראה-מקום, מראה-מקום ריק עד החילוץ.",
|
||||
"details": "קבצים: web-ui/src/components/precedents/precedent-upload-sheet.tsx:121-123 (committee path ממפה citation→case_number); web/app.py:5147-5163 (PrecedentUpdateRequest חסר case_number); mcp-server/.../internal_decisions.py (id_field=case_number, display_name_fallback=case_number); precedent_metadata_extractor.py:247-253 (guard: case_name מתוקן רק אם ריק או ==case_number, לכן לא תיקן). תיקון מוצע: (1) בטופס committee — שדה נפרד \"מספר תיק (מזהה ייחודי)\" שממפה ל-case_number, ולמפות \"מראה המקום\" ל-citation (→citation_formatted), במקום לדחוס הכל ל-case_number; (2) להוסיף case_number ל-PrecedentUpdateRequest כדי שהעריכה תוכל לתקן בדיעבד (update_case_law כבר מתיר אותו); (3) להריץ `npm run api:types`. ראה כללי השם שהוגדרו: מזהה ייחודי = שם הקובץ/מספר תיק; מראה-מקום בשדה שלו; שם קצר = שם הצד.",
|
||||
@@ -2508,7 +2508,7 @@
|
||||
"updatedAt": "2026-06-02T12:17:44.302Z"
|
||||
},
|
||||
{
|
||||
"id": 78,
|
||||
"id": "78",
|
||||
"title": "כשל שקט ב-wakeup לחילוץ פסיקה — חילוץ אוטומטי לא רץ והתיק נתקע ב-pending",
|
||||
"description": "אחרי העלאת פסיקה, הבקאנד מסמן metadata+halacha כ-pending וקורא ל-pc_wake_for_precedent_extraction להעיר את ה-CEO. הקריאה נבלעת בשקט (try/except 'non-fatal') — אם PAPERCLIP_BOARD_API_KEY חסר מחזיר {ok:false,skipped:no_api_key}, או שה-wakeup API נכשל — והתוצאה: ה-CEO לא מתעורר, לא רץ חילוץ מטא ולא חילוץ הלכות, וה-UI מציג 'ממתין לחילוץ' לנצח. קרה ב-8027-25 (תוקן ידנית עם precedent_process_pending). זה גם הסיבה ששדות המטא (תקציר/headnote/תגיות/citation) היו ריקים.",
|
||||
"details": "קבצים: web/app.py:5250-5262 (wakeup best-effort, exception נבלעת); web/paperclip_client.py:816-820 (skip שקט כש-no api key) ו-~905-907 (כשל API נבלע). תיקון מוצע: (1) להציף את הכשל למשתמש — סטטוס מובחן (extraction_wakeup_failed / 'ממתין-לעיבוד-ידני') ב-UI במקום 'pending' אילם; (2) fallback אוטומטי — אם ה-wakeup נכשל, או job מתוזמן (כמו sync-case-status) שמנקז את התור עם precedent_process_pending, או retry; (3) לאמת אם PAPERCLIP_BOARD_API_KEY מוגדר בקונטיינר (Coolify env) — אם לא, להוסיף. עיין reference: project_precedent_auto_extraction. לא לבלוע exceptions בשקט (feedback_silent_swallow).",
|
||||
@@ -2520,7 +2520,7 @@
|
||||
"updatedAt": "2026-06-02T12:07:22.194Z"
|
||||
},
|
||||
{
|
||||
"id": 79,
|
||||
"id": "79",
|
||||
"title": "[#55 follow-up] chunker — כותרות-סעיף מבודדות נשארות chunks זעירים (<50)",
|
||||
"description": "ה-chunker ההיררכי הנוכחי (אחרי תיקון #55) עדיין פולט מדי פעם chunk זעיר שהוא כותרת-סעיף בודדת שלא מוזגה קדימה לתוכן שאחריה. התגלה בסגירת #57 (re-chunk legacy): מתוך 73 תקדימים שעברו reindex, נשארו 4 chunks זעירים — כולם כותרות מבודדות: 'דיון' (ע\"א 5138/04, len=4), 'טענות המשיבים' (בג\"ץ 6525/15, len=13), 'העובדות וההליכים' (בר\"מ 2340/02, len=16), ושבר-ציטוט 'כלל התושבים\". (ע' 13 להחלטה)' (403-17, len=32). לא שאריות legacy — אלה פלט דטרמיניסטי של ה-chunker הנוכחי.",
|
||||
"details": "השפעה: נמוכה — chunks אלה כבר מסוננים ב-query-time (פילטר length>=50 שנוסף ב-#55), אז החיפוש לא מושפע; זו בעיית-איכות-chunking ולא בעיית-אחזור. מיקום: chunker ההיררכי ב-mcp-server (chunk_document_hierarchical / מדיניות מיזוג הכותרות שב-#55). התיקון הצפוי: כשכותרת/heading קצרה (<50, או section_type שמתחיל סעיף) נותרת כ-chunk עצמאי ללא גוף — למזג אותה קדימה אל ה-chunk הבא (anchor-forward), או אחורה אם אין הבא. לשים לב ל-section boundaries: 'דיון'/'טענות המשיבים' הן תחילת סעיף — המיזוג צריך לצרף את הכותרת לראש הסעיף שאחריה, לא לזנב הקודם. אימות: להריץ scripts/rechunk_legacy_precedents.py אחרי התיקון — אמור להגיע ל-0 chunks<50 (או רק שברי-ציטוט לגיטימיים נדירים). תלוי ב-#55. ראה גם feedback_no_reocr_retrofit (re-chunk מ-full_text בלבד).",
|
||||
@@ -2534,7 +2534,7 @@
|
||||
"updatedAt": "2026-06-03T08:10:57.844Z"
|
||||
},
|
||||
{
|
||||
"id": 80,
|
||||
"id": "80",
|
||||
"title": "[#15 follow-up] בדיקת ערך image-answer ל-multimodal → הכרעה על backfill 140 legacy",
|
||||
"description": "נסגר 2026-06-03 — ההנחה התבררה שגויה בכל מרכיב (full check). לא '140 מסמכים / 17,700 עמ' / שעתיים / אישור-עלות chaim + תיוג דפנה', אלא: מתוך 140 חסרי-image רק 65 PDF (השאר MD/DOCX — ה-pipeline מרנדר PDF בלבד), ובסך 704 עמ'. תיקי-השמאות (כל ערך ה-multimodal) כבר היו 8/12 מוטמעים — הפער היחיד היה תיק 8070-25 (4 מסמכי שמאות).",
|
||||
"details": "בוצע: backfill מקומי (multimodal_backfill.py 8070-25, voyage-multimodal-3, ~30 שניות) → כל 14 מסמכי 8070-25 הוטמעו. **כיסוי שמאות עכשיו 12/12 (100%)**. נותרו 51 PDF/649 עמ' ללא multimodal — כולם טקסטואליים (reference/response/appeal), ו-#15 הוכיח ש-multimodal לא עוזר (אף מדלל) על מסמכים טקסטואליים → **מושארים בכוונה** text-only; זו לא חוסר-עקביות אלא הקונפיג הנכון. אין צורך ב-gold-set/דפנה/אישור-עלות — העלות הייתה סנטים והערך הוכח ב-#15 לתיקי ועדה/שמאות. #80 done (טכני, לא human-gated).",
|
||||
@@ -2548,7 +2548,7 @@
|
||||
"updatedAt": "2026-06-03T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": 81,
|
||||
"id": "81",
|
||||
"title": "איכות חילוץ הלכות — לוודא שמה שמחולץ הוא הלכה אמיתית ולא ציטוט/אמרת-אגב",
|
||||
"description": "מנוע חילוץ ההלכות מפיק כיום פריטים שאינם 'הלכות' במובן המהותי: אמרות-אגב שהערכאה לא הכריעה בהן, יישומים ספציפיים-לתיק (rule_type=application), ציטוטים חתוכים, ופירוק-יתר (עד 351 'הלכות' מפסק אחד). המשימה: לחדד את ה-prompt ולהוסיף ולידטורים אוטומטיים כך שרק עיקרון משפטי בר-הכללה ובר-הסתמכות ייכנס למאגר. מבוססת מחקר מקצועי (ratio decidendi מול obiter dictum, holding-extraction בספרות legal-NLP, קריטריונים לאיכות rule_statement). תתי-המשימות יוגדרו לאחר המחקר.",
|
||||
"details": "רקע מבצעי (ניקוי 2026-06-03): נמחקו 196 רשומות מתוך 1650 (165 כפילויות תוכן + 31 Tier B/C). גיבוי: data/audit/halacha-cleanup-backup-20260603T101747Z.sql ; מניפסט: data/audit/halacha-cleanup-manifest-20260603T101747Z.csv . קוד רלוונטי: mcp-server/src/legal_mcp/services/halacha_extractor.py (prompts BINDING/PERSUASIVE, _coerce_halacha, אימות ציטוט). תחומי בדיקה ידועים: (א) חסימת quote_verified=false מהתור; (ב) הוצאת rule_type=application מהגדרת 'הלכה'; (ג) שמירה על דחיית dicta שלא הוכרעו; (ד) תקרת כמות/גרנולריות לפסק; (ה) הגנה מפני ציטוט חתוך (truncation guard). מפרט מאומת: docs/halacha-strict-rubric.md (הרובריקה האגרסיבית שהנחיתה ניקוי קורפוס 1454→534 ב-2026-06-03, שפר 51→22). להטמיע אותה במחלץ + dedup-on-insert (#82).",
|
||||
@@ -2648,7 +2648,7 @@
|
||||
"updatedAt": "2026-06-03T16:27:24.755Z"
|
||||
},
|
||||
{
|
||||
"id": 82,
|
||||
"id": "82",
|
||||
"title": "Dedup בזמן הכנסה — מניעת הלכות כמעט-זהות בכתיבה (semantic dedup on insert)",
|
||||
"description": "store_halachot_for_chunk מבצע INSERT עיוור ללא בדיקת כפילות, ולכן נצברו עשרות הלכות 'אותו עיקרון במילים אחרות'. המשימה: לפני שמירה, לבדוק דמיון סמנטי (embedding) מול הלכות קיימות באותו פסק ולדלג/למזג near-duplicates, וכן לזהות ציטוט-תומך זהה. מבוססת מחקר (ספי near-duplicate ב-embedding-IR, MinHash/LSH מול cosine, בחירת צורה קנונית, merge מול skip). תתי-המשימות יוגדרו לאחר המחקר.",
|
||||
"details": "ממצא מהניקוי: סף cosine ≥0.90 תוך-פסק זיהה כפילויות אמיתיות בוודאות גבוהה (הרצועה 0.90-0.95 הייתה כמעט כולה 'אותו עיקרון בניסוח שונה'); ≥0.95 = ודאי. ציטוט-תומך זהה = איתות ודאי. להחליט: סף מבצעי, התנהגות (skip/merge/flag-for-review), ובחירת השורד (approved>pending, ביטחון גבוה, quote_verified). קוד: db.store_halachot_for_chunk; קיים idx_halachot_vec (pgvector).",
|
||||
@@ -2736,7 +2736,7 @@
|
||||
"updatedAt": "2026-06-03T12:32:19.721Z"
|
||||
},
|
||||
{
|
||||
"id": 83,
|
||||
"id": "83",
|
||||
"title": "חוסן pipeline החילוץ — re-run אידמפוטנטי + אינדוקס תקין (תיקון באגים)",
|
||||
"description": "התגלו שני באגים: (1) halacha_index מוקצה per-chunk ולכן אינו ייחודי לפסק — שני עקרונות שונים מקבלים אותו מספר (לא כפילות, אך שובר dedup/מיון מבוסס-אינדקס); (2) חילוץ רץ פי-2/3 על אותו פסק (למשל 85026-17 שלוש ריצות תוך דקתיים) ומוסיף append במקום להחליף — ה-advisory lock לא מנע. המשימה: אינדוקס ייחודי לפסק, force=True שמוחק לפני re-extract, וחיזוק ה-lock/אידמפוטנטיות. מחקר קצר: דפוסי idempotency/exactly-once ב-pipelines.",
|
||||
"details": "קוד: halacha_extractor.py (global advisory lock, per-chunk checkpoints ב-precedent_chunks.halacha_extracted_at, force flag), db.store_halachot_for_chunk (הקצאת halacha_index). לשקול unique constraint (case_law_id, halacha_index) אחרי תיקון ההקצאה.",
|
||||
@@ -2822,7 +2822,7 @@
|
||||
"updatedAt": "2026-06-03T13:08:10.793Z"
|
||||
},
|
||||
{
|
||||
"id": 84,
|
||||
"id": "84",
|
||||
"title": "טריאז' תור אישור ההלכות — אישור יעיל ולא מתיש",
|
||||
"description": "אישור ההלכות ידני ומתיש: קריאת עקרונות כמעט-זהים שוב ושוב, ללא תיעדוף או קיבוץ. המשימה: לייעל את חוויית האישור — מיון לפי ביטחון/corroboration, קיבוץ near-duplicates יחד, auto-defer/הסתרה של פריטים באיכות נמוכה, ופעולות batch (אישור/דחייה מרובים). מבוססת מחקר (human-in-the-loop review UX, active-learning prioritization, triage queues). תתי-המשימות לאחר המחקר.",
|
||||
"details": "הקשר: הדחייה כמעט לא בשימוש (1/1650) — התור הוא 'אשר-או-השאר-תלוי'. כלים קיימים: halachot_pending, halacha_review (MCP), דף ביקורת ב-UI. לשלב עם פלט #81 (איכות) ו-#82 (dedup) כדי שהתור יציג רק מועמדים אמיתיים ומקובצים.",
|
||||
@@ -2918,18 +2918,19 @@
|
||||
"updatedAt": "2026-06-03T13:43:18.488Z"
|
||||
},
|
||||
{
|
||||
"id": 85,
|
||||
"id": "85",
|
||||
"title": "CEO MCP instance: nested claude -p exits 1 in write_interim_draft",
|
||||
"description": "write_interim_draft נכשל לכל 5 הבלוקים מתוך session ה-CEO עם 'Claude CLI failed (exit 1): unknown error'. אומת: claude CLI תקין מ-bash (exit 0), PATH+HOME של תהליך ה-MCP תקינים (/home/chaim/.local/bin/claude), אין ANTHROPIC_API_KEY ב-.env, הבלוקים נכתבים סדרתית (לא concurrency). סוכני משנה (proofreader/analyst CMPA-73..76) הריצו claude -p בהצלחה באותו יום. ⇒ כשל ספציפי ל-MCP server instance של ה-CEO. עוקף: האצלה ל-writer agent. דרוש: לבדוק מדוע nested claude -p נכשל מ-instance זה (אולי session lock / env stale); שקול restart ל-CEO agent session.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "high",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:47.925Z"
|
||||
},
|
||||
{
|
||||
"id": 86,
|
||||
"id": "86",
|
||||
"title": "טיפול ב-preamble/רציו של נבו — anti-contamination + gold-set מהרציו",
|
||||
"description": "התגלה (2026-06-03) ש-`strip_nevo_preamble` קיים ומחווט ל-ingest, אבל ה-regex `_DECISION_START` מזהה רק פתיחות של ועדת ערר (בפנינו/הערר שבנדון/ועדת הערר לתכנון/רקע עובדתי/עסקינן) — ולא פסקי-דין שנפתחים ב'פסק-דין' (כמו בג\"ץ 1764/05). לכן בפסקי-דין מנבו — בדיוק אלה שיש להם מיני-רציו — ה-preamble/רציו **אינו נחתך**, דולף לצ'אנקים, ועלול לזהם את חילוץ ההלכות (המחלץ קורא את התשובון של נבו) ואת הקורפוס. במקביל — הרציו של נבו הוא gold-set אנושי-מקצועי חינמי לאמידת איכות החילוץ.",
|
||||
"details": "קוד: mcp-server/src/legal_mcp/services/extractor.py — `strip_nevo_preamble` (~367), `_NEVO_MARKERS` (ספרות:/חקיקה שאוזכרה:/מיני-רציו:/...), `_DECISION_START` (~361). מחווט ב-ingest.py:161 ו-documents.py:152. הוכחה: ב-1764/05 המיני-רציו שרד כ-chunk מסוג intro (לא נחתך) ורק במזל לא חולץ (intro לא ב-EXTRACTABLE_SECTIONS). השוואת benchmark שבוצעה ידנית על 1764/05: 14 הלכות שלנו כיסו 100% מ-4 הלכות-הרציו של נבו + 2 נוספות, בגרנולריות פי ~3.5 (קשור ל-#81.5).",
|
||||
@@ -2977,204 +2978,218 @@
|
||||
"updatedAt": "2026-06-03T16:56:13.158Z"
|
||||
},
|
||||
{
|
||||
"id": 87,
|
||||
"id": "87",
|
||||
"title": "claims_coverage: להבחין בין טענות כתב-ערר לטענות תכתובת",
|
||||
"description": "בדיקת claims_coverage מסמנת false-positives: טענות שעלו רק בתכתובות/תגובות בין הצדדים (לא בכתב הערר) מסומנות כ'לא נענו'. יש להבחין בין טענות מכתב הערר (חובה מענה) לטענות מתכתובת (לא חייבות מענה עצמאי, במיוחד כשהערר מתקבל במלואו). מקור: chair_feedback תיק 1033-25.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:47.933Z"
|
||||
},
|
||||
{
|
||||
"id": 88,
|
||||
"id": "88",
|
||||
"title": "פער DB↔file: decision_blocks (DB) מול drafts/decision.md (disk)",
|
||||
"description": "legal-writer מעדכן decision_blocks ב-DB אבל legal-qa קורא drafts/decision.md מהדיסק — שני מקורות אמת לא מסונכרנים גורמים ל-QA להיכשל פעמיים על אותה בעיה. נדרש: או כתיבה אטומית ל-DB+file יחד, או hook אוטומטי regenerate-draft אחרי כל עדכון בלוק. מקור: chair_feedback תיקים 8126-03-25, CMPA-62. ראה legal-decision-lessons.md #35.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "high",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:47.943Z"
|
||||
},
|
||||
{
|
||||
"id": 89,
|
||||
"id": "89",
|
||||
"title": "[רכישת-סגנון T0] הזרקת הפרופיל-המופשט ל-block_writer + מדיניות-העתקה",
|
||||
"description": "הלוֹבר הראשי. block_writer.py יטען voice-fingerprint+author-features+Copy-Paste Templates ל-{style_context} בכל בלוק. הוראת-מדיניות לפי סוג-תוכן: נוסחה/בוילרפלייט→מותר להעתיק, ניתוח ספציפי→הכלל והתאם, מהות מתיק אחר→אסור. פיצול {precedents_context} ל-{daphna_style_exemplars} (סגנון) ו-{case_law_citations} (פסיקה). קבצים: block_writer.py:205-260,710,795-815. MVP.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "high",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:47.951Z"
|
||||
},
|
||||
{
|
||||
"id": 90,
|
||||
"id": "90",
|
||||
"title": "[רכישת-סגנון T1] Backfill decision_paragraphs+paragraph_embeddings מ-style_corpus",
|
||||
"description": "אכלוס כל 48 ההחלטות עם author='daphna' כדי שאחזור-הבלוק (search_similar_paragraphs) יחזיר פסקאות אמיתיות של דפנה. documents.py:186-215 + סקריפט חד-פעמי. תלוי: אין. MVP-enabler.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "high",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:47.959Z"
|
||||
},
|
||||
{
|
||||
"id": 91,
|
||||
"id": "91",
|
||||
"title": "[רכישת-סגנון T2] הרחבת search_similar_paragraphs — סינון outcome+practice_area+block_type",
|
||||
"description": "db.py:2243 — להוסיף סינון, להחזיר פסקה מלאה, להרחיב לבלוקים ז/ח (לא רק י). block_writer.py:710 מעביר outcome, 4→6 exemplars. תלוי: T1.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "high",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:47.965Z"
|
||||
},
|
||||
{
|
||||
"id": 92,
|
||||
"id": "92",
|
||||
"title": "[רכישת-סגנון T3] דוגמאות contrastive + תיוג 'תבנית-קול בלבד'",
|
||||
"description": "להחזיר גם 'במה דפנה שונה' לא רק דומה (author-features+contrastive, arxiv 2504.08745). תלוי: T2,T0.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:47.973Z"
|
||||
},
|
||||
{
|
||||
"id": 93,
|
||||
"id": "93",
|
||||
"title": "[רכישת-סגנון T4] חיבור learning_loop ל-mark-final דרך ה-curator",
|
||||
"description": "mark-final מסמן+מעיר; curator מריץ ingest_final_version (claude_session לא בקונטיינר). app.py:3217-3283, paperclip_client.py, hermes-curator.md. תלוי: אין. MVP.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "high",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:47.982Z"
|
||||
},
|
||||
{
|
||||
"id": 94,
|
||||
"id": "94",
|
||||
"title": "[רכישת-סגנון T5] פנקס-התאמה draft_final_pairs + snapshot ב-mark-final (INV-LRN4)",
|
||||
"description": "טבלה draft_final_pairs(case_id,draft_text,final_text,diff_stats,status,created_at). snapshot של הטיוטה ברגע mark-final (אחרת diff מזוהם). זו 'רשימת ההחלטות' של כלל-העל + ground-truth ל-T7. תלוי: T4. MVP.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "high",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:47.991Z"
|
||||
},
|
||||
{
|
||||
"id": 95,
|
||||
"id": "95",
|
||||
"title": "[רכישת-סגנון T6] פנקס-התאמה ב-UI + קטגוריה במרכז-אישורים",
|
||||
"description": "רשימת כל ההחלטות + סטטוס (draft_done/final_received/analyzed/lessons_folded). מרכז-אישורים: קטגוריה 'ממתינות להשוואה מול סופי'. תלוי: T5.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:47.998Z"
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"id": "96",
|
||||
"title": "[רכישת-סגנון T7] מדד מרחק-סגנון (style_distance.py)",
|
||||
"description": "golden_ratio_adherence + anti_pattern_hits + draft_to_final_diff (ללא LLM). lessons.py יקבל ANTI_PATTERNS. חשיפה דרך get_metrics/tool. תלוי: T5. MVP.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "high",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:48.007Z"
|
||||
},
|
||||
{
|
||||
"id": 97,
|
||||
"id": "97",
|
||||
"title": "[רכישת-סגנון T8] הסרת LIMIT 20 ב-style_analyzer (כיסוי 48/48)",
|
||||
"description": "style_analyzer.py:124 — LIMIT 20→פרמטר/הסרה. מזין author-features של T0. תלוי: אין.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:48.015Z"
|
||||
},
|
||||
{
|
||||
"id": 98,
|
||||
"id": "98",
|
||||
"title": "[רכישת-סגנון T9] תיקון-המספור: ביטול אנטי-דפוס + מספור-אוטומטי בייצוא",
|
||||
"description": "ביטול 'אסור רשימה ממוספרת' (voice-fingerprint 3.1 — שגוי, ההחלטה ממוספרת תמיד). ייצוא DOCX יחיל מספור-אוטומטי של Word (skills/dafna-decision-template); הכותב יפסיק להזריק מספרים ידניים. בדיקה: האם הייצוא כבר ממספר אוטומטית. תלוי: אין.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:48.022Z"
|
||||
},
|
||||
{
|
||||
"id": 99,
|
||||
"id": "99",
|
||||
"title": "[רכישת-סגנון T10] get_style_guide דינמי — golden-ratios נמדדים מקורפוס",
|
||||
"description": "drafting.py:68 — golden-ratios נמדדים מהקורפוס לצד הקבועים, סימון פער. תלוי: T1,T8.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "low",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:48.028Z"
|
||||
},
|
||||
{
|
||||
"id": 100,
|
||||
"id": "100",
|
||||
"title": "[רכישת-סגנון T11] regen API types + deploy",
|
||||
"description": "npm run api:types ב-web-ui אם נוסף tool/endpoint; commit+push+Coolify deploy; MCP restart מקומי. תלוי: כל משימה שמשנה endpoint/tool.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:48.038Z"
|
||||
},
|
||||
{
|
||||
"id": 101,
|
||||
"id": "101",
|
||||
"title": "[רכישת-סגנון T12] /methodology — קטגוריות profile חדשות",
|
||||
"description": "להוסיף ל-CRUD הגנרי (/api/methodology/{category}) קטגוריות: transition_phrases, anti_patterns, voice_invariants (קבועי voice-fingerprint). + טאבים ב-web-ui/src/app/methodology. תלוי: אין.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:48.044Z"
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"id": "102",
|
||||
"title": "[רכישת-סגנון T13] /training — טאבי learning חדשים",
|
||||
"description": "טאב מדד-מרחק (מגמת T7), טאב פנקס-התאמה (T6), חיווט 'השוואה' ל-draft_final_pairs, פורטרט 'נמדד מול יעד'. תלוי: T5,T7.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:48.051Z"
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"id": "103",
|
||||
"title": "[רכישת-סגנון T14] אישור הצעות-distillation של ה-curator → כתיבה לפרופיל",
|
||||
"description": "משטח אישור הצעות ה-curator (שער INV-G10) שכותב ל-methodology/voice-fingerprint. /training טאב הסוכן. תלוי: T4.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-06-06T21:02:48.060Z"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-06-03T16:56:13.158Z",
|
||||
"taskCount": 86,
|
||||
"completedCount": 77,
|
||||
"lastModified": "2026-06-06T21:02:48.060Z",
|
||||
"taskCount": 103,
|
||||
"completedCount": 95,
|
||||
"tags": [
|
||||
"legal-ai"
|
||||
],
|
||||
"created": "2026-06-06T12:53:14.496Z",
|
||||
"description": "Tasks for legal-ai context",
|
||||
"updated": "2026-06-06T15:58:42.555Z"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
241
CLAUDE.md
241
CLAUDE.md
@@ -1,10 +1,11 @@
|
||||
# עוזר משפטי — Legal Decision Assistant
|
||||
|
||||
> **אינדקס דק.** הכללים הקריטיים נמצאים כאן; העומק התפעולי (Deploy, Paperclip-ops, adapters, מבנה-תיקיות, Chair-Feedback, TaskMaster מלא) הוצא ל-[`docs/operations-runbook.md`](docs/operations-runbook.md) כדי לרזות את ההקשר הנטען בכל סשן.
|
||||
|
||||
## רקע הפרויקט
|
||||
|
||||
מערכת AI לסיוע בכתיבת החלטות של **ועדת ערר לתכנון ובניה, מחוז ירושלים**, בראשות **עו"ד דפנה תמיר**.
|
||||
|
||||
### מה עושה ועדת ערר?
|
||||
ועדת ערר היא גוף מעין-שיפוטי שדן בעררים על החלטות ועדות מקומיות לתכנון ובניה. הוועדה מקבלת חומרי מקור (כתבי ערר, תגובות, פרוטוקולים, תכניות), דנה בטענות הצדדים, ומוציאה **החלטה כתובה מנומקת** — מסמך משפטי פורמלי שניתן לביקורת שיפוטית בבית משפט לעניינים מנהליים.
|
||||
|
||||
### שלושה סוגי עררים
|
||||
@@ -15,12 +16,7 @@
|
||||
| פיצויים (ס' 197) | 9xxx | קר ומקצועי | דומה להיטל השבחה |
|
||||
|
||||
### מטרת המערכת
|
||||
לבנות כלי עבודה שמסייע ליו"ר הוועדה לנסח החלטות:
|
||||
1. **ניהול תיקים** — ייבוא חומרי מקור, סיווג מסמכים, מעקב סטטוס
|
||||
2. **בסיס ידע** — פסיקה, ביטויי מעבר, לקחים מהחלטות קודמות, חקיקה
|
||||
3. **חיפוש סמנטי (RAG)** — מציאת תקדימים רלוונטיים ופסקאות דומות
|
||||
4. **סיוע בכתיבה** — ייצור טיוטות לפי ארכיטקטורת 12 בלוקים בסגנון דפנה
|
||||
5. **ייצוא DOCX** — מסמך מעוצב מוכן להגשה
|
||||
כלי עבודה שמסייע ליו"ר הוועדה: **ניהול תיקים** (ייבוא, סיווג, מעקב סטטוס) · **בסיס ידע** (פסיקה, ביטויי מעבר, לקחים, חקיקה) · **חיפוש סמנטי (RAG)** · **סיוע בכתיבה** (טיוטות לפי 12 בלוקים בסגנון דפנה) · **ייצוא DOCX**.
|
||||
|
||||
### ⭐ יעד-העל: רכישת-הסגנון של דפנה (Style Acquisition)
|
||||
**היעד הראשי של המערכת הוא שהסוכנים יכתבו וינתחו עררים בדיוק כמו עו"ד דפנה תמיר** — לא רק לייצר טיוטה תקנית, אלא להפנים את **הקול והשיטה** שלה. זה מחייב **הפרדה מובהקת בין שתי תת-מערכות**:
|
||||
@@ -30,19 +26,9 @@
|
||||
|
||||
**הגישה (state-of-the-art לדאטה-מועט):** Text Style Transfer מבוסס **Authorial Style Profiling** — להכליל את סגנון דפנה ולהתאים לתיק. העתקת פסקאות מותרת לתוכן קבוע/נוסחאי; ניתוח ספציפי → להכליל; **מהות משפטית (הלכה/עובדה) — אסור להעתיק מתיק לתיק**. *לא* fine-tuning של משקולות (Opus סגור; קורפוס קטן מדי).
|
||||
|
||||
**כלל-העל — INV-LRN4:** כל החלטה אינה "סגורה" עד שהושוותה מול הגרסה הסופית של דפנה; כל סופי מנותח מול הטיוטה. כך לומדים מכל החלטה. **INV-LRN5:** שכבת-ידע-הקול לא תכיל מהות ספציפית — רק סגנון ושיטה.
|
||||
**כלל-העל — INV-LRN4:** כל החלטה אינה "סגורה" עד שהושוותה מול הגרסה הסופית של דפנה; כל סופי מנותח מול הטיוטה. **INV-LRN5:** שכבת-ידע-הקול לא תכיל מהות ספציפית — רק סגנון ושיטה. ספ מלא: [`docs/spec/07-learning.md`](docs/spec/07-learning.md) §0. ארכיטקטורה ומשימות: תוכנית `style-acquisition-subsystem`.
|
||||
|
||||
ספ מלא: [`docs/spec/07-learning.md`](docs/spec/07-learning.md) §0. ארכיטקטורה ומשימות: תוכנית `style-acquisition-subsystem`.
|
||||
|
||||
### מה היה קודם (Legacy)
|
||||
המערכת הקודמת היתה **Obsidian vault** עם Claude Code skills על שרת אחר. פותחו:
|
||||
- ניתוח סגנון של 3 החלטות (הכט — דחייה, בית הכרם — קבלה חלקית, אריאלי — השוואה)
|
||||
- ארכיטקטורת 12 בלוקים מבוססת CREAC / DITA / Akoma Ntoso / Federal Judicial Center
|
||||
- כללי כתיבה (רקע ניטרלי, ללא כפילות, טענות מקוריות בלבד)
|
||||
- לקחים מהשוואת טיוטות לגרסאות סופיות
|
||||
- סקריפט ייצוא DOCX
|
||||
|
||||
הידע שהופק מה-vault הוטמע במערכת הנוכחית — מסמכי ייחוס (`docs/`), קורפוס אימון (`data/training/`), ומבנה 12 בלוקים. ה-vault המקורי נמחק; הפרויקט הנוכחי עובד עם PostgreSQL + pgvector.
|
||||
> **Legacy:** המערכת הקודמת היתה Obsidian vault עם Claude Code skills. הידע שהופק ממנה (ניתוח סגנון, 12 בלוקים מבוססי CREAC/DITA/Akoma-Ntoso/FJC, כללי כתיבה, לקחים, ייצוא DOCX) הוטמע בפרויקט הנוכחי (`docs/`, `data/training/`). ה-vault נמחק; כעת PostgreSQL + pgvector.
|
||||
|
||||
---
|
||||
|
||||
@@ -58,6 +44,7 @@
|
||||
| [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
|
||||
| [`docs/legal-decision-lessons.md`](docs/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** |
|
||||
| [`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/fjc-principles-extraction.md` | חומר מקור: מיצוי מ-Judicial Writing Manual (FJC) | רק לבדיקת מקור |
|
||||
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
|
||||
@@ -73,6 +60,8 @@
|
||||
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
|
||||
| [`.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 |
|
||||
| [`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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -105,17 +94,13 @@
|
||||
|
||||
**לכן — כל סשן שעומד לכתוב/לשנות קוד או תיעוד חייב לעבוד ב-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
|
||||
```bash
|
||||
cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד ב-worktree" (כלי EnterWorktree)
|
||||
```
|
||||
נוצר תחת `.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`.
|
||||
נוצר תחת `.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`).
|
||||
|
||||
### הפרוטוקול (חל על שתי הדרכים)
|
||||
1. **בתחילת עבודת-כתיבה** — צור worktree (מומלץ: `claude --worktree`; ידני-fallback: `git worktree add -b <branch> .claude/worktrees/<slug> origin/main` — **תחת `.claude/worktrees/`** כדי שההגדרות יחולו).
|
||||
@@ -126,202 +111,41 @@ cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד
|
||||
6. **אל תיגע** בשינויים לא-מתויקים שאינם שלך בעץ הראשי — הם של סשן אחר. אם העץ הראשי על ענף זר — אל תתייק עליו.
|
||||
|
||||
> **בידוד-DB:** ה-worktree מבודד-קבצים בלבד — לא בידוד-repo ולא בידוד-DB. **אל תריץ migrations מ-2 worktrees במקביל** על Postgres המשותף (`localhost:5433`) — סכמה שאף סשן לא מצפה לה ([Run agents in parallel](https://code.claude.com/docs/en/agents)).
|
||||
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w` (קיימת רק ב-fork ה-deepseek שלנו). כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. הניתוח המלא והדרכים שנשקלו: TaskMaster `legal-ai` #104 (נסגר כ-cancelled — "לתעד, לא לבדד").
|
||||
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w`. כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. ניתוח מלא: TaskMaster `legal-ai` #104 (נסגר cancelled — "לתעד, לא לבדד").
|
||||
|
||||
---
|
||||
|
||||
## שרת Nautilus (158.178.131.193)
|
||||
## Deploy — תמצית קריטית
|
||||
|
||||
| שירות | תפקיד | כתובת |
|
||||
|-------|--------|-------|
|
||||
| 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` |
|
||||
שלושה מודלי-הרצה דרים יחד; ערבוב = הטעות הנפוצה. **פירוט מלא, UUIDs ופקודות: [`docs/operations-runbook.md`](docs/operations-runbook.md).**
|
||||
|
||||
### ⚠️ ארכיטקטורת Deploy — חובה לקרוא
|
||||
|
||||
**עוזר משפטי (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).
|
||||
- **legal-ai** (`web/`, `web-ui/`) = **Docker דרך Coolify**. שינוי קוד לא נכנס לתוקף עד `git commit` + `git push origin main` → Gitea Actions בונה image → `mcp__coolify__deploy` (~2-4 דק'). **אסור** uvicorn/`next dev` מקומית — אין Python על המכונה. בדיקה: `curl https://legal-ai.nautilus.marcusgroup.org/api/health`.
|
||||
- **Paperclip** = **pm2 מקומי** (`localhost:3100`). שינוי → `pm2 restart paperclip`. **אין** Docker/Coolify.
|
||||
- **legal-chat-service** = **pm2 מקומי** (`127.0.0.1:8770`), גשר claude CLI לטאב הצ'אט ב-/training. שינוי → `pm2 restart legal-chat-service`.
|
||||
|
||||
---
|
||||
|
||||
## מבנה תיקיות
|
||||
## Paperclip — כללים קריטיים (תמצית)
|
||||
|
||||
```
|
||||
/home/chaim/legal-ai/
|
||||
├── CLAUDE.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/ ← סקריפטים שהושלמו (לא להריץ)
|
||||
```
|
||||
**פירוט מלא + דוגמאות + פקודות sync: [`docs/operations-runbook.md`](docs/operations-runbook.md).**
|
||||
|
||||
- **Wakeup תמיד דרך API**: `POST /api/agents/{agent-id}/wakeup` עם `payload.issueId`. **אסור** `INSERT INTO agent_wakeup_requests` ישיר — הסוכן לא יתעורר לעולם (אין `heartbeat_run`).
|
||||
- **ניתוב comments דרך CEO**: תגובת-משתמש → פלאגין מעיר CEO → CEO מנתב ויוצר issue. סוכנים קוראים comments אחרונים לפני עבודה (HEARTBEAT 2b-2c).
|
||||
- **קריאות API דרך helper בלבד**: bash → `scripts/pc.sh`; Python → `pc_request()` מ-`web/paperclip_api.py`. **אסור** `curl` ישיר ל-Paperclip או `httpx.AsyncClient` ישיר.
|
||||
- **Cross-company sync**: 14 סוכנים = 7 × 2 חברות (CMP=1xxx master, CMPA=8xxx mirror). אחרי כל שינוי הגדרות/skills של סוכן — להריץ `scripts/sync_agents_across_companies.py --apply`. **מדלג** על סוכנים עם `adapter_type` שונה בין החברות (למשל `deepseek_local`) — להחיל ידנית בשתיהן.
|
||||
|
||||
---
|
||||
|
||||
## כלל: עדכון `scripts/SCRIPTS.md`
|
||||
|
||||
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/` — **חובה לעדכן את `scripts/SCRIPTS.md`** בהתאם.
|
||||
הקובץ מתעד את התפקיד, הסטטוס, וההחלפה (אם יש) של כל סקריפט.
|
||||
|
||||
---
|
||||
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/` — **חובה לעדכן את `scripts/SCRIPTS.md`** (תפקיד, סטטוס, החלפה).
|
||||
|
||||
## ניהול משימות — 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` ישירות.
|
||||
**תמיד** 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).
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
|
||||
---
|
||||
|
||||
## עקרונות כתיבה קריטיים
|
||||
## עקרונות כתיבה קריטיים (G11)
|
||||
|
||||
1. **"מבחן השופט"** — כל החלטה חייבת להיות קריאה לשופט שלא מכיר את התיק
|
||||
2. **"רקע ניטרלי"** — בלוק ו = עובדות בלבד. אין ציטוטים מצדדים, אין מילות שיפוט
|
||||
@@ -330,14 +154,7 @@ CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **
|
||||
5. **ארכיטקטורת 12 בלוקים** — ראה `docs/block-schema.md`
|
||||
6. **צ'קליסט תוכן** — בלוק י מקבל צ'קליסט תוכן אוטומטי לפי סוג הערר (ראה `lessons.py: CONTENT_CHECKLISTS`)
|
||||
|
||||
## הערות יו"ר (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
|
||||
> **הערות יו"ר (Chair Feedback):** מנגנון תיעוד הערות דפנה — טבלת `chair_feedback`, API `/api/feedback`, MCP `record_chair_feedback`/`list_chair_feedback`, UI `/feedback`. פירוט: [`docs/operations-runbook.md`](docs/operations-runbook.md).
|
||||
|
||||
## יו"ר: עו"ד דפנה תמיר
|
||||
- מדריך סגנון מלא: `skills/decision/SKILL.md`
|
||||
מדריך סגנון מלא: [`skills/decision/SKILL.md`](skills/decision/SKILL.md).
|
||||
|
||||
62
docs/anti-hallucination-gate.md
Normal file
62
docs/anti-hallucination-gate.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# שער anti-hallucination — הגנה משותפת מפני הזיות (INV-AH)
|
||||
|
||||
> **מקור-אמת אחד לכל הסוכנים.** כל סוכן נוגע-מהות מפנה לכאן (דרך [HEARTBEAT.md](.claude/agents/HEARTBEAT.md)
|
||||
> ובלוק "קרא לפני פעולה" שלו). אל תשכפל את הכללים בקובץ-סוכן — הפנה לכאן (G2 — בלי מסלולים מקבילים).
|
||||
> זהו המקבילה התוכנית ל-INV-AG1 (קריאת-ספ): כמו שאינך פועל "מהזיכרון" לגבי התנהגות-המערכת, אינך
|
||||
> מצטט פסיקה/חוק/הלכה/מספר "מהזיכרון".
|
||||
|
||||
## למה זה קיים
|
||||
כלי-AI משפטיים מובילים (Lexis+ AI, Westlaw) **הוזים פסיקה ב-17%–33%** גם עם RAG — זו לא בעיה
|
||||
שנעלמת מעצמה ("RAG ≠ hallucination-free"). בתחום מעין-שיפוטי, ציטוט-שווא של פסק-דין/סעיף/הלכה הוא
|
||||
כשל קריטי הניתן לביקורת שיפוטית. חמש הטכניקות למטה הן הקונצנזוס המקצועי להפחתת הזיות, מותאם לתחום.
|
||||
|
||||
---
|
||||
|
||||
## חמש הטכניקות הקשיחות (חלות על כל סוכן נוגע-מהות)
|
||||
|
||||
**AH-1 · עיגון-מקור (grounding) — אפס ציטוט מהזיכרון.**
|
||||
כל אזכור של פסק-דין / מספר-תיק / סעיף-חוק / הלכה / מקדם / "מתודה שמאית" / נתון כמותי חייב לבוא
|
||||
ממקור מאומת: **תוצאת כלי-אחזור** (`search_precedent_library`, `search_internal_decisions`,
|
||||
`search_case_documents`, `search_decisions`, `find_similar_cases`, `precedent_library_get`,
|
||||
`halacha_review`) **או מסמך בתיק**. אם לא הרצת חיפוש/לא קראת מסמך — אין לך את הפריט. *(Stanford RegLab / Magesh et al., JELS 2025; Anthropic — ground in retrieved sources.)*
|
||||
|
||||
**AH-2 · Quote-or-retract.**
|
||||
לכל אזכור-מקור צרף את הציטוט/מזהה המדויק שהמקור החזיר (`supporting_quote`/headnote/ציטוט מהמסמך).
|
||||
**אין ציטוט מאשר → הסר את האזכור.** *(Anthropic — retract if no supporting quote; RAGAS faithfulness — כל טענה חייבת להיות נתמכת ב-context.)*
|
||||
|
||||
**AH-3 · Abstention — "לא יודע" עדיף על המצאה.**
|
||||
לא נמצא מקור? כתוב מפורשות **"לא נמצא בקורפוס/בתיק — דורש אימות חיצוני"**. אסור לסגור פער בהשערה
|
||||
שנכתבת כעובדה. *(Anthropic — give the model an out.)*
|
||||
|
||||
**AH-4 · תיוג-ודאות.** סמן כל טענה לא-טריוויאלית:
|
||||
`[מאומת]` (מקור+ציטוט) · `[טעון-אימות]` (סביר/עולה מהמסמכים, אך לא אותר מקור מאשר) · `[ספקולציה]`
|
||||
(השערה אנליטית — מותרת רק כשאלה/הסתייגות, לא כקביעה). *(NIST AI RMF GenAI Profile — explainability/קליברציה; RAGAS — atomic-claim grounding.)*
|
||||
|
||||
**AH-5 · Chain-of-Verification (CoVe) — מעבר-אימות לפני סיום.**
|
||||
אחרי הטיוטה, פרק כל טענה עובדתית/אזכור לרשימה, ולכל אחת שאל "מאיזה מקור מאומת זה מגיע?".
|
||||
כל מה שאין לו עוגן — **הסר או הורד ל-`[ספקולציה]`**. *(Chain-of-Verification — Dhuliawala et al., arXiv:2309.11495, 2023.)*
|
||||
|
||||
> **ההבחנה שמכריעה הכל — "פער" מותר, "המצאה" אסורה:**
|
||||
> ✅ "אזכרתי את X — חיפשתי ולא מצאתי בקורפוס; דורש אימות." (פער לגיטימי) ·
|
||||
> ❌ "הנה תקדים Y רלוונטי" כש-Y לא הגיע מכלי-אחזור. (המצאה)
|
||||
|
||||
---
|
||||
|
||||
## יישום לפי תפקיד
|
||||
| סוכן | איך השער חל |
|
||||
|------|-------------|
|
||||
| **analyst / researcher** | מייצרי-מהות — עיגון-קורפוס מלא, log שאילתות + negative evidence, "מקור: כתבי טענות → דורש אימות". (כבר נהוג; כעת אחיד ומעוגן-מקור.) |
|
||||
| **writer** | **צרכן read-only** של פלט-המנתח המעוגן. **אסור** להוסיף פסיקה/סעיף/הלכה שלא הגיעו מהמנתח/הקורפוס. ציטוט בהחלטה = רק מ-`supporting_quote` מאומת. |
|
||||
| **qa** | **אוכף** את AH-1…AH-5 כשער-איכות: כל אזכור בטיוטה — האם מאומת-מקור? אם לא — `needs_revision`. |
|
||||
| **ceo** | מנתב ומסכם — לא ממציא מקורות; אם מצטט, מצטט ממה שהסוכנים אימתו. |
|
||||
| **proofreader** | תיקון-OCR בלבד — **אל "תתקן" לכיוון מונח משפטי סביר** (שם-תקדים/מספר-תיק/סכום): שמר את לשון-המקור; ספק → סמן, לא "תקן". |
|
||||
| **exporter** | מכני (DOCX) — אפס מהות חדשה. |
|
||||
| **hermes-curator** | הצעות בלבד (G10) — מעוגן-מקור, לא מזין שכבת-קול עם מהות (INV-LRN5). |
|
||||
| **שטן מליץ (Gemini)** | מימוש-הייחוס המלא של השער (`legal-analyst-gemini-critique.md`) — לידים-לא-הכרעות ליו"ר (human-in-the-loop, NIST). |
|
||||
|
||||
## מקורות מקצועיים
|
||||
1. Magesh, Surani, Dahl, Suzgun, Manning, Ho — *Hallucination-Free? Assessing the Reliability of Leading AI Legal Research Tools*, J. Empirical Legal Studies (2025), Stanford RegLab/HAI — שיעורי-הזיה 17–33% גם עם RAG.
|
||||
2. Anthropic — *Reduce hallucinations* (docs.anthropic.com): allow "I don't know" · cite quotes/sources · retract-if-no-quote · chain-of-thought.
|
||||
3. Dhuliawala et al. — *Chain-of-Verification Reduces Hallucination in LLMs*, arXiv:2309.11495 (2023).
|
||||
4. Es et al. — *RAGAS: Automated Evaluation of RAG*, arXiv:2309.15217 — faithfulness = יחס הטענות הנתמכות-בקונטקסט.
|
||||
5. NIST — *AI RMF: Generative AI Profile* (NIST-AI-600-1, 2024) — human-in-the-loop oversight ב-high-stakes.
|
||||
70
docs/corpus-graph.md
Normal file
70
docs/corpus-graph.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# מפת הקורפוס — גרף ציטוטים אינטראקטיבי (`/graph`)
|
||||
|
||||
תצוגת‑רשת אינטראקטיבית של קורפוס הפסיקה, בסגנון Obsidian Graph View, **מוטמעת נייטיב ב‑web‑ui**. כל פריט הוא נקודה, קישורים הם קווים, וגודל הנקודה משקף חשיבות — כך שאפשר להתמקד בנושא ולראות מה קשור אליו.
|
||||
|
||||
## למה נייטיב ולא 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+מטריקות), לא תוצאות חיפוש מדורגות.
|
||||
|
||||
## שכבות (כולן opt‑in דרך 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). **צבע** (color‑by, ברירת‑מחדל "סוג"): סוג · תחום · דרגת‑סמכות · **אשכול** (community) · עדכניות.
|
||||
|
||||
## אנליטיקה (Graph Analysis)
|
||||
|
||||
`metrics=true` מפעיל חישוב **in‑memory** (ללא DB) ב‑[`web/graph_metrics.py`](../web/graph_metrics.py) — pure, ללא תלויות (אין networkx):
|
||||
- **PageRank** (power‑iteration) — השפעה גלובלית.
|
||||
- **Betweenness** (Brandes) — "גשריות" (פסיקות שמחברות אשכולות).
|
||||
- **Community** (label‑propagation דטרמיניסטי + fallback ל‑connected‑components) — אשכולות תמטיים.
|
||||
|
||||
מחושב על **תת‑גרף הפסיקות בלבד** (cites/same_chain) — קשתות hub/gap/digest/halacha מוחרגות. ב‑UI: בוררי "צביעה לפי" / "גודל לפי" + פאנל דירוג ("המשפיעות" / "גשרים").
|
||||
|
||||
## ניווט וחוויה
|
||||
|
||||
- **Deep‑link** `/graph?focus=cl:<id>` — לינק שיתופי; כפתור **"הצג בגרף"** בכל דף פסיקה.
|
||||
- **Local graph** — לחיצה על נקודה → התמקדות בשכניה (BFS, סליידר עומק 1–3).
|
||||
- **ייצוא 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: צומת + שכנים בעומק 1–3 |
|
||||
| `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` ציור react‑force‑graph‑2d · `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. **dangling‑edge 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()`; אפס כתיבות; מטריקות in‑memory. ללא store מקביל.
|
||||
- **G5** — כל פילטר server‑side, parameterized.
|
||||
- **UI2** — `response_model` מפורש בכל endpoint; **UI4** — שגיאות UI מוצגות, לא נבלעות.
|
||||
- **טופולוגיה ≠ אחזור** — מבנה הקורפוס, לא תוצאות חיפוש.
|
||||
|
||||
## היסטוריית מימוש
|
||||
|
||||
PR #113 (בסיס) · #118 (תיקון תוויות) · #126 (מטא‑דאטה) · #129 (אנליטיקה) · #131 (gaps) · #132 (יומונים) · #134 (ניווט) · #137 (הלכות) · #139 (api:types).
|
||||
203
docs/operations-runbook.md
Normal file
203
docs/operations-runbook.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Operations Runbook — עוזר משפטי
|
||||
|
||||
> תוכן תפעולי-עומק שהוצא מ-[`CLAUDE.md`](../CLAUDE.md) כדי לרזות את ההקשר הנטען בכל סשן (TaskMaster #107.1).
|
||||
> ה-CLAUDE.md מחזיק את **הכללים הקריטיים בקצרה**; כאן נמצאים הפרטים המלאים, הפקודות, וטבלאות-הייחוס.
|
||||
> כשעובדים על Deploy, Paperplip-ops, או adapters — לקרוא את הסעיף הרלוונטי כאן.
|
||||
|
||||
---
|
||||
|
||||
## שרת Nautilus (158.178.131.193)
|
||||
|
||||
| שירות | תפקיד | כתובת |
|
||||
|-------|--------|-------|
|
||||
| Coolify | ניהול containers | `http://158.178.131.193:8000` |
|
||||
| PostgreSQL + pgvector | בסיס נתונים ראשי | `legal-ai-postgres` (`localhost:5433`, user `legal_ai`) |
|
||||
| Redis | תור משימות | `legal-ai-redis` |
|
||||
| Gitea | מאגר קוד | `gitea.nautilus.marcusgroup.org/ezer-mishpati` |
|
||||
| ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` |
|
||||
| Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` |
|
||||
| legal-chat-service | גשר claude CLI לטאב הצ'אט ב-/training (pm2, loopback) | `127.0.0.1:8770` |
|
||||
| Infisical | ניהול סודות | `secret.dev.marcus-law.co.il` |
|
||||
|
||||
---
|
||||
|
||||
## ארכיטקטורת Deploy — חובה לקרוא
|
||||
|
||||
שלושה מודלי-הרצה דרים יחד. ערבוב ביניהם הוא הטעות הנפוצה ביותר.
|
||||
|
||||
### עוזר משפטי (Legal-AI) — Docker container דרך Coolify
|
||||
- UUID: `gyjo0mtw2c42ej3xxvbz8zio` (build_pack: `dockerimage`, **לא** `dockerfile`)
|
||||
- שינוי קוד ב-`web/` או `web-ui/` **לא נכנס לתוקף** עד ש:
|
||||
1. עושים `git commit` + `git push origin main`
|
||||
2. Gitea Actions בונה image → דוחף ל-registry → מפעיל redeploy ב-Coolify (`mcp__coolify__deploy`)
|
||||
3. ממתינים ~2-4 דקות לבנייה
|
||||
- **אסור** לנסות להריץ uvicorn / `next dev` מקומית — אין סביבת Python על המכונה
|
||||
- ה-container מריץ Next.js (`:3000`, חשוף) + FastAPI (`:8000`, פנימי)
|
||||
- בדיקה: `curl https://legal-ai.nautilus.marcusgroup.org/api/health`
|
||||
- runbook מלא של ה-pipeline: `~/CI-CD-MIGRATION-GUIDE.md`
|
||||
|
||||
### Paperclip — מקומית דרך pm2
|
||||
- פורט: `localhost:3100`, DB: `localhost:54329` (Postgres embedded)
|
||||
- שינויי קוד נכנסים לתוקף אחרי `pm2 restart paperclip`
|
||||
- **אין צורך ב-Docker או Coolify** (מיגרציה ל-Coolify נוסתה 2026-04-04 והוחזרה 2026-04-08)
|
||||
- תרגום/RTL: `~/.paperclip/hebrew/` → `bash ~/.paperclip/hebrew/apply-hebrew.sh && pm2 restart paperclip`
|
||||
|
||||
### legal-chat-service — מקומית דרך pm2 (מאפריל 2026)
|
||||
- פורט: `127.0.0.1:8770` (loopback בלבד)
|
||||
- שירות aiohttp קצר שעוטף את `claude` CLI ב-streaming + session continuation, ומשרת את הטאב "שיחה" בדף `/training`. הקונטיינר משדל אליו proxy דרך `host.docker.internal:8770`.
|
||||
- קוד: [`mcp-server/src/legal_mcp/chat_service/`](../mcp-server/src/legal_mcp/chat_service/)
|
||||
- התקנה: `pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs && pm2 save`
|
||||
- בריאות: `curl http://127.0.0.1:8770/health` → `{"ok":true,...}`
|
||||
- שינויי קוד: `pm2 restart legal-chat-service`
|
||||
- **אפס עלות API** — claude CLI משתמש ב-claude.ai subscription של chaim. הנחת היסוד של `claude_session.py` (claude CLI מקומי בלבד) נשמרת.
|
||||
- Coolify dependency: ה-Service Definition של legal-ai חייב להכיל `extra_hosts: host.docker.internal:host-gateway` (אחרת ה-proxy יקבל ConnectError).
|
||||
|
||||
---
|
||||
|
||||
## מבנה תיקיות
|
||||
|
||||
```
|
||||
/home/chaim/legal-ai/
|
||||
├── CLAUDE.md ← אינדקס דק (כללים קריטיים + מצביעים)
|
||||
├── docs/operations-runbook.md ← הקובץ הזה (עומק תפעולי)
|
||||
├── Dockerfile ← Docker build
|
||||
├── docs/ ← תיעוד + לקחים
|
||||
│ ├── architecture.md ארכיטקטורה
|
||||
│ ├── block-schema.md 12 בלוקים (המסמך החשוב ביותר)
|
||||
│ ├── migration-plan.md תוכנית מעבר vault → DB
|
||||
│ ├── legal-decision-lessons.md לקחים מ-3 החלטות
|
||||
│ └── memory.md הקשר כללי — skills, פרויקטים
|
||||
├── skills/ ← כלי עבודה ומדריכים
|
||||
│ ├── decision/ מדריך סגנון + references + 12 בלוקים
|
||||
│ ├── assistant/ קטלוג מסמכים
|
||||
│ ├── docx/ עיצוב DOCX
|
||||
│ ├── dafna-decision-template/ export DOCX לפי תבנית Word של דפנה
|
||||
│ └── new-company-setup/ blueprint הוספת חברה חדשה
|
||||
├── .claude/
|
||||
│ └── agents/ ← הוראות סוכנים + HEARTBEAT.md (symlinks ב-Paperclip)
|
||||
│ ├── HEARTBEAT.md checklist הפעלה משותף לכל הסוכנים
|
||||
│ ├── legal-ceo.md תזמורן + בקרת זרימה
|
||||
│ ├── legal-writer.md כתיבת בלוקים בסגנון דפנה
|
||||
│ ├── legal-analyst.md ניתוח משפטי + חילוץ טענות
|
||||
│ ├── legal-researcher.md חיפוש תקדימים
|
||||
│ ├── legal-qa.md 7 שערי איכות
|
||||
│ ├── legal-proofreader.md תיקון OCR
|
||||
│ ├── legal-exporter.md ייצוא DOCX סופי
|
||||
│ └── hermes-curator.md סוכן Hermes לניתוח סגנון post-export
|
||||
├── data/
|
||||
│ ├── training/ ← 4 החלטות לאימון (DOCX)
|
||||
│ ├── exports/ ← טיוטות DOCX מיוצאות
|
||||
│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB)
|
||||
├── web/ ← FastAPI backend (Python): 75+ API endpoints
|
||||
│ ├── app.py ← API ראשי
|
||||
│ ├── paperclip_api.py ← אינטגרציית Paperclip: `pc_request()` + `emit_case_status_webhook()`
|
||||
│ ├── paperclip_client.py ← legacy client (ישן — השתמש ב-paperclip_api.py)
|
||||
│ └── gitea_client.py ← אינטגרציית Gitea
|
||||
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
|
||||
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
|
||||
├── mcp-server/ ← MCP server + services + tools
|
||||
├── adapters/ ← Paperclip external adapters
|
||||
│ └── deepseek-paperclip-adapter/ ← `deepseek_local` (Hermes-pinned ל-DeepSeek profile)
|
||||
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
|
||||
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Paperclip — כללי אינטגרציה (פירוט מלא)
|
||||
|
||||
> הכללים הקריטיים בתמצית נמצאים ב-[`CLAUDE.md`](../CLAUDE.md). כאן הפירוט המלא, הדוגמאות, וה-"למה".
|
||||
|
||||
### Wakeup API — תמיד דרך API, לעולם לא דרך DB
|
||||
- **הנתיב הנכון**: `POST /api/agents/{agent-id}/wakeup` (לא `/wake`!)
|
||||
- **⚠️ אסור**: `INSERT INTO agent_wakeup_requests` ישירות — זה יוצר רק רשומה בלי `heartbeat_run`, והסוכן **לא יתעורר לעולם**
|
||||
- **⚠️ חובה לשלוח `payload` עם `issueId`** — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי issue, בלי cwd נכון)
|
||||
- דוגמה נכונה:
|
||||
```json
|
||||
{"source": "automation", "triggerDetail": "system", "reason": "...",
|
||||
"payload": {"issueId": "...", "mutation": "comment", "commentId": "..."}}
|
||||
```
|
||||
- **Board API Key**: שמור ב-DB (`board_api_keys`), auth: `Authorization: Bearer pbk_...`
|
||||
|
||||
### ניתוב comments דרך CEO
|
||||
- כשמשתמש כותב תגובה על issue ב-Paperclip, הפלאגין (`plugin-legal-ai`) מעיר את ה-CEO דרך `ctx.agents.invoke()`
|
||||
- ה-CEO קורא את ה-comment, מחליט על ניתוב, ויוצר issue לסוכן המתאים
|
||||
- כל הסוכנים חייבים לקרוא comments אחרונים לפני שהם מתחילים לעבוד (HEARTBEAT שלבים 2b-2c)
|
||||
|
||||
### קריאות API — תמיד דרך helper, לעולם לא `curl` ישיר
|
||||
- **bash (סוכנים):** `~/legal-ai/scripts/pc.sh <METHOD> <PATH> [BODY_JSON]` — מוסיף Authorization, X-Paperclip-Run-Id, Content-Type, base URL. ראה `HEARTBEAT.md §0`.
|
||||
- **Python (FastAPI):** `from web.paperclip_api import pc_request; await pc_request("POST", "/api/...", json={...})` — שימוש ב-board API key.
|
||||
- **אסור** `curl ... $PAPERCLIP_API_URL` ישיר ב-bash; **אסור** `httpx.AsyncClient` ישיר ל-Paperclip ב-Python.
|
||||
- **למה:** ה-skill הרשמי דורש `X-Paperclip-Run-Id` בכל קריאה משנה issue. אצלנו ה-audit trail עבד ממילא דרך JWT claims (`runId: runIdHeader || claims.run_id`), אבל ה-helper מבטיח עקביות + תאימות ל-board API keys (long-lived) שלא נושאות JWT claims.
|
||||
|
||||
### Cross-company agent sync — אחרי כל שינוי הגדרות
|
||||
- יש 14 סוכנים = 7 × 2 חברות (CMP=1xxx, CMPA=8xxx). Paperclip מחייב `agents.company_id NOT NULL` — אין shared agents.
|
||||
- **Master = CMP (1xxx)**, **Mirror = CMPA (8xxx)**.
|
||||
- אחרי כל שינוי ב-`adapter_config`, `runtime_config`, `budget_monthly_cents`, או skills של סוכן ב-master (UI, SQL, או API), **חובה להריץ:**
|
||||
```bash
|
||||
PAPERCLIP_BOARD_API_KEY=$(...infisical...) \
|
||||
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify # לבדיקה
|
||||
PAPERCLIP_BOARD_API_KEY=$(...) \
|
||||
python ~/legal-ai/scripts/sync_agents_across_companies.py --apply # לסנכרן
|
||||
```
|
||||
- הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent.
|
||||
- שאלות ה-skill הרשמי של Paperclip — `paperclip` skill תחת `paperclipai/paperclip`.
|
||||
|
||||
### Webhook יוצא — עדכון סטטוס תיק לפלאגין
|
||||
כשסטטוס תיק משתנה דרך `PUT /api/cases/{case_number}`, הבקאנד שולח webhook אסינכרוני לפלאגין:
|
||||
|
||||
```
|
||||
PUT /api/cases/{case_number} → emit_case_status_webhook() [BackgroundTask]
|
||||
→ POST /api/plugins/marcusgroup.legal-ai/webhooks/case-status
|
||||
→ plugin-legal-ai/onWebhook()
|
||||
→ comment בעברית על issue + CEO wakeup (כשסטטוס = qa_failed)
|
||||
```
|
||||
|
||||
- הקוד ב-`web/paperclip_api.py` (`emit_case_status_webhook`), fire-and-forget, timeout 5s
|
||||
- הפלאגין שומר idempotency key ב-state עם TTL 5 דקות למניעת spam על retry
|
||||
- `GET /api/cases/stale?days=N` — תיקים שלא עודכנו N ימים; מוחרגים: `new`, `final`, `exported`
|
||||
- `GET /api/chair-feedback/weekly-summary` — סיכום פידבק YU"R לשבוע האחרון
|
||||
|
||||
### Scheduled Jobs (plugin-legal-ai)
|
||||
| Job | לוח זמנים | מה עושה |
|
||||
|-----|-----------|---------|
|
||||
| `stale-case-reminder` | יומי 08:00 | שולח comment אזהרה על תיקים תקועים >3 ימים |
|
||||
| `weekly-feedback-analysis` | ראשון 19:00 | מעיר CEO לניתוח פידבק YU"R ועדכון `docs/legal-decision-lessons.md` |
|
||||
| `sync-case-status` | כל 30 דק' | מסנכרן סטטוסי תיקים בין legal-ai ל-Paperclip |
|
||||
|
||||
CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **אין לו issueId, אל תנסה לפרסם comment או לסגור issue**.
|
||||
|
||||
### External adapters — `deepseek_local`
|
||||
- מיקום ה-package: [`adapters/deepseek-paperclip-adapter/`](../adapters/deepseek-paperclip-adapter/) (לא ב-`node_modules`).
|
||||
- רישום ב-Paperclip: רשומה ב-`~/.paperclip/adapter-plugins.json` (נטען אוטומטית ב-startup דרך `buildExternalAdapters`). אין צורך בעריכת `node_modules`.
|
||||
- **מה ה-adapter עושה**: spawnל-`hermes chat` עם `HERMES_HOME=/home/chaim/.hermes/profiles/deepseek` כך שה-CLI טוען את `config.yaml` (`base_url=https://api.deepseek.com/v1`, `provider=custom`, `key_env=DEEPSEEK_API_KEY`) ואת `.env` (שמכיל את ה-key).
|
||||
- **מודלים זמינים** (lookup ב-DeepSeek `/v1/models`): `deepseek-v4-pro` (default), `deepseek-v4-flash`. יופיעו כדרופ-דאון ב-UI.
|
||||
- **התקנה מחדש / עדכון**: `curl -X POST -H "Authorization: Bearer pcapi_legal_install_key_2026" -H "Content-Type: application/json" -d '{"packageName":"/home/chaim/legal-ai/adapters/deepseek-paperclip-adapter","isLocalPath":true}' http://localhost:3100/api/adapters/install`. לעדכון hot — `POST /api/adapters/deepseek_local/reload`.
|
||||
- **⚠ Cross-company sync**: `sync_agents_across_companies.py` **מדלג** על סוכנים עם `adapter_type` שונה בין CMP ל-CMPA. כשעוברים סוכן ל-`deepseek_local` חובה להחיל ידנית בשתי החברות לפני sync.
|
||||
- **תוספת adapters עתידיים** (OpenAI ישיר, Anthropic ישיר, וכו'): אותו דפוס. ה-package הראשי חייב לייצא `createServerAdapter()` שמחזיר `{ type, label, models, agentConfigurationDoc, execute, testEnvironment, sessionCodec, listSkills, syncSkills, ... }`. ראה את [`adapters/deepseek-paperclip-adapter/dist/index.js`](../adapters/deepseek-paperclip-adapter/dist/index.js) כתבנית.
|
||||
|
||||
### External adapters — Hermes Curator (`curator-cmp` / `curator-cmpa`)
|
||||
- פרופילי Hermes נפרדים לסוכן `hermes-curator` — מנתח החלטות סופיות ומציע עדכוני SKILL.md/lessons.md
|
||||
- מיקום: `~/.hermes/profiles/curator-cmp/` + `~/.hermes/profiles/curator-cmpa/`
|
||||
- מופעל אחרי export סופי; אינו מעדכן קבצים ישירות
|
||||
- **תהליך אישור הצעות:** הצעות ה-curator מגיעות כ-comment ב-Paperclip → חיים בוחן ומאשר ידנית → commits ל-`SKILL.md` ו-`docs/legal-decision-lessons.md`
|
||||
|
||||
---
|
||||
|
||||
## הערות יו"ר (Chair Feedback)
|
||||
|
||||
מנגנון לתיעוד הערות דפנה על טיוטות:
|
||||
- **DB**: טבלת `chair_feedback` (case_id, block_id, feedback_text, category, lesson_extracted)
|
||||
- **API**: `GET/POST /api/feedback`, `PATCH /api/feedback/{id}/resolve`
|
||||
- **MCP tools**: `record_chair_feedback`, `list_chair_feedback`
|
||||
- **UI**: דף ניהול ב-`/feedback` (ב-Next.js)
|
||||
- **קטגוריות**: missing_content, wrong_tone, wrong_structure, factual_error, style, other
|
||||
|
||||
---
|
||||
|
||||
## ניהול משימות — TaskMaster AI (פירוט)
|
||||
|
||||
- קובץ המשימות הקנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (יחסי ל-project root, **לא** `~/.taskmaster/tasks/tasks.json`). מכיל את כל ה-tags של legal-ai (`master`, `legal-ai`).
|
||||
- פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`
|
||||
- לפני התחלת עבודה → `next_task`; אחרי סיום → `update_task` עם status=done; משימה מורכבת → `expand_task`
|
||||
- **⚠️ מלכוד cwd ב-CLI:** הדגל `--tag` בוחר קבוצה לוגית *בתוך* הקובץ — הוא **לא** בוחר לאיזה `tasks.json` לכתוב. ה-CLI מאתר את הקובץ לפי ה-cwd. תמיד `cd ~/legal-ai` לפני `task-master add-task` או כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את `~/legal-ai/.taskmaster/tasks/tasks.json` ישירות.
|
||||
@@ -155,3 +155,78 @@ CEO צריך להעביר את ה-issue ל-`in_review` (לא `in_progress`) כש
|
||||
### סטטוס
|
||||
|
||||
- **תיקון בכל הסקייל** (CLAUDE.md זיכרון: `reference_paperclip_wakeup.md`)
|
||||
|
||||
---
|
||||
|
||||
## 5. מחיקת npx cache → crash-loop בהפעלה (השרת מנצח את הפאטצ')
|
||||
|
||||
### מה קורה
|
||||
|
||||
Paperclip מופעל דרך `exec npx -y paperclipai@<version> run` ב-[start-paperclip.sh](../../.paperclip/scripts/start-paperclip.sh). npx **עושה reuse** ל-cache שכבר חולץ (`~/.npm/_npx/<hash>/node_modules/@paperclipai/server/`) — הוא **לא** מחלץ מחדש בכל הפעלה. כל עוד ה-cache קיים, הפאטצ'ים שהוחלו עליו פעם אחת נשמרים על פני ריסטארטים.
|
||||
|
||||
הבעיה מתחילה כש-ה-cache **נמחק** (`npm cache clean`, prune, או ניקוי ידני) בזמן שהתהליך רץ. אז נוצרות שתי תקלות נפרדות:
|
||||
|
||||
1. **התהליך הישן ממשיך "online" אבל שבור** — המודולים של node כבר טעונים בזיכרון, אז `/api/health` עדיין מחזיר 200, אבל `GET /` קורא את `ui-dist/index.html` **מהדיסק בכל בקשה** (`readFileSync`) → `ENOENT` → **HTTP 500** (`{"error":"Internal server error"}`). גם ה-URL הציבורי `pc.nautilus...` מחזיר 500.
|
||||
2. **בריסטארט נכנסים ל-crash-loop** — npx מחלץ עותק **טרי ולא-מתוקן**. השרת מריץ `assertCloudDatabaseContract()` (ראה patch §4 ב-start script) שמסרב ל-embedded PG במצב authenticated/public → **קורס מיד**, לפני שלולאת-הרקע (5/20/60ש') מספיקה להחיל את פאטץ' ה-bypass. כל ריסטארט מחלץ-וקורס מחדש ⇒ עשרות ריסטארטים, שום דבר לא מאזין על 3100.
|
||||
|
||||
### ראיה אמפירית — 06/06/26
|
||||
|
||||
```
|
||||
# התהליך הישן: online 5D אבל GET / נכשל
|
||||
GET / 500 — ENOENT: no such file or directory,
|
||||
open '.../@paperclipai/server/ui-dist/index.html'
|
||||
/api/health → 200 # שורד כי לא קורא קבצים
|
||||
|
||||
# אחרי restart: crash-loop
|
||||
pm2 describe paperclip → status: "waiting restart", restarts: 36, nothing on :3100
|
||||
ERROR log → "Paperclip server failed to start.
|
||||
authenticated public deployments require DATABASE_URL ...;
|
||||
refusing embedded PostgreSQL fallback"
|
||||
```
|
||||
|
||||
הורדת החבילה איטית (~30ש', native builds) — מה שמחמיר את ה-loop: `min_uptime` של PM2 קוטע את ה-npx **באמצע ההורדה** לפני שהוא מסיים לחלץ, כך שה-cache לעולם לא מתמלא.
|
||||
|
||||
### ההשפעה על הצינור שלנו
|
||||
|
||||
Paperclip מושבת לגמרי — ה-UI לא עולה לאף משתמש, וכל סוכני Paperclip (14 הסוכנים) לא יכולים לרוץ כי הם חולקים את התהליך הזה.
|
||||
|
||||
### תיקון — שער סינכרוני לפני הפעלת השרת
|
||||
|
||||
**שורש הבעיה:** פאטץ' ה-cloud-db-bypass חייב להיות על הדיסק **לפני** שהשרת רץ; לולאת-הרקע מאוחרת מדי. ב-[start-paperclip.sh](../../.paperclip/scripts/start-paperclip.sh) נוספה `ensure_patched_before_run()` (06/06/26) שרצה סינכרונית לפני `exec`:
|
||||
|
||||
1. בודקת אם `@paperclipai/server/ui-dist/index.html` קיים ב-cache (ראה "מלכודות בדרך" — זה הסמן הנכון, לא `dist/index.js`).
|
||||
2. אם לא — מריצה `npx -y paperclipai@<version> --help`. זה מאלץ את npx **לחלץ את כל החבילה** (כולל `ui-dist/`) כדי להריץ את ה-CLI, שמדפיס help ו**יוצא לבד ב-exit 0** — **לא** מפעיל שרת ולא תופס את 3100 (אומת). אין תהליך-רקע, אין שרת לא-מתוקן מוקדם, ואין מה להרוג.
|
||||
3. מחילה את **כל** הפאטצ'ים (כולל bypass) על ה-cache המחולץ — עם guard שלא מפיל את ה-wrapper אם patch נכשל.
|
||||
4. רק אז `exec npx ... run` — npx עושה reuse ל-cache המתוקן והשרת עולה נקי.
|
||||
|
||||
לולאת-הרקע (post-exec) נשמרה כרשת-ביטחון idempotent.
|
||||
|
||||
**אומת מקצה-לקצה (06/06/26):** מחיקת ה-cache בכוונה + `pm2 restart` → השער חילץ אוטומטית דרך `--help` (~64ש'), תיקן, והשרת עלה ל-200 ב-~72ש'. מונה הריסטארטים של PM2 **לא זז** (אפס crash-loop).
|
||||
|
||||
> **מלכודות שהתגלו בדרך (גרסה ראשונה של הפיקס נכשלה):**
|
||||
> 1. **סמן חילוץ שגוי** — `dist/index.js` נכתב ~שניות **לפני** `ui-dist/`. שער שממתין ל-`dist` ומריץ מיד → ui-dist עדיין חסר → 500. הסמן הנכון הוא `ui-dist/index.html` (הקובץ האחרון, וגם זה שגרם ל-500 המקורי).
|
||||
> 2. **`set -e` + patch כושל** — אם `apply-hebrew.sh` רץ בלי ui-dist הוא מחזיר שגיאה, ותחת `set -e` ה-wrapper מת → crash-loop חדש. הפתרון: `apply_all_patches || echo WARNING`.
|
||||
> 3. **`pkill -f "paperclipai@..."` תופס את עצמו** — מחרוזת הדפוס מופיעה ב-command line של ה-shell שמריץ את ה-pkill, אז הוא הורג את עצמו (exit 144). זו הסיבה שגישת spawn-`run`-then-`pkill` ננטשה לטובת `--help` שיוצא לבד. אם בכל זאת צריך להרוג — לפי PID (`kill $PID; pkill -P $PID`), לא לפי `-f`.
|
||||
|
||||
**שחזור** — עם הפיקס פרוס, מספיק `pm2 restart paperclip` וה-`ensure_patched_before_run()` מתאושש לבד. אם צריך לעשות זאת ידנית (fix אחר, דיבוג):
|
||||
```bash
|
||||
pm2 stop paperclip # לעצור loop אם קיים
|
||||
export PATH=/home/chaim/.nvm/versions/node/v24.14.0/bin:$PATH
|
||||
npx -y paperclipai@2026.529.0 --help >/dev/null 2>&1 # חילוץ נקי שיוצא לבד (לא מפעיל שרת)
|
||||
find ~/.npm/_npx -path "*@paperclipai/server/ui-dist/index.html" -type f # לאמת חילוץ מלא
|
||||
# להחיל פאטצ'ים על ה-cache, ובמיוחד ה-bypass:
|
||||
bash ~/.paperclip/hermes-patches/apply-cloud-db-bypass.sh
|
||||
bash ~/.paperclip/hebrew/apply-hebrew.sh
|
||||
bash ~/.paperclip/hermes-patches/apply-hermes-fixes.sh
|
||||
bash ~/.paperclip/hermes-patches/apply-deepseek-reaper-fix.sh
|
||||
grep -q HEBREW_PATCH_BYPASS_CLOUD_DB \
|
||||
~/.npm/_npx/*/node_modules/@paperclipai/server/dist/index.js && echo "BYPASS OK"
|
||||
pm2 start paperclip && pm2 save # reuse ל-cache המתוקן
|
||||
```
|
||||
> אל תשתמש ב-`pkill -f "paperclipai@..."` / `-f "@paperclipai/server"` — הדפוס תופס את ה-shell של עצמך (exit 144). אם חייבים להרוג תהליך — לפי PID.
|
||||
|
||||
### סטטוס
|
||||
|
||||
- **תוקן ב-start script** ע"י `ensure_patched_before_run()` (06/06/26) — שער סינכרוני שמחלץ+מתקן לפני exec.
|
||||
- **הערה מטעה תוקנה**: ההערה הישנה בראש ה-script טענה ש-`npx run` מחלץ-מחדש בכל הפעלה (לכן הסתמכו על לולאת-הרקע בלבד) — זה לא נכון, npx עושה reuse ל-cache תקין; הסכנה היא cache **מחוק**.
|
||||
- **לקח כללי**: כל patch שה-target שלו הוא assert בזמן-startup חייב להיות מוחל לפני `exec`, לא בלולאת-רקע.
|
||||
|
||||
@@ -227,7 +227,7 @@ Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוט
|
||||
|
||||
## 7. אינדקס הספ
|
||||
|
||||
> הערה: כל קבצי הספ (00, 01–07, X1–X10) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
||||
> הערה: כל קבצי הספ (00, 01–07, X1–X12) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
||||
|
||||
| קובץ | תפקיד | אוכף invariants |
|
||||
|------|--------|-----------------|
|
||||
@@ -250,6 +250,8 @@ Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוט
|
||||
| [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) | חוזה 71 כלי-ה-MCP: envelope · שמות · idempotency · extract/get-symmetry · שלמות-הרשאות | G2, G3, G10 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
> **X6–X10 (מחזור-2):** מכסים את 8 משטחי-האפליקציה שמחוץ לצינור-הליבה (אינטגרציה, web-ui, מילוי-שדות,
|
||||
> אחסון-ניתוחים, כלי-MCP, deploy/env). הממצאים ב-[gap-audit.md](gap-audit.md) (GAP-24..62 → FU-9..15)
|
||||
|
||||
@@ -155,6 +155,14 @@ RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
|
||||
**אכיפה:** 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)).
|
||||
|
||||
### 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
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
(`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**,
|
||||
ושם נולדת ההפרה ב-§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-DIG1–DIG3).
|
||||
|
||||
---
|
||||
|
||||
## 2. עיצוב ה-hybrid retrieval
|
||||
@@ -176,3 +183,4 @@ re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף
|
||||
- [02-data-model.md](02-data-model.md) — חוזה-השלמות (searchable) + re-index שהאחזור מסנן לפיהם.
|
||||
- [05-qa-review.md](05-qa-review.md) — שער-הלכה הידני (`review_status`) שמגדיר אילו הלכות searchable.
|
||||
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור מלאה של כל span מוחזר (בסיס ל-INV-RET5).
|
||||
- [X12-digests-radar.md](X12-digests-radar.md) — שכבת-הגילוי (יומונים) שמעל הקורפוסים — מצביעה, לא מצוטטת.
|
||||
|
||||
@@ -52,6 +52,18 @@
|
||||
**INV-LRN5 (טוהר-הקול → G4/G11):** שכבת-ידע-הקול (voice-fingerprint, style_patterns, exemplars) **לא תכיל הלכות/עובדות ספציפיות** — רק סגנון ושיטה. מהות מנותבת ל-precedent_library/halacha. ה-distillation מפריד במקור.
|
||||
*מקורות:* 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. שלוש לולאות-המשנה
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
|
||||
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
|
||||
|
||||
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X10 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
||||
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X16 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
||||
- X1–X5: מזהים · רב-חברתי · אינטגרציה+deploy · סוכנים · audit.
|
||||
- X6–X10 (מחזור-2, 8 משטחי-האפליקציה): חוזה UI↔API · לקוח-Paperclip · מילוי-שדות · חוזה כלי-MCP · deploy/env/secrets.
|
||||
- X11–X14 (הרחבות-תחום): citator פנימי (תיקוף-הלכות) · יומונים כשכבת-גילוי (radar) · אחזור-פסיקה אוטומטי מנט המשפט (שירות) · אחסון-אובייקטים (MinIO/S3, הגירת `data/`).
|
||||
- X15–X16 (ארכיטקטורת-יסוד): שער-הפלטפורמה (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).
|
||||
בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.md
|
||||
|
||||
185
docs/spec/X12-digests-radar.md
Normal file
185
docs/spec/X12-digests-radar.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# X12 — יומונים כשכבת-גילוי (Digests Radar)
|
||||
|
||||
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md). הוא מגדיר **שכבת-גילוי (discovery/radar)**
|
||||
מעל קורפוסי-הפסיקה: קליטה וחיפוש של **יומונים** — סיכומי-עמוד-אחד של משרד עפר טויסטר ("כל יום —
|
||||
היומון לענייני תכנון ובנייה") על פסק-דין/החלטה בודדים. היומון הוא **מקור משני** המצביע על פסק-הדין
|
||||
המקורי; הוא **אינו** נכנס לאף אחד מ-3 קורפוסי-הציטוט, **אינו** מצוטט בהחלטה, ו**אינו** מחלץ הלכות.
|
||||
הוא נשען על [INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||
(אין מסלול מקביל), [INV-G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)
|
||||
(שלמות + אין בליעה שקטה) ו-[INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
|
||||
(עקיבוּת-מקור), ומובחן מ-3 הקורפוסים של [03-retrieval.md](03-retrieval.md).
|
||||
|
||||
> **TARGET, לא תיאור-מצב.** התת-מערכת כולה היא יעד — אין כיום טבלת `digests`, כלי-`digest_*`,
|
||||
> ולא אינטגרציית-חוקר. כל רכיב מסומן מפורשות כ-audit-finding לבנייה (§6). כל טענה על הקוד `file:line`.
|
||||
|
||||
---
|
||||
|
||||
## 1. הרעיון — radar, לא קורפוס-ציטוט
|
||||
|
||||
חיים מקבל כמעט יומית מייל עם **יומון**: PDF של עמוד אחד שמסכם פסק-דין/החלטה בודדים בתחום
|
||||
רישוי-ובנייה / היטל-השבחה / פיצויים(ס'197). היומון אינו הטקסט המשפטי המקורי — הוא **ניתוח של צד
|
||||
שלישי** (עפר טויסטר), הנושא הבהרה מודפסת: *"האמור הוא מידע ראשוני בלבד ואין הוא תחליף לייעוץ
|
||||
משפטי"*. במונחי-מחקר-משפטי זהו **מקור משני (secondary authority)**: כלי-איתור והכוונה, לא סמכות
|
||||
שמצטטים בהחלטה.
|
||||
|
||||
הערך שלו עצום דווקא כ-**radar**: כל יומון הוא *headnote + תג-נושא כתובים-מראש בידי מומחה*, המצביע
|
||||
על פסק-דין מקורי. כשמנסחים החלטה, `search_digests` מחזיר את היומון הרלוונטי → החוקר קורא את ניתוח
|
||||
טויסטר **כרקע** → מחלץ את מראה-המקום של פסק-הדין המקורי → מביא את הפסק עצמו לקורפוס-הפסיקה הקיים
|
||||
(הזמינות גבוהה) → ומצטט **משם**. היומון מצביע; הציטוט תמיד נשען על המקור.
|
||||
|
||||
---
|
||||
|
||||
## 2. מה היומון מכיל
|
||||
|
||||
מבנה קבוע (אומת מול הקבצים ב-`data/precedents/incoming/`, יומון 5158/5159/5160/5163):
|
||||
|
||||
| רכיב | דוגמה | תפקיד |
|
||||
|------|-------|-------|
|
||||
| מספר-יומון + תאריך-גיליון | `יומון מס' 5163 7 ביוני 2026` | מפתח-upsert + `digest_date` |
|
||||
| תג-מושג | `"שיקול הדעת המצומצם"` | ציר-נושא לחיפוש |
|
||||
| כותרת-הלכה | `ביהמ"ש - שיקול דעת הוועדה המחוזית אינו מצומצם...` | הסיכום בשורה |
|
||||
| גוף-ניתוח (1–2 עמ') | ניתוח עפר-טויסטר | רקע + מקור-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_*`.
|
||||
180
docs/spec/X13-court-fetch.md
Normal file
180
docs/spec/X13-court-fetch.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# X13 — אחזור-פסיקה אוטומטי מנט המשפט (Court Verdict Fetch)
|
||||
|
||||
> כפוף ל-[חוקת המערכת](00-constitution.md). תת-מערכת **שירות** (לא קורפוס) שמורידה פסקי-דין
|
||||
> ציבוריים של בתי-משפט ומזרימה אותם ל**צינור-הקליטה הקנוני** של ספריית-הפסיקה. אחות-מושגית
|
||||
> ל-[X12 — Digests Radar](X12-digests-radar.md) (הטריגר העיקרי) ול-[01-ingest](01-ingest.md)
|
||||
> (היעד). אינה קורפוס רביעי ואינה מסלול-ingest מקביל.
|
||||
|
||||
---
|
||||
|
||||
## 0. ייעוד והקשר
|
||||
|
||||
יומון (digest) מצביע על פסק-דין נושא (`underlying_citation`, למשל `עת"מ 46111-12-22`). כשהפסק
|
||||
אינו בקורפוס, המערכת **מאחזרת אותו אוטומטית** ממקור ציבורי, מחלצת טקסט, וקולטת אותו דרך
|
||||
`precedent_library_upload` → `ingest_precedent`. כך הופך פסק-דין מ"מצוטט-בלבד" ל"שמיש לחיפוש
|
||||
וחילוץ-הלכות".
|
||||
|
||||
**הבחנת-מקור קריטית:** רק **פסקי-דין של בתי-משפט** ניתנים לאחזור ציבורי. **החלטות ועדת-ערר**
|
||||
אינן זמינות ציבורית (נדרש נבו) — מסומנות כפער ולא נשלחות לאחזור.
|
||||
|
||||
**דרכי-מקור ציבוריות (ניתוב לפי זמינות-פורמט-נט, לא לפי ערכאה):**
|
||||
- **נט המשפט** (מציג-התיקים) משרת **כל הערכאות** — מחוזי/שלום *וגם עליון* — כל עוד יש מספר
|
||||
בפורמט תיק-חודש-שנה. ASP.NET WebForms (`__doPostBack`/VIEWSTATE), anti-bot של F5, מסמכים
|
||||
בצופה-עמודים (turn.js). מחייב **דפדפן-אמת** (host-side) → שירות-מארח ב-pm2 (כדפוס
|
||||
`legal-chat-service`). **זהו המסלול הראשי המאומת.**
|
||||
- **עליון בפורמט-סדרתי** (עע"מ/בג"ץ NNNN/YY, ללא חודש — לא ניתן לחיפוש בנט) → `supremedecisions.court.gov.il`
|
||||
(httpx, ללא CAPTCHA, ללא דפדפן). **פוענח ואומת (2026-06-08):** `POST Home/SearchVerdicts` עם
|
||||
`document` מובנה (`{Year:"YYYY", CaseNum, OldMainNumFormat:true, SearchText:[…]}`) + כותרת
|
||||
**`X-Requested-With: XMLHttpRequest`** → רשומות; `GET Home/Download?path=&fileName=&type=4` → PDF.
|
||||
בוחר מסמך best-first (פסק-דין→מספר-עמודים) ומדלג על מסמכי published-report החסומים (`s`-prefix).
|
||||
תיקים ישנים-מאוד שלא דיגיטצו (למשל 389/87) → `manual`.
|
||||
|
||||
> **אומת end-to-end (2026-06-07) על עת"מ 46111-12-22** — פס"ד 34 עמ' הורד **אוטונומית מלא,
|
||||
> נטו קוד-פתוח, ללא כרטיס-חכם וללא פתרון-CAPTCHA**. ממצאי-המפתח מהכיול:
|
||||
> - **החיפוש והניווט לתיק — ללא reCAPTCHA כלל.** מסלול: דף-בית → `btnExternalSearchCases`
|
||||
> → מילוי `BamaCaseNumberTextBoxH`(=מס' תיק) + `BamaMonthYearTextBoxHT`(="MM-YY") →
|
||||
> `CaseDetails.aspx` → לשונית "פסקי דין" → `DecisionList.aspx` → צופה `NGCSViewerPage.aspx`.
|
||||
> - **reCAPTCHA קיים רק בצופה ורק על שמירה/הדפסה מפורשת** — *לא* על הצגת המסמך. הצופה
|
||||
> מגיש את העמודים כ-PNG דרך PageMethod **`GetImages`** (4 עמ'/batch) **ללא CAPTCHA**.
|
||||
> אחזור = לכידת `documentNumber` מהקריאה הראשונה + משיכת כל ה-batches ב-`fetch` עם הכותרת
|
||||
> **`X-Requested-With: XMLHttpRequest`** (חובה — ה-WAF חוסם AJAX בלעדיה) → הרכבת PDF (Pillow).
|
||||
> - דפדפן: **Camoufox דרך חבילת-הפייתון** (`camoufox.async_api`, in-process — לא שרת-Node).
|
||||
> על שרת ללא-מסך נדרש **Xvfb** (אחרת Firefox קורס). פותר-ה-reCAPTCHA האודיו (Whisper) נשמר
|
||||
> כ-fallback למסלול-השמירה-המפורש בלבד; מסלול-התמונות אינו זקוק לו.
|
||||
|
||||
---
|
||||
|
||||
## 1. ארכיטקטורה — שלוש שכבות (tiered)
|
||||
|
||||
```
|
||||
underlying_citation → [classifier] → {tier, האם יש פורמט-נט (תיק-חודש-שנה)}
|
||||
skip(ערר/בל"מ) → missing_precedent (נבו ידני) — לא אחזור
|
||||
── ניתוב לפי זמינות-פורמט-נט, לא לפי קידומת (נט המשפט משרת כל הערכאות) ──
|
||||
פורמט-נט קיים (עמ"נ/עת"מ/עליון-בפורמט-נט כמו בר"מ 72182-06-25)
|
||||
→ Tier 1: legal-court-fetch-service (host/pm2 + Xvfb) — אוטונומי, מאומת
|
||||
→ Camoufox(python) → external-search → CaseDetails → פסקי דין
|
||||
→ NGCSViewerPage → GetImages(X-Requested-With) → PNGs → PDF
|
||||
עליון סדרתי-בלבד (בג"ץ/בר"מ NNNN/YY, בלי חודש)
|
||||
→ Tier 0: httpx → supremedecisions (SearchVerdicts+Download) — מפוענח ומאומת
|
||||
כשל אוטונומי → Tier 2: missing_precedent + התראה (VNC עתידי) — שער-אנושי
|
||||
(כל ה-tiers) → precedent_library_upload(source_type=court_ruling) → ingest_precedent
|
||||
→ chunks+embeddings+halachot(pending) → relink digest / close gap
|
||||
```
|
||||
|
||||
מצב-העבודה מנוהל בטבלת-תור `court_fetch_jobs` (idempotent, נצפה, retryable). הניקוז
|
||||
האוטומטי: `legal-court-fetch-drain` (pm2 cron שעתי) → `orchestrator.drain_pending`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Invariants
|
||||
|
||||
### INV-CF1: מסלול-קליטה יחיד — אין ingest מקביל
|
||||
**כלל:** כל ה-tiers מתנקזים ל**צינור-הקליטה הקנוני היחיד** (`precedent_library_upload` →
|
||||
`ingest_precedent`). המאחזר מספק קובץ+מטא בלבד; אסור לו לכתוב `case_law`/`precedent_chunks`/
|
||||
`halachot` ישירות או לשכפל לוגיקת-chunking/embedding.
|
||||
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G2](00-constitution.md#inv-g2) (מקור-אמת יחיד, אין מסלול מקביל) על תת-מערכת זו.
|
||||
**אכיפה:** האורקסטרטור קורא רק ל-API/שירות-הקליטה הקיים; ביקורת-ארכיטקטורה ב-PR.
|
||||
**הפרה ידועה:** —
|
||||
|
||||
### INV-CF2: אין בליעה שקטה — כל אחזור נצפה
|
||||
**כלל:** לכל פסק-דין שזוהה לאחזור יש רשומת-job עם סטטוס סופי מפורש
|
||||
(`done`/`failed`/`manual`). כישלון-אחזור **לעולם אינו נבלע** — הוא מסומן ומועלה (Tier 2),
|
||||
לא נזרק בשקט. `except: pass` אסור.
|
||||
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G4](00-constitution.md#inv-g4) וכלל-ההנדסה "אין בליעה שקטה" (§6).
|
||||
**אכיפה:** טבלת `court_fetch_jobs` (status+error+attempts) + לוג-warning בכל כישלון + Tier-2 gate.
|
||||
**הפרה ידועה:** ~~הפער ב-X12 — `try_autolink` שנכשל מחזיר `None` בשקט~~ → **תוקן**: `try_autolink` שנכשל על ציטוט פס"ד-בימ"ש מזניק job ל-`court_fetch_jobs` (status=pending); `court_fetch_drain` מנקז (סדרתי) ומקשר את היומון חזרה בהצלחה.
|
||||
|
||||
### INV-CF3: אוטונומי-first, שער-אנושי חובה ב-fallback
|
||||
**כלל:** האחזור מנסה אוטונומית; אך כש-N נסיונות נכשלים, **שער-אנושי** (VNC לפתרון-CAPTCHA
|
||||
חי / סימון missing_precedent + התראה) הוא **חובה, לא רשות**. המערכת אינה "מוותרת" ואינה
|
||||
"מסתירה" — היא מסלימה לאדם.
|
||||
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G10](00-constitution.md#inv-g10) (המערכת מסייעת; שערים אנושיים = invariant).
|
||||
**אכיפה:** מונה-נסיונות בטבלת-התור + מעבר אוטומטי ל-status=`manual` עם נתיב-פעולה ל-chaim.
|
||||
**הפרה ידועה:** —
|
||||
|
||||
### INV-CF4: אחזור-אחראי (politeness) — סדרתי, מרווח, חתימה-אמיתית
|
||||
**כלל:** האחזור מאתר-ממשלתי הוא **אחראי**: סדרתי (לא מקבילי), עם cooldown בין בקשות,
|
||||
כיבוד-`robots`/תנאי-שימוש, ו-rate מתון. אסור flooding/parallel-hammering שעלול לחסום IP
|
||||
או להעמיס על שירות ציבורי.
|
||||
**מקורות:** RFC 9309 (*Robots Exclusion Protocol*, IETF 2022) · Google Search Central —
|
||||
*Crawler / crawl-rate guidance* · OWASP — *Automated Threat Handbook* (OAT-021 Denial of
|
||||
Service / responsible automation) | סטטוס: verified
|
||||
**אכיפה:** האורקסטרטור והשירות אוכפים serial + `INTER_FETCH_COOLDOWN_SEC`; Camoufox מספק
|
||||
חתימת-דפדפן אמיתית (לא spoof-חמדני). מראה לדפוס-התור ב-[`precedent_library.py`](../../mcp-server/src/legal_mcp/services/precedent_library.py).
|
||||
**הפרה ידועה:** —
|
||||
|
||||
### INV-CF5: אחזור idempotent
|
||||
**כלל:** אחזור הוא **idempotent** — מפתח-job דטרמיניסטי לפי `case_number` מנורמל. אחזור
|
||||
חוזר של אותו תיק אינו יוצר job כפול ואינו קולט פסק-דין פעמיים (upsert על המפתח הקנוני).
|
||||
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G3](00-constitution.md#inv-g3) (ingest idempotent) ו-[G1](00-constitution.md#inv-g1) (מזהה מנורמל בכתיבה).
|
||||
**אכיפה:** אילוץ-ייחודיות על `court_fetch_jobs.case_number_norm`; הקליטה עצמה idempotent דרך `ingest_precedent`.
|
||||
**הפרה ידועה:** —
|
||||
|
||||
### INV-CF6: שער-סיווג מקור — רק פסקי-דין של בתי-משפט
|
||||
**כלל:** רק ציטוט שסווג כ**פסק-דין של בית-משפט** נשלח לאחזור. **ועדת-ערר (ערר/בל"מ) לעולם
|
||||
אינה נשלחת לאחזור-ציבורי** (נדרש נבו) — היא מסומנת `missing_precedent` בלבד. הפריט הנקלט
|
||||
נושא `source_type=court_ruling`, `source_kind=external_upload`, `precedent_level` לפי הערכאה.
|
||||
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G5](00-constitution.md#inv-g5) (metadata מלא + הפרדת-קורפוס)
|
||||
ותואם את הבחנת-המקור ב-[01-ingest](01-ingest.md) (`court_ruling` מול `appeals_committee`).
|
||||
**אכיפה:** המסווג מחזיר `tier=skip` ל-ערר/בל"מ; הקליטה אוכפת `source_type`.
|
||||
**הפרה ידועה:** —
|
||||
|
||||
### INV-CF7: עקיבוּת-מקור + גבול-ToS
|
||||
**כלל:** כל אחזור נושם **provenance** מלא (`source_url`, tier, זמן, מזהה-job) ב-audit-trail.
|
||||
האחזור מוגבל ל**מסמכים ציבוריים** הזמינים ללא הזדהות (smart-card); אופי המערכת הוא
|
||||
**הורדה-בסיוע** (עם שער-אנושי), לא בוט-סמוי לעקיפת בקרת-גישה.
|
||||
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G9](00-constitution.md#inv-g9) (עקיבוּת + audit-trail);
|
||||
גבול-ה-ToS מועלה ליו"ר (חיים) כשיקול-מדיניות (עיקרון-עבודה 4: המשתמש הוא הסמכות).
|
||||
**אכיפה:** `source_url`+tier נשמרים על `case_law`/`court_fetch_jobs`; שער-אנושי שומר על אופי בסיוע.
|
||||
**הפרה ידועה:** —
|
||||
|
||||
---
|
||||
|
||||
## 3. מודל-נתונים — `court_fetch_jobs`
|
||||
|
||||
| עמודה | טיפוס | תפקיד |
|
||||
|--------|-------|-------|
|
||||
| `id` | UUID PK | מזהה-job |
|
||||
| `case_number_norm` | TEXT UNIQUE | מפתח-idempotency קנוני (INV-CF5) |
|
||||
| `citation_raw` | TEXT | הציטוט המקורי כפי שזוהה |
|
||||
| `tier` | TEXT | `supreme` \| `admin` \| `skip` |
|
||||
| `court` | TEXT | ערכאה שזוהתה |
|
||||
| `status` | TEXT | `pending` \| `running` \| `done` \| `failed` \| `manual` |
|
||||
| `attempts` | INT | מונה-נסיונות (ל-Tier 2 gate, INV-CF3) |
|
||||
| `error` | TEXT | הודעת-כישלון אחרונה (INV-CF2) |
|
||||
| `case_law_id` | UUID FK | הפסק שנקלט (NULL עד done) |
|
||||
| `digest_id` | UUID FK | היומון-מקור (NULL לאד-הוק) |
|
||||
| `source_url` | TEXT | provenance (INV-CF7) |
|
||||
| `created_at` / `updated_at` | TIMESTAMPTZ | |
|
||||
|
||||
---
|
||||
|
||||
## 4. רכיבי-מימוש (מיפוי לקוד)
|
||||
|
||||
| רכיב | קובץ | מקור-תבנית / שימוש-חוזר |
|
||||
|------|------|------------------------|
|
||||
| מסווג | `mcp-server/.../services/court_citation.py` | regex מ-`citation_extractor.py:67-132` |
|
||||
| Tier 0 | `services/court_fetch_supreme.py` | httpx; דפוס-cooldown מ-`precedent_library.py:176-186` |
|
||||
| Tier 1 שירות | `mcp-server/.../court_fetch_service/server.py` | שכפול `chat_service/server.py` (aiohttp+Bearer+bind 10.0.1.1) |
|
||||
| Camoufox client | `court_fetch_service/camofox_client.py` | חיקוי `~/.hermes/.../browser_camofox.py` |
|
||||
| reCAPTCHA audio | `court_fetch_service/recaptcha_audio.py` | faster-whisper מקומי |
|
||||
| proxy בקונטיינר | `web/court_fetch_proxy.py` | שכפול `web/chat_proxy.py` |
|
||||
| pm2 | `scripts/legal-court-fetch-service.config.cjs` | שכפול `legal-chat-service.config.cjs` |
|
||||
| אורקסטרטור+תור | `services/court_fetch_orchestrator.py` + `db.py` (SCHEMA_Vxx) | דפוס-תור קיים |
|
||||
| כלי-MCP | `tools/court_fetch.py` (`court_verdict_fetch` / `court_fetch_status` / `court_fetch_drain`) | חוזה-envelope [X9](X9-mcp-tool-contract.md) |
|
||||
| טריגר אוטומטי | `services/digest_library.py` (`try_autolink` fail → `_enqueue_court_fetch`) → drain ע"י `orchestrator.drain_pending` | X12 |
|
||||
| סוד | `COURT_FETCH_SHARED_SECRET` (Infisical + Coolify) | דפוס `LEGAL_CHAT_SHARED_SECRET`, [X10](X10-deploy-env-secrets.md) |
|
||||
|
||||
---
|
||||
|
||||
## 5. סיכונים (R&D — לעקוב)
|
||||
- reCAPTCHA נלחם פעיל בפותרי-אודיו → שיעור-כישלון אפשרי גבוה → Tier 2 הוא קו-ההגנה (INV-CF3).
|
||||
- F5/anti-bot עלול לחסום IP → politeness סדרתי + Camoufox (INV-CF4).
|
||||
- שבירות מול שינויי-אתר → ריכוז selectors במקום אחד + בדיקות-עשן תקופתיות.
|
||||
- גבול-ToS על אתר .gov → INV-CF7 + שיקול-יו"ר.
|
||||
- ~~**Tier-0 (supremedecisions) טרם מפוענח**~~ → **פוענח ומאומת (2026-06-08)** — עליון בפורמט-סדרתי
|
||||
(בג"ץ/בר"מ NNNN/YY) יורד אוטומטית דרך `Home/SearchVerdicts`+`Home/Download`. מגבלה שנותרה: תיקים
|
||||
ישנים-מאוד שלא דיגיטצו בפורטל (0 רשומות) → `manual`. גם `backfill_missing_precedents.py` מזין את
|
||||
ה-`missing_precedents` הפתוחים (עליון+נט-format) לתור-האחזור.
|
||||
- **דליפת-זיכרון מדפדפנים יתומים** (fetch שנתקע/נהרג משאיר `camoufox-bin`) → שלוש שכבות-הגנה:
|
||||
(א) `async with` סוגר את הדפדפן בכל exception; (ב) `asyncio.wait_for` קשיח (`COURT_FETCH_HARD_TIMEOUT_S`, ברירת-מחדל 180ש') מבטל hang + reap; (ג) reaper של `camoufox-bin` יתומים (`ppid=1`) לפני/אחרי כל fetch + דמון `legal-reaper` (pm2) + תקרת `max_memory_restart`. סדרתיות (INV-CF4) מבטיחה שכל דפדפן `ppid=1` הוא שארית בטוחה-להריגה. **הערה:** הדליפה הגדולה בפועל בשרת היא `task-master-mcp` (כלי נפרד), שגם אותו ה-reaper מנקה.
|
||||
146
docs/spec/X14-storage-minio.md
Normal file
146
docs/spec/X14-storage-minio.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# X14 — אחסון-אובייקטים (Object Storage: MinIO / S3)
|
||||
|
||||
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **אחסון קבצים בינאריים** —
|
||||
מסמכי-מקור, נגזרים, וייצוא — והגירתם ממערכת-קבצים מקומית (`data/`) ל-**MinIO** (object store תואם-S3).
|
||||
הוא מגדיר את חוזה-האחסון (שכבה יחידה), סכמת-הדליות-והמפתחות, מודל-האי-שינויוּת המשפטי, ותוכנית-ההגירה.
|
||||
|
||||
> **invariant הנדסי + תפעולי-משפטי.** INV-STG1/2/5/6 נשענים על עקרונות מוכרים (S3 API, 12-Factor, presigned-URL,
|
||||
> separation blob↔metadata) — ≥3 מקורות (docs.min.io, AWS S3 spec, minio-py). INV-STG3/4/7 הם תפעוליים/משפטיים
|
||||
> של *מערכת זו* (גבול-ממשל, WORM להחלטות חתומות, git=טקסט) ונקשרים ל-[G2](00-constitution.md) (מסלול-אחסון יחיד).
|
||||
|
||||
---
|
||||
|
||||
## 1. מצב קיים (מאומת מול הקוד וה-infra, 2026-06-08)
|
||||
|
||||
### 1.1 מלאי-הדיסק (`data/`, ללא `backups/`)
|
||||
| קטגוריה | נפח | תוכן | סוג |
|
||||
|---|---|---|---|
|
||||
| `data/cases/{case}/` | 1.2GB | `documents/{originals,extracted,proofread,research,backup}`, `drafts/`, `exports/`, `thumbnails/{doc_uuid}/pNNN.jpg`, `.git` per-case | מקור + נגזר |
|
||||
| `data/digests/{reference,incoming}/` | 251MB | יומונים (X12) | מקור |
|
||||
| `data/training/{cmp,cmpa}/{raw,proofread}/` | 157MB | קורפוס-קול + `.git` | מקור |
|
||||
| `data/precedent-library/{appeals_committee,court_ruling,other}/` | 105MB | פסיקה + `thumbnails/` | מקור |
|
||||
| `data/internal-decisions/{region}/` | 45MB | החלטות-פנים לפי מחוז | מקור |
|
||||
| `data/exports/` | 216KB | legacy (הוחלף ב-per-case) | נגזר |
|
||||
| `data/{audit,eval,logs}/` | ~52MB | CSV/JSON תפעוליים — **לא מסמכים, נשארים בדיסק** | תפעולי |
|
||||
|
||||
ספירה (ללא backups): ~9,449 קבצים — 2,473 JPG (thumbnails נגזרים), 883 PDF, 250 TXT (extracted), 155 DOCX, 54 DOC.
|
||||
|
||||
### 1.2 הקונטיינר (Coolify)
|
||||
legal-ai (`gyjo0mtw2c42ej3xxvbz8zio`) רץ עם **bind-mounts**: host `data/`→`/data`, host `data/cases/`→`/cases`.
|
||||
האחסון היום = תיקייה על המארח, חשופה ישירות.
|
||||
|
||||
### 1.3 MinIO — **כבר פרוס ובריא** ✅ (שירות Coolify `minio`, `bx2ykvw94xbutsex41hz4vv8`, 2026-06-08)
|
||||
- **API:** `https://s3.nautilus.marcusgroup.org` (9000) · **Console:** `https://minio.nautilus.marcusgroup.org` (9001)
|
||||
- **Credentials:** `SERVICE_USER_MINIO` / `SERVICE_PASSWORD_MINIO` (סודות מנוהלי-Coolify)
|
||||
- **אחסון:** named-volume `minio-data`→`/data` — **Single-Node Single-Drive**; versioning/object-lock **לא** מופעלים עדיין
|
||||
- **רשת:** רשת-Docker משלו (`bx2ykvw...`, external), **לא** משותפת ל-legal-ai → דרושה קישוריות (§4 שלב 0)
|
||||
|
||||
### 1.4 הקוד — **אין שכבת-אחסון מרכזית** (כשל-השורש שהתחום מייבש)
|
||||
ה-I/O מפוזר על ~8 שירותים, נתיבים נבנים inline:
|
||||
- העלאה: `tools/documents.py:54` (originals), `:152` (training)
|
||||
- חילוץ + thumbnails: `services/processor.py:43,153`
|
||||
- staging פסיקה/יומונים/החלטות: `services/ingest.py:69`
|
||||
- ייצוא DOCX: `services/docx_exporter.py:462`
|
||||
- הגשה (FileResponse): `web/app.py` — 6 endpoints
|
||||
- git per-case: `services/git_sync.py` (`git add .` + push ל-Gitea, sweep כל 30ש׳)
|
||||
|
||||
### 1.5 עמודות-DB המאחסנות נתיבים (schema inline ב-`db.py`, ללא migrations)
|
||||
`documents.file_path` · `cases.active_draft_path` · `case_law.source_document_path` · `digests.source_document_path`
|
||||
· `document_image_pages.image_thumbnail_path` · `precedent_image_pages.image_thumbnail_path` · `draft_final_pairs.final_path`
|
||||
|
||||
### 1.6 Paperclip — צרכן-API בלבד
|
||||
הפלאגין ניגש דרך `listDocuments`/`getDocumentText` ל-API (`plugin-legal-ai/src/legal-api.ts:89`). אינו נוגע בדיסק →
|
||||
**הגירה שקופה אליו** כל עוד ה-API יציב.
|
||||
|
||||
---
|
||||
|
||||
## 2. Invariants של התחום
|
||||
|
||||
### INV-STG1: שכבת-אחסון יחידה — כל I/O דרך `storage.py`
|
||||
**כלל:** קיים מודול-אחסון **יחיד** (`services/storage.py`) שכל קריאה/כתיבה של קובץ בינארי עוברת דרכו
|
||||
(`put/get/presign_get/presign_put/delete/list`). אסור `open()`/`shutil.copy()`/`Path.write_bytes()` ישיר על
|
||||
נתיב-אחסון מחוץ למודול. **מקיים [G2](00-constitution.md)** — מבטל את ה-I/O המפוזר (§1.4) שהוא מסלול-מקביל-מתפצל.
|
||||
|
||||
### INV-STG2: מפתח-אובייקט אטומי; שם עברי במטא בלבד
|
||||
**כלל:** מפתח-האובייקט הוא ASCII/UUID (`cases/{case}/originals/{uuid}.pdf`). שם-הקובץ העברי המקורי נשמר ב-DB
|
||||
(`*_filename`) וכ-`x-amz-meta-filename` + מוגש דרך `Content-Disposition` ב-presigned-GET. **למה:** תקציב-מפתח
|
||||
1024 bytes (255/segment), עברית=2B/תו, ובעיות percent-encoding/XML — נמנעות.
|
||||
|
||||
### INV-STG3: דליות לפי גבול-ממשל, prefix לפי קטגוריה/תיק
|
||||
**כלל:** versioning/object-lock/replication הם per-bucket → מה שדורש ממשל שונה יושב בדלי נפרד. שלוש דליות
|
||||
קבועות (§3.1); תיקים/קטגוריות הם prefixes, **לא** דלי-לכל-תיק.
|
||||
|
||||
### INV-STG4: "סופי" = WORM (Object-Lock COMPLIANCE)
|
||||
**כלל:** החלטה חתומה/סופית נכתבת לדלי `legal-immutable` עם Object-Lock **COMPLIANCE** + versioning — בלתי-ניתנת
|
||||
לשינוי/מחיקה ע"י איש (כולל root) עד תום-תקופת-השמירה. טיוטות חיות בדלי רגיל ו"מקודמות" (copy) לדלי-הסגור עם החתימה.
|
||||
**(הכרעת-יו"ר 2026-06-08: סופי בלבד; מסמכי-מקור — versioning ללא נעילה קשיחה.)**
|
||||
|
||||
### INV-STG5: pgvector נשאר מקור-האמת לטקסט/embeddings; MinIO = blob בלבד
|
||||
**כלל:** טקסט-מחולץ + embeddings נשארים ב-Postgres/pgvector (מקור-אמת לאחזור). MinIO מאחסן את ה-blob המקורי
|
||||
(+עותק-ארכיון אופציונלי של ה-extracted text). **אסור** ש-MinIO יהיה מקור-אמת לוקטורים. תואם
|
||||
`no-reocr-retrofit` — לא מריצים OCR מחדש בהגירה.
|
||||
|
||||
### INV-STG6: הגשה לדפדפן דרך presigned-URL — bytes לא דרך FastAPI
|
||||
**כלל:** הורדה/תצוגה/העלאה מהדפדפן עוברות ב-presigned-URL (TTL דקות) מול `s3.nautilus.marcusgroup.org`.
|
||||
ה-backend מנפיק את ה-URL בלבד; ה-bytes לא עוברים דרכו. endpoints קיימים שמחזירים FileResponse → 302→presigned.
|
||||
|
||||
### INV-STG7: git-per-case שומר טקסט/מטא בלבד; בינאריים ב-MinIO
|
||||
**כלל:** `.git` per-case ממשיך לגרסן `case.json`/`notes.md`/`documents/extracted/*.txt`/`research/*.md`. PDF/DOCX/JPG
|
||||
מוחרגים מ-tracking (`.gitignore` per-case) ויושבים ב-MinIO. **(הכרעת-יו"ר 2026-06-08.)** `git_sync.py` ו-sweep
|
||||
מסתמכים על אותו working-tree → ההחרגה חייבת לקדום לכל קומיט-הגירה כדי לא לשבור היסטוריה.
|
||||
|
||||
---
|
||||
|
||||
## 3. ארכיטקטורת-היעד
|
||||
|
||||
### 3.1 דליות ומפתחות
|
||||
| דלי | Versioning | Object-Lock | prefixes |
|
||||
|---|---|---|---|
|
||||
| `legal-documents` | ✅ | ❌ | `cases/{case}/originals/{uuid}.pdf` · `cases/{case}/proofread/{uuid}.txt` · `precedent-library/{type}/{uuid}.pdf` · `internal-decisions/{region}/{uuid}.pdf` · `digests/{uuid}.pdf` · `training/{cmp\|cmpa}/{raw\|proofread}/{uuid}.pdf` |
|
||||
| `legal-immutable` | ✅ | ✅ COMPLIANCE | `decisions-final/{case}/{uuid}.docx` (החלטות חתומות בלבד) |
|
||||
| `legal-derived` | ❌ | ❌ (+lifecycle) | `thumbnails/{doc_uuid}/pNNN.jpg` · `extracted/{uuid}.txt` (נגזר, ניתן-לשחזור) |
|
||||
|
||||
### 3.2 `services/storage.py` (לב ההגירה) — adapter כפול
|
||||
```
|
||||
put(category, key, data, content_type, meta) -> uri # category→bucket+prefix
|
||||
get(uri) -> bytes
|
||||
presign_get(key, ttl) / presign_put(key, ttl) -> url
|
||||
delete(key) / list(prefix)
|
||||
```
|
||||
backend נבחר ב-env `STORAGE_BACKEND ∈ {filesystem, dual, s3}` (ברירת-מחדל filesystem) — מאפשר מעבר הדרגתי ללא
|
||||
שינוי-התנהגות. SDK: `aioboto3` (async-native מול `endpoint_url=http://minio:9000`); `minio-py` לסקריפטי-הגירה.
|
||||
|
||||
### 3.3 שינויי-DB
|
||||
הוספת `*_object_key` (או נרמול ל-`storage_uri` עם סכמה `s3://`/`file://`) לצד העמודות הקיימות (§1.5); backfill;
|
||||
דה-קומיישן הנתיב-קובץ. תוספת inline ב-`db.py` בסגנון הקיים (אין migrations).
|
||||
|
||||
---
|
||||
|
||||
## 4. תוכנית-ביצוע בשלבים (→ TaskMaster, tag legal-ai)
|
||||
|
||||
| שלב | תוכן | תלות |
|
||||
|---|---|---|
|
||||
| **0 — תשתית** | חיבור רשת-Docker (minio↔legal-ai); הזרקת credentials ל-env legal-ai (Coolify); `mc alias`; יצירת 3 דליות + הפעלת versioning + Object-Lock (immutable); הוספת `aioboto3` ל-deps | — |
|
||||
| **1 — שכבת-אחסון** | `services/storage.py` + adapter כפול (default filesystem). אפס שינוי-התנהגות. PR מצהיר INV-STG1/2/3 | 0 |
|
||||
| **2 — חיווט-כתיבה** | הפניית כל נקודות-הכתיבה (§1.4) דרך `storage.py`; כתיבה-כפולה (`STORAGE_BACKEND=dual`) | 1 |
|
||||
| **3 — הגירת-נתונים** | `mc mirror --dry-run`→`--overwrite` של 5 הקטגוריות; backfill `*_object_key` ב-DB; אימות count+checksum | 0,2 |
|
||||
| **4 — חיווט-קריאה + presigned** | endpoints→302→presigned; thumbnails דרך presigned; dual-read (S3, fallback disk); החרגת בינאריים מ-git per-case (INV-STG7) | 2,3 |
|
||||
| **5 — cutover** | `STORAGE_BACKEND=s3`; `mc mirror --watch` עד החלפה; אימות מלא; כיבוי כתיבה-לדיסק | 4 |
|
||||
| **6 — git + גיבוי + ניקוי** | קידום-החלטות-סופיות ל-immutable (INV-STG4); `mc mirror`/bucket-replication מתוזמן off-site; דה-קומיישן bind-mount `data/` (השארת audit/eval/logs) | 5 |
|
||||
|
||||
---
|
||||
|
||||
## 5. סיכונים
|
||||
- **I/O מפוזר** → INV-STG1 (`storage.py`) חובה לפני כל שאר השלבים, אחרת drift והפרת-G2.
|
||||
- **שמות עבריים כמפתחות** → INV-STG2 (UUID-keys + מטא).
|
||||
- **רשת נפרדת ל-MinIO** → לאמת קישוריות בשלב 0 לפני הכל.
|
||||
- **git-per-case** מצמיד בינאריים ל-Gitea → INV-STG7, ההחרגה חייבת לקדום לכל קומיט.
|
||||
- **SNSD ללא erasure-coding** → גיבוי off-site (שלב 6) הוא חובה, לא nice-to-have.
|
||||
- **בידוד-worktree + ספ-first** → כל PR מצהיר invariants (G2 + INV-STG*).
|
||||
|
||||
---
|
||||
|
||||
## 6. קישורים
|
||||
- חוקה: [00-constitution.md](00-constitution.md) · נתונים: [02-data-model.md](02-data-model.md) · קליטה: [01-ingest.md](01-ingest.md)
|
||||
- deploy/env: [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) · אינטגרציה: [X3-integration-deploy.md](X3-integration-deploy.md)
|
||||
- מקורות-MinIO: docs.min.io (community), AWS S3 object-keys/bucket-naming/presigned-URL, github.com/minio/minio-py
|
||||
149
docs/spec/X15-agent-platform-port.md
Normal file
149
docs/spec/X15-agent-platform-port.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# 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). מורחב מ-G1–G10 בתור **G12**.
|
||||
> רישומו ברשימת-הגלובליים ובאינדקס של [00-constitution.md](00-constitution.md) מתבצע במשימת
|
||||
> R0b (תיקון-תיעוד) — עד אז המסמך הזה הוא מקור-האמת ל-G12.
|
||||
|
||||
---
|
||||
|
||||
## 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) — עמידות-פייפליין (החלטה נפרדת, נושקת).
|
||||
96
docs/spec/X16-pipeline-durability.md
Normal file
96
docs/spec/X16-pipeline-durability.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# X16 — עמידות-פייפליין (Durable Pipeline Execution)
|
||||
|
||||
> כפוף ל-[00-constitution.md](00-constitution.md). מחזק את **INV-G3** (idempotency)
|
||||
> ב-checkpointing+replay לפייפליינים הדטרמיניסטיים המקומיים. נושק ל-[07-learning.md](07-learning.md)
|
||||
> ו-[X11-citation-corroboration.md](X11-citation-corroboration.md).
|
||||
|
||||
## 0. הבעיה
|
||||
|
||||
שני הפייפליינים המקומיים החד-פעמיים —
|
||||
[final_halacha_pipeline.py](../../scripts/final_halacha_pipeline.py) (כפתור run-halacha,
|
||||
אימות-הלכות, X11) ו-[final_learning_pipeline.py](../../scripts/final_learning_pipeline.py)
|
||||
(כפתור run-learning, למידת-סגנון, 07-learning) — חולקים **צורה זהה**: סקריפט מקומי,
|
||||
3–4 שלבים בטור, 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 תוך-פאנל | 1–2 ימים |
|
||||
| **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) — הסקריפטים המושפעים.
|
||||
@@ -92,12 +92,14 @@ NCSC/JTC — *AI in Courts* (verifiable citation) | סטטוס: verified
|
||||
**אכיפה:** `proofreader.verify_quote` בעת חילוץ → `quote_verified`.
|
||||
**הפרה ידועה:** — (קיים; ה-flag נכתב, אך אין חיווי ב-UI — ראה [X6 INV-UI6](X6-ui-api-contract.md)).
|
||||
|
||||
### INV-FP5: חילוץ אסינכרוני דרך claude_session מקומי
|
||||
**כלל:** חילוץ-LLM (מטא, הלכות) רץ **אסינכרוני, מתור**, דרך `claude_session` **מקומי בלבד** — לא חוסם את
|
||||
ה-web, ולא קורא ל-LLM מהקונטיינר. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||
(מסלול-LLM קנוני יחיד). **פרויקטלי-תפעולי.** תואם זיכרון `feedback_claude_session_local_only`.
|
||||
**מקור-סמכות:** [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).
|
||||
**אכיפה:** queue + `precedent_process_pending`; קריאות-LLM רק מ-MCP מקומי.
|
||||
### INV-FP5: חילוץ אסינכרוני, מתור, צד-מארח (לא מהקונטיינר)
|
||||
**כלל:** חילוץ-LLM (מטא, הלכות) רץ **אסינכרוני, מתור, מצד-המארח** — לא חוסם את ה-web ולא קורא ל-LLM
|
||||
מהקונטיינר. **בחירת-מנוע לפי אופי-המשימה (לא מסלול מקביל):** חילוץ-מטא הוא משימה *תחומה* (טקסט→JSON)
|
||||
ולכן רץ על **Gemini Flash** (`gemini_session`, structured JSON) — ה-claude CLI ה-agentic פגע ב-
|
||||
`error_max_turns`; חילוץ-הלכות (רגיש-קול/agentic) נשאר על **`claude_session`** (CLI מקומי, מנוי דפנה).
|
||||
שני המנועים מתנקזים לתור-החילוץ הקנוני היחיד ([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)). **פרויקטלי-תפעולי.**
|
||||
**מקור-סמכות:** [ingest.py](../../mcp-server/src/legal_mcp/services/ingest.py) (queue → `process_pending_extractions`); [gemini_session.py](../../mcp-server/src/legal_mcp/services/gemini_session.py) (מטא); [legal-ai/CLAUDE.md](../../CLAUDE.md) (claude_session local-only להלכות). `GEMINI_API_KEY` בצד-המארח בלבד — לא בקונטיינר (תואם `feedback_claude_session_local_only`: אין קריאות-LLM מהקונטיינר).
|
||||
**אכיפה:** queue + `precedent_process_pending` + drainers מתוזמנים (`legal-metadata-drain`/CEO); קריאות-LLM רק מצד-המארח.
|
||||
**הפרה ידועה:** תור-החילוץ **סמוי** (אין הבחנה pending-initial מול pending-review; אין extraction-job table) ([gap-audit GAP-45](gap-audit.md); [X9](X9-mcp-tool-contract.md)).
|
||||
|
||||
---
|
||||
|
||||
@@ -21,6 +21,19 @@ dependencies = [
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"httpx>=0.27.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
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -54,6 +54,10 @@ REDIS_URL = os.environ.get("REDIS_URL", "redis://127.0.0.1:6380/0")
|
||||
# pinned.
|
||||
HALACHA_EXTRACT_MODEL = os.environ.get("HALACHA_EXTRACT_MODEL", "claude-opus-4-8")
|
||||
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).
|
||||
# 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;
|
||||
@@ -162,6 +166,13 @@ HALACHA_DEDUP_COSINE = float(os.environ.get("HALACHA_DEDUP_COSINE", "0.93"))
|
||||
# dropping a possibly-distinct principle unreviewed. 0.83 from the same cleanup.
|
||||
HALACHA_DEDUP_BAND_COSINE = float(os.environ.get("HALACHA_DEDUP_BAND_COSINE", "0.83"))
|
||||
|
||||
# Halacha review-queue clustering (#84.2) — when the review queue is requested
|
||||
# with cluster=true, halachot of the SAME precedent whose rule-embeddings are
|
||||
# within this cosine are grouped into ONE review card (canonical + variants), so
|
||||
# the chair judges near-identical principles once instead of repeatedly. Display
|
||||
# only — never merges/deletes. 0.90 = "same principle, reworded".
|
||||
HALACHA_CLUSTER_COSINE = float(os.environ.get("HALACHA_CLUSTER_COSINE", "0.90"))
|
||||
|
||||
# Halacha NLI entailment validator (#81.3) — after extraction, a claude_session
|
||||
# judge checks each halacha's rule_statement is entailed by its supporting_quote.
|
||||
# Non-entailed (neutral/contradiction) → quality flag 'nli_unsupported' that
|
||||
@@ -191,6 +202,32 @@ EXPORTS_DIR = DATA_DIR / "exports" # legacy exports only
|
||||
# Cases directory — flat structure: data/cases/{case_number}/
|
||||
CASES_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:
|
||||
"""Return the case directory for a given case number."""
|
||||
|
||||
7
mcp-server/src/legal_mcp/court_fetch_service/__init__.py
Normal file
7
mcp-server/src/legal_mcp/court_fetch_service/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Host-side Tier-1 verdict fetch service (X13).
|
||||
|
||||
Runs on the host under pm2 (it needs a real browser, which the legal-ai
|
||||
container can't run). Drives a Camoufox stealth browser against נט המשפט to
|
||||
download administrative/district-court verdicts the Supreme portal (Tier 0)
|
||||
doesn't carry. See docs/spec/X13-court-fetch.md.
|
||||
"""
|
||||
314
mcp-server/src/legal_mcp/court_fetch_service/camofox_client.py
Normal file
314
mcp-server/src/legal_mcp/court_fetch_service/camofox_client.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Camoufox driver for נט המשפט — calibrated, proven flow (X13, Tier 1).
|
||||
|
||||
Open-source, zero-API-cost: drives a **Camoufox** stealth browser (a Firefox
|
||||
fork with C++ fingerprint spoofing) via its official Python package
|
||||
(``camoufox.async_api``) — in-process, no separate Node server. The full flow
|
||||
was reverse-engineered and validated end-to-end against עת"מ 46111-12-22
|
||||
(2026-06-07): a 34-page verdict PDF retrieved with **no smart-card and no
|
||||
CAPTCHA-solving**.
|
||||
|
||||
The proven path:
|
||||
1. homepage → DOM-click ``btnExternalSearchCases`` ("תיקים לפי מס' תיק מקור").
|
||||
2. Fill the visible header case-locator: ``BamaCaseNumberTextBoxH`` = case
|
||||
number, ``BamaMonthYearTextBoxHT`` = "MM-YY"; click ``SearchHeaderCaseButton``.
|
||||
→ lands on ``FolderCaseDetails/CaseDetails.aspx`` for the case.
|
||||
3. Click the "פסקי דין" sidebar tab → ``Decisions/DecisionList.aspx``.
|
||||
4. Click the document → popup ``Viewer/NGCSViewerPage.aspx?DocumentNumber=…``.
|
||||
5. The viewer renders pages as PNG images via the ``GetImages`` PageMethod —
|
||||
**served without reCAPTCHA** (the reCAPTCHA on the viewer only gates the
|
||||
explicit save/print, which we don't use). Capture the internal
|
||||
``documentNumber`` from the viewer's first ``GetImages`` call, then pull
|
||||
every 4-page batch via ``fetch`` **with header ``X-Requested-With:
|
||||
XMLHttpRequest``** (required — the F5 WAF blocks AJAX calls without it).
|
||||
6. Decode the base64 PNGs → assemble a PDF (Pillow). The existing ingest
|
||||
pipeline OCRs it (Google Vision) → text → corpus.
|
||||
|
||||
Operational requirements (see scripts/legal-court-fetch-service.config.cjs):
|
||||
* a virtual display — Camoufox/Firefox crashes headless on this server
|
||||
without one. Set ``DISPLAY`` to a running Xvfb (e.g. ``:99``).
|
||||
* RAM — a Firefox content process loading the heavy ASP.NET pages needs
|
||||
~0.5–1 GB; keep the box from swapping.
|
||||
|
||||
reCAPTCHA note: ``recaptcha_audio`` (local Whisper) remains as a fallback for
|
||||
the explicit-PDF-download path, but the primary image-API path needs no
|
||||
solving, so it is normally unused.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NGCS_HOME = "https://www.court.gov.il/ngcs.web.site/homepage.aspx"
|
||||
|
||||
# Headless Camoufox needs a virtual display on this server.
|
||||
_DISPLAY = os.environ.get("DISPLAY", "")
|
||||
_NAV_TIMEOUT_MS = int(float(os.environ.get("COURT_FETCH_BROWSER_TIMEOUT_S", "60")) * 1000)
|
||||
_PAGE_BATCH = 4 # the viewer's GetImages batch size
|
||||
_MAX_PAGES = 400 # hard cap on a single document
|
||||
# Hard wall-clock cap on a single fetch so a hung browser can't pin a Firefox
|
||||
# process forever (anti-leak; INV-CF4 politeness). The async-with cleanup runs
|
||||
# on the resulting CancelledError, tearing the browser down.
|
||||
_FETCH_HARD_TIMEOUT_S = float(os.environ.get("COURT_FETCH_HARD_TIMEOUT_S", "180"))
|
||||
|
||||
|
||||
def _reap_orphan_browsers() -> int:
|
||||
"""Kill any ``camoufox-bin`` orphaned to ``ppid=1`` before we launch.
|
||||
|
||||
Fetching is serial (INV-CF4), so any browser not owned by a live parent is
|
||||
a leftover from a prior crashed/killed fetch. Pure /proc, best-effort —
|
||||
never raises into the fetch path.
|
||||
"""
|
||||
killed = 0
|
||||
try:
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdigit():
|
||||
continue
|
||||
try:
|
||||
with open(f"/proc/{pid}/status", "rb") as f:
|
||||
status = f.read().decode("utf-8", "replace")
|
||||
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||
cmd = f.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
continue
|
||||
if "camoufox-bin" not in cmd:
|
||||
continue
|
||||
ppid = 0
|
||||
for line in status.splitlines():
|
||||
if line.startswith("PPid:"):
|
||||
try: ppid = int(line.split()[1])
|
||||
except (IndexError, ValueError): pass
|
||||
break
|
||||
if ppid == 1:
|
||||
try:
|
||||
os.kill(int(pid), 9)
|
||||
killed += 1
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
if killed:
|
||||
logger.warning("reaped %d orphaned camoufox-bin before fetch", killed)
|
||||
return killed
|
||||
|
||||
|
||||
class CamofoxUnavailable(RuntimeError):
|
||||
"""Camoufox (or its virtual display) isn't available."""
|
||||
|
||||
|
||||
class NgcsFlowError(RuntimeError):
|
||||
"""A step in the נט-המשפט flow failed (navigation / not found / blocked)."""
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""True if the Camoufox package imports (browser binary present)."""
|
||||
try:
|
||||
import camoufox.async_api # noqa: F401
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def health() -> dict:
|
||||
return {"camoufox_import": is_enabled(), "display": _DISPLAY or "(none)"}
|
||||
|
||||
|
||||
async def _fill_visible(page, id_substr: str, value: str) -> bool:
|
||||
for el in await page.locator(f"input[id*='{id_substr}']").all():
|
||||
try:
|
||||
if await el.is_visible() and await el.is_editable():
|
||||
await el.fill(value)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
async def _reach_viewer(page, *, case_number: str, month_year: str):
|
||||
"""Drive home → search → case → פסקי דין → viewer popup. Returns the popup page."""
|
||||
await page.goto(NGCS_HOME, wait_until="domcontentloaded", timeout=_NAV_TIMEOUT_MS)
|
||||
await page.wait_for_timeout(2500)
|
||||
await page.eval_on_selector(
|
||||
"#Header1_UpperMenu1_btnExternalSearchCases", "el => el.click()"
|
||||
)
|
||||
try:
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=_NAV_TIMEOUT_MS)
|
||||
except Exception:
|
||||
pass
|
||||
await page.wait_for_timeout(4500)
|
||||
|
||||
if not await _fill_visible(page, "BamaCaseNumberTextBoxH", case_number):
|
||||
raise NgcsFlowError("שדה מספר-תיק לא נמצא בעמוד החיפוש")
|
||||
my_filled = False
|
||||
for el in await page.locator("input[id*='BamaMonthYearTextBoxHT']").all():
|
||||
if await el.is_visible():
|
||||
await el.click()
|
||||
await page.keyboard.type(month_year, delay=60)
|
||||
my_filled = True
|
||||
break
|
||||
if not my_filled:
|
||||
raise NgcsFlowError("שדה חודש-שנה לא נמצא")
|
||||
clicked = False
|
||||
for b in await page.locator("[id*='SearchHeaderCaseButton']").all():
|
||||
if await b.is_visible():
|
||||
await b.click()
|
||||
clicked = True
|
||||
break
|
||||
if not clicked:
|
||||
raise NgcsFlowError("כפתור החיפוש לא נמצא")
|
||||
await page.wait_for_timeout(6000)
|
||||
if "CaseDetails" not in page.url:
|
||||
raise NgcsFlowError(
|
||||
f"לא הגענו לעמוד-התיק (URL={page.url[:80]}) — ייתכן שהתיק לא נמצא/לא פתוח לעיון"
|
||||
)
|
||||
|
||||
# פסקי דין tab → DecisionList
|
||||
psak = page.locator("a:has-text('פסקי דין')")
|
||||
opened = False
|
||||
for i in range(await psak.count()):
|
||||
el = psak.nth(i)
|
||||
if await el.is_visible():
|
||||
await el.click()
|
||||
opened = True
|
||||
break
|
||||
if not opened:
|
||||
raise NgcsFlowError("לשונית 'פסקי דין' לא נמצאה בעמוד-התיק")
|
||||
await page.wait_for_timeout(6000)
|
||||
|
||||
# open the verdict document viewer (popup)
|
||||
viewers = page.locator(
|
||||
"a[href*='Viewer'],[onclick*='Viewer'],a[href*='Document'],a:has-text('צפייה')"
|
||||
)
|
||||
async with page.context.expect_page(timeout=15000) as pop:
|
||||
clicked = False
|
||||
for i in range(await viewers.count()):
|
||||
el = viewers.nth(i)
|
||||
if await el.is_visible():
|
||||
await el.click()
|
||||
clicked = True
|
||||
break
|
||||
if not clicked:
|
||||
raise NgcsFlowError("לא נמצא מסמך פסק-דין לצפייה")
|
||||
return await pop.value
|
||||
|
||||
|
||||
async def fetch_admin_verdict(
|
||||
*, file_number: str, month: str, year: str, case_number: str, court: str
|
||||
) -> dict:
|
||||
"""Fetch an admin/district court verdict as a PDF. Returns
|
||||
``{content: bytes, filename, source_url, court}``; raises on failure.
|
||||
|
||||
``file_number``/``month``/``year`` are the נט-המשפט triple (e.g. 46111/12/22).
|
||||
"""
|
||||
try:
|
||||
from camoufox.async_api import AsyncCamoufox
|
||||
except Exception as e:
|
||||
raise CamofoxUnavailable(
|
||||
"חבילת camoufox אינה מותקנת/זמינה. הרץ `pip install camoufox` ו-"
|
||||
"`python -m camoufox fetch`. ראה docs/spec/X13-court-fetch.md."
|
||||
) from e
|
||||
if not _DISPLAY:
|
||||
# Headless Firefox crashes here without a virtual display.
|
||||
raise CamofoxUnavailable(
|
||||
"אין DISPLAY — Camoufox דורש Xvfb על שרת ללא מסך. הפעל Xvfb (למשל :99) "
|
||||
"והגדר DISPLAY (ראה pm2 config)."
|
||||
)
|
||||
|
||||
month_year = f"{int(month):02d}-{year[-2:]}"
|
||||
|
||||
# Belt-and-suspenders against browser leaks: kill any orphaned browser from
|
||||
# a prior crashed fetch before we launch a new one (serial → safe).
|
||||
_reap_orphan_browsers()
|
||||
|
||||
async def _run() -> dict:
|
||||
doc_num = {"v": None}
|
||||
|
||||
async def on_resp(resp):
|
||||
if "GetImages" in resp.url and not doc_num["v"]:
|
||||
try:
|
||||
doc_num["v"] = json.loads(resp.request.post_data).get("documentNumber")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with AsyncCamoufox(
|
||||
headless=True, geoip=False, humanize=True, locale="he-IL"
|
||||
) as browser:
|
||||
page = await browser.new_page()
|
||||
page.context.on("response", lambda r: asyncio.create_task(on_resp(r)))
|
||||
vp = await _reach_viewer(page, case_number=file_number, month_year=month_year)
|
||||
source_url = vp.url
|
||||
await vp.wait_for_timeout(9000)
|
||||
if not doc_num["v"]:
|
||||
raise NgcsFlowError("לא נלכד documentNumber מהצופה (ייתכן שהמסמך לא נטען)")
|
||||
|
||||
# Pull every page batch through fetch() with X-Requested-With (WAF-safe).
|
||||
imgs = await vp.evaluate(
|
||||
"""async (args) => {
|
||||
const [dn, maxPages, batch] = args;
|
||||
const url = window.location.href.split('?')[0] + '/GetImages';
|
||||
const out = {};
|
||||
for (let f = 0; f < maxPages; f += batch) {
|
||||
let d;
|
||||
try {
|
||||
const r = await fetch(url, {method:'POST', credentials:'include',
|
||||
headers:{'Content-Type':'application/json; charset=utf-8',
|
||||
'X-Requested-With':'XMLHttpRequest'},
|
||||
body: JSON.stringify({documentNumber:dn, fromIndex:f, toIndex:f+batch-1})});
|
||||
if (!r.ok) break;
|
||||
const j = await r.json(); d = (j.d !== undefined) ? j.d : j;
|
||||
} catch (e) { break; }
|
||||
if (!Array.isArray(d) || d.length === 0) break;
|
||||
d.forEach((html, k) => { if (html) out[f+k] = html; });
|
||||
if (d.length < batch) break;
|
||||
await new Promise(r => setTimeout(r, 350));
|
||||
}
|
||||
return out;
|
||||
}""",
|
||||
[doc_num["v"], _MAX_PAGES, _PAGE_BATCH],
|
||||
)
|
||||
|
||||
if not imgs:
|
||||
raise NgcsFlowError("לא התקבלו עמודי-מסמך מ-GetImages")
|
||||
from PIL import Image
|
||||
|
||||
pages = []
|
||||
for idx in sorted(imgs, key=lambda x: int(x)):
|
||||
m = re.search(r"base64,([A-Za-z0-9+/=]+)", imgs[idx] or "")
|
||||
if not m:
|
||||
continue
|
||||
pages.append(Image.open(io.BytesIO(base64.b64decode(m.group(1)))).convert("RGB"))
|
||||
if not pages:
|
||||
raise NgcsFlowError("עמודי-המסמך לא ניתנים לפענוח (base64)")
|
||||
|
||||
buf = io.BytesIO()
|
||||
pages[0].save(buf, format="PDF", save_all=True, append_images=pages[1:])
|
||||
content = buf.getvalue()
|
||||
logger.info("נט המשפט: fetched %s — %d pages, %d bytes",
|
||||
case_number, len(pages), len(content))
|
||||
return {
|
||||
"content": content,
|
||||
"filename": f"{case_number}.pdf",
|
||||
"source_url": source_url,
|
||||
"court": court or "בית משפט מחוזי",
|
||||
"pages": len(pages),
|
||||
}
|
||||
|
||||
# Hard wall-clock cap: on a hung browser, the timeout cancels _run(); the
|
||||
# async-with __aexit__ tears the browser down, and the reap below sweeps any
|
||||
# process that outlived the cancellation.
|
||||
try:
|
||||
return await asyncio.wait_for(_run(), _FETCH_HARD_TIMEOUT_S)
|
||||
except asyncio.TimeoutError:
|
||||
_reap_orphan_browsers()
|
||||
raise NgcsFlowError(
|
||||
f"אחזור עבר את מגבלת-הזמן ({_FETCH_HARD_TIMEOUT_S:.0f}ש') ובוטל"
|
||||
)
|
||||
finally:
|
||||
_reap_orphan_browsers()
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Open-source reCAPTCHA v2 audio-challenge solver (X13, Tier 1).
|
||||
|
||||
Pure open-source, zero-API-cost: switch the reCAPTCHA widget to its **audio**
|
||||
challenge, download the mp3, transcribe it with a **local Whisper** model
|
||||
(``faster-whisper``), and submit the transcript. This is the well-known
|
||||
"Buster"-style technique. It is intentionally a *best-effort* solver —
|
||||
reCAPTCHA actively fights audio solving, so a non-trivial failure rate is
|
||||
expected and handled by the Tier-2 human fallback (INV-CF3), never hidden.
|
||||
|
||||
Model is loaded lazily and cached; ``WHISPER_MODEL`` (default ``small``) and
|
||||
``WHISPER_DEVICE`` (default ``cpu``) tune it. The dependency is optional — if
|
||||
``faster-whisper`` isn't installed, ``transcribe_audio`` raises a clear error
|
||||
so the caller falls back to a human solve rather than crashing the service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WHISPER_MODEL_NAME = os.environ.get("WHISPER_MODEL", "small")
|
||||
_WHISPER_DEVICE = os.environ.get("WHISPER_DEVICE", "cpu")
|
||||
_model = None
|
||||
|
||||
|
||||
class AudioSolveUnavailable(RuntimeError):
|
||||
"""faster-whisper isn't installed — cannot solve audio locally."""
|
||||
|
||||
|
||||
def _get_model():
|
||||
global _model
|
||||
if _model is not None:
|
||||
return _model
|
||||
try:
|
||||
from faster_whisper import WhisperModel # type: ignore
|
||||
except ImportError as e:
|
||||
raise AudioSolveUnavailable(
|
||||
"faster-whisper אינו מותקן — לא ניתן לפתור reCAPTCHA אודיו מקומית. "
|
||||
"התקן `pip install faster-whisper` או הסתמך על fallback אנושי (VNC)."
|
||||
) from e
|
||||
logger.info("loading whisper model %s on %s", _WHISPER_MODEL_NAME, _WHISPER_DEVICE)
|
||||
_model = WhisperModel(
|
||||
_WHISPER_MODEL_NAME, device=_WHISPER_DEVICE, compute_type="int8"
|
||||
)
|
||||
return _model
|
||||
|
||||
|
||||
async def download_audio(audio_url: str) -> bytes:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as c:
|
||||
r = await c.get(audio_url)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
|
||||
|
||||
def transcribe_audio(mp3_bytes: bytes) -> str:
|
||||
"""Transcribe a reCAPTCHA audio clip to its (English) digit/word phrase.
|
||||
|
||||
Raises ``AudioSolveUnavailable`` if the local model isn't installed.
|
||||
"""
|
||||
model = _get_model()
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=True) as f:
|
||||
f.write(mp3_bytes)
|
||||
f.flush()
|
||||
# reCAPTCHA audio is English regardless of page locale.
|
||||
segments, _info = model.transcribe(f.name, language="en")
|
||||
text = " ".join(seg.text for seg in segments).strip()
|
||||
# Normalise: reCAPTCHA expects the bare phrase, lower-case, no punctuation.
|
||||
cleaned = "".join(ch for ch in text.lower() if ch.isalnum() or ch.isspace())
|
||||
return " ".join(cleaned.split())
|
||||
|
||||
|
||||
async def solve_from_audio_url(audio_url: str) -> str:
|
||||
"""Convenience: download + transcribe an audio-challenge URL."""
|
||||
mp3 = await download_audio(audio_url)
|
||||
return transcribe_audio(mp3)
|
||||
275
mcp-server/src/legal_mcp/court_fetch_service/server.py
Normal file
275
mcp-server/src/legal_mcp/court_fetch_service/server.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Host-side HTTP bridge for Tier-1 verdict fetching (X13).
|
||||
|
||||
Mirrors ``legal_mcp.chat_service.server`` — the proven host-side pattern: an
|
||||
aiohttp app, bound to the docker bridge gateway, Bearer-auth, that does the one
|
||||
thing the container can't (here: drive a real browser against נט המשפט).
|
||||
|
||||
Endpoints:
|
||||
POST /fetch body {file_number, month, year, case_number, court}
|
||||
→ {ok, content_b64, filename, source_url, court, reason}
|
||||
REQUIRES Authorization: Bearer <COURT_FETCH_SHARED_SECRET>.
|
||||
GET /health liveness (no auth); reports camofox + VNC URL if available.
|
||||
GET /pm2 read-only pm2 status of legal-* / paperclip services (no auth).
|
||||
POST /pm2/control body {name, action: restart|stop|start} → run pm2 on a
|
||||
whitelisted legal-* process. REQUIRES Bearer (mutating).
|
||||
|
||||
Run with pm2:
|
||||
pm2 start scripts/legal-court-fetch-service.config.cjs
|
||||
|
||||
Security posture (identical rationale to legal-chat-service):
|
||||
1. Bind defaults to ``10.0.1.1`` (docker0 bridge gateway) — reachable from
|
||||
the host + containers on docker bridges, invisible to outside networks.
|
||||
2. ``/fetch`` requires a Bearer token (constant-time compare); the service
|
||||
refuses to start without ``COURT_FETCH_SHARED_SECRET`` set.
|
||||
3. ``/health`` is unauthenticated and spawns nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
_pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
if _pkg_root not in sys.path:
|
||||
sys.path.insert(0, _pkg_root)
|
||||
|
||||
from legal_mcp.court_fetch_service import camofox_client # noqa: E402
|
||||
|
||||
logger = logging.getLogger("legal_court_fetch_service")
|
||||
|
||||
_SHARED_SECRET: str = ""
|
||||
|
||||
|
||||
async def health(request: web.Request) -> web.Response:
|
||||
info = {"ok": True, "service": "legal-court-fetch-service",
|
||||
"camofox_enabled": camofox_client.is_enabled()}
|
||||
if camofox_client.is_enabled():
|
||||
try:
|
||||
info["camofox"] = await camofox_client.health()
|
||||
except Exception as e: # health must never throw
|
||||
info["camofox_error"] = str(e)
|
||||
return web.json_response(info)
|
||||
|
||||
|
||||
# Background services we surface on the /operations dashboard. pm2 jlist is a
|
||||
# host-only capability (the legal-ai container can't run pm2), so the container's
|
||||
# FastAPI proxies this read-only endpoint over the docker bridge. No secret:
|
||||
# pm2 status (names/cpu/mem) carries nothing sensitive and the bind (10.0.1.1)
|
||||
# is already host/container-only.
|
||||
_PM2_PREFIXES = ("legal-", "paperclip")
|
||||
|
||||
|
||||
def _trim_service(a: dict) -> dict:
|
||||
"""Project a pm2 jlist app entry into the fields the dashboard needs."""
|
||||
env = a.get("pm2_env", {}) or {}
|
||||
return {
|
||||
"name": a.get("name", ""),
|
||||
"status": env.get("status", ""),
|
||||
"restarts": env.get("restart_time", 0),
|
||||
"uptime_ms": env.get("pm_uptime", 0),
|
||||
"cpu": (a.get("monit") or {}).get("cpu", 0),
|
||||
"memory_bytes": (a.get("monit") or {}).get("memory", 0),
|
||||
"cron": env.get("cron_restart") or "",
|
||||
"autorestart": env.get("autorestart", True),
|
||||
}
|
||||
|
||||
|
||||
async def _pm2_run(*args: str, timeout: float = 10) -> tuple[int, bytes, bytes]:
|
||||
"""Run a pm2 subcommand; returns (returncode, stdout, stderr)."""
|
||||
import asyncio as _asyncio
|
||||
|
||||
proc = await _asyncio.create_subprocess_exec(
|
||||
"pm2", *args,
|
||||
stdout=_asyncio.subprocess.PIPE, stderr=_asyncio.subprocess.PIPE,
|
||||
)
|
||||
out, err = await _asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
return proc.returncode or 0, out, err
|
||||
|
||||
|
||||
async def pm2_status(request: web.Request) -> web.Response:
|
||||
"""Return a trimmed ``pm2 jlist`` for the legal-ai background services."""
|
||||
try:
|
||||
rc, out, err = await _pm2_run("jlist")
|
||||
if rc != 0:
|
||||
return web.json_response(
|
||||
{"error": f"pm2 jlist failed: {err.decode('utf-8','replace')[:200]}"},
|
||||
status=502,
|
||||
)
|
||||
apps = json.loads(out.decode("utf-8", "replace"))
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "pm2 not found on PATH"}, status=502)
|
||||
except Exception as e: # never throw
|
||||
return web.json_response({"error": f"pm2 error: {e}"}, status=502)
|
||||
|
||||
services = [
|
||||
_trim_service(a) for a in apps
|
||||
if any(str(a.get("name", "")).startswith(p) for p in _PM2_PREFIXES)
|
||||
]
|
||||
services.sort(key=lambda s: s["name"])
|
||||
return web.json_response({"services": services})
|
||||
|
||||
|
||||
# Process control (restart/stop/start) for the dashboard's "Windows-services"
|
||||
# panel. Mutating, so it requires the Bearer secret (unlike read-only /pm2).
|
||||
# Whitelisted to ``legal-`` names only — never paperclip or arbitrary processes.
|
||||
_PM2_ACTIONS = {"restart", "stop", "start"}
|
||||
|
||||
# Our own pm2 process name. Restarting/stopping ourselves kills this process
|
||||
# mid-reply, so those self-actions are detached (see pm2_control).
|
||||
_OWN_PM2_NAME = os.environ.get("COURT_FETCH_SERVICE_PM2_NAME", "legal-court-fetch-service")
|
||||
|
||||
|
||||
async def pm2_control(request: web.Request) -> web.Response:
|
||||
"""Run ``pm2 <action> <name>`` for a whitelisted legal-* process."""
|
||||
unauth = _check_bearer(request)
|
||||
if unauth is not None:
|
||||
return unauth
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response({"error": "invalid JSON body"}, status=400)
|
||||
|
||||
name = str(body.get("name", "")).strip()
|
||||
action = str(body.get("action", "")).strip()
|
||||
if action not in _PM2_ACTIONS:
|
||||
return web.json_response(
|
||||
{"error": f"action must be one of {sorted(_PM2_ACTIONS)}"}, status=400
|
||||
)
|
||||
if not name.startswith("legal-"):
|
||||
return web.json_response(
|
||||
{"error": "name must be a legal-* process"}, status=403
|
||||
)
|
||||
|
||||
# Self restart/stop kills this process before it can reply (client sees a
|
||||
# dropped connection / 502) even though pm2 does perform the action. Detach
|
||||
# it with a brief delay so the HTTP response flushes first, then report
|
||||
# success optimistically.
|
||||
if name == _OWN_PM2_NAME and action in ("restart", "stop"):
|
||||
import asyncio as _asyncio
|
||||
|
||||
await _asyncio.create_subprocess_shell(f"sleep 1; pm2 {action} {name} --silent")
|
||||
return web.json_response(
|
||||
{"ok": True, "action": action, "deferred": True, "service": None}
|
||||
)
|
||||
|
||||
try:
|
||||
rc, out, err = await _pm2_run(action, name, "--silent", timeout=30)
|
||||
if rc != 0:
|
||||
return web.json_response(
|
||||
{"ok": False,
|
||||
"error": f"pm2 {action} {name} failed: "
|
||||
f"{err.decode('utf-8','replace')[:200]}"},
|
||||
status=502,
|
||||
)
|
||||
# Re-read just this process so the UI settles on the real new state.
|
||||
rc2, out2, _ = await _pm2_run("jlist")
|
||||
svc = None
|
||||
if rc2 == 0:
|
||||
for a in json.loads(out2.decode("utf-8", "replace")):
|
||||
if a.get("name") == name:
|
||||
svc = _trim_service(a)
|
||||
break
|
||||
return web.json_response({"ok": True, "action": action, "service": svc})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "pm2 not found on PATH"}, status=502)
|
||||
except Exception as e: # never throw
|
||||
return web.json_response({"ok": False, "error": f"pm2 error: {e}"}, status=502)
|
||||
|
||||
|
||||
def _check_bearer(request: web.Request) -> web.Response | None:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
expected = "Bearer " + _SHARED_SECRET
|
||||
if not auth or not hmac.compare_digest(auth, expected):
|
||||
return web.json_response(
|
||||
{"error": "unauthorized: missing or invalid Bearer token"}, status=401
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def fetch(request: web.Request) -> web.Response:
|
||||
unauth = _check_bearer(request)
|
||||
if unauth is not None:
|
||||
return unauth
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response({"error": "invalid JSON body"}, status=400)
|
||||
|
||||
required = ("file_number", "month", "year")
|
||||
if not all(body.get(k) for k in required):
|
||||
return web.json_response(
|
||||
{"ok": False, "reason": f"missing one of {required}"}, status=400
|
||||
)
|
||||
|
||||
try:
|
||||
result = await camofox_client.fetch_admin_verdict(
|
||||
file_number=str(body["file_number"]),
|
||||
month=str(body["month"]),
|
||||
year=str(body["year"]),
|
||||
case_number=str(body.get("case_number", "")),
|
||||
court=str(body.get("court", "")),
|
||||
)
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"content_b64": base64.b64encode(result["content"]).decode("ascii"),
|
||||
"filename": result.get("filename", ""),
|
||||
"source_url": result.get("source_url", ""),
|
||||
"court": result.get("court", ""),
|
||||
})
|
||||
except (camofox_client.CamofoxUnavailable, camofox_client.NgcsFlowError) as e:
|
||||
# Expected, recoverable failure → orchestrator escalates (INV-CF3).
|
||||
return web.json_response({"ok": False, "reason": str(e)}, status=200)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("fetch failed")
|
||||
return web.json_response({"ok": False, "reason": f"unexpected: {e}"}, status=200)
|
||||
|
||||
|
||||
def build_app() -> web.Application:
|
||||
app = web.Application(client_max_size=64 * 1024 * 1024)
|
||||
app.router.add_get("/health", health)
|
||||
app.router.add_get("/pm2", pm2_status)
|
||||
app.router.add_post("/pm2/control", pm2_control)
|
||||
app.router.add_post("/fetch", fetch)
|
||||
return app
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="legal-court-fetch-service")
|
||||
parser.add_argument("--port", type=int, default=8771)
|
||||
parser.add_argument("--host", default="10.0.1.1",
|
||||
help="bind address; default = docker0 bridge gateway")
|
||||
parser.add_argument("--log-level", default="INFO")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=args.log_level.upper(),
|
||||
format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||
|
||||
secret = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
|
||||
if not secret:
|
||||
logger.error(
|
||||
"COURT_FETCH_SHARED_SECRET is empty; refusing to start. Set it in "
|
||||
"/home/chaim/.legal-court-fetch-service.env (loaded by pm2) and "
|
||||
"mirror it as a Coolify env var on the legal-ai app."
|
||||
)
|
||||
return 2
|
||||
if len(secret) < 24:
|
||||
logger.error("COURT_FETCH_SHARED_SECRET too short (>=32 chars expected).")
|
||||
return 2
|
||||
global _SHARED_SECRET
|
||||
_SHARED_SECRET = secret
|
||||
|
||||
app = build_app()
|
||||
logger.info("legal-court-fetch-service listening on %s:%d", args.host, args.port)
|
||||
web.run_app(app, host=args.host, port=args.port, print=lambda _m: None)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -58,6 +58,8 @@ from legal_mcp.tools import ( # noqa: E402
|
||||
missing_precedents as mp_tools,
|
||||
citations as cit_tools,
|
||||
training_enrichment as train_tools,
|
||||
digests as digest_tools,
|
||||
court_fetch as cf_tools,
|
||||
)
|
||||
|
||||
|
||||
@@ -340,6 +342,81 @@ async def search_precedent_library(
|
||||
)
|
||||
|
||||
|
||||
# Digests radar (X12) — secondary discovery layer; NOT a citation corpus.
|
||||
@mcp.tool()
|
||||
async def digest_upload(
|
||||
file_path: str,
|
||||
yomon_number: str = "",
|
||||
digest_date: str = "",
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
subject_tags: list[str] | None = None,
|
||||
) -> str:
|
||||
"""העלאת יומון ("כל יום") לקורפוס-הגילוי (radar) + חילוץ מטא-דאטה אוטומטי. היומון הוא מקור-משני המצביע על הפסק המקורי — אינו מצוטט בהחלטה ואינו מחלץ הלכות (INV-DIG1/2). practice_area: rishuy_uvniya / betterment_levy / compensation_197."""
|
||||
return await digest_tools.digest_upload(
|
||||
file_path, yomon_number, digest_date, practice_area,
|
||||
appeal_subtype, subject_tags,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def digest_list(
|
||||
practice_area: str = "",
|
||||
concept_tag: str = "",
|
||||
linked: bool | None = None,
|
||||
search: str = "",
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""רשימת יומונים בקורפוס-הגילוי, עם פילטרים. linked=false → יומונים שהפסק המקורי שלהם עוד לא נקלט לספריית הפסיקה (פער-ידע גלוי, INV-DIG3)."""
|
||||
return await digest_tools.digest_list(
|
||||
practice_area, concept_tag, linked, search, _clamp_limit(limit),
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def digest_get(digest_id: str) -> str:
|
||||
"""יומון ספציפי לפי מזהה (כולל מראה-מקום, ניתוח, וקישור לפסק המקורי אם קיים)."""
|
||||
return await digest_tools.digest_get(digest_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def digest_link(digest_id: str, case_law_id: str) -> str:
|
||||
"""קישור ידני של יומון לפסק הדין המקורי בספריית הפסיקה (INV-DIG3). idempotent."""
|
||||
return await digest_tools.digest_link(digest_id, case_law_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def digest_relink(digest_id: str) -> str:
|
||||
"""ניסיון-קישור מחדש: בודק אם פסק הדין המקורי של היומון כבר בספרייה ומקשר אוטומטית."""
|
||||
return await digest_tools.digest_relink(digest_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def digest_delete(digest_id: str) -> str:
|
||||
"""מחיקת יומון מקורפוס-הגילוי."""
|
||||
return await digest_tools.digest_delete(digest_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search_digests(
|
||||
query: str,
|
||||
practice_area: str = "",
|
||||
subject_tag: str = "",
|
||||
concept_tag: str = "",
|
||||
limit: int = 10,
|
||||
) -> str:
|
||||
"""חיפוש סמנטי בקורפוס-הגילוי (יומוני "כל יום") — מצפן-מחקר (radar). מחזיר את היומון הרלוונטי + מראה-המקום של הפסק המקורי. ⚠️ היומון אינו מצוטט בהחלטה (INV-DIG1) — הצטט מהפסק המקורי דרך search_precedent_library. החוקר משתמש בזה בשלב 2ב.0 לפני האימות."""
|
||||
return await digest_tools.search_digests(
|
||||
query, practice_area, subject_tag, concept_tag, _clamp_limit(limit),
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def digest_process_pending(limit: int = 20) -> str:
|
||||
"""ריקון תור היומונים שהועלו מה-UI וממתינים לעיבוד-LLM מקומי. מריץ חילוץ-מטא-דאטה + embedding + autolink על כל יומון 'pending' (מקומית עם CLI). חלופת-MCP ל-scripts/ingest_digests_batch.py."""
|
||||
return await digest_tools.digest_process_pending(_clamp_limit(limit))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def halacha_review(
|
||||
halacha_id: str,
|
||||
@@ -895,6 +972,28 @@ async def missing_precedent_close(
|
||||
)
|
||||
|
||||
|
||||
# ── Court verdict auto-fetch (X13) ────────────────────────────────
|
||||
@mcp.tool()
|
||||
async def court_verdict_fetch(citation: str) -> str:
|
||||
"""אחזור אוטומטי של פסק-דין בית-משפט מנט המשפט/פורטל-העליון וקליטה לקורפוס.
|
||||
|
||||
מסווג את הציטוט (עליון→Tier0 / מנהלי→Tier1 / ערר→skip), מוריד וקולט דרך
|
||||
צינור-הקליטה הקנוני. דוגמה: 'עת"מ 46111-12-22'. כלי מקומי בלבד."""
|
||||
return await cf_tools.court_verdict_fetch(citation)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def court_fetch_status(case_number: str = "", status_filter: str = "") -> str:
|
||||
"""סטטוס תור-אחזור הפסיקה. case_number לפריט יחיד, או status_filter (pending/failed/manual/done)."""
|
||||
return await cf_tools.court_fetch_status(case_number, status_filter)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def court_fetch_drain(limit: int = 10) -> str:
|
||||
"""ריקון תור-אחזור הפסיקה — מוריד וקולט jobs ממתינים שהיומונים מילאו, וקושר חזרה ליומון. מקומי בלבד."""
|
||||
return await cf_tools.court_fetch_drain(limit)
|
||||
|
||||
|
||||
# ── Internal citations graph (TaskMaster #34) ─────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ Output: data/cases/{case_number}/exports/ניתוח-משפטי-v{N}.docx
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -34,7 +35,7 @@ from docx.text.paragraph import Paragraph
|
||||
from docx.text.run import Run
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import db, research_md
|
||||
from legal_mcp.services import db, research_md, storage
|
||||
|
||||
|
||||
def _mark_run_rtl(run: Run) -> None:
|
||||
@@ -494,10 +495,19 @@ async def build_analysis_docx(case_number: str) -> Path:
|
||||
continue
|
||||
_emit_content_line(doc, raw)
|
||||
|
||||
# Save versioned
|
||||
# Save versioned through the storage layer (INV-STG1). export_dir.mkdir +
|
||||
# the glob in _next_version still read disk (correct under filesystem/dual;
|
||||
# storage-native versioning is a cutover concern). out_path is always under
|
||||
# DATA_DIR, so the bytes land exactly where they did before.
|
||||
export_dir = case_dir / "exports"
|
||||
export_dir.mkdir(parents=True, exist_ok=True)
|
||||
version = _next_version(export_dir)
|
||||
out_path = export_dir / f"ניתוח-משפטי-v{version}.docx"
|
||||
doc.save(str(out_path))
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
await storage.put_bytes(
|
||||
out_path.relative_to(config.DATA_DIR).as_posix(), buf.getvalue(),
|
||||
bucket=storage.Bucket.DOCUMENTS,
|
||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
return out_path
|
||||
|
||||
@@ -335,18 +335,30 @@ async def get_legal_arguments(
|
||||
case_id,
|
||||
)
|
||||
|
||||
# Pull supporting claim ids for each argument in one round-trip.
|
||||
# Pull supporting claims (id + full text) for each argument in one
|
||||
# round-trip. ``supporting_claims`` stays id-only for backwards compat
|
||||
# (counts, MCP consumers); ``supporting_propositions`` carries the text
|
||||
# so the UI can show the raw propositions without an extra fetch.
|
||||
arg_ids = [r["id"] for r in rows]
|
||||
supporting: dict[UUID, list[str]] = {}
|
||||
propositions: dict[UUID, list[dict]] = {}
|
||||
if arg_ids:
|
||||
joins = await conn.fetch(
|
||||
"""SELECT argument_id, claim_id
|
||||
FROM legal_argument_propositions
|
||||
WHERE argument_id = ANY($1::uuid[])""",
|
||||
"""SELECT lap.argument_id, lap.claim_id,
|
||||
c.claim_text, c.source_document, c.claim_index
|
||||
FROM legal_argument_propositions lap
|
||||
JOIN claims c ON c.id = lap.claim_id
|
||||
WHERE lap.argument_id = ANY($1::uuid[])
|
||||
ORDER BY c.claim_index""",
|
||||
arg_ids,
|
||||
)
|
||||
for j in joins:
|
||||
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] = []
|
||||
for r in rows:
|
||||
@@ -354,5 +366,6 @@ async def get_legal_arguments(
|
||||
d["id"] = str(d["id"])
|
||||
d["case_id"] = str(d["case_id"])
|
||||
d["supporting_claims"] = supporting.get(r["id"], [])
|
||||
d["supporting_propositions"] = propositions.get(r["id"], [])
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
121
mcp-server/src/legal_mcp/services/bulletin_library.py
Normal file
121
mcp-server/src/legal_mcp/services/bulletin_library.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Ingest a monthly "עו"ד על נדל"ן" bulletin into the digests radar (X12).
|
||||
|
||||
A bulletin PDF is multi-topic: it EXPLODES into several digest rows — one per
|
||||
case-law pointer (digest_kind='decision') and one per article (digest_kind=
|
||||
'article'), all tagged publication='עו"ד על נדל"ן' to distinguish them from the
|
||||
daily "כל יום" issues. This reuses the existing radar (no parallel corpus — G2):
|
||||
the case pointers join search_digests / the /digests page and autolink to the
|
||||
underlying ruling exactly like a daily digest; articles are deep-context only.
|
||||
|
||||
LOCAL-ONLY (LLM split + embedding) — host scripts/MCP, never the container path.
|
||||
Idempotent: each item's content_hash (hash of its analysis_text) is the dedup
|
||||
key, so re-running a bulletin skips already-ingested items.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from legal_mcp.services import db, embeddings, extractor
|
||||
from legal_mcp.services import bulletin_splitter, digest_library
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PUBLICATION = 'עו"ד על נדל"ן'
|
||||
SOURCE_FIRM = "צבי שוב + רונית אלפר, עורכי דין"
|
||||
|
||||
|
||||
async def _store_and_embed(digest_row: dict) -> None:
|
||||
"""Compute + store the single radar embedding for a freshly created item."""
|
||||
emb_text = digest_library._embedding_text(digest_row)
|
||||
if not emb_text:
|
||||
return
|
||||
try:
|
||||
vecs = await embeddings.embed_texts([emb_text], input_type="document")
|
||||
if vecs:
|
||||
await db.store_digest_embedding(digest_row["id"], vecs[0])
|
||||
except Exception as e: # §6 — surfaced, not swallowed
|
||||
logger.warning("bulletin item embedding failed for %s: %s", digest_row.get("id"), e)
|
||||
|
||||
|
||||
async def _create_item(*, analysis_text: str, kind: str, concept_tag: str,
|
||||
headline: str, summary: str, citation: str, court: str,
|
||||
practice_area: str, subject_tags: list[str], src: str) -> dict | None:
|
||||
"""Create one digest row from a bulletin item. Returns the row, or None if it
|
||||
already exists (idempotent skip) or the insert raced on content_hash."""
|
||||
content_hash = db._content_hash(analysis_text)
|
||||
if await db.get_digest_by_content_hash(content_hash):
|
||||
return None
|
||||
try:
|
||||
return await db.create_digest(
|
||||
analysis_text=analysis_text,
|
||||
publication=PUBLICATION,
|
||||
source_firm=SOURCE_FIRM,
|
||||
concept_tag=concept_tag,
|
||||
headline_holding=headline,
|
||||
summary=summary,
|
||||
underlying_citation=citation,
|
||||
underlying_court=court,
|
||||
practice_area=practice_area,
|
||||
subject_tags=subject_tags,
|
||||
source_document_path=src,
|
||||
extraction_status="completed",
|
||||
digest_kind=kind,
|
||||
)
|
||||
except Exception as e:
|
||||
# uq_digests_content_hash race (concurrent run) → treat as already-present.
|
||||
if "uq_digests_content_hash" in str(e):
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
async def ingest_bulletin(file_path: str, model: str | None = None) -> dict:
|
||||
"""Split a bulletin PDF into digest rows (case pointers + articles).
|
||||
|
||||
Returns counts: {cases, articles, created, skipped, linked}. Idempotent.
|
||||
"""
|
||||
path = str(file_path)
|
||||
raw_text, _pages, _meta = await extractor.extract_text(path)
|
||||
split = await bulletin_splitter.split(raw_text, model=model)
|
||||
cases, articles = split.get("cases", []), split.get("articles", [])
|
||||
|
||||
out = {"file": Path(path).name, "cases": len(cases), "articles": len(articles),
|
||||
"created": 0, "skipped": 0, "linked": 0}
|
||||
|
||||
for c in cases:
|
||||
# analysis_text bundles the pointer's substance → stable per-item hash.
|
||||
atext = "\n".join(p for p in (
|
||||
c["concept_tag"], c["headline_holding"], c["summary"], c["underlying_citation"]
|
||||
) if p).strip()
|
||||
row = await _create_item(
|
||||
analysis_text=atext, kind="decision", concept_tag=c["concept_tag"],
|
||||
headline=c["headline_holding"], summary=c["summary"],
|
||||
citation=c["underlying_citation"], court=c["underlying_court"],
|
||||
practice_area=c["practice_area"], subject_tags=c["subject_tags"], src=path,
|
||||
)
|
||||
if row is None:
|
||||
out["skipped"] += 1
|
||||
continue
|
||||
out["created"] += 1
|
||||
await _store_and_embed(row)
|
||||
linked = await digest_library.try_autolink(row["id"], c["underlying_citation"])
|
||||
if linked:
|
||||
out["linked"] += 1
|
||||
|
||||
for a in articles:
|
||||
# The article body is the substance; prefix authors into the summary.
|
||||
body = a["body"] or a["summary"]
|
||||
summary = (f"מאת {a['authors']}. " if a["authors"] else "") + (a["summary"] or "")
|
||||
atext = "\n".join(p for p in (a["title"], summary, body) if p).strip()
|
||||
row = await _create_item(
|
||||
analysis_text=atext, kind="article", concept_tag=a["title"],
|
||||
headline=a["title"], summary=summary, citation="", court="",
|
||||
practice_area=a["practice_area"], subject_tags=a["subject_tags"], src=path,
|
||||
)
|
||||
if row is None:
|
||||
out["skipped"] += 1
|
||||
continue
|
||||
out["created"] += 1
|
||||
await _store_and_embed(row)
|
||||
|
||||
return out
|
||||
147
mcp-server/src/legal_mcp/services/bulletin_splitter.py
Normal file
147
mcp-server/src/legal_mcp/services/bulletin_splitter.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Split a monthly "עו"ד על נדל"ן" bulletin into typed radar items (X12).
|
||||
|
||||
The monthly bulletin (a SEPARATE publication from the daily "כל יום" digest) is
|
||||
multi-topic: it bundles a featured ARTICLE, a list of legislative updates, and a
|
||||
set of CASE-LAW pointers grouped by topic. The chair chose to catalog the
|
||||
**case-law pointers** (each → a digest, like the daily issue) and the
|
||||
**articles** (deep-context background) — legislative updates are skipped.
|
||||
|
||||
This module is the LLM splitter only. ``bulletin_library.ingest_bulletin`` turns
|
||||
its output into digest rows. Like the daily extractor it is LOCAL-ONLY (claude
|
||||
CLI) and MUST NOT be imported from the FastAPI container path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from legal_mcp import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_VALID_PRACTICE_AREAS = {"rishuy_uvniya", "betterment_levy", "compensation_197"}
|
||||
|
||||
BULLETIN_SPLIT_PROMPT = """\
|
||||
אתה מקבל טקסט מלא של **עלון חודשי "עו"ד על נדל"ן"** (פרסום מקצועי רב-נושאי בתחום
|
||||
תכנון ובנייה, מקרקעין, היטל השבחה, פיצויים והתחדשות עירונית). פצל אותו לפריטים.
|
||||
|
||||
העלון בנוי משלושה חלקים: (א) **מאמר** מקצועי ארוך אחד או יותר; (ב) **עדכוני חקיקה**
|
||||
(תיקוני-חוק, אישורי-תכניות, חוזרים) — **התעלם מהם, אל תחלץ**; (ג) **עדכוני פסיקה**
|
||||
מקובצים לפי נושא — כל פריט = מראה-מקום של פסק דין/החלטה + שורת-תקציר.
|
||||
|
||||
**אל תמציא** — חלץ רק מה שמופיע בטקסט. שדה חסר → מחרוזת ריקה.
|
||||
|
||||
## פלט נדרש
|
||||
החזר JSON אחד (object), ללא markdown:
|
||||
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"underlying_citation": "מראה-המקום המלא של הפסק כפי שמופיע, מילה במילה (למשל 'ערר 8018-02-22 הועדה המקומית בת ים נ' קבוצת מזרחי ובניו השקעות בע\\"מ'). השדה הקריטי.",
|
||||
"concept_tag": "הנושא/הכותרת שתחתיה מופיע הפריט (למשל 'היטל השבחה', 'הפקעות', 'פירוק שיתוף').",
|
||||
"headline_holding": "שורת-התקציר/הכותרת של הפריט — מה נקבע/השאלה (למשל 'חוסר וודאות בין תכנית קודמת לבין ההקלה').",
|
||||
"summary": "תקציר ניטרלי קצר אם יש פירוט נוסף בגוף; אחרת חזור על headline_holding.",
|
||||
"underlying_court": "הערכאה אם מצוינת (למשל 'בית המשפט המחוזי', 'ועדת ערר').",
|
||||
"practice_area": "אחד מ: 'rishuy_uvniya' / 'betterment_levy' / 'compensation_197' — אם ברור מהנושא; אחרת ריק.",
|
||||
"subject_tags": ["2-5 תגיות snake_case בעברית"]
|
||||
}
|
||||
],
|
||||
"articles": [
|
||||
{
|
||||
"title": "כותרת המאמר (למשל 'הפקעת קרקעות כיום - על המחוקק לתקן את העיוות שנוצר').",
|
||||
"authors": "שמות המחברים (למשל 'עו\\"ד צבי שוב, עו\\"ד רונית אלפר').",
|
||||
"summary": "2-4 משפטים: על מה המאמר ומה הטענה המרכזית.",
|
||||
"body": "הטקסט המלא של המאמר (כל הפסקאות), לצורך embedding וחיפוש-עומק.",
|
||||
"practice_area": "אחד מ-3 אם ברור; אחרת ריק.",
|
||||
"subject_tags": ["2-5 תגיות snake_case"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
## כללים
|
||||
1. **underlying_citation** — חלץ במלואו ובדיוק; הוא הגשר לפסק. פריט-פסיקה בלי מראה-מקום ברור → דלג עליו.
|
||||
2. **cases** — כל מצביעי-הפסיקה בעלון, גם אם תחת נושאים שונים. אל תאחד פריטים נפרדים.
|
||||
3. **articles** — רק מאמרי-עומק (לא רשימת עדכונים). body = הטקסט המלא.
|
||||
4. **עדכוני חקיקה/אישורי-תכניות/חוזרים — לא לחלץ כלל.**
|
||||
5. אם אין מאמר או אין פסיקה — החזר מערך ריק לאותו מפתח.
|
||||
"""
|
||||
|
||||
|
||||
def _norm_str(d: dict, key: str) -> str:
|
||||
v = d.get(key)
|
||||
return v.strip() if isinstance(v, str) else ""
|
||||
|
||||
|
||||
def _norm_tags(d: dict) -> list[str]:
|
||||
tags = d.get("subject_tags")
|
||||
if not isinstance(tags, list):
|
||||
return []
|
||||
return [str(t).strip() for t in tags if str(t).strip()][:8]
|
||||
|
||||
|
||||
def _norm_pa(d: dict) -> str:
|
||||
pa = _norm_str(d, "practice_area")
|
||||
return pa if pa in _VALID_PRACTICE_AREAS else ""
|
||||
|
||||
|
||||
async def split(raw_text: str, model: str | None = None) -> dict:
|
||||
"""Return ``{"cases": [...], "articles": [...]}`` extracted from a bulletin.
|
||||
|
||||
Empty lists on any failure (surfaced as a warning, never raised) so the
|
||||
batch keeps going. Each item is type-normalized; malformed items are dropped.
|
||||
"""
|
||||
from legal_mcp.services import claude_session
|
||||
|
||||
text = (raw_text or "").strip()
|
||||
if not text:
|
||||
return {"cases": [], "articles": []}
|
||||
|
||||
try:
|
||||
result = await claude_session.query_json(
|
||||
text,
|
||||
system=BULLETIN_SPLIT_PROMPT,
|
||||
model=(model or config.DIGEST_EXTRACT_MODEL or None),
|
||||
tools="", # pure text→JSON; disable tools (avoids error_max_turns)
|
||||
)
|
||||
except Exception as e: # §6 — surfaced, not swallowed
|
||||
logger.warning("bulletin_splitter: query failed: %s", e)
|
||||
return {"cases": [], "articles": []}
|
||||
|
||||
if not isinstance(result, dict):
|
||||
logger.warning("bulletin_splitter: expected dict, got %s", type(result).__name__)
|
||||
return {"cases": [], "articles": []}
|
||||
|
||||
cases: list[dict] = []
|
||||
for c in result.get("cases") or []:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
citation = _norm_str(c, "underlying_citation")
|
||||
if not citation: # rule 1: no anchor → skip
|
||||
continue
|
||||
cases.append({
|
||||
"underlying_citation": citation,
|
||||
"concept_tag": _norm_str(c, "concept_tag"),
|
||||
"headline_holding": _norm_str(c, "headline_holding"),
|
||||
"summary": _norm_str(c, "summary") or _norm_str(c, "headline_holding"),
|
||||
"underlying_court": _norm_str(c, "underlying_court"),
|
||||
"practice_area": _norm_pa(c),
|
||||
"subject_tags": _norm_tags(c),
|
||||
})
|
||||
|
||||
articles: list[dict] = []
|
||||
for a in result.get("articles") or []:
|
||||
if not isinstance(a, dict):
|
||||
continue
|
||||
title = _norm_str(a, "title")
|
||||
body = _norm_str(a, "body")
|
||||
if not (title or body):
|
||||
continue
|
||||
articles.append({
|
||||
"title": title,
|
||||
"authors": _norm_str(a, "authors"),
|
||||
"summary": _norm_str(a, "summary"),
|
||||
"body": body,
|
||||
"practice_area": _norm_pa(a),
|
||||
"subject_tags": _norm_tags(a),
|
||||
})
|
||||
|
||||
return {"cases": cases, "articles": articles}
|
||||
@@ -82,6 +82,7 @@ async def query(
|
||||
system: str | None = None,
|
||||
model: str | None = None,
|
||||
effort: str | None = None,
|
||||
tools: str | None = None,
|
||||
) -> str:
|
||||
"""Send a prompt to Claude Code headless and return the text response.
|
||||
|
||||
@@ -104,6 +105,12 @@ async def query(
|
||||
effort: Optional effort level (``low``/``medium``/``high``/``xhigh``/
|
||||
``max``). When set, passed as ``--effort``. Pairs with ``model``;
|
||||
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:
|
||||
The text response from Claude.
|
||||
@@ -126,6 +133,8 @@ async def query(
|
||||
cmd += ["--model", model]
|
||||
if 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 ""
|
||||
last_err = "unknown error"
|
||||
@@ -204,13 +213,15 @@ async def query_json(
|
||||
system: str | None = None,
|
||||
model: str | None = None,
|
||||
effort: str | None = None,
|
||||
tools: str | None = None,
|
||||
) -> dict | list | None:
|
||||
"""Send a prompt and parse the response as JSON.
|
||||
|
||||
Uses parse_llm_json for robust parsing (handles markdown wrapping, truncation).
|
||||
``model``/``effort`` are forwarded to :func:`query` (see its docstring).
|
||||
``model``/``effort``/``tools`` are forwarded to :func:`query` (see its docstring).
|
||||
Pure text→JSON extractors should pass ``tools=""`` to avoid ``error_max_turns``.
|
||||
"""
|
||||
raw = await query(prompt, timeout=timeout, system=system, model=model, effort=effort)
|
||||
raw = await query(prompt, timeout=timeout, system=system, model=model, effort=effort, tools=tools)
|
||||
return parse_llm_json(raw)
|
||||
|
||||
|
||||
|
||||
212
mcp-server/src/legal_mcp/services/court_citation.py
Normal file
212
mcp-server/src/legal_mcp/services/court_citation.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Court-citation classifier for the auto-fetch subsystem (X13).
|
||||
|
||||
Given a raw citation string (typically a digest's ``underlying_citation``,
|
||||
e.g. ``עת"מ 46111-12-22 יכין-אפק נ' הוועדה המחוזית``), decide:
|
||||
|
||||
* **which tier** can fetch it (``supreme`` | ``admin`` | ``skip``), and
|
||||
* the **canonical case number** plus, for נט המשפט, the
|
||||
(file, month, year) triple the public case-search form needs.
|
||||
|
||||
Tier mapping (INV-CF6 — only court rulings are auto-fetched; ועדת-ערר is
|
||||
never sent to a public fetch, it needs Nevo):
|
||||
|
||||
* ``supreme`` — Supreme Court prefixes (עע"מ/בג"ץ/ע"א/רע"א/דנ"א/בר"מ/בש"א).
|
||||
Fetched directly from ``supremedecisions.court.gov.il`` (Tier 0, no CAPTCHA).
|
||||
* ``admin`` — district / administrative-court prefixes (עת"מ/עמ"נ/…) and
|
||||
the bare נט-המשפט "filed" format ``NNNNN-MM-YY``. Fetched via the
|
||||
host-side stealth browser against נט המשפט (Tier 1).
|
||||
* ``skip`` — ועדת-ערר (ערר/בל"מ). Not publicly fetchable → missing_precedent.
|
||||
|
||||
Regex families intentionally mirror ``citation_extractor.py`` (the canonical
|
||||
prefix/number patterns) so the two stay in sync — we reuse ``_NUM_RX`` shape
|
||||
and ``_normalize_case_number`` semantics rather than inventing a parallel
|
||||
parser (INV-CF1 / engineering "symmetry" rule).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Canonical number core, identical shape to citation_extractor._NUM_RX:
|
||||
# 3-5 digits, optional separator + 2-4 digits, optional third group
|
||||
# (the NNNNN-MM-YY "filed" format — 46111-12-22 = file 46111, month 12, yr 22).
|
||||
_NUM_RX = r"\d{1,5}(?:[-/]\d{1,4}(?:[-/]\d{2,4})?)?"
|
||||
|
||||
# Hebrew gershayim: straight (") or curly (״).
|
||||
_Q = r"[\"״]"
|
||||
|
||||
# Optional leading one-letter Hebrew preposition/conjunction (ב/ל/ה/ו/כ/מ/ש)
|
||||
# attached to the prefix — e.g. "בערר", "וערר", "כפי שקבעתי בערר". Anchored by
|
||||
# a lookbehind that forbids a *preceding* Hebrew letter, so we don't match a
|
||||
# prefix buried inside a longer word. Regex backtracking lets the preposition
|
||||
# match empty when the prefix itself starts with one of these letters (בג"ץ).
|
||||
_LEAD = r"(?<![א-ת])(?:[בלהוכמש])?"
|
||||
|
||||
# Supreme Court prefixes → Tier 0 (supremedecisions public download API).
|
||||
_SUPREME_PREFIXES = [
|
||||
rf"עע{_Q}מ", # ערעור מנהלי (לעליון)
|
||||
rf"בג{_Q}ץ", # בג"ץ
|
||||
rf"בג{_Q}צ", # variant spelling
|
||||
rf"דנג{_Q}ץ", # דיון נוסף בג"ץ
|
||||
rf"ע{_Q}א", # ערעור אזרחי
|
||||
rf"רע{_Q}א", # רשות ערעור אזרחי
|
||||
rf"דנ{_Q}א", # דיון נוסף אזרחי
|
||||
rf"בר{_Q}מ", # בקשת רשות ערעור מנהלי (עליון)
|
||||
rf"בש{_Q}א", # בקשת רשות … (עליון)
|
||||
]
|
||||
|
||||
# District / administrative-court prefixes → Tier 1 (נט המשפט case viewer).
|
||||
_ADMIN_PREFIXES = [
|
||||
rf"עת{_Q}מ", # עתירה מנהלית (בימ"ש לעניינים מנהליים)
|
||||
rf"עמ{_Q}נ", # ערעור מנהלי (מחוזי)
|
||||
rf"ת{_Q}א", # תביעה אזרחית (מחוזי/שלום)
|
||||
rf"ה{_Q}פ", # המרצת פתיחה
|
||||
]
|
||||
|
||||
# Appeals-committee → skip (needs Nevo; never auto-fetched).
|
||||
_SKIP_PREFIXES = [
|
||||
rf"ערר",
|
||||
rf"בל{_Q}מ",
|
||||
]
|
||||
|
||||
_SUPREME_RX = re.compile(
|
||||
_LEAD + r"(" + "|".join(_SUPREME_PREFIXES) + r")\s*(" + _NUM_RX + r")",
|
||||
re.UNICODE,
|
||||
)
|
||||
_ADMIN_RX = re.compile(
|
||||
_LEAD + r"(" + "|".join(_ADMIN_PREFIXES) + r")\s*(" + _NUM_RX + r")",
|
||||
re.UNICODE,
|
||||
)
|
||||
_SKIP_RX = re.compile(
|
||||
_LEAD + r"(" + "|".join(_SKIP_PREFIXES) + r")" + r"(?:\s*\([^)\n]{0,80}\))?\s*(" + _NUM_RX + r")",
|
||||
re.UNICODE,
|
||||
)
|
||||
|
||||
# Bare נט-המשפט filed format with no prefix: 46111-12-22 (5/4-digit file,
|
||||
# 1-2 digit month, 2-4 digit year). Used when a digest gives just the number.
|
||||
_BARE_FILED_RX = re.compile(r"(?<!\d)(\d{1,5})-(\d{1,2})-(\d{2,4})(?!\d)", re.UNICODE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CourtCitation:
|
||||
"""Result of classifying a citation for auto-fetch routing."""
|
||||
|
||||
tier: str # "supreme" | "admin" | "skip" | "unknown"
|
||||
court_prefix: str # e.g. 'עת"מ', or "" for bare/unknown
|
||||
case_number_raw: str # the matched number as written, e.g. "46111-12-22"
|
||||
case_number_norm: str # canonical: slashes→dashes, digits/sep only
|
||||
# נט-המשפט form fields (only when the filed format NNNNN-MM-YY is present):
|
||||
file_number: str | None = None
|
||||
month: str | None = None
|
||||
year: str | None = None
|
||||
|
||||
@property
|
||||
def fetchable(self) -> bool:
|
||||
return self.tier in ("supreme", "admin")
|
||||
|
||||
|
||||
def normalize_case_number(raw: str) -> str:
|
||||
"""Canonicalize a case number for idempotency keys / matching.
|
||||
|
||||
Mirrors ``citation_extractor._normalize_case_number``: strip everything
|
||||
but digits and separators, unify ``/`` → ``-``. Display value is never
|
||||
derived from this.
|
||||
"""
|
||||
cleaned = re.sub(r"[^\d/\-]", "", raw or "")
|
||||
return cleaned.replace("/", "-").strip("-")
|
||||
|
||||
|
||||
def _split_filed(num_norm: str) -> tuple[str, str, str] | None:
|
||||
"""Split a normalized NNNNN-MM-YY number into (file, month, year).
|
||||
|
||||
Only the three-group "filed" format yields a נט-המשפט triple; two-group
|
||||
formats (1234-22 / 1234/22) are Supreme-style serials and return None.
|
||||
"""
|
||||
m = _BARE_FILED_RX.fullmatch(num_norm)
|
||||
if not m:
|
||||
return None
|
||||
file_no, month, year = m.group(1), m.group(2), m.group(3)
|
||||
# Plausibility: month 1-12, year 2-4 digits. Reject implausible months
|
||||
# (avoids mis-reading a 2-group serial that slipped through).
|
||||
if not (1 <= int(month) <= 12):
|
||||
return None
|
||||
return file_no, month, year
|
||||
|
||||
|
||||
def classify(citation: str) -> CourtCitation:
|
||||
"""Classify a raw citation string into a fetch tier + parsed number.
|
||||
|
||||
Resolution order: ועדת-ערר (skip) is checked FIRST so an "ערר" prefix is
|
||||
never mis-routed to a court tier; then Supreme prefixes; then admin
|
||||
prefixes; then a bare filed number defaults to ``admin`` (נט המשפט is the
|
||||
only public source for prefix-less district/שלום numbers).
|
||||
"""
|
||||
text = (citation or "").strip()
|
||||
if not text:
|
||||
return CourtCitation("unknown", "", "", "")
|
||||
|
||||
# 1. ועדת-ערר → skip (must win over any court match).
|
||||
m = _SKIP_RX.search(text)
|
||||
if m:
|
||||
raw = m.group(2)
|
||||
return CourtCitation(
|
||||
tier="skip",
|
||||
court_prefix=m.group(1),
|
||||
case_number_raw=raw,
|
||||
case_number_norm=normalize_case_number(raw),
|
||||
)
|
||||
|
||||
# 2. Supreme Court prefix → Tier 0. Still parse a נט-format triple when the
|
||||
# number carries one (e.g. בר"מ 72182-06-25): נט המשפט serves Supreme
|
||||
# cases too, so a triple lets the orchestrator route to the validated
|
||||
# Tier-1 flow instead of the serial-only Tier-0.
|
||||
m = _SUPREME_RX.search(text)
|
||||
if m:
|
||||
raw = m.group(2)
|
||||
norm = normalize_case_number(raw)
|
||||
filed = _split_filed(norm)
|
||||
return CourtCitation(
|
||||
tier="supreme",
|
||||
court_prefix=m.group(1),
|
||||
case_number_raw=raw,
|
||||
case_number_norm=norm,
|
||||
file_number=filed[0] if filed else None,
|
||||
month=filed[1] if filed else None,
|
||||
year=filed[2] if filed else None,
|
||||
)
|
||||
|
||||
# 3. District / admin prefix → Tier 1.
|
||||
m = _ADMIN_RX.search(text)
|
||||
if m:
|
||||
raw = m.group(2)
|
||||
norm = normalize_case_number(raw)
|
||||
filed = _split_filed(norm)
|
||||
return CourtCitation(
|
||||
tier="admin",
|
||||
court_prefix=m.group(1),
|
||||
case_number_raw=raw,
|
||||
case_number_norm=norm,
|
||||
file_number=filed[0] if filed else None,
|
||||
month=filed[1] if filed else None,
|
||||
year=filed[2] if filed else None,
|
||||
)
|
||||
|
||||
# 4. Bare filed number (no prefix) → default admin (נט המשפט).
|
||||
m = _BARE_FILED_RX.search(text)
|
||||
if m:
|
||||
raw = m.group(0)
|
||||
norm = normalize_case_number(raw)
|
||||
filed = _split_filed(norm)
|
||||
if filed:
|
||||
return CourtCitation(
|
||||
tier="admin",
|
||||
court_prefix="",
|
||||
case_number_raw=raw,
|
||||
case_number_norm=norm,
|
||||
file_number=filed[0],
|
||||
month=filed[1],
|
||||
year=filed[2],
|
||||
)
|
||||
|
||||
return CourtCitation("unknown", "", "", "")
|
||||
323
mcp-server/src/legal_mcp/services/court_fetch_orchestrator.py
Normal file
323
mcp-server/src/legal_mcp/services/court_fetch_orchestrator.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""X13 orchestrator — classify → fetch → ingest → record.
|
||||
|
||||
The single entry point (`fetch_and_ingest`) wires the three tiers to the
|
||||
**canonical** precedent-ingest pipeline (INV-CF1 — no parallel ingest path)
|
||||
and keeps the `court_fetch_jobs` row honest at every step (INV-CF2 — a job
|
||||
always ends in an explicit terminal state, never a silent drop).
|
||||
|
||||
Tier routing (from `court_citation.classify`):
|
||||
* ``skip`` — ועדת-ערר → never fetched; logged as a missing_precedent gap.
|
||||
* ``supreme`` — Tier 0, in-process httpx (`court_fetch_supreme`).
|
||||
* ``admin`` — Tier 1, the host-side stealth-browser service over loopback.
|
||||
|
||||
Fallback (INV-CF3): after ``MAX_AUTONOMOUS_ATTEMPTS`` autonomous failures the
|
||||
job flips to ``manual`` and a missing_precedent row is opened so the chair
|
||||
sees the gap and can solve the CAPTCHA live (VNC) or drop the file manually.
|
||||
|
||||
This module runs **in the local MCP server only** — `ingest_precedent` drives
|
||||
halacha extraction via the local ``claude`` CLI (see `claude_session.py`). It
|
||||
is invoked from the `court_verdict_fetch` MCP tool, not from the container.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
|
||||
from legal_mcp.services import court_citation, db
|
||||
from legal_mcp.services.court_fetch_supreme import (
|
||||
SupremeFetchError,
|
||||
fetch_supreme_verdict,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# After this many autonomous failures, stop auto-retrying and escalate to a
|
||||
# human (INV-CF3). Kept low — the .gov site shouldn't be hammered (INV-CF4).
|
||||
MAX_AUTONOMOUS_ATTEMPTS = int(os.environ.get("COURT_FETCH_MAX_ATTEMPTS", "2"))
|
||||
|
||||
# The host-side Tier-1 browser service (pm2). It binds the docker0 bridge
|
||||
# gateway (10.0.1.1) — same as legal-chat-service — so both the host MCP server
|
||||
# and containers can reach it; the host reaches 10.0.1.1 as a local interface.
|
||||
# Override with COURT_FETCH_SERVICE_URL.
|
||||
COURT_FETCH_SERVICE_URL = os.environ.get(
|
||||
"COURT_FETCH_SERVICE_URL", "http://10.0.1.1:8771"
|
||||
)
|
||||
_SHARED_SECRET = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
|
||||
_TIER1_TIMEOUT_S = float(os.environ.get("COURT_FETCH_TIER1_TIMEOUT_S", "300"))
|
||||
|
||||
# Provenance level by tier — Supreme rulings are binding; admin-court verdicts
|
||||
# are administrative (set is_binding conservatively True, chair can downgrade).
|
||||
_LEVEL_BY_TIER = {"supreme": "עליון", "admin": "מנהלי"}
|
||||
|
||||
|
||||
class _Tier1Unavailable(RuntimeError):
|
||||
"""The host browser service is not reachable / not configured."""
|
||||
|
||||
|
||||
async def _ingest_bytes(
|
||||
*, content: bytes, filename: str, citation: str, tier: str,
|
||||
court: str, source_url: str,
|
||||
) -> dict:
|
||||
"""Stage bytes to a temp file and run the canonical ingest (INV-CF1)."""
|
||||
from legal_mcp.services import precedent_library
|
||||
|
||||
suffix = Path(filename).suffix or ".pdf"
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
prefix="court_fetch_", suffix=suffix, delete=False
|
||||
)
|
||||
try:
|
||||
tmp.write(content)
|
||||
tmp.flush()
|
||||
tmp.close()
|
||||
result = await precedent_library.ingest_precedent(
|
||||
file_path=tmp.name,
|
||||
citation=citation,
|
||||
court=court,
|
||||
source_type="court_ruling", # INV-CF6
|
||||
precedent_level=_LEVEL_BY_TIER.get(tier, ""),
|
||||
is_binding=True,
|
||||
)
|
||||
# Stamp provenance on the new case_law row (INV-CF7).
|
||||
case_law_id = result.get("case_law_id")
|
||||
if case_law_id and source_url:
|
||||
try:
|
||||
await db.update_case_law(
|
||||
UUID(str(case_law_id)), source_url=source_url
|
||||
)
|
||||
except Exception: # provenance is best-effort, never blocks ingest
|
||||
logger.warning("could not stamp source_url on %s", case_law_id)
|
||||
return result
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
async def _fetch_tier1_admin(cit: court_citation.CourtCitation) -> dict:
|
||||
"""Call the host-side browser service to fetch an admin-court verdict.
|
||||
|
||||
Returns the service's JSON: ``{ok, content_b64, filename, source_url,
|
||||
court, reason}``. Raises ``_Tier1Unavailable`` if the service can't be
|
||||
reached, ``SupremeFetchError``-style RuntimeError on a fetch failure the
|
||||
service reports.
|
||||
"""
|
||||
if not (cit.file_number and cit.month and cit.year):
|
||||
raise RuntimeError(
|
||||
f"מספר-תיק {cit.case_number_norm} אינו בפורמט נט-המשפט (תיק-חודש-שנה)"
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {_SHARED_SECRET}"} if _SHARED_SECRET else {}
|
||||
payload = {
|
||||
"file_number": cit.file_number,
|
||||
"month": cit.month,
|
||||
"year": cit.year,
|
||||
"case_number": cit.case_number_norm,
|
||||
"court": cit.court_prefix,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIER1_TIMEOUT_S) as client:
|
||||
resp = await client.post(
|
||||
f"{COURT_FETCH_SERVICE_URL}/fetch", json=payload, headers=headers
|
||||
)
|
||||
except httpx.ConnectError as e:
|
||||
raise _Tier1Unavailable(
|
||||
f"שירות-האחזור (legal-court-fetch-service) אינו זמין ב-"
|
||||
f"{COURT_FETCH_SERVICE_URL}: {e}"
|
||||
) from e
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"שירות-האחזור החזיר {resp.status_code}: {resp.text[:200]}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def fetch_and_ingest(
|
||||
citation: str, *, digest_id: UUID | None = None
|
||||
) -> dict:
|
||||
"""Classify a citation, fetch the verdict, ingest it, and record the job.
|
||||
|
||||
Idempotent on the canonical case number (INV-CF5): a case already fetched
|
||||
(job ``done``) is returned without re-fetching.
|
||||
"""
|
||||
cit = court_citation.classify(citation)
|
||||
|
||||
# ── skip: ועדת-ערר — never auto-fetched (INV-CF6). Surface as a gap. ──
|
||||
if cit.tier == "skip":
|
||||
await _open_gap(citation, reason="ועדת-ערר — לא ניתן לאחזור ציבורי (נדרש נבו)")
|
||||
return {"status": "skipped", "tier": "skip", "citation": citation,
|
||||
"reason": "appeals_committee — needs Nevo"}
|
||||
if cit.tier == "unknown" or not cit.case_number_norm:
|
||||
return {"status": "unrecognized", "citation": citation}
|
||||
|
||||
# ── idempotent job row ──
|
||||
job = await db.court_fetch_job_upsert(
|
||||
case_number_norm=cit.case_number_norm,
|
||||
citation_raw=citation,
|
||||
tier=cit.tier,
|
||||
court=cit.court_prefix,
|
||||
digest_id=digest_id,
|
||||
)
|
||||
if job.get("status") == "done":
|
||||
return {"status": "already_done", "job": job}
|
||||
if job.get("status") == "manual":
|
||||
return {"status": "awaiting_manual", "job": job}
|
||||
|
||||
job_id = UUID(str(job["id"]))
|
||||
await db.court_fetch_job_update(job_id, status="running", bump_attempts=True)
|
||||
|
||||
# ── fetch ──
|
||||
# Route by what the number lets us do, not just the court prefix: נט המשפט
|
||||
# (Tier 1) serves ALL courts — Supreme included — as long as the citation
|
||||
# carries a נט-format triple (file-month-year). Validated live on both
|
||||
# district (עת"מ 43830-12-24) and Supreme (בר"מ 72182-06-25). Only a serial-
|
||||
# only Supreme number (e.g. עע"מ 5886/24, no month) can't be looked up that
|
||||
# way → fall through to Tier 0 (supremedecisions).
|
||||
has_net_format = bool(cit.file_number and cit.month and cit.year)
|
||||
try:
|
||||
if has_net_format:
|
||||
res = await _fetch_tier1_admin(cit)
|
||||
if not res.get("ok"):
|
||||
raise RuntimeError(res.get("reason") or "אחזור נכשל")
|
||||
import base64
|
||||
content = base64.b64decode(res["content_b64"])
|
||||
filename = res.get("filename") or f"{cit.case_number_norm}.pdf"
|
||||
source_url = res.get("source_url", "")
|
||||
court = res.get("court") or cit.court_prefix
|
||||
elif cit.tier == "supreme":
|
||||
fetched = await fetch_supreme_verdict(
|
||||
citation=citation, case_number_norm=cit.case_number_norm
|
||||
)
|
||||
content, filename = fetched.content, fetched.filename
|
||||
source_url, court = fetched.source_url, fetched.court
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"מספר-תיק {cit.case_number_norm} אינו בפורמט נט-המשפט ואינו עליון — "
|
||||
"אין מסלול-אחזור ציבורי"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — any fetch error is recorded, never
|
||||
# left hanging in 'running' (INV-CF2). _record_failure escalates to
|
||||
# 'manual' after MAX_AUTONOMOUS_ATTEMPTS (INV-CF3).
|
||||
return await _record_failure(job_id, cit, citation, str(e))
|
||||
|
||||
# ── ingest into the canonical pipeline (INV-CF1) ──
|
||||
try:
|
||||
result = await _ingest_bytes(
|
||||
content=content, filename=filename, citation=citation,
|
||||
tier=cit.tier, court=court, source_url=source_url,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — recorded, never swallowed (INV-CF2)
|
||||
logger.exception("ingest failed for %s", cit.case_number_norm)
|
||||
return await _record_failure(job_id, cit, citation, f"קליטה נכשלה: {e}")
|
||||
|
||||
case_law_id = result.get("case_law_id")
|
||||
await db.court_fetch_job_update(
|
||||
job_id, status="done",
|
||||
case_law_id=UUID(str(case_law_id)) if case_law_id else None,
|
||||
source_url=source_url, error="",
|
||||
)
|
||||
# Close the digest gap (INV-DIG3): if this fetch traces back to a digest,
|
||||
# link it to the freshly-ingested ruling. Best-effort; never fails the job.
|
||||
link_digest_id = digest_id or job.get("digest_id")
|
||||
if case_law_id and link_digest_id:
|
||||
try:
|
||||
await db.link_digest_to_case_law(link_digest_id, UUID(str(case_law_id)))
|
||||
logger.info("linked digest %s → case_law %s", link_digest_id, case_law_id)
|
||||
except Exception:
|
||||
logger.warning("could not relink digest %s after fetch", link_digest_id)
|
||||
|
||||
# Close any open missing-precedent gap this fetch fills (the citation graph
|
||||
# often records the same ruling as a gap). Best-effort.
|
||||
if case_law_id:
|
||||
await _close_matching_gaps(cit.case_number_norm, UUID(str(case_law_id)))
|
||||
|
||||
return {"status": "done", "tier": cit.tier, "case_law_id": case_law_id,
|
||||
"citation": citation, "source_url": source_url, "ingest": result}
|
||||
|
||||
|
||||
async def _close_matching_gaps(case_number_norm: str, case_law_id: UUID) -> None:
|
||||
"""Close open missing_precedents whose citation matches the fetched case."""
|
||||
try:
|
||||
gaps = await db.list_missing_precedents(status="open", limit=500)
|
||||
for g in gaps:
|
||||
if court_citation.normalize_case_number(g.get("citation", "")) == case_number_norm:
|
||||
await db.close_missing_precedent(
|
||||
UUID(str(g["id"])), linked_case_law_id=case_law_id,
|
||||
status="closed", notes="נקלט אוטומטית דרך אחזור-פסיקה (X13)",
|
||||
)
|
||||
logger.info("closed missing_precedent %s", g["id"])
|
||||
except Exception:
|
||||
logger.warning("could not close gaps for %s", case_number_norm)
|
||||
|
||||
|
||||
# Politeness between consecutive court fetches in a drain (INV-CF4) — serial,
|
||||
# spaced. Mirrors the precedent-extraction queue cadence.
|
||||
_INTER_FETCH_COOLDOWN_S = float(os.environ.get("COURT_FETCH_DRAIN_COOLDOWN_S", "20"))
|
||||
|
||||
|
||||
async def drain_pending(limit: int = 10) -> dict:
|
||||
"""Process queued court-fetch jobs (status pending/failed) serially.
|
||||
|
||||
Drains the ``court_fetch_jobs`` queue the digest trigger fills — fetch +
|
||||
ingest each, link back to its digest. Serial with a cooldown (INV-CF4); a
|
||||
job that fails is recorded and retried next drain until it escalates to
|
||||
``manual`` (INV-CF3). Local-only (runs the ingest pipeline / claude CLI).
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
jobs = await db.court_fetch_job_list(status="pending", limit=limit)
|
||||
jobs += await db.court_fetch_job_list(status="failed", limit=limit)
|
||||
seen, queue = set(), []
|
||||
for j in jobs:
|
||||
k = j["case_number_norm"]
|
||||
if k not in seen:
|
||||
seen.add(k); queue.append(j)
|
||||
results = []
|
||||
for i, j in enumerate(queue[:limit]):
|
||||
if i:
|
||||
await asyncio.sleep(_INTER_FETCH_COOLDOWN_S)
|
||||
digest_id = j.get("digest_id")
|
||||
try:
|
||||
r = await fetch_and_ingest(j["citation_raw"], digest_id=digest_id)
|
||||
except Exception as e: # noqa: BLE001 — recorded per-job, never aborts the drain
|
||||
logger.exception("drain item failed: %s", j["case_number_norm"])
|
||||
r = {"status": "error", "citation": j["citation_raw"], "error": str(e)}
|
||||
results.append(r)
|
||||
done = sum(1 for r in results if r.get("status") in ("done", "already_done"))
|
||||
return {"processed": len(results), "done": done, "results": results}
|
||||
|
||||
|
||||
async def _record_failure(
|
||||
job_id: UUID, cit: court_citation.CourtCitation, citation: str, err: str
|
||||
) -> dict:
|
||||
"""Record a fetch/ingest failure; escalate to manual after N attempts (INV-CF3)."""
|
||||
job = await db.court_fetch_job_get(cit.case_number_norm)
|
||||
attempts = (job or {}).get("attempts", 1)
|
||||
if attempts >= MAX_AUTONOMOUS_ATTEMPTS:
|
||||
await db.court_fetch_job_update(job_id, status="manual", error=err)
|
||||
await _open_gap(
|
||||
citation,
|
||||
reason=f"אחזור אוטונומי נכשל ({attempts} נסיונות) — נדרשת הורדה ידנית. {err}",
|
||||
)
|
||||
logger.warning("court fetch escalated to manual: %s — %s", citation, err)
|
||||
return {"status": "manual", "citation": citation, "error": err,
|
||||
"attempts": attempts}
|
||||
await db.court_fetch_job_update(job_id, status="failed", error=err)
|
||||
logger.warning("court fetch failed (will retry): %s — %s", citation, err)
|
||||
return {"status": "failed", "citation": citation, "error": err,
|
||||
"attempts": attempts}
|
||||
|
||||
|
||||
async def _open_gap(citation: str, *, reason: str) -> None:
|
||||
"""Open a missing_precedent gap so the chair sees it (INV-CF2/CF3).
|
||||
|
||||
Best-effort + de-duplicated by the missing_precedents layer; a failure
|
||||
here is logged, never raised (it must not mask the original outcome).
|
||||
"""
|
||||
try:
|
||||
await db.create_missing_precedent(citation=citation, notes=reason)
|
||||
except Exception:
|
||||
logger.warning("could not open missing_precedent for %s", citation)
|
||||
197
mcp-server/src/legal_mcp/services/court_fetch_supreme.py
Normal file
197
mcp-server/src/legal_mcp/services/court_fetch_supreme.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Tier 0 — Supreme Court verdict fetcher (X13), via supremedecisions.court.gov.il.
|
||||
|
||||
Pulls a published Supreme Court verdict PDF from the **public** decisions portal
|
||||
— no smart-card, no CAPTCHA, no browser (pure httpx). Used for serial-format
|
||||
citations (בג"ץ/בר"מ/עע"מ NNNN/YY) that have no נט-format triple and so can't go
|
||||
through the Tier-1 נט-המשפט flow.
|
||||
|
||||
The portal is an AngularJS SPA over a small ASP.NET JSON API, reverse-engineered
|
||||
and validated live (2026-06-08 on בג"ץ 3483/05 → 75 KB PDF). The flow:
|
||||
|
||||
POST Home/SearchVerdicts
|
||||
body: {"document": {"Year": "YYYY", "CaseNum": "NNNN", "Month": {},
|
||||
"dateType": 1, "publishDate": 8,
|
||||
"SearchText": [<empty clause>],
|
||||
"OldMainNumFormat": true}, "lan": 1}
|
||||
→ {"data": [{Path, FileName, CaseName, Type, Pages, VerdictDt, ...}, ...]}
|
||||
GET Home/Download?path=<Path>&fileName=<FileName>&type=4 → the verdict PDF
|
||||
|
||||
Two things are required to get JSON instead of an F5 WAF block (verified):
|
||||
* the **X-Requested-With: XMLHttpRequest** header on every AJAX call;
|
||||
* a **complete** browser header set (UA + Accept + Accept-Language).
|
||||
|
||||
A case can have many documents (interim החלטות + the final פסק דין). We pick the
|
||||
verdict: prefer a record whose Type contains "פסק דין", else the most-paginated /
|
||||
latest one. Politeness (INV-CF4): serial, with a cooldown.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as _dt
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE = "https://supremedecisions.court.gov.il"
|
||||
|
||||
_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "he-IL,he;q=0.9,en;q=0.8",
|
||||
"X-Requested-With": "XMLHttpRequest", # required — F5 WAF blocks AJAX without it
|
||||
"Referer": _BASE + "/",
|
||||
}
|
||||
|
||||
_REQUEST_TIMEOUT_S = float(os.environ.get("COURT_FETCH_HTTP_TIMEOUT_S", "30"))
|
||||
_INTER_REQUEST_COOLDOWN_S = float(os.environ.get("COURT_FETCH_COOLDOWN_S", "2"))
|
||||
_DOC_TYPE_PDF = "4"
|
||||
|
||||
# Empty search clause the portal expects inside the document.
|
||||
_EMPTY_CLAUSE = {
|
||||
"Text": "", "textOperator": 1, "option": 2, "Inverted": False,
|
||||
"Synonym": False, "NearDistance": 3, "MatchOrder": False,
|
||||
}
|
||||
|
||||
|
||||
class FetchedVerdict:
|
||||
"""A downloaded verdict file held in memory, ready for ingest."""
|
||||
|
||||
def __init__(self, content: bytes, filename: str, source_url: str,
|
||||
court: str = "בית המשפט העליון", case_name: str = ""):
|
||||
self.content = content
|
||||
self.filename = filename
|
||||
self.source_url = source_url
|
||||
self.court = court
|
||||
self.case_name = case_name
|
||||
|
||||
|
||||
class SupremeFetchError(RuntimeError):
|
||||
"""The public portal returned an unexpected shape / no document. Carries a
|
||||
Hebrew reason for the job row (INV-CF2)."""
|
||||
|
||||
|
||||
def _four_digit_year(yy: str) -> str:
|
||||
"""2-digit citation year → 4-digit. Pivot on the current year: a 2-digit
|
||||
value above (this year + 4) is last century. e.g. 05→2005, 87→1987, 16→2016."""
|
||||
yy = re.sub(r"\D", "", yy or "")
|
||||
if len(yy) == 4:
|
||||
return yy
|
||||
if len(yy) != 2:
|
||||
return yy
|
||||
n = int(yy)
|
||||
cutoff = (_dt.date.today().year % 100) + 4
|
||||
return f"20{yy}" if n <= cutoff else f"19{yy}"
|
||||
|
||||
|
||||
def _parse_serial(case_number_norm: str, citation: str) -> tuple[str, str]:
|
||||
"""Extract (CaseNum, YYYY) from a serial citation like 'בג"ץ 3483/05'.
|
||||
|
||||
Works off the normalized number (e.g. '3483-05') with the raw citation as a
|
||||
fallback. Raises SupremeFetchError if it can't find a NNNN/YY pair.
|
||||
"""
|
||||
m = re.search(r"(\d{1,5})[-/](\d{2,4})\b", case_number_norm or "")
|
||||
if not m:
|
||||
m = re.search(r"(\d{1,5})/(\d{2,4})", citation or "")
|
||||
if not m:
|
||||
raise SupremeFetchError(
|
||||
f"לא ניתן לפרק '{citation}' למספר-תיק/שנה (פורמט עליון סדרתי)"
|
||||
)
|
||||
return m.group(1), _four_digit_year(m.group(2))
|
||||
|
||||
|
||||
def _dt_key(r: dict) -> int:
|
||||
m = re.search(r"/Date\((\d+)", str(r.get("VerdictDt") or ""))
|
||||
return int(m.group(1)) if m else 0
|
||||
|
||||
|
||||
def _rank_candidates(records: list[dict]) -> list[dict]:
|
||||
"""Order a case's documents by how good a corpus target each is, best first.
|
||||
|
||||
Preference: the reasoned ruling (Type contains 'פסק') over interim החלטות;
|
||||
then more pages (substantive over one-liners); then most recent. We return
|
||||
a *ranked list*, not one pick, because the formally-labeled פסק-דין is
|
||||
sometimes a published-report ('s'-prefix) file that the free Download
|
||||
endpoint blocks (WAF) — the caller tries each until one downloads as a PDF.
|
||||
Records without a Path/FileName are dropped.
|
||||
"""
|
||||
usable = [r for r in records if r.get("Path") and r.get("FileName")]
|
||||
|
||||
def _score(r: dict) -> tuple:
|
||||
is_verdict = 1 if "פסק" in str(r.get("Type") or "") else 0
|
||||
return (is_verdict, int(r.get("Pages") or 0), _dt_key(r))
|
||||
|
||||
return sorted(usable, key=_score, reverse=True)
|
||||
|
||||
|
||||
async def fetch_supreme_verdict(
|
||||
*, citation: str, case_number_norm: str
|
||||
) -> FetchedVerdict:
|
||||
"""Fetch a Supreme Court verdict PDF by serial citation. Raises on failure."""
|
||||
case_num, yyyy = _parse_serial(case_number_norm, citation)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
http2=False, headers=_HEADERS, timeout=_REQUEST_TIMEOUT_S,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
document = {
|
||||
"Year": yyyy, "CaseNum": case_num, "Month": {},
|
||||
"dateType": 1, "publishDate": 8, "SearchText": [dict(_EMPTY_CLAUSE)],
|
||||
"OldMainNumFormat": True,
|
||||
}
|
||||
try:
|
||||
await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S)
|
||||
resp = await client.post(
|
||||
f"{_BASE}/Home/SearchVerdicts", json={"document": document, "lan": 1}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except httpx.HTTPError as e:
|
||||
raise SupremeFetchError(f"חיפוש בפורטל העליון נכשל עבור {citation}: {e}") from e
|
||||
except ValueError as e:
|
||||
raise SupremeFetchError(f"תשובת-חיפוש לא-JSON מהפורטל עבור {citation}") from e
|
||||
|
||||
records = payload.get("data") if isinstance(payload, dict) else None
|
||||
candidates = _rank_candidates(records or [])
|
||||
if not candidates:
|
||||
raise SupremeFetchError(
|
||||
f"לא נמצא מסמך-פסק עבור {citation} בפורטל העליון "
|
||||
f"(תיק {case_num}/{yyyy[-2:]}; ייתכן שאינו פורסם או טרם דיגיטציה)."
|
||||
)
|
||||
|
||||
# Try documents best-first until one downloads as a real PDF. The
|
||||
# formally-labeled פסק-דין is sometimes a published-report file the free
|
||||
# Download endpoint blocks (WAF) — fall back to the next substantive doc.
|
||||
last_reason = ""
|
||||
for rec in candidates[:6]:
|
||||
path, fname = str(rec["Path"]), str(rec["FileName"])
|
||||
qs = urllib.parse.urlencode(
|
||||
{"path": path, "fileName": fname, "type": _DOC_TYPE_PDF}
|
||||
)
|
||||
try:
|
||||
await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S)
|
||||
dl = await client.get(f"{_BASE}/Home/Download?{qs}")
|
||||
dl.raise_for_status()
|
||||
except httpx.HTTPError as e:
|
||||
last_reason = f"הורדה נכשלה ({e})"
|
||||
continue
|
||||
if dl.content[:4] == b"%PDF":
|
||||
return FetchedVerdict(
|
||||
content=dl.content,
|
||||
filename=f"{case_number_norm}.pdf",
|
||||
source_url=f"{_BASE}/Home/Download?{qs}",
|
||||
case_name=str(rec.get("CaseName") or ""),
|
||||
)
|
||||
last_reason = f"מסמך {fname} חסום/לא-PDF ({len(dl.content)}B)"
|
||||
|
||||
raise SupremeFetchError(
|
||||
f"אף מסמך של {citation} לא ירד כ-PDF ({len(candidates)} מועמדים) — {last_reason}"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
422
mcp-server/src/legal_mcp/services/digest_library.py
Normal file
422
mcp-server/src/legal_mcp/services/digest_library.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""Orchestrator for the Digests radar (X12).
|
||||
|
||||
A digest ("כל יום" daily one-pager) is a SECONDARY source that POINTS at a
|
||||
ruling — it is never cited in a decision (INV-DIG1) and never enters the
|
||||
precedent/halacha pipeline (INV-DIG2). Ingest reuses only ATOMIC services
|
||||
(extract_text, embeddings), NOT the canonical ``ingest.ingest_document``.
|
||||
|
||||
Two intake paths share one enrichment core:
|
||||
|
||||
- ``ingest_digest`` (local/MCP, e.g. batch script) — does everything
|
||||
synchronously: stage → extract_text → create →
|
||||
LLM enrich → embed → autolink → completed.
|
||||
- ``create_pending_digest`` (CONTAINER-SAFE — the web upload) — stage →
|
||||
extract_text → create row with status='pending'.
|
||||
No LLM, no embedding. ``process_pending_digests``
|
||||
(local/MCP) drains the queue and enriches.
|
||||
|
||||
claude_session rule: ``digest_metadata_extractor`` (local CLI) is imported
|
||||
LAZILY inside the enrichment core only, so this module stays import-safe from
|
||||
the FastAPI container for create_pending / search / list / link / delete
|
||||
(DB + voyage only — voyage embedding only runs in the local enrich path).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import db, embeddings, extractor, ingest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ProgressCb = Callable[[str, int, str], Awaitable[None]]
|
||||
|
||||
DIGEST_LIBRARY_DIR = Path(config.DATA_DIR) / "digests"
|
||||
|
||||
_VALID_PRACTICE_AREAS = frozenset(
|
||||
{"", "rishuy_uvniya", "betterment_levy", "compensation_197"}
|
||||
)
|
||||
|
||||
|
||||
async def _noop_progress(_status: str, _percent: int, _msg: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_date(v) -> date | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if isinstance(v, date):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return date.fromisoformat(v[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _embedding_text(row: dict) -> str:
|
||||
"""The single vector indexes the digest as an atomic discovery unit."""
|
||||
parts = [
|
||||
row.get("concept_tag", ""),
|
||||
row.get("headline_holding", ""),
|
||||
row.get("summary", ""),
|
||||
row.get("analysis_text", ""),
|
||||
]
|
||||
return "\n".join(p for p in parts if p).strip()
|
||||
|
||||
|
||||
async def try_autolink(digest_id: UUID | str, underlying_citation: str) -> str | None:
|
||||
"""Best-effort link of a digest to the underlying ruling in case_law
|
||||
(INV-DIG3). Returns the case_law_id (str) if linked, else None. Never raises."""
|
||||
citation = (underlying_citation or "").strip()
|
||||
if not citation:
|
||||
return None
|
||||
try:
|
||||
match = await db.find_case_law_by_citation_fuzzy(citation)
|
||||
except Exception as e:
|
||||
logger.warning("digest try_autolink lookup failed for %r: %s", citation, e)
|
||||
return None
|
||||
if not match:
|
||||
# Gap (INV-DIG3): the underlying ruling isn't in the corpus. If it's a
|
||||
# court verdict (not ועדת-ערר), enqueue an X13 auto-fetch job so the gap
|
||||
# is actionable instead of silently dropped (INV-CF2). Never raises.
|
||||
await _enqueue_court_fetch(digest_id, citation)
|
||||
return None
|
||||
await db.link_digest_to_case_law(digest_id, match["id"])
|
||||
return str(match["id"])
|
||||
|
||||
|
||||
async def _enqueue_court_fetch(digest_id: UUID | str, citation: str) -> None:
|
||||
"""Queue an X13 court-verdict fetch for an unlinked digest citation.
|
||||
|
||||
Court rulings (supreme/admin) → a ``court_fetch_jobs`` row drained later by
|
||||
``court_fetch_drain``. ועדת-ערר (skip) is left alone — it needs Nevo and is
|
||||
surfaced through the normal missing-precedent path, not auto-fetch.
|
||||
"""
|
||||
try:
|
||||
from legal_mcp.services import court_citation
|
||||
cit = court_citation.classify(citation)
|
||||
if cit.tier not in ("supreme", "admin"):
|
||||
return
|
||||
await db.court_fetch_job_upsert(
|
||||
case_number_norm=cit.case_number_norm,
|
||||
citation_raw=citation,
|
||||
tier=cit.tier,
|
||||
court=cit.court_prefix,
|
||||
digest_id=UUID(str(digest_id)),
|
||||
)
|
||||
logger.info("digest %s: enqueued court-fetch for %r (tier=%s)",
|
||||
digest_id, citation, cit.tier)
|
||||
except Exception as e: # never break digest ingest
|
||||
logger.warning("digest court-fetch enqueue failed for %r: %s", citation, e)
|
||||
|
||||
|
||||
# ── Container-safe creation (web upload) — no LLM, no embedding ──────
|
||||
|
||||
async def create_pending_digest(
|
||||
*,
|
||||
file_path: str | Path,
|
||||
yomon_number: str = "",
|
||||
digest_date: date | str | None = None,
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
subject_tags: list[str] | None = None,
|
||||
progress: ProgressCb | None = None,
|
||||
) -> dict:
|
||||
"""Stage the file, extract text (PyMuPDF — container-safe), and create a
|
||||
digest row with extraction_status='pending'. The LLM metadata extraction,
|
||||
embedding, and autolink are deferred to ``process_pending_digests`` (local).
|
||||
|
||||
Returns {status, digest_id, extraction_status} or {status:'exists', ...}.
|
||||
Idempotent on content_hash (INV-G3).
|
||||
"""
|
||||
progress = progress or _noop_progress
|
||||
if practice_area and practice_area not in _VALID_PRACTICE_AREAS:
|
||||
raise ValueError(f"invalid practice_area: {practice_area!r}")
|
||||
src = Path(file_path)
|
||||
if not src.exists():
|
||||
raise ValueError(f"file not found: {file_path}")
|
||||
|
||||
await progress("staging", 10, "מעתיק קובץ")
|
||||
staged = await ingest._stage_file(src, DIGEST_LIBRARY_DIR, "incoming")
|
||||
rel_path = str(staged.relative_to(config.DATA_DIR)) \
|
||||
if str(staged).startswith(str(config.DATA_DIR)) else str(staged)
|
||||
|
||||
await progress("extracting_text", 50, "מחלץ טקסט")
|
||||
raw_text, _pc, _off = await extractor.extract_text(str(staged))
|
||||
raw_text = (raw_text or "").strip()
|
||||
if not raw_text:
|
||||
raise ValueError("no text extracted from digest")
|
||||
|
||||
content_hash = db._content_hash(raw_text)
|
||||
existing = await db.get_digest_by_content_hash(content_hash)
|
||||
if existing:
|
||||
await progress("completed", 100, "יומון זהה כבר קיים")
|
||||
return {"status": "exists", "digest_id": existing["id"],
|
||||
"extraction_status": existing.get("extraction_status")}
|
||||
|
||||
record = await db.create_digest(
|
||||
analysis_text=raw_text,
|
||||
yomon_number=yomon_number.strip(),
|
||||
digest_date=_coerce_date(digest_date),
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype.strip(),
|
||||
subject_tags=list(subject_tags) if subject_tags else [],
|
||||
source_document_path=rel_path,
|
||||
extraction_status="pending",
|
||||
)
|
||||
await progress("queued", 100, "ממתין לעיבוד מקומי (LLM)")
|
||||
return {"status": "pending", "digest_id": record["id"],
|
||||
"extraction_status": "pending"}
|
||||
|
||||
|
||||
# ── Local enrichment core (LLM + embed + autolink) ──────────────────
|
||||
|
||||
async def enrich_digest(digest_id: UUID | str, progress: ProgressCb | None = None) -> dict:
|
||||
"""Run LLM metadata extraction over a digest's analysis_text, fill ONLY
|
||||
empty fields (preserve user-supplied values), embed, autolink, complete.
|
||||
|
||||
**MCP-tool-only path** (uses the local LLM extractor). Idempotent.
|
||||
"""
|
||||
progress = progress or _noop_progress
|
||||
row = await db.get_digest(digest_id)
|
||||
if not row:
|
||||
raise ValueError("digest not found")
|
||||
analysis = (row.get("analysis_text") or "").strip()
|
||||
if not analysis:
|
||||
await db.update_digest(digest_id, extraction_status="failed")
|
||||
return {"status": "no_text", "digest_id": str(digest_id)}
|
||||
|
||||
await db.update_digest(digest_id, extraction_status="processing")
|
||||
await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (LLM)")
|
||||
from legal_mcp.services import digest_metadata_extractor
|
||||
extracted = await digest_metadata_extractor.extract(analysis)
|
||||
|
||||
# Fill only empty fields (preserve user-supplied values from the form).
|
||||
fields: dict = {}
|
||||
for key in ("yomon_number", "concept_tag", "headline_holding", "summary",
|
||||
"underlying_citation", "underlying_court", "underlying_judge",
|
||||
"practice_area", "appeal_subtype"):
|
||||
if not (row.get(key) or "").strip() and extracted.get(key):
|
||||
fields[key] = extracted[key]
|
||||
if row.get("digest_date") is None and extracted.get("digest_date"):
|
||||
fields["digest_date"] = extracted["digest_date"]
|
||||
if row.get("underlying_date") is None and extracted.get("underlying_date"):
|
||||
fields["underlying_date"] = extracted["underlying_date"]
|
||||
if not (row.get("subject_tags") or []) and extracted.get("subject_tags"):
|
||||
fields["subject_tags"] = extracted["subject_tags"]
|
||||
# digest_kind classifies the issue (decision vs announcement). A successful
|
||||
# extraction (any field returned) must end with a non-empty kind — that is the
|
||||
# signal the drain self-heal uses to tell "enriched" from "failed". If the
|
||||
# model omitted it, infer: a ruling citation → decision, else announcement.
|
||||
if extracted and not (row.get("digest_kind") or "").strip():
|
||||
kind = extracted.get("digest_kind")
|
||||
if kind not in ("decision", "announcement", "other"):
|
||||
cite = fields.get("underlying_citation") or row.get("underlying_citation") or ""
|
||||
kind = "decision" if cite.strip() else "announcement"
|
||||
fields["digest_kind"] = kind
|
||||
|
||||
if fields:
|
||||
try:
|
||||
await db.update_digest(digest_id, **fields)
|
||||
except Exception as e:
|
||||
# The same yomon issue can arrive as two different PDFs (re-sent /
|
||||
# forwarded twice → different bytes → content_hash dedup misses it),
|
||||
# but the yomon_number is unique. The extracted number then collides
|
||||
# on uq_digests_yomon_number. This row is a duplicate of an already-
|
||||
# ingested yomon → drop it so it isn't retried forever by the cron.
|
||||
if "uq_digests_yomon_number" in str(e):
|
||||
await db.delete_digest(digest_id)
|
||||
logger.info(
|
||||
"digest %s is a duplicate yomon (%s) — deleted",
|
||||
digest_id, fields.get("yomon_number"),
|
||||
)
|
||||
return {"status": "duplicate", "digest_id": str(digest_id),
|
||||
"yomon_number": fields.get("yomon_number")}
|
||||
raise
|
||||
merged = await db.get_digest(digest_id)
|
||||
|
||||
await progress("embedding", 75, "מחשב embedding")
|
||||
emb_text = _embedding_text(merged)
|
||||
if emb_text:
|
||||
try:
|
||||
vecs = await embeddings.embed_texts([emb_text], input_type="document")
|
||||
if vecs:
|
||||
await db.store_digest_embedding(digest_id, vecs[0])
|
||||
except Exception as e: # surfaced, not swallowed (§6)
|
||||
logger.warning("digest embedding failed for %s: %s", digest_id, e)
|
||||
|
||||
await progress("linking", 90, "מנסה לקשר לפסק המקורי")
|
||||
linked_id = None
|
||||
if not merged.get("linked_case_law_id"):
|
||||
linked_id = await try_autolink(digest_id, merged.get("underlying_citation", ""))
|
||||
|
||||
await db.update_digest(digest_id, extraction_status="completed")
|
||||
await progress("completed", 100, "הושלם")
|
||||
return {
|
||||
"status": "completed",
|
||||
"digest_id": str(digest_id),
|
||||
"yomon_number": merged.get("yomon_number", ""),
|
||||
"underlying_citation": merged.get("underlying_citation", ""),
|
||||
"linked_case_law_id": merged.get("linked_case_law_id") or linked_id,
|
||||
"fields_filled": sorted(fields.keys()),
|
||||
}
|
||||
|
||||
|
||||
async def process_pending_digests(limit: int = 20) -> dict:
|
||||
"""Drain the digest extraction queue (rows stamped extraction_status='pending'
|
||||
by the web upload). Local/MCP only — runs the LLM enrichment per row.
|
||||
Sequential (avoids LLM rate-limit storms), mirrors process_pending_extractions."""
|
||||
pending = await db.list_pending_digests(limit=limit)
|
||||
if not pending:
|
||||
return {"status": "no_pending", "processed": 0, "results": []}
|
||||
results = []
|
||||
processed = 0
|
||||
for row in pending:
|
||||
did = row["id"]
|
||||
try:
|
||||
res = await enrich_digest(did)
|
||||
processed += 1
|
||||
results.append({"digest_id": str(did), "status": res.get("status"),
|
||||
"linked": bool(res.get("linked_case_law_id"))})
|
||||
except Exception as e:
|
||||
logger.exception("process_pending_digests failed for %s: %s", did, e)
|
||||
try:
|
||||
await db.update_digest(did, extraction_status="failed")
|
||||
except Exception:
|
||||
logger.exception("could not mark digest %s failed", did)
|
||||
results.append({"digest_id": str(did), "status": "failed", "error": str(e)})
|
||||
return {"status": "completed", "processed": processed,
|
||||
"total_pending": len(pending), "results": results}
|
||||
|
||||
|
||||
# ── Full synchronous ingest (local/MCP, e.g. batch script) ──────────
|
||||
|
||||
async def ingest_digest(
|
||||
*,
|
||||
file_path: str | Path,
|
||||
yomon_number: str = "",
|
||||
digest_date: date | str | None = None,
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
subject_tags: list[str] | None = None,
|
||||
progress: ProgressCb | None = None,
|
||||
) -> dict:
|
||||
"""Ingest one digest synchronously. **MCP-tool-only** (uses the LLM).
|
||||
|
||||
Creates the row (with any user-supplied values) then enriches in place.
|
||||
Idempotent on content_hash (INV-G3).
|
||||
"""
|
||||
progress = progress or _noop_progress
|
||||
created = await create_pending_digest(
|
||||
file_path=file_path, yomon_number=yomon_number, digest_date=digest_date,
|
||||
practice_area=practice_area, appeal_subtype=appeal_subtype,
|
||||
subject_tags=subject_tags, progress=progress,
|
||||
)
|
||||
if created.get("status") == "exists":
|
||||
return created
|
||||
digest_id = created["digest_id"]
|
||||
enriched = await enrich_digest(digest_id, progress=progress)
|
||||
return enriched
|
||||
|
||||
|
||||
# ── Linking (INV-DIG3) ──────────────────────────────────────────────
|
||||
|
||||
async def link_digest(digest_id: UUID | str, case_law_id: UUID | str) -> dict:
|
||||
"""Manually link a digest to an underlying ruling (INV-DIG3). Idempotent."""
|
||||
digest = await db.get_digest(digest_id)
|
||||
if not digest:
|
||||
raise ValueError("digest not found")
|
||||
ruling = await db.get_case_law(
|
||||
case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
|
||||
)
|
||||
if not ruling:
|
||||
raise ValueError("case_law not found")
|
||||
updated = await db.link_digest_to_case_law(digest_id, case_law_id)
|
||||
return {
|
||||
"linked": True,
|
||||
"digest_id": str(digest_id),
|
||||
"case_law_id": str(case_law_id),
|
||||
"case_number": ruling.get("case_number"),
|
||||
"digest": updated,
|
||||
}
|
||||
|
||||
|
||||
async def relink_digest(digest_id: UUID | str) -> dict:
|
||||
"""Re-run autolink for an unlinked digest. No-op if already linked / no match."""
|
||||
digest = await db.get_digest(digest_id)
|
||||
if not digest:
|
||||
raise ValueError("digest not found")
|
||||
if digest.get("linked_case_law_id"):
|
||||
return {"linked": True, "digest_id": str(digest_id),
|
||||
"case_law_id": digest["linked_case_law_id"], "changed": False}
|
||||
linked_id = await try_autolink(digest_id, digest.get("underlying_citation", ""))
|
||||
return {
|
||||
"linked": linked_id is not None,
|
||||
"digest_id": str(digest_id),
|
||||
"case_law_id": linked_id,
|
||||
"changed": linked_id is not None,
|
||||
}
|
||||
|
||||
|
||||
async def unlink_digest(digest_id: UUID | str) -> dict:
|
||||
"""Clear a digest's link to the underlying ruling."""
|
||||
updated = await db.link_digest_to_case_law(digest_id, None)
|
||||
if updated is None:
|
||||
raise ValueError("digest not found")
|
||||
return {"unlinked": True, "digest_id": str(digest_id)}
|
||||
|
||||
|
||||
# ── Read / search (container-safe: DB + voyage) ─────────────────────
|
||||
|
||||
async def search_digests(
|
||||
query: str,
|
||||
practice_area: str = "",
|
||||
subject_tag: str = "",
|
||||
concept_tag: str = "",
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""Semantic search over the digests radar. Container-safe (voyage + DB)."""
|
||||
if not query.strip():
|
||||
return []
|
||||
query_vec = await embeddings.embed_query(query)
|
||||
return await db.search_digests_semantic(
|
||||
query_embedding=query_vec,
|
||||
practice_area=practice_area,
|
||||
subject_tag=subject_tag,
|
||||
concept_tag=concept_tag,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
async def get_digest(digest_id: UUID | str) -> dict | None:
|
||||
return await db.get_digest(digest_id)
|
||||
|
||||
|
||||
async def list_digests(
|
||||
practice_area: str = "",
|
||||
concept_tag: str = "",
|
||||
linked: bool | None = None,
|
||||
search: str = "",
|
||||
publication: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
return await db.list_digests(
|
||||
practice_area=practice_area, concept_tag=concept_tag, linked=linked,
|
||||
search=search, publication=publication, limit=limit, offset=offset,
|
||||
)
|
||||
|
||||
|
||||
async def update_digest(digest_id: UUID | str, **fields) -> dict | None:
|
||||
return await db.update_digest(digest_id, **fields)
|
||||
|
||||
|
||||
async def delete_digest(digest_id: UUID | str) -> bool:
|
||||
return await db.delete_digest(digest_id)
|
||||
151
mcp-server/src/legal_mcp/services/digest_metadata_extractor.py
Normal file
151
mcp-server/src/legal_mcp/services/digest_metadata_extractor.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Auto-extract catalog metadata from a "כל יום" daily digest (X12).
|
||||
|
||||
A digest is a one-page secondary summary (Ofer Toister) of a single ruling.
|
||||
This module reads its raw text and asks the local Claude CLI to extract the
|
||||
fields the radar needs: yomon number, concept tag, headline holding, a short
|
||||
summary, the UNDERLYING ruling's citation (the critical bridge field — INV-DIG3),
|
||||
its court / date / judge, practice area and subject tags.
|
||||
|
||||
claude_session rule: this module imports ``claude_session`` (the local CLI),
|
||||
so it is **MCP-tool-only** — never import it from the FastAPI container. It is
|
||||
pulled in lazily inside ``digest_library.ingest_digest`` only.
|
||||
|
||||
Unlike ``precedent_metadata_extractor`` (which patches a DB row), this returns
|
||||
a plain dict from raw text; ``digest_library`` decides how to merge/store it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date as date_type
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.config import parse_llm_json
|
||||
from legal_mcp.services import claude_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_VALID_PRACTICE_AREAS = {"", "rishuy_uvniya", "betterment_levy", "compensation_197"}
|
||||
|
||||
|
||||
# Concatenated with f-strings at call time, NOT .format() — the JSON example
|
||||
# below contains '{' / '}' which str.format would treat as placeholders and
|
||||
# crash (same trap documented in precedent_metadata_extractor).
|
||||
DIGEST_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. לפניך "יומון" — סיכום עמוד-אחד של משרד עפר טויסטר (עלון "כל יום")
|
||||
על **החלטה/פסק דין** אחד, או על **עדכון/הודעה** (חקיקה, נוהל, הודעת-תכנון, ברכת-שנה) בתחום תכנון ובנייה / היטל השבחה / פיצויים (ס' 197). חלץ ממנו מטא-דאטה לקטלוג.
|
||||
|
||||
**אל תמציא** — שדה שלא מופיע בטקסט → השאר ריק (מחרוזת ריקה / מערך ריק).
|
||||
|
||||
## פלט נדרש
|
||||
החזר JSON אחד (object — לא array), ללא markdown וללא הסברים:
|
||||
|
||||
{
|
||||
"digest_kind": "סווג את הגיליון: 'decision' = סיכום פסק דין/החלטה (יש מראה-מקום בתחתית) · 'announcement' = עדכון/הודעה ללא הכרעה (חקיקה, נוהל, הודעת-תכנון, ברכה) · 'other' = אחר. **חובה למלא תמיד.**",
|
||||
"yomon_number": "מספר היומון מהכותרת ('יומון מס' 5163' → '5163'). ספרות בלבד. אם אין — ריק.",
|
||||
"digest_date_iso": "YYYY-MM-DD — תאריך גיליון היומון (בכותרת, למשל '7 ביוני 2026' → '2026-06-07').",
|
||||
"concept_tag": "תג-המושג/הכותרת בראש העמוד (למשל 'שיקול הדעת המצומצם', 'Cherry-picking', או 'עדכונים לשנה החדשה' בעדכון). ביטוי קצר אחד. **חלץ תמיד — קיים לכל סוג גיליון.**",
|
||||
"headline_holding": "הכותרת המודגשת מתחת לתג — משפט אחד שמסכם את עיקר הגיליון (מה נקבע בהחלטה, או נושא העדכון). **חלץ תמיד.**",
|
||||
"summary": "תקציר ניטרלי 2-3 משפטים בגוף שלישי: בהחלטה — מה הייתה השאלה ומה הוכרע; בעדכון — מה תוכן/משמעות העדכון. בלי שיפוט. **חלץ תמיד.**",
|
||||
"underlying_citation": "**רק ל-decision** — מראה-המקום של פסק הדין/ההחלטה המקורי, כפי שמופיע בתחתית היומון, מילה במילה (למשל 'עת\\"מ 46111-12-22 יכין-אפק בע\\"מ נ' הוועדה המחוזית'). בעדכון/הודעה — ריק. זהו השדה הקריטי ל-decision — חלץ אותו במלואו ובדיוק.",
|
||||
"underlying_court": "הערכאה שנתנה את פסק הדין המקורי (למשל 'בית המשפט לעניינים מנהליים מרכז-לוד', 'ועדת הערר מחוז ירושלים').",
|
||||
"underlying_date_iso": "YYYY-MM-DD — תאריך מתן פסק הדין/ההחלטה המקורי (לרוב 'ניתן ביום DD.M.YY' בתחתית). שים לב: זה שונה מתאריך גיליון היומון!",
|
||||
"underlying_judge": "שם השופט/ת או יו\\"ר ההרכב שנתן את פסק הדין המקורי (למשל 'יעל טויסטר ישראלי'). בלי תארים ('עו\\"ד', 'כב' השופט').",
|
||||
"practice_area": "אחד מ-3: 'rishuy_uvniya' (רישוי ובנייה/הקלות/שימוש חורג) / 'betterment_levy' (היטל השבחה) / 'compensation_197' (פיצויים ס'197). אם לא ברור — ריק.",
|
||||
"appeal_subtype": "תת-סוג קצר אם בולט (למשל 'הקלה', 'שיקול דעת הוועדה', 'מימוש במכר'). אחרת ריק.",
|
||||
"subject_tags": ["3-7 תגיות בעברית snake_case (שיקול_דעת, הקלה, ועדה_מחוזית, היטל_השבחה, ...)"]
|
||||
}
|
||||
|
||||
## כללי איכות
|
||||
1. **digest_kind** — חובה. אם יש מראה-מקום של פסק דין/החלטה בתחתית → 'decision'. אם זה עדכון/הודעה/נוהל/ברכה ללא הכרעה → 'announcement'.
|
||||
2. **concept_tag / headline_holding / summary** — חלץ **תמיד**, לכל סוג גיליון (גם עדכון). אלה לא ייחודיים להחלטות.
|
||||
3. **underlying_citation** — רק ל-decision; הוא הגשר לפסק הדין. בעדכון — השאר ריק (זה תקין, לא חוסר).
|
||||
4. **הבחן בין שני התאריכים**: digest_date_iso = תאריך גיליון היומון (בכותרת); underlying_date_iso = מועד מתן פסק הדין (בתחתית, 'ניתן ביום...'). אל תבלבל.
|
||||
5. **subject_tags** — snake_case, תחום ועדת ערר תכנון ובנייה בלבד.
|
||||
6. אם רכיב לא מופיע בבירור — השאר את אותו שדה ריק. אל תנחש.
|
||||
"""
|
||||
|
||||
|
||||
def _norm_str(result: dict, key: str) -> str:
|
||||
v = result.get(key)
|
||||
return v.strip() if isinstance(v, str) else ""
|
||||
|
||||
|
||||
def _norm_date(result: dict, key: str) -> date_type | None:
|
||||
v = result.get(key)
|
||||
if not isinstance(v, str) or not v.strip():
|
||||
return None
|
||||
try:
|
||||
return date_type.fromisoformat(v.strip()[:10])
|
||||
except ValueError:
|
||||
logger.debug("digest_metadata_extractor: ignoring invalid %s=%r", key, v)
|
||||
return None
|
||||
|
||||
|
||||
async def extract(raw_text: str, model: str | None = None) -> dict:
|
||||
"""Extract digest metadata from raw text. Returns a dict (never raises).
|
||||
|
||||
Keys: yomon_number, digest_date (date|None), concept_tag, headline_holding,
|
||||
summary, underlying_citation, underlying_court, underlying_date (date|None),
|
||||
underlying_judge, practice_area, appeal_subtype, subject_tags (list[str]).
|
||||
Missing/invalid fields are omitted so the caller's merge keeps user values.
|
||||
|
||||
Model: defaults to ``config.DIGEST_EXTRACT_MODEL`` (Sonnet — this is a
|
||||
high-volume, simple extraction; no need for Opus). Override per-call via
|
||||
``model``.
|
||||
"""
|
||||
text = (raw_text or "").strip()
|
||||
if not text:
|
||||
return {}
|
||||
|
||||
user_msg = f"--- תחילת היומון ---\n{text}\n--- סוף היומון ---"
|
||||
try:
|
||||
result = await claude_session.query_json(
|
||||
user_msg, system=DIGEST_EXTRACTION_PROMPT,
|
||||
model=(model or config.DIGEST_EXTRACT_MODEL or None),
|
||||
tools="", # pure text→JSON: disable tools so the model never emits
|
||||
# stop_reason=tool_use and trips --max-turns (error_max_turns).
|
||||
)
|
||||
except Exception as e: # surfaced as warning, not swallowed silently (§6)
|
||||
logger.warning("digest_metadata_extractor: query failed: %s", e)
|
||||
return {}
|
||||
|
||||
if not isinstance(result, dict):
|
||||
logger.warning(
|
||||
"digest_metadata_extractor: expected dict, got %s",
|
||||
type(result).__name__,
|
||||
)
|
||||
return {}
|
||||
|
||||
out: dict = {}
|
||||
for key in (
|
||||
"yomon_number", "concept_tag", "headline_holding", "summary",
|
||||
"underlying_citation", "underlying_court", "underlying_judge",
|
||||
"appeal_subtype",
|
||||
):
|
||||
s = _norm_str(result, key)
|
||||
if s:
|
||||
out[key] = s
|
||||
|
||||
kind = _norm_str(result, "digest_kind").lower()
|
||||
if kind in ("decision", "announcement", "other"):
|
||||
out["digest_kind"] = kind
|
||||
|
||||
dd = _norm_date(result, "digest_date_iso")
|
||||
if dd is not None:
|
||||
out["digest_date"] = dd
|
||||
ud = _norm_date(result, "underlying_date_iso")
|
||||
if ud is not None:
|
||||
out["underlying_date"] = ud
|
||||
|
||||
pa = _norm_str(result, "practice_area")
|
||||
if pa in _VALID_PRACTICE_AREAS and pa:
|
||||
out["practice_area"] = pa
|
||||
|
||||
tags = result.get("subject_tags")
|
||||
if isinstance(tags, list):
|
||||
clean = [str(t).strip() for t in tags if str(t).strip()]
|
||||
if clean:
|
||||
out["subject_tags"] = clean
|
||||
|
||||
return out
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import date
|
||||
@@ -17,7 +18,7 @@ from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import db
|
||||
from legal_mcp.services import db, storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -474,8 +475,19 @@ async def export_decision(
|
||||
pass
|
||||
output_path = str(export_dir / f"{prefix}-v{next_ver}.docx")
|
||||
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.save(output_path)
|
||||
# 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).write_bytes(data)
|
||||
logger.info("DOCX exported (mode=%s): %s", mode, output_path)
|
||||
return output_path
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import storage
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
@@ -304,10 +307,17 @@ def retrofit_bookmarks(
|
||||
end_idx = len(paragraphs) - 1
|
||||
ranges.append((name, start_idx, max(start_idx, end_idx)))
|
||||
|
||||
# Backup if overwriting in place
|
||||
# Backup if overwriting in place — through the storage layer (INV-STG1).
|
||||
if backup and output_path.resolve() == docx_path.resolve():
|
||||
backup_path = docx_path.with_suffix(".pre-retrofit.docx")
|
||||
shutil.copy2(str(docx_path), str(backup_path))
|
||||
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))
|
||||
|
||||
# Inject bookmarks, skipping any that already exist
|
||||
next_id = _next_bookmark_id(doc_tree)
|
||||
|
||||
@@ -13,6 +13,9 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import storage
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
@@ -98,6 +101,22 @@ def _load_docx_xml(docx_path: Path) -> tuple[dict[str, bytes], etree._Element, e
|
||||
return members, document_tree, settings_tree
|
||||
|
||||
|
||||
_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(
|
||||
members: dict[str, bytes],
|
||||
document_tree: etree._Element,
|
||||
@@ -113,12 +132,11 @@ def _save_docx_xml(
|
||||
settings_tree, xml_declaration=True, encoding="UTF-8", standalone=True
|
||||
)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
buffer = BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, data in members.items():
|
||||
zf.writestr(name, data)
|
||||
output_path.write_bytes(buffer.getvalue())
|
||||
_persist_docx_sync(output_path, buffer.getvalue())
|
||||
|
||||
|
||||
def _ensure_track_revisions(settings_tree: etree._Element) -> None:
|
||||
@@ -511,4 +529,11 @@ def copy_with_revisions(
|
||||
source_path: str | Path, output_path: str | Path,
|
||||
) -> None:
|
||||
"""Copy source → output unchanged (used when revisions list is empty)."""
|
||||
shutil.copy2(str(source_path), str(output_path))
|
||||
out = Path(output_path)
|
||||
try:
|
||||
key = out.resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix()
|
||||
storage.put_file_sync(source_path, key, bucket=storage.Bucket.DOCUMENTS,
|
||||
content_type=_DOCX_CTYPE)
|
||||
except ValueError:
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(source_path), str(out))
|
||||
|
||||
@@ -23,6 +23,7 @@ from docx import Document as DocxDocument
|
||||
from striprtf.striprtf import rtf_to_text
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import storage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from google.cloud import vision
|
||||
@@ -345,7 +346,19 @@ def render_pages_for_multimodal(
|
||||
max(1, int(img.height * ratio)),
|
||||
)
|
||||
thumb = img.resize(thumb_size, Image.Resampling.LANCZOS)
|
||||
thumb.save(thumb_path, "JPEG", quality=75, optimize=True)
|
||||
# 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)
|
||||
|
||||
out.append((img, thumb_path))
|
||||
finally:
|
||||
|
||||
97
mcp-server/src/legal_mcp/services/gemini_session.py
Normal file
97
mcp-server/src/legal_mcp/services/gemini_session.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Gemini structured-output helper — a drop-in for ``claude_session.query_json``
|
||||
for BOUNDED extraction tasks (text → JSON).
|
||||
|
||||
Why a second LLM path: metadata extraction is a single structured call (fill
|
||||
case_name/summary/headnote/tags from a verdict's text), not an agentic loop. The
|
||||
``claude -p`` CLI behind ``claude_session`` is agentic — it reaches for tools and
|
||||
hits ``error_max_turns`` on a task that should be one shot — so it was slow and
|
||||
flaky for the precedent metadata queue. Gemini Flash with JSON mode
|
||||
(``responseMimeType: application/json``) is the right tool: one call, schema-
|
||||
clean JSON, fast, and ~$0.10/1M tokens (negligible for this volume).
|
||||
|
||||
Scope: **bounded extraction only** (precedent metadata). The agentic, voice-
|
||||
sensitive work — decision writing, analysis, halacha extraction — stays on
|
||||
``claude_session`` (Daphna's subscription, zero API cost). This is a deliberate
|
||||
per-task provider choice, not a wholesale move off Claude.
|
||||
|
||||
Key: ``GEMINI_API_KEY`` (host ~/.env; SoT Infisical nautilus:/external-apis/gemini
|
||||
as ``GOOGLE_GEMINI_API_KEY``). Model: ``GEMINI_MODEL`` (default gemini-2.5-flash).
|
||||
Direct REST via httpx — no extra SDK dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE = "https://generativelanguage.googleapis.com/v1beta"
|
||||
_DEFAULT_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
|
||||
_DEFAULT_TIMEOUT = float(os.environ.get("GEMINI_TIMEOUT_S", "120"))
|
||||
|
||||
|
||||
class GeminiError(RuntimeError):
|
||||
"""Gemini API call failed or returned an unexpected shape."""
|
||||
|
||||
|
||||
def _api_key() -> str:
|
||||
key = os.environ.get("GEMINI_API_KEY", "").strip()
|
||||
if not key:
|
||||
raise GeminiError(
|
||||
"GEMINI_API_KEY אינו מוגדר (host ~/.env / Infisical "
|
||||
"nautilus:/external-apis/gemini)."
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
async def query_json(
|
||||
prompt: str,
|
||||
timeout: float | int = _DEFAULT_TIMEOUT,
|
||||
*,
|
||||
system: str | None = None,
|
||||
model: str | None = None,
|
||||
# Accepted for drop-in parity with claude_session.query_json; ignored here.
|
||||
effort: str | None = None,
|
||||
tools: str | None = None,
|
||||
) -> dict | list | None:
|
||||
"""Single structured-output call → parsed JSON. Drop-in for
|
||||
``claude_session.query_json``. Raises ``GeminiError`` on failure (the caller
|
||||
treats that like any extraction failure — recorded, never silently wrong).
|
||||
"""
|
||||
model = model or _DEFAULT_MODEL
|
||||
body: dict = {
|
||||
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
|
||||
"generationConfig": {
|
||||
"responseMimeType": "application/json",
|
||||
"temperature": 0,
|
||||
},
|
||||
}
|
||||
if system:
|
||||
body["system_instruction"] = {"parts": [{"text": system}]}
|
||||
|
||||
url = f"{_BASE}/models/{model}:generateContent"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=float(timeout)) as client:
|
||||
resp = await client.post(url, params={"key": _api_key()}, json=body)
|
||||
except httpx.HTTPError as e:
|
||||
raise GeminiError(f"Gemini request failed: {e}") from e
|
||||
if resp.status_code != 200:
|
||||
raise GeminiError(f"Gemini HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
|
||||
data = resp.json()
|
||||
# Surface an explicit safety/finish block rather than returning empty.
|
||||
cand = (data.get("candidates") or [{}])[0]
|
||||
if cand.get("finishReason") in ("SAFETY", "RECITATION", "PROHIBITED_CONTENT"):
|
||||
raise GeminiError(f"Gemini blocked output: finishReason={cand['finishReason']}")
|
||||
try:
|
||||
text = cand["content"]["parts"][0]["text"]
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
raise GeminiError(f"Gemini unexpected response: {str(data)[:200]}") from e
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
raise GeminiError(f"Gemini returned non-JSON: {text[:200]}") from e
|
||||
@@ -76,8 +76,12 @@ EXTRACTABLE_SECTIONS = ("legal_analysis", "ruling", "conclusion")
|
||||
# wants to be able to cite "another committee reached the same conclusion"
|
||||
# even though it is not binding.
|
||||
#
|
||||
# The schema's rule_type field accepts six values:
|
||||
# binding | interpretive | procedural | obiter | application | persuasive
|
||||
# The prompt branches on is_binding only to choose the EXTRACTION STRATEGY
|
||||
# (what to pull, how to phrase) — NOT the rule_type. rule_type is the rule
|
||||
# ROLE and uses the SAME five values for both sources (INV-DM7):
|
||||
# holding | interpretive | procedural | application | obiter
|
||||
# The authority axis (binding/persuasive) is derived from the source, never
|
||||
# a rule_type value — so the model never classifies it.
|
||||
|
||||
HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה בדיני תכנון ובניה (ועדות ערר, היטל השבחה, פיצויים לפי סעיף 197 לחוק התכנון והבניה). תפקידך: לחלץ הלכות מחייבות מתוך פסק דין/החלטה משפטית של ערכאה עליונה (עליון / מנהלי).
|
||||
|
||||
@@ -101,10 +105,12 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
|
||||
|
||||
הלכה אחת יכולה לחול על כמה תחומים — practice_areas הוא array ולא string יחיד.
|
||||
|
||||
## סוגי הלכה (rule_type)
|
||||
- binding — הלכה מחייבת שהוחלה על התיק.
|
||||
- interpretive — פרשנות סעיף חוק/תכנית שאומצה.
|
||||
- procedural — כלל פרוצדורלי (סמכות, מועדים, הליכי שמיעה).
|
||||
## סוג הכלל (rule_type) — מהות הכלל בלבד, לא סמכות-המקור
|
||||
**אל תסווג "מחייב/משכנע"** — דרגת-המחייבות נגזרת אוטומטית מזהות הערכאה. כאן בחר רק את **סוג הכלל**:
|
||||
- holding — עיקרון מהותי שהיה הכרחי להכרעה (ה-ratio; מבחן Wambaugh: שלילתו הייתה משנה את התוצאה).
|
||||
- interpretive — פרשנות הוראת-חוק/מונח/תכנית שאומצה.
|
||||
- procedural — כלל סדר-דין (סמכות, מועדים, זכות-עמידה, מיצוי הליכים, נטל).
|
||||
- application — החלת כלל על עובדות התיק (תלוי-עובדות; לרוב לא-הלכה בת-הכללה).
|
||||
- obiter — אמרת אגב חשובה (חלץ רק אם משמעותית; סמן confidence נמוך).
|
||||
|
||||
## פלט נדרש
|
||||
@@ -112,7 +118,7 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
|
||||
[
|
||||
{
|
||||
"rule_statement": "ניסוח הכלל בלשון משפטית מדויקת בגוף שלישי, 1-3 משפטים.",
|
||||
"rule_type": "binding",
|
||||
"rule_type": "holding",
|
||||
"reasoning_summary": "תמצית ההיגיון: למה בית המשפט הגיע לכלל הזה (1-2 משפטים).",
|
||||
"supporting_quote": "ציטוט מילולי מדויק מהפסק התומך בכלל. חייב להופיע מילה במילה בטקסט הקלט.",
|
||||
"page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות מהקלט.",
|
||||
@@ -139,11 +145,11 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
|
||||
|
||||
המקור הזה **אינו** מקור להלכות מחייבות חדשות (binding rules). הלכות מחייבות מגיעות מהעליון/מנהלי. עם זאת, יש כאן ערך משמעותי שצריך לחלץ — איך הפנל הזה ניתח ויישם את הדין הקיים. כשנכתוב החלטה עתידית, נצטט מהמקור הזה כ"גם ועדת הערר ב-X הגיעה למסקנה דומה" — לא כסמכות מחייבת, אלא כתמיכה משכנעת.
|
||||
|
||||
**יש לחלץ:**
|
||||
**יש לחלץ** (סווג לפי **סוג הכלל** בלבד — אל תסווג "מחייב/משכנע", דרגת-המחייבות נגזרת אוטומטית):
|
||||
- **יישום של הלכה ידועה** (rule_type=`application`) — הפנל החיל הלכה ידועה (של עליון/מנהלי) על עובדות הנידונות. תצטט את ניסוח הכלל **כפי שהוצג כאן** (לא בהכרח כפי שנקבע במקור) ואת התוצאה.
|
||||
- **עקרון פרשני שאומץ** (rule_type=`interpretive`) — איך הפנל פירש סעיף חוק / תכנית, באופן שניתן לאמץ.
|
||||
- **כלל פרוצדורלי** (rule_type=`procedural`) — קביעות בנושאי סמכות, מועדים, הליך.
|
||||
- **מסקנה מנומקת ומשכנעת** (rule_type=`persuasive`) — מסקנה שלמה של הפנל בסוגיה, עם ההיגיון התומך, ניתנת לציטוט כאסמכתא משכנעת.
|
||||
- **מסקנה מהותית מנומקת** (rule_type=`holding`) — מסקנה עקרונית שלמה של הפנל בסוגיה, עם ההיגיון התומך, בת-הכללה ובת-הסתמכות.
|
||||
|
||||
**אין לחלץ:**
|
||||
- ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים קונקרטיים) — חלץ את העיקרון/היישום בניסוח בר-הכללה בלבד.
|
||||
@@ -175,7 +181,7 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
|
||||
## כללי איכות
|
||||
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה.
|
||||
2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אל תמתח את הרשימה. אם אין מה לחלץ — החזר [].
|
||||
3. **rule_type מדויק** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. persuasive = מסקנה כללית בעלת ערך כאסמכתא.
|
||||
3. **rule_type מדויק (סוג הכלל בלבד)** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. holding = מסקנה מהותית עקרונית. **לא** binding/persuasive (סמכות נגזרת אוטומטית).
|
||||
4. **לא לפצל יתר על המידה — קריטי** — כל פריט = שאלה משפטית מובחנת אחת. פנים שונים של אותה שאלה = פריט אחד (בחר את הניסוח הכללי ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים.
|
||||
5. **שפה** — עברית משפטית מקצועית, גוף שלישי.
|
||||
6. **subject_tags** — 2-5 תגיות בעברית, snake_case.
|
||||
@@ -184,10 +190,15 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
|
||||
|
||||
|
||||
_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 = {
|
||||
"binding", "interpretive", "procedural", "obiter",
|
||||
"application", "persuasive",
|
||||
"holding", "interpretive", "procedural", "application", "obiter",
|
||||
}
|
||||
# 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:
|
||||
@@ -227,13 +238,14 @@ def _verify_quote(supporting_quote: str, full_text: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _coerce_halacha(raw: dict, is_binding: bool = True) -> dict | None:
|
||||
def _coerce_halacha(raw: dict) -> dict | None:
|
||||
"""Validate and normalize one LLM-returned halacha dict.
|
||||
|
||||
Returns ``None`` if the entry is missing required fields. ``is_binding``
|
||||
only affects the default rule_type when the LLM returned an unknown
|
||||
value — for binding sources we default to ``binding``, otherwise to
|
||||
``persuasive`` (never pretend an appeals committee created halacha).
|
||||
Returns ``None`` if the entry is missing required fields. ``rule_type`` is
|
||||
the rule ROLE only (INV-DM7) — it is NEVER defaulted from the source's
|
||||
bindingness (that was the source-conflation this split removed). Legacy
|
||||
authority values fold to the nearest role; unknown defaults to
|
||||
``interpretive`` (the most common role).
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
@@ -242,13 +254,10 @@ def _coerce_halacha(raw: dict, is_binding: bool = True) -> dict | None:
|
||||
if not rule_statement or not supporting_quote:
|
||||
return None
|
||||
|
||||
default_rule_type = "binding" if is_binding else "persuasive"
|
||||
rule_type = (raw.get("rule_type") or default_rule_type).strip().lower()
|
||||
rule_type = (raw.get("rule_type") or "").strip().lower()
|
||||
rule_type = _LEGACY_RULE_TYPE_FOLD.get(rule_type, rule_type)
|
||||
if rule_type not in _VALID_RULE_TYPES:
|
||||
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"
|
||||
rule_type = "interpretive"
|
||||
|
||||
practice_areas_raw = raw.get("practice_areas") or []
|
||||
if isinstance(practice_areas_raw, str):
|
||||
@@ -580,7 +589,7 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
|
||||
return
|
||||
cleaned: list[dict] = []
|
||||
for raw in items:
|
||||
coerced = _coerce_halacha(raw, is_binding=is_binding)
|
||||
coerced = _coerce_halacha(raw)
|
||||
if coerced is None:
|
||||
continue
|
||||
coerced["quote_verified"] = _verify_quote(
|
||||
@@ -597,10 +606,10 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
|
||||
coerced["quality_flags"] = flags
|
||||
if halacha_quality.FLAG_NON_DECISION in flags and coerced["rule_type"] != "obiter":
|
||||
coerced["rule_type"] = "obiter"
|
||||
# #81.4 — a binding-labeled rule that reads as a case-application is
|
||||
# #81.4 — a holding-labeled rule that reads as a case-application is
|
||||
# re-typed application (it carries FLAG_APPLICATION either way).
|
||||
elif (halacha_quality.FLAG_APPLICATION in flags
|
||||
and coerced["rule_type"] == "binding"):
|
||||
and coerced["rule_type"] == "holding"):
|
||||
coerced["rule_type"] = "application"
|
||||
cleaned.append(coerced)
|
||||
# #81.3 NLI entailment — one batched judge call per chunk (fail-open).
|
||||
|
||||
@@ -18,6 +18,37 @@ from __future__ import annotations
|
||||
|
||||
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) ──
|
||||
|
||||
_HEB_QUOTE_VARIANTS = "\"'׳״‘’“”«»„′″"
|
||||
@@ -337,7 +368,7 @@ def compute_quality_flags(
|
||||
supporting_quote: str,
|
||||
reasoning_summary: str = "",
|
||||
quote_verified: bool = True,
|
||||
rule_type: str = "binding",
|
||||
rule_type: str = "interpretive",
|
||||
) -> list[str]:
|
||||
"""Return the list of quality flags for one halacha (empty == clean).
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
@@ -23,7 +23,7 @@ from typing import Awaitable, Callable
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import chunker, db, embeddings, extractor
|
||||
from legal_mcp.services import chunker, db, embeddings, extractor, storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,11 +66,20 @@ def _safe_filename(name: str) -> str:
|
||||
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"upload-{uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _stage_file(src_path: Path, root: Path, subdir: str) -> Path:
|
||||
dest_dir = root / (subdir or "other")
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = dest_dir / f"{uuid4().hex[:8]}_{_safe_filename(src_path.name)}"
|
||||
shutil.copy2(src_path, dest)
|
||||
async def _stage_file(src_path: Path, root: Path, subdir: str) -> Path:
|
||||
"""Stage an intake file through the unified storage layer (INV-STG1).
|
||||
|
||||
Returns the DATA_DIR path the rest of the pipeline reads from — under the
|
||||
filesystem/dual backends the bytes are on disk and the key is the
|
||||
DATA_DIR-relative path. The Hebrew original filename rides as object
|
||||
metadata, never as the key (INV-STG2)."""
|
||||
dest = root / (subdir or "other") / f"{uuid4().hex[:8]}_{_safe_filename(src_path.name)}"
|
||||
key = dest.relative_to(config.DATA_DIR).as_posix()
|
||||
await storage.put_file(
|
||||
src_path, key, bucket=storage.Bucket.DOCUMENTS,
|
||||
content_type=mimetypes.guess_type(src_path.name)[0],
|
||||
metadata={"filename": src_path.name},
|
||||
)
|
||||
return dest
|
||||
|
||||
|
||||
@@ -151,7 +160,7 @@ async def ingest_document(
|
||||
if not src.is_file():
|
||||
raise FileNotFoundError(f"file not found: {src}")
|
||||
await progress("staging", 5, "מעתיק את הקובץ לאחסון")
|
||||
staged = _stage_file(src, spec.staging_root, spec.staging_subdir(inputs))
|
||||
staged = await _stage_file(src, spec.staging_root, spec.staging_subdir(inputs))
|
||||
await progress("extracting", 15, "מחלץ טקסט מהקובץ")
|
||||
try:
|
||||
raw_text, page_count, page_offsets = await extractor.extract_text(str(staged))
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable
|
||||
from uuid import UUID
|
||||
@@ -179,6 +180,9 @@ async def reextract_halachot(
|
||||
# precedent into a 429 storm. Observed 2026-05-03: 1110/20 succeeded with 9
|
||||
# halachot, 317/10 immediately after returned silent no_halachot.
|
||||
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'
|
||||
# (i.e. >50% chunks crashed). Each retry uses a longer cooldown.
|
||||
@@ -212,6 +216,16 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
|
||||
if kind not in {"metadata", "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)
|
||||
if not pending:
|
||||
return {"status": "no_pending", "kind": kind, "processed": 0, "results": []}
|
||||
@@ -226,11 +240,14 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
|
||||
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] = []
|
||||
processed = 0
|
||||
for idx, row in enumerate(pending):
|
||||
if idx > 0:
|
||||
await asyncio.sleep(INTER_PRECEDENT_COOLDOWN_SEC)
|
||||
await asyncio.sleep(cooldown)
|
||||
cid = UUID(str(row["id"]))
|
||||
attempts = 0
|
||||
result: dict = {}
|
||||
|
||||
@@ -19,7 +19,7 @@ from datetime import date as date_type
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.config import parse_llm_json
|
||||
from legal_mcp.services import claude_session, db
|
||||
from legal_mcp.services import db, gemini_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -150,7 +150,10 @@ async def extract_metadata(case_law_id: UUID | str) -> dict:
|
||||
)
|
||||
|
||||
try:
|
||||
result = await claude_session.query_json(
|
||||
# Bounded structured extraction → Gemini Flash (JSON mode). The agentic
|
||||
# claude CLI hit error_max_turns on this single-shot task; see
|
||||
# gemini_session.py. Voice-sensitive/agentic work stays on claude_session.
|
||||
result = await gemini_session.query_json(
|
||||
user_msg, system=METADATA_EXTRACTION_PROMPT,
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -8,7 +8,9 @@ from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import chunker, db, embeddings, extractor, references_extractor
|
||||
from legal_mcp.services import (
|
||||
chunker, db, embeddings, extractor, references_extractor, storage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,13 +42,17 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
|
||||
page_count=page_count,
|
||||
)
|
||||
|
||||
# Save extracted text to documents/extracted/ directory
|
||||
# Save extracted text (a DERIVED artifact — the DB column holds the
|
||||
# source of truth, INV-STG5) through the storage layer (INV-STG1).
|
||||
# Non-fatal: the .txt is a convenience copy, the pipeline reads the DB.
|
||||
original_path = Path(doc["file_path"])
|
||||
extracted_dir = original_path.parent.parent / "extracted"
|
||||
extracted_dir.mkdir(parents=True, exist_ok=True)
|
||||
txt_path = extracted_dir / (original_path.stem + ".txt")
|
||||
txt_path = original_path.parent.parent / "extracted" / (original_path.stem + ".txt")
|
||||
try:
|
||||
txt_path.write_text(text, encoding="utf-8")
|
||||
await storage.put_bytes(
|
||||
txt_path.relative_to(config.DATA_DIR).as_posix(),
|
||||
text.encode("utf-8"), bucket=storage.Bucket.DERIVED,
|
||||
content_type="text/plain; charset=utf-8",
|
||||
)
|
||||
logger.info("Saved extracted text to %s", txt_path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save text file (non-fatal): %s", e)
|
||||
|
||||
513
mcp-server/src/legal_mcp/services/storage.py
Normal file
513
mcp-server/src/legal_mcp/services/storage.py
Normal file
@@ -0,0 +1,513 @@
|
||||
"""Unified object-storage layer (X14, INV-STG1).
|
||||
|
||||
THE single choke-point for all binary file I/O — originals, derived
|
||||
artifacts (thumbnails / extracted text), and exports. It replaces the
|
||||
scattered ``open()`` / ``shutil.copy`` / ``Path.write_bytes`` calls spread
|
||||
across ~8 services (G2: one storage path, no parallel routes). See
|
||||
docs/spec/X14-storage-minio.md.
|
||||
|
||||
Keys
|
||||
----
|
||||
A *key* is a DATA_DIR-relative POSIX path, e.g.::
|
||||
|
||||
cases/8174-24/documents/originals/<uuid>.pdf
|
||||
precedent-library/thumbnails/<case_law_id>/p001.jpg
|
||||
|
||||
The filesystem backend maps ``key -> DATA_DIR / key``, preserving the exact
|
||||
current on-disk layout (zero behaviour change when ``STORAGE_BACKEND`` is the
|
||||
default ``filesystem``). The S3 backend maps a logical *bucket*
|
||||
(documents/immutable/derived) to a MinIO bucket and uses the key verbatim as
|
||||
the object key.
|
||||
|
||||
INV-STG2: keys are atomic ASCII/UUID paths; a Hebrew original filename is
|
||||
carried as object metadata / a DB column, never as the key itself.
|
||||
INV-STG5: pgvector stays the source of truth for text + embeddings — this
|
||||
layer stores blobs only. INV-STG6: presigned URLs (minted against the public
|
||||
endpoint) serve bytes straight to the browser.
|
||||
|
||||
Backends (config.STORAGE_BACKEND)
|
||||
---------------------------------
|
||||
- ``filesystem`` (default) — disk only; current behaviour.
|
||||
- ``dual`` — write disk + S3; read S3, fall back to disk.
|
||||
The migration window; disk stays authoritative.
|
||||
- ``s3`` — MinIO only.
|
||||
|
||||
``aioboto3`` is imported lazily so this module loads even where the dependency
|
||||
is absent (the default filesystem backend needs nothing extra).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
from enum import Enum
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Iterable
|
||||
|
||||
from legal_mcp import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Bucket(str, Enum):
|
||||
"""Logical governance buckets (INV-STG3). Resolved to MinIO bucket names
|
||||
via config; ignored by the filesystem backend (which keeps one tree)."""
|
||||
|
||||
DOCUMENTS = "documents"
|
||||
IMMUTABLE = "immutable"
|
||||
DERIVED = "derived"
|
||||
|
||||
|
||||
def _bucket_name(bucket: Bucket) -> str:
|
||||
return {
|
||||
Bucket.DOCUMENTS: config.MINIO_BUCKET_DOCUMENTS,
|
||||
Bucket.IMMUTABLE: config.MINIO_BUCKET_IMMUTABLE,
|
||||
Bucket.DERIVED: config.MINIO_BUCKET_DERIVED,
|
||||
}[bucket]
|
||||
|
||||
|
||||
def normalize_key(key: str | Path) -> str:
|
||||
"""Return a clean DATA_DIR-relative POSIX key.
|
||||
|
||||
Rejects absolute paths and ``..`` traversal (defence in depth — keys are
|
||||
built internally, never from raw user input). An absolute path under
|
||||
DATA_DIR is accepted and re-relativised so call-sites can pass either a
|
||||
key or a full ``Path`` during the migration.
|
||||
"""
|
||||
p = Path(key)
|
||||
if p.is_absolute():
|
||||
try:
|
||||
p = p.relative_to(config.DATA_DIR)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"absolute path outside DATA_DIR: {key!r}") from exc
|
||||
posix = PurePosixPath(p.as_posix())
|
||||
parts = posix.parts
|
||||
if not parts or any(part == ".." for part in parts):
|
||||
raise ValueError(f"invalid storage key: {key!r}")
|
||||
return posix.as_posix().lstrip("/")
|
||||
|
||||
|
||||
class StorageBackend:
|
||||
"""Abstract backend. All methods are async except the cheap path helpers."""
|
||||
|
||||
name = "abstract"
|
||||
|
||||
async def put_bytes(self, key, data, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def put_file(self, src, key, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> str:
|
||||
with open(src, "rb") as fh:
|
||||
return await self.put_bytes(
|
||||
key, fh.read(), bucket=bucket,
|
||||
content_type=content_type, metadata=metadata,
|
||||
)
|
||||
|
||||
async def get_bytes(self, key, *, bucket=Bucket.DOCUMENTS) -> bytes:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exists(self, key, *, bucket=Bucket.DOCUMENTS) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
async def delete(self, key, *, bucket=Bucket.DOCUMENTS) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_keys(self, prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def presign_get(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
download_name=None) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def presign_put(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
content_type=None) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def local_path(self, key, *, bucket=Bucket.DOCUMENTS) -> Path | None:
|
||||
"""Return a real filesystem path if one exists *without* downloading,
|
||||
else ``None``. Use :meth:`ensure_local` when a path is required."""
|
||||
return None
|
||||
|
||||
async def ensure_local(self, key, *, bucket=Bucket.DOCUMENTS) -> Path:
|
||||
"""Return a local path to the object, downloading to a temp file if the
|
||||
backend has no on-disk copy. Caller owns cleanup of temp files."""
|
||||
path = self.local_path(key, bucket=bucket)
|
||||
if path is not None:
|
||||
return path
|
||||
data = await self.get_bytes(key, bucket=bucket)
|
||||
suffix = PurePosixPath(normalize_key(key)).suffix
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
||||
tmp.write(data)
|
||||
tmp.close()
|
||||
return Path(tmp.name)
|
||||
|
||||
|
||||
class FilesystemBackend(StorageBackend):
|
||||
"""Disk under DATA_DIR. ``bucket`` is ignored — the existing single tree is
|
||||
preserved verbatim, so the default backend is byte-for-byte the legacy
|
||||
behaviour."""
|
||||
|
||||
name = "filesystem"
|
||||
|
||||
def _abs(self, key, *, bucket=Bucket.DOCUMENTS) -> Path:
|
||||
rel = normalize_key(key)
|
||||
path = (Path(config.DATA_DIR) / rel).resolve()
|
||||
root = Path(config.DATA_DIR).resolve()
|
||||
if root not in path.parents and path != root:
|
||||
raise ValueError(f"resolved path escapes DATA_DIR: {key!r}")
|
||||
return path
|
||||
|
||||
async def put_bytes(self, key, data, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> str:
|
||||
path = self._abs(key, bucket=bucket)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return f"file://{path}"
|
||||
|
||||
async def put_file(self, src, key, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> str:
|
||||
path = self._abs(key, bucket=bucket)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, path) # preserve mtime, as the legacy code did
|
||||
return f"file://{path}"
|
||||
|
||||
async def get_bytes(self, key, *, bucket=Bucket.DOCUMENTS) -> bytes:
|
||||
return self._abs(key, bucket=bucket).read_bytes()
|
||||
|
||||
async def exists(self, key, *, bucket=Bucket.DOCUMENTS) -> bool:
|
||||
return self._abs(key, bucket=bucket).exists()
|
||||
|
||||
async def delete(self, key, *, bucket=Bucket.DOCUMENTS) -> None:
|
||||
self._abs(key, bucket=bucket).unlink(missing_ok=True)
|
||||
|
||||
async def list_keys(self, prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
|
||||
root = Path(config.DATA_DIR).resolve()
|
||||
base = self._abs(prefix, bucket=bucket) if prefix else root
|
||||
if not base.exists():
|
||||
return []
|
||||
out: list[str] = []
|
||||
for p in sorted(base.rglob("*")):
|
||||
if p.is_file():
|
||||
out.append(p.resolve().relative_to(root).as_posix())
|
||||
return out
|
||||
|
||||
def local_path(self, key, *, bucket=Bucket.DOCUMENTS) -> Path | None:
|
||||
path = self._abs(key, bucket=bucket)
|
||||
return path if path.exists() else None
|
||||
|
||||
async def presign_get(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
download_name=None) -> str:
|
||||
raise NotImplementedError(
|
||||
"presigned URLs require the S3 backend (set STORAGE_BACKEND=dual|s3)"
|
||||
)
|
||||
|
||||
async def presign_put(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
content_type=None) -> str:
|
||||
raise NotImplementedError(
|
||||
"presigned URLs require the S3 backend (set STORAGE_BACKEND=dual|s3)"
|
||||
)
|
||||
|
||||
|
||||
class S3Backend(StorageBackend):
|
||||
"""MinIO via aioboto3. Server-side ops use the internal endpoint; presigned
|
||||
URLs are minted against the public endpoint so the browser can reach them
|
||||
(INV-STG6)."""
|
||||
|
||||
name = "s3"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session = None # lazy aioboto3 session
|
||||
|
||||
def _boto(self):
|
||||
import aioboto3 # lazy — absent in the default filesystem path
|
||||
from botocore.config import Config as BotoConfig
|
||||
if self._session is None:
|
||||
self._session = aioboto3.Session()
|
||||
cfg = BotoConfig(signature_version="s3v4", s3={"addressing_style": "path"})
|
||||
return aioboto3, BotoConfig, cfg
|
||||
|
||||
def _client(self, *, public: bool = False):
|
||||
_aioboto3, _BotoConfig, cfg = self._boto()
|
||||
endpoint = config.MINIO_PUBLIC_ENDPOINT if public else config.MINIO_ENDPOINT
|
||||
return self._session.client(
|
||||
"s3",
|
||||
endpoint_url=endpoint,
|
||||
aws_access_key_id=config.MINIO_ACCESS_KEY,
|
||||
aws_secret_access_key=config.MINIO_SECRET_KEY,
|
||||
region_name=config.MINIO_REGION,
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
async def put_bytes(self, key, data, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> str:
|
||||
k = normalize_key(key)
|
||||
extra = {}
|
||||
if content_type:
|
||||
extra["ContentType"] = content_type
|
||||
if metadata:
|
||||
extra["Metadata"] = {kk: str(vv) for kk, vv in metadata.items()}
|
||||
async with self._client() as s3:
|
||||
await s3.put_object(Bucket=_bucket_name(bucket), Key=k, Body=data, **extra)
|
||||
return f"s3://{_bucket_name(bucket)}/{k}"
|
||||
|
||||
async def get_bytes(self, key, *, bucket=Bucket.DOCUMENTS) -> bytes:
|
||||
k = normalize_key(key)
|
||||
async with self._client() as s3:
|
||||
resp = await s3.get_object(Bucket=_bucket_name(bucket), Key=k)
|
||||
async with resp["Body"] as stream:
|
||||
return await stream.read()
|
||||
|
||||
async def exists(self, key, *, bucket=Bucket.DOCUMENTS) -> bool:
|
||||
from botocore.exceptions import ClientError
|
||||
k = normalize_key(key)
|
||||
async with self._client() as s3:
|
||||
try:
|
||||
await s3.head_object(Bucket=_bucket_name(bucket), Key=k)
|
||||
return True
|
||||
except ClientError as exc:
|
||||
if exc.response["Error"]["Code"] in ("404", "NoSuchKey", "NotFound"):
|
||||
return False
|
||||
raise
|
||||
|
||||
async def delete(self, key, *, bucket=Bucket.DOCUMENTS) -> None:
|
||||
k = normalize_key(key)
|
||||
async with self._client() as s3:
|
||||
await s3.delete_object(Bucket=_bucket_name(bucket), Key=k)
|
||||
|
||||
async def list_keys(self, prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
|
||||
pfx = normalize_key(prefix) if prefix else ""
|
||||
out: list[str] = []
|
||||
async with self._client() as s3:
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
async for page in paginator.paginate(Bucket=_bucket_name(bucket), Prefix=pfx):
|
||||
for obj in page.get("Contents", []):
|
||||
out.append(obj["Key"])
|
||||
return out
|
||||
|
||||
async def presign_get(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
download_name=None) -> str:
|
||||
k = normalize_key(key)
|
||||
params = {"Bucket": _bucket_name(bucket), "Key": k}
|
||||
if download_name:
|
||||
# RFC 5987 — keep the Hebrew original filename on download (INV-STG2)
|
||||
from urllib.parse import quote
|
||||
params["ResponseContentDisposition"] = (
|
||||
f"attachment; filename*=UTF-8''{quote(download_name)}"
|
||||
)
|
||||
async with self._client(public=True) as s3:
|
||||
return await s3.generate_presigned_url(
|
||||
"get_object", Params=params,
|
||||
ExpiresIn=ttl or config.MINIO_PRESIGN_TTL,
|
||||
)
|
||||
|
||||
async def presign_put(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
content_type=None) -> str:
|
||||
k = normalize_key(key)
|
||||
params = {"Bucket": _bucket_name(bucket), "Key": k}
|
||||
if content_type:
|
||||
params["ContentType"] = content_type
|
||||
async with self._client(public=True) as s3:
|
||||
return await s3.generate_presigned_url(
|
||||
"put_object", Params=params,
|
||||
ExpiresIn=ttl or config.MINIO_PRESIGN_TTL,
|
||||
)
|
||||
|
||||
|
||||
class DualBackend(StorageBackend):
|
||||
"""Migration window: writes go to BOTH disk and S3 (disk authoritative);
|
||||
reads prefer S3 and fall back to disk. An S3 write failure is logged (never
|
||||
swallowed — engineering §6) but does not break the app while disk holds the
|
||||
canonical copy."""
|
||||
|
||||
name = "dual"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.fs = FilesystemBackend()
|
||||
self.s3 = S3Backend()
|
||||
|
||||
async def put_bytes(self, key, data, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> str:
|
||||
uri = await self.fs.put_bytes(key, data, bucket=bucket,
|
||||
content_type=content_type, metadata=metadata)
|
||||
try:
|
||||
await self.s3.put_bytes(key, data, bucket=bucket,
|
||||
content_type=content_type, metadata=metadata)
|
||||
except Exception as exc: # noqa: BLE001 — log, don't swallow
|
||||
logger.warning("dual put_bytes: S3 mirror failed for %s: %s", key, exc)
|
||||
return uri
|
||||
|
||||
async def put_file(self, src, key, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> str:
|
||||
uri = await self.fs.put_file(src, key, bucket=bucket,
|
||||
content_type=content_type, metadata=metadata)
|
||||
try:
|
||||
await self.s3.put_file(src, key, bucket=bucket,
|
||||
content_type=content_type, metadata=metadata)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("dual put_file: S3 mirror failed for %s: %s", key, exc)
|
||||
return uri
|
||||
|
||||
async def get_bytes(self, key, *, bucket=Bucket.DOCUMENTS) -> bytes:
|
||||
try:
|
||||
return await self.s3.get_bytes(key, bucket=bucket)
|
||||
except Exception as exc: # noqa: BLE001 — fall back to disk
|
||||
logger.debug("dual get_bytes: S3 miss for %s (%s); using disk", key, exc)
|
||||
return await self.fs.get_bytes(key, bucket=bucket)
|
||||
|
||||
async def exists(self, key, *, bucket=Bucket.DOCUMENTS) -> bool:
|
||||
if await self.fs.exists(key, bucket=bucket):
|
||||
return True
|
||||
try:
|
||||
return await self.s3.exists(key, bucket=bucket)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
async def delete(self, key, *, bucket=Bucket.DOCUMENTS) -> None:
|
||||
await self.fs.delete(key, bucket=bucket)
|
||||
try:
|
||||
await self.s3.delete(key, bucket=bucket)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("dual delete: S3 delete failed for %s: %s", key, exc)
|
||||
|
||||
async def list_keys(self, prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
|
||||
return await self.fs.list_keys(prefix, bucket=bucket)
|
||||
|
||||
def local_path(self, key, *, bucket=Bucket.DOCUMENTS) -> Path | None:
|
||||
return self.fs.local_path(key, bucket=bucket)
|
||||
|
||||
async def presign_get(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
download_name=None) -> str:
|
||||
return await self.s3.presign_get(key, bucket=bucket, ttl=ttl,
|
||||
download_name=download_name)
|
||||
|
||||
async def presign_put(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
content_type=None) -> str:
|
||||
return await self.s3.presign_put(key, bucket=bucket, ttl=ttl,
|
||||
content_type=content_type)
|
||||
|
||||
|
||||
_BACKENDS = {
|
||||
"filesystem": FilesystemBackend,
|
||||
"dual": DualBackend,
|
||||
"s3": S3Backend,
|
||||
}
|
||||
|
||||
_singleton: StorageBackend | None = None
|
||||
|
||||
|
||||
def get_storage() -> StorageBackend:
|
||||
"""Return the process-wide storage backend selected by config.STORAGE_BACKEND
|
||||
(cached). Unknown values fall back to ``filesystem`` with a warning rather
|
||||
than crashing the app."""
|
||||
global _singleton
|
||||
if _singleton is None:
|
||||
cls = _BACKENDS.get(config.STORAGE_BACKEND)
|
||||
if cls is None:
|
||||
logger.warning(
|
||||
"unknown STORAGE_BACKEND=%r — falling back to filesystem",
|
||||
config.STORAGE_BACKEND,
|
||||
)
|
||||
cls = FilesystemBackend
|
||||
_singleton = cls()
|
||||
logger.info("storage backend = %s", _singleton.name)
|
||||
return _singleton
|
||||
|
||||
|
||||
def reset_storage_cache() -> None:
|
||||
"""Drop the cached backend (tests / after an env change)."""
|
||||
global _singleton
|
||||
_singleton = None
|
||||
|
||||
|
||||
# ── module-level convenience wrappers ──────────────────────────────
|
||||
# Thin pass-throughs so call-sites can ``from legal_mcp.services import storage``
|
||||
# and use ``await storage.put_bytes(...)`` without fetching the singleton.
|
||||
|
||||
async def put_bytes(key, data, *, bucket=Bucket.DOCUMENTS, content_type=None,
|
||||
metadata=None) -> str:
|
||||
return await get_storage().put_bytes(
|
||||
key, data, bucket=bucket, content_type=content_type, metadata=metadata)
|
||||
|
||||
|
||||
async def put_file(src, key, *, bucket=Bucket.DOCUMENTS, content_type=None,
|
||||
metadata=None) -> str:
|
||||
return await get_storage().put_file(
|
||||
src, key, bucket=bucket, content_type=content_type, metadata=metadata)
|
||||
|
||||
|
||||
async def get_bytes(key, *, bucket=Bucket.DOCUMENTS) -> bytes:
|
||||
return await get_storage().get_bytes(key, bucket=bucket)
|
||||
|
||||
|
||||
async def exists(key, *, bucket=Bucket.DOCUMENTS) -> bool:
|
||||
return await get_storage().exists(key, bucket=bucket)
|
||||
|
||||
|
||||
async def delete(key, *, bucket=Bucket.DOCUMENTS) -> None:
|
||||
return await get_storage().delete(key, bucket=bucket)
|
||||
|
||||
|
||||
async def list_keys(prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
|
||||
return await get_storage().list_keys(prefix, bucket=bucket)
|
||||
|
||||
|
||||
async def presign_get(key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
download_name=None) -> str:
|
||||
return await get_storage().presign_get(
|
||||
key, bucket=bucket, ttl=ttl, download_name=download_name)
|
||||
|
||||
|
||||
async def presign_put(key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
content_type=None) -> str:
|
||||
return await get_storage().presign_put(
|
||||
key, bucket=bucket, ttl=ttl, content_type=content_type)
|
||||
|
||||
|
||||
def local_path(key, *, bucket=Bucket.DOCUMENTS) -> Path | None:
|
||||
return get_storage().local_path(key, bucket=bucket)
|
||||
|
||||
|
||||
async def ensure_local(key, *, bucket=Bucket.DOCUMENTS) -> Path:
|
||||
return await get_storage().ensure_local(key, bucket=bucket)
|
||||
|
||||
|
||||
# ── synchronous facade ─────────────────────────────────────────────
|
||||
# A few legacy writers are plain sync functions (track-changes save, retrofit
|
||||
# backup, the multimodal thumbnail renderer which runs in a worker thread via
|
||||
# asyncio.to_thread). They go through the same layer via this blocking shim so
|
||||
# INV-STG1 holds everywhere.
|
||||
|
||||
def _run_coro_blocking(coro):
|
||||
"""Run a storage coroutine to completion from synchronous code.
|
||||
|
||||
No running loop in this thread (the common case — sync helpers, or a
|
||||
to_thread worker) → asyncio.run. If a loop *is* already running here, the
|
||||
coroutine is offloaded to a fresh thread so we never deadlock the loop."""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
box: dict = {}
|
||||
|
||||
def _worker():
|
||||
box["value"] = asyncio.run(coro)
|
||||
|
||||
import threading
|
||||
t = threading.Thread(target=_worker)
|
||||
t.start()
|
||||
t.join()
|
||||
return box["value"]
|
||||
|
||||
|
||||
def put_bytes_sync(key, data, *, bucket=Bucket.DOCUMENTS, content_type=None,
|
||||
metadata=None) -> str:
|
||||
return _run_coro_blocking(
|
||||
put_bytes(key, data, bucket=bucket, content_type=content_type, metadata=metadata))
|
||||
|
||||
|
||||
def put_file_sync(src, key, *, bucket=Bucket.DOCUMENTS, content_type=None,
|
||||
metadata=None) -> str:
|
||||
return _run_coro_blocking(
|
||||
put_file(src, key, bucket=bucket, content_type=content_type, metadata=metadata))
|
||||
66
mcp-server/src/legal_mcp/tools/court_fetch.py
Normal file
66
mcp-server/src/legal_mcp/tools/court_fetch.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""MCP tools for the X13 court-verdict auto-fetch subsystem.
|
||||
|
||||
- ``court_verdict_fetch`` — classify a citation, fetch the verdict from the
|
||||
matching public source (Supreme portal / נט המשפט), and ingest it into the
|
||||
precedent library via the canonical pipeline. The standalone entry point
|
||||
(also driven automatically from digest auto-link, see X12/X13).
|
||||
- ``court_fetch_status`` — inspect the fetch-job queue (pending/failed/manual).
|
||||
|
||||
Local-only: ``court_verdict_fetch`` runs the ingest pipeline, which drives
|
||||
halacha extraction via the local ``claude`` CLI — same constraint as
|
||||
``precedent_process_pending``. Invoking it from the container will fail.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from legal_mcp.services import court_fetch_orchestrator as orch
|
||||
from legal_mcp.services import db
|
||||
from legal_mcp.tools.envelope import err as _err, ok as _ok
|
||||
|
||||
|
||||
async def court_verdict_fetch(citation: str) -> str:
|
||||
"""אחזור אוטומטי של פסק-דין בית-משפט וקליטה לקורפוס.
|
||||
|
||||
מקבל ציטוט (למשל 'עת"מ 46111-12-22' או 'עע"מ 1234/22'), מסווג את הערכאה,
|
||||
מוריד את הפסק מהמקור הציבורי המתאים, וקולט אותו דרך צינור-הקליטה הקנוני.
|
||||
ערר/בל"מ (ועדת-ערר) אינם ניתנים לאחזור ציבורי ויסומנו כפער.
|
||||
"""
|
||||
if not (citation or "").strip():
|
||||
return _err("citation is required")
|
||||
try:
|
||||
result = await orch.fetch_and_ingest(citation.strip())
|
||||
except Exception as e: # noqa: BLE001 — surfaced, not swallowed (INV-CF2)
|
||||
return _err(f"אחזור נכשל: {e}")
|
||||
|
||||
status = result.get("status")
|
||||
if status in ("done", "already_done"):
|
||||
return _ok(result, message="הפסק נקלט לקורפוס")
|
||||
if status == "skipped":
|
||||
return _ok(result, message="ועדת-ערר — לא ניתן לאחזור ציבורי (סומן כפער)")
|
||||
if status in ("manual", "awaiting_manual"):
|
||||
return _ok(result, message="האחזור האוטונומי נכשל — הוסלם להורדה ידנית")
|
||||
if status == "unrecognized":
|
||||
return _err("הציטוט לא זוהה כמספר-תיק תקין")
|
||||
return _ok(result, message=f"סטטוס: {status}")
|
||||
|
||||
|
||||
async def court_fetch_status(case_number: str = "", status_filter: str = "") -> str:
|
||||
"""סטטוס תור-האחזור. case_number לפריט יחיד, או status_filter לסינון רשימה."""
|
||||
if case_number.strip():
|
||||
from legal_mcp.services.court_citation import normalize_case_number
|
||||
job = await db.court_fetch_job_get(normalize_case_number(case_number))
|
||||
if not job:
|
||||
return _ok({"job": None}, message="אין job עבור תיק זה")
|
||||
return _ok({"job": job})
|
||||
jobs = await db.court_fetch_job_list(status=status_filter.strip() or None)
|
||||
return _ok({"jobs": jobs, "count": len(jobs)})
|
||||
|
||||
|
||||
async def court_fetch_drain(limit: int = 10) -> str:
|
||||
"""ריקון תור-האחזור: מוריד וקולט את ה-jobs הממתינים (pending/failed) שהיומונים
|
||||
מילאו, וקושר כל פסק שנקלט חזרה ליומון-המקור. סדרתי. כלי מקומי בלבד."""
|
||||
try:
|
||||
result = await orch.drain_pending(limit=max(1, min(int(limit or 10), 50)))
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _err(f"ריקון התור נכשל: {e}")
|
||||
return _ok(result, message=f"עובדו {result.get('processed', 0)}, נקלטו {result.get('done', 0)}")
|
||||
172
mcp-server/src/legal_mcp/tools/digests.py
Normal file
172
mcp-server/src/legal_mcp/tools/digests.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""MCP tools for the Digests radar (X12).
|
||||
|
||||
A digest ("כל יום" daily one-pager, Ofer Toister) is a SECONDARY, discovery-
|
||||
layer source that POINTS at a ruling. It is distinct from the three citation
|
||||
corpora:
|
||||
|
||||
- ``search_precedent_library`` — authoritative external court rulings.
|
||||
- ``search_internal_decisions`` — appeals-committee decisions.
|
||||
- ``search_decisions`` — Dafna's prior decisions (style corpus).
|
||||
|
||||
A digest is NEVER cited in a decision (INV-DIG1) and NEVER enters the halacha
|
||||
pipeline (INV-DIG2). ``search_digests`` is a research compass: it surfaces the
|
||||
relevant digest + the UNDERLYING ruling's citation, which is then ingested into
|
||||
the precedent library and cited from there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import db, digest_library, telemetry
|
||||
from legal_mcp.tools.envelope import empty, err as _err, ok as _ok
|
||||
|
||||
|
||||
async def digest_upload(
|
||||
file_path: str,
|
||||
yomon_number: str = "",
|
||||
digest_date: str = "",
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
subject_tags: list[str] | None = None,
|
||||
) -> str:
|
||||
"""העלאת יומון ("כל יום") לקורפוס-הגילוי + חילוץ מטא-דאטה אוטומטי.
|
||||
|
||||
היומון הוא מקור-משני המצביע על פסק הדין המקורי — אינו מצוטט בהחלטה.
|
||||
Args:
|
||||
file_path: נתיב מלא לקובץ PDF/DOCX של היומון.
|
||||
yomon_number: מספר היומון (אופציונלי — יחולץ מהטקסט אם ריק).
|
||||
digest_date: ISO date של גיליון היומון (אופציונלי).
|
||||
practice_area: rishuy_uvniya / betterment_levy / compensation_197.
|
||||
subject_tags: תגיות נושא (אופציונלי — יחולצו אם ריק).
|
||||
Returns: JSON עם digest_id, מספר היומון, מראה-המקום, וקישור-אוטומטי אם נמצא.
|
||||
"""
|
||||
try:
|
||||
result = await digest_library.ingest_digest(
|
||||
file_path=file_path,
|
||||
yomon_number=yomon_number,
|
||||
digest_date=digest_date or None,
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype,
|
||||
subject_tags=subject_tags or None,
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(str(e))
|
||||
return _ok(result)
|
||||
|
||||
|
||||
async def digest_list(
|
||||
practice_area: str = "",
|
||||
concept_tag: str = "",
|
||||
linked: bool | None = None,
|
||||
search: str = "",
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""רשימת יומונים בקורפוס-הגילוי, עם פילטרים. linked=false → יומונים שהפסק
|
||||
המקורי שלהם עוד לא נקלט לספריית הפסיקה (פער-ידע גלוי)."""
|
||||
rows = await digest_library.list_digests(
|
||||
practice_area=practice_area,
|
||||
concept_tag=concept_tag,
|
||||
linked=linked,
|
||||
search=search,
|
||||
limit=limit,
|
||||
)
|
||||
return _ok(rows)
|
||||
|
||||
|
||||
async def digest_get(digest_id: str) -> str:
|
||||
"""יומון ספציפי לפי מזהה."""
|
||||
try:
|
||||
cid = UUID(digest_id)
|
||||
except ValueError:
|
||||
return _err("digest_id לא תקין")
|
||||
record = await digest_library.get_digest(cid)
|
||||
if not record:
|
||||
return _err("יומון לא נמצא")
|
||||
return _ok(record)
|
||||
|
||||
|
||||
async def digest_link(digest_id: str, case_law_id: str) -> str:
|
||||
"""קישור ידני של יומון לפסק הדין המקורי בספריית הפסיקה (INV-DIG3)."""
|
||||
try:
|
||||
UUID(digest_id)
|
||||
UUID(case_law_id)
|
||||
except ValueError:
|
||||
return _err("מזהה לא תקין")
|
||||
try:
|
||||
result = await digest_library.link_digest(digest_id, case_law_id)
|
||||
except Exception as e:
|
||||
return _err(str(e))
|
||||
return _ok(result)
|
||||
|
||||
|
||||
async def digest_relink(digest_id: str) -> str:
|
||||
"""ניסיון-קישור מחדש: בודק אם פסק הדין המקורי של היומון כבר בספרייה ומקשר."""
|
||||
try:
|
||||
UUID(digest_id)
|
||||
except ValueError:
|
||||
return _err("digest_id לא תקין")
|
||||
try:
|
||||
result = await digest_library.relink_digest(digest_id)
|
||||
except Exception as e:
|
||||
return _err(str(e))
|
||||
return _ok(result)
|
||||
|
||||
|
||||
async def digest_delete(digest_id: str) -> str:
|
||||
"""מחיקת יומון מקורפוס-הגילוי."""
|
||||
try:
|
||||
cid = UUID(digest_id)
|
||||
except ValueError:
|
||||
return _err("digest_id לא תקין")
|
||||
ok_ = await digest_library.delete_digest(cid)
|
||||
if not ok_:
|
||||
return _err("יומון לא נמצא")
|
||||
return _ok({"deleted": True, "digest_id": digest_id})
|
||||
|
||||
|
||||
async def search_digests(
|
||||
query: str,
|
||||
practice_area: str = "",
|
||||
subject_tag: str = "",
|
||||
concept_tag: str = "",
|
||||
limit: int = 10,
|
||||
) -> str:
|
||||
"""חיפוש סמנטי בקורפוס-הגילוי (יומוני "כל יום"). מצפן-מחקר בלבד — מחזיר את
|
||||
היומון הרלוונטי + מראה-המקום של הפסק המקורי (radar). היומון אינו מצוטט
|
||||
בהחלטה (INV-DIG1); הצטט מהפסק המקורי דרך search_precedent_library."""
|
||||
if not query or len(query.strip()) < 2:
|
||||
return empty("שאילתה קצרה מדי (פחות מ-2 תווים).")
|
||||
q = query.strip()
|
||||
t0 = time.perf_counter()
|
||||
results = await digest_library.search_digests(
|
||||
query=q,
|
||||
practice_area=practice_area,
|
||||
subject_tag=subject_tag,
|
||||
concept_tag=concept_tag,
|
||||
limit=limit,
|
||||
)
|
||||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||
telemetry.log_search_bg(
|
||||
search_type="digests",
|
||||
query=q,
|
||||
results=results,
|
||||
duration_ms=elapsed_ms,
|
||||
practice_area=practice_area or None,
|
||||
user_agent="unknown",
|
||||
)
|
||||
if not results:
|
||||
return empty("לא נמצאו יומונים תואמים.")
|
||||
return _ok(results)
|
||||
|
||||
|
||||
async def digest_process_pending(limit: int = 20) -> str:
|
||||
"""ריקון תור היומונים שהועלו מה-UI וממתינים לעיבוד-LLM מקומי. מריץ חילוץ-
|
||||
מטא-דאטה + embedding + autolink על כל יומון בסטטוס 'pending', מקומית עם
|
||||
ה-CLI (claude_session local-only). מנקה לסטטוס 'completed'."""
|
||||
try:
|
||||
result = await digest_library.process_pending_digests(limit=limit)
|
||||
except Exception as e:
|
||||
return _err(str(e))
|
||||
return _ok(result)
|
||||
@@ -4,12 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import audit, db, git_sync, processor
|
||||
from legal_mcp.services import audit, db, git_sync, processor, storage
|
||||
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||
|
||||
|
||||
@@ -50,11 +50,14 @@ async def document_upload(
|
||||
"idempotent_existing": True,
|
||||
}, message=f"הקובץ כבר הועלה לתיק {case_number} (זהה ב-hash) — מוחזר הקיים, ללא עיבוד מחדש.")
|
||||
|
||||
# Copy file to case directory
|
||||
case_dir = config.find_case_dir(case_number) / "documents" / "originals"
|
||||
case_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = case_dir / source.name
|
||||
shutil.copy2(str(source), str(dest))
|
||||
# Stage the original through the unified storage layer (INV-STG1).
|
||||
dest = config.find_case_dir(case_number) / "documents" / "originals" / source.name
|
||||
await storage.put_file(
|
||||
source, dest.relative_to(config.DATA_DIR).as_posix(),
|
||||
bucket=storage.Bucket.DOCUMENTS,
|
||||
content_type=mimetypes.guess_type(source.name)[0],
|
||||
metadata={"filename": source.name},
|
||||
)
|
||||
|
||||
# For auto classification, start with "reference" — will be updated after processing
|
||||
initial_doc_type = doc_type if doc_type != "auto" else "reference"
|
||||
@@ -156,10 +159,14 @@ async def document_upload_training(
|
||||
}
|
||||
subdir = _SUBTYPE_DIRS.get(appeal_subtype, "")
|
||||
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
|
||||
if source.resolve() != dest.resolve():
|
||||
shutil.copy2(str(source), str(dest))
|
||||
await storage.put_file(
|
||||
source, dest.relative_to(config.DATA_DIR).as_posix(),
|
||||
bucket=storage.Bucket.DOCUMENTS,
|
||||
content_type=mimetypes.guess_type(source.name)[0],
|
||||
metadata={"filename": source.name},
|
||||
)
|
||||
|
||||
# Extract text and strip Nevo preamble
|
||||
text, page_count, _ = await extractor.extract_text(str(dest))
|
||||
|
||||
91
mcp-server/tests/test_court_citation.py
Normal file
91
mcp-server/tests/test_court_citation.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Unit tests for the X13 court-citation classifier."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from legal_mcp.services.court_citation import classify, normalize_case_number
|
||||
|
||||
|
||||
def test_admin_filed_format_the_example():
|
||||
"""The plan's example: עת"מ 46111-12-22 → admin, parsed into (46111,12,22)."""
|
||||
c = classify('עת"מ 46111-12-22 יכין-אפק בע"מ נ\' הוועדה המחוזית')
|
||||
assert c.tier == "admin"
|
||||
assert c.court_prefix in ('עת"מ', "עת״מ")
|
||||
assert c.case_number_raw == "46111-12-22"
|
||||
assert c.case_number_norm == "46111-12-22"
|
||||
assert (c.file_number, c.month, c.year) == ("46111", "12", "22")
|
||||
assert c.fetchable is True
|
||||
|
||||
|
||||
def test_bare_filed_number_defaults_admin():
|
||||
c = classify("46111-12-22")
|
||||
assert c.tier == "admin"
|
||||
assert (c.file_number, c.month, c.year) == ("46111", "12", "22")
|
||||
|
||||
|
||||
def test_supreme_prefixes():
|
||||
for cit, pref in [
|
||||
('עע"מ 1234/22', "supreme"),
|
||||
('בג"ץ 5678/21', "supreme"),
|
||||
('ע"א 999/20', "supreme"),
|
||||
('רע"א 4/19', "supreme"),
|
||||
('בר"מ 8126/24', "supreme"),
|
||||
]:
|
||||
c = classify(cit)
|
||||
assert c.tier == pref, f"{cit} -> {c.tier}"
|
||||
assert c.fetchable is True
|
||||
|
||||
|
||||
def test_appeals_committee_is_skip():
|
||||
"""ערר / בל"מ must never be auto-fetched (needs Nevo) — INV-CF6."""
|
||||
for cit in ['ערר 1110/20', 'בל"מ 8048/24', "ערר 1015-01-24 ירושלים שקופה"]:
|
||||
c = classify(cit)
|
||||
assert c.tier == "skip", f"{cit} -> {c.tier}"
|
||||
assert c.fetchable is False
|
||||
|
||||
|
||||
def test_skip_wins_over_court_match():
|
||||
"""An 'ערר' citation that also contains court-like digits stays skip."""
|
||||
c = classify("ראה החלטתי בערר 1041/24 ובהמשך")
|
||||
assert c.tier == "skip"
|
||||
|
||||
|
||||
def test_admin_amn_prefix():
|
||||
c = classify('עמ"נ 12345-06-23')
|
||||
assert c.tier == "admin"
|
||||
assert (c.file_number, c.month, c.year) == ("12345", "06", "23")
|
||||
|
||||
|
||||
def test_two_group_serial_has_no_filed_triple():
|
||||
"""Supreme serial 1234/22 normalizes but yields no (file,month,year)."""
|
||||
c = classify('עע"מ 1234/22')
|
||||
assert c.case_number_norm == "1234-22"
|
||||
assert c.file_number is None
|
||||
|
||||
|
||||
def test_implausible_month_not_parsed_as_filed():
|
||||
# 1234-22-05 has month=22 → not a valid filed triple.
|
||||
assert classify("1234-22-05").tier in ("unknown", "admin")
|
||||
c = classify("1234-22-05")
|
||||
if c.tier == "admin":
|
||||
assert c.month is None
|
||||
|
||||
|
||||
def test_empty_and_garbage():
|
||||
assert classify("").tier == "unknown"
|
||||
assert classify("שלום עולם בלי ציטוט").tier == "unknown"
|
||||
|
||||
|
||||
def test_normalize_case_number():
|
||||
assert normalize_case_number('עת"מ 46111/12/22') == "46111-12-22"
|
||||
assert normalize_case_number("1110/20") == "1110-20"
|
||||
|
||||
|
||||
def test_supreme_with_net_format_triple():
|
||||
"""A Supreme prefix carrying a נט-format number exposes the triple so the
|
||||
orchestrator can route it to Tier-1 (נט המשפט serves Supreme too)."""
|
||||
c = classify('בר"מ 72182-06-25 הימנותא נ\' הוועדה המקומית')
|
||||
assert c.tier == "supreme"
|
||||
assert (c.file_number, c.month, c.year) == ("72182", "06", "25")
|
||||
# serial-format Supreme has no triple → stays Tier-0-only
|
||||
s = classify('עע"מ 5886/24')
|
||||
assert s.tier == "supreme" and s.file_number is None
|
||||
46
mcp-server/tests/test_halacha_coerce.py
Normal file
46
mcp-server/tests/test_halacha_coerce.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""rule_type coercion after the authority/role split (INV-DM7).
|
||||
|
||||
The extractor's rule_type holds the rule ROLE only — it is never defaulted from
|
||||
the source's bindingness. Legacy authority values fold to the nearest role.
|
||||
"""
|
||||
from legal_mcp.services.halacha_extractor import (
|
||||
_LEGACY_RULE_TYPE_FOLD,
|
||||
_VALID_RULE_TYPES,
|
||||
_coerce_halacha,
|
||||
)
|
||||
|
||||
_BASE = {"rule_statement": "כלל כלשהו", "supporting_quote": "ציטוט תומך כלשהו"}
|
||||
|
||||
|
||||
def _rt(rule_type):
|
||||
return _coerce_halacha({**_BASE, "rule_type": rule_type})["rule_type"]
|
||||
|
||||
|
||||
def test_valid_roles_are_the_five_roles_only():
|
||||
assert _VALID_RULE_TYPES == {
|
||||
"holding", "interpretive", "procedural", "application", "obiter",
|
||||
}
|
||||
assert "binding" not in _VALID_RULE_TYPES
|
||||
assert "persuasive" not in _VALID_RULE_TYPES
|
||||
|
||||
|
||||
def test_legacy_authority_values_fold_to_a_role():
|
||||
assert _rt("binding") == "holding"
|
||||
assert _rt("persuasive") == "interpretive"
|
||||
assert _LEGACY_RULE_TYPE_FOLD == {"binding": "holding", "persuasive": "interpretive"}
|
||||
|
||||
|
||||
def test_genuine_roles_pass_through():
|
||||
for role in ("holding", "interpretive", "procedural", "application", "obiter"):
|
||||
assert _rt(role) == role
|
||||
|
||||
|
||||
def test_unknown_or_missing_defaults_to_interpretive():
|
||||
assert _rt("nonsense") == "interpretive"
|
||||
assert _coerce_halacha(_BASE)["rule_type"] == "interpretive"
|
||||
|
||||
|
||||
def test_coerce_rejects_rows_missing_required_fields():
|
||||
assert _coerce_halacha({"rule_statement": "x"}) is None
|
||||
assert _coerce_halacha({"supporting_quote": "y"}) is None
|
||||
assert _coerce_halacha("not a dict") is None
|
||||
@@ -211,23 +211,40 @@ def test_application_flag_from_rule_type():
|
||||
assert hq.FLAG_APPLICATION in flags
|
||||
|
||||
|
||||
def test_application_flag_from_deixis_even_if_binding():
|
||||
def test_application_flag_from_deixis_even_if_holding():
|
||||
flags = hq.compute_quality_flags(
|
||||
"במקרה דנן נדחה הערר", "כפי שקבענו במקרה דנן נדחה הערר",
|
||||
rule_type="binding",
|
||||
rule_type="holding",
|
||||
)
|
||||
assert hq.FLAG_APPLICATION in flags
|
||||
|
||||
|
||||
def test_clean_binding_rule_has_no_flags():
|
||||
def test_clean_holding_rule_has_no_flags():
|
||||
flags = hq.compute_quality_flags(
|
||||
"ועדת הערר מוסמכת לדון בטענות חוקתיות הנוגעות לתכנית",
|
||||
"הוועדה מוסמכת לדון אף בטענות מסוג זה, ככל שהן נוגעות לתכנית שבנדון.",
|
||||
rule_type="binding",
|
||||
rule_type="holding",
|
||||
)
|
||||
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 ──
|
||||
|
||||
def test_jaccard_high_for_reworded_same_rule():
|
||||
|
||||
198
mcp-server/tests/test_storage.py
Normal file
198
mcp-server/tests/test_storage.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Unit tests for the unified storage layer (X14, services/storage.py).
|
||||
|
||||
Sync tests driving the async API via asyncio.run (matches the repo
|
||||
convention — no pytest-asyncio). The filesystem backend is exercised against a
|
||||
tmp DATA_DIR; the S3 path is stubbed so the suite needs no MinIO and no
|
||||
aioboto3.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import storage
|
||||
from legal_mcp.services.storage import Bucket
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tmp_datadir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(config, "STORAGE_BACKEND", "filesystem")
|
||||
storage.reset_storage_cache()
|
||||
yield tmp_path
|
||||
storage.reset_storage_cache()
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# ── normalize_key ──────────────────────────────────────────────────
|
||||
|
||||
def test_normalize_key_relative():
|
||||
assert storage.normalize_key("cases/8174-24/originals/x.pdf") == \
|
||||
"cases/8174-24/originals/x.pdf"
|
||||
|
||||
|
||||
def test_normalize_key_rejects_parent_traversal():
|
||||
with pytest.raises(ValueError):
|
||||
storage.normalize_key("cases/../../etc/passwd")
|
||||
|
||||
|
||||
def test_normalize_key_accepts_abs_under_datadir(_tmp_datadir):
|
||||
abs_key = _tmp_datadir / "cases" / "x.pdf"
|
||||
assert storage.normalize_key(abs_key) == "cases/x.pdf"
|
||||
|
||||
|
||||
def test_normalize_key_rejects_abs_outside_datadir():
|
||||
with pytest.raises(ValueError):
|
||||
storage.normalize_key("/var/secret/x.pdf")
|
||||
|
||||
|
||||
# ── filesystem backend ─────────────────────────────────────────────
|
||||
|
||||
def test_filesystem_roundtrip(_tmp_datadir):
|
||||
be = storage.FilesystemBackend()
|
||||
key = "cases/1/originals/a.pdf"
|
||||
uri = run(be.put_bytes(key, b"hello", content_type="application/pdf"))
|
||||
assert uri.startswith("file://")
|
||||
assert (_tmp_datadir / key).read_bytes() == b"hello"
|
||||
assert run(be.exists(key)) is True
|
||||
assert run(be.get_bytes(key)) == b"hello"
|
||||
run(be.delete(key))
|
||||
assert run(be.exists(key)) is False
|
||||
# delete is idempotent (missing_ok)
|
||||
run(be.delete(key))
|
||||
|
||||
|
||||
def test_filesystem_put_file(_tmp_datadir):
|
||||
be = storage.FilesystemBackend()
|
||||
src = _tmp_datadir / "src.bin"
|
||||
src.write_bytes(b"payload")
|
||||
run(be.put_file(src, "precedent-library/court_ruling/u.pdf"))
|
||||
assert (_tmp_datadir / "precedent-library/court_ruling/u.pdf").read_bytes() == b"payload"
|
||||
|
||||
|
||||
def test_filesystem_bucket_is_ignored_legacy_layout(_tmp_datadir):
|
||||
"""Even for a non-default bucket, the FS backend keeps the single tree —
|
||||
so the default backend is byte-for-byte the legacy layout."""
|
||||
be = storage.FilesystemBackend()
|
||||
run(be.put_bytes("thumbnails/d/p001.jpg", b"img", bucket=Bucket.DERIVED))
|
||||
assert (_tmp_datadir / "thumbnails/d/p001.jpg").exists()
|
||||
|
||||
|
||||
def test_filesystem_list_keys(_tmp_datadir):
|
||||
be = storage.FilesystemBackend()
|
||||
run(be.put_bytes("cases/1/a.txt", b"a"))
|
||||
run(be.put_bytes("cases/1/sub/b.txt", b"b"))
|
||||
run(be.put_bytes("cases/2/c.txt", b"c"))
|
||||
keys = run(be.list_keys("cases/1"))
|
||||
assert keys == ["cases/1/a.txt", "cases/1/sub/b.txt"]
|
||||
|
||||
|
||||
def test_filesystem_local_path_and_ensure_local(_tmp_datadir):
|
||||
be = storage.FilesystemBackend()
|
||||
run(be.put_bytes("cases/1/a.pdf", b"x"))
|
||||
assert be.local_path("cases/1/a.pdf") == (_tmp_datadir / "cases/1/a.pdf").resolve()
|
||||
assert be.local_path("cases/1/missing.pdf") is None
|
||||
# ensure_local returns the real path without copying
|
||||
assert run(be.ensure_local("cases/1/a.pdf")) == (_tmp_datadir / "cases/1/a.pdf").resolve()
|
||||
|
||||
|
||||
def test_filesystem_presign_unsupported(_tmp_datadir):
|
||||
be = storage.FilesystemBackend()
|
||||
with pytest.raises(NotImplementedError):
|
||||
run(be.presign_get("cases/1/a.pdf"))
|
||||
|
||||
|
||||
# ── backend selection ──────────────────────────────────────────────
|
||||
|
||||
def test_get_storage_default_is_filesystem(monkeypatch):
|
||||
monkeypatch.setattr(config, "STORAGE_BACKEND", "filesystem")
|
||||
storage.reset_storage_cache()
|
||||
assert isinstance(storage.get_storage(), storage.FilesystemBackend)
|
||||
|
||||
|
||||
def test_get_storage_unknown_falls_back(monkeypatch):
|
||||
monkeypatch.setattr(config, "STORAGE_BACKEND", "bogus")
|
||||
storage.reset_storage_cache()
|
||||
assert isinstance(storage.get_storage(), storage.FilesystemBackend)
|
||||
|
||||
|
||||
def test_get_storage_dual(monkeypatch):
|
||||
monkeypatch.setattr(config, "STORAGE_BACKEND", "dual")
|
||||
storage.reset_storage_cache()
|
||||
assert isinstance(storage.get_storage(), storage.DualBackend)
|
||||
|
||||
|
||||
# ── dual backend (S3 stubbed) ──────────────────────────────────────
|
||||
|
||||
class _FakeS3:
|
||||
"""Minimal async S3 stub. ``store`` None ⇒ S3 'down' (raises)."""
|
||||
|
||||
def __init__(self, store):
|
||||
self.store = store # dict or None
|
||||
|
||||
async def put_bytes(self, key, data, *, bucket=Bucket.DOCUMENTS, content_type=None, metadata=None):
|
||||
if self.store is None:
|
||||
raise RuntimeError("s3 down")
|
||||
self.store[storage.normalize_key(key)] = data
|
||||
return f"s3://stub/{storage.normalize_key(key)}"
|
||||
|
||||
async def put_file(self, src, key, *, bucket=Bucket.DOCUMENTS, content_type=None, metadata=None):
|
||||
with open(src, "rb") as fh:
|
||||
return await self.put_bytes(key, fh.read(), bucket=bucket)
|
||||
|
||||
async def get_bytes(self, key, *, bucket=Bucket.DOCUMENTS):
|
||||
if self.store is None:
|
||||
raise RuntimeError("s3 down")
|
||||
return self.store[storage.normalize_key(key)]
|
||||
|
||||
async def exists(self, key, *, bucket=Bucket.DOCUMENTS):
|
||||
return self.store is not None and storage.normalize_key(key) in self.store
|
||||
|
||||
async def delete(self, key, *, bucket=Bucket.DOCUMENTS):
|
||||
if self.store is not None:
|
||||
self.store.pop(storage.normalize_key(key), None)
|
||||
|
||||
|
||||
def _dual_with(store):
|
||||
dual = storage.DualBackend()
|
||||
dual.s3 = _FakeS3(store)
|
||||
return dual
|
||||
|
||||
|
||||
def test_dual_writes_both(_tmp_datadir):
|
||||
store = {}
|
||||
dual = _dual_with(store)
|
||||
run(dual.put_bytes("cases/1/a.pdf", b"data"))
|
||||
assert (_tmp_datadir / "cases/1/a.pdf").read_bytes() == b"data" # disk
|
||||
assert store["cases/1/a.pdf"] == b"data" # s3
|
||||
|
||||
|
||||
def test_dual_s3_write_failure_does_not_break(_tmp_datadir):
|
||||
dual = _dual_with(None) # s3 down
|
||||
# disk write still succeeds; s3 failure is logged, not raised
|
||||
run(dual.put_bytes("cases/1/a.pdf", b"data"))
|
||||
assert (_tmp_datadir / "cases/1/a.pdf").read_bytes() == b"data"
|
||||
|
||||
|
||||
def test_dual_get_prefers_s3(_tmp_datadir):
|
||||
dual = _dual_with({"cases/1/a.pdf": b"from-s3"})
|
||||
run(dual.fs.put_bytes("cases/1/a.pdf", b"from-disk"))
|
||||
assert run(dual.get_bytes("cases/1/a.pdf")) == b"from-s3"
|
||||
|
||||
|
||||
def test_dual_get_falls_back_to_disk(_tmp_datadir):
|
||||
dual = _dual_with(None) # s3 down → must read disk
|
||||
run(dual.fs.put_bytes("cases/1/a.pdf", b"from-disk"))
|
||||
assert run(dual.get_bytes("cases/1/a.pdf")) == b"from-disk"
|
||||
|
||||
|
||||
def test_bucket_name_resolution(monkeypatch):
|
||||
monkeypatch.setattr(config, "MINIO_BUCKET_DOCUMENTS", "legal-documents")
|
||||
monkeypatch.setattr(config, "MINIO_BUCKET_IMMUTABLE", "legal-immutable")
|
||||
monkeypatch.setattr(config, "MINIO_BUCKET_DERIVED", "legal-derived")
|
||||
assert storage._bucket_name(Bucket.DOCUMENTS) == "legal-documents"
|
||||
assert storage._bucket_name(Bucket.IMMUTABLE) == "legal-immutable"
|
||||
assert storage._bucket_name(Bucket.DERIVED) == "legal-derived"
|
||||
77
mcp-server/tests/test_storage_staging.py
Normal file
77
mcp-server/tests/test_storage_staging.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Regression tests for the write call-sites rewired onto storage.py (X14
|
||||
Phase 2). They assert the rewired staging lands bytes at the exact legacy
|
||||
on-disk location under the default filesystem backend — i.e. zero behaviour
|
||||
change.
|
||||
"""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import ingest, storage
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tmp_datadir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(config, "STORAGE_BACKEND", "filesystem")
|
||||
storage.reset_storage_cache()
|
||||
yield tmp_path
|
||||
storage.reset_storage_cache()
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_stage_file_lands_under_datadir(_tmp_datadir):
|
||||
src = _tmp_datadir / "src" / "כתב ערר.pdf"
|
||||
src.parent.mkdir(parents=True)
|
||||
src.write_bytes(b"%PDF-1.4 ...")
|
||||
root = _tmp_datadir / "precedent-library"
|
||||
dest = run(ingest._stage_file(src, root, "court_ruling"))
|
||||
# dest is under the staging subdir, prefixed with a uuid, original suffix kept
|
||||
assert dest.parent == root / "court_ruling"
|
||||
assert dest.exists()
|
||||
assert dest.read_bytes() == b"%PDF-1.4 ..."
|
||||
assert dest.suffix == ".pdf"
|
||||
# and the key is DATA_DIR-relative (what the DB column will store)
|
||||
assert dest.relative_to(_tmp_datadir).as_posix().startswith("precedent-library/court_ruling/")
|
||||
|
||||
|
||||
def test_stage_file_default_subdir(_tmp_datadir):
|
||||
src = _tmp_datadir / "x.docx"
|
||||
src.write_bytes(b"doc")
|
||||
dest = run(ingest._stage_file(src, _tmp_datadir / "digests", ""))
|
||||
assert dest.parent == _tmp_datadir / "digests" / "other"
|
||||
assert dest.exists()
|
||||
|
||||
|
||||
def test_thumbnail_renderer_routes_through_storage(_tmp_datadir):
|
||||
"""extractor.render_pages_for_multimodal (a sync renderer) now persists the
|
||||
JPEG thumbnail via the sync storage facade — under filesystem it must land
|
||||
at the requested thumbnail_dir."""
|
||||
fitz = pytest.importorskip("fitz")
|
||||
from legal_mcp.services import extractor
|
||||
|
||||
pdf = _tmp_datadir / "doc.pdf"
|
||||
d = fitz.open()
|
||||
d.new_page(width=200, height=200)
|
||||
d.save(str(pdf))
|
||||
d.close()
|
||||
|
||||
thumb_dir = _tmp_datadir / "cases" / "1" / "thumbnails" / "docid"
|
||||
out = extractor.render_pages_for_multimodal(pdf, embed_dpi=72, thumb_dpi=36,
|
||||
thumbnail_dir=thumb_dir)
|
||||
assert len(out) == 1
|
||||
_img, thumb_path = out[0]
|
||||
assert thumb_path == thumb_dir / "p001.jpg"
|
||||
assert thumb_path.exists() # written through storage.put_bytes_sync (DERIVED)
|
||||
assert thumb_path.read_bytes()[:2] == b"\xff\xd8" # JPEG magic
|
||||
|
||||
|
||||
def test_put_bytes_sync_roundtrip(_tmp_datadir):
|
||||
src_key = "cases/1/exports/x.docx"
|
||||
storage.put_bytes_sync(src_key, b"PK\x03\x04zip", bucket=storage.Bucket.DOCUMENTS)
|
||||
assert (_tmp_datadir / src_key).read_bytes() == b"PK\x03\x04zip"
|
||||
@@ -19,6 +19,14 @@
|
||||
| `fu2c_reconcile_external_case_numbers.py` | python | **FU-2c (GAP-08, #68) — תיאום `case_number` של פסיקה חיצונית** (`source_kind <> internal_committee`) מציטוט-מלא לצורה קנונית **מציין-הליך + docket** (החלטת-יו"ר 2026-05-31, Option A: `/` נשמר, *לא* `-`; תואם db.py:369 ו-INV-ID2). דטרמיניסטי (designator+docket; 0/>1 docket → flag). `--dry-run` (ברירת-מחדל) מפיק `data/audit/fu2c-reconciliation-*.{csv,md}` עם flags (MISMATCH / NO_CITATION / CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK). `--apply --approved <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) |
|
||||
| `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 |
|
||||
| `legal-court-fetch-service.config.cjs` | pm2/js | **שירות-מארח Tier-1 לאחזור פסקי-דין מנט המשפט (X13)** — 2 apps: (א) `legal-court-fetch-xvfb` (Xvfb :99, צג-וירטואלי ל-Camoufox); (ב) `legal-court-fetch-service` (`python -m legal_mcp.court_fetch_service.server`, bound `10.0.1.1:8771`, Bearer `COURT_FETCH_SHARED_SECRET` מ-`~/.legal-court-fetch-service.env`, `DISPLAY=:99`). מריץ Camoufox דרך חבילת-הפייתון (in-process) כי הקונטיינר לא יכול דפדפן. תלות: `pip install -e "mcp-server[court-fetch]" && python -m camoufox fetch`. אחזור = ניווט→צופה→`GetImages`(X-Requested-With)→PDF, ללא CAPTCHA; כשל→`ok:false`→orchestrator מסלים ל-fallback אנושי. **אומת על עת"מ 46111-12-22 (34 עמ').** מראָה לדפוס `legal-chat-service.config.cjs`. ספ: `docs/spec/X13-court-fetch.md`. התקנה: `pm2 start scripts/legal-court-fetch-service.config.cjs && pm2 save`. בריאות: `curl http://10.0.1.1:8771/health`. | pm2 (host-side) |
|
||||
| `reap_orphan_procs.py` | python | **reaper לתהליכים-יתומים שמרווים את שרת Nautilus** — הורג `task-master-mcp` (Node, מתנפח ל~3GB) ו-`camoufox-bin` (Firefox מ-X13 fetch שקרס) **רק כשהם יתומים (`ppid=1`)** — תהליך עם הורה-חי לעולם לא נוגעים בו. `/proc` טהור, בלי psutil. `--dry-run` (דיווח), `--loop N` (דמון כל N ש'). ראה זיכרון [[project_taskmaster_mcp_memory_leak]]. | דרך `legal-reaper.config.cjs` (pm2) |
|
||||
| `legal-reaper.config.cjs` | pm2/js | **דמון pm2 ל-`reap_orphan_procs.py --loop`** (ברירת-מחדל 180ש', `REAP_INTERVAL_S` לעקיפה). `max_memory_restart 100M` (ה-reaper עצמו לא ידלוף). התקנה: `pm2 start scripts/legal-reaper.config.cjs && pm2 save`. לוגים: `pm2 logs legal-reaper`. | pm2 (host-side) |
|
||||
| `drain_court_fetch.py` | python | **ריקון תור-אחזור הפסיקה (X13)** — קורא ל-`court_fetch_orchestrator.drain_pending(limit)` שמוריד+קולט כל job ממתין שהיומונים מילאו, וקושר חזרה ליומון. מקומי בלבד (ingest = claude CLI). no-op מהיר כשהתור ריק. הרצה ידנית: `mcp-server/.venv/bin/python scripts/drain_court_fetch.py [limit]`. | דרך `legal-court-fetch-drain.config.cjs` (pm2 cron) |
|
||||
| `backfill_missing_precedents.py` | python | **הזנת `missing_precedents` פתוחים לתור-האחזור (X13)** — מסווג כל פער-פתוח; עליון-סדרתי→Tier-0(supremedecisions), נט-format→Tier-1; ועדת-ערר/לא-מזוהה→דילוג. יוצר `court_fetch_jobs` (idempotent). `--apply` (ברירת-מחדל dry-run). אחרי הרצה: drain-court-fetch קולט. | ידני (חד-פעמי/לפי-צורך) |
|
||||
| `legal-court-fetch-drain.config.cjs` | pm2/js | **תזמון שעתי של `drain_court_fetch.py`** (cron `17 * * * *`, `COURT_FETCH_DRAIN_CRON` לעקיפה) — הופך את לולאת יומון→אחזור→קליטה ל-fully-autonomous. `autorestart:false` (one-shot per tick). דורש `legal-court-fetch-service` רץ. התקנה: `pm2 start scripts/legal-court-fetch-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
|
||||
| `drain_metadata_queue.py` | python | **ריקון תור חילוץ-המטא של הפסיקה** — `process_pending_extractions(kind='metadata')` ב-batches עד ריק. רץ על **Gemini Flash** (structured JSON, `gemini_session`) — מהיר ואמין, במקום ה-claude CLI ה-agentic שפגע ב-`error_max_turns`. no-op מהיר כשריק. הרצה ידנית: `mcp-server/.venv/bin/python scripts/drain_metadata_queue.py [batch]`. | דרך `legal-metadata-drain.config.cjs` (pm2 cron) |
|
||||
| `legal-metadata-drain.config.cjs` | pm2/js | **תזמון כל 15 דק' של `drain_metadata_queue.py`** (cron `*/15 * * * *`, `METADATA_DRAIN_CRON` לעקיפה) — מונע סתימה של תור חילוץ-המטא ב-/precedents. דורש `GEMINI_API_KEY` ב-`~/.env`. התקנה: `pm2 start scripts/legal-metadata-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
|
||||
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
||||
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
||||
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
||||
@@ -38,8 +46,18 @@
|
||||
| `rechunk_legacy_precedents.py` | python | **#57** — re-chunk + re-embed פסיקה שהוטמעה לפני תיקון ה-chunker (#55). בוחר כל `case_law` עם chunk זעיר (`length(trim(content))<50` — טביעת-האצבע של ה-chunker הישן) ומריץ `ingest.reindex_case_law` (re-chunk+re-embed מ-`full_text` שמור בלבד — ללא re-OCR/LLM, feedback_no_reocr_retrofit; idempotent DELETE-then-INSERT). idempotent ברמת-הבאטץ' (שואב מחדש את הסט המושפע בכל ריצה). דגל `--limit N`. רץ עם venv של mcp-server (`cd mcp-server && .venv/bin/python ../scripts/rechunk_legacy_precedents.py`) | חד-פעמי — מיגרציית-נתונים של פסיקה legacy (תוקן 2026-06-03) |
|
||||
| `backfill_nevo_preamble.py` | python | **#86.2** — מיגרציית-נתונים: חיתוך preamble/רציו של נבו שדלף לפסיקה שהוטמעה לפני תיקון #86.1. מאתר כל `case_law` ש-`strip_nevo_preamble(full_text)` עדיין מקצר (דליפה היסטורית), ומבצע: (1) לכידת ה-מיני-רציו ל-`case_law.nevo_ratio` (gold-set ל-#86.3); (2) שכתוב `full_text` החתוך + חישוב-מחדש של `content_hash`; (3) `reindex_case_law` (re-chunk+embed, ללא re-OCR/LLM); (4) **סימון (לא מחיקה)** הלכות ש-`supporting_quote` שלהן בתוך ה-preamble שהוסר → `pending_review` + quality_flag `nevo_preamble_leak`. **שומר-בטיחות:** שורות עם keep%<`--min-keep` (ברירת-מחדל 60) מוחרגות מ-`--apply` כחשד over-strip (אלא אם `--include-suspicious`). **dry-run כברירת-מחדל**; `--apply` כותב backup JSON + manifest CSV ל-`data/audit/` תחילה. idempotent. רץ עם venv של mcp-server. **chair-gated** (לאמת manifest לפני apply) | מיגרציית-נתונים — dry-run בוצע (19 פסקים, 27 הלכות מזוהמות); apply ממתין לאישור |
|
||||
| `nevo_ratio_benchmark.py` | python | **#86.3** — מדידת איכות חילוץ-הלכות מול ה-מיני-רציו של נבו (gold-set מקצועי חינמי). לכל פסק עם `nevo_ratio` (או נגזר מ-`full_text` אם טרם בוצע backfill): LLM-judge מקומי (`claude_session`, אפס עלות) ממפה סמנטית את הלכות-המערכת מול הלכות-נבו ומפיק **recall** (כיסוי הלכות-נבו), **precision** (אחוז הלכותינו הממופות), **granularity** (יחס פירוק — איתות over-extraction ל-#81.5). `--case <num>` / `--all [--limit N]` / `--model` / `--out`. כותב CSV ל-`data/audit/`. רץ עם venv של mcp-server (דורש Claude CLI מקומי). אומת על בג"ץ 1764/05: recall 0.875, precision 1.0, granularity 1.75x | ידני — מדידת-איכות (CI/ad-hoc) |
|
||||
| `halacha_goldset.py` | python | **#81.7** — הארנס gold-set לאיכות חילוץ-הלכות. `export --n N` מייצא מדגם מרובד (לפי precedent×rule_type) ל-CSV עם עמודות-תיוג ריקות (`is_holding`/`correct_type`/`quote_complete`) לתיוג ידני (חיים/דפנה). `score --in <csv>` קורא את ה-CSV המתויג ומודד כל ולידטור (`compute_quality_flags`/`is_fact_dependent`/`is_quote_truncated`/`is_thin_restatement`) מול אמת-המידה האנושית: P/R/F1 + confusion. בסיס ל-#81.8 (כיול סף האישור). מייבא את אותם ולידטורים שה-extractor מריץ. רץ עם venv של mcp-server | ידני — export→תיוג→score |
|
||||
| `halacha_batch_reconcile.py` | python | **#82.7** — dedup חוצה-פסקים offline (שמרני, **dry-run בלבד**). dedup-on-insert משווה רק תוך-פסק; כאן סף מחמיר (cosine ≥0.95, `--cosine`) ולא-הרסני: מאתר זוגות הלכות near-duplicate בין פסקים שונים (pgvector `<=>` exact) עם איתות לקסיקלי (Jaccard/Levenshtein) ומדווח ל-CSV ב-`data/audit/` לסקירת היו"ר. לא מדלג/ממזג/מוחק. `--include-pending`. רץ עם venv של mcp-server. אומת: 819 הלכות → 5 זוגות מועמדים | ידני — דוח-סקירה |
|
||||
| `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_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`. מקפל את הזרימה לפקודה אחת כדי שהסוכן לא ירכיב כמה קריאות (חסין). idempotent. **חובה מקומי**. `--case <num>`. | אוטו (כפתור run-learning) / ידני |
|
||||
| `final_halacha_pipeline.py` | python | **תזמור שלב-אימות-ההלכות (פקודה אחת).** מופעל ע"י הרמס כשלוחצים "הרץ אימות-הלכות". דטרמיניסטי: (1) `extract_internal_citations(chair)`, (2) `corroboration.build_all()`, (3) `halacha_panel_approve --apply`. **חובה מקומי**. `--case <num>` / `--limit N` (תקרת תור). | אוטו (כפתור run-halacha) / ידני |
|
||||
| `curator_apply_pipeline_branch.py` | python | **מקור-אמת לחיווט-הכפתורים של הרמס.** prompt-ה-curator חי רק ב-Paperclip DB (`agents.adapter_config.promptTemplate`). הסקריפט מקדים branch כך שיקיצה עם reason `final_learning_*`/`final_halacha_*` מריצה את ה-pipeline המתאים (HOME/DOTENV/DATA_DIR מוחלטים → DeepSeek+Gemini keys + DATA_DIR נפתרים נכון) ועוצרת, אחרת §A/§B כרגיל. idempotent (מסיר branch קודם). מחיל על שני הסוכנים (CMP+CMPA). `--verify`. **להריץ אחרי reset/יצירה-מחדש של סוכן-curator.** | אחרי reset prompt של curator |
|
||||
| `halacha_panel_audit.py` | python | **רשת-ביטחון לפאנל** (selective-prediction monitoring) — דוגם הלכות שאושרו ע"י הפאנל (`reviewer LIKE 'panel:%'`), מריץ עליהן **שוב** את הצבעת-ה-KEEP של 3 השופטים, ומציף כל מקרה שכעת נוטה DROP (false-keep פוטנציאלי). report-only כברירת-מחדל; `--flag` מחזיר את ה-flips ל-`pending_review` לסקירת-יו"ר. `--sample N`/`--seed`. בסיס 2026-06-07: 0/15. מיועד להרצה תקופתית (שבועי). מייבא שופטים מ-`halacha_panel_approve`. **חובה מקומי**. | תקופתי (שבועי) — ניטור |
|
||||
| `halacha_panel_calibrate.py` | python | **כיול מדיניות-ההצבעה של הפאנל** (Trust-or-Escalate, ICLR 2025). מריץ את שאלת-ה-KEEP של `halacha_panel_approve` על מדגם-הזהב ומודד מול `is_holding` (הציר-הגס) precision+coverage לכל מדיניות (unanimous/majority) + ספירת false-keep/false-drop. נותן את **אחוז-הטעות בפועל** לבחירת סף-סיכון α. מייבא שופטים מ-`halacha_panel_approve` (מקור-אמת יחיד). read-only, **חובה מקומי**. | ידני — לפני חיווט `--apply` |
|
||||
| `halacha_rule_role_backfill.py` | python | **INV-DM7** — backfill חד-פעמי: מסווג-מחדש את ההלכות הישנות (`rule_type IN ('binding','persuasive')` — ערכי-סמכות שנשמרו במסווה תפקיד לפני פיצול הצירים) לאחד מחמשת **תפקידי-הכלל** (holding/interpretive/procedural/application/obiter) דרך claude_session המקומי (אפס עלות). **לא נוגע בסמכות** (נגזרת מ-`precedent_level`). `--apply` (ברירת-מחדל dry-run) / `--limit N` / `--concurrency`. כותב backup CSV ל-`data/audit/` תחילה. fail-safe (פריט שנכשל → נשמר ערך ישן). **חובה מקומי** (claude_session). | ידני חד-פעמי אחרי deploy של פיצול-הסמכות |
|
||||
| `halacha_batch_reconcile.py` | python | **#82.7** — dedup חוצה-פסקים offline (שמרני, **dry-run בלבד**). dedup-on-insert משווה רק תוך-פסק; כאן סף מחמיר (cosine ≥0.95, `--cosine`) ולא-הרסני: מאתר זוגות הלכות near-duplicate בין פסקים שונים (pgvector `<=>` exact) עם איתות לקסיקלי (Jaccard/Levenshtein) ומדווח ל-CSV ב-`data/audit/` לסקירת היו"ר. לא מדלג/ממזג/מוחק. `--include-pending`. **`--link`** רושם את הזוגות שנמצאו כ-`equivalent_halachot` (parallel authority, #84.2 — קישור-מקביל ברמת-הלכה, **לא** ציטוט; idempotent, לא-הרסני). רץ עם venv של mcp-server. אומת: 800 הלכות → 5 זוגות (קושרו). | ידני — דוח-סקירה / `--link` לקישור |
|
||||
| `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest <path>`. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) |
|
||||
| `audit_corpus_integrity.py` | python | בדיקה תקופתית של עקביות הקורפוס — 3 בדיקות SQL read-only על `case_law` ו-`cases`: (A) `external_upload` עם prefix פנימי `ערר`/`בל"מ`; (B) `internal_committee` חסר `chair_name`/`district`; (C) `cases.practice_area` מחוץ ל-{`rishuy_uvniya`, `betterment_levy`, `compensation_197`, `''`}. כותב log מצטבר ל-`data/logs/corpus_integrity_audit.log` ובמצב הפרות שולח wakeup ל-CEO ב-Paperclip (best-effort, רק אם `PAPERCLIP_API_URL`+`PAPERCLIP_API_KEY` מוגדרים). דגל: `--no-notify`. Idempotent, יוצא 0. **Cron יומי 07:00**: `0 7 * * * /home/chaim/legal-ai/mcp-server/.venv/bin/python /home/chaim/legal-ai/scripts/audit_corpus_integrity.py` | `0 7 * * *` (cron) |
|
||||
| `backfill_legal_arguments.py` | python | Backfill `legal_arguments` לתיקים עם `claims` קיימים (TaskMaster #36). מקבץ פרופוזיציות גולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד) דרך `argument_aggregator.aggregate_claims_to_arguments` (Claude CLI). תומך `--dry-run`/`--apply`/`--force`/`--case <num>...`. **חייב לרוץ מהמכונה המקומית** (לא קונטיינר) — `claude_session` דורש Claude CLI | ידני per-case (`python scripts/backfill_legal_arguments.py --apply --case 1017-03-26`) |
|
||||
@@ -81,7 +99,11 @@
|
||||
| `run_curator_deepseek_test_v2.sh` | A/B test #2 (2026-05-05) — אותו run אבל עם interaction. תוצאה: 9:08 דק׳, 5 ממצאים, היחיד מ-4 הריצות שזיהה תוצאה עובדתית נכונה (קבלה חלקית). interaction נכשל ב-API ("Agent run id required" בריצה ידנית). | החלפת Curator לאדפטר DeepSeek מקומי |
|
||||
| `run_curator_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 גדול) |
|
||||
| `drain_halacha_queue.py` | python | ריקון תור חילוץ ההלכות (`process_pending_extractions kind='halacha'`) ב-batches של 4 עד שהתור ריק (2 סבבים ריקים). משמש אחרי `ingest_incoming_batch.py`. | ידני אחרי batch (חלופה ל-MCP `precedent_process_pending`) |
|
||||
| `drain_halacha_queue.py` | python | ריקון תור חילוץ ההלכות (`process_pending_extractions kind='halacha'`) ב-batches של 4 עד שהתור ריק (2 סבבים ריקים). חילוץ-הלכות נשאר על claude_session (לא Gemini). self-heal ל-orphaned `processing`. ההלכות נוחתות `pending_review` (שער-יו"ר). | דרך `legal-halacha-drain.config.cjs` (pm2 cron) / ידני |
|
||||
| `legal-halacha-drain.config.cjs` | pm2/js | **תזמון כל שעתיים של `drain_halacha_queue.py`** (cron `47 */2 * * *`, `HALACHA_DRAIN_CRON` לעקיפה) — מונע סתימה של תור חילוץ-ההלכות. קצב שמרני (Claude איטי + כל ריצה מוסיפה לתור-אישור-היו"ר). דורש claude CLI. התקנה: `pm2 start scripts/legal-halacha-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
|
||||
| `ingest_digests_batch.py` | python | קליטת batch של יומוני "כל יום" מ-`data/digests/incoming/` דרך המסלול העצמאי של קורפוס-הגילוי (`digest_library.ingest_digest`) — חילוץ-LLM (תג-מושג, כותרת-הלכה, מראה-מקום, שני-תאריכים), embedding יחיד, ו-autolink לפסק המקורי (X12/INV-DIG3). רצף (לא מקבילי). מזהה-יומון+תאריך נגזרים משם-הקובץ; העלון החודשי מדולג. **לא מעביר קבצים** — ה-DB (content_hash) הוא מקור-האמת היחיד; הרצה חוזרת מדלגת על קיימים (`exists`). config מ-`~/.env`. | ידני, per-batch (חלופה ל-MCP `digest_upload`) |
|
||||
| `drain_digests.py` | python | ריקון תור ההעשרה של יומונים (X12): מעבד כל digest בסטטוס `pending` דרך `digest_library.enrich_digest` (חילוץ-LLM Sonnet + embedding + autolink). מקבילי (CONCURRENCY=3, env-tunable), idempotent. מוסיף `~/.local/bin` ל-PATH כדי שה-claude CLI יימצא תחת cron. בודק דגל `drain_controls('legal-digest-drain')` ב-startup → no-op כשכבוי מ-/operations. | דרך `legal-digest-drain.config.cjs` (pm2 cron) + ידני אחרי backfill. חלופת-MCP: `digest_process_pending` |
|
||||
| `legal-digest-drain.config.cjs` | pm2/js | **תזמון כל שעתיים של `drain_digests.py`** (cron `0 */2 * * *`, `DIGEST_DRAIN_CRON` לעקיפה) — הועבר מ-crontab של המערכת ל-pm2 כדי שיופיע ויהיה שליט בדף `/operations` (הרץ-עכשיו/הפעל/כבה). `autorestart:false` (one-shot per tick). דורש claude CLI + `VOYAGE_API_KEY`. התקנה: `pm2 start scripts/legal-digest-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
|
||||
|
||||
## סקריפטים שנמחקו (git history בלבד)
|
||||
|
||||
|
||||
56
scripts/backfill_missing_precedents.py
Normal file
56
scripts/backfill_missing_precedents.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Backfill: enqueue publicly-fetchable open missing_precedents for auto-fetch.
|
||||
|
||||
The citation graph records cited-but-absent rulings in ``missing_precedents``.
|
||||
The ones with a public source — Supreme serial (בג"ץ/בר"מ/עע"מ NNNN/YY) → Tier-0
|
||||
supremedecisions; district/Supreme with a נט-format triple → Tier-1 נט המשפט —
|
||||
can be fetched + ingested automatically. ועדת-ערר (needs Nevo) and serial cases
|
||||
with no public record are left for the chair.
|
||||
|
||||
This stamps a ``court_fetch_jobs`` row for each fetchable gap; the court-fetch
|
||||
drainer (``drain_court_fetch.py`` / pm2 cron) then fetches, ingests, and closes
|
||||
the gap. Idempotent (upsert on the canonical case number).
|
||||
|
||||
scripts/backfill_missing_precedents.py # dry-run (report only)
|
||||
scripts/backfill_missing_precedents.py --apply # enqueue
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||
|
||||
from legal_mcp.services import court_citation, db
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
apply = "--apply" in sys.argv
|
||||
gaps = await db.list_missing_precedents(status="open", limit=2000)
|
||||
enq = skipped = 0
|
||||
by_tier: dict[str, int] = {}
|
||||
for g in gaps:
|
||||
cit = court_citation.classify(g.get("citation", ""))
|
||||
net = bool(cit.file_number and cit.month and cit.year)
|
||||
# Fetchable: Supreme serial (Tier-0) or anything with a נט triple (Tier-1).
|
||||
if cit.tier == "supreme" or (cit.tier == "admin" and net):
|
||||
route = "Tier-0/supreme" if (cit.tier == "supreme" and not net) else "Tier-1/net"
|
||||
by_tier[route] = by_tier.get(route, 0) + 1
|
||||
if apply:
|
||||
await db.court_fetch_job_upsert(
|
||||
case_number_norm=cit.case_number_norm,
|
||||
citation_raw=g.get("citation", ""),
|
||||
tier=cit.tier, court=cit.court_prefix,
|
||||
)
|
||||
enq += 1
|
||||
else:
|
||||
skipped += 1
|
||||
verb = "enqueued" if apply else "would enqueue"
|
||||
print(f"{verb}: {enq} (routes: {by_tier})", flush=True)
|
||||
print(f"skipped (ועדת-ערר/serial-no-record/unrecognized): {skipped}", flush=True)
|
||||
if not apply:
|
||||
print("dry-run — re-run with --apply to enqueue.", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
107
scripts/curator_apply_pipeline_branch.py
Normal file
107
scripts/curator_apply_pipeline_branch.py
Normal file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reproducible source-of-truth for the curator's PIPELINE-WAKE branch.
|
||||
|
||||
The Hermes curator's prompt lives ONLY in the Paperclip DB
|
||||
(agents.adapter_config.promptTemplate), not in a repo file. This script (re-)prepends
|
||||
a branch so that a wake whose reason starts with `final_learning_` / `final_halacha_`
|
||||
runs the matching local pipeline script and stops — instead of the default §A/§B
|
||||
style-curation routine. Without it, clicking the run-learning / run-halacha buttons
|
||||
wakes the curator but it does style-curation, never the pipeline.
|
||||
|
||||
Idempotent: strips any prior branch (by the ORIG_START marker) and re-prepends the
|
||||
current one. Applies to BOTH company curators (CMP + CMPA) so cross-company sync stays
|
||||
consistent. Re-run this after any curator-prompt reset / agent re-creation.
|
||||
|
||||
python3 scripts/curator_apply_pipeline_branch.py # apply
|
||||
python3 scripts/curator_apply_pipeline_branch.py --verify # show head, no write
|
||||
|
||||
Env knobs (defaults shown):
|
||||
PAPERCLIP_DB_URL=postgresql://paperclip:paperclip@localhost:54329/paperclip
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import asyncpg
|
||||
|
||||
PAPERCLIP_DB = os.environ.get(
|
||||
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@localhost:54329/paperclip"
|
||||
)
|
||||
CURATOR_AGENTS = [
|
||||
"60dce831-5c5b-4bae-bda9-5282d506f0dc", # CMP (1xxx — licensing)
|
||||
"d6f7c55d-570a-46b8-8d72-1286d07da0d8", # CMPA (8xxx/9xxx — betterment)
|
||||
]
|
||||
MARKER = "### PIPELINE-WAKE BRANCH (auto)"
|
||||
ORIG_START = "אתה מנהל ידע" # first line of the canonical curator prompt
|
||||
|
||||
# HOME=/home/chaim is the root fix: the Hermes agent runs with $HOME pointing at its
|
||||
# profile dir, so Path.home() / expanduser('~') would resolve DATA_DIR and the
|
||||
# DeepSeek/Gemini key files wrong. Pinning HOME (+ DOTENV_PATH/DATA_DIR) makes the
|
||||
# pipeline resolve the case files AND both judge keys correctly.
|
||||
BRANCH = MARKER + r'''
|
||||
לפני כל דבר אחר — בדוק אם זו יקיצת-pipeline אוטומטית. הרץ בדיוק את הבלוק הבא:
|
||||
```bash
|
||||
WAKE="{{wakeReason}}"
|
||||
case "$WAKE" in
|
||||
final_learning_*|final_halacha_*)
|
||||
KIND=$(printf '%s' "$WAKE" | cut -d_ -f2)
|
||||
CASE="${WAKE#final_${KIND}_}"
|
||||
cd /home/chaim/legal-ai/mcp-server && \
|
||||
HOME=/home/chaim DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data \
|
||||
nohup .venv/bin/python ../scripts/final_${KIND}_pipeline.py --case "$CASE" \
|
||||
> "/tmp/final_${KIND}_${CASE}.log" 2>&1 &
|
||||
sleep 2
|
||||
echo "PIPELINE_STARTED final_${KIND}_pipeline case=$CASE log=/tmp/final_${KIND}_${CASE}.log"
|
||||
;;
|
||||
*) echo "NO_PIPELINE_WAKE" ;;
|
||||
esac
|
||||
```
|
||||
אם הפלט הוא `PIPELINE_STARTED ...` — **זו כל המשימה**: כתוב comment קצר בעברית ("הופעל צינור <KIND> לתיק <CASE>; התוצאות יופיעו ב-/training (סגנון) או /approvals + /precedents (הלכות) תוך מספר דקות."), סגור את ה-issue (status=done), ו**סיים מיד — אל תמשיך לסעיפים שלמטה**.
|
||||
אם הפלט הוא `NO_PIPELINE_WAKE` — המשך כרגיל לתבנית שלמטה.
|
||||
|
||||
---
|
||||
|
||||
'''
|
||||
|
||||
|
||||
async def main(verify: bool) -> int:
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB)
|
||||
try:
|
||||
for aid in CURATOR_AGENTS:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT name, adapter_config->>'promptTemplate' AS t FROM agents WHERE id=$1",
|
||||
aid,
|
||||
)
|
||||
if not row or not row["t"]:
|
||||
print(f"{aid}: NO TEMPLATE — skip")
|
||||
continue
|
||||
t = row["t"]
|
||||
if verify:
|
||||
has = MARKER in t
|
||||
print(f"{aid} ({row['name']}): branch={'present' if has else 'MISSING'} "
|
||||
f"len={len(t)}")
|
||||
continue
|
||||
i = t.find(ORIG_START)
|
||||
if i < 0:
|
||||
print(f"{aid}: ORIG_START not found — skip (manual check)")
|
||||
continue
|
||||
new = BRANCH + t[i:] # strip any prior branch, re-prepend
|
||||
await conn.execute(
|
||||
"UPDATE agents SET adapter_config = "
|
||||
"jsonb_set(adapter_config,'{promptTemplate}', to_jsonb($2::text)), "
|
||||
"updated_at = now() WHERE id=$1",
|
||||
aid, new,
|
||||
)
|
||||
print(f"{aid} ({row['name']}): branch applied; template now {len(new)} chars")
|
||||
finally:
|
||||
await conn.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--verify", action="store_true", help="report only, no write")
|
||||
raise SystemExit(asyncio.run(main(ap.parse_args().verify)))
|
||||
49
scripts/drain_court_fetch.py
Normal file
49
scripts/drain_court_fetch.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Drain the X13 court-verdict fetch queue (jobs the digest trigger fills).
|
||||
|
||||
When a digest points at a court ruling not yet in the corpus, the digest
|
||||
trigger enqueues a ``court_fetch_jobs`` row (status=pending). This script
|
||||
drains those: for each pending/failed job it runs the full Tier-0/Tier-1 fetch
|
||||
(via the host browser service) + the canonical ingest, then links the verdict
|
||||
back to its source digest. Serial with a cooldown (INV-CF4); failures are
|
||||
recorded and retried until they escalate to ``manual`` (INV-CF3).
|
||||
|
||||
Host-only: ingest drives halacha extraction via the local ``claude`` CLI (same
|
||||
constraint as ``drain_halacha_queue.py``). A no-op (fast) when the queue is
|
||||
empty. Scheduled hourly by ``legal-court-fetch-drain`` (pm2 cron); also runnable
|
||||
by hand:
|
||||
|
||||
mcp-server/.venv/bin/python scripts/drain_court_fetch.py [limit]
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||
|
||||
from legal_mcp.services import court_fetch_orchestrator as orch
|
||||
from legal_mcp.services import db
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
# /operations "disable" switch — no-op immediately if turned off (pm2
|
||||
# cron_restart can still fire a stopped job, so the gate lives in the DB).
|
||||
if await db.is_drain_disabled("legal-court-fetch-drain"):
|
||||
print("===SKIP=== legal-court-fetch-drain disabled via /operations", flush=True)
|
||||
return 0
|
||||
limit = int(sys.argv[1]) if len(sys.argv) > 1 else 5
|
||||
res = await orch.drain_pending(limit=limit)
|
||||
print(f"===court-fetch drain=== processed={res.get('processed', 0)} "
|
||||
f"ingested={res.get('done', 0)}", flush=True)
|
||||
for r in res.get("results", []):
|
||||
line = f" [{r.get('status')}] {r.get('citation', '')}"
|
||||
if r.get("error"):
|
||||
line += f" — {r['error'][:120]}"
|
||||
if r.get("case_law_id"):
|
||||
line += f" → case_law {r['case_law_id']}"
|
||||
print(line, flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
118
scripts/drain_digests.py
Normal file
118
scripts/drain_digests.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Drain the digest enrichment queue (X12) — local LLM enrichment of pending digests.
|
||||
|
||||
The web/n8n upload path creates digest rows with extraction_status='pending'
|
||||
(container-safe: stage + extract_text only). The LLM metadata extraction +
|
||||
embedding + autolink MUST run locally (claude_session is local-only — the
|
||||
``claude`` CLI is not in the container). This script is that local drainer:
|
||||
|
||||
pending digests → digest_library.enrich_digest (Sonnet, tools="") → completed
|
||||
|
||||
Concurrency-limited (avoids LLM rate-limit storms). Idempotent — only touches
|
||||
rows still 'pending'; safe to re-run. The DB is the single source of truth.
|
||||
|
||||
Used two ways:
|
||||
1. Manually after a backfill: mcp-server/.venv/bin/python scripts/drain_digests.py
|
||||
2. pm2 cron ``legal-digest-drain`` (scripts/legal-digest-drain.config.cjs) —
|
||||
one-shot per tick. Controllable from the /operations dashboard (run-now /
|
||||
enable / disable). Logs to data/digests/drain.log.
|
||||
|
||||
claude CLI must be on PATH (the cron line prepends ~/.local/bin). Config
|
||||
(POSTGRES_URL, VOYAGE_API_KEY) auto-loads from ~/.env via legal_mcp.config.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Ensure the local claude CLI is reachable even under a bare cron PATH.
|
||||
os.environ["PATH"] = os.path.expanduser("~/.local/bin") + os.pathsep + os.environ.get("PATH", "")
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||
|
||||
from legal_mcp.services import db, digest_library as dl # noqa: E402
|
||||
|
||||
CONCURRENCY = int(os.environ.get("DIGEST_DRAIN_CONCURRENCY", "3"))
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
pool = await db.get_pool()
|
||||
# /operations "disable" switch — no-op immediately if turned off (pm2
|
||||
# cron_restart can still fire a stopped job, so the gate lives in the DB).
|
||||
if await db.is_drain_disabled("legal-digest-drain"):
|
||||
print("===SKIP=== legal-digest-drain disabled via /operations", flush=True)
|
||||
await db.close_pool()
|
||||
return 0
|
||||
# get_pool() runs schema migrations first — incl. the V32 digest_kind backfill
|
||||
# that classifies legacy rows — so the failure check below is safe from the
|
||||
# very first run (no legacy row has digest_kind='').
|
||||
#
|
||||
# Self-heal: a successful enrich ALWAYS sets digest_kind (decision/announcement
|
||||
# /other). So a 'completed' row with digest_kind='' means the extraction never
|
||||
# landed (e.g. the local claude subscription window was exhausted) — reset to
|
||||
# 'pending' to retry (idempotent auto-resume). This correctly does NOT touch
|
||||
# announcements (digest_kind='announcement', legitimately no citation), which
|
||||
# the old "both fields empty" heuristic wrongly retried forever.
|
||||
healed = await pool.execute(
|
||||
"UPDATE digests SET extraction_status = 'pending' "
|
||||
"WHERE extraction_status = 'completed' "
|
||||
"AND coalesce(digest_kind,'') = '' "
|
||||
"AND coalesce(analysis_text,'') <> ''"
|
||||
)
|
||||
if healed and healed != "UPDATE 0":
|
||||
print(f"self-heal: reset unclassified (failed) digests → pending ({healed})", flush=True)
|
||||
# Self-heal stale 'processing': flock guarantees a single drainer, so at the
|
||||
# start of THIS run any row left 'processing' is from a previous run that was
|
||||
# killed mid-row (session/quota cutoff). Reset to 'pending' so it is retried.
|
||||
stale = await pool.execute(
|
||||
"UPDATE digests SET extraction_status = 'pending' WHERE extraction_status = 'processing'"
|
||||
)
|
||||
if stale and stale != "UPDATE 0":
|
||||
print(f"self-heal: reset stale processing → pending ({stale})", flush=True)
|
||||
rows = await pool.fetch(
|
||||
"SELECT id FROM digests WHERE extraction_status = 'pending' ORDER BY created_at"
|
||||
)
|
||||
ids = [r["id"] for r in rows]
|
||||
total = len(ids)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
|
||||
if not total:
|
||||
print(f"[{stamp}] no pending digests — nothing to drain", flush=True)
|
||||
await db.close_pool()
|
||||
return 0
|
||||
print(f"[{stamp}] draining {total} pending digests @ concurrency={CONCURRENCY}", flush=True)
|
||||
sem = asyncio.Semaphore(CONCURRENCY)
|
||||
state = {"done": 0, "ok": 0, "linked": 0, "fail": 0}
|
||||
t0 = time.time()
|
||||
|
||||
async def work(did):
|
||||
async with sem:
|
||||
try:
|
||||
res = await dl.enrich_digest(did)
|
||||
state["ok"] += 1
|
||||
if res.get("linked_case_law_id"):
|
||||
state["linked"] += 1
|
||||
except Exception as e:
|
||||
state["fail"] += 1
|
||||
print(f" FAIL {did}: {type(e).__name__}: {e}", flush=True)
|
||||
state["done"] += 1
|
||||
if state["done"] % 20 == 0 or state["done"] == total:
|
||||
el = (time.time() - t0) / 60
|
||||
print(
|
||||
f" progress {state['done']}/{total} | ok={state['ok']} "
|
||||
f"linked={state['linked']} fail={state['fail']} | {el:.1f}min",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
await asyncio.gather(*[work(i) for i in ids])
|
||||
done_stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
|
||||
print(
|
||||
f"[{done_stamp}] DONE {state['done']}/{total} | ok={state['ok']} "
|
||||
f"linked={state['linked']} fail={state['fail']} | {(time.time()-t0)/60:.1f}min",
|
||||
flush=True,
|
||||
)
|
||||
await db.close_pool()
|
||||
return 1 if state["fail"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
47
scripts/drain_halacha_queue.py
Normal file
47
scripts/drain_halacha_queue.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Drain the halacha extraction queue for the incoming batch.
|
||||
|
||||
Calls the canonical process_pending_extractions(kind='halacha') in small batches
|
||||
until the queue is empty (two consecutive zero-progress rounds). Serial + global
|
||||
advisory-lock coordinated inside the service — avoids concurrent Claude load spikes.
|
||||
|
||||
Run: mcp-server/.venv/bin/python scripts/drain_halacha_queue.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||
|
||||
from legal_mcp.services import db
|
||||
from legal_mcp.services import precedent_library as pl
|
||||
|
||||
|
||||
async def main():
|
||||
# /operations "disable" switch — no-op immediately if turned off (pm2
|
||||
# cron_restart can still fire a stopped job, so the gate lives in the DB).
|
||||
if await db.is_drain_disabled("legal-halacha-drain"):
|
||||
print("===SKIP=== legal-halacha-drain disabled via /operations", flush=True)
|
||||
return
|
||||
total = 0
|
||||
empty_rounds = 0
|
||||
rnd = 0
|
||||
while empty_rounds < 2:
|
||||
rnd += 1
|
||||
out = await pl.process_pending_extractions(kind="halacha", limit=4)
|
||||
processed = out.get("processed", 0)
|
||||
total_pending = out.get("total_pending", 0)
|
||||
total += processed
|
||||
print(f"[round {rnd}] processed={processed} total_pending={total_pending} status={out.get('status')}", flush=True)
|
||||
for r in out.get("results", []):
|
||||
print(f" {r.get('case_number')}: {r.get('status')} stored={r.get('stored')} retry={r.get('retry_attempts')}", flush=True)
|
||||
if processed == 0:
|
||||
empty_rounds += 1
|
||||
await asyncio.sleep(5)
|
||||
else:
|
||||
empty_rounds = 0
|
||||
print(f"\n===DONE=== total halachot rounds processed; cases handled cumulatively={total}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
54
scripts/drain_metadata_queue.py
Normal file
54
scripts/drain_metadata_queue.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Drain the precedent metadata-extraction queue.
|
||||
|
||||
Calls ``process_pending_extractions(kind='metadata')`` in batches until the
|
||||
queue is empty (two consecutive zero-progress rounds). Metadata extraction runs
|
||||
on **Gemini Flash** (structured JSON) — fast and reliable, unlike the agentic
|
||||
claude CLI which hit ``error_max_turns`` on this bounded task. A no-op (fast)
|
||||
when the queue is empty.
|
||||
|
||||
Host-only (reads GEMINI_API_KEY + POSTGRES_URL from ~/.env via legal_mcp.config).
|
||||
Scheduled by ``legal-metadata-drain`` (pm2 cron); also runnable by hand:
|
||||
|
||||
mcp-server/.venv/bin/python scripts/drain_metadata_queue.py [batch]
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||
|
||||
from legal_mcp.services import db
|
||||
from legal_mcp.services import precedent_library as pl
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
# /operations "disable" switch — no-op immediately if turned off (pm2
|
||||
# cron_restart can still fire a stopped job, so the gate lives in the DB).
|
||||
if await db.is_drain_disabled("legal-metadata-drain"):
|
||||
print("===SKIP=== legal-metadata-drain disabled via /operations", flush=True)
|
||||
return 0
|
||||
batch = int(sys.argv[1]) if len(sys.argv) > 1 else 10
|
||||
total = 0
|
||||
empty_rounds = 0
|
||||
rnd = 0
|
||||
while empty_rounds < 2:
|
||||
rnd += 1
|
||||
out = await pl.process_pending_extractions(kind="metadata", limit=batch)
|
||||
processed = out.get("processed", 0)
|
||||
total += processed
|
||||
print(f"[round {rnd}] processed={processed} total_pending={out.get('total_pending', 0)} "
|
||||
f"status={out.get('status')}", flush=True)
|
||||
for r in out.get("results", []):
|
||||
print(f" {str(r.get('case_number',''))[:42]}: {r.get('status')}", flush=True)
|
||||
if processed == 0:
|
||||
empty_rounds += 1
|
||||
await asyncio.sleep(3)
|
||||
else:
|
||||
empty_rounds = 0
|
||||
print(f"===DONE=== metadata extracted (cumulative cases handled={total})", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
120
scripts/final_halacha_pipeline.py
Normal file
120
scripts/final_halacha_pipeline.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/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.
|
||||
|
||||
cd ~/legal-ai/mcp-server
|
||||
.venv/bin/python ../scripts/final_halacha_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
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
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 "דפנה תמיר"
|
||||
|
||||
# [0] extract the halachot the decision ITSELF states (its own row in case_law) —
|
||||
# so they are not left pending. Idempotent: skip when already completed or on dry-run.
|
||||
row = await _decision_law_row(case_number)
|
||||
if not row:
|
||||
print(f"[0/4] ההחלטה {case_number} אינה ב-case_law עדיין — דילוג על חילוץ-הלכות")
|
||||
elif row.get("halacha_extraction_status") == "completed":
|
||||
print(f"[0/4] חילוץ-הלכות מההחלטה — דולג (כבר completed)")
|
||||
elif args.dry_run:
|
||||
print(f"[0/4] חילוץ-הלכות מההחלטה — מדולג (dry-run)")
|
||||
else:
|
||||
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'))}")
|
||||
except Exception as e:
|
||||
print(f" ⚠ halacha extraction failed (non-fatal): {e}")
|
||||
|
||||
# [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')}")
|
||||
except Exception:
|
||||
print(f" (citations returned: {str(raw)[:160]})")
|
||||
|
||||
# [2] corroboration signal + policy (whole corpus backfill) — skipped on dry-run
|
||||
if args.dry_run:
|
||||
print("[2/4] corroboration_rebuild — מדולג (dry-run)")
|
||||
else:
|
||||
print("[2/4] corroboration_rebuild (backfill)…", flush=True)
|
||||
try:
|
||||
cr = await corroboration.build_all()
|
||||
print(f" ✓ {cr}")
|
||||
except Exception as e:
|
||||
print(f" ⚠ corroboration failed (non-fatal): {e}")
|
||||
|
||||
# [3] three-judge halacha panel
|
||||
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))
|
||||
print("\n✓ pipeline-אימות-הלכות הושלם" + (" (dry-run)" if args.dry_run else ""))
|
||||
return rc 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")
|
||||
raise SystemExit(asyncio.run(main(ap.parse_args())))
|
||||
140
scripts/final_learning_pipeline.py
Normal file
140
scripts/final_learning_pipeline.py
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/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.
|
||||
|
||||
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 importable.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
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")
|
||||
|
||||
# [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(f"[1/3] ingest_final_version — דולג (הזוג כבר analyzed; --force לחידוש)")
|
||||
else:
|
||||
print("[1/3] ingest_final_version — דיסטילציית טיוטה↔סופי…", flush=True)
|
||||
raw = await ingest_final_version(case_number, file_path=final_path)
|
||||
try:
|
||||
env = json.loads(raw)
|
||||
if env.get("status") == "error":
|
||||
print(f" ✗ {env.get('message')}")
|
||||
return 1
|
||||
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')}")
|
||||
except Exception:
|
||||
print(f" (ingest returned: {raw[:200]})")
|
||||
|
||||
# [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(" ✓ כבר רשום בקורפוס-הסגנון")
|
||||
else:
|
||||
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]})")
|
||||
|
||||
# [3] two-judge style panel (DeepSeek + Gemini)
|
||||
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,
|
||||
))
|
||||
print("\n✓ pipeline-למידה הושלם" + (" (dry-run)" if args.dry_run else ""))
|
||||
return rc 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")
|
||||
raise SystemExit(asyncio.run(main(ap.parse_args())))
|
||||
101
scripts/goldset_ai_recommend.py
Normal file
101
scripts/goldset_ai_recommend.py
Normal file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the AI second-opinion for gold-set items (#81.7 QA aid).
|
||||
|
||||
For each gold-set halacha, an INDEPENDENT local-LLM (claude_session, zero cost)
|
||||
judges: is it a real generalizable holding, what is its correct rule_type, and a
|
||||
one-line rationale. Stored in halacha_goldset.ai_* and shown beside the human
|
||||
tag so the chair can spot disagreements and reconsider.
|
||||
|
||||
This is a QA aid, NOT ground truth and NOT auto-applied. It is also independent
|
||||
of the rule-based validators that #81.8 measures, so it doesn't bias that score.
|
||||
|
||||
Must run locally (claude_session needs the local CLI — not the container):
|
||||
|
||||
cd ~/legal-ai/mcp-server
|
||||
.venv/bin/python ../scripts/goldset_ai_recommend.py # missing only
|
||||
.venv/bin/python ../scripts/goldset_ai_recommend.py --force # regenerate all
|
||||
.venv/bin/python ../scripts/goldset_ai_recommend.py --limit 10 # smoke
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import claude_session, db
|
||||
|
||||
VALID_TYPES = {"holding", "interpretive", "procedural", "application", "obiter"}
|
||||
|
||||
SYSTEM = (
|
||||
"אתה בוחן-איכות משפטי המסווג 'הלכות' שחולצו מהחלטות ועדת-ערר ומפסקי-דין. "
|
||||
"לכל פריט הכרע שתי שאלות, באופן עצמאי ולפי המהות:\n"
|
||||
"1) is_holding — האם זו הלכה אמיתית בת-הכללה ובת-הסתמכות (true), או שזו יישום "
|
||||
"תלוי-עובדות / אמרת-אגב / ציטוט-עובדה ולא כלל בר-הכללה (false).\n"
|
||||
"2) type — **סוג הכלל בלבד** (אל תסווג מחייב/משכנע — דרגת-המחייבות נגזרת אוטומטית "
|
||||
"מזהות הערכאה): 'holding' (עיקרון מהותי שהיה הכרחי להכרעה — ratio), 'interpretive' "
|
||||
"(פרשנות חוק/מונח/תכנית), 'procedural' (סדר-דין: מועדים/סמכות/מיצוי/נטל), "
|
||||
"'application' (החלה על עובדות התיק — לרוב לא-הלכה), 'obiter' (אמרת-אגב שלא "
|
||||
"הוכרעה — לא-הלכה).\n"
|
||||
"עקביות: is_holding=true → holding/interpretive/procedural; "
|
||||
"is_holding=false → application/obiter.\n"
|
||||
'החזר JSON בלבד: {"is_holding": true/false, "type": "<אחד מהחמישה>", '
|
||||
'"rationale": "<משפט אחד קצר בעברית>"}. ללא markdown.'
|
||||
)
|
||||
|
||||
|
||||
def _prompt(item: dict) -> str:
|
||||
src = "פסק-דין" if item.get("source_type") == "court_ruling" else "החלטת ועדת-ערר"
|
||||
return (
|
||||
f"מקור: {src} ({item.get('case_number') or ''}).\n"
|
||||
f"סוג שהמכונה נתנה: {item.get('rule_type')}.\n\n"
|
||||
f"ניסוח הכלל:\n{item.get('rule_statement') or ''}\n\n"
|
||||
f"ציטוט תומך:\n{item.get('supporting_quote') or ''}"
|
||||
)
|
||||
|
||||
|
||||
async def main(args: argparse.Namespace) -> int:
|
||||
items = await db.goldset_list(args.batch)
|
||||
todo = [it for it in items if args.force or not it.get("ai_generated_at")]
|
||||
if args.limit:
|
||||
todo = todo[: args.limit]
|
||||
print(f"gold-set {args.batch}: {len(items)} items, {len(todo)} to recommend", flush=True)
|
||||
|
||||
ok, fail, disagree = 0, 0, 0
|
||||
for i, it in enumerate(todo, 1):
|
||||
try:
|
||||
v = await claude_session.query_json(_prompt(it), system=SYSTEM, effort="low")
|
||||
except Exception as e: # noqa: BLE001
|
||||
fail += 1
|
||||
print(f"[{i}/{len(todo)}] {it['case_number']}: FAIL {e}", flush=True)
|
||||
continue
|
||||
if not isinstance(v, dict):
|
||||
fail += 1
|
||||
continue
|
||||
ai_hold = bool(v.get("is_holding"))
|
||||
ai_type = str(v.get("type") or "").strip()
|
||||
if ai_type not in VALID_TYPES:
|
||||
ai_type = ""
|
||||
await db.goldset_set_ai_recommendation(
|
||||
UUID(str(it["id"])), ai_is_holding=ai_hold, ai_correct_type=ai_type,
|
||||
ai_rationale=str(v.get("rationale") or "")[:300],
|
||||
)
|
||||
ok += 1
|
||||
# note disagreements with the human tag (if tagged)
|
||||
flag = ""
|
||||
if it.get("is_holding") is not None and it["is_holding"] != ai_hold:
|
||||
disagree += 1
|
||||
flag = " ⚠ DISAGREE is_holding"
|
||||
print(f"[{i}/{len(todo)}] {it['case_number']}: ai={ai_hold}/{ai_type}{flag}", flush=True)
|
||||
|
||||
print(f"\nDONE — {ok} stored, {fail} failed, {disagree} disagree with existing human tag",
|
||||
flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--batch", default="default")
|
||||
ap.add_argument("--force", action="store_true", help="regenerate even if present")
|
||||
ap.add_argument("--limit", type=int, default=None)
|
||||
sys.exit(asyncio.run(main(ap.parse_args())))
|
||||
166
scripts/goldset_independent_judge.py
Normal file
166
scripts/goldset_independent_judge.py
Normal file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent second-judge for gold-set rule_ROLE — breaks the AI-anchoring loop.
|
||||
|
||||
The gold-set human role tags were made WHILE seeing a claude AI recommendation,
|
||||
so human↔AI agreement (~100%) is contaminated by anchoring — it is not an
|
||||
independent measure of role-classification accuracy. This script adds a THIRD,
|
||||
genuinely independent judge: a DIFFERENT model (DeepSeek, OpenAI-compatible API)
|
||||
classifies the rule ROLE blind — it never sees the human tag NOR the first AI's
|
||||
answer. Comparing deepseek↔human against ai↔human tells us whether the labels
|
||||
are trustworthy or just anchored.
|
||||
|
||||
Zero tagging from the chair. Read-only on the gold-set.
|
||||
|
||||
cd ~/legal-ai/mcp-server
|
||||
.venv/bin/python ../scripts/goldset_independent_judge.py # all tagged
|
||||
.venv/bin/python ../scripts/goldset_independent_judge.py --limit 10 # smoke
|
||||
.venv/bin/python ../scripts/goldset_independent_judge.py --model deepseek-reasoner
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from legal_mcp.services import db
|
||||
|
||||
ROLES = {"holding", "interpretive", "procedural", "application", "obiter"}
|
||||
|
||||
SYSTEM = (
|
||||
"אתה משפטן בכיר המסווג 'הלכות' שחולצו מפסיקה ישראלית לפי **סוג הכלל** בלבד. "
|
||||
"אל תסווג מחייב/משכנע (דרגת-המחייבות אינה רלוונטית). בחר ערך אחד:\n"
|
||||
"- holding — עיקרון מהותי שהיה הכרחי להכרעה (ratio; מבחן Wambaugh).\n"
|
||||
"- interpretive — פרשנות הוראת-חוק/מונח/תכנית.\n"
|
||||
"- procedural — סדר-דין: סמכות/מועדים/זכות-עמידה/מיצוי/נטל.\n"
|
||||
"- application — החלה תלוית-עובדות על נסיבות התיק (לרוב לא-הלכה בת-הכללה).\n"
|
||||
"- obiter — אמרת-אגב שלא הוכרעה.\n"
|
||||
'החזר JSON בלבד: {"role":"<אחד מהחמישה>"}. ללא markdown, ללא הסבר.'
|
||||
)
|
||||
|
||||
|
||||
def _deepseek_key() -> str:
|
||||
for p in (Path.home() / ".hermes/profiles/deepseek/.env", Path.home() / ".env"):
|
||||
if p.exists():
|
||||
for line in p.read_text().splitlines():
|
||||
if line.startswith("DEEPSEEK_API_KEY="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
return os.environ.get("DEEPSEEK_API_KEY", "")
|
||||
|
||||
|
||||
def _user_prompt(it: dict) -> str:
|
||||
src = "פסק-דין" if it.get("source_type") == "court_ruling" else "החלטת ועדת-ערר"
|
||||
return (
|
||||
f"מקור: {src}.\n\n"
|
||||
f"ניסוח הכלל:\n{it.get('rule_statement') or ''}\n\n"
|
||||
f"היגיון:\n{it.get('reasoning_summary') or ''}\n\n"
|
||||
f"ציטוט תומך:\n{it.get('supporting_quote') or ''}"
|
||||
)
|
||||
|
||||
|
||||
async def _judge(client: httpx.AsyncClient, key: str, model: str, it: dict) -> str | None:
|
||||
try:
|
||||
r = await client.post(
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM},
|
||||
{"role": "user", "content": _user_prompt(it)},
|
||||
],
|
||||
"temperature": 0,
|
||||
"max_tokens": 60,
|
||||
"response_format": {"type": "json_object"},
|
||||
},
|
||||
timeout=90,
|
||||
)
|
||||
r.raise_for_status()
|
||||
content = r.json()["choices"][0]["message"]["content"]
|
||||
role = str(json.loads(content).get("role", "")).strip().lower()
|
||||
return role if role in ROLES else None
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" ! judge error: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def _agree(rows: list[dict], a: str, b: str) -> tuple[int, int, float]:
|
||||
"""Return (matches, comparable, percent) — percent is 0..100."""
|
||||
valid = [r for r in rows if r.get(a) and r.get(b)]
|
||||
ok = sum(1 for r in valid if r[a] == r[b])
|
||||
return ok, len(valid), (100.0 * ok / len(valid) if valid else 0.0)
|
||||
|
||||
|
||||
async def main(args: argparse.Namespace) -> int:
|
||||
key = _deepseek_key()
|
||||
if not key:
|
||||
print("no DEEPSEEK_API_KEY found", flush=True)
|
||||
return 1
|
||||
|
||||
items = await db.goldset_list(args.batch)
|
||||
# only items with a HUMAN role tag (the ground truth we are testing)
|
||||
tagged = [it for it in items if (it.get("correct_type") or "").strip() in ROLES]
|
||||
if args.limit:
|
||||
tagged = tagged[: args.limit]
|
||||
print(f"independent judge ({args.model}) on {len(tagged)} human-tagged items\n", flush=True)
|
||||
|
||||
sem = asyncio.Semaphore(args.concurrency)
|
||||
rows: list[dict] = []
|
||||
async with httpx.AsyncClient() as client:
|
||||
async def one(it: dict):
|
||||
async with sem:
|
||||
ds = await _judge(client, key, args.model, it)
|
||||
rows.append({
|
||||
"human": (it.get("correct_type") or "").strip().lower(),
|
||||
"ai": (it.get("ai_correct_type") or "").strip().lower(),
|
||||
"deepseek": ds,
|
||||
"machine": (it.get("rule_type") or "").strip().lower(),
|
||||
"source": it.get("source_type"),
|
||||
})
|
||||
for i in range(0, len(tagged), args.concurrency):
|
||||
await asyncio.gather(*(one(it) for it in tagged[i : i + args.concurrency]))
|
||||
print(f" …{len(rows)}/{len(tagged)}", flush=True)
|
||||
|
||||
judged = [r for r in rows if r["deepseek"]]
|
||||
print(f"\n=== INTER-RATER AGREEMENT on rule_role ({len(judged)} judged) ===")
|
||||
print(" ai↔human (anchored baseline): %d/%d = %.0f%%" % _agree(rows, "ai", "human"))
|
||||
print(" deepseek↔human (INDEPENDENT — key): %d/%d = %.0f%%" % _agree(judged, "deepseek", "human"))
|
||||
print(" deepseek↔ai (cross-model): %d/%d = %.0f%%" % _agree(judged, "deepseek", "ai"))
|
||||
una = [r for r in judged if r["human"] == r["ai"] == r["deepseek"]]
|
||||
print(f" 3-way unanimous (human=ai=deepseek): {len(una)}/{len(judged)} = {len(una)/max(1,len(judged)):.0%}")
|
||||
|
||||
print("\n=== where the INDEPENDENT judge disagrees with the human (the real signal) ===")
|
||||
mm = Counter((r["human"], r["deepseek"]) for r in judged if r["human"] != r["deepseek"])
|
||||
for (h, d), n in mm.most_common():
|
||||
print(f" human={h} → deepseek={d}: {n}")
|
||||
|
||||
# COARSE axis: is this a generalizable rule at all? (holding/interpretive/
|
||||
# procedural collapse to one class) vs the non-generalizable markers
|
||||
# (application/obiter). If fine-grained agreement is low but coarse is high,
|
||||
# the disagreement is a cosmetic sub-distinction, not a meaningful one.
|
||||
GEN = {"holding", "interpretive", "procedural"}
|
||||
def coarse(v): return "rule" if v in GEN else ("nonrule" if v in {"application", "obiter"} else None)
|
||||
for r in judged:
|
||||
r["human_c"], r["deepseek_c"], r["ai_c"] = coarse(r["human"]), coarse(r["deepseek"]), coarse(r["ai"])
|
||||
print("\n=== COARSE agreement (generalizable-rule vs application/obiter) ===")
|
||||
print(" deepseek↔human (coarse): %d/%d = %.0f%%" % _agree(judged, "deepseek_c", "human_c"))
|
||||
print(" ai↔human (coarse): %d/%d = %.0f%%" % _agree(judged, "ai_c", "human_c"))
|
||||
|
||||
Path("/tmp/goldset_judge_raw.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1))
|
||||
print("\nraw judgments → /tmp/goldset_judge_raw.json")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--batch", default="default")
|
||||
ap.add_argument("--model", default="deepseek-chat", help="deepseek-chat | deepseek-reasoner")
|
||||
ap.add_argument("--limit", type=int, default=0)
|
||||
ap.add_argument("--concurrency", type=int, default=6)
|
||||
sys.exit(asyncio.run(main(ap.parse_args())))
|
||||
@@ -91,7 +91,22 @@ async def main(args: argparse.Namespace) -> int:
|
||||
w = csv.DictWriter(f, fieldnames=list(pairs[0].keys()))
|
||||
w.writeheader()
|
||||
w.writerows(pairs)
|
||||
print(f"\nreport: {out} (review-only — nothing changed)", flush=True)
|
||||
print(f"\nreport: {out}", flush=True)
|
||||
|
||||
if args.link and pairs:
|
||||
# #84.2 — record each pair as parallel authority (equivalent_halachot).
|
||||
# Non-destructive: links only, never merges/deletes. Idempotent.
|
||||
linked = 0
|
||||
for p in pairs:
|
||||
if await db.link_equivalent_halachot(
|
||||
p["id_a"], p["id_b"], cosine=p["cosine"],
|
||||
note="cross-precedent parallel authority (halacha_batch_reconcile)",
|
||||
created_by="batch_reconcile",
|
||||
):
|
||||
linked += 1
|
||||
print(f"linked {linked}/{len(pairs)} pairs as equivalent_halachot", flush=True)
|
||||
elif pairs:
|
||||
print("(review-only — pass --link to record them as equivalent_halachot)", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -102,5 +117,7 @@ if __name__ == "__main__":
|
||||
help="min cosine for a cross-precedent candidate (default 0.95)")
|
||||
ap.add_argument("--include-pending", action="store_true",
|
||||
help="also scan pending_review halachot (default: approved/published only)")
|
||||
ap.add_argument("--link", action="store_true",
|
||||
help="record found pairs as equivalent_halachot (parallel authority, #84.2)")
|
||||
args = ap.parse_args()
|
||||
sys.exit(asyncio.run(main(args)))
|
||||
|
||||
340
scripts/halacha_panel_approve.py
Normal file
340
scripts/halacha_panel_approve.py
Normal file
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Multi-judge panel to triage the halacha approval queue — DRY-RUN by default.
|
||||
|
||||
The chair cannot review every pending halacha. We proved (goldset_independent_
|
||||
judge.py) that the COARSE axis — "is this a genuine, generalizable rule worth
|
||||
keeping as a citable precedent?" — is reliable ACROSS independent models (92%
|
||||
cross-model agreement), while the fine sub-type is not. This script turns that
|
||||
into a triage: THREE independent-lineage judges vote on the coarse question, and
|
||||
only a UNANIMOUS verdict acts automatically — every split escalates to the chair.
|
||||
That collapses the queue without removing the human gate (INV-G10).
|
||||
|
||||
Three judges, three lineages (diversity is the point):
|
||||
- claude (Opus via claude_session — local CLI, zero marginal cost) [Anthropic]
|
||||
- deepseek (api.deepseek.com) [DeepSeek]
|
||||
- gemini (generativelanguage — gemini-2.5-flash, #1 on LegalBench) [Google]
|
||||
|
||||
Three buckets of pending_review:
|
||||
1. clean, below confidence threshold → panel votes KEEP? unanimous-keep would
|
||||
auto-approve; split → chair.
|
||||
2. nli_unsupported (rule maybe over-reaches its quote) → panel RE-ADJUDICATES
|
||||
entailment; unanimous-entailed would clear the flag + approve; split → chair.
|
||||
3. other quality flags (quote_unverified/truncated/thin) → genuine extraction
|
||||
defects → flagged for re-extraction, never auto-approved.
|
||||
|
||||
DRY-RUN writes NOTHING. --apply acts on the agreed verdicts (clean: 2/3 majority;
|
||||
nli: unanimous-entailed clears the flag) — reversible, backed up to data/audit/ first.
|
||||
Splits/defects stay pending_review for the chair. Local-only (claude_session needs CLI).
|
||||
|
||||
cd ~/legal-ai/mcp-server
|
||||
.venv/bin/python ../scripts/halacha_panel_approve.py --limit 12 # smoke
|
||||
.venv/bin/python ../scripts/halacha_panel_approve.py # full dry-run
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from legal_mcp.services import claude_session, db
|
||||
|
||||
# ── keys (local files, same pattern as the other local judges) ──
|
||||
|
||||
def _env_key(name: str, *files: str) -> str:
|
||||
for f in files:
|
||||
p = Path(f).expanduser()
|
||||
if p.exists():
|
||||
for line in p.read_text().splitlines():
|
||||
if line.startswith(name + "="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
return os.environ.get(name, "")
|
||||
|
||||
|
||||
DEEPSEEK_KEY = _env_key("DEEPSEEK_API_KEY", "~/.hermes/profiles/deepseek/.env", "~/.env")
|
||||
# canonical Infisical name is GOOGLE_GEMINI_API_KEY (/external-apis/gemini); accept
|
||||
# the bare GEMINI_API_KEY too for back-compat.
|
||||
GEMINI_KEY = _env_key("GOOGLE_GEMINI_API_KEY", "~/.env") or _env_key("GEMINI_API_KEY", "~/.env")
|
||||
|
||||
# ── the two coarse questions (the reliable axis — NOT the fuzzy sub-type) ──
|
||||
|
||||
KEEP_SYSTEM = (
|
||||
"אתה משפטן בכיר בוועדת ערר לתכנון ובנייה. הוכרע אם 'הלכה' שחולצה מפסיקה ראויה "
|
||||
"להישמר כתקדים בר-ציטוט. ראויה (keep=true) = עיקרון משפטי בר-הכללה והסתמכות "
|
||||
"(holding/פרשנות/כלל-פרוצדורלי). לא-ראויה (keep=false) = החלה תלוית-עובדות על "
|
||||
"התיק הספציפי, סוגיה שלא הוכרעה (אמרת-אגב), או חזרה מילולית על הציטוט ללא הפשטה. "
|
||||
'החזר JSON בלבד: {"keep": true/false, "reason": "<משפט קצר>"}. ללא markdown.'
|
||||
)
|
||||
|
||||
NLI_SYSTEM = (
|
||||
"אתה בודק היסק משפטי. בהינתן כלל וציטוט-תומך, הכרע האם הציטוט באמת תומך בכלל "
|
||||
"ואינו מרחיב מעבר למה שכתוב בו (entailed=true), או שהכלל מרחיב/חורג מהציטוט "
|
||||
'(entailed=false). החזר JSON בלבד: {"entailed": true/false}. ללא markdown, ללא הסבר.'
|
||||
)
|
||||
|
||||
|
||||
def _keep_user(h: dict) -> str:
|
||||
return (
|
||||
f"ניסוח הכלל:\n{h.get('rule_statement') or ''}\n\n"
|
||||
f"היגיון:\n{h.get('reasoning_summary') or ''}\n\n"
|
||||
f"ציטוט תומך:\n{h.get('supporting_quote') or ''}"
|
||||
)
|
||||
|
||||
|
||||
def _nli_user(h: dict) -> str:
|
||||
return f"כלל:\n{h.get('rule_statement') or ''}\n\nציטוט:\n{h.get('supporting_quote') or ''}"
|
||||
|
||||
|
||||
# ── three judges, one signature: (system, user) -> dict|None ──
|
||||
|
||||
async def judge_claude(system: str, user: str) -> dict | None:
|
||||
try:
|
||||
return await claude_session.query_json(user, system=system)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def judge_deepseek(client: httpx.AsyncClient, system: str, user: str) -> dict | None:
|
||||
if not DEEPSEEK_KEY:
|
||||
return None
|
||||
try:
|
||||
r = await client.post(
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {DEEPSEEK_KEY}", "Content-Type": "application/json"},
|
||||
json={"model": "deepseek-chat", "temperature": 0, "max_tokens": 120,
|
||||
"response_format": {"type": "json_object"},
|
||||
"messages": [{"role": "system", "content": system},
|
||||
{"role": "user", "content": user}]},
|
||||
timeout=90,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return json.loads(r.json()["choices"][0]["message"]["content"])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def judge_gemini(client: httpx.AsyncClient, system: str, user: str) -> dict | None:
|
||||
if not GEMINI_KEY:
|
||||
return None
|
||||
try:
|
||||
r = await client.post(
|
||||
f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={GEMINI_KEY}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={"system_instruction": {"parts": [{"text": system}]},
|
||||
"contents": [{"parts": [{"text": user}]}],
|
||||
"generationConfig": {"temperature": 0, "maxOutputTokens": 4000,
|
||||
"responseMimeType": "application/json"}},
|
||||
timeout=90,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return json.loads(r.json()["candidates"][0]["content"]["parts"][0]["text"])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _bool(d: dict | None, key: str) -> bool | None:
|
||||
if not isinstance(d, dict) or key not in d:
|
||||
return None
|
||||
v = d[key]
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
return str(v).strip().lower() in ("true", "1", "yes", "כן")
|
||||
|
||||
|
||||
async def panel_vote(client, system, user, key) -> dict:
|
||||
"""Run all three judges; return per-judge bools + the verdict."""
|
||||
c, ds, gm = await asyncio.gather(
|
||||
judge_claude(system, user),
|
||||
judge_deepseek(client, system, user),
|
||||
judge_gemini(client, system, user),
|
||||
)
|
||||
votes = {"claude": _bool(c, key), "deepseek": _bool(ds, key), "gemini": _bool(gm, key)}
|
||||
valid = [v for v in votes.values() if v is not None]
|
||||
unanimous_yes = len(valid) == 3 and all(valid)
|
||||
unanimous_no = len(valid) == 3 and not any(valid)
|
||||
votes["_verdict"] = ("unanimous_yes" if unanimous_yes else
|
||||
"unanimous_no" if unanimous_no else
|
||||
"split" if len(valid) >= 2 else "incomplete")
|
||||
return votes
|
||||
|
||||
|
||||
async def main(args: argparse.Namespace) -> int:
|
||||
print(f"judges available — deepseek:{bool(DEEPSEEK_KEY)} gemini:{bool(GEMINI_KEY)} "
|
||||
f"claude:local\n", flush=True)
|
||||
pending = await db.list_halachot(review_status="pending_review", limit=5000)
|
||||
if args.limit:
|
||||
pending = pending[: args.limit]
|
||||
|
||||
NLI = "nli_unsupported"
|
||||
DEFECT = {"quote_unverified", "truncated_quote", "thin_restatement", "near_duplicate"}
|
||||
|
||||
def bucket(h):
|
||||
flags = set(h.get("quality_flags") or [])
|
||||
if not flags:
|
||||
return "clean"
|
||||
if flags & DEFECT:
|
||||
return "defect" # genuine extraction problem → re-extraction
|
||||
if NLI in flags:
|
||||
return "nli" # re-adjudicate entailment
|
||||
return "other"
|
||||
|
||||
buckets = defaultdict(list)
|
||||
for h in pending:
|
||||
buckets[bucket(h)].append(h)
|
||||
print("queue:", {k: len(v) for k, v in buckets.items()}, "\n", flush=True)
|
||||
|
||||
sem = asyncio.Semaphore(args.concurrency)
|
||||
results = {"clean": [], "nli": []}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async def run(h, system_fn, user_fn, key, tag):
|
||||
async with sem:
|
||||
v = await panel_vote(client, system_fn, user_fn(h), key)
|
||||
v["_h"] = h
|
||||
results[tag].append(v)
|
||||
|
||||
tasks = []
|
||||
for h in buckets["clean"]:
|
||||
tasks.append(run(h, KEEP_SYSTEM, _keep_user, "keep", "clean"))
|
||||
for h in buckets["nli"]:
|
||||
tasks.append(run(h, NLI_SYSTEM, _nli_user, "entailed", "nli"))
|
||||
# bounded fan-out
|
||||
for i in range(0, len(tasks), args.concurrency):
|
||||
await asyncio.gather(*tasks[i : i + args.concurrency])
|
||||
done = len(results["clean"]) + len(results["nli"])
|
||||
print(f" …{done}/{len(tasks)} judged", flush=True)
|
||||
|
||||
# ── report ──
|
||||
def summarize(rows, yes_label, no_label):
|
||||
c = Counter(r["_verdict"] for r in rows)
|
||||
return c
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("PANEL DRY-RUN (no DB writes)")
|
||||
print("=" * 60)
|
||||
|
||||
clean = results["clean"]
|
||||
cc = summarize(clean, "keep", "drop")
|
||||
print(f"\nBUCKET 1 — clean, below threshold ({len(clean)}):")
|
||||
print(f" ✓ auto-APPROVE (3/3 keep): {cc['unanimous_yes']}")
|
||||
print(f" ✗ auto-REJECT (3/3 drop): {cc['unanimous_no']}")
|
||||
print(f" → CHAIR (split): {cc['split']}")
|
||||
print(f" ? incomplete (judge errors): {cc['incomplete']}")
|
||||
|
||||
nli = results["nli"]
|
||||
nc = summarize(nli, "entailed", "not")
|
||||
print(f"\nBUCKET 2 — nli_unsupported ({len(nli)}):")
|
||||
print(f" ✓ clear-flag + APPROVE (3/3 entailed): {nc['unanimous_yes']}")
|
||||
print(f" ✗ confirm-flag (3/3 not-entailed): {nc['unanimous_no']}")
|
||||
print(f" → CHAIR (split): {nc['split']}")
|
||||
print(f" ? incomplete: {nc['incomplete']}")
|
||||
|
||||
print(f"\nBUCKET 3 — extraction defects ({len(buckets['defect'])}): → re-extraction")
|
||||
if buckets["other"]:
|
||||
print(f"BUCKET 4 — other flags ({len(buckets['other'])}): → chair")
|
||||
|
||||
auto = cc["unanimous_yes"] + cc["unanimous_no"] + nc["unanimous_yes"] + nc["unanimous_no"]
|
||||
chair = cc["split"] + nc["split"] + cc["incomplete"] + nc["incomplete"] + len(buckets["other"])
|
||||
reext = len(buckets["defect"])
|
||||
print("\n" + "-" * 60)
|
||||
print(f"NET: {len(pending)} pending → panel resolves {auto} automatically, "
|
||||
f"{chair} to chair, {reext} to re-extraction")
|
||||
print(f" chair queue collapses {len(pending)} → {chair}")
|
||||
|
||||
Path("/tmp/halacha_panel_dryrun.json").write_text(json.dumps(
|
||||
[{**{k: v for k, v in r.items() if not k.startswith("_h")},
|
||||
"id": str(r["_h"]["id"]), "case": r["_h"].get("case_number"),
|
||||
"rule": (r["_h"].get("rule_statement") or "")[:120]}
|
||||
for r in clean + nli], ensure_ascii=False, indent=1))
|
||||
print("\nper-item verdicts → /tmp/halacha_panel_dryrun.json")
|
||||
|
||||
# ── apply the chair-approved policy (reversible; backup first) ──────────
|
||||
# CLEAN → majority 2/3 (keep→approved, drop→rejected, tie→chair)
|
||||
# NLI → asymmetric: unanimous-entailed → clear nli flag (+approve if clean),
|
||||
# majority not-entailed → rejected, else → chair
|
||||
# DEFECT → untouched (needs re-extraction)
|
||||
if not args.apply:
|
||||
print("\n(dry-run — pass --apply to write the approved policy)")
|
||||
return 0
|
||||
|
||||
def majority(v: dict) -> bool | None:
|
||||
vs = [v[k] for k in ("claude", "deepseek", "gemini") if v[k] is not None]
|
||||
if len(vs) < 2:
|
||||
return None
|
||||
y, n = sum(vs), len(vs) - sum(vs)
|
||||
return True if y > n else (False if n > y else None)
|
||||
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
audit = Path(__file__).resolve().parent.parent / "data" / "audit"
|
||||
audit.mkdir(parents=True, exist_ok=True)
|
||||
backup = audit / f"halacha-panel-apply-backup-{ts}.csv"
|
||||
with backup.open("w", encoding="utf-8", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["id", "review_status", "quality_flags"])
|
||||
for r in clean + nli:
|
||||
h = r["_h"]
|
||||
w.writerow([h["id"], h["review_status"], "|".join(h.get("quality_flags") or [])])
|
||||
|
||||
pool = await db.get_pool()
|
||||
REV = "panel:opus+deepseek+gemini"
|
||||
approved = rejected = cleared = chair = 0
|
||||
|
||||
for r in clean:
|
||||
d = majority(r)
|
||||
if d is True:
|
||||
await pool.execute("UPDATE halachot SET review_status='approved', "
|
||||
"reviewed_at=now(), reviewer=$2, updated_at=now() WHERE id=$1",
|
||||
r["_h"]["id"], REV + " 2/3-keep")
|
||||
approved += 1
|
||||
elif d is False:
|
||||
await pool.execute("UPDATE halachot SET review_status='rejected', "
|
||||
"reviewed_at=now(), reviewer=$2, updated_at=now() WHERE id=$1",
|
||||
r["_h"]["id"], REV + " 2/3-drop")
|
||||
rejected += 1
|
||||
else:
|
||||
chair += 1
|
||||
|
||||
for r in nli:
|
||||
vs = [r[k] for k in ("claude", "deepseek", "gemini") if r[k] is not None]
|
||||
unanimous_yes = len(vs) == 3 and all(vs)
|
||||
maj_no = len(vs) >= 2 and sum(vs) < len(vs) - sum(vs)
|
||||
if unanimous_yes:
|
||||
rest = [x for x in (r["_h"].get("quality_flags") or []) if x != "nli_unsupported"]
|
||||
if rest: # other flags remain → clear nli but keep in queue
|
||||
await pool.execute("UPDATE halachot SET quality_flags=$2, updated_at=now() "
|
||||
"WHERE id=$1", r["_h"]["id"], rest)
|
||||
cleared += 1; chair += 1
|
||||
else: # nli was the only blocker → clear + approve
|
||||
await pool.execute("UPDATE halachot SET quality_flags='{}', "
|
||||
"review_status='approved', reviewed_at=now(), reviewer=$2, "
|
||||
"updated_at=now() WHERE id=$1", r["_h"]["id"], REV + " 3/3-entailed")
|
||||
approved += 1; cleared += 1
|
||||
elif maj_no:
|
||||
await pool.execute("UPDATE halachot SET review_status='rejected', "
|
||||
"reviewed_at=now(), reviewer=$2, updated_at=now() WHERE id=$1",
|
||||
r["_h"]["id"], REV + " maj-not-entailed")
|
||||
rejected += 1
|
||||
else:
|
||||
chair += 1
|
||||
|
||||
print(f"\nAPPLIED (reversible): approved {approved} · rejected {rejected} · "
|
||||
f"nli-flag-cleared {cleared} · left to chair {chair + len(buckets['defect'])} "
|
||||
f"(incl. {len(buckets['defect'])} defects for re-extraction)")
|
||||
print(f"backup → {backup}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--limit", type=int, default=0)
|
||||
ap.add_argument("--concurrency", type=int, default=6)
|
||||
ap.add_argument("--apply", action="store_true",
|
||||
help="write the agreed verdicts (reversible, CSV-backed); default dry-run")
|
||||
raise SystemExit(asyncio.run(main(ap.parse_args())))
|
||||
93
scripts/halacha_panel_audit.py
Normal file
93
scripts/halacha_panel_audit.py
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Safety-net audit for panel-approved halachot (selective-prediction monitoring).
|
||||
|
||||
A panel auto-approval is reversible and low-harm, but not infallible. The
|
||||
literature (Trust-or-Escalate; selective prediction) prescribes MONITORING the
|
||||
auto-decision error rate over time rather than trusting it blindly. This samples
|
||||
panel-approved halachot, RE-RUNS the same 3-judge KEEP vote, and surfaces any
|
||||
where the panel now leans DROP — the candidate false-keeps a human should glance
|
||||
at. Zero standing load on the chair: it just produces a short weekly list.
|
||||
|
||||
Report-only by default. ``--flag`` sends the flips back to ``pending_review``
|
||||
(with an audit reviewer note) so they re-enter the chair queue.
|
||||
|
||||
cd ~/legal-ai/mcp-server
|
||||
.venv/bin/python ../scripts/halacha_panel_audit.py --sample 15
|
||||
.venv/bin/python ../scripts/halacha_panel_audit.py --sample 15 --flag
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from legal_mcp.services import db
|
||||
from halacha_panel_approve import ( # noqa: E402 — single source of truth for judges
|
||||
KEEP_SYSTEM, _bool, _keep_user, judge_claude, judge_deepseek, judge_gemini,
|
||||
)
|
||||
|
||||
|
||||
def _majority(votes: list[bool]) -> bool | None:
|
||||
vs = [v for v in votes if v is not None]
|
||||
if len(vs) < 2:
|
||||
return None
|
||||
y, n = sum(vs), len(vs) - sum(vs)
|
||||
return True if y > n else (False if n > y else None)
|
||||
|
||||
|
||||
async def main(args: argparse.Namespace) -> int:
|
||||
pool = await db.get_pool()
|
||||
# sample panel-approved halachot (ORDER BY random is fine for a small audit)
|
||||
rows = await pool.fetch(
|
||||
"SELECT h.id, h.rule_statement, h.reasoning_summary, h.supporting_quote, "
|
||||
" cl.case_number "
|
||||
"FROM halachot h LEFT JOIN case_law cl ON cl.id = h.case_law_id "
|
||||
"WHERE h.review_status='approved' AND h.reviewer LIKE 'panel:%' "
|
||||
"ORDER BY md5(h.id::text || $1) LIMIT $2",
|
||||
args.seed, args.sample,
|
||||
)
|
||||
print(f"auditing {len(rows)} panel-approved halachot (re-running the KEEP vote)\n", flush=True)
|
||||
|
||||
flips = []
|
||||
sem = asyncio.Semaphore(args.concurrency)
|
||||
async with httpx.AsyncClient() as client:
|
||||
async def one(r):
|
||||
async with sem:
|
||||
user = _keep_user(dict(r))
|
||||
c, ds, gm = await asyncio.gather(
|
||||
judge_claude(KEEP_SYSTEM, user),
|
||||
judge_deepseek(client, KEEP_SYSTEM, user),
|
||||
judge_gemini(client, KEEP_SYSTEM, user),
|
||||
)
|
||||
votes = [_bool(c, "keep"), _bool(ds, "keep"), _bool(gm, "keep")]
|
||||
if _majority(votes) is False: # panel now leans DROP → candidate false-keep
|
||||
flips.append((r, votes))
|
||||
tasks = [one(r) for r in rows]
|
||||
for i in range(0, len(tasks), args.concurrency):
|
||||
await asyncio.gather(*tasks[i : i + args.concurrency])
|
||||
|
||||
rate = len(flips) / len(rows) if rows else 0.0
|
||||
print(f"=== AUDIT: {len(flips)}/{len(rows)} now lean DROP ({rate:.0%} candidate false-keeps) ===")
|
||||
for r, votes in flips:
|
||||
print(f"\n {r['case_number']} votes(c/ds/gm)={votes}")
|
||||
print(f" {r['rule_statement'][:140]}")
|
||||
|
||||
if flips and args.flag:
|
||||
for r, _ in flips:
|
||||
await pool.execute(
|
||||
"UPDATE halachot SET review_status='pending_review', "
|
||||
"reviewer='panel-audit:reopened', updated_at=now() WHERE id=$1", r["id"])
|
||||
print(f"\n→ flagged {len(flips)} back to pending_review for chair review.")
|
||||
elif flips:
|
||||
print("\n(report-only — pass --flag to reopen these for the chair)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--sample", type=int, default=15)
|
||||
ap.add_argument("--seed", default="audit", help="vary to draw a different sample")
|
||||
ap.add_argument("--flag", action="store_true", help="reopen flips to pending_review")
|
||||
ap.add_argument("--concurrency", type=int, default=6)
|
||||
raise SystemExit(asyncio.run(main(ap.parse_args())))
|
||||
117
scripts/halacha_panel_calibrate.py
Normal file
117
scripts/halacha_panel_calibrate.py
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Calibrate the approval-panel voting policy on the gold-set (Trust-or-Escalate).
|
||||
|
||||
The literature (Trust or Escalate, ICLR 2025; PoLL; selective prediction) says:
|
||||
don't guess the aggregation policy — calibrate it to a target risk α on a
|
||||
calibration set, and ESCALATE disagreement to the human. We have a calibration
|
||||
set: the gold-set's ``is_holding`` is the COARSE "is this a real, keepable rule?"
|
||||
label — the axis we already proved is reliable across models (92%).
|
||||
|
||||
This runs the panel's KEEP question (3 independent judges) on every gold-set item
|
||||
that has an is_holding label, then reports, FOR EACH POLICY, the auto-decision
|
||||
precision (vs is_holding) and coverage (how many it decides vs escalates):
|
||||
|
||||
- unanimous : auto-decide only on 3/3 agreement, else escalate
|
||||
- majority : auto-decide on 2/3, else escalate
|
||||
|
||||
Pick the policy whose auto-error stays under your tolerance while covering the
|
||||
most items. Read-only. Local-only (claude_session needs the CLI).
|
||||
|
||||
cd ~/legal-ai/mcp-server
|
||||
.venv/bin/python ../scripts/halacha_panel_calibrate.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from legal_mcp.services import db
|
||||
# reuse the exact panel judges + KEEP question (single source of truth)
|
||||
from halacha_panel_approve import ( # noqa: E402
|
||||
KEEP_SYSTEM, _bool, _keep_user, judge_claude, judge_deepseek, judge_gemini,
|
||||
)
|
||||
|
||||
|
||||
async def _votes(client, h) -> list[bool]:
|
||||
user = _keep_user(h)
|
||||
c, ds, gm = await asyncio.gather(
|
||||
judge_claude(KEEP_SYSTEM, user),
|
||||
judge_deepseek(client, KEEP_SYSTEM, user),
|
||||
judge_gemini(client, KEEP_SYSTEM, user),
|
||||
)
|
||||
return [v for v in (_bool(c, "keep"), _bool(ds, "keep"), _bool(gm, "keep")) if v is not None]
|
||||
|
||||
|
||||
def _decide(votes: list[bool], policy: str) -> bool | None:
|
||||
"""Auto-decision (True=keep / False=drop) or None=escalate."""
|
||||
if len(votes) < 2:
|
||||
return None
|
||||
yes, no = sum(votes), len(votes) - sum(votes)
|
||||
if policy == "unanimous":
|
||||
if len(votes) == 3 and yes == 3:
|
||||
return True
|
||||
if len(votes) == 3 and no == 3:
|
||||
return False
|
||||
return None
|
||||
# majority
|
||||
if yes > no:
|
||||
return True
|
||||
if no > yes:
|
||||
return False
|
||||
return None # tie
|
||||
|
||||
|
||||
async def main(args: argparse.Namespace) -> int:
|
||||
items = [it for it in await db.goldset_list(args.batch) if it.get("is_holding") is not None]
|
||||
if args.limit:
|
||||
items = items[: args.limit]
|
||||
print(f"calibrating panel KEEP vs is_holding on {len(items)} gold-set items\n", flush=True)
|
||||
|
||||
sem = asyncio.Semaphore(args.concurrency)
|
||||
rows = []
|
||||
async with httpx.AsyncClient() as client:
|
||||
async def one(it):
|
||||
async with sem:
|
||||
v = await _votes(client, it)
|
||||
rows.append({"truth": bool(it["is_holding"]), "votes": v})
|
||||
tasks = [one(it) for it in items]
|
||||
for i in range(0, len(tasks), args.concurrency):
|
||||
await asyncio.gather(*tasks[i : i + args.concurrency])
|
||||
print(f" …{len(rows)}/{len(items)}", flush=True)
|
||||
|
||||
print("\n" + "=" * 64)
|
||||
print(f"{'policy':<11}{'auto':>6}{'escalate':>10}{'correct':>9}{'wrong':>7}{'precision':>11}{'coverage':>10}")
|
||||
print("-" * 64)
|
||||
for policy in ("unanimous", "majority"):
|
||||
auto = wrong = correct = 0
|
||||
for r in rows:
|
||||
d = _decide(r["votes"], policy)
|
||||
if d is None:
|
||||
continue
|
||||
auto += 1
|
||||
if d == r["truth"]:
|
||||
correct += 1
|
||||
else:
|
||||
wrong += 1
|
||||
esc = len(rows) - auto
|
||||
prec = correct / auto if auto else 0.0
|
||||
cov = auto / len(rows) if rows else 0.0
|
||||
print(f"{policy:<11}{auto:>6}{esc:>10}{correct:>9}{wrong:>7}{prec:>10.1%}{cov:>10.1%}")
|
||||
|
||||
# where do the WRONG auto-decisions fall? (false-keep is the costly one)
|
||||
print("\n=== costly errors: panel auto-KEEPS but human says NOT-holding (per policy) ===")
|
||||
for policy in ("unanimous", "majority"):
|
||||
fk = sum(1 for r in rows if _decide(r["votes"], policy) is True and not r["truth"])
|
||||
fd = sum(1 for r in rows if _decide(r["votes"], policy) is False and r["truth"])
|
||||
print(f" {policy:<11} false-KEEP (bad rule approved): {fk} false-DROP (good rule rejected): {fd}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--batch", default="default")
|
||||
ap.add_argument("--limit", type=int, default=0)
|
||||
ap.add_argument("--concurrency", type=int, default=6)
|
||||
raise SystemExit(asyncio.run(main(ap.parse_args())))
|
||||
151
scripts/halacha_rule_role_backfill.py
Normal file
151
scripts/halacha_rule_role_backfill.py
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-time backfill: recover the rule ROLE for pre-split halachot (INV-DM7).
|
||||
|
||||
Before the authority/role split, the extractor stored ``rule_type='binding'``
|
||||
for higher-court sources and ``'persuasive'`` for committee sources — i.e. it
|
||||
recorded the source's AUTHORITY in the role field. Those 276 rows therefore have
|
||||
NO genuine role. This script re-classifies each into one of the five real roles
|
||||
(holding/interpretive/procedural/application/obiter) using the same local
|
||||
claude_session judge the gold-set trusts (zero API cost), and writes it back to
|
||||
``halachot.rule_type``.
|
||||
|
||||
authority is NOT touched — it is derived from ``case_law.precedent_level`` at
|
||||
read time and was never stored.
|
||||
|
||||
cd ~/legal-ai/mcp-server
|
||||
.venv/bin/python ../scripts/halacha_rule_role_backfill.py --limit 5 # smoke (dry)
|
||||
.venv/bin/python ../scripts/halacha_rule_role_backfill.py --apply # full backfill
|
||||
|
||||
Local-only (claude_session needs the local CLI, not the container).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import csv
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import claude_session, db
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
AUDIT_DIR = REPO_ROOT / "data" / "audit"
|
||||
|
||||
VALID_ROLES = {"holding", "interpretive", "procedural", "application", "obiter"}
|
||||
|
||||
SYSTEM = (
|
||||
"אתה משפטן בכיר המסווג 'הלכות' שחולצו מפסיקה לפי **סוג הכלל** בלבד "
|
||||
"(אל תסווג מחייב/משכנע — דרגת-המחייבות נגזרת אוטומטית מזהות הערכאה). "
|
||||
"בחר ערך אחד מתוך:\n"
|
||||
"- holding — עיקרון מהותי שהיה הכרחי להכרעה (ratio; מבחן Wambaugh).\n"
|
||||
"- interpretive — פרשנות הוראת-חוק/מונח/תכנית.\n"
|
||||
"- procedural — סדר-דין: סמכות/מועדים/זכות-עמידה/מיצוי/נטל.\n"
|
||||
"- application — החלה תלוית-עובדות על נסיבות התיק (לרוב לא-הלכה בת-הכללה).\n"
|
||||
"- obiter — אמרת-אגב שלא הוכרעה.\n"
|
||||
'החזר JSON בלבד: {"role": "<אחד מהחמישה>"}. ללא markdown, ללא הסבר.'
|
||||
)
|
||||
|
||||
|
||||
def _prompt(row: dict) -> str:
|
||||
return (
|
||||
f"מקור: {row.get('case_number') or ''} "
|
||||
f"(precedent_level={row.get('precedent_level') or ''}).\n"
|
||||
f"סיווג ישן (סמכות, להתעלם): {row.get('rule_type')}.\n\n"
|
||||
f"ניסוח הכלל:\n{row.get('rule_statement') or ''}\n\n"
|
||||
f"היגיון:\n{row.get('reasoning_summary') or ''}\n\n"
|
||||
f"ציטוט תומך:\n{row.get('supporting_quote') or ''}"
|
||||
)
|
||||
|
||||
|
||||
async def _classify(row: dict) -> str | None:
|
||||
"""Return the role for one row, or None on failure (caller keeps old value)."""
|
||||
try:
|
||||
raw = await claude_session.query_json(_prompt(row), system=SYSTEM)
|
||||
except Exception as e: # noqa: BLE001 — log and skip, never crash the batch
|
||||
print(f" ! {row['id']}: judge error ({e}) — skipped", flush=True)
|
||||
return None
|
||||
role = ""
|
||||
if isinstance(raw, dict):
|
||||
role = str(raw.get("role") or "").strip().lower()
|
||||
if role not in VALID_ROLES:
|
||||
print(f" ? {row['id']}: invalid role {role!r} — skipped", flush=True)
|
||||
return None
|
||||
return role
|
||||
|
||||
|
||||
async def _fetch_legacy_rows() -> list[dict]:
|
||||
pool = await db.get_pool()
|
||||
rows = await pool.fetch(
|
||||
"SELECT h.id, h.rule_type, h.rule_statement, h.reasoning_summary, "
|
||||
" h.supporting_quote, cl.case_number, cl.precedent_level "
|
||||
"FROM halachot h LEFT JOIN case_law cl ON cl.id = h.case_law_id "
|
||||
"WHERE h.rule_type IN ('binding','persuasive') "
|
||||
"ORDER BY h.case_law_id, h.halacha_index"
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _backup(rows: list[dict]) -> Path:
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
out = AUDIT_DIR / f"halacha-rule-role-backfill-backup-{ts}.csv"
|
||||
with out.open("w", encoding="utf-8", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["id", "old_rule_type", "case_number", "precedent_level"])
|
||||
for r in rows:
|
||||
w.writerow([r["id"], r["rule_type"], r.get("case_number") or "",
|
||||
r.get("precedent_level") or ""])
|
||||
return out
|
||||
|
||||
|
||||
async def main(args: argparse.Namespace) -> int:
|
||||
rows = await _fetch_legacy_rows()
|
||||
if args.limit:
|
||||
rows = rows[: args.limit]
|
||||
print(f"legacy binding/persuasive rows to reclassify: {len(rows)}", flush=True)
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
backup = _backup(rows)
|
||||
print(f"backup written → {backup}", flush=True)
|
||||
|
||||
pool = await db.get_pool()
|
||||
changed = skipped = 0
|
||||
sem = asyncio.Semaphore(args.concurrency)
|
||||
|
||||
async def _one(row: dict):
|
||||
nonlocal changed, skipped
|
||||
async with sem:
|
||||
role = await _classify(row)
|
||||
if role is None:
|
||||
skipped += 1
|
||||
return
|
||||
old = row["rule_type"]
|
||||
print(f" {row.get('case_number') or '':<14} {old:>10} → {role}", flush=True)
|
||||
if args.apply and role != old:
|
||||
await pool.execute(
|
||||
"UPDATE halachot SET rule_type = $2, updated_at = now() WHERE id = $1",
|
||||
row["id"], role,
|
||||
)
|
||||
changed += 1
|
||||
|
||||
# process in chunks to bound concurrent CLI subprocesses
|
||||
for i in range(0, len(rows), args.concurrency):
|
||||
await asyncio.gather(*(_one(r) for r in rows[i : i + args.concurrency]))
|
||||
|
||||
mode = "APPLIED" if args.apply else "DRY-RUN (no writes)"
|
||||
print(f"\n{mode}: {changed} reclassified, {skipped} skipped (kept old).", flush=True)
|
||||
if not args.apply:
|
||||
print("re-run with --apply to write changes.", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--apply", action="store_true", help="write changes (default: dry-run)")
|
||||
ap.add_argument("--limit", type=int, default=0, help="only first N rows (smoke test)")
|
||||
ap.add_argument("--concurrency", type=int, default=4, help="parallel judge calls")
|
||||
sys.exit(asyncio.run(main(ap.parse_args())))
|
||||
56
scripts/ingest_bulletins.py
Normal file
56
scripts/ingest_bulletins.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Ingest the monthly "עו"ד על נדל"ן" bulletin archive into the digests radar (X12).
|
||||
|
||||
Each staged bulletin PDF (data/bulletins/incoming) is split by LLM into case-law
|
||||
pointers (digest_kind='decision') + articles (digest_kind='article'), all tagged
|
||||
publication='עו"ד על נדל"ן'. Idempotent — per-item content_hash dedup, so re-runs
|
||||
only add new items. Runs on the HOST (LLM is local-only), with the mcp-server venv:
|
||||
|
||||
mcp-server/.venv/bin/python scripts/ingest_bulletins.py [--dir PATH] [--limit N]
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "src"))
|
||||
|
||||
from legal_mcp.services import db, bulletin_library # noqa: E402
|
||||
|
||||
DEFAULT_DIR = Path(__file__).resolve().parent.parent / "data" / "bulletins" / "incoming"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dir", default=str(DEFAULT_DIR), help="folder of bulletin PDFs")
|
||||
ap.add_argument("--limit", type=int, default=0, help="process at most N files (0=all)")
|
||||
args = ap.parse_args()
|
||||
|
||||
folder = Path(args.dir)
|
||||
files = sorted(p for p in folder.glob("*.pdf"))
|
||||
if args.limit:
|
||||
files = files[: args.limit]
|
||||
total = len(files)
|
||||
print(f"ingesting {total} bulletins from {folder}", flush=True)
|
||||
|
||||
await db.get_pool()
|
||||
agg = {"cases": 0, "articles": 0, "created": 0, "skipped": 0, "linked": 0}
|
||||
t0 = time.time()
|
||||
for i, f in enumerate(files, 1):
|
||||
try:
|
||||
r = await bulletin_library.ingest_bulletin(str(f))
|
||||
for k in agg:
|
||||
agg[k] += r.get(k, 0)
|
||||
print(f"[{i}/{total}] {r['file']}: cases={r['cases']} articles={r['articles']} "
|
||||
f"created={r['created']} skipped={r['skipped']} linked={r['linked']}", flush=True)
|
||||
except Exception as e: # one bad bulletin must not abort the batch
|
||||
print(f"[{i}/{total}] FAIL {f.name}: {type(e).__name__}: {e}", flush=True)
|
||||
|
||||
print(f"\nDONE {total} bulletins in {(time.time()-t0)/60:.1f}min | "
|
||||
f"cases={agg['cases']} articles={agg['articles']} created={agg['created']} "
|
||||
f"skipped={agg['skipped']} linked={agg['linked']}", flush=True)
|
||||
await db.close_pool()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
138
scripts/ingest_digests_batch.py
Normal file
138
scripts/ingest_digests_batch.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Batch ingest of "כל יום" daily digests staged in data/digests/incoming/ (X12).
|
||||
|
||||
Sequential (NOT concurrent — same load-spike caution as ingest_incoming_batch.py)
|
||||
ingest of each yomon PDF via the standalone digest pipeline
|
||||
(``digest_library.ingest_digest``), which:
|
||||
- extracts text, dedups on content_hash (idempotent),
|
||||
- runs the local LLM metadata extractor (concept_tag, headline, underlying
|
||||
citation, two dates, practice_area, subject_tags),
|
||||
- stores a single embedding,
|
||||
- auto-links to the underlying ruling if it is already in the precedent
|
||||
library (INV-DIG3).
|
||||
|
||||
The digest is a SECONDARY, radar-only source — it never enters the precedent /
|
||||
halacha pipeline and is never cited in a decision (INV-DIG1/2). After this run,
|
||||
relink unmatched digests once the originals are uploaded, or surface them via
|
||||
missing_precedent_create.
|
||||
|
||||
SINGLE SOURCE OF TRUTH: the `digests` table (DB) is the ONLY authority for what
|
||||
has been ingested. This script does NOT move files between folders — re-running
|
||||
is safe because ``ingest_digest`` dedups on content_hash (already-ingested →
|
||||
returns ``exists``). Files left in ``incoming/`` are simply re-checked and
|
||||
skipped. (Earlier versions moved files to a ``processed/`` folder; that created
|
||||
a second, divergent state and was removed.)
|
||||
|
||||
Yomon number + issue date are parsed from the filename
|
||||
("יומון 5158 - 31.5.26.pdf") as hints; the LLM also extracts them from the
|
||||
body and the explicit hint wins. The monthly bulletin (e.g. "201 יוני.pdf") is
|
||||
multi-topic and skipped (Phase 3).
|
||||
|
||||
Run: mcp-server/.venv/bin/python scripts/ingest_digests_batch.py
|
||||
(optionally pass explicit file paths as args)
|
||||
Config (POSTGRES_URL, VOYAGE_API_KEY, ANTHROPIC_API_KEY) auto-loads from ~/.env.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||
|
||||
from legal_mcp import config # noqa: E402
|
||||
from legal_mcp.services import digest_library as svc # noqa: E402
|
||||
|
||||
INCOMING = Path(config.DATA_DIR) / "digests" / "incoming"
|
||||
|
||||
# Matches "יומון 5158 - 31.5.26" → ("5158", "31.5.26")
|
||||
_NAME_RE = re.compile(r"יומון\s*(\d+)\s*-\s*(\d{1,2})\.(\d{1,2})\.(\d{2,4})")
|
||||
|
||||
|
||||
def _parse_name(fname: str) -> tuple[str, str | None]:
|
||||
"""Return (yomon_number, iso_date_or_None) parsed from the filename."""
|
||||
m = _NAME_RE.search(fname)
|
||||
if not m:
|
||||
return "", None
|
||||
num, dd, mm, yy = m.groups()
|
||||
year = int(yy)
|
||||
if year < 100:
|
||||
year += 2000
|
||||
try:
|
||||
iso = f"{year:04d}-{int(mm):02d}-{int(dd):02d}"
|
||||
except ValueError:
|
||||
iso = None
|
||||
return num, iso
|
||||
|
||||
|
||||
def _discover() -> list[Path]:
|
||||
if not INCOMING.exists():
|
||||
return []
|
||||
out = []
|
||||
for p in sorted(INCOMING.glob("*.pdf")):
|
||||
if "יומון" not in p.name:
|
||||
print(f"⊘ skip (not a single yomon): {p.name}", flush=True)
|
||||
continue
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
async def main(argv: list[str]) -> None:
|
||||
files = [Path(a) for a in argv] if argv else _discover()
|
||||
if not files:
|
||||
print(f"No yomon PDFs found in {INCOMING}", flush=True)
|
||||
return
|
||||
|
||||
results = []
|
||||
for idx, fp in enumerate(files):
|
||||
rec = {"file": fp.name}
|
||||
if not fp.exists():
|
||||
rec["error"] = "file-missing"
|
||||
print(f"✗ {fp.name}: file missing", flush=True)
|
||||
results.append(rec)
|
||||
continue
|
||||
yomon_number, iso_date = _parse_name(fp.name)
|
||||
try:
|
||||
out = await svc.ingest_digest(
|
||||
file_path=fp,
|
||||
yomon_number=yomon_number,
|
||||
digest_date=iso_date,
|
||||
)
|
||||
rec.update({
|
||||
"status": out.get("status"),
|
||||
"digest_id": out.get("digest_id"),
|
||||
"yomon_number": out.get("yomon_number"),
|
||||
"underlying_citation": out.get("underlying_citation"),
|
||||
"linked_case_law_id": out.get("linked_case_law_id"),
|
||||
})
|
||||
link = "🔗 linked" if out.get("linked_case_law_id") else "⚠ unlinked"
|
||||
print(
|
||||
f"✓ {fp.name}: {out.get('status')} | yomon={out.get('yomon_number')} | "
|
||||
f"{link} | {out.get('underlying_citation')}",
|
||||
flush=True,
|
||||
)
|
||||
# No folder move — the DB (content_hash) is the single source of
|
||||
# truth. Re-running re-checks incoming/ and skips already-ingested.
|
||||
except Exception as e:
|
||||
rec["error"] = f"{type(e).__name__}: {e}"
|
||||
print(f"✗ {fp.name}: {e}", flush=True)
|
||||
traceback.print_exc()
|
||||
results.append(rec)
|
||||
|
||||
print("\n===SUMMARY===", flush=True)
|
||||
for r in results:
|
||||
print(r, flush=True)
|
||||
linked = sum(1 for r in results if r.get("linked_case_law_id"))
|
||||
unlinked = sum(
|
||||
1 for r in results
|
||||
if r.get("status") in ("completed", "exists") and not r.get("linked_case_law_id")
|
||||
)
|
||||
print(
|
||||
f"\nTotal: {len(results)} | linked: {linked} | unlinked (need precedent upload): {unlinked}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main(sys.argv[1:]))
|
||||
@@ -37,7 +37,8 @@ const fs = require("fs");
|
||||
// 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
|
||||
// env var so the FastAPI proxy sends a matching Authorization header.
|
||||
// Migrate to Infisical (/_GUIDELINES) once the MCP server is back.
|
||||
// SoT in Infisical: nautilus:/legal-ai/LEGAL_CHAT_SHARED_SECRET (migrated
|
||||
// 2026-06-07). This local file remains the runtime source; rotate in both.
|
||||
const ENV_FILE = "/home/chaim/.legal-chat-service.env";
|
||||
const env = {
|
||||
HOME: "/home/chaim",
|
||||
|
||||
40
scripts/legal-court-fetch-drain.config.cjs
Normal file
40
scripts/legal-court-fetch-drain.config.cjs
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* pm2 ecosystem entry for legal-court-fetch-drain — a scheduled (hourly) one-shot
|
||||
* that drains the X13 court-verdict fetch queue the digest trigger fills, making
|
||||
* the digest → fetch → ingest loop fully autonomous (no manual court_fetch_drain).
|
||||
*
|
||||
* Pattern: cron_restart fires the script on schedule; autorestart:false means it
|
||||
* runs once and exits (pm2 shows it "stopped" between ticks — expected for a cron
|
||||
* job). A no-op (fast) when the queue is empty, so hourly is cheap.
|
||||
*
|
||||
* Requires (already deployed): legal-court-fetch-service (+xvfb) running for the
|
||||
* browser fetch, and the host env (~/.env: POSTGRES_URL, VOYAGE_API_KEY,
|
||||
* COURT_FETCH_SHARED_SECRET) the venv loads via legal_mcp.config. Ingest uses the
|
||||
* local claude CLI for halacha extraction (halachot land pending_review — the
|
||||
* chair's approval gate is untouched).
|
||||
*
|
||||
* Install (once):
|
||||
* pm2 start /home/chaim/legal-ai/scripts/legal-court-fetch-drain.config.cjs
|
||||
* pm2 save
|
||||
* Logs: pm2 logs legal-court-fetch-drain --lines 50
|
||||
* Run now (manual): mcp-server/.venv/bin/python scripts/drain_court_fetch.py
|
||||
*
|
||||
* Schedule override: COURT_FETCH_DRAIN_CRON (default hourly at :17 to avoid the
|
||||
* top-of-hour stampede with other jobs).
|
||||
*/
|
||||
const cron = process.env.COURT_FETCH_DRAIN_CRON || "17 * * * *";
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "legal-court-fetch-drain",
|
||||
cwd: "/home/chaim/legal-ai",
|
||||
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
|
||||
args: "scripts/drain_court_fetch.py 5",
|
||||
env: { HOME: "/home/chaim", PYTHONUNBUFFERED: "1" },
|
||||
autorestart: false, // one-shot per cron tick
|
||||
cron_restart: cron,
|
||||
max_memory_restart: "800M",
|
||||
},
|
||||
],
|
||||
};
|
||||
81
scripts/legal-court-fetch-service.config.cjs
Normal file
81
scripts/legal-court-fetch-service.config.cjs
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* pm2 ecosystem entry for legal-court-fetch-service — the host-side Tier-1
|
||||
* verdict fetcher (X13). It drives a Camoufox stealth browser against
|
||||
* נט המשפט to download administrative/district-court verdicts the Supreme
|
||||
* portal (Tier 0) doesn't carry. Lives on the host because the legal-ai
|
||||
* container can't run a browser. See docs/spec/X13-court-fetch.md.
|
||||
*
|
||||
* Mirrors legal-chat-service.config.cjs (same security model):
|
||||
* 1. Bind to 10.0.1.1 (docker0 bridge gateway) — host + docker-bridge
|
||||
* containers only; nothing from outside the host.
|
||||
* 2. Bearer token auth — COURT_FETCH_SHARED_SECRET loaded from
|
||||
* /home/chaim/.legal-court-fetch-service.env (chmod 600) and mirrored in
|
||||
* Coolify so the FastAPI proxy sends a matching Authorization header.
|
||||
* The service refuses to start without the secret.
|
||||
*
|
||||
* Prereqs for Tier-1 to actually fetch (otherwise it returns ok:false and the
|
||||
* orchestrator escalates to the human fallback — INV-CF3):
|
||||
* - camofox-browser running, CAMOFOX_URL set (e.g. http://127.0.0.1:9377).
|
||||
* git clone https://github.com/jo-inc/camofox-browser && npm i && npm start
|
||||
* - faster-whisper installed in the venv for the reCAPTCHA audio solver.
|
||||
*
|
||||
* Install (once):
|
||||
* pm2 start /home/chaim/legal-ai/scripts/legal-court-fetch-service.config.cjs
|
||||
* pm2 save
|
||||
* Smoke test:
|
||||
* curl http://10.0.1.1:8771/health
|
||||
* Update:
|
||||
* pm2 restart legal-court-fetch-service --update-env
|
||||
*/
|
||||
const fs = require("fs");
|
||||
|
||||
// SoT in Infisical: nautilus:/legal-ai/COURT_FETCH_SHARED_SECRET (migrated
|
||||
// 2026-06-07). This local file is the runtime source; rotate in both.
|
||||
const ENV_FILE = "/home/chaim/.legal-court-fetch-service.env";
|
||||
const env = {
|
||||
HOME: "/home/chaim",
|
||||
PATH: "/home/chaim/.local/bin:/usr/local/bin:/usr/bin:/bin",
|
||||
PYTHONUNBUFFERED: "1",
|
||||
// Camoufox (headless Firefox) crashes on this server without a virtual
|
||||
// display, so the service points at the Xvfb companion app below (:99).
|
||||
DISPLAY: ":99",
|
||||
};
|
||||
try {
|
||||
const text = fs.readFileSync(ENV_FILE, "utf8");
|
||||
for (const line of text.split("\n")) {
|
||||
if (!line || line.trim().startsWith("#")) continue;
|
||||
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (m) env[m[1]] = m[2];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`legal-court-fetch-service: failed to load ${ENV_FILE}: ${e.message}`);
|
||||
console.error("Service will refuse to start without COURT_FETCH_SHARED_SECRET.");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
// Persistent virtual display for Camoufox (headless Firefox needs it on
|
||||
// this screenless server). Bound to :99 to match DISPLAY above.
|
||||
name: "legal-court-fetch-xvfb",
|
||||
script: "/usr/bin/Xvfb",
|
||||
args: ":99 -screen 0 1920x1080x24 -nolisten tcp",
|
||||
autorestart: true,
|
||||
max_restarts: 10,
|
||||
restart_delay: 3000,
|
||||
},
|
||||
{
|
||||
name: "legal-court-fetch-service",
|
||||
cwd: "/home/chaim/legal-ai/mcp-server",
|
||||
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
|
||||
args: "-m legal_mcp.court_fetch_service.server --port 8771 --host 10.0.1.1",
|
||||
env,
|
||||
restart_delay: 5000,
|
||||
max_restarts: 10,
|
||||
autorestart: true,
|
||||
// A Firefox content process loading the heavy ASP.NET pages can spike;
|
||||
// give headroom but cap so a leak can't threaten Postgres.
|
||||
max_memory_restart: "1500M",
|
||||
},
|
||||
],
|
||||
};
|
||||
37
scripts/legal-digest-drain.config.cjs
Normal file
37
scripts/legal-digest-drain.config.cjs
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* pm2 ecosystem entry for legal-digest-drain — scheduled (every 2 h) drain of
|
||||
* the digest-enrichment queue (X12: "כל יום" yomonim → Sonnet enrichment +
|
||||
* embedding + autolink). Migrated from a bare system crontab line to pm2 so it
|
||||
* appears in — and is controllable from — the /operations dashboard (run-now /
|
||||
* enable / disable) like every other drain.
|
||||
*
|
||||
* Pattern: cron_restart fires the script on schedule; autorestart:false → runs
|
||||
* once and exits (pm2 shows "stopped" between ticks — expected). The script
|
||||
* already serialises itself (it self-heals stale 'processing' rows), so no flock
|
||||
* is needed under pm2's one-shot model.
|
||||
*
|
||||
* Requires (host ~/.env via legal_mcp.config): POSTGRES_URL, VOYAGE_API_KEY, and
|
||||
* the local `claude` CLI on PATH (the script prepends ~/.local/bin).
|
||||
*
|
||||
* Install (once):
|
||||
* pm2 start /home/chaim/legal-ai/scripts/legal-digest-drain.config.cjs
|
||||
* pm2 save
|
||||
* Run now (manual): mcp-server/.venv/bin/python scripts/drain_digests.py
|
||||
* Schedule override: DIGEST_DRAIN_CRON (default every 2 h at :00).
|
||||
*/
|
||||
const cron = process.env.DIGEST_DRAIN_CRON || "0 */2 * * *";
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "legal-digest-drain",
|
||||
cwd: "/home/chaim/legal-ai",
|
||||
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
|
||||
args: "scripts/drain_digests.py",
|
||||
env: { HOME: "/home/chaim", PYTHONUNBUFFERED: "1" },
|
||||
autorestart: false, // one-shot per cron tick
|
||||
cron_restart: cron,
|
||||
max_memory_restart: "800M",
|
||||
},
|
||||
],
|
||||
};
|
||||
39
scripts/legal-halacha-drain.config.cjs
Normal file
39
scripts/legal-halacha-drain.config.cjs
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* pm2 ecosystem entry for legal-halacha-drain — scheduled drain of the precedent
|
||||
* halacha-extraction queue. Halacha extraction stays on claude_session (local
|
||||
* CLI, high reasoning quality for holding/ratio) — unlike metadata which moved
|
||||
* to Gemini. Extracted halachot land 'pending_review' for the chair's approval
|
||||
* gate (INV-G10); this drainer only produces them, it never approves.
|
||||
*
|
||||
* The drain self-heals orphaned 'processing' rows (precedent_library) and is
|
||||
* serialised by a global advisory lock, so overlapping ticks are safe.
|
||||
*
|
||||
* Pattern: cron_restart fires the script; autorestart:false → one-shot per tick
|
||||
* (pm2 shows "stopped" between ticks). Cheap no-op when the queue is empty.
|
||||
* Cadence is conservative (every 2h) because Claude extraction is slow/rate-
|
||||
* limited and each run adds to the chair's review queue.
|
||||
*
|
||||
* Requires the local ``claude`` CLI + host ~/.env (POSTGRES_URL, etc.).
|
||||
*
|
||||
* Install (once):
|
||||
* pm2 start /home/chaim/legal-ai/scripts/legal-halacha-drain.config.cjs
|
||||
* pm2 save
|
||||
* Run now (manual): mcp-server/.venv/bin/python scripts/drain_halacha_queue.py
|
||||
* Schedule override: HALACHA_DRAIN_CRON (default every 2 hours at :47).
|
||||
*/
|
||||
const cron = process.env.HALACHA_DRAIN_CRON || "47 */2 * * *";
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "legal-halacha-drain",
|
||||
cwd: "/home/chaim/legal-ai",
|
||||
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
|
||||
args: "scripts/drain_halacha_queue.py",
|
||||
env: { HOME: "/home/chaim", PYTHONUNBUFFERED: "1" },
|
||||
autorestart: false, // one-shot per cron tick
|
||||
cron_restart: cron,
|
||||
max_memory_restart: "800M",
|
||||
},
|
||||
],
|
||||
};
|
||||
34
scripts/legal-metadata-drain.config.cjs
Normal file
34
scripts/legal-metadata-drain.config.cjs
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* pm2 ecosystem entry for legal-metadata-drain — scheduled (every 15 min) drain
|
||||
* of the precedent metadata-extraction queue (Gemini Flash). Keeps the
|
||||
* /precedents metadata queue from clogging (the prior agentic claude-CLI path
|
||||
* hit error_max_turns and nothing drained it autonomously).
|
||||
*
|
||||
* Pattern: cron_restart fires the script on schedule; autorestart:false → runs
|
||||
* once and exits (pm2 shows "stopped" between ticks — expected). Cheap no-op
|
||||
* when the queue is empty; Gemini Flash ≈ $0.10/1M tokens.
|
||||
*
|
||||
* Requires (host ~/.env via legal_mcp.config): GEMINI_API_KEY, POSTGRES_URL.
|
||||
*
|
||||
* Install (once):
|
||||
* pm2 start /home/chaim/legal-ai/scripts/legal-metadata-drain.config.cjs
|
||||
* pm2 save
|
||||
* Run now (manual): mcp-server/.venv/bin/python scripts/drain_metadata_queue.py
|
||||
* Schedule override: METADATA_DRAIN_CRON (default every 15 min).
|
||||
*/
|
||||
const cron = process.env.METADATA_DRAIN_CRON || "*/15 * * * *";
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "legal-metadata-drain",
|
||||
cwd: "/home/chaim/legal-ai",
|
||||
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
|
||||
args: "scripts/drain_metadata_queue.py 10",
|
||||
env: { HOME: "/home/chaim", PYTHONUNBUFFERED: "1" },
|
||||
autorestart: false, // one-shot per cron tick
|
||||
cron_restart: cron,
|
||||
max_memory_restart: "500M",
|
||||
},
|
||||
],
|
||||
};
|
||||
35
scripts/legal-reaper.config.cjs
Normal file
35
scripts/legal-reaper.config.cjs
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* pm2 ecosystem entry for legal-reaper — a host-side daemon that periodically
|
||||
* reaps orphaned, runaway processes that saturate the Nautilus box:
|
||||
* - task-master-mcp (Node) orphaned to ppid=1, ballooning to ~3GB each
|
||||
* (memory: project_taskmaster_mcp_memory_leak).
|
||||
* - camoufox-bin (Firefox) leftover from a crashed/killed X13 court fetch.
|
||||
* Only ppid=1 orphans are killed — live, parented processes are never touched.
|
||||
* See scripts/reap_orphan_procs.py for the safety rationale.
|
||||
*
|
||||
* Install (once):
|
||||
* pm2 start /home/chaim/legal-ai/scripts/legal-reaper.config.cjs
|
||||
* pm2 save
|
||||
* Logs:
|
||||
* pm2 logs legal-reaper --lines 50
|
||||
*
|
||||
* Interval defaults to 180s; override with REAP_INTERVAL_S.
|
||||
*/
|
||||
const interval = process.env.REAP_INTERVAL_S || "180";
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "legal-reaper",
|
||||
cwd: "/home/chaim/legal-ai",
|
||||
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
|
||||
args: `scripts/reap_orphan_procs.py --loop ${interval}`,
|
||||
env: { HOME: "/home/chaim", PYTHONUNBUFFERED: "1" },
|
||||
autorestart: true,
|
||||
max_restarts: 20,
|
||||
restart_delay: 5000,
|
||||
// The reaper itself is tiny and must never be the thing that leaks.
|
||||
max_memory_restart: "100M",
|
||||
},
|
||||
],
|
||||
};
|
||||
119
scripts/reap_orphan_procs.py
Normal file
119
scripts/reap_orphan_procs.py
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reap orphaned/runaway processes that saturate the Nautilus box.
|
||||
|
||||
Two known offenders (2026-06-07):
|
||||
1. ``task-master-mcp`` (Node) — spawned by the Claude Code VSCode extension,
|
||||
orphaned to ``ppid=1`` when its session ends, then **balloons to ~3GB
|
||||
each**. They accrue as sessions cycle and exhaust RAM within minutes,
|
||||
risking the OOM-killer hitting Postgres/Paperclip. See memory
|
||||
``project_taskmaster_mcp_memory_leak``.
|
||||
2. ``camoufox-bin`` (Firefox) — the X13 court-fetch browser. A fetch that
|
||||
hangs or is killed mid-flight can leave a stray browser orphaned to
|
||||
``ppid=1``. Serial-only fetching means any ``ppid=1`` camoufox-bin is
|
||||
stale and safe to kill.
|
||||
|
||||
Safety: only processes **orphaned to ``ppid=1``** are reaped — a process still
|
||||
owned by a live parent (an attached MCP server, or a browser a fetch is
|
||||
actively using) is never touched. Pure ``/proc`` parsing, no psutil dependency.
|
||||
|
||||
Usage:
|
||||
python scripts/reap_orphan_procs.py # one pass, print what was reaped
|
||||
python scripts/reap_orphan_procs.py --dry-run # report only
|
||||
python scripts/reap_orphan_procs.py --loop 180 # daemon: reap every 180s
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Process-name substrings to reap when orphaned (ppid==1).
|
||||
TARGETS = ("task-master-mcp", "camoufox-bin")
|
||||
|
||||
|
||||
def _read(path: str) -> str:
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return f.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _proc_info(pid: str) -> tuple[int, str, int] | None:
|
||||
"""Return (ppid, cmdline, rss_kb) for a pid, or None if it vanished."""
|
||||
status = _read(f"/proc/{pid}/status")
|
||||
if not status:
|
||||
return None
|
||||
ppid, rss = 0, 0
|
||||
for line in status.splitlines():
|
||||
if line.startswith("PPid:"):
|
||||
try: ppid = int(line.split()[1])
|
||||
except (IndexError, ValueError): pass
|
||||
elif line.startswith("VmRSS:"):
|
||||
try: rss = int(line.split()[1])
|
||||
except (IndexError, ValueError): pass
|
||||
cmd = _read(f"/proc/{pid}/cmdline").replace("\x00", " ").strip()
|
||||
return ppid, cmd, rss
|
||||
|
||||
|
||||
def find_orphans() -> list[tuple[str, str, int]]:
|
||||
"""Return [(pid, cmd, rss_kb)] of ppid==1 processes matching TARGETS."""
|
||||
out = []
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdigit():
|
||||
continue
|
||||
info = _proc_info(pid)
|
||||
if not info:
|
||||
continue
|
||||
ppid, cmd, rss = info
|
||||
if ppid == 1 and any(t in cmd for t in TARGETS):
|
||||
out.append((pid, cmd, rss))
|
||||
return out
|
||||
|
||||
|
||||
def reap(dry_run: bool = False) -> int:
|
||||
orphans = find_orphans()
|
||||
freed_mb = 0
|
||||
for pid, cmd, rss in orphans:
|
||||
name = next((t for t in TARGETS if t in cmd), cmd[:30])
|
||||
freed_mb += rss // 1024
|
||||
if dry_run:
|
||||
print(f"[dry-run] would reap pid={pid} ({name}) rss={rss//1024}MB", flush=True)
|
||||
continue
|
||||
try:
|
||||
os.kill(int(pid), signal.SIGKILL)
|
||||
print(f"reaped pid={pid} ({name}) rss={rss//1024}MB", flush=True)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except PermissionError:
|
||||
print(f" permission denied for pid={pid} ({name})", flush=True)
|
||||
if orphans:
|
||||
print(f"{'would free' if dry_run else 'freed'} ~{freed_mb}MB "
|
||||
f"from {len(orphans)} orphan(s)", flush=True)
|
||||
return len(orphans)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Reap orphaned task-master-mcp / camoufox-bin")
|
||||
ap.add_argument("--dry-run", action="store_true", help="report only, kill nothing")
|
||||
ap.add_argument("--loop", type=int, default=0, metavar="SECONDS",
|
||||
help="run forever, reaping every N seconds")
|
||||
args = ap.parse_args()
|
||||
if args.loop:
|
||||
print(f"reaper loop: every {args.loop}s targets={TARGETS}", flush=True)
|
||||
while True:
|
||||
try:
|
||||
reap(args.dry_run)
|
||||
except Exception as e: # never let the daemon die
|
||||
print(f"reap error: {e}", flush=True)
|
||||
time.sleep(args.loop)
|
||||
else:
|
||||
reap(args.dry_run)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
354
scripts/style_lesson_panel.py
Normal file
354
scripts/style_lesson_panel.py
Normal file
@@ -0,0 +1,354 @@
|
||||
#!/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())))
|
||||
387
web-ui/package-lock.json
generated
387
web-ui/package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -3903,6 +3904,12 @@
|
||||
"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": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -4606,6 +4613,15 @@
|
||||
"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": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -5022,6 +5038,16 @@
|
||||
"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": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
@@ -5203,6 +5229,18 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||
@@ -5608,6 +5646,222 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -6949,6 +7203,20 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -6965,6 +7233,32 @@
|
||||
"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": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
@@ -7537,6 +7831,15 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||
@@ -7577,6 +7880,15 @@
|
||||
"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": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
@@ -8241,6 +8553,15 @@
|
||||
"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": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@@ -8373,6 +8694,18 @@
|
||||
"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": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -8709,6 +9042,12 @@
|
||||
"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": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -10649,6 +10988,16 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -10914,6 +11263,23 @@
|
||||
"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": {
|
||||
"version": "7.72.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz",
|
||||
@@ -10936,6 +11302,21 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"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": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
@@ -12171,6 +12552,12 @@
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"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": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
* מרכז אישורים — דפנה (INV-G10).
|
||||
*
|
||||
* עמוד אחד שמרכז את כל השערים האנושיים הממתינים להכרעת היו"ר: אישור הלכות,
|
||||
* פסיקה חסרה, הערות שטרם יושמו, תיקים שנכשלו ב-QA, וסקירת gold-set. המטרה:
|
||||
* פסיקה חסרה, הערות שטרם יושמו, ותיקים שנכשלו ב-QA. המטרה:
|
||||
* שאף פריט הדורש את אישורך לא יישכח. הנתונים נשלפים חי מ-/api/chair/pending.
|
||||
*/
|
||||
const SEVERITY_BADGE: Record<ApprovalSeverity, string> = {
|
||||
|
||||
82
web-ui/src/app/digests/page.tsx
Normal file
82
web-ui/src/app/digests/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DigestListPanel } from "@/components/digests/digest-list-panel";
|
||||
import { DigestSearchPanel } from "@/components/digests/digest-search-panel";
|
||||
import { useDigestPending } from "@/lib/api/digests";
|
||||
|
||||
/**
|
||||
* Digests radar page (X12) — a SECONDARY discovery layer ABOVE the citation
|
||||
* corpora. Deliberately a SEPARATE page from /precedents to keep the
|
||||
* authoritative/secondary boundary visible: a digest POINTS at a ruling, it is
|
||||
* never cited in a decision (INV-DIG1) and never extracts halachot (INV-DIG2).
|
||||
*
|
||||
* Two tabs:
|
||||
* - יומונים — browse + upload + link to the underlying ruling
|
||||
* - חיפוש — semantic radar search
|
||||
*/
|
||||
|
||||
function PendingBadge() {
|
||||
const { data } = useDigestPending();
|
||||
const n = data?.count ?? 0;
|
||||
if (!n) return null;
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ms-1 bg-gold-wash text-gold-deep border-gold/40 text-[0.65rem]"
|
||||
>
|
||||
{n} ממתינים
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DigestsPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">יומונים</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">יומונים — רדאר פסיקה</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
|
||||
סיכומי "כל יום" (עפר טויסטר) של פסקי דין והחלטות עדכניים.
|
||||
שכבת-גילוי בלבד: כל יומון <strong>מצביע</strong> על פסק הדין המקורי —
|
||||
הוא אינו מצוטט בהחלטה ואינו מחלץ הלכות. כשהפסק רלוונטי, מעלים אותו
|
||||
לספריית הפסיקה ומצטטים משם.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<Tabs defaultValue="list" dir="rtl">
|
||||
<TabsList className="bg-rule-soft/60">
|
||||
<TabsTrigger value="list">
|
||||
יומונים
|
||||
<PendingBadge />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="search">חיפוש</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="list" className="mt-5">
|
||||
<DigestListPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="search" className="mt-5">
|
||||
<DigestSearchPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
38
web-ui/src/app/graph/page.tsx
Normal file
38
web-ui/src/app/graph/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { GraphView } from "@/components/graph/graph-view";
|
||||
|
||||
export default function GraphPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">
|
||||
בית
|
||||
</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">מפת הקורפוס</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">מפת הקורפוס</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
|
||||
רשת הציטוטים של ספריית הפסיקה — כל נקודה היא פסיקה או נושא, וקו מציין ציטוט או שיוך.
|
||||
גודל הנקודה משקף כמה פעמים הפסיקה צוטטה. לחצו על נקודה כדי להתמקד בשכניה.
|
||||
</p>
|
||||
</header>
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid h-[560px] place-items-center text-sm text-ink-muted">
|
||||
טוען גרף…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<GraphView />
|
||||
</Suspense>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
483
web-ui/src/app/operations/page.tsx
Normal file
483
web-ui/src/app/operations/page.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
useOperations,
|
||||
useServiceAction,
|
||||
useDrainToggle,
|
||||
type OpsService,
|
||||
type OperationsSnapshot,
|
||||
type PipelineStats,
|
||||
} from "@/lib/api/operations";
|
||||
|
||||
function mb(bytes: number): string {
|
||||
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
|
||||
}
|
||||
|
||||
function ago(ms: number): string {
|
||||
if (!ms) return "—";
|
||||
const secs = Math.floor((Date.now() - ms) / 1000);
|
||||
if (secs < 60) return `לפני ${secs}ש׳`;
|
||||
if (secs < 3600) return `לפני ${Math.floor(secs / 60)}ד׳`;
|
||||
if (secs < 86400) return `לפני ${Math.floor(secs / 3600)}ש׳`;
|
||||
return `לפני ${Math.floor(secs / 86400)}י׳`;
|
||||
}
|
||||
|
||||
// Hebrew labels for the raw status strings the backend reports.
|
||||
const STATUS_HE: Record<string, string> = {
|
||||
online: "פעיל",
|
||||
stopped: "עצור",
|
||||
errored: "שגיאה",
|
||||
launching: "עולה",
|
||||
pending: "ממתין",
|
||||
processing: "בעיבוד",
|
||||
running: "רץ",
|
||||
done: "הושלם",
|
||||
completed: "הושלם",
|
||||
failed: "נכשל",
|
||||
manual: "ידני",
|
||||
approved: "אושר",
|
||||
pending_review: "ממתין לאישור",
|
||||
rejected: "נדחה",
|
||||
published: "פורסם",
|
||||
deferred: "נדחה זמנית",
|
||||
open: "פתוח",
|
||||
closed: "סגור",
|
||||
unknown: "לא ידוע",
|
||||
};
|
||||
|
||||
function he(status: string): string {
|
||||
return STATUS_HE[status] ?? status;
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
online: "default",
|
||||
done: "default",
|
||||
completed: "default",
|
||||
approved: "default",
|
||||
published: "default",
|
||||
stopped: "secondary",
|
||||
pending: "secondary",
|
||||
pending_review: "secondary",
|
||||
open: "secondary",
|
||||
running: "outline",
|
||||
processing: "outline",
|
||||
launching: "outline",
|
||||
failed: "destructive",
|
||||
errored: "destructive",
|
||||
manual: "destructive",
|
||||
rejected: "destructive",
|
||||
};
|
||||
|
||||
function StatusBadge({ value, count }: { value: string; count?: number }) {
|
||||
return (
|
||||
<Badge variant={STATUS_VARIANT[value] ?? "outline"} className="font-normal">
|
||||
{he(value)}
|
||||
{count !== undefined ? <span className="ms-1 font-semibold">{count}</span> : null}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const SERVICE_LABELS: Record<string, string> = {
|
||||
"legal-court-fetch-service": "שירות אחזור פסיקה (דפדפן נט המשפט)",
|
||||
"legal-court-fetch-xvfb": "צג וירטואלי (Xvfb) לדפדפן",
|
||||
"legal-court-fetch-drain": "תזמון: ניקוז תור אחזור פסיקה (שעתי)",
|
||||
"legal-metadata-drain": "תזמון: חילוץ מטא-דאטה (Gemini, ×15 דק׳)",
|
||||
"legal-halacha-drain": "תזמון: חילוץ הלכות (Claude, ×שעתיים)",
|
||||
"legal-digest-drain": "תזמון: העשרת יומונים (Sonnet, ×שעתיים)",
|
||||
"legal-reaper": "מנקה תהליכים-יתומים (נגד דליפות זיכרון)",
|
||||
"legal-chat-service": "שירות צ׳אט אימון (גשר ל-claude CLI)",
|
||||
};
|
||||
|
||||
// ── Process-management panel (the "Windows services" view) ─────────────────
|
||||
function ServiceControls({
|
||||
s,
|
||||
busy,
|
||||
onAction,
|
||||
onToggle,
|
||||
}: {
|
||||
s: OpsService;
|
||||
busy: boolean;
|
||||
onAction: (action: "restart" | "stop" | "start" | "run-now") => void;
|
||||
onToggle: (disabled: boolean) => void;
|
||||
}) {
|
||||
const isCron = !!s.cron;
|
||||
|
||||
if (isCron) {
|
||||
// Cron drain: "run now" (pm2 restart) + an enable/disable switch (DB flag).
|
||||
return (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={busy || s.disabled}
|
||||
onClick={() => onAction("run-now")}
|
||||
title={s.disabled ? "הפעל את התזמון כדי להריץ" : "הרץ את ה-drain פעם אחת מיד"}
|
||||
>
|
||||
הרץ עכשיו
|
||||
</Button>
|
||||
<label className="flex items-center gap-1.5 text-[0.7rem] text-ink-muted cursor-pointer">
|
||||
<Switch
|
||||
checked={!s.disabled}
|
||||
disabled={busy}
|
||||
onCheckedChange={(on) => {
|
||||
if (on || confirm(`לכבות את התזמון "${SERVICE_LABELS[s.name] ?? s.name}"?`)) {
|
||||
onToggle(!on);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{s.disabled ? "כבוי" : "פעיל"}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Daemon: restart / stop / start.
|
||||
const online = s.status === "online";
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Button size="xs" variant="outline" disabled={busy} onClick={() => onAction("restart")}>
|
||||
הפעל מחדש
|
||||
</Button>
|
||||
{online ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm(`לעצור את "${SERVICE_LABELS[s.name] ?? s.name}"?`)) onAction("stop");
|
||||
}}
|
||||
>
|
||||
עצור
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="xs" variant="ghost" disabled={busy} onClick={() => onAction("start")}>
|
||||
הפעל
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServicesPanel({ data }: { data: OperationsSnapshot }) {
|
||||
const action = useServiceAction();
|
||||
const toggle = useDrainToggle();
|
||||
const busy = action.isPending || toggle.isPending;
|
||||
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h2 className="text-navy text-lg mb-1">ניהול תהליכי-רקע (pm2)</h2>
|
||||
<p className="text-ink-muted text-xs mb-4">
|
||||
כמו "שירותים" ב-Windows. דמון = שירות רץ-תמיד (הפעל-מחדש/עצור/הפעל).
|
||||
תזמון (cron) = רץ לפי לוח-זמנים ("הרץ עכשיו" להרצה מיידית, ומתג
|
||||
הפעלה/כיבוי של התזמון).
|
||||
</p>
|
||||
{data.services_error ? (
|
||||
<p className="text-sm text-destructive">{data.services_error}</p>
|
||||
) : data.services.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">אין שירותים.</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{data.services.map((s: OpsService) => {
|
||||
const isCron = !!s.cron;
|
||||
return (
|
||||
<div
|
||||
key={s.name}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-rule-soft bg-rule-soft/30 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isCron ? (
|
||||
<Badge
|
||||
variant={s.disabled ? "destructive" : "default"}
|
||||
className="font-normal"
|
||||
>
|
||||
{s.disabled ? "כבוי" : "פעיל (מתוזמן)"}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge value={s.status} />
|
||||
)}
|
||||
{s.cron ? (
|
||||
<span className="text-[0.7rem] text-ink-muted font-mono" dir="ltr">
|
||||
{s.cron}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[0.8rem] text-navy truncate mt-0.5">
|
||||
{SERVICE_LABELS[s.name] ?? s.name}
|
||||
</div>
|
||||
<div className="text-[0.66rem] text-ink-muted flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono" dir="ltr">
|
||||
{s.name}
|
||||
</span>
|
||||
<span>{mb(s.memory_bytes)}</span>
|
||||
<span>↻{s.restarts}</span>
|
||||
<span>{isCron ? `ריצה אחרונה ${ago(s.uptime_ms)}` : ago(s.uptime_ms)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ServiceControls
|
||||
s={s}
|
||||
busy={busy}
|
||||
onAction={(a) => action.mutate({ name: s.name, action: a })}
|
||||
onToggle={(disabled) => toggle.mutate({ name: s.name, disabled })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Uniform queue stats ────────────────────────────────────────────────────
|
||||
function StatTile({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
title,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "navy" | "muted" | "amber" | "green" | "red";
|
||||
title?: string;
|
||||
}) {
|
||||
const toneClass = {
|
||||
navy: "text-navy",
|
||||
muted: "text-ink-muted",
|
||||
amber: "text-gold-deep",
|
||||
green: "text-emerald-600",
|
||||
red: "text-destructive",
|
||||
}[tone];
|
||||
return (
|
||||
<div
|
||||
title={title}
|
||||
className="flex flex-col items-center rounded-md border border-rule-soft px-2.5 py-1.5 min-w-[68px]"
|
||||
>
|
||||
<span className={`text-base font-semibold leading-none ${toneClass}`}>{value}</span>
|
||||
<span className="text-[0.66rem] text-ink-muted mt-1 text-center">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UniformStats({ p }: { p: PipelineStats }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatTile
|
||||
label="בתור"
|
||||
value={p.queued}
|
||||
tone="navy"
|
||||
title="ממתינים שנדרשו במפורש לעיבוד — אלה שה-drain הבא יטפל בהם"
|
||||
/>
|
||||
<StatTile
|
||||
label="ממתין (בקלוג)"
|
||||
value={p.pending}
|
||||
tone="muted"
|
||||
title="כל הפריטים שטרם עובדו (ברירת-מחדל) — לאו דווקא בתור הפעיל"
|
||||
/>
|
||||
<StatTile label="בעיבוד" value={p.processing} tone="amber" />
|
||||
<StatTile label="הושלם" value={p.done} tone="green" />
|
||||
{p.failed > 0 ? <StatTile label="נכשל" value={p.failed} tone="red" /> : null}
|
||||
</div>
|
||||
{p.running_now.length > 0 ? (
|
||||
<div className="text-[0.74rem] text-navy">
|
||||
<span className="text-ink-muted">רץ עכשיו: </span>
|
||||
{p.running_now.join(" · ")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[0.72rem] text-ink-muted">אין פריט בעיבוד כרגע</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({ by }: { by: Record<string, number> }) {
|
||||
const entries = Object.entries(by).filter(([, n]) => n > 0);
|
||||
if (entries.length === 0) return <span className="text-ink-muted text-sm">ריק</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{entries
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, n]) => (
|
||||
<StatusBadge key={k} value={k} count={n} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineCard({
|
||||
title,
|
||||
desc,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
desc: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4 space-y-2.5">
|
||||
<div>
|
||||
<h3 className="text-navy text-sm font-semibold mb-0.5">{title}</h3>
|
||||
<p className="text-ink-muted text-[0.72rem]">{desc}</p>
|
||||
</div>
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
const { data, isLoading, error } = useOperations();
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">תפעול</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">תפעול — מה רץ ברקע</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
|
||||
כל מה שמוריד ומנתח אוטומטית: שירותי-המארח, משימות-התזמון, ותורי-העבודה.
|
||||
ניתן להפעיל-מחדש / לעצור / להריץ-עכשיו כל תהליך. מתרענן כל 5 שניות.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<Card className="bg-surface border-rule">
|
||||
<CardContent className="px-6 py-5 text-sm text-destructive">
|
||||
שגיאה בטעינת מצב התפעול: {String(error)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isLoading || !data ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ServicesPanel data={data} />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<PipelineCard
|
||||
title="אחזור פסיקה (X13)"
|
||||
desc="הורדה אוטומטית מנט-המשפט / פורטל-העליון → קורפוס"
|
||||
>
|
||||
<UniformStats p={data.pipelines.court_fetch} />
|
||||
</PipelineCard>
|
||||
|
||||
<PipelineCard
|
||||
title="חילוץ מטא-דאטה"
|
||||
desc="Gemini Flash — שם/תקציר/תגיות לכל פסיקה"
|
||||
>
|
||||
<UniformStats p={data.pipelines.metadata_extraction} />
|
||||
</PipelineCard>
|
||||
|
||||
<PipelineCard
|
||||
title="חילוץ הלכות"
|
||||
desc="Claude — הלכות מכל פסיקה (→ אישור יו״ר)"
|
||||
>
|
||||
<UniformStats p={data.pipelines.halacha_extraction} />
|
||||
</PipelineCard>
|
||||
|
||||
<PipelineCard
|
||||
title="העשרת יומונים (רדאר)"
|
||||
desc="Sonnet — תקציר/תגיות/קישור-לפסיקה לכל יומון"
|
||||
>
|
||||
<UniformStats p={data.pipelines.digests} />
|
||||
<p className="text-[0.72rem] text-ink-muted">
|
||||
{data.pipelines.digests.linked}/{data.pipelines.digests.total} מקושרים
|
||||
לפסיקה
|
||||
</p>
|
||||
</PipelineCard>
|
||||
|
||||
<PipelineCard
|
||||
title="אישור הלכות (שער יו״ר)"
|
||||
desc="הלכות שחולצו, ממתינות להכרעת דפנה ב-/approvals — שער-אנושי, לא תהליך"
|
||||
>
|
||||
<StatusRow by={data.pipelines.halacha_review.by_status} />
|
||||
</PipelineCard>
|
||||
|
||||
<PipelineCard
|
||||
title="פסיקה חסרה"
|
||||
desc="פערים בקורפוס — נסגרים אוטומטית כשנקלטים"
|
||||
>
|
||||
<StatusRow by={data.pipelines.missing_precedents.by_status} />
|
||||
</PipelineCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4">
|
||||
<h3 className="text-navy text-sm font-semibold mb-2">
|
||||
אחזורים אחרונים (תור)
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{data.pipelines.court_fetch.recent.map((j) => (
|
||||
<div
|
||||
key={j.case_number_norm}
|
||||
className="flex items-start justify-between gap-2 text-[0.78rem] border-b border-rule-soft pb-1.5 last:border-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<StatusBadge value={j.status} />
|
||||
<span className="text-navy ms-2">
|
||||
{j.citation_raw?.slice(0, 48) || j.case_number_norm}
|
||||
</span>
|
||||
{j.error ? (
|
||||
<div className="text-ink-muted text-[0.68rem] truncate">
|
||||
{j.error.slice(0, 80)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-ink-muted text-[0.66rem] shrink-0" dir="ltr">
|
||||
{(j.tier || "").slice(0, 7)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4">
|
||||
<h3 className="text-navy text-sm font-semibold mb-2">
|
||||
נקלטו לאחרונה (מבתי-משפט)
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{data.pipelines.ingested_recent.map((r) => (
|
||||
<div
|
||||
key={r.case_number}
|
||||
className="text-[0.78rem] text-navy border-b border-rule-soft pb-1.5 last:border-0 truncate"
|
||||
>
|
||||
{r.case_number}
|
||||
<span className="text-ink-muted text-[0.66rem] ms-2" dir="ltr">
|
||||
{r.created_at?.slice(0, 16).replace("T", " ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{data.pipelines.ingested_recent.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm">עדיין אין.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { use, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Pencil, Check, X } from "lucide-react";
|
||||
import { Pencil, Check, X, Share2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -88,9 +88,16 @@ export default function PrecedentDetailPage({
|
||||
{data.case_number}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||
<Pencil className="w-3.5 h-3.5 me-1" /> ערוך פרטים
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/graph?focus=cl:${id}`}>
|
||||
<Share2 className="w-3.5 h-3.5 me-1" /> הצג בגרף
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||
<Pencil className="w-3.5 h-3.5 me-1" /> ערוך פרטים
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Citation per Israeli unified citation rules. The LLM
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user