Compare commits
3 Commits
36d10b6a70
...
0990db7a3c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0990db7a3c | |||
| 955675eb1f | |||
| 8171572cdd |
@@ -223,12 +223,15 @@ new → proofread → documents_ready → analyst_verified → research_complete
|
|||||||
חיים העלה PDF פסיקה לתיק → ה-citation הוא:
|
חיים העלה PDF פסיקה לתיק → ה-citation הוא:
|
||||||
├── "ערר NNNN/YY" או "בל"מ NNNN/YY"
|
├── "ערר NNNN/YY" או "בל"מ NNNN/YY"
|
||||||
│ → internal_decision_upload (חובה chair_name + district)
|
│ → internal_decision_upload (חובה chair_name + district)
|
||||||
└── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
|
├── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
|
||||||
→ precedent_library_upload (external_upload)
|
│ → precedent_library_upload (external_upload)
|
||||||
|
└── PDF יומון "כל יום" (סיכום-משני של עפר טויסטר, עמוד אחד)
|
||||||
|
→ digest_upload (קורפוס-גילוי; לא קורפוס-ציטוט — X12)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`internal_decision_upload`** דורש: `file_path`, `case_number`, `chair_name`, `district`. district מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
|
- **`internal_decision_upload`** דורש: `file_path`, `case_number`, `chair_name`, `district`. district מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
|
||||||
- **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה.
|
- **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה.
|
||||||
|
- **`digest_upload`** — ליומון "כל יום" בלבד (מקור-משני שמצביע על פסק; INV-DIG1/2). אינו מצוטט בהחלטה ואינו מחלץ הלכות. **אל** תעלה יומון דרך precedent/internal — ואל תעלה פסק-דין דרך digest.
|
||||||
- פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש".
|
- פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ tools:
|
|||||||
- mcp__legal-ai__precedent_list
|
- mcp__legal-ai__precedent_list
|
||||||
- mcp__legal-ai__search_case_precedents
|
- mcp__legal-ai__search_case_precedents
|
||||||
- mcp__legal-ai__search_precedent_library
|
- mcp__legal-ai__search_precedent_library
|
||||||
|
- mcp__legal-ai__search_digests
|
||||||
|
- mcp__legal-ai__digest_link
|
||||||
|
- mcp__legal-ai__digest_upload
|
||||||
- mcp__legal-ai__internal_decision_upload
|
- mcp__legal-ai__internal_decision_upload
|
||||||
- mcp__legal-ai__precedent_library_upload
|
- mcp__legal-ai__precedent_library_upload
|
||||||
- mcp__legal-ai__precedent_library_get
|
- mcp__legal-ai__precedent_library_get
|
||||||
@@ -193,6 +196,26 @@ mcp__legal-ai__internal_decision_upload(
|
|||||||
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
|
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
|
||||||
- `search_case_precedents` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
- `search_case_precedents` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
||||||
|
|
||||||
|
#### 2ב.0 — שכבת-גילוי: יומוני "כל יום" (`search_digests`) — מצפן, לפני האימות
|
||||||
|
|
||||||
|
לכל סוגיה מרכזית — הרץ `search_digests` כ**מצפן-מחקר (radar)**, **לא** כמקור-ציטוט. היומון הוא סיכום-משני (עפר טויסטר) של פסק-דין בודד, והוא מפנה אותך אל **הפסק המקורי**. אם נמצא יומון רלוונטי:
|
||||||
|
|
||||||
|
1. קרא את כותרת-ההלכה ואת ניתוח עפר-טויסטר **כרקע/orientation בלבד**.
|
||||||
|
2. חלץ את **מראה-המקום של הפסק המקורי** מהיומון (שדה `underlying_citation`, למשל `עת"מ 46111-12-22`).
|
||||||
|
3. **בדוק אם הפסק המקורי בקורפוס** — `search_precedent_library` **וגם** `search_internal_decisions` לפי פרוטוקול 2ב.4א (לפי קידומת-הציטוט; flowchart §8).
|
||||||
|
4. **אם נמצא** → אמת וצטט את הפסק המקורי כרגיל (`precedent_attach`), וקרא `digest_link(digest_id, case_law_id)` כדי לקשר את היומון לפסק.
|
||||||
|
5. **אם לא נמצא** → קרא `missing_precedent_create` על **הפסק המקורי** (לא על היומון), עם `notes="זוהה דרך יומון 'כל יום' מס' NNNN"`. היומון הוא הטריגר; הרשומה החסרה היא הפסק. (אם הפסק זמין — אפשר להעלותו דרך `precedent_library_upload`/`internal_decision_upload` ואז `digest_link`.)
|
||||||
|
|
||||||
|
⚠️ **היומון לעולם אינו מצוטט בהחלטה ואינו נרשם דרך `precedent_attach`** (INV-DIG1). הוא radar בלבד — מצביע, לא מקור. ראה [docs/spec/X12-digests-radar.md](../../docs/spec/X12-digests-radar.md).
|
||||||
|
|
||||||
|
```
|
||||||
|
search_digests(
|
||||||
|
query="...",
|
||||||
|
practice_area="betterment_levy", # rishuy_uvniya / betterment_levy / compensation_197
|
||||||
|
limit=10
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
||||||
|
|
||||||
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
|
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
|
||||||
@@ -310,6 +333,10 @@ mcp__legal-ai__missing_precedent_create(
|
|||||||
|
|
||||||
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
|
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
|
||||||
|
|
||||||
|
#### 2ב.6 — תיעוד סריקת היומונים — סעיף "ט" ב-`precedent-research.md`
|
||||||
|
|
||||||
|
הוסף סעיף נפרד `## ט. סריקת יומונים (radar — לא ציטוט)` שמתעד אילו יומונים נסרקו לכל סוגיה, אילו פסקי-דין מקוריים הם הצביעו עליהם, וסטטוס כל אחד: *בקורפוס (קושר) / נרשם כחסר / לא רלוונטי*. ציין מפורש: **רשומות אלה אינן ציטוטים** — הן עקבות-מחקר (radar). ה-writer וה-QA מתעלמים מהן כמקור-סמכות (INV-DIG1); הציטוט בהחלטה תמיד נשען על הפסק המקורי שבסעיפים ז/ח.
|
||||||
|
|
||||||
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
||||||
|
|
||||||
### שלב 3: מיפוי תכנית
|
### שלב 3: מיפוי תכנית
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוט
|
|||||||
|
|
||||||
## 7. אינדקס הספ
|
## 7. אינדקס הספ
|
||||||
|
|
||||||
> הערה: כל קבצי הספ (00, 01–07, X1–X10) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
> הערה: כל קבצי הספ (00, 01–07, X1–X12) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
||||||
|
|
||||||
| קובץ | תפקיד | אוכף invariants |
|
| קובץ | תפקיד | אוכף 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 |
|
| [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) | חוזה 71 כלי-ה-MCP: envelope · שמות · idempotency · extract/get-symmetry · שלמות-הרשאות | G2, G3, G10 |
|
||||||
| [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) | env-catalog SSoT · מקור-config יחיד (Coolify) · ללא hardcode · secrets · drift | G2, G4, G9 |
|
| [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) | env-catalog SSoT · מקור-config יחיד (Coolify) · ללא hardcode · secrets · drift | G2, G4, G9 |
|
||||||
| [X11-citation-corroboration.md](X11-citation-corroboration.md) | citator פנימי — תיקוף הלכות בטיפול-שיפוטי מצטבר · תיקון-G10 מבוקר · סף-corroboration · התאמה-להלכה | G9, G10 |
|
| [X11-citation-corroboration.md](X11-citation-corroboration.md) | citator פנימי — תיקוף הלכות בטיפול-שיפוטי מצטבר · תיקון-G10 מבוקר · סף-corroboration · התאמה-להלכה | G9, G10 |
|
||||||
|
| [X12-digests-radar.md](X12-digests-radar.md) | יומונים כשכבת-גילוי (radar) — מקור-משני המצביע על הפסק המקורי · לא קורפוס-ציטוט רביעי · לא מצוטט/לא מחלץ-הלכות | G2, G4, G9 |
|
||||||
|
| [X13-court-fetch.md](X13-court-fetch.md) | אחזור-פסיקה אוטומטי מנט המשפט — 3 שכבות (עליון/מנהלי/skip) · שירות-מארח · reCAPTCHA · שער-אנושי | G2, G3, G4, G5, G9, G10 |
|
||||||
|
|
||||||
> **X6–X10 (מחזור-2):** מכסים את 8 משטחי-האפליקציה שמחוץ לצינור-הליבה (אינטגרציה, web-ui, מילוי-שדות,
|
> **X6–X10 (מחזור-2):** מכסים את 8 משטחי-האפליקציה שמחוץ לצינור-הליבה (אינטגרציה, web-ui, מילוי-שדות,
|
||||||
> אחסון-ניתוחים, כלי-MCP, deploy/env). הממצאים ב-[gap-audit.md](gap-audit.md) (GAP-24..62 → FU-9..15)
|
> אחסון-ניתוחים, כלי-MCP, deploy/env). הממצאים ב-[gap-audit.md](gap-audit.md) (GAP-24..62 → FU-9..15)
|
||||||
|
|||||||
@@ -35,6 +35,13 @@
|
|||||||
(`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**,
|
(`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**,
|
||||||
ושם נולדת ההפרה ב-§5.
|
ושם נולדת ההפרה ב-§5.
|
||||||
|
|
||||||
|
> **שכבת-גילוי — יומונים, לא קורפוס-ציטוט.** מעל 3 הקורפוסים יושבת שכבת-radar נפרדת: **יומונים**
|
||||||
|
> (סיכומי עפר-טויסטר), בטבלה פיזית נפרדת `digests` עם כלי `search_digests`. היומון הוא **מקור משני
|
||||||
|
> המצביע** על הפסק המקורי — **אינו** קורפוס-ציטוט רביעי, **אינו** עקיב-בפלט ([INV-RET5](#inv-ret5-כל-span-מוחזר-עקיב-למקורו)),
|
||||||
|
> ו**אינו** נוגע ב-`case_law`/`document_chunks`. ההפרדה כאן **פיזית** (טבלה נפרדת), לא תנאי-סינון —
|
||||||
|
> ולכן [INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query) מתקיים טריוויאלית. מלא ב-
|
||||||
|
> [X12-digests-radar.md](X12-digests-radar.md) (INV-DIG1–DIG3).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. עיצוב ה-hybrid retrieval
|
## 2. עיצוב ה-hybrid retrieval
|
||||||
@@ -176,3 +183,4 @@ re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף
|
|||||||
- [02-data-model.md](02-data-model.md) — חוזה-השלמות (searchable) + re-index שהאחזור מסנן לפיהם.
|
- [02-data-model.md](02-data-model.md) — חוזה-השלמות (searchable) + re-index שהאחזור מסנן לפיהם.
|
||||||
- [05-qa-review.md](05-qa-review.md) — שער-הלכה הידני (`review_status`) שמגדיר אילו הלכות searchable.
|
- [05-qa-review.md](05-qa-review.md) — שער-הלכה הידני (`review_status`) שמגדיר אילו הלכות searchable.
|
||||||
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור מלאה של כל span מוחזר (בסיס ל-INV-RET5).
|
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור מלאה של כל span מוחזר (בסיס ל-INV-RET5).
|
||||||
|
- [X12-digests-radar.md](X12-digests-radar.md) — שכבת-הגילוי (יומונים) שמעל הקורפוסים — מצביעה, לא מצוטטת.
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
|
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
|
||||||
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
|
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
|
||||||
|
|
||||||
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X10 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X13 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
||||||
- X1–X5: מזהים · רב-חברתי · אינטגרציה+deploy · סוכנים · audit.
|
- X1–X5: מזהים · רב-חברתי · אינטגרציה+deploy · סוכנים · audit.
|
||||||
- X6–X10 (מחזור-2, 8 משטחי-האפליקציה): חוזה UI↔API · לקוח-Paperclip · מילוי-שדות · חוזה כלי-MCP · deploy/env/secrets.
|
- X6–X10 (מחזור-2, 8 משטחי-האפליקציה): חוזה UI↔API · לקוח-Paperclip · מילוי-שדות · חוזה כלי-MCP · deploy/env/secrets.
|
||||||
|
- X11–X13 (הרחבות-תחום): citator פנימי (תיקוף-הלכות) · יומונים כשכבת-גילוי (radar) · אחזור-פסיקה אוטומטי מנט המשפט (שירות).
|
||||||
|
|
||||||
מפות-ממצאים: [gap-audit.md](gap-audit.md) (GAP-01..62 → FU-1..15; מחזור-1 ✅ הושלם, מחזור-2 פתוח) · [ui-audit.md](ui-audit.md) (ביקורת 13 דפי-UI).
|
מפות-ממצאים: [gap-audit.md](gap-audit.md) (GAP-01..62 → FU-1..15; מחזור-1 ✅ הושלם, מחזור-2 פתוח) · [ui-audit.md](ui-audit.md) (ביקורת 13 דפי-UI).
|
||||||
בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.md
|
בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.md
|
||||||
|
|||||||
163
docs/spec/X12-digests-radar.md
Normal file
163
docs/spec/X12-digests-radar.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# 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` (גיליון היומון) — מקור-באגים נפוץ; חילוץ-המטא-דאטה
|
||||||
|
מבחין ביניהם מפורשות.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
**הפרה ידועה:** — (תת-מערכת חדשה)
|
||||||
|
|
||||||
|
### 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_*`.
|
||||||
151
docs/spec/X13-court-fetch.md
Normal file
151
docs/spec/X13-court-fetch.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# X13 — אחזור-פסיקה אוטומטי מנט המשפט (Court Verdict Fetch)
|
||||||
|
|
||||||
|
> כפוף ל-[חוקת המערכת](00-constitution.md). תת-מערכת **שירות** (לא קורפוס) שמורידה פסקי-דין
|
||||||
|
> ציבוריים של בתי-משפט ומזרימה אותם ל**צינור-הקליטה הקנוני** של ספריית-הפסיקה. אחות-מושגית
|
||||||
|
> ל-[X12 — Digests Radar](X12-digests-radar.md) (הטריגר העיקרי) ול-[01-ingest](01-ingest.md)
|
||||||
|
> (היעד). אינה קורפוס רביעי ואינה מסלול-ingest מקביל.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. ייעוד והקשר
|
||||||
|
|
||||||
|
יומון (digest) מצביע על פסק-דין נושא (`underlying_citation`, למשל `עת"מ 46111-12-22`). כשהפסק
|
||||||
|
אינו בקורפוס, המערכת **מאחזרת אותו אוטומטית** ממקור ציבורי, מחלצת טקסט, וקולטת אותו דרך
|
||||||
|
`precedent_library_upload` → `ingest_precedent`. כך הופך פסק-דין מ"מצוטט-בלבד" ל"שמיש לחיפוש
|
||||||
|
וחילוץ-הלכות".
|
||||||
|
|
||||||
|
**הבחנת-מקור קריטית:** רק **פסקי-דין של בתי-משפט** ניתנים לאחזור ציבורי. **החלטות ועדת-ערר**
|
||||||
|
אינן זמינות ציבורית (נדרש נבו) — מסומנות כפער ולא נשלחות לאחזור.
|
||||||
|
|
||||||
|
**שתי דרכי-מקור ציבוריות:**
|
||||||
|
- **עליון** (עע"מ/בג"ץ/ע"א/רע"א/בר"מ/דנ"א) → `supremedecisions.court.gov.il` — הורדה ישירה (httpx), ללא CAPTCHA.
|
||||||
|
- **מנהלי/מחוזי/שלום** (עת"מ/עמ"נ/...) → מציג-התיקים של **נט המשפט** — ASP.NET WebForms
|
||||||
|
(`__doPostBack`/VIEWSTATE), anti-bot של F5, reCAPTCHA על החיפוש הציבורי, מסמכים כ-S3 cleared URLs.
|
||||||
|
מחייב **דפדפן-אמת** (host-side), ולכן שירות-מארח ב-pm2 (כדפוס `legal-chat-service`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ארכיטקטורה — שלוש שכבות (tiered)
|
||||||
|
|
||||||
|
```
|
||||||
|
underlying_citation → [classifier] → tier ∈ {supreme, admin, skip}
|
||||||
|
skip(ערר/בל"מ) → missing_precedent (נבו ידני) — לא אחזור
|
||||||
|
supreme → Tier 0: httpx בקונטיינר → supremedecisions — אוטונומי מלא
|
||||||
|
admin → Tier 1: legal-court-fetch-service (host/pm2) — אוטונומי-first
|
||||||
|
→ Camoufox stealth browser → external-search → reCAPTCHA(audio/Whisper)
|
||||||
|
→ download cleared PDF
|
||||||
|
→ Tier 2 fallback: VNC ידני / missing_precedent + התראה — שער-אנושי
|
||||||
|
(כל ה-tiers) → precedent_library_upload(source_type=court_ruling) → ingest_precedent
|
||||||
|
→ chunks+embeddings+halachot(pending) → relink digest / close gap
|
||||||
|
```
|
||||||
|
|
||||||
|
מצב-העבודה מנוהל בטבלת-תור `court_fetch_jobs` (idempotent, נצפה, retryable).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Invariants
|
||||||
|
|
||||||
|
### INV-CF1: מסלול-קליטה יחיד — אין ingest מקביל
|
||||||
|
**כלל:** כל ה-tiers מתנקזים ל**צינור-הקליטה הקנוני היחיד** (`precedent_library_upload` →
|
||||||
|
`ingest_precedent`). המאחזר מספק קובץ+מטא בלבד; אסור לו לכתוב `case_law`/`precedent_chunks`/
|
||||||
|
`halachot` ישירות או לשכפל לוגיקת-chunking/embedding.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G2](00-constitution.md#inv-g2) (מקור-אמת יחיד, אין מסלול מקביל) על תת-מערכת זו.
|
||||||
|
**אכיפה:** האורקסטרטור קורא רק ל-API/שירות-הקליטה הקיים; ביקורת-ארכיטקטורה ב-PR.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF2: אין בליעה שקטה — כל אחזור נצפה
|
||||||
|
**כלל:** לכל פסק-דין שזוהה לאחזור יש רשומת-job עם סטטוס סופי מפורש
|
||||||
|
(`done`/`failed`/`manual`). כישלון-אחזור **לעולם אינו נבלע** — הוא מסומן ומועלה (Tier 2),
|
||||||
|
לא נזרק בשקט. `except: pass` אסור.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G4](00-constitution.md#inv-g4) וכלל-ההנדסה "אין בליעה שקטה" (§6).
|
||||||
|
**אכיפה:** טבלת `court_fetch_jobs` (status+error+attempts) + לוג-warning בכל כישלון + Tier-2 gate.
|
||||||
|
**הפרה ידועה:** הפער הקיים ב-X12 — `try_autolink` שנכשל מחזיר `None` בשקט (יתוקן ע"י טריגר זה).
|
||||||
|
|
||||||
|
### INV-CF3: אוטונומי-first, שער-אנושי חובה ב-fallback
|
||||||
|
**כלל:** האחזור מנסה אוטונומית; אך כש-N נסיונות נכשלים, **שער-אנושי** (VNC לפתרון-CAPTCHA
|
||||||
|
חי / סימון missing_precedent + התראה) הוא **חובה, לא רשות**. המערכת אינה "מוותרת" ואינה
|
||||||
|
"מסתירה" — היא מסלימה לאדם.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G10](00-constitution.md#inv-g10) (המערכת מסייעת; שערים אנושיים = invariant).
|
||||||
|
**אכיפה:** מונה-נסיונות בטבלת-התור + מעבר אוטומטי ל-status=`manual` עם נתיב-פעולה ל-chaim.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF4: אחזור-אחראי (politeness) — סדרתי, מרווח, חתימה-אמיתית
|
||||||
|
**כלל:** האחזור מאתר-ממשלתי הוא **אחראי**: סדרתי (לא מקבילי), עם cooldown בין בקשות,
|
||||||
|
כיבוד-`robots`/תנאי-שימוש, ו-rate מתון. אסור flooding/parallel-hammering שעלול לחסום IP
|
||||||
|
או להעמיס על שירות ציבורי.
|
||||||
|
**מקורות:** RFC 9309 (*Robots Exclusion Protocol*, IETF 2022) · Google Search Central —
|
||||||
|
*Crawler / crawl-rate guidance* · OWASP — *Automated Threat Handbook* (OAT-021 Denial of
|
||||||
|
Service / responsible automation) | סטטוס: verified
|
||||||
|
**אכיפה:** האורקסטרטור והשירות אוכפים serial + `INTER_FETCH_COOLDOWN_SEC`; Camoufox מספק
|
||||||
|
חתימת-דפדפן אמיתית (לא spoof-חמדני). מראה לדפוס-התור ב-[`precedent_library.py`](../../mcp-server/src/legal_mcp/services/precedent_library.py).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF5: אחזור idempotent
|
||||||
|
**כלל:** אחזור הוא **idempotent** — מפתח-job דטרמיניסטי לפי `case_number` מנורמל. אחזור
|
||||||
|
חוזר של אותו תיק אינו יוצר job כפול ואינו קולט פסק-דין פעמיים (upsert על המפתח הקנוני).
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G3](00-constitution.md#inv-g3) (ingest idempotent) ו-[G1](00-constitution.md#inv-g1) (מזהה מנורמל בכתיבה).
|
||||||
|
**אכיפה:** אילוץ-ייחודיות על `court_fetch_jobs.case_number_norm`; הקליטה עצמה idempotent דרך `ingest_precedent`.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF6: שער-סיווג מקור — רק פסקי-דין של בתי-משפט
|
||||||
|
**כלל:** רק ציטוט שסווג כ**פסק-דין של בית-משפט** נשלח לאחזור. **ועדת-ערר (ערר/בל"מ) לעולם
|
||||||
|
אינה נשלחת לאחזור-ציבורי** (נדרש נבו) — היא מסומנת `missing_precedent` בלבד. הפריט הנקלט
|
||||||
|
נושא `source_type=court_ruling`, `source_kind=external_upload`, `precedent_level` לפי הערכאה.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G5](00-constitution.md#inv-g5) (metadata מלא + הפרדת-קורפוס)
|
||||||
|
ותואם את הבחנת-המקור ב-[01-ingest](01-ingest.md) (`court_ruling` מול `appeals_committee`).
|
||||||
|
**אכיפה:** המסווג מחזיר `tier=skip` ל-ערר/בל"מ; הקליטה אוכפת `source_type`.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF7: עקיבוּת-מקור + גבול-ToS
|
||||||
|
**כלל:** כל אחזור נושם **provenance** מלא (`source_url`, tier, זמן, מזהה-job) ב-audit-trail.
|
||||||
|
האחזור מוגבל ל**מסמכים ציבוריים** הזמינים ללא הזדהות (smart-card); אופי המערכת הוא
|
||||||
|
**הורדה-בסיוע** (עם שער-אנושי), לא בוט-סמוי לעקיפת בקרת-גישה.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G9](00-constitution.md#inv-g9) (עקיבוּת + audit-trail);
|
||||||
|
גבול-ה-ToS מועלה ליו"ר (חיים) כשיקול-מדיניות (עיקרון-עבודה 4: המשתמש הוא הסמכות).
|
||||||
|
**אכיפה:** `source_url`+tier נשמרים על `case_law`/`court_fetch_jobs`; שער-אנושי שומר על אופי בסיוע.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. מודל-נתונים — `court_fetch_jobs`
|
||||||
|
|
||||||
|
| עמודה | טיפוס | תפקיד |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `id` | UUID PK | מזהה-job |
|
||||||
|
| `case_number_norm` | TEXT UNIQUE | מפתח-idempotency קנוני (INV-CF5) |
|
||||||
|
| `citation_raw` | TEXT | הציטוט המקורי כפי שזוהה |
|
||||||
|
| `tier` | TEXT | `supreme` \| `admin` \| `skip` |
|
||||||
|
| `court` | TEXT | ערכאה שזוהתה |
|
||||||
|
| `status` | TEXT | `pending` \| `running` \| `done` \| `failed` \| `manual` |
|
||||||
|
| `attempts` | INT | מונה-נסיונות (ל-Tier 2 gate, INV-CF3) |
|
||||||
|
| `error` | TEXT | הודעת-כישלון אחרונה (INV-CF2) |
|
||||||
|
| `case_law_id` | UUID FK | הפסק שנקלט (NULL עד done) |
|
||||||
|
| `digest_id` | UUID FK | היומון-מקור (NULL לאד-הוק) |
|
||||||
|
| `source_url` | TEXT | provenance (INV-CF7) |
|
||||||
|
| `created_at` / `updated_at` | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. רכיבי-מימוש (מיפוי לקוד)
|
||||||
|
|
||||||
|
| רכיב | קובץ | מקור-תבנית / שימוש-חוזר |
|
||||||
|
|------|------|------------------------|
|
||||||
|
| מסווג | `mcp-server/.../services/court_citation.py` | regex מ-`citation_extractor.py:67-132` |
|
||||||
|
| Tier 0 | `services/court_fetch_supreme.py` | httpx; דפוס-cooldown מ-`precedent_library.py:176-186` |
|
||||||
|
| Tier 1 שירות | `mcp-server/.../court_fetch_service/server.py` | שכפול `chat_service/server.py` (aiohttp+Bearer+bind 10.0.1.1) |
|
||||||
|
| Camoufox client | `court_fetch_service/camofox_client.py` | חיקוי `~/.hermes/.../browser_camofox.py` |
|
||||||
|
| reCAPTCHA audio | `court_fetch_service/recaptcha_audio.py` | faster-whisper מקומי |
|
||||||
|
| proxy בקונטיינר | `web/court_fetch_proxy.py` | שכפול `web/chat_proxy.py` |
|
||||||
|
| pm2 | `scripts/legal-court-fetch-service.config.cjs` | שכפול `legal-chat-service.config.cjs` |
|
||||||
|
| אורקסטרטור+תור | `services/court_fetch_orchestrator.py` + `db.py` (SCHEMA_Vxx) | דפוס-תור קיים |
|
||||||
|
| כלי-MCP | `tools/court_fetch.py` (`court_verdict_fetch`) | חוזה-envelope [X9](X9-mcp-tool-contract.md) |
|
||||||
|
| טריגר | `services/digest_library.py` (`try_autolink` fail-path) | X12 |
|
||||||
|
| סוד | `COURT_FETCH_SHARED_SECRET` (Infisical + Coolify) | דפוס `LEGAL_CHAT_SHARED_SECRET`, [X10](X10-deploy-env-secrets.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. סיכונים (R&D — לעקוב)
|
||||||
|
- reCAPTCHA נלחם פעיל בפותרי-אודיו → שיעור-כישלון אפשרי גבוה → Tier 2 הוא קו-ההגנה (INV-CF3).
|
||||||
|
- F5/anti-bot עלול לחסום IP → politeness סדרתי + Camoufox (INV-CF4).
|
||||||
|
- שבירות מול שינויי-אתר → ריכוז selectors במקום אחד + בדיקות-עשן תקופתיות.
|
||||||
|
- גבול-ToS על אתר .gov → INV-CF7 + שיקול-יו"ר.
|
||||||
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.
|
||||||
|
"""
|
||||||
148
mcp-server/src/legal_mcp/court_fetch_service/camofox_client.py
Normal file
148
mcp-server/src/legal_mcp/court_fetch_service/camofox_client.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""Camoufox-browser client + נט-המשפט navigation flow (X13, Tier 1).
|
||||||
|
|
||||||
|
Open-source, zero-API-cost stealth browsing: a self-hosted ``camofox-browser``
|
||||||
|
REST server (``jo-inc/camofox-browser``, wrapping Camoufox — a Firefox fork
|
||||||
|
with C++ fingerprint spoofing) drives a real browser. We talk to it over the
|
||||||
|
same REST surface the Hermes agent uses (``~/.hermes/.../browser_camofox.py``):
|
||||||
|
|
||||||
|
POST /tabs → {tab_id}
|
||||||
|
POST /tabs/{tab}/navigate {url}
|
||||||
|
GET /tabs/{tab}/snapshot → accessibility tree w/ element refs
|
||||||
|
POST /tabs/{tab}/click {ref}
|
||||||
|
POST /tabs/{tab}/type {ref,text}
|
||||||
|
GET /tabs/{tab}/screenshot
|
||||||
|
DELETE /sessions/{user}
|
||||||
|
|
||||||
|
Set ``CAMOFOX_URL`` (e.g. ``http://127.0.0.1:9377``) to enable. The server's
|
||||||
|
``/health`` exposes a VNC URL — that's the human-fallback surface (INV-CF3):
|
||||||
|
when the autonomous reCAPTCHA solve fails, the chair opens the VNC and solves
|
||||||
|
it live, and this flow continues.
|
||||||
|
|
||||||
|
⚠ CALIBRATION: the נט-המשפט external-case-search is an ASP.NET WebForms app
|
||||||
|
behind an F5 WAF + reCAPTCHA. The element selectors and step sequence below
|
||||||
|
are the *documented plan* of the flow; they must be calibrated against the
|
||||||
|
live snapshot on first run (the site rate-limited static probing during
|
||||||
|
development). Every step that can't find its target **raises** a clear Hebrew
|
||||||
|
reason (INV-CF2 — no silent success-with-garbage) so the orchestrator escalates
|
||||||
|
to the Tier-2 human fallback rather than returning an empty/wrong file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# נט המשפט public entry points (discovered from the homepage __doPostBack menu).
|
||||||
|
NGCS_HOME = "https://www.court.gov.il/ngcs.web.site/homepage.aspx"
|
||||||
|
|
||||||
|
CAMOFOX_URL = os.environ.get("CAMOFOX_URL", "").rstrip("/")
|
||||||
|
_TIMEOUT = float(os.environ.get("COURT_FETCH_BROWSER_TIMEOUT_S", "60"))
|
||||||
|
|
||||||
|
|
||||||
|
class CamofoxUnavailable(RuntimeError):
|
||||||
|
"""camofox-browser isn't configured/reachable."""
|
||||||
|
|
||||||
|
|
||||||
|
class NgcsFlowError(RuntimeError):
|
||||||
|
"""A step in the נט-המשפט flow failed (selector/CAPTCHA/navigation)."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_enabled() -> bool:
|
||||||
|
return bool(CAMOFOX_URL)
|
||||||
|
|
||||||
|
|
||||||
|
async def health() -> dict:
|
||||||
|
"""Probe camofox-browser; surfaces the VNC URL for the human fallback."""
|
||||||
|
if not CAMOFOX_URL:
|
||||||
|
raise CamofoxUnavailable("CAMOFOX_URL is not set")
|
||||||
|
async with httpx.AsyncClient(timeout=10) as c:
|
||||||
|
r = await c.get(f"{CAMOFOX_URL}/health")
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
class _Browser:
|
||||||
|
"""Thin async wrapper over the camofox-browser REST surface."""
|
||||||
|
|
||||||
|
def __init__(self, client: httpx.AsyncClient, tab_id: str, user_id: str):
|
||||||
|
self._c = client
|
||||||
|
self.tab = tab_id
|
||||||
|
self.user = user_id
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def open(cls, client: httpx.AsyncClient) -> "_Browser":
|
||||||
|
r = await client.post(f"{CAMOFOX_URL}/tabs", json={})
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
return cls(client, data["tab_id"], data.get("user_id", data["tab_id"]))
|
||||||
|
|
||||||
|
async def navigate(self, url: str) -> None:
|
||||||
|
r = await self._c.post(f"{CAMOFOX_URL}/tabs/{self.tab}/navigate", json={"url": url})
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
async def snapshot(self) -> dict:
|
||||||
|
r = await self._c.get(f"{CAMOFOX_URL}/tabs/{self.tab}/snapshot")
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
async def click(self, ref: str) -> dict:
|
||||||
|
r = await self._c.post(f"{CAMOFOX_URL}/tabs/{self.tab}/click", json={"ref": ref})
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
async def type(self, ref: str, text: str) -> None:
|
||||||
|
r = await self._c.post(
|
||||||
|
f"{CAMOFOX_URL}/tabs/{self.tab}/type", json={"ref": ref, "text": text}
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
try:
|
||||||
|
await self._c.delete(f"{CAMOFOX_URL}/sessions/{self.user}")
|
||||||
|
except httpx.HTTPError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_admin_verdict(
|
||||||
|
*, file_number: str, month: str, year: str, case_number: str, court: str
|
||||||
|
) -> dict:
|
||||||
|
"""Drive נט המשפט to download an admin/district verdict PDF.
|
||||||
|
|
||||||
|
Returns ``{content: bytes, filename: str, source_url: str, court: str}``.
|
||||||
|
Raises ``CamofoxUnavailable`` / ``NgcsFlowError`` on failure.
|
||||||
|
|
||||||
|
The flow (to be calibrated against the live snapshot):
|
||||||
|
1. Open the homepage; trigger "חיפוש תיקים חיצוני" (btnExternalSearchCases).
|
||||||
|
2. Fill the case-number / month / year fields.
|
||||||
|
3. Solve the reCAPTCHA via the audio challenge (recaptcha_audio); on
|
||||||
|
repeated failure, surface the VNC URL for a human solve (INV-CF3).
|
||||||
|
4. Submit; open the matched case; locate the verdict ("פסק דין") document.
|
||||||
|
5. Download the cleared PDF (served via S3 pre-signed URL) and return bytes.
|
||||||
|
"""
|
||||||
|
if not CAMOFOX_URL:
|
||||||
|
raise CamofoxUnavailable(
|
||||||
|
"שירות-הדפדפן (camofox-browser) אינו מוגדר — הגדר CAMOFOX_URL "
|
||||||
|
"והפעל את jo-inc/camofox-browser. ראה docs/spec/X13-court-fetch.md."
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
br = await _Browser.open(client)
|
||||||
|
try:
|
||||||
|
await br.navigate(NGCS_HOME)
|
||||||
|
snap = await br.snapshot()
|
||||||
|
_ = snap # calibration anchor: locate btnExternalSearchCases here.
|
||||||
|
|
||||||
|
# The concrete selector/CAPTCHA/download steps require live
|
||||||
|
# calibration with camofox running. Until calibrated we fail
|
||||||
|
# loudly so the orchestrator escalates to the human fallback
|
||||||
|
# (INV-CF2/CF3) rather than pretending success.
|
||||||
|
raise NgcsFlowError(
|
||||||
|
"זרימת נט-המשפט (Tier 1) ממתינה לכיול מול snapshot חי של "
|
||||||
|
"camofox-browser — בקשת-אחזור מוסלמת ל-fallback אנושי (VNC/ידני)."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await br.close()
|
||||||
@@ -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)
|
||||||
145
mcp-server/src/legal_mcp/court_fetch_service/server.py
Normal file
145
mcp-server/src/legal_mcp/court_fetch_service/server.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""Host-side HTTP bridge for Tier-1 verdict fetching (X13).
|
||||||
|
|
||||||
|
Mirrors ``legal_mcp.chat_service.server`` — the proven host-side pattern: an
|
||||||
|
aiohttp app, bound to the docker bridge gateway, Bearer-auth, that does the one
|
||||||
|
thing the container can't (here: drive a real browser against נט המשפט).
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /fetch body {file_number, month, year, case_number, court}
|
||||||
|
→ {ok, content_b64, filename, source_url, court, reason}
|
||||||
|
REQUIRES Authorization: Bearer <COURT_FETCH_SHARED_SECRET>.
|
||||||
|
GET /health liveness (no auth); reports camofox + VNC URL if available.
|
||||||
|
|
||||||
|
Run with pm2:
|
||||||
|
pm2 start scripts/legal-court-fetch-service.config.cjs
|
||||||
|
|
||||||
|
Security posture (identical rationale to legal-chat-service):
|
||||||
|
1. Bind defaults to ``10.0.1.1`` (docker0 bridge gateway) — reachable from
|
||||||
|
the host + containers on docker bridges, invisible to outside networks.
|
||||||
|
2. ``/fetch`` requires a Bearer token (constant-time compare); the service
|
||||||
|
refuses to start without ``COURT_FETCH_SHARED_SECRET`` set.
|
||||||
|
3. ``/health`` is unauthenticated and spawns nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
_pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
if _pkg_root not in sys.path:
|
||||||
|
sys.path.insert(0, _pkg_root)
|
||||||
|
|
||||||
|
from legal_mcp.court_fetch_service import camofox_client # noqa: E402
|
||||||
|
|
||||||
|
logger = logging.getLogger("legal_court_fetch_service")
|
||||||
|
|
||||||
|
_SHARED_SECRET: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
async def health(request: web.Request) -> web.Response:
|
||||||
|
info = {"ok": True, "service": "legal-court-fetch-service",
|
||||||
|
"camofox_enabled": camofox_client.is_enabled()}
|
||||||
|
if camofox_client.is_enabled():
|
||||||
|
try:
|
||||||
|
info["camofox"] = await camofox_client.health()
|
||||||
|
except Exception as e: # health must never throw
|
||||||
|
info["camofox_error"] = str(e)
|
||||||
|
return web.json_response(info)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_bearer(request: web.Request) -> web.Response | None:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
expected = "Bearer " + _SHARED_SECRET
|
||||||
|
if not auth or not hmac.compare_digest(auth, expected):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "unauthorized: missing or invalid Bearer token"}, status=401
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch(request: web.Request) -> web.Response:
|
||||||
|
unauth = _check_bearer(request)
|
||||||
|
if unauth is not None:
|
||||||
|
return unauth
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.json_response({"error": "invalid JSON body"}, status=400)
|
||||||
|
|
||||||
|
required = ("file_number", "month", "year")
|
||||||
|
if not all(body.get(k) for k in required):
|
||||||
|
return web.json_response(
|
||||||
|
{"ok": False, "reason": f"missing one of {required}"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await camofox_client.fetch_admin_verdict(
|
||||||
|
file_number=str(body["file_number"]),
|
||||||
|
month=str(body["month"]),
|
||||||
|
year=str(body["year"]),
|
||||||
|
case_number=str(body.get("case_number", "")),
|
||||||
|
court=str(body.get("court", "")),
|
||||||
|
)
|
||||||
|
return web.json_response({
|
||||||
|
"ok": True,
|
||||||
|
"content_b64": base64.b64encode(result["content"]).decode("ascii"),
|
||||||
|
"filename": result.get("filename", ""),
|
||||||
|
"source_url": result.get("source_url", ""),
|
||||||
|
"court": result.get("court", ""),
|
||||||
|
})
|
||||||
|
except (camofox_client.CamofoxUnavailable, camofox_client.NgcsFlowError) as e:
|
||||||
|
# Expected, recoverable failure → orchestrator escalates (INV-CF3).
|
||||||
|
return web.json_response({"ok": False, "reason": str(e)}, status=200)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.exception("fetch failed")
|
||||||
|
return web.json_response({"ok": False, "reason": f"unexpected: {e}"}, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
def build_app() -> web.Application:
|
||||||
|
app = web.Application(client_max_size=64 * 1024 * 1024)
|
||||||
|
app.router.add_get("/health", health)
|
||||||
|
app.router.add_post("/fetch", fetch)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="legal-court-fetch-service")
|
||||||
|
parser.add_argument("--port", type=int, default=8771)
|
||||||
|
parser.add_argument("--host", default="10.0.1.1",
|
||||||
|
help="bind address; default = docker0 bridge gateway")
|
||||||
|
parser.add_argument("--log-level", default="INFO")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=args.log_level.upper(),
|
||||||
|
format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
secret = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
|
||||||
|
if not secret:
|
||||||
|
logger.error(
|
||||||
|
"COURT_FETCH_SHARED_SECRET is empty; refusing to start. Set it in "
|
||||||
|
"/home/chaim/.legal-court-fetch-service.env (loaded by pm2) and "
|
||||||
|
"mirror it as a Coolify env var on the legal-ai app."
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
if len(secret) < 24:
|
||||||
|
logger.error("COURT_FETCH_SHARED_SECRET too short (>=32 chars expected).")
|
||||||
|
return 2
|
||||||
|
global _SHARED_SECRET
|
||||||
|
_SHARED_SECRET = secret
|
||||||
|
|
||||||
|
app = build_app()
|
||||||
|
logger.info("legal-court-fetch-service listening on %s:%d", args.host, args.port)
|
||||||
|
web.run_app(app, host=args.host, port=args.port, print=lambda _m: None)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -58,6 +58,8 @@ from legal_mcp.tools import ( # noqa: E402
|
|||||||
missing_precedents as mp_tools,
|
missing_precedents as mp_tools,
|
||||||
citations as cit_tools,
|
citations as cit_tools,
|
||||||
training_enrichment as train_tools,
|
training_enrichment as train_tools,
|
||||||
|
digests as digest_tools,
|
||||||
|
court_fetch as cf_tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -340,6 +342,75 @@ 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()
|
@mcp.tool()
|
||||||
async def halacha_review(
|
async def halacha_review(
|
||||||
halacha_id: str,
|
halacha_id: str,
|
||||||
@@ -895,6 +966,22 @@ async def missing_precedent_close(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Court verdict auto-fetch (X13) ────────────────────────────────
|
||||||
|
@mcp.tool()
|
||||||
|
async def court_verdict_fetch(citation: str) -> str:
|
||||||
|
"""אחזור אוטומטי של פסק-דין בית-משפט מנט המשפט/פורטל-העליון וקליטה לקורפוס.
|
||||||
|
|
||||||
|
מסווג את הציטוט (עליון→Tier0 / מנהלי→Tier1 / ערר→skip), מוריד וקולט דרך
|
||||||
|
צינור-הקליטה הקנוני. דוגמה: 'עת"מ 46111-12-22'. כלי מקומי בלבד."""
|
||||||
|
return await cf_tools.court_verdict_fetch(citation)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def court_fetch_status(case_number: str = "", status_filter: str = "") -> str:
|
||||||
|
"""סטטוס תור-אחזור הפסיקה. case_number לפריט יחיד, או status_filter (pending/failed/manual/done)."""
|
||||||
|
return await cf_tools.court_fetch_status(case_number, status_filter)
|
||||||
|
|
||||||
|
|
||||||
# ── Internal citations graph (TaskMaster #34) ─────────────────────
|
# ── Internal citations graph (TaskMaster #34) ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
204
mcp-server/src/legal_mcp/services/court_citation.py
Normal file
204
mcp-server/src/legal_mcp/services/court_citation.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""Court-citation classifier for the auto-fetch subsystem (X13).
|
||||||
|
|
||||||
|
Given a raw citation string (typically a digest's ``underlying_citation``,
|
||||||
|
e.g. ``עת"מ 46111-12-22 יכין-אפק נ' הוועדה המחוזית``), decide:
|
||||||
|
|
||||||
|
* **which tier** can fetch it (``supreme`` | ``admin`` | ``skip``), and
|
||||||
|
* the **canonical case number** plus, for נט המשפט, the
|
||||||
|
(file, month, year) triple the public case-search form needs.
|
||||||
|
|
||||||
|
Tier mapping (INV-CF6 — only court rulings are auto-fetched; ועדת-ערר is
|
||||||
|
never sent to a public fetch, it needs Nevo):
|
||||||
|
|
||||||
|
* ``supreme`` — Supreme Court prefixes (עע"מ/בג"ץ/ע"א/רע"א/דנ"א/בר"מ/בש"א).
|
||||||
|
Fetched directly from ``supremedecisions.court.gov.il`` (Tier 0, no CAPTCHA).
|
||||||
|
* ``admin`` — district / administrative-court prefixes (עת"מ/עמ"נ/…) and
|
||||||
|
the bare נט-המשפט "filed" format ``NNNNN-MM-YY``. Fetched via the
|
||||||
|
host-side stealth browser against נט המשפט (Tier 1).
|
||||||
|
* ``skip`` — ועדת-ערר (ערר/בל"מ). Not publicly fetchable → missing_precedent.
|
||||||
|
|
||||||
|
Regex families intentionally mirror ``citation_extractor.py`` (the canonical
|
||||||
|
prefix/number patterns) so the two stay in sync — we reuse ``_NUM_RX`` shape
|
||||||
|
and ``_normalize_case_number`` semantics rather than inventing a parallel
|
||||||
|
parser (INV-CF1 / engineering "symmetry" rule).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# Canonical number core, identical shape to citation_extractor._NUM_RX:
|
||||||
|
# 3-5 digits, optional separator + 2-4 digits, optional third group
|
||||||
|
# (the NNNNN-MM-YY "filed" format — 46111-12-22 = file 46111, month 12, yr 22).
|
||||||
|
_NUM_RX = r"\d{1,5}(?:[-/]\d{1,4}(?:[-/]\d{2,4})?)?"
|
||||||
|
|
||||||
|
# Hebrew gershayim: straight (") or curly (״).
|
||||||
|
_Q = r"[\"״]"
|
||||||
|
|
||||||
|
# Optional leading one-letter Hebrew preposition/conjunction (ב/ל/ה/ו/כ/מ/ש)
|
||||||
|
# attached to the prefix — e.g. "בערר", "וערר", "כפי שקבעתי בערר". Anchored by
|
||||||
|
# a lookbehind that forbids a *preceding* Hebrew letter, so we don't match a
|
||||||
|
# prefix buried inside a longer word. Regex backtracking lets the preposition
|
||||||
|
# match empty when the prefix itself starts with one of these letters (בג"ץ).
|
||||||
|
_LEAD = r"(?<![א-ת])(?:[בלהוכמש])?"
|
||||||
|
|
||||||
|
# Supreme Court prefixes → Tier 0 (supremedecisions public download API).
|
||||||
|
_SUPREME_PREFIXES = [
|
||||||
|
rf"עע{_Q}מ", # ערעור מנהלי (לעליון)
|
||||||
|
rf"בג{_Q}ץ", # בג"ץ
|
||||||
|
rf"בג{_Q}צ", # variant spelling
|
||||||
|
rf"דנג{_Q}ץ", # דיון נוסף בג"ץ
|
||||||
|
rf"ע{_Q}א", # ערעור אזרחי
|
||||||
|
rf"רע{_Q}א", # רשות ערעור אזרחי
|
||||||
|
rf"דנ{_Q}א", # דיון נוסף אזרחי
|
||||||
|
rf"בר{_Q}מ", # בקשת רשות ערעור מנהלי (עליון)
|
||||||
|
rf"בש{_Q}א", # בקשת רשות … (עליון)
|
||||||
|
]
|
||||||
|
|
||||||
|
# District / administrative-court prefixes → Tier 1 (נט המשפט case viewer).
|
||||||
|
_ADMIN_PREFIXES = [
|
||||||
|
rf"עת{_Q}מ", # עתירה מנהלית (בימ"ש לעניינים מנהליים)
|
||||||
|
rf"עמ{_Q}נ", # ערעור מנהלי (מחוזי)
|
||||||
|
rf"ת{_Q}א", # תביעה אזרחית (מחוזי/שלום)
|
||||||
|
rf"ה{_Q}פ", # המרצת פתיחה
|
||||||
|
]
|
||||||
|
|
||||||
|
# Appeals-committee → skip (needs Nevo; never auto-fetched).
|
||||||
|
_SKIP_PREFIXES = [
|
||||||
|
rf"ערר",
|
||||||
|
rf"בל{_Q}מ",
|
||||||
|
]
|
||||||
|
|
||||||
|
_SUPREME_RX = re.compile(
|
||||||
|
_LEAD + r"(" + "|".join(_SUPREME_PREFIXES) + r")\s*(" + _NUM_RX + r")",
|
||||||
|
re.UNICODE,
|
||||||
|
)
|
||||||
|
_ADMIN_RX = re.compile(
|
||||||
|
_LEAD + r"(" + "|".join(_ADMIN_PREFIXES) + r")\s*(" + _NUM_RX + r")",
|
||||||
|
re.UNICODE,
|
||||||
|
)
|
||||||
|
_SKIP_RX = re.compile(
|
||||||
|
_LEAD + r"(" + "|".join(_SKIP_PREFIXES) + r")" + r"(?:\s*\([^)\n]{0,80}\))?\s*(" + _NUM_RX + r")",
|
||||||
|
re.UNICODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bare נט-המשפט filed format with no prefix: 46111-12-22 (5/4-digit file,
|
||||||
|
# 1-2 digit month, 2-4 digit year). Used when a digest gives just the number.
|
||||||
|
_BARE_FILED_RX = re.compile(r"(?<!\d)(\d{1,5})-(\d{1,2})-(\d{2,4})(?!\d)", re.UNICODE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CourtCitation:
|
||||||
|
"""Result of classifying a citation for auto-fetch routing."""
|
||||||
|
|
||||||
|
tier: str # "supreme" | "admin" | "skip" | "unknown"
|
||||||
|
court_prefix: str # e.g. 'עת"מ', or "" for bare/unknown
|
||||||
|
case_number_raw: str # the matched number as written, e.g. "46111-12-22"
|
||||||
|
case_number_norm: str # canonical: slashes→dashes, digits/sep only
|
||||||
|
# נט-המשפט form fields (only when the filed format NNNNN-MM-YY is present):
|
||||||
|
file_number: str | None = None
|
||||||
|
month: str | None = None
|
||||||
|
year: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fetchable(self) -> bool:
|
||||||
|
return self.tier in ("supreme", "admin")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_case_number(raw: str) -> str:
|
||||||
|
"""Canonicalize a case number for idempotency keys / matching.
|
||||||
|
|
||||||
|
Mirrors ``citation_extractor._normalize_case_number``: strip everything
|
||||||
|
but digits and separators, unify ``/`` → ``-``. Display value is never
|
||||||
|
derived from this.
|
||||||
|
"""
|
||||||
|
cleaned = re.sub(r"[^\d/\-]", "", raw or "")
|
||||||
|
return cleaned.replace("/", "-").strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_filed(num_norm: str) -> tuple[str, str, str] | None:
|
||||||
|
"""Split a normalized NNNNN-MM-YY number into (file, month, year).
|
||||||
|
|
||||||
|
Only the three-group "filed" format yields a נט-המשפט triple; two-group
|
||||||
|
formats (1234-22 / 1234/22) are Supreme-style serials and return None.
|
||||||
|
"""
|
||||||
|
m = _BARE_FILED_RX.fullmatch(num_norm)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
file_no, month, year = m.group(1), m.group(2), m.group(3)
|
||||||
|
# Plausibility: month 1-12, year 2-4 digits. Reject implausible months
|
||||||
|
# (avoids mis-reading a 2-group serial that slipped through).
|
||||||
|
if not (1 <= int(month) <= 12):
|
||||||
|
return None
|
||||||
|
return file_no, month, year
|
||||||
|
|
||||||
|
|
||||||
|
def classify(citation: str) -> CourtCitation:
|
||||||
|
"""Classify a raw citation string into a fetch tier + parsed number.
|
||||||
|
|
||||||
|
Resolution order: ועדת-ערר (skip) is checked FIRST so an "ערר" prefix is
|
||||||
|
never mis-routed to a court tier; then Supreme prefixes; then admin
|
||||||
|
prefixes; then a bare filed number defaults to ``admin`` (נט המשפט is the
|
||||||
|
only public source for prefix-less district/שלום numbers).
|
||||||
|
"""
|
||||||
|
text = (citation or "").strip()
|
||||||
|
if not text:
|
||||||
|
return CourtCitation("unknown", "", "", "")
|
||||||
|
|
||||||
|
# 1. ועדת-ערר → skip (must win over any court match).
|
||||||
|
m = _SKIP_RX.search(text)
|
||||||
|
if m:
|
||||||
|
raw = m.group(2)
|
||||||
|
return CourtCitation(
|
||||||
|
tier="skip",
|
||||||
|
court_prefix=m.group(1),
|
||||||
|
case_number_raw=raw,
|
||||||
|
case_number_norm=normalize_case_number(raw),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Supreme Court prefix → Tier 0.
|
||||||
|
m = _SUPREME_RX.search(text)
|
||||||
|
if m:
|
||||||
|
raw = m.group(2)
|
||||||
|
return CourtCitation(
|
||||||
|
tier="supreme",
|
||||||
|
court_prefix=m.group(1),
|
||||||
|
case_number_raw=raw,
|
||||||
|
case_number_norm=normalize_case_number(raw),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. District / admin prefix → Tier 1.
|
||||||
|
m = _ADMIN_RX.search(text)
|
||||||
|
if m:
|
||||||
|
raw = m.group(2)
|
||||||
|
norm = normalize_case_number(raw)
|
||||||
|
filed = _split_filed(norm)
|
||||||
|
return CourtCitation(
|
||||||
|
tier="admin",
|
||||||
|
court_prefix=m.group(1),
|
||||||
|
case_number_raw=raw,
|
||||||
|
case_number_norm=norm,
|
||||||
|
file_number=filed[0] if filed else None,
|
||||||
|
month=filed[1] if filed else None,
|
||||||
|
year=filed[2] if filed else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Bare filed number (no prefix) → default admin (נט המשפט).
|
||||||
|
m = _BARE_FILED_RX.search(text)
|
||||||
|
if m:
|
||||||
|
raw = m.group(0)
|
||||||
|
norm = normalize_case_number(raw)
|
||||||
|
filed = _split_filed(norm)
|
||||||
|
if filed:
|
||||||
|
return CourtCitation(
|
||||||
|
tier="admin",
|
||||||
|
court_prefix="",
|
||||||
|
case_number_raw=raw,
|
||||||
|
case_number_norm=norm,
|
||||||
|
file_number=filed[0],
|
||||||
|
month=filed[1],
|
||||||
|
year=filed[2],
|
||||||
|
)
|
||||||
|
|
||||||
|
return CourtCitation("unknown", "", "", "")
|
||||||
241
mcp-server/src/legal_mcp/services/court_fetch_orchestrator.py
Normal file
241
mcp-server/src/legal_mcp/services/court_fetch_orchestrator.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""X13 orchestrator — classify → fetch → ingest → record.
|
||||||
|
|
||||||
|
The single entry point (`fetch_and_ingest`) wires the three tiers to the
|
||||||
|
**canonical** precedent-ingest pipeline (INV-CF1 — no parallel ingest path)
|
||||||
|
and keeps the `court_fetch_jobs` row honest at every step (INV-CF2 — a job
|
||||||
|
always ends in an explicit terminal state, never a silent drop).
|
||||||
|
|
||||||
|
Tier routing (from `court_citation.classify`):
|
||||||
|
* ``skip`` — ועדת-ערר → never fetched; logged as a missing_precedent gap.
|
||||||
|
* ``supreme`` — Tier 0, in-process httpx (`court_fetch_supreme`).
|
||||||
|
* ``admin`` — Tier 1, the host-side stealth-browser service over loopback.
|
||||||
|
|
||||||
|
Fallback (INV-CF3): after ``MAX_AUTONOMOUS_ATTEMPTS`` autonomous failures the
|
||||||
|
job flips to ``manual`` and a missing_precedent row is opened so the chair
|
||||||
|
sees the gap and can solve the CAPTCHA live (VNC) or drop the file manually.
|
||||||
|
|
||||||
|
This module runs **in the local MCP server only** — `ingest_precedent` drives
|
||||||
|
halacha extraction via the local ``claude`` CLI (see `claude_session.py`). It
|
||||||
|
is invoked from the `court_verdict_fetch` MCP tool, not from the container.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from legal_mcp.services import court_citation, db
|
||||||
|
from legal_mcp.services.court_fetch_supreme import (
|
||||||
|
SupremeFetchError,
|
||||||
|
fetch_supreme_verdict,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# After this many autonomous failures, stop auto-retrying and escalate to a
|
||||||
|
# human (INV-CF3). Kept low — the .gov site shouldn't be hammered (INV-CF4).
|
||||||
|
MAX_AUTONOMOUS_ATTEMPTS = int(os.environ.get("COURT_FETCH_MAX_ATTEMPTS", "2"))
|
||||||
|
|
||||||
|
# The host-side Tier-1 browser service (pm2). The MCP server runs on the host,
|
||||||
|
# so it reaches the service over loopback directly (the container bridge in
|
||||||
|
# web/court_fetch_proxy.py is a separate, optional entry point).
|
||||||
|
COURT_FETCH_SERVICE_URL = os.environ.get(
|
||||||
|
"COURT_FETCH_SERVICE_URL", "http://127.0.0.1:8771"
|
||||||
|
)
|
||||||
|
_SHARED_SECRET = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
|
||||||
|
_TIER1_TIMEOUT_S = float(os.environ.get("COURT_FETCH_TIER1_TIMEOUT_S", "300"))
|
||||||
|
|
||||||
|
# Provenance level by tier — Supreme rulings are binding; admin-court verdicts
|
||||||
|
# are administrative (set is_binding conservatively True, chair can downgrade).
|
||||||
|
_LEVEL_BY_TIER = {"supreme": "עליון", "admin": "מנהלי"}
|
||||||
|
|
||||||
|
|
||||||
|
class _Tier1Unavailable(RuntimeError):
|
||||||
|
"""The host browser service is not reachable / not configured."""
|
||||||
|
|
||||||
|
|
||||||
|
async def _ingest_bytes(
|
||||||
|
*, content: bytes, filename: str, citation: str, tier: str,
|
||||||
|
court: str, source_url: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Stage bytes to a temp file and run the canonical ingest (INV-CF1)."""
|
||||||
|
from legal_mcp.services import precedent_library
|
||||||
|
|
||||||
|
suffix = Path(filename).suffix or ".pdf"
|
||||||
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
prefix="court_fetch_", suffix=suffix, delete=False
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
tmp.write(content)
|
||||||
|
tmp.flush()
|
||||||
|
tmp.close()
|
||||||
|
result = await precedent_library.ingest_precedent(
|
||||||
|
file_path=tmp.name,
|
||||||
|
citation=citation,
|
||||||
|
court=court,
|
||||||
|
source_type="court_ruling", # INV-CF6
|
||||||
|
precedent_level=_LEVEL_BY_TIER.get(tier, ""),
|
||||||
|
is_binding=True,
|
||||||
|
)
|
||||||
|
# Stamp provenance on the new case_law row (INV-CF7).
|
||||||
|
case_law_id = result.get("case_law_id")
|
||||||
|
if case_law_id and source_url:
|
||||||
|
try:
|
||||||
|
await db.update_case_law(
|
||||||
|
UUID(str(case_law_id)), source_url=source_url
|
||||||
|
)
|
||||||
|
except Exception: # provenance is best-effort, never blocks ingest
|
||||||
|
logger.warning("could not stamp source_url on %s", case_law_id)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_tier1_admin(cit: court_citation.CourtCitation) -> dict:
|
||||||
|
"""Call the host-side browser service to fetch an admin-court verdict.
|
||||||
|
|
||||||
|
Returns the service's JSON: ``{ok, content_b64, filename, source_url,
|
||||||
|
court, reason}``. Raises ``_Tier1Unavailable`` if the service can't be
|
||||||
|
reached, ``SupremeFetchError``-style RuntimeError on a fetch failure the
|
||||||
|
service reports.
|
||||||
|
"""
|
||||||
|
if not (cit.file_number and cit.month and cit.year):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"מספר-תיק {cit.case_number_norm} אינו בפורמט נט-המשפט (תיק-חודש-שנה)"
|
||||||
|
)
|
||||||
|
headers = {"Authorization": f"Bearer {_SHARED_SECRET}"} if _SHARED_SECRET else {}
|
||||||
|
payload = {
|
||||||
|
"file_number": cit.file_number,
|
||||||
|
"month": cit.month,
|
||||||
|
"year": cit.year,
|
||||||
|
"case_number": cit.case_number_norm,
|
||||||
|
"court": cit.court_prefix,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIER1_TIMEOUT_S) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{COURT_FETCH_SERVICE_URL}/fetch", json=payload, headers=headers
|
||||||
|
)
|
||||||
|
except httpx.ConnectError as e:
|
||||||
|
raise _Tier1Unavailable(
|
||||||
|
f"שירות-האחזור (legal-court-fetch-service) אינו זמין ב-"
|
||||||
|
f"{COURT_FETCH_SERVICE_URL}: {e}"
|
||||||
|
) from e
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"שירות-האחזור החזיר {resp.status_code}: {resp.text[:200]}")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_and_ingest(
|
||||||
|
citation: str, *, digest_id: UUID | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""Classify a citation, fetch the verdict, ingest it, and record the job.
|
||||||
|
|
||||||
|
Idempotent on the canonical case number (INV-CF5): a case already fetched
|
||||||
|
(job ``done``) is returned without re-fetching.
|
||||||
|
"""
|
||||||
|
cit = court_citation.classify(citation)
|
||||||
|
|
||||||
|
# ── skip: ועדת-ערר — never auto-fetched (INV-CF6). Surface as a gap. ──
|
||||||
|
if cit.tier == "skip":
|
||||||
|
await _open_gap(citation, reason="ועדת-ערר — לא ניתן לאחזור ציבורי (נדרש נבו)")
|
||||||
|
return {"status": "skipped", "tier": "skip", "citation": citation,
|
||||||
|
"reason": "appeals_committee — needs Nevo"}
|
||||||
|
if cit.tier == "unknown" or not cit.case_number_norm:
|
||||||
|
return {"status": "unrecognized", "citation": citation}
|
||||||
|
|
||||||
|
# ── idempotent job row ──
|
||||||
|
job = await db.court_fetch_job_upsert(
|
||||||
|
case_number_norm=cit.case_number_norm,
|
||||||
|
citation_raw=citation,
|
||||||
|
tier=cit.tier,
|
||||||
|
court=cit.court_prefix,
|
||||||
|
digest_id=digest_id,
|
||||||
|
)
|
||||||
|
if job.get("status") == "done":
|
||||||
|
return {"status": "already_done", "job": job}
|
||||||
|
if job.get("status") == "manual":
|
||||||
|
return {"status": "awaiting_manual", "job": job}
|
||||||
|
|
||||||
|
job_id = UUID(str(job["id"]))
|
||||||
|
await db.court_fetch_job_update(job_id, status="running", bump_attempts=True)
|
||||||
|
|
||||||
|
# ── fetch ──
|
||||||
|
try:
|
||||||
|
if cit.tier == "supreme":
|
||||||
|
fetched = await fetch_supreme_verdict(
|
||||||
|
citation=citation, case_number_norm=cit.case_number_norm
|
||||||
|
)
|
||||||
|
content, filename = fetched.content, fetched.filename
|
||||||
|
source_url, court = fetched.source_url, fetched.court
|
||||||
|
else: # admin → Tier 1
|
||||||
|
res = await _fetch_tier1_admin(cit)
|
||||||
|
if not res.get("ok"):
|
||||||
|
raise RuntimeError(res.get("reason") or "אחזור נכשל")
|
||||||
|
import base64
|
||||||
|
content = base64.b64decode(res["content_b64"])
|
||||||
|
filename = res.get("filename") or f"{cit.case_number_norm}.pdf"
|
||||||
|
source_url = res.get("source_url", "")
|
||||||
|
court = res.get("court") or cit.court_prefix
|
||||||
|
except (_Tier1Unavailable, SupremeFetchError, RuntimeError) as e:
|
||||||
|
return await _record_failure(job_id, cit, citation, str(e))
|
||||||
|
|
||||||
|
# ── ingest into the canonical pipeline (INV-CF1) ──
|
||||||
|
try:
|
||||||
|
result = await _ingest_bytes(
|
||||||
|
content=content, filename=filename, citation=citation,
|
||||||
|
tier=cit.tier, court=court, source_url=source_url,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001 — recorded, never swallowed (INV-CF2)
|
||||||
|
logger.exception("ingest failed for %s", cit.case_number_norm)
|
||||||
|
return await _record_failure(job_id, cit, citation, f"קליטה נכשלה: {e}")
|
||||||
|
|
||||||
|
case_law_id = result.get("case_law_id")
|
||||||
|
await db.court_fetch_job_update(
|
||||||
|
job_id, status="done",
|
||||||
|
case_law_id=UUID(str(case_law_id)) if case_law_id else None,
|
||||||
|
source_url=source_url, error="",
|
||||||
|
)
|
||||||
|
return {"status": "done", "tier": cit.tier, "case_law_id": case_law_id,
|
||||||
|
"citation": citation, "source_url": source_url, "ingest": result}
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_failure(
|
||||||
|
job_id: UUID, cit: court_citation.CourtCitation, citation: str, err: str
|
||||||
|
) -> dict:
|
||||||
|
"""Record a fetch/ingest failure; escalate to manual after N attempts (INV-CF3)."""
|
||||||
|
job = await db.court_fetch_job_get(cit.case_number_norm)
|
||||||
|
attempts = (job or {}).get("attempts", 1)
|
||||||
|
if attempts >= MAX_AUTONOMOUS_ATTEMPTS:
|
||||||
|
await db.court_fetch_job_update(job_id, status="manual", error=err)
|
||||||
|
await _open_gap(
|
||||||
|
citation,
|
||||||
|
reason=f"אחזור אוטונומי נכשל ({attempts} נסיונות) — נדרשת הורדה ידנית. {err}",
|
||||||
|
)
|
||||||
|
logger.warning("court fetch escalated to manual: %s — %s", citation, err)
|
||||||
|
return {"status": "manual", "citation": citation, "error": err,
|
||||||
|
"attempts": attempts}
|
||||||
|
await db.court_fetch_job_update(job_id, status="failed", error=err)
|
||||||
|
logger.warning("court fetch failed (will retry): %s — %s", citation, err)
|
||||||
|
return {"status": "failed", "citation": citation, "error": err,
|
||||||
|
"attempts": attempts}
|
||||||
|
|
||||||
|
|
||||||
|
async def _open_gap(citation: str, *, reason: str) -> None:
|
||||||
|
"""Open a missing_precedent gap so the chair sees it (INV-CF2/CF3).
|
||||||
|
|
||||||
|
Best-effort + de-duplicated by the missing_precedents layer; a failure
|
||||||
|
here is logged, never raised (it must not mask the original outcome).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await db.create_missing_precedent(citation=citation, notes=reason)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("could not open missing_precedent for %s", citation)
|
||||||
181
mcp-server/src/legal_mcp/services/court_fetch_supreme.py
Normal file
181
mcp-server/src/legal_mcp/services/court_fetch_supreme.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""Tier 0 — Supreme Court verdict fetcher (X13).
|
||||||
|
|
||||||
|
Pulls a published Supreme Court verdict PDF from the **public** decisions
|
||||||
|
portal ``supremedecisions.court.gov.il`` — no smart-card, no CAPTCHA. The
|
||||||
|
portal is an AngularJS SPA backed by a small JSON API (reverse-engineered
|
||||||
|
from ``/Scripts/app/config.js`` + the search/results controllers):
|
||||||
|
|
||||||
|
POST Home/SearchVerdicts body {"document": <query>, "lan": 1} → result list
|
||||||
|
GET Home/GetCasesYearNum ?... (year + number lookup) → case + docs
|
||||||
|
GET Home/Download?path=<path>&fileName=<file>&type=4 → the PDF bytes
|
||||||
|
|
||||||
|
Two things matter for getting a 200 instead of an F5 connection-reset
|
||||||
|
(verified empirically 2026-06-07):
|
||||||
|
* a **complete** browser header set — UA + Accept + Accept-Language. A bare
|
||||||
|
UA alone gets reset.
|
||||||
|
* **politeness** (INV-CF4): one request at a time, a cooldown between them,
|
||||||
|
a Referer of the portal root. We never parallelise or hammer.
|
||||||
|
|
||||||
|
Honesty / scope: the *result→download* field mapping (where ``path`` and
|
||||||
|
``fileName`` live in the SearchVerdicts JSON) is derived from the client code,
|
||||||
|
not yet confirmed against a live JSON response (the live site rate-limited
|
||||||
|
probing during development). ``fetch_supreme_verdict`` therefore validates the
|
||||||
|
response shape and **raises** on anything unexpected (INV-CF2 — no silent
|
||||||
|
swallow) so the orchestrator can record the failure and fall back, rather than
|
||||||
|
returning a wrong/empty file. The first live run is the validation pass; see
|
||||||
|
the X13 verification section.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BASE = "https://supremedecisions.court.gov.il"
|
||||||
|
|
||||||
|
# A complete, browser-like header set. Empirically required to pass the F5
|
||||||
|
# WAF (a bare User-Agent gets a TCP reset).
|
||||||
|
_HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"Accept-Language": "he-IL,he;q=0.9,en;q=0.8",
|
||||||
|
"Referer": _BASE + "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Politeness knobs (INV-CF4). Serial only — never run these concurrently.
|
||||||
|
_REQUEST_TIMEOUT_S = float(os.environ.get("COURT_FETCH_HTTP_TIMEOUT_S", "30"))
|
||||||
|
_INTER_REQUEST_COOLDOWN_S = float(os.environ.get("COURT_FETCH_COOLDOWN_S", "2"))
|
||||||
|
|
||||||
|
# type=4 → PDF in the portal's Download endpoint (from resultsControler.js).
|
||||||
|
_DOC_TYPE_PDF = "4"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FetchedVerdict:
|
||||||
|
"""A downloaded verdict file held in memory, ready for ingest."""
|
||||||
|
|
||||||
|
content: bytes
|
||||||
|
filename: str
|
||||||
|
source_url: str
|
||||||
|
court: str = "בית המשפט העליון"
|
||||||
|
|
||||||
|
|
||||||
|
class SupremeFetchError(RuntimeError):
|
||||||
|
"""Raised when the public portal returns an unexpected shape / no document.
|
||||||
|
|
||||||
|
Carries a human-readable Hebrew reason so the orchestrator can persist it
|
||||||
|
on the job row (INV-CF2) and decide on fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _get(client: httpx.AsyncClient, path: str, **kwargs) -> httpx.Response:
|
||||||
|
await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S)
|
||||||
|
resp = await client.get(f"{_BASE}/{path.lstrip('/')}", **kwargs)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
async def _post(client: httpx.AsyncClient, path: str, json: dict) -> httpx.Response:
|
||||||
|
await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S)
|
||||||
|
resp = await client.post(f"{_BASE}/{path.lstrip('/')}", json=json)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_doc_ref(results: object) -> tuple[str, str] | None:
|
||||||
|
"""Pull (path, fileName) of the first verdict document from a results blob.
|
||||||
|
|
||||||
|
The SearchVerdicts/GetCasesYearNum responses nest documents under varying
|
||||||
|
keys across the portal's endpoints. We probe the known shapes defensively
|
||||||
|
and return the first (path, fileName) pair found; ``None`` if none.
|
||||||
|
"""
|
||||||
|
def walk(node):
|
||||||
|
if isinstance(node, dict):
|
||||||
|
# A document node carries both a path and a file name.
|
||||||
|
path = node.get("Path") or node.get("path")
|
||||||
|
fname = node.get("FileName") or node.get("fileName") or node.get("Filename")
|
||||||
|
if path and fname:
|
||||||
|
yield (str(path), str(fname))
|
||||||
|
for v in node.values():
|
||||||
|
yield from walk(v)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for v in node:
|
||||||
|
yield from walk(v)
|
||||||
|
|
||||||
|
for pair in walk(results):
|
||||||
|
return pair
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_supreme_verdict(
|
||||||
|
*, citation: str, case_number_norm: str
|
||||||
|
) -> FetchedVerdict:
|
||||||
|
"""Fetch a Supreme Court verdict PDF by citation. Raises on failure.
|
||||||
|
|
||||||
|
Flow: full-text search for the citation → locate the verdict document's
|
||||||
|
(path, fileName) → download the PDF. Serial + cooled-down throughout.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
http2=True,
|
||||||
|
headers=_HEADERS,
|
||||||
|
timeout=_REQUEST_TIMEOUT_S,
|
||||||
|
follow_redirects=True,
|
||||||
|
) as client:
|
||||||
|
# 1. Search. The portal's quick-search posts {document, lan}; lan=1=Hebrew.
|
||||||
|
try:
|
||||||
|
search = await _post(
|
||||||
|
client, "Home/SearchVerdicts",
|
||||||
|
json={"document": citation, "lan": 1},
|
||||||
|
)
|
||||||
|
results = search.json()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise SupremeFetchError(
|
||||||
|
f"חיפוש בפורטל העליון נכשל עבור {citation}: {e}"
|
||||||
|
) from e
|
||||||
|
except ValueError as e: # non-JSON body
|
||||||
|
raise SupremeFetchError(
|
||||||
|
f"תשובת-חיפוש לא-JSON מהפורטל עבור {citation}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
ref = _extract_doc_ref(results)
|
||||||
|
if not ref:
|
||||||
|
raise SupremeFetchError(
|
||||||
|
f"לא נמצא מסמך-פסק עבור {citation} בפורטל העליון "
|
||||||
|
f"(ייתכן שאינו פורסם או שמבנה-התשובה השתנה)."
|
||||||
|
)
|
||||||
|
path, fname = ref
|
||||||
|
|
||||||
|
# 2. Download the PDF.
|
||||||
|
try:
|
||||||
|
dl = await _get(
|
||||||
|
client, "Home/Download",
|
||||||
|
params={"path": path, "fileName": fname, "type": _DOC_TYPE_PDF},
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise SupremeFetchError(
|
||||||
|
f"הורדת PDF נכשלה עבור {citation} (path={path}): {e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
content = dl.content
|
||||||
|
ctype = dl.headers.get("content-type", "")
|
||||||
|
if not content or ("pdf" not in ctype.lower() and not content[:4] == b"%PDF"):
|
||||||
|
raise SupremeFetchError(
|
||||||
|
f"הקובץ שהתקבל עבור {citation} אינו PDF תקין (content-type={ctype})."
|
||||||
|
)
|
||||||
|
|
||||||
|
source_url = (
|
||||||
|
f"{_BASE}/Home/Download?path={path}&fileName={fname}&type={_DOC_TYPE_PDF}"
|
||||||
|
)
|
||||||
|
safe_name = fname if fname.lower().endswith(".pdf") else f"{case_number_norm}.pdf"
|
||||||
|
return FetchedVerdict(
|
||||||
|
content=content, filename=safe_name, source_url=source_url,
|
||||||
|
)
|
||||||
@@ -1287,6 +1287,101 @@ ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_generated_at TIMESTAMPTZ
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_V30_SQL = """
|
||||||
|
-- digests (X12): Ofer Toister daily "כל יום" one-pagers. A SECONDARY,
|
||||||
|
-- discovery-layer ("radar") source — NOT authoritative law. Kept in its OWN
|
||||||
|
-- table (never case_law) so it cannot pollute the precedent corpus, never
|
||||||
|
-- enters the halacha pipeline (INV-DIG2), and is never cited directly in a
|
||||||
|
-- decision (INV-DIG1). Its only job is to point the researcher at the
|
||||||
|
-- UNDERLYING ruling, which is ingested separately into case_law and cited from
|
||||||
|
-- there. linked_case_law_id is the bridge (INV-DIG3): filled once the
|
||||||
|
-- underlying ruling is in the library; NULL = an open knowledge gap.
|
||||||
|
CREATE TABLE IF NOT EXISTS digests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
yomon_number TEXT NOT NULL DEFAULT '', -- "5163"
|
||||||
|
digest_date DATE, -- date of the yomon ISSUE
|
||||||
|
publication TEXT NOT NULL DEFAULT 'כל יום',
|
||||||
|
source_firm TEXT NOT NULL DEFAULT 'עפר טויסטר, עורכי דין',
|
||||||
|
concept_tag TEXT NOT NULL DEFAULT '', -- "שיקול הדעת המצומצם"
|
||||||
|
headline_holding TEXT NOT NULL DEFAULT '', -- bold subtitle = the holding
|
||||||
|
analysis_text TEXT NOT NULL DEFAULT '', -- the 1-2 page body (raw text)
|
||||||
|
summary TEXT NOT NULL DEFAULT '', -- 2-3 sentence LLM summary
|
||||||
|
underlying_citation TEXT NOT NULL DEFAULT '', -- 'עת"מ 46111-12-22 יכין-אפק...'
|
||||||
|
underlying_court TEXT NOT NULL DEFAULT '',
|
||||||
|
underlying_date DATE, -- date the RULING was given (≠ digest_date)
|
||||||
|
underlying_judge TEXT NOT NULL DEFAULT '',
|
||||||
|
practice_area TEXT NOT NULL DEFAULT '', -- rishuy_uvniya/betterment_levy/compensation_197
|
||||||
|
appeal_subtype TEXT NOT NULL DEFAULT '',
|
||||||
|
subject_tags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
linked_case_law_id UUID REFERENCES case_law(id) ON DELETE SET NULL,
|
||||||
|
embedding vector(1024), -- single vector of concept+headline+summary+analysis
|
||||||
|
source_document_path TEXT NOT NULL DEFAULT '', -- staged PDF path (rel to DATA_DIR)
|
||||||
|
content_hash TEXT NOT NULL DEFAULT '', -- sha256 of extracted text — idempotent upload
|
||||||
|
extraction_status TEXT NOT NULL DEFAULT 'pending', -- pending/processing/completed/failed
|
||||||
|
content_tsv tsvector GENERATED ALWAYS AS (
|
||||||
|
to_tsvector('simple',
|
||||||
|
coalesce(concept_tag,'') || ' ' || coalesce(headline_holding,'') || ' ' ||
|
||||||
|
coalesce(summary,'') || ' ' || coalesce(analysis_text,''))
|
||||||
|
) STORED,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Idempotent re-upload (INV-G3): same yomon number = same digest. yomon_number
|
||||||
|
-- can be '' transiently (before extraction), so the unique index is partial.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_digests_yomon_number
|
||||||
|
ON digests(yomon_number) WHERE yomon_number <> '';
|
||||||
|
-- Secondary dedup key when yomon_number couldn't be parsed.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_digests_content_hash
|
||||||
|
ON digests(content_hash) WHERE content_hash <> '';
|
||||||
|
|
||||||
|
-- HNSW (not ivfflat): the digests radar is a small, slowly-growing corpus
|
||||||
|
-- (~1/day). ivfflat trains `lists` centroids and probes a subset at query time,
|
||||||
|
-- so on a small table a single probe can hit an empty list and return 0 rows
|
||||||
|
-- (recall cliff). HNSW has no list-training/probe step — correct recall from
|
||||||
|
-- the first row — so it is the right index for a corpus that starts ~empty.
|
||||||
|
DROP INDEX IF EXISTS idx_digests_embedding; -- drop any pre-existing ivfflat
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_digests_embedding_hnsw
|
||||||
|
ON digests USING hnsw (embedding vector_cosine_ops);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_digests_linked ON digests(linked_case_law_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_digests_practice_area ON digests(practice_area);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_digests_concept_tag ON digests(concept_tag);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_digests_subject_tags ON digests USING gin(subject_tags);
|
||||||
|
-- Lexical half of a future hybrid (Phase-1 search is semantic-only; index is ready).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_digests_content_tsv ON digests USING gin(content_tsv);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── X13 — Court Verdict Fetch queue ──────────────────────────────────────
|
||||||
|
# A lightweight, observable, idempotent job queue for the auto-fetch
|
||||||
|
# subsystem (docs/spec/X13-court-fetch.md). One row per court verdict we try
|
||||||
|
# to pull from a public source. Mirrors the extraction-queue pattern: status
|
||||||
|
# is always explicit (INV-CF2 — no silent drop), the canonical case number is
|
||||||
|
# the idempotency key (INV-CF5), and ``attempts`` drives the human-fallback
|
||||||
|
# gate (INV-CF3 — flip to 'manual' after N autonomous failures).
|
||||||
|
# V31 — digests (X12) took V30 when it merged first.
|
||||||
|
SCHEMA_V31_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS court_fetch_jobs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
case_number_norm TEXT NOT NULL UNIQUE, -- idempotency key (INV-CF5)
|
||||||
|
citation_raw TEXT NOT NULL DEFAULT '',
|
||||||
|
tier TEXT NOT NULL DEFAULT '', -- supreme | admin | skip
|
||||||
|
court TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending', -- pending|running|done|failed|manual
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
case_law_id UUID REFERENCES case_law(id) ON DELETE SET NULL,
|
||||||
|
digest_id UUID, -- source digest (X12), nullable for ad-hoc
|
||||||
|
source_url TEXT NOT NULL DEFAULT '', -- provenance (INV-CF7)
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_court_fetch_jobs_status ON court_fetch_jobs(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_court_fetch_jobs_digest ON court_fetch_jobs(digest_id)
|
||||||
|
WHERE digest_id IS NOT NULL;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute(SCHEMA_SQL)
|
await conn.execute(SCHEMA_SQL)
|
||||||
@@ -1319,7 +1414,9 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
|||||||
await conn.execute(SCHEMA_V27_SQL)
|
await conn.execute(SCHEMA_V27_SQL)
|
||||||
await conn.execute(SCHEMA_V28_SQL)
|
await conn.execute(SCHEMA_V28_SQL)
|
||||||
await conn.execute(SCHEMA_V29_SQL)
|
await conn.execute(SCHEMA_V29_SQL)
|
||||||
logger.info("Database schema initialized (v1-v29)")
|
await conn.execute(SCHEMA_V30_SQL)
|
||||||
|
await conn.execute(SCHEMA_V31_SQL)
|
||||||
|
logger.info("Database schema initialized (v1-v31)")
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -3494,6 +3591,311 @@ async def delete_case_law(case_law_id: UUID) -> bool:
|
|||||||
return result == "DELETE 1"
|
return result == "DELETE 1"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Digests (X12 — radar layer; separate table, INV-DIG1/2/3) ────────
|
||||||
|
|
||||||
|
_DIGEST_COLS = (
|
||||||
|
"id, yomon_number, digest_date, publication, source_firm, concept_tag, "
|
||||||
|
"headline_holding, analysis_text, summary, underlying_citation, "
|
||||||
|
"underlying_court, underlying_date, underlying_judge, practice_area, "
|
||||||
|
"appeal_subtype, subject_tags, linked_case_law_id, source_document_path, "
|
||||||
|
"content_hash, extraction_status, created_at, updated_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
_DIGEST_UPDATE_ALLOWED = {
|
||||||
|
"yomon_number", "digest_date", "publication", "source_firm", "concept_tag",
|
||||||
|
"headline_holding", "analysis_text", "summary", "underlying_citation",
|
||||||
|
"underlying_court", "underlying_date", "underlying_judge", "practice_area",
|
||||||
|
"appeal_subtype", "subject_tags", "source_document_path", "content_hash",
|
||||||
|
"extraction_status",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_digest(row: asyncpg.Record | dict | None) -> dict | None:
|
||||||
|
"""Normalize a digests row: ISO-format dates, ensure subject_tags is a list."""
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
d = dict(row)
|
||||||
|
for k in ("digest_date", "underlying_date", "created_at", "updated_at"):
|
||||||
|
if d.get(k) is not None and hasattr(d[k], "isoformat"):
|
||||||
|
d[k] = d[k].isoformat()
|
||||||
|
if d.get("subject_tags") is None:
|
||||||
|
d["subject_tags"] = []
|
||||||
|
if d.get("id") is not None:
|
||||||
|
d["id"] = str(d["id"])
|
||||||
|
if d.get("linked_case_law_id") is not None:
|
||||||
|
d["linked_case_law_id"] = str(d["linked_case_law_id"])
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
async def create_digest(
|
||||||
|
*,
|
||||||
|
analysis_text: str,
|
||||||
|
yomon_number: str = "",
|
||||||
|
digest_date: date | None = None,
|
||||||
|
publication: str = "כל יום",
|
||||||
|
source_firm: str = "עפר טויסטר, עורכי דין",
|
||||||
|
concept_tag: str = "",
|
||||||
|
headline_holding: str = "",
|
||||||
|
summary: str = "",
|
||||||
|
underlying_citation: str = "",
|
||||||
|
underlying_court: str = "",
|
||||||
|
underlying_date: date | None = None,
|
||||||
|
underlying_judge: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
source_document_path: str = "",
|
||||||
|
extraction_status: str = "processing",
|
||||||
|
) -> dict:
|
||||||
|
"""Upsert a digest (X12). Idempotent on yomon_number (INV-G3): a repeat
|
||||||
|
upload of the same yomon updates in place. content_hash is the secondary
|
||||||
|
dedup key for digests whose number couldn't be parsed."""
|
||||||
|
pool = await get_pool()
|
||||||
|
content_hash = _content_hash(analysis_text)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
# Upsert on the partial unique index uq_digests_yomon_number
|
||||||
|
# (yomon_number WHERE yomon_number <> ''). Predicate repeated in
|
||||||
|
# ON CONFLICT as required for partial indexes.
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
INSERT INTO digests (
|
||||||
|
yomon_number, digest_date, publication, source_firm, concept_tag,
|
||||||
|
headline_holding, analysis_text, summary, underlying_citation,
|
||||||
|
underlying_court, underlying_date, underlying_judge, practice_area,
|
||||||
|
appeal_subtype, subject_tags, source_document_path,
|
||||||
|
content_hash, extraction_status
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||||
|
$14, $15, $16, $17, $18
|
||||||
|
)
|
||||||
|
ON CONFLICT (yomon_number) WHERE yomon_number <> ''
|
||||||
|
DO UPDATE SET
|
||||||
|
digest_date = COALESCE(EXCLUDED.digest_date, digests.digest_date),
|
||||||
|
publication = EXCLUDED.publication,
|
||||||
|
source_firm = EXCLUDED.source_firm,
|
||||||
|
concept_tag = EXCLUDED.concept_tag,
|
||||||
|
headline_holding = EXCLUDED.headline_holding,
|
||||||
|
analysis_text = EXCLUDED.analysis_text,
|
||||||
|
summary = EXCLUDED.summary,
|
||||||
|
underlying_citation = EXCLUDED.underlying_citation,
|
||||||
|
underlying_court = EXCLUDED.underlying_court,
|
||||||
|
underlying_date = COALESCE(EXCLUDED.underlying_date, digests.underlying_date),
|
||||||
|
underlying_judge = EXCLUDED.underlying_judge,
|
||||||
|
practice_area = EXCLUDED.practice_area,
|
||||||
|
appeal_subtype = EXCLUDED.appeal_subtype,
|
||||||
|
subject_tags = EXCLUDED.subject_tags,
|
||||||
|
source_document_path = COALESCE(NULLIF(EXCLUDED.source_document_path, ''), digests.source_document_path),
|
||||||
|
content_hash = EXCLUDED.content_hash,
|
||||||
|
extraction_status = EXCLUDED.extraction_status,
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING {_DIGEST_COLS}
|
||||||
|
""",
|
||||||
|
yomon_number, digest_date, publication, source_firm, concept_tag,
|
||||||
|
headline_holding, analysis_text, summary, underlying_citation,
|
||||||
|
underlying_court, underlying_date, underlying_judge, practice_area,
|
||||||
|
appeal_subtype, list(subject_tags or []), source_document_path,
|
||||||
|
content_hash, extraction_status,
|
||||||
|
)
|
||||||
|
return _row_to_digest(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_digest(digest_id: UUID | str) -> dict | None:
|
||||||
|
pool = await get_pool()
|
||||||
|
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
f"SELECT {_DIGEST_COLS} FROM digests WHERE id = $1", cid,
|
||||||
|
)
|
||||||
|
return _row_to_digest(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_digest_by_content_hash(content_hash: str) -> dict | None:
|
||||||
|
if not content_hash:
|
||||||
|
return None
|
||||||
|
pool = await get_pool()
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
f"SELECT {_DIGEST_COLS} FROM digests WHERE content_hash = $1", content_hash,
|
||||||
|
)
|
||||||
|
return _row_to_digest(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_digest(digest_id: UUID | str, **fields) -> dict | None:
|
||||||
|
"""Patch metadata fields on a digest row. Whitelist via _DIGEST_UPDATE_ALLOWED."""
|
||||||
|
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
|
||||||
|
updates = {k: v for k, v in fields.items() if k in _DIGEST_UPDATE_ALLOWED}
|
||||||
|
if not updates:
|
||||||
|
return await get_digest(cid)
|
||||||
|
pool = await get_pool()
|
||||||
|
set_parts = []
|
||||||
|
params: list = [cid]
|
||||||
|
for i, (k, v) in enumerate(updates.items(), start=2):
|
||||||
|
if k == "subject_tags":
|
||||||
|
v = list(v or [])
|
||||||
|
set_parts.append(f"{k} = ${i}")
|
||||||
|
params.append(v)
|
||||||
|
set_parts.append("updated_at = now()")
|
||||||
|
sql = f"UPDATE digests SET {', '.join(set_parts)} WHERE id = $1 RETURNING {_DIGEST_COLS}"
|
||||||
|
row = await pool.fetchrow(sql, *params)
|
||||||
|
return _row_to_digest(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def store_digest_embedding(digest_id: UUID | str, vector: list[float]) -> None:
|
||||||
|
pool = await get_pool()
|
||||||
|
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
|
||||||
|
await pool.execute(
|
||||||
|
"UPDATE digests SET embedding = $2, updated_at = now() WHERE id = $1",
|
||||||
|
cid, vector,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def link_digest_to_case_law(
|
||||||
|
digest_id: UUID | str, case_law_id: UUID | str | None,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Set (or clear, with None) the bridge to the underlying ruling (INV-DIG3)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
|
||||||
|
clid = None
|
||||||
|
if case_law_id is not None:
|
||||||
|
clid = case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
f"UPDATE digests SET linked_case_law_id = $2, updated_at = now() "
|
||||||
|
f"WHERE id = $1 RETURNING {_DIGEST_COLS}",
|
||||||
|
cid, clid,
|
||||||
|
)
|
||||||
|
return _row_to_digest(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_digest(digest_id: UUID | str) -> bool:
|
||||||
|
pool = await get_pool()
|
||||||
|
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
|
||||||
|
result = await pool.execute("DELETE FROM digests WHERE id = $1", cid)
|
||||||
|
return result == "DELETE 1"
|
||||||
|
|
||||||
|
|
||||||
|
async def list_digests(
|
||||||
|
practice_area: str = "",
|
||||||
|
concept_tag: str = "",
|
||||||
|
linked: bool | None = None,
|
||||||
|
search: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List digests with simple filters. linked=True/False filters on whether
|
||||||
|
the underlying ruling is in the library yet (INV-DIG3 gap surfacing)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
conditions: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
idx = 1
|
||||||
|
if practice_area:
|
||||||
|
conditions.append(f"practice_area = ${idx}")
|
||||||
|
params.append(practice_area)
|
||||||
|
idx += 1
|
||||||
|
if concept_tag:
|
||||||
|
conditions.append(f"concept_tag ILIKE ${idx}")
|
||||||
|
params.append(f"%{concept_tag}%")
|
||||||
|
idx += 1
|
||||||
|
if linked is True:
|
||||||
|
conditions.append("linked_case_law_id IS NOT NULL")
|
||||||
|
elif linked is False:
|
||||||
|
conditions.append("linked_case_law_id IS NULL")
|
||||||
|
if search:
|
||||||
|
conditions.append(
|
||||||
|
f"(yomon_number ILIKE ${idx} OR concept_tag ILIKE ${idx} "
|
||||||
|
f"OR headline_holding ILIKE ${idx} OR underlying_citation ILIKE ${idx} "
|
||||||
|
f"OR summary ILIKE ${idx})"
|
||||||
|
)
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
idx += 1
|
||||||
|
where_sql = (" WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||||
|
params.extend([limit, offset])
|
||||||
|
sql = (
|
||||||
|
f"SELECT {_DIGEST_COLS} FROM digests{where_sql} "
|
||||||
|
f"ORDER BY digest_date DESC NULLS LAST, created_at DESC "
|
||||||
|
f"LIMIT ${idx} OFFSET ${idx + 1}"
|
||||||
|
)
|
||||||
|
rows = await pool.fetch(sql, *params)
|
||||||
|
return [_row_to_digest(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def search_digests_semantic(
|
||||||
|
query_embedding: list[float],
|
||||||
|
practice_area: str = "",
|
||||||
|
subject_tag: str = "",
|
||||||
|
concept_tag: str = "",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Pure-semantic search over the digests radar (X12). Single vector per row
|
||||||
|
(no chunks/halachot), so no RRF here — see X12 §6. Joins the linked ruling's
|
||||||
|
citation when present so the researcher sees the pointer target directly."""
|
||||||
|
pool = await get_pool()
|
||||||
|
conditions = ["d.embedding IS NOT NULL"]
|
||||||
|
params: list = [query_embedding, limit]
|
||||||
|
idx = 3
|
||||||
|
if practice_area:
|
||||||
|
conditions.append(f"d.practice_area = ${idx}")
|
||||||
|
params.append(practice_area)
|
||||||
|
idx += 1
|
||||||
|
if subject_tag:
|
||||||
|
conditions.append(f"${idx} = ANY(d.subject_tags)")
|
||||||
|
params.append(subject_tag)
|
||||||
|
idx += 1
|
||||||
|
if concept_tag:
|
||||||
|
conditions.append(f"d.concept_tag ILIKE ${idx}")
|
||||||
|
params.append(f"%{concept_tag}%")
|
||||||
|
idx += 1
|
||||||
|
sql = f"""
|
||||||
|
SELECT {', '.join('d.' + c for c in _DIGEST_COLS.split(', '))},
|
||||||
|
cl.case_number AS linked_case_number,
|
||||||
|
cl.case_name AS linked_case_name,
|
||||||
|
cl.searchable AS linked_searchable,
|
||||||
|
1 - (d.embedding <=> $1) AS score
|
||||||
|
FROM digests d
|
||||||
|
LEFT JOIN case_law cl ON cl.id = d.linked_case_law_id
|
||||||
|
WHERE {' AND '.join(conditions)}
|
||||||
|
ORDER BY d.embedding <=> $1
|
||||||
|
LIMIT $2
|
||||||
|
"""
|
||||||
|
rows = await pool.fetch(sql, *params)
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = _row_to_digest(r)
|
||||||
|
d["linked_case_number"] = r["linked_case_number"]
|
||||||
|
d["linked_case_name"] = r["linked_case_name"]
|
||||||
|
d["linked_searchable"] = r["linked_searchable"]
|
||||||
|
d["score"] = float(r["score"])
|
||||||
|
d["type"] = "digest"
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def find_case_law_by_citation_fuzzy(citation: str) -> dict | None:
|
||||||
|
"""Best-effort match of a digest's underlying_citation to a case_law row,
|
||||||
|
for autolink (INV-DIG3). Tries: (1) exact case_number; (2) canonical docket
|
||||||
|
substring (e.g. '46111-12-22') contained in a case_law.case_number. Returns
|
||||||
|
the first match or None — never raises, never mutates."""
|
||||||
|
citation = (citation or "").strip()
|
||||||
|
if not citation:
|
||||||
|
return None
|
||||||
|
pool = await get_pool()
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT * FROM case_law WHERE case_number = $1 LIMIT 1",
|
||||||
|
citation,
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
return _row_to_case_law(row)
|
||||||
|
# Extract a docket-like token: digits with '-' or '/' separators, e.g.
|
||||||
|
# 46111-12-22 or 3975/22. Match it as a substring of case_number.
|
||||||
|
m = re.search(r"\d+[-/]\d+(?:[-/]\d+)?", citation)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
docket = m.group(0)
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT * FROM case_law "
|
||||||
|
"WHERE case_number ILIKE $1 ORDER BY created_at LIMIT 1",
|
||||||
|
f"%{docket}%",
|
||||||
|
)
|
||||||
|
return _row_to_case_law(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def store_precedent_chunks(
|
async def store_precedent_chunks(
|
||||||
case_law_id: UUID, chunks: list[dict],
|
case_law_id: UUID, chunks: list[dict],
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -5559,3 +5961,110 @@ async def find_missing_precedent_by_citation(
|
|||||||
citation.strip(),
|
citation.strip(),
|
||||||
)
|
)
|
||||||
return _row_to_missing_precedent(row) if row else None
|
return _row_to_missing_precedent(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── X13 — Court Verdict Fetch jobs ───────────────────────────────────────
|
||||||
|
# CRUD for the auto-fetch queue (docs/spec/X13-court-fetch.md). Status is
|
||||||
|
# always explicit; failures are recorded, never swallowed (INV-CF2). Upsert
|
||||||
|
# is keyed on the canonical case number (INV-CF5).
|
||||||
|
|
||||||
|
def _row_to_court_fetch_job(row) -> dict:
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def court_fetch_job_upsert(
|
||||||
|
case_number_norm: str,
|
||||||
|
citation_raw: str = "",
|
||||||
|
tier: str = "",
|
||||||
|
court: str = "",
|
||||||
|
digest_id: UUID | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Idempotent create-or-get of a fetch job by canonical case number.
|
||||||
|
|
||||||
|
Re-requesting the same case number returns the existing row (with a
|
||||||
|
``_existing`` flag) rather than creating a duplicate — the canonical
|
||||||
|
number is a UNIQUE key. A job that already reached a terminal state is
|
||||||
|
returned as-is so callers can decide whether to retry.
|
||||||
|
"""
|
||||||
|
if not (case_number_norm or "").strip():
|
||||||
|
raise ValueError("case_number_norm is required")
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
"SELECT * FROM court_fetch_jobs WHERE case_number_norm = $1",
|
||||||
|
case_number_norm,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
out = _row_to_court_fetch_job(existing)
|
||||||
|
out["_existing"] = True
|
||||||
|
return out
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""INSERT INTO court_fetch_jobs
|
||||||
|
(case_number_norm, citation_raw, tier, court, digest_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *""",
|
||||||
|
case_number_norm, citation_raw, tier, court, digest_id,
|
||||||
|
)
|
||||||
|
out = _row_to_court_fetch_job(row)
|
||||||
|
out["_existing"] = False
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def court_fetch_job_update(
|
||||||
|
job_id: UUID,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
case_law_id: UUID | None = None,
|
||||||
|
source_url: str | None = None,
|
||||||
|
bump_attempts: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Patch a job row. Only provided fields change; ``updated_at`` always does."""
|
||||||
|
sets = ["updated_at = now()"]
|
||||||
|
args: list = []
|
||||||
|
if status is not None:
|
||||||
|
args.append(status); sets.append(f"status = ${len(args)}")
|
||||||
|
if error is not None:
|
||||||
|
args.append(error); sets.append(f"error = ${len(args)}")
|
||||||
|
if case_law_id is not None:
|
||||||
|
args.append(case_law_id); sets.append(f"case_law_id = ${len(args)}")
|
||||||
|
if source_url is not None:
|
||||||
|
args.append(source_url); sets.append(f"source_url = ${len(args)}")
|
||||||
|
if bump_attempts:
|
||||||
|
sets.append("attempts = attempts + 1")
|
||||||
|
args.append(job_id)
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"UPDATE court_fetch_jobs SET {', '.join(sets)} "
|
||||||
|
f"WHERE id = ${len(args)} RETURNING *",
|
||||||
|
*args,
|
||||||
|
)
|
||||||
|
return _row_to_court_fetch_job(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def court_fetch_job_get(case_number_norm: str) -> dict | None:
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT * FROM court_fetch_jobs WHERE case_number_norm = $1",
|
||||||
|
case_number_norm,
|
||||||
|
)
|
||||||
|
return _row_to_court_fetch_job(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def court_fetch_job_list(status: str | None = None, limit: int = 100) -> list[dict]:
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if status:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT * FROM court_fetch_jobs WHERE status = $1 "
|
||||||
|
"ORDER BY created_at DESC LIMIT $2",
|
||||||
|
status, limit,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT * FROM court_fetch_jobs ORDER BY created_at DESC LIMIT $1",
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
return [_row_to_court_fetch_job(r) for r in rows]
|
||||||
|
|||||||
268
mcp-server/src/legal_mcp/services/digest_library.py
Normal file
268
mcp-server/src/legal_mcp/services/digest_library.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""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 is therefore a short, standalone
|
||||||
|
path that reuses only ATOMIC services (extract_text, embeddings), NOT the
|
||||||
|
canonical ``ingest.ingest_document`` (which is bound to case_law):
|
||||||
|
|
||||||
|
file → extract_text → content_hash (idempotent) → LLM metadata extract
|
||||||
|
→ create_digest → single embedding (concept+headline+summary+analysis)
|
||||||
|
→ try_autolink(underlying_citation → case_law) [INV-DIG3]
|
||||||
|
→ extraction_status='completed'
|
||||||
|
|
||||||
|
claude_session rule: ``digest_metadata_extractor`` (local CLI) is imported
|
||||||
|
LAZILY inside ``ingest_digest`` only, so this module is import-safe from the
|
||||||
|
FastAPI container for the search/list/link/delete paths (DB + voyage only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 _embedding_text(fields: dict) -> str:
|
||||||
|
"""The single vector indexes the digest as an atomic discovery unit."""
|
||||||
|
parts = [
|
||||||
|
fields.get("concept_tag", ""),
|
||||||
|
fields.get("headline_holding", ""),
|
||||||
|
fields.get("summary", ""),
|
||||||
|
fields.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:
|
||||||
|
return None
|
||||||
|
await db.link_digest_to_case_law(digest_id, match["id"])
|
||||||
|
return str(match["id"])
|
||||||
|
|
||||||
|
|
||||||
|
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. **MCP-tool-only** (uses the local LLM extractor).
|
||||||
|
|
||||||
|
User-supplied args win over LLM-extracted values for the same field
|
||||||
|
(the chair typed them deliberately); empty args are filled from the LLM.
|
||||||
|
Idempotent on yomon_number / 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", 5, "מעתיק קובץ")
|
||||||
|
staged = 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", 20, "מחלץ טקסט")
|
||||||
|
raw_text, _page_count, _offsets = await extractor.extract_text(str(staged))
|
||||||
|
raw_text = (raw_text or "").strip()
|
||||||
|
if not raw_text:
|
||||||
|
raise ValueError("no text extracted from digest")
|
||||||
|
|
||||||
|
# Idempotency: identical text already ingested → return existing row.
|
||||||
|
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"],
|
||||||
|
"yomon_number": existing.get("yomon_number", ""),
|
||||||
|
"linked_case_law_id": existing.get("linked_case_law_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# LLM metadata extraction (lazy import — keeps this module container-safe).
|
||||||
|
await progress("extracting_metadata", 45, "מחלץ מטא-דאטה (LLM)")
|
||||||
|
from legal_mcp.services import digest_metadata_extractor
|
||||||
|
extracted = await digest_metadata_extractor.extract(raw_text)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Merge: explicit user args win; otherwise fall back to LLM extraction.
|
||||||
|
fields = {
|
||||||
|
"analysis_text": raw_text,
|
||||||
|
"yomon_number": yomon_number.strip() or extracted.get("yomon_number", ""),
|
||||||
|
"digest_date": _coerce_date(digest_date) or extracted.get("digest_date"),
|
||||||
|
"concept_tag": extracted.get("concept_tag", ""),
|
||||||
|
"headline_holding": extracted.get("headline_holding", ""),
|
||||||
|
"summary": extracted.get("summary", ""),
|
||||||
|
"underlying_citation": extracted.get("underlying_citation", ""),
|
||||||
|
"underlying_court": extracted.get("underlying_court", ""),
|
||||||
|
"underlying_date": extracted.get("underlying_date"),
|
||||||
|
"underlying_judge": extracted.get("underlying_judge", ""),
|
||||||
|
"practice_area": practice_area or extracted.get("practice_area", ""),
|
||||||
|
"appeal_subtype": appeal_subtype.strip() or extracted.get("appeal_subtype", ""),
|
||||||
|
"subject_tags": list(subject_tags) if subject_tags else extracted.get("subject_tags", []),
|
||||||
|
"source_document_path": rel_path,
|
||||||
|
"extraction_status": "processing",
|
||||||
|
}
|
||||||
|
|
||||||
|
await progress("storing", 70, "שומר רשומה")
|
||||||
|
record = await db.create_digest(**fields)
|
||||||
|
digest_id = record["id"]
|
||||||
|
|
||||||
|
# Single embedding for the whole digest (atomic discovery unit — X12 §6).
|
||||||
|
await progress("embedding", 85, "מחשב embedding")
|
||||||
|
emb_text = _embedding_text(fields)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Bridge to the underlying ruling if it is already in the library (INV-DIG3).
|
||||||
|
await progress("linking", 95, "מנסה לקשר לפסק המקורי")
|
||||||
|
linked_id = await try_autolink(digest_id, fields["underlying_citation"])
|
||||||
|
|
||||||
|
await db.update_digest(digest_id, extraction_status="completed")
|
||||||
|
await progress("completed", 100, "הושלם")
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"digest_id": digest_id,
|
||||||
|
"yomon_number": fields["yomon_number"],
|
||||||
|
"underlying_citation": fields["underlying_citation"],
|
||||||
|
"linked_case_law_id": linked_id,
|
||||||
|
"fields_extracted": sorted(extracted.keys()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 a digest whose underlying ruling may now be in the
|
||||||
|
library. No-op if already linked or no match found."""
|
||||||
|
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 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 = "",
|
||||||
|
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,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_digest(digest_id: UUID | str) -> bool:
|
||||||
|
return await db.delete_digest(digest_id)
|
||||||
137
mcp-server/src/legal_mcp/services/digest_metadata_extractor.py
Normal file
137
mcp-server/src/legal_mcp/services/digest_metadata_extractor.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""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.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 וללא הסברים:
|
||||||
|
|
||||||
|
{
|
||||||
|
"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": "מראה-המקום של פסק הדין/ההחלטה המקורי, כפי שמופיע בתחתית היומון, מילה במילה (למשל 'עת\\"מ 46111-12-22 יכין-אפק בע\\"מ נ' הוועדה המחוזית'). זהו השדה הקריטי — חלץ אותו במלואו ובדיוק.",
|
||||||
|
"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. **underlying_citation** — השדה החשוב ביותר; הוא הגשר לפסק הדין המקורי. חלץ מההערות/התחתית, מילה במילה.
|
||||||
|
2. **הבחן בין שני התאריכים**: digest_date_iso = תאריך גיליון היומון (בכותרת); underlying_date_iso = מועד מתן פסק הדין (בתחתית, 'ניתן ביום...'). אל תבלבל.
|
||||||
|
3. **summary** — ניטרלי, גוף שלישי, בלי מילות שיפוט.
|
||||||
|
4. **subject_tags** — snake_case, תחום ועדת ערר תכנון ובנייה בלבד.
|
||||||
|
5. אם רכיב לא מופיע בבירור — השאר את אותו שדה ריק. אל תנחש.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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) -> 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.
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
56
mcp-server/src/legal_mcp/tools/court_fetch.py
Normal file
56
mcp-server/src/legal_mcp/tools/court_fetch.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""MCP tools for the X13 court-verdict auto-fetch subsystem.
|
||||||
|
|
||||||
|
- ``court_verdict_fetch`` — classify a citation, fetch the verdict from the
|
||||||
|
matching public source (Supreme portal / נט המשפט), and ingest it into the
|
||||||
|
precedent library via the canonical pipeline. The standalone entry point
|
||||||
|
(also driven automatically from digest auto-link, see X12/X13).
|
||||||
|
- ``court_fetch_status`` — inspect the fetch-job queue (pending/failed/manual).
|
||||||
|
|
||||||
|
Local-only: ``court_verdict_fetch`` runs the ingest pipeline, which drives
|
||||||
|
halacha extraction via the local ``claude`` CLI — same constraint as
|
||||||
|
``precedent_process_pending``. Invoking it from the container will fail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from legal_mcp.services import court_fetch_orchestrator as orch
|
||||||
|
from legal_mcp.services import db
|
||||||
|
from legal_mcp.tools.envelope import err as _err, ok as _ok
|
||||||
|
|
||||||
|
|
||||||
|
async def court_verdict_fetch(citation: str) -> str:
|
||||||
|
"""אחזור אוטומטי של פסק-דין בית-משפט וקליטה לקורפוס.
|
||||||
|
|
||||||
|
מקבל ציטוט (למשל 'עת"מ 46111-12-22' או 'עע"מ 1234/22'), מסווג את הערכאה,
|
||||||
|
מוריד את הפסק מהמקור הציבורי המתאים, וקולט אותו דרך צינור-הקליטה הקנוני.
|
||||||
|
ערר/בל"מ (ועדת-ערר) אינם ניתנים לאחזור ציבורי ויסומנו כפער.
|
||||||
|
"""
|
||||||
|
if not (citation or "").strip():
|
||||||
|
return _err("citation is required")
|
||||||
|
try:
|
||||||
|
result = await orch.fetch_and_ingest(citation.strip())
|
||||||
|
except Exception as e: # noqa: BLE001 — surfaced, not swallowed (INV-CF2)
|
||||||
|
return _err(f"אחזור נכשל: {e}")
|
||||||
|
|
||||||
|
status = result.get("status")
|
||||||
|
if status in ("done", "already_done"):
|
||||||
|
return _ok(result, message="הפסק נקלט לקורפוס")
|
||||||
|
if status == "skipped":
|
||||||
|
return _ok(result, message="ועדת-ערר — לא ניתן לאחזור ציבורי (סומן כפער)")
|
||||||
|
if status in ("manual", "awaiting_manual"):
|
||||||
|
return _ok(result, message="האחזור האוטונומי נכשל — הוסלם להורדה ידנית")
|
||||||
|
if status == "unrecognized":
|
||||||
|
return _err("הציטוט לא זוהה כמספר-תיק תקין")
|
||||||
|
return _ok(result, message=f"סטטוס: {status}")
|
||||||
|
|
||||||
|
|
||||||
|
async def court_fetch_status(case_number: str = "", status_filter: str = "") -> str:
|
||||||
|
"""סטטוס תור-האחזור. case_number לפריט יחיד, או status_filter לסינון רשימה."""
|
||||||
|
if case_number.strip():
|
||||||
|
from legal_mcp.services.court_citation import normalize_case_number
|
||||||
|
job = await db.court_fetch_job_get(normalize_case_number(case_number))
|
||||||
|
if not job:
|
||||||
|
return _ok({"job": None}, message="אין job עבור תיק זה")
|
||||||
|
return _ok({"job": job})
|
||||||
|
jobs = await db.court_fetch_job_list(status=status_filter.strip() or None)
|
||||||
|
return _ok({"jobs": jobs, "count": len(jobs)})
|
||||||
161
mcp-server/src/legal_mcp/tools/digests.py
Normal file
161
mcp-server/src/legal_mcp/tools/digests.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""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)
|
||||||
80
mcp-server/tests/test_court_citation.py
Normal file
80
mcp-server/tests/test_court_citation.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Unit tests for the X13 court-citation classifier."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from legal_mcp.services.court_citation import classify, normalize_case_number
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_filed_format_the_example():
|
||||||
|
"""The plan's example: עת"מ 46111-12-22 → admin, parsed into (46111,12,22)."""
|
||||||
|
c = classify('עת"מ 46111-12-22 יכין-אפק בע"מ נ\' הוועדה המחוזית')
|
||||||
|
assert c.tier == "admin"
|
||||||
|
assert c.court_prefix in ('עת"מ', "עת״מ")
|
||||||
|
assert c.case_number_raw == "46111-12-22"
|
||||||
|
assert c.case_number_norm == "46111-12-22"
|
||||||
|
assert (c.file_number, c.month, c.year) == ("46111", "12", "22")
|
||||||
|
assert c.fetchable is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_filed_number_defaults_admin():
|
||||||
|
c = classify("46111-12-22")
|
||||||
|
assert c.tier == "admin"
|
||||||
|
assert (c.file_number, c.month, c.year) == ("46111", "12", "22")
|
||||||
|
|
||||||
|
|
||||||
|
def test_supreme_prefixes():
|
||||||
|
for cit, pref in [
|
||||||
|
('עע"מ 1234/22', "supreme"),
|
||||||
|
('בג"ץ 5678/21', "supreme"),
|
||||||
|
('ע"א 999/20', "supreme"),
|
||||||
|
('רע"א 4/19', "supreme"),
|
||||||
|
('בר"מ 8126/24', "supreme"),
|
||||||
|
]:
|
||||||
|
c = classify(cit)
|
||||||
|
assert c.tier == pref, f"{cit} -> {c.tier}"
|
||||||
|
assert c.fetchable is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_appeals_committee_is_skip():
|
||||||
|
"""ערר / בל"מ must never be auto-fetched (needs Nevo) — INV-CF6."""
|
||||||
|
for cit in ['ערר 1110/20', 'בל"מ 8048/24', "ערר 1015-01-24 ירושלים שקופה"]:
|
||||||
|
c = classify(cit)
|
||||||
|
assert c.tier == "skip", f"{cit} -> {c.tier}"
|
||||||
|
assert c.fetchable is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_skip_wins_over_court_match():
|
||||||
|
"""An 'ערר' citation that also contains court-like digits stays skip."""
|
||||||
|
c = classify("ראה החלטתי בערר 1041/24 ובהמשך")
|
||||||
|
assert c.tier == "skip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_amn_prefix():
|
||||||
|
c = classify('עמ"נ 12345-06-23')
|
||||||
|
assert c.tier == "admin"
|
||||||
|
assert (c.file_number, c.month, c.year) == ("12345", "06", "23")
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_group_serial_has_no_filed_triple():
|
||||||
|
"""Supreme serial 1234/22 normalizes but yields no (file,month,year)."""
|
||||||
|
c = classify('עע"מ 1234/22')
|
||||||
|
assert c.case_number_norm == "1234-22"
|
||||||
|
assert c.file_number is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_implausible_month_not_parsed_as_filed():
|
||||||
|
# 1234-22-05 has month=22 → not a valid filed triple.
|
||||||
|
assert classify("1234-22-05").tier in ("unknown", "admin")
|
||||||
|
c = classify("1234-22-05")
|
||||||
|
if c.tier == "admin":
|
||||||
|
assert c.month is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_and_garbage():
|
||||||
|
assert classify("").tier == "unknown"
|
||||||
|
assert classify("שלום עולם בלי ציטוט").tier == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_case_number():
|
||||||
|
assert normalize_case_number('עת"מ 46111/12/22') == "46111-12-22"
|
||||||
|
assert normalize_case_number("1110/20") == "1110-20"
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
| `fu2c_reconcile_external_case_numbers.py` | python | **FU-2c (GAP-08, #68) — תיאום `case_number` של פסיקה חיצונית** (`source_kind <> internal_committee`) מציטוט-מלא לצורה קנונית **מציין-הליך + docket** (החלטת-יו"ר 2026-05-31, Option A: `/` נשמר, *לא* `-`; תואם db.py:369 ו-INV-ID2). דטרמיניסטי (designator+docket; 0/>1 docket → flag). `--dry-run` (ברירת-מחדל) מפיק `data/audit/fu2c-reconciliation-*.{csv,md}` עם flags (MISMATCH / NO_CITATION / CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK). `--apply --approved <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) |
|
| `fu2c_reconcile_external_case_numbers.py` | python | **FU-2c (GAP-08, #68) — תיאום `case_number` של פסיקה חיצונית** (`source_kind <> internal_committee`) מציטוט-מלא לצורה קנונית **מציין-הליך + docket** (החלטת-יו"ר 2026-05-31, Option A: `/` נשמר, *לא* `-`; תואם db.py:369 ו-INV-ID2). דטרמיניסטי (designator+docket; 0/>1 docket → flag). `--dry-run` (ברירת-מחדל) מפיק `data/audit/fu2c-reconciliation-*.{csv,md}` עם flags (MISMATCH / NO_CITATION / CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK). `--apply --approved <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) |
|
||||||
| `eval_gold_bootstrap.py` | python | **FU-5 (GAP-11) — bootstrap ל-gold-set** של הערכת-אחזור ל-`data/eval/gold-set.jsonl`. שני מקורות: `--source citations` (cited==relevant מ-`search_relevance_feedback`; ריק עד שייצברו ציטוטים) ו-`--source known_item` (query=שם-תיק → relevant=עצמו; אות אמיתי היום). Idempotent — שומר שורות `source=chair`, מחדש `bootstrap_*`. דורש POSTGRES. | לפני eval; חוזר כשנצבר ground-truth |
|
| `eval_gold_bootstrap.py` | python | **FU-5 (GAP-11) — bootstrap ל-gold-set** של הערכת-אחזור ל-`data/eval/gold-set.jsonl`. שני מקורות: `--source citations` (cited==relevant מ-`search_relevance_feedback`; ריק עד שייצברו ציטוטים) ו-`--source known_item` (query=שם-תיק → relevant=עצמו; אות אמיתי היום). Idempotent — שומר שורות `source=chair`, מחדש `bootstrap_*`. דורש POSTGRES. | לפני eval; חוזר כשנצבר ground-truth |
|
||||||
| `eval_retrieval.py` | python | **FU-5 (GAP-11, INV-RET4/G8) — harness הערכת-אחזור** — מריץ את מסלול-האחזור בייצור (`search_library`/`search_internal`) על ה-gold-set, מחשב precision@k/recall@k/MRR/nDCG@k (k=5,10), מצרף overall+per-corpus+per-PA ל-`data/eval/eval-report-<ts>.{json,md}` + delta מול `data/eval/baseline.json` (מתעד retrieval_config). `--self-test` בודק את המטריקות offline; `--update-baseline` מאמץ snapshot. **שער-CI במשמעת:** הרץ לפני/אחרי כל שינוי בשכבת-האחזור באותו קונפיג. דורש POSTGRES+VOYAGE_API_KEY. | לפני/אחרי שינוי RRF/k/embedder/rerank |
|
| `eval_retrieval.py` | python | **FU-5 (GAP-11, INV-RET4/G8) — harness הערכת-אחזור** — מריץ את מסלול-האחזור בייצור (`search_library`/`search_internal`) על ה-gold-set, מחשב precision@k/recall@k/MRR/nDCG@k (k=5,10), מצרף overall+per-corpus+per-PA ל-`data/eval/eval-report-<ts>.{json,md}` + delta מול `data/eval/baseline.json` (מתעד retrieval_config). `--self-test` בודק את המטריקות offline; `--update-baseline` מאמץ snapshot. **שער-CI במשמעת:** הרץ לפני/אחרי כל שינוי בשכבת-האחזור באותו קונפיג. דורש POSTGRES+VOYAGE_API_KEY. | לפני/אחרי שינוי RRF/k/embedder/rerank |
|
||||||
|
| `legal-court-fetch-service.config.cjs` | pm2/js | **שירות-מארח Tier-1 לאחזור פסקי-דין מנט המשפט (X13)** — מריץ `python -m legal_mcp.court_fetch_service.server` ב-pm2, bound ל-`10.0.1.1:8771`, Bearer-auth (`COURT_FETCH_SHARED_SECRET` מ-`~/.legal-court-fetch-service.env`). מריץ דפדפן Camoufox (open-source) כי הקונטיינר לא יכול. תלות לאחזור-בפועל: `camofox-browser` רץ (`CAMOFOX_URL`) + `faster-whisper` ל-reCAPTCHA אודיו; אחרת מחזיר ok:false וה-orchestrator מסלים ל-fallback אנושי. מראָה לדפוס `legal-chat-service.config.cjs`. ספ: `docs/spec/X13-court-fetch.md`. התקנה: `pm2 start scripts/legal-court-fetch-service.config.cjs && pm2 save`. בריאות: `curl http://10.0.1.1:8771/health`. | pm2 (host-side) |
|
||||||
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
||||||
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
||||||
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
||||||
@@ -83,6 +84,7 @@
|
|||||||
| `run_curator_sonnet_rerun.sh` | A/B test #3 (2026-05-05) — ריצה חוזרת של Sonnet 4.5 על אותו CMP-78. תוצאה: 12:52 דק׳ (לעומת 20:13 בריצה המקורית — כי בלי לולאת interaction.json). זיהה תוצאה שגויה ("דחייה") **בעקביות עם הריצה המקורית** — Sonnet עקבי-בטעות, DeepSeek אקראי. | בדיקה חד-פעמית — לא להריץ שוב |
|
| `run_curator_sonnet_rerun.sh` | A/B test #3 (2026-05-05) — ריצה חוזרת של Sonnet 4.5 על אותו CMP-78. תוצאה: 12:52 דק׳ (לעומת 20:13 בריצה המקורית — כי בלי לולאת interaction.json). זיהה תוצאה שגויה ("דחייה") **בעקביות עם הריצה המקורית** — Sonnet עקבי-בטעות, DeepSeek אקראי. | בדיקה חד-פעמית — לא להריץ שוב |
|
||||||
| `ingest_incoming_batch.py` | python | קליטת batch של החלטות ועדת ערר מ-`data/precedents/incoming/` דרך המסלול הקנוני (`ingest_internal_decision`) + חילוץ מטא-דאטה לכל תיק (המסלול הפנימי לא מתזמן metadata — INV-ING3). רצף (לא מקבילי, להימנע מעומס CLI). רשימת `DECISIONS` נערכת ידנית לכל batch. config מ-`~/.env`. תומך תהליך [[project_precedent_incoming_workflow]]. | ידני, per-batch (חלופה ל-MCP `internal_decision_upload` כש-batch גדול) |
|
| `ingest_incoming_batch.py` | python | קליטת batch של החלטות ועדת ערר מ-`data/precedents/incoming/` דרך המסלול הקנוני (`ingest_internal_decision`) + חילוץ מטא-דאטה לכל תיק (המסלול הפנימי לא מתזמן metadata — INV-ING3). רצף (לא מקבילי, להימנע מעומס CLI). רשימת `DECISIONS` נערכת ידנית לכל batch. config מ-`~/.env`. תומך תהליך [[project_precedent_incoming_workflow]]. | ידני, per-batch (חלופה ל-MCP `internal_decision_upload` כש-batch גדול) |
|
||||||
| `drain_halacha_queue.py` | python | ריקון תור חילוץ ההלכות (`process_pending_extractions kind='halacha'`) ב-batches של 4 עד שהתור ריק (2 סבבים ריקים). משמש אחרי `ingest_incoming_batch.py`. | ידני אחרי batch (חלופה ל-MCP `precedent_process_pending`) |
|
| `drain_halacha_queue.py` | python | ריקון תור חילוץ ההלכות (`process_pending_extractions kind='halacha'`) ב-batches של 4 עד שהתור ריק (2 סבבים ריקים). משמש אחרי `ingest_incoming_batch.py`. | ידני אחרי batch (חלופה ל-MCP `precedent_process_pending`) |
|
||||||
|
| `ingest_digests_batch.py` | python | קליטת batch של יומוני "כל יום" מ-`data/digests/incoming/` דרך המסלול העצמאי של קורפוס-הגילוי (`digest_library.ingest_digest`) — חילוץ-LLM (תג-מושג, כותרת-הלכה, מראה-מקום, שני-תאריכים), embedding יחיד, ו-autolink לפסק המקורי (X12/INV-DIG3). רצף (לא מקבילי). מזהה-יומון+תאריך נגזרים משם-הקובץ; העלון החודשי מדולג. קבצים מועברים ל-`processed/`. config מ-`~/.env`. | ידני, per-batch (חלופה ל-MCP `digest_upload`) |
|
||||||
|
|
||||||
## סקריפטים שנמחקו (git history בלבד)
|
## סקריפטים שנמחקו (git history בלבד)
|
||||||
|
|
||||||
|
|||||||
137
scripts/ingest_digests_batch.py
Normal file
137
scripts/ingest_digests_batch.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""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.
|
||||||
|
|
||||||
|
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 shutil
|
||||||
|
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"
|
||||||
|
PROCESSED = Path(config.DATA_DIR) / "digests" / "processed"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
PROCESSED.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
# Move to processed/ so re-runs are clean (idempotent anyway).
|
||||||
|
try:
|
||||||
|
shutil.move(str(fp), str(PROCESSED / fp.name))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" (could not move {fp.name}: {e})", flush=True)
|
||||||
|
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:]))
|
||||||
65
scripts/legal-court-fetch-service.config.cjs
Normal file
65
scripts/legal-court-fetch-service.config.cjs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* pm2 ecosystem entry for legal-court-fetch-service — the host-side Tier-1
|
||||||
|
* verdict fetcher (X13). It drives a Camoufox stealth browser against
|
||||||
|
* נט המשפט to download administrative/district-court verdicts the Supreme
|
||||||
|
* portal (Tier 0) doesn't carry. Lives on the host because the legal-ai
|
||||||
|
* container can't run a browser. See docs/spec/X13-court-fetch.md.
|
||||||
|
*
|
||||||
|
* Mirrors legal-chat-service.config.cjs (same security model):
|
||||||
|
* 1. Bind to 10.0.1.1 (docker0 bridge gateway) — host + docker-bridge
|
||||||
|
* containers only; nothing from outside the host.
|
||||||
|
* 2. Bearer token auth — COURT_FETCH_SHARED_SECRET loaded from
|
||||||
|
* /home/chaim/.legal-court-fetch-service.env (chmod 600) and mirrored in
|
||||||
|
* Coolify so the FastAPI proxy sends a matching Authorization header.
|
||||||
|
* The service refuses to start without the secret.
|
||||||
|
*
|
||||||
|
* Prereqs for Tier-1 to actually fetch (otherwise it returns ok:false and the
|
||||||
|
* orchestrator escalates to the human fallback — INV-CF3):
|
||||||
|
* - camofox-browser running, CAMOFOX_URL set (e.g. http://127.0.0.1:9377).
|
||||||
|
* git clone https://github.com/jo-inc/camofox-browser && npm i && npm start
|
||||||
|
* - faster-whisper installed in the venv for the reCAPTCHA audio solver.
|
||||||
|
*
|
||||||
|
* Install (once):
|
||||||
|
* pm2 start /home/chaim/legal-ai/scripts/legal-court-fetch-service.config.cjs
|
||||||
|
* pm2 save
|
||||||
|
* Smoke test:
|
||||||
|
* curl http://10.0.1.1:8771/health
|
||||||
|
* Update:
|
||||||
|
* pm2 restart legal-court-fetch-service --update-env
|
||||||
|
*/
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const ENV_FILE = "/home/chaim/.legal-court-fetch-service.env";
|
||||||
|
const env = {
|
||||||
|
HOME: "/home/chaim",
|
||||||
|
PATH: "/home/chaim/.local/bin:/usr/local/bin:/usr/bin:/bin",
|
||||||
|
PYTHONUNBUFFERED: "1",
|
||||||
|
// CAMOFOX_URL: "http://127.0.0.1:9377", // set when camofox-browser is up
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const text = fs.readFileSync(ENV_FILE, "utf8");
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
if (!line || line.trim().startsWith("#")) continue;
|
||||||
|
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||||
|
if (m) env[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`legal-court-fetch-service: failed to load ${ENV_FILE}: ${e.message}`);
|
||||||
|
console.error("Service will refuse to start without COURT_FETCH_SHARED_SECRET.");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "legal-court-fetch-service",
|
||||||
|
cwd: "/home/chaim/legal-ai/mcp-server",
|
||||||
|
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
|
||||||
|
args: "-m legal_mcp.court_fetch_service.server --port 8771 --host 10.0.1.1",
|
||||||
|
env,
|
||||||
|
restart_delay: 5000,
|
||||||
|
max_restarts: 10,
|
||||||
|
autorestart: true,
|
||||||
|
max_memory_restart: "1G",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user