feat(digests): קורפוס יומונים כשכבת-גילוי (radar) — X12 #109
@@ -223,12 +223,15 @@ new → proofread → documents_ready → analyst_verified → research_complete
|
||||
חיים העלה PDF פסיקה לתיק → ה-citation הוא:
|
||||
├── "ערר NNNN/YY" או "בל"מ NNNN/YY"
|
||||
│ → internal_decision_upload (חובה chair_name + district)
|
||||
└── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
|
||||
→ precedent_library_upload (external_upload)
|
||||
├── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
|
||||
│ → precedent_library_upload (external_upload)
|
||||
└── PDF יומון "כל יום" (סיכום-משני של עפר טויסטר, עמוד אחד)
|
||||
→ digest_upload (קורפוס-גילוי; לא קורפוס-ציטוט — X12)
|
||||
```
|
||||
|
||||
- **`internal_decision_upload`** דורש: `file_path`, `case_number`, `chair_name`, `district`. district מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
|
||||
- **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה.
|
||||
- **`digest_upload`** — ליומון "כל יום" בלבד (מקור-משני שמצביע על פסק; INV-DIG1/2). אינו מצוטט בהחלטה ואינו מחלץ הלכות. **אל** תעלה יומון דרך precedent/internal — ואל תעלה פסק-דין דרך digest.
|
||||
- פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש".
|
||||
|
||||
---
|
||||
|
||||
@@ -21,6 +21,9 @@ tools:
|
||||
- mcp__legal-ai__precedent_list
|
||||
- mcp__legal-ai__search_case_precedents
|
||||
- mcp__legal-ai__search_precedent_library
|
||||
- mcp__legal-ai__search_digests
|
||||
- mcp__legal-ai__digest_link
|
||||
- mcp__legal-ai__digest_upload
|
||||
- mcp__legal-ai__internal_decision_upload
|
||||
- mcp__legal-ai__precedent_library_upload
|
||||
- mcp__legal-ai__precedent_library_get
|
||||
@@ -193,6 +196,26 @@ mcp__legal-ai__internal_decision_upload(
|
||||
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
|
||||
- `search_case_precedents` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
||||
|
||||
#### 2ב.0 — שכבת-גילוי: יומוני "כל יום" (`search_digests`) — מצפן, לפני האימות
|
||||
|
||||
לכל סוגיה מרכזית — הרץ `search_digests` כ**מצפן-מחקר (radar)**, **לא** כמקור-ציטוט. היומון הוא סיכום-משני (עפר טויסטר) של פסק-דין בודד, והוא מפנה אותך אל **הפסק המקורי**. אם נמצא יומון רלוונטי:
|
||||
|
||||
1. קרא את כותרת-ההלכה ואת ניתוח עפר-טויסטר **כרקע/orientation בלבד**.
|
||||
2. חלץ את **מראה-המקום של הפסק המקורי** מהיומון (שדה `underlying_citation`, למשל `עת"מ 46111-12-22`).
|
||||
3. **בדוק אם הפסק המקורי בקורפוס** — `search_precedent_library` **וגם** `search_internal_decisions` לפי פרוטוקול 2ב.4א (לפי קידומת-הציטוט; flowchart §8).
|
||||
4. **אם נמצא** → אמת וצטט את הפסק המקורי כרגיל (`precedent_attach`), וקרא `digest_link(digest_id, case_law_id)` כדי לקשר את היומון לפסק.
|
||||
5. **אם לא נמצא** → קרא `missing_precedent_create` על **הפסק המקורי** (לא על היומון), עם `notes="זוהה דרך יומון 'כל יום' מס' NNNN"`. היומון הוא הטריגר; הרשומה החסרה היא הפסק. (אם הפסק זמין — אפשר להעלותו דרך `precedent_library_upload`/`internal_decision_upload` ואז `digest_link`.)
|
||||
|
||||
⚠️ **היומון לעולם אינו מצוטט בהחלטה ואינו נרשם דרך `precedent_attach`** (INV-DIG1). הוא radar בלבד — מצביע, לא מקור. ראה [docs/spec/X12-digests-radar.md](../../docs/spec/X12-digests-radar.md).
|
||||
|
||||
```
|
||||
search_digests(
|
||||
query="...",
|
||||
practice_area="betterment_levy", # rishuy_uvniya / betterment_levy / compensation_197
|
||||
limit=10
|
||||
)
|
||||
```
|
||||
|
||||
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
||||
|
||||
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
|
||||
@@ -310,6 +333,10 @@ mcp__legal-ai__missing_precedent_create(
|
||||
|
||||
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
|
||||
|
||||
#### 2ב.6 — תיעוד סריקת היומונים — סעיף "ט" ב-`precedent-research.md`
|
||||
|
||||
הוסף סעיף נפרד `## ט. סריקת יומונים (radar — לא ציטוט)` שמתעד אילו יומונים נסרקו לכל סוגיה, אילו פסקי-דין מקוריים הם הצביעו עליהם, וסטטוס כל אחד: *בקורפוס (קושר) / נרשם כחסר / לא רלוונטי*. ציין מפורש: **רשומות אלה אינן ציטוטים** — הן עקבות-מחקר (radar). ה-writer וה-QA מתעלמים מהן כמקור-סמכות (INV-DIG1); הציטוט בהחלטה תמיד נשען על הפסק המקורי שבסעיפים ז/ח.
|
||||
|
||||
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
||||
|
||||
### שלב 3: מיפוי תכנית
|
||||
|
||||
@@ -227,7 +227,7 @@ Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוט
|
||||
|
||||
## 7. אינדקס הספ
|
||||
|
||||
> הערה: כל קבצי הספ (00, 01–07, X1–X10) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
||||
> הערה: כל קבצי הספ (00, 01–07, X1–X12) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
||||
|
||||
| קובץ | תפקיד | אוכף invariants |
|
||||
|------|--------|-----------------|
|
||||
@@ -250,6 +250,7 @@ Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוט
|
||||
| [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) | חוזה 71 כלי-ה-MCP: envelope · שמות · idempotency · extract/get-symmetry · שלמות-הרשאות | G2, G3, G10 |
|
||||
| [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) | env-catalog SSoT · מקור-config יחיד (Coolify) · ללא hardcode · secrets · drift | G2, G4, G9 |
|
||||
| [X11-citation-corroboration.md](X11-citation-corroboration.md) | citator פנימי — תיקוף הלכות בטיפול-שיפוטי מצטבר · תיקון-G10 מבוקר · סף-corroboration · התאמה-להלכה | G9, G10 |
|
||||
| [X12-digests-radar.md](X12-digests-radar.md) | יומונים כשכבת-גילוי (radar) — מקור-משני המצביע על הפסק המקורי · לא קורפוס-ציטוט רביעי · לא מצוטט/לא מחלץ-הלכות | G2, G4, G9 |
|
||||
|
||||
> **X6–X10 (מחזור-2):** מכסים את 8 משטחי-האפליקציה שמחוץ לצינור-הליבה (אינטגרציה, web-ui, מילוי-שדות,
|
||||
> אחסון-ניתוחים, כלי-MCP, deploy/env). הממצאים ב-[gap-audit.md](gap-audit.md) (GAP-24..62 → FU-9..15)
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
(`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**,
|
||||
ושם נולדת ההפרה ב-§5.
|
||||
|
||||
> **שכבת-גילוי — יומונים, לא קורפוס-ציטוט.** מעל 3 הקורפוסים יושבת שכבת-radar נפרדת: **יומונים**
|
||||
> (סיכומי עפר-טויסטר), בטבלה פיזית נפרדת `digests` עם כלי `search_digests`. היומון הוא **מקור משני
|
||||
> המצביע** על הפסק המקורי — **אינו** קורפוס-ציטוט רביעי, **אינו** עקיב-בפלט ([INV-RET5](#inv-ret5-כל-span-מוחזר-עקיב-למקורו)),
|
||||
> ו**אינו** נוגע ב-`case_law`/`document_chunks`. ההפרדה כאן **פיזית** (טבלה נפרדת), לא תנאי-סינון —
|
||||
> ולכן [INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query) מתקיים טריוויאלית. מלא ב-
|
||||
> [X12-digests-radar.md](X12-digests-radar.md) (INV-DIG1–DIG3).
|
||||
|
||||
---
|
||||
|
||||
## 2. עיצוב ה-hybrid retrieval
|
||||
@@ -176,3 +183,4 @@ re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף
|
||||
- [02-data-model.md](02-data-model.md) — חוזה-השלמות (searchable) + re-index שהאחזור מסנן לפיהם.
|
||||
- [05-qa-review.md](05-qa-review.md) — שער-הלכה הידני (`review_status`) שמגדיר אילו הלכות searchable.
|
||||
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור מלאה של כל span מוחזר (בסיס ל-INV-RET5).
|
||||
- [X12-digests-radar.md](X12-digests-radar.md) — שכבת-הגילוי (יומונים) שמעל הקורפוסים — מצביעה, לא מצוטטת.
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
|
||||
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
|
||||
|
||||
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X10 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
||||
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X12 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
||||
- X1–X5: מזהים · רב-חברתי · אינטגרציה+deploy · סוכנים · audit.
|
||||
- X6–X10 (מחזור-2, 8 משטחי-האפליקציה): חוזה UI↔API · לקוח-Paperclip · מילוי-שדות · חוזה כלי-MCP · deploy/env/secrets.
|
||||
- X11–X12 (הרחבות-תחום): citator פנימי (תיקוף-הלכות) · יומונים כשכבת-גילוי (radar).
|
||||
|
||||
מפות-ממצאים: [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
|
||||
|
||||
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_*`.
|
||||
@@ -58,6 +58,7 @@ from legal_mcp.tools import ( # noqa: E402
|
||||
missing_precedents as mp_tools,
|
||||
citations as cit_tools,
|
||||
training_enrichment as train_tools,
|
||||
digests as digest_tools,
|
||||
)
|
||||
|
||||
|
||||
@@ -340,6 +341,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()
|
||||
async def halacha_review(
|
||||
halacha_id: str,
|
||||
|
||||
@@ -1287,6 +1287,71 @@ 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);
|
||||
"""
|
||||
|
||||
|
||||
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(SCHEMA_SQL)
|
||||
@@ -1319,7 +1384,8 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||
await conn.execute(SCHEMA_V27_SQL)
|
||||
await conn.execute(SCHEMA_V28_SQL)
|
||||
await conn.execute(SCHEMA_V29_SQL)
|
||||
logger.info("Database schema initialized (v1-v29)")
|
||||
await conn.execute(SCHEMA_V30_SQL)
|
||||
logger.info("Database schema initialized (v1-v30)")
|
||||
|
||||
|
||||
async def init_schema() -> None:
|
||||
@@ -3494,6 +3560,311 @@ async def delete_case_law(case_law_id: UUID) -> bool:
|
||||
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(
|
||||
case_law_id: UUID, chunks: list[dict],
|
||||
) -> int:
|
||||
|
||||
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
|
||||
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)
|
||||
@@ -83,6 +83,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 אקראי. | בדיקה חד-פעמית — לא להריץ שוב |
|
||||
| `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`) |
|
||||
| `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 בלבד)
|
||||
|
||||
|
||||
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:]))
|
||||
Reference in New Issue
Block a user