Compare commits
1 Commits
0990db7a3c
...
36d10b6a70
| Author | SHA1 | Date | |
|---|---|---|---|
| 36d10b6a70 |
@@ -223,15 +223,12 @@ new → proofread → documents_ready → analyst_verified → research_complete
|
|||||||
חיים העלה PDF פסיקה לתיק → ה-citation הוא:
|
חיים העלה PDF פסיקה לתיק → ה-citation הוא:
|
||||||
├── "ערר NNNN/YY" או "בל"מ NNNN/YY"
|
├── "ערר NNNN/YY" או "בל"מ NNNN/YY"
|
||||||
│ → internal_decision_upload (חובה chair_name + district)
|
│ → internal_decision_upload (חובה chair_name + district)
|
||||||
├── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
|
└── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
|
||||||
│ → precedent_library_upload (external_upload)
|
→ precedent_library_upload (external_upload)
|
||||||
└── PDF יומון "כל יום" (סיכום-משני של עפר טויסטר, עמוד אחד)
|
|
||||||
→ digest_upload (קורפוס-גילוי; לא קורפוס-ציטוט — X12)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`internal_decision_upload`** דורש: `file_path`, `case_number`, `chair_name`, `district`. district מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
|
- **`internal_decision_upload`** דורש: `file_path`, `case_number`, `chair_name`, `district`. district מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
|
||||||
- **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה.
|
- **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה.
|
||||||
- **`digest_upload`** — ליומון "כל יום" בלבד (מקור-משני שמצביע על פסק; INV-DIG1/2). אינו מצוטט בהחלטה ואינו מחלץ הלכות. **אל** תעלה יומון דרך precedent/internal — ואל תעלה פסק-דין דרך digest.
|
|
||||||
- פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש".
|
- פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ tools:
|
|||||||
- mcp__legal-ai__precedent_list
|
- mcp__legal-ai__precedent_list
|
||||||
- mcp__legal-ai__search_case_precedents
|
- mcp__legal-ai__search_case_precedents
|
||||||
- mcp__legal-ai__search_precedent_library
|
- mcp__legal-ai__search_precedent_library
|
||||||
- mcp__legal-ai__search_digests
|
|
||||||
- mcp__legal-ai__digest_link
|
|
||||||
- mcp__legal-ai__digest_upload
|
|
||||||
- mcp__legal-ai__internal_decision_upload
|
- mcp__legal-ai__internal_decision_upload
|
||||||
- mcp__legal-ai__precedent_library_upload
|
- mcp__legal-ai__precedent_library_upload
|
||||||
- mcp__legal-ai__precedent_library_get
|
- mcp__legal-ai__precedent_library_get
|
||||||
@@ -196,26 +193,6 @@ mcp__legal-ai__internal_decision_upload(
|
|||||||
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
|
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
|
||||||
- `search_case_precedents` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
- `search_case_precedents` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
||||||
|
|
||||||
#### 2ב.0 — שכבת-גילוי: יומוני "כל יום" (`search_digests`) — מצפן, לפני האימות
|
|
||||||
|
|
||||||
לכל סוגיה מרכזית — הרץ `search_digests` כ**מצפן-מחקר (radar)**, **לא** כמקור-ציטוט. היומון הוא סיכום-משני (עפר טויסטר) של פסק-דין בודד, והוא מפנה אותך אל **הפסק המקורי**. אם נמצא יומון רלוונטי:
|
|
||||||
|
|
||||||
1. קרא את כותרת-ההלכה ואת ניתוח עפר-טויסטר **כרקע/orientation בלבד**.
|
|
||||||
2. חלץ את **מראה-המקום של הפסק המקורי** מהיומון (שדה `underlying_citation`, למשל `עת"מ 46111-12-22`).
|
|
||||||
3. **בדוק אם הפסק המקורי בקורפוס** — `search_precedent_library` **וגם** `search_internal_decisions` לפי פרוטוקול 2ב.4א (לפי קידומת-הציטוט; flowchart §8).
|
|
||||||
4. **אם נמצא** → אמת וצטט את הפסק המקורי כרגיל (`precedent_attach`), וקרא `digest_link(digest_id, case_law_id)` כדי לקשר את היומון לפסק.
|
|
||||||
5. **אם לא נמצא** → קרא `missing_precedent_create` על **הפסק המקורי** (לא על היומון), עם `notes="זוהה דרך יומון 'כל יום' מס' NNNN"`. היומון הוא הטריגר; הרשומה החסרה היא הפסק. (אם הפסק זמין — אפשר להעלותו דרך `precedent_library_upload`/`internal_decision_upload` ואז `digest_link`.)
|
|
||||||
|
|
||||||
⚠️ **היומון לעולם אינו מצוטט בהחלטה ואינו נרשם דרך `precedent_attach`** (INV-DIG1). הוא radar בלבד — מצביע, לא מקור. ראה [docs/spec/X12-digests-radar.md](../../docs/spec/X12-digests-radar.md).
|
|
||||||
|
|
||||||
```
|
|
||||||
search_digests(
|
|
||||||
query="...",
|
|
||||||
practice_area="betterment_levy", # rishuy_uvniya / betterment_levy / compensation_197
|
|
||||||
limit=10
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
||||||
|
|
||||||
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
|
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
|
||||||
@@ -333,10 +310,6 @@ mcp__legal-ai__missing_precedent_create(
|
|||||||
|
|
||||||
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
|
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
|
||||||
|
|
||||||
#### 2ב.6 — תיעוד סריקת היומונים — סעיף "ט" ב-`precedent-research.md`
|
|
||||||
|
|
||||||
הוסף סעיף נפרד `## ט. סריקת יומונים (radar — לא ציטוט)` שמתעד אילו יומונים נסרקו לכל סוגיה, אילו פסקי-דין מקוריים הם הצביעו עליהם, וסטטוס כל אחד: *בקורפוס (קושר) / נרשם כחסר / לא רלוונטי*. ציין מפורש: **רשומות אלה אינן ציטוטים** — הן עקבות-מחקר (radar). ה-writer וה-QA מתעלמים מהן כמקור-סמכות (INV-DIG1); הציטוט בהחלטה תמיד נשען על הפסק המקורי שבסעיפים ז/ח.
|
|
||||||
|
|
||||||
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
||||||
|
|
||||||
### שלב 3: מיפוי תכנית
|
### שלב 3: מיפוי תכנית
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוט
|
|||||||
|
|
||||||
## 7. אינדקס הספ
|
## 7. אינדקס הספ
|
||||||
|
|
||||||
> הערה: כל קבצי הספ (00, 01–07, X1–X12) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
> הערה: כל קבצי הספ (00, 01–07, X1–X10) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
||||||
|
|
||||||
| קובץ | תפקיד | אוכף invariants |
|
| קובץ | תפקיד | אוכף invariants |
|
||||||
|------|--------|-----------------|
|
|------|--------|-----------------|
|
||||||
@@ -250,7 +250,6 @@ 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 |
|
| [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, מילוי-שדות,
|
||||||
|
|||||||
@@ -35,13 +35,6 @@
|
|||||||
(`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**,
|
(`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**,
|
||||||
ושם נולדת ההפרה ב-§5.
|
ושם נולדת ההפרה ב-§5.
|
||||||
|
|
||||||
> **שכבת-גילוי — יומונים, לא קורפוס-ציטוט.** מעל 3 הקורפוסים יושבת שכבת-radar נפרדת: **יומונים**
|
|
||||||
> (סיכומי עפר-טויסטר), בטבלה פיזית נפרדת `digests` עם כלי `search_digests`. היומון הוא **מקור משני
|
|
||||||
> המצביע** על הפסק המקורי — **אינו** קורפוס-ציטוט רביעי, **אינו** עקיב-בפלט ([INV-RET5](#inv-ret5-כל-span-מוחזר-עקיב-למקורו)),
|
|
||||||
> ו**אינו** נוגע ב-`case_law`/`document_chunks`. ההפרדה כאן **פיזית** (טבלה נפרדת), לא תנאי-סינון —
|
|
||||||
> ולכן [INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query) מתקיים טריוויאלית. מלא ב-
|
|
||||||
> [X12-digests-radar.md](X12-digests-radar.md) (INV-DIG1–DIG3).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. עיצוב ה-hybrid retrieval
|
## 2. עיצוב ה-hybrid retrieval
|
||||||
@@ -183,4 +176,3 @@ re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף
|
|||||||
- [02-data-model.md](02-data-model.md) — חוזה-השלמות (searchable) + re-index שהאחזור מסנן לפיהם.
|
- [02-data-model.md](02-data-model.md) — חוזה-השלמות (searchable) + re-index שהאחזור מסנן לפיהם.
|
||||||
- [05-qa-review.md](05-qa-review.md) — שער-הלכה הידני (`review_status`) שמגדיר אילו הלכות searchable.
|
- [05-qa-review.md](05-qa-review.md) — שער-הלכה הידני (`review_status`) שמגדיר אילו הלכות searchable.
|
||||||
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור מלאה של כל span מוחזר (בסיס ל-INV-RET5).
|
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור מלאה של כל span מוחזר (בסיס ל-INV-RET5).
|
||||||
- [X12-digests-radar.md](X12-digests-radar.md) — שכבת-הגילוי (יומונים) שמעל הקורפוסים — מצביעה, לא מצוטטת.
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X13 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
מבנה: 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) · אחזור-פסיקה אוטומטי מנט המשפט (שירות).
|
- X11 · X13: citator תיקוף-הלכות · אחזור-פסיקה אוטומטי מנט המשפט (שירות).
|
||||||
|
|
||||||
מפות-ממצאים: [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
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
# X12 — יומונים כשכבת-גילוי (Digests Radar)
|
|
||||||
|
|
||||||
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md). הוא מגדיר **שכבת-גילוי (discovery/radar)**
|
|
||||||
מעל קורפוסי-הפסיקה: קליטה וחיפוש של **יומונים** — סיכומי-עמוד-אחד של משרד עפר טויסטר ("כל יום —
|
|
||||||
היומון לענייני תכנון ובנייה") על פסק-דין/החלטה בודדים. היומון הוא **מקור משני** המצביע על פסק-הדין
|
|
||||||
המקורי; הוא **אינו** נכנס לאף אחד מ-3 קורפוסי-הציטוט, **אינו** מצוטט בהחלטה, ו**אינו** מחלץ הלכות.
|
|
||||||
הוא נשען על [INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
|
||||||
(אין מסלול מקביל), [INV-G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)
|
|
||||||
(שלמות + אין בליעה שקטה) ו-[INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
|
|
||||||
(עקיבוּת-מקור), ומובחן מ-3 הקורפוסים של [03-retrieval.md](03-retrieval.md).
|
|
||||||
|
|
||||||
> **TARGET, לא תיאור-מצב.** התת-מערכת כולה היא יעד — אין כיום טבלת `digests`, כלי-`digest_*`,
|
|
||||||
> ולא אינטגרציית-חוקר. כל רכיב מסומן מפורשות כ-audit-finding לבנייה (§6). כל טענה על הקוד `file:line`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. הרעיון — radar, לא קורפוס-ציטוט
|
|
||||||
|
|
||||||
חיים מקבל כמעט יומית מייל עם **יומון**: PDF של עמוד אחד שמסכם פסק-דין/החלטה בודדים בתחום
|
|
||||||
רישוי-ובנייה / היטל-השבחה / פיצויים(ס'197). היומון אינו הטקסט המשפטי המקורי — הוא **ניתוח של צד
|
|
||||||
שלישי** (עפר טויסטר), הנושא הבהרה מודפסת: *"האמור הוא מידע ראשוני בלבד ואין הוא תחליף לייעוץ
|
|
||||||
משפטי"*. במונחי-מחקר-משפטי זהו **מקור משני (secondary authority)**: כלי-איתור והכוונה, לא סמכות
|
|
||||||
שמצטטים בהחלטה.
|
|
||||||
|
|
||||||
הערך שלו עצום דווקא כ-**radar**: כל יומון הוא *headnote + תג-נושא כתובים-מראש בידי מומחה*, המצביע
|
|
||||||
על פסק-דין מקורי. כשמנסחים החלטה, `search_digests` מחזיר את היומון הרלוונטי → החוקר קורא את ניתוח
|
|
||||||
טויסטר **כרקע** → מחלץ את מראה-המקום של פסק-הדין המקורי → מביא את הפסק עצמו לקורפוס-הפסיקה הקיים
|
|
||||||
(הזמינות גבוהה) → ומצטט **משם**. היומון מצביע; הציטוט תמיד נשען על המקור.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. מה היומון מכיל
|
|
||||||
|
|
||||||
מבנה קבוע (אומת מול הקבצים ב-`data/precedents/incoming/`, יומון 5158/5159/5160/5163):
|
|
||||||
|
|
||||||
| רכיב | דוגמה | תפקיד |
|
|
||||||
|------|-------|-------|
|
|
||||||
| מספר-יומון + תאריך-גיליון | `יומון מס' 5163 7 ביוני 2026` | מפתח-upsert + `digest_date` |
|
|
||||||
| תג-מושג | `"שיקול הדעת המצומצם"` | ציר-נושא לחיפוש |
|
|
||||||
| כותרת-הלכה | `ביהמ"ש - שיקול דעת הוועדה המחוזית אינו מצומצם...` | הסיכום בשורה |
|
|
||||||
| גוף-ניתוח (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,7 +58,6 @@ from legal_mcp.tools import ( # noqa: E402
|
|||||||
missing_precedents as mp_tools,
|
missing_precedents as mp_tools,
|
||||||
citations as cit_tools,
|
citations as cit_tools,
|
||||||
training_enrichment as train_tools,
|
training_enrichment as train_tools,
|
||||||
digests as digest_tools,
|
|
||||||
court_fetch as cf_tools,
|
court_fetch as cf_tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -342,75 +341,6 @@ async def search_precedent_library(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Digests radar (X12) — secondary discovery layer; NOT a citation corpus.
|
|
||||||
@mcp.tool()
|
|
||||||
async def digest_upload(
|
|
||||||
file_path: str,
|
|
||||||
yomon_number: str = "",
|
|
||||||
digest_date: str = "",
|
|
||||||
practice_area: str = "",
|
|
||||||
appeal_subtype: str = "",
|
|
||||||
subject_tags: list[str] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""העלאת יומון ("כל יום") לקורפוס-הגילוי (radar) + חילוץ מטא-דאטה אוטומטי. היומון הוא מקור-משני המצביע על הפסק המקורי — אינו מצוטט בהחלטה ואינו מחלץ הלכות (INV-DIG1/2). practice_area: rishuy_uvniya / betterment_levy / compensation_197."""
|
|
||||||
return await digest_tools.digest_upload(
|
|
||||||
file_path, yomon_number, digest_date, practice_area,
|
|
||||||
appeal_subtype, subject_tags,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def digest_list(
|
|
||||||
practice_area: str = "",
|
|
||||||
concept_tag: str = "",
|
|
||||||
linked: bool | None = None,
|
|
||||||
search: str = "",
|
|
||||||
limit: int = 100,
|
|
||||||
) -> str:
|
|
||||||
"""רשימת יומונים בקורפוס-הגילוי, עם פילטרים. linked=false → יומונים שהפסק המקורי שלהם עוד לא נקלט לספריית הפסיקה (פער-ידע גלוי, INV-DIG3)."""
|
|
||||||
return await digest_tools.digest_list(
|
|
||||||
practice_area, concept_tag, linked, search, _clamp_limit(limit),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def digest_get(digest_id: str) -> str:
|
|
||||||
"""יומון ספציפי לפי מזהה (כולל מראה-מקום, ניתוח, וקישור לפסק המקורי אם קיים)."""
|
|
||||||
return await digest_tools.digest_get(digest_id)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def digest_link(digest_id: str, case_law_id: str) -> str:
|
|
||||||
"""קישור ידני של יומון לפסק הדין המקורי בספריית הפסיקה (INV-DIG3). idempotent."""
|
|
||||||
return await digest_tools.digest_link(digest_id, case_law_id)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def digest_relink(digest_id: str) -> str:
|
|
||||||
"""ניסיון-קישור מחדש: בודק אם פסק הדין המקורי של היומון כבר בספרייה ומקשר אוטומטית."""
|
|
||||||
return await digest_tools.digest_relink(digest_id)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def digest_delete(digest_id: str) -> str:
|
|
||||||
"""מחיקת יומון מקורפוס-הגילוי."""
|
|
||||||
return await digest_tools.digest_delete(digest_id)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def search_digests(
|
|
||||||
query: str,
|
|
||||||
practice_area: str = "",
|
|
||||||
subject_tag: str = "",
|
|
||||||
concept_tag: str = "",
|
|
||||||
limit: int = 10,
|
|
||||||
) -> str:
|
|
||||||
"""חיפוש סמנטי בקורפוס-הגילוי (יומוני "כל יום") — מצפן-מחקר (radar). מחזיר את היומון הרלוונטי + מראה-המקום של הפסק המקורי. ⚠️ היומון אינו מצוטט בהחלטה (INV-DIG1) — הצטט מהפסק המקורי דרך search_precedent_library. החוקר משתמש בזה בשלב 2ב.0 לפני האימות."""
|
|
||||||
return await digest_tools.search_digests(
|
|
||||||
query, practice_area, subject_tag, concept_tag, _clamp_limit(limit),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def halacha_review(
|
async def halacha_review(
|
||||||
halacha_id: str,
|
halacha_id: str,
|
||||||
|
|||||||
@@ -1287,71 +1287,6 @@ ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_generated_at TIMESTAMPTZ
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
SCHEMA_V30_SQL = """
|
|
||||||
-- digests (X12): Ofer Toister daily "כל יום" one-pagers. A SECONDARY,
|
|
||||||
-- discovery-layer ("radar") source — NOT authoritative law. Kept in its OWN
|
|
||||||
-- table (never case_law) so it cannot pollute the precedent corpus, never
|
|
||||||
-- enters the halacha pipeline (INV-DIG2), and is never cited directly in a
|
|
||||||
-- decision (INV-DIG1). Its only job is to point the researcher at the
|
|
||||||
-- UNDERLYING ruling, which is ingested separately into case_law and cited from
|
|
||||||
-- there. linked_case_law_id is the bridge (INV-DIG3): filled once the
|
|
||||||
-- underlying ruling is in the library; NULL = an open knowledge gap.
|
|
||||||
CREATE TABLE IF NOT EXISTS digests (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
yomon_number TEXT NOT NULL DEFAULT '', -- "5163"
|
|
||||||
digest_date DATE, -- date of the yomon ISSUE
|
|
||||||
publication TEXT NOT NULL DEFAULT 'כל יום',
|
|
||||||
source_firm TEXT NOT NULL DEFAULT 'עפר טויסטר, עורכי דין',
|
|
||||||
concept_tag TEXT NOT NULL DEFAULT '', -- "שיקול הדעת המצומצם"
|
|
||||||
headline_holding TEXT NOT NULL DEFAULT '', -- bold subtitle = the holding
|
|
||||||
analysis_text TEXT NOT NULL DEFAULT '', -- the 1-2 page body (raw text)
|
|
||||||
summary TEXT NOT NULL DEFAULT '', -- 2-3 sentence LLM summary
|
|
||||||
underlying_citation TEXT NOT NULL DEFAULT '', -- 'עת"מ 46111-12-22 יכין-אפק...'
|
|
||||||
underlying_court TEXT NOT NULL DEFAULT '',
|
|
||||||
underlying_date DATE, -- date the RULING was given (≠ digest_date)
|
|
||||||
underlying_judge TEXT NOT NULL DEFAULT '',
|
|
||||||
practice_area TEXT NOT NULL DEFAULT '', -- rishuy_uvniya/betterment_levy/compensation_197
|
|
||||||
appeal_subtype TEXT NOT NULL DEFAULT '',
|
|
||||||
subject_tags TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
linked_case_law_id UUID REFERENCES case_law(id) ON DELETE SET NULL,
|
|
||||||
embedding vector(1024), -- single vector of concept+headline+summary+analysis
|
|
||||||
source_document_path TEXT NOT NULL DEFAULT '', -- staged PDF path (rel to DATA_DIR)
|
|
||||||
content_hash TEXT NOT NULL DEFAULT '', -- sha256 of extracted text — idempotent upload
|
|
||||||
extraction_status TEXT NOT NULL DEFAULT 'pending', -- pending/processing/completed/failed
|
|
||||||
content_tsv tsvector GENERATED ALWAYS AS (
|
|
||||||
to_tsvector('simple',
|
|
||||||
coalesce(concept_tag,'') || ' ' || coalesce(headline_holding,'') || ' ' ||
|
|
||||||
coalesce(summary,'') || ' ' || coalesce(analysis_text,''))
|
|
||||||
) STORED,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Idempotent re-upload (INV-G3): same yomon number = same digest. yomon_number
|
|
||||||
-- can be '' transiently (before extraction), so the unique index is partial.
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_digests_yomon_number
|
|
||||||
ON digests(yomon_number) WHERE yomon_number <> '';
|
|
||||||
-- Secondary dedup key when yomon_number couldn't be parsed.
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_digests_content_hash
|
|
||||||
ON digests(content_hash) WHERE content_hash <> '';
|
|
||||||
|
|
||||||
-- HNSW (not ivfflat): the digests radar is a small, slowly-growing corpus
|
|
||||||
-- (~1/day). ivfflat trains `lists` centroids and probes a subset at query time,
|
|
||||||
-- so on a small table a single probe can hit an empty list and return 0 rows
|
|
||||||
-- (recall cliff). HNSW has no list-training/probe step — correct recall from
|
|
||||||
-- the first row — so it is the right index for a corpus that starts ~empty.
|
|
||||||
DROP INDEX IF EXISTS idx_digests_embedding; -- drop any pre-existing ivfflat
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_digests_embedding_hnsw
|
|
||||||
ON digests USING hnsw (embedding vector_cosine_ops);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_digests_linked ON digests(linked_case_law_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_digests_practice_area ON digests(practice_area);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_digests_concept_tag ON digests(concept_tag);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_digests_subject_tags ON digests USING gin(subject_tags);
|
|
||||||
-- Lexical half of a future hybrid (Phase-1 search is semantic-only; index is ready).
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_digests_content_tsv ON digests USING gin(content_tsv);
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
# ── X13 — Court Verdict Fetch queue ──────────────────────────────────────
|
# ── X13 — Court Verdict Fetch queue ──────────────────────────────────────
|
||||||
# A lightweight, observable, idempotent job queue for the auto-fetch
|
# A lightweight, observable, idempotent job queue for the auto-fetch
|
||||||
# subsystem (docs/spec/X13-court-fetch.md). One row per court verdict we try
|
# subsystem (docs/spec/X13-court-fetch.md). One row per court verdict we try
|
||||||
@@ -1359,8 +1294,11 @@ CREATE INDEX IF NOT EXISTS idx_digests_content_tsv ON digests USING gin(content_
|
|||||||
# is always explicit (INV-CF2 — no silent drop), the canonical case number is
|
# is always explicit (INV-CF2 — no silent drop), the canonical case number is
|
||||||
# the idempotency key (INV-CF5), and ``attempts`` drives the human-fallback
|
# the idempotency key (INV-CF5), and ``attempts`` drives the human-fallback
|
||||||
# gate (INV-CF3 — flip to 'manual' after N autonomous failures).
|
# gate (INV-CF3 — flip to 'manual' after N autonomous failures).
|
||||||
# V31 — digests (X12) took V30 when it merged first.
|
#
|
||||||
SCHEMA_V31_SQL = """
|
# NOTE (merge): main is at V29; the digests-radar worktree adds its own V30.
|
||||||
|
# If digests-radar lands first, renumber this block to V31 and update the
|
||||||
|
# apply loop. Kept as V30 here so the branch is self-consistent on main.
|
||||||
|
SCHEMA_V30_SQL = """
|
||||||
CREATE TABLE IF NOT EXISTS court_fetch_jobs (
|
CREATE TABLE IF NOT EXISTS court_fetch_jobs (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
case_number_norm TEXT NOT NULL UNIQUE, -- idempotency key (INV-CF5)
|
case_number_norm TEXT NOT NULL UNIQUE, -- idempotency key (INV-CF5)
|
||||||
@@ -1415,8 +1353,7 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
|||||||
await conn.execute(SCHEMA_V28_SQL)
|
await conn.execute(SCHEMA_V28_SQL)
|
||||||
await conn.execute(SCHEMA_V29_SQL)
|
await conn.execute(SCHEMA_V29_SQL)
|
||||||
await conn.execute(SCHEMA_V30_SQL)
|
await conn.execute(SCHEMA_V30_SQL)
|
||||||
await conn.execute(SCHEMA_V31_SQL)
|
logger.info("Database schema initialized (v1-v30)")
|
||||||
logger.info("Database schema initialized (v1-v31)")
|
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -3591,311 +3528,6 @@ async def delete_case_law(case_law_id: UUID) -> bool:
|
|||||||
return result == "DELETE 1"
|
return result == "DELETE 1"
|
||||||
|
|
||||||
|
|
||||||
# ── Digests (X12 — radar layer; separate table, INV-DIG1/2/3) ────────
|
|
||||||
|
|
||||||
_DIGEST_COLS = (
|
|
||||||
"id, yomon_number, digest_date, publication, source_firm, concept_tag, "
|
|
||||||
"headline_holding, analysis_text, summary, underlying_citation, "
|
|
||||||
"underlying_court, underlying_date, underlying_judge, practice_area, "
|
|
||||||
"appeal_subtype, subject_tags, linked_case_law_id, source_document_path, "
|
|
||||||
"content_hash, extraction_status, 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:
|
||||||
|
|||||||
@@ -1,268 +0,0 @@
|
|||||||
"""Orchestrator for the Digests radar (X12).
|
|
||||||
|
|
||||||
A digest ("כל יום" daily one-pager) is a SECONDARY source that POINTS at a
|
|
||||||
ruling — it is never cited in a decision (INV-DIG1) and never enters the
|
|
||||||
precedent/halacha pipeline (INV-DIG2). Ingest 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)
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"""Auto-extract catalog metadata from a "כל יום" daily digest (X12).
|
|
||||||
|
|
||||||
A digest is a one-page secondary summary (Ofer Toister) of a single ruling.
|
|
||||||
This module reads its raw text and asks the local Claude CLI to extract the
|
|
||||||
fields the radar needs: yomon number, concept tag, headline holding, a short
|
|
||||||
summary, the UNDERLYING ruling's citation (the critical bridge field — INV-DIG3),
|
|
||||||
its court / date / judge, practice area and subject tags.
|
|
||||||
|
|
||||||
claude_session rule: this module imports ``claude_session`` (the local CLI),
|
|
||||||
so it is **MCP-tool-only** — never import it from the FastAPI container. It is
|
|
||||||
pulled in lazily inside ``digest_library.ingest_digest`` only.
|
|
||||||
|
|
||||||
Unlike ``precedent_metadata_extractor`` (which patches a DB row), this returns
|
|
||||||
a plain dict from raw text; ``digest_library`` decides how to merge/store it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import date as date_type
|
|
||||||
|
|
||||||
from legal_mcp.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
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
"""MCP tools for the Digests radar (X12).
|
|
||||||
|
|
||||||
A digest ("כל יום" daily one-pager, Ofer Toister) is a SECONDARY, discovery-
|
|
||||||
layer source that POINTS at a ruling. It is distinct from the three citation
|
|
||||||
corpora:
|
|
||||||
|
|
||||||
- ``search_precedent_library`` — authoritative external court rulings.
|
|
||||||
- ``search_internal_decisions`` — appeals-committee decisions.
|
|
||||||
- ``search_decisions`` — Dafna's prior decisions (style corpus).
|
|
||||||
|
|
||||||
A digest is NEVER cited in a decision (INV-DIG1) and NEVER enters the halacha
|
|
||||||
pipeline (INV-DIG2). ``search_digests`` is a research compass: it surfaces the
|
|
||||||
relevant digest + the UNDERLYING ruling's citation, which is then ingested into
|
|
||||||
the precedent library and cited from there.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from legal_mcp.services import db, digest_library, telemetry
|
|
||||||
from legal_mcp.tools.envelope import empty, err as _err, ok as _ok
|
|
||||||
|
|
||||||
|
|
||||||
async def digest_upload(
|
|
||||||
file_path: str,
|
|
||||||
yomon_number: str = "",
|
|
||||||
digest_date: str = "",
|
|
||||||
practice_area: str = "",
|
|
||||||
appeal_subtype: str = "",
|
|
||||||
subject_tags: list[str] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""העלאת יומון ("כל יום") לקורפוס-הגילוי + חילוץ מטא-דאטה אוטומטי.
|
|
||||||
|
|
||||||
היומון הוא מקור-משני המצביע על פסק הדין המקורי — אינו מצוטט בהחלטה.
|
|
||||||
Args:
|
|
||||||
file_path: נתיב מלא לקובץ PDF/DOCX של היומון.
|
|
||||||
yomon_number: מספר היומון (אופציונלי — יחולץ מהטקסט אם ריק).
|
|
||||||
digest_date: ISO date של גיליון היומון (אופציונלי).
|
|
||||||
practice_area: rishuy_uvniya / betterment_levy / compensation_197.
|
|
||||||
subject_tags: תגיות נושא (אופציונלי — יחולצו אם ריק).
|
|
||||||
Returns: JSON עם digest_id, מספר היומון, מראה-המקום, וקישור-אוטומטי אם נמצא.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = await digest_library.ingest_digest(
|
|
||||||
file_path=file_path,
|
|
||||||
yomon_number=yomon_number,
|
|
||||||
digest_date=digest_date or None,
|
|
||||||
practice_area=practice_area,
|
|
||||||
appeal_subtype=appeal_subtype,
|
|
||||||
subject_tags=subject_tags or None,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return _err(str(e))
|
|
||||||
return _ok(result)
|
|
||||||
|
|
||||||
|
|
||||||
async def digest_list(
|
|
||||||
practice_area: str = "",
|
|
||||||
concept_tag: str = "",
|
|
||||||
linked: bool | None = None,
|
|
||||||
search: str = "",
|
|
||||||
limit: int = 100,
|
|
||||||
) -> str:
|
|
||||||
"""רשימת יומונים בקורפוס-הגילוי, עם פילטרים. linked=false → יומונים שהפסק
|
|
||||||
המקורי שלהם עוד לא נקלט לספריית הפסיקה (פער-ידע גלוי)."""
|
|
||||||
rows = await digest_library.list_digests(
|
|
||||||
practice_area=practice_area,
|
|
||||||
concept_tag=concept_tag,
|
|
||||||
linked=linked,
|
|
||||||
search=search,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
return _ok(rows)
|
|
||||||
|
|
||||||
|
|
||||||
async def digest_get(digest_id: str) -> str:
|
|
||||||
"""יומון ספציפי לפי מזהה."""
|
|
||||||
try:
|
|
||||||
cid = UUID(digest_id)
|
|
||||||
except ValueError:
|
|
||||||
return _err("digest_id לא תקין")
|
|
||||||
record = await digest_library.get_digest(cid)
|
|
||||||
if not record:
|
|
||||||
return _err("יומון לא נמצא")
|
|
||||||
return _ok(record)
|
|
||||||
|
|
||||||
|
|
||||||
async def digest_link(digest_id: str, case_law_id: str) -> str:
|
|
||||||
"""קישור ידני של יומון לפסק הדין המקורי בספריית הפסיקה (INV-DIG3)."""
|
|
||||||
try:
|
|
||||||
UUID(digest_id)
|
|
||||||
UUID(case_law_id)
|
|
||||||
except ValueError:
|
|
||||||
return _err("מזהה לא תקין")
|
|
||||||
try:
|
|
||||||
result = await digest_library.link_digest(digest_id, case_law_id)
|
|
||||||
except Exception as e:
|
|
||||||
return _err(str(e))
|
|
||||||
return _ok(result)
|
|
||||||
|
|
||||||
|
|
||||||
async def digest_relink(digest_id: str) -> str:
|
|
||||||
"""ניסיון-קישור מחדש: בודק אם פסק הדין המקורי של היומון כבר בספרייה ומקשר."""
|
|
||||||
try:
|
|
||||||
UUID(digest_id)
|
|
||||||
except ValueError:
|
|
||||||
return _err("digest_id לא תקין")
|
|
||||||
try:
|
|
||||||
result = await digest_library.relink_digest(digest_id)
|
|
||||||
except Exception as e:
|
|
||||||
return _err(str(e))
|
|
||||||
return _ok(result)
|
|
||||||
|
|
||||||
|
|
||||||
async def digest_delete(digest_id: str) -> str:
|
|
||||||
"""מחיקת יומון מקורפוס-הגילוי."""
|
|
||||||
try:
|
|
||||||
cid = UUID(digest_id)
|
|
||||||
except ValueError:
|
|
||||||
return _err("digest_id לא תקין")
|
|
||||||
ok_ = await digest_library.delete_digest(cid)
|
|
||||||
if not ok_:
|
|
||||||
return _err("יומון לא נמצא")
|
|
||||||
return _ok({"deleted": True, "digest_id": digest_id})
|
|
||||||
|
|
||||||
|
|
||||||
async def search_digests(
|
|
||||||
query: str,
|
|
||||||
practice_area: str = "",
|
|
||||||
subject_tag: str = "",
|
|
||||||
concept_tag: str = "",
|
|
||||||
limit: int = 10,
|
|
||||||
) -> str:
|
|
||||||
"""חיפוש סמנטי בקורפוס-הגילוי (יומוני "כל יום"). מצפן-מחקר בלבד — מחזיר את
|
|
||||||
היומון הרלוונטי + מראה-המקום של הפסק המקורי (radar). היומון אינו מצוטט
|
|
||||||
בהחלטה (INV-DIG1); הצטט מהפסק המקורי דרך search_precedent_library."""
|
|
||||||
if not query or len(query.strip()) < 2:
|
|
||||||
return empty("שאילתה קצרה מדי (פחות מ-2 תווים).")
|
|
||||||
q = query.strip()
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
results = await digest_library.search_digests(
|
|
||||||
query=q,
|
|
||||||
practice_area=practice_area,
|
|
||||||
subject_tag=subject_tag,
|
|
||||||
concept_tag=concept_tag,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
|
||||||
telemetry.log_search_bg(
|
|
||||||
search_type="digests",
|
|
||||||
query=q,
|
|
||||||
results=results,
|
|
||||||
duration_ms=elapsed_ms,
|
|
||||||
practice_area=practice_area or None,
|
|
||||||
user_agent="unknown",
|
|
||||||
)
|
|
||||||
if not results:
|
|
||||||
return empty("לא נמצאו יומונים תואמים.")
|
|
||||||
return _ok(results)
|
|
||||||
@@ -84,7 +84,6 @@
|
|||||||
| `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 בלבד)
|
||||||
|
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
"""Batch ingest of "כל יום" daily digests staged in data/digests/incoming/ (X12).
|
|
||||||
|
|
||||||
Sequential (NOT concurrent — same load-spike caution as ingest_incoming_batch.py)
|
|
||||||
ingest of each yomon PDF via the standalone digest pipeline
|
|
||||||
(``digest_library.ingest_digest``), which:
|
|
||||||
- extracts text, dedups on content_hash (idempotent),
|
|
||||||
- runs the local LLM metadata extractor (concept_tag, headline, underlying
|
|
||||||
citation, two dates, practice_area, subject_tags),
|
|
||||||
- stores a single embedding,
|
|
||||||
- auto-links to the underlying ruling if it is already in the precedent
|
|
||||||
library (INV-DIG3).
|
|
||||||
|
|
||||||
The digest is a SECONDARY, radar-only source — it never enters the precedent /
|
|
||||||
halacha pipeline and is never cited in a decision (INV-DIG1/2). After this run,
|
|
||||||
relink unmatched digests once the originals are uploaded, or surface them via
|
|
||||||
missing_precedent_create.
|
|
||||||
|
|
||||||
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