Compare commits
270 Commits
fix/fu4-co
...
worktree-s
| Author | SHA1 | Date | |
|---|---|---|---|
| e4651a9d06 | |||
| a571ad535b | |||
| afc1548bca | |||
| e096c51037 | |||
| 85c5a4aacb | |||
| 420cb819f5 | |||
| 32ef259843 | |||
| 1286a1e60d | |||
| 366d89e6bb | |||
| fb51a0e869 | |||
| 12bdec10fa | |||
| 8ec24cf822 | |||
| 3b9f77daa8 | |||
| 5fa76a09b4 | |||
| 32a6e2b57b | |||
| 3c68383e86 | |||
| 37c00bac13 | |||
| f20a3a09fd | |||
| 6313fcd316 | |||
| ee76455a9a | |||
| 7b1c0c1a32 | |||
| e4fbda6c1f | |||
| 3b3e1e3bbf | |||
| 37dcb30604 | |||
| dc0936adf9 | |||
| 0059c326f1 | |||
| a2236363d4 | |||
| b515f3453e | |||
| 14568fdd15 | |||
| 172511339f | |||
| ad4350029a | |||
| 424dc7cd18 | |||
| 2e20e27e17 | |||
| ea84a602e6 | |||
| 29af008271 | |||
| 0a514cc276 | |||
| cde7f94628 | |||
| 9a3e7faf08 | |||
| 79b9c37301 | |||
| dd46ffb3e3 | |||
| a3451775fa | |||
| caeaf51db4 | |||
| 9a6d690e0e | |||
| a3ef9e5e34 | |||
| 7a2865339c | |||
| 0d995483ce | |||
| 24f9ceb164 | |||
| c482414819 | |||
| 014eb4937e | |||
| b9bdca0572 | |||
| f17e0e382a | |||
| aa0a736a7b | |||
| c52b5986a3 | |||
| 6bf19bd0d7 | |||
| b97e8d595d | |||
| 8a3bcd3ffc | |||
| 11f20072ea | |||
| d37274a31b | |||
| 9c77123fa3 | |||
| 770d23b198 | |||
| 1565a636a8 | |||
| 40c1111e9b | |||
| 4977ab8d9a | |||
| 701efab726 | |||
| d3f1d04915 | |||
| ea8b48c6ac | |||
| 0d0f5aa8e9 | |||
| 034b609bd3 | |||
| b53d65c1f6 | |||
| ebfe7f6a1d | |||
| 67a3d9a9b0 | |||
| 482f302d54 | |||
| 27b40dfec5 | |||
| 1f1a025509 | |||
| fdeed8a045 | |||
| 7f4e036211 | |||
| 35c15720a5 | |||
| 4174217179 | |||
| dd0e754dad | |||
| e3e3da09e5 | |||
| 59ff4e31cf | |||
| 68a77c11b6 | |||
| c83d0162ca | |||
| f5926506fe | |||
| df97e21d22 | |||
| c35e0e50ed | |||
| 6dd125c491 | |||
| f8c3fd6c89 | |||
| d47a633fcf | |||
| fb60dca796 | |||
| 5efb8cf915 | |||
| f196bed564 | |||
| e25507f9ad | |||
| 476c2fc5d1 | |||
| db6bad5d1e | |||
| eeb70a5758 | |||
| 7ebddcce6d | |||
| 0f64b4c062 | |||
| 8e3d14abee | |||
| ca959d4a9c | |||
| b0ec24a9d5 | |||
| f5d14fd6b8 | |||
| bbe3db7b94 | |||
| 7d0d4a9b27 | |||
| 61dde4cd83 | |||
| 2a9168a1b4 | |||
| 5a00a0ef47 | |||
| 4debe9995b | |||
| bb42aeeff4 | |||
| 6fcfdc76db | |||
| 0a88bed58b | |||
| d4046c2fbd | |||
| f74fa13146 | |||
| 434341cc29 | |||
| c7c6f3eb9c | |||
| 76fae77393 | |||
| 901ec9f869 | |||
| 7be1c3162c | |||
| 9295e74762 | |||
| fc0c36b2f8 | |||
| 2d7ab26c71 | |||
| 1d3e235556 | |||
| 7471dcf3cc | |||
| d790fb26e0 | |||
| 7e34c53224 | |||
| 77ed0361b7 | |||
| 5d63a903ce | |||
| aeddcb41eb | |||
| 1aadd3b455 | |||
| f66a2a27e7 | |||
| f46bf47d5b | |||
| 9f2adc4dd0 | |||
| e79f74bc23 | |||
| 3bd2d16652 | |||
| b4d1fc5539 | |||
| ed547e20ad | |||
| df007784c9 | |||
| 391b025e8a | |||
| 885cba543e | |||
| acfd5bae3e | |||
| 8e4ea23882 | |||
| 6183e24316 | |||
| 807053ec54 | |||
| 62e5e5183d | |||
| 1b62fa4af8 | |||
| e712573766 | |||
| 6ed5c9e99f | |||
| a9472187ff | |||
| 5abfbd2746 | |||
| b57e590275 | |||
| 33f955e372 | |||
| dbc176ae66 | |||
| 09eec6a906 | |||
| ca31932a5f | |||
| beba24dfc5 | |||
| ae8efc0b63 | |||
| 887079535c | |||
| d83a2a2fb2 | |||
| 37c56ff22a | |||
| c70a03f91e | |||
| 1cc7c0e757 | |||
| ae7d475103 | |||
| a02a606f34 | |||
| ff5187c9c1 | |||
| 7161c3d010 | |||
| eef04b0f09 | |||
| 411ee18786 | |||
| 83d6b5ecf0 | |||
| c231782ee8 | |||
| dfa2f5bd7f | |||
| 19d3dc81d0 | |||
| aee2140b0b | |||
| 6ff2e36bf9 | |||
| cfcac80de2 | |||
| 4fce9d503f | |||
| 9dbc1bafbf | |||
| e5b34e01dc | |||
| 4d8422198a | |||
| a66ab3b3cd | |||
| aac383acb7 | |||
| adc196ac20 | |||
| e8431a2adf | |||
| 43873adc90 | |||
| 8477fd87e7 | |||
| e46868feda | |||
| ab8d17fdd8 | |||
| a41fcedc28 | |||
| c2de69272d | |||
| 105d9626ca | |||
| fc502a6441 | |||
| 7e35a24d80 | |||
| 7341ee8275 | |||
| 8a0c206ecd | |||
| f008820ec8 | |||
| 63abf83e76 | |||
| c8de42150e | |||
| c7c7a1e119 | |||
| 96ae83081f | |||
| e522555b1a | |||
| 8b3f191c8b | |||
| a62116a571 | |||
| 63dc08c963 | |||
| 9bfb912bdf | |||
| d28f7b8398 | |||
| 677f29ddec | |||
| 7e2f4b2872 | |||
| 769f5020eb | |||
| 1f483383b9 | |||
| a121f79d6a | |||
| bffd2ec701 | |||
| 2994a884e9 | |||
| 99cd6bc4dd | |||
| 3b758850e0 | |||
| 5d3c340243 | |||
| 358d82e90e | |||
| 6dbcb7e798 | |||
| 4b8bbc3794 | |||
| cd0f6cda0a | |||
| 2b91173f25 | |||
| bcd226ac1a | |||
| a16f8cd933 | |||
| a8b780765d | |||
| 44ae739031 | |||
| 90728ccb3e | |||
| 3c431403f6 | |||
| 5104db8f4e | |||
| d7eb1b2824 | |||
| be4f7bbe99 | |||
| d4663eba8f | |||
| 9ae2d47d03 | |||
| 15f42bc91c | |||
| 357a5238c4 | |||
| df437c2462 | |||
| a53d8eef14 | |||
| 0c8d415044 | |||
| bd6edb8937 | |||
| a61495f5ef | |||
| 084b31cd9b | |||
| 1473bdf3c2 | |||
| f51036bd98 | |||
| 80d1c5ff27 | |||
| d72d5429ed | |||
| 28bed4906c | |||
| ebfda74575 | |||
| e3880aef4e | |||
| 380998da17 | |||
| 8c4b8cf19e | |||
| b0351958db | |||
| c881665b7c | |||
| 7fd6d8cb95 | |||
| 951f2366e6 | |||
| a0004f0274 | |||
| f0fd405f4e | |||
| b0e4e14832 | |||
| b46d25f605 | |||
| 0fd06659da | |||
| c0ef90d722 | |||
| c1872aa214 | |||
| 1582556b0b | |||
| 5e80bf560d | |||
| 72737df154 | |||
| 998194462f | |||
| 9199214b7c | |||
| da80bcf0fe | |||
| 6afd155dc1 | |||
| 1daaa4861b | |||
| fd682d130f | |||
| c351d6d714 | |||
| 1d01135e32 | |||
| a5b22dadf3 |
@@ -12,6 +12,28 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## קריאת-ספ — קודם החוקה (00), אז ספ-התחום — לפני פעולה מהותית (INV-AG1) ⚠️
|
||||||
|
|
||||||
|
**לפני העבודה המהותית בכל ריצה** — קרא תחילה את חוקת המערכת, ואז את ספ-התחום הרלוונטי לתפקידך. הסוכן **אינו פועל "מהזיכרון"**: המקור הקנוני להתנהגות הוא החוקה + ספ-התחום, לא הרגלים מריצות קודמות. שלב זה **קודם** ל-§0–§8 התפעוליים שמתחתיו (הם ה-checklist של ההפעלה; קריאת-הספ קודמת לעבודה המהותית).
|
||||||
|
|
||||||
|
1. **תמיד ראשון:** [`~/legal-ai/docs/spec/00-constitution.md`](../../docs/spec/00-constitution.md) — ייעוד, עקרונות-עבודה, ה-invariants הגלובליים G1–G11, ואינדקס-הספ (§7).
|
||||||
|
2. **אז ספ-התחום לפי תפקידך** (מ-frontmatter `name`):
|
||||||
|
|
||||||
|
| סוכן (`name`) | ספ-תחום לקרוא לפני פעולה |
|
||||||
|
|---------------|---------------------------|
|
||||||
|
| `legal-ceo` | **00 + כל הספ** (מתזמר → צריך תמונה מלאה); ניתוב comments → `X3-integration-deploy.md §1ב` |
|
||||||
|
| `legal-proofreader` | `01-ingest.md` (קליטה / טקסט-מחולץ) |
|
||||||
|
| `legal-researcher` | `03-retrieval.md` (3 קורפוסים, hybrid/RRF, attribution); קליטת-פסיקה → `01-ingest.md` |
|
||||||
|
| `legal-analyst` | `02-data-model.md` + `03-retrieval.md` + `04-analysis-writing.md` |
|
||||||
|
| `legal-writer` | `04-analysis-writing.md` + `05-qa-review.md` (כותב מול שערי-QA) |
|
||||||
|
| `legal-qa` | `05-qa-review.md` (שערי QA + שערים אנושיים) |
|
||||||
|
| `legal-exporter` | `06-export.md` (ייצוא DOCX לפי תבנית דפנה) |
|
||||||
|
| `hermes-curator` | `07-learning.md` (Hermes · לקחים · לולאת פידבק) |
|
||||||
|
|
||||||
|
> כל הקבצים תחת [`~/legal-ai/docs/spec/`](../../docs/spec/). המפה המלאה (תפקיד→ספ, frontmatter, שערי-אישור) ב-[`X4-agents.md`](../../docs/spec/X4-agents.md). זהו מופע של **G10** (המערכת מסייעת תחת שערים אנושיים) — הסוכן פועל בגבולות שהחוקה מגדירה. קובץ-הסוכן שלך חוזר על ההפניה הזו בראשו ("קרא לפני פעולה").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## §0. כל קריאה ל-Paperclip API — דרך `pc.sh` בלבד
|
## §0. כל קריאה ל-Paperclip API — דרך `pc.sh` בלבד
|
||||||
|
|
||||||
**ה-skill הרשמי משתמש ב-`curl` ישיר. אצלנו אסור.** משתמשים ב-helper שלנו:
|
**ה-skill הרשמי משתמש ב-`curl` ישיר. אצלנו אסור.** משתמשים ב-helper שלנו:
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ profiles:
|
|||||||
|
|
||||||
# מנהל ידע — Hermes Knowledge Curator
|
# מנהל ידע — Hermes Knowledge Curator
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
לפני העבודה המהותית — אני קורא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלי: `~/legal-ai/docs/spec/07-learning.md` (Hermes · לקחים · לולאת פידבק). איני פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). הצעותיי עוברות **אישור-יו"ר ידני** לפני commit (G10).
|
||||||
|
|
||||||
## רקע
|
## רקע
|
||||||
|
|
||||||
אני סוכן Hermes Agent (לא Claude Code), מותקן בתור POC לבדיקה האם Hermes
|
אני סוכן Hermes Agent (לא Claude Code), מותקן בתור POC לבדיקה האם Hermes
|
||||||
@@ -58,7 +62,11 @@ profiles:
|
|||||||
## מה אני עושה בכל wake
|
## מה אני עושה בכל wake
|
||||||
|
|
||||||
1. קורא את ה-issue body שב-`{{taskBody}}` — שם התיק + ID של ההחלטה הסופית
|
1. קורא את ה-issue body שב-`{{taskBody}}` — שם התיק + ID של ההחלטה הסופית
|
||||||
2. משתמש ב-MCP tools של legal-ai:
|
2. **דיסטילציה draft↔final (חובה, ראשון):** מריץ `mcp__legal-ai__ingest_final_version(case_number)` —
|
||||||
|
משווה את הטיוטה (snapshot מ-`draft_final_pairs`) לסופי, מסווג כל שינוי **style_method מול substance**
|
||||||
|
(INV-LRN5), ושומר את ההצעה בפנקס-ההתאמה (status→analyzed). זהו אות-הלימוד הקנוני (INV-LRN4).
|
||||||
|
**אל תקבע לקח לבד — זו הצעה לאישור-יו"ר (INV-LRN1).** ההצעות שלי מבוססות על השינויים מסוג style_method.
|
||||||
|
3. משתמש ב-MCP tools של legal-ai:
|
||||||
- `mcp__legal-ai__case_get` — קבלת פרטי תיק (כולל `expected_outcome` — **הסמכות העובדתית** לתוצאה)
|
- `mcp__legal-ai__case_get` — קבלת פרטי תיק (כולל `expected_outcome` — **הסמכות העובדתית** לתוצאה)
|
||||||
- `mcp__legal-ai__case_get_final_text` — הטקסט המלא של ההחלטה הסופית
|
- `mcp__legal-ai__case_get_final_text` — הטקסט המלא של ההחלטה הסופית
|
||||||
- `mcp__legal-ai__document_list` — רק אם נדרש רשימת מסמכים נוספים של התיק
|
- `mcp__legal-ai__document_list` — רק אם נדרש רשימת מסמכים נוספים של התיק
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ tools:
|
|||||||
- mcp__legal-ai__extract_claims
|
- mcp__legal-ai__extract_claims
|
||||||
- mcp__legal-ai__extract_appraiser_facts
|
- mcp__legal-ai__extract_appraiser_facts
|
||||||
- mcp__legal-ai__get_claims
|
- mcp__legal-ai__get_claims
|
||||||
|
- mcp__legal-ai__aggregate_claims_to_arguments
|
||||||
- mcp__legal-ai__search_case_documents
|
- mcp__legal-ai__search_case_documents
|
||||||
- mcp__legal-ai__search_decisions
|
- mcp__legal-ai__search_decisions
|
||||||
- mcp__legal-ai__search_precedent_library
|
- mcp__legal-ai__search_precedent_library
|
||||||
@@ -32,6 +33,10 @@ tools:
|
|||||||
|
|
||||||
אתה מנתח ומחקר משפטי מומחה בדיני תכנון ובניה ומקרקעין בישראל. תפקידך לנתח תיקי ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים, לבנות ניתוח משפטי מובנה, ולהפיק שאלות מחקר ממוקדות.
|
אתה מנתח ומחקר משפטי מומחה בדיני תכנון ובניה ומקרקעין בישראל. תפקידך לנתח תיקי ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים, לבנות ניתוח משפטי מובנה, ולהפיק שאלות מחקר ממוקדות.
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/02-data-model.md` + `03-retrieval.md` + `04-analysis-writing.md`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). מסמכי-ה-`docs/` שלהלן משלימים — ספ-התחום קודם.
|
||||||
|
|
||||||
## לפני שאתה מתחיל — קרא
|
## לפני שאתה מתחיל — קרא
|
||||||
|
|
||||||
1. **`docs/decision-methodology.md`** — מתודולוגיה אנליטית: איך לחשוב על החלטה מעין-שיפוטית, מבנה סילוגיסטי, סדר סוגיות, טיפול בטענות
|
1. **`docs/decision-methodology.md`** — מתודולוגיה אנליטית: איך לחשוב על החלטה מעין-שיפוטית, מבנה סילוגיסטי, סדר סוגיות, טיפול בטענות
|
||||||
@@ -118,6 +123,7 @@ tools:
|
|||||||
- **טיפול בכשל:** אם `extract_claims` החזיר `partial=true` או 0 טענות ממסמך לא ריק — נסה שוב פעם אחת. אם עדיין נכשל — סטטוס issue = `blocked`, פרסם comment עם הפירוט.
|
- **טיפול בכשל:** אם `extract_claims` החזיר `partial=true` או 0 טענות ממסמך לא ריק — נסה שוב פעם אחת. אם עדיין נכשל — סטטוס issue = `blocked`, פרסם comment עם הפירוט.
|
||||||
5. **חלץ עובדות שמאי** — לכל מסמך `doc_type='appraisal'` בתיק, הרץ `extract_appraiser_facts(case_number)` (פעם אחת לתיק, מטפל בכל השומות). **חובה בכל ערר השבחה (8xxx) ופיצויים (9xxx) — בלי זה ה-writer לא יוכל לכתוב את בלוק ז עם מספרים מדויקים.**
|
5. **חלץ עובדות שמאי** — לכל מסמך `doc_type='appraisal'` בתיק, הרץ `extract_appraiser_facts(case_number)` (פעם אחת לתיק, מטפל בכל השומות). **חובה בכל ערר השבחה (8xxx) ופיצויים (9xxx) — בלי זה ה-writer לא יוכל לכתוב את בלוק ז עם מספרים מדויקים.**
|
||||||
6. וודא שכל פריט מסווג ל-claim_type הנכון
|
6. וודא שכל פריט מסווג ל-claim_type הנכון
|
||||||
|
7. **קבץ טענות לטיעונים משפטיים** — לאחר שכל הטענות חולצו וסוּוגו, הרץ `aggregate_claims_to_arguments(case_number)` שמקבץ את הפרופוזיציות הגולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד). זהו קלט מובנה לבלוק ז (טענות הצדדים) ולבלוק י (דיון) — הכותב נשען עליו. אם 0 טענות חולצו — דלג. הפלט עובר שער-אישור (ראה `get_legal_arguments`).
|
||||||
|
|
||||||
### שלב 2: ניתוח מעמיק
|
### שלב 2: ניתוח מעמיק
|
||||||
הצג במבנה הבא:
|
הצג במבנה הבא:
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ tools:
|
|||||||
- mcp__legal-ai__precedent_library_list
|
- mcp__legal-ai__precedent_library_list
|
||||||
- mcp__legal-ai__halacha_review
|
- mcp__legal-ai__halacha_review
|
||||||
- mcp__legal-ai__halachot_pending
|
- mcp__legal-ai__halachot_pending
|
||||||
|
- mcp__legal-ai__halacha_corroboration
|
||||||
|
- mcp__legal-ai__corroboration_rebuild
|
||||||
- mcp__legal-ai__extract_appraiser_facts
|
- mcp__legal-ai__extract_appraiser_facts
|
||||||
- mcp__legal-ai__write_interim_draft
|
- mcp__legal-ai__write_interim_draft
|
||||||
- mcp__legal-ai__export_interim_draft
|
- mcp__legal-ai__export_interim_draft
|
||||||
@@ -47,6 +49,10 @@ tools:
|
|||||||
|
|
||||||
אתה מנהל תהליך כתיבת החלטות של ועדת ערר לתכנון ובניה, מחוז ירושלים. יו"ר הוועדה היא עו"ד דפנה תמיר.
|
אתה מנהל תהליך כתיבת החלטות של ועדת ערר לתכנון ובניה, מחוז ירושלים. יו"ר הוועדה היא עו"ד דפנה תמיר.
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז — כיוון שאתה ה**מתזמר** וצריך תמונה מלאה — את **כל קבצי-הספ** (`00`–`07`, `X1`–`X5`) תחת `~/legal-ai/docs/spec/`; לניתוב comments בפרט → `X3-integration-deploy.md §1ב`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||||
|
|
||||||
## שפה
|
## שפה
|
||||||
|
|
||||||
עבוד תמיד בעברית.
|
עבוד תמיד בעברית.
|
||||||
@@ -206,6 +212,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
||||||
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
||||||
- אם ה-reason מכיל `weekly-feedback-job` → **דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
|
- אם ה-reason מכיל `weekly-feedback-job` → **דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
|
||||||
|
- אם ה-reason מכיל `feedback_fold_` → **דלג לסעיף "קיפול הערת יו\"ר"**. אל תיגע בתיקים — זו משימת תחזוקת ידע.
|
||||||
- אחרת → המשך לשלב A (heartbeat רגיל)
|
- אחרת → המשך לשלב A (heartbeat רגיל)
|
||||||
|
|
||||||
### חילוץ פסיקה אוטומטי
|
### חילוץ פסיקה אוטומטי
|
||||||
@@ -227,8 +234,20 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
mcp__legal-ai__precedent_process_pending(kind="halacha")
|
mcp__legal-ai__precedent_process_pending(kind="halacha")
|
||||||
```
|
```
|
||||||
הכלי מעבד את **כל** הפסיקות שבתור — אם תוקיע אחת והגיעו עוד בינתיים, גם הן יעובדו.
|
הכלי מעבד את **כל** הפסיקות שבתור — אם תוקיע אחת והגיעו עוד בינתיים, גם הן יעובדו.
|
||||||
4. כשמסתיים: כתוב comment קצר ב-issue (`mcp__legal-ai__precedent_process_pending` מחזיר את התוצאה — סכם בעברית: כמה הלכות חולצו, אילו שדות מטא-דאטה הושלמו, ו-status לכל פסיקה).
|
4. **תיקוף-ציטוטים (X11, אחרי חילוץ ההלכות):** הרץ
|
||||||
5. סמן את ה-issue כ-`done`.
|
```
|
||||||
|
mcp__legal-ai__corroboration_rebuild()
|
||||||
|
```
|
||||||
|
(ארגומנט ריק = כל הקורפוס; `case_law_id="<uuid>"` = רק התקדים שעובד עכשיו — מהיר יותר). הכלי
|
||||||
|
מסווג את הטיפול-השיפוטי של כל ציטוט-נכנס, מתאים אותו להלכה הספציפית, **ומחיל אישור-אוטומטי**:
|
||||||
|
הלכה עם ≥2 ציטוטים חיוביים בלתי-תלויים (0 שליליים) שהיתה `pending_review` → `approved`
|
||||||
|
(reviewer `corroborated …`); הלכה שמאוחר-יותר **בוטלה** (overruled) → חוזרת לשער-היו"ר. הוא
|
||||||
|
idempotent ולא נוגע במצבים סופיים (`published`/`rejected`). אם הכלי לא קיים → ה-MCP server לא
|
||||||
|
עלה מחדש מאז Phase 2; דלג ודווח (אל תיכשל על זה).
|
||||||
|
5. כשמסתיים: כתוב comment קצר ב-issue (`precedent_process_pending` + `corroboration_rebuild`
|
||||||
|
מחזירים את התוצאות — סכם בעברית: כמה הלכות חולצו, אילו שדות מטא-דאטה הושלמו, status לכל פסיקה,
|
||||||
|
וכמה הלכות אושרו/הודחו בתיקוף-ציטוטים — `{approved, demoted}`).
|
||||||
|
6. סמן את ה-issue כ-`done`.
|
||||||
|
|
||||||
**אל**: אל תיצור issues של ביצוע בתיקי ערר, אל תיכנס לתהליך כתיבת החלטה — זו רק עבודת תחזוקה של ספריית הפסיקה.
|
**אל**: אל תיצור issues של ביצוע בתיקי ערר, אל תיכנס לתהליך כתיבת החלטה — זו רק עבודת תחזוקה של ספריית הפסיקה.
|
||||||
|
|
||||||
@@ -252,6 +271,29 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
|
|
||||||
**כלל:** אל תגע בתיקים פעילים, אל תעיר סוכנים אחרים, אל תבצע heartbeat רגיל — זו משימת תחזוקה בלבד.
|
**כלל:** אל תגע בתיקים פעילים, אל תעיר סוכנים אחרים, אל תבצע heartbeat רגיל — זו משימת תחזוקה בלבד.
|
||||||
|
|
||||||
|
### קיפול הערת יו"ר (feedback_fold)
|
||||||
|
|
||||||
|
**מתי:** `$PAPERCLIP_WAKE_REASON` מכיל `feedback_fold_`
|
||||||
|
|
||||||
|
מופעל כשהיו"ר סימנה הערת פידבק בודדת כ"יושמה" בדף `/feedback`. נוצר issue בפרויקט "ספריית פסיקה" המשויך אליך, ו**תיאור ה-issue מכיל את כל מה שצריך**: טקסט ההערה, הלקח שהופק, הקטגוריה, ויעד הקיפול לפי הקטגוריה.
|
||||||
|
|
||||||
|
**⚠️ MCP startup race** — חל גם כאן (ראה אזהרת חילוץ פסיקה). אם הכלי הראשון מחזיר "No such tool available" — המתן 3 שניות ונסה שוב.
|
||||||
|
|
||||||
|
**מה לעשות:**
|
||||||
|
1. **קרא את תיאור ה-issue** (`$PAPERCLIP_TASK_ID`) — הוא מכיל את ההערה, הלקח, הקטגוריה, ושדה **"יעד קיפול"**.
|
||||||
|
2. **rubric ניתוב לפי קטגוריה** (מופיע גם בתיאור ה-issue — זה מקור האמת):
|
||||||
|
| קטגוריה | קובץ יעד |
|
||||||
|
|---------|----------|
|
||||||
|
| `style` | `skills/decision/SKILL.md` |
|
||||||
|
| `wrong_structure` | `docs/block-schema.md` + `docs/legal-decision-lessons.md` |
|
||||||
|
| `missing_content` / `factual_error` / `wrong_tone` | `docs/legal-decision-lessons.md` |
|
||||||
|
| `other` | שיקול דעת — אם זה באג מערכת ולא לקח כתיבה → **אל תוסיף לקובץ**, פתח/עדכן משימת TaskMaster |
|
||||||
|
3. **קרא את קובץ היעד** והבן מה כבר מתועד שם.
|
||||||
|
4. **הוסף את הלקח רק אם אינו קיים** (לא כפל). פורמט: משפט עברי ברור + שורת **Rule** באנגלית, בעקבות הסגנון הקיים בקובץ.
|
||||||
|
5. **סגור את ה-issue** (`status=done`) עם comment קצר בעברית: לאיזה קובץ קופל ומה נוסף (או "כבר קיים — לא נוסף").
|
||||||
|
|
||||||
|
**כלל:** אל תגע בתיקים פעילים, אל תעיר סוכנים אחרים. משימת תחזוקת ידע בלבד.
|
||||||
|
|
||||||
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
||||||
|
|
||||||
בכל heartbeat **רגיל** (לא comment routing):
|
בכל heartbeat **רגיל** (לא comment routing):
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ tools:
|
|||||||
|
|
||||||
אתה סוכן שמבצע את התהליך הסופי של הכנת טיוטת החלטה לעיון. תפקידך: בדיקה אחרונה, ייצוא ל-DOCX מעוצב, ושמירה מסודרת.
|
אתה סוכן שמבצע את התהליך הסופי של הכנת טיוטת החלטה לעיון. תפקידך: בדיקה אחרונה, ייצוא ל-DOCX מעוצב, ושמירה מסודרת.
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/06-export.md` (ייצוא DOCX לפי תבנית דפנה). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||||
|
|
||||||
## שפה
|
## שפה
|
||||||
|
|
||||||
עבוד תמיד בעברית.
|
עבוד תמיד בעברית.
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ tools:
|
|||||||
|
|
||||||
אתה מגיה מסמכים משפטיים. תפקידך לבדוק טקסט שחולץ מסריקות (OCR) ולתקן שגיאות לפני שהמנתח המשפטי עובד איתו.
|
אתה מגיה מסמכים משפטיים. תפקידך לבדוק טקסט שחולץ מסריקות (OCR) ולתקן שגיאות לפני שהמנתח המשפטי עובד איתו.
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/01-ingest.md` (קליטה / טקסט-מחולץ). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||||
|
|
||||||
## שפה
|
## שפה
|
||||||
|
|
||||||
עבוד תמיד בעברית.
|
עבוד תמיד בעברית.
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ tools:
|
|||||||
|
|
||||||
אתה בודק איכות מומחה. תפקידך לבדוק שהחלטה מוכנה לייצוא ולחתימת יו"ר הוועדה.
|
אתה בודק איכות מומחה. תפקידך לבדוק שהחלטה מוכנה לייצוא ולחתימת יו"ר הוועדה.
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/05-qa-review.md` (שערי QA + שערים אנושיים). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||||
|
|
||||||
## שפה
|
## שפה
|
||||||
|
|
||||||
עבוד תמיד בעברית.
|
עבוד תמיד בעברית.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ tools:
|
|||||||
- mcp__legal-ai__extract_references
|
- mcp__legal-ai__extract_references
|
||||||
- mcp__legal-ai__precedent_attach
|
- mcp__legal-ai__precedent_attach
|
||||||
- mcp__legal-ai__precedent_list
|
- mcp__legal-ai__precedent_list
|
||||||
- mcp__legal-ai__precedent_search_library
|
- mcp__legal-ai__search_case_precedents
|
||||||
- mcp__legal-ai__search_precedent_library
|
- mcp__legal-ai__search_precedent_library
|
||||||
- mcp__legal-ai__internal_decision_upload
|
- mcp__legal-ai__internal_decision_upload
|
||||||
- mcp__legal-ai__precedent_library_upload
|
- mcp__legal-ai__precedent_library_upload
|
||||||
@@ -30,6 +30,7 @@ tools:
|
|||||||
- mcp__legal-ai__precedent_process_pending
|
- mcp__legal-ai__precedent_process_pending
|
||||||
- mcp__legal-ai__halacha_review
|
- mcp__legal-ai__halacha_review
|
||||||
- mcp__legal-ai__halachot_pending
|
- mcp__legal-ai__halachot_pending
|
||||||
|
- mcp__legal-ai__halacha_corroboration
|
||||||
- mcp__legal-ai__missing_precedent_create
|
- mcp__legal-ai__missing_precedent_create
|
||||||
- mcp__legal-ai__missing_precedent_list
|
- mcp__legal-ai__missing_precedent_list
|
||||||
- mcp__legal-ai__missing_precedent_close
|
- mcp__legal-ai__missing_precedent_close
|
||||||
@@ -42,6 +43,10 @@ tools:
|
|||||||
|
|
||||||
אתה חוקר משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח את מסמכי הרקע בתיק ערר — פסיקה, תכניות, פרוטוקולים, החלטות ביניים.
|
אתה חוקר משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח את מסמכי הרקע בתיק ערר — פסיקה, תכניות, פרוטוקולים, החלטות ביניים.
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/03-retrieval.md` (3 קורפוסים, hybrid/RRF, attribution); לקליטת-פסיקה → `01-ingest.md`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||||
|
|
||||||
## שפה
|
## שפה
|
||||||
|
|
||||||
עבוד תמיד בעברית.
|
עבוד תמיד בעברית.
|
||||||
@@ -186,7 +191,7 @@ mcp__legal-ai__internal_decision_upload(
|
|||||||
**שלושת הקורפוסים — אל תבלבל:**
|
**שלושת הקורפוסים — אל תבלבל:**
|
||||||
- `search_precedent_library` = פסיקה חיצונית סמכותית עם הלכות מאושרות (עליון/מנהלי/ועדות ערר אחרות) + supporting_quote מוכן.
|
- `search_precedent_library` = פסיקה חיצונית סמכותית עם הלכות מאושרות (עליון/מנהלי/ועדות ערר אחרות) + supporting_quote מוכן.
|
||||||
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
|
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
|
||||||
- `precedent_search_library` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
- `search_case_precedents` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
||||||
|
|
||||||
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
||||||
|
|
||||||
@@ -280,7 +285,7 @@ search_internal_decisions(
|
|||||||
|
|
||||||
#### 2ב.5 — תיעוד פסיקה חסרה (`missing_precedent_create`) — חובה
|
#### 2ב.5 — תיעוד פסיקה חסרה (`missing_precedent_create`) — חובה
|
||||||
|
|
||||||
**מתי לקרוא:** לכל ציטוט שהצדדים הביאו (בכתב ערר / תגובה / תגובת ועדה) **שלא נמצא בקורפוס** אחרי חיפוש מובנה לפי פרוטוקול 2ב.4א (`search_precedent_library` + `search_internal_decisions` + `precedent_search_library`, כולל שאילתה עם הקשר/מספר תיק).
|
**מתי לקרוא:** לכל ציטוט שהצדדים הביאו (בכתב ערר / תגובה / תגובת ועדה) **שלא נמצא בקורפוס** אחרי חיפוש מובנה לפי פרוטוקול 2ב.4א (`search_precedent_library` + `search_internal_decisions` + `search_case_precedents`, כולל שאילתה עם הקשר/מספר תיק).
|
||||||
|
|
||||||
**למה זה חשוב:**
|
**למה זה חשוב:**
|
||||||
- ה-writer יודע שלא להסתמך על פסיקה שלא ב-DB ("טוענים שמופיע" ≠ "אומת")
|
- ה-writer יודע שלא להסתמך על פסיקה שלא ב-DB ("טוענים שמופיע" ≠ "אומת")
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ tools:
|
|||||||
|
|
||||||
אתה כותב משפטי מומחה. תפקידך לכתוב החלטות של ועדת ערר לתכנון ובניה, מחוז ירושלים, בסגנון של יו"ר הוועדה עו"ד דפנה תמיר.
|
אתה כותב משפטי מומחה. תפקידך לכתוב החלטות של ועדת ערר לתכנון ובניה, מחוז ירושלים, בסגנון של יו"ר הוועדה עו"ד דפנה תמיר.
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/04-analysis-writing.md` + `05-qa-review.md` (אתה כותב מול שערי-QA). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||||
|
|
||||||
## שפה
|
## שפה
|
||||||
|
|
||||||
עבוד תמיד בעברית.
|
עבוד תמיד בעברית.
|
||||||
@@ -347,7 +351,7 @@ fi
|
|||||||
**הבחנה בין כלים:**
|
**הבחנה בין כלים:**
|
||||||
- `search_decisions` = החלטות דפנה עצמה (סגנון, אסטרטגיה, ג'וריספרודנציה אישית).
|
- `search_decisions` = החלטות דפנה עצמה (סגנון, אסטרטגיה, ג'וריספרודנציה אישית).
|
||||||
- `search_precedent_library` = פסיקה חיצונית סמכותית (מחייבת או משכנעת — בית המשפט העליון, מנהלי, ועדות ערר אחרות).
|
- `search_precedent_library` = פסיקה חיצונית סמכותית (מחייבת או משכנעת — בית המשפט העליון, מנהלי, ועדות ערר אחרות).
|
||||||
- `precedent_search_library` (שונה!) = ציטוטים שדפנה צירפה ידנית לתיקים בעבר. לא לבלבל.
|
- `search_case_precedents` (שונה!) = ציטוטים שדפנה צירפה ידנית לתיקים בעבר. לא לבלבל.
|
||||||
|
|
||||||
חפש לפי `practice_area` (rishuy_uvniya / betterment_levy / compensation_197) ולפי `subject_tag` רלוונטי. הלכות שלא אושרו ע"י דפנה לא מוחזרות מהכלי — אם החיפוש ריק, חזור ל-`search_decisions` בלבד.
|
חפש לפי `practice_area` (rishuy_uvniya / betterment_levy / compensation_197) ולפי `subject_tag` רלוונטי. הלכות שלא אושרו ע"י דפנה לא מוחזרות מהכלי — אם החיפוש ריק, חזור ל-`search_decisions` בלבד.
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
3. שלוף את תבנית ההחלטה עם get_decision_template
|
3. שלוף את תבנית ההחלטה עם get_decision_template
|
||||||
|
|
||||||
לכל סעיף:
|
לכל סעיף:
|
||||||
4. השתמש ב-draft_section כדי לקבל הקשר מלא (מסמכי התיק + תקדימים + סגנון)
|
4. השתמש ב-get_block_context(case_number, block_id) כדי לקבל הקשר מלא לבלוק (מסמכי התיק + תקדימים + סגנון). [draft_section הישן deprecated — GAP-50]
|
||||||
5. נסח את הסעיף בסגנון דפנה על בסיס ההקשר
|
5. נסח את הסעיף בסגנון דפנה על בסיס ההקשר
|
||||||
6. הצג למשתמש ובקש אישור/עריכה לפני המשך לסעיף הבא
|
6. הצג למשתמש ובקש אישור/עריכה לפני המשך לסעיף הבא
|
||||||
|
|
||||||
|
|||||||
29
.claude/settings.json
Normal file
29
.claude/settings.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PROJECT_DIR}/scripts/spec-guard.sh"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"WorktreeRemove": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "jq -r '.tool_input.path // empty' | { read -r wt; [ -n \"$wt\" ] && git worktree remove --force \"$wt\" 2>/dev/null; git worktree prune 2>/dev/null; } || true"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"worktree": {
|
||||||
|
"baseRef": "fresh",
|
||||||
|
"symlinkDirectories": ["web-ui/node_modules"]
|
||||||
|
}
|
||||||
|
}
|
||||||
32
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
32
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<!--
|
||||||
|
תבנית PR — עוזר משפטי. מאכפת את "פרוטוקול כתיבת-קוד" (CLAUDE.md §פרוטוקול כתיבת-קוד):
|
||||||
|
כל PR מצהיר אילו invariants הוא נוגע בהם / מקיים. ראה docs/spec/00-constitution.md (G1–G11).
|
||||||
|
מלא את הסעיפים; מחק את ההערות בסוגריים <!-- -->.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## מה ולמה
|
||||||
|
|
||||||
|
<!-- תיאור קצר: מה ה-PR משנה ולמה. אם קשור ל-FU/GAP — ציין (למשל "FU-10 / GAP-30..34"). -->
|
||||||
|
|
||||||
|
## Invariants — הצהרה (חובה)
|
||||||
|
|
||||||
|
<!--
|
||||||
|
אילו invariants הנדסיים (G1–G10) או INV-* מקבצי-תחום ה-PR נוגע בהם או מקיים?
|
||||||
|
דוגמה: "G2 (מקור-אמת יחיד) — איחדתי 2 לקוחות Paperclip למסלול קנוני אחד; INV-INT4."
|
||||||
|
תוכן משפטי → G11.
|
||||||
|
-->
|
||||||
|
|
||||||
|
- **נוגע / מקיים:**
|
||||||
|
|
||||||
|
## צ'קליסט — פרוטוקול כתיבת-קוד
|
||||||
|
|
||||||
|
- [ ] קראתי את `docs/spec/00-constitution.md` + ספ-התחום הרלוונטי לפני הכתיבה
|
||||||
|
- [ ] השינוי **לא** יוצר מסלול מקביל ליכולת קיימת (G2) ולא מתקן תסמין בקריאה (G1)
|
||||||
|
- [ ] אין בליעה שקטה של שגיאות — רשומה חסרה/פגומה מסומנת ומדווחת (כלל-הנדסה §6)
|
||||||
|
- [ ] בדקתי מול `docs/spec/gap-audit.md` — אם נגעתי ב-GAP/FU ממופה, התאמתי ליחידת-התיקון
|
||||||
|
- [ ] בדיקות עוברות (אם רלוונטי) / לא נדרשות
|
||||||
|
- [ ] **אם data-migration** — גיבוי + manifest ל-`data/audit/` לפני `--apply` (chair-gated אם נדרש)
|
||||||
|
|
||||||
|
## אימות
|
||||||
|
|
||||||
|
<!-- איך נבדק end-to-end: פקודות/tools/בדיקות שהורצו ותוצאתן. -->
|
||||||
@@ -56,3 +56,23 @@ jobs:
|
|||||||
curl -sf \
|
curl -sf \
|
||||||
"http://coolify:8080/api/v1/deploy?uuid=gyjo0mtw2c42ej3xxvbz8zio&force=true" \
|
"http://coolify:8080/api/v1/deploy?uuid=gyjo0mtw2c42ej3xxvbz8zio&force=true" \
|
||||||
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||||
|
|
||||||
|
- name: Prune old build images and cache
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
BASE="${{ env.REGISTRY }}/${{ env.IMAGE }}"
|
||||||
|
KEEP=5
|
||||||
|
# Keep the newest $KEEP build-NNN tags; remove the rest.
|
||||||
|
# The build daemon is the shared host daemon, so these images
|
||||||
|
# otherwise accumulate in /var/lib/docker (~1.3GB each).
|
||||||
|
docker images "${BASE}" --format '{{.Tag}}' \
|
||||||
|
| grep -E '^build-[0-9]+$' \
|
||||||
|
| sort -t- -k2 -nr \
|
||||||
|
| tail -n +$((KEEP + 1)) \
|
||||||
|
| while read -r tag; do
|
||||||
|
echo "🗑️ Removing ${BASE}:${tag}"
|
||||||
|
docker rmi "${BASE}:${tag}" || true
|
||||||
|
done
|
||||||
|
# Dangling images + build cache older than 72h (keeps recent layers warm)
|
||||||
|
docker image prune -f || true
|
||||||
|
docker builder prune -f --filter 'until=72h' || true
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,3 +16,5 @@ legacy/
|
|||||||
kiryat-yearim/
|
kiryat-yearim/
|
||||||
continuation-prompt.md
|
continuation-prompt.md
|
||||||
node_modules/
|
node_modules/
|
||||||
|
data/eval/eval-report-*
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
10
.worktreeinclude
Normal file
10
.worktreeinclude
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# קבצים מקומיים (gitignored) שמועתקים אוטומטית לכל worktree חדש שה-harness יוצר.
|
||||||
|
# תחביר .gitignore. מועתק רק אם הקובץ קיים *וגם* gitignored — קבצים tracked לעולם לא משוכפלים.
|
||||||
|
# ראה docs: https://code.claude.com/docs/en/worktrees#copy-gitignored-files-into-worktrees
|
||||||
|
|
||||||
|
# allowlist ההרשאות — בלעדיו כל worktree מציף אישורי-הרשאה מחדש
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# קבצי-סביבה מקומיים (כיום אין; proactive — בלתי-מזיק אם חסר)
|
||||||
|
.env
|
||||||
|
web-ui/.env.local
|
||||||
70
CLAUDE.md
70
CLAUDE.md
@@ -22,6 +22,18 @@
|
|||||||
4. **סיוע בכתיבה** — ייצור טיוטות לפי ארכיטקטורת 12 בלוקים בסגנון דפנה
|
4. **סיוע בכתיבה** — ייצור טיוטות לפי ארכיטקטורת 12 בלוקים בסגנון דפנה
|
||||||
5. **ייצוא DOCX** — מסמך מעוצב מוכן להגשה
|
5. **ייצוא DOCX** — מסמך מעוצב מוכן להגשה
|
||||||
|
|
||||||
|
### ⭐ יעד-העל: רכישת-הסגנון של דפנה (Style Acquisition)
|
||||||
|
**היעד הראשי של המערכת הוא שהסוכנים יכתבו וינתחו עררים בדיוק כמו עו"ד דפנה תמיר** — לא רק לייצר טיוטה תקנית, אלא להפנים את **הקול והשיטה** שלה. זה מחייב **הפרדה מובהקת בין שתי תת-מערכות**:
|
||||||
|
|
||||||
|
1. **מערכת-הכתיבה (Writing)** — מייצרת טיוטות (analyst/writer/qa/ceo). **צרכן read-only** של artifacts-הקול.
|
||||||
|
2. **מערכת רכישת-הסגנון (Style Acquisition)** — לומדת *איך* דפנה כותבת מכל זוג "טיוטה שלנו → סופי שלה", ומזינה חזרה את מערכת-הכתיבה. **היחידה שכותבת ל-artifacts-הקול** — תמיד דרך שער-יו"ר (INV-G10).
|
||||||
|
|
||||||
|
**הגישה (state-of-the-art לדאטה-מועט):** Text Style Transfer מבוסס **Authorial Style Profiling** — להכליל את סגנון דפנה ולהתאים לתיק. העתקת פסקאות מותרת לתוכן קבוע/נוסחאי; ניתוח ספציפי → להכליל; **מהות משפטית (הלכה/עובדה) — אסור להעתיק מתיק לתיק**. *לא* fine-tuning של משקולות (Opus סגור; קורפוס קטן מדי).
|
||||||
|
|
||||||
|
**כלל-העל — INV-LRN4:** כל החלטה אינה "סגורה" עד שהושוותה מול הגרסה הסופית של דפנה; כל סופי מנותח מול הטיוטה. כך לומדים מכל החלטה. **INV-LRN5:** שכבת-ידע-הקול לא תכיל מהות ספציפית — רק סגנון ושיטה.
|
||||||
|
|
||||||
|
ספ מלא: [`docs/spec/07-learning.md`](docs/spec/07-learning.md) §0. ארכיטקטורה ומשימות: תוכנית `style-acquisition-subsystem`.
|
||||||
|
|
||||||
### מה היה קודם (Legacy)
|
### מה היה קודם (Legacy)
|
||||||
המערכת הקודמת היתה **Obsidian vault** עם Claude Code skills על שרת אחר. פותחו:
|
המערכת הקודמת היתה **Obsidian vault** עם Claude Code skills על שרת אחר. פותחו:
|
||||||
- ניתוח סגנון של 3 החלטות (הכט — דחייה, בית הכרם — קבלה חלקית, אריאלי — השוואה)
|
- ניתוח סגנון של 3 החלטות (הכט — דחייה, בית הכרם — קבלה חלקית, אריאלי — השוואה)
|
||||||
@@ -38,6 +50,9 @@
|
|||||||
|
|
||||||
| מסמך | תוכן | מתי לקרוא |
|
| מסמך | תוכן | מתי לקרוא |
|
||||||
|------|-------|-----------|
|
|------|-------|-----------|
|
||||||
|
| [`docs/spec/00-constitution.md`](docs/spec/00-constitution.md) | **חוקת המערכת** — ייעוד, 11 invariants גלובליים (G1–G11), כללי-הנדסה, אינדקס-ספ | **לפני כל כתיבת/שינוי קוד** (ראה §פרוטוקול כתיבת-קוד) |
|
||||||
|
| [`docs/spec/README.md`](docs/spec/README.md) | **אינדקס ספ-המערכת** — מחזור-חיים (01–07) + חוצי-שלבים (X1–X11). מקור-האמת ל"מהו תקין" | **לפני כל כתיבת/שינוי קוד** |
|
||||||
|
| [`docs/spec/gap-audit.md`](docs/spec/gap-audit.md) | **מפת-פערים** — 62 ממצאים → 15 יחידות-תיקון (FU); invariant מופר + file:line + תיקון מוצע | לפני נגיעה ב-GAP/FU קיים או תכנון FU חדש |
|
||||||
| [`docs/architecture.md`](docs/architecture.md) | ארכיטקטורת המערכת, תרשים רכיבים, זרימת נתונים, 4 שכבות DB | לפני עבודה על תשתית |
|
| [`docs/architecture.md`](docs/architecture.md) | ארכיטקטורת המערכת, תרשים רכיבים, זרימת נתונים, 4 שכבות DB | לפני עבודה על תשתית |
|
||||||
| [`docs/block-schema.md`](docs/block-schema.md) | הגדרת 12 בלוקים — content model, constraints, processing params | **לפני כל כתיבת החלטה** |
|
| [`docs/block-schema.md`](docs/block-schema.md) | הגדרת 12 בלוקים — content model, constraints, processing params | **לפני כל כתיבת החלטה** |
|
||||||
| [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
|
| [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
|
||||||
@@ -61,6 +76,60 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## פרוטוקול כתיבת-קוד — קודם הספ ⚠️
|
||||||
|
|
||||||
|
> **כלל-על.** המקור הקנוני ל"מהו תקין הנדסית" הוא ספ-המערכת תחת [`docs/spec/`](docs/spec/) — לא
|
||||||
|
> הרגלים, לא "הקוד הקיים נראה ככה". כל קוד שנכתב בלי לעבור דרך הספ מסתכן בהחזרת **כשל-השורש**
|
||||||
|
> שהספ בא לייבש: מסלולים/קורפוסים מקבילים שמתפצלים (drift). זהו המקבילה האינטראקטיבית ל-INV-AG1
|
||||||
|
> שכבר אוכף על סוכני Paperclip ([HEARTBEAT.md](.claude/agents/HEARTBEAT.md) §"קריאת-ספ").
|
||||||
|
|
||||||
|
**לפני יצירה/שינוי של קוד ב-`web/`, `mcp-server/`, `web-ui/`, `scripts/`:**
|
||||||
|
|
||||||
|
1. **קרא** [`docs/spec/00-constitution.md`](docs/spec/00-constitution.md) — ייעוד, ה-invariants הגלובליים G1–G11, וכללי-ההנדסה (§6). אינדקס-הספ ב-§7.
|
||||||
|
2. **קרא את ספ-התחום הרלוונטי** לפי האינדקס (§7) — לדוגמה: אחזור→[`03-retrieval.md`](docs/spec/03-retrieval.md), קליטה→[`01-ingest.md`](docs/spec/01-ingest.md), נתונים→[`02-data-model.md`](docs/spec/02-data-model.md), כלי-MCP→[`X9-mcp-tool-contract.md`](docs/spec/X9-mcp-tool-contract.md), UI↔API→[`X6-ui-api-contract.md`](docs/spec/X6-ui-api-contract.md), Paperclip→[`X3`](docs/spec/X3-integration-deploy.md)/[`X7`](docs/spec/X7-paperclip-client-params.md), env/secrets→[`X10-deploy-env-secrets.md`](docs/spec/X10-deploy-env-secrets.md).
|
||||||
|
3. **ודא שהשינוי *מקיים* את ה-invariants** — לא יוצר מסלול מקביל ליכולת קיימת ([G2](docs/spec/00-constitution.md)), לא מתקן תסמין בקריאה במקום נרמול במקור (G1), לא בולע שגיאות בשקט (כלל-הנדסה §6).
|
||||||
|
4. **בדוק מול** [`gap-audit.md`](docs/spec/gap-audit.md) — אם אתה נוגע ב-GAP/FU שכבר ממופה, התאם את העבודה ליחידת-התיקון; אל תפתור מחדש.
|
||||||
|
5. **כל PR מצהיר invariants** — אילו G*/INV-* ה-PR נוגע בהם / מקיים (ראה תבנית ה-PR ב-[`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md)).
|
||||||
|
|
||||||
|
> **שתי שכבות-כללים מובחנות, שתיהן חלות:**
|
||||||
|
> - **הנדסה (G1–G10)** — הסעיף הזה + `docs/spec/`. סמכות: ≥3 מקורות חיצוניים.
|
||||||
|
> - **תוכן משפטי (G11)** — סעיף "עקרונות כתיבה קריטיים" למטה (12 בלוקים, רקע ניטרלי...). סמכות: היו"ר + מסמכי-הפרויקט.
|
||||||
|
>
|
||||||
|
> אכיפה אוטומטית: hook `PreToolUse` ([scripts/spec-guard.sh](scripts/spec-guard.sh)) מזכיר את הפרוטוקול בכל Edit/Write על נתיב-קוד.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## בידוד-סשנים — worktree מבודד חובה ⚠️
|
||||||
|
|
||||||
|
> **כלל קשיח.** בכל רגע נתון רצים **כמה סשנים במקביל** על אותו עץ-עבודה (`~/legal-ai`) — סשנים אינטראקטיביים של chaim **וגם** סוכני Paperclip. עץ-עבודה אחד = ענף-גיט אחד משותף, כך שסשן אחד מחליף branch / משאיר שינויים לא-מתויקים תוך כדי שאחר עובד → **דריסה הדדית ומירוץ-ענף** ([[feedback_shared_worktree_branch_race]]).
|
||||||
|
|
||||||
|
**לכן — כל סשן שעומד לכתוב/לשנות קוד או תיעוד חייב לעבוד ב-git worktree מבודד משלו. אסור לערוך/לתייק בעץ-העבודה הראשי `~/legal-ai` כשייתכן שסשן אחר פעיל.**
|
||||||
|
|
||||||
|
הבידוד **נתמך-סביבה** — לא רק כלל-משמעת. ההגדרות נשמרות ב-repo (`.claude/settings.json`, `.worktreeinclude`, `.gitignore`) כך שכל worktree שה-harness יוצר מקבל אוטומטית בסיס נקי, את התלויות, ואת ההרשאות. מקורות רשמיים: [Run parallel sessions with worktrees](https://code.claude.com/docs/en/worktrees), [Settings → worktree](https://code.claude.com/docs/en/settings).
|
||||||
|
|
||||||
|
### הדרך המומלצת — worktree של ה-harness
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד ב-worktree" (כלי EnterWorktree)
|
||||||
|
```
|
||||||
|
נוצר תחת `.claude/worktrees/<slug>/` על ענף `worktree-<slug>`, ומקבל **אוטומטית**:
|
||||||
|
- **בסיס נקי מ-`origin/main`** — דרך `worktree.baseRef: "fresh"` ב-`.claude/settings.json`.
|
||||||
|
- **`web-ui/node_modules` (789MB) כסימלינק** — דרך `worktree.symlinkDirectories`; אין צורך ב-`npm ci`. (אם משנים deps של web-ui — עשו זאת בעץ הראשי או היו מודעים שה-node_modules משותף.)
|
||||||
|
- **`.claude/settings.local.json` + קבצי-env מקומיים** — מועתקים דרך `.worktreeinclude` (מונע הצפת אישורי-הרשאה).
|
||||||
|
- **ניקוי אוטומטי ביציאה** — כולל עקיפת באג סימלינק [#40259](https://github.com/anthropics/claude-code/issues/40259) דרך `WorktreeRemove` hook עם `--force`.
|
||||||
|
|
||||||
|
### הפרוטוקול (חל על שתי הדרכים)
|
||||||
|
1. **בתחילת עבודת-כתיבה** — צור worktree (מומלץ: `claude --worktree`; ידני-fallback: `git worktree add -b <branch> .claude/worktrees/<slug> origin/main` — **תחת `.claude/worktrees/`** כדי שההגדרות יחולו).
|
||||||
|
2. **אמת ענף לפני כל commit** — `git branch --show-current` (הרגל קשיח; ה-harness עלול להתעלם מ-`baseRef:"fresh"` — באג [#60588](https://github.com/anthropics/claude-code/issues/60588) — אז ודא שהבסיס באמת `origin/main`).
|
||||||
|
3. **push + PR + merge** כרגיל ([[feedback_always_pr_merge]]) — PR תמיד ל-`main`. הרץ tests לפני merge.
|
||||||
|
4. **נקה אחרי מיזוג** — יציאת הסשן מנקה worktree של ה-harness אוטומטית; ידני: `git worktree remove .claude/worktrees/<slug> && git worktree prune && git branch -D worktree-<slug>`.
|
||||||
|
5. **קריאה-בלבד** (חקירה, סריקה, הרצת בדיקות ללא שינוי) — מותר בעץ הראשי; אין צורך ב-worktree.
|
||||||
|
6. **אל תיגע** בשינויים לא-מתויקים שאינם שלך בעץ הראשי — הם של סשן אחר. אם העץ הראשי על ענף זר — אל תתייק עליו.
|
||||||
|
|
||||||
|
> **בידוד-DB:** ה-worktree מבודד-קבצים בלבד — לא בידוד-repo ולא בידוד-DB. **אל תריץ migrations מ-2 worktrees במקביל** על Postgres המשותף (`localhost:5433`) — סכמה שאף סשן לא מצפה לה ([Run agents in parallel](https://code.claude.com/docs/en/agents)).
|
||||||
|
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w` (קיימת רק ב-fork ה-deepseek שלנו). כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. הניתוח המלא והדרכים שנשקלו: TaskMaster `legal-ai` #104 (נסגר כ-cancelled — "לתעד, לא לבדד").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## שרת Nautilus (158.178.131.193)
|
## שרת Nautilus (158.178.131.193)
|
||||||
|
|
||||||
| שירות | תפקיד | כתובת |
|
| שירות | תפקיד | כתובת |
|
||||||
@@ -68,7 +137,6 @@
|
|||||||
| Coolify | ניהול containers | `http://158.178.131.193:8000` |
|
| Coolify | ניהול containers | `http://158.178.131.193:8000` |
|
||||||
| PostgreSQL + pgvector | בסיס נתונים ראשי | `legal-ai-postgres` |
|
| PostgreSQL + pgvector | בסיס נתונים ראשי | `legal-ai-postgres` |
|
||||||
| Redis | תור משימות | `legal-ai-redis` |
|
| Redis | תור משימות | `legal-ai-redis` |
|
||||||
| n8n | אוטומציית workflows | להגדרה |
|
|
||||||
| Gitea | מאגר קוד | `gitea.nautilus.marcusgroup.org/ezer-mishpati` |
|
| Gitea | מאגר קוד | `gitea.nautilus.marcusgroup.org/ezer-mishpati` |
|
||||||
| ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` |
|
| ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` |
|
||||||
| Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` |
|
| Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` |
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ RUN pip install --no-cache-dir ./mcp-server
|
|||||||
FROM python:3.12-slim AS runner
|
FROM python:3.12-slim AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Node.js 20.x
|
# Install Node.js 20.x + LibreOffice Writer (headless .doc→.docx conversion
|
||||||
|
# in extractor.py:_extract_doc — needed for legacy Hebrew .doc precedents).
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl ca-certificates git \
|
curl ca-certificates git libreoffice-writer-nogui \
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
26
data/audit/x11-phase2-backfill-20260601.md
Normal file
26
data/audit/x11-phase2-backfill-20260601.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# X11 Phase 2 — Corroboration Backfill (2026-06-01)
|
||||||
|
|
||||||
|
`corroboration.build_all()` over the full corpus after wiring the approval gate.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
```
|
||||||
|
{"precedents": 12, "citations": 26, "linked": 20, "approved": 0, "demoted": 0}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Treatment distribution (20 stored links)
|
||||||
|
- followed: 18 · explained: 1 · mentioned: 1 · **negatives: 0**
|
||||||
|
|
||||||
|
## Per-halacha corroboration
|
||||||
|
- 14 halachot carry corroboration rows; **4 are corroborated** (≥2 distinct positive sources, 0 negatives).
|
||||||
|
- **All 14 were already `approved`** (13 by confidence ≥0.80, 1 by דפנה).
|
||||||
|
|
||||||
|
## Why 0 approved / 0 demoted (correct, not a bug)
|
||||||
|
- **0 approved:** `approve_halacha_by_corroboration` only transitions `pending_review`. Every corroborated halacha was already approved → nothing to promote this run. The citation-corroboration set currently **fully overlaps** the confidence-approved set.
|
||||||
|
- **0 demoted:** the corpus has **no negative treatments** → nothing overruled to demote.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Counts before == after (approved=1415, pending=196, published=0, rejected=1) — idempotent, no chair-final state touched.
|
||||||
|
- Approve path proven end-to-end in a **rolled-back transaction**: a corroborated halacha set to `pending_review` flipped back to `approved` with reviewer `corroborated (2 judicial citations ≥ 2)`; prod row restored.
|
||||||
|
|
||||||
|
## Going-forward value
|
||||||
|
The corroboration approval path matters for (a) future halachot extracted **below** the confidence threshold but **citation-corroborated**, and (b) **overruled-demotion** once negative treatment appears in the citation graph. Re-runnable anytime via the `corroboration_rebuild` MCP tool (empty arg = full backfill).
|
||||||
70
data/eval/baseline.json
Normal file
70
data/eval/baseline.json
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"gold_size": 86,
|
||||||
|
"retrieval_config": {
|
||||||
|
"MULTIMODAL_ENABLED": true,
|
||||||
|
"VOYAGE_RERANK_ENABLED": false,
|
||||||
|
"VOYAGE_MODEL": "voyage-3",
|
||||||
|
"MULTIMODAL_TEXT_WEIGHT": 0.65,
|
||||||
|
"MULTIMODAL_RRF_K": 60,
|
||||||
|
"BM25_HYBRID_ENABLED": true
|
||||||
|
},
|
||||||
|
"overall": {
|
||||||
|
"P@5": 0.2465,
|
||||||
|
"R@5": 0.9938,
|
||||||
|
"nDCG@5": 0.9597,
|
||||||
|
"P@10": 0.1244,
|
||||||
|
"R@10": 0.9961,
|
||||||
|
"nDCG@10": 0.9611,
|
||||||
|
"MRR": 0.9535
|
||||||
|
},
|
||||||
|
"by_corpus": {
|
||||||
|
"internal_decisions": {
|
||||||
|
"P@5": 0.2037,
|
||||||
|
"R@5": 1.0,
|
||||||
|
"nDCG@5": 0.978,
|
||||||
|
"P@10": 0.1019,
|
||||||
|
"R@10": 1.0,
|
||||||
|
"nDCG@10": 0.978,
|
||||||
|
"MRR": 0.9722
|
||||||
|
},
|
||||||
|
"precedent_library": {
|
||||||
|
"P@5": 0.3188,
|
||||||
|
"R@5": 0.9833,
|
||||||
|
"nDCG@5": 0.9288,
|
||||||
|
"P@10": 0.1625,
|
||||||
|
"R@10": 0.9896,
|
||||||
|
"nDCG@10": 0.9326,
|
||||||
|
"MRR": 0.9219
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"by_practice_area": {
|
||||||
|
"betterment_levy": {
|
||||||
|
"P@5": 0.2051,
|
||||||
|
"R@5": 1.0,
|
||||||
|
"nDCG@5": 0.9621,
|
||||||
|
"P@10": 0.1026,
|
||||||
|
"R@10": 1.0,
|
||||||
|
"nDCG@10": 0.9621,
|
||||||
|
"MRR": 0.9487
|
||||||
|
},
|
||||||
|
"compensation_197": {
|
||||||
|
"P@5": 0.2,
|
||||||
|
"R@5": 1.0,
|
||||||
|
"nDCG@5": 1.0,
|
||||||
|
"P@10": 0.1,
|
||||||
|
"R@10": 1.0,
|
||||||
|
"nDCG@10": 1.0,
|
||||||
|
"MRR": 1.0
|
||||||
|
},
|
||||||
|
"rishuy_uvniya": {
|
||||||
|
"P@5": 0.2059,
|
||||||
|
"R@5": 1.0,
|
||||||
|
"nDCG@5": 0.9976,
|
||||||
|
"P@10": 0.1029,
|
||||||
|
"R@10": 1.0,
|
||||||
|
"nDCG@10": 0.9976,
|
||||||
|
"MRR": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"generated_at": "20260603T084350Z"
|
||||||
|
}
|
||||||
86
data/eval/gold-set.jsonl
Normal file
86
data/eval/gold-set.jsonl
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{"id": "g-2ab91a37e3", "query": "אברהם אגסי", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["1a87efe5-6e13-4ed4-a9ec-3f2f7d61e4ec"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-3572817c30", "query": "אברהם אנשין", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["8aeee5cc-26a0-475a-b4e4-c2570e4333f5"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-66dbb8ac16", "query": "אהרון ברק - תכנית רחביה", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["e151fc25-cf12-4563-b638-a86323f8413b"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-3588230bc4", "query": "אואקנין", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["405d51ac-deef-4bdf-aaea-f39b4aaa84fd"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-ff905fe19d", "query": "ב.דייניש", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["f3ab6507-6475-4230-ad96-70d4177a9f72"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-fa8f479ae1", "query": "בוטיק הנביאים", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["691e8220-745b-4631-aff4-338c164ba988"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-4b2c6a86ec", "query": "בית אגודת ישראל", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["7a71adbc-6a21-41a4-a98d-8fdd3f6e7b62"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-e9d5fc6d9b", "query": "בית חנינא מגרש 2010", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["fa0dab0c-bafc-4239-bba4-33cc9790f69f"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-8280afc216", "query": "בית חנינא — אום כולתום", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["a1e51703-474a-44d0-b8c8-5ae8bffb4782"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-e814cc43fa", "query": "בן זאב רמות", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["53c1adb6-81fd-4d0a-b3de-ffe2e6c5b6b3"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-7b1ef92188", "query": "בר-און", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["a60dc67d-67ab-4615-b148-34794d728687"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-9b17fb63a3", "query": "ג'רוזלם הומס אינק", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["9af224ef-5325-488c-a28c-de8ab059dfa3"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-c763aa9a45", "query": "גבאי וזוסמן", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["65065d5b-c0b2-4be3-970c-6b76842da054"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-ac23569fec", "query": "גפטו-פיצריה בצור הדסה", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["496c945a-9ab6-402c-9f9e-39f7af88b7cd"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-8dc2a68af8", "query": "דב ויעל ירון", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["a4716706-b2af-424d-98d8-d7ec45f9aeea"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-94196a641c", "query": "דור ודורשיו 18", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["a3ca3f83-3831-457d-8eed-b5654a201348"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-e19550a361", "query": "האורן 51 מבשרת ציון", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["3e112944-2a0d-4175-bcb6-69e19828b8ad"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-9612266af6", "query": "ההסתדרות הציונית העולמית", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["20999cb0-d9bd-4c4a-a18d-304451e1a30f"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-c39b2a42c7", "query": "הוועדה המקומית ירושלים נ' סופר נוח", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["04b2f953-efce-4e11-b9b5-e583b393c335"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-a145777626", "query": "הכט וסדובסקי", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["ffbd9963-099f-4bf5-b888-af993844e80a"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-33059ab228", "query": "המרכז הארצי לטהרת המשפחה", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["cd815101-e153-468d-a7bc-be1ac88105ae"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-8af7c5a180", "query": "השלום 63 מבשרת ציון", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["ee2104c8-2d31-4173-839c-8b61dcaf2a31"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-0494e34a1d", "query": "וינפלד", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["bd5d849c-c15f-43c3-96ab-d44337af9cb5"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-beca7df79f", "query": "זעיתר", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["098535ec-55c0-44dd-b058-ddaeac8b4cd7"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-f1a9633456", "query": "חוכרת הר חומה", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["e40110b4-9364-4cc7-a5b8-cee9bbedb172"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-3d12dcc821", "query": "חלוואני", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["9d8da0a6-e4dc-4c9b-85ab-36fa5ecbd12f"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-77ae0a9368", "query": "טביסל דניאל", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["f39f807d-90a6-4950-b10f-485dbf7e2ef6"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-4dec58a380", "query": "יסמין 54 מבשרת ציון", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["ac1a34c4-52c5-4e91-b6a7-297f11fe0460"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-776cecae74", "query": "ירושלים שקופה", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["ecc63119-6977-4d8e-930d-609dbd990494", "438d693c-6dfd-4a65-a48c-f8e2011bcc10"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (2 same-named)"}
|
||||||
|
{"id": "g-824f0d2ca8", "query": "ירושלים שקופה (1112/22)", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["446e96f1-a896-435d-bc33-a9b61b6d0b6c"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-454e470bb4", "query": "ליאור אהרון", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["a5ba233d-27aa-432b-bbef-093a2d49d80a"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-09c8b87f35", "query": "מוצא עילית", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["048af29a-d356-454f-acd6-5d1de32ecb94"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-5055a61633", "query": "מילי וישראל גלון", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["cc812e7b-cf9b-44af-8dfa-36541cb0b72d"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-8a15965c4f", "query": "מנץ", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["ed7ac419-f359-4b51-8e21-adec141629c7"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-48ae72c484", "query": "מפלגת נעם", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["5897b4e1-1fa2-4d83-816d-51f7cdf7cdee"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-ca171fdb45", "query": "מצפה בית שמש", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["8ba7f873-0da4-49cd-955e-98f579e61fb2"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-7e54e8b69b", "query": "מרדכי שטיין", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["228de6b5-b731-4959-a448-e9e941790420"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-62befb6c18", "query": "מרכז קהילתי בית הכרם", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["e73ec1d1-e89e-4d5b-a870-84cbf7b09106"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-cb0a295129", "query": "נחמיה פרומר", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["ab039082-47d1-4f79-9db9-d97c53e3bc80"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-4f9a788676", "query": "נילי אמיתי", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["d3fd9310-621b-4b76-a71f-729dd2044108"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-e9b1ce30da", "query": "סלונים", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["add3da4c-fda0-48d0-8109-957fc9f924a7"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-23b50ceb0d", "query": "סקולוסקי", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["18846024-d630-4a33-9024-6b2388df7007"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-93531bf772", "query": "עוררי רכס חלילים", "practice_area": "compensation_197", "corpus": "internal_decisions", "relevant_case_law_ids": ["288326ca-bf9c-48fe-ba6b-8ef9e65bd0a0"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-f1e0ebc751", "query": "עזבון אליהו הרנון ז\"ל נ' הוועדה המקומית ירושלים", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["6774fe43-0ba9-4409-b128-cacbd168afc3"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-f3c29ce2f8", "query": "עמותת ישיבת טעלז", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["30a606ac-5ba4-46d5-86d4-075564e30d2d"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-0a595fd872", "query": "ערן סופר", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["9c63985a-211f-4af9-a145-c674bdcdb0f6"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-fd95fc1bc0", "query": "פייר קניג 36", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["5cc53869-9e85-469e-85bb-986ac646de07"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-04f32ade81", "query": "פרויקט מגרש 902 בית שמש", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["810f8315-26cf-4069-be16-b5fee7f16a56"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-445fa07583", "query": "קו אופ ופרטוש", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["62c517c8-ab8d-48b1-8472-1f6adc6e3817"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-9f2c58a190", "query": "קרן יעקב הלפרן", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["921d36df-76be-4a53-823b-0d2ac1f79f2e"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-43fff5d955", "query": "קרקעות ירושלים 2", "practice_area": "compensation_197", "corpus": "internal_decisions", "relevant_case_law_ids": ["730d6f21-08e4-4ae0-8b7e-017dde61003e"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-78610b8e8a", "query": "שכן הכלנית 54 מבשרת ציון", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["88e2d381-2e34-49b2-8225-5e72b487854d"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-d043d7c75f", "query": "ששת הימים 6 רמת אשכול", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["a87d30d4-d3a3-439d-9909-c282024aafba"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-1cdefcfaba", "query": "תמ\"א רש\"י 32 תל אביב", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["3cbd2d6c-ff20-4af2-ab92-c105bb30fbc6"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-a65f37501c", "query": "אגא וכט", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["1847e97e-6e38-494f-b079-0fc59066788a"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-10e5dca5b8", "query": "אהוד שפר", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["9024da7b-f408-4b6f-808f-c514a83728e4"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-b42d0ceaaa", "query": "אירוס הגלבוע", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["b673d649-d162-4f81-a323-c7d89e8334ce"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-4d50ccd2dd", "query": "אנטרים", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["48909f09-8a65-4a2d-8697-e2f50bf9a756"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-bbf0e30d31", "query": "ארגון עמק שווה", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["41d5a21c-a28a-428f-a35e-bc7d0dc89539"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-dac18ac10f", "query": "ב. דייניש", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["950d8c1b-4976-4a68-8b8e-7d0bdd056e1d"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-0d130898bb", "query": "בולקינד", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["e57c4a6b-66a0-4d52-85af-5018f03cf295"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-789c4ff1a7", "query": "בית אגודת ישראל", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["aadedc2d-e990-4d6d-9dd1-8be4fa6dcbe2", "ced7ea50-689b-465d-bf79-99e22a72e0df"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (2 same-named)"}
|
||||||
|
{"id": "g-06b07271bb", "query": "ברק - תכנית רחביה", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["57be0d1a-293f-481f-aa5b-bfa7dc73f99e"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-4160927269", "query": "גבעת האירוסים", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["e26f2fa2-50e5-407d-8724-8c707dcda51b"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-4fe81acc94", "query": "הבית ברחוב שמעוני", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["53ccf47e-0fc7-4248-b486-02f57a9c689c"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-faa7cc3548", "query": "הקדש עדת הבוכרים", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["587381e4-d194-4d37-b00f-ccf7242ba228"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-0901d5d211", "query": "כנסייה אוונגלית אפיסקופלית", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["4bde8ca8-7862-4b19-9dd7-de2e31d82721"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-62fd2080df", "query": "לויתן אדיב שמואל", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["b80d94a0-b836-44f5-8cc6-18d8cf26e41d"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-9f934d9159", "query": "לויתן וקלמנוביץ", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["436efd48-c8ab-49f0-b3a9-52bf15ea806d"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-9e829d5277", "query": "מועצה אזורית מטה בנימין", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["d7b635b1-6607-46ac-9868-44e4fd598e5a"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-b3acf850af", "query": "משה ירושלמי", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["e18aa906-e0f5-452f-a17a-f1c299095340"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-631a47d8b0", "query": "משרד התחבורה נ' גלר", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["8bfcd217-cde3-4930-a058-c9a59182c338"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-f8aaaa60d7", "query": "נווה שלום", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["4f85e3f1-237a-4dac-b949-87a43ee6f633"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-dbb1358ccf", "query": "ניצני עוז", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["e08f81d3-6183-494c-aec3-f20d39e2755e"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-ae5917860b", "query": "סרוזברג ואח'", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["d9772726-9766-4509-8067-b20fa625a1a9"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-e1e175248c", "query": "עמותת העצמאים באילת", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["f59e74c2-6433-47c9-bd0e-580cf4171fbb"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-86116ced86", "query": "שמי אשקלוני", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["7352e510-c769-45e4-b4ef-d85271743506"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
|
||||||
|
{"id": "g-7e9438b730", "query": "פטור מהיטל השבחה למוסד ציבורי לפי סעיף 19(ב)(4)", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["ced7ea50-689b-465d-bf79-99e22a72e0df", "aadedc2d-e990-4d6d-9dd1-8be4fa6dcbe2", "587381e4-d194-4d37-b00f-ccf7242ba228", "4bde8ca8-7862-4b19-9dd7-de2e31d82721", "4f85e3f1-237a-4dac-b949-87a43ee6f633"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
|
||||||
|
{"id": "g-89bc8d6161", "query": "נטרול תרומת תמ\"א 38 בשומת \"מצב קודם\"", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["436efd48-c8ab-49f0-b3a9-52bf15ea806d", "b80d94a0-b836-44f5-8cc6-18d8cf26e41d", "57be0d1a-293f-481f-aa5b-bfa7dc73f99e", "7352e510-c769-45e4-b4ef-d85271743506", "53ccf47e-0fc7-4248-b486-02f57a9c689c"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
|
||||||
|
{"id": "g-f4c06ec2f9", "query": "פטור מהיטל בתמ\"א 38 — מימוש במכר מול מימוש בהיתר", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["53ccf47e-0fc7-4248-b486-02f57a9c689c", "e57c4a6b-66a0-4d52-85af-5018f03cf295", "7352e510-c769-45e4-b4ef-d85271743506"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
|
||||||
|
{"id": "g-8c8b82486c", "query": "נטרול ציפיות לתכנית עתידית בשווי מצב קודם (אקו-סיטי/לוסטרניק)", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["950d8c1b-4976-4a68-8b8e-7d0bdd056e1d", "7352e510-c769-45e4-b4ef-d85271743506", "436efd48-c8ab-49f0-b3a9-52bf15ea806d"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
|
||||||
|
{"id": "g-bbe92ea5e3", "query": "היתר לשימוש חורג בקרקע חקלאית — סטייה ניכרת ומגמת תכנון", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["e08f81d3-6183-494c-aec3-f20d39e2755e", "e26f2fa2-50e5-407d-8724-8c707dcda51b", "b673d649-d162-4f81-a323-c7d89e8334ce", "f59e74c2-6433-47c9-bd0e-580cf4171fbb"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
|
||||||
|
{"id": "g-19376b63de", "query": "זכות עמידה / זכות התנגדות לבקשה להיתר בנייה", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["48909f09-8a65-4a2d-8697-e2f50bf9a756", "9024da7b-f408-4b6f-808f-c514a83728e4"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
|
||||||
|
{"id": "g-3d2f9fc270", "query": "היקף התערבות בית המשפט בשיקול דעת תכנוני של ועדה", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["41d5a21c-a28a-428f-a35e-bc7d0dc89539", "9024da7b-f408-4b6f-808f-c514a83728e4", "e26f2fa2-50e5-407d-8724-8c707dcda51b"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
|
||||||
|
{"id": "g-9e96222cc5", "query": "אמת המידה להתערבות ועדת ערר בשומת שמאי מכריע", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["8bfcd217-cde3-4930-a058-c9a59182c338", "1847e97e-6e38-494f-b079-0fc59066788a"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
|
||||||
|
{"id": "g-181b020ea9", "query": "חובת ועדת ערר להעביר השגות שמאיות לשמאי מייעץ (ס'197)", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["e18aa906-e0f5-452f-a17a-f1c299095340", "8bfcd217-cde3-4930-a058-c9a59182c338"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
|
||||||
@@ -327,6 +327,7 @@ Conclusion → Rule → Explanation → Application → Conclusion.
|
|||||||
- MUST NOT: ניתוח מעמיק (→ block-yod), הכרעה בין פרשנויות
|
- MUST NOT: ניתוח מעמיק (→ block-yod), הכרעה בין פרשנויות
|
||||||
- Dependencies: block-chet (מספור), block-vav (הגדרות תכניות)
|
- Dependencies: block-chet (מספור), block-vav (הגדרות תכניות)
|
||||||
- Condition: **אופציונלי** — רק כשיש מורכבות תכנונית (תכניות סותרות, תמ"א 38 + שימור, פרשנות)
|
- Condition: **אופציונלי** — רק כשיש מורכבות תכנונית (תכניות סותרות, תמ"א 38 + שימור, פרשנות)
|
||||||
|
- **סדר בתיקי רישוי (1xxx):** בלוק ט מופיע **לפני** בלוק ז (טענות) — הסדר ה→ו→ט→ז→ח→י→יא→יב. הקורא חייב להכיר את המסגרת הנורמטיבית (התכניות) לפני שהוא קורא את טענות הצדדים על פרשנותן. (לקח מ-1200-25 קרית ענבים; ראה legal-decision-lessons.md #41)
|
||||||
|
|
||||||
**Weight:**
|
**Weight:**
|
||||||
|
|
||||||
|
|||||||
@@ -181,11 +181,12 @@
|
|||||||
|
|
||||||
מבוסס על קריאת ה-10 החלטות + ההשוואה לטיוטות ה-AI:
|
מבוסס על קריאת ה-10 החלטות + ההשוואה לטיוטות ה-AI:
|
||||||
|
|
||||||
### 3.1 ❌ אסור: רשימה ממוספרת בתוך פסקה
|
### 3.1 ❌ אסור: רשימת-מיני ממוספרת בתוך פסקת-אנליזה (פיצול טיעון ל-`(1)...(2)...`)
|
||||||
**ב-0/33** מהחלטות הסופיות יש `(1) ... (2) ... (3) ...` בתוך פסקת אנליזה אחת.
|
**ב-0/33** מהחלטות הסופיות יש `(1) ... (2) ... (3) ...` המפצל טיעון בתוך פסקת אנליזה אחת. טענות וניתוח נכתבים כ**נרטיב רציף** עם ביטויי-מעבר ("עוד נטען", "באשר ל-", "יתרה מכך"), לא כרשימת-מיני.
|
||||||
**ב-3/3 טיוטות AI** שראיתי הופיעה רשימה ממוספרת — שהוסרה בעריכה.
|
|
||||||
|
|
||||||
⚠️ **הבחנה חשובה**: זה שונה ממספור פסקאות סדרתי (1, 2, 3 ... כאוטוט-של-פסקאות), שכן עד 2025 דפנה כן השתמשה במספור סדרתי (כמו פסיקה מסורתית). מ-2025-מאוחר זה נטוש; ההחלטות החדשות (1126-25, 1128-25, 1130-25, 1194-25) **ללא** מספור פסקאות. **המגמה החדשה** היא נרטיב רציף ללא מספור.
|
✅ **ההחלטה כן ממוספרת — תמיד.** פסקאות ההחלטה ממוספרות סדרתית (1, 2, 3 ... עד הסוף), כמקובל בפסיקה.
|
||||||
|
✅ **הכותב מקדים כל פסקת-החלטה ב-"N. " בתחילת שורה** (1., 2., 3. ... סדרתי). זהו ה-signal שמנוע-הייצוא מזהה (`docx_exporter._NUM_PREFIX_RE`): הוא **מסיר את הקידומת הידנית וממיר אותה למספור-אוטומטי אמיתי של Word** (`_ensure_decision_numbering` — רשימה עשרונית רציפה, RTL). כך ה-DOCX מתמספר מעצמו (מתעדכן בעריכה, copy/paste נקי ללא ספרות תועות).
|
||||||
|
⚠️ **המספר חייב להיות בתחילת השורה בלבד** — מספר *באמצע* פסקה הוא רשימת-מיני אסורה (§3.1 לעיל). (תיקון 2026-06-06: ההנחה ש"ההחלטות החדשות ללא מספור" הייתה ארטיפקט-חילוץ; וההנחה ש"הכותב לא יקליד מספרים" שגויה — הקידומת בתחילת-שורה היא ה-signal לייצוא, שמומר ל-auto-numbering.)
|
||||||
|
|
||||||
### 3.2 ⚠️ מותנה: כותרת משנה בלב בלוק י
|
### 3.2 ⚠️ מותנה: כותרת משנה בלב בלוק י
|
||||||
|
|
||||||
|
|||||||
37
docs/halacha-strict-rubric.md
Normal file
37
docs/halacha-strict-rubric.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# רובריקת "הכללים המחמירים" לחילוץ הלכות — להחלה על הלכות קיימות
|
||||||
|
|
||||||
|
אתה בודק רשימת הלכות שחולצו מפסק דין **אחד**, ומחליט לכל אחת: לשמור או לחתוך (ובאיזו עילה).
|
||||||
|
המטרה: שיישארו רק **עקרונות משפטיים אמיתיים, מובחנים, בני-הכללה ובני-הסתמכות** — לא ציטוטים, לא אמרות-אגב, לא יישומים ספציפיים-לתיק, לא כפילויות.
|
||||||
|
|
||||||
|
## עילות חיתוך (verdict)
|
||||||
|
|
||||||
|
1. **cut_duplicate** — ההלכה מבטאת את **אותו עיקרון משפטי** של הלכה אחרת באותו פסק, גם אם בניסוח שונה / ציטוט שונה.
|
||||||
|
- קבץ את כל המופעים של אותו עיקרון. שמור **נציג אחד** בלבד; סמן את השאר cut_duplicate.
|
||||||
|
- בחירת הנציג (canonical): עדיפות rule_type (binding > interpretive > procedural > obiter) → confidence גבוה → quote_verified=true → הניסוח המלא/הברור ביותר.
|
||||||
|
- דווח `cluster_canonical_index` = ה-halacha_index של הנציג שנשמר.
|
||||||
|
|
||||||
|
2. **cut_obiter** — אמרת-אגב שהערכאה **לא הכריעה בה**. סימנים: "אין צורך להכריע", "מבלי לקבוע מסמרות", "איני רואה לקבוע מסמרות", "לא ראינו לקבוע", "ניתן/יש להניח ... אך", "למעלה מן הצורך", "אגב אורחא", או הסתמכות על "לכאורה" כבסיס.
|
||||||
|
- מבחן Wambaugh: אם שלילת הכלל **לא** הייתה משנה את תוצאת הפסק → obiter.
|
||||||
|
|
||||||
|
3. **cut_application** — קביעה שתלויה ב**עובדות התיק הספציפי** ואינה בת-הכללה: שמות צדדים ("המשיבים", "המערערים", שם משפחה), "במקרה דנן/שבפנינו", סכומים/תאריכים/מספרים ספציפיים למחלוקת, יישום הכלל על המבנה/ההיתר הקונקרטי. זהו "ציטוט שטוב שיש" — המחשה, לא הלכה.
|
||||||
|
|
||||||
|
4. **cut_thin** — restatement דק: ה-rule_statement כמעט מעתיק את supporting_quote בלי הפשטה; **או** הכלל מנוסח כרקע/מוסכמה ("אין חולק כי...") ולא כהכרעה.
|
||||||
|
|
||||||
|
5. **cut_quote** — ה-supporting_quote קטוע באמצע משפט / חסר, או quote_verified=false וההלכה נשענת עליו.
|
||||||
|
|
||||||
|
6. **keep** — עיקרון משפטי אמיתי, מובחן, בר-הכללה, שהוכרע, עם ציטוט תומך שלם.
|
||||||
|
|
||||||
|
## כללי הכרעה — רמה אגרסיבית
|
||||||
|
המטרה: להשאיר רק את **גרעין העקרונות המובחנים**. עדיף תמציתי ומדויק על פני שלם-ומנופח.
|
||||||
|
|
||||||
|
- **cut_application אסרטיבי:** כל קביעה שנשענת על עובדות/צדדים/סכומים ספציפיים לתיק → cut_application, גם אם משתמעת ממנה הלכה. ההלכה המופשטת כבר אמורה להופיע בנפרד; היישום עצמו מיותר.
|
||||||
|
- **מיזוג facets חופפים (cut_duplicate מורחב):** אם שתי הלכות עונות על **אותה שאלה משפטית** גם אם מזווית/פן שונה — מזג לנציג הכללי/binding ביותר. דוגמאות למיזוג: עקרונות-משנה בתוך אותו נושא (סמכות ועדת הערר, מתחם שיקול-הדעת התכנוני, מיצוי הליכים, בטלות יחסית).
|
||||||
|
- **גבול המיזוג (שמור):** אל תמזג הלכות שעונות על **שאלות משפטיות שונות** (למשל "מועד 30 יום להגשת ערר" ≠ "עקרון מיצוי ההליכים"; "פרשנות תיקון 43" ≠ "סמכות לפי סיווג הבקשה"). מזג פנים-של-אותה-שאלה, לא בין-שאלות.
|
||||||
|
- **dedup מושגי הוא העיקרי:** רוב החיתוך מ-cut_duplicate. שים לב לעקרונות שחוזרים 3-5 פעמים בניסוחים שונים וגם ל-facets שחוזרים סביב אותו נושא.
|
||||||
|
- בספק בין keep ל-cut בקטגוריה מאבדת-מידע: ברמה זו **נטה לחתוך** (אך לעולם לא למזג שאלות-משפטיות שונות).
|
||||||
|
|
||||||
|
## פלט (JSON בלבד)
|
||||||
|
מערך, פריט לכל הלכה:
|
||||||
|
```json
|
||||||
|
[{"halacha_index": <int>, "verdict": "keep|cut_duplicate|cut_obiter|cut_application|cut_thin|cut_quote", "cluster_canonical_index": <int או null>, "reason": "<משפט אחד>"}]
|
||||||
|
```
|
||||||
@@ -446,3 +446,88 @@ The draft's biggest structural error was adding the "נבאר" doctrinal paragra
|
|||||||
- [ ] Update block-schema.md: block order for 1xxx cases (ט before ז)
|
- [ ] Update block-schema.md: block order for 1xxx cases (ט before ז)
|
||||||
- [ ] Update daphna-architecture-by-outcome.md: add "bridge planning" analysis for rejections
|
- [ ] Update daphna-architecture-by-outcome.md: add "bridge planning" analysis for rejections
|
||||||
- [ ] Update writer system prompt: mandatory "להלן מתוך" pattern
|
- [ ] Update writer system prompt: mandatory "להלן מתוך" pattern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lessons from Weekly Feedback (May 31, 2026)
|
||||||
|
|
||||||
|
### Source
|
||||||
|
- Chair feedback summary for week ending 2026-05-31
|
||||||
|
- Case: 8126-03-25 (ערר על חבות בהיטל השבחה - יעקב עמיאל), entries from CMPA-62
|
||||||
|
|
||||||
|
### 34. Don't Manufacture Doubt About Clear Statutes
|
||||||
|
- **Lesson:** סעיף 19(ג)(2) לתוספת השלישית קובע באופן חד-משמעי כי תקופת המגורים היא ארבע שנים מגמר הבנייה — אסור להציע "פרשנות חלופית" של שנה אחת או להכניס שאלות פתוחות על נוסח חוק שהוא ברור; הצגת ספק מלאכותי בכלל ברור מערפלת את הניתוח ומחלישה את הכרעה.
|
||||||
|
- **Rule:** When a statutory provision is unambiguous on its face, the analysis must state it as the binding rule — not as one possible reading among others. Spurious interpretive doubt is a methodology failure, not a sign of intellectual humility.
|
||||||
|
|
||||||
|
### 35. Writer/QA Sync Gap — Two Sources of Truth
|
||||||
|
- **Problem:** legal-writer updates `decision_blocks` in the DB, but legal-qa reads from `drafts/decision.md` on disk. In CMPA-62 the writer reported updating block headers in DB but the file did not re-sync, causing QA-2 to fail on exactly the same issue twice.
|
||||||
|
- **Lesson:** Single source of truth is mandatory — either the writer must write to BOTH the DB and the decision.md file in one atomic step, or there must be an automatic `regenerate-draft` hook that runs after every block update so the file always reflects the latest DB state. Two unsynchronized sources will keep producing the same false-fail loop.
|
||||||
|
- **Owner:** Infrastructure task — not a writer/QA prompt fix.
|
||||||
|
- **✅ RESOLVED (GAP-88, 2026-06-06):** `block_writer._update_draft_file` is now an automatic regenerate hook called from `store_block` (every persist) **and** `renumber_all_blocks` — so `drafts/decision.md` always reflects `decision_blocks`. legal-qa already validates against the DB; both sides are now identical.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lessons from Chair Feedback Backlog (June 6, 2026)
|
||||||
|
|
||||||
|
### Source
|
||||||
|
- Consolidation of all unresolved `chair_feedback` entries (21 items) from cases
|
||||||
|
1033-25, 1130-25 (קרית יערים), 1200-25 (קרית ענבים), 8126-03-25, 8137-24.
|
||||||
|
- Folded manually as part of closing the feedback→agent-knowledge loop. Some
|
||||||
|
overlap with earlier sections (1200-25, weekly-feedback) is intentional — this
|
||||||
|
section is the authoritative roll-up of the backlog.
|
||||||
|
|
||||||
|
### 36. Planning Background Is Argumentation, Not "General Info" (1130-25)
|
||||||
|
- **Lesson:** רקע תכנוני בהחלטה אינו "מידע כללי" — הוא משרת סוגיה ספציפית ומנוסח כחלק מהארגומנטציה הסילוגיסטית. בניתוח שינוי נסיבות, היסטוריית התכנון מראש ועד הפסקה האחרונה חיונית: היא ההנחה התחתונה (עובדות) של הסילוגיזם, לא רקע ניטרלי.
|
||||||
|
- **Rule:** When the discussion turns on change-of-circumstances, write the full planning history (every plan, every amendment, with years) as the factual premise of the argument — not as background filler.
|
||||||
|
|
||||||
|
### 37. Detail the Content of Another Body's Actions When Cited as Evidence (1130-25)
|
||||||
|
- **Lesson:** כשעמדת ועדת הערר מסתמכת על פעולות של גוף אחר (ועדה מחוזית) כראיה לשינוי נסיבות — חובה לפרט את **תוכן** אותן פעולות (מה התבקש, מה אושר, אילו תנאים), לא רק לציין שהתרחשו.
|
||||||
|
- **Rule:** "The district committee approved similar plans in 2023 and 2024" is insufficient — specify what each plan requested and what was approved, so the reader can judge whether it's truly comparable.
|
||||||
|
|
||||||
|
### 38. Map/GIS Images Are Visual Evidence, Not Decoration (1130-25)
|
||||||
|
- **Lesson:** תמונות מפה/GIS בהחלטות תכנון ובניה הן חלק מהארגומנטציה — ראיה ויזואלית שמשלימה את הניתוח הטקסטואלי (מיקום חלקות, סמיכות גיאוגרפית, כבישים ותשתיות מתוכננות). הכותב יסמן placeholder `[תמונה: <תיאור>]` והיו"ר תכניס בעריכה הסופית.
|
||||||
|
- **Rule:** When geographic proximity or planned infrastructure matters to the analysis, insert an image placeholder in the discussion — it is evidence, treated like any other.
|
||||||
|
|
||||||
|
### 39. Address Parallel Appeals in the Same Area Explicitly (1130-25)
|
||||||
|
- **Lesson:** כשיש עררים מקבילים באותו אזור (למשל ערר 1194-25 בחלקה סמוכה) — ההחלטה צריכה להתייחס לכך במפורש, לציין את ההבחנה בין התיקים, ולהבהיר שכל בקשה נבחנת לגופה. "אפקט דומינו" שהתממש הוא עובדה תכנונית, לא חשש תיאורטי.
|
||||||
|
- **Rule:** Name the parallel appeal, state how the present case differs, and reaffirm case-by-case examination.
|
||||||
|
|
||||||
|
### 40. The Chair's Text Skeleton Is a Structural Directive (1130-25)
|
||||||
|
- **Lesson:** שלד טקסט שהיו"ר מספקת (זרימה נרטיבית + נקודות מפתח ממוספרות) הוא הנחיה מבנית מחייבת — הכותב צריך לעקוב אחרי המבנה ולמלא בתוכן מלא, לא לנסח מחדש את הסדר. ה-placeholder "..." מסמן מעבר שצריך להשלים.
|
||||||
|
- **Rule:** When `get_chair_directions` / analysis-and-research.md contains a narrative skeleton, follow it step-by-step; treat each numbered point as a required paragraph.
|
||||||
|
|
||||||
|
### 41. Block Order in Licensing (1xxx): ט Before ז (1200-25)
|
||||||
|
- **Lesson:** בתיקי רישוי (1xxx) — בלוק ט (תכניות חלות) צריך להופיע **לפני** בלוק ז (טענות), לא אחריו. הסדר הנכון: ה→ו→ט→ז→ח→י→יא→יב. הרציונל: הקורא צריך להכיר את המסגרת הנורמטיבית (התכניות) לפני שהוא קורא את טענות הצדדים על פרשנותן.
|
||||||
|
- **Rule:** For 1xxx cases, emit applicable plans (ט) before the parties' claims (ז). See `docs/block-schema.md`.
|
||||||
|
|
||||||
|
### 42. "להלן מתוך [מסמך]:" Is Mandatory (1200-25)
|
||||||
|
- **Lesson:** תבנית "להלן מתוך [שם המסמך]:" היא חובה בכל מקום שמתייחסים למסמך מקור — placeholder להכנסת ציטוט ישיר/תמונה. דוגמאות: "להלן מתוך הוראות התכנית:", "להלן מתוך פרוטוקול הדיון:", "להלן מתוך הבקשה להיתר:". See `skills/decision/SKILL.md`.
|
||||||
|
- **Rule:** Every reference to a source document gets a "להלן מתוך [exact doc name]:" placeholder.
|
||||||
|
|
||||||
|
### 43. Block ו Must Contain a Full Timeline (1200-25)
|
||||||
|
- **Lesson:** בלוק ו חייב לספר את "הסיפור" המלא של התיק עם ציר זמן: מתי הוגשה הבקשה, מתי פורסמה, כמה התנגדויות הוגשו, מתי התקיימו דיונים בוועדה מקומית ומה הוחלט בכל אחד, ומתי הוגש הערר. כל ישיבה עם תאריך + תוצאה.
|
||||||
|
- **Rule:** Block ו is a dated narrative, not a one-liner.
|
||||||
|
|
||||||
|
### 44. Point-Plan vs. Comprehensive-Plan Harmony (1200-25)
|
||||||
|
- **Lesson:** בתיק רישוי שבו המבקש מקדם גם תכנית — חובה לנתח האם התכנית הנקודתית תואמת את התכנית הכוללנית. אם יש סתירה (למשל השוואה כמותית: הכוללנית מקצה 4,404 מ"ר לכל המסחר ביישוב, מול 1,425 מ"ר בבקשה אחת) — זה **מחזק** את הדחייה. מסגרת "גשר תכנוני": שימוש חורג יכול לגשר על פער תכנוני רק אם התכנית המקודמת תואמת את הכיוון הכולל (כוכבה תורן).
|
||||||
|
- **Rule:** Check `search_case_documents` for pending plans; compare point-plan to comprehensive-plan; a contradiction strengthens rejection.
|
||||||
|
|
||||||
|
### 45. Don't Skip the "Non-Profit Institution" Threshold in s.19(ב)(4) (8137-24)
|
||||||
|
- **Lesson:** כשמסמכי יסוד של מוסד מוגשים, אין לדלג על תנאי "המוסד שאין עיסוקו לשם קבלת רווחים" בס' 19(ב)(4) — זהו התנאי **הראשון** ויש לבססו על ציטוט פסקאות ספציפיות מתעודות היסוד (חוקה, תקנון, הסכמים), לא על רישום מלכ"ר בלבד. רישום ≠ ראיה חלוטה (תקדים הלפרן, ערר מרכז 8013-03-21). יש לתחם: הפרק מכריע בתנאי הזהות+אי-רווח בלבד; תנאי השימוש לפרק נפרד.
|
||||||
|
- **Rule:** In betterment-levy exemption cases, the non-profit-identity condition is condition #1 — prove it via specific cited paragraphs of the foundational documents, never via registration status alone.
|
||||||
|
|
||||||
|
### 46. Distinguish Appeal-Letter Claims from Correspondence Claims (1033-25)
|
||||||
|
- **Lesson:** בדיקת כיסוי הטענות (claims_coverage) צריכה להבחין בין טענות שעלו בכתב הערר (חובה לענות) לבין טענות שעלו בתכתובות/תגובות בין הצדדים (לא חייבות מענה עצמאי, במיוחד כשהערר מתקבל במלואו וההחלטה בוטלה). סימון טענות-תכתובת כ"לא נענו" הוא false-positive.
|
||||||
|
- **Rule:** Only claims raised in the appeal letter itself require a dedicated answer; correspondence-only claims do not, especially when the appeal is fully accepted. (Also tracked as a system task — the automated check needs this distinction.)
|
||||||
|
|
||||||
|
### System/Infrastructure Items (NOT writer lessons)
|
||||||
|
These two entries are technical gaps, not decision-writing lessons — captured in TaskMaster, not consumed by the writer:
|
||||||
|
- **claims_coverage check** (1033-25): the automated coverage check must distinguish appeal-letter claims from correspondence claims (see #46).
|
||||||
|
- **DB↔file sync gap** (8126-03-25): see #35 above — writer writes to `decision_blocks` (DB) while QA reads `drafts/decision.md` (disk). Infrastructure fix.
|
||||||
|
|
||||||
|
### Note on case-specific issue-ordering entries
|
||||||
|
Two 1200-25 entries recorded a case-specific issue order (threshold → plan interpretation
|
||||||
|
→ ancillary-vs-primary → significant-deviation → comprehensive-plan → grouped: reasoning,
|
||||||
|
traffic) with no generalizable rule. They are case artifacts, captured in that case's
|
||||||
|
analysis-and-research.md — no general lesson folded.
|
||||||
|
|
||||||
|
|||||||
297
docs/spec/00-constitution.md
Normal file
297
docs/spec/00-constitution.md
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# 00 — חוקת המערכת (Constitution)
|
||||||
|
|
||||||
|
זהו שער-הכניסה היחיד לספ המערכת *עוזר משפטי*. הוא מגדיר את הייעוד, עקרונות-העבודה,
|
||||||
|
תבנית ה-invariant, פרוטוקול-האימות, ה-invariants הגלובליים (G1–G11), כללי-ההנדסה,
|
||||||
|
אינדקס הספ ונספח המקורות. כל קובץ-תחום (01–07, X1–X5) כפוף לחוקה זו ומפנה אליה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ייעוד
|
||||||
|
|
||||||
|
> מערכת AI שמסייעת ליו"ר ועדת הערר לתכנון ובנייה (מחוז ירושלים, עו"ד דפנה תמיר) לנסח
|
||||||
|
> **החלטות מעין-שיפוטיות כתובות ומנומקות** — מסמכים משפטיים פורמליים שעומדים לביקורת
|
||||||
|
> שיפוטית — תוך שמירה על **הקול, השיקול והאחריות של היו"ר**.
|
||||||
|
|
||||||
|
- **משרת:** יו"ר הוועדה (משתמש-על) והסוכנים הפועלים בשמה.
|
||||||
|
- **מחזור-חיים:** ניהול תיקים → בסיס ידע (3 קורפוסים) → אחזור סמנטי (RAG) → סיוע-כתיבה
|
||||||
|
(12 בלוקים, סגנון דפנה) → ייצוא DOCX.
|
||||||
|
- **3 סוגי עררים:** רישוי ובנייה (1xxx, חם), היטל השבחה (8xxx, קר), פיצויים ס'197 (9xxx, קר).
|
||||||
|
- **ה"למה" העמוק:** המערכת מסייעת — היו"ר מכריעה (שערים קריטיים ידניים בכוונה); מנוע
|
||||||
|
צבירת-ידע (לומד מהחלטות סופיות ומפידבק); רב-חברתי (CMP/CMPA).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. עקרונות-עבודה
|
||||||
|
|
||||||
|
1. **אסור להניח שהקיים תקין (בהנדסה).** כל מה שמופה בקוד = "טענה לבדיקה", לא "אמת".
|
||||||
|
"תקין" מבחינה הנדסית נגזר ממקורות חיצוניים סמכותיים, לא מהמערכת שתחת חשד.
|
||||||
|
2. **פרוטוקול אימות 3-מקורות — חל על החלטות הנדסה/פיתוח בלבד:** כל invariant הנדסי/
|
||||||
|
ארכיטקטוני (תכנון ובניית האפליקציה — נתונים, מזהים, ingest, אחזור) מגובה ב-**≥3 מקורות
|
||||||
|
סמכותיים מוכרים** בעלי ידע מקצועי מוכח. כשאין 3 → מסומן `⚠ UNVERIFIED` ומועלה ליו"ר.
|
||||||
|
**התוכן המשפטי אינו כפוף לכלל זה** — הסמכות עליו היא היו"ר (דפנה) ומסמכי-הפרויקט
|
||||||
|
(block-schema, decision-methodology, legal-decision-lessons, skills/decision), לא
|
||||||
|
מקורות חיצוניים.
|
||||||
|
3. **מנגנון:** מחקר עצמאי → טיוטה לביקורת. קודם חוקרים את הסמכויות החיצוניות (להחלטות
|
||||||
|
הנדסה), ורק אז מנסחים את ה-invariant.
|
||||||
|
4. **מודל-שיתוף:** על החלטות טכניות/אדריכליות אני חוקר ומכריע מקצועית ומציג תוצאה
|
||||||
|
מוגמרת. שואל את היו"ר (חיים) רק במקום שבו *הוא* הסמכות — כוונה, עדיפויות עסקיות,
|
||||||
|
ותוכן משפטי-דומייני.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. תבנית-invariant
|
||||||
|
|
||||||
|
מבנה אחיד לכל חוק בספ (בכל הקבצים):
|
||||||
|
|
||||||
|
```
|
||||||
|
### INV-<תחום><מספר>: <כותרת קצרה>
|
||||||
|
**כלל:** <ניסוח נורמטיבי חד — מה חייב להתקיים>
|
||||||
|
**מקורות:** <≥3 סמכויות> | סטטוס: verified / ⚠ UNVERIFIED
|
||||||
|
**אכיפה:** <היכן/איך נאכף — schema / ולידציית-כתיבה / בדיקת-בריאות / שער אנושי>
|
||||||
|
**הפרה ידועה:** <דוגמה מהמערכת, אם יש — מקשר ל-audit; אחרת "—">
|
||||||
|
```
|
||||||
|
|
||||||
|
> **שדה המקורות לפי סוג invariant (שלושה מודלי-סמכות):**
|
||||||
|
> 1. **הנדסי** (תאוריה כללית — נתונים/אחזור/ארכיטקטורה) → `מקורות` = ≥3 סמכויות חיצוניות + `סטטוס`.
|
||||||
|
> 2. **תוכן-משפטי** → `מקור-סמכות` = היו"ר + מסמכי-הפרויקט (ללא סטטוס-אימות חיצוני).
|
||||||
|
> 3. **פרויקטלי-תפעולי** (עובדות על האינטגרציה/התפעול של *מערכת זו* — אין להן סמכות
|
||||||
|
> חיצונית, למשל "wakeup דרך API") → `מקור-סמכות` = ה-runbooks של הפרויקט
|
||||||
|
> (CLAUDE.md, HEARTBEAT.md, סקריפטים), **קשור** ל-invariant הנדסי גלובלי שאותו הוא מיישם.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. פרוטוקול-אימות
|
||||||
|
|
||||||
|
> חל על **invariants הנדסיים (G1–G10)** — החלטות תכנון/בניית האפליקציה. ה-invariant של
|
||||||
|
> תוכן-משפטי (G11) **אינו** כפוף לפרוטוקול זה; הסמכות עליו היא היו"ר + מסמכי-הפרויקט.
|
||||||
|
|
||||||
|
- כל invariant הנדסי נושא שדה `מקורות` + `סטטוס: verified / ⚠ UNVERIFIED`.
|
||||||
|
- **verified** = מגובה ב-**≥3 מקורות סמכותיים** מוכרים בעלי ידע מקצועי מוכח.
|
||||||
|
- **⚠ UNVERIFIED** = החלטה הנדסית שיש לה פחות מ-3 מקורות סמכותיים מאומתים. פריט כזה
|
||||||
|
**לא מוכרע לבד** — מועלה ליו"ר עם הערת-הסלמה המתעדת מה חסר והיכן יאומת.
|
||||||
|
- החלטות טכניות → מחקר עצמאי + הכרעה מקצועית + הצגת תוצאה. שאלה ליו"ר רק במקום
|
||||||
|
שבו הוא הסמכות (ראה עיקרון 4 לעיל).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Invariants גלובליים
|
||||||
|
|
||||||
|
אלה החוקים החוצים את כל המערכת — לב החוקה. הם נחלקים לשני סוגים לפי **מקור-הסמכות**:
|
||||||
|
|
||||||
|
- **G1–G10 — invariants הנדסיים** (תכנון/בניית האפליקציה): כל אחד מגובה ב-**≥3 סמכויות
|
||||||
|
טכניות מוכרות** (נספח §8). ביחד הם מייבשים את כשל-השורש החוזר: מסלולים/קורפוסים
|
||||||
|
מקבילים שמתפצלים (drift) בלי שכבה שמגדירה ואוכפת "תקין".
|
||||||
|
- **G11 — invariant תוכן-משפטי:** הסמכות עליו היא **היו"ר (דפנה) + מסמכי-הפרויקט**, לא
|
||||||
|
מקורות חיצוניים, ואינו כפוף לפרוטוקול ≥3-המקורות.
|
||||||
|
|
||||||
|
### 5א. Invariants הנדסיים (G1–G10)
|
||||||
|
|
||||||
|
### INV-G1: מזהה קנוני מנורמל בכתיבה
|
||||||
|
**כלל:** לכל ישות יש מזהה קנוני יחיד, **מנורמל בנקודת-הכתיבה** (לא תיקון-סלחני בקריאה
|
||||||
|
בלבד). `case_number` נשמר בצורה קנונית אחת; קריאה משווה מול הצורה הקנונית, לא מטליאה.
|
||||||
|
**מקורות:** SSOT (Single Source of Truth — normalization principle) · E.F. Codd, First
|
||||||
|
Normal Form (CACM 13(6), 1970) · Martin Kleppmann, *Designing Data-Intensive Applications*
|
||||||
|
(O'Reilly, 2017) | סטטוס: verified
|
||||||
|
**אכיפה:** schema (אילוץ ייחודיות על המפתח הקנוני) + ולידציית-כתיבה בנקודת-הקליטה;
|
||||||
|
מפורט ב-[X1-identifiers.md](X1-identifiers.md) ו-[02-data-model.md](02-data-model.md).
|
||||||
|
**הפרה ידועה:** `_normalize_case_number` סלחני בקריאה בלבד (קומיט "tolerant case_number
|
||||||
|
lookup"); `8126-25` לא נמצא מול האמיתי `8126-03-25` → ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-G2: מקור-אמת יחיד — אין מסלולים מקבילים מתפצלים
|
||||||
|
**כלל:** לכל סוג-נתון יש **מקור-אמת יחיד** ומסלול-קוד קנוני אחד. אסור להוסיף מסלול
|
||||||
|
מקביל ליכולת קיימת — ישויות-אחיות חולקות מסלול קנוני אחד; נתונים נגזרים (derived)
|
||||||
|
משוחזרים מהמקור, לא נכתבים במקביל.
|
||||||
|
**מקורות:** Martin Kleppmann (system of record vs. derived data, *DDIA* 2017) · Martin
|
||||||
|
Fowler (Canonical Data Model) · SSOT (Single Source of Truth) | סטטוס: verified
|
||||||
|
**אכיפה:** ביקורת-ארכיטקטורה + כלל-הנדסה "סימטריה" (§6); מפורט ב-[01-ingest.md](01-ingest.md).
|
||||||
|
**הפרה ידועה:** שני מסלולי ingest מקבילים לישויות-אחיות (`ingest_precedent` מול
|
||||||
|
`ingest_internal_decision`) שמתפצלים — לדוגמה: המסלול החיצוני מתזמן חילוץ metadata
|
||||||
|
(`request_metadata_extraction`), והמסלול הפנימי לא — ולכן ערן סופר 8046/24 נקלטה בלי
|
||||||
|
metadata → ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-G3: ingest אחיד ו-idempotent
|
||||||
|
**כלל:** קליטה היא **אחידה ו-idempotent** — upsert על מפתח דטרמיניסטי. קליטה חוזרת של
|
||||||
|
אותו פריט אינה יוצרת כפילות ואינה משנה תוצאה.
|
||||||
|
**מקורות:** Martin Kleppmann (*DDIA*, idempotence & exactly-once) · Stripe / CDC
|
||||||
|
idempotency-key pattern · ISO 8000 (Data quality) | סטטוס: verified
|
||||||
|
**אכיפה:** ולידציית-כתיבה + מפתח-upsert דטרמיניסטי בנקודת-הקליטה; מפורט ב-
|
||||||
|
[01-ingest.md](01-ingest.md).
|
||||||
|
**הפרה ידועה:** 3 החלטות "סופר" נקלטו ב-3 פורמטים שונים (`8126/24`, ציטוט-מלא
|
||||||
|
כ-case_number) — היעדר upsert דטרמיניסטי → ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-G4: חוזה-שלמות לפני "שמיש / ניתן-לחיפוש"
|
||||||
|
**כלל:** רשומה אינה נחשבת "שמישה" או "ניתנת-לחיפוש" עד ש**שדות-החובה שלה מולאו ואומתו
|
||||||
|
מול spec מפורש**. שלמות נבדקת לפני חשיפה לאחזור.
|
||||||
|
**מקורות:** ISO 8000 (completeness) · DAMA-UK *Six Primary Dimensions for Data Quality*
|
||||||
|
(2013, completeness) · ISO 15489-1:2016 (records reliability) | סטטוס: verified
|
||||||
|
**אכיפה:** חוזה-שלמות באכיפת-קוד + בדיקת-בריאות; מפורט ב-[02-data-model.md](02-data-model.md)
|
||||||
|
ו-[03-retrieval.md](03-retrieval.md).
|
||||||
|
**הפרה ידועה:** ערן סופר 8046/24 אונדקס עם `headnote`/`summary`/`tags` ריקים → ממצא
|
||||||
|
ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-G5: metadata מלא + הפרדת-קורפוס נאכפת בכל query
|
||||||
|
**כלל:** לכל פריט מואנדקס יש **metadata מלא** (כולל מזהה-מקור וסוג-קורפוס), ו**הפרדת-
|
||||||
|
הקורפוס נאכפת בכל מסלול-query** — אין דליפה בין 3 הקורפוסים.
|
||||||
|
**מקורות:** Pinecone (multitenancy / metadata filtering) · RAG attribution (Lewis et al.,
|
||||||
|
2020, NeurIPS) · ISO 8000 (Data quality) | סטטוס: verified
|
||||||
|
**אכיפה:** schema (metadata חובה) + פילטר-קורפוס נאכף בשכבת-החיפוש; מפורט ב-
|
||||||
|
[03-retrieval.md](03-retrieval.md) ו-[X5-audit-provenance.md](X5-audit-provenance.md).
|
||||||
|
**הפרה ידועה:** משימה #56 — דליפת `source_kind` ב-`halacha_filters` בין קורפוסים →
|
||||||
|
ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-G6: re-index בכל שינוי תוכן
|
||||||
|
**כלל:** כל שינוי-תוכן של פריט מואנדקס מפעיל **re-index** של ה-embedding שלו. אין
|
||||||
|
embeddings מיושנים מול התוכן הנוכחי.
|
||||||
|
**מקורות:** Pinecone (index freshness / data sync) · Weaviate (re-vectorization on update)
|
||||||
|
· RAG freshness (Lewis et al., 2020) | סטטוס: verified
|
||||||
|
**אכיפה:** טריגר re-index בנקודת-העדכון + בדיקת-בריאות (גילוי drift); מפורט ב-
|
||||||
|
[02-data-model.md](02-data-model.md) ו-[03-retrieval.md](03-retrieval.md).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-G7: מיזוג RRF — לא סכום-ציונים
|
||||||
|
**כלל:** מיזוג תוצאות בין retrievers נעשה **לפי דירוג (Reciprocal Rank Fusion)**, לא
|
||||||
|
סכום/ממוצע ציונים גולמיים — שכן ציונים בסקיילים שונים אינם בני-השוואה ישירה.
|
||||||
|
**מקורות:** Elastic (*Reciprocal Rank Fusion*) · Weaviate (*Hybrid Search Explained*) ·
|
||||||
|
OpenSearch / Azure AI Search (corroborating RRF guidance) | סטטוס: verified
|
||||||
|
**אכיפה:** קוד-המיזוג בשכבת-האחזור; מפורט ב-[03-retrieval.md](03-retrieval.md).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-G8: איכות-אחזור נמדדת — precision + recall
|
||||||
|
**כלל:** איכות-האחזור **נמדדת אמפירית (precision + recall)** באמצעות eval harness, לא
|
||||||
|
מונחת. שינוי בשכבת-האחזור מלווה במדידה.
|
||||||
|
**מקורות:** Manning, Raghavan & Schütze, *Introduction to Information Retrieval* (CUP,
|
||||||
|
2008) · RAG evaluation literature (Lewis et al., 2020 ואחריו) · Elastic (relevance
|
||||||
|
evaluation guidance) | סטטוס: verified
|
||||||
|
**אכיפה:** eval harness + בדיקת-בריאות תקופתית; מפורט ב-[03-retrieval.md](03-retrieval.md).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-G9: עקיבוּת-מקור + audit-trail ל-AI
|
||||||
|
**כלל:** כל פלט של המערכת **עקיב למקורו** (citation/provenance), וכל שימוש ב-AI מתועד
|
||||||
|
ב-**audit-trail** הניתן לביקורת.
|
||||||
|
**מקורות:** Council of Europe / CEPEJ — *European Ethical Charter on AI in judicial systems*
|
||||||
|
(2018, user-control principle) · NCSC/JTC — *Principles & Practices for AI Use in Courts* ·
|
||||||
|
ISO 15489-1:2016 (records authenticity/integrity) | סטטוס: verified
|
||||||
|
**אכיפה:** audit-trail באכיפת-קוד + עקיבוּת-מקור בכל פלט; מפורט ב-
|
||||||
|
[X5-audit-provenance.md](X5-audit-provenance.md).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-G10: המערכת מסייעת — שערים אנושיים הם invariant
|
||||||
|
**כלל:** המערכת **מסייעת ואינה מחליפה את שיקול-הדעת האנושי**. השערים האנושיים (אישור
|
||||||
|
הלכה, בחירת תוצאה, פידבק היו"ר) הם **invariant — חובה, לא רשות**.
|
||||||
|
**תיקון (החלטת-יו"ר 2026-05-31):** שער אישור-ההלכה יכול להיות מסופק ע"י **טיפול שיפוטי מצטבר**
|
||||||
|
(citator פנימי), לא רק ע"י היו"ר — הלכה ש**אומצה (followed) ע"י ≥N ערכאות/ועדות מצטטות, ללא
|
||||||
|
טיפול שלילי**, מאושרת אוטומטית. זהו **שיפוט אנושי** (של המצטטים), לא שיפוט-AI (ה-AI רק מזהה
|
||||||
|
ומסווג את הטיפול הקיים). **שער-היו"ר נשאר חובה** לזנב הלא-מצוטט ולכל טיפול שלילי
|
||||||
|
(distinguished/overruled). מפורט ב-[X11-citation-corroboration.md](X11-citation-corroboration.md)
|
||||||
|
(INV-COR1–COR6).
|
||||||
|
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* ("never replace human
|
||||||
|
judgment") · CEPEJ (2018, under user control) · Federal Judicial Center — *Judicial Writing
|
||||||
|
Manual* (2d ed.) · [לתיקון — מקורות פתוחים:] Fowler et al., *Network Analysis and the Law*
|
||||||
|
(Political Analysis 15:3, 2007) — ציטוטים-נכנסים = מדד-סמכות · Demir & Canbaz, *Validate Your
|
||||||
|
Authority: Benchmarking LLMs on Multi-Label Precedent Treatment Classification* (NLLP/ACL, 2025) ·
|
||||||
|
Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוטי-מצטבר כמתודולוגיה מתועדת
|
||||||
|
| סטטוס: verified
|
||||||
|
**אכיפה:** שערים אנושיים בקוד-הזרימה (gate לא ניתן לעקיפה); מסלול-corroboration ב-
|
||||||
|
[X11](X11-citation-corroboration.md); מפורט ב-[05-qa-review.md](05-qa-review.md).
|
||||||
|
**הפרה ידועה:** 10/19 הלכות מאושרות, התגלה במקרה — שער ידני שקוף בלי נראות backlog →
|
||||||
|
ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### 5ב. Invariant תוכן-משפטי (G11)
|
||||||
|
|
||||||
|
### INV-G11: תוכן החלטה מנומקת
|
||||||
|
**כלל:** החלטה מנומקת מקיימת: **רקע ניטרלי** (עובדות בלבד, ללא שיפוט) · **ללא כפילות**
|
||||||
|
(בלוק דיון מפנה, לא חוזר) · **מענה לטענות הצד המפסיד** · **"מבחן-השופט"** (קריא לשופט שלא
|
||||||
|
מכיר את התיק) · **טענות מקוריות בלבד** (מכתבי הטענות).
|
||||||
|
**מקור-סמכות:** היו"ר (עו"ד דפנה תמיר) + מסמכי-הפרויקט — [block-schema.md](../block-schema.md),
|
||||||
|
[decision-methodology.md](../decision-methodology.md), [legal-decision-lessons.md](../legal-decision-lessons.md),
|
||||||
|
[skills/decision/SKILL.md](../../skills/decision/SKILL.md). **אינו כפוף לפרוטוקול ≥3-המקורות החיצוני** —
|
||||||
|
זהו תוכן משפטי-דומייני, באחריות היו"ר.
|
||||||
|
**אכיפה:** שערי QA + checklist-תוכן לפי סוג-ערר; מפורט ב-[04-analysis-writing.md](04-analysis-writing.md)
|
||||||
|
ו-[05-qa-review.md](05-qa-review.md).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. כללי-הנדסה (מונעים הישנות)
|
||||||
|
|
||||||
|
- **סימטריה:** אסור להוסיף מסלול מקביל ליכולת קיימת — מרחיבים את המסלול הקנוני
|
||||||
|
(נגזר מ-[G2](#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)).
|
||||||
|
- **נרמול לא תיקון-תסמין:** מתקנים נתון במקור (קנוני), לא מטליאים בקריאה
|
||||||
|
(נגזר מ-[G1](#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)).
|
||||||
|
- **Quality-at-source:** שלמות נאכפת קרוב ככל האפשר לקליטה (Martin Fowler — Data Mesh /
|
||||||
|
quality-at-source; נגזר מ-[G4](#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)).
|
||||||
|
- **אין בליעה שקטה:** רשומה חסרה/פגומה מסומנת ומדווחת, לא מתקבלת בשקט (תואם feedback
|
||||||
|
קיים — אסור bare `except: pass`; נגזר מ-[G4](#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. אינדקס הספ
|
||||||
|
|
||||||
|
> הערה: כל קבצי הספ (00, 01–07, X1–X10) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
||||||
|
|
||||||
|
| קובץ | תפקיד | אוכף invariants |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| [00-constitution.md](00-constitution.md) | חוקה — ייעוד, invariants גלובליים, כללי-הנדסה, אינדקס | G1–G11 |
|
||||||
|
| [01-ingest.md](01-ingest.md) | קליטה מאוחדת: מסמכי-תיק / פסיקה חיצונית / החלטות-ועדה — חוזה מסלול-יחיד | G2, G3 |
|
||||||
|
| [02-data-model.md](02-data-model.md) | אחסון: ישויות (cases, case_law, documents, chunks, halachot…) + חוזה-שלמות לכל ישות | G1, G4, G6 |
|
||||||
|
| [03-retrieval.md](03-retrieval.md) | 3 קורפוסים + כלי-חיפוש · hybrid/RRF · attribution · eval harness | G4, G5, G6, G7, G8, G9 |
|
||||||
|
| [04-analysis-writing.md](04-analysis-writing.md) | חילוץ טענות · 12 בלוקים · סגנון דפנה (מצטט block-schema.md) | G11 |
|
||||||
|
| [05-qa-review.md](05-qa-review.md) | שערי QA + שערים אנושיים (אישור הלכה, בחירת תוצאה, פידבק) כ-invariant | G10, G11 |
|
||||||
|
| [06-export.md](06-export.md) | ייצוא DOCX לפי תבנית דפנה | G2, G9 |
|
||||||
|
| [07-learning.md](07-learning.md) | Hermes · לקחים · לולאת פידבק היו"ר · צמיחת קורפוס (quality-at-source) | G4, G10 |
|
||||||
|
| [X1-identifiers.md](X1-identifiers.md) | מודל מזהים קנוני: נרמול case_number בכתיבה · cases מול case_law · פורמטי ציטוט | G1 |
|
||||||
|
| [X2-multi-company.md](X2-multi-company.md) | CMP/CMPA · 14 סוכנים · כללי sync | G2 |
|
||||||
|
| [X3-integration-deploy.md](X3-integration-deploy.md) | Paperclip (wakeup, ניתוב comments, webhooks) · Coolify/pm2 | G2, G9 (תפעולי) |
|
||||||
|
| [X4-agents.md](X4-agents.md) | מפת הסוכנים (דומיין + סוכני-התהליך) | G10 |
|
||||||
|
| [X5-audit-provenance.md](X5-audit-provenance.md) | audit-trail לשימוש ב-AI · עקיבוּת כל מקור מצוטט · שלמות-רשומה | G5, G9 |
|
||||||
|
| [X6-ui-api-contract.md](X6-ui-api-contract.md) | web-ui ↔ API: OpenAPI=SSoT · response models · envelope · SSE · חוזי-טופס + כללי-עיצוב | G2, G4, G9 (UI) |
|
||||||
|
| [X7-paperclip-client-params.md](X7-paperclip-client-params.md) | לקוח-Paperclip קנוני · IDs/env/keys מ-config · webhook idempotency/אירוע מגורס | G2, G9 (תפעולי) |
|
||||||
|
| [X8-field-provenance.md](X8-field-provenance.md) | מקור-מילוי כל שדה (דטרמיניסטי/Opus/ידני/נגזר) · preservation · trust · verbatim-quote | G9, 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 |
|
||||||
|
| [X11-citation-corroboration.md](X11-citation-corroboration.md) | citator פנימי — תיקוף הלכות בטיפול-שיפוטי מצטבר · תיקון-G10 מבוקר · סף-corroboration · התאמה-להלכה | G9, G10 |
|
||||||
|
|
||||||
|
> **X6–X10 (מחזור-2):** מכסים את 8 משטחי-האפליקציה שמחוץ לצינור-הליבה (אינטגרציה, web-ui, מילוי-שדות,
|
||||||
|
> אחסון-ניתוחים, כלי-MCP, deploy/env). הממצאים ב-[gap-audit.md](gap-audit.md) (GAP-24..62 → FU-9..15)
|
||||||
|
> וב-[ui-audit.md](ui-audit.md). הרחבות-אחות: [02-data-model](02-data-model.md) (INV-DM4–DM6), [X4-agents](X4-agents.md) (INV-AG3).
|
||||||
|
|
||||||
|
**עקרונות:** כל קובץ עצמאי, ממוקד, agent-readable, יעד ≤~500 שורות (תפיחה = סימן
|
||||||
|
לפיצול). מסמכים קיימים (`architecture.md`, `product-specification.md`, `block-schema.md`…)
|
||||||
|
לא נמחקים ולא משוכפלים — מצוטטים כ"מקור" ומאומתים מול הסמכויות; סתירה = ממצא ל-audit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. נספח מקורות סמכותיים
|
||||||
|
|
||||||
|
(מאומתים במחקר 30.5.2026)
|
||||||
|
|
||||||
|
**ממשל-AI שיפוטי + שערים אנושיים (G9, G10)**
|
||||||
|
- NCSC / JTC — *Court Technology Standards* + *Principles & Practices for AI Use in Courts*.
|
||||||
|
https://www.ncsc.org/our-centers-projects/joint-technology-committee/court-technology-standards
|
||||||
|
- Council of Europe / CEPEJ — *European Ethical Charter on the use of AI in judicial
|
||||||
|
systems* (2018, user-control principle).
|
||||||
|
- Federal Judicial Center — *Judicial Writing Manual* (2d ed.) — לעניין שיקול-הדעת
|
||||||
|
האנושי בכתיבה השיפוטית.
|
||||||
|
https://www.fjc.gov/content/judicial-writing-manual-pocket-guide-judges-second-edition
|
||||||
|
|
||||||
|
**אחזור / RAG / IR**
|
||||||
|
- Lewis et al. (2020) — *Retrieval-Augmented Generation* (NeurIPS).
|
||||||
|
https://arxiv.org/abs/2005.11401
|
||||||
|
- Manning, Raghavan & Schütze — *Introduction to Information Retrieval* (CUP, 2008).
|
||||||
|
https://nlp.stanford.edu/IR-book/
|
||||||
|
- Elastic — *Reciprocal Rank Fusion*.
|
||||||
|
https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion
|
||||||
|
- Pinecone — *Implement multitenancy*.
|
||||||
|
https://docs.pinecone.io/guides/index-data/implement-multitenancy
|
||||||
|
- Weaviate — *Hybrid Search Explained*. https://weaviate.io/blog/hybrid-search-explained
|
||||||
|
|
||||||
|
**שלמות-נתונים / איכות / רשומות**
|
||||||
|
- DAMA-DMBOK2 + DAMA-UK — *Six Primary Dimensions for Data Quality* (2013).
|
||||||
|
- ISO 8000 — Data quality (8000-8/61/110).
|
||||||
|
- ISO 15489-1:2016 — Records management (authenticity/reliability/integrity/usability).
|
||||||
|
- Martin Kleppmann — *Designing Data-Intensive Applications* (O'Reilly, 2017).
|
||||||
|
- E.F. Codd — Relational model & normalization (CACM 13(6), 1970).
|
||||||
|
- Martin Fowler — Canonical Data Model / Data Mesh (quality-at-source).
|
||||||
|
|
||||||
|
(נספח המקורות מתייחס ל-invariants ההנדסיים G1–G10 בלבד. התוכן המשפטי — G11 — נשען על
|
||||||
|
מסמכי-הפרויקט וסמכות היו"ר, כמפורט ב-G11.)
|
||||||
150
docs/spec/01-ingest.md
Normal file
150
docs/spec/01-ingest.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# 01 — קליטה מאוחדת (Unified Ingest Contract)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומפרט את **חוזה הקליטה** של כל סוגי
|
||||||
|
ה-intake. הוא אוכף את [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
(מקור-אמת יחיד, אין מסלולים מקבילים) ואת [G3](00-constitution.md#inv-g3-ingest-אחיד-ו-idempotent)
|
||||||
|
(ingest אחיד ו-idempotent), ונשען על [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)
|
||||||
|
ו-[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן).
|
||||||
|
|
||||||
|
כשל-השורש שהקובץ מייבש: **שני מסלולי ingest לישויות-אחיות שמתפצלים** — `ingest_precedent`
|
||||||
|
(פסיקה חיצונית) מול `ingest_internal_decision` (החלטות-ועדה). מסלולים מקבילים גוררים drift:
|
||||||
|
פריט שנקלט במסלול אחד מקבל טיפול שונה מפריט במסלול האחר, והפער מתגלה רק כשרשומה חסרה
|
||||||
|
metadata או לא נמצאת בחיפוש. החוזה כאן מגדיר **מסלול קנוני אחד** ש-3 סוגי ה-intake עוברים בו.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. שלושת סוגי ה-intake
|
||||||
|
|
||||||
|
| סוג-intake | מזהה-קנוני | קורפוס-יעד | מאפיין ייחודי |
|
||||||
|
|------------|------------|------------|----------------|
|
||||||
|
| מסמכי-תיק (case documents) | `case_number` + מזהה-מסמך | תיק ערר פעיל | משויך לתיק, מסווג לפי סוג-מסמך |
|
||||||
|
| פסיקה חיצונית (external precedent) | `citation` (קנוני) | `case_law` (external) | staging לפי `source_type`, ולידציית-enums, citation guard, multimodal |
|
||||||
|
| החלטות-ועדה (internal-committee) | `case_number` (קנוני) | `case_law` (internal_committee) | staging לפי district, `chair_name` חובה, גזירת district/proceeding_type |
|
||||||
|
|
||||||
|
שלושתם הם **ישויות-אחיות**: אותו טיפוס-עיבוד (קובץ → טקסט → chunks → embeddings → metadata
|
||||||
|
→ הלכות), נבדלים בפרמטרים בלבד — לא במסלול-קוד. זוהי משמעות "סימטריה" (חוקה §6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. המסלול הקנוני (Canonical Pipeline)
|
||||||
|
|
||||||
|
צעדי-העיבוד, **בסדר מחייב**. כל סוג-intake עובר את אותם צעדים; ההבדל הוא אילו פרמטרים
|
||||||
|
מוזרקים בקלט, לא אילו צעדים מורצים.
|
||||||
|
|
||||||
|
1. **Stage file** — העתקה דטרמיניסטית לאחסון המתמיד. נתיב-ה-staging הוא פרמטר
|
||||||
|
(`source_type` לפסיקה חיצונית, district להחלטות-ועדה), לא ענף-קוד נפרד.
|
||||||
|
2. **Extract text** — `extractor.extract_text` → `(text, page_count, page_offsets)`.
|
||||||
|
טקסט ריק = כשל מדווח (לא בליעה שקטה; חוקה §6).
|
||||||
|
3. **Strip Nevo preamble** — `extractor.strip_nevo_preamble` להסרת עטיפת-Nevo. **אחיד לכל סוג.**
|
||||||
|
4. **Chunk** — היררכי (`chunk_document_hierarchical`) אם `PARENT_DOC_RETRIEVAL_ENABLED`,
|
||||||
|
אחרת שטוח (`chunk_document`). **אותו ענף-flag בדיוק לכל סוג** — בורר הצ'אנקינג נגזר
|
||||||
|
מ-config, לא מסוג-ה-intake.
|
||||||
|
5. **Embed** — `embeddings.embed_texts(..., input_type="document")` ל-children (היררכי)
|
||||||
|
או לכל ה-chunks (שטוח).
|
||||||
|
6. **Store chunks** — `store_precedent_chunks_hierarchical` או `store_precedent_chunks`.
|
||||||
|
7. **Page-image embed (multimodal)** — אם `MULTIMODAL_ENABLED` **וגם** הקובץ PDF
|
||||||
|
**וגם** `page_count>0`: הטמעת עמודי-תמונה (`_embed_precedent_pages`). non-fatal:
|
||||||
|
מסלול-הטקסט כבר הצליח. **התנאי אחיד** — הפעלה תלויה ב-flag+סוג-קובץ, לא בסוג-ה-intake.
|
||||||
|
8. **Queue metadata extraction** — `request_metadata_extraction(case_law_id)`. נדרש לכל
|
||||||
|
סוג שתומך במטא-דאטה (ראה [INV-ING3](#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)).
|
||||||
|
9. **Queue halacha extraction** — `request_halacha_extraction(case_law_id)`.
|
||||||
|
10. **Set statuses** — `extraction_status=completed`, `halacha_status=pending`.
|
||||||
|
החילוץ ה-LLM-י (metadata + הלכות) רץ בנפרד מ-Claude Code המקומי
|
||||||
|
(`precedent_process_pending`), כי `claude` CLI אינו זמין בקונטיינר.
|
||||||
|
|
||||||
|
> **צעדים שחייבים להיות אחידים בכל סוג (תיקון האסימטריה):** 2 (extract), 3 (strip-Nevo),
|
||||||
|
> 4 (בורר-chunk לפי flag), 5–6 (embed+store), **7 (multimodal — לפי flag+PDF, לא לפי
|
||||||
|
> סוג)**, **8–9 (תיזמון שני החילוצים)**, 10 (statuses). מה ש**רשאי** להשתנות לפי סוג:
|
||||||
|
> נתיב-ה-staging (צעד 1), ולידציות-קלט ספציפיות, וגזירת-שדות (district/proceeding_type)
|
||||||
|
> — אלו פרמטרים של אותו מסלול, לא מסלול נפרד.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-ING1: מסלול-קליטה קנוני יחיד
|
||||||
|
**כלל:** כל סוגי ה-intake (מסמכי-תיק / פסיקה חיצונית / החלטות-ועדה) זורמים דרך **פונקציית-
|
||||||
|
קליטה קנונית אחת**. סוג-intake חדש מורחב דרך **פרמטרים** של אותה פונקציה — לעולם לא דרך
|
||||||
|
פונקציה מקבילה. נתון-נגזר (district, proceeding_type) מחושב בתוך המסלול, לא בענף נפרד.
|
||||||
|
**מקורות:** Martin Kleppmann, *DDIA* (O'Reilly, 2017 — system of record יחיד) · Martin
|
||||||
|
Fowler (*Canonical Data Model*) · SSOT (Single Source of Truth) | סטטוס: verified
|
||||||
|
**אכיפה:** ביקורת-ארכיטקטורה + כלל-הנדסה "סימטריה" (חוקה §6); הקליטה מתנקזת לפונקציה אחת
|
||||||
|
שמקבלת פרמטרי-סוג. אוכף את [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
|
||||||
|
**הפרה ידועה:** היום קיימים **שני** מסלולים — `ingest_precedent`
|
||||||
|
(`precedent_library.py:88`) ו-`ingest_internal_decision` (`internal_decisions.py:73`) —
|
||||||
|
שמשכפלים את צעדי 2–10 ומתפצלים בפרטים → ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-ING2: קליטה idempotent על המזהה הקנוני
|
||||||
|
**כלל:** הקליטה היא **idempotent על המזהה הקנוני** (`citation` לפסיקה חיצונית,
|
||||||
|
`case_number` להחלטות-ועדה ולמסמכי-תיק). קליטה חוזרת של אותו פריט = **upsert** —
|
||||||
|
אין רשומה כפולה ואין chunks כפולים; התוצאה זהה.
|
||||||
|
**מקורות:** Martin Kleppmann, *DDIA* (idempotence & exactly-once) · Stripe / CDC
|
||||||
|
idempotency-key pattern · ISO 8000 (Data quality) | סטטוס: verified
|
||||||
|
**אכיפה:** מפתח-upsert דטרמיניסטי על המזהה הקנוני בנקודת-הקליטה (`create_external_case_law`
|
||||||
|
/ `create_internal_committee_decision`) + ולידציית-כתיבה; קשור ל-
|
||||||
|
[X1-identifiers.md](X1-identifiers.md) (נרמול בכתיבה). אוכף את
|
||||||
|
[G3](00-constitution.md#inv-g3-ingest-אחיד-ו-idempotent).
|
||||||
|
**הפרה ידועה:** 3 החלטות "סופר" נקלטו ב-3 פורמטים (`8126/24`, ציטוט-מלא כ-`case_number`)
|
||||||
|
— היעדר מפתח-upsert דטרמיניסטי גרר רשומות-כפל במקום עדכון → ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-ING3: תור חילוץ מטא-דאטה + הלכות לכל סוג
|
||||||
|
**כלל:** חילוץ-מטא-דאטה **וגם** חילוץ-הלכות מתוזמנים (queue) עבור **כל** סוג-intake שתומך
|
||||||
|
בהם — תיזמון אחיד, **לא** מותנה במסלול. שני התורים נפתחים יחד בסיום העיבוד הלא-LLM-י.
|
||||||
|
**מקורות:** ISO 8000 (completeness) · DAMA-UK *Six Primary Dimensions for Data Quality*
|
||||||
|
(2013, completeness) · Martin Fowler (quality-at-source) | סטטוס: verified
|
||||||
|
**אכיפה:** קריאה ל-`request_metadata_extraction` **ו**-`request_halacha_extraction`
|
||||||
|
בנקודת-סיום-הקליטה, לכל סוג; חוזה-שלמות יסמן רשומה ללא מטא-דאטה כלא-שמישה
|
||||||
|
([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), מפורט ב-
|
||||||
|
[02-data-model.md](02-data-model.md)).
|
||||||
|
**הפרה ידועה:** המסלול הפנימי (`internal_decisions.py:208`) מתזמן **רק**
|
||||||
|
`request_halacha_extraction` ואינו קורא ל-`request_metadata_extraction` (בניגוד
|
||||||
|
ל-`precedent_library.py:292-293` שקורא לשניהם) → ערן סופר 8046/24 נקלטה **בלי
|
||||||
|
metadata** (headnote/summary/tags ריקים) → ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-ING4: re-index בקליטה-חוזרת (upsert ⇒ re-embed)
|
||||||
|
**כלל:** קליטה-חוזרת ששינתה את תוכן-הפריט מפעילה **re-index** — chunks ו-embeddings
|
||||||
|
ישנים נמחקים ונבנים מחדש מהתוכן החדש. אין embeddings מיושנים אחרי upsert.
|
||||||
|
**מקורות:** Pinecone (index freshness / data sync) · Weaviate (re-vectorization on update)
|
||||||
|
· RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
|
||||||
|
**אכיפה:** טריגר re-embed בנתיב ה-upsert של הקליטה + בדיקת-בריאות לגילוי drift; מפורט
|
||||||
|
ב-[02-data-model.md](02-data-model.md) ו-[03-retrieval.md](03-retrieval.md). אוכף את
|
||||||
|
[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. מצב קיים מול יעד — audit-findings
|
||||||
|
|
||||||
|
הסעיף מתעד את ההבדלים בין שני המסלולים הקיימים. **אלו תסמינים לאיחוד תחת המסלול הקנוני,
|
||||||
|
לא התנהגויות תקינות.** כל פריט אומת מול הקוד בפועל.
|
||||||
|
|
||||||
|
- **חילוץ מטא-דאטה חסר במסלול הפנימי.** ראה [INV-ING3](#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)
|
||||||
|
(ההפרה המתועדת שם — ערן סופר 8046/24). **יעד:** צעד 8 (תור חילוץ) אחיד לשני הסוגים.
|
||||||
|
- **ולידציית-enums א-סימטרית.** המסלול החיצוני מוודא `practice_area`/`source_type` מול
|
||||||
|
רשימות חוקיות (`precedent_library.py:131-134`); המסלול הפנימי **אינו** מוודא enums.
|
||||||
|
**יעד:** ולידציה אחידה בנקודת-הקליטה (חוזה-שלמות, [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)).
|
||||||
|
- **staging מפוצל.** החיצוני עושה stage לפי `source_type` (`precedent_library.py:138`);
|
||||||
|
הפנימי עושה stage לפי district (`internal_decisions.py:113-115`). **יעד:** נתיב-staging
|
||||||
|
כפרמטר של המסלול הקנוני (צעד 1), לא ענף-קוד.
|
||||||
|
- **גזירת-שדות רק במסלול הפנימי.** הפנימי גוזר district מ-court (`:104`) ו-proceeding_type
|
||||||
|
מ-appeal_subtype/case_name (`:105`), ודורש `chair_name` (`:134`). החיצוני אינו גוזר אלו.
|
||||||
|
**יעד:** גזירה כפרמטר אופציונלי של המסלול הקנוני (שדות-סוג, לא מסלול-סוג).
|
||||||
|
- **citation guard רק במסלול החיצוני.** החיצוני חוסם ציטוט שמתחיל ב-`ערר`/`בל"מ`
|
||||||
|
ומפנה למסלול הפנימי (`precedent_library.py:124-130`). היעד שומר על השער הזה כניתוב-סוג
|
||||||
|
בתוך המסלול הקנוני, לא כהפרדת-פונקציות.
|
||||||
|
- **multimodal page-image embed רק במסלול החיצוני.** החיצוני מטמיע עמודי-תמונה כש-
|
||||||
|
`MULTIMODAL_ENABLED` + PDF (`precedent_library.py:272-278`); הפנימי **אינו** מטמיע
|
||||||
|
עמודי-תמונה. **יעד:** צעד 7 אחיד — מותנה ב-flag+סוג-קובץ בלבד.
|
||||||
|
- **fallback `case_name→citation` רק במסלול החיצוני.** החיצוני נופל ל-`citation` כשם
|
||||||
|
כשחסר `case_name` (`precedent_library.py:158`); הפנימי נופל ל-`case_number`
|
||||||
|
(`internal_decisions.py:130`). **יעד:** מדיניות-fallback אחת לשם-תצוגה במסלול הקנוני.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md) — invariants גלובליים + כללי-הנדסה.
|
||||||
|
- [02-data-model.md](02-data-model.md) — סכמת-האחסון + חוזה-שלמות שאוכף את תוצרי הקליטה.
|
||||||
|
- [03-retrieval.md](03-retrieval.md) — אחזור, re-index, eval — היעד של ה-chunks הנקלטים.
|
||||||
|
- [X1-identifiers.md](X1-identifiers.md) — נרמול המזהה הקנוני בכתיבה (בסיס ל-INV-ING2).
|
||||||
|
- [X5-audit-provenance.md](X5-audit-provenance.md) — שלמות-רשומה + עקיבוּת-מקור של פריט נקלט.
|
||||||
192
docs/spec/02-data-model.md
Normal file
192
docs/spec/02-data-model.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# 02 — מודל-הנתונים (Data Model & Completeness Contract)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומגדיר את **מודל-הנתונים הקנוני (TARGET)**
|
||||||
|
של עוזר משפטי — הישויות, שדות-המפתח, והיכן יושב כל פריט מואנדקס. הוא אוכף את
|
||||||
|
[G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה) (מזהה קנוני יחיד),
|
||||||
|
[G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) (חוזה-שלמות) ו-
|
||||||
|
[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן) (re-index בשינוי-תוכן).
|
||||||
|
|
||||||
|
> **TARGET, לא תיאור-מצב.** המודל כאן הוא היעד הקנוני. כל מקום שבו ה-schema בפועל
|
||||||
|
> (`mcp-server/src/legal_mcp/services/db.py`) סוטה ממנו — מתועד כ-**audit-finding** (§4),
|
||||||
|
> תסמין לאיחוד, לא התנהגות תקינה. כל טענה על ה-schema הקיים מצוטטת `file:line`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. הישויות הקנוניות
|
||||||
|
|
||||||
|
הטבלה מונה את ישויות-הליבה. "מזהה-קנוני" = השדה היחיד המזהה רשומה ([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)).
|
||||||
|
|
||||||
|
| ישות | תפקיד | מזהה-קנוני | שדות-מפתח (מאומתים `db.py`) |
|
||||||
|
|------|--------|-------------|------------------------------|
|
||||||
|
| `cases` | תיק ערר חי (1xxx/8xxx/9xxx) | `case_number` + `proceeding_type` | `title`, `status`, `practice_area`, `appeal_subtype`, `proceeding_type`, `chair_name` (`db.py:74-91,182-189,747,912`) |
|
||||||
|
| `documents` | מסמך-מקור משויך לתיק | `id` (UUID); FK→`cases` | `doc_type`, `title`, `file_path`, `extracted_text`, `extraction_status`, `page_count` (`db.py:93-104`) |
|
||||||
|
| `document_chunks` | chunk של מסמך-תיק + embedding | `id`; FK→`documents`/`cases` | `chunk_index`, `content`, `section_type`, `embedding vector(1024)`, `page_number` (`db.py:106-116`) |
|
||||||
|
| `case_law` | קורפוס פסיקה — חיצוני **וגם** החלטות-ועדה | ראה [§2 + INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות) | `case_name`, `court`, `practice_area`, `source_kind`, `proceeding_type`, `source_type`, `headnote`, `summary`, `subject_tags`, `extraction_status`, `halacha_extraction_status` (`db.py:366-378,522-526,599-611,883,907`) |
|
||||||
|
| `precedent_chunks` | chunk של פסק-דין מואנדקס (`source_kind='external_upload'`/`internal_committee`) | `id`; FK→`case_law` | `chunk_index`, `content`, `section_type`, `page_number`, `embedding vector(1024)`, `content_tsv` (`db.py:624-634,776`) |
|
||||||
|
| `halachot` | הלכה מחולצת — כלל + ציטוט מילולי | `id`; FK→`case_law` | `rule_statement`, `supporting_quote`, `rule_type`, `practice_areas`, `subject_tags`, `confidence`, `quote_verified`, `review_status`, `embedding`, `rule_tsv` (`db.py:644-666,780`) |
|
||||||
|
| `decisions` | החלטת-תיק מנוסחת (גרסה) | `id`; `UNIQUE(case_id, version)` | `version`, `status`, `outcome`, `outcome_summary` (`db.py:299-314`) |
|
||||||
|
| `decision_blocks` | בלוק (12) של החלטה | `id`; `UNIQUE(decision_id, block_id)` | `block_id`, `block_index`, `content`, `status` (`db.py:317-334`) |
|
||||||
|
| `claims` | טענת-צד (בלוק ז) | `id`; FK→`cases` | `party_role`, `claim_text`, `source_document`, `claim_type`, `claim_handling` (`db.py:349-359,506-512`) |
|
||||||
|
| `chair_feedback` | הערת-יו"ר על טיוטה | `id`; FK→`cases` | `block_id`, `feedback_text`, `category`, `lesson_extracted`, `resolved` (`db.py:452-462`) |
|
||||||
|
| `missing_precedents` | תקדים חסר שהתבקש ולא נמצא | `id` | (`db.py:806`) — backlog ל-quality-at-source |
|
||||||
|
| `style_corpus` | קורפוס-סגנון של דפנה (אימון) | `id`; FK→`documents` | `decision_number`, `full_text`, `practice_area`, `appeal_subtype` (`db.py:118-131`) |
|
||||||
|
|
||||||
|
> שכבות-עזר נוספות (`document_image_embeddings`, `precedent_image_embeddings` — multimodal,
|
||||||
|
> `db.py:707,726`; `case_law_relations` — שרשרת-תיק, `db.py:754`; `precedent_internal_citations`
|
||||||
|
> — גרף-ציטוטים, `db.py:937`) הן נגזרות ([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)):
|
||||||
|
> משוחזרות מהמקור, לא מקור-אמת עצמאי.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. חוזה-שלמות לכל ישות (Completeness Contract)
|
||||||
|
|
||||||
|
[G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) דורש: **רשומה אינה "שמישה /
|
||||||
|
ניתנת-לחיפוש" עד ששדות-החובה שלה מולאו ואומתו מול spec מפורש.** כל ישות מגדירה שתי רמות —
|
||||||
|
**usable** (קיימת ומזוהה) ו-**searchable** (חשופה לאחזור). רשומה שנכשלת בחוזה **מסומנת
|
||||||
|
ומדווחת — לא מתקבלת בשקט** (חוקה §6, "אין בליעה שקטה").
|
||||||
|
|
||||||
|
### 2א. `case_law` — החוזה הקונקרטי
|
||||||
|
|
||||||
|
המזהה הקנוני אינו `case_number` לבדו: `case_law` נושאת **שני** unique partial indexes לפי
|
||||||
|
`source_kind` (`db.py:904-909`) — חיצוני: `UNIQUE(case_number)`; פנימי: `UNIQUE(case_number,
|
||||||
|
proceeding_type)`. לכן המזהה הקנוני הוא **(`case_number` מנורמל, `source_kind`,
|
||||||
|
`proceeding_type`)**.
|
||||||
|
|
||||||
|
**רמת usable** (רשומה לגיטימית):
|
||||||
|
- `case_number` קנוני מנורמל-בכתיבה ([INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות) — **לא** ציטוט-מלא)
|
||||||
|
- `case_name` לא-ריק (לא fallback לציטוט/למספר)
|
||||||
|
- `court` לא-ריק
|
||||||
|
- `practice_area ∈ {rishuy_uvniya, betterment_levy, compensation_197}` (אכוף ב-CHECK, `db.py:614-617`)
|
||||||
|
- `source_kind` מהמילון (`external_upload` / `cited_only` / `internal_committee` / `nevo_seed`) (`db.py:599-601`, `internal_decisions.py:4`)
|
||||||
|
- `proceeding_type ∈ {ערר, בל"מ}` כשפנימי (אכוף ב-CHECK, `db.py:897-899`)
|
||||||
|
|
||||||
|
**רמת searchable** (חשוף לאחזור — מעבר ל-usable):
|
||||||
|
- **≥1 `precedent_chunk`** עם `embedding` לא-NULL (אחרת אין מה לאחזר סמנטית)
|
||||||
|
- **metadata לא-ריק:** לפחות אחד מ-`headnote` / `summary` / `subject_tags` מלא — אלו השדות
|
||||||
|
ש-search מציג ומסנן לפיהם
|
||||||
|
- `extraction_status = completed` (מטא-דאטה הושלם, `db.py:603`)
|
||||||
|
|
||||||
|
**אכיפה מפורשת:** רשומה שעוברת usable אך נכשלת ב-searchable — **מסומנת `searchable=false`
|
||||||
|
ולא מוחזרת מ-search**, ומופיעה ב-health-check כ-backlog. היא **אינה מתקבלת בשקט** כ"זמינה".
|
||||||
|
|
||||||
|
### 2ב. חוזה תמציתי לישויות נוספות
|
||||||
|
|
||||||
|
- `documents` → usable: `file_path`+`doc_type`; searchable: `extraction_status=completed` ו-`extracted_text` לא-ריק ו-≥1 `document_chunk` עם embedding.
|
||||||
|
- `halachot` → usable: `rule_statement`+`supporting_quote`; **searchable: `review_status ∈ {approved, published}` בלבד** — `pending_review`/`rejected` מוסתרות מ-`search_precedent_library` (שער-הלכה ידני, `db.py:644-660`, [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)).
|
||||||
|
- `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק.
|
||||||
|
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
|
||||||
|
|
||||||
|
### 2ג. ישויות-נגזרות (אחסון-ניתוחים)
|
||||||
|
|
||||||
|
מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי
|
||||||
|
ה-provenance של [X8](X8-field-provenance.md) ולשערי [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant):
|
||||||
|
|
||||||
|
| ישות-נגזרת | מקור-מילוי | שער-אישור | קישור-מקור |
|
||||||
|
|------------|------------|-----------|------------|
|
||||||
|
| `claims` | OPUS (`extract_claims`) | — | `source_document` (string, לא-FK) |
|
||||||
|
| `legal_arguments` (+`legal_argument_propositions`) | OPUS (`aggregate_claims_to_arguments`) | **חסר** (בניגוד ל-halachot) | `cited_precedents TEXT[]` (לא-FK) |
|
||||||
|
| `appraiser_facts` | OPUS (`extract_appraiser_facts`) | — | `document_id` (FK); `appraiser_side` default `''` |
|
||||||
|
| `halachot` | OPUS (`halacha_extractor`) | **`review_status`** ✓ | `case_law_id` (FK); `quote_verified` |
|
||||||
|
| `decision_blocks` / `decision_paragraphs` | Opus/script (`write_block`) | `status` | `model_used` + audit-event provenance (FU-7); `citations JSONB` ללא-FK |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-DM1: searchable רק כשחוזה-השלמות מתקיים
|
||||||
|
**כלל:** רשומת `case_law` נחשבת **searchable** אך ורק כשחוזה-השלמות של [§2א](#2א-case_law--החוזה-הקונקרטי)
|
||||||
|
מתקיים במלואו (מזהה קנוני · `case_name`/`court`/`practice_area`/`source_kind` · ≥1 chunk עם
|
||||||
|
embedding · metadata לא-ריק). רשומה שנכשלת **מסומנת `searchable=false` ומדווחת ל-health-check —
|
||||||
|
לא מוחזרת מ-search ולא מתקבלת בשקט**.
|
||||||
|
**מקורות:** ISO 8000 (completeness) · DAMA-UK *Six Primary Dimensions for Data Quality* (2013,
|
||||||
|
completeness) · ISO 15489-1:2016 (records reliability/usability) | סטטוס: verified
|
||||||
|
**אכיפה:** ולידציית-כתיבה בנקודת-הקליטה ([01-ingest.md](01-ingest.md) צעד 8) + בדיקת-בריאות
|
||||||
|
תקופתית שמסמנת backlog; הסינון נאכף בשכבת-החיפוש ([03-retrieval.md](03-retrieval.md)). אוכף את
|
||||||
|
[G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש).
|
||||||
|
**הפרה ידועה:** ערן סופר 8046/24 אונדקס כ-searchable עם `headnote`/`summary`/`subject_tags`
|
||||||
|
ריקים — המסלול הפנימי לא תיזמן חילוץ-מטא-דאטה ([01-ingest INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג),
|
||||||
|
`internal_decisions.py:208`) → ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-DM2: מזהה קנוני יחיד לכל ישות
|
||||||
|
**כלל:** לכל ישות **מזהה קנוני אחד**, מנורמל בכתיבה. **אסור** ששדה-המזהה יאחסן ציטוט-מלא —
|
||||||
|
`case_number` הוא מספר-תיק מנורמל (`8126-03-25`), **לא** מחרוזת-ציטוט (`ערר 8126/24 פלוני נ' הוועדה
|
||||||
|
(נבו...)`). הציטוט המלא חי בשדה ייעודי נפרד (`citation_formatted`, `db.py:1070`), לא במזהה.
|
||||||
|
**מקורות:** SSOT (Single Source of Truth — normalization) · E.F. Codd, First Normal Form (CACM
|
||||||
|
13(6), 1970) · Martin Kleppmann, *Designing Data-Intensive Applications* (O'Reilly, 2017) | סטטוס: verified
|
||||||
|
**אכיפה:** unique partial indexes על המזהה הקנוני (`db.py:904-909`) + נרמול-בכתיבה
|
||||||
|
([X1-identifiers.md](X1-identifiers.md)); ציטוט-מלא ב-`citation_formatted` בלבד. אוכף את
|
||||||
|
[G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה).
|
||||||
|
**הפרה ידועה:** החלטות "סופר" נקלטו עם **ציטוט-מלא כ-`case_number`** (שדה-המזהה של רשומה מכיל את
|
||||||
|
מחרוזת-הציטוט במקום מספר-תיק מנורמל) — חיפוש מול `8126-03-25` נכשל, ו-`_normalize_case_number`
|
||||||
|
(`db.py:1196-1211`) רק **מטליא בקריאה** (סלחני, לא קנוני), בניגוד ל-[G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)
|
||||||
|
→ ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-DM3: שינוי-תוכן ⇒ re-index
|
||||||
|
**כלל:** כל שינוי בתוכן-המקור של ישות מואנדקסת (`content` של chunk, `rule_statement`/`supporting_quote`
|
||||||
|
של הלכה, `full_text`/`extracted_text` של מסמך) מפעיל **re-index** של ה-embedding **ושל
|
||||||
|
ה-tsvector** הנגזרים. אין embedding או `content_tsv`/`rule_tsv`/`meta_tsv` מיושנים מול התוכן.
|
||||||
|
**מקורות:** Pinecone (index freshness / data sync) · Weaviate (re-vectorization on update) ·
|
||||||
|
RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
|
||||||
|
**אכיפה:** טריגר re-embed בנקודת-העדכון + בדיקת-בריאות לגילוי drift; ה-tsvectors `GENERATED ALWAYS
|
||||||
|
… STORED` (`db.py:776-788,1083-1090`) מתעדכנים אוטומטית, אך ה-`embedding` **אינו** generated —
|
||||||
|
הוא תלוי-טריגר. מפורט ב-[03-retrieval.md](03-retrieval.md). אוכף את
|
||||||
|
[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-DM4: לכל ישות-נגזרת — provenance מוצהר
|
||||||
|
**כלל:** כל ישות-נגזרת (claims, legal_arguments, appraiser_facts, decision_blocks, halachot) נושאת
|
||||||
|
**provenance** — מי/מה הפיק (מודל, גרסה, זמן) ולאילו chunks/מקורות היא קשורה. מופע של
|
||||||
|
[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai); מקביל ל-[X8 INV-FP1](X8-field-provenance.md).
|
||||||
|
**מקורות:** ISO 8000-110 (data lineage) · DAMA-DMBOK2 (lineage) · ISO 15489-1:2016 (records authenticity) | סטטוס: verified
|
||||||
|
**אכיפה:** עמודות-provenance + קישור block→source (חלקית דרך audit-event ב-FU-7/GAP-19; ל-legal_arguments טרם).
|
||||||
|
**הפרה ידועה:** `legal_arguments` ללא provenance; `embedding` ללא model/version ([gap-audit GAP-42](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-DM5: פלט-ניתוח של LLM נכנס בשער-אישור (כמו halachot)
|
||||||
|
**כלל:** ישות-נגזרת שמוּלאת ע"י LLM ומשפיעה על ההחלטה נכנסת **לא-מאושרת** עד אישור-יו"ר — אותו שער כמו
|
||||||
|
`halachot.review_status`. מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant); תואם [X8 INV-FP3](X8-field-provenance.md).
|
||||||
|
**מקור-סמכות:** דפוס `halachot.review_status` (`db.py:659`); [05-qa-review.md](05-qa-review.md). (פרויקטלי-תפעולי — משרת G10.)
|
||||||
|
**אכיפה:** שדה-סטטוס-אישור על ישויות-נגזרות מהותיות.
|
||||||
|
**הפרה ידועה:** `legal_arguments` **חסר** שער-אישור — נכתב ומשמש ללא בקרת-יו"ר ([gap-audit GAP-39](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-DM6: ולידציה — CHECK-enums, FK לציטוטים, ללא טבלאות-מקבילות
|
||||||
|
**כלל:** ערכי-enum נאכפים ב-CHECK (לא TEXT חופשי); ציטוט-מקור נשמר כ-FK (לא string/array חופשי); אין שתי
|
||||||
|
טבלאות לאותה ישות. מופע של [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים). **הנדסי.**
|
||||||
|
**מקורות:** E.F. Codd (referential integrity, CACM 1970) · ISO 8000 (validity) · Kleppmann *DDIA* | סטטוס: verified
|
||||||
|
**אכיפה:** CHECK על enums; FK על `cited_precedents`/`decision_paragraphs.citations`; איחוד `case_precedents`↔`case_law`.
|
||||||
|
**הפרה ידועה:** 20+ enums כ-TEXT חופשי; `legal_arguments.cited_precedents TEXT[]` ללא-FK (הזיות-LLM נבלעות); `case_precedents` מול `case_law` מקבילות ([gap-audit GAP-40/42/43](gap-audit.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. מצב קיים מול יעד — audit-findings
|
||||||
|
|
||||||
|
ההבדלים בין ה-schema בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול `db.py`.
|
||||||
|
|
||||||
|
- **`case_law` כפולת-תפקיד ללא מזהה מודע-סוג בכתיבה.** טבלה אחת משרתת פסיקה חיצונית **וגם**
|
||||||
|
החלטות-ועדה, מובדלות ב-`source_kind` (`db.py:599`). המזהה הקנוני האמיתי הוא טריפלט
|
||||||
|
(`case_number, source_kind, proceeding_type`, `db.py:904-909`), אך השדה `case_number TEXT
|
||||||
|
UNIQUE NOT NULL` המקורי (`db.py:368`) הוסר רק ב-V15 (`db.py:902-903`) — מורשת שאפשרה את
|
||||||
|
הפרת [INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות). **יעד:** נרמול-בכתיבה אכוף + ציטוט-מלא רק ב-`citation_formatted`.
|
||||||
|
- **`summary` קיים על `case_law` אך לא בחוזה-הקליטה הפנימי.** העמודה קיימת (`db.py:373`) אך
|
||||||
|
המסלול הפנימי אינו ממלא אותה (כפועל-יוצא מהיעדר חילוץ-מטא-דאטה, [INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)).
|
||||||
|
**יעד:** searchable מותנה ב-metadata לא-ריק ([INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
|
||||||
|
- **שני שדות-סטטוס-חילוץ נפרדים, ללא דגל-`searchable` מפורש.** `extraction_status` +
|
||||||
|
`halacha_extraction_status` (`db.py:603-605`) מתארים תהליך, אך אין שדה יחיד שמסמן "עבר
|
||||||
|
חוזה-שלמות → searchable". **יעד:** דגל/view נגזר ש-search מסנן לפיו, מגובה health-check.
|
||||||
|
- **`embedding` אינו `GENERATED` (בניגוד ל-tsvector).** ה-tsvectors מסונכרנים אוטומטית
|
||||||
|
(`db.py:776,780,1083`), אך ה-`embedding vector(1024)` תלוי-טריגר חיצוני — נקודת-drift אפשרית
|
||||||
|
ל-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). **יעד:** טריגר re-embed מובטח + health-check ל-drift.
|
||||||
|
- **`halachot.review_status` כשער-searchable ללא נראות-backlog.** הסינון תקין (`pending_review`
|
||||||
|
מוסתר, `db.py:659`), אך אין נראות כמה ממתינות — תואם את ההפרה הידועה ב-[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||||
|
(10/19 מאושרות, התגלה במקרה). **יעד:** health-check חושף backlog-הלכות.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md) — invariants גלובליים (G1, G4, G6) + כללי-הנדסה.
|
||||||
|
- [01-ingest.md](01-ingest.md) — חוזה-הקליטה שמייצר את הרשומות; חוזה-השלמות כאן אוכף את תוצריו.
|
||||||
|
- [03-retrieval.md](03-retrieval.md) — שכבת-האחזור שאוכפת את הסינון searchable + re-index.
|
||||||
|
- [X1-identifiers.md](X1-identifiers.md) — נרמול המזהה הקנוני בכתיבה (בסיס ל-INV-DM2).
|
||||||
|
- [X5-audit-provenance.md](X5-audit-provenance.md) — שלמות-רשומה + עקיבוּת-מקור.
|
||||||
|
- [X8-field-provenance.md](X8-field-provenance.md) — מקור-מילוי השדות (בסיס ל-INV-DM4/DM5).
|
||||||
|
- [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) — הכלים שמייצרים את הישויות-הנגזרות.
|
||||||
178
docs/spec/03-retrieval.md
Normal file
178
docs/spec/03-retrieval.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# 03 — אחזור (Retrieval: Corpora · Hybrid/RRF · Attribution · Eval)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומגדיר את **שכבת-האחזור הקנונית (TARGET)** —
|
||||||
|
שלושת הקורפוסים, כלי-החיפוש המכוונים לכל אחד, מנגנון ה-hybrid (dense + lexical) ומיזוג ה-RRF,
|
||||||
|
עקיבוּת-המקור והרמוניית-המדידה. הוא אוכף את
|
||||||
|
[G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) (חוזה-שלמות לפני "ניתן-לחיפוש"),
|
||||||
|
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query) (הפרדת-קורפוס בכל query),
|
||||||
|
[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן) (re-index),
|
||||||
|
[G7](00-constitution.md#inv-g7-מיזוג-rrf--לא-סכום-ציונים) (מיזוג RRF),
|
||||||
|
[G8](00-constitution.md#inv-g8-איכות-אחזור-נמדדת--precision--recall) (eval) ו-
|
||||||
|
[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) (עקיבוּת-מקור).
|
||||||
|
|
||||||
|
> **TARGET, לא תיאור-מצב.** כל מקום שבו הקוד בפועל סוטה מהיעד מתועד כ-**audit-finding** (§5),
|
||||||
|
> תסמין לתיקון — לא התנהגות תקינה. כל טענה על הקוד מצוטטת `file:line`.
|
||||||
|
|
||||||
|
כשל-השורש שהקובץ מייבש: **3 קורפוסים שחולקים תשתית-אחזור אחת, אך הפרדת-הקורפוס נאכפת רק על
|
||||||
|
חלק ממסלולי-ה-query** — כך שפריט מקורפוס אחד דולף לתוצאה של חיפוש בקורפוס אחר (cross-corpus leak).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. שלושת הקורפוסים וכלי-החיפוש
|
||||||
|
|
||||||
|
| קורפוס | טבלת-אחסון | `source_kind` | כלי-MCP מכוון | אימות `file:line` |
|
||||||
|
|--------|------------|----------------|----------------|--------------------|
|
||||||
|
| מסמכי-תיק + קורפוס-סגנון דפנה | `document_chunks` | — (מובחן ב-`case_id`/`practice_area`) | `search_decisions` · `search_case_documents` · `find_similar_cases` | `search.py:15,91,145` → `hybrid_search.py:41` (`search_documents_hybrid`) → `db.search_similar` (`hybrid_search.py:56`) |
|
||||||
|
| פסיקה חיצונית סמכותית | `case_law` + `precedent_chunks`/`halachot` | `external_upload` | `search_precedent_library` | `search.py`→`precedent_library.py:235` → `search_library` → `hybrid_search.py:89,101` (`source_kind="external_upload"`) |
|
||||||
|
| החלטות ועדות-ערר (פנימי) | `case_law` + `precedent_chunks`/`halachot` | `internal_committee` | `search_internal_decisions` | `search.py:228` → `internal_decisions.py:395,411-418` (`source_kind="internal_committee"`) → `hybrid_search.py:89` |
|
||||||
|
|
||||||
|
**הבחנת-שם קריטית (לא קורפוס רביעי):** `precedent_search_library` (`server.py:160`) הוא כלי **שונה** —
|
||||||
|
מחפש בציטוטים שהיו"ר צירפה ידנית לתיקים (`case_precedents`), לא בקורפוס הפסיקה הסמכותית.
|
||||||
|
`search_precedent_library` (`server.py:280`) הוא הכלי לקורפוס החיצוני. אל תבלבל ביניהם.
|
||||||
|
|
||||||
|
הקורפוס החיצוני והפנימי **חולקים טבלה אחת** (`case_law`), מובחנים ב-`source_kind` בלבד
|
||||||
|
([02-data-model §2א](02-data-model.md#2א-case_law--החוזה-הקונקרטי)). שניהם רצים דרך **אותן** פונקציות-DB
|
||||||
|
(`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**,
|
||||||
|
ושם נולדת ההפרה ב-§5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. עיצוב ה-hybrid retrieval
|
||||||
|
|
||||||
|
לכל קורפוס שני retrievers הטרוגניים המאוחים ב-RRF, ולא בסכום-ציונים — ראה [INV-RET3](#inv-ret3-מיזוג-retrievers-הטרוגניים-ב-rrf-בלבד):
|
||||||
|
|
||||||
|
1. **Dense (semantic)** — דמיון-קוסינוס מול `embedding vector(1024)` (voyage). פסיקה:
|
||||||
|
`search_precedent_library_semantic` (`db.py:3143`); מסמכי-תיק: `db.search_similar`.
|
||||||
|
2. **Lexical (BM25-style)** — `ts_rank_cd` מול `content_tsv`/`rule_tsv`/`meta_tsv` (Postgres FTS).
|
||||||
|
פסיקה: `search_precedent_library_lexical` (`db.py:3366`). מופעל כש-`BM25_HYBRID_ENABLED`
|
||||||
|
(`hybrid_search.py:139`).
|
||||||
|
3. **מיזוג sem+lex** — `_merge_sem_lex` (`hybrid_search.py:240-308`), נוסחת
|
||||||
|
`rrf_score = 1/(k+sem_rank) + 1/(k+lex_rank)` (`hybrid_search.py:256`).
|
||||||
|
4. **שכבת-multimodal (אופציונלית)** — כש-`MULTIMODAL_ENABLED`, עמודי-תמונה (voyage-multimodal-3)
|
||||||
|
מאוחים לטקסט ב-RRF נפרד: `_merge` (`hybrid_search.py:311-389`), `text_weight/(k+rank) +
|
||||||
|
img_weight/(k+rank)` (`hybrid_search.py:356-357`).
|
||||||
|
5. **Diversity cap (MMR-style)** — `_diversify_by_case_law` (`hybrid_search.py:196-225`): לכל היותר
|
||||||
|
`max_per_case_law` hits לכל `case_law_id`, כדי שפסק-דין יחיד לא ישתלט על הרשימה.
|
||||||
|
|
||||||
|
> **למה RRF ולא סכום משוקלל:** קוסינוס (~0.4–0.7) ו-`ts_rank_cd` (~0.001–0.5, תלוי-אורך-שאילתה)
|
||||||
|
> חיים בסקיילים שונים — סכום משוקלל היה נותן לצד אחד להשתלט במקרה. RRF מאחד **לפי דירוג**, ולכן
|
||||||
|
> עמיד להבדלי-סקייל (`hybrid_search.py:248-252,319-323`). תואם feedback קיים (RRF, לא weighted-sum).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-RET1: הפרדת-קורפוס נאכפת ב-100% ממסלולי-ה-query
|
||||||
|
**כלל:** הפרדת 3 הקורפוסים נאכפת בכל מסלול-אחזור — **גם בסינון ה-chunks וגם בסינון ההלכות**.
|
||||||
|
אין פריט מקורפוס אחד שמופיע בתוצאת חיפוש שכוון לקורפוס אחר. כל ענף-SQL (semantic/lexical,
|
||||||
|
chunks/halachot) נושא את אותו תנאי-`source_kind`.
|
||||||
|
**מקורות:** Pinecone — *Implement multitenancy* (metadata-filter isolation per tenant) · RAG
|
||||||
|
attribution (Lewis et al., 2020, NeurIPS — pinned non-leaking provenance) · ISO 8000 (Data
|
||||||
|
quality / conformance) | סטטוס: verified
|
||||||
|
**אכיפה:** תנאי-`source_kind` בכל ענף-SQL בשכבת-החיפוש; בדיקת-בריאות שמריצה שאילתת-ביקורת
|
||||||
|
(חיפוש מכוון-קורפוס שמחזיר פריט בעל `source_kind` זר = כשל). אוכף את
|
||||||
|
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
|
||||||
|
**הפרה ידועה:** משימה #56 — `halacha_filters` **אינם** כוללים `cl.source_kind` ב-
|
||||||
|
`search_precedent_library_semantic` (`db.py:3168`, ענף ה-halacha; לעומת `chunk_filters` שכן —
|
||||||
|
`db.py:3169`) **וב**-`search_precedent_library_lexical` (`db.py:3401` מול `db.py:3402`). שני
|
||||||
|
ה-`halacha_sql` עושים `JOIN case_law cl` בלי לסנן `source_kind` (`db.py:3236-3238`, `db.py:3475-3477`)
|
||||||
|
→ הלכות מהקורפוס הפנימי דולפות לתוצאות החיפוש בקורפוס החיצוני ולהפך → ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-RET2: אין החזרה/אינדוקס בלי metadata מלא + locator פתיר
|
||||||
|
**כלל:** פריט אינו מוחזר מ-search (ואינו נחשף לאחזור) אלא אם **שדות-החובה שלו מולאו**
|
||||||
|
([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)) **ובידו locator פתיר למקור**
|
||||||
|
(`case_law_id`/`document_id` + מזהה-עמוד/chunk). רשומה ללא metadata לא-ריק או ללא chunk עם
|
||||||
|
embedding מסומנת `searchable=false` ולא מוחזרת ([02-data-model INV-DM1](02-data-model.md#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
|
||||||
|
**מקורות:** Pinecone (metadata filtering — completeness לפני שליפה) · RAG attribution (Lewis et
|
||||||
|
al., 2020) · ISO 8000 (completeness) | סטטוס: verified
|
||||||
|
**אכיפה:** חוזה-שלמות בנקודת-הקליטה ([02-data-model §2](02-data-model.md#2-חוזה-שלמות-לכל-ישות-completeness-contract))
|
||||||
|
+ סינון בשכבת-החיפוש (`embedding IS NOT NULL`, `db.py:3239,3271`; `length(trim(content))>=50`,
|
||||||
|
`db.py:3274`) + בדיקת-בריאות שחושפת backlog. אוכף את
|
||||||
|
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
|
||||||
|
**הפרה ידועה:** ערן סופר 8046/24 — נקלטה בלי metadata (headnote/summary/tags ריקים), היעדר
|
||||||
|
תיזמון חילוץ-מטא-דאטה במסלול הפנימי ([01-ingest INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)),
|
||||||
|
אך ללא דגל-`searchable` מפורש שימנע את חשיפתה לאחזור → ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-RET3: מיזוג retrievers הטרוגניים ב-RRF בלבד
|
||||||
|
**כלל:** מיזוג תוצאות בין retrievers שונים (semantic↔lexical, text↔image) נעשה **אך ורק
|
||||||
|
לפי דירוג (Reciprocal Rank Fusion)** — לעולם לא סכום/ממוצע ציונים גולמיים, שכן ציונים בסקיילים
|
||||||
|
שונים אינם בני-השוואה ישירה.
|
||||||
|
**מקורות:** Elastic — *Reciprocal Rank Fusion* · Weaviate — *Hybrid Search Explained* · Manning,
|
||||||
|
Raghavan & Schütze, *Introduction to Information Retrieval* (CUP, 2008) | סטטוס: verified
|
||||||
|
**אכיפה:** מיזוג sem+lex ב-`_merge_sem_lex` (`hybrid_search.py:240-308`, נוסחה ב-`:256`) ומיזוג
|
||||||
|
text+image ב-`_merge` (`hybrid_search.py:311-389`, נוסחה ב-`:356-357`), שניהם עם
|
||||||
|
`k = MULTIMODAL_RRF_K`. אוכף את [G7](00-constitution.md#inv-g7-מיזוג-rrf--לא-סכום-ציונים).
|
||||||
|
**מצב:** **כבר ממומש** (codify, לא gap) — הקוד הקיים מיישם RRF נכון בשני המיזוגים. ה-invariant
|
||||||
|
מקבע את ההתנהגות הקיימת כחוזה. **הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-RET4: איכות-אחזור נמדדת ב-eval harness עומד (precision + recall)
|
||||||
|
**כלל:** איכות-האחזור **נמדדת אמפירית** — precision **ו**-recall — מול **סט-שאילתות מתויג קבוע**
|
||||||
|
(labeled query set) ב-eval harness עומד. כל שינוי בשכבת-האחזור (משקלי-RRF, `k`, סף-chunk, embedder)
|
||||||
|
מלווה במדידה לפני/אחרי; אין כוונון "לפי תחושה".
|
||||||
|
**מקורות:** Manning, Raghavan & Schütze, *Introduction to Information Retrieval* (CUP, 2008 — fixed
|
||||||
|
relevance judgments, precision/recall) · RAG evaluation literature (Lewis et al., 2020 ואחריו) ·
|
||||||
|
Elastic — *relevance evaluation guidance* | סטטוס: verified
|
||||||
|
**אכיפה:** eval harness עם gold-set מתויג + בדיקת-בריאות תקופתית; שער-CI על שינוי שכבת-האחזור.
|
||||||
|
אוכף את [G8](00-constitution.md#inv-g8-איכות-אחזור-נמדדת--precision--recall).
|
||||||
|
**הפרה ידועה (GAP):** אין כיום eval harness ולא gold-set — קיים רק `telemetry.log_search_bg`
|
||||||
|
(`search.py:62,118,190,271`; `precedent_library.py:280`) שמתעד שאילתות בפועל, אך **אינו מודד
|
||||||
|
precision/recall מול תיוג** (תצפית, לא הערכה). היעד: harness שמריץ סט קבוע ומחזיר metrics →
|
||||||
|
ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-RET5: כל span מוחזר עקיב למקורו
|
||||||
|
**כלל:** כל קטע מוחזר נושא **עקיבוּת-מקור מלאה** — מזהה-מסמך/פסק-דין (`case_law_id`/`document_id`/
|
||||||
|
`case_number`) **ו**-locator בתוכו (`page_number` / `chunk_id` / `supporting_quote` להלכה). פלט
|
||||||
|
ללא ייחוס פתיר אינו תקין; היו"ר חייבת לאמת כל ציטוט מול מקורו.
|
||||||
|
**מקורות:** Council of Europe / CEPEJ — *European Ethical Charter on AI in judicial systems*
|
||||||
|
(2018, traceability) · RAG attribution (Lewis et al., 2020) · ISO 15489-1:2016 (records
|
||||||
|
authenticity/integrity) | סטטוס: verified
|
||||||
|
**אכיפה:** כל פורמטר-תוצאה כולל מזהה + locator: `search.py:77-86` (case_number/page/section),
|
||||||
|
`_format_internal_row` (`search.py:322-343`: case_number/case_name/court + content/page או
|
||||||
|
rule/quote להלכה). עקיבוּת מלאה מפורטת ב-[X5-audit-provenance.md](X5-audit-provenance.md). אוכף את
|
||||||
|
[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. re-index ושינוי-תוכן (G6)
|
||||||
|
|
||||||
|
האחזור מסתמך על embeddings מסונכרנים מול התוכן. ה-tsvectors (`content_tsv`/`rule_tsv`/`meta_tsv`)
|
||||||
|
הם `GENERATED ALWAYS … STORED` (`db.py:778,782,1086`) ולכן מתעדכנים אוטומטית; אך ה-`embedding
|
||||||
|
vector(1024)` **אינו** generated — הוא תלוי-טריגר-חיצוני, נקודת-drift אפשרית
|
||||||
|
([02-data-model INV-DM3](02-data-model.md#inv-dm3-שינוי-תוכן--re-index)). שינוי-תוכן חייב להפעיל
|
||||||
|
re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף את
|
||||||
|
[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. מצב קיים מול יעד — audit-findings
|
||||||
|
|
||||||
|
ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול הקוד.
|
||||||
|
|
||||||
|
- **דליפת-הלכות חוצת-קורפוס (משימה #56).** `halacha_filters` נפתחים רק עם `review_status`
|
||||||
|
(`db.py:3168`, `db.py:3401`) ואינם מוסיפים `cl.source_kind`, בעוד `chunk_filters` כן
|
||||||
|
(`db.py:3169`, `db.py:3402`). שני ה-`halacha_sql` עושים `JOIN case_law` בלי סינון
|
||||||
|
(`db.py:3236-3242`, `db.py:3463-3482`). **תסמין:** חיפוש בקורפוס החיצוני
|
||||||
|
(`search_precedent_library`, `source_kind="external_upload"`) יכול להחזיר הלכה שמקורה
|
||||||
|
בהחלטת-ועדה פנימית — ולהפך עבור `search_internal_decisions` (`source_kind="internal_committee"`,
|
||||||
|
`internal_decisions.py:418`). **יעד:** `halacha_filters` יתחילו ב-`cl.source_kind = '{source_kind}'`
|
||||||
|
בדיוק כמו `chunk_filters` ([INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)).
|
||||||
|
- **אין eval harness — מדידת-איכות לא קיימת.** רק `telemetry.log_search_bg` מתעד שאילתות
|
||||||
|
(`search.py:62,118,190,271`); אין gold-set מתויג ואין precision/recall. **יעד:** harness עומד
|
||||||
|
([INV-RET4](#inv-ret4-איכות-אחזור-נמדדת-ב-eval-harness-עומד-precision--recall)).
|
||||||
|
- **`search_decisions` מתעד אזהרה כשאין `practice_area` אך לא חוסם.** ללא פילטר-תחום החיפוש
|
||||||
|
עלול לערבב תחומים משפטיים (`search.py:45-49,172-176` — `logger.warning`, ממשיך). **יעד:** הפרדה
|
||||||
|
לפי תחום נאכפת, לא מומלצת בלבד — תואם את עקרון ההפרדה ב-[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
|
||||||
|
- **`embedding` אינו `GENERATED` (בניגוד ל-tsvector).** נקודת-drift אפשרית בין תוכן ל-embedding
|
||||||
|
אחרי עדכון ([§4](#4-re-index-ושינוי-תוכן-g6); תואם [02-data-model](02-data-model.md#inv-dm3-שינוי-תוכן--re-index)).
|
||||||
|
**יעד:** טריגר re-embed מובטח + health-check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md) — invariants גלובליים (G4–G9) + כללי-הנדסה.
|
||||||
|
- [01-ingest.md](01-ingest.md) — חוזה-הקליטה שמייצר את ה-chunks/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).
|
||||||
186
docs/spec/04-analysis-writing.md
Normal file
186
docs/spec/04-analysis-writing.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# 04 — ניתוח וכתיבה (Analysis & Writing)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומפרט את שלב **הסיוע-בכתיבה** —
|
||||||
|
חילוץ הטענות, ארכיטקטורת 12 הבלוקים, וסגנון דפנה. הוא אוכף את
|
||||||
|
[INV-G11](00-constitution.md#inv-g11-תוכן-החלטה-מנומקת) (תוכן החלטה מנומקת).
|
||||||
|
|
||||||
|
> **⚠ מודל-סמכות שונה מ-01–03.** זהו קובץ **תוכן-משפטי**, לא קובץ-הנדסה. לפי החוקה
|
||||||
|
> (§2 עיקרון 2, §5ב) הסמכות עליו היא **היו"ר (עו"ד דפנה תמיר) + מסמכי-הפרויקט** —
|
||||||
|
> [block-schema.md](../block-schema.md), [decision-methodology.md](../decision-methodology.md),
|
||||||
|
> [legal-decision-lessons.md](../legal-decision-lessons.md),
|
||||||
|
> [corpus-analysis.md](../corpus-analysis.md), [skills/decision/SKILL.md](../../skills/decision/SKILL.md).
|
||||||
|
> ה-invariants כאן **אינם** כפופים לפרוטוקול ≥3-המקורות החיצוני, ו**אינם** נושאים
|
||||||
|
> `סטטוס: verified / ⚠ UNVERIFIED`. במקום `מקורות: … | סטטוס` הם נושאים `מקור-סמכות:`.
|
||||||
|
> מסמכי-הפרויקט הם המקור המוסמך; קובץ זה מצטט אותם בגובה-ספ, לא משכפל את ההגדרות.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. חילוץ טענות → טיעונים מאוגדים
|
||||||
|
|
||||||
|
לפני הכתיבה, חומרי-המקור הופכים למבנה-נתונים שמזין את הבלוקים. שני שלבים:
|
||||||
|
|
||||||
|
### 1.1 חילוץ טענות גולמיות (claims)
|
||||||
|
|
||||||
|
`extract_claims(case_number, doc_title="", party_hint="")` קורא לכתבי-הטענות בתיק,
|
||||||
|
ושומר טענות גולמיות ב-DB. הוא מסנן למסמכים מסוג `appeal` / `response` / `objection`
|
||||||
|
(אלא אם צוין `doc_title` מפורש), ולכל מסמך קורא ל-`claims_extractor.extract_and_store_claims`
|
||||||
|
— ראה `mcp-server/src/legal_mcp/tools/documents.py:300-347`.
|
||||||
|
|
||||||
|
כל טענה נשמרת עם `party_role` מתוך התפקידים המוכרים: **`appellant` (עוררים)** ·
|
||||||
|
**`respondent` (משיבים)** · **`committee` (ועדה מקומית)** · **`permit_applicant`
|
||||||
|
(מבקשי היתר)** · **`appraiser` (שמאי)**. `get_claims(case_number, party_role="")`
|
||||||
|
שולף ומציג אותן בעברית, עם סינון אופציונלי לפי תפקיד
|
||||||
|
(`documents.py:350-385`; מיפוי-העברית ב-`:370-376`).
|
||||||
|
|
||||||
|
### 1.2 כינוס לטיעונים משפטיים מובחנים (legal arguments)
|
||||||
|
|
||||||
|
`aggregate_claims_to_arguments(case_number, force=False)` מכנס את הפרופוזיציות
|
||||||
|
הגולמיות לטיעונים משפטיים מובחנים (de-duplication) דרך
|
||||||
|
`argument_aggregator.aggregate_claims_to_arguments`; `force=True` מוחק טיעונים קיימים
|
||||||
|
ומחשב מחדש — ראה `mcp-server/src/legal_mcp/tools/legal_arguments.py:11-33`.
|
||||||
|
`get_legal_arguments(case_number, party="")` שולף את הטיעונים המאוגדים, מקובצים לפי
|
||||||
|
צד (`appellant`/`respondent`/`committee`/`permit_applicant`/`unknown`); אם אין —
|
||||||
|
הוא מחזיר הנחיה להריץ קודם את הכינוס (`legal_arguments.py:36-83`).
|
||||||
|
|
||||||
|
> **מדוע זה חשוב לתוכן:** הטיעונים המאוגדים הם הקלט ל-[INV-WR3](#inv-wr3-מענה-לכל-טענה-של-הצד-המפסיד)
|
||||||
|
> (מענה לכל טענה עיקרית) ול-[INV-WR4](#inv-wr4-בלוק-ז--טענות-מקוריות-בלבד) (הפרדת טענות
|
||||||
|
> מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון
|
||||||
|
> שלו מקבל מענה בבלוק י.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ארכיטקטורת 12 הבלוקים (סיכום)
|
||||||
|
|
||||||
|
המבנה הפורמלי המלא — content model, constraints, משקלות, ופרמטרי-עיבוד לכל בלוק —
|
||||||
|
מוגדר ב-[block-schema.md](../block-schema.md) (המקור המוסמך). כאן רק מפת-גובה:
|
||||||
|
|
||||||
|
| בלוק | תפקיד | CREAC | תוכן מהותי? |
|
||||||
|
|------|--------|-------|-------------|
|
||||||
|
| א–ד | כותרת מוסדית · הרכב · צדדים · "החלטה" | — | לא (template-fill) |
|
||||||
|
| ה | פתיחה ("לפנינו…") | C ראשוני | קל |
|
||||||
|
| **ו** | רקע עובדתי ("פתח דבר") | — | **כן — עובדות בלבד** |
|
||||||
|
| **ז** | טענות הצדדים | — | **כן — טענות מקוריות בלבד** |
|
||||||
|
| ח | הליכים בפני הוועדה | — | כן (תיעוד, ללא הערכה) |
|
||||||
|
| ט | תכניות חלות (אופציונלי) | R | כן (כשיש מורכבות תכנונית) |
|
||||||
|
| **י** | דיון והכרעה | full-CREAC | **כן — ה-ratio decidendi** |
|
||||||
|
| יא | סיכום / סוף דבר | C אחרון | קל |
|
||||||
|
| יב | חתימות | — | לא |
|
||||||
|
|
||||||
|
יסודות תיאורטיים (CREAC · FJC Judicial Writing Manual · DITA · Akoma Ntoso),
|
||||||
|
תלויות-בין-בלוקים, וכללי-ולידציה — ב-[block-schema.md](../block-schema.md) §§1, 5, 6.
|
||||||
|
מתודולוגיית-המשקלות (Communicative / Reader-attention / Judicial-review / Empirical)
|
||||||
|
— שם §4. **טיוטת-ביניים** (Pre-Ruling Draft) בוחרת תת-קבוצת בלוקים (ו, ט, ז, ח) —
|
||||||
|
block-schema.md §7; שלב-החילוץ השמאי שלה (`extract_appraiser_facts`) מזין את בלוק ט.
|
||||||
|
|
||||||
|
> **התמקדות לפי feedback היו"ר:** הסיוע מתמקד בבלוקים המהותיים (ו–יב); בלוקים א–ד
|
||||||
|
> ממולאים מ-template ואינם דורשים ניתוח. ראה `MEMORY.md` → "התעלם מכותרות".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. סגנון דפנה (סיכום)
|
||||||
|
|
||||||
|
מדריך-הסגנון המלא הוא [skills/decision/SKILL.md](../../skills/decision/SKILL.md);
|
||||||
|
המתודולוגיה האנליטית ("איך לחשוב לפני איך לכתוב") היא
|
||||||
|
[decision-methodology.md](../decision-methodology.md). נקודות-מפתח:
|
||||||
|
|
||||||
|
- **טון לפי סוג-ערר** — רישוי (1xxx) חם יחסית; היטל-השבחה (8xxx) ופיצויים ס'197 (9xxx)
|
||||||
|
קרים ויבשים (SKILL.md §1; methodology §א.2).
|
||||||
|
- **מבנה הדיון (בלוק י)** — נפתח במסקנה (CREAC: C→R→E→A→C), סילוגיזם לכל סוגיה,
|
||||||
|
steel-manning של הצד המפסיד, ציטוט-פסיקה ב"סנדוויץ'" (methodology §§ד, ו, ז).
|
||||||
|
- **מסלול-דיון לפי תוצאה** — דחייה (עיגולים קונצנטריים) · קבלה (נימוק-נימוק) · קבלה
|
||||||
|
חלקית (מיפוי-מתחים) · היטל-השבחה (פתיחה ישירה) — SKILL.md §7.3; block-schema.md בלוק י.
|
||||||
|
- **3 מקורות-פסיקה נפרדים** — אסור לבלבל ביניהם (SKILL.md §7.5; ראה גם
|
||||||
|
[03-retrieval.md](03-retrieval.md) לשכבת-האחזור שמזינה אותם).
|
||||||
|
- **לקחים מצטברים** — [legal-decision-lessons.md](../legal-decision-lessons.md) +
|
||||||
|
ביטויי-מעבר; מתעדכנים מפידבק-היו"ר ומ-Hermes (ראה forward-ref [07-learning.md](07-learning.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Invariants של התחום — תוכן החלטה מנומקת
|
||||||
|
|
||||||
|
חמשת ה-invariants הבאים הם **פאֶטים של [INV-G11](00-constitution.md#inv-g11-תוכן-החלטה-מנומקת)**.
|
||||||
|
כולם נושאים `מקור-סמכות` (היו"ר + מסמכי-הפרויקט), **ללא** שדה-מקורות-חיצוני ו**ללא**
|
||||||
|
סטטוס-אימות — כמתחייב מהבחנת שתי-הסמכויות בחוקה (§5).
|
||||||
|
|
||||||
|
### INV-WR1: רקע ניטרלי (בלוק ו) — עובדות בלבד
|
||||||
|
**כלל:** בלוק ו מציג **עובדות בלבד** ואינו טוען. אסורות מילות-ערך/שיפוט ("חריג",
|
||||||
|
"בעייתי", "למרבה הפליאה") ואסורים ציטוטים ישירים מצדדים (אלה שייכים לבלוק ז). החלטות
|
||||||
|
קודמות מובאות כעובדה יבשה ("ביום X נדחתה תכנית Y"), ללא נימוקים. ניטרליות אינה הסתרה:
|
||||||
|
עובדה מהותית התומכת בצד המפסיד **חייבת** להופיע.
|
||||||
|
**מקור-סמכות:** היו"ר (עו"ד דפנה תמיר) + [block-schema.md](../block-schema.md) (בלוק ו,
|
||||||
|
§5.2 "רקע ניטרלי") + [decision-methodology.md](../decision-methodology.md) §ח.2.
|
||||||
|
**אכיפה:** ולידציית-תוכן בבלוק ו (סעיף עם ציטוט-צד או מילת-שיפוט → לא שייך כאן) + שערי
|
||||||
|
QA; מפורט ב-[05-qa-review.md](05-qa-review.md).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-WR2: ללא כפילות (בלוק י מפנה, לא חוזר)
|
||||||
|
**כלל:** בלוק י (דיון) **מפנה** לעובדות ולטענות שכבר הוצגו בבלוקים הקודמים ("כאמור
|
||||||
|
בסעיף X לעיל", "כפי שפורט") — ואינו חוזר עליהן. חריג יחיד: חזרה מכוונת עם שכבת-ניתוח
|
||||||
|
חדשה ("נשוב על כך כי…"). אין עובדות חדשות בדיון שלא הופיעו ברקע.
|
||||||
|
**מקור-סמכות:** היו"ר + [block-schema.md](../block-schema.md) (בלוק י, §5.2 "ללא
|
||||||
|
כפילות") + [skills/decision/SKILL.md](../../skills/decision/SKILL.md) §9.1.
|
||||||
|
**אכיפה:** ולידציית-מבנה (עובדה בדיון ללא עוגן ברקע = flag) + שערי QA;
|
||||||
|
מפורט ב-[05-qa-review.md](05-qa-review.md).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-WR3: מענה לכל טענה של הצד המפסיד
|
||||||
|
**כלל:** כל **טענה עיקרית** שהוצגה בבלוק ז — ובמיוחד של הצד המפסיד — מקבלת **מענה
|
||||||
|
מנומק** בבלוק י (ישיר, "למעלה מן הצורך", או מקובץ עם דומותיה). מותר לא להכריע בטענה
|
||||||
|
נחוצה-פחות ("נוכח מסקנתנו לעיל, אין צורך…"), אך אסור להתעלם מטענה מרכזית — הצד המפסיד
|
||||||
|
חייב לראות שהוועדה שקלה את יסודות עמדתו (steel-manning).
|
||||||
|
**מקור-סמכות:** היו"ר + [decision-methodology.md](../decision-methodology.md) §§ג.2, ו.2 +
|
||||||
|
[block-schema.md](../block-schema.md) (בלוק י MUST: "מענה לכל טענה" §5.4) +
|
||||||
|
[skills/decision/SKILL.md](../../skills/decision/SKILL.md) §6.2.
|
||||||
|
**אכיפה:** מיפוי טענות-בלוק-ז → מענה-בלוק-י (נשען על §1.2, הטיעונים המאוגדים) + שערי QA;
|
||||||
|
מפורט ב-[05-qa-review.md](05-qa-review.md).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-WR4: בלוק ז — טענות מקוריות בלבד
|
||||||
|
**כלל:** בלוק ז מכיל **אך ורק** טענות מכתבי-הטענות המקוריים (כתב-ערר, כתב-תשובה).
|
||||||
|
תוכן מהשלמות-טיעון, החלטות-ביניים, ותגובות-מאוחרות → **בלוק ח** (הליכים), לא בלוק ז.
|
||||||
|
הצגת-הטענות היא בנאמנות וללא הערכה ("טענה זו חלשה") — ההערכה שייכת לבלוק י.
|
||||||
|
**מקור-סמכות:** היו"ר + [block-schema.md](../block-schema.md) (בלוק ז Sources +
|
||||||
|
§5.2 "טענות מקוריות בלבד") + [skills/decision/SKILL.md](../../skills/decision/SKILL.md) §4.
|
||||||
|
**אכיפה:** סיווג-מקור של טענה בעת החילוץ (`extract_claims` מסנן `appeal`/`response`/
|
||||||
|
`objection`; מסמכי פוסט-דיון מתויגים `is_post_hearing` ומופנים לבלוק ח — block-schema.md §7)
|
||||||
|
+ שערי QA; מפורט ב-[05-qa-review.md](05-qa-review.md).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-WR5: "מבחן-השופט" — החלטה עצמאית וקריאה
|
||||||
|
**כלל:** ההחלטה חייבת להיות **עצמאית וקריאה לשופט שלא מכיר את התיק** — תשתית עובדתית
|
||||||
|
מלאה (בלוק ו), תיעוד procedural-fairness (בלוק ח), והנמקה שעומדת בבדיקת סבירות
|
||||||
|
ומידתיות (בלוק י). הקורא לא נדרש לחומרי-המקור כדי להבין את ההחלטה ואת הצדקתה.
|
||||||
|
**מקור-סמכות:** היו"ר + [block-schema.md](../block-schema.md) §4.3 ("מבחן השופט" /
|
||||||
|
Judicial-Review weight) + [decision-methodology.md](../decision-methodology.md) §יב
|
||||||
|
(רשימת-ביקורת) + [corpus-analysis.md](../corpus-analysis.md).
|
||||||
|
**אכיפה:** שער QA סופי ("מבחן-השופט") על ההחלטה כיחידה שלמה;
|
||||||
|
מפורט ב-[05-qa-review.md](05-qa-review.md).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. צ'קליסט-תוכן לפי סוג-ערר
|
||||||
|
|
||||||
|
בלוק י מקבל **צ'קליסט-תוכן** המוזרק אוטומטית ל-prompt לפי סוג-הערר, מתוך
|
||||||
|
`CONTENT_CHECKLISTS` ב-`mcp-server/src/legal_mcp/services/lessons.py:355`. הבורר
|
||||||
|
(`lessons.py:532-555`) ממפה לסוג: `tama38` (תמ"א 38) · `betterment_levy` (היטל-השבחה) ·
|
||||||
|
`licensing_property` · `licensing_threshold` (שאלת-סף) · `licensing_substantive`
|
||||||
|
(ברירת-מחדל לרישוי). הצ'קליסט מבטיח שהדיון מכסה את הנושאים התכנוניים/המשפטיים שדפנה
|
||||||
|
מכסה בפועל בקורפוס — ראה [corpus-analysis.md](../corpus-analysis.md) §§3, 6 לדפוסי-התוכן
|
||||||
|
ולפער שנסגר (§5.3). זהו מנגנון-תוכן באחריות היו"ר, לא חוק-הנדסה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md#inv-g11-תוכן-החלטה-מנומקת) — INV-G11 + הבחנת
|
||||||
|
שתי-הסמכויות (תוכן-משפטי מול הנדסה).
|
||||||
|
- [03-retrieval.md](03-retrieval.md) — שכבת-האחזור (3 קורפוסי-פסיקה) שמזינה ציטוטים לבלוק י.
|
||||||
|
- [05-qa-review.md](05-qa-review.md) — שערי-QA שאוכפים את INV-WR1–WR5 + שערים אנושיים.
|
||||||
|
- [06-export.md](06-export.md) — ייצוא DOCX לפי תבנית-דפנה (אחרי הכתיבה).
|
||||||
|
- [07-learning.md](07-learning.md) — לולאת פידבק-היו"ר + Hermes שמעדכנת lessons/SKILL.
|
||||||
|
- מסמכי-המקור המוסמכים: [block-schema.md](../block-schema.md) ·
|
||||||
|
[decision-methodology.md](../decision-methodology.md) ·
|
||||||
|
[legal-decision-lessons.md](../legal-decision-lessons.md) ·
|
||||||
|
[corpus-analysis.md](../corpus-analysis.md) ·
|
||||||
|
[skills/decision/SKILL.md](../../skills/decision/SKILL.md).
|
||||||
198
docs/spec/05-qa-review.md
Normal file
198
docs/spec/05-qa-review.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# 05 — בקרת איכות ושערים אנושיים (QA & Human Review)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומפרט את שלב **הביקורת** לפני
|
||||||
|
ייצוא: (1) **שערי-QA אוטומטיים** (`validate_decision` — 6 בדיקות) ו-(2) **שערים אנושיים**
|
||||||
|
(אישור הלכה, בחירת תוצאה, פידבק היו"ר). הוא אוכף את
|
||||||
|
[INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||||
|
(שערים אנושיים) ואת [INV-G11](00-constitution.md#inv-g11-תוכן-החלטה-מנומקת) (תוכן מנומק).
|
||||||
|
|
||||||
|
> **⚠ קובץ מעורב — שני מודלי-סמכות.** לפי החוקה (§3, §5):
|
||||||
|
> - **שערי-הממשל** (שערים אנושיים, שער-הייצוא) הם **invariants הנדסיים** במודל
|
||||||
|
> הממשל-שיפוטי → נושאים `מקורות:` (NCSC/JTC · CEPEJ 2018 · FJC) + `סטטוס: verified`.
|
||||||
|
> - **מכניקת בדיקות-התוכן** (מה הבדיקה האוטומטית בוחנת בפועל — רקע ניטרלי, ללא כפילות,
|
||||||
|
> כיסוי-טענות) היא **תוכן-משפטי** → נושאת `מקור-סמכות:` (היו"ר + מסמכי-הפרויקט +
|
||||||
|
> [04-analysis-writing.md](04-analysis-writing.md)), **ללא** מקורות חיצוניים וללא סטטוס.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. שערי-QA אוטומטיים — `validate_decision`
|
||||||
|
|
||||||
|
`validate_decision(case_number)` (wrapper ב-`tools/drafting.py:363`, נחשף ב-`server.py:551`)
|
||||||
|
טוען את בלוקי-ההחלטה והטענות מה-DB ומריץ **6 בדיקות**, אז כותב את התוצאות לטבלת
|
||||||
|
`qa_results` ומחזיר `passed` / `critical_failures` / `export_blocked`. הליבה:
|
||||||
|
`services/qa_validator.py:292` (`validate_decision`). כל בדיקה מחזירה
|
||||||
|
`{name, passed, errors, severity}`; `severity ∈ {critical, warning}`.
|
||||||
|
|
||||||
|
> **חישוב החסימה:** `critical_failures = Σ(not passed ∧ severity=="critical")`
|
||||||
|
> (`qa_validator.py:338`), ו-`export_blocked = critical_failures > 0`
|
||||||
|
> (`qa_validator.py:362`). בדיקת `warning` שנכשלת מורידה `passed=False` אך **אינה** חוסמת
|
||||||
|
> ייצוא. ראה [§3 / INV-QA3](#inv-qa3-החלטה-לא-מיוצאת-עם-כשל-קריטי-governance--g10).
|
||||||
|
|
||||||
|
### 1.1 ששת השערים
|
||||||
|
|
||||||
|
| # | בדיקה | מה בוחנת | severity | פונקציה (file:line) |
|
||||||
|
|---|-------|----------|----------|---------------------|
|
||||||
|
| 1 | `neutral_background` | רקע (בלוק ו) ללא מילות-שיפוט (`VALUE_WORDS`) וללא ציטוט-צד (`QUOTE_INDICATORS`) | **warning** | `check_neutral_background` — `qa_validator.py:66` |
|
||||||
|
| 2 | `claims_coverage` | כל טענה מבלוק ז נענתה בבלוק י (בדיקה סמנטית דרך Claude) | **critical** | `check_claims_coverage` — `qa_validator.py:107` |
|
||||||
|
| 3 | `weight_compliance` | משקל-מילים של כל בלוק בטווח לפי סוג-ערר (`WEIGHT_RANGES`) | **warning** | `check_weight_compliance` — `qa_validator.py:177` |
|
||||||
|
| 4 | `structural_integrity` | בלוקי-חובה קיימים (ה, ז, י, יא) + בלוק י הוא הכבד ביותר | **critical** | `check_structural_integrity` — `qa_validator.py:206` |
|
||||||
|
| 5 | `no_duplication` | אין משפט מבלוק ו (>30 תווים) שחוזר מילה-במילה בבלוק י | **warning** | `check_no_duplication` — `qa_validator.py:235` |
|
||||||
|
| 6 | `sequential_numbering` | מספור-סעיפים רציף בכל הבלוקים, מתחיל ב-1, ללא פערים | **warning** | `check_sequential_numbering` — `qa_validator.py:261` |
|
||||||
|
|
||||||
|
### 1.2 דקויות חשובות (אל תניח — מהקוד)
|
||||||
|
|
||||||
|
- **רק 2 שערים קריטיים** חוסמים ייצוא: `claims_coverage` ו-`structural_integrity`. שאר
|
||||||
|
הארבעה הם `warning` בנתיב הרגיל — `qa_validator.py:86, 202, 257, 286`.
|
||||||
|
- **דקות `neutral_background` — שני נתיבי-החזרה:** הנתיב הרגיל מסומן `warning` (`:86`); נתיב
|
||||||
|
ה-fallback של בלוק-ו ריק/חסר מסומן `critical` (`:70`) **אך מחזיר `passed=True`**, ולכן
|
||||||
|
אינו נספר ב-`critical_failures` ואינו חוסם ייצוא. תפקודית — השער אינו חוסם.
|
||||||
|
- **`claims_coverage` סובלני ל-20%:** עובר אם `len(missing) ≤ total*0.2`
|
||||||
|
(`qa_validator.py:170`). מסנן לטענות `appellant`/`respondent` שאינן מבלוק-ז
|
||||||
|
(`qa_validator.py:120-129`), כי טענות `committee`/`permit_applicant` הן עמדות-הגנה ולא
|
||||||
|
דורשות מענה. כשל-פענוח של Claude → fallback `passed=True` כדי לא לחסום ייצוא על תקלת-LLM
|
||||||
|
(`qa_validator.py:148-152`).
|
||||||
|
- **`neutral_background` ריק = עובר:** בלוק ו ריק/חסר מחזיר `passed=True`
|
||||||
|
(`qa_validator.py:69`). הבדיקה היא lexical (רשימת-מילים + regex), לא סמנטית.
|
||||||
|
- **`no_duplication` תופס רק חזרה מילה-במילה** (substring) — לא פרפרזה.
|
||||||
|
- כל ריצה **מנקה** את `qa_results` הקודמות של התיק ואז כותבת מחדש (`qa_validator.py:344-357`).
|
||||||
|
|
||||||
|
### 1.3 שערי-התוכן מתפעלים את WR1–WR3
|
||||||
|
|
||||||
|
שלוש מ-6 הבדיקות הן ההפעלה האוטומטית (חלקית) של ה-invariants של התוכן ב-
|
||||||
|
[04-analysis-writing.md](04-analysis-writing.md):
|
||||||
|
|
||||||
|
| שער QA | invariant-תוכן | פער (אוטומטי מול הגדרה) |
|
||||||
|
|--------|----------------|--------------------------|
|
||||||
|
| `neutral_background` | [INV-WR1](04-analysis-writing.md#inv-wr1-רקע-ניטרלי-בלוק-ו--עובדות-בלבד) | lexical בלבד — לא תופס שיפוט עקיף; warning, לא critical |
|
||||||
|
| `no_duplication` | [INV-WR2](04-analysis-writing.md#inv-wr2-ללא-כפילות-בלוק-י-מפנה-לא-חוזר) | מילה-במילה בלבד — לא תופס כפילות מנוסחת-מחדש |
|
||||||
|
| `claims_coverage` | [INV-WR3](04-analysis-writing.md#inv-wr3-מענה-לכל-טענה-של-הצד-המפסיד) | סמנטי (Claude), סובלני ל-20% חוסר |
|
||||||
|
|
||||||
|
ראה [INV-QA4](#inv-qa4-שערי-התוכן-האוטומטיים-אוכפים-את-wr1wr3-content--g11). WR4 (טענות
|
||||||
|
מקוריות) ו-WR5 ("מבחן-השופט") **אינם** מכוסים על-ידי `validate_decision` — WR4 נאכף
|
||||||
|
בנקודת-החילוץ (`extract_claims`), WR5 הוא שער-איכות אנושי/agent. הסוכן `legal-qa`
|
||||||
|
(ראה [X4-agents.md](X4-agents.md)) מוסיף שערים ידניים מעבר ל-6 הקוד-יים (קול-דפנה,
|
||||||
|
שאילתות-קורפוס, צירוף-פסיקה) — `.claude/agents/legal-qa.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. שערים אנושיים — היו"ר מכריעה
|
||||||
|
|
||||||
|
המערכת מסייעת; ההכרעה היא של היו"ר. שלושה שערים אנושיים מובנים בקוד-הזרימה ואינם ניתנים
|
||||||
|
לעקיפה אוטומטית (זהו [INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)).
|
||||||
|
|
||||||
|
### 2.1 אישור הלכה (halacha approval)
|
||||||
|
|
||||||
|
הלכות מחולצות אוטומטית מפסיקה (`halacha_extractor.py`), אך **נכנסות כ-`pending_review`
|
||||||
|
ובלתי-נראות לחיפוש** עד אישור היו"ר:
|
||||||
|
|
||||||
|
- **כתיבה:** `db.add_halacha` קובע `review_status = "approved" if auto_approve else
|
||||||
|
"pending_review"` (`db.py:3003`), כאשר `auto_approve` נגזר מסף-ביטחון
|
||||||
|
`HALACHA_AUTO_APPROVE_THRESHOLD` (ברירת-מחדל `0.80`, `config.py:111`). הלכות מתחת לסף
|
||||||
|
נשארות `pending_review`.
|
||||||
|
- **שער-האישור:** `halacha_review(halacha_id, status, reviewer="דפנה", …)`
|
||||||
|
(`tools/precedent_library.py:291`, נחשף ב-`server.py:298`) — היו"ר מאשרת/דוחה/עורכת.
|
||||||
|
`status ∈ {pending_review, approved, rejected, published}` (`precedent_library.py:311`).
|
||||||
|
- **תור-ההמתנה:** `halachot_pending(limit=100)` (`precedent_library.py:335`) מחזיר את
|
||||||
|
`review_status='pending_review'`.
|
||||||
|
- **חשיפה רק לאחר אישור:** החיפוש מסנן `h.review_status IN ('approved','published')`
|
||||||
|
(`db.py:3168` ו-`db.py:3401`) — הלכה שלא אושרה **לעולם** לא עולה בתוצאות.
|
||||||
|
|
||||||
|
### 2.2 בחירת תוצאה (outcome selection)
|
||||||
|
|
||||||
|
`set_outcome(case_number, outcome, reasoning="")` (`tools/workflow.py:145`,
|
||||||
|
`server.py:646`) — היו"ר קובעת `outcome ∈ {rejected, accepted, partial}`
|
||||||
|
(`workflow.py:163`). זוהי **הכרעה משפטית**: היא קודמת לכתיבת-הטיוטה וקובעת את מסלול-הדיון
|
||||||
|
(ראה [04-analysis-writing.md](04-analysis-writing.md) §3). אין נתיב שבו המערכת בוחרת תוצאה
|
||||||
|
לבד — אם לא סופק נימוק, המערכת מציעה כיווני-נימוק (`brainstorm`), אך הבחירה נשארת אנושית.
|
||||||
|
|
||||||
|
### 2.3 פידבק היו"ר (chair feedback)
|
||||||
|
|
||||||
|
- `record_chair_feedback(case_number, feedback_text, block_id, category, …)`
|
||||||
|
(`tools/workflow.py:348`, `server.py:896`) — מתעד הערת-דפנה; `category` מתוך
|
||||||
|
`{missing_content, wrong_tone, wrong_structure, factual_error, style, other}`
|
||||||
|
(`workflow.py:367`).
|
||||||
|
- `list_chair_feedback(case_number, category, unresolved_only=True)`
|
||||||
|
(`tools/workflow.py:393`, `server.py:910`) — שליפה לסקירה.
|
||||||
|
|
||||||
|
הפידבק מזין את לולאת-הלמידה ([07-learning.md](07-learning.md)) ואת
|
||||||
|
[legal-decision-lessons.md](../legal-decision-lessons.md). זהו שיפוט-אנושי על איכות —
|
||||||
|
לעולם לא מוסק או מוחל אוטומטית.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-QA1: אישור הלכה הוא שער אנושי (governance →G10)
|
||||||
|
**כלל:** אישור הלכה הוא **הכרעה ידנית של היו"ר**. הלכות שחולצו אוטומטית הן
|
||||||
|
`pending_review` עד שהיו"ר מאשרת; **רק הלכות מאושרות** (`approved`/`published`) עולות
|
||||||
|
בחיפוש. תור-ההמתנה חייב להיות **נראה** (`halachot_pending`) כדי שאישור-חסר לא יישאר סמוי.
|
||||||
|
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (human-in-the-loop) ·
|
||||||
|
Council of Europe / CEPEJ (2018, under user control) · Federal Judicial Center —
|
||||||
|
*Judicial Writing Manual* (2d ed.) | סטטוס: verified
|
||||||
|
**אכיפה:** ברירת-מחדל `pending_review` בכתיבה (`db.py:3003`) + סינון
|
||||||
|
`review_status IN ('approved','published')` בכל query (`db.py:3168`, `db.py:3401`) + שער-אישור
|
||||||
|
`halacha_review` (`precedent_library.py:291`).
|
||||||
|
**הפרה ידועה:** 10/19 הלכות מאושרות — שער-ידני שקוף בלי נראות-backlog; ההפרש התגלה במקרה →
|
||||||
|
ממצא ל-[audit](../audit-report.md) (ראה גם [INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)).
|
||||||
|
|
||||||
|
### INV-QA2: בחירת-תוצאה ופידבק הם שערים אנושיים (governance →G10)
|
||||||
|
**כלל:** **בחירת התוצאה** (`set_outcome`) ו**פידבק-היו"ר** (`record_chair_feedback`) הם
|
||||||
|
שערים אנושיים — **לעולם לא אוטומטיים**. המערכת מסייעת (מציעה כיווני-נימוק, מתעדת הערות),
|
||||||
|
אך ההכרעה והשיפוט-על-האיכות הם של היו"ר.
|
||||||
|
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* ("never replace human
|
||||||
|
judgment") · Council of Europe / CEPEJ (2018, under user control) · Federal Judicial
|
||||||
|
Center — *Judicial Writing Manual* (2d ed.) | סטטוס: verified
|
||||||
|
**אכיפה:** `set_outcome` דורש `outcome` מפורש מהיו"ר (`workflow.py:145-165`);
|
||||||
|
`record_chair_feedback`/`list_chair_feedback` מתעדים בלבד (`workflow.py:348, 393`) — אין
|
||||||
|
מסלול-קוד שמסיק תוצאה או פידבק לבד.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-QA3: החלטה לא מיוצאת עם כשל קריטי (governance →G10)
|
||||||
|
**כלל:** החלטה **אינה ניתנת לייצוא** כל עוד שער-QA **קריטי** נכשל
|
||||||
|
(`claims_coverage` או `structural_integrity`). `export_blocked` חייב להיבדק לפני ייצוא;
|
||||||
|
ייצוא בכשל-קריטי הוא הפרה. שערי-`warning` שנכשלים מתועדים אך אינם חוסמים.
|
||||||
|
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (controlled, auditable
|
||||||
|
AI output) · Council of Europe / CEPEJ (2018, under user control) · Federal Judicial
|
||||||
|
Center — *Judicial Writing Manual* (2d ed.) | סטטוס: verified
|
||||||
|
**אכיפה:** `export_blocked = critical_failures > 0` (`qa_validator.py:362`); נאכף בשער-הזרימה
|
||||||
|
של הסוכן `legal-exporter` ("לעולם אל תייצא בלי `validate_decision` קודם", "בדוק שאין
|
||||||
|
כשלים קריטיים" — `.claude/agents/legal-exporter.md:71, 149`). קושר ל-[06-export.md](06-export.md).
|
||||||
|
**הפרה ידועה:** `export_docx` (`drafting.py:384`) **אינו** מריץ `validate_decision` בעצמו —
|
||||||
|
החסימה היא ברמת-הזרימה/agent, לא hard-block בקוד-הייצוא. פער זה → ראה [§4](#4-current-vs-target--ממצאי-audit) (audit).
|
||||||
|
|
||||||
|
### INV-QA4: שערי-התוכן האוטומטיים אוכפים את WR1–WR3 (content →G11)
|
||||||
|
**כלל:** שערי-התוכן האוטומטיים מתפעלים את invariants-התוכן: `neutral_background`↔
|
||||||
|
[WR1](04-analysis-writing.md#inv-wr1-רקע-ניטרלי-בלוק-ו--עובדות-בלבד) (רקע ניטרלי) ·
|
||||||
|
`no_duplication`↔[WR2](04-analysis-writing.md#inv-wr2-ללא-כפילות-בלוק-י-מפנה-לא-חוזר)
|
||||||
|
(ללא כפילות) · `claims_coverage`↔[WR3](04-analysis-writing.md#inv-wr3-מענה-לכל-טענה-של-הצד-המפסיד)
|
||||||
|
(מענה-לטענות). האכיפה האוטומטית היא **רצפה, לא תקרה** — WR4/WR5 וההבטים העדינים (שיפוט-עקיף,
|
||||||
|
כפילות מנוסחת-מחדש) נשארים בשיקול-הדעת האנושי (INV-QA1–QA3).
|
||||||
|
**מקור-סמכות:** היו"ר (עו"ד דפנה תמיר) + [04-analysis-writing.md](04-analysis-writing.md)
|
||||||
|
(INV-WR1–WR3) + `mcp-server/src/legal_mcp/services/qa_validator.py` (הבדיקות בפועל).
|
||||||
|
**אכיפה:** `check_neutral_background` (`qa_validator.py:66`), `check_no_duplication`
|
||||||
|
(`qa_validator.py:235`), `check_claims_coverage` (`qa_validator.py:107`).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Current vs Target — ממצאי-audit
|
||||||
|
|
||||||
|
- **Halacha backlog בלתי-נראה (INV-QA1):** 10/19 הלכות מאושרות; 9 נשארו `pending_review`
|
||||||
|
ולא עלו בחיפוש. השער עבד כשורה — אך חוסר-נראות של ה-backlog הסתיר את הפער עד שהתגלה
|
||||||
|
במקרה. **Target:** מדד-נראות (count `pending_review`) כחלק מבדיקת-בריאות, לא רק
|
||||||
|
`halachot_pending` בדרישה. ראה [audit](../audit-report.md).
|
||||||
|
- **שער-ייצוא אכוף-זרימה ולא אכוף-קוד (INV-QA3):** `export_docx` לא קורא ל-`validate_decision`;
|
||||||
|
החסימה תלויה במשמעת הסוכן `legal-exporter`. **Target:** hard-block בתוך `export_docx`
|
||||||
|
(בדיקת `qa_results`/`export_blocked` לפני כתיבת DOCX) כדי שלא יהיה ניתן לעקיפה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) —
|
||||||
|
INV-G10 (שערים אנושיים) + INV-G11 + הבחנת שתי-הסמכויות.
|
||||||
|
- [04-analysis-writing.md](04-analysis-writing.md) — INV-WR1–WR5 שהשערים האוטומטיים מתפעלים.
|
||||||
|
- [06-export.md](06-export.md) — ייצוא DOCX (השלב אחרי המעבר בשער הקריטי).
|
||||||
|
- [07-learning.md](07-learning.md) — לולאת פידבק-היו"ר + Hermes שמעדכנת lessons/SKILL.
|
||||||
|
- [X4-agents.md](X4-agents.md) — הסוכן `legal-qa` (שערים ידניים נוספים) ו-`legal-exporter`.
|
||||||
|
- [X5-audit-provenance.md](X5-audit-provenance.md) — audit-trail לפלטי-AI ועקיבוּת-מקור.
|
||||||
168
docs/spec/06-export.md
Normal file
168
docs/spec/06-export.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# 06 — ייצוא DOCX (Export Contract)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומגדיר את **חוזה-הייצוא** של עוזר
|
||||||
|
משפטי: הרינדור של החלטה ל-DOCX מעוצב (גופן David, RTL, סגנונות-טמפלט). העיקרון המכונן —
|
||||||
|
**ה-DB הוא מקור-האמת היחיד, וה-DOCX הוא נתון נגזר (derived) הניתן לשחזור**. הקובץ אוכף את
|
||||||
|
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (מקור-אמת
|
||||||
|
יחיד / נתון-נגזר משוחזר) ואת [INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
|
||||||
|
(עקיבוּת-מקור), והוא השלב שאחרי שער-הייצוא הקריטי של
|
||||||
|
[05-qa-review.md / INV-QA3](05-qa-review.md#inv-qa3-החלטה-לא-מיוצאת-עם-כשל-קריטי-governance--g10).
|
||||||
|
|
||||||
|
> **כללי-סגנון — סמכות אחת.** מכניקת העיצוב (line classification, dash policy, placeholder,
|
||||||
|
> מיפוי-סגנונות, RTL-runs) מתועדת במלואה בסקיל
|
||||||
|
> [`dafna-decision-template/SKILL.md`](../../skills/dafna-decision-template/SKILL.md) — **הוא
|
||||||
|
> המקור הסמכותי**. הקובץ הזה **מסכם ומפנה**, לא משכפל. כללי-הסגנון עצמם הם תוכן-משפטי-דומייני
|
||||||
|
> (סמכות היו"ר + הסקיל), בעוד שחוזה-ה-derived-data (INV-EX1) ועקיבוּת-המקור (INV-EX2) הם
|
||||||
|
> invariants הנדסיים הנושאים `מקורות` + `סטטוס`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. חוזה-הייצוא — DB הוא המקור, DOCX הוא הנגזר
|
||||||
|
|
||||||
|
החלטה מאוחסנת כ-**בלוקים מובְנים ב-DB** — `decision_blocks` (12 בלוקים, מפתח קנוני
|
||||||
|
`UNIQUE(decision_id, block_id)`) תחת `decisions` (`UNIQUE(case_id, version)`); ראה
|
||||||
|
[02-data-model.md §1](02-data-model.md). ה-DOCX **נגזר** מהבלוקים האלה ואינו מקור-אמת עצמאי:
|
||||||
|
מחיקתו אינה מאבדת תוכן, וייצוא חוזר מאותם בלוקים מפיק מסמך שקול.
|
||||||
|
|
||||||
|
**מסלול-הייצוא הקנוני (הסופי):**
|
||||||
|
|
||||||
|
1. `export_docx(case_number)` (`tools/drafting.py:384`, נחשף `server.py:557`) שולף את התיק,
|
||||||
|
ואז קורא ל-`docx_exporter.export_decision(case_id, …, mode="final")`
|
||||||
|
(`services/docx_exporter.py:306`).
|
||||||
|
2. `export_decision` שולף את הבלוקים **ישירות מ-`decision_blocks`**
|
||||||
|
(`SELECT block_id, block_index, title, content, word_count … ORDER BY block_index`,
|
||||||
|
`docx_exporter.py:336-342`) — אין מקור-תוכן אחר.
|
||||||
|
3. טוען את טמפלט-דפנה (`skills/docx/decision_template.docx`, `docx_exporter.py:27-29,364`),
|
||||||
|
מנקה את גוף-המסמך (`_clear_body`), וכותב כל בלוק עם **bookmark עוטף** (אנקור ל-revisions
|
||||||
|
עתידיים, `_wrap_block_with_bookmarks`, `docx_exporter.py:367-382`).
|
||||||
|
4. שומר לקובץ מגורסן `data/cases/{case_number}/exports/טיוטה-v{N}.docx` (גרסה אוטומטית עולה,
|
||||||
|
`docx_exporter.py:384-400`).
|
||||||
|
|
||||||
|
> **שני מסלולי-ייצוא לפי מקור-התוכן (לא מסלולים-מקבילים מתפצלים):**
|
||||||
|
> - `docx_exporter.py` — **ההחלטה הסופית** מ-12 הבלוקים ב-`decision_blocks` (`mode="final"`),
|
||||||
|
> וגם **טיוטת-ביניים** (`mode="interim"` — תת-קבוצת בלוקים בסדר חדש: רקע→תכניות→טענות→הליכים,
|
||||||
|
> `export_interim_draft`, `drafting.py:511`). שני המצבים שולפים מאותה טבלה — וריאציית-תצוגה
|
||||||
|
> של אותו מקור-אמת, לא מסלול שני.
|
||||||
|
> - `analysis_docx_exporter.py` (`build_analysis_docx`, `:401`) — מייצא את מסמך **הניתוח
|
||||||
|
> המשפטי** (`analysis-and-research.md`) שכתב `legal-analyst`, לא את בלוקי-ההחלטה. זהו תוצר-עזר
|
||||||
|
> שונה (שלב ניתוח, לא החלטה) — והוא המסלול שהסקיל מתעד בעיקר. שניהם חולקים את **אותו טמפלט
|
||||||
|
> ואותם כללי-סגנון**, כנדרש מ-[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
> (סימטריה — לא שתי שכבות-סגנון מתפצלות).
|
||||||
|
|
||||||
|
## 2. כללי-הסגנון — סיכום (הסמכות: הסקיל)
|
||||||
|
|
||||||
|
ה-service מחיל את סגנונות-הטמפלט בלבד (`paragraph.style = "Heading 2"`) — בלי font/size/indent
|
||||||
|
ידני; העיצוב (David, RTL, גדלים) מגיע מ-`styles.xml`. הפירוט המלא + ה-XML של כל סגנון:
|
||||||
|
[`SKILL.md`](../../skills/dafna-decision-template/SKILL.md) + `references/`.
|
||||||
|
|
||||||
|
- **סיווג-שורות (`_classify_line`):** כל שורה מסווגת לאחת מ-6 קטגוריות — `label_heading`,
|
||||||
|
`inline_label`, `numbered`, `bullet`, `heb_letter`, `plain` — שקובעות את הסגנון המוחל
|
||||||
|
(Heading 2 / Normal / List Paragraph). ראה
|
||||||
|
[`references/line-classification.md`](../../skills/dafna-decision-template/references/line-classification.md).
|
||||||
|
- **מדיניות-מקפים (`_no_dash`):** דפנה ביקשה "בלי מקפים בכלל" — `—` (U+2014) ו-`–` (U+2013)
|
||||||
|
מוסרים מכל טקסט נכתב; מקף רגיל (`-`) נשמר.
|
||||||
|
- **שדות-placeholder:** `chair_position` עם סימן-ריק (`[ימולא ע"י יו"ר הוועדה]` וכד') מוחלף
|
||||||
|
ב-`[טרם מולאה עמדת ועדת הוועדה]` ב-italic — סימן ויזואלי שנותר להשלים (תואם
|
||||||
|
[INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) — היו"ר
|
||||||
|
משלימה, לא המערכת).
|
||||||
|
- **RTL-runs:** כל run מסומן `<w:rtl/>` (`_mark_run_rtl`) — אחרת Word נופל ל-Times New Roman
|
||||||
|
במקום David. ראה [`references/rtl-runs.md`](../../skills/dafna-decision-template/references/rtl-runs.md).
|
||||||
|
- **מספור:** מספור אוטומטי רק ב-`List Paragraph` (decimal); שורות `(א)(ב)` מקבלות
|
||||||
|
`List Paragraph` עם `_strip_numpr()` (המספור העברי בטקסט).
|
||||||
|
|
||||||
|
## 3. רישום הגרסה — `active_draft_path` + git
|
||||||
|
|
||||||
|
לאחר כתיבת ה-DOCX, `export_docx` (`drafting.py:404-408`):
|
||||||
|
|
||||||
|
1. **`set_active_draft_path(case_id, path)`** (`db.py:1177`) — רושם את ה-DOCX שיוצא כ-
|
||||||
|
active-draft הנוכחי (`cases.active_draft_path`, `db.py:189`). שדה זה הוא **האנקור לעריכות
|
||||||
|
עוקבות** (`revise_draft`/`apply_user_edit`/`list_bookmarks`), לא מקור-אמת-תוכן מתחרה ל-DB.
|
||||||
|
2. **`git_sync.commit_and_push(case_dir, "ייצוא DOCX: …")`** (`drafting.py:408`) — מקבע את
|
||||||
|
הקובץ ב-git של תיקיית-התיק (audit-trail של פלט,
|
||||||
|
[INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai); ראה
|
||||||
|
[X5-audit-provenance.md](X5-audit-provenance.md)).
|
||||||
|
|
||||||
|
אותו דפוס (`set_active_draft_path` + commit) חוזר ב-`export_interim_draft` (`drafting.py:533,536`),
|
||||||
|
`revise_draft` (`drafting.py:692,695`) ו-`apply_user_edit` (`drafting.py:579,582`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-EX1: ייצוא דטרמיניסטי ומשוחזר מהבלוקים — DOCX הוא נתון-נגזר (→G2)
|
||||||
|
**כלל:** הייצוא **דטרמיניסטי וניתן-לשחזור** מבלוקי-ההחלטה המאוחסנים ב-`decision_blocks`:
|
||||||
|
אותם בלוקים + אותו טמפלט מפיקים מסמך שקול. ה-DOCX הוא **נתון-נגזר (derived)** — **לעולם לא
|
||||||
|
מקור-אמת עצמאי**. אסור מסלול-תוכן שני שכותב DOCX ממקור שאינו ה-DB; וריאציות (final/interim)
|
||||||
|
הן תצוגות של אותו מקור.
|
||||||
|
**מקורות:** Martin Kleppmann — *Designing Data-Intensive Applications* (O'Reilly, 2017,
|
||||||
|
system-of-record מול derived data, ושחזור derived מהמקור) · Martin Fowler (Canonical Data
|
||||||
|
Model / Single Source of Truth) · SSOT (Single Source of Truth principle) | סטטוס: verified
|
||||||
|
**אכיפה:** `export_decision` שולף אך-ורק מ-`decision_blocks` (`docx_exporter.py:336-342`);
|
||||||
|
פלט מגורסן + idempotent מבחינת-תוכן; אוכף את
|
||||||
|
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) וכלל-ההנדסה
|
||||||
|
"סימטריה" (חוקה §6).
|
||||||
|
**הפרה ידועה:** אחרי `revise_draft`/`apply_user_edit`, ה-DOCX המסומן `active_draft_path` הופך
|
||||||
|
ל"מקור-האמת" לעריכות-Track-Changes העוקבות (`db.py:185-188`), ו**בלוקי-ה-DB אינם מתעדכנים
|
||||||
|
חזרה** — הנתון-הנגזר זוחל למקור-אמת בפועל בלי סנכרון לאחור. **יעד:** או re-sync מהבלוקים, או
|
||||||
|
חוזה מפורש ש-`active_draft_path` הוא רק אנקור-revision ולא מקור-תוכן → ראה [§5](#5-current-vs-target).
|
||||||
|
|
||||||
|
### INV-EX2: עקיבוּת-מקור נשמרת בהחלטה המיוצאת (→G9)
|
||||||
|
**כלל:** ההחלטה המיוצאת **שומרת על עקיבוּת-מקור** היכן שנדרש — סמכויות-משפטיות מצוטטות
|
||||||
|
ניתנות-לאיתור (citation resolvable), והפלט מקובע ב-audit-trail (commit git). הפניות-פסיקה
|
||||||
|
בבלוקים אינן מאבדות את מקורן בעת הרינדור.
|
||||||
|
**מקורות:** Council of Europe / CEPEJ — *European Ethical Charter on AI in judicial systems*
|
||||||
|
(2018, traceability/transparency) · ISO 15489-1:2016 (records authenticity/integrity) ·
|
||||||
|
Lewis et al. (2020, NeurIPS — RAG attribution) | סטטוס: verified
|
||||||
|
**אכיפה:** `export_docx` מקבע כל פלט ב-git (`git_sync.commit_and_push`, `drafting.py:408`) +
|
||||||
|
רושם `active_draft_path` (`db.py:1177`); עקיבוּת-המקור של הציטוטים עצמם נאכפת במעלה-הזרם
|
||||||
|
(חילוץ-טענות/הלכות + provenance, [04-analysis-writing.md](04-analysis-writing.md),
|
||||||
|
[X5-audit-provenance.md](X5-audit-provenance.md)). אוכף את
|
||||||
|
[INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-EX3: אין ייצוא בכשל-QA קריטי (restate של INV-QA3 →G10)
|
||||||
|
**כלל:** הייצוא **חסום** כל עוד שער-QA קריטי נכשל (`claims_coverage` / `structural_integrity`);
|
||||||
|
`export_blocked` חייב להיבדק לפני ייצוא. זהו אותו invariant של
|
||||||
|
[INV-QA3](05-qa-review.md#inv-qa3-החלטה-לא-מיוצאת-עם-כשל-קריטי-governance--g10), בצד-הייצוא.
|
||||||
|
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (controlled, auditable
|
||||||
|
output) · Council of Europe / CEPEJ (2018, under user control) · Federal Judicial Center —
|
||||||
|
*Judicial Writing Manual* (2d ed.) | סטטוס: verified
|
||||||
|
**אכיפה:** `export_blocked = critical_failures > 0` (`qa_validator.py:362`); **נאכף ברמת-
|
||||||
|
הזרימה/agent בלבד** — הסוכן `legal-exporter` מחויב להריץ `validate_decision` ולבדוק
|
||||||
|
כשלים-קריטיים לפני ייצוא (`.claude/agents/legal-exporter.md:71,149`).
|
||||||
|
**הפרה ידועה:** `export_docx` (`drafting.py:384`) **אינו** קורא ל-`validate_decision` בעצמו —
|
||||||
|
הוא ניגש ישירות ל-`docx_exporter.export_decision` בלי לבדוק `export_blocked`. החסימה תלויה
|
||||||
|
במשמעת-הסוכן ואינה hard-block בקוד-הייצוא → ראה [§5](#5-current-vs-target) (תואם
|
||||||
|
[05-qa-review §4](05-qa-review.md#4-current-vs-target--ממצאי-audit)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Current vs Target
|
||||||
|
|
||||||
|
- **שער-ייצוא אכוף-זרימה ולא אכוף-קוד (INV-EX3 / INV-QA3).** אומת בקוד: `export_docx`
|
||||||
|
(`drafting.py:384-419`) קורא ישירות ל-`docx_exporter.export_decision` (`:403`) ללא קריאה
|
||||||
|
ל-`qa_validator.validate_decision` ובלי בדיקת `export_blocked`. החסימה מתקיימת רק כי הסוכן
|
||||||
|
`legal-exporter` מחויב להריץ QA קודם (`legal-exporter.md:71,149`) — אדם/סוכן שיקרא
|
||||||
|
ל-`export_docx` ישירות **יעקוף** את השער. **יעד:** hard-block בתוך `export_docx` — שליפת
|
||||||
|
`qa_results`/`export_blocked` ודחייה לפני כתיבת ה-DOCX, כך שאי-אפשר לעקוף.
|
||||||
|
- **`active_draft_path` כ-derived-שזוחל-למקור (INV-EX1).** ה-DOCX נגזר מהבלוקים בייצוא הראשון,
|
||||||
|
אך אחרי עריכה (`revise_draft`/`apply_user_edit`) ה-DOCX הופך ל"מקור-האמת" לעריכות הבאות
|
||||||
|
(`db.py:185-188`) בלי לעדכן את `decision_blocks` חזרה — סטייה אפשרית בין הבלוקים למסמך-החי.
|
||||||
|
**יעד:** חוזה מפורש — או re-sync מהבלוקים, או הגדרת `active_draft_path` כאנקור-revision בלבד
|
||||||
|
(לא מקור-תוכן), עם בדיקת-בריאות לגילוי drift בין הבלוקים ל-DOCX הפעיל.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md) — [INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
(derived-data / מקור-יחיד) · [INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
|
||||||
|
(עקיבוּת) · [INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) (שערים).
|
||||||
|
- [02-data-model.md](02-data-model.md) — `decisions`/`decision_blocks` (המקור שממנו מייצאים).
|
||||||
|
- [04-analysis-writing.md](04-analysis-writing.md) — כתיבת הבלוקים שמהם נגזר ה-DOCX.
|
||||||
|
- [05-qa-review.md](05-qa-review.md#inv-qa3-החלטה-לא-מיוצאת-עם-כשל-קריטי-governance--g10) —
|
||||||
|
INV-QA3 (שער-הייצוא הקריטי שקודם לשלב זה).
|
||||||
|
- [07-learning.md](07-learning.md) — `ingest_final_version` + Hermes על ההחלטה הסופית.
|
||||||
|
- [X5-audit-provenance.md](X5-audit-provenance.md) — audit-trail (commit git) ועקיבוּת-מקור.
|
||||||
|
- [`skills/dafna-decision-template/SKILL.md`](../../skills/dafna-decision-template/SKILL.md) —
|
||||||
|
**המקור הסמכותי** לכללי-הסגנון (line classification · dash policy · placeholder · RTL-runs).
|
||||||
224
docs/spec/07-learning.md
Normal file
224
docs/spec/07-learning.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# 07 — לולאת הלמידה (Learning Loop)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומפרט כיצד המערכת **לומדת לאורך
|
||||||
|
זמן** — מהחלטות סופיות (Hermes), מפידבק-היו"ר, ומצמיחת-הקורפוס — באופן שמזין חזרה את
|
||||||
|
הכתיבה ([04-analysis-writing.md](04-analysis-writing.md)) ואת שערי-האיכות
|
||||||
|
([05-qa-review.md](05-qa-review.md)). הוא אוכף את
|
||||||
|
[INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||||
|
(שערים אנושיים — אישור היו"ר על כל עדכון-ידע) ואת
|
||||||
|
[INV-G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) /
|
||||||
|
כלל-ההנדסה **quality-at-source** (האחריות לאיכות יושבת במקור, לא בטלאי במורד הזרם).
|
||||||
|
|
||||||
|
> **⚠ קובץ מעורב — שני מודלי-סמכות** (לפי החוקה §3, §5):
|
||||||
|
> - **שער-הממשל** (Hermes מציע — היו"ר מאשרת ידנית; אין auto-commit ל-SKILL/lessons)
|
||||||
|
> הוא **invariant הנדסי** במודל הממשל-שיפוטי → נושא `מקורות:` (NCSC/JTC · CEPEJ 2018 ·
|
||||||
|
> FJC) + `סטטוס: verified`.
|
||||||
|
> - **כלל-ההנדסה quality-at-source** (היכן יושבת האחריות לאיכות-הידע) → invariant הנדסי
|
||||||
|
> במודל הנדסת-הנתונים → נושא `מקורות:` (Fowler — Data Mesh / quality-at-source ·
|
||||||
|
> DAMA-UK · ISO 8000) + `סטטוס: verified`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. תת-מערכת רכישת-הסגנון (Style Acquisition) — יעד-העל וההפרדה מהכתיבה
|
||||||
|
|
||||||
|
**יעד-העל של legal-ai:** שהסוכנים יכתבו וינתחו עררים **בדיוק כמו עו"ד דפנה תמיר** — להפנים את הקול והשיטה, לא רק לייצר טיוטה תקנית. ל-end זה מחייב **הפרדה מובהקת בין שתי תת-מערכות**:
|
||||||
|
|
||||||
|
| | **Writing Subsystem** | **Style-Acquisition Subsystem** |
|
||||||
|
|---|---|---|
|
||||||
|
| שאלה | "איך אכתוב את התיק כמו דפנה?" | "מה למדנו מהפער בין מה שכתבנו למה שדפנה חתמה?" |
|
||||||
|
| טריגר | issue כתיבה | `mark-final` |
|
||||||
|
| פלט | 12 בלוקים | עדכוני-קול מאושרים + מדד-מרחק |
|
||||||
|
| סוכנים | writer/analyst/qa/ceo | hermes-curator (מורחב) |
|
||||||
|
| יחס ל-artifacts-הקול | **צרכן read-only** | **היחיד שכותב** (דרך שער INV-G10) |
|
||||||
|
|
||||||
|
### 0.1 הגישה: Authorial Style Profiling, לא fine-tuning
|
||||||
|
היעד הוא **Text Style Transfer** מבוסס **פרופיל-סגנון מופשט** — להכליל את סגנון/שיטת דפנה ולהתאים לתיק הספציפי. fine-tuning של משקולות **לא רלוונטי**: המודל (Opus) סגור, והקורפוס (~48 החלטות, יו"ר חדשה) קטן מדי — מצב שבו הספרות מראה שפרופיל-מופשט + דוגמאות מנצח (≈+15% מעל RAG-בלבד). **מדיניות-העתקה לפי סוג-תוכן:** קבוע/נוסחאי (פתיחים דוקטרינליים, תבניות-סיום) → מותר להעתיק; ניתוח/טענות ספציפיים → להכליל ולהתאים; מהות (הלכה/עובדה מתיק אחר) → אסור (INV-LRN5).
|
||||||
|
|
||||||
|
### 0.2 שלושת ערוצי-ההזנה לכותב
|
||||||
|
1. **A — פרופיל-מופשט (ראשי):** voice-fingerprint + author-features כמותיים, מוזרק לכתיבה.
|
||||||
|
2. **B — דוגמאות + תבניות (תומך):** פסקאות-בלוק אמיתיות + Copy-Paste Templates + contrastive.
|
||||||
|
3. **C — deep-read (נקודתי):** voice-XXXX.md — worked example לתיק-מופת.
|
||||||
|
|
||||||
|
### 0.3 הצינור החוזר per-final (7 שלבים)
|
||||||
|
`mark-final` → [1] INTAKE (snapshot של הטיוטה) → [2] PAIRING (בלוק↔בלוק) → [3] ALIGNMENT (diff פר-בלוק) → [4] DISTILLATION (מפריד סגנון↔מהות) → [5] CURATION (Hermes + שער-יו"ר) → [6] FEEDBACK (ניתוב לערוץ A/B/C) → [7] MEASUREMENT (מדד-מרחק-סגנון).
|
||||||
|
|
||||||
|
### 0.4 ניהול ב-UI
|
||||||
|
`/methodology` = **עורך-הפרופיל** (declarative: יחסי-זהב, כללי-דיון, צ׳קליסטים, ביטויי-מעבר, אנטי-דפוסים, voice-invariants). `/training` = **שולחן-הלמידה** (קורפוס, פורטרט-סגנון, השוואת draft↔final, curator, מדד-מרחק, פנקס-התאמה).
|
||||||
|
|
||||||
|
### 0.5 Invariants חדשים
|
||||||
|
**INV-LRN4 (ניגוד-אמת → G10/G9):** למידת-קול מבוססת **pairing draft↔final ברמת-בלוק**, לא קריאת-final בלבד. כל החלטה אינה "סגורה" עד שהושוותה מול הסופי; כל סופי מנותח מול הטיוטה. נשמר פנקס-התאמה (`draft_final_pairs`) עם מצב-חיים `draft_done → final_received → analyzed → lessons_folded`.
|
||||||
|
*מקורות:* imitation-learning-from-expert-edits · contrastive personalization (arxiv 2504.08745) · author-profiling. *סטטוס: verified.*
|
||||||
|
|
||||||
|
**INV-LRN5 (טוהר-הקול → G4/G11):** שכבת-ידע-הקול (voice-fingerprint, style_patterns, exemplars) **לא תכיל הלכות/עובדות ספציפיות** — רק סגנון ושיטה. מהות מנותבת ל-precedent_library/halacha. ה-distillation מפריד במקור.
|
||||||
|
*מקורות:* quality-at-source (Data Mesh) · separation-of-concerns. *סטטוס: verified.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. שלוש לולאות-המשנה
|
||||||
|
|
||||||
|
הלמידה אינה אירוע יחיד אלא **שלוש לולאות** המתנקזות לאותם מסמכי-ידע מוסמכים
|
||||||
|
([legal-decision-lessons.md](../legal-decision-lessons.md),
|
||||||
|
[skills/decision/SKILL.md](../../skills/decision/SKILL.md)) ולקורפוסים:
|
||||||
|
|
||||||
|
### 1.1 לולאת-Hermes (post-export → הצעה → אישור)
|
||||||
|
|
||||||
|
הסוכן [hermes-curator](../../.claude/agents/hermes-curator.md) (adapter `deepseek_local`,
|
||||||
|
פרופילים `curator-cmp` / `curator-cmpa`) נקרא **אחרי שדפנה מסמנת קובץ כסופי** ב-UI
|
||||||
|
(`POST /api/cases/{case_number}/exports/{filename}/mark-final` → `pc_wake_curator_for_final()`
|
||||||
|
ב-`web/paperclip_client.py` → sub-issue + wakeup; **חיבור ישיר מה-UI, לא דרך CEO** —
|
||||||
|
`hermes-curator.md:27-35`). הוא:
|
||||||
|
|
||||||
|
- **קורא בלבד** את הטקסט הסופי (`case_get_final_text`), `get_style_guide`, ואת
|
||||||
|
`SKILL.md` / `legal-decision-lessons.md` / `corpus-analysis.md` המקומיים
|
||||||
|
(`hermes-curator.md:60-70`).
|
||||||
|
- מזהה **3–5 דפוסים/פערים** חדשים, כל ממצא מתויג `[סגנון]` / `[מבנה]` /
|
||||||
|
`[לקסיקון משפטי]` / `[טבלאי]` (`hermes-curator.md:99-108`).
|
||||||
|
- **מציע** — comment ב-Paperclip + רישום כל ממצא כ-`decision_lesson` דרך
|
||||||
|
`POST /api/training/corpus/{corpus_id}/lessons` (`source:"curator"`) שמופיע ב-UI
|
||||||
|
תחת הטאב "מה למדנו" (`hermes-curator.md:73-96`).
|
||||||
|
- **אינו מעדכן** קבצים בעצמו (skills/, lessons.py, DB) — רק מציע (`hermes-curator.md:125-130`).
|
||||||
|
|
||||||
|
### 1.2 לולאת-פידבק-היו"ר (capture → ניתוח שבועי → לקחים)
|
||||||
|
|
||||||
|
- **לכידה מובנית:** `record_chair_feedback` שומר הערת-דפנה בטבלת `chair_feedback`
|
||||||
|
(`category ∈ {missing_content, wrong_tone, wrong_structure, factual_error, style,
|
||||||
|
other}`) — `tools/workflow.py:348`, ראה [05-qa-review.md](05-qa-review.md) §2.3.
|
||||||
|
- **ניתוח שבועי:** ה-scheduled job `weekly-feedback-analysis` (ראשון 19:00,
|
||||||
|
`plugin-legal-ai/src/manifest.ts:175-179`) מושך `GET /api/chair-feedback/weekly-summary`,
|
||||||
|
ואם יש פריטים — **מעיר את ה-CEO** לעדכן את `legal-decision-lessons.md` עם הלקחים
|
||||||
|
החדשים (`worker.ts:784-837`; הוראת ה-prompt: "הוסף רק לקחים חדשים… קבץ לפי נושא"
|
||||||
|
— `worker.ts:830`).
|
||||||
|
- אין פריטים → הג'וב מדלג בשקט (`worker.ts:805`). ל-CEO שמתעורר מ-`weekly-feedback-job`
|
||||||
|
**אין `issueId`** — הוא כותב לקובץ בלבד, לא מפרסם comment ולא סוגר issue
|
||||||
|
(כלל מ-[CLAUDE.md](../../CLAUDE.md) "Scheduled Jobs").
|
||||||
|
|
||||||
|
### 1.3 לולאת-צמיחת-הקורפוס (החלטה סופית → קורפוס → אחזור)
|
||||||
|
|
||||||
|
החלטה סופית נקלטת לקורפוס-הסגנון (`ingest_final_version` — ראה [06-export.md](06-export.md)
|
||||||
|
§ Hermes), ופסיקה/החלטות-ועדה חדשות נקלטות דרך המסלול הקנוני של
|
||||||
|
[01-ingest.md](01-ingest.md). כך הקורפוס שמזין את האחזור ([03-retrieval.md](03-retrieval.md))
|
||||||
|
**גדל מהפלט עצמו** — והדיון הבא נשען על תקדים עשיר יותר. צמיחה זו כפופה לאותו חוזה-שלמות
|
||||||
|
([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)) כמו כל קליטה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. הלולאה במלואה (הציור)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
┌─────────────▼─────────────┐ ┌────────────────────────┐ │
|
||||||
|
│ כתיבה (04) │ ───▶ │ QA + שערים אנושיים (05)│ │
|
||||||
|
│ 12 בלוקים · סגנון דפנה │ │ validate_decision + │ │
|
||||||
|
│ ← lessons.py CONTENT_ │ │ פידבק-היו"ר │ │
|
||||||
|
│ CHECKLISTS · SKILL.md │ └───────────┬────────────┘ │
|
||||||
|
└───────────────────────────┘ │ ייצוא (06) │
|
||||||
|
▲ ▼ │
|
||||||
|
│ ┌──────────────────────┐ │
|
||||||
|
┌────────┴──────────────┐ │ סימון "סופי" (UI) │ │
|
||||||
|
│ legal-decision- │ │ mark-final │ │
|
||||||
|
│ lessons.md + SKILL.md │ └───────┬──────────────┘ │
|
||||||
|
│ (מסמכי-ידע מוסמכים) │ │ │
|
||||||
|
└────────▲──────────────┘ ┌──────────┴───────────┐ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ ✋ אישור-יו"ר ידני ┌───────────────┐ ┌────────────────┐│
|
||||||
|
└──────────────────────│ Hermes curator │ │ ingest_final → ││
|
||||||
|
(commit ידני בלבד) │ → הצעות(comment)│ │ קורפוס-סגנון → ┘│
|
||||||
|
└───────────────┘ │ אחזור (03) │
|
||||||
|
┌───────────────────────────┐ └────────────────┘
|
||||||
|
│ פידבק-היו"ר (05) ──┐ │
|
||||||
|
│ chair_feedback │ │
|
||||||
|
└────────────────────┼───────┘
|
||||||
|
▼
|
||||||
|
weekly-feedback-analysis (job)
|
||||||
|
│ מעיר CEO
|
||||||
|
▼
|
||||||
|
עדכון legal-decision-lessons.md ──┐
|
||||||
|
└──▶ (חזרה ל-04 / lessons.py)
|
||||||
|
```
|
||||||
|
|
||||||
|
הקשר לכתיבה: הלקחים והצ'קליסטים שב-`CONTENT_CHECKLISTS`
|
||||||
|
(`mcp-server/src/legal_mcp/services/lessons.py:355`, בורר `get_content_checklist`
|
||||||
|
`:509-555`) ו-`get_lessons_for_outcome` (`lessons.py:309`) מוזרקים ל-prompt-הכתיבה לפי
|
||||||
|
סוג-ערר ותוצאה — ראה [04-analysis-writing.md](04-analysis-writing.md) §5. כל סגירה של
|
||||||
|
לולאה (Hermes או פידבק) שמשנה את `legal-decision-lessons.md` / `SKILL.md` משפיעה ישירות
|
||||||
|
על הכתיבה הבאה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-LRN1: עדכון-ידע דורש אישור-יו"ר ידני — אין auto-commit (governance →G10)
|
||||||
|
**כלל:** מנגנוני-הלמידה (Hermes, ניתוח-פידבק שבועי) **מציעים בלבד**. כל שינוי ב-
|
||||||
|
[SKILL.md](../../skills/decision/SKILL.md) או ב-[legal-decision-lessons.md](../legal-decision-lessons.md)
|
||||||
|
מחייב **בחינה ואישור ידניים של היו"ר/חיים** ואז commit ידני — **לעולם לא auto-committed**.
|
||||||
|
Hermes כותב comment + `decision_lesson`, לא קבצים; ה-CEO השבועי כותב לקובץ אך הצעותיו
|
||||||
|
מאומתות ידנית לפני קיבוע. זהו פֶּאֶט של [INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||||
|
על שכבת-הידע: גם הלמידה כפופה לשיקול-הדעת האנושי.
|
||||||
|
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (human-in-the-loop;
|
||||||
|
never replace human judgment) · Council of Europe / CEPEJ (2018, under user control) ·
|
||||||
|
Federal Judicial Center — *Judicial Writing Manual* (2d ed.) | סטטוס: verified
|
||||||
|
**אכיפה:** הסוכן read-only על תוכן ו-write רק על comments (`hermes-curator.md:1-3, 125-130`);
|
||||||
|
תהליך-האישור — הצעת-curator כ-comment ב-Paperclip → חיים בוחן ומאשר ידנית → commit ל-
|
||||||
|
`SKILL.md` ו-`docs/legal-decision-lessons.md` (מ-[CLAUDE.md](../../CLAUDE.md) "Hermes Curator");
|
||||||
|
ה-CEO השבועי מתעורר בלי `issueId` וכותב לקובץ בלבד ([CLAUDE.md](../../CLAUDE.md) "Scheduled Jobs").
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-LRN2: האחריות לאיכות יושבת במקור — quality-at-source (engineering →G4)
|
||||||
|
**כלל:** האחריות לאיכות-הידע (לקחים, הלכות, metadata של פריטים מואנדקסים) נאכפת **קרוב
|
||||||
|
ככל האפשר לנקודת-היצירה/הקליטה** — בעת ניסוח-ההחלטה, בעת לכידת-הפידבק, ובעת קליטת-פריט —
|
||||||
|
**לא** מתוקנת בדיעבד במורד-הזרם (re-OCR, טלאי-קריאה, ניחוש בזמן-חיפוש). פריט-ידע חסר-שלמות
|
||||||
|
מסומן ומדווח בנקודת-הכניסה, לא מתקבל בשקט.
|
||||||
|
**מקורות:** Martin Fowler — *Data Mesh* (quality-at-source: domain owns data quality at
|
||||||
|
the point of creation) · DAMA-UK *Six Primary Dimensions for Data Quality* (2013,
|
||||||
|
completeness) · ISO 8000 (Data quality) | סטטוס: verified
|
||||||
|
**אכיפה:** חוזה-שלמות בקליטה ([01-ingest.md](01-ingest.md) §2, [02-data-model.md](02-data-model.md))
|
||||||
|
+ "אין בליעה שקטה" (חוקה §6); לכידת-פידבק מובנית בנקודת-ההערה (`record_chair_feedback`,
|
||||||
|
`tools/workflow.py:348`); לקחים נשמרים מבני ולא ad-hoc (`lessons.py`,
|
||||||
|
[legal-decision-lessons.md](../legal-decision-lessons.md)).
|
||||||
|
**הפרה ידועה:** ראה [INV-G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)
|
||||||
|
(ערן סופר 8046/24 אונדקס עם `headnote`/`summary`/`tags` ריקים — שלמות לא נאכפה במקור) →
|
||||||
|
ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-LRN3: ידע נלכד באופן מובנה — לא ad-hoc (engineering →G9)
|
||||||
|
**כלל:** פידבק ולקחים נלכדים ב**מבנה דטרמיניסטי ועקיב** — `chair_feedback` (עם `category`
|
||||||
|
ו-`block_id`), `decision_lessons` (עם `category`/`source`), ו-`CONTENT_CHECKLISTS` בקוד —
|
||||||
|
כך שהלמידה **עמידה וניתנת-לביקורת**, לא פזורה בהערות חופשיות. מקור-הלקח (`source:"curator"`
|
||||||
|
מול פידבק-יו"ר) משומר לעקיבוּת.
|
||||||
|
**מקורות:** ISO 15489-1:2016 (records reliability/authenticity) · DAMA-UK *Six Primary
|
||||||
|
Dimensions for Data Quality* (2013) · ISO 8000 (Data quality) | סטטוס: verified
|
||||||
|
**אכיפה:** טבלת `chair_feedback` + `record_chair_feedback`/`list_chair_feedback`
|
||||||
|
(`tools/workflow.py:348, 393`); `decision_lessons` עם `source`+`category`
|
||||||
|
(`hermes-curator.md:79-96`); `CONTENT_CHECKLISTS`/`get_lessons_for_outcome`
|
||||||
|
(`lessons.py:355, 309`). עקיבוּת-מקור קושרת ל-[X5-audit-provenance.md](X5-audit-provenance.md).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. הג'ובים המתוזמנים (תמיכת-תשתית ללולאה)
|
||||||
|
|
||||||
|
| Job (`manifest.ts`) | לוח-זמנים | תפקיד בלולאה |
|
||||||
|
|---------------------|-----------|---------------|
|
||||||
|
| `weekly-feedback-analysis` | ראשון 19:00 (`:175-179`) | מסכם פידבק-יו"ר → מעיר CEO לעדכון `legal-decision-lessons.md` (`worker.ts:784-837`) |
|
||||||
|
| `stale-case-reminder` | יומי 08:00 (`:169-172`) | תזכורת על תיקים תקועים 30+ ימים (`worker.ts:710-780`) — היגיינת-תהליך, לא ידע |
|
||||||
|
| `sync-case-status` | כל 15 דק' (`:162-166`) | מסנכרן סטטוסי-תיקים legal-ai↔Paperclip (`worker.ts:624`) — תשתית, לא ידע |
|
||||||
|
|
||||||
|
רק `weekly-feedback-analysis` הוא חלק מלולאת-הלמידה; שני האחרים הם היגיינת-תהליך/סנכרון.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) —
|
||||||
|
INV-G10 (שערים אנושיים) + [INV-G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)
|
||||||
|
(quality-at-source) + כלל-ההנדסה §6.
|
||||||
|
- [04-analysis-writing.md](04-analysis-writing.md) — הכתיבה שהלקחים/הצ'קליסטים מזינים (§3, §5).
|
||||||
|
- [05-qa-review.md](05-qa-review.md) — שער פידבק-היו"ר (§2.3) שמתחיל את לולאת-הפידבק.
|
||||||
|
- [01-ingest.md](01-ingest.md) — קליטה אחידה (quality-at-source) לצמיחת-הקורפוס.
|
||||||
|
- [03-retrieval.md](03-retrieval.md) — האחזור שהקורפוס הגדל מזין.
|
||||||
|
- [06-export.md](06-export.md) — `mark-final` שמפעיל את Hermes + `ingest_final_version`.
|
||||||
|
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור של לקחים (`source`).
|
||||||
|
- הסוכן: [.claude/agents/hermes-curator.md](../../.claude/agents/hermes-curator.md).
|
||||||
|
- מסמכי-הידע המוסמכים: [legal-decision-lessons.md](../legal-decision-lessons.md) ·
|
||||||
|
[skills/decision/SKILL.md](../../skills/decision/SKILL.md) ·
|
||||||
|
[corpus-analysis.md](../corpus-analysis.md).
|
||||||
11
docs/spec/README.md
Normal file
11
docs/spec/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# ספ המערכת — עוזר משפטי (Living System Spec)
|
||||||
|
|
||||||
|
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
|
||||||
|
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
|
||||||
|
|
||||||
|
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X10 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
||||||
|
- X1–X5: מזהים · רב-חברתי · אינטגרציה+deploy · סוכנים · audit.
|
||||||
|
- X6–X10 (מחזור-2, 8 משטחי-האפליקציה): חוזה UI↔API · לקוח-Paperclip · מילוי-שדות · חוזה כלי-MCP · deploy/env/secrets.
|
||||||
|
|
||||||
|
מפות-ממצאים: [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
|
||||||
168
docs/spec/X1-identifiers.md
Normal file
168
docs/spec/X1-identifiers.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# X1 — מודל המזהים הקנוני (Canonical Identifier Model)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **מזהי הישויות**
|
||||||
|
של עוזר משפטי. הוא אוכף את [G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה) (מזהה
|
||||||
|
קנוני מנורמל בכתיבה) ומעמיק את [INV-DM2](02-data-model.md#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות)
|
||||||
|
מ-[02-data-model.md](02-data-model.md). שני הקבצים חייבים להישאר עקביים: 02 מגדיר *אילו*
|
||||||
|
שדות מזהים כל ישות; X1 מגדיר את *הצורה הקנונית* של המזהה ו*איך* הוא מנורמל.
|
||||||
|
|
||||||
|
> **TARGET, לא תיאור-מצב.** המודל כאן הוא היעד הקנוני. כל מקום שבו הקוד בפועל
|
||||||
|
> (`mcp-server/src/legal_mcp/services/db.py`) סוטה ממנו — מתועד כ-**audit-finding** (§4),
|
||||||
|
> תסמין, לא התנהגות תקינה. כל טענה על הקוד הקיים מצוטטת `file:line` ואינה מונחת כתקינה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. הצורה הקנונית של `case_number`
|
||||||
|
|
||||||
|
מזהה-התיק (`case_number`) הוא **מספר-תיק מנורמל** — לא מחרוזת-ציטוט, לא תווית-תצוגה. הצורה
|
||||||
|
הקנונית מוגדרת ע"י **נרמול בנקודת-הכתיבה** (write-time canonicalization), כך שכל הרשומות
|
||||||
|
חולקות פורמט יחיד והשוואה היא תמיד שוויון-מחרוזת מול הצורה הקנונית.
|
||||||
|
|
||||||
|
**הנרמול הקנוני (TARGET — מופעל בכתיבה):**
|
||||||
|
|
||||||
|
| צעד | פעולה | דוגמה |
|
||||||
|
|------|--------|--------|
|
||||||
|
| trim | הסרת רווחים מקיפים | `" 8137/24 "` → `"8137/24"` |
|
||||||
|
| prefix-strip | הסרת קידומת-הליך לפני הספרה הראשונה ("ערר", "בל\"מ", "עע\"מ") | `"ערר 8137/24"` → `"8137/24"` |
|
||||||
|
| separator | איחוד מפריד `/` → `-` | `"8137/24"` → `"8137-24"` |
|
||||||
|
|
||||||
|
> **הצורה הקנונית = המספר הרשמי שהוקצה ע"י הוועדה, נשמר ככתבו** — לרבות מקטע-החודש **כשהוקצה**
|
||||||
|
> (למשל `8126-03-25`). מספרי-מורשת מסוימים הוקצו **ללא** חודש (למשל `8126-25`); המערכת **אסור**
|
||||||
|
> שתמציא או תוסיף (pad) מקטע-חודש שמעולם לא הוקצה. הנרמול-בכתיבה הוא **פורמט-בלבד ודטרמיניסטי**
|
||||||
|
> (trim · `/`→`-` · prefix-strip) — הוא **אינו מוסיף ואינו מסיר** מקטע-חודש. הפורמט המועדף
|
||||||
|
> מכאן-ואילך כולל את החודש.
|
||||||
|
|
||||||
|
> סוג-ההליך (`proceeding_type ∈ {ערר, בל"מ}`) הוא **חלק מהמפתח הקנוני** — לא חלק ממחרוזת
|
||||||
|
> ה-`case_number`. הקידומת "ערר"/"בל\"מ" מהכותרת נשללת מהמספר ונשמרת בעמודה ייעודית
|
||||||
|
> (`cases.proceeding_type`, `db.py:912`). כך "ערר 8137/24" ו-"בל\"מ 8137/24" הם שתי
|
||||||
|
> רשומות מובחנות בעלות אותו `case_number=8137-24` ו-`proceeding_type` שונה.
|
||||||
|
|
||||||
|
**נרמול-בכתיבה הוא המנגנון הראשי; התאמה-סלחנית-בקריאה היא נוחות משנית בלבד.** כלל-ההנדסה
|
||||||
|
"נרמול לא תיקון-תסמין" (חוקה §6) קובע: מתקנים את הנתון במקור, לא מטליאים בקריאה. אם רשומה
|
||||||
|
נשמרה בצורה לא-קנונית — היעד הוא לנרמל אותה במיגרציה/בכתיבה, **לא** לסמוך על מנוע-קריאה
|
||||||
|
שיגשר על הפער. ההתאמה-הסלחנית (§3) קיימת כדי לבלוע *קלט-משתמש* רב-צורני (כותרת Paperclip),
|
||||||
|
לא כדי לתרץ נתון-מאוחסן לא-קנוני.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. שני מרחבי-מזהים: `cases` מול `case_law`
|
||||||
|
|
||||||
|
`case_number` מופיע בשתי טבלאות נפרדות עם **שני מרחבי-מזהים שונים** ו**ללא FK חוצה-טבלאות**
|
||||||
|
ביניהן. בלבול בין השניים הוא כשל-שורש: תיק חי אינו תקדים, ולהפך.
|
||||||
|
|
||||||
|
| ממד | `cases` (תיק חי) | `case_law` (קורפוס פסיקה) |
|
||||||
|
|------|------------------|---------------------------|
|
||||||
|
| תפקיד | הערר שבטיפול כעת (1xxx/8xxx/9xxx) | תקדים — פסיקה חיצונית **וגם** החלטות-ועדה |
|
||||||
|
| מפתח קנוני | `(case_number, proceeding_type)` | `(case_number, source_kind, proceeding_type)` — ראה להלן |
|
||||||
|
| אילוץ-ייחודיות | `uq_cases_number_proc` על `(case_number, proceeding_type)` (`db.py:923-924`) | שני partial unique לפי `source_kind` (`db.py:904-909`) |
|
||||||
|
| מורשת (הוסרה) | `case_number TEXT UNIQUE NOT NULL` (`db.py:76`), הוסר V15 (`db.py:921-922`) | `case_number TEXT UNIQUE NOT NULL` (`db.py:368`), הוסר V15 (`db.py:902-903`) |
|
||||||
|
| FK חוצה | **אין** — `cases` ו-`case_law` הם מרחבים נפרדים | **אין** |
|
||||||
|
|
||||||
|
**`case_law` — מזהה מודע-source_kind.** ה-V15 החליפה את `UNIQUE(case_number)` הגלובלי בשני
|
||||||
|
partial unique indexes (`db.py:904-909`):
|
||||||
|
|
||||||
|
- **`internal_committee`** (החלטות-ועדה פנימיות): `UNIQUE(case_number, proceeding_type)`
|
||||||
|
— `uq_case_law_internal_number_proc`, `WHERE source_kind = 'internal_committee'`.
|
||||||
|
- **חיצוני** (`external_upload` / `cited_only` / `nevo_seed`): `UNIQUE(case_number)`
|
||||||
|
— `uq_case_law_external_number`, `WHERE source_kind <> 'internal_committee'`.
|
||||||
|
|
||||||
|
לכן המזהה הקנוני של `case_law` הוא הטריפלט **(`case_number` מנורמל, `source_kind`,
|
||||||
|
`proceeding_type`)** — עקבי עם [02-data-model §2א](02-data-model.md#2א-case_law--החוזה-הקונקרטי).
|
||||||
|
|
||||||
|
**אין הצמדה חוצה-טבלאות.** כשהחלטת-תיק מ-`cases` מצוטטת בהמשך כתקדים, היא נכנסת ל-`case_law`
|
||||||
|
כרשומה *חדשה* (`source_kind='internal_committee'`) — לא כ-FK ל-`cases`. שני המרחבים נשארים
|
||||||
|
עצמאיים; הגישור ביניהם הוא דרך הקליטה ([01-ingest.md](01-ingest.md)), לא דרך מפתח-זר.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ציטוט מול מזהה — `citation_formatted` הוא תצוגה, לא מפתח
|
||||||
|
|
||||||
|
הציטוט-המלא והמזהה-הקנוני הם **שני שדות נפרדים בכוונה**:
|
||||||
|
|
||||||
|
- **מזהה קנוני** = `case_number` מנורמל (`8126-03-25`) — המפתח שמשמש לחיפוש, ל-upsert,
|
||||||
|
ולאילוצי-ייחודיות.
|
||||||
|
- **ציטוט מעוצב** = `citation_formatted` (`db.py:1070`, V19) — מחרוזת-תצוגה לפי כללי-הציטוט
|
||||||
|
האחיד, למשל: `ערר (ועדות ערר - תכנון ובנייה ת"א-יפו) 81002-01-21 **אברהם אגסי נ' הועדה
|
||||||
|
המקומית** (נבו 25.9.2025)` (`db.py:1067-1068`).
|
||||||
|
|
||||||
|
הציטוט הוא **שדה נגזר לתצוגה** — מכיל את המזהה אך גם צדדים, ערכאה, ותאריך-פרסום. הוא **לעולם
|
||||||
|
אינו המפתח**. אחסון מחרוזת-ציטוט בשדה-המזהה שובר את הנרמול ([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)),
|
||||||
|
מערבב תצוגה עם זהות (פוגע ב-1NF — ערך לא-אטומי בשדה-מפתח), ומונע התאמת-שוויון מול המספר
|
||||||
|
המנורמל.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-ID1: `case_number` מנורמל בכתיבה — התאמה-סלחנית משנית
|
||||||
|
**כלל:** `case_number` מנורמל לצורה קנונית יחידה **בנקודת-הכתיבה** בנרמול **פורמט-בלבד
|
||||||
|
ודטרמיניסטי** (trim · prefix-strip · `/`→`-`) — הנרמול **אינו ממציא ואינו מוסיף** מקטע-חודש
|
||||||
|
שלא הוקצה. הצורה הקנונית היא **המספר הרשמי שהוקצה** (עם חודש כשהוקצה, למשל `8126-03-25`),
|
||||||
|
והשוואה-בקריאה היא שוויון מול הצורה הקנונית. **התאמה-סלחנית-בקריאה היא
|
||||||
|
נוחות משנית בלבד** — היא בולעת קלט-משתמש רב-צורני, ואינה תחליף לנרמול-בכתיבה ([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה),
|
||||||
|
כלל-ההנדסה "נרמול לא תיקון-תסמין", חוקה §6).
|
||||||
|
**מקורות:** SSOT (Single Source of Truth — normalization principle) · E.F. Codd, First Normal
|
||||||
|
Form (CACM 13(6), 1970) · Martin Kleppmann, *Designing Data-Intensive Applications* (O'Reilly,
|
||||||
|
2017) | סטטוס: verified
|
||||||
|
**אכיפה:** נרמול-בכתיבה בנקודת-הקליטה ([01-ingest.md](01-ingest.md)) + אילוצי-ייחודיות על
|
||||||
|
המפתח הקנוני (`uq_cases_number_proc`, `db.py:923-924`; partial unique `case_law`, `db.py:904-909`).
|
||||||
|
**הפרה ידועה:** `_normalize_case_number` (`db.py:1196-1211`) מנרמל **בקריאה בלבד** ("tolerant
|
||||||
|
lookup", `db.py:1197`), ו-`get_case_by_number` (`db.py:1214-1231`) משווה two-pass (`case_number=$1`
|
||||||
|
**OR** `replace(btrim(case_number),'/','-')=$2`, `db.py:1223-1224`) — אין מסלול-כתיבה שמקנן את
|
||||||
|
הערך המאוחסן. בנפרד מכך: כשאותו תיק נקלט גם בצורה ללא-חודש וגם עם-חודש (סחף-הזנה, למשל `8126-25`
|
||||||
|
מול `8126-03-25` המתייחסים לתיק אחד), הצורה **עם-החודש (הרשמית) היא הקנונית** והרשומה החסרה
|
||||||
|
מתואמת אליה — זו **בעיית-תיאום (reconciliation)**, לא חולשה בנרמול (הנרמול אינו אמור לפדד חודש).
|
||||||
|
תיאום רשומות-מורשת מעורבות-צורה הוא **פריט ניקיון-נתונים/מיגרציה חד-פעמי** (ראה
|
||||||
|
[gap-audit / תת-פרויקט 2](../audit-report.md)), לא אלגוריתם-padding בזמן-ריצה → ממצא
|
||||||
|
ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-ID2: אין ציטוט-מלא כמזהה — הציטוט שדה-תצוגה נגזר
|
||||||
|
**כלל:** אף ישות **אינה** משתמשת במחרוזת-ציטוט-מלאה כמזהה. שדה-המזהה מכיל מספר-תיק מנורמל
|
||||||
|
בלבד; הציטוט-המלא חי בשדה ייעודי נפרד (`citation_formatted`, `db.py:1070`) ככלי-תצוגה נגזר
|
||||||
|
([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה), [INV-DM2](02-data-model.md#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות)).
|
||||||
|
**מקורות:** SSOT (Single Source of Truth — normalization principle) · E.F. Codd, First Normal
|
||||||
|
Form (CACM 13(6), 1970) · Martin Kleppmann, *Designing Data-Intensive Applications* (O'Reilly,
|
||||||
|
2017) | סטטוס: verified
|
||||||
|
**אכיפה:** הפרדת-שדות ב-schema — מזהה ב-`case_number` (אילוצי-ייחודיות, `db.py:904-909,923-924`),
|
||||||
|
ציטוט ב-`citation_formatted` בלבד (`db.py:1070`); נרמול-בכתיבה שדוחה מחרוזת-ציטוט בשדה-המזהה.
|
||||||
|
**הפרה ידועה:** החלטות "סופר" נקלטו עם **ציטוט-מלא מאוחסן כ-`case_number`** (שדה-המזהה מכיל
|
||||||
|
את מחרוזת-הציטוט במקום מספר-תיק מנורמל) — חיפוש מול המספר המנורמל נכשל, והפער מתגלגל ל-INV-ID1
|
||||||
|
(`_normalize_case_number` רק מטליא בקריאה) → ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. מצב קיים מול יעד — audit-findings
|
||||||
|
|
||||||
|
ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול `db.py`.
|
||||||
|
|
||||||
|
- **נרמול בצד-הקריאה בלבד.** `_normalize_case_number` (`db.py:1196-1211`) מתואר במפורש כ-
|
||||||
|
"tolerant lookup" (`db.py:1197`) — מסיר קידומת לפני הספרה הראשונה, trim, ו-`/`→`-` — אך
|
||||||
|
**אינו מנרמל את הערך המאוחסן**. `get_case_by_number` (`db.py:1214-1231`) בונה סביבו two-pass
|
||||||
|
(exact `OR` normalized, `db.py:1223-1224`). **תסמין:** הנרמול חי כתיקון-תסמין בקריאה ולא
|
||||||
|
כקנוניזציה-בכתיבה, בניגוד ל-[G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה) וכלל-ההנדסה
|
||||||
|
§6. **יעד:** מסלול-כתיבה שמנרמל את `case_number` (פורמט-בלבד: trim/prefix-strip/`/`→`-`,
|
||||||
|
**ללא המצאת חודש**) בנקודת-הקליטה; הקריאה הופכת להשוואת-שוויון פשוטה.
|
||||||
|
- **רשומות-מורשת מעורבות-צורה (בעיית-תיאום, לא padding).** כשאותו תיק נקלט גם כ-`8126-25`
|
||||||
|
וגם כ-`8126-03-25` (סחף-הזנה), ה-two-pass אינו מזהה אותם כתיק אחד. **יעד:** תיאום חד-פעמי
|
||||||
|
של הרשומות לצורה הרשמית עם-החודש (הקנונית) במסגרת ניקיון-נתונים/מיגרציה
|
||||||
|
([gap-audit / תת-פרויקט 2](../audit-report.md)) — **לא** אלגוריתם-padding בזמן-ריצה שממציא חודש.
|
||||||
|
- **ציטוט-מלא כ-`case_number` (מורשת).** השדה המקורי `case_number TEXT UNIQUE NOT NULL`
|
||||||
|
(`cases` `db.py:76`, `case_law` `db.py:368`) לא אכף צורה — מה שאפשר אחסון מחרוזת-ציטוט בשדה
|
||||||
|
זה (החלטות "סופר"). הוחלף ב-partial unique מודע-`source_kind` ב-V15 (`db.py:902-909`), אך
|
||||||
|
**ללא ולידציית-צורה בכתיבה**. **יעד:** ולידציית-כתיבה שדוחה ערך שאינו מספר-תיק מנורמל ומפנה
|
||||||
|
ציטוט ל-`citation_formatted`.
|
||||||
|
- **שני מרחבי-מזהים, סיכון-בלבול בקוד-קריאה.** `get_case_by_number` (`db.py:1214`) פונה
|
||||||
|
ל-`cases` בלבד; `get_case_law_by_citation` (`db.py:2503`) פונה ל-`case_law` בלבד — נכון, אך
|
||||||
|
שמות-הפונקציות אינם מבדילים את מרחב-המזהים בבירור. **יעד:** תיעוד מפורש (קובץ זה) + עקביות
|
||||||
|
שמות שמשקפת `cases` מול `case_law` כשני מרחבים נפרדים ללא FK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md) — [G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)
|
||||||
|
(מזהה קנוני מנורמל בכתיבה) + כלל-ההנדסה "נרמול לא תיקון-תסמין" (§6).
|
||||||
|
- [02-data-model.md](02-data-model.md) — [INV-DM2](02-data-model.md#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות)
|
||||||
|
(מזהה קנוני יחיד) + החוזה הקונקרטי של `case_law`; X1 הוא ה-deep-dive על אותו מזהה.
|
||||||
|
- [01-ingest.md](01-ingest.md) — נקודת-הכתיבה שבה הנרמול-בכתיבה צריך להיאכף.
|
||||||
|
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור (הציטוט כשדה-תצוגה נגזר).
|
||||||
86
docs/spec/X10-deploy-env-secrets.md
Normal file
86
docs/spec/X10-deploy-env-secrets.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# X10 — Deploy, סביבה וסודות (Deploy, Environment & Secrets)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **קונפיגורציה, משתני-סביבה
|
||||||
|
וסודות** — מה שהיה מכוסה כחצי-deploy בלבד ב-[X3 §2](X3-integration-deploy.md). הוא מגדיר את חוזה-ה-env
|
||||||
|
(SSoT אחד), מקור-ה-config (Coolify), טיפול-הסודות, ואי-ה-hardcode. X3 נשאר הבעלים של **זרימות**-האינטגרציה;
|
||||||
|
X10 הבעלים של **הקונפיגורציה וה-deploy**.
|
||||||
|
|
||||||
|
> **invariant פרויקטלי-תפעולי + הנדסי.** ENV1/ENV3/ENV4/ENV5 נשענים על עקרונות-הנדסה מוכרים (12-Factor,
|
||||||
|
> ניהול-סודות) — ≥3 מקורות. ENV2 (מקור-config של *מערכת זו*) הוא תפעולי, נקשר ל-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. מצב קיים (מאומת מול הקוד)
|
||||||
|
|
||||||
|
- **מודל-deploy:** legal-ai = Coolify Docker (UUID `gyjo0mtw2c42ej3xxvbz8zio`, build_pack `dockerimage`);
|
||||||
|
ה-env **מוזרק ישירות מ-Coolify**, לא מ-Infisical ([X3 §2](X3-integration-deploy.md); זיכרון `reference_legal_ai_env_architecture`).
|
||||||
|
- **40+ משתני-env** נקראים על-פני [config.py](../../mcp-server/src/legal_mcp/config.py), [web/app.py](../../web/app.py),
|
||||||
|
[paperclip_api.py](../../web/paperclip_api.py)/[paperclip_client.py](../../web/paperclip_client.py),
|
||||||
|
[gitea_client.py](../../web/gitea_client.py), [chat_proxy.py](../../web/chat_proxy.py).
|
||||||
|
- **קטלוג-UI** ([mcp_env_catalog.py](../../web/mcp_env_catalog.py)) מכסה **13 בלבד** מתוך ה-40+ → השאר בלתי-נראים
|
||||||
|
לדף-ההגדרות ולגילוי-drift.
|
||||||
|
- **Infisical:** קוד-ה-SDK ב-[config.py](../../mcp-server/src/legal_mcp/config.py) קורא `INFISICAL_TOKEN`, אך
|
||||||
|
בקונטיינר הוא **לעולם לא מוגדר** → קוד מת; ה-priority בפועל = Coolify-env בלבד.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-ENV1: env-catalog יחיד = SSoT לכל משתני-הסביבה
|
||||||
|
**כלל:** קיים **קטלוג-env יחיד** המתאר את **כל** המשתנים (שם, ברירת-מחדל, סוד?, מי-קורא, מה-שולט). אין משתנה
|
||||||
|
שנקרא-בקוד אך לא-בקטלוג, ואין משתנה-בקטלוג שלא-נקרא. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
ו-[G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) (שלמות-הקטלוג). **הנדסי.**
|
||||||
|
**מקורות:** *The Twelve-Factor App — III. Config* (https://12factor.net/config) · OWASP — *Configuration / Secrets Management Cheat Sheet*
|
||||||
|
(https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html) · Kleppmann *DDIA* (config as data) | סטטוס: verified
|
||||||
|
**אכיפה:** קטלוג מקיף + בדיקה ש-getenv call-sites ⊆ קטלוג. **כיום:** 13/40+ בלבד ([gap-audit GAP-60](gap-audit.md)).
|
||||||
|
**הפרה ידועה:** `PAPERCLIP_BOARD_API_KEY`/`GITEA_*`/`CHAT_SERVICE_URL`/`LEGAL_CHAT_SHARED_SECRET` לא בקטלוג; `GITEA_ACCESS_TOKEN` מול `GITEA_TOKEN` (שני שמות) ([gap-audit GAP-58](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-ENV2: מקור-config יחיד ומתועד (Coolify) — בלי קוד-מת
|
||||||
|
**כלל:** למערכת **מקור-config אחד מתועד** (Coolify-env לקונטיינר), והקוד אינו מניח מקור-שני שאינו פעיל.
|
||||||
|
אין "Infisical priority" מדומה כשאין `INFISICAL_TOKEN`. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
(מקור-אמת יחיד) וכלל "אין בליעה שקטה" ([§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)). **פרויקטלי-תפעולי.**
|
||||||
|
**מקור-סמכות:** זיכרון `reference_legal_ai_env_architecture`; `feedback_infisical_coolify_drift`; [X3 §2](X3-integration-deploy.md).
|
||||||
|
**אכיפה:** לתעד Coolify כ-SSoT; להסיר/לבודד את קוד-ה-Infisical או להפעילו אמיתית.
|
||||||
|
**הפרה ידועה:** קוד-Infisical ב-[config.py](../../mcp-server/src/legal_mcp/config.py) מת בקונטיינר; ה-priority המתועד לא תואם מציאות ([gap-audit GAP-55](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-ENV3: ללא hardcode — IDs/URLs/נתיבים מ-config
|
||||||
|
**כלל:** מזהים (company/agent), כתובות (Paperclip/Coolify/Gitea/chat/frontend), פורטים ונתיבים **נגזרים מ-config**,
|
||||||
|
לא קבועים בקוד. אין `/home/chaim` קשיח ואין UUID קשיח. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
(SSoT) — תואם [X7 INV-INT5](X7-paperclip-client-params.md). **הנדסי.**
|
||||||
|
**מקורות:** *Twelve-Factor App — III. Config* · *Twelve-Factor — X. Dev/prod parity* (https://12factor.net/dev-prod-parity) ·
|
||||||
|
Google *SRE / configuration as data* (https://sre.google/workbook/configuration-design/) | סטטוס: verified
|
||||||
|
**אכיפה:** grep-gate נגד literals (UUID/URL/path) בקוד-חדש. **כיום אין.**
|
||||||
|
**הפרה ידועה:** UUIDs קשיחים ([paperclip_client.py:36-62](../../web/paperclip_client.py), [app.py:3976](../../web/app.py)); URLs קשיחים (`pc.nautilus...`, `coolify...`, `legal-ai-next...`); `LEGAL_AI_WORKSPACE_CWD="/home/chaim/legal-ai"`; chat-URL `10.0.1.1` מול תיעוד `host.docker.internal` ([gap-audit GAP-56/59/61](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-ENV4: אין secrets בקוד/בברירות-מחדל — fail-loud
|
||||||
|
**כלל:** שום סוד (creds/key/token) אינו בקוד או בברירת-מחדל; היעדר-סוד **נכשל בקול** (לא נופל לברירת-מחדל
|
||||||
|
שקטה עם creds). אין סוד מודלף ל-log או ל-git. מופע של [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
|
||||||
|
(integrity) וכלל "אין בליעה שקטה" ([§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)). **הנדסי.** תואם זיכרון `feedback_secrets_first`.
|
||||||
|
**מקורות:** OWASP — *Secrets Management Cheat Sheet* (https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html) ·
|
||||||
|
*Twelve-Factor — III. Config* (no secrets in code) · CWE-798 — *Use of Hard-coded Credentials* (https://cwe.mitre.org/data/definitions/798.html) | סטטוס: verified
|
||||||
|
**אכיפה:** ברירות-מחדל ריקות + כישלון-מפורש; secret-scan ב-CI.
|
||||||
|
**הפרה ידועה:** `PAPERCLIP_DB_URL` ברירת-מחדל `postgresql://paperclip:paperclip@...` (creds plaintext) ב-3 מקומות ([paperclip_client.py:21](../../web/paperclip_client.py), [app.py:3789,3964](../../web/app.py)) ([gap-audit GAP-57](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-ENV5: drift-detection מכסה את כל המשתנים הקריטיים
|
||||||
|
**כלל:** מנגנון גילוי-ה-drift (Coolify↔container) מכסה את **כל** המשתנים הקריטיים, לא תת-קבוצה. מופע של
|
||||||
|
[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן) ברוח-שלו (freshness של config) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים). **הנדסי.**
|
||||||
|
**מקורות:** *Twelve-Factor — III. Config* · Google *SRE — config drift* · HashiCorp — *config drift / desired state* (https://developer.hashicorp.com/well-architected-framework) | סטטוס: verified
|
||||||
|
**אכיפה:** הרחבת ה-catalog ל-drift-detection מלא בדף-ההגדרות.
|
||||||
|
**הפרה ידועה:** רק 13/40+ במנגנון; 8+ סודות קריטיים בלתי-מנוטרים ([gap-audit GAP-60](gap-audit.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Deploy — עמידוּת (מ-X3 §2, מורחב)
|
||||||
|
- **מחזור:** commit→push→Gitea Actions→Coolify redeploy (~2-4 דק'); endpoint חדש דורש גם `npm run api:types` ([X3 §2](X3-integration-deploy.md), [INV-INT2](X3-integration-deploy.md)).
|
||||||
|
- **חולשות-עמידוּת שנמצאו:** [start.sh](../../start.sh) **אינו נכשל** אם uvicorn לא עולה (ה-UI עולה עם בקאנד שבור);
|
||||||
|
ה-curl ל-Coolify ב-[.gitea/workflows/deploy.yaml](../../.gitea/workflows/deploy.yaml) הוא fire-and-forget (אין אימות-הצלחה) ([gap-audit GAP-62](gap-audit.md)).
|
||||||
|
- **host.docker.internal:** ה-chat-service נדרש דרך gateway; תיעוד מול קוד לא-תואמים (10.0.1.1) — ENV3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. הפניות-אחיות
|
||||||
|
- [X3-integration-deploy.md](X3-integration-deploy.md) — זרימות-אינטגרציה + INV-INT2 (מחזור-deploy).
|
||||||
|
- [X7-paperclip-client-params.md](X7-paperclip-client-params.md) — IDs/keys של Paperclip (INV-INT5 תואם ENV3).
|
||||||
|
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים), [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai), כלל "אין בליעה שקטה" ([§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
|
||||||
|
- זיכרונות: `reference_legal_ai_env_architecture`, `feedback_infisical_coolify_drift`, `feedback_secrets_first`.
|
||||||
|
- [config.py](../../mcp-server/src/legal_mcp/config.py), [mcp_env_catalog.py](../../web/mcp_env_catalog.py), [Dockerfile](../../Dockerfile), [start.sh](../../start.sh), [.env.example](../../.env.example).
|
||||||
182
docs/spec/X11-citation-corroboration.md
Normal file
182
docs/spec/X11-citation-corroboration.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# X11 — תיקוף-הלכות בציטוטים (Citation Corroboration / Internal Citator)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md). הוא מגדיר **שכבת citator פנימית**: שימוש
|
||||||
|
ב**ציטוטים-הנכנסים** לפסיקה (איך ערכאות וועדות מאוחרות *טיפלו* בה) כדי **לתקף ולחדד את ההלכות
|
||||||
|
שחולצו ממנה**, וכך לצמצם את היקף האישור-הידני של היו"ר. הוא אוכף את
|
||||||
|
[INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) (כפי שתוקן —
|
||||||
|
ראה §6), נשען על [INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
|
||||||
|
(עקיבוּת-מקור), ומעמיק את מודל-הציטוטים של [02-data-model.md](02-data-model.md).
|
||||||
|
|
||||||
|
> **TARGET, לא תיאור-מצב.** המנגנון כאן הוא היעד. רכיבים שטרם נבנו מסומנים מפורשות
|
||||||
|
> כ-audit-finding (§7), ולא כהתנהגות קיימת. כל טענה על הקוד מצוטטת `file:line`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. הרעיון — citator פנימי
|
||||||
|
|
||||||
|
בעולם המשפטי, הכלים שמאמתים פסיקה לפי הציטוטים-הנכנסים אליה הם **citators** (Shepard's של
|
||||||
|
LexisNexis, KeyCite של Westlaw, BCite של Bloomberg). הם עונים על שתי שאלות: *האם הפסק עדיין
|
||||||
|
"good law"?* ו-*איך ערכאות מאוחרות טיפלו בו?* — לפי **סיווג-טיפול** (treatment) של כל ציטוט-נכנס.
|
||||||
|
|
||||||
|
המערכת שלנו מחזיקה כבר את חומר-הגלם: גרף-ציטוטים פנימי (§2). מה שחסר הוא **השכבה שמחברת אותו
|
||||||
|
להלכות** — לתקף הלכה ספציפית לפי כך שערכאות/ועדות מאוחרות *אימצו* אותה בפועל. הלכה שאומצה
|
||||||
|
שוב-ושוב ע"י פאנלים אחרים אינה "ניחוש של מודל" — היא **טיפול שיפוטי אנושי מצטבר**, וזה הבסיס
|
||||||
|
שמאפשר אישור-אוטומטי בלי לפגוע בשיקול-הדעת האנושי (ראה תיקון INV-G10, §6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. חומר-הגלם הקיים — שני גרפי-ציטוט
|
||||||
|
|
||||||
|
| טבלה | קושר | הקשר נשמר | סיווג-טיפול |
|
||||||
|
|------|------|-----------|-------------|
|
||||||
|
| `case_law_citations` (`db.py:382`) | פסיקה ← **החלטת-ועדה פנימית** (`decisions`) | `context_text` | `citation_type` (support/distinguish/overrule/obiter) |
|
||||||
|
| `precedent_internal_citations` (`db.py:938`) | פסיקה ← **פסיקה אחרת** (`case_law`) | `match_context` | — (אין שדה-טיפול) |
|
||||||
|
|
||||||
|
**audit-finding (קיים):** ב-`precedent_internal_citations` **אין** שדה סיווג-טיפול, ו-ב-
|
||||||
|
`case_law_citations` שדה `citation_type` קיים אך **ברירת-המחדל `'support'`** (`db.py:387`) —
|
||||||
|
כלומר רוב הרשומות לא סווגו בפועל. סיווג-הטיפול הוא רכיב שיש לבנות (§4, INV-COR2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. תנאי-קדם — גרף-זהות נקי
|
||||||
|
|
||||||
|
ה-corroboration מצרף ציטוטים להלכות **דרך רשומת ה-`case_law`**. אם אותו תקדים מיוצג בשתי
|
||||||
|
רשומות (stub `cited_only` + רשומת-תוכן), הציטוטים יושבים על האחת וההלכות על האחרת — וה-join
|
||||||
|
נשבר. לכן **[INV-G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)/[INV-ID1](X1-identifiers.md)
|
||||||
|
הם תנאי-קדם קשיח** ל-X11.
|
||||||
|
|
||||||
|
**הפרה ידועה (תוקנה 2026-05-31):** אהוד שפר עע"מ 317/10 הוחזק בשתי רשומות — `external_upload`
|
||||||
|
עם ציטוט-מלא כ-`case_number` (הפרת INV-ID2) + `cited_only` stub שתפס את 7 הציטוטים-הנכנסים בנפרד
|
||||||
|
מ-53 ההלכות. מוזג לרשומה קנונית אחת; סריקת-קורפוס מלאה (128 רשומות) אישרה **0** stubs עם
|
||||||
|
ציטוטים-תקועים שנותרו. ראה [#70 / FU-2c-b](../audit-report.md). הניקוי השוטף של 49 ה-`cited_only`
|
||||||
|
(הרחבת `_DOCKET_RE`, ציטוטים-משולבים) ממשיך תחת #70.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. המנגנון (TARGET)
|
||||||
|
|
||||||
|
```
|
||||||
|
לכל הלכה h של תקדים P:
|
||||||
|
1. אסוף ציטוטים-נכנסים ל-P (שני הגרפים, §2).
|
||||||
|
2. סווג טיפול לכל ציטוט (followed / distinguished / criticized / overruled / explained)
|
||||||
|
מתוך ההקשר (context_text / match_context) — Opus 4.8 @ xhigh. [INV-COR2]
|
||||||
|
3. התאם כל ציטוט להלכה הספציפית: דמיון סמנטי בין ההקשר לבין rule_statement של h,
|
||||||
|
מעל רף; הציטוט נספר ל-h רק אם הוא נוגע *לאותה הלכה*, לא לפסק כולו. [INV-COR3]
|
||||||
|
4. ספֵר corroboration של h = מספר ציטוטים חיוביים בלתי-תלויים שהותאמו אליה.
|
||||||
|
5. אישור:
|
||||||
|
אם ≥N חיוביים בלתי-תלויים ∧ 0 שליליים → אישור-אוטומטי (corroborated). [INV-COR4]
|
||||||
|
אם יש טיפול שלילי (distinguished/criticized/overruled) → אסור אוטו;
|
||||||
|
דגל ליו"ר, ואף הדחה אם overruled. [INV-COR2]
|
||||||
|
אחרת (לא-מצוטט) → נשאר בשער-היו"ר הרגיל (סף-confidence). [INV-COR5]
|
||||||
|
6. העשרה (משני): נסח-מחדש/חדד את rule_statement לפי המסגור של הפאנל המצטט.
|
||||||
|
```
|
||||||
|
|
||||||
|
**N (סף-corroboration)** ייקבע אמפירית (≥2 ברירת-מחדל; ציטוט יחיד אינו מספיק — INV-COR4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-COR1: corroboration = טיפול שיפוטי אנושי מצטבר, לא שיפוט-AI
|
||||||
|
**כלל:** אישור-הלכה מבוסס-ציטוט נשען על כך ש**ערכאות/ועדות אנושיות אימצו את ההלכה בפועל** —
|
||||||
|
לא על ציון-ביטחון של מודל. ה-AI רק **מזהה ומסווג** את הטיפול הקיים; ההכרעה הערכית שההלכה
|
||||||
|
תקפה ניתנה ע"י השופטים המצטטים. זהו הבסיס לתיקון INV-G10 (§6).
|
||||||
|
**מקורות (פתוחים):** Fowler, Johnson, Spriggs, Jeon & Wahlbeck, *Network Analysis and the Law:
|
||||||
|
Measuring the Legal Importance of Precedents at the U.S. Supreme Court* (Political Analysis 15:3,
|
||||||
|
2007) — סמכות-תקדים נמדדת מהציטוטים-הנכנסים, מאומת בניבוי ציטוט עתידי · *LePaRD: A Large-Scale
|
||||||
|
Dataset of Judicial Citations to Precedent* (arXiv 2311.09356, 2023) · Hellyer, *Evaluating
|
||||||
|
Shepard's, KeyCite, and BCite* (Law Library Journal 110:4, 2018, open-access) | סטטוס: verified
|
||||||
|
**אכיפה:** מנגנון §4 — corroboration נספר רק מטיפול שיפוטי מתועד, לא מ-confidence.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-COR2: סיווג-טיפול חובה לפני ספירה — שלילי לעולם לא מאשר
|
||||||
|
**כלל:** כל ציטוט-נכנס מסווג ל**טיפול** (followed/explained = חיובי-נייטרלי;
|
||||||
|
distinguished/criticized/questioned/overruled = שלילי) לפני שהוא נספר. **טיפול שלילי לעולם אינו
|
||||||
|
תורם ל-corroboration ואינו מאשר אוטומטית**; overruled → הדחת ההלכה לבדיקת-יו"ר.
|
||||||
|
**מקורות (פתוחים):** Demir & Canbaz, *Validate Your Authority: Benchmarking LLMs on Multi-Label
|
||||||
|
Precedent Treatment Classification* (NLLP Workshop @ ACL, 2025) — LLM מסווג טיפול-תקדים
|
||||||
|
(Gemini 2.5 79.1% / GPT-5-mini 67.7%) · Galgani & Hoffmann, *LEXA* — knowledge bases for automatic
|
||||||
|
legal citation classification · *Towards Automatically Classifying Case Law Citation Treatment
|
||||||
|
Using Neural Networks* · UNC Law, *Describing Negative Legal Precedent in Citators* | סטטוס: verified
|
||||||
|
**אכיפה:** שלב 2+5 ב-§4; סכֵמת-טיפול ב-`precedent_internal_citations` (שדה חדש) +
|
||||||
|
`case_law_citations.citation_type` (לא להישען על ברירת-המחדל `'support'`).
|
||||||
|
**הפרה ידועה:** סיווג-טיפול לא קיים בפועל (§2) — רכיב לבנייה.
|
||||||
|
|
||||||
|
### INV-COR3: התאמה להלכה הספציפית — לא לפסק כולו
|
||||||
|
**כלל:** ציטוט נספר ל-corroboration של הלכה h **רק אם ההקשר המצטט נוגע לאותה הלכה** (דמיון
|
||||||
|
סמנטי מעל רף). פסק מצוטט לעניין A אינו מתקף הלכה B שחולצה מאותו פסק.
|
||||||
|
**מקורות (פתוחים):** Hellyer (2018, open-access) — *"a 'followed' tag might refer to a different
|
||||||
|
legal point than the one you care about"* · Zheng, Guha, Anderson, Henderson & Ho, *CaseHOLD*
|
||||||
|
(arXiv 2104.08671, 2021) — סיווג-טיפול ברמת ה-holding הבודד, לא הפסק כולו · UChicago Library /
|
||||||
|
Northwestern Pritzker — מדריכי-מחקר (treatment ≠ point-specific) | סטטוס: verified
|
||||||
|
**אכיפה:** שלב 3 ב-§4 — רף-דמיון סמנטי בין ההקשר ל-rule_statement; Opus 4.8 כשופט-התאמה.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-COR4: סף ≥N ציטוטים בלתי-תלויים — ציטוט יחיד אינו מספיק
|
||||||
|
**כלל:** אישור-אוטומטי דורש **≥N ציטוטים חיוביים בלתי-תלויים** — כלומר מ-**מקורות-מצטטים
|
||||||
|
מובחנים** (החלטות/פסקים שונים; שני אזכורים באותה החלטה = ציטוט אחד). ברירת-מחדל N=2. מקור יחיד
|
||||||
|
אינו ראיה מספקת; citators עצמם מפספסים 23–25% מהטיפול — לכן נדרשת חזרתיות חוצת-מקורות.
|
||||||
|
**מקורות (פתוחים):** Demir & Canbaz (NLLP/ACL 2025) — דיוק סיווג-טיפול 67.7–79.1% בלבד, לכן
|
||||||
|
סיווג בודד אינו ראיה מספקת ונדרשת חזרתיות · Fowler et al. (Political Analysis 2007) — סמכות =
|
||||||
|
*צבירת* ציטוטים, לא ציטוט יחיד · Hellyer (2018) — citator coverage gaps (פספוס 23–25% מהטיפול)
|
||||||
|
· Manning, Raghavan & Schütze, *Introduction to Information Retrieval* (CUP 2008) — aggregation of
|
||||||
|
weak signals | סטטוס: verified
|
||||||
|
**אכיפה:** שלב 4-5 ב-§4; `HALACHA_CORROBORATION_MIN_CITES` (env-tunable, ברירת-מחדל 2).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-COR5: השער האנושי נשמר לזנב הלא-מצוטט ולשלילי
|
||||||
|
**כלל:** corroboration **מצמצם** את היקף האישור-הידני; הוא **אינו מבטל** את שער-היו"ר. הלכות
|
||||||
|
לא-מצוטטות, וכל הלכה עם טיפול שלילי, **נשארות בשער-היו"ר**. גם ה-citators המקצועיים קובעים
|
||||||
|
ש"human review remains essential".
|
||||||
|
**מקורות (פתוחים):** Demir & Canbaz (NLLP/ACL 2025) — *"misclassification carries significant
|
||||||
|
risk"*, ה-citators האוטומטיים *not infallible* → עיון-אנוש נחוץ · Hellyer (2018) — *"There's no
|
||||||
|
substitute for reading the actual citing case"* · NCSC/JTC, *Principles & Practices for AI Use in
|
||||||
|
Courts* (human-in-the-loop) · CEPEJ (2018, user-control) | סטטוס: verified
|
||||||
|
**אכיפה:** שלב 5 ב-§4; שער-היו"ר הקיים ([05-qa-review.md](05-qa-review.md)) נשאר על הזנב.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-COR6: עקיבוּת — כל אישור-אוטומטי שומר את ראיית-הציטוט
|
||||||
|
**כלל:** הלכה שאושרה ב-corroboration **שומרת את הציטוטים המתקפים** (מזהי-המקור + ההקשר +
|
||||||
|
הטיפול) כ-provenance הניתן לביקורת — מי אישר, על סמך אילו פסקים, ובאיזה טיפול.
|
||||||
|
**מקורות:** [INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) · ISO 15489-1:2016
|
||||||
|
(records authenticity) · CEPEJ (2018, transparency) | סטטוס: verified (נגזר מ-G9)
|
||||||
|
**אכיפה:** `halachot.reviewer` = `corroborated (≥N judicial citations)` + טבלת-קישור
|
||||||
|
הלכה↔ציטוטים-מתקפים; מוצג ביו"ר-UI.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. תיקון INV-G10 (מבוקר)
|
||||||
|
|
||||||
|
INV-G10 קובע ששער אישור-ההלכה הוא invariant אנושי-חובה. **התיקון** (החלטת-יו"ר 2026-05-31)
|
||||||
|
אינו מבטל את השער אלא **מרחיב את מקור-הסמכות האנושית שלו**: השער מסופק ע"י **טיפול שיפוטי
|
||||||
|
מצטבר** (ערכאות/ועדות מצטטות) עבור תת-הקבוצה ה-corroborated החיובית, בעוד **שער-היו"ר נשאר חובה**
|
||||||
|
לזנב הלא-מצוטט ולכל טיפול-שלילי. הנוסח המתוקן + המקורות נכתבים ב-
|
||||||
|
[00-constitution.md INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
|
||||||
|
עיקרון-העל (INV-COR1) שומר על רוח G10: זהו שיפוט אנושי (של המצטטים), לא שיפוט-AI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. מצב קיים מול יעד — audit-findings
|
||||||
|
|
||||||
|
- **קישור הלכה↔ציטוט לא קיים.** אין טבלה/שאילתה שמצרפת ציטוט-נכנס להלכה ספציפית — רכיב-ליבה
|
||||||
|
לבנייה (§4 שלב 3).
|
||||||
|
- **סיווג-טיפול חסר.** `precedent_internal_citations` ללא שדה-טיפול; `case_law_citations.citation_type`
|
||||||
|
על ברירת-מחדל `'support'` (`db.py:387`) — לא מסווג בפועל (§2, INV-COR2).
|
||||||
|
- **אישור-אוטומטי כיום מבוסס-confidence בלבד.** `db.store_halachot` מאשר ב-`confidence ≥
|
||||||
|
HALACHA_AUTO_APPROVE_THRESHOLD` (`db.py:3221`, ברירת-מחדל 0.80) — לא מבוסס-ציטוט. X11 מוסיף
|
||||||
|
מסלול-אישור שני (corroboration) לצד/מעל סף-ה-confidence.
|
||||||
|
- **גרף-זהות.** תוקן לשפר + dedup content-affecting (§3); המשך ניקוי ב-#70.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md) — INV-G9 (provenance), INV-G10 (שער אנושי, מתוקן §6),
|
||||||
|
פרוטוקול ≥3-מקורות.
|
||||||
|
- [02-data-model.md](02-data-model.md) — טבלות הציטוטים (`case_law_citations`,
|
||||||
|
`precedent_internal_citations`) + ישות `halachot`.
|
||||||
|
- [05-qa-review.md](05-qa-review.md) — שער אישור-ההלכה הקיים (נשאר על הזנב, INV-COR5).
|
||||||
|
- [07-learning.md](07-learning.md) — צמיחת-קורפוס + לולאת-הלכות.
|
||||||
|
- [X1-identifiers.md](X1-identifiers.md) — תנאי-הקדם: זהות קנונית (INV-ID1/ID2).
|
||||||
|
- [#70 / FU-2c-b](../audit-report.md) — dedup של `cited_only` (תנאי-קדם, §3).
|
||||||
157
docs/spec/X2-multi-company.md
Normal file
157
docs/spec/X2-multi-company.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# X2 — מודל רב-החברתי וכללי ה-Sync (Multi-Company & Sync)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **המבנה הרב-חברתי**
|
||||||
|
של עוזר משפטי — שתי החברות (CMP/CMPA), 14 הסוכנים, ואיך שינוי-הגדרות מפושט מ-Master ל-Mirror.
|
||||||
|
הוא אוכף את [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (מקור-אמת
|
||||||
|
יחיד — אין מסלולים מקבילים מתפצלים) בהקשר של תצורת-סוכנים: שתי החברות הן שתי העתקות של אותה
|
||||||
|
מערכת, ואסור להן להתפצל (drift).
|
||||||
|
|
||||||
|
> **invariant פרויקטלי-תפעולי.** ה-invariants כאן הם **עובדות על איך המערכת *הזו* מנוהלת**
|
||||||
|
> רב-חברתית — לא תאוריה הנדסית כללית ולא תוכן משפטי. אין סמכות חיצונית ל"איך מסנכרנים
|
||||||
|
> CMP↔CMPA"; לכן הם נושאים שדה `מקור-סמכות` = הראנבוקים והקוד של הפרויקט עצמו ([CLAUDE.md](../../CLAUDE.md),
|
||||||
|
> [HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md), [scripts/sync_agents_across_companies.py](../../scripts/sync_agents_across_companies.py))
|
||||||
|
> — **לא** ≥3 מקורות חיצוניים ו**ללא** סטטוס verified/UNVERIFIED. אבל כל invariant **נקשר
|
||||||
|
> לעיקרון הגלובלי שהוא משרת**: כלל אי-ה-drift הוא מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. שתי החברות: Master מול Mirror
|
||||||
|
|
||||||
|
Paperclip מחייב `agents.company_id NOT NULL` — אין סוכנים משותפים. כדי לשרת את שני סוגי
|
||||||
|
העררים, המערכת מורצת כ**שתי חברות** נפרדות, כל אחת עם מערך-סוכנים מלא משלה:
|
||||||
|
|
||||||
|
| ממד | CMP — **Master** | CMPA — **Mirror** |
|
||||||
|
|------|------------------|-------------------|
|
||||||
|
| תפקיד | מקור-האמת לתצורת-סוכנים | העתקה מסונכרנת מ-Master |
|
||||||
|
| COMPANY_ID | `42a7acd0-30c5-4cbd-ac97-7424f65df294` | `8639e837-4c9d-47fa-a76b-95788d651896` |
|
||||||
|
| סוגי תיקים | רישוי ובנייה | היטל השבחה + פיצויים ס'197 |
|
||||||
|
| טווח-מספרים | **1xxx** | **8xxx, 9xxx** |
|
||||||
|
| CEO Agent ID | `752cebdd-6748-4a04-aacd-c7ab0294ef33` | `cdbfa8bc-3d61-41a4-a2e7-677ec7d34562` |
|
||||||
|
|
||||||
|
(המקור: [HEARTBEAT.md §1](../../.claude/agents/HEARTBEAT.md), שורות 38–44; מזהי-החברות מקודדים גם
|
||||||
|
ב-[sync_agents_across_companies.py:62-63](../../scripts/sync_agents_across_companies.py).)
|
||||||
|
|
||||||
|
**14 סוכנים = 7 × 2.** כל חברה מחזיקה את אותם 7 תפקידי-סוכן (CEO, writer, analyst, researcher,
|
||||||
|
qa, proofreader, exporter — ראה [X4-agents.md](X4-agents.md)). מאחר ש-`company_id` הוא `NOT NULL`,
|
||||||
|
כל תפקיד מיוצג בשתי **רשומות-סוכן נפרדות** — אחת ל-CMP, אחת ל-CMPA. אין רשומה משותפת.
|
||||||
|
|
||||||
|
**Master = CMP, Mirror = CMPA.** התצורה נכתבת ומתוחזקת בחברת ה-Master (CMP, 1xxx), והסנכרון
|
||||||
|
הוא **חד-כיווני** CMP → CMPA ([sync...py:1-7,361-362](../../scripts/sync_agents_across_companies.py)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ניתוב לפי חברה — סינון ב-`company_id`
|
||||||
|
|
||||||
|
הזרימה התפעולית נאכפת לפי `$PAPERCLIP_COMPANY_ID` של הסוכן הפועל ([HEARTBEAT.md §1](../../.claude/agents/HEARTBEAT.md)):
|
||||||
|
|
||||||
|
- `42a7acd0…` → הסוכן מטפל **רק** בתיקי 1xxx; `8639e837…` → **רק** בתיקי 8xxx/9xxx (שורות 43–44).
|
||||||
|
- **אסור** ליצור פרויקט/issue/תוכן לתיק מחוץ לטווח-החברה (שורה 45); issue שמכוון לתיק מחוץ
|
||||||
|
לטווח → סירוב מנומס ב-comment + העֵרת ה-CEO של החברה הנכונה (שורה 46).
|
||||||
|
- **CEO שונה לכל חברה** — בחירת ה-CEO ל-wakeup נגזרת מ-`$PAPERCLIP_COMPANY_ID`, **לעולם לא**
|
||||||
|
UUID hardcoded ([HEARTBEAT.md §4ג](../../.claude/agents/HEARTBEAT.md), שורות 143–150).
|
||||||
|
- **גבול-חברה נאכף בצד-Paperclip:** wakeup לחברה אחרת נדחה — `Agent key cannot access another
|
||||||
|
company` ([HEARTBEAT.md §4ג](../../.claude/agents/HEARTBEAT.md), שורה 157).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. כלל ה-Sync — אחרי כל שינוי-הגדרות ב-Master
|
||||||
|
|
||||||
|
> **טריגר:** כל שינוי ב-`adapter_config`, `runtime_config`, `budget_monthly_cents`, או skills
|
||||||
|
> של סוכן ב-Master (UI / SQL / API). מקור: סעיף "Cross-company agent sync" ב-[legal-ai/CLAUDE.md](../../CLAUDE.md)
|
||||||
|
> וב-[root CLAUDE.md](../../../CLAUDE.md).
|
||||||
|
|
||||||
|
הפעולה החובה — קודם בדיקה, אז החלה:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PAPERCLIP_BOARD_API_KEY=$(…infisical…) \
|
||||||
|
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify # drift report
|
||||||
|
PAPERCLIP_BOARD_API_KEY=$(…) \
|
||||||
|
python ~/legal-ai/scripts/sync_agents_across_companies.py --apply # backup + apply
|
||||||
|
```
|
||||||
|
|
||||||
|
**מה הסקריפט עושה** (מאומת מול הקוד):
|
||||||
|
|
||||||
|
- **חד-כיווני CMP → CMPA**, סינכרון של שדות-תצורה מוגדרים: top-level (`budget_monthly_cents`,
|
||||||
|
`metadata`, `icon`, `title`, `role`), מפתחות `adapter_config` נבחרים (`model`, `effort`,
|
||||||
|
`timeoutSec`, `maxTurnsPerRun`, נתיבי-instructions, `cwd`…), ו-`runtime_config` כ-full-replace
|
||||||
|
([sync...py:66-75,124-160](../../scripts/sync_agents_across_companies.py)). שדות פר-חברה
|
||||||
|
(`id`, `company_id`, `adapter_type`, `agent_api_keys`, `status`, `spent_monthly_cents`,
|
||||||
|
`permissions`) **אינם** מסונכרנים ([sync...py:24-29](../../scripts/sync_agents_across_companies.py)).
|
||||||
|
- **מבוסס-API, לא DB ישיר.** ה-PATCH דרך `PATCH /api/agents/{id}` וה-skills דרך
|
||||||
|
`POST /api/agents/{id}/skills/sync` עם `Authorization: Bearer` ([sync...py:204-237](../../scripts/sync_agents_across_companies.py)).
|
||||||
|
- **מסנן skills מקומיים שלא קיימים ב-Mirror.** `desiredSkills` מושוות כ-subset; skills מקומיים
|
||||||
|
של CMP (למשל `local/eba6210d5a/legal-decision`) שלא קיימים ב-CMPA נשמטים עם אזהרה
|
||||||
|
([sync...py:138-154,194-195](../../scripts/sync_agents_across_companies.py)).
|
||||||
|
- **יוצר revisions.** סנכרון skills עובר דרך endpoint ייעודי שמייצר `skill-sync` revision
|
||||||
|
([sync...py:277-284](../../scripts/sync_agents_across_companies.py)).
|
||||||
|
- **idempotent + אל-כשל.** `--verify`/`--dry-run` כברירת-מחדל, גיבוי `pg_dump` לפני `--apply`,
|
||||||
|
pre-flight על קבצי-instructions, ו-re-verify אוטומטי אחרי ההחלה ([sync...py:9,163-173,408-465](../../scripts/sync_agents_across_companies.py)).
|
||||||
|
- **מדלג על סוכן עם `adapter_type` שונה בין החברות.** אם ל-Master ול-Mirror `adapter_type`
|
||||||
|
שונה → `SKIPPING`, ללא סנכרון ([sync...py:387-389](../../scripts/sync_agents_across_companies.py)).
|
||||||
|
זו המלכודת ב-INV-MC1 (להלן).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Invariants של התחום (פרויקטלי-תפעולי)
|
||||||
|
|
||||||
|
### INV-MC1: תצורת-סוכן ב-Master מפושטת ל-Mirror — אין drift בין החברות
|
||||||
|
**כלל:** כל שינוי ב-`adapter_config` / `runtime_config` / `budget_monthly_cents` / skills של
|
||||||
|
סוכן בחברת ה-Master (CMP) **חייב** להיות מפושט ל-Mirror (CMPA) דרך סקריפט ה-Sync המבוסס-API
|
||||||
|
(`--verify` ואז `--apply`). שתי החברות **לא מתפצלות** — הן שתי העתקות מסונכרנות של אותה תצורה
|
||||||
|
(מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) — מקור-אמת
|
||||||
|
יחיד, אין מסלולים מקבילים מתפצלים; וכלל-ההנדסה "סימטריה", [חוקה §6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
|
||||||
|
**מקור-סמכות:** סעיף "Cross-company agent sync" ב-[legal-ai/CLAUDE.md](../../CLAUDE.md) +
|
||||||
|
ב-[root CLAUDE.md](../../../CLAUDE.md) +
|
||||||
|
[scripts/sync_agents_across_companies.py](../../scripts/sync_agents_across_companies.py) +
|
||||||
|
[HEARTBEAT.md §1, §4ג](../../.claude/agents/HEARTBEAT.md). (invariant פרויקטלי-תפעולי — ללא
|
||||||
|
פרוטוקול ≥3-המקורות; משרת את העיקרון הגלובלי G2.)
|
||||||
|
**אכיפה:** סקריפט ה-Sync (idempotent, מבוסס-API, גיבוי+re-verify) — מורץ **ידנית** אחרי כל
|
||||||
|
שינוי-תצורה ב-Master. **אין אכיפה אוטומטית** (ראה §5).
|
||||||
|
**הפרה ידועה:** הסקריפט **מדלג** על סוכן ש-`adapter_type` שונה בין CMP ל-CMPA
|
||||||
|
([sync...py:387-389](../../scripts/sync_agents_across_companies.py)). כשמעבירים סוכן ל-`deepseek_local`
|
||||||
|
ב-Master, ה-Mirror נשאר על ה-adapter הישן והסנכרון מדלג עליו — **חובה להחיל את שינוי ה-`adapter_type`
|
||||||
|
ידנית בשתי החברות לפני הרצת ה-Sync** ([CLAUDE.md "External adapters — deepseek_local"](../../CLAUDE.md)),
|
||||||
|
אחרת נוצר drift שקט באותו סוכן.
|
||||||
|
|
||||||
|
### INV-MC2: אין סוכן משותף — רשומה נפרדת לכל חברה
|
||||||
|
**כלל:** סוכן **לעולם אינו רשומה משותפת** בין החברות. כל אחד מ-7 התפקידים מיוצג בשתי
|
||||||
|
רשומות-סוכן נפרדות (CMP + CMPA), שכן Paperclip מחייב `agents.company_id NOT NULL`. הסנכרון
|
||||||
|
מעתיק *ערכי-תצורה* בין שתי רשומות — לא ממזג אותן לרשומה אחת (תואם [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים):
|
||||||
|
מקור-אמת יחיד לתצורה, גם כשהיא משוכפלת על פני רשומות).
|
||||||
|
**מקור-סמכות:** סעיף "Cross-company agent sync" ב-[legal-ai/CLAUDE.md](../../CLAUDE.md) (14 agents = 7 × 2;
|
||||||
|
`agents.company_id NOT NULL`) + [sync...py:4-7,83-103](../../scripts/sync_agents_across_companies.py)
|
||||||
|
(שולף מערכי-סוכן נפרדים לכל `company_id`) + [HEARTBEAT.md §1](../../.claude/agents/HEARTBEAT.md).
|
||||||
|
(invariant פרויקטלי-תפעולי.)
|
||||||
|
**אכיפה:** אילוץ `company_id NOT NULL` בצד-Paperclip; הסקריפט מתאים סוכנים בין החברות לפי
|
||||||
|
`name` ולעולם לא יוצר רשומה משותפת ([sync...py:372,383-385](../../scripts/sync_agents_across_companies.py)
|
||||||
|
— "we never auto-create").
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. מצב קיים מול יעד — פער אכיפה
|
||||||
|
|
||||||
|
ה-Sync הוא **ידני ולא-נאכף**. הסקריפט עצמו בנוי "אל-כשל" (dry-run כברירת-מחדל, גיבוי,
|
||||||
|
re-verify), אך **שום מנגנון לא מכריח** הרצה אחרי שינוי-תצורה ב-Master:
|
||||||
|
|
||||||
|
- **drift אם שוכחים.** שינוי `adapter_config`/`runtime_config`/budget/skills ב-CMP בלי הרצת
|
||||||
|
`--apply` משאיר את CMPA מאחור — שתי החברות מתפצלות בשקט, בניגוד ל-INV-MC1. **יעד:** טריגר/
|
||||||
|
בדיקת-בריאות תקופתית שמריצה `--verify` ומדווחת drift (היום ההרצה תלויה בזיכרון המפעיל).
|
||||||
|
- **מלכודת `adapter_type`-skip.** סוכן עם `adapter_type` שונה בין החברות נשמט מהסנכרון
|
||||||
|
([sync...py:387-389](../../scripts/sync_agents_across_companies.py)) — ה-`--verify` ידווח
|
||||||
|
`SKIPPING`, אך אם המפעיל לא יחיל את שינוי ה-adapter ידנית בשתי החברות, הסוכן יישאר drifted.
|
||||||
|
**יעד:** אזהרת-SKIPPING שמתבלטת ב-report + צ'קליסט-ידני (כבר מתועד ב-[CLAUDE.md](../../CLAUDE.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
(מקור-אמת יחיד, אין מסלולים מקבילים מתפצלים) + כלל-ההנדסה "סימטריה" ([§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
|
||||||
|
- [X4-agents.md](X4-agents.md) — מפת 7 תפקידי-הסוכן שמשוכפלים על פני שתי החברות.
|
||||||
|
- [X3-integration-deploy.md](X3-integration-deploy.md) — Paperclip (wakeup, ניתוב comments) ו-deploy;
|
||||||
|
ה-wakeup-per-company משלים את הניתוב כאן.
|
||||||
|
- [scripts/sync_agents_across_companies.py](../../scripts/sync_agents_across_companies.py) — מימוש ה-Sync.
|
||||||
|
- [legal-ai/CLAUDE.md](../../CLAUDE.md) + [root CLAUDE.md](../../../CLAUDE.md) — סעיף
|
||||||
|
"Cross-company agent sync" + "External adapters — deepseek_local" (מלכודת ה-adapter_type).
|
||||||
|
- [.claude/agents/HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) — §1 (סינון-חברה) + §4ג (wake CEO לפי חברה).
|
||||||
220
docs/spec/X3-integration-deploy.md
Normal file
220
docs/spec/X3-integration-deploy.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# X3 — אינטגרציה ו-Deploy (Integration & Deploy)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **שני ממדי-התפעול**
|
||||||
|
של עוזר משפטי: (א) **האינטגרציה עם Paperclip** — איך המערכת מעירה סוכנים, איך תגובות-משתמש
|
||||||
|
מנותבות, ואיך שינוי-סטטוס תיק מתפרסם חזרה; (ב) **מודל ה-Deploy** — שני מודלי-הרצה הדו-קיימים
|
||||||
|
על שרת Nautilus (Coolify-Docker מול pm2-מקומי) ומחזור-השינוי של legal-ai.
|
||||||
|
|
||||||
|
> **invariant פרויקטלי-תפעולי.** ה-invariants כאן הם **עובדות על איך המערכת *הזו* משתלבת
|
||||||
|
> ונפרסת** — לא תאוריה הנדסית כללית ולא תוכן משפטי. אין סמכות חיצונית ל"איך מעירים סוכן
|
||||||
|
> Paperclip" או "איך פורסים את legal-ai"; לכן הם נושאים שדה `מקור-סמכות` = הראנבוקים והקוד
|
||||||
|
> של הפרויקט עצמו ([root CLAUDE.md](../../../CLAUDE.md), [legal-ai/CLAUDE.md](../../CLAUDE.md),
|
||||||
|
> [HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md), זיכרון `reference_paperclip_wakeup`,
|
||||||
|
> ו-[web/paperclip_api.py](../../web/paperclip_api.py)) — **לא** ≥3 מקורות חיצוניים ו**ללא**
|
||||||
|
> סטטוס verified/UNVERIFIED. אבל כל invariant **נקשר לעיקרון הגלובלי שהוא משרת**: כלל
|
||||||
|
> ה-wakeup-דרך-API-בלבד הוא מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
> (מסלול קנוני יחיד; ה-DB-insert המקביל אסור כי הוא מתפצל מהמסלול שיוצר `heartbeat_run`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. אינטגרציית Paperclip
|
||||||
|
|
||||||
|
עוזר משפטי משתלב עם Paperclip בשלושה כיוונים: **wakeup** (legal-ai/אוטומציה → סוכן),
|
||||||
|
**ניתוב comments** (משתמש → CEO → סוכן), ו-**webhook יוצא** (legal-ai → פלאגין).
|
||||||
|
|
||||||
|
### 1א. Wakeup — תמיד דרך API, לעולם לא דרך DB
|
||||||
|
|
||||||
|
הנתיב הקנוני היחיד להערת סוכן הוא `POST /api/agents/{agent-id}/wakeup` עם `payload` המכיל
|
||||||
|
`issueId` ([root CLAUDE.md](../../../CLAUDE.md) "Wakeup API"; [legal-ai/CLAUDE.md](../../CLAUDE.md)
|
||||||
|
"Wakeup API"; [HEARTBEAT.md §4ד, שורות 152–158](../../.claude/agents/HEARTBEAT.md)):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
|
||||||
|
'{"source":"automation","triggerDetail":"system","reason":"...",
|
||||||
|
"payload":{"issueId":"...","mutation":"comment","commentId":"..."}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`POST .../wakeup`, לא `/wake`** — שם-הנתיב מדויק ([legal-ai/CLAUDE.md](../../CLAUDE.md)).
|
||||||
|
- **חובה `payload.issueId`** — בלעדיו הסוכן מתעורר בלי הקשר (בלי תיק, בלי issue, בלי `cwd`
|
||||||
|
נכון) ([HEARTBEAT.md שורה 156](../../.claude/agents/HEARTBEAT.md)).
|
||||||
|
- **אסור `INSERT INTO agent_wakeup_requests` ישיר** — insert ל-DB יוצר רשומת-בקשה בלבד **בלי
|
||||||
|
`heartbeat_run`**, והסוכן **לא יתעורר לעולם** ([HEARTBEAT.md שורה 158](../../.claude/agents/HEARTBEAT.md);
|
||||||
|
זיכרון `reference_paperclip_wakeup`).
|
||||||
|
זהו בדיוק "מסלול מקביל מתפצל" שאסור לפי [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
|
||||||
|
- **CEO לכל חברה** — מזהה-ה-CEO ל-wakeup נגזר מ-`$PAPERCLIP_COMPANY_ID`, לעולם לא UUID
|
||||||
|
hardcoded; wakeup לחברה אחרת נדחה (`Agent key cannot access another company`)
|
||||||
|
([HEARTBEAT.md §4ג](../../.claude/agents/HEARTBEAT.md); ראה [X2-multi-company.md §2](X2-multi-company.md)).
|
||||||
|
|
||||||
|
### 1ב. ניתוב comments — דרך ה-CEO
|
||||||
|
|
||||||
|
תגובת-משתמש על issue ב-Paperclip **אינה** מנותבת ישירות לסוכן-המטרה. הזרימה
|
||||||
|
([root CLAUDE.md](../../../CLAUDE.md) "Comment routing"; [legal-ai/CLAUDE.md](../../CLAUDE.md)):
|
||||||
|
|
||||||
|
```
|
||||||
|
user comment → plugin-legal-ai → ctx.agents.invoke() מעיר CEO
|
||||||
|
→ CEO קורא comment, מחליט ניתוב, יוצר issue לסוכן המתאים
|
||||||
|
```
|
||||||
|
|
||||||
|
- ה-CEO הוא נקודת-הניתוב היחידה — סוכן-משנה לא מקבל עבודה ישירות מ-comment.
|
||||||
|
- כל סוכן **חייב** לקרוא comments אחרונים לפני שהוא מתחיל עבודה ([HEARTBEAT שלבים 2b–2c](../../.claude/agents/HEARTBEAT.md)).
|
||||||
|
|
||||||
|
### 1ג. Webhook יוצא — עדכון סטטוס תיק לפלאגין
|
||||||
|
|
||||||
|
כשסטטוס תיק משתנה דרך `PUT /api/cases/{case_number}`, הבקאנד שולח webhook אסינכרוני
|
||||||
|
לפלאגין כ-BackgroundTask, fire-and-forget:
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/cases/{n} → [BackgroundTask] emit_case_status_webhook()
|
||||||
|
→ POST /api/plugins/marcusgroup.legal-ai/webhooks/case-status
|
||||||
|
→ plugin-legal-ai/onWebhook() → comment בעברית + CEO wakeup (כש-qa_failed)
|
||||||
|
```
|
||||||
|
|
||||||
|
מאומת מול הקוד:
|
||||||
|
|
||||||
|
- ה-call-site: [web/app.py:2045-2061](../../web/app.py) — ה-webhook מתוזמן רק כש-`old_status
|
||||||
|
!= new_status`, ו-`company_id` נגזר מ-prefix מספר-התיק (`1`→licensing, `8/9`→betterment).
|
||||||
|
- המימוש: [web/paperclip_api.py:87-117](../../web/paperclip_api.py) — `emit_case_status_webhook`
|
||||||
|
קורא ל-`pc_request("POST", "/api/plugins/.../webhooks/case-status", ...)` עם `timeout=5.0`,
|
||||||
|
בלוק `try/except` שמתעד `logger.warning` ולעולם לא raise (לא חוסם את הקורא).
|
||||||
|
- אותו דפוס משרת אירועים נוספים: `emit_missing_precedent_webhook`
|
||||||
|
([paperclip_api.py:120-165](../../web/paperclip_api.py)) ו-`emit_export_complete_webhook`
|
||||||
|
([paperclip_api.py:168+](../../web/paperclip_api.py)).
|
||||||
|
|
||||||
|
> **חוזה ה-webhook (idempotency / at-least-once / אירוע מגורס)** מפורט ב-[X7 INV-INT7/INT8](X7-paperclip-client-params.md):
|
||||||
|
> ה-emitter הנוכחי fire-and-forget בולע שגיאות וללא event-id/dedup — יעד FU-9.
|
||||||
|
|
||||||
|
### 1ד. כל קריאת-API דרך helper — לא curl/httpx ישיר
|
||||||
|
|
||||||
|
קריאות ל-Paperclip עוברות תמיד דרך helper, לא דרך לקוח גולמי:
|
||||||
|
|
||||||
|
- **bash (סוכנים):** `~/legal-ai/scripts/pc.sh <METHOD> <PATH> [BODY]` — מוסיף אוטומטית
|
||||||
|
`Authorization: Bearer`, `X-Paperclip-Run-Id`, `Content-Type`, ו-base URL
|
||||||
|
([HEARTBEAT.md §0, שורות 15–32](../../.claude/agents/HEARTBEAT.md); [scripts/pc.sh:8-9,39-40](../../scripts/pc.sh)).
|
||||||
|
- **Python (FastAPI):** `from web.paperclip_api import pc_request` — בונה headers דרך
|
||||||
|
`_build_headers` ([paperclip_api.py:47-84](../../web/paperclip_api.py)), משתמש ב-board API key.
|
||||||
|
- **למה:** ה-skill הרשמי דורש `X-Paperclip-Run-Id` בכל קריאה משנה issue (audit trail);
|
||||||
|
ה-helper מבטיח עקביות + תאימות ל-board API keys long-lived שלא נושאות JWT claims
|
||||||
|
([legal-ai/CLAUDE.md](../../CLAUDE.md) "קריאות API — תמיד דרך helper").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. מודל ה-Deploy — שני מודלים דו-קיימים
|
||||||
|
|
||||||
|
> **קונפיגורציה, env וסודות** — ה-deep-dive המלא (catalog ה-env, מקור-config, secrets, hardcode,
|
||||||
|
> drift) ב-[X10-deploy-env-secrets.md](X10-deploy-env-secrets.md). כאן נשאר רק מודל-ההרצה.
|
||||||
|
|
||||||
|
על שרת Nautilus דרים **שני מודלי-הרצה**. ערבוב ביניהם הוא הטעות הנפוצה ביותר
|
||||||
|
([root CLAUDE.md](../../../CLAUDE.md) "Deploy architecture"; [legal-ai/CLAUDE.md](../../CLAUDE.md)
|
||||||
|
"ארכיטקטורת Deploy").
|
||||||
|
|
||||||
|
| ממד | legal-ai (web + web-ui) | Paperclip + legal-chat-service |
|
||||||
|
|------|--------------------------|--------------------------------|
|
||||||
|
| מודל | **Coolify-managed (Docker)** | **PM2-managed (Node/Python מקומי)** |
|
||||||
|
| מחזור-שינוי | commit → push → Gitea Actions build → Coolify redeploy (~2–4 דק') | עריכה → `pm2 restart` |
|
||||||
|
| Coolify UUID | `gyjo0mtw2c42ej3xxvbz8zio` | — |
|
||||||
|
| build_pack | **`dockerimage`** (לא `dockerfile`) | — |
|
||||||
|
| פורטים | Next.js `:3000` (חשוף) + FastAPI `:8000` (פנימי) | Paperclip `localhost:3100`; legal-chat-service `127.0.0.1:8770` (loopback) |
|
||||||
|
| הרצה מקומית | **אין** — אין venv של Python על ה-host; אסור `uvicorn`/`next dev` לפרוד | יש; מתחזק דרך pm2 |
|
||||||
|
|
||||||
|
### 2א. מחזור-השינוי של legal-ai (Coolify dockerimage)
|
||||||
|
|
||||||
|
שינוי קוד ב-`web/` או `web-ui/` **לא נכנס לתוקף** עד שמריצים את כל הצעדים, בסדר:
|
||||||
|
|
||||||
|
1. `git commit` + `git push origin main` ל-Gitea.
|
||||||
|
2. Gitea Actions בונה image ודוחף ל-registry (`gitea.nautilus.marcusgroup.org/...`).
|
||||||
|
3. ה-workflow מפעיל Coolify redeploy דרך API (UUID `gyjo0mtw2c42ej3xxvbz8zio`).
|
||||||
|
4. ~2–4 דקות end-to-end. בדיקה: `curl -s https://legal-ai.nautilus.marcusgroup.org/api/health`.
|
||||||
|
|
||||||
|
- **אסור** לנסות `uvicorn`/`next dev` לפרוד — הקונטיינר מספק את שני התהליכים; אין סביבת
|
||||||
|
Python על ה-host ([root CLAUDE.md](../../../CLAUDE.md); [legal-ai/CLAUDE.md](../../CLAUDE.md)).
|
||||||
|
- **endpoint חדש ≠ זמין ל-UI.** הוספת endpoint ב-`web/app.py` היא תנאי הכרחי אך לא מספיק
|
||||||
|
לצריכה מה-frontend — חובה `npm run api:types` בתוך `web-ui/` כדי לחדש את ה-OpenAPI types
|
||||||
|
([root CLAUDE.md](../../../CLAUDE.md), שורה 89; [legal-ai/CLAUDE.md](../../CLAUDE.md)).
|
||||||
|
|
||||||
|
### 2ב. legal-chat-service ו-host.docker.internal
|
||||||
|
|
||||||
|
legal-chat-service (`127.0.0.1:8770`, pm2) הוא גשר host-side שעוטף את `claude` CLI ב-streaming
|
||||||
|
לטאב הצ'אט ב-`/training`. הקונטיינר מגיע אליו דרך `host.docker.internal:8770` — ולכן ה-Service
|
||||||
|
Definition של legal-ai ב-Coolify **חייב** לכלול `extra_hosts: host.docker.internal:host-gateway`,
|
||||||
|
אחרת ה-proxy יקבל `ConnectError` ([root CLAUDE.md](../../../CLAUDE.md); [legal-ai/CLAUDE.md](../../CLAUDE.md)
|
||||||
|
"legal-chat-service"). הנחת-היסוד של "קריאות LLM רק ממקומי" נשמרת — ראה
|
||||||
|
זיכרון `feedback_claude_session_local_only`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Invariants של התחום (פרויקטלי-תפעולי)
|
||||||
|
|
||||||
|
### INV-INT1: wakeup דרך API בלבד — DB-insert אסור
|
||||||
|
**כלל:** הערת סוכן Paperclip **חייבת** לעבור דרך `POST /api/agents/{agent-id}/wakeup` עם
|
||||||
|
`payload.issueId`. **אסור** `INSERT INTO agent_wakeup_requests` ישיר — insert ל-DB אינו יוצר
|
||||||
|
`heartbeat_run`, ולכן הסוכן **לא יתעורר לעולם**. זהו המסלול הקנוני היחיד; ה-DB-insert הוא
|
||||||
|
מסלול-מקביל-מתפצל אסור (מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
— מקור-אמת/מסלול קנוני יחיד; וכלל-ההנדסה "סימטריה", [חוקה §6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
|
||||||
|
**מקור-סמכות:** "Wakeup API" ב-[root CLAUDE.md](../../../CLAUDE.md) + ב-[legal-ai/CLAUDE.md](../../CLAUDE.md) +
|
||||||
|
זיכרון `reference_paperclip_wakeup` +
|
||||||
|
[HEARTBEAT.md §4ד, שורות 152–158](../../.claude/agents/HEARTBEAT.md). (invariant פרויקטלי-תפעולי —
|
||||||
|
ללא פרוטוקול ≥3-המקורות; משרת את העיקרון הגלובלי G2.)
|
||||||
|
**אכיפה:** קריאות-wakeup דרך `pc.sh`/`pc_request` בלבד; `payload.issueId` חובה; בדיקה
|
||||||
|
ש-`heartbeat_run` נוצר. **אין אכיפה סכמתית** שתחסום insert ישיר ל-`agent_wakeup_requests` —
|
||||||
|
המניעה היא נוהל (ראה §4).
|
||||||
|
**הפרה ידועה:** insert ישיר ל-`agent_wakeup_requests` (fallback ישן) → רשומה בלי `heartbeat_run`,
|
||||||
|
הסוכן נשאר רדום (זיכרון `reference_paperclip_wakeup`).
|
||||||
|
|
||||||
|
### INV-INT2: שינוי-קוד legal-ai נכנס לתוקף רק דרך commit→push→Coolify deploy
|
||||||
|
**כלל:** שינוי קוד ב-`web/` או `web-ui/` **לא נכנס לתוקף** עד `git commit` + `git push origin main`
|
||||||
|
+ build ב-Gitea Actions + Coolify redeploy (build_pack `dockerimage`, UUID `gyjo0mtw2c42ej3xxvbz8zio`).
|
||||||
|
**אין** הרצת `uvicorn`/`next dev` מקומית לפרוד. endpoint חדש ב-`web/app.py` דורש גם
|
||||||
|
`npm run api:types` ב-`web-ui/` כדי להיחשף ל-UI.
|
||||||
|
**מקור-סמכות:** "Deploy architecture" ב-[root CLAUDE.md](../../../CLAUDE.md) (UUID, dockerimage,
|
||||||
|
no local uvicorn, api:types) + "ארכיטקטורת Deploy" ב-[legal-ai/CLAUDE.md](../../CLAUDE.md) +
|
||||||
|
זיכרון `reference_deployment`.
|
||||||
|
(invariant פרויקטלי-תפעולי — ללא פרוטוקול ≥3-המקורות.)
|
||||||
|
**אכיפה:** pipeline Gitea Actions → Coolify (אוטומטי בדחיפה ל-main); בדיקה ידנית
|
||||||
|
`curl .../api/health` אחרי deploy. **אין** מסלול-פריסה חלופי.
|
||||||
|
**הפרה ידועה:** בדיקת שינוי מול הרצה מקומית שלא קיימת — הקוד בפרוד נשאר ישן עד deploy; וכן
|
||||||
|
drift אפשרי Infisical↔Coolify env (env לא מתעדכן אוטומטית מ-Infisical, ראה
|
||||||
|
זיכרון `feedback_infisical_coolify_drift`).
|
||||||
|
|
||||||
|
### INV-INT3: כל קריאת-Paperclip דרך helper — לא curl/httpx ישיר
|
||||||
|
**כלל:** קריאות ל-Paperclip API עוברות **תמיד** דרך helper — `pc.sh` (bash/סוכנים) או
|
||||||
|
`pc_request` (Python/FastAPI) — ולעולם לא `curl`/`httpx` גולמי. ה-helper מזריק `Authorization`,
|
||||||
|
`X-Paperclip-Run-Id` (audit), ו-`Content-Type` באופן עקבי, ותומך ב-board API keys long-lived
|
||||||
|
(מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) — מסלול-גישה
|
||||||
|
קנוני יחיד ל-Paperclip; ושל [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) —
|
||||||
|
audit-trail עקבי).
|
||||||
|
**מקור-סמכות:** "קריאות API — תמיד דרך helper" ב-[legal-ai/CLAUDE.md](../../CLAUDE.md) +
|
||||||
|
[HEARTBEAT.md §0, שורות 15–32](../../.claude/agents/HEARTBEAT.md) +
|
||||||
|
[scripts/pc.sh:8-9,39-40](../../scripts/pc.sh) + [web/paperclip_api.py:47-84](../../web/paperclip_api.py).
|
||||||
|
(invariant פרויקטלי-תפעולי — ללא פרוטוקול ≥3-המקורות.)
|
||||||
|
**אכיפה:** נוהל + code-review; `pc.sh` ו-`pc_request` הם נקודות-הכניסה היחידות. **אין אכיפה
|
||||||
|
אוטומטית** שתחסום `httpx.AsyncClient` ישיר ל-Paperclip בקוד חדש.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. מצב קיים מול יעד — פער אכיפה
|
||||||
|
|
||||||
|
האינטגרציה נשענת על **נוהל**, לא על מחסום-קוד:
|
||||||
|
|
||||||
|
- **wakeup (INV-INT1):** אין constraint סכמתי שחוסם insert ישיר ל-`agent_wakeup_requests`;
|
||||||
|
המניעה היא ידע-נוהל ([HEARTBEAT](../../.claude/agents/HEARTBEAT.md)). **יעד:** wrapper/בדיקת-בריאות
|
||||||
|
שמסמן בקשות-wakeup ללא `heartbeat_run` תואם.
|
||||||
|
- **helper (INV-INT3):** אין linter/בדיקה שתתפוס `httpx`/`curl` ישיר ל-Paperclip בקוד חדש.
|
||||||
|
**יעד:** כלל-lint שמכריח שימוש ב-`pc_request`/`pc.sh`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
(מסלול קנוני יחיד) + [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) (audit-trail) +
|
||||||
|
כלל-ההנדסה "סימטריה" ([§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
|
||||||
|
- [X2-multi-company.md](X2-multi-company.md) — wakeup-per-company + ניתוב לפי `company_id` משלים את §1 כאן.
|
||||||
|
- [X4-agents.md](X4-agents.md) — מפת הסוכנים שה-CEO מנתב אליהם comments.
|
||||||
|
- [root CLAUDE.md](../../../CLAUDE.md) + [legal-ai/CLAUDE.md](../../CLAUDE.md) — "Wakeup API",
|
||||||
|
"Comment routing", "Deploy architecture", "קריאות API — תמיד דרך helper".
|
||||||
|
- [.claude/agents/HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) — §0 (pc.sh), §4ג–§4ד (wake CEO + payload).
|
||||||
|
- [web/paperclip_api.py](../../web/paperclip_api.py) — `pc_request`, `emit_case_status_webhook`.
|
||||||
|
- [scripts/pc.sh](../../scripts/pc.sh) — helper ה-bash.
|
||||||
|
- [X7-paperclip-client-params.md](X7-paperclip-client-params.md) — שכבת-הלקוח + פרמטרי-החיבור (INV-INT4–INT8).
|
||||||
|
- [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) — env/secrets/deploy deep-dive (INV-ENV1–ENV5).
|
||||||
174
docs/spec/X4-agents.md
Normal file
174
docs/spec/X4-agents.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# X4 — מפת הסוכנים (Agents Map)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **מי הם הסוכנים**
|
||||||
|
של עוזר משפטי, **מה תפקיד כל אחד**, ו**אילו קבצי-ספ כל סוכן חייב לקרוא לפני שהוא פועל**. הוא
|
||||||
|
מסייע לסוכן לדעת באיזה ספ לקרוא — ומעגן את [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||||
|
(המערכת מסייעת; השערים האנושיים הם invariant): כל סוכן קורא את החוקה תחילה ופועל בתחום-אחריותו,
|
||||||
|
לא מחליף את שיקול-הדעת האנושי.
|
||||||
|
|
||||||
|
> **invariant פרויקטלי-תפעולי.** ה-invariants כאן הם **עובדות על איך הסוכנים של המערכת *הזו*
|
||||||
|
> מאורגנים ומופעלים** — לא תאוריה הנדסית כללית ולא תוכן משפטי. אין סמכות חיצונית ל"מי קורא מה
|
||||||
|
> לפני שהוא פועל"; לכן הם נושאים שדה `מקור-סמכות` = הראנבוקים וקבצי-הסוכן של הפרויקט עצמו
|
||||||
|
> ([HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md), קבצי הסוכן תחת [.claude/agents/](../../.claude/agents/),
|
||||||
|
> ו-[החוקה](00-constitution.md)) — **לא** ≥3 מקורות חיצוניים ו**ללא** סטטוס verified/UNVERIFIED.
|
||||||
|
> אבל כל invariant **נקשר לעיקרון הגלובלי שהוא משרת**: כלל "קרא-לפני-שתפעל" + תחום-אחריות הם
|
||||||
|
> מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) (סיוע תחת
|
||||||
|
> שערים אנושיים) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ההפעלה המשותפת — HEARTBEAT.md
|
||||||
|
|
||||||
|
לפני כל עבודה, **כל** סוכן Paperclip עובר את ה-checklist המשותף ב-[HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md):
|
||||||
|
זיהוי וסינון-חברה (§1), קריאת comments אחרונים (§1.5, 2b–2c), קריאת `heartbeat-context` עם
|
||||||
|
attachments (§1.5ב), וקריאות-API דרך `pc.sh` בלבד (§0). HEARTBEAT גובר על ה-skill הרשמי של
|
||||||
|
Paperclip בקונפליקט (project-specific מנצח default), אך אינו מחליף את החוקה — הוא מצטרף אליה:
|
||||||
|
קודם החוקה (00) + ספ-התחום, אז ה-HEARTBEAT התפעולי.
|
||||||
|
|
||||||
|
**הקשר רב-חברתי.** ל-Paperclip אילוץ `agents.company_id NOT NULL` — אין סוכן משותף. לכן כל אחד
|
||||||
|
מ-7 תפקידי הסוכן-הדומייני מיוצג בשתי רשומות (CMP / CMPA), וסוכן מטפל **רק** בתיקי-החברה שלו לפי
|
||||||
|
`$PAPERCLIP_COMPANY_ID` (1xxx ל-CMP; 8xxx/9xxx ל-CMPA). ראה [X2-multi-company.md](X2-multi-company.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. מפת הסוכנים הדומייניים (7 תפקידים × 2 חברות)
|
||||||
|
|
||||||
|
הסט המדויק (`ls .claude/agents/`): `HEARTBEAT.md`, `hermes-curator.md`, `legal-analyst.md`,
|
||||||
|
`legal-ceo.md`, `legal-exporter.md`, `legal-proofreader.md`, `legal-qa.md`, `legal-researcher.md`,
|
||||||
|
`legal-writer.md`. התפקיד נלקח מה-frontmatter של כל קובץ; עמודת "ספ לקרוא" מקשרת תפקיד לקבצי-הספ
|
||||||
|
שהוא אוכף/צורך.
|
||||||
|
|
||||||
|
| סוכן (קובץ) | תפקיד (מה-frontmatter) | ספ-תחום לקרוא לפני פעולה |
|
||||||
|
|-------------|------------------------|---------------------------|
|
||||||
|
| [legal-ceo.md](../../.claude/agents/legal-ceo.md) | מנהל תהליך כתיבת החלטות, מתזמר סוכנים, מפקח על התקדמות | **00 + כל הספ** (מתזמר → צריך תמונה מלאה); ניתוב comments → [X3 §1ב](X3-integration-deploy.md) |
|
||||||
|
| [legal-proofreader.md](../../.claude/agents/legal-proofreader.md) | מגיה — תיקון שגיאות OCR בטקסט עברי לפני ניתוח | [01-ingest.md](01-ingest.md) (קליטה/טקסט-מחולץ) |
|
||||||
|
| [legal-researcher.md](../../.claude/agents/legal-researcher.md) | חוקר תקדימים — פסיקה, מיפוי תכניות, סיכום פרוטוקולים | [03-retrieval.md](03-retrieval.md) (3 קורפוסים, hybrid/RRF, attribution); קליטת-פסיקה → [01-ingest.md](01-ingest.md) |
|
||||||
|
| [legal-analyst.md](../../.claude/agents/legal-analyst.md) | מנתח משפטי — חילוץ טענות, ניתוח אסטרטגי, שאלות מחקר | [02-data-model.md](02-data-model.md) + [03-retrieval.md](03-retrieval.md) + [04-analysis-writing.md](04-analysis-writing.md) |
|
||||||
|
| [legal-writer.md](../../.claude/agents/legal-writer.md) | כותב — כתיבת בלוקי ההחלטה בסגנון דפנה תמיר | [04-analysis-writing.md](04-analysis-writing.md) + [05-qa-review.md](05-qa-review.md) (כותב מול שערי-QA) |
|
||||||
|
| [legal-qa.md](../../.claude/agents/legal-qa.md) | בודק איכות — שלמות, ניטרליות, כיסוי טענות, משקלות לפני ייצוא | [05-qa-review.md](05-qa-review.md) (שערי QA + שערים אנושיים) |
|
||||||
|
| [legal-exporter.md](../../.claude/agents/legal-exporter.md) | מייצא — בדיקה סופית, ייצוא DOCX, שמירה מגורסת | [06-export.md](06-export.md) (ייצוא DOCX לפי תבנית דפנה) |
|
||||||
|
| [hermes-curator.md](../../.claude/agents/hermes-curator.md) | Knowledge Curator (Hermes) — מנתח החלטות סופיות post-export, מציע עדכוני skills/lessons; read-only על תוכן, write רק על comments | [07-learning.md](07-learning.md) (Hermes · לקחים · לולאת פידבק) |
|
||||||
|
|
||||||
|
**הערות על הסט:**
|
||||||
|
|
||||||
|
- **CEO = נקודת-הניתוב היחידה.** תגובת-משתמש על issue מעירה את ה-CEO; הוא מחליט ניתוב ויוצר
|
||||||
|
issue לסוכן-המשנה — סוכן-משנה לא מקבל עבודה ישירות מ-comment ([X3 §1ב](X3-integration-deploy.md)).
|
||||||
|
- **Hermes — חיבור ישיר, לא דרך CEO.** מופעל מ"סמן כסופי" ב-UI (`mark-final` → `pc_wake_curator_for_final()`),
|
||||||
|
לא מ-CEO; ופועל על מודל `deepseek_local` (לא Claude Code) — ראה [X2 INV-MC1](X2-multi-company.md#inv-mc1-תצורת-סוכן-ב-master-מפושטת-ל-mirror--אין-drift-בין-החברות)
|
||||||
|
למלכודת ה-`adapter_type`-skip בסנכרון. הצעות ה-curator עוברות **אישור-יו"ר ידני** לפני commit
|
||||||
|
ל-`SKILL.md`/`lessons.md` — מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
|
||||||
|
- **company_id פר-סוכן.** כל שורה בטבלה מיוצגת פעמיים (CMP + CMPA); ה-CEO לכל חברה שונה
|
||||||
|
([X2 §1](X2-multi-company.md)). הסוכן פועל רק בטווח-החברה שלו ([X2 §2](X2-multi-company.md)).
|
||||||
|
|
||||||
|
### 2א. מפת-הרשאות (tool grants) — frontmatter מול הוראות
|
||||||
|
|
||||||
|
כל קובץ-סוכן מצהיר ב-frontmatter `tools:` (כולם: `Read/Bash/Grep/Glob` + תת-קבוצת `mcp__legal-ai__*`).
|
||||||
|
מפת-ההרשאות חייבת **לתאום** את מה שהוראות-הסוכן מצריכות ([X9 INV-TOOL6](X9-mcp-tool-contract.md), INV-AG3 להלן).
|
||||||
|
|
||||||
|
**סטטוס FU-13 — נסגר (2026-06-06):** GAP-46 טופל בהכרעת-יו"ר "היבריד". התברר שהפער שמופה ב-31.5
|
||||||
|
היה רחב מדי — הכלים יוחסו לפי *תיאור-התפקיד*, לא לפי ההוראות בפועל. ההכרעה:
|
||||||
|
|
||||||
|
| סוכן | מצב בפועל | פעולה ב-FU-13 |
|
||||||
|
|------|-----------|----------------|
|
||||||
|
| legal-researcher | כבר מעניק `extract_references` + `precedent_extract_halachot`/`precedent_extract_metadata`/`precedent_process_pending` (frontmatter) | ✅ אין פער — היה מיושן |
|
||||||
|
| legal-analyst | חסר `aggregate_claims_to_arguments`; הוראותיו לא השתמשו בו | ✅ נוסף ל-frontmatter + שלב 7 ב-"שלב 1" (קיבוץ טענות→טיעונים) |
|
||||||
|
|
||||||
|
`extract_references` / `extract_internal_citations` הם **מטלת-מחקר** (חילוץ ציטוטים/רפרנסים) ושייכים
|
||||||
|
ל-`legal-researcher` (שמחזיק אותם) — **לא** ל-`legal-analyst`, שמאמת פסיקה דרך *חיפוש* (§8א בקובץ-הסוכן),
|
||||||
|
לא חילוץ. לכן הוסרו מרשימת "החסרים" של ה-analyst (INV-AG3 "לא עודף").
|
||||||
|
|
||||||
|
→ [gap-audit GAP-46](gap-audit.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. סוכני-התהליך (תת-פרויקט 5) — סעיף שמור (RESERVED)
|
||||||
|
|
||||||
|
> **סטטוס: מתוכנן, טרם נבנה.** הסעיף הזה הוא **מקום שמור מכוון** עבור סוכני-התהליך שיוגדרו
|
||||||
|
> ב**תת-פרויקט 5** — הם **אינם קיימים כיום** ואין לטעות בהם כמופעלים. הם מתועדים כאן כדי
|
||||||
|
> שהמפה תהיה שלמה ושכיוון-העבודה יהיה ברור, לא כ-TODO פתוח.
|
||||||
|
|
||||||
|
בניגוד לסוכנים הדומייניים (סעיף 2) שמטפלים בתיקי-עררים, **סוכני-התהליך** הם סוכנים שיקראו את
|
||||||
|
ספ-המערכת (קבצי 00–07, X1–X5) ו"יעשו את שיעורי-הבית" — יפעלו על *המערכת עצמה*, לא על תיק. שלושה
|
||||||
|
תפקידים מתוכננים:
|
||||||
|
|
||||||
|
| סוכן-תהליך (מתוכנן) | תפקיד מיועד |
|
||||||
|
|----------------------|-------------|
|
||||||
|
| **add-feature** | הוספת יכולת חדשה — קורא את הספ הרלוונטי, מאתר את ה-invariants שחלים, ומיישם בלי לשבור G1–G11 |
|
||||||
|
| **fix-feature** | תיקון תקלה — מאתר את ה-invariant שהופֵר (מול [audit-report.md](../audit-report.md)) ומתקן במקור, לא בתסמין |
|
||||||
|
| **spec-guardian** | שמירת עקביות הספ — מאתר drift בין הקוד לספ ובין קבצי-הספ עצמם; סתירה = ממצא ל-audit |
|
||||||
|
|
||||||
|
ההגדרה המלאה (frontmatter, tools, instructions, מיפוי תפקיד→ספ, ושערי-האישור) **תיכתב בתת-פרויקט 5**.
|
||||||
|
עד אז — אין רשומות-סוכן, אין wakeup, ואין הסתמכות עליהם בזרימה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Invariants של התחום (פרויקטלי-תפעולי)
|
||||||
|
|
||||||
|
### INV-AG1: כל סוכן קורא את החוקה תחילה, אז את ספ-התחום הרלוונטי — לפני פעולה
|
||||||
|
**כלל:** כל סוכן (דומייני או תהליך) **חייב** לקרוא את [00-constitution.md](00-constitution.md)
|
||||||
|
תחילה, ואז את ספ-התחום הרלוונטי לתפקידו (לפי הטבלה בסעיף 2), **לפני** שהוא פועל. ה-checklist
|
||||||
|
המשותף ב-HEARTBEAT מתבצע בכל ריצה; קריאת-הספ קודמת לעבודה המהותית. סוכן אינו פועל "מהזיכרון" —
|
||||||
|
המקור הקנוני להתנהגות הוא החוקה + ספ-התחום (מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||||
|
— המערכת מסייעת תחת שערים אנושיים, והסוכן פועל בגבולות שהחוקה מגדירה).
|
||||||
|
**מקור-סמכות:** [HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) (checklist הפעלה משותף) +
|
||||||
|
קבצי-הסוכן תחת [.claude/agents/](../../.claude/agents/) (frontmatter + instructions) +
|
||||||
|
[00-constitution.md §7](00-constitution.md#7-אינדקס-הספ) (אינדקס הספ — איזה קובץ אוכף איזה invariant).
|
||||||
|
(invariant פרויקטלי-תפעולי — ללא פרוטוקול ≥3-המקורות; משרת את העיקרון הגלובלי G10.)
|
||||||
|
**אכיפה:** נוהל — **מחוּוט** (FU-8b, 2026-05-31): סעיף "קריאת-ספ — קודם החוקה (00), אז ספ-התחום"
|
||||||
|
ב-[HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) (כולל טבלת תפקיד→ספ) + סעיף "קרא לפני פעולה (INV-AG1)"
|
||||||
|
בכל אחד מ-8 קבצי-הסוכן. אכיפה **פרוצדורלית** (נוהל לפני עבודה), לא אוטומטית: אין שער-קוד שמכריח
|
||||||
|
את הקריאה — זה גלום בטבע ה-invariant (פרויקטלי-תפעולי, מבוצע ע"י הסוכן). ראה §5.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-AG2: סוכן דומייני פועל רק בתחום-החברה שלו
|
||||||
|
**כלל:** סוכן דומייני מטפל **רק** בתיקי-החברה שלו לפי `$PAPERCLIP_COMPANY_ID` (CMP→1xxx;
|
||||||
|
CMPA→8xxx/9xxx). אסור ליצור פרויקט/issue/תוכן לתיק מחוץ לטווח; issue מחוץ-לטווח → סירוב מנומס
|
||||||
|
ב-comment + העֵרת ה-CEO של החברה הנכונה (מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
— הפרדה נאכפת לפי `company_id`, אין מסלולים חוצי-חברה מתפצלים; ראה [X2 §2](X2-multi-company.md)).
|
||||||
|
**מקור-סמכות:** [HEARTBEAT.md §1](../../.claude/agents/HEARTBEAT.md) (סינון-חברה — כלל-ברזל) +
|
||||||
|
קבצי-הסוכן (סעיף "סינון תיקים לפי חברה") + [X2-multi-company.md §2](X2-multi-company.md).
|
||||||
|
(invariant פרויקטלי-תפעולי — ללא פרוטוקול ≥3-המקורות; משרת את העיקרון הגלובלי G2.)
|
||||||
|
**אכיפה:** סינון-חברה ב-HEARTBEAT + גבול-חברה נאכף בצד-Paperclip (`Agent key cannot access
|
||||||
|
another company`, [X2 §2](X2-multi-company.md)).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-AG3: מפת-ההרשאות תואמת את הוראות-הסוכן — לא חסר ולא עודף
|
||||||
|
**כלל:** ה-frontmatter `tools:` של כל סוכן מעניק **בדיוק** את הכלים שהוראותיו דורשות — כל כלי שההוראות
|
||||||
|
מצריכות מוענק, וכלי שמוענק-ולא-בשימוש נבחן. מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||||
|
(שערים מוגדרים) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים); מקביל ל-[X9 INV-TOOL6](X9-mcp-tool-contract.md).
|
||||||
|
**מקור-סמכות:** frontmatter `tools:` מול ה-instructions בקבצי-[.claude/agents/](../../.claude/agents/). (פרויקטלי-תפעולי.)
|
||||||
|
**אכיפה:** בדיקת-עקביות tools↔instructions (FU-13 ✅ 2026-06-06). אכיפה אוטומטית עתידית — בתת-פרויקט 5 (spec-guardian).
|
||||||
|
**הפרה ידועה:** — (טופל ב-FU-13: legal-analyst קיבל `aggregate_claims_to_arguments`; researcher כבר היה תקין; `extract_references`/`extract_internal_citations` הם מטלת-researcher, לא analyst — ראה §2א).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. חיווט הספ לסוכנים — בוצע (FU-8b)
|
||||||
|
|
||||||
|
עד FU-8b קבצי-הסוכן וה-HEARTBEAT **לא הפנו** לספ-המערכת במפורש; הם הפנו ל-CLAUDE.md, למסמכי-`docs/`
|
||||||
|
הישנים, ול-skills. **בוצע ב-2026-05-31 (FU-8b / GAP-23):**
|
||||||
|
|
||||||
|
- **HEARTBEAT.md:** נוסף סעיף עליון "קריאת-ספ — קודם החוקה (00), אז ספ-התחום — לפני פעולה מהותית
|
||||||
|
(INV-AG1)", **לפני** §0–§8 התפעוליים, ובו טבלת תפקיד→ספ (זהה לסעיף 2 כאן). זה ממקם את קריאת-החוקה
|
||||||
|
קודם ל-checklist ההפעלה ("קודם החוקה (00) + ספ-התחום, אז ה-HEARTBEAT התפעולי").
|
||||||
|
- **8 קבצי-הסוכן:** כל אחד קיבל סעיף "קרא לפני פעולה (INV-AG1)" בראש גוף-הקובץ — קריאת
|
||||||
|
`00-constitution.md` תחילה, ואז ספ-התחום הרלוונטי לתפקידו (לפי הטבלה בסעיף 2).
|
||||||
|
- **אופי האכיפה:** פרוצדורלית (נוהל), לא שער-קוד — ראה INV-AG1 "אכיפה".
|
||||||
|
|
||||||
|
זהו תנאי-מוקדם לסוכני-התהליך (סעיף 3), שכל עבודתם היא "לקרוא את הספ ולעשות שיעורי-בית".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md) — [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||||
|
(שערים אנושיים) + [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
(מקור-אמת/הפרדה) + [§7 אינדקס הספ](00-constitution.md#7-אינדקס-הספ).
|
||||||
|
- [X2-multi-company.md](X2-multi-company.md) — 14 סוכנים = 7 × 2, `company_id` פר-סוכן, כללי sync.
|
||||||
|
- [X3-integration-deploy.md](X3-integration-deploy.md) — wakeup, ניתוב comments דרך CEO, webhooks.
|
||||||
|
- ספ-התחום שכל סוכן צורך: [01-ingest.md](01-ingest.md), [02-data-model.md](02-data-model.md),
|
||||||
|
[03-retrieval.md](03-retrieval.md), [04-analysis-writing.md](04-analysis-writing.md),
|
||||||
|
[05-qa-review.md](05-qa-review.md), [06-export.md](06-export.md), [07-learning.md](07-learning.md).
|
||||||
|
- [.claude/agents/HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) + קבצי-הסוכן תחת
|
||||||
|
[.claude/agents/](../../.claude/agents/) — frontmatter (תפקיד) + instructions (סינון-חברה, זרימה).
|
||||||
|
- [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) — חוזה-הכלים שההרשאות (INV-AG3 / §2א) מעניקות.
|
||||||
|
- [skills/](../../skills/) — 5 skills (decision, assistant, docx, dafna-decision-template, new-company-setup); עקביות-skills↔סוכן + dedup → FU-13.
|
||||||
163
docs/spec/X5-audit-provenance.md
Normal file
163
docs/spec/X5-audit-provenance.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# X5 — Audit-Trail ועקיבוּת-מקור (Provenance)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומגדיר את **חוזה העקיבוּת וה-audit-trail (TARGET)**
|
||||||
|
של עוזר משפטי: (א) כל **תוצר מסיוע-AI** (בלוק-טיוטה, תוצאת-אחזור, הצעת-curator) מתעד **מה הפיק אותו**
|
||||||
|
(מקורות/נתונים/מודל); (ב) כל **סמכות מצוטטת** בהחלטה **פתירה חזרה לקורפוס**; (ג) **שלמות-הרשומה
|
||||||
|
לאורך זמן** — החלטה/רשומה שלמה ובלתי-משתנה אלא דרך **שינויים עקיבים ומיוחסים** (היסטוריית git +
|
||||||
|
Track Changes). הקובץ אוכף את
|
||||||
|
[INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) (עקיבוּת + audit-trail) ואת
|
||||||
|
[INV-G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query) (attribution באחזור).
|
||||||
|
|
||||||
|
> **TARGET, לא תיאור-מצב.** היכן שהקוד בפועל סוטה מהיעד — מתועד כ-**audit-finding** ([§5](#5-current-vs-target--ממצאי-audit)),
|
||||||
|
> תסמין לתיקון, לא התנהגות תקינה. כל טענה על הקוד מצוטטת `file:line`.
|
||||||
|
|
||||||
|
כשל-השורש שהקובץ מייבש: **קיימים רכיבי-עקיבוּת נקודתיים** (commit git לפלטים · `model_used` לכל בלוק ·
|
||||||
|
`decision_paragraphs.citations` · גרף-ציטוטים · telemetry של חיפושים), אך **אין רשומת-provenance
|
||||||
|
מאוחדת מקצה-לקצה** שמקשרת בלוק-החלטה → קטעי-הקורפוס/הגנרציות שהפיקו אותו, ו**טבלת ה-`audit_log`
|
||||||
|
אינה מתועדת בפועל** לרוב פעולות ה-AI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. שלוש שכבות העקיבוּת (TARGET)
|
||||||
|
|
||||||
|
| שכבה | מה צריך להירשם | היכן (קיים / יעד) |
|
||||||
|
|------|-----------------|---------------------|
|
||||||
|
| **A — provenance של תוצר-AI** | לכל בלוק-טיוטה/תוצאת-אחזור/הצעת-curator: מודל, סוג-גנרציה, וקטעי-המקור (chunks/precedents) שהוזנו | קיים חלקית: `decision_blocks.model_used/generation_type/temperature` (`db.py:326-328`); **חסר** קישור בלוק→קטעי-מקור |
|
||||||
|
| **B — עקיבוּת ציטוט→קורפוס** | כל סמכות מצוטטת פתירה ל-`case_law_id`/`document_id` + locator | קיים: `decision_paragraphs.citations` JSONB `[{case_law_id,text,type}]` (`db.py:343`); גרף `precedent_internal_citations` (`db.py:937-947`) |
|
||||||
|
| **C — שלמות-רשומה לאורך זמן** | החלטה/מסמך שלם ובלתי-משתנה אלא דרך שינוי עקיב ומיוחס | קיים: commit git לכל פלט (`git_sync.commit_and_push`); Track Changes ב-revisions ([06-export §3](06-export.md#3-רישום-הגרסה--active_draft_path--git)) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. רכיבי-העקיבוּת הקיימים (מאומת `file:line`)
|
||||||
|
|
||||||
|
1. **קיבוע-פלט ב-git.** כל כתיבת-DOCX/עדכון-תיק מקובעת בהיסטוריית-git של תיקיית-התיק:
|
||||||
|
`export_docx` (`drafting.py:408`), `export_interim_draft` (`drafting.py:536`),
|
||||||
|
`apply_user_edit` (`drafting.py:582`), `revise_draft` (`drafting.py:695`), עדכון-תיק
|
||||||
|
(`cases.py:387`), הוספת-מסמך (`documents.py:86`) — כולם `git_sync.commit_and_push(...)`
|
||||||
|
(`git_sync.py:75`). זו שכבת ה-audit-trail של **שלמות-הפלט** (שכבה C).
|
||||||
|
2. **provenance של מודל לכל בלוק.** `decision_blocks` נושא `model_used` / `generation_type` /
|
||||||
|
`temperature` (`db.py:326-328`), הנכתבים ב-upsert של ה-block-writer
|
||||||
|
(`block_writer.py:1017-1034`, `_build_result` `:400-407`). מתעד **איזה מודל** הפיק את הבלוק
|
||||||
|
(שכבה A — חלקי).
|
||||||
|
3. **עקיבוּת ציטוט ברמת-סעיף.** `decision_paragraphs.citations` (`db.py:343`) שומר
|
||||||
|
`[{case_law_id, text, type}]` — כל ציטוט בסעיף מצביע ל-`case_law` (שכבה B). telemetry
|
||||||
|
ממנף זאת ל-"cited == relevant" (`telemetry.py:18-23`).
|
||||||
|
4. **גרף-ציטוטים פנימי.** `precedent_internal_citations` (`db.py:937-947`) רושם קשת
|
||||||
|
החלטה→החלטה מצוטטת (resolved ל-`case_law` או stub); נחשף דרך `extract_internal_citations` /
|
||||||
|
`list_internal_citations` / `list_incoming_citations` (`citations.py:40,81,112`).
|
||||||
|
ON CONFLICT DO NOTHING → idempotent (`citations.py:54`).
|
||||||
|
5. **locator פתיר בכל תוצאת-אחזור.** כל span מוחזר נושא מזהה-מקור + locator
|
||||||
|
([03-retrieval INV-RET5](03-retrieval.md#inv-ret5-כל-span-מוחזר-עקיב-למקורו), `search.py:77-86,322-343`);
|
||||||
|
הלכות נושאות `supporting_quote` (`db.py:652`) + `page_number` (`db.py:631,711,729`).
|
||||||
|
6. **telemetry של חיפושים.** `telemetry.log_search_bg` (ב-search.py) → מפעיל את `log_search` האסינכרוני → `search_logs`
|
||||||
|
(`telemetry.py:105,161`, `search.py:62,118,190,271`) רושם query/practice_area/top_case_law_ids —
|
||||||
|
תצפית על מה נשלף, fire-and-forget (`telemetry.py:8-12,100-101`).
|
||||||
|
7. **לקחים ופידבק מיוחסים.** `decision_lessons.source` (`db.py:208`: manual/curator/chair/
|
||||||
|
style_analyzer) ו-`chair_feedback.lesson_extracted`/`applied_to` (`db.py:458-459`) מתעדים את
|
||||||
|
**מקור** הלקח ([07-learning.md](07-learning.md)).
|
||||||
|
8. **טבלת `audit_log` (פעולה כללית).** `log_action(action, case_id, document_id, details, user)` (עמודת-DB: `actor`)
|
||||||
|
(`audit.py:18-44`) → `audit_log` (`db.py:159-167`, אינדקסים `:168-170`). קיימת, אך נכתבת
|
||||||
|
כיום כמעט-ורק ב-`case_subtype_override` (`cases.py:203`) — ראה [§5](#5-current-vs-target--ממצאי-audit).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-AUD1: כל תוצר מסיוע-AI מתעד את ה-provenance שלו (→G9)
|
||||||
|
**כלל:** כל תוצר שנוצר בסיוע-AI — בלוק-טיוטה, תוצאת-אחזור, הצעת-curator — **רושם את מקורו**:
|
||||||
|
**איזה מודל** הפיק אותו, **באיזה סוג-גנרציה**, ו**אילו קטעי-מקור** (chunks/precedents/מסמכי-תיק)
|
||||||
|
הוזנו אליו. הרשומה ניתנת-לביקורת בדיעבד (מי/מתי/ממה).
|
||||||
|
**מקורות:** Council of Europe / CEPEJ — *European Ethical Charter on AI in judicial systems*
|
||||||
|
(2018, transparency/traceability + user-control) · NCSC/JTC — *Principles & Practices for AI Use
|
||||||
|
in Courts* (auditable AI output) · ISO 15489-1:2016 (records authenticity — metadata about
|
||||||
|
creation) | סטטוס: verified
|
||||||
|
**אכיפה:** `decision_blocks.model_used/generation_type/temperature` בכל upsert של בלוק
|
||||||
|
(`block_writer.py:1017-1034`); telemetry על כל חיפוש (`telemetry.py:105`); **יעד נוסף:** קישור
|
||||||
|
מפורש בלוק→קטעי-מקור (provenance edges) + כתיבת `audit_log.log_action` לכל גנרציה. אוכף את
|
||||||
|
[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
|
||||||
|
**הפרה ידועה (GAP):** ה-provenance קיים **חלקית** — `model_used` נרשם לכל בלוק, וה-commit ב-git
|
||||||
|
מקבע פלטים, אך **אין רשומה מאוחדת** שמקשרת בלוק-החלטה לקטעי-הקורפוס/הגנרציות שהזינו אותו, וטבלת
|
||||||
|
`audit_log` כמעט-ולא נכתבת לפעולות-AI (רק `case_subtype_override`, `cases.py:203`) → יעד
|
||||||
|
([§5](#5-current-vs-target--ממצאי-audit)).
|
||||||
|
|
||||||
|
### INV-AUD2: רשומה שמורה שלמה ובלתי-משתנה אלא דרך שינוי עקיב ומיוחס (→G9, שלמות-רשומה)
|
||||||
|
**כלל:** החלטה/רשומה שמורה היא **שלמה ובלתי-משתנה** — כל שינוי בה נעשה רק דרך **מנגנון עקיב
|
||||||
|
ומיוחס** (commit git עם הודעה + actor, או Track Changes מיוחסות), ולא דרך דריסה שקטה. ניתן
|
||||||
|
לשחזר את מצב-הרשומה בכל נקודת-זמן ולזהות מי שינה מה ומתי.
|
||||||
|
**מקורות:** ISO 15489-1:2016 (§5.2.2 — integrity: records protected against unauthorized
|
||||||
|
alteration; אמינות/שלמות-רשומה) · Council of Europe / CEPEJ (2018, traceability) · DAMA-UK —
|
||||||
|
*Six Primary Dimensions for Data Quality* (2013, consistency/integrity over time) | סטטוס: verified
|
||||||
|
**אכיפה:** קיבוע git לכל פלט (`git_sync.commit_and_push` — `drafting.py:408,536,582,695`;
|
||||||
|
`cases.py:387`; `documents.py:86`) עם הודעה תיאורית; Track Changes ב-revisions עוקבות
|
||||||
|
([06-export §3](06-export.md#3-רישום-הגרסה--active_draft_path--git)); `decision_blocks` עם מפתח
|
||||||
|
קנוני `UNIQUE(decision_id, block_id)` (`db.py:333`) ו-`updated_at`. אוכף את
|
||||||
|
[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
|
||||||
|
**הפרה ידועה:** עריכת-DOCX (`revise_draft`/`apply_user_edit`) הופכת את `active_draft_path` למקור-
|
||||||
|
בפועל **בלי לעדכן את בלוקי-ה-DB חזרה** — הנתון-הנגזר זוחל למקור-אמת ושלמות ה-DB מול המסמך-החי
|
||||||
|
נחלשת ([06-export INV-EX1](06-export.md#inv-ex1-ייצוא-דטרמיניסטי-ומשוחזר-מהבלוקים--docx-הוא-נתון-נגזר-g2)) → ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-AUD3: כל סמכות מצוטטת פתירה חזרה לקורפוס (→G5)
|
||||||
|
**כלל:** כל סמכות-משפטית המצוטטת בהחלטה (פסק-דין, הלכה, מסמך-תיק) **פתירה לרשומת-מקור בקורפוס**
|
||||||
|
דרך locator יציב — `case_law_id`/`document_id` + מזהה-עמוד/chunk/quote. ציטוט שאינו פתיר אינו
|
||||||
|
תקין; הוא נחסם או מסומן לאימות-יו"ר. זהו צד-ה-attribution של [INV-RET5](03-retrieval.md#inv-ret5-כל-span-מוחזר-עקיב-למקורו).
|
||||||
|
**מקורות:** Pinecone — *Implement multitenancy* (metadata-locator לכל פריט מואנדקס) · RAG
|
||||||
|
attribution (Lewis et al., 2020, NeurIPS — pinned/non-leaking provenance) · ISO 8000 (Data
|
||||||
|
quality — completeness/identifiability) | סטטוס: verified
|
||||||
|
**אכיפה:** `decision_paragraphs.citations` `[{case_law_id,text,type}]` (`db.py:343`); גרף
|
||||||
|
`precedent_internal_citations` (`db.py:937-947`) פותר ציטוט ל-`case_law` קיים או שומר stub;
|
||||||
|
פורמטרי-האחזור מצרפים מזהה+locator (`search.py:77-86,322-343`). אוכף את
|
||||||
|
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
|
||||||
|
**הפרה ידועה (GAP):** הקישור קיים ברמת-הסעיף (`decision_paragraphs.citations`), אך **אין אכיפה**
|
||||||
|
שכל ציטוט בטקסט-הבלוק אכן מקושר לרשומת-קורפוס; ציטוט שהמודל ייצר בלי locator יכול לעבור בלי
|
||||||
|
חסימה אוטומטית — אימות נשען על שער-היו"ר ([05-qa-review](05-qa-review.md)) → יעד.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. רשומת-ה-provenance המאוחדת (TARGET)
|
||||||
|
|
||||||
|
היעד שמאחד את שלוש השכבות: לכל **בלוק-החלטה** נשמר, מעבר ל-`model_used` הקיים, **קישור לקטעי-
|
||||||
|
המקור** שהוזנו לגנרציה (chunk-ids/`case_law_id`s שהוחזרו מהאחזור והוצגו ל-writer) — כך שניתן לענות
|
||||||
|
"מאיזו פסיקה/מסמך נולד המשפט הזה?". המנגנון הקנוני המוצע: כתיבת `audit_log.log_action`
|
||||||
|
(`audit.py:18`) בכל גנרציה (`action="write_block"`, `details={model, generation_type, source_chunk_ids,
|
||||||
|
retrieved_case_law_ids}`) — הטבלה כבר תומכת ב-`details JSONB` + `actor` + `case_id`/`document_id`
|
||||||
|
(`db.py:159-167`). זה ממיר את ה-audit_log מ"כמעט-ריק" ל-audit-trail מקצה-לקצה, בלי טבלה חדשה
|
||||||
|
(תואם כלל-ההנדסה "סימטריה" — הרחבת מסלול קיים, [חוקה §6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Current vs Target — ממצאי-audit
|
||||||
|
|
||||||
|
ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול הקוד.
|
||||||
|
|
||||||
|
- **`audit_log` קיימת אך כמעט-ולא נכתבת (INV-AUD1).** `log_action` (`audit.py:18-44`) ו-טבלת
|
||||||
|
`audit_log` (`db.py:159-167`) מוכנות, אך הקריאה היחידה בפועל היא `case_subtype_override`
|
||||||
|
(`cases.py:203`) — אין רישום ל-`upload`/`extract_claims`/`write_block`/`export` (למרות ש-docstring
|
||||||
|
של `log_action` מונה אותם, `audit.py:28`). **תסמין:** אין audit-trail אחיד "מי עשה מה מתי" לרוב
|
||||||
|
פעולות-ה-AI. **יעד:** קריאת `log_action` בכל פעולה משנה-מצב, כולל גנרציות.
|
||||||
|
- **אין קישור בלוק→קטעי-מקור (INV-AUD1).** `decision_blocks` מתעד `model_used`/`generation_type`
|
||||||
|
(`db.py:326-327`) אך **לא** את ה-chunks/precedents שהוזנו לגנרציה. **תסמין:** אי-אפשר לשחזר מאיזו
|
||||||
|
פסיקה/מסמך נגזר בלוק ספציפי. **יעד:** רשומת-provenance מאוחדת ([§4](#4-רשומת-ה-provenance-המאוחדת-target)).
|
||||||
|
- **ציטוט→קורפוס לא נאכף אוטומטית (INV-AUD3).** `decision_paragraphs.citations` (`db.py:343`)
|
||||||
|
תומך בקישור, אך אין בדיקה שכל ציטוט בטקסט אכן פתיר ל-`case_law`. **תסמין:** ציטוט שהמודל ייצר בלי
|
||||||
|
locator יכול לעבור. **יעד:** ולידציה שכל citation בעלת `case_law_id` פתיר, אחרת flag לאימות-יו"ר.
|
||||||
|
- **שלמות ה-DB מול ה-DOCX-החי נחלשת אחרי עריכה (INV-AUD2).** אחרי `revise_draft`/`apply_user_edit`,
|
||||||
|
`active_draft_path` הופך מקור-בפועל בלי re-sync לבלוקים (`db.py:189`;
|
||||||
|
[06-export INV-EX1](06-export.md#inv-ex1-ייצוא-דטרמיניסטי-ומשוחזר-מהבלוקים--docx-הוא-נתון-נגזר-g2)).
|
||||||
|
**יעד:** re-sync מהבלוקים או חוזה מפורש + health-check לגילוי drift.
|
||||||
|
- **telemetry בולעת שגיאות בשתיקה (תיעוד, לא הערכה).** `log_search` swallow מכוון
|
||||||
|
(`telemetry.py:100-101`) כדי שלא להפיל חיפוש — תקין כ-fire-and-forget, אך אינו audit-trail
|
||||||
|
מהימן (רשומה עלולה ללכת לאיבוד בשקט). תואם את העיקרון "אין בליעה שקטה" רק כי זו telemetry-תצפית,
|
||||||
|
לא רשומת-שלמות; ה-audit-trail המהימן הוא git ([§2.1](#2-רכיבי-העקיבוּת-הקיימים-מאומת-fileline)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md) — [INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
|
||||||
|
(עקיבוּת + audit-trail) · [INV-G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query) (attribution).
|
||||||
|
- [03-retrieval.md](03-retrieval.md#inv-ret5-כל-span-מוחזר-עקיב-למקורו) — INV-RET5 (locator פתיר בכל span — בסיס ל-INV-AUD3).
|
||||||
|
- [06-export.md](06-export.md#inv-ex2-עקיבוּת-מקור-נשמרת-בהחלטה-המיוצאת-g9) — INV-EX2 (עקיבוּת בפלט) + commit git (INV-AUD2).
|
||||||
|
- [05-qa-review.md](05-qa-review.md) — שער-היו"ר שמאמת ציטוטים (משלים את INV-AUD3).
|
||||||
|
- [02-data-model.md](02-data-model.md) — `decision_blocks`/`decision_paragraphs`/`case_law` (הישויות שעליהן נשמרת ה-provenance).
|
||||||
|
- [07-learning.md](07-learning.md) — `decision_lessons.source` + `chair_feedback` (מקור הלקחים).
|
||||||
|
- [01-ingest.md](01-ingest.md) — קליטה שמייצרת את הקטעים שאליהם פותרים ציטוטים.
|
||||||
108
docs/spec/X6-ui-api-contract.md
Normal file
108
docs/spec/X6-ui-api-contract.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# X6 — חוזה UI↔API וכללי-עיצוב הממשק (UI↔API Contract & Design Rules)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **הממשק (web-ui) וחוזה
|
||||||
|
ה-API בינו לבקאנד** — שלא היה מכוסה בספ עד כה. הוא מגדיר: (א) חוזה-הקשר פרונט↔בק (OpenAPI כ-SSoT,
|
||||||
|
מודלי-תשובה, envelope, SSE, טיפול-שגיאות); (ב) **כללי-עיצוב הממשק** — מקור-אמת יחיד ל-enums/תוויות,
|
||||||
|
helpers משותפים, וחוזה-טופס לכל סוג-מסמך. הממצאים בפועל מתועדים ב-[ui-audit.md](ui-audit.md).
|
||||||
|
|
||||||
|
> **שני סוגי invariant כאן.** UI1–UI5 הם **הנדסיים** (חוזה-API/קליינט כללי — ≥3 מקורות + סטטוס).
|
||||||
|
> UI6 (חוזה-טופס) הוא **פרויקטלי-תפעולי**, נגזר מ-[X8](X8-field-provenance.md), ומשרת
|
||||||
|
> [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ארכיטקטורה קיימת
|
||||||
|
|
||||||
|
- **web-ui** — Next.js 16 + TS + Tailwind v4 + shadcn + TanStack Query. 13 דפים (ראה [ui-audit.md](ui-audit.md)).
|
||||||
|
- **Proxy** — [next.config.ts](../../web-ui/next.config.ts): `/api/*` → `NEXT_PUBLIC_API_ORIGIN` (ברירת-מחדל `http://127.0.0.1:8000`); `/openapi.json` → schema של ה-FastAPI.
|
||||||
|
- **לקוח** — [client.ts](../../web-ui/src/lib/api/client.ts): `apiRequest<T>` + `ApiError` + `makeQueryClient`. 18 מודולי-API.
|
||||||
|
- **טיפוסים** — [types.ts](../../web-ui/src/lib/api/types.ts) (auto-gen `openapi-typescript`, 124 operations). `npm run api:types`.
|
||||||
|
- **SSE** — [sse.ts](../../web-ui/src/lib/sse.ts): `openSSE` (progress של העלאות/עיבוד).
|
||||||
|
- **בקאנד** — [web/app.py](../../web/app.py): 143 endpoints, מונוליטי, **~60% ללא Pydantic response model**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-UI1: ה-OpenAPI schema הוא ה-SSoT לחוזה — טיפוסי-לקוח נגזרים, לא ידניים-סוטים
|
||||||
|
**כלל:** חוזה ה-API מוגדר **פעם אחת** ב-OpenAPI (שמופק מהבקאנד); טיפוסי-ה-frontend **נגזרים** ממנו
|
||||||
|
(`openapi-typescript`), ואינם מתוחזקים ידנית במקביל. אין "טיפוס-מראה" מקומי שמשכפל endpoint וסוטה ממנו.
|
||||||
|
מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (מקור-אמת יחיד).
|
||||||
|
**מקורות:** OpenAPI Specification 3.1 (single contract / source of truth; JSON-Schema 2020-12)
|
||||||
|
(https://spec.openapis.org/oas/latest.html) · Pact — *consumer-driven contract testing*
|
||||||
|
(https://docs.pact.io/) · Speakeasy — *Pact vs OpenAPI* (provider-driven SSoT)
|
||||||
|
(https://www.speakeasy.com/blog/pact-vs-openapi) | סטטוס: verified
|
||||||
|
**אכיפה:** `npm run api:types` ב-CI; איסור טיפוסי-מראה ידניים. **כיום אין** — ה-frontend מתחזק טיפוסים ידניים.
|
||||||
|
**הפרה ידועה:** [cases.ts:1-9](../../web-ui/src/lib/api/cases.ts) מתעד מפורשות שה-`/api/cases` מחזיר `unknown`
|
||||||
|
ולכן מוחזק טיפוס `CaseDetail` ידני; `PracticeArea` מוגדר ב-3 מקומות עם ערכים שונים ([ui-audit.md](ui-audit.md), [gap-audit GAP-30/31](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-UI2: לכל endpoint נצרך — response model מפורש (חוזה-שלמות API)
|
||||||
|
**כלל:** כל endpoint שה-UI צורך נושא **response model מפורש** (Pydantic), כך ש-OpenAPI מפיק טיפוס אמיתי
|
||||||
|
(לא `unknown`/`object`). זהו פאֶט של [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) (שלמות-חוזה לפני צריכה).
|
||||||
|
**מקורות:** OpenAPI 3.1 (schema objects) · Zalando *RESTful API Guidelines* (explicit schemas)
|
||||||
|
(https://opensource.zalando.com/restful-api-guidelines/) · FastAPI *Response Model* docs
|
||||||
|
(https://fastapi.tiangolo.com/tutorial/response-model/) | סטטוס: verified
|
||||||
|
**אכיפה:** linter/CI שמסמן endpoint נצרך ללא response_model. **כיום אין** — ~60% מהendpoints ללא מודל.
|
||||||
|
**הפרה ידועה:** רוב ה-endpoints ב-[app.py](../../web/app.py) מחזירים dict חופשי → `unknown` ב-types.ts ([gap-audit GAP-30](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-UI3: envelope-תשובה ושגיאה עקבי על-פני ה-API
|
||||||
|
**כלל:** כל ה-endpoints חולקים **מבנה-תשובה ומבנה-שגיאה אחיד** (לא string-לפעמים-JSON-לפעמים). שגיאות
|
||||||
|
לפי תבנית סטנדרטית (Problem Details). מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
|
||||||
|
**מקורות:** RFC 9457 — *Problem Details for HTTP APIs*
|
||||||
|
(https://www.rfc-editor.org/rfc/rfc9457) · Zalando *RESTful API Guidelines* (consistent responses) ·
|
||||||
|
Microsoft *REST API Guidelines* (error structure)
|
||||||
|
(https://github.com/microsoft/api-guidelines) | סטטוס: verified
|
||||||
|
**אכיפה:** envelope משותף ב-app.py + handler-שגיאות גלובלי. **כיום אין** — מעורב string/JSON/`{error}`/`{detail}`.
|
||||||
|
**הפרה ידועה:** [search.py](../../web/app.py) מחזיר `"לא נמצאו תוצאות."` או JSON; חלק מהכלים `{error:...}`, חלק raise ([gap-audit GAP-32](gap-audit.md), [X9 INV-TOOL1](X9-mcp-tool-contract.md)).
|
||||||
|
|
||||||
|
### INV-UI4: אין בליעת-שגיאה ב-UI
|
||||||
|
**כלל:** כל מצב-שגיאה (fetch/mutation) **מוצג או מטופל מפורשות** — error boundary ו/או טיפול ב-`error`
|
||||||
|
של `useQuery`/`useMutation`. אין כשל שקט שמשאיר את המשתמש בלי משוב. תואם כלל "אין בליעה שקטה"
|
||||||
|
([חוקה §6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
|
||||||
|
**מקורות:** React docs — *Error Boundaries*
|
||||||
|
(https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) ·
|
||||||
|
TanStack Query — *Error handling* (https://tanstack.com/query/latest/docs/framework/react/guides/query-functions#handling-and-throwing-errors) ·
|
||||||
|
Nielsen Norman Group — *Error-Message Guidelines* (https://www.nngroup.com/articles/error-message-guidelines/) | סטטוס: verified
|
||||||
|
**אכיפה:** error boundary ברמת-האפליקציה + רכיב-שגיאה משותף; code-review. **כיום חלקי** — חלק מהדפים אינם
|
||||||
|
מטפלים ב-`error`; כרטיסי-שגיאה משוכפלים ולא-עקביים.
|
||||||
|
**הפרה ידועה:** [ui-audit.md](ui-audit.md) — כרטיס-שגיאה משוכפל ×3, fallback של SSE שמסתיר כישלון כ-"completed" ([gap-audit GAP-32/33](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-UI5: חוזה-SSE/progress עם terminal states מוגדרים
|
||||||
|
**כלל:** ערוץ ה-progress (SSE) נושא **terminal states מפורשים** (completed/failed/timeout). אין הנחת-השלמה
|
||||||
|
שקטה על timeout; אי-התאמות-TTL (frontend↔backend) נמנעות. נקשר ל-freshness ([G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן)).
|
||||||
|
**מקורות:** WHATWG HTML — *Server-Sent Events / EventSource* (https://html.spec.whatwg.org/multipage/server-sent-events.html) ·
|
||||||
|
MDN — *Using server-sent events* (https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) ·
|
||||||
|
TanStack Query — *Important Defaults* (staleTime/refetch) (https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults) | סטטוס: verified
|
||||||
|
**אכיפה:** סכמת-אירוע SSE עם terminal state מפורש; יישור TTL. **כיום:** fallback של 10ש' מניח completed.
|
||||||
|
**הפרה ידועה:** [documents.ts:226-232](../../web-ui/src/lib/api/documents.ts) — timeout→`{status:"completed"}`; TTL 5ש' front מול 300ש' redis ([gap-audit GAP-33](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-UI6: חוזה-טופס מוצהר לכל סוג-מסמך + שיקוף מקור-המילוי
|
||||||
|
**כלל:** לכל סוג-מסמך (מסמך-תיק / פסיקה חיצונית / החלטה פנימית) יש **חוזה-טופס מוצהר** — אילו שדות,
|
||||||
|
חובה/רשות/אוטו/pending/editable — **נגזר מ-[X8](X8-field-provenance.md)**; וה-UI **משקף את מקור-המילוי**
|
||||||
|
(מסמן מה חולץ אוטומטית/ע"י-Opus מול מה שהיו"ר הזין), כדי שהיו"ר ידע מה לאמת. מופע של
|
||||||
|
[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) (שקיפות-מקור). **invariant פרויקטלי-תפעולי.**
|
||||||
|
**מקור-סמכות:** [X8-field-provenance.md](X8-field-provenance.md) (טבלת-ה-provenance); feedback היו"ר.
|
||||||
|
**אכיפה:** רכיב-טופס נגזר-X8 + אינדיקציית "מולא-ע"י-Opus"/"ממתין"/`searchable`. **כיום אין** — שדות-Opus
|
||||||
|
מוצגים כשדות-עריכה רגילים ללא סימון.
|
||||||
|
**הפרה ידועה:** [precedents/[id]/page.tsx](../../web-ui/src/app/precedents/%5Bid%5D/page.tsx) — `summary`/`headnote`/`key_quote` ללא חיווי-מקור; אין חיווי `searchable` ([gap-audit GAP-36](gap-audit.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. כללי-עיצוב (Design Rules) — נגזרים מה-invariants
|
||||||
|
- **SSoT ל-enums/תוויות/tones:** כל enum (CaseStatus, PracticeArea, AppealSubtype, DocType, outcome) +
|
||||||
|
תוויותיו + צבעיו מוגדרים **פעם אחת** ונצרכים מיבוא — לא משוכפלים בין דפים/רכיבים (מופע UI1/G2).
|
||||||
|
- **helpers משותפים:** פירמוט-תאריך, builder ל-FormData (העלאות), רכיב-שגיאה, query-config (intervals) —
|
||||||
|
משותפים, לא מועתקים.
|
||||||
|
- **חוזי-טופס:** ראה INV-UI6 ([X8](X8-field-provenance.md)).
|
||||||
|
|
||||||
|
הממצאים הקונקרטיים (כפילויות, הגדרות-שגויות, redundancy) ב-[ui-audit.md](ui-audit.md); התיקון — **FU-10**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. הפניות-אחיות
|
||||||
|
- [ui-audit.md](ui-audit.md) — audit דף-אחר-דף (13 דפים) בתבנית-ה-gap.
|
||||||
|
- [X8-field-provenance.md](X8-field-provenance.md) — מקור-מילוי-שדות (בסיס ל-INV-UI6).
|
||||||
|
- [X7-paperclip-client-params.md](X7-paperclip-client-params.md) — חוזה-ה-API שהפלאגין צורך.
|
||||||
|
- [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) — חוזה-envelope מקביל בכלי-ה-MCP.
|
||||||
|
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים), [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai), כלל "אין בליעה שקטה" ([§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
|
||||||
|
- [web-ui/next.config.ts](../../web-ui/next.config.ts), [client.ts](../../web-ui/src/lib/api/client.ts), [types.ts](../../web-ui/src/lib/api/types.ts), [sse.ts](../../web-ui/src/lib/sse.ts).
|
||||||
155
docs/spec/X7-paperclip-client-params.md
Normal file
155
docs/spec/X7-paperclip-client-params.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# X7 — לקוח-Paperclip ופרמטרי-חיבור (Paperclip Client & Connection Parameters)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומשלים את [X3](X3-integration-deploy.md):
|
||||||
|
בעוד X3 מתאר את **זרימות**-האינטגרציה (wakeup, ניתוב comments, webhook), קובץ זה הוא ה-deep-dive
|
||||||
|
על **שכבת-הלקוח והפרמטרים** — *איך* legal-ai מדבר עם Paperclip בקוד (אילו לקוחות, אילו מסלולים),
|
||||||
|
ועל **כל הפרמטרים המחברים** (מזהי-חברה/סוכן, env, מפתחות, `plugin_state`, גזירת `company_id`).
|
||||||
|
|
||||||
|
> **invariant פרויקטלי-תפעולי.** ה-invariants כאן הם עובדות על איך *מערכת זו* בנויה — אין להן
|
||||||
|
> סמכות חיצונית; מקור-הסמכות = ה-runbooks והקוד ([root CLAUDE.md](../../../CLAUDE.md),
|
||||||
|
> [legal-ai/CLAUDE.md](../../CLAUDE.md), [web/paperclip_api.py](../../web/paperclip_api.py),
|
||||||
|
> [web/paperclip_client.py](../../web/paperclip_client.py)). כל invariant **נקשר** ל-G גלובלי שהוא משרת —
|
||||||
|
> כאן בעיקר [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (מסלול קנוני יחיד)
|
||||||
|
> ו-[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) (עקיבוּת/audit), וכלל-ההנדסה "סימטריה" ([חוקה §6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. מצב קיים — שני לקוחות מקבילים
|
||||||
|
|
||||||
|
ל-legal-ai יש **שני לקוחות Paperclip שונים** שחיים בו-זמנית, וזהו מקור-השורש לרוב הפערים כאן:
|
||||||
|
|
||||||
|
| לקוח | קובץ | אופי | מה מנהל |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| "current" (API) | [web/paperclip_api.py](../../web/paperclip_api.py) | HTTP דרך `pc_request` + board API key | webhooks יוצאים, wakeup חלקי |
|
||||||
|
| "legacy" (DB-ישיר) | [web/paperclip_client.py](../../web/paperclip_client.py) | **חיבור psql ישיר** ל-DB של Paperclip + API | projects, issues, comments, wakeup, queries |
|
||||||
|
|
||||||
|
[legal-ai/CLAUDE.md](../../CLAUDE.md) מתעד ש-`paperclip_client.py` הוא "legacy — השתמש ב-paperclip_api.py",
|
||||||
|
אך בפועל ה-legacy עדיין מבצע את **רוב העבודה הכבדה** (יצירת תיקים/issues, comments, wakeup-ים),
|
||||||
|
וחלקו דרך **`INSERT`/`SELECT` ישיר** ל-DB של Paperclip — מסלול-מקביל לעוקף את ה-API.
|
||||||
|
|
||||||
|
זוהי בדיוק התבנית ש-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) אוסר:
|
||||||
|
שני מסלולי-קוד מקבילים ליכולת אחת (גישה ל-Paperclip), שמתפצלים ועלולים לסטות.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. הפרמטרים המחברים (Connection Parameters)
|
||||||
|
|
||||||
|
### 2א. משתני-סביבה
|
||||||
|
| Var | קורא | ברירת-מחדל | סוד? |
|
||||||
|
|-----|------|-----------|------|
|
||||||
|
| `PAPERCLIP_API_URL` | [paperclip_api.py](../../web/paperclip_api.py) | `http://localhost:3100` | לא |
|
||||||
|
| `PAPERCLIP_BOARD_API_KEY` | paperclip_api.py / paperclip_client.py | `""` | **כן** (board key long-lived, לא JWT) |
|
||||||
|
| `PAPERCLIP_DB_URL` | [paperclip_client.py:21](../../web/paperclip_client.py), [app.py:3789](../../web/app.py) | `postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip` | **כן — creds בתוך ברירת-המחדל** |
|
||||||
|
| `PAPERCLIP_COMPANY_ID` | [app.py:3976](../../web/app.py) | `42a7acd0-...` (CMP, hardcoded) | לא |
|
||||||
|
| `legalApiBaseUrl` | plugin (instance config) | `http://localhost:8085` | לא |
|
||||||
|
|
||||||
|
> ראה גם [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) — חוזה-ה-env המלא וטיפול-הסודות.
|
||||||
|
|
||||||
|
### 2ב. מזהים קשיחים בקוד (hardcoded) — סתירה ל-X3
|
||||||
|
[paperclip_client.py:36-62](../../web/paperclip_client.py) מכיל **מזהי-חברה וסוכן קשיחים**:
|
||||||
|
- `COMPANIES["licensing"] = "42a7acd0-..."` (CMP), `COMPANIES["betterment"] = "8639e837-..."` (CMPA)
|
||||||
|
- CEO/curator/analyst UUIDs לכל חברה (CMP CEO `752cebdd-...`, וכו').
|
||||||
|
- ה-plugin ([worker.ts](../../../plugin-legal-ai/src/worker.ts)) מכיל CEO IDs קשיחים משלו.
|
||||||
|
|
||||||
|
זו **סתירה ישירה** ל-[X3 §1א](X3-integration-deploy.md) הקובע "מזהה-ה-CEO נגזר מ-`$PAPERCLIP_COMPANY_ID`,
|
||||||
|
**לעולם לא UUID hardcoded**". הסתירה מתועדת כממצא ([gap-audit GAP-26](gap-audit.md), וכן GAP-56 ב-X10).
|
||||||
|
|
||||||
|
### 2ג. `plugin_state` keys (חוזה הקישור Paperclip↔legal-ai)
|
||||||
|
| `scope_kind` | `state_key` | ערך | משמעות |
|
||||||
|
|--------------|-------------|-----|--------|
|
||||||
|
| `issue` | `legal-case-number` | מספר-תיק | קישור issue→תיק |
|
||||||
|
| `issue` | `precedent-case-law-id` | case_law_id | קישור issue→פסיקה לחילוץ |
|
||||||
|
| `instance` | `webhook-idem-{requestId}` | timestamp | guard idempotency 5 דק' (inbound) |
|
||||||
|
|
||||||
|
### 2ד. גזירת `company_id` — שתי דרכים שונות
|
||||||
|
- **app.py**: נגזר מ-prefix מספר-התיק (`1`→licensing, `8/9`→betterment) ([X3 §1ג](X3-integration-deploy.md)).
|
||||||
|
- **paperclip_client.py**: מ-`_FALLBACK_APPEAL_TYPE_TO_COMPANY` (מיפוי tag→company) + lookup ב-DB.
|
||||||
|
|
||||||
|
שתי דרכי-גזירה לאותו ערך = drift פוטנציאלי ([gap-audit GAP-27](gap-audit.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. צד נכנס (Inbound) — הפלאגין
|
||||||
|
|
||||||
|
[plugin-legal-ai/src/worker.ts](../../../plugin-legal-ai/src/worker.ts) (לא בריפו זה) קורא ל-legal-ai דרך
|
||||||
|
`legalApiBaseUrl`. שלושה סוגי-משטח, שכולם חוזה-API שאינו מתועד היום ב-[X6](X6-ui-api-contract.md):
|
||||||
|
- **16 כלי `legal_*`** — עוטפים endpoints של `/api/cases/...`, `/api/search`, וכו'.
|
||||||
|
- **`onWebhook`** — מקבל את ה-webhook היוצא (ראה [X3 §1ג](X3-integration-deploy.md) ו-INV-INT8 להלן).
|
||||||
|
- **3 cron jobs** — `sync-case-status` (כל 15 דק'), `stale-case-reminder` (יומי), `weekly-feedback-analysis` (שבועי).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-INT4: לקוח-Paperclip קנוני יחיד — אין לקוח-מקביל ואין גישת-DB ישירה
|
||||||
|
**כלל:** כל גישה ל-Paperclip עוברת דרך **לקוח-API קנוני יחיד** (`pc_request`/`pc.sh`). **אסור** מסלול-מקביל —
|
||||||
|
לא לקוח שני, ולא `INSERT`/`SELECT`/`UPDATE` ישיר ל-DB של Paperclip. נתונים נקראים/נכתבים דרך ה-API
|
||||||
|
הרשמי בלבד; ה-DB של Paperclip הוא מקור-האמת של Paperclip, ו-legal-ai אינו מסלול-כתיבה מקביל אליו.
|
||||||
|
מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) וכלל "סימטריה" ([חוקה §6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
|
||||||
|
**מקור-סמכות:** [legal-ai/CLAUDE.md](../../CLAUDE.md) ("paperclip_client.py legacy — השתמש ב-paperclip_api.py";
|
||||||
|
"קריאות API — תמיד דרך helper"); [X3 INV-INT3](X3-integration-deploy.md). (פרויקטלי-תפעולי — משרת G2.)
|
||||||
|
**אכיפה:** איחוד שני הלקוחות ללקוח-API אחד; הסרת `PAPERCLIP_DB_URL` כמסלול-כתיבה. **כיום אין אכיפה** —
|
||||||
|
שני הלקוחות דו-קיימים (יעד FU-9).
|
||||||
|
**הפרה ידועה:** [paperclip_client.py](../../web/paperclip_client.py) — `create_project`/`post_comment`-fallback
|
||||||
|
עושים `INSERT` ישיר ל-`projects`/`issues`/`comments`/`plugin_state` ([gap-audit GAP-24, GAP-25](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-INT5: מזהי-חברה/סוכן מ-config — לא hardcoded בקוד
|
||||||
|
**כלל:** מזהי-החברה (CMP/CMPA) ומזהי-הסוכנים (CEO/curator/analyst) **נגזרים מ-config** (env/טבלת-מיפוי),
|
||||||
|
**לא** קבועים בקוד. הוספת חברה/החלפת instance אינה דורשת שינוי-קוד. מופע של
|
||||||
|
[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (SSoT למיפוי) — מקור-אמת יחיד למיפוי.
|
||||||
|
**מקור-סמכות:** [X3 §1א](X3-integration-deploy.md) ("לעולם לא UUID hardcoded"); [X2-multi-company.md](X2-multi-company.md).
|
||||||
|
(פרויקטלי-תפעולי — משרת G2.)
|
||||||
|
**אכיפה:** טבלת-מיפוי/env יחידה; code-review. **כיום אין אכיפה** — UUIDs קשיחים.
|
||||||
|
**הפרה ידועה:** [paperclip_client.py:36-62](../../web/paperclip_client.py) + [app.py:3976](../../web/app.py) +
|
||||||
|
[plugin worker.ts](../../../plugin-legal-ai/src/worker.ts) — IDs קשיחים. **סותר את X3 §1א** ([gap-audit GAP-26](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-INT6: גזירת `company_id` קנונית יחידה
|
||||||
|
**כלל:** ל-`company_id` יש **מסלול-גזירה אחד** מתוך מספר-התיק/סוג-הערר, במקום יחיד. אסור שתי לוגיקות-גזירה
|
||||||
|
מקבילות (prefix מול fallback-map) שעלולות לסטות. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
|
||||||
|
**מקור-סמכות:** [X3 §1ג](X3-integration-deploy.md); [X2-multi-company.md](X2-multi-company.md). (פרויקטלי-תפעולי.)
|
||||||
|
**אכיפה:** פונקציית-גזירה יחידה משותפת ל-app.py ול-client.py (יעד FU-9). **כיום אין.**
|
||||||
|
**הפרה ידועה:** prefix ב-[app.py](../../web/app.py) מול `_FALLBACK_APPEAL_TYPE_TO_COMPANY` ב-[paperclip_client.py](../../web/paperclip_client.py) ([gap-audit GAP-27](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-INT7: webhook יוצא — at-least-once + idempotency + ללא בליעה שקטה
|
||||||
|
**כלל:** ה-webhook היוצא (legal-ai→plugin) מספק **at-least-once** עם **מפתח-idempotency יציב** (event id),
|
||||||
|
כך שמסירה-כפולה בטוחה בצד-המקבל; וכישלון-מסירה **נרשם ומדווח** (telemetry/health), לא נבלע בשקט.
|
||||||
|
זהו invariant **הנדסי** (סמנטיקת-מסירה כללית), הקשור ל-[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
|
||||||
|
(עקיבוּת) ולכלל "אין בליעה שקטה" ([חוקה §6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
|
||||||
|
**מקורות:** Stripe — *Webhooks / at-least-once delivery & idempotency*
|
||||||
|
(https://docs.stripe.com/webhooks) · Hookdeck — *At-Least-Once vs Exactly-Once Webhook Delivery*
|
||||||
|
(https://hookdeck.com/webhooks/guides/webhook-delivery-guarantees) · Martin Kleppmann, *DDIA*
|
||||||
|
(O'Reilly 2017, idempotence & exactly-once semantics) | סטטוס: verified
|
||||||
|
**אכיפה:** event-id יציב + UNIQUE-dedup בצד-המקבל; ה-emitter רושם כישלון ל-telemetry (יעד). **כיום:**
|
||||||
|
inbound יש guard 5 דק' ([X3 §1ג](X3-integration-deploy.md)); **outbound אין idempotency**, וה-emitter בולע
|
||||||
|
שגיאות ב-`logger.warning` בלבד.
|
||||||
|
**הפרה ידועה:** `emit_*_webhook` ב-[paperclip_api.py](../../web/paperclip_api.py) — fire-and-forget, `try/except`
|
||||||
|
שמתעד warning ולעולם לא raise, ללא event-id/dedup ([gap-audit GAP-28](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-INT8: חוזה-אירועי-webhook מתוקען ומגורס
|
||||||
|
**כלל:** ל-webhook חוזה-אירוע **מפורש ומגורס** — `eventType` מתוך קבוצה סגורה, סכמת-payload מתועדת לכל
|
||||||
|
סוג, וגרסה. אין `eventType` חופשי ואין "ברירת-מחדל שקטה". מופע של
|
||||||
|
[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)/[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
|
||||||
|
**מקור-סמכות:** [X3 §1ג](X3-integration-deploy.md) (3 סוגי-האירוע: `status_change`, `missing_precedent_created`,
|
||||||
|
`export_complete`); קוד ה-emitter ([paperclip_api.py:87+](../../web/paperclip_api.py)). (פרויקטלי-תפעולי — משרת G2/G9.)
|
||||||
|
**אכיפה:** enum + סכמה משותפים emitter↔handler. **כיום:** `eventType` נופל ל-`status_change` כברירת-מחדל
|
||||||
|
אם חסר/לא-מוכר ([gap-audit GAP-29](gap-audit.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. מצב קיים מול יעד — פער אכיפה
|
||||||
|
האינטגרציה נשענת על **נוהל + שני לקוחות**, לא על מסלול-קוד קנוני אחד:
|
||||||
|
- **לקוח (INV-INT4):** יעד — לקוח-API יחיד; הסרת מסלול-ה-DB הישיר.
|
||||||
|
- **מזהים (INV-INT5/INT6):** יעד — טבלת-מיפוי/env יחידה; פונקציית-גזירה אחת.
|
||||||
|
- **webhook (INV-INT7/INT8):** יעד — event-id + dedup + enum-אירוע מגורס + רישום-כישלון.
|
||||||
|
|
||||||
|
כל אלה מקובצים ל-**FU-9** ([gap-audit.md](gap-audit.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. הפניות-אחיות
|
||||||
|
- [X3-integration-deploy.md](X3-integration-deploy.md) — זרימות (wakeup, comments, webhook) + INV-INT1/2/3.
|
||||||
|
- [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) — חוזה-env מלא, סודות, hardcoded IDs/creds.
|
||||||
|
- [X2-multi-company.md](X2-multi-company.md) — CMP/CMPA, sync, company filtering.
|
||||||
|
- [X6-ui-api-contract.md](X6-ui-api-contract.md) — חוזה ה-API שהפלאגין (inbound) צורך.
|
||||||
|
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים), [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai), כלל "סימטריה" ([§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
|
||||||
|
- [web/paperclip_api.py](../../web/paperclip_api.py), [web/paperclip_client.py](../../web/paperclip_client.py), [scripts/pc.sh](../../scripts/pc.sh).
|
||||||
118
docs/spec/X8-field-provenance.md
Normal file
118
docs/spec/X8-field-provenance.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# X8 — כללי-מילוי-שדות וחילוץ (Field-Population & Extraction Rules)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-**SSoT לכללים שכרגע סמויים בקוד**:
|
||||||
|
כשמעלים החלטה/פסק-דין/מסמך-תיק — *איזה שדה מתמלא מאיזה מקור*, ומה הכללים על-גבי זה (אי-דריסת
|
||||||
|
ערך-יו"ר, שער-אישור, ציטוט-verbatim). הכללים האלה חיים היום מפוזרים על-פני 4 שירותים; כאן הם מאוחדים.
|
||||||
|
הוא משלים את [01-ingest.md](01-ingest.md) (הפייפליין) ו-[02-data-model.md](02-data-model.md) (הסכמה),
|
||||||
|
ומזין את [X6 INV-UI6](X6-ui-api-contract.md) (שיקוף-מקור ב-UI).
|
||||||
|
|
||||||
|
> **מודלי-סמכות מעורבים.** FP1 ו-FP4 הם **הנדסיים** (lineage/integrity — ≥3 מקורות). FP2/FP3/FP5 הם
|
||||||
|
> **פרויקטלי-תפעוליים** הנקשרים ל-[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||||
|
> (שער אנושי) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ארבעת מקורות-המילוי
|
||||||
|
|
||||||
|
| מקור | הגדרה | דוגמאות |
|
||||||
|
|------|-------|---------|
|
||||||
|
| **DETERMINISTIC** | parse של שם-קובץ / מטא-PDF / OCR / regex — ללא LLM | `full_text`, `extraction_status`, `source_kind`, chunks, page_number |
|
||||||
|
| **OPUS-ANALYSIS** | Claude Opus קורא את כל המסמך, ממלא **רק שדה ריק/placeholder**, אסינכרוני | `headnote`, `summary`, `key_quote`, `subject_tags`, `case_name`, `court`, `date`, `appeal_subtype`, `precedent_level`, `source_type`, `citation_formatted`, halachot |
|
||||||
|
| **CHAIR-MANUAL** | היו"ר מזין בטופס; חובה או רשות | `citation`/`case_number` (חובה), והשאר נשאר לעריכה |
|
||||||
|
| **DERIVED** | מחושב משדות אחרים | `district` מ-court, `proceeding_type` מ-appeal_subtype, `searchable` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. טבלת-provenance לפי סוג-מסמך (ה-SSoT)
|
||||||
|
|
||||||
|
> מאומת מול [precedent_metadata_extractor.py](../../mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py),
|
||||||
|
> [halacha_extractor.py](../../mcp-server/src/legal_mcp/services/halacha_extractor.py),
|
||||||
|
> [ingest.py](../../mcp-server/src/legal_mcp/services/ingest.py), [db.py](../../mcp-server/src/legal_mcp/services/db.py).
|
||||||
|
|
||||||
|
### 2א. פסיקה חיצונית (`case_law`, source_kind=`external_upload`)
|
||||||
|
| שדה | מקור | הערה |
|
||||||
|
|-----|------|------|
|
||||||
|
| `case_number` (citation) | CHAIR (חובה) | מפתח idempotency |
|
||||||
|
| `full_text`, `extraction_status`, `source_kind` | DETERMINISTIC | — |
|
||||||
|
| `case_name`, `court`, `date`, `headnote`, `summary`, `key_quote`, `subject_tags`, `appeal_subtype`, `precedent_level`, `source_type`, `citation_formatted` | CHAIR או OPUS | Opus ממלא רק אם ריק |
|
||||||
|
| `is_binding` | CHAIR (default true) | קובע prompt-הלכה |
|
||||||
|
| chunks (`content`/`section_type`/`page_number`) | DETERMINISTIC | — |
|
||||||
|
| `embedding` (chunks) | Voyage (לא-LLM-reasoning) | ⚠ לא-GENERATED ([gap-audit GAP-09](gap-audit.md)) |
|
||||||
|
| כל `halachot` | OPUS | נכנס pending_review |
|
||||||
|
|
||||||
|
### 2ב. החלטה פנימית (`case_law`, source_kind=`internal_committee`)
|
||||||
|
כמו 2א, ובנוסף: `case_number` **חובה**; `chair_name`/`district`/`proceeding_type` — CHAIR או OPUS או DERIVED;
|
||||||
|
`source_type` = `appeals_committee` (DETERMINISTIC קבוע). placeholder `"(טרם חולץ)"` מסומן ל-chair_name/district
|
||||||
|
ריקים ומטופל כריק ע"י ה-extractor.
|
||||||
|
|
||||||
|
### 2ג. מסמך-תיק (`documents`)
|
||||||
|
| שדה | מקור |
|
||||||
|
|-----|------|
|
||||||
|
| `case_id`, `title` | CHAIR |
|
||||||
|
| `doc_type` | DETERMINISTIC (local_classifier) → fallback Claude אם confidence<0.8 |
|
||||||
|
| `extracted_text`, `extraction_status`, `page_count` | DETERMINISTIC |
|
||||||
|
| chunks + `embedding` | DETERMINISTIC + Voyage |
|
||||||
|
| claims / appraiser_facts | OPUS (כלי-חילוץ נפרדים — ראה [X9](X9-mcp-tool-contract.md)) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-FP1: לכל שדה מקור-מילוי מוצהר — הטבלה היא ה-SSoT
|
||||||
|
**כלל:** לכל שדה-מטא יש **מקור-מילוי מוצהר** (deterministic / opus / chair / derived), ב**מקום יחיד**
|
||||||
|
(טבלת §2). אין כללי-מילוי סמויים מפוזרים בין שירותים. מופע של
|
||||||
|
[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) (lineage — מאיפה כל ערך). **הנדסי.**
|
||||||
|
**מקורות:** ISO 8000-110 (data quality — provenance) · DAMA-DMBOK2 (data lineage) · OpenLineage spec
|
||||||
|
(https://openlineage.io/) | סטטוס: verified
|
||||||
|
**אכיפה:** טבלת-provenance מוצהרת (§2) + עמודת-מקור-מילוי לכל שדה-נגזר (יעד; ראה [02-data-model.md](02-data-model.md)).
|
||||||
|
**הפרה ידועה:** הכללים מפוזרים על precedent_metadata_extractor/halacha_extractor/ingest/recompute_searchable; אין SSoT ([gap-audit GAP-35](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-FP2: חילוץ-LLM אינו דורס ערך שהוזן ידנית
|
||||||
|
**כלל:** חילוץ-Opus ממלא **רק שדה ריק/placeholder** — ערך שהיו"ר הזין **לעולם אינו נדרס**. סמכות-התוכן
|
||||||
|
היא היו"ר. מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant). **פרויקטלי-תפעולי.**
|
||||||
|
**מקור-סמכות:** [precedent_metadata_extractor.py](../../mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py)
|
||||||
|
(`apply_to_record` — compare-to-empty); feedback היו"ר. (משרת G10.)
|
||||||
|
**אכיפה:** לוגיקת compare-to-empty ב-extractor; convention placeholder מתועד.
|
||||||
|
**הפרה ידועה:** placeholder `"(טרם חולץ)"` כמחרוזת-קסם לא-מתועדת/שבירה ([gap-audit GAP-37](gap-audit.md)).
|
||||||
|
|
||||||
|
### INV-FP3: פלט-LLM נכנס כ-pending — רק אישור-יו"ר הופך אותו לשמיש
|
||||||
|
**כלל:** פלט-חילוץ של LLM (הלכות; ובהמשך גם טענות-משפטיות) נכנס במצב **לא-מאושר** (`pending_review`),
|
||||||
|
ואינו נחשף לחיפוש/החלטה עד **אישור-יו"ר**. מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||||
|
(שער אנושי) — תואם [05-qa-review.md](05-qa-review.md). **פרויקטלי-תפעולי.**
|
||||||
|
**מקור-סמכות:** [halacha_extractor.py](../../mcp-server/src/legal_mcp/services/halacha_extractor.py) (review_status); [01-ingest.md](01-ingest.md).
|
||||||
|
**אכיפה:** `review_status` חוסם חיפוש עד `approved`/`published`.
|
||||||
|
**הפרה ידועה:** `legal_arguments` **חסר** שער-אישור מקביל ([gap-audit GAP-39](gap-audit.md); [02-data-model.md](02-data-model.md)).
|
||||||
|
|
||||||
|
### INV-FP4: supporting_quote חייב להיות verbatim
|
||||||
|
**כלל:** כל ציטוט-תומך (`supporting_quote` של הלכה, `key_quote`) חייב להופיע **מילה-במילה** בטקסט-המקור;
|
||||||
|
אחרת מסומן (`quote_verified=false`). מופע של [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
|
||||||
|
(integrity). **הנדסי.**
|
||||||
|
**מקורות:** ISO 15489-1:2016 (records integrity/authenticity) · RAG attribution (Lewis et al., 2020, NeurIPS) ·
|
||||||
|
NCSC/JTC — *AI in Courts* (verifiable citation) | סטטוס: verified
|
||||||
|
**אכיפה:** `proofreader.verify_quote` בעת חילוץ → `quote_verified`.
|
||||||
|
**הפרה ידועה:** — (קיים; ה-flag נכתב, אך אין חיווי ב-UI — ראה [X6 INV-UI6](X6-ui-api-contract.md)).
|
||||||
|
|
||||||
|
### INV-FP5: חילוץ אסינכרוני דרך claude_session מקומי
|
||||||
|
**כלל:** חילוץ-LLM (מטא, הלכות) רץ **אסינכרוני, מתור**, דרך `claude_session` **מקומי בלבד** — לא חוסם את
|
||||||
|
ה-web, ולא קורא ל-LLM מהקונטיינר. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
(מסלול-LLM קנוני יחיד). **פרויקטלי-תפעולי.** תואם זיכרון `feedback_claude_session_local_only`.
|
||||||
|
**מקור-סמכות:** [ingest.py](../../mcp-server/src/legal_mcp/services/ingest.py) (queue בצעד 12 → `process_pending_extractions`); [legal-ai/CLAUDE.md](../../CLAUDE.md) (claude_session local-only).
|
||||||
|
**אכיפה:** queue + `precedent_process_pending`; קריאות-LLM רק מ-MCP מקומי.
|
||||||
|
**הפרה ידועה:** תור-החילוץ **סמוי** (אין הבחנה pending-initial מול pending-review; אין extraction-job table) ([gap-audit GAP-45](gap-audit.md); [X9](X9-mcp-tool-contract.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. חוזה-searchable (תזכורת — מוגדר ב-02)
|
||||||
|
רשומת `case_law` היא `searchable` רק כשמתקיים חוזה-השלמות ([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש),
|
||||||
|
[02-data-model.md](02-data-model.md), FU-2a): ≥1 chunk עם embedding · `extraction_status='completed'` ·
|
||||||
|
`case_number`/`source_kind` לא-ריקים · practice_area (לפנימי) · ≥1 שדה-מטא ({headnote/summary/subject_tags}).
|
||||||
|
ה-UI חייב **לשקף** את ה-flag הזה ([X6 INV-UI6](X6-ui-api-contract.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. הפניות-אחיות
|
||||||
|
- [01-ingest.md](01-ingest.md) — הפייפליין הקנוני (12 צעדים) שבו החילוץ יושב.
|
||||||
|
- [02-data-model.md](02-data-model.md) — סכמת השדות + חוזה-searchable + ישויות-נגזרות.
|
||||||
|
- [X6 INV-UI6](X6-ui-api-contract.md) — שיקוף מקור-המילוי ב-UI.
|
||||||
|
- [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) — כלי-החילוץ (claims/appraiser_facts/halachot/metadata).
|
||||||
|
- [00-constitution.md](00-constitution.md) — [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai), [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant), [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
|
||||||
103
docs/spec/X9-mcp-tool-contract.md
Normal file
103
docs/spec/X9-mcp-tool-contract.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# X9 — חוזה כלי-ה-MCP (Agent MCP Tool Contract)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **משטח כלי-ה-MCP** —
|
||||||
|
71 הכלים ש-[mcp-server](../../mcp-server/) חושף לסוכני Paperclip (CEO/analyst/researcher/writer/qa/…).
|
||||||
|
עד כה הספ תיאר *מה הסוכנים עושים* ([X4-agents.md](X4-agents.md)) אך לא **חוזה-הכלים** עצמו: envelope,
|
||||||
|
שמות, idempotency, סימטריית extract/get, ומפת-הרשאות. הקובץ מגדיר את הכללים; הממצאים → [gap-audit.md](gap-audit.md).
|
||||||
|
|
||||||
|
> **מודלי-סמכות מעורבים.** TOOL1/TOOL2/TOOL3/TOOL5 הם **הנדסיים** (עיצוב-API/כלים — ≥3 מקורות).
|
||||||
|
> TOOL4 ו-TOOL6 הם **פרויקטלי-תפעוליים**, הנקשרים ל-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
> ו-[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. אינוונטר (71 כלים, [server.py](../../mcp-server/src/legal_mcp/server.py))
|
||||||
|
|
||||||
|
| דומיין | כלים (מייצג) |
|
||||||
|
|--------|--------------|
|
||||||
|
| ניהול-תיק | case_create/list/get/update/delete, case_get_final_text |
|
||||||
|
| מסמכים | document_upload, document_upload_training, document_list/get_text/update, extract_references |
|
||||||
|
| טענות+טיעונים | extract_claims, get_claims, aggregate_claims_to_arguments, get_legal_arguments |
|
||||||
|
| **חיפוש (6 — חופפים)** | search_decisions, search_case_documents, find_similar_cases, search_internal_decisions, search_precedent_library, precedent_search_library |
|
||||||
|
| **כתיבת-בלוק (6 — חופפים)** | draft_section, get_block_context, write_block, write_all_blocks, write_interim_draft, save_block_content |
|
||||||
|
| ייצוא/QA | export_docx, export_interim_draft, validate_decision, revise_draft, list_bookmarks, apply_user_edit |
|
||||||
|
| פסיקה (3 תת-מערכות) | case-attached (precedent_attach/list/remove/search_library) · library (precedent_library_*) · internal (internal_decision_*) |
|
||||||
|
| הלכות | halacha_review, halachot_pending, precedent_extract_halachot/metadata, precedent_process_pending |
|
||||||
|
| ציטוטים | extract_internal_citations, list_internal_citations, list_incoming_citations |
|
||||||
|
| missing-precedents | missing_precedent_create/list/close |
|
||||||
|
| workflow/feedback | workflow_status, get_metrics, processing_status, set_outcome, brainstorm_directions, approve_direction, ingest_final_version, record/list_chair_feedback |
|
||||||
|
| appraiser/style | extract_appraiser_facts, style_corpus_enrich, style_corpus_pending_enrichment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-TOOL1: envelope-תשובה עקבי לכל הכלים
|
||||||
|
**כלל:** כל כלי מחזיר **מבנה אחיד** (למשל `{status, data, message}`) — לא string-לפעמים-JSON-לפעמים-`{error}`.
|
||||||
|
שגיאה מובחנת ממצב-ריק ממצב-הצלחה באופן עקבי. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים);
|
||||||
|
מקביל ל-[X6 INV-UI3](X6-ui-api-contract.md). **הנדסי.**
|
||||||
|
**מקורות:** Anthropic — *MCP / tool result conventions* (https://modelcontextprotocol.io/) ·
|
||||||
|
JSON-RPC 2.0 (result/error envelope) (https://www.jsonrpc.org/specification) · RFC 9457 (Problem Details) | סטטוס: verified
|
||||||
|
**אכיפה:** wrapper-תשובה משותף בכל הכלים — `tools/envelope.py` (`ok`/`empty`/`err` → `{status,data,message}`, status ∈ ok/empty/error — מבחין הצלחה/ריק/שגיאה), SSoT יחיד שמחליף את 5 ה-`_ok`/`_err` המשוכפלים. עיקרון: envelope-`status` משקף אם **הקריאה לכלי** הצליחה; תוצאות-עסקיות (failed_gates/results/...) נשמרות בתוך `data`. צרכני-API ב-`web/app.py` מפרקים דרך `envelope_unwrap` (+בדיקת `status=="error"`→4xx) כדי לשמר את חוזה-ה-UI↔API (X6) ללא-שינוי. **GAP-48 ✅ הושלם (2026-06-06):** כל ~12 משפחות-הכלים הומרו ל-envelope (search · precedent_library · citations · internal_decisions · missing_precedents · training_enrichment · precedents · legal_arguments · cases · documents · workflow · drafting). מסלול הפקת-ההחלטה (`export_docx` שער-QA) מאומת ב-`test_export_qa_gate`. 182/182 טסטים עוברים.
|
||||||
|
**הפרה ידועה:** — (נסגר)
|
||||||
|
|
||||||
|
### INV-TOOL2: שמות עקביים + חיפוש לפי-קורפוס
|
||||||
|
**כלל:** שמות-הכלים עוקבים אחר convention אחיד, ושם משקף התנהגות. כלי-חיפוש מובחנים **לפי הקורפוס**
|
||||||
|
(style / internal / external / case-attached), לא ב-6 שמות חופפים; כלי-כתיבת-בלוק אינם חופפים (context מול write).
|
||||||
|
מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) ("סימטריה", [§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)). **הנדסי.**
|
||||||
|
**מקורות:** Anthropic — *Writing effective tools / clear names* (https://www.anthropic.com/engineering/writing-tools-for-agents) ·
|
||||||
|
Google *API Design Guide* (naming) (https://cloud.google.com/apis/design/naming_convention) ·
|
||||||
|
Zalando *RESTful API Guidelines* | סטטוס: verified
|
||||||
|
**אכיפה:** איחוד/מיזוג כלי-חיפוש + כלי-בלוק; rename של שמות-מטעים. **GAP-49 (חלק קריטי) ✅ נסגר (2026-06-06):** הכלי המטעה `precedent_search_library` (חיפוש ציטוטים מצורפים-לתיק) שונה ל-**`search_case_precedents`** — מבטל את ההיפוך המסוכן מול `search_precedent_library` (הספרייה הסמכותית); הישן נשמר כ-alias deprecated לתאימות. docstrings של שני הכלים הובהרו (case-attached מול authoritative). 5 כלי-החיפוש הנותרים (search_decisions=סגנון-דפנה · search_case_documents=תיק · find_similar_cases=cross-case · search_internal_decisions=ועדות-ערר · search_precedent_library=פסיקה-סמכותית) מחפשים קורפוסים מובחנים עם שמות סבירים.
|
||||||
|
**GAP-50 ✅ נסגר (2026-06-06, הכרעת-יו"ר):** הכפילות האמיתית היחידה — `draft_section` (הקשר לפי-סעיף, ישן) — סומנה **deprecated** לטובת `get_block_context` (הקשר לפי-בלוק, תואם 12-הבלוקים). שאר כלי-הכתיבה (`write_block`/`write_all_blocks`/`save_block_content`/`write_interim_draft`) **מובחנים בכוונה** — משרתים זרימות שונות (CLI/initial-draft מול תהליך-ה-writer שבו "התיקון חי בקובץ, לא ב-DB"), ולא מוזגו במכוון.
|
||||||
|
**הפרה ידועה:** — (נסגר)
|
||||||
|
|
||||||
|
### INV-TOOL3: idempotency בכל כלי-מוטציה
|
||||||
|
**כלל:** כלי שמשנה-מצב הוא **idempotent על מפתח דטרמיניסטי** — קריאה חוזרת אינה יוצרת כפילות. מופע של
|
||||||
|
[G3](00-constitution.md#inv-g3-ingest-אחיד-ו-idempotent). **הנדסי.**
|
||||||
|
**מקורות:** Stripe — *Idempotent requests* (https://docs.stripe.com/api/idempotent_requests) ·
|
||||||
|
Kleppmann *DDIA* (idempotence) · IETF — *Idempotency-Key header* draft (https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) | סטטוס: verified
|
||||||
|
**אכיפה:** upsert/ON CONFLICT (או בדיקת-מפתח ברמת-אפליקציה) בכלי-מוטציה. **GAP-52 ✅ נסגר (2026-06-06):** `case_create` (מפתח case_number, UNIQUE), `precedent_attach` (מפתח case_id+section_id+citation+quote), `document_upload` (מפתח case_id+SHA-256 של הקובץ — מדלג על OCR/embed כפול) — כולם מחזירים את הקיים במקום כפילות. נבחרה בדיקת-מפתח ברמת-אפליקציה (לא UNIQUE-constraint) כדי לא לשבור startup על נתונים-קיימים כפולים. קודמים: `missing_precedent_create`/`precedent_link_cases`/`extract_internal_citations`.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-TOOL4: סימטריית extract/get + persistence
|
||||||
|
**כלל:** לכל כלי-חילוץ שכותב ל-DB יש **כלי-קריאה (get) מקביל**, והפלט **נשמר durably** (לא מוחזר-ונאבד).
|
||||||
|
מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (מקור-אמת נגיש). **פרויקטלי-תפעולי.**
|
||||||
|
**מקור-סמכות:** דפוס `extract_claims`↔`get_claims`, `aggregate`↔`get_legal_arguments` ב-[server.py](../../mcp-server/src/legal_mcp/server.py).
|
||||||
|
**אכיפה:** לכל extract — get מקביל. **GAP-44 ✅ + GAP-45 ✅ נסגרו (2026-06-06):** נוסף `get_appraiser_facts` (קורא `list_appraiser_facts`+`detect_appraiser_conflicts`, ללא חילוץ-מחדש); נוסף `extraction_status` שחושף את עומק תור-החילוץ (metadata/halacha) + גיל הבקשה הוותיקה — read-only. **GAP-47 (חלק provenance) ✅ נסגר (2026-06-06):** `draft_section` מחזיר `document_id`+`page`+`score` לכל קטע (provenance מ-`search_similar` שהיה נזרק) → מקור-אמת נגיש ובר-ציטוט (G9). נותר ב-GAP-47: הנחיות-יו"ר ל-DB (פרוסה נפרדת).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-TOOL5: limit-caps על כל כלי-רשימה/חיפוש
|
||||||
|
**כלל:** לכל כלי שמחזיר רשימה יש **תקרת-limit נאכפת** (הגנה מפני עומס/DoS); pagination היכן שרלוונטי. **הנדסי.**
|
||||||
|
**מקורות:** OWASP API Security Top 10 — *API4:2023 Unrestricted Resource Consumption* (https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/) ·
|
||||||
|
Microsoft *REST API Guidelines* (pagination) · Stripe API (limit caps) | סטטוס: verified
|
||||||
|
**אכיפה:** clamp ל-max בכל כלי-רשימה. **GAP-53 ✅ נסגר (2026-06-06):** `_clamp_limit` (תקרה 200) על ~13 כלי list/search ב-[server.py](../../mcp-server/src/legal_mcp/server.py); `list_chair_feedback` קיבל param `limit` (server→workflow→db עם `LIMIT`).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-TOOL6: שלמות-הרשאות — כל כלי שהוראות-הסוכן דורשות מוענק
|
||||||
|
**כלל:** מפת-ההרשאות (אילו כלים מוענקים לכל סוכן) **תואמת** את מה שהוראות-הסוכן מצריכות — לא חסר ולא עודף.
|
||||||
|
מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) (שערים מוגדרים); מפורט ב-[X4-agents.md](X4-agents.md). **פרויקטלי-תפעולי.**
|
||||||
|
**מקור-סמכות:** frontmatter `tools:` ב-[.claude/agents/](../../.claude/agents/) מול הוראות-הסוכן.
|
||||||
|
**אכיפה:** בדיקת-עקביות tools↔instructions (יעד FU-13).
|
||||||
|
**הפרה ידועה:** legal-analyst חסר `aggregate_claims_to_arguments`/`extract_references`/`extract_internal_citations`; researcher חסר טריגרי-חילוץ ([gap-audit GAP-46](gap-audit.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. הערות-עיצוב
|
||||||
|
- **set_outcome — GAP-51 ✅ נסגר (2026-06-06):** SSoT יחיד = 3 תוצאות קנוניות `rejection/partial_acceptance/full_acceptance`
|
||||||
|
ב-`lessons.VALID_OUTCOMES`; `OUTCOME_LABELS_HE` = מפת-תוויות עברית אחת (אנגלית ב-DB, עברית ב-UI); `canonical_outcome()`
|
||||||
|
ממפה ערכי-legacy (rejected/accepted/partial). `betterment_levy` הוצא מהיותו תוצאה → `PRACTICE_AREA_OVERRIDES`
|
||||||
|
(override לפי practice_area מעל התוצאה). נתונים נורמלו (~9 שורות, גיבוי ב-`data/audit/gap51-outcome-backup-*`).
|
||||||
|
- **3 מסלולי-קליטת-פסיקה** (library / internal / training) עם ולידציה א-סימטרית — נקשר ל-[01-ingest.md](01-ingest.md) / GAP-01/05.
|
||||||
|
|
||||||
|
הממצאים המלאים + התיקון → **FU-14** ([gap-audit.md](gap-audit.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. הפניות-אחיות
|
||||||
|
- [X4-agents.md](X4-agents.md) — מפת-הסוכנים + ההרשאות (INV-TOOL6).
|
||||||
|
- [X8-field-provenance.md](X8-field-provenance.md) — כלי-החילוץ ומה שהם שומרים.
|
||||||
|
- [X6-ui-api-contract.md](X6-ui-api-contract.md) — envelope מקביל בצד-ה-API.
|
||||||
|
- [01-ingest.md](01-ingest.md), [03-retrieval.md](03-retrieval.md) — מסלולי-קליטה/חיפוש שהכלים עוטפים.
|
||||||
|
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים), [G3](00-constitution.md#inv-g3-ingest-אחיד-ו-idempotent), [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
|
||||||
|
- [mcp-server/src/legal_mcp/server.py](../../mcp-server/src/legal_mcp/server.py), [tools/](../../mcp-server/src/legal_mcp/tools/).
|
||||||
234
docs/spec/gap-audit.md
Normal file
234
docs/spec/gap-audit.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# Gap-Audit — פערים בין המערכת הקיימת ל-spec
|
||||||
|
|
||||||
|
מסמך זה הוא **מפת-הפערים הקנונית** בין המערכת הקיימת (קוד ב-`web/`, `mcp-server/`,
|
||||||
|
`scripts/`) לבין ה-invariants שב-[`docs/spec/`](README.md). הוא תוצר של תת-פרויקט 2
|
||||||
|
(מיפוי-פערים), ומובחן מ-[`docs/audit-report.md`](../audit-report.md) הישן: ה-audit הוא
|
||||||
|
דוח-מצב נקודתי, וזה ה-gap-map שמקשר כל ממצא ל-invariant מופר וליחידת-תיקון.
|
||||||
|
|
||||||
|
**איך הופק:** סקירה חוצת-קבצים של כל קבצי-הספ (00 + 01–07 + X1–X5) מול הקוד הקיים,
|
||||||
|
30.5.2026. כל ממצא נושא: `invariant מופר` (ה-G*/INV-* שהוא סותר), הערכת-`severity`,
|
||||||
|
`קבצים מושפעים` (file:line), ו-`תיקון מוצע`.
|
||||||
|
|
||||||
|
**הערה על severity/priority:** דירוג ה-severity להלן הוא הערכה הנדסית (לפי סיכון
|
||||||
|
לשלמות-נתונים, דליפה חוצת-קורפוס, ועקיפת שער אנושי). **קביעת ה-priority בפועל —
|
||||||
|
מה לתקן ראשון — היא של היו"ר.** ה-severity מנמק; הוא אינו מכריע.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 23 הממצאים
|
||||||
|
|
||||||
|
> **סטטוס מחזור-1 (עודכן 31.5.2026):** כל 23 הממצאים **✅ נסגרו** — FU-1..FU-8b מוזגו ל-main
|
||||||
|
> (PRs #11–#23: FU-1/2a, FU-2b #15, FU-2c #17, FU-3, FU-4, FU-5 #18, FU-6, FU-7 #13, FU-8a #16, FU-8b #23).
|
||||||
|
> 122 בדיקות עוברות. הטבלה נשמרת כתיעוד-מקור; פירוט-ה-FU והסטטוס בסעיף "יחידות-תיקון".
|
||||||
|
|
||||||
|
| ID | כותרת | invariant מופר | severity | קבצים מושפעים (file:line) | תיקון מוצע |
|
||||||
|
|----|-------|----------------|----------|---------------------------|------------|
|
||||||
|
| GAP-01 | שני מסלולי ingest מקבילים שמתפצלים | INV-ING1, G2 | High | `precedent_library.py:88`, `internal_decisions.py:73` | מסלול-קליטה קנוני יחיד; ישויות-אחיות חולקות פייפליין |
|
||||||
|
| GAP-02 | ingest פנימי מדלג על חילוץ metadata | INV-ING3, DM1, RET2 | Critical | `internal_decisions.py:208` | להוסיף `request_metadata_extraction` לכל סוג; חוסם indexing ריק |
|
||||||
|
| GAP-03 | אין upsert דטרמיניסטי על מזהה קנוני | INV-ING2, G3 | Critical | `precedent_library.py`, `internal_decisions.py` | upsert על מפתח קנוני — קליטה חוזרת = update לא duplicate |
|
||||||
|
| GAP-04 | ולידציית-enum א-סימטרית | INV-G4 | Medium | `precedent_library.py:131-134` | להחיל אותה ולידציית practice_area/source_type בשני המסלולים |
|
||||||
|
| GAP-05 | staging/derivation/citation-guard/multimodal/fallback א-סימטריים | INV-ING1, G2 | High | `01-ingest §4` (שני המסלולים) | מיזוג כל שלבי-העיבוד למסלול הקנוני האחד |
|
||||||
|
| GAP-06 | case_number מנורמל בקריאה בלבד | INV-G1, ID1 | High | `db.py:1196-1211` | נרמול בנקודת-הכתיבה; `8126-25`→canonical |
|
||||||
|
| GAP-07 | מספרי-תיק מעורבים (חודש/חסר) — reconciliation חד-פעמי | INV-ID1 | High | data (cases, case_law) | מיגרציה: canonical = הצורה הרשמית שהוקצתה [chair-confirmed] |
|
||||||
|
| GAP-08 | ציטוט-מלא נשמר כ-case_number | INV-DM2, ID2 | Medium | data (legacy pre-V15) | ניקוי: ציטוט = שדה-תצוגה נגזר, לא מזהה |
|
||||||
|
| GAP-09 | `embedding` אינו GENERATED (בניגוד ל-tsvectors) | INV-DM3, RET, G6 | High | schema (chunks/case_law) | re-index באכיפה — טריגר או GENERATED-equivalent בשינוי תוכן |
|
||||||
|
| GAP-10 | דליפת הלכה חוצת-קורפוס | INV-RET1, G5 | Critical | `db.py:3168`, `db.py:3401`, JOINs `3236-3238`/`3475-3477` | להוסיף `cl.source_kind` ל-halacha_filters |
|
||||||
|
| GAP-11 | אין eval harness / gold-set מתויג | INV-RET4, G8 | High | `telemetry.log_search_bg` (היחיד) | להקים eval harness + gold-set; precision/recall נמדד |
|
||||||
|
| GAP-12 | search_decisions מזהיר אך לא חוסם practice_area חסר | INV-RET, G5 | High | `search.py:45-49`, `search.py:172-176` | לחסום query בלי practice_area — ערבוב-תחום אסור |
|
||||||
|
| GAP-13 | אין דגל `searchable` מפורש | INV-DM1 | Medium | schema (case_law, chunks) | דגל `searchable` שמסומן רק כשחוזה-השלמות מתקיים |
|
||||||
|
| GAP-14 | backlog הלכות סמוי | INV-QA1, G10 | Medium | (אין health-check) | לחשוף `pending_review` ב-health-check / dashboard |
|
||||||
|
| GAP-15 | שער-ייצוא נאכף-זרימה ולא נאכף-קוד | INV-QA3, EX3 | Critical | `drafting.py:384` | `export_docx` קורא `validate_decision` + בודק `export_blocked` |
|
||||||
|
| GAP-16 | neutral_background קריטי-אך-עובר | INV-QA3 (`05 §1.2`) | High | `qa_validator.py:70` | בלוק-ו ריק/חסר = passed=False; חוסם ייצוא |
|
||||||
|
| GAP-17 | active_draft_path נגזר זוחל ל-source-of-truth | INV-EX1, AUD2 | High | `db.py:189` | DOCX = נגזר; re-sync בלוקים אחרי revise/apply_user_edit |
|
||||||
|
| GAP-18 | audit_log כמעט לא נכתב | INV-AUD1 | High | `cases.py:203` (היחיד) | כתיבת audit על upload/extract/write_block/export |
|
||||||
|
| GAP-19 | אין קישור block→source-chunks | INV-AUD1 | High | `decision_blocks` (model_used בלבד) | לתעד אילו chunks/precedents הזינו כל בלוק |
|
||||||
|
| GAP-20 | citation→corpus לא נאכף אוטומטית | INV-AUD3 | Medium | `decision_paragraphs.citations` | ולידציה שכל ציטוט בטקסט פתיר לקורפוס |
|
||||||
|
| GAP-21 | cross-company sync ידני ולא-נאכף | INV-MC1 | Medium | `sync_agents_across_companies.py:387-389` | אכיפת `--apply` אחרי שינוי-Master; להרעיש על דילוג adapter_type |
|
||||||
|
| GAP-22 | אינטגרציית-Paperclip על נוהל ולא מחסום-קוד | INV-INT1, INT3 | Medium | schema / lint (אין) | אילוץ-schema נגד DB-insert; linter נגד httpx/curl גולמי |
|
||||||
|
| GAP-23 | הספ עדיין לא מחובר לסוכנים | INV-AG1 | High | `.claude/agents/HEARTBEAT.md`, agent files | חובת קריאת 00-constitution + ספ-תחום לפני פעולה |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ממצאי מחזור-2 (8 משטחי-האפליקציה מחוץ לצינור-הליבה) — GAP-24..62
|
||||||
|
|
||||||
|
> הופקו בסקירת-קוד word-for-word (30–31.5.2026) של 8 המשטחים: גבול-Paperclip, web-ui,
|
||||||
|
> מילוי-שדות, אחסון-ניתוחים, כלי-MCP (71), סוכנים+skills, deploy/env. ממצאי-ה-UI ברמת-הדף
|
||||||
|
> מפורטים ב-[ui-audit.md](ui-audit.md). ה-invariants ב-[X6](X6-ui-api-contract.md)–[X10](X10-deploy-env-secrets.md).
|
||||||
|
> **כל מחזור-2 פתוח** (אומת 31.5.2026: creds plaintext קיימים, 2 לקוחות קיימים, אין get_appraiser_facts, analyst חסר 3 כלים).
|
||||||
|
|
||||||
|
| ID | כותרת | invariant מופר | severity | קבצים מושפעים (file:line) | תיקון מוצע |
|
||||||
|
|----|-------|----------------|----------|---------------------------|------------|
|
||||||
|
| GAP-24 | שני לקוחות Paperclip מקבילים (api מול client legacy) | INV-INT4, G2 | High | `web/paperclip_api.py`, `web/paperclip_client.py` | לקוח-API קנוני יחיד |
|
||||||
|
| GAP-25 | גישת-DB ישירה ל-Paperclip (INSERT projects/issues/plugin_state) עוקפת API+audit | INV-INT4, G2, G9 | High | `web/paperclip_client.py` | להעביר הכל ל-API; להסיר מסלול-DB |
|
||||||
|
| GAP-26 | company/agent IDs קשיחים — **סותר X3 §1א** | INV-INT5, G2 | High | `web/paperclip_client.py:36-62`, `web/app.py:3976`, plugin `worker.ts` | מיפוי מ-config/env |
|
||||||
|
| GAP-27 | `company_id` נגזר בשתי דרכים (prefix מול fallback-map) | INV-INT6, G2 | Medium | `web/app.py` (prefix), `web/paperclip_client.py` (`_FALLBACK_APPEAL_TYPE_TO_COMPANY`) | פונקציית-גזירה יחידה |
|
||||||
|
| GAP-28 | webhooks fire-and-forget בולעים שגיאות, ללא idempotency | INV-INT7, G9, §6 | Medium | `web/paperclip_api.py:87-205` | event-id+dedup+רישום-כישלון |
|
||||||
|
| GAP-29 | חוזה-אירוע webhook לא-מתוקען (eventType חופשי, default שקט) | INV-INT8, G2 | Medium | `web/paperclip_api.py:87+`, plugin `onWebhook` | enum-אירוע מגורס |
|
||||||
|
| GAP-30 | ~60% endpoints ללא Pydantic → `unknown` → טיפוסים ידניים סוטים | INV-UI1/UI2, G2/G4 | High | `web/app.py` (רוב), `web-ui/src/lib/api/cases.ts:1-9` | response models + `api:types` |
|
||||||
|
| GAP-31 | `PracticeArea`/enum-סטטוס משוכפלים פרונט (3 מקומות, ערכים שונים) | INV-UI1, G2 | High | `web-ui/src/lib/practice-area.ts:12`, `lib/api/precedent-library.ts:26`, `components/precedents/practice-area.ts` | SSoT יחיד (ui-audit UI-A1/B1) |
|
||||||
|
| GAP-32 | אין envelope עקבי; שגיאות נבלעות ב-UI | INV-UI3/UI4, §6 | Medium | `web/app.py` (search ועוד), דפי-UI | envelope אחיד + error-card |
|
||||||
|
| GAP-33 | fallback SSE מסתיר כישלון; cache-TTL לא-תואם (5ש'↔300ש') | INV-UI5 | Low | `web-ui/src/lib/api/documents.ts:226-232` | terminal-state מפורש |
|
||||||
|
| GAP-34 | URLs קשיחים ב-UI/בק | INV-UI3/ENV3 | Low | `web-ui/.../app-shell.tsx:70`, `web/app.py:110` | env |
|
||||||
|
| GAP-35 | מקור-מילוי-שדות לא-מוצהר — מפוזר על 4 שירותים | INV-FP1, G9 | High | `precedent_metadata_extractor.py`, `halacha_extractor.py`, `ingest.py`, `db.py` (recompute_searchable) | טבלת-provenance SSoT (X8 §2) |
|
||||||
|
| GAP-36 | אין שקיפות-UI למה מולא ע"י Opus מול ידני | INV-UI6/FP1, G9 | Medium | `web-ui/src/app/precedents/[id]/page.tsx:160-185` | חיווי מקור-מילוי |
|
||||||
|
| GAP-37 | placeholder `"(טרם חולץ)"` כמחרוזת-קסם לא-מתועדת | INV-FP2 | Low | `internal_decisions.py`, `precedent_metadata_extractor.py` | constant מתועד |
|
||||||
|
| GAP-38 | שתי עמודות-סטטוס-חילוץ ב-case_law | INV-DM1, G2 | Medium | `db.py:603-606` | סטטוס יחיד / extraction-jobs |
|
||||||
|
| GAP-39 | `legal_arguments` ללא שער-אישור (בניגוד ל-halachot) | INV-DM5, G10 | High | `db.py:845-872` | `review_status` ל-legal_arguments |
|
||||||
|
| GAP-40 | `legal_arguments.cited_precedents TEXT[]` ללא FK → הזיות-LLM נבלעות | INV-DM6, G9, §6 | Medium | `db.py:858`, `argument_aggregator.py` | FK + דיווח-כישלון-קישור |
|
||||||
|
| GAP-41 | `appraiser_facts`↔`claims` התנגשות; `appraiser_side` default '' מעורפל | INV-DM6 | Medium | `db.py:549-576` | CHECK + הבחנה document↔case |
|
||||||
|
| GAP-42 | 20+ enums כ-TEXT חופשי; אין embedding-provenance | INV-DM6/DM4, G4 | Medium | `db.py` (source_type, rule_type, status…) | CHECK-enums + עמודת-model |
|
||||||
|
| GAP-43 | `case_precedents`↔`case_law` טבלאות-פסיקה מקבילות legacy | INV-G2 | Low | `db.py` | איחוד/סימון-deprecated |
|
||||||
|
| GAP-44 | אסימטריית extract/get — אין `get_appraiser_facts` (חילוץ-חוזר יקר) | INV-TOOL4, G2 | High | `mcp-server/.../drafting.py`, `server.py:563` | להוסיף `get_appraiser_facts` |
|
||||||
|
| GAP-45 | תור-חילוץ סמוי (pending-initial מול pending-review); אין extraction-job table | INV-TOOL4/FP5, G10 | Medium | `precedent_library.py`, `ingest.py` | `*_extraction_status` tool + טבלת-jobs |
|
||||||
|
| GAP-46 | הרשאות-סוכן לא-מתועדות (analyst/researcher חסרי כלים) | INV-AG3/TOOL6 | High | `.claude/agents/legal-analyst.md`, `legal-researcher.md` | יישור tools↔instructions |
|
||||||
|
| GAP-47 | `draft_section` ללא provenance (chunk→document/page); הנחיות-יו"ר ב-md ולא DB | INV-TOOL4, G9 | Medium | `mcp-server/.../drafting.py` | provenance בפלט + DB ל-directions |
|
||||||
|
| GAP-48 | envelope-תשובה לא-עקבי (71 כלים: string/JSON/{error}) | INV-TOOL1, G2 | Medium | `mcp-server/.../server.py`, tools/ | wrapper `{status,data,message}` |
|
||||||
|
| GAP-49 | 6 כלי-חיפוש חופפים + `precedent_search_library` שם-מטעה | INV-TOOL2, G2 | Medium | `server.py` (search_*), `precedents.py:81` | ✅ **שם-מטעה תוקן** (`precedent_search_library`→`search_case_precedents`, alias deprecated); 5 הנותרים = קורפוסים מובחנים בשמות סבירים |
|
||||||
|
| GAP-50 | 6 כלי-כתיבת-בלוק חופפים (draft_section/get_block_context/write_*/save_*) | INV-TOOL2, G2 | Medium | `server.py:500-616` | ✅ **draft_section deprecated→get_block_context** (הכרעת-יו"ר); write_*/save_* מובחנים בכוונה (זרימות שונות), לא מוזגו |
|
||||||
|
| GAP-51 | `set_outcome` enum-mismatch (3≠4); אוצרות-מילים סותרות | INV-TOOL1/UI1 | Medium | `block_writer.py:442` מול `lessons.py:11`, `workflow.py:145` | SSoT יחיד ל-outcome |
|
||||||
|
| GAP-52 | רוב הכלים לא-idempotent (case_create/document_upload/precedent_attach) | INV-TOOL3, G3 | Medium | `server.py`, tools/ | upsert/ON CONFLICT |
|
||||||
|
| GAP-53 | אין limit-caps (precedent_library_list/search_*/list_chair_feedback) | INV-TOOL5 | Low | tools/ | clamp ל-max |
|
||||||
|
| GAP-54 | 3 מסלולי-קליטת-פסיקה ולידציה א-סימטרית; citation-guard לא-מתועד | INV-ING1, G2 | Medium | `precedent_library.py`, `internal_decisions.py` | ✅ **נפתר ע"י FU-1** — שני מסלולי-הפסיקה (library+internal) עוברים דרך `ingest.ingest_document` הקנוני (ולידציית-enums + citation-guard סימטריים, מתועד ב-01-ingest §4); המסלול ה-3 (training→`style_corpus`) הוא קורפוס נפרד במכוון (סגנון, לא פסיקה). מאומת ב-`test_unified_ingest.py` |
|
||||||
|
| GAP-55 | Infisical dead-code; מקור-config לא-מתועד (Coolify-only) | INV-ENV2, G2 | Medium | `mcp-server/.../config.py` | לתעד Coolify SSoT / לבודד Infisical |
|
||||||
|
| GAP-56 | UUIDs קשיחים (company/agent) — תואם GAP-26 | INV-ENV3/INT5 | High | `web/paperclip_client.py:36-62`, `web/app.py:3976` | config-driven |
|
||||||
|
| GAP-57 | creds plaintext בברירת-מחדל (`paperclip:paperclip`) | INV-ENV4, G9, §6 | High | `web/paperclip_client.py:21`, `web/app.py:3789,3964` | default ריק + fail-loud |
|
||||||
|
| GAP-58 | `GITEA_ACCESS_TOKEN`↔`GITEA_TOKEN` שני שמות; קטלוג חלקי | INV-ENV1 | Low | `web/gitea_client.py:22`, `git_sync.py:30`, `tools/cases.py:28` | שם קנוני יחיד + קטלוג |
|
||||||
|
| GAP-59 | chat-URL docs↔reality (`10.0.1.1` מול `host.docker.internal`) | INV-ENV3 | Medium | `web/chat_proxy.py:49`, `chat_service/server.py` | יישור env + תיעוד |
|
||||||
|
| GAP-60 | 13/40+ env vars ב-drift-catalog; 8+ סודות בלתי-מנוטרים | INV-ENV5/ENV1 | Medium | `web/mcp_env_catalog.py` | קטלוג מקיף |
|
||||||
|
| GAP-61 | URLs + `/home/chaim` קשיחים | INV-ENV3 | Low | `web/paperclip_client.py:31`, app.py | env/config |
|
||||||
|
| GAP-62 | start.sh לא-נכשל-על-uvicorn; deploy-curl fire-and-forget | INV-ENV2/§6 | Low | `start.sh`, `.gitea/workflows/deploy.yaml` | health-gate + אימות-deploy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## יחידות-תיקון מוצעות (Proposed Fix-Units)
|
||||||
|
|
||||||
|
23 הממצאים מקובצים ל-8 יחידות-עבודה קוהרנטיות. הקיבוץ נגזר מהעיקרון שרבים מהממצאים
|
||||||
|
נפתרים יחד (כל פערי ה-ingest-asymmetry → יחידה אחת). זהו זרע למשימות TaskMaster
|
||||||
|
ולתת-פרויקט 3 (שכבת-שלמות).
|
||||||
|
|
||||||
|
> **✅ מחזור-1 הושלם (31.5.2026):** FU-1..FU-8b כולם מוזגו ל-main. מחזור-2 (FU-9..15, להלן)
|
||||||
|
> נגזר מ-GAP-24..62 ו**פתוח**.
|
||||||
|
|
||||||
|
### FU-1 — איחוד מסלול-הקליטה (Unify ingest path)
|
||||||
|
- **מכסה:** GAP-01, GAP-02, GAP-04, GAP-05
|
||||||
|
- **מספק invariants:** INV-ING1, INV-ING3, INV-G2, INV-G4; (תורם ל-DM1/RET2 דרך GAP-02)
|
||||||
|
- **effort:** L
|
||||||
|
- **תלויות:** — (יסוד — FU-2/FU-3 נשענים עליה)
|
||||||
|
- **סוג:** pure-code
|
||||||
|
|
||||||
|
### FU-2 — קליטה idempotent + מזהים קנוניים
|
||||||
|
- **מכסה:** GAP-03, GAP-06, GAP-07, GAP-08, GAP-13
|
||||||
|
- **מספק invariants:** INV-ING2, INV-G3, INV-G1, INV-ID1, INV-ID2, INV-DM2, INV-DM1
|
||||||
|
- **effort:** L
|
||||||
|
- **תלויות:** FU-1 (מסלול אחד לפני upsert אחיד)
|
||||||
|
- **סוג:** **data-migration** — GAP-07 reconciliation של case_number מעורב (chair-confirmed),
|
||||||
|
GAP-08 ניקוי ציטוט-כ-מזהה; + code (upsert key, write-time normalize, דגל searchable)
|
||||||
|
|
||||||
|
### FU-3 — re-index באכיפה בשינוי-תוכן
|
||||||
|
- **מכסה:** GAP-09
|
||||||
|
- **מספק invariants:** INV-DM3, INV-G6, INV-RET (freshness)
|
||||||
|
- **effort:** M
|
||||||
|
- **תלויות:** FU-1 (re-embed יושב בקליטה הקנונית)
|
||||||
|
- **סוג:** **data-migration** — re-chunk/re-embed של רשומות קיימות + טריגר/אכיפה קדימה
|
||||||
|
|
||||||
|
### FU-4 — הפרדת-קורפוס נאכפת בכל query
|
||||||
|
- **מכסה:** GAP-10, GAP-12
|
||||||
|
- **מספק invariants:** INV-RET1, INV-G5
|
||||||
|
- **effort:** M
|
||||||
|
- **תלויות:** — (עצמאי; דחוף — Critical leak)
|
||||||
|
- **סוג:** pure-code
|
||||||
|
|
||||||
|
### FU-5 — eval harness + נראות-בריאות
|
||||||
|
- **מכסה:** GAP-11, GAP-14
|
||||||
|
- **מספק invariants:** INV-RET4, INV-G8, INV-QA1, INV-G10 (נראות backlog)
|
||||||
|
- **effort:** M
|
||||||
|
- **תלויות:** FU-2 (gold-set יציב דורש מזהים קנוניים)
|
||||||
|
- **סוג:** pure-code + **chair-decision** — הגדרת gold-set מתויג דורשת אישור היו"ר
|
||||||
|
(מה "תוצאה נכונה" לכל query)
|
||||||
|
|
||||||
|
### FU-6 — שערי-QA נאכפים-קוד (Code-enforced gates)
|
||||||
|
- **מכסה:** GAP-15, GAP-16
|
||||||
|
- **מספק invariants:** INV-QA3, INV-EX3, INV-G10
|
||||||
|
- **effort:** S
|
||||||
|
- **תלויות:** — (עצמאי; חוסם עקיפת-ייצוא)
|
||||||
|
- **סוג:** pure-code
|
||||||
|
|
||||||
|
### FU-7 — Audit-trail + provenance (זרע תת-פרויקט 3)
|
||||||
|
- **מכסה:** GAP-17, GAP-18, GAP-19, GAP-20
|
||||||
|
- **מספק invariants:** INV-AUD1, INV-AUD2, INV-AUD3, INV-EX1, INV-G9
|
||||||
|
- **effort:** L
|
||||||
|
- **תלויות:** FU-1 (provenance נלכד בקליטה/כתיבה הקנונית)
|
||||||
|
- **סוג:** pure-code (schema-additive) — חלק מ-GAP-17 דורש **data-backfill** קל
|
||||||
|
לסנכרון בלוקים↔DOCX קיימים
|
||||||
|
|
||||||
|
### FU-8 — מחסומי-תהליך הופכים למחסומי-קוד
|
||||||
|
- **מכסה:** GAP-21, GAP-22, GAP-23
|
||||||
|
- **מספק invariants:** INV-MC1, INV-INT1, INV-INT3, INV-AG1
|
||||||
|
- **effort:** M
|
||||||
|
- **תלויות:** ה-spec גמור (GAP-23 דורש קבצי-ספ יציבים לחבר לסוכנים)
|
||||||
|
- **סוג:** pure-code + **chair-decision** — GAP-23 (חיבור ספ לסוכני-Paperclip) הוא
|
||||||
|
prerequisite לתת-פרויקט 5 ומשנה התנהגות-סוכן בייצור
|
||||||
|
- **סטטוס:** ✅ FU-8a (GAP-21/22, PR #16) + FU-8b (GAP-23, PR #23) מוזגו.
|
||||||
|
|
||||||
|
> **— מחזור-2 (FU-9..15): 8 משטחי-האפליקציה מחוץ לצינור-הליבה. כולם פתוחים. —**
|
||||||
|
|
||||||
|
### FU-9 — לקוח-Paperclip קנוני
|
||||||
|
- **מכסה:** GAP-24..29 · **invariants:** INV-INT4–INT8 · **effort:** L · **תלויות:** [X7](X7-paperclip-client-params.md) יציב
|
||||||
|
- **סוג:** code — איחוד 2 הלקוחות, הסרת מסלול-DB, IDs מ-config, company_id יחיד, webhook idempotency+enum
|
||||||
|
|
||||||
|
### FU-10 — חוזה UI↔API + design-system SSoT
|
||||||
|
- **מכסה:** GAP-30..34 + [ui-audit](ui-audit.md) (UI-A1..D6) · **invariants:** INV-UI1–UI6 · **effort:** L · **תלויות:** —
|
||||||
|
- **סוג:** code — Pydantic models+`api:types`, SSoT ל-enums/תוויות/tones, helpers משותפים, ניקוי redundancy
|
||||||
|
|
||||||
|
### FU-11 — מילוי-שדות מוצהר + שקיפות-UI
|
||||||
|
- **מכסה:** GAP-35..37 · **invariants:** INV-FP1–FP5, UI6 · **effort:** M · **תלויות:** —
|
||||||
|
- **סוג:** code — טבלת-provenance SSoT, formalize placeholder, חיווי "מולא-ע"י-Opus" + searchable + pending ב-UI
|
||||||
|
|
||||||
|
### FU-12 — חיזוק אחסון-הניתוחים
|
||||||
|
- **מכסה:** GAP-38..43 · **invariants:** INV-DM4–DM6 · **effort:** M · **תלויות:** FU-1
|
||||||
|
- **סוג:** code + data-migration קל — provenance, שער-אישור ל-legal_arguments, CHECK-enums, FK, איחוד case_precedents
|
||||||
|
|
||||||
|
### FU-13 — סוכנים + skills — ✅ נסגר (2026-06-06)
|
||||||
|
- **מכסה:** GAP-46 (מרחיב GAP-23) · **invariants:** INV-AG3, INV-TOOL6 · **effort:** S · **תלויות:** ה-spec יציב
|
||||||
|
- **סוג:** code/docs — שלמות-הרשאות (tools↔instructions), DRY-boilerplate, dedup-skills
|
||||||
|
- **סטטוס:** הכרעת-יו"ר "היבריד". התברר שהפער ב-31.5 היה רחב מדי (יוחס לפי תיאור-תפקיד, לא הוראות בפועל).
|
||||||
|
researcher כבר היה תקין (מיושן ב-spec). analyst קיבל `aggregate_claims_to_arguments` + שלב 7 ("שלב 1");
|
||||||
|
`extract_references`/`extract_internal_citations` נשארו אצל researcher (מטלת-מחקר, לא analyst). עודכן [X4 §2א](X4-agents.md).
|
||||||
|
|
||||||
|
### FU-14 — חוזה כלי-ה-MCP
|
||||||
|
- **מכסה:** GAP-44,45,47..54 · **invariants:** INV-TOOL1–TOOL5 · **effort:** L · **תלויות:** FU-1
|
||||||
|
- **סוג:** code — envelope אחיד, מיזוג חיפוש/בלוקים, idempotency, limit-caps, get-symmetry, set_outcome SSoT
|
||||||
|
- **סטטוס חלקי (פרוסה 1, 2026-06-06):** ✅ **GAP-44** — נוסף `get_appraiser_facts` (ה-get המקביל ל-extract, INV-TOOL4); ✅ **GAP-53** — נוסף `_clamp_limit` (תקרה 200, INV-TOOL5) על ~13 כלי list/search + הוספת limit ל-`list_chair_feedback` (שהיה ללא תקרה).
|
||||||
|
- **סטטוס חלקי (פרוסה 2, 2026-06-06):** ✅ **GAP-52** (INV-TOOL3 idempotency) — `case_create`/`precedent_attach`/`document_upload` מחזירים קיים במקום כפילות (בדיקת-מפתח ברמת-אפליקציה; document_upload לפי SHA-256 → מדלג OCR/embed כפול); ✅ **GAP-45** (INV-TOOL4 visibility) — נוסף `extraction_status` שחושף עומק תור-החילוץ (metadata/halacha) + גיל הבקשה הוותיקה.
|
||||||
|
- **סטטוס חלקי (פרוסה 3, 2026-06-06):** ✅ **GAP-51** (set_outcome SSoT, הכרעת-יו"ר "3 תוצאות + הוצאת betterment_levy") — קנוני `rejection/partial_acceptance/full_acceptance` ב-`lessons.VALID_OUTCOMES`; `OUTCOME_LABELS_HE` (עברית-ב-UI SSoT); `canonical_outcome()` ל-legacy; `betterment_levy`→`PRACTICE_AREA_OVERRIDES` (override לפי practice_area); block_writer/set_outcome/drafting/web-ui יושרו; נתונים נורמלו (9 שורות + גיבוי).
|
||||||
|
- **סטטוס חלקי (פרוסה 4, 2026-06-06):** ✅ **GAP-47 (חלק provenance, INV-TOOL4/G9)** — `draft_section` חושף בפלט `document_id`+`page`+`score` לכל קטע ב-`case_documents`/`precedents` (ה-provenance כבר נשלף ב-`search_similar` ונזרק; כעת מוחזר), כך שהכותב יכול לעקוב אל המקור ולצטטו. תוספתי, לא-שובר. **נותר ב-GAP-47:** העברת הנחיות-יו"ר מ-`analysis-and-research.md` ל-DB (`get_chair_directions`) — שינוי-מסלול גדול יותר הנוגע ל-UI של דפנה ולזרימת-האנליסט; לפרוסה נפרדת.
|
||||||
|
- **סטטוס חלקי (פרוסה 5, 2026-06-06):** 🔄 **GAP-48 (envelope אחיד, INV-TOOL1) — תחילת מיגרציה הדרגתית.** נוצר `tools/envelope.py` כ-SSoT יחיד (`ok`/`empty`/`err` → `{status,data,message}`, status מבחין הצלחה/ריק/שגיאה) המחליף 3 מוסכמות סותרות (raw payload / `{error}` / `{status,message}` אד-הוק) ו-5 עותקי `_ok`/`_err` משוכפלים. **משפחת-החיפוש הראשונה הומרה** (`search_decisions`/`search_case_documents`/`find_similar_cases`/`search_internal_decisions`); `web/app.py` מפרק דרך `envelope_unwrap` לשמירת חוזה-ה-API (X6) ללא-שינוי; טסט `test_search_domain_scope` עודכן לחוזה החדש (5/5 ✅). **החלטה הנדסית:** הדרגתי לפי-משפחה ולא big-bang — מפת-צרכנים (Explore) הראתה ש-server.py הוא pass-through, web-ui מבודד (`/api/*`), ורק 17 כלים נצרכים ישירות מ-app.py — כך הסיכון לסוכנים החיים ממוזער. נותרו ~73 כלים בפרוסות הבאות.
|
||||||
|
- **סטטוס חלקי (פרוסה 6, 2026-06-06):** 🔄 **GAP-48 — מיגרציה רוחבית.** הומרו 10 משפחות נוספות ל-envelope: `precedent_library` (14), `citations` (3), `internal_decisions` (1), `missing_precedents` (4), `training_enrichment` (2), `precedents` (4), `legal_arguments` (2), `cases` (7), `documents` (8), `workflow` (9). בוטלו 5 עותקי `_ok`/`_err` משוכפלים (alias ל-SSoT, G2). עיקרון: envelope-`status` = הצלחת-הקריאה; תוצאה-עסקית (idempotent_existing/noop/...) ב-`data`. צרכני-app.py של cases/workflow/precedents חוּוטו דרך `envelope_unwrap` + בדיקת `status=="error"`→4xx, לשמירת חוזה-ה-API. כל הטסטים עוברים (182/182; `test_corpus_constraints` עודכן לחוזה). **נותר:** משפחת `drafting` (18 כלים — מסלול הפקת-ההחלטה) בפרוסה נפרדת.
|
||||||
|
- **פרוסה 7, 2026-06-06 — ✅ GAP-48 הושלם.** משפחת `drafting` (18 כלים) הומרה ל-envelope. export_docx/revise_draft/apply_user_edit משתמשים ב-`err`-לכשל (כך שהסוכן והמשתמש רואים את הכשל ברמת-המעטפת), כש-`failed_gates` רוכב ב-`data`; 6 צרכני-app.py (get_decision_template/apply_user_edit×2/revise_draft/list_bookmarks/export_docx) חוּוטו עם בדיקת envelope-status; `test_export_qa_gate` עודכן לחוזה (182/182 עוברים). **GAP-48 סגור — כל ~12 המשפחות אחידות.**
|
||||||
|
- **פרוסה 8, 2026-06-06 — ✅ GAP-49 (החלק הקריטי).** השם המטעה `precedent_search_library` (ציטוטים מצורפים-לתיק) שונה ל-`search_case_precedents` ובכך בוטל ההיפוך המסוכן מול `search_precedent_library` (ספרייה סמכותית — מקור CREAC). הישן נשמר כ-alias deprecated (ב-server.py) → אפס שבירה לסוכנים חיים. docstrings הובהרו; עודכנו app.py (typeahead) + legal-researcher/legal-writer docs + precedent_library docstring. 5 כלי-החיפוש הנותרים מחפשים קורפוסים מובחנים בשמות סבירים — לא בוצע rename-המוני (churn גבוה, ערך נמוך). 182/182 עוברים. **⚠ אחרי merge+deploy:** סנכרון cross-company של doc-הסוכן (frontmatter `search_case_precedents`). נותר ב-FU-14: GAP-50 (מיזוג כלי-בלוק — נוגע בתהליך-הכתיבה, דורש הכרעת-יו"ר), GAP-54, GAP-47-חלק-ב.
|
||||||
|
- **פרוסה 9, 2026-06-06 — ✅ GAP-50 (הכרעת-יו"ר).** מיפוי הראה שכלי-הבלוק אינם "כפילות מיותרת": `write_block`/`write_all_blocks`/`save_block_content`/`write_interim_draft` משרתים זרימות שונות (CLI/initial-draft מול תהליך-ה-writer "התיקון בקובץ, לא ב-DB"). הכפילות האמיתית היחידה — `draft_section` (הקשר לפי-סעיף, כמעט-נטוש) חופף ל-`get_block_context` (לפי-בלוק, קנוני). הוחלט (יו"ר): **draft_section deprecated** (docstring ב-server.py+drafting.py מפנה ל-get_block_context; draft-decision.md עודכן) — בלי הסרה, בלי מיזוג כלי-הכתיבה (שמירת תהליך-הכתיבה המכוון). 182/182 עוברים. **GAP-49+50 סגורים.** נותר ב-FU-14: GAP-54 (איחוד קליטת-פסיקה), GAP-47-חלק-ב (הנחיות-יו"ר→DB).
|
||||||
|
- **פרוסה 10, 2026-06-06 — ✅ GAP-54 (נסגר כ-resolved-by-FU-1).** אימות (G2: לא לפתור מחדש): `ingest.ingest_document` הוא המסלול הקנוני; `precedent_library` ו-`internal_decisions` שניהם עוברים דרכו עם ולידציית-enums + citation-guard סימטריים (מתועד ב-01-ingest §4); training→`style_corpus` הוא קורפוס נפרד במכוון. 9/9 `test_unified_ingest` עוברים — אין קוד לכתוב. **FU-14 כמעט-מלא: נותר רק GAP-47-חלק-ב** (העברת הנחיות-יו"ר מ-`analysis-and-research.md` ל-DB) — פיצ'ר UI+זרימת-אנליסט נפרד, לא דחוף.
|
||||||
|
|
||||||
|
### FU-15 — deploy/env/secrets
|
||||||
|
- **מכסה:** GAP-55..62 · **invariants:** INV-ENV1–ENV5 · **effort:** M · **תלויות:** —
|
||||||
|
- **סוג:** code/config + **chair-decision** (rotation סודות) — env-catalog SSoT, מקור-config יחיד, de-hardcode, drift מלא, start.sh עמיד
|
||||||
|
- **סטטוס חלקי:** GAP-57 (creds plaintext, אבטחה CWE-798) **נסגר ב-web/ 2026-06-06** — 3 מופעים ב-`web/paperclip_api.py`/`paperclip_client.py`/`app.py` הומרו ל-`require_paperclip_db_url()` fail-loud. נותרו 2 מופעים בסקריפטים מקומיים (`sync_agents_across_companies.py`, `sync_missing_agent_skills.py`) + GAP-55,56,58–62 — לטיפול ב-FU-15 המלא.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## סיכום סיווג לפי סוג-עבודה
|
||||||
|
|
||||||
|
- **pure-code (ללא מיגרציה):** FU-1, FU-4, FU-6; הליבה של FU-7, FU-8.
|
||||||
|
- **דורש data-migration:** FU-2 (case_number reconciliation, ניקוי ציטוטים), FU-3
|
||||||
|
(re-chunk/re-embed), backfill קל ב-FU-7 (סנכרון בלוקים↔DOCX).
|
||||||
|
- **דורש chair-decision:** FU-5 (הגדרת gold-set), FU-8/GAP-23 (חיבור ספ לסוכנים);
|
||||||
|
GAP-07 כבר chair-confirmed (canonical = הצורה הרשמית שהוקצתה).
|
||||||
|
|
||||||
|
**רצף מומלץ (תלויות):** FU-1 → FU-2 → FU-3; FU-4 ו-FU-6 במקביל (עצמאיים, Critical);
|
||||||
|
FU-7 אחרי FU-1; FU-5 אחרי FU-2; FU-8 אחרי ייצוב-הספ. **(מחזור-1 ✅ הושלם.)**
|
||||||
|
|
||||||
|
**מחזור-2 (FU-9..15) — 8 משטחי-האפליקציה:** FU-10 (UI+design-system) ו-FU-15 (deploy/env) עצמאיים —
|
||||||
|
ניתן במקביל. FU-9 (לקוח-Paperclip) אחרי [X7](X7-paperclip-client-params.md). FU-12 (אחסון) ו-FU-14 (כלי-MCP)
|
||||||
|
אחרי FU-1. FU-11 (מילוי-שדות) עצמאי. FU-13 (סוכנים+skills) אחרי ייצוב-הספ.
|
||||||
|
**סיווג:** pure-code — FU-9/10/11/13/14; +data-migration קל — FU-12; +chair-decision — FU-15 (rotation סודות).
|
||||||
|
priority בפועל — של היו"ר.
|
||||||
72
docs/spec/ui-audit.md
Normal file
72
docs/spec/ui-audit.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# UI-Audit — ביקורת דף-אחר-דף של ה-web-ui
|
||||||
|
|
||||||
|
מסמך זה הוא **מפת-הממצאים של ה-frontend** (web-ui), מקביל ל-[gap-audit.md](gap-audit.md) אך ברמת-הדף/הרכיב.
|
||||||
|
הוא תוצר סריקה word-for-word של 13 הדפים (5 cases-flow + 5 knowledge + 3 admin) + השכבה המשותפת.
|
||||||
|
כל ממצא נושא: `invariant מופר` (מ-[X6](X6-ui-api-contract.md)/[X8](X8-field-provenance.md)) · `severity` ·
|
||||||
|
`file:line` · `תיקון`. severity = הערכה הנדסית; priority = היו"ר.
|
||||||
|
|
||||||
|
**איך הופק:** סקירת 13 הדפים + `src/lib/api/*` + `src/components/*`, מאומת מול הקוד. התיקון מקובץ ל-**FU-10**.
|
||||||
|
|
||||||
|
> **דפים שנסרקו:** dashboard, cases/new, cases/[caseNumber], cases/[caseNumber]/compose, archive ·
|
||||||
|
> precedents, precedents/[id], training, methodology, missing-precedents · settings, skills, diagnostics.
|
||||||
|
> **נווט מלא:** כל 13 הדפים נגישים מ-[app-shell.tsx](../../web-ui/src/components/app-shell.tsx) — אין דף-יתום.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. מוגדר-לא-נכון (Wrong Definitions)
|
||||||
|
|
||||||
|
| ID | כותרת | invariant | severity | file:line | תיקון |
|
||||||
|
|----|-------|-----------|----------|-----------|-------|
|
||||||
|
| UI-A1 | `PracticeArea` מוגדר ב-**3 מקומות עם ערכים שונים** — [lib/practice-area.ts:12](../../web-ui/src/lib/practice-area.ts) (`appeals_committee/national_insurance/labor_law` — שאריות מפרויקט אחר!), [lib/api/precedent-library.ts:26](../../web-ui/src/lib/api/precedent-library.ts) (`rishuy_uvniya/...`), ו-[components/precedents/practice-area.ts](../../web-ui/src/components/precedents/practice-area.ts) | UI1, G2 | **CRITICAL** | 3 קבצים | SSoT יחיד; הסרת שאריות national_insurance/labor_law |
|
||||||
|
| UI-A2 | `key_quote` חסר מטיפוס `Precedent`; גישה דרך `as {key_quote?:string}` | UI1/UI2 | **CRITICAL** | [precedents/[id]/page.tsx:178](../../web-ui/src/app/precedents/%5Bid%5D/page.tsx), [precedent-edit-sheet.tsx:94](../../web-ui/src/components/precedents/precedent-edit-sheet.tsx) | הוספת `key_quote` לטיפוס/OpenAPI |
|
||||||
|
| UI-A3 | תווית לא-עקבית לאותו ערך: "פיצויים (197)" מול "פיצויים לפי ס' 197" | UI1 | High | [precedents/[id]/page.tsx:26-35](../../web-ui/src/app/precedents/%5Bid%5D/page.tsx) מול practice-area.ts | תווית מ-SSoT יחיד |
|
||||||
|
| UI-A4 | enum נצרך לא-נגזר-מטיפוס (zod ידחה subtype חדש) | UI1 | High | [schemas/case.ts:78-86](../../web-ui/src/lib/schemas/case.ts) | zod נגזר מ-PracticeArea/AppealSubtype |
|
||||||
|
| UI-A5 | `expectedOutcomes`/`set_outcome` — אוצר-מילים לא-תואם בק (`rejected/accepted/partial` מול `rejection/.../betterment_levy`) | UI1, G2 | High | [schemas/case.ts:35-41](../../web-ui/src/lib/schemas/case.ts); בק `block_writer.py:442`/`lessons.py:11` | SSoT יחיד ל-enum-תוצאה |
|
||||||
|
|
||||||
|
## 2. כפילות (Duplication)
|
||||||
|
|
||||||
|
| ID | כותרת | invariant | severity | file:line | תיקון |
|
||||||
|
|----|-------|-----------|----------|-----------|-------|
|
||||||
|
| UI-B1 | `CaseStatus` + `STATUS_LABELS` + `STATUS_TONE` ב-3 מקומות | UI1, G2 | **CRITICAL** | [cases.ts:16-33](../../web-ui/src/lib/api/cases.ts), [status-badge.tsx:11-29,77-95](../../web-ui/src/components/cases/status-badge.tsx), [status-changer.tsx:18-24](../../web-ui/src/components/cases/status-changer.tsx) | enum+labels+tones מ-SSoT, ייבוא |
|
||||||
|
| UI-B2 | `STATUS_LABELS` של מסמכים משוכפל (ולא-שלם) | UI1 | High | [upload-sheet.tsx:39-46](../../web-ui/src/components/documents/upload-sheet.tsx), [documents-panel.tsx:39-46](../../web-ui/src/components/cases/documents-panel.tsx) | ל-`lib/doc-types.ts` |
|
||||||
|
| UI-B3 | פירמוט-תאריך משוכפל ×5 | UI/§6 | Medium | archive.tsx, case-header.tsx, documents-panel.tsx (+2) | `lib/format.ts` משותף |
|
||||||
|
| UI-B4 | תוויות practice-area/source-type משוכפלות | UI1 | High | [precedents/[id]/page.tsx:26-35](../../web-ui/src/app/precedents/%5Bid%5D/page.tsx) | ייבוא מ-practice-area.ts |
|
||||||
|
| UI-B5 | boilerplate העלאת-קבצים (FormData+fetch) ×4 | §6/G2 | Medium | documents.ts, training.ts, exports.ts, missing-precedents.ts | `uploadMultipart<T>()` ב-client.ts |
|
||||||
|
| UI-B6 | כרטיס-שגיאה משוכפל ×3 | UI4 | Medium | detail/library/missing pages | `<ErrorCard>` משותף |
|
||||||
|
|
||||||
|
## 3. מיותר / מת (Redundancy / Dead)
|
||||||
|
|
||||||
|
| ID | כותרת | invariant | severity | file:line | תיקון |
|
||||||
|
|----|-------|-----------|----------|-----------|-------|
|
||||||
|
| UI-C1 | 3 דפי-פסיקה חופפים (/precedents, /training, /missing-precedents) — גבולות מטושטשים | G2 | Medium | 3 דפים | הגדרת אחריות; שקילת איחוד |
|
||||||
|
| UI-C2 | כפתור "חלץ מטא-דאטה" שלא מרענן, מפנה ל-CLI ידני | UI5/FP5 | Medium | [precedent-edit-sheet.tsx:130](../../web-ui/src/components/precedents/precedent-edit-sheet.tsx) | auto-refresh/poll על תור-החילוץ |
|
||||||
|
| UI-C3 | `useCase` refetch כל 5ש' גם במנוחה/בעריכה | UI5 | Low | [cases.ts:150-152](../../web-ui/src/lib/api/cases.ts) | interval מותנה-סטטוס |
|
||||||
|
| UI-C4 | magic-numbers (intervals) מפוזרים ב-18 מודולים | UI5/§6 | Low | כל `lib/api/*` | `lib/api/query-config.ts` |
|
||||||
|
|
||||||
|
## 4. אי-עקביות + הפרת-כללים (Inconsistency / Rule-Violations)
|
||||||
|
|
||||||
|
| ID | כותרת | invariant | severity | file:line | תיקון |
|
||||||
|
|----|-------|-----------|----------|-----------|-------|
|
||||||
|
| UI-D1 | ~60% endpoints `unknown` → טיפוסים ידניים-סוטים | UI1/UI2 | **CRITICAL** | [cases.ts:1-9](../../web-ui/src/lib/api/cases.ts) (מתועד מפורשות) + בק | Pydantic models + `api:types` |
|
||||||
|
| UI-D2 | שדות-Opus מוצגים ללא חיווי "חולץ-אוטומטית"; היו"ר לא יודע מה לאמת | UI6/FP1 | High | [precedents/[id]/page.tsx:160-185](../../web-ui/src/app/precedents/%5Bid%5D/page.tsx) | badge "מולא-ע"י-Opus" |
|
||||||
|
| UI-D3 | אין חיווי `searchable`; הלכות `pending_review` לא מובלטות בדף-הפרט | UI6/FP3 | High | precedents/[id]/page.tsx | חיווי searchable + אזהרת-pending |
|
||||||
|
| UI-D4 | fallback SSE מסתיר כישלון כ-"completed"; TTL 5ש'↔300ש' | UI4/UI5 | Medium | [documents.ts:226-232](../../web-ui/src/lib/api/documents.ts) | terminal-state מפורש |
|
||||||
|
| UI-D5 | query-keys לא-עקביים (חלק `.all`, חלק לא; חלק exported, חלק לא) | §6 | Low | agents.ts, feedback.ts (+) | convention אחיד |
|
||||||
|
| UI-D6 | URLs קשיחים (`PAPERCLIP_BASE`, coolify, frontend) | UI3/ENV3 | Low | [app-shell.tsx:70](../../web-ui/src/components/app-shell.tsx) | env (ראה [X10](X10-deploy-env-secrets.md)) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. סיכום ל-FU-10
|
||||||
|
- **SSoT ל-enums/תוויות/tones** (UI-A1..A5, UI-B1/B2/B4) — תיקון-השורש של רוב הממצאים.
|
||||||
|
- **Pydantic models + OpenAPI=SSoT** (UI-D1) — מבטל את הטיפוסים-הידניים.
|
||||||
|
- **helpers משותפים** (UI-B3/B5/B6, UI-C4) — תאריך, upload, error-card, query-config.
|
||||||
|
- **שקיפות-מקור-מילוי** (UI-D2/D3) — נגזר מ-[X8](X8-field-provenance.md)/[X6 INV-UI6](X6-ui-api-contract.md).
|
||||||
|
- **ניקוי redundancy** (UI-C1..C3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. הפניות-אחיות
|
||||||
|
- [X6-ui-api-contract.md](X6-ui-api-contract.md) — ה-invariants (UI1–UI6) שממצאים אלו מפרים.
|
||||||
|
- [X8-field-provenance.md](X8-field-provenance.md) — מקור-מילוי (בסיס ל-UI-D2/D3).
|
||||||
|
- [gap-audit.md](gap-audit.md) — GAP-30..34 (התקבילים ברמת-הארכיטקטורה) + FU-10.
|
||||||
|
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים), [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
|
||||||
833
docs/superpowers/plans/2026-05-30-fu1-unified-ingest.md
Normal file
833
docs/superpowers/plans/2026-05-30-fu1-unified-ingest.md
Normal file
@@ -0,0 +1,833 @@
|
|||||||
|
# FU-1 Unified Ingest Path — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Collapse the two parallel ingest functions (`ingest_precedent`, `ingest_internal_decision`) into one canonical pipeline parameterized by an `IntakeSpec`, closing GAP-01/02/04/05.
|
||||||
|
|
||||||
|
**Architecture:** New module `services/ingest.py` holds a Template-Method skeleton `ingest_document(spec, ...)`; per-type variation rides on a frozen `IntakeSpec` config object (staging resolver, validate callable, enum_fields data, derive callable, display-name fallback, injected `create_record`). The two existing public functions stay as named entry points that build a spec and delegate. The DB-create functions are NOT merged (FU-2 boundary) — only routed via `spec.create_record`.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, asyncpg, pytest (offline, monkeypatched I/O), local `.venv` at `mcp-server/.venv`.
|
||||||
|
|
||||||
|
**Spec:** [docs/superpowers/specs/2026-05-30-fu1-unified-ingest-design.md](../specs/2026-05-30-fu1-unified-ingest-design.md)
|
||||||
|
|
||||||
|
**Run tests with:** `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_unified_ingest.py -v`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Create** `mcp-server/src/legal_mcp/services/ingest.py` — canonical pipeline + `IntakeSpec` + shared helpers (`_stage_file`, `_coerce_date`, `_safe_filename`, `_embed_pages`).
|
||||||
|
- **Create** `mcp-server/tests/test_unified_ingest.py` — offline behavioral tests.
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/services/precedent_library.py` — `ingest_precedent` becomes a thin wrapper building `_EXTERNAL_SPEC`; delete inline pipeline + moved helpers; keep everything else (search, reextract, process_pending, list, delete, get).
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/services/internal_decisions.py` — `ingest_internal_decision` becomes a thin wrapper building `_INTERNAL_SPEC`; delete inline pipeline + moved helpers; keep migrate_*, enrich_*, search_internal.
|
||||||
|
|
||||||
|
**Unchanged callers (verify, don't edit):** `tools/precedent_library.py`, `tools/internal_decisions.py`, `web/` HTTP handlers — they call the two public functions whose signatures are preserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Failing tests for the unified pipeline
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `mcp-server/tests/test_unified_ingest.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""FU-1: unified ingest pipeline tests (offline, all I/O monkeypatched).
|
||||||
|
|
||||||
|
Proves both intake types flow through services.ingest.ingest_document and that
|
||||||
|
the canonical pipeline is symmetric: BOTH metadata and halacha extraction are
|
||||||
|
queued for BOTH types (GAP-02 regression), enum validation applies to both
|
||||||
|
(GAP-04), multimodal is gated by flag+PDF not by intake type (GAP-05), and the
|
||||||
|
external citation guard is preserved.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from legal_mcp import config
|
||||||
|
from legal_mcp.services import db, embeddings, chunker, extractor
|
||||||
|
from legal_mcp.services import ingest, precedent_library, internal_decisions
|
||||||
|
|
||||||
|
|
||||||
|
def _run(coro):
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
class _Chunk:
|
||||||
|
def __init__(self, i):
|
||||||
|
self.chunk_index = i
|
||||||
|
self.content = f"chunk-{i}"
|
||||||
|
self.section_type = "body"
|
||||||
|
self.page_number = 1
|
||||||
|
self.role = "child"
|
||||||
|
self.local_id = f"c{i}"
|
||||||
|
self.parent_local_id = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def patched(monkeypatch, tmp_path):
|
||||||
|
"""Patch every I/O boundary. Record queue + create calls."""
|
||||||
|
calls = {"metadata": [], "halacha": [], "create": [], "chunks": [], "pages": []}
|
||||||
|
|
||||||
|
async def _extract_text(path):
|
||||||
|
return ("full decision text", 2, [0, 100])
|
||||||
|
|
||||||
|
def _strip(text):
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _chunk(text, page_offsets=None):
|
||||||
|
return [_Chunk(0), _Chunk(1)]
|
||||||
|
|
||||||
|
async def _embed(texts, input_type="document"):
|
||||||
|
return [[0.0] * 8 for _ in texts]
|
||||||
|
|
||||||
|
async def _store_chunks(cid, dicts):
|
||||||
|
calls["chunks"].append((cid, len(dicts)))
|
||||||
|
return len(dicts)
|
||||||
|
|
||||||
|
async def _create_external(**kw):
|
||||||
|
calls["create"].append(("external", kw))
|
||||||
|
return {"id": uuid4()}
|
||||||
|
|
||||||
|
async def _create_internal(**kw):
|
||||||
|
calls["create"].append(("internal", kw))
|
||||||
|
return {"id": uuid4()}
|
||||||
|
|
||||||
|
async def _req_meta(cid):
|
||||||
|
calls["metadata"].append(cid)
|
||||||
|
|
||||||
|
async def _req_hal(cid):
|
||||||
|
calls["halacha"].append(cid)
|
||||||
|
|
||||||
|
async def _set_status(cid, status):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(extractor, "extract_text", _extract_text)
|
||||||
|
monkeypatch.setattr(extractor, "strip_nevo_preamble", _strip)
|
||||||
|
monkeypatch.setattr(chunker, "chunk_document", _chunk)
|
||||||
|
monkeypatch.setattr(embeddings, "embed_texts", _embed)
|
||||||
|
monkeypatch.setattr(db, "store_precedent_chunks", _store_chunks)
|
||||||
|
monkeypatch.setattr(db, "create_external_case_law", _create_external)
|
||||||
|
monkeypatch.setattr(db, "create_internal_committee_decision", _create_internal)
|
||||||
|
monkeypatch.setattr(db, "request_metadata_extraction", _req_meta)
|
||||||
|
monkeypatch.setattr(db, "request_halacha_extraction", _req_hal)
|
||||||
|
monkeypatch.setattr(db, "set_case_law_extraction_status", _set_status)
|
||||||
|
monkeypatch.setattr(db, "set_case_law_halacha_status", _set_status)
|
||||||
|
# Force flat chunking + multimodal OFF unless a test flips it.
|
||||||
|
monkeypatch.setattr(config, "PARENT_DOC_RETRIEVAL_ENABLED", False)
|
||||||
|
monkeypatch.setattr(config, "MULTIMODAL_ENABLED", False)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pdf(tmp_path) -> str:
|
||||||
|
p = tmp_path / "decision.pdf"
|
||||||
|
p.write_bytes(b"%PDF-1.4 fake")
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_queues_BOTH_metadata_and_halacha(patched, tmp_path):
|
||||||
|
"""GAP-02 regression: the internal path must queue metadata too."""
|
||||||
|
_run(internal_decisions.ingest_internal_decision(
|
||||||
|
case_number="8046/24", text="decision text", chair_name="דפנה תמיר",
|
||||||
|
district="ירושלים", practice_area="betterment_levy",
|
||||||
|
))
|
||||||
|
assert len(patched["metadata"]) == 1, "internal path must queue metadata (GAP-02)"
|
||||||
|
assert len(patched["halacha"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_queues_both(patched, tmp_path):
|
||||||
|
_run(precedent_library.ingest_precedent(
|
||||||
|
file_path=_make_pdf(tmp_path), citation="עע\"מ 1234/20",
|
||||||
|
practice_area="rishuy_uvniya", source_type="court_ruling",
|
||||||
|
))
|
||||||
|
assert len(patched["metadata"]) == 1
|
||||||
|
assert len(patched["halacha"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_types_go_through_ingest_document(patched, tmp_path, monkeypatch):
|
||||||
|
seen = []
|
||||||
|
real = ingest.ingest_document
|
||||||
|
|
||||||
|
async def _spy(spec, **kw):
|
||||||
|
seen.append(spec.source_kind)
|
||||||
|
return await real(spec, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ingest, "ingest_document", _spy)
|
||||||
|
_run(internal_decisions.ingest_internal_decision(
|
||||||
|
case_number="8046/24", text="t", chair_name="דפנה תמיר", practice_area="betterment_levy"))
|
||||||
|
_run(precedent_library.ingest_precedent(
|
||||||
|
file_path=_make_pdf(tmp_path), citation="עע\"מ 1/20", practice_area="rishuy_uvniya"))
|
||||||
|
assert seen == ["internal_committee", "external_upload"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_validation_rejects_bad_practice_area_internal(patched, tmp_path):
|
||||||
|
"""GAP-04: internal path must validate enums like the external one."""
|
||||||
|
with pytest.raises(ValueError, match="practice_area"):
|
||||||
|
_run(internal_decisions.ingest_internal_decision(
|
||||||
|
case_number="8046/24", text="t", chair_name="x", practice_area="bogus"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_validation_rejects_bad_practice_area_external(patched, tmp_path):
|
||||||
|
with pytest.raises(ValueError, match="practice_area"):
|
||||||
|
_run(precedent_library.ingest_precedent(
|
||||||
|
file_path=_make_pdf(tmp_path), citation="עע\"מ 1/20", practice_area="bogus"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_citation_guard_still_blocks_arar(patched, tmp_path):
|
||||||
|
with pytest.raises(ValueError, match="ערר"):
|
||||||
|
_run(precedent_library.ingest_precedent(
|
||||||
|
file_path=_make_pdf(tmp_path), citation="ערר 1234/24"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_text_path_works_without_file(patched):
|
||||||
|
out = _run(internal_decisions.ingest_internal_decision(
|
||||||
|
case_number="8046/24", text="t", chair_name="x", practice_area="betterment_levy"))
|
||||||
|
assert out["status"] == "completed"
|
||||||
|
assert out["case_law_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_requires_file_or_text(patched):
|
||||||
|
with pytest.raises(ValueError, match="file_path or text"):
|
||||||
|
_run(internal_decisions.ingest_internal_decision(
|
||||||
|
case_number="8046/24", chair_name="x", practice_area="betterment_levy"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_name_fallback_uses_canonical_id(patched, tmp_path):
|
||||||
|
_run(internal_decisions.ingest_internal_decision(
|
||||||
|
case_number="8046/24", text="t", chair_name="x", practice_area="betterment_levy"))
|
||||||
|
kind, kw = patched["create"][0]
|
||||||
|
assert kw["case_name"] == "8046/24", "missing case_name falls back to canonical id"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_unified_ingest.py -v`
|
||||||
|
Expected: FAIL — `ModuleNotFoundError: No module named 'legal_mcp.services.ingest'` (or ImportError).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit the red tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/tests/test_unified_ingest.py
|
||||||
|
git commit -m "test(ingest): failing tests for unified pipeline (FU-1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Canonical module `ingest.py` — IntakeSpec + shared helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `mcp-server/src/legal_mcp/services/ingest.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the module header, IntakeSpec, and shared helpers**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Canonical ingest pipeline (FU-1).
|
||||||
|
|
||||||
|
One pipeline for all sibling-entity intake types (external precedent,
|
||||||
|
internal committee decision). Per-type variation rides on an ``IntakeSpec``
|
||||||
|
config object — never a parallel function. See
|
||||||
|
docs/spec/01-ingest.md and docs/superpowers/specs/2026-05-30-fu1-unified-ingest-design.md.
|
||||||
|
|
||||||
|
claude_session rule preserved: this module only QUEUES extraction
|
||||||
|
(``request_*_extraction`` = pure DB writes). It never imports
|
||||||
|
halacha_extractor / precedent_metadata_extractor, so it is safe to call
|
||||||
|
from the FastAPI container where the ``claude`` CLI is unavailable.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from legal_mcp import config
|
||||||
|
from legal_mcp.services import chunker, db, embeddings, extractor
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ProgressCb = Callable[[str, int, str], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
async def _noop_progress(_status: str, _percent: int, _msg: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IntakeSpec:
|
||||||
|
"""Describes everything that varies between intake types."""
|
||||||
|
source_kind: str
|
||||||
|
id_field: str
|
||||||
|
staging_root: Path
|
||||||
|
staging_subdir: Callable[[dict], str]
|
||||||
|
validate: Callable[[dict], None]
|
||||||
|
enum_fields: dict[str, frozenset[str]]
|
||||||
|
derive: Callable[[dict], dict]
|
||||||
|
display_name_fallback: str
|
||||||
|
create_record: Callable[..., Awaitable[dict]]
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_date(value) -> date | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value[:10])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(name: str) -> str:
|
||||||
|
base = Path(name).name
|
||||||
|
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"upload-{uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_file(src_path: Path, root: Path, subdir: str) -> Path:
|
||||||
|
dest_dir = root / (subdir or "other")
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = dest_dir / f"{uuid4().hex[:8]}_{_safe_filename(src_path.name)}"
|
||||||
|
shutil.copy2(src_path, dest)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_enums(spec: IntakeSpec, inputs: dict) -> None:
|
||||||
|
for field_name, allowed in spec.enum_fields.items():
|
||||||
|
value = inputs.get(field_name, "") or ""
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(f"invalid {field_name}: {value!r}")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the multimodal page-embed helper (moved verbatim from precedent_library.py)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _embed_pages(case_law_id: UUID, pdf_path: Path, page_count: int) -> dict:
|
||||||
|
"""Render PDF pages → embed via voyage-multimodal → store. Non-fatal caller."""
|
||||||
|
thumb_dir = spec_thumb_dir(case_law_id)
|
||||||
|
rendered = await asyncio.to_thread(
|
||||||
|
extractor.render_pages_for_multimodal,
|
||||||
|
pdf_path, config.MULTIMODAL_DPI, config.MULTIMODAL_THUMB_DPI, thumb_dir,
|
||||||
|
)
|
||||||
|
images = [pil for pil, _ in rendered]
|
||||||
|
thumbs = [t for _, t in rendered]
|
||||||
|
img_embs = await embeddings.embed_images(images)
|
||||||
|
|
||||||
|
page_records = []
|
||||||
|
for i, (emb, thumb) in enumerate(zip(img_embs, thumbs)):
|
||||||
|
rel_thumb = None
|
||||||
|
if thumb is not None:
|
||||||
|
try:
|
||||||
|
rel_thumb = str(thumb.relative_to(config.DATA_DIR))
|
||||||
|
except ValueError:
|
||||||
|
rel_thumb = str(thumb)
|
||||||
|
page_records.append({
|
||||||
|
"page_number": i + 1, "embedding": emb, "image_thumbnail_path": rel_thumb,
|
||||||
|
})
|
||||||
|
stored = await db.store_precedent_image_embeddings(
|
||||||
|
case_law_id, page_records, model_name=config.MULTIMODAL_MODEL,
|
||||||
|
)
|
||||||
|
logger.info("Multimodal: stored %d page-image embeddings for case_law %s", stored, case_law_id)
|
||||||
|
return {"pages_embedded": stored}
|
||||||
|
|
||||||
|
|
||||||
|
def spec_thumb_dir(case_law_id: UUID) -> Path:
|
||||||
|
"""Thumbnails live under the precedent-library tree regardless of intake type."""
|
||||||
|
return Path(config.DATA_DIR) / "precedent-library" / "thumbnails" / str(case_law_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the module imports cleanly**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.services import ingest; print(ingest.IntakeSpec.__name__)"`
|
||||||
|
Expected: prints `IntakeSpec`, no error.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/services/ingest.py
|
||||||
|
git commit -m "feat(ingest): IntakeSpec + shared helpers for canonical pipeline (FU-1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Canonical `ingest_document`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mcp-server/src/legal_mcp/services/ingest.py` (append `ingest_document`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append the canonical pipeline function**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def ingest_document(
|
||||||
|
spec: IntakeSpec,
|
||||||
|
*,
|
||||||
|
inputs: dict,
|
||||||
|
file_path: str | Path | None = None,
|
||||||
|
text: str | None = None,
|
||||||
|
document_id: UUID | None = None,
|
||||||
|
progress: ProgressCb | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Run the canonical 12-step pipeline for one intake item.
|
||||||
|
|
||||||
|
``inputs`` carries the type-specific record fields (citation/case_number,
|
||||||
|
case_name, court, practice_area, etc.). ``spec`` decides how they are
|
||||||
|
validated, staged, derived, and which DB-create runs. Returns a dict with
|
||||||
|
at least: status, case_law_id, chunks.
|
||||||
|
"""
|
||||||
|
progress = progress or _noop_progress
|
||||||
|
|
||||||
|
# Step 1: input validation (type-specific) + enums (uniform mechanism).
|
||||||
|
if not file_path and text is None:
|
||||||
|
raise ValueError("either file_path or text is required")
|
||||||
|
spec.validate(inputs)
|
||||||
|
_validate_enums(spec, inputs)
|
||||||
|
|
||||||
|
# Step 2: field derivation (identity for external).
|
||||||
|
inputs = {**inputs, **spec.derive(inputs)}
|
||||||
|
|
||||||
|
# Steps 3-5: stage (if file) + extract + strip.
|
||||||
|
page_count = 0
|
||||||
|
page_offsets = None
|
||||||
|
staged: Path | None = None
|
||||||
|
if file_path:
|
||||||
|
src = Path(file_path)
|
||||||
|
if not src.is_file():
|
||||||
|
raise FileNotFoundError(f"file not found: {src}")
|
||||||
|
await progress("staging", 5, "מעתיק את הקובץ לאחסון")
|
||||||
|
staged = _stage_file(src, spec.staging_root, spec.staging_subdir(inputs))
|
||||||
|
await progress("extracting", 15, "מחלץ טקסט מהקובץ")
|
||||||
|
try:
|
||||||
|
raw_text, page_count, page_offsets = await extractor.extract_text(str(staged))
|
||||||
|
except Exception as e:
|
||||||
|
await progress("failed", 100, f"כשל בחילוץ טקסט: {e}")
|
||||||
|
raise
|
||||||
|
raw_text = extractor.strip_nevo_preamble((raw_text or "")).strip()
|
||||||
|
else:
|
||||||
|
raw_text = (text or "").strip()
|
||||||
|
if not raw_text:
|
||||||
|
await progress("failed", 100, "לא נמצא טקסט בקובץ")
|
||||||
|
raise ValueError("no extractable text in file")
|
||||||
|
|
||||||
|
# Step 6: DB create (type-specific, routed — get case_law_id).
|
||||||
|
await progress("storing_metadata", 25, "שומר את הרשומה במסד הנתונים")
|
||||||
|
display_name = (inputs.get("case_name") or "").strip() or (
|
||||||
|
inputs.get(spec.display_name_fallback) or ""
|
||||||
|
).strip()
|
||||||
|
record = await spec.create_record(
|
||||||
|
full_text=raw_text,
|
||||||
|
case_name=display_name,
|
||||||
|
decision_date=_coerce_date(inputs.get("decision_date")),
|
||||||
|
document_id=document_id,
|
||||||
|
**{k: v for k, v in inputs.items()
|
||||||
|
if k not in {"case_name", "decision_date", "file_path", "text"}},
|
||||||
|
)
|
||||||
|
case_law_id = UUID(str(record["id"]))
|
||||||
|
|
||||||
|
try:
|
||||||
|
stored_chunks = await _chunk_embed_store(case_law_id, raw_text, page_offsets, page_count, progress)
|
||||||
|
|
||||||
|
# Step 9: multimodal — uniform: flag + PDF + page_count, NOT intake type.
|
||||||
|
if (config.MULTIMODAL_ENABLED and page_count > 0
|
||||||
|
and staged is not None and staged.suffix.lower() == ".pdf"):
|
||||||
|
try:
|
||||||
|
await progress("embedding_images", 70, f"מטמיע {page_count} עמודי תמונה (multimodal)")
|
||||||
|
await _embed_pages(case_law_id, staged, page_count)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Multimodal embedding failed (non-fatal): %s", e)
|
||||||
|
|
||||||
|
# Steps 10-12: queue BOTH extractions (GAP-02 fix) + statuses.
|
||||||
|
await db.set_case_law_extraction_status(case_law_id, "completed")
|
||||||
|
await db.set_case_law_halacha_status(case_law_id, "pending")
|
||||||
|
await db.request_metadata_extraction(case_law_id)
|
||||||
|
await db.request_halacha_extraction(case_law_id)
|
||||||
|
|
||||||
|
await progress("completed", 100,
|
||||||
|
f"נקלט: {stored_chunks} chunks. חילוץ הלכות ומטא-דאטה ממתינים בתור.")
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"case_law_id": str(case_law_id),
|
||||||
|
"chunks": stored_chunks,
|
||||||
|
"halachot": 0,
|
||||||
|
"halachot_pending": True,
|
||||||
|
"metadata_filled": [],
|
||||||
|
"pages": page_count,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("ingest_document failed (%s): %s", spec.source_kind, e)
|
||||||
|
await db.set_case_law_extraction_status(case_law_id, "failed")
|
||||||
|
await progress("failed", 100, f"כשל בעיבוד: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _chunk_embed_store(case_law_id, text, page_offsets, page_count, progress) -> int:
|
||||||
|
"""Steps 7-8: chunk (hierarchical/flat by flag) → embed children → store."""
|
||||||
|
if config.PARENT_DOC_RETRIEVAL_ENABLED:
|
||||||
|
await progress("chunking", 40, f"מחלק את הטקסט ל-chunks היררכיים ({page_count} עמ')")
|
||||||
|
h_chunks = chunker.chunk_document_hierarchical(text, page_offsets=page_offsets)
|
||||||
|
if not h_chunks:
|
||||||
|
return 0
|
||||||
|
children = [c for c in h_chunks if c.role == "child"]
|
||||||
|
parents = [c for c in h_chunks if c.role == "parent"]
|
||||||
|
await progress("embedding", 55, f"מייצר embeddings ל-{len(children)} children ({len(parents)} parents)")
|
||||||
|
child_vectors = await embeddings.embed_texts([c.content for c in children], input_type="document")
|
||||||
|
chunk_dicts: list[dict] = []
|
||||||
|
for p in parents:
|
||||||
|
chunk_dicts.append({
|
||||||
|
"role": "parent", "local_id": p.local_id, "parent_local_id": None,
|
||||||
|
"chunk_index": p.chunk_index, "content": p.content,
|
||||||
|
"section_type": p.section_type, "page_number": p.page_number, "embedding": None,
|
||||||
|
})
|
||||||
|
for c, v in zip(children, child_vectors):
|
||||||
|
chunk_dicts.append({
|
||||||
|
"role": "child", "local_id": c.local_id, "parent_local_id": c.parent_local_id,
|
||||||
|
"chunk_index": c.chunk_index, "content": c.content,
|
||||||
|
"section_type": c.section_type, "page_number": c.page_number, "embedding": v,
|
||||||
|
})
|
||||||
|
counts = await db.store_precedent_chunks_hierarchical(case_law_id, chunk_dicts)
|
||||||
|
return counts["children"]
|
||||||
|
else:
|
||||||
|
await progress("chunking", 40, f"מחלק את הטקסט ל-chunks ({page_count} עמ')")
|
||||||
|
chunks = chunker.chunk_document(text, page_offsets=page_offsets)
|
||||||
|
if not chunks:
|
||||||
|
return 0
|
||||||
|
await progress("embedding", 55, f"מייצר embeddings ל-{len(chunks)} chunks")
|
||||||
|
chunk_vectors = await embeddings.embed_texts([c.content for c in chunks], input_type="document")
|
||||||
|
chunk_dicts = [
|
||||||
|
{"chunk_index": c.chunk_index, "content": c.content,
|
||||||
|
"section_type": c.section_type, "page_number": c.page_number, "embedding": v}
|
||||||
|
for c, v in zip(chunks, chunk_vectors)
|
||||||
|
]
|
||||||
|
return await db.store_precedent_chunks(case_law_id, chunk_dicts)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify import**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.services import ingest; print(ingest.ingest_document.__name__)"`
|
||||||
|
Expected: prints `ingest_document`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/services/ingest.py
|
||||||
|
git commit -m "feat(ingest): canonical ingest_document pipeline (FU-1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note on `create_record` kwargs:** the wrappers (Tasks 4-5) build `inputs` so the
|
||||||
|
> leftover keys after popping `case_name`/`decision_date`/`file_path`/`text` exactly match
|
||||||
|
> each DB-create's remaining parameters. Verify against the signatures:
|
||||||
|
> `create_external_case_law(case_number, full_text, court, practice_area, appeal_subtype, subject_tags, summary, headnote, source_type, precedent_level, is_binding, ...)`
|
||||||
|
> and `create_internal_committee_decision(case_number, full_text, court, chair_name, district, practice_area, appeal_subtype, subject_tags, summary, is_binding, proceeding_type, ...)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: External spec + rewrite `ingest_precedent` as wrapper
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mcp-server/src/legal_mcp/services/precedent_library.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the top-of-file ingest section with a spec + wrapper**
|
||||||
|
|
||||||
|
Replace the body of `ingest_precedent` (lines ~88-317) and remove `_stage_file`, `_coerce_date`,
|
||||||
|
`_safe_filename`, `_embed_precedent_pages`, and the `_VALID_*` constants used only by ingest.
|
||||||
|
Keep `_VALID_PRACTICE_AREAS`/`_VALID_SOURCE_TYPES` values but move them into the spec. Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from legal_mcp.services import ingest
|
||||||
|
|
||||||
|
PRECEDENT_LIBRARY_DIR = Path(config.DATA_DIR) / "precedent-library"
|
||||||
|
|
||||||
|
_VALID_PRACTICE_AREAS = frozenset({"", "rishuy_uvniya", "betterment_levy", "compensation_197"})
|
||||||
|
_VALID_SOURCE_TYPES = frozenset({"", "court_ruling", "appeals_committee"})
|
||||||
|
|
||||||
|
|
||||||
|
def _external_validate(inputs: dict) -> None:
|
||||||
|
citation = (inputs.get("citation") or "").strip()
|
||||||
|
if not citation:
|
||||||
|
raise ValueError("citation is required")
|
||||||
|
if citation.startswith(("ערר ", "ערר(", 'בל"מ ', 'בל"מ(', "ARAR ")):
|
||||||
|
raise ValueError(
|
||||||
|
"ציטוט שמתחיל ב-'ערר' או 'בל\"מ' הוא החלטת ועדת ערר. "
|
||||||
|
"השתמש ב-internal_decision_upload (דורש chair_name + district), "
|
||||||
|
"לא ב-precedent_library_upload."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _external_staging_subdir(inputs: dict) -> str:
|
||||||
|
st = inputs.get("source_type") or ""
|
||||||
|
return st if st in {"court_ruling", "appeals_committee"} else "other"
|
||||||
|
|
||||||
|
|
||||||
|
_EXTERNAL_SPEC = ingest.IntakeSpec(
|
||||||
|
source_kind="external_upload",
|
||||||
|
id_field="citation",
|
||||||
|
staging_root=PRECEDENT_LIBRARY_DIR,
|
||||||
|
staging_subdir=_external_staging_subdir,
|
||||||
|
validate=_external_validate,
|
||||||
|
enum_fields={"practice_area": _VALID_PRACTICE_AREAS, "source_type": _VALID_SOURCE_TYPES},
|
||||||
|
derive=lambda inputs: {},
|
||||||
|
display_name_fallback="citation",
|
||||||
|
create_record=_create_external_record,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_external_record(**kw) -> dict:
|
||||||
|
"""Adapter: maps canonical inputs (citation) to create_external_case_law(case_number)."""
|
||||||
|
return await db.create_external_case_law(
|
||||||
|
case_number=kw["citation"].strip(),
|
||||||
|
case_name=kw["case_name"],
|
||||||
|
full_text=kw["full_text"],
|
||||||
|
court=(kw.get("court") or "").strip(),
|
||||||
|
decision_date=kw.get("decision_date"),
|
||||||
|
practice_area=kw.get("practice_area", ""),
|
||||||
|
appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
|
||||||
|
subject_tags=list(kw.get("subject_tags") or []),
|
||||||
|
summary=(kw.get("summary") or "").strip(),
|
||||||
|
headnote=(kw.get("headnote") or "").strip(),
|
||||||
|
source_type=kw.get("source_type", ""),
|
||||||
|
precedent_level=kw.get("precedent_level", ""),
|
||||||
|
is_binding=kw.get("is_binding", True),
|
||||||
|
document_id=kw.get("document_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_precedent(
|
||||||
|
*,
|
||||||
|
file_path: str | Path,
|
||||||
|
citation: str,
|
||||||
|
case_name: str = "",
|
||||||
|
court: str = "",
|
||||||
|
decision_date=None,
|
||||||
|
source_type: str = "",
|
||||||
|
precedent_level: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
is_binding: bool = True,
|
||||||
|
headnote: str = "",
|
||||||
|
summary: str = "",
|
||||||
|
document_id: UUID | None = None,
|
||||||
|
progress: ingest.ProgressCb | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Ingest one external precedent. Thin wrapper over the canonical pipeline."""
|
||||||
|
inputs = {
|
||||||
|
"citation": citation, "case_name": case_name, "court": court,
|
||||||
|
"decision_date": decision_date, "source_type": source_type,
|
||||||
|
"precedent_level": precedent_level, "practice_area": practice_area,
|
||||||
|
"appeal_subtype": appeal_subtype, "subject_tags": subject_tags,
|
||||||
|
"is_binding": is_binding, "headnote": headnote, "summary": summary,
|
||||||
|
}
|
||||||
|
return await ingest.ingest_document(
|
||||||
|
_EXTERNAL_SPEC, inputs=inputs, file_path=file_path,
|
||||||
|
document_id=document_id, progress=progress,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
> Define `_create_external_record` ABOVE `_EXTERNAL_SPEC` (Python resolves the name at
|
||||||
|
> dataclass-construction time). Reorder if needed.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run external-path tests**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_unified_ingest.py -k "external" -v`
|
||||||
|
Expected: `test_external_queues_both`, `test_enum_validation_rejects_bad_practice_area_external`,
|
||||||
|
`test_external_citation_guard_still_blocks_arar` PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/services/precedent_library.py
|
||||||
|
git commit -m "refactor(ingest): ingest_precedent delegates to canonical pipeline (FU-1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Internal spec + rewrite `ingest_internal_decision` as wrapper
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mcp-server/src/legal_mcp/services/internal_decisions.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the ingest section with a spec + wrapper**
|
||||||
|
|
||||||
|
Remove `_coerce_date`, `_safe_filename`, and the inline pipeline body of
|
||||||
|
`ingest_internal_decision` (lines ~73-220). Keep `_VALID_DISTRICTS`, `_COURT_TO_DISTRICT`,
|
||||||
|
`_district_from_court`, and all migrate_*/enrich_*/search_internal functions. Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from legal_mcp.services import ingest
|
||||||
|
|
||||||
|
INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions"
|
||||||
|
|
||||||
|
_VALID_PRACTICE_AREAS = frozenset({"", "rishuy_uvniya", "betterment_levy", "compensation_197"})
|
||||||
|
_VALID_DISTRICTS = frozenset({"", "ירושלים", "מרכז", "תל אביב", "צפון", "דרום", "ארצי"})
|
||||||
|
|
||||||
|
|
||||||
|
def _internal_validate(inputs: dict) -> None:
|
||||||
|
if not (inputs.get("case_number") or "").strip():
|
||||||
|
raise ValueError("case_number is required")
|
||||||
|
|
||||||
|
|
||||||
|
def _internal_derive(inputs: dict) -> dict:
|
||||||
|
district = (inputs.get("district") or "").strip() or _district_from_court(inputs.get("court") or "")
|
||||||
|
proc = (inputs.get("proceeding_type") or "").strip() or derive_proceeding_type(
|
||||||
|
appeal_subtype=inputs.get("appeal_subtype") or "", subject=inputs.get("case_name") or "",
|
||||||
|
)
|
||||||
|
return {"district": district, "proceeding_type": proc}
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_internal_record(**kw) -> dict:
|
||||||
|
return await db.create_internal_committee_decision(
|
||||||
|
case_number=kw["case_number"].strip(),
|
||||||
|
case_name=kw["case_name"],
|
||||||
|
full_text=kw["full_text"],
|
||||||
|
court=(kw.get("court") or "").strip(),
|
||||||
|
decision_date=kw.get("decision_date"),
|
||||||
|
chair_name=(kw.get("chair_name") or "").strip(),
|
||||||
|
district=kw.get("district", ""),
|
||||||
|
practice_area=kw.get("practice_area", ""),
|
||||||
|
appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
|
||||||
|
subject_tags=list(kw.get("subject_tags") or []),
|
||||||
|
summary=(kw.get("summary") or "").strip(),
|
||||||
|
is_binding=kw.get("is_binding", True),
|
||||||
|
document_id=kw.get("document_id"),
|
||||||
|
proceeding_type=kw.get("proceeding_type") or "ערר",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_INTERNAL_SPEC = ingest.IntakeSpec(
|
||||||
|
source_kind="internal_committee",
|
||||||
|
id_field="case_number",
|
||||||
|
staging_root=INTERNAL_DECISIONS_DIR,
|
||||||
|
staging_subdir=lambda inputs: (inputs.get("district") or "other"),
|
||||||
|
validate=_internal_validate,
|
||||||
|
enum_fields={"practice_area": _VALID_PRACTICE_AREAS, "district": _VALID_DISTRICTS},
|
||||||
|
derive=_internal_derive,
|
||||||
|
display_name_fallback="case_number",
|
||||||
|
create_record=_create_internal_record,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_internal_decision(
|
||||||
|
*,
|
||||||
|
case_number: str,
|
||||||
|
case_name: str = "",
|
||||||
|
court: str = "",
|
||||||
|
decision_date=None,
|
||||||
|
chair_name: str = "",
|
||||||
|
district: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
summary: str = "",
|
||||||
|
is_binding: bool = True,
|
||||||
|
file_path: str | Path | None = None,
|
||||||
|
text: str | None = None,
|
||||||
|
document_id: UUID | None = None,
|
||||||
|
queue_halachot: bool = True, # retained for signature compat; pipeline always queues
|
||||||
|
proceeding_type: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Ingest one appeals-committee decision. Thin wrapper over the canonical pipeline."""
|
||||||
|
inputs = {
|
||||||
|
"case_number": case_number, "case_name": case_name, "court": court,
|
||||||
|
"decision_date": decision_date, "chair_name": chair_name, "district": district,
|
||||||
|
"practice_area": practice_area, "appeal_subtype": appeal_subtype,
|
||||||
|
"subject_tags": subject_tags, "summary": summary, "is_binding": is_binding,
|
||||||
|
"proceeding_type": proceeding_type,
|
||||||
|
}
|
||||||
|
out = await ingest.ingest_document(
|
||||||
|
_INTERNAL_SPEC, inputs=inputs, file_path=file_path, text=text,
|
||||||
|
document_id=document_id,
|
||||||
|
)
|
||||||
|
return {"status": out["status"], "case_law_id": out["case_law_id"],
|
||||||
|
"chunks": out["chunks"], "halachot_pending": True}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `queue_halachot=False` was only used by `migrate_from_style_corpus`. The canonical pipeline
|
||||||
|
> always queues both (per INV-ING3). Confirm with the user during execution that bulk
|
||||||
|
> re-migration queueing is acceptable; the migrate path is out of FU-1 scope but calls this
|
||||||
|
> wrapper. If suppression is still required, that is a follow-up — note it, do not silently drop.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the full test file**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_unified_ingest.py -v`
|
||||||
|
Expected: ALL 9 tests PASS — including `test_internal_queues_BOTH_metadata_and_halacha` (GAP-02).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/services/internal_decisions.py
|
||||||
|
git commit -m "refactor(ingest): ingest_internal_decision delegates to canonical pipeline; queue metadata too (GAP-02, FU-1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Dead-code sweep, smoke import, full suite
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify: `mcp-server/src/legal_mcp/services/precedent_library.py`, `internal_decisions.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm no orphaned references to removed helpers**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && grep -rn "_embed_precedent_pages\|_stage_file\|_safe_filename\|_coerce_date" src/legal_mcp/services/precedent_library.py src/legal_mcp/services/internal_decisions.py`
|
||||||
|
Expected: NO matches (all moved to `ingest.py`). If any remain in code paths other than ingest, leave them; if orphaned, delete.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Smoke-import every affected module + its callers**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai/mcp-server && .venv/bin/python -c "
|
||||||
|
from legal_mcp.services import ingest, precedent_library, internal_decisions
|
||||||
|
from legal_mcp.tools import precedent_library as t1, internal_decisions as t2
|
||||||
|
import inspect
|
||||||
|
sig_p = inspect.signature(precedent_library.ingest_precedent)
|
||||||
|
sig_i = inspect.signature(internal_decisions.ingest_internal_decision)
|
||||||
|
assert 'citation' in sig_p.parameters and 'file_path' in sig_p.parameters
|
||||||
|
assert 'case_number' in sig_i.parameters and 'text' in sig_i.parameters
|
||||||
|
print('signatures preserved; imports clean')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: prints `signatures preserved; imports clean`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the entire test suite (no regressions elsewhere)**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/ -q`
|
||||||
|
Expected: all pre-existing tests still pass + the 9 new ones.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Lint the changed files (match repo style)**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m ruff check src/legal_mcp/services/ingest.py src/legal_mcp/services/precedent_library.py src/legal_mcp/services/internal_decisions.py 2>/dev/null || echo "ruff not configured — skip"`
|
||||||
|
Expected: clean, or "skip".
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update TaskMaster #59 → done**
|
||||||
|
|
||||||
|
Mark subtasks 59.1-59.4 and task 59 as done via task-master (verify via MCP get_task).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add -A mcp-server/
|
||||||
|
git commit -m "chore(ingest): dead-code sweep + smoke checks for unified pipeline (FU-1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **GAP-01** (single path) → Tasks 2-5. **GAP-02** (metadata queue) → Task 3 step 1 + test `test_internal_queues_BOTH_metadata_and_halacha`. **GAP-04** (enum validation) → `_validate_enums` + tests. **GAP-05** (staging/derive/multimodal/fallback/guard unified) → Task 3 + specs in Tasks 4-5.
|
||||||
|
- **Boundary preserved:** DB-create functions untouched (routed via `create_record`); no migration.
|
||||||
|
- **Open execution check:** `queue_halachot=False` suppression in `migrate_from_style_corpus` (Task 5 note) — surface to user, do not silently change bulk-migration behavior.
|
||||||
|
- **claude_session rule:** `ingest.py` imports only db/chunker/embeddings/extractor — no LLM extractors. Safe for container.
|
||||||
613
docs/superpowers/plans/2026-05-30-fu2a-idempotent-ingest.md
Normal file
613
docs/superpowers/plans/2026-05-30-fu2a-idempotent-ingest.md
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
# FU-2a: Idempotent Ingest + Write-Time Normalization + `searchable` Flag — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make ingest idempotent (`ON CONFLICT` upsert), normalize identifiers at the write boundary (type-aware), and add a materialized `searchable` flag — all forward-only, no identifier migration.
|
||||||
|
|
||||||
|
**Architecture:** Pure-code + one schema-additive migration (V21) in `db.py`. The two `create_*_case_law` functions move from app-level SELECT-then-INSERT/UPDATE to atomic `INSERT … ON CONFLICT … DO UPDATE` against the existing V15 partial unique indexes (predicate repeated). A new `_canonical_case_number` normalizes at write for identifier-keyed corpora (internal/cases), not for external (citation is its id). A new `searchable` boolean is recomputed from the completeness contract on ingest/metadata completion; the search-layer filter is gated behind a dry-run.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, asyncpg, PostgreSQL (pgvector) at localhost:5433, pytest offline, local `.venv` at `mcp-server/.venv`.
|
||||||
|
|
||||||
|
**Spec:** [docs/superpowers/specs/2026-05-30-fu2a-idempotent-ingest-design.md](../specs/2026-05-30-fu2a-idempotent-ingest-design.md)
|
||||||
|
|
||||||
|
**Run tests:** `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_idempotent_ingest.py -v`
|
||||||
|
**DB smoke (real Postgres):** source `~/.env`, connect to `localhost:5433` db `legal_ai` (see Task 6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/services/db.py`:
|
||||||
|
- add `_canonical_case_number(s)` (pure) near `_normalize_case_number` (~line 1196).
|
||||||
|
- add pure `_compute_searchable(row, has_embedded_chunk)` + async `recompute_searchable(...)`.
|
||||||
|
- add `SCHEMA_V21_SQL` (after V20, ~line 1094) + wire into `_run_schema_migrations` (~line 1119).
|
||||||
|
- normalize at write in `create_case`, `create_internal_committee_decision` (NOT `create_external_case_law`).
|
||||||
|
- convert `create_external_case_law` + `create_internal_committee_decision` to `ON CONFLICT … DO UPDATE`.
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/services/ingest.py`: call `db.recompute_searchable(case_law_id)` after statuses are set (uniform, both types).
|
||||||
|
- **Modify** the search layer (`services/hybrid_search.py` and/or `db.py` search functions) — gated `searchable = true` filter (Task 6, only if dry-run is clean).
|
||||||
|
- **Create** `mcp-server/tests/test_idempotent_ingest.py` — offline tests for the pure pieces + ingest wiring.
|
||||||
|
|
||||||
|
**Unchanged:** public signatures of `ingest_precedent`/`ingest_internal_decision` (FU-1) and the DB-create parameter lists. Normalization/upsert live inside the write boundary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Failing tests (pure logic + ingest wiring)
|
||||||
|
|
||||||
|
**Files:** Create `mcp-server/tests/test_idempotent_ingest.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""FU-2a: idempotent ingest + write-time normalization + searchable flag.
|
||||||
|
|
||||||
|
Offline tests for the *pure* pieces (canonical normalization, completeness
|
||||||
|
predicate) and ingest wiring. The real ON CONFLICT upsert is verified by a
|
||||||
|
DB smoke test against localhost:5433 (see plan Task 6), since it requires a
|
||||||
|
live Postgres partial unique index.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from legal_mcp.services import db, ingest
|
||||||
|
|
||||||
|
|
||||||
|
def _run(coro):
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
# ── GAP-06: canonical normalization (pure, deterministic) ──────────────
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
("ערר 8137/24", "8137-24"),
|
||||||
|
(" עע\"מ 1/20 ", "1-20"),
|
||||||
|
("8126-03-25", "8126-03-25"), # month segment preserved
|
||||||
|
("בל\"מ 1010-01-25", "1010-01-25"),
|
||||||
|
("8047/23", "8047-23"),
|
||||||
|
])
|
||||||
|
def test_canonical_case_number(raw, expected):
|
||||||
|
assert db._canonical_case_number(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_does_not_invent_month():
|
||||||
|
# No month in input → none added (X1 §1).
|
||||||
|
assert db._canonical_case_number("8126/24") == "8126-24"
|
||||||
|
|
||||||
|
|
||||||
|
# ── GAP-13: completeness predicate (pure) ──────────────────────────────
|
||||||
|
def _complete_row():
|
||||||
|
return {
|
||||||
|
"case_number": "8047-23", "case_name": "פלוני נ' הוועדה",
|
||||||
|
"practice_area": "rishuy_uvniya", "source_kind": "internal_committee",
|
||||||
|
"extraction_status": "completed", "headnote": "תקציר",
|
||||||
|
"summary": "", "subject_tags": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_searchable_true_when_complete():
|
||||||
|
assert db._compute_searchable(_complete_row(), has_embedded_chunk=True) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_searchable_false_without_embedded_chunk():
|
||||||
|
assert db._compute_searchable(_complete_row(), has_embedded_chunk=False) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_searchable_false_without_metadata():
|
||||||
|
row = _complete_row()
|
||||||
|
row["headnote"] = ""; row["summary"] = ""; row["subject_tags"] = []
|
||||||
|
assert db._compute_searchable(row, has_embedded_chunk=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_searchable_false_when_extraction_incomplete():
|
||||||
|
row = _complete_row(); row["extraction_status"] = "pending"
|
||||||
|
assert db._compute_searchable(row, has_embedded_chunk=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_searchable_false_without_core_fields():
|
||||||
|
row = _complete_row(); row["practice_area"] = ""
|
||||||
|
assert db._compute_searchable(row, has_embedded_chunk=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── ingest wires in recompute_searchable (both types) ──────────────────
|
||||||
|
def test_ingest_calls_recompute_searchable(monkeypatch, tmp_path):
|
||||||
|
calls = {"recompute": [], "meta": [], "hal": []}
|
||||||
|
|
||||||
|
async def _extract_text(path): return ("text", 1, [0])
|
||||||
|
monkeypatch.setattr(ingest.extractor, "extract_text", _extract_text)
|
||||||
|
monkeypatch.setattr(ingest.extractor, "strip_nevo_preamble", lambda t: t)
|
||||||
|
monkeypatch.setattr(ingest.chunker, "chunk_document",
|
||||||
|
lambda t, page_offsets=None: [type("C", (), {
|
||||||
|
"chunk_index": 0, "content": "c", "section_type": "b",
|
||||||
|
"page_number": 1})()])
|
||||||
|
|
||||||
|
async def _embed(texts, input_type="document"): return [[0.0] * 8 for _ in texts]
|
||||||
|
monkeypatch.setattr(ingest.embeddings, "embed_texts", _embed)
|
||||||
|
|
||||||
|
async def _store(cid, dicts): return len(dicts)
|
||||||
|
monkeypatch.setattr(ingest.db, "store_precedent_chunks", _store)
|
||||||
|
|
||||||
|
async def _create_internal(**kw): return {"id": uuid4()}
|
||||||
|
monkeypatch.setattr(ingest.db, "create_internal_committee_decision", _create_internal)
|
||||||
|
|
||||||
|
async def _noop(*a, **k): return None
|
||||||
|
monkeypatch.setattr(ingest.db, "set_case_law_extraction_status", _noop)
|
||||||
|
monkeypatch.setattr(ingest.db, "set_case_law_halacha_status", _noop)
|
||||||
|
monkeypatch.setattr(ingest.db, "request_metadata_extraction",
|
||||||
|
lambda cid: calls["meta"].append(cid) or _noop())
|
||||||
|
monkeypatch.setattr(ingest.db, "request_halacha_extraction",
|
||||||
|
lambda cid: calls["hal"].append(cid) or _noop())
|
||||||
|
|
||||||
|
async def _recompute(cid): calls["recompute"].append(cid)
|
||||||
|
monkeypatch.setattr(ingest.db, "recompute_searchable", _recompute)
|
||||||
|
monkeypatch.setattr(ingest.config, "PARENT_DOC_RETRIEVAL_ENABLED", False)
|
||||||
|
monkeypatch.setattr(ingest.config, "MULTIMODAL_ENABLED", False)
|
||||||
|
|
||||||
|
from legal_mcp.services import internal_decisions
|
||||||
|
_run(internal_decisions.ingest_internal_decision(
|
||||||
|
case_number="8047/23", text="t", chair_name="x", practice_area="rishuy_uvniya"))
|
||||||
|
assert len(calls["recompute"]) == 1, "ingest must recompute searchable after success"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify failure**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_idempotent_ingest.py -v`
|
||||||
|
Expected: FAIL — `AttributeError: module 'legal_mcp.services.db' has no attribute '_canonical_case_number'` (and `_compute_searchable`, `recompute_searchable`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/tests/test_idempotent_ingest.py
|
||||||
|
git commit -m "test(ingest): failing tests for idempotent ingest + searchable (FU-2a)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `_canonical_case_number` + write-time normalization
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/services/db.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `_canonical_case_number` next to `_normalize_case_number` (~line 1212)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _canonical_case_number(s: str) -> str:
|
||||||
|
"""Canonical write-time form per X1 §1: trim · prefix-strip · '/'→'-'.
|
||||||
|
|
||||||
|
Deterministic and format-only — does NOT add or remove a month segment.
|
||||||
|
Used at the write boundary for identifier-keyed corpora (internal
|
||||||
|
committee decisions, active cases). NOT for external precedents, whose
|
||||||
|
canonical identifier is the full citation.
|
||||||
|
"""
|
||||||
|
s = (s or "").strip()
|
||||||
|
m = re.search(r"\d", s)
|
||||||
|
if m:
|
||||||
|
s = s[m.start():]
|
||||||
|
return s.strip().replace("/", "-")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Normalize at write in `create_case` (~line 1158)**
|
||||||
|
|
||||||
|
Change the INSERT's `case_number` binding to normalized form. Replace `case_id, case_number, title,` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
case_id, _canonical_case_number(case_number), title,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Normalize at write in `create_internal_committee_decision` (top of function body, ~line 2649)**
|
||||||
|
|
||||||
|
Immediately after `pool = await get_pool()`, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
case_number = _canonical_case_number(case_number)
|
||||||
|
```
|
||||||
|
|
||||||
|
(Do NOT add this to `create_external_case_law` — external keeps its citation verbatim; that function only `.strip()`s, which the caller adapter already does.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run normalization tests**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_idempotent_ingest.py -k "canonical" -v`
|
||||||
|
Expected: `test_canonical_case_number` (5 cases) + `test_canonical_does_not_invent_month` PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/services/db.py
|
||||||
|
git commit -m "feat(ingest): write-time canonical case_number normalization (GAP-06, FU-2a)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Convert both create functions to `ON CONFLICT DO UPDATE`
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/services/db.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace `create_external_case_law` body (lines 2566-2624, from `pool = await get_pool()` to `return _row_to_case_law(row)`)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
pool = await get_pool()
|
||||||
|
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
# Atomic upsert on the V15 partial unique index
|
||||||
|
# uq_case_law_external_number (case_number) WHERE source_kind <> 'internal_committee'.
|
||||||
|
# The predicate is repeated in ON CONFLICT (required for partial indexes).
|
||||||
|
# This also subsumes the old cited_only→external_upload promotion: a
|
||||||
|
# cited_only row with the same case_number conflicts and is promoted by
|
||||||
|
# DO UPDATE. Scoped to the external partial index, so an internal row with
|
||||||
|
# the same number is NOT touched (the old SELECT-without-source_kind could
|
||||||
|
# wrongly promote it).
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO case_law (
|
||||||
|
case_number, case_name, court, date, subject_tags,
|
||||||
|
summary, key_quote, full_text, source_url,
|
||||||
|
source_kind, document_id, extraction_status,
|
||||||
|
halacha_extraction_status, practice_area, appeal_subtype,
|
||||||
|
headnote, source_type, precedent_level, is_binding
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||||
|
'external_upload', $10, 'processing', 'pending',
|
||||||
|
$11, $12, $13, $14, $15, $16
|
||||||
|
)
|
||||||
|
ON CONFLICT (case_number) WHERE source_kind <> 'internal_committee'
|
||||||
|
DO UPDATE SET
|
||||||
|
case_name = EXCLUDED.case_name,
|
||||||
|
court = COALESCE(NULLIF(EXCLUDED.court, ''), case_law.court),
|
||||||
|
date = COALESCE(EXCLUDED.date, case_law.date),
|
||||||
|
practice_area = EXCLUDED.practice_area,
|
||||||
|
appeal_subtype = EXCLUDED.appeal_subtype,
|
||||||
|
subject_tags = EXCLUDED.subject_tags,
|
||||||
|
summary = COALESCE(NULLIF(EXCLUDED.summary, ''), case_law.summary),
|
||||||
|
headnote = EXCLUDED.headnote,
|
||||||
|
key_quote = COALESCE(NULLIF(EXCLUDED.key_quote, ''), case_law.key_quote),
|
||||||
|
full_text = EXCLUDED.full_text,
|
||||||
|
source_url = COALESCE(NULLIF(EXCLUDED.source_url, ''), case_law.source_url),
|
||||||
|
source_type = EXCLUDED.source_type,
|
||||||
|
precedent_level = EXCLUDED.precedent_level,
|
||||||
|
is_binding = EXCLUDED.is_binding,
|
||||||
|
document_id = COALESCE(EXCLUDED.document_id, case_law.document_id),
|
||||||
|
source_kind = 'external_upload',
|
||||||
|
extraction_status = 'processing',
|
||||||
|
halacha_extraction_status = 'pending'
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
case_number, case_name, court, decision_date, tags_json,
|
||||||
|
summary, key_quote, full_text, source_url,
|
||||||
|
document_id, practice_area, appeal_subtype, headnote,
|
||||||
|
source_type, precedent_level, is_binding,
|
||||||
|
)
|
||||||
|
return _row_to_case_law(row)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace `create_internal_committee_decision` body (lines 2649-2708)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
pool = await get_pool()
|
||||||
|
case_number = _canonical_case_number(case_number)
|
||||||
|
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
# Atomic upsert on V15 partial unique index
|
||||||
|
# uq_case_law_internal_number_proc (case_number, proceeding_type)
|
||||||
|
# WHERE source_kind = 'internal_committee'. Predicate repeated for the
|
||||||
|
# partial index. Replaces the old SELECT-then-INSERT/UPDATE (race-prone).
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO case_law (
|
||||||
|
case_number, case_name, court, date, chair_name, district,
|
||||||
|
subject_tags, summary, full_text,
|
||||||
|
source_kind, source_type, document_id,
|
||||||
|
extraction_status, halacha_extraction_status,
|
||||||
|
practice_area, appeal_subtype, is_binding, proceeding_type
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6,
|
||||||
|
$7, $8, $9,
|
||||||
|
'internal_committee', 'appeals_committee', $10,
|
||||||
|
'processing', 'pending',
|
||||||
|
$11, $12, $13, $14
|
||||||
|
)
|
||||||
|
ON CONFLICT (case_number, proceeding_type)
|
||||||
|
WHERE source_kind = 'internal_committee'
|
||||||
|
DO UPDATE SET
|
||||||
|
case_name = EXCLUDED.case_name,
|
||||||
|
court = COALESCE(NULLIF(EXCLUDED.court, ''), case_law.court),
|
||||||
|
date = COALESCE(EXCLUDED.date, case_law.date),
|
||||||
|
chair_name = COALESCE(NULLIF(EXCLUDED.chair_name, ''), case_law.chair_name),
|
||||||
|
district = COALESCE(NULLIF(EXCLUDED.district, ''), case_law.district),
|
||||||
|
practice_area = EXCLUDED.practice_area,
|
||||||
|
appeal_subtype = EXCLUDED.appeal_subtype,
|
||||||
|
subject_tags = EXCLUDED.subject_tags,
|
||||||
|
summary = COALESCE(NULLIF(EXCLUDED.summary, ''), case_law.summary),
|
||||||
|
full_text = EXCLUDED.full_text,
|
||||||
|
source_type = 'appeals_committee',
|
||||||
|
source_kind = 'internal_committee',
|
||||||
|
is_binding = EXCLUDED.is_binding,
|
||||||
|
document_id = COALESCE(EXCLUDED.document_id, case_law.document_id),
|
||||||
|
extraction_status = 'processing',
|
||||||
|
halacha_extraction_status = 'pending'
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
case_number, case_name, court, decision_date, chair_name, district,
|
||||||
|
tags_json, summary, full_text,
|
||||||
|
document_id, practice_area, appeal_subtype, is_binding,
|
||||||
|
proceeding_type,
|
||||||
|
)
|
||||||
|
return _row_to_case_law(row)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify import + no syntax error**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.services import db; print('db imports')"`
|
||||||
|
Expected: prints `db imports`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/services/db.py
|
||||||
|
git commit -m "feat(ingest): atomic ON CONFLICT upsert in create_*_case_law (GAP-03, FU-2a)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: V21 migration — `searchable` column + recompute
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/services/db.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `SCHEMA_V21_SQL` after `SCHEMA_V20_SQL` (~line 1094)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ── V21: explicit `searchable` flag (GAP-13 / INV-DM1) ─────────────
|
||||||
|
# Materialized completeness flag — a case_law row is exposed to search only
|
||||||
|
# when it satisfies the completeness contract (02-data-model §2a). Recomputed
|
||||||
|
# on ingest/metadata completion via recompute_searchable(); not inferred at
|
||||||
|
# query time. Default false so a freshly-inserted row is excluded until proven
|
||||||
|
# complete. Health-check surfaces count(*) FILTER (WHERE NOT searchable).
|
||||||
|
SCHEMA_V21_SQL = """
|
||||||
|
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS searchable boolean NOT NULL DEFAULT false;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_case_law_searchable ON case_law (searchable);
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire V21 into `_run_schema_migrations` (~line 1119) and bump the log line**
|
||||||
|
|
||||||
|
After `await conn.execute(SCHEMA_V20_SQL)` add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await conn.execute(SCHEMA_V21_SQL)
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the log line `"Database schema initialized (v1-v20)"` → `"Database schema initialized (v1-v21)"`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `_compute_searchable` (pure) + `recompute_searchable` (async) near the case_law helpers (after `create_internal_committee_decision`, ~line 2709)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _compute_searchable(row: dict, has_embedded_chunk: bool) -> bool:
|
||||||
|
"""Completeness contract (INV-DM1 / 02-data-model §2a).
|
||||||
|
|
||||||
|
A row is searchable IFF: canonical id present · case_name/practice_area/
|
||||||
|
source_kind present · ≥1 chunk with a non-null embedding · extraction
|
||||||
|
completed · metadata non-empty (≥1 of headnote/summary/subject_tags).
|
||||||
|
Pure — `has_embedded_chunk` is supplied by the caller (cross-table check).
|
||||||
|
"""
|
||||||
|
if not has_embedded_chunk:
|
||||||
|
return False
|
||||||
|
if (row.get("extraction_status") or "") != "completed":
|
||||||
|
return False
|
||||||
|
if not (row.get("case_number") or "").strip():
|
||||||
|
return False
|
||||||
|
if not (row.get("case_name") or "").strip():
|
||||||
|
return False
|
||||||
|
if not (row.get("practice_area") or "").strip():
|
||||||
|
return False
|
||||||
|
if not (row.get("source_kind") or "").strip():
|
||||||
|
return False
|
||||||
|
tags = row.get("subject_tags") or []
|
||||||
|
has_meta = bool((row.get("headnote") or "").strip()) \
|
||||||
|
or bool((row.get("summary") or "").strip()) \
|
||||||
|
or (len(tags) > 0)
|
||||||
|
return has_meta
|
||||||
|
|
||||||
|
|
||||||
|
async def recompute_searchable(case_law_id: "UUID | str | None" = None) -> int:
|
||||||
|
"""Recompute and persist the `searchable` flag. Idempotent / reversible.
|
||||||
|
|
||||||
|
If case_law_id is None, recompute ALL rows (used by the V21 backfill and
|
||||||
|
the dry-run). Returns the number of rows now marked searchable=true.
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if case_law_id is not None:
|
||||||
|
cid = case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT * FROM case_law WHERE id = $1", cid)
|
||||||
|
else:
|
||||||
|
rows = await conn.fetch("SELECT * FROM case_law")
|
||||||
|
n_true = 0
|
||||||
|
for r in rows:
|
||||||
|
row = dict(r)
|
||||||
|
# subject_tags is stored jsonb; _row_to_case_law parses it, but here
|
||||||
|
# we read raw — normalize to a list length check.
|
||||||
|
tags = row.get("subject_tags")
|
||||||
|
if isinstance(tags, str):
|
||||||
|
try:
|
||||||
|
tags = json.loads(tags)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
tags = []
|
||||||
|
row["subject_tags"] = tags or []
|
||||||
|
has_chunk = await conn.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM precedent_chunks "
|
||||||
|
"WHERE case_law_id = $1 AND embedding IS NOT NULL)", row["id"])
|
||||||
|
val = _compute_searchable(row, bool(has_chunk))
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE case_law SET searchable = $2 WHERE id = $1", row["id"], val)
|
||||||
|
if val:
|
||||||
|
n_true += 1
|
||||||
|
return n_true
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the completeness-predicate tests**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_idempotent_ingest.py -k "searchable and not ingest" -v`
|
||||||
|
Expected: all `test_compute_searchable_*` PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/services/db.py
|
||||||
|
git commit -m "feat(data-model): V21 searchable flag + recompute_searchable (GAP-13, FU-2a)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Wire `recompute_searchable` into ingest
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/services/ingest.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Call recompute after statuses are set in `ingest_document`**
|
||||||
|
|
||||||
|
In `ingest.py`, find the block (added by FU-1) that sets statuses + queues extraction:
|
||||||
|
```python
|
||||||
|
await db.set_case_law_extraction_status(case_law_id, "completed")
|
||||||
|
await db.set_case_law_halacha_status(case_law_id, "pending")
|
||||||
|
await db.request_metadata_extraction(case_law_id)
|
||||||
|
await db.request_halacha_extraction(case_law_id)
|
||||||
|
```
|
||||||
|
Immediately AFTER `request_halacha_extraction`, add:
|
||||||
|
```python
|
||||||
|
await db.recompute_searchable(case_law_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
> Rationale: at this point chunks+embeddings are stored and extraction_status is
|
||||||
|
> completed, so the completeness predicate is meaningful. Metadata may still be
|
||||||
|
> pending (queued), so the row may compute searchable=false until metadata fills —
|
||||||
|
> the metadata extractor also calls recompute (Task 5 Step 2).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Call recompute after metadata extraction fills fields**
|
||||||
|
|
||||||
|
In `mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py`, find `extract_and_apply`'s success path (where it persists the filled metadata fields). After the DB update that writes the extracted metadata, add a call:
|
||||||
|
```python
|
||||||
|
await db.recompute_searchable(case_law_id)
|
||||||
|
```
|
||||||
|
(Import `db` is already present in that module; if not, add `from legal_mcp.services import db`. Confirm by reading the file's imports first.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the ingest-wiring test**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_idempotent_ingest.py -k "ingest_calls_recompute" -v`
|
||||||
|
Expected: `test_ingest_calls_recompute_searchable` PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/services/ingest.py mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py
|
||||||
|
git commit -m "feat(ingest): recompute searchable on ingest + metadata completion (GAP-13, FU-2a)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: DB smoke + dry-run + GATED search filter
|
||||||
|
|
||||||
|
**Files:** Modify search layer ONLY if dry-run is clean (see Step 4).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Apply the V21 migration to the local DB and smoke-test upsert idempotency**
|
||||||
|
|
||||||
|
Run (sources env, exercises real Postgres):
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai && set -a && source ~/.env && set +a
|
||||||
|
cd mcp-server && .venv/bin/python -c "
|
||||||
|
import asyncio, uuid
|
||||||
|
from legal_mcp.services import db
|
||||||
|
async def main():
|
||||||
|
await db.get_pool() # runs migrations incl V21
|
||||||
|
# idempotent internal upsert: same (case_number, proceeding_type) twice
|
||||||
|
cn = 'ZZ9999/24'
|
||||||
|
r1 = await db.create_internal_committee_decision(case_number=cn, case_name='t', full_text='x', practice_area='rishuy_uvniya')
|
||||||
|
r2 = await db.create_internal_committee_decision(case_number=cn, case_name='t2', full_text='x2', practice_area='rishuy_uvniya')
|
||||||
|
assert r1['id'] == r2['id'], 'upsert must update, not duplicate'
|
||||||
|
# cleanup
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as c:
|
||||||
|
await c.execute(\"DELETE FROM case_law WHERE case_number = 'ZZ9999-24'\")
|
||||||
|
print('UPSERT IDEMPOTENT OK; normalized stored as ZZ9999-24')
|
||||||
|
asyncio.run(main())
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: `UPSERT IDEMPOTENT OK` and no duplicate. (Note: `ZZ9999/24` normalizes to `ZZ9999-24` — confirms write-time normalization too.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Backfill the `searchable` flag (recompute, reversible)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai && set -a && source ~/.env && set +a
|
||||||
|
cd mcp-server && .venv/bin/python -c "
|
||||||
|
import asyncio
|
||||||
|
from legal_mcp.services import db
|
||||||
|
async def main():
|
||||||
|
n = await db.recompute_searchable()
|
||||||
|
print('recompute_searchable: rows now searchable =', n)
|
||||||
|
asyncio.run(main())
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Dry-run report — which rows would drop from search if the filter is enabled**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai && set -a && source ~/.env && set +a
|
||||||
|
PGPASSWORD="$POSTGRES_PASSWORD" psql "host=$POSTGRES_HOST port=$POSTGRES_PORT dbname=$POSTGRES_DB user=$POSTGRES_USER" -c "
|
||||||
|
SELECT source_kind,
|
||||||
|
count(*) AS total,
|
||||||
|
count(*) FILTER (WHERE NOT searchable) AS would_drop
|
||||||
|
FROM case_law GROUP BY source_kind ORDER BY source_kind;"
|
||||||
|
```
|
||||||
|
Report the table to the controller. **Decision gate:** if `would_drop` includes legitimate, currently-findable precedents (e.g. external_upload / internal_committee rows that users rely on), DO NOT enable the search filter in Step 4 — stop and report; the filter waits for FU-2b. If `would_drop` is only genuinely-incomplete rows, proceed.
|
||||||
|
|
||||||
|
- [ ] **Step 4: (GATED) Enable `searchable = true` filter in the search layer**
|
||||||
|
|
||||||
|
ONLY if Step 3 is clean. Read `mcp-server/src/legal_mcp/services/hybrid_search.py` to find the `case_law` WHERE clauses in `search_precedent_library_hybrid` / `search_documents_hybrid`. Add `AND cl.searchable = true` (alias as used in that query) to the case_law-joined precedent search paths. Add a focused test asserting a non-searchable row is excluded (monkeypatch or DB smoke). If deferred, write a one-line note in the spec §7 that the filter is pending FU-2b and skip.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add health-check visibility**
|
||||||
|
|
||||||
|
Find the health-check endpoint/function (search `def health` / `processing_status` in `web/app.py` or `tools/`). Add a field `non_searchable_case_law = SELECT count(*) FROM case_law WHERE NOT searchable`. Keep it a single cheap COUNT.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add -A mcp-server/ web/
|
||||||
|
git commit -m "feat(retrieval): gated searchable filter + health-check visibility (GAP-13, FU-2a)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Full suite + smoke + lint + TaskMaster
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full test suite**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/ -q`
|
||||||
|
Expected: all pass (the FU-1 77 + new FU-2a tests). Report the summary line.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Smoke-import**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.services import db, ingest, precedent_library, internal_decisions; print('clean')"`
|
||||||
|
Expected: `clean`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Lint changed files (if ruff available)**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m ruff check src/legal_mcp/services/db.py src/legal_mcp/services/ingest.py 2>/dev/null; echo "exit=$?"`
|
||||||
|
Expected: clean or "ruff not available".
|
||||||
|
|
||||||
|
- [ ] **Step 4: Mark TaskMaster #60 + subtasks done**
|
||||||
|
|
||||||
|
Controller handles this (edit `.taskmaster/tasks/tasks.json`, verify via MCP get_task). Subtasks 60.1 (GAP-03), 60.2 (GAP-06), 60.5 (GAP-13).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **GAP-03** → Task 3 (ON CONFLICT both functions). **GAP-06** → Task 2 (`_canonical_case_number` + write-time, type-aware). **GAP-13** → Tasks 4-5 (column + recompute + wiring) and gated Task 6 (filter).
|
||||||
|
- **No identifier migration** — FU-2b (#67) owns GAP-07/08. The V21 backfill only sets a derived, reversible flag.
|
||||||
|
- **Gated search filter** (Task 6 Step 3-4): the behavior-visible change is contingent on a clean dry-run; otherwise deferred. Surface the dry-run table to the user.
|
||||||
|
- **Offline-test limitation:** ON CONFLICT needs real Postgres → verified by Task 6 Step 1 smoke; offline tests cover the pure logic (normalize, completeness) and ingest wiring.
|
||||||
|
- **Type-consistency:** `_canonical_case_number`, `_compute_searchable(row, has_embedded_chunk)`, `recompute_searchable(case_law_id=None)` — names used identically in tests (Task 1) and impl (Tasks 2,4).
|
||||||
421
docs/superpowers/plans/2026-05-30-fu3-reindex-on-change.md
Normal file
421
docs/superpowers/plans/2026-05-30-fu3-reindex-on-change.md
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
# FU-3: Re-Index on Content Change — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Detect content changes via a SHA-256 `content_hash`, expose a standalone `reindex_case_law` that re-embeds from stored `full_text` (no re-OCR, no file needed), and surface embedding-drift in the health-check — enforcing INV-G6 where embeddings can't be DB-GENERATED.
|
||||||
|
|
||||||
|
**Architecture:** Two additive `case_law` columns (V23): `content_hash` (hash of current full_text, written at the create boundary) and `indexed_hash` (hash the current chunks/embeddings were built from, set by `mark_indexed` after a successful store). Stale ⇔ `content_hash IS DISTINCT FROM indexed_hash`. `reindex_case_law` reuses the canonical `_chunk_embed_store` over stored text. Backfill only computes hashes (no re-embed — existing rows keep their vectors).
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, asyncpg, PostgreSQL@localhost:5433, voyage embeddings API, pytest offline, `.venv` at `mcp-server/.venv`.
|
||||||
|
|
||||||
|
**Spec:** [docs/superpowers/specs/2026-05-30-fu3-reindex-on-change-design.md](../specs/2026-05-30-fu3-reindex-on-change-design.md)
|
||||||
|
|
||||||
|
**Run tests:** `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_reindex_on_change.py -v`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/services/db.py` — `_content_hash`; V23 migration; `content_hash` in `create_external_case_law`/`create_internal_committee_decision`/`create_case`; `mark_indexed`; `list_stale_case_law`; `recompute_content_hashes`.
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/services/ingest.py` — `reindex_case_law`; call `mark_indexed` after `_chunk_embed_store` in `ingest_document`.
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/services/metrics.py` — `stale_embedding_case_law` count.
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/tools/precedent_library.py` + `server.py` — MCP tool `precedent_reindex`.
|
||||||
|
- **Create** `mcp-server/tests/test_reindex_on_change.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Failing tests
|
||||||
|
|
||||||
|
**Files:** Create `mcp-server/tests/test_reindex_on_change.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""FU-3: re-index on content change (offline, monkeypatched I/O)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from legal_mcp.services import db, ingest
|
||||||
|
|
||||||
|
|
||||||
|
def _run(coro):
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
# ── content_hash is deterministic ──────────────────────────────────────
|
||||||
|
def test_content_hash_deterministic():
|
||||||
|
h1 = db._content_hash("פסק דין כלשהו")
|
||||||
|
h2 = db._content_hash("פסק דין כלשהו")
|
||||||
|
assert h1 == h2 and len(h1) == 64 # sha256 hex
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_hash_empty_is_blank():
|
||||||
|
assert db._content_hash("") == ""
|
||||||
|
assert db._content_hash(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_hash_changes_with_text():
|
||||||
|
assert db._content_hash("alpha") != db._content_hash("beta")
|
||||||
|
|
||||||
|
|
||||||
|
# ── mark_indexed copies content_hash → indexed_hash ─────────────────────
|
||||||
|
def test_mark_indexed_executes_update(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class _Conn:
|
||||||
|
async def execute(self, q, *a):
|
||||||
|
seen["q"] = q; seen["args"] = a
|
||||||
|
async def __aenter__(self): return self
|
||||||
|
async def __aexit__(self, *a): return False
|
||||||
|
|
||||||
|
class _Pool:
|
||||||
|
def acquire(self): return _Conn()
|
||||||
|
|
||||||
|
async def _pool(): return _Pool()
|
||||||
|
monkeypatch.setattr(db, "get_pool", _pool)
|
||||||
|
|
||||||
|
cid = uuid4()
|
||||||
|
_run(db.mark_indexed(cid))
|
||||||
|
assert "indexed_hash" in seen["q"] and "content_hash" in seen["q"]
|
||||||
|
assert seen["args"][0] == cid
|
||||||
|
|
||||||
|
|
||||||
|
# ── reindex_case_law re-embeds from stored text, no extractor/LLM ───────
|
||||||
|
def test_reindex_case_law_uses_stored_text(monkeypatch):
|
||||||
|
cid = uuid4()
|
||||||
|
calls = {"chunk_embed_store": [], "mark_indexed": []}
|
||||||
|
|
||||||
|
async def _get_case_law(x):
|
||||||
|
return {"id": cid, "full_text": "טקסט שמור של ההחלטה"}
|
||||||
|
monkeypatch.setattr(ingest.db, "get_case_law", _get_case_law)
|
||||||
|
|
||||||
|
async def _ces(case_law_id, text, page_offsets, page_count, progress):
|
||||||
|
calls["chunk_embed_store"].append((case_law_id, text))
|
||||||
|
return 5
|
||||||
|
monkeypatch.setattr(ingest, "_chunk_embed_store", _ces)
|
||||||
|
|
||||||
|
async def _mark(x):
|
||||||
|
calls["mark_indexed"].append(x)
|
||||||
|
monkeypatch.setattr(ingest.db, "mark_indexed", _mark)
|
||||||
|
|
||||||
|
out = _run(ingest.reindex_case_law(cid))
|
||||||
|
assert out["chunks"] == 5 and out["reindexed"] is True
|
||||||
|
assert calls["chunk_embed_store"][0][1] == "טקסט שמור של ההחלטה"
|
||||||
|
assert calls["mark_indexed"] == [cid]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reindex_case_law_missing_row_raises(monkeypatch):
|
||||||
|
async def _none(x): return None
|
||||||
|
monkeypatch.setattr(ingest.db, "get_case_law", _none)
|
||||||
|
with pytest.raises(ValueError, match="not found"):
|
||||||
|
_run(ingest.reindex_case_law(uuid4()))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify failure**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_reindex_on_change.py -v`
|
||||||
|
Expected: FAIL — `AttributeError: ... no attribute '_content_hash'` / `mark_indexed` / `reindex_case_law`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/tests/test_reindex_on_change.py
|
||||||
|
git commit -m "test(reindex): failing tests for content-hash re-index (FU-3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: V23 + hash helpers + content_hash at write
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/services/db.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ensure `hashlib` import + add `_content_hash`**
|
||||||
|
|
||||||
|
READ the top imports of db.py. If `import hashlib` is absent, add it. Add this helper near `_canonical_case_number` (~line 1227):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _content_hash(text: str) -> str:
|
||||||
|
"""SHA-256 hex of the text — deterministic content fingerprint (FU-3/GAP-09).
|
||||||
|
|
||||||
|
Empty/None → "" (a row with no text has no content fingerprint).
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `SCHEMA_V23_SQL` after `SCHEMA_V22_SQL` + wire it**
|
||||||
|
|
||||||
|
READ near `SCHEMA_V22_SQL` and `_run_schema_migrations`. Add after the V22 block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ── V23: case_law content/indexed hashes — re-index on content change (GAP-09) ──
|
||||||
|
# content_hash = SHA-256 of current full_text (written at the create boundary).
|
||||||
|
# indexed_hash = the content_hash the CURRENT chunks/embeddings were built from
|
||||||
|
# (set by mark_indexed after a successful store). Stale ⇔ content_hash IS
|
||||||
|
# DISTINCT FROM indexed_hash. embedding can't be a GENERATED column (needs an
|
||||||
|
# API call), so freshness is enforced by detection + reindex_case_law + health-check.
|
||||||
|
SCHEMA_V23_SQL = """
|
||||||
|
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS content_hash text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS indexed_hash text;
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
After `await conn.execute(SCHEMA_V22_SQL)` add `await conn.execute(SCHEMA_V23_SQL)`; bump the log line to `v1-v23`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `content_hash` in the two case_law create functions**
|
||||||
|
|
||||||
|
In `create_external_case_law` and `create_internal_committee_decision` (db.py ~2610-2760), the `INSERT ... ON CONFLICT ... DO UPDATE` was built in FU-2a. For EACH:
|
||||||
|
1. Add `content_hash` to the INSERT column list (append after the last data column, before the closing `)`).
|
||||||
|
2. Add a matching `$N` placeholder in VALUES (next number after the current max).
|
||||||
|
3. Add `content_hash = EXCLUDED.content_hash` to the `DO UPDATE SET` clause.
|
||||||
|
4. Append `_content_hash(full_text)` as the LAST positional arg in the `conn.fetchrow(..., <args>)` call (matching the new `$N`).
|
||||||
|
|
||||||
|
CRITICAL: the new placeholder number must equal `(current highest $N) + 1`, and the new arg must be appended LAST in the args tuple in the SAME order. Read the current SQL + args carefully and count. After editing, verify param count = placeholder count (Step 5 import check will catch a gross mismatch; the DB smoke in Task 6 confirms at runtime).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write `content_hash` in `create_case`**
|
||||||
|
|
||||||
|
In `create_case` (db.py ~1130-1165), the INSERT into `cases` — add `content_hash`? NO: `cases` is a different table (active appeal cases), and FU-3's scope is `case_law` (the corpus). Do NOT alter `create_case` or the `cases` table here. (The spec §3 mentioned create_case for normalization in FU-2a; for FU-3 hashing, scope is `case_law` only. Skip create_case.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add `mark_indexed`, `list_stale_case_law`, `recompute_content_hashes` (after `get_case_law`, ~line 2547)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def mark_indexed(case_law_id: UUID) -> None:
|
||||||
|
"""Mark a case_law row's embeddings as built from its current content (FU-3).
|
||||||
|
|
||||||
|
Sets indexed_hash := content_hash. Call AFTER a successful chunk+embed+store.
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE case_law SET indexed_hash = content_hash WHERE id = $1",
|
||||||
|
case_law_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_stale_case_law(limit: int = 500) -> list[dict]:
|
||||||
|
"""case_law rows whose embeddings are stale vs current content (GAP-09/INV-G6)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT id, case_number, source_kind
|
||||||
|
FROM case_law
|
||||||
|
WHERE coalesce(full_text, '') <> ''
|
||||||
|
AND content_hash IS DISTINCT FROM indexed_hash
|
||||||
|
ORDER BY created_at LIMIT $1""",
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def recompute_content_hashes() -> dict:
|
||||||
|
"""Backfill (FU-3): set content_hash for all rows; set indexed_hash=content_hash
|
||||||
|
only where chunks already exist (those are already embedded). Rows with text but
|
||||||
|
no chunks get indexed_hash=NULL → surface as stale. Hash-only; no re-embed."""
|
||||||
|
pool = await get_pool()
|
||||||
|
updated = 0
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("SELECT id, full_text FROM case_law")
|
||||||
|
for r in rows:
|
||||||
|
ch = _content_hash(r["full_text"] or "")
|
||||||
|
has_chunks = await conn.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM precedent_chunks WHERE case_law_id = $1)",
|
||||||
|
r["id"])
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE case_law SET content_hash = $2, "
|
||||||
|
"indexed_hash = CASE WHEN $3 THEN $2 ELSE indexed_hash END WHERE id = $1",
|
||||||
|
r["id"], ch, bool(has_chunks))
|
||||||
|
updated += 1
|
||||||
|
return {"updated": updated}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the helper tests**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_reindex_on_change.py -k "content_hash or mark_indexed" -v`
|
||||||
|
Expected: `test_content_hash_*` (3) + `test_mark_indexed_executes_update` PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/services/db.py
|
||||||
|
git commit -m "feat(reindex): V23 content/indexed hashes + helpers + write content_hash (GAP-09, FU-3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `reindex_case_law` + mark_indexed on ingest
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/services/ingest.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Call `mark_indexed` after successful chunk+embed+store in `ingest_document`**
|
||||||
|
|
||||||
|
READ `ingest_document` — find the line `stored_chunks = await _chunk_embed_store(case_law_id, raw_text, page_offsets, page_count, progress)` (~line 184). Immediately AFTER it, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await db.mark_indexed(case_law_id)
|
||||||
|
```
|
||||||
|
(After a fresh ingest, chunks were just built from the current text → indexed_hash = content_hash.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `reindex_case_law` (append to ingest.py)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def reindex_case_law(
|
||||||
|
case_law_id: "UUID | str",
|
||||||
|
progress: ProgressCb | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Re-chunk + re-embed an existing case_law row from its STORED full_text (GAP-09).
|
||||||
|
|
||||||
|
No re-extract / no re-OCR (uses the stored text — see feedback_no_reocr_retrofit)
|
||||||
|
and no LLM/CLI (only chunker + voyage embeddings), so it is safe to run anywhere.
|
||||||
|
Idempotent: store_precedent_chunks(_hierarchical) is DELETE-then-INSERT.
|
||||||
|
"""
|
||||||
|
progress = progress or _noop_progress
|
||||||
|
cid = case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
|
||||||
|
row = await db.get_case_law(cid)
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"case_law not found: {cid}")
|
||||||
|
text = (row.get("full_text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("case_law has no stored full_text to re-index")
|
||||||
|
stored = await _chunk_embed_store(cid, text, None, 0, progress)
|
||||||
|
await db.mark_indexed(cid)
|
||||||
|
await progress("completed", 100, f"הוטמע מחדש: {stored} chunks")
|
||||||
|
return {"status": "completed", "case_law_id": str(cid), "chunks": stored, "reindexed": True}
|
||||||
|
```
|
||||||
|
(`UUID`, `db`, `_chunk_embed_store`, `_noop_progress`, `ProgressCb` are already in ingest.py.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run reindex tests**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_reindex_on_change.py -v`
|
||||||
|
Expected: ALL pass (incl `test_reindex_case_law_uses_stored_text`, `test_reindex_case_law_missing_row_raises`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/services/ingest.py
|
||||||
|
git commit -m "feat(reindex): reindex_case_law from stored text + mark_indexed on ingest (GAP-09, FU-3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Health-check drift count
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/services/metrics.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `stale_embedding_case_law` count**
|
||||||
|
|
||||||
|
READ metrics.py — the aggregation that holds `non_searchable_case_law` / `cases_with_stale_blocks` (added in FU-2a/FU-7). Add a sibling, mirroring the exact pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
stale_embedding_case_law = await conn.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM case_law "
|
||||||
|
"WHERE coalesce(full_text,'') <> '' AND content_hash IS DISTINCT FROM indexed_hash")
|
||||||
|
```
|
||||||
|
and expose it in the returned summary dict: `"stale_embedding_case_law": stale_embedding_case_law`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Smoke-import + commit**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.services import metrics; print('clean')"`
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/services/metrics.py
|
||||||
|
git commit -m "feat(reindex): health-check stale_embedding_case_law count (GAP-09, FU-3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: MCP tool `precedent_reindex`
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/tools/precedent_library.py`, `mcp-server/src/legal_mcp/server.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the tool function in precedent_library.py (mirror `precedent_extract_metadata`)**
|
||||||
|
|
||||||
|
READ `precedent_extract_metadata` (tools/precedent_library.py ~205-216) for the `_ok`/`_err`/UUID pattern. Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def precedent_reindex(case_law_id: str) -> str:
|
||||||
|
"""re-chunk + re-embed פסיקה קיימת מה-full_text השמור (FU-3/GAP-09).
|
||||||
|
|
||||||
|
לתיקון drift של embeddings או אחרי שינוי-תוכן. אינו מריץ OCR/LLM — רק
|
||||||
|
chunking + voyage embeddings. idempotent (מוחק ובונה chunks מחדש).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cid = UUID(case_law_id)
|
||||||
|
except ValueError:
|
||||||
|
return _err("case_law_id לא תקין")
|
||||||
|
try:
|
||||||
|
from legal_mcp.services import ingest
|
||||||
|
result = await ingest.reindex_case_law(cid)
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok(result)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Register in server.py (mirror the precedent tools' `@mcp.tool()` registration)**
|
||||||
|
|
||||||
|
READ server.py — find where `precedent_extract_metadata` (or another `precedent_*` tool) is registered with `@mcp.tool()` and delegated to `tools.precedent_library`. Add an equivalent registration for `precedent_reindex` following the identical pattern (decorator + delegation + the same import style). Report the exact registration block you added.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Smoke-import + commit**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.tools import precedent_library; import legal_mcp.server; print('clean')"`
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/tools/precedent_library.py mcp-server/src/legal_mcp/server.py
|
||||||
|
git commit -m "feat(reindex): precedent_reindex MCP tool (GAP-09, FU-3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Backfill + full suite + DB smoke + lint + TaskMaster
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full offline suite**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/ -q`
|
||||||
|
Expected: all pass (FU-1/2a/7 + new FU-3). If a pre-existing test that calls `ingest_document` breaks because `mark_indexed` isn't stubbed, fix that fixture to stub `db.mark_indexed` (same pattern as the FU-2a `recompute_searchable` fixture fix). Report.
|
||||||
|
|
||||||
|
- [ ] **Step 2: DB smoke + backfill (real Postgres — applies V23, runs backfill)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai && set -a && source ~/.env 2>/dev/null && set +a
|
||||||
|
cd mcp-server && .venv/bin/python -c "
|
||||||
|
import asyncio
|
||||||
|
from legal_mcp.services import db
|
||||||
|
async def main():
|
||||||
|
await db.get_pool() # applies V23
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as c:
|
||||||
|
cols = await c.fetchval(\"SELECT count(*) FROM information_schema.columns WHERE table_name='case_law' AND column_name IN ('content_hash','indexed_hash')\")
|
||||||
|
print('V23 columns present:', cols, '(expect 2)')
|
||||||
|
res = await db.recompute_content_hashes()
|
||||||
|
print('backfill:', res)
|
||||||
|
stale = await db.list_stale_case_law()
|
||||||
|
print('stale after backfill:', len(stale))
|
||||||
|
asyncio.run(main())
|
||||||
|
" 2>&1 | grep -vE 'INFO|WARNING|httpx|deprecat|command not found|\^\^\^' | tail -5
|
||||||
|
```
|
||||||
|
Expected: `V23 columns present: 2`, backfill updated ~129, `stale after backfill:` a small number (rows with text but no chunks, e.g. cited_only). Report the stale count.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Lint**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m ruff check src/legal_mcp/services/db.py src/legal_mcp/services/ingest.py 2>/dev/null; echo "exit=$?"`
|
||||||
|
Expected: clean or "ruff not available".
|
||||||
|
|
||||||
|
- [ ] **Step 4: TaskMaster** — controller marks #61 + subtask 61.1 done (61.2 already cancelled), verifies via MCP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **GAP-09** → content_hash detection (Task 2) + reindex_case_law (Task 3) + drift health-check (Task 4) + MCP tool (Task 5).
|
||||||
|
- **No re-OCR:** reindex uses stored `full_text` only (Task 3) — honors feedback_no_reocr_retrofit.
|
||||||
|
- **Backfill is hash-only** (Task 6 Step 2) — no re-embed, no API cost; existing vectors untouched.
|
||||||
|
- **#61.2 closed** (not-applicable, in the spec commit) — no multimodal backfill task here.
|
||||||
|
- **Scope:** `case_law` only — `create_case`/`cases` table NOT touched (Task 2 Step 4).
|
||||||
|
- **Type consistency:** `_content_hash(text)->str`, `mark_indexed(case_law_id)`, `reindex_case_law(id)->{chunks,reindexed}`, `list_stale_case_law()`, `recompute_content_hashes()->{updated}` — names identical across tasks + tests.
|
||||||
|
- **Param-count risk** (Task 2 Step 3): the FU-2a upsert SQL must get exactly one new placeholder + one new arg per function; verified at runtime by the Task 6 DB smoke (a mismatch raises immediately).
|
||||||
521
docs/superpowers/plans/2026-05-30-fu7-audit-provenance.md
Normal file
521
docs/superpowers/plans/2026-05-30-fu7-audit-provenance.md
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
# FU-7: Audit-Trail + Provenance — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Turn `audit_log` into an end-to-end audit trail, attach source-provenance to generated blocks, enforce citation→corpus resolution, and flag DOCX↔blocks drift — all forward-only, no data migration.
|
||||||
|
|
||||||
|
**Architecture:** Reuse `audit_log.log_action` with a `details` JSONB payload (X5 §4 — no new table) via a non-fatal `log_action_safe` wrapper. Provenance is an append-only `write_block` audit event carrying the source ids that fed the generation. GAP-17 drift is a deterministic `cases.blocks_stale` flag (V22) set at the known divergence points + a health-check count — not a fragile DOCX→blocks reparse. GAP-20 is a structural `case_law_id` resolver surfaced as a QA warning.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, asyncpg, PostgreSQL@localhost:5433, pytest offline, `.venv` at `mcp-server/.venv`.
|
||||||
|
|
||||||
|
**Spec:** [docs/superpowers/specs/2026-05-30-fu7-audit-provenance-design.md](../specs/2026-05-30-fu7-audit-provenance-design.md)
|
||||||
|
|
||||||
|
**Run tests:** `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_audit_provenance.py -v`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/services/audit.py` — add `log_action_safe(...)`.
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/services/db.py` — V22 migration (`cases.blocks_stale`), `mark_blocks_stale`, `resolve_citation_case_law_ids`.
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/tools/documents.py` — audit in `document_upload`, `extract_claims`.
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/services/block_writer.py` — collect source ids; audit `write_block`; clear `blocks_stale` on save.
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/tools/drafting.py` — audit `export_docx`; set/clear `blocks_stale` in `export_docx`/`revise_draft`/`apply_user_edit`.
|
||||||
|
- **Modify** QA path (`services/qa_validator.py`) — citation→corpus warning.
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/services/metrics.py` — `cases_with_stale_blocks` count.
|
||||||
|
- **Create** `mcp-server/tests/test_audit_provenance.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Failing tests
|
||||||
|
|
||||||
|
**Files:** Create `mcp-server/tests/test_audit_provenance.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""FU-7: audit-trail + provenance (offline, monkeypatched I/O)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from legal_mcp.services import audit, db
|
||||||
|
|
||||||
|
|
||||||
|
def _run(coro):
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
# ── GAP-18: log_action_safe is non-fatal ───────────────────────────────
|
||||||
|
def test_log_action_safe_swallows_db_error(monkeypatch):
|
||||||
|
async def _boom(*a, **k):
|
||||||
|
raise RuntimeError("db down")
|
||||||
|
monkeypatch.setattr(audit, "log_action", _boom)
|
||||||
|
# must NOT raise
|
||||||
|
_run(audit.log_action_safe("write_block", details={"x": 1}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_action_safe_forwards_args(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
async def _capture(action, case_id=None, document_id=None, details=None, user="system"):
|
||||||
|
seen.update(action=action, details=details)
|
||||||
|
monkeypatch.setattr(audit, "log_action", _capture)
|
||||||
|
_run(audit.log_action_safe("export_docx", details={"path": "/x"}))
|
||||||
|
assert seen["action"] == "export_docx" and seen["details"] == {"path": "/x"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── GAP-20: structural citation resolver ────────────────────────────────
|
||||||
|
def test_resolve_citation_case_law_ids_splits(monkeypatch):
|
||||||
|
good = uuid4()
|
||||||
|
bad = uuid4()
|
||||||
|
|
||||||
|
class _Conn:
|
||||||
|
async def fetchval(self, q, cid):
|
||||||
|
return cid == good
|
||||||
|
async def __aenter__(self): return self
|
||||||
|
async def __aexit__(self, *a): return False
|
||||||
|
|
||||||
|
class _Pool:
|
||||||
|
def acquire(self): return _Conn()
|
||||||
|
|
||||||
|
async def _pool():
|
||||||
|
return _Pool()
|
||||||
|
monkeypatch.setattr(db, "get_pool", _pool)
|
||||||
|
|
||||||
|
out = _run(db.resolve_citation_case_law_ids([good, bad]))
|
||||||
|
assert good in out["resolved"] and bad in out["unresolved"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── GAP-17: blocks_stale helper ────────────────────────────────────────
|
||||||
|
def test_mark_blocks_stale_executes_update(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class _Conn:
|
||||||
|
async def execute(self, q, *a):
|
||||||
|
seen["q"] = q; seen["args"] = a
|
||||||
|
async def __aenter__(self): return self
|
||||||
|
async def __aexit__(self, *a): return False
|
||||||
|
|
||||||
|
class _Pool:
|
||||||
|
def acquire(self): return _Conn()
|
||||||
|
|
||||||
|
async def _pool(): return _Pool()
|
||||||
|
monkeypatch.setattr(db, "get_pool", _pool)
|
||||||
|
|
||||||
|
cid = uuid4()
|
||||||
|
_run(db.mark_blocks_stale(cid, True))
|
||||||
|
assert "blocks_stale" in seen["q"] and seen["args"][0] is True and seen["args"][1] == cid
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify failure**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_audit_provenance.py -v`
|
||||||
|
Expected: FAIL — `AttributeError: ... has no attribute 'log_action_safe'` / `resolve_citation_case_law_ids` / `mark_blocks_stale`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/tests/test_audit_provenance.py
|
||||||
|
git commit -m "test(audit): failing tests for audit-trail + provenance (FU-7)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: V22 migration + core helpers
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/services/audit.py`, `mcp-server/src/legal_mcp/services/db.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `log_action_safe` to audit.py (after `log_action`)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def log_action_safe(
|
||||||
|
action: str,
|
||||||
|
case_id: "UUID | None" = None,
|
||||||
|
document_id: "UUID | None" = None,
|
||||||
|
details: dict | None = None,
|
||||||
|
user: str = "system",
|
||||||
|
) -> None:
|
||||||
|
"""Non-fatal audit: never let an audit-log failure break the caller's action.
|
||||||
|
|
||||||
|
The authoritative integrity trail is git (X5 §2.1); audit_log is the
|
||||||
|
'who/what/when' observability layer, so a write failure is logged as a
|
||||||
|
warning and swallowed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await log_action(action, case_id=case_id, document_id=document_id,
|
||||||
|
details=details, user=user)
|
||||||
|
except Exception as e: # noqa: BLE001 — observability must not break the op
|
||||||
|
logger.warning("audit log_action failed (non-fatal) for %s: %s", action, e)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `SCHEMA_V22_SQL` after `SCHEMA_V21_SQL` in db.py + wire it**
|
||||||
|
|
||||||
|
READ db.py near `SCHEMA_V21_SQL` (~line 1097-1133). Add after the V21 block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ── V22: cases.blocks_stale — DOCX↔blocks drift flag (GAP-17 / INV-EX1) ──
|
||||||
|
# Set true when revise_draft/apply_user_edit make active_draft_path the live
|
||||||
|
# source-of-truth without re-syncing decision_blocks; cleared when blocks are
|
||||||
|
# re-exported or re-saved. Surfaced by health-check. Source-of-truth remains
|
||||||
|
# decision_blocks — this only flags known drift (no fragile DOCX→blocks reparse).
|
||||||
|
SCHEMA_V22_SQL = """
|
||||||
|
ALTER TABLE cases ADD COLUMN IF NOT EXISTS blocks_stale boolean NOT NULL DEFAULT false;
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
After `await conn.execute(SCHEMA_V21_SQL)` add `await conn.execute(SCHEMA_V22_SQL)` and bump the log line to `v1-v22`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `mark_blocks_stale` + `resolve_citation_case_law_ids` to db.py (near the case helpers, after `get_active_draft_path`)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def mark_blocks_stale(case_id: UUID, stale: bool) -> None:
|
||||||
|
"""Flag/clear DOCX↔blocks drift for a case (GAP-17)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE cases SET blocks_stale = $1, updated_at = now() WHERE id = $2",
|
||||||
|
stale, case_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_citation_case_law_ids(ids) -> dict:
|
||||||
|
"""Structural citation→corpus resolution (GAP-20 / INV-AUD3).
|
||||||
|
|
||||||
|
Given case_law_id values referenced by a decision's citations/provenance,
|
||||||
|
split into resolvable (exist in case_law) vs unresolvable.
|
||||||
|
"""
|
||||||
|
resolved, unresolved = [], []
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
for cid in ids:
|
||||||
|
try:
|
||||||
|
exists = await conn.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM case_law WHERE id = $1)", cid)
|
||||||
|
except Exception:
|
||||||
|
exists = False
|
||||||
|
(resolved if exists else unresolved).append(cid)
|
||||||
|
return {"resolved": resolved, "unresolved": unresolved}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run Task-1 tests for these helpers**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_audit_provenance.py -v`
|
||||||
|
Expected: all 4 tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/services/audit.py mcp-server/src/legal_mcp/services/db.py
|
||||||
|
git commit -m "feat(audit): log_action_safe + V22 blocks_stale + citation resolver (FU-7)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: GAP-18 — audit calls on upload / extract_claims / export
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/tools/documents.py`, `mcp-server/src/legal_mcp/tools/drafting.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: `document_upload` — audit after processing (documents.py)**
|
||||||
|
|
||||||
|
READ `document_upload` (lines ~14-94). It computes `case_id`, `doc` (with `doc["id"]`), `actual_doc_type`, and `result` (with `result["classification"]`). Ensure `from legal_mcp.services import audit` is imported (add if missing). Immediately BEFORE the final `return json.dumps({...})`, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await audit.log_action_safe(
|
||||||
|
"document_upload", case_id=case_id, document_id=UUID(doc["id"]),
|
||||||
|
details={"title": title, "doc_type": actual_doc_type},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `extract_claims` — audit before return (documents.py)**
|
||||||
|
|
||||||
|
In `extract_claims` (lines ~300-348), before the final `return json.dumps(results, ...)`, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await audit.log_action_safe(
|
||||||
|
"extract_claims", case_id=case_id,
|
||||||
|
details={"docs_processed": len(docs), "results": len(results)},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: `export_docx` — audit after export (drafting.py)**
|
||||||
|
|
||||||
|
READ `export_docx` in `drafting.py` (around lines 384-439). It resolves `case_id`, builds `path`, and calls `db.set_active_draft_path(case_id, path)`. Ensure `audit` is imported. After the `set_active_draft_path` call, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await audit.log_action_safe(
|
||||||
|
"export_docx", case_id=case_id,
|
||||||
|
details={"path": str(path)},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify imports**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.tools import documents, drafting; print('clean')"`
|
||||||
|
Expected: `clean`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/tools/documents.py mcp-server/src/legal_mcp/tools/drafting.py
|
||||||
|
git commit -m "feat(audit): log document_upload/extract_claims/export_docx (GAP-18, FU-7)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: GAP-19 — block→source provenance
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/services/block_writer.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Make `_build_precedents_context` also return the case_law ids it used**
|
||||||
|
|
||||||
|
READ `_build_precedents_context` (lines ~671-716). Change the `caselaw_rows` SELECT to also fetch `cl.id`:
|
||||||
|
replace `"""SELECT cl.case_number, cl.case_name, cl.court, cl.summary, cl.key_quote,` with
|
||||||
|
`"""SELECT cl.id, cl.case_number, cl.case_name, cl.court, cl.summary, cl.key_quote,`.
|
||||||
|
Collect ids and change the function to return a tuple. At the function's two `return` points:
|
||||||
|
- replace `return "\n\n".join(parts) if parts else "(אין תקדימים)"` with
|
||||||
|
`return ("\n\n".join(parts) if parts else "(אין תקדימים)"), case_law_ids`
|
||||||
|
- ensure `case_law_ids = []` is initialized at the top, and inside the caselaw loop append `r["id"]` (str(r["id"])).
|
||||||
|
|
||||||
|
If there is an early/exception return path that returns a bare string, make it return `("(אין תקדימים)", [])` too.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the caller in `write_block` + collect document/claim ids**
|
||||||
|
|
||||||
|
READ `write_block` (lines ~280-394). Line ~321 currently:
|
||||||
|
`precedents_context = await _build_precedents_context(case_id, block_id)`
|
||||||
|
Change to:
|
||||||
|
`precedents_context, _precedent_case_law_ids = await _build_precedents_context(case_id, block_id)`
|
||||||
|
|
||||||
|
Add a helper `_collect_block_sources` (after `_build_result`, ~line 408):
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _collect_block_sources(case_id: UUID, block_id: str) -> dict:
|
||||||
|
"""Deterministic source ids available to a block's generation (GAP-19).
|
||||||
|
|
||||||
|
document_ids: case documents matching the block's allowed doc-types.
|
||||||
|
claim_ids: extracted claims for the case. (case_law_ids are captured
|
||||||
|
separately from the precedent search inside write_block.)
|
||||||
|
"""
|
||||||
|
allowed = _BLOCK_DOC_TYPES.get(block_id, [])
|
||||||
|
docs = await db.list_documents(case_id)
|
||||||
|
if allowed:
|
||||||
|
docs = [d for d in docs if d.get("doc_type") in allowed]
|
||||||
|
claims = await db.get_claims(case_id)
|
||||||
|
return {
|
||||||
|
"document_ids": [str(d["id"]) for d in docs],
|
||||||
|
"claim_ids": [str(c["id"]) for c in claims],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `write_block`, just before the final `return _build_result(block_id, content, block_cfg)` (the non-template path, ~line 394), build the sources and attach to the result:
|
||||||
|
|
||||||
|
```python
|
||||||
|
sources = await _collect_block_sources(case_id, block_id)
|
||||||
|
sources["case_law_ids"] = _precedent_case_law_ids
|
||||||
|
result = _build_result(block_id, content, block_cfg)
|
||||||
|
result["sources"] = sources
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
(For the template path return at ~line 308, attach an empty sources dict: `r = _build_result(...); r["sources"] = {"document_ids": [], "claim_ids": [], "case_law_ids": []}; return r`.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the provenance audit in `write_and_store_block` and `save_block_content`**
|
||||||
|
|
||||||
|
In `write_and_store_block` (~line 1039), after `await store_block(UUID(decision["id"]), result)`, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await audit.log_action_safe(
|
||||||
|
"write_block", case_id=case_id,
|
||||||
|
details={
|
||||||
|
"decision_id": str(decision["id"]),
|
||||||
|
"block_id": block_id,
|
||||||
|
"model_used": result.get("model_used"),
|
||||||
|
"generation_type": result.get("generation_type"),
|
||||||
|
"sources": result.get("sources", {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await db.mark_blocks_stale(case_id, False)
|
||||||
|
```
|
||||||
|
|
||||||
|
In `save_block_content` (~line 905), after `await store_block(...)` add the same `mark_blocks_stale(case_id, False)` (a saved block means DB blocks are current). Ensure `from legal_mcp.services import audit` is imported in block_writer.py (add if missing).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Smoke-import + targeted check**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.services import block_writer; print('clean')"`
|
||||||
|
Expected: `clean`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/services/block_writer.py
|
||||||
|
git commit -m "feat(audit): block→source provenance via write_block audit event (GAP-19, FU-7)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: GAP-20 — citation→corpus validation as QA warning
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/services/qa_validator.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the QA validator structure**
|
||||||
|
|
||||||
|
READ `mcp-server/src/legal_mcp/services/qa_validator.py` — find the function that runs the QA checks and returns findings (look for the list of checks / findings dicts with severity like `warning`/`critical`). Identify the findings structure (keys, how a check is appended).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a citation-resolution check**
|
||||||
|
|
||||||
|
Add a check that gathers `case_law_id`s referenced by the decision's provenance/citations and resolves them. Concretely, add a function in qa_validator.py:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _check_citation_resolution(case_id, decision_id) -> list[dict]:
|
||||||
|
"""GAP-20/INV-AUD3: every cited case_law_id must resolve to the corpus.
|
||||||
|
|
||||||
|
Reads case_law_ids from the decision's write_block audit provenance
|
||||||
|
(audit_log details.sources.case_law_ids) and verifies each resolves.
|
||||||
|
Unresolvable ids → non-blocking warning + audit('citation_unresolved').
|
||||||
|
"""
|
||||||
|
from legal_mcp.services import db, audit
|
||||||
|
from uuid import UUID
|
||||||
|
rows = await audit.get_audit_log(case_id=case_id, action="write_block", limit=200)
|
||||||
|
ids = set()
|
||||||
|
for r in rows:
|
||||||
|
details = r.get("details") or {}
|
||||||
|
if isinstance(details, str):
|
||||||
|
import json as _json
|
||||||
|
try: details = _json.loads(details)
|
||||||
|
except (ValueError, TypeError): details = {}
|
||||||
|
for raw in (details.get("sources") or {}).get("case_law_ids", []):
|
||||||
|
try: ids.add(UUID(str(raw)))
|
||||||
|
except (ValueError, TypeError): pass
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
res = await db.resolve_citation_case_law_ids(list(ids))
|
||||||
|
findings = []
|
||||||
|
if res["unresolved"]:
|
||||||
|
await audit.log_action_safe(
|
||||||
|
"citation_unresolved", case_id=case_id,
|
||||||
|
details={"unresolved": [str(x) for x in res["unresolved"]]},
|
||||||
|
)
|
||||||
|
findings.append({
|
||||||
|
"check": "citation_resolution",
|
||||||
|
"severity": "warning",
|
||||||
|
"passed": False,
|
||||||
|
"message": f"{len(res['unresolved'])} ציטוטים אינם פתירים לקורפוס — דורש אימות יו\"ר",
|
||||||
|
})
|
||||||
|
return findings
|
||||||
|
```
|
||||||
|
|
||||||
|
Then wire `_check_citation_resolution` into the validator's main run function so its findings are appended to the result list (match the existing findings shape — adjust the dict keys to the validator's actual schema discovered in Step 1). It must be a **warning**, never a critical gate (does not block export).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Smoke-import**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.services import qa_validator; print('clean')"`
|
||||||
|
Expected: `clean`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/services/qa_validator.py
|
||||||
|
git commit -m "feat(qa): citation→corpus resolution as non-blocking warning (GAP-20, FU-7)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: GAP-17 — blocks_stale wiring + health-check
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/tools/drafting.py`, `mcp-server/src/legal_mcp/services/metrics.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Set `blocks_stale=true` in `revise_draft` and `apply_user_edit`**
|
||||||
|
|
||||||
|
READ `revise_draft` (~647-733) and `apply_user_edit` (~569-613) in drafting.py. Each ends by calling `db.set_active_draft_path(case_id, ...)`. Immediately after that call in EACH function, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await db.mark_blocks_stale(case_id, True)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Clear `blocks_stale=false` in `export_docx`**
|
||||||
|
|
||||||
|
In `export_docx` (after the `set_active_draft_path` + the audit added in Task 3), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await db.mark_blocks_stale(case_id, False)
|
||||||
|
```
|
||||||
|
(export_docx renders FROM the blocks, so the DOCX matches blocks → not stale.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Health-check count in metrics.py**
|
||||||
|
|
||||||
|
READ `mcp-server/src/legal_mcp/services/metrics.py` — find the aggregation that already runs counts (the one FU-2a added `non_searchable_case_law` to). Add a sibling count:
|
||||||
|
|
||||||
|
```python
|
||||||
|
cases_with_stale_blocks = await conn.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM cases WHERE blocks_stale")
|
||||||
|
```
|
||||||
|
and expose it in the returned summary dict as `"cases_with_stale_blocks": cases_with_stale_blocks`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Smoke-import**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.tools import drafting; from legal_mcp.services import metrics; print('clean')"`
|
||||||
|
Expected: `clean`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/src/legal_mcp/tools/drafting.py mcp-server/src/legal_mcp/services/metrics.py
|
||||||
|
git commit -m "feat(audit): blocks_stale drift flag + health-check visibility (GAP-17, FU-7)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Full suite + DB smoke + lint + TaskMaster
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full offline suite**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/ -q`
|
||||||
|
Expected: all pass (FU-1/2a + new FU-7 tests). Report the summary line. If a pre-existing test fails because a newly-audited function now calls `audit`/`mark_blocks_stale` without a stub, fix that test's fixture to stub the new boundary (same pattern as the FU-2a `recompute_searchable` fixture fix).
|
||||||
|
|
||||||
|
- [ ] **Step 2: DB smoke (real Postgres — applies V22, exercises helpers)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai && set -a && source ~/.env 2>/dev/null && set +a
|
||||||
|
cd mcp-server && .venv/bin/python -c "
|
||||||
|
import asyncio, uuid
|
||||||
|
from legal_mcp.services import db, audit
|
||||||
|
async def main():
|
||||||
|
await db.get_pool() # applies V22
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as c:
|
||||||
|
col = await c.fetchval(\"SELECT 1 FROM information_schema.columns WHERE table_name='cases' AND column_name='blocks_stale'\")
|
||||||
|
print('V22 blocks_stale present:', bool(col))
|
||||||
|
# citation resolver: random id is unresolved
|
||||||
|
out = await db.resolve_citation_case_law_ids([uuid.uuid4()])
|
||||||
|
print('resolver unresolved count:', len(out['unresolved']))
|
||||||
|
# log_action_safe never raises
|
||||||
|
await audit.log_action_safe('fu7_smoke', details={'ok': True})
|
||||||
|
print('log_action_safe ok')
|
||||||
|
asyncio.run(main())
|
||||||
|
" 2>&1 | grep -vE 'INFO|WARNING|httpx|deprecat|command not found|\^\^\^' | tail -5
|
||||||
|
```
|
||||||
|
Expected: `V22 blocks_stale present: True`, `resolver unresolved count: 1`, `log_action_safe ok`. (Optionally clean the smoke row: `DELETE FROM audit_log WHERE action='fu7_smoke'`.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Lint**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m ruff check src/legal_mcp/services/audit.py src/legal_mcp/services/db.py src/legal_mcp/services/block_writer.py 2>/dev/null; echo "exit=$?"`
|
||||||
|
Expected: clean or "ruff not available".
|
||||||
|
|
||||||
|
- [ ] **Step 4: Mark TaskMaster #65 done** — controller edits `.taskmaster/tasks/tasks.json` + verifies via MCP get_task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **GAP-18** → Task 3 (+ write_block audit in Task 4). **GAP-19** → Task 4 (provenance event). **GAP-20** → Task 5 (resolver + QA warning). **GAP-17** → Tasks 2+6 (V22 flag + wiring + health).
|
||||||
|
- **No new table** (audit_log reused, X5 §4). **No data migration** (V22 additive; provenance forward-only).
|
||||||
|
- **Non-fatal audit:** all calls via `log_action_safe`. **GAP-20 is warning-only** (never a critical gate — doesn't block export, consistent with FU-6 gates).
|
||||||
|
- **Type consistency:** `log_action_safe`, `mark_blocks_stale(case_id, stale)`, `resolve_citation_case_law_ids(ids)->{resolved,unresolved}`, `result["sources"]={document_ids,claim_ids,case_law_ids}` — names identical across tasks + tests.
|
||||||
|
- **Offline-test limit:** real audit_log INSERT / V22 verified by Task 7 Step 2 smoke; offline tests cover the pure wrappers/resolver logic.
|
||||||
254
docs/superpowers/plans/2026-05-30-system-spec-set.md
Normal file
254
docs/superpowers/plans/2026-05-30-system-spec-set.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# System Spec-Set (Sub-Project 1) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Author the living system spec-set under `docs/spec/` that canonically defines the *עוזר משפטי* system and its invariants ("what is correct"), each invariant backed by ≥3 authoritative sources.
|
||||||
|
|
||||||
|
**Architecture:** A `00-constitution.md` keystone (mission, global invariants, engineering rules, invariant template, verification protocol, index) + lifecycle-organized domain files (`01-ingest` … `07-learning`) + cross-cutting files (`X1`…`X5`). Existing docs are cited as verified sources, never duplicated. This is documentation, not code: the "test" is the **verification gate** — every invariant carries ≥3 verified sources or is marked `⚠ UNVERIFIED` and escalated to the chair (never decided solo).
|
||||||
|
|
||||||
|
**Tech Stack:** Markdown. Sources verified via WebSearch/WebFetch + primary texts (Nevo for Israeli statutes). Design basis: [docs/superpowers/specs/2026-05-30-system-spec-design.md](../specs/2026-05-30-system-spec-design.md).
|
||||||
|
|
||||||
|
**Branch:** `system-spec` (already created; design doc committed at `a5b22da`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions for every file (apply in each task)
|
||||||
|
|
||||||
|
- **Invariant template** (use verbatim structure):
|
||||||
|
```
|
||||||
|
### INV-<DOMAIN><n>: <short title>
|
||||||
|
**כלל:** <one crisp normative statement — what MUST hold>
|
||||||
|
**מקורות:** <≥3 authorities> | סטטוס: verified / ⚠ UNVERIFIED
|
||||||
|
**אכיפה:** <where/how enforced — schema / write-validation / health-check / human gate>
|
||||||
|
**הפרה ידועה:** <example from the system if any → links to audit; else "—">
|
||||||
|
```
|
||||||
|
- **Language:** Hebrew prose, English for technical terms and source names (matches project docs + RTL preference).
|
||||||
|
- **Length target:** ≤ ~500 lines/file. If exceeding, that domain needs splitting — note it, don't cram.
|
||||||
|
- **Citing existing docs:** reference (e.g., `block-schema.md`) as a *source to verify*; if it contradicts the ≥3 authorities, record a one-line audit-finding rather than silently trusting it.
|
||||||
|
- **Cross-links:** link sibling spec files by relative path; link global invariants as `00-constitution.md#inv-g<n>`.
|
||||||
|
|
||||||
|
## Per-file verification gate (the "test")
|
||||||
|
|
||||||
|
A file passes only when ALL hold (this checklist is a literal step in each task):
|
||||||
|
1. Every `INV-*` has either ≥3 named authoritative sources (`verified`) or is marked `⚠ UNVERIFIED` with an escalation note.
|
||||||
|
2. No placeholder text (`TBD`/`TODO`/"להשלים").
|
||||||
|
3. All cross-links resolve to a real file/anchor.
|
||||||
|
4. Consistent with `00-constitution.md` (no invariant contradicts a global invariant).
|
||||||
|
5. ≤ ~500 lines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Scaffold
|
||||||
|
|
||||||
|
### Task 0: Create the spec directory
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/spec/README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `docs/spec/` with a short README**
|
||||||
|
|
||||||
|
Write `docs/spec/README.md`:
|
||||||
|
```markdown
|
||||||
|
# ספ המערכת — עוזר משפטי (Living System Spec)
|
||||||
|
|
||||||
|
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
|
||||||
|
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
|
||||||
|
|
||||||
|
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X5 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
||||||
|
בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.md
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/spec/README.md
|
||||||
|
git commit -m "docs(spec): scaffold docs/spec/ living spec-set"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Keystone (REVIEW CHECKPOINT after)
|
||||||
|
|
||||||
|
### Task 1: `00-constitution.md` — the keystone
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/spec/00-constitution.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the constitution** with these sections (content is already determined by the approved design):
|
||||||
|
|
||||||
|
1. **ייעוד** — paste the confirmed mission paragraph from the design doc §2.
|
||||||
|
2. **עקרונות-עבודה** — the 4 work principles (design doc §3): don't assume existing is correct; 3-source protocol; research→draft; collaboration model.
|
||||||
|
3. **תבנית-invariant** — the template from "Conventions" above.
|
||||||
|
4. **פרוטוקול-אימות** — `verified` vs `⚠ UNVERIFIED`; escalation to chair; never decide solo.
|
||||||
|
5. **Invariants גלובליים G1–G11** — each written with the full template. Content + sources from design doc §6 / §9:
|
||||||
|
|
||||||
|
- **INV-G1 מזהה קנוני מנורמל בכתיבה** — SSOT/normalization · Codd 1NF (CACM 13(6), 1970) · Kleppmann DDIA. אכיפה: normalization-on-write in the ingest path + `X1-identifiers.md`. הפרה ידועה: tolerant `_normalize_case_number` on read only; `8126-25` vs `8126-03-25`.
|
||||||
|
- **INV-G2 מקור-אמת יחיד, אין מסלולים מקבילים מתפצלים** — Kleppmann (system of record) · Fowler (Canonical Data Model) · SSOT. אכיפה: one canonical ingest path; siblings share it. הפרה ידועה: `ingest_precedent` vs `ingest_internal_decision` asymmetry.
|
||||||
|
- **INV-G3 ingest אחיד ו-idempotent (upsert על מפתח דטרמיניסטי)** — Kleppmann · Stripe/CDC idempotency · ISO 8000. אכיפה: `01-ingest.md` unified path.
|
||||||
|
- **INV-G4 חוזה-שלמות לפני "שמיש/ניתן-לחיפוש"** — ISO 8000 · DAMA-UK (completeness) · ISO 15489 (reliability). אכיפה: write-validation + health-check; `02-data-model.md`. הפרה ידועה: ערן סופר 8046/24 indexed with empty headnote/summary/tags.
|
||||||
|
- **INV-G5 metadata מלא לכל פריט מואנדקס + הפרדת-קורפוס בכל query** — Pinecone (multitenancy) · RAG attribution (Lewis et al.) · ISO 8000. אכיפה: `03-retrieval.md`. הפרה ידועה: task #56 halacha_filters source_kind leak.
|
||||||
|
- **INV-G6 re-index בכל שינוי תוכן** — Pinecone · Weaviate · RAG freshness. אכיפה: ingest/update path.
|
||||||
|
- **INV-G7 מיזוג RRF לא סכום-ציונים** — Elastic (RRF) · Weaviate · OpenSearch/Azure (corrob.). אכיפה: retrieval fusion (already implemented — codified).
|
||||||
|
- **INV-G8 איכות-אחזור נמדדת (precision+recall)** — Manning IR textbook · RAG eval literature · (Elastic eval guidance). אכיפה: eval harness in `03-retrieval.md`.
|
||||||
|
- **INV-G9 עקיבוּת-מקור + audit-trail ל-AI** — CEPEJ (user control) · NCSC · ISO 15489. אכיפה: `X5-audit-provenance.md`.
|
||||||
|
- **INV-G10 המערכת מסייעת; שערים אנושיים = invariant** — NCSC ("never replace human judgment") · CEPEJ · FJC. אכיפה: `05-qa-review.md` human gates.
|
||||||
|
- **INV-G11 תוכן החלטה מנומקת** (רקע ניטרלי · ללא כפילות · מענה לטענות המפסיד · מבחן-השופט · טענות מקוריות) — FJC Writing Manual · South Bucks [2004] UKHL 33 · חוק לתיקון סדרי המינהל (החלטות והנמקות) תשי"ט-1958. אכיפה: `04-analysis-writing.md` + `05-qa-review.md`.
|
||||||
|
|
||||||
|
6. **כללי-הנדסה** — סימטריה · נרמול-לא-תיקון-תסמין · quality-at-source (Fowler/Data-Mesh) · אין בליעה שקטה.
|
||||||
|
7. **אינדקס** — table linking all spec files (00, 01–07, X1–X5) with one-line purpose each.
|
||||||
|
8. **נספח מקורות** — paste the full source appendix from design doc §9.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the per-file verification gate** (the 5-point checklist above). Fix inline.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/spec/00-constitution.md
|
||||||
|
git commit -m "docs(spec): 00-constitution — mission, 11 global invariants, engineering rules"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: REVIEW CHECKPOINT** — present `00-constitution.md` to חיים. Do not start Phase 2 until approved. If the constitution's framing changes, the domain files adapt to it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Lifecycle domain files
|
||||||
|
|
||||||
|
> Each task: (a) targeted research to verify domain-specific invariants to ≥3 sources (global invariants already verified — reuse their sources; only NEW domain claims need fresh sourcing); (b) draft the file; (c) run the verification gate; (d) commit. Group review checkpoint at end of Phase 2.
|
||||||
|
|
||||||
|
### Task 2: `01-ingest.md` — unified intake contract
|
||||||
|
|
||||||
|
**Files:** Create `docs/spec/01-ingest.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Document the **target single ingest path** for all three intake kinds (case documents / external precedent / internal-committee decisions). Describe the canonical pipeline: stage file → extract text → chunk → embed → store → queue metadata extraction → queue halacha extraction → set statuses. State which steps are **uniform across all kinds** (this is the fix for the asymmetry).
|
||||||
|
- [ ] **Step 2:** Define domain invariants applying INV-G2/G3/G4/G6 to ingest, e.g.:
|
||||||
|
- **INV-ING1:** every intake kind flows through the same canonical ingest function; a new kind extends it via parameters, never a parallel function. (sources: INV-G2 set)
|
||||||
|
- **INV-ING2:** ingest is idempotent on the canonical identifier (re-ingest = upsert, no duplicate row/chunks). (sources: INV-G3 set)
|
||||||
|
- **INV-ING3:** metadata extraction is queued for *every* kind that has extractable metadata — not conditional per path. (sources: INV-G4 set; הפרה ידועה: internal path skipped `request_metadata_extraction`)
|
||||||
|
- [ ] **Step 3:** Cite current reality as audit-findings (the 8 documented asymmetries from the design research) — as `הפרה ידועה` lines, not as "correct."
|
||||||
|
- [ ] **Step 4:** Run verification gate. **Step 5:** Commit `docs(spec): 01-ingest unified intake contract`.
|
||||||
|
|
||||||
|
### Task 3: `02-data-model.md` — entities + completeness contract
|
||||||
|
|
||||||
|
**Files:** Create `docs/spec/02-data-model.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Enumerate the canonical entities (cases, case_law, documents, chunks, halachot, chair_feedback, …) — name, purpose, key fields. Mark this as the **target** model (verify field names against current schema during execution; divergences → audit-findings).
|
||||||
|
- [ ] **Step 2:** Define the **completeness contract per entity** — the mandatory-field set that makes a record "usable/searchable" (INV-G4). For `case_law`: e.g., canonical case_number, case_name, court, practice_area, source_kind, + (for searchable) ≥1 chunk and non-empty metadata. State explicitly that records failing the contract are flagged, not silently searchable.
|
||||||
|
- **INV-DM1:** a case_law row is "searchable" only when its completeness contract is satisfied. (sources: ISO 8000 · DAMA-UK · ISO 15489)
|
||||||
|
- **INV-DM2:** each entity has exactly one canonical identifier; no field stores a full citation as the identifier. (sources: INV-G1 set; הפרה ידועה: citation-as-case_number for סופר entries)
|
||||||
|
- [ ] **Step 3:** Run gate. **Step 4:** Commit `docs(spec): 02-data-model entities + completeness contract`.
|
||||||
|
|
||||||
|
### Task 4: `03-retrieval.md` — corpora + retrieval invariants
|
||||||
|
|
||||||
|
**Files:** Create `docs/spec/03-retrieval.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Document the 3 corpora + their search tools (source_kind mapping) and the hybrid/RRF design. (Reuse research from design §9 RAG sources — already verified.)
|
||||||
|
- [ ] **Step 2:** Define invariants (apply INV-G5/G6/G7/G8/G9):
|
||||||
|
- **INV-RET1:** corpus separation enforced on 100% of query paths (chunks AND halachot filters). (Pinecone · ISO · RAG; הפרה ידועה: task #56)
|
||||||
|
- **INV-RET2:** no item indexed without complete required metadata + resolvable source locator. (INV-G5 set)
|
||||||
|
- **INV-RET3:** heterogeneous retrievers fused by RRF, never raw-score sum. (Elastic · Weaviate)
|
||||||
|
- **INV-RET4:** retrieval quality measured by a standing precision+recall eval harness on a fixed labeled query set. (Manning · RAG eval)
|
||||||
|
- **INV-RET5:** every returned span is attributable to its source. (CEPEJ · RAG)
|
||||||
|
- [ ] **Step 3:** Run gate. **Step 4:** Commit `docs(spec): 03-retrieval corpora + retrieval invariants`.
|
||||||
|
|
||||||
|
### Task 5: `04-analysis-writing.md` — claims, 12 blocks, Dafna style
|
||||||
|
|
||||||
|
**Files:** Create `docs/spec/04-analysis-writing.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Reference (cite, don't duplicate) `block-schema.md`, `decision-methodology.md`, `skills/decision/SKILL.md` as sources; summarize the 12-block model + claims extraction at spec altitude.
|
||||||
|
- [ ] **Step 2:** Verify the Israeli reasoned-decision sources (design doc §8 open items #1–#3): confirm exact section of חוק 1958 (תשכ"ט-1969 amendment) on Nevo; confirm/locate ברק-ארז citation; confirm בג"ץ 143/56 / עע"ם 2994/21. Mark each `verified` or `⚠ UNVERIFIED` + escalate.
|
||||||
|
- [ ] **Step 3:** Define invariants from INV-G11:
|
||||||
|
- **INV-WR1:** block ו (background) is neutral — no judgment words, no party quotes. (FJC · חובת הנמקה)
|
||||||
|
- **INV-WR2:** no duplication — block י references prior blocks, does not restate facts. (FJC §non-duplication)
|
||||||
|
- **INV-WR3:** every losing-side principal argument is addressed. (FJC · South Bucks adequacy)
|
||||||
|
- **INV-WR4:** block ז = original claims only; supplements go to block ח. (project rule; cite corpus-analysis)
|
||||||
|
- **INV-WR5:** judge-unfamiliar-with-case test — decision is self-contained and traceable. (FJC · South Bucks)
|
||||||
|
- [ ] **Step 4:** Run gate. **Step 5:** Commit `docs(spec): 04-analysis-writing — 12 blocks + reasoned-decision invariants`.
|
||||||
|
|
||||||
|
### Task 6: `05-qa-review.md` — QA gates + human gates
|
||||||
|
|
||||||
|
**Files:** Create `docs/spec/05-qa-review.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Document the existing automated QA gates (`validate_decision`: neutral_background, claims_coverage, weight_compliance, structural_integrity, no_duplication, sequential_numbering) — as the QA contract (verify against `qa_validator.py` at execution).
|
||||||
|
- [ ] **Step 2:** Define human-gate invariants (INV-G10):
|
||||||
|
- **INV-QA1:** halacha approval is a manual chair decision; auto-extracted halachot are `pending_review` until the chair approves. (NCSC · CEPEJ · project rule)
|
||||||
|
- **INV-QA2:** outcome selection and chair feedback are human gates, never automated. (NCSC · CEPEJ · FJC)
|
||||||
|
- **INV-QA3:** a decision cannot be exported while critical QA gates fail. (FJC · validate_decision design)
|
||||||
|
- [ ] **Step 3:** Run gate. **Step 4:** Commit `docs(spec): 05-qa-review — QA + human gates`.
|
||||||
|
|
||||||
|
### Task 7: `06-export.md` — DOCX export contract
|
||||||
|
|
||||||
|
**Files:** Create `docs/spec/06-export.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Reference `skills/dafna-decision-template/SKILL.md`; document the export contract: line classification, dash policy, placeholder handling, template styles. Define:
|
||||||
|
- **INV-EX1:** export is deterministic from the stored decision blocks (single source = DB blocks; the DOCX is derived). (INV-G2 derived-data set)
|
||||||
|
- **INV-EX2:** export preserves source traceability where required. (INV-G9)
|
||||||
|
- [ ] **Step 2:** Run gate. **Step 3:** Commit `docs(spec): 06-export DOCX contract`.
|
||||||
|
|
||||||
|
### Task 8: `07-learning.md` — Hermes, lessons, feedback loop
|
||||||
|
|
||||||
|
**Files:** Create `docs/spec/07-learning.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Document the learning loop: Hermes curator (post-export analysis), `docs/legal-decision-lessons.md`, chair-feedback weekly analysis. Define:
|
||||||
|
- **INV-LRN1:** curator proposes; changes to SKILL.md/lessons.md require manual chair approval. (INV-G10; project rule)
|
||||||
|
- **INV-LRN2:** quality accountability sits at the source (ingest/authoring), not downstream. (Fowler/Data-Mesh)
|
||||||
|
- [ ] **Step 2:** Run gate. **Step 3:** Commit `docs(spec): 07-learning loop`.
|
||||||
|
|
||||||
|
- [ ] **Phase 2 REVIEW CHECKPOINT** — present `01`–`07` to חיים for review before Phase 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Cross-cutting files (final REVIEW after)
|
||||||
|
|
||||||
|
### Task 9: `X1-identifiers.md` — canonical identifier model
|
||||||
|
|
||||||
|
**Files:** Create `docs/spec/X1-identifiers.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Define the canonical case_number model: the normalized written form, the relationship `cases.case_number` vs `case_law.case_number`, and citation formats. Specify **normalize-on-write** (INV-G1), with tolerant-match-on-read as a *secondary* convenience, not the primary mechanism.
|
||||||
|
- **INV-ID1:** case_number is normalized to canonical form at write time. (SSOT · Codd · Kleppmann)
|
||||||
|
- **INV-ID2:** no entity uses a full citation string as its identifier. (INV-G1; הפרה ידועה: סופר entries)
|
||||||
|
- [ ] **Step 2:** Run gate. **Step 3:** Commit `docs(spec): X1-identifiers canonical model`.
|
||||||
|
|
||||||
|
### Task 10: `X2-multi-company.md`
|
||||||
|
|
||||||
|
**Files:** Create `docs/spec/X2-multi-company.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Document CMP (1xxx) / CMPA (8xxx), 14 agents (7×2), and the sync rules (cite `sync_agents_across_companies.py`, `HEARTBEAT.md`). Define:
|
||||||
|
- **INV-MC1:** any agent-config change in master must be synced to the mirror company via the API sync script. (project rule)
|
||||||
|
- [ ] **Step 2:** Run gate. **Step 3:** Commit `docs(spec): X2-multi-company`.
|
||||||
|
|
||||||
|
### Task 11: `X3-integration-deploy.md`
|
||||||
|
|
||||||
|
**Files:** Create `docs/spec/X3-integration-deploy.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Document Paperclip integration (wakeup via API not DB; comment routing via CEO; outbound case-status webhook) and the deploy model (Coolify dockerimage for legal-ai; pm2 for paperclip/chat-service). Define:
|
||||||
|
- **INV-INT1:** Paperclip wakeup goes through `POST /api/agents/{id}/wakeup` with `payload.issueId`, never a direct DB insert. (project rule; cite memory reference)
|
||||||
|
- **INV-INT2:** legal-ai code changes require commit→push→Coolify deploy; no local uvicorn. (project rule)
|
||||||
|
- [ ] **Step 2:** Run gate. **Step 3:** Commit `docs(spec): X3-integration-deploy`.
|
||||||
|
|
||||||
|
### Task 12: `X4-agents.md`
|
||||||
|
|
||||||
|
**Files:** Create `docs/spec/X4-agents.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Map the domain agents (ceo, researcher, analyst, writer, qa, proofreader, exporter, hermes) — role + which spec files each must read. Reserve a section for the **process agents** (sub-project 5: add-feature / fix-feature / spec-guardian) to be defined later. Define:
|
||||||
|
- **INV-AG1:** every agent reads `00-constitution.md` first and the relevant domain spec before acting. (governance rule)
|
||||||
|
- [ ] **Step 2:** Run gate. **Step 3:** Commit `docs(spec): X4-agents map`.
|
||||||
|
|
||||||
|
### Task 13: `X5-audit-provenance.md`
|
||||||
|
|
||||||
|
**Files:** Create `docs/spec/X5-audit-provenance.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Define the audit-trail + provenance requirements (INV-G9): logging of AI-assisted generation, traceability of every cited authority/source in a decision back to the corpus, record integrity over time.
|
||||||
|
- **INV-AUD1:** every AI-assisted artifact records what sources/data produced it. (CEPEJ user-control · NCSC · ISO 15489)
|
||||||
|
- **INV-AUD2:** record integrity — a stored decision/record is complete and unaltered except via tracked, attributed changes. (ISO 15489 §5.2.2.3)
|
||||||
|
- [ ] **Step 2:** Run gate. **Step 3:** Commit `docs(spec): X5-audit-provenance`.
|
||||||
|
|
||||||
|
- [ ] **FINAL REVIEW** — present the complete spec-set to חיים. On approval, sub-project 1 is done; proceed to sub-project 2 (Audit) in its own spec→plan cycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (run after writing this plan)
|
||||||
|
|
||||||
|
- **Spec coverage:** every design-doc section maps to a task — mission/principles → Task 1; G1–G11 → Task 1 + applied in 2–13; spec-set structure → Tasks 0–13; verification protocol → conventions + gate; open legal items → Task 5 Step 2. ✓
|
||||||
|
- **Placeholder scan:** domain-file invariants are enumerated with IDs + sources, not "define later"; the only deferred content is the process-agents section (Task 12) which is explicitly sub-project 5, and the legal `⚠ UNVERIFIED` items (Task 5) which are an intentional escalation, not a placeholder. ✓
|
||||||
|
- **Type/name consistency:** invariant IDs are unique (G1–G11, ING1–3, DM1–2, RET1–5, WR1–5, QA1–3, EX1–2, LRN1–2, ID1–2, MC1, INT1–2, AG1, AUD1–2); file names consistent with design doc §5. ✓
|
||||||
|
```
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
# FU-2b: Internal Identifier Reconciliation — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a reversible, chair-gated migration script that rewrites `internal_committee` `case_number` values currently holding a full citation into the canonical normalized bare number (X1: trim · prefix-strip · `/`→`-`, month preserved), leaving `citation_formatted` untouched.
|
||||||
|
|
||||||
|
**Architecture:** A standalone `scripts/` migration (not editable-service code), `--dry-run` by default. Dry-run emits a reconciliation table (CSV + Hebrew Markdown) for chair review; `--apply --approved <csv>` writes a backup then updates only chair-approved rows. Extraction is deterministic (single number-token regex) — no LLM. The production apply runs only AFTER Dafna approves the table.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, asyncpg, PostgreSQL@localhost:5433, pytest offline, `.venv` at `mcp-server/.venv`.
|
||||||
|
|
||||||
|
**Spec:** [docs/superpowers/specs/2026-05-31-fu2b-identifier-reconciliation-design.md](../specs/2026-05-31-fu2b-identifier-reconciliation-design.md)
|
||||||
|
|
||||||
|
**Run script:** `PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python; $PY scripts/fu2b_reconcile_internal_case_numbers.py` (dry-run)
|
||||||
|
**Run tests:** `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_fu2b_reconcile.py -v`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Create** `scripts/fu2b_reconcile_internal_case_numbers.py` — the migration (pure `_extract_bare` + reconciliation builder + table/backup/revert writers + argparse `--dry-run`/`--apply`).
|
||||||
|
- **Create** `mcp-server/tests/test_fu2b_reconcile.py` — offline tests for `_extract_bare` + consistency flagging (imports the script module via sys.path).
|
||||||
|
- **Modify** `scripts/SCRIPTS.md` — register the new script (CLAUDE.md rule).
|
||||||
|
- **Artifact (produced, committed for review)** `data/audit/fu2b-reconciliation-<ts>.md` — the chair table from the dry-run.
|
||||||
|
|
||||||
|
No service code changes; no schema change. FK-safe (all `case_law` FKs use `id` UUID — verified).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Failing tests for `_extract_bare`
|
||||||
|
|
||||||
|
**Files:** Create `mcp-server/tests/test_fu2b_reconcile.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""FU-2b: deterministic bare-number extraction (offline)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Load the migration script as a module (it lives in scripts/, not a package).
|
||||||
|
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "fu2b_reconcile_internal_case_numbers.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("fu2b_reconcile", _SCRIPT)
|
||||||
|
fu2b = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(fu2b)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected_bare", [
|
||||||
|
("ערר (ועדות ערר - תכנון ובנייה ירושלים) 403/17 אהרון ברק נ'", "403-17"),
|
||||||
|
("ערר (...) 8136-10-24 שחר שות'", "8136-10-24"), # month preserved
|
||||||
|
("בל\"מ (...) 1028/20 חלוואני ריאד", "1028-20"),
|
||||||
|
("8047/23", "8047-23"), # already-bare-ish
|
||||||
|
("ערר 81002-01-21", "81002-01-21"),
|
||||||
|
])
|
||||||
|
def test_extract_bare_single_token(raw, expected_bare):
|
||||||
|
bare, flag = fu2b._extract_bare(raw)
|
||||||
|
assert bare == expected_bare
|
||||||
|
assert flag == "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_bare_no_number():
|
||||||
|
bare, flag = fu2b._extract_bare("ערר אדלר נ' הוועדה")
|
||||||
|
assert bare is None and flag == "NO_NUMBER"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_bare_multiple_numbers_flagged():
|
||||||
|
# Two case-number-shaped tokens → ambiguous, must NOT auto-pick.
|
||||||
|
bare, flag = fu2b._extract_bare("ערר 403/17 ו-1024/24 מאוחדים")
|
||||||
|
assert bare is None and flag == "MULTI_NUMBER"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_bare_preserves_month_not_padding():
|
||||||
|
# Month kept exactly; 2-part stays 2-part (no invented month).
|
||||||
|
assert fu2b._extract_bare("ערר 8126/24 פלוני")[0] == "8126-24"
|
||||||
|
assert fu2b._extract_bare("ערר 8126-03-25 פלוני")[0] == "8126-03-25"
|
||||||
|
|
||||||
|
|
||||||
|
def test_consistency_flag_when_bare_absent_from_citation():
|
||||||
|
# proposed bare must appear in citation_formatted, else MISMATCH.
|
||||||
|
assert fu2b._consistency_flag("403-17", "ערר (...) 403/17 אהרון ברק") == "OK"
|
||||||
|
assert fu2b._consistency_flag("403-17", "ערר (...) 1975/24 מישהו אחר") == "MISMATCH"
|
||||||
|
assert fu2b._consistency_flag("403-17", "") == "NO_CITATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify failure**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_fu2b_reconcile.py -v`
|
||||||
|
Expected: FAIL — `FileNotFoundError`/`ModuleNotFoundError` (script doesn't exist) or `AttributeError: _extract_bare`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/tests/test_fu2b_reconcile.py
|
||||||
|
git commit -m "test(fu2b): failing tests for bare-number extraction (FU-2b)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: The migration script (dry-run + apply + backup)
|
||||||
|
|
||||||
|
**Files:** Create `scripts/fu2b_reconcile_internal_case_numbers.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the script**
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""FU-2b — reconcile internal_committee case_number → canonical bare number.
|
||||||
|
|
||||||
|
Rewrites case_number values that currently hold a full citation into the
|
||||||
|
canonical normalized bare number (X1: trim · prefix-strip · '/'→'-', month
|
||||||
|
preserved). citation_formatted is the display field and is left untouched.
|
||||||
|
|
||||||
|
DETERMINISTIC — no LLM. Extraction takes the single case-number-shaped token
|
||||||
|
from the value; 0 or >1 tokens are flagged for chair review, never guessed.
|
||||||
|
|
||||||
|
Usage (must use the mcp-server venv — asyncpg/pgvector vendored there):
|
||||||
|
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
|
||||||
|
|
||||||
|
# Dry-run (default): builds the reconciliation table for chair review.
|
||||||
|
$PY scripts/fu2b_reconcile_internal_case_numbers.py
|
||||||
|
|
||||||
|
# Apply ONLY the chair-approved rows (after Dafna's review), backup first:
|
||||||
|
$PY scripts/fu2b_reconcile_internal_case_numbers.py --apply \
|
||||||
|
--approved data/audit/fu2b-approved-<ts>.csv
|
||||||
|
|
||||||
|
Scope: source_kind='internal_committee' only (external → #68/FU-2c). FK-safe:
|
||||||
|
all case_law FKs reference case_law.id (UUID), not case_number.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
|
||||||
|
|
||||||
|
if "POSTGRES_URL" not in os.environ:
|
||||||
|
os.environ["POSTGRES_URL"] = (
|
||||||
|
f"postgres://{os.environ.get('POSTGRES_USER','legal_ai')}:"
|
||||||
|
f"{os.environ.get('POSTGRES_PASSWORD','')}@"
|
||||||
|
f"{os.environ.get('POSTGRES_HOST','127.0.0.1')}:"
|
||||||
|
f"{os.environ.get('POSTGRES_PORT','5433')}/"
|
||||||
|
f"{os.environ.get('POSTGRES_DB','legal_ai')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
AUDIT_DIR = REPO_ROOT / "data" / "audit"
|
||||||
|
_TOKEN_RE = re.compile(r"[0-9]{2,6}(?:[-/][0-9]{1,2}){1,2}")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bare(case_number: str) -> tuple[str | None, str]:
|
||||||
|
"""Return (canonical_bare, flag). flag ∈ {OK, NO_NUMBER, MULTI_NUMBER}.
|
||||||
|
|
||||||
|
Deterministic: finds case-number-shaped tokens (NNNN/YY or NNNN-MM-YY).
|
||||||
|
Exactly one → normalize '/'→'-' (month preserved, none invented). 0 or >1
|
||||||
|
→ None + flag (chair decides; never guess).
|
||||||
|
"""
|
||||||
|
tokens = _TOKEN_RE.findall(case_number or "")
|
||||||
|
if len(tokens) == 1:
|
||||||
|
return tokens[0].replace("/", "-"), "OK"
|
||||||
|
if not tokens:
|
||||||
|
return None, "NO_NUMBER"
|
||||||
|
return None, "MULTI_NUMBER"
|
||||||
|
|
||||||
|
|
||||||
|
def _consistency_flag(bare: str | None, citation_formatted: str) -> str:
|
||||||
|
"""OK if bare appears in citation_formatted; MISMATCH if not; NO_CITATION if empty."""
|
||||||
|
if not citation_formatted:
|
||||||
|
return "NO_CITATION"
|
||||||
|
if not bare:
|
||||||
|
return "NO_NUMBER"
|
||||||
|
# compare against the citation with separators unified, to match 403/17 vs 403-17
|
||||||
|
cf = citation_formatted.replace("/", "-")
|
||||||
|
return "OK" if bare in cf else "MISMATCH"
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_reconciliation() -> list[dict]:
|
||||||
|
from legal_mcp.services import db
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT id, case_number, proceeding_type, coalesce(citation_formatted,'') AS cf "
|
||||||
|
"FROM case_law WHERE source_kind='internal_committee' ORDER BY case_number")
|
||||||
|
# detect dup serials across proceeding_type for a DUP_CHECK flag
|
||||||
|
out: list[dict] = []
|
||||||
|
for r in rows:
|
||||||
|
bare, flag = _extract_bare(r["case_number"])
|
||||||
|
cons = _consistency_flag(bare, r["cf"])
|
||||||
|
changes = bare is not None and bare != r["case_number"]
|
||||||
|
out.append({
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"current_case_number": r["case_number"],
|
||||||
|
"proposed_bare": bare or "",
|
||||||
|
"proceeding_type": r["proceeding_type"] or "",
|
||||||
|
"citation_formatted": r["cf"],
|
||||||
|
"extract_flag": flag,
|
||||||
|
"consistency": cons,
|
||||||
|
"will_change": "yes" if changes else "no",
|
||||||
|
})
|
||||||
|
# DUP_CHECK: same proposed_bare appearing on >1 row (any proceeding_type)
|
||||||
|
from collections import Counter
|
||||||
|
bare_counts = Counter(d["proposed_bare"] for d in out if d["proposed_bare"])
|
||||||
|
for d in out:
|
||||||
|
if d["proposed_bare"] and bare_counts[d["proposed_bare"]] > 1:
|
||||||
|
d["dup_check"] = "DUP_CHECK"
|
||||||
|
else:
|
||||||
|
d["dup_check"] = ""
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _ts() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_table(rows: list[dict], ts: str) -> tuple[Path, Path]:
|
||||||
|
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
csv_path = AUDIT_DIR / f"fu2b-reconciliation-{ts}.csv"
|
||||||
|
md_path = AUDIT_DIR / f"fu2b-reconciliation-{ts}.md"
|
||||||
|
cols = ["id", "current_case_number", "proposed_bare", "proceeding_type",
|
||||||
|
"citation_formatted", "extract_flag", "consistency", "dup_check", "will_change"]
|
||||||
|
with csv_path.open("w", newline="", encoding="utf-8") as f:
|
||||||
|
w = csv.DictWriter(f, fieldnames=cols)
|
||||||
|
w.writeheader()
|
||||||
|
w.writerows(rows)
|
||||||
|
changing = [r for r in rows if r["will_change"] == "yes"]
|
||||||
|
flagged = [r for r in rows if r["extract_flag"] != "OK" or r["consistency"] == "MISMATCH" or r["dup_check"]]
|
||||||
|
with md_path.open("w", encoding="utf-8") as f:
|
||||||
|
f.write(f"# FU-2b — טבלת-תיאום מזהים (internal_committee) — {ts}\n\n")
|
||||||
|
f.write(f"- סה\"כ רשומות: {len(rows)}\n- ישתנו: {len(changing)}\n- מסומנות לסקירה: {len(flagged)}\n\n")
|
||||||
|
f.write("## דורש הכרעת-יו\"ר (flags)\n\n")
|
||||||
|
f.write("| current_case_number | proposed_bare | proc | flags |\n|---|---|---|---|\n")
|
||||||
|
for r in flagged:
|
||||||
|
fl = " ".join(x for x in [r["extract_flag"] if r["extract_flag"] != "OK" else "",
|
||||||
|
r["consistency"] if r["consistency"] == "MISMATCH" else "",
|
||||||
|
r["dup_check"]] if x)
|
||||||
|
f.write(f"| {r['current_case_number'][:50]} | {r['proposed_bare']} | {r['proceeding_type']} | {fl} |\n")
|
||||||
|
f.write("\n## כל השינויים המוצעים\n\n")
|
||||||
|
f.write("| current_case_number | → proposed_bare | proc |\n|---|---|---|\n")
|
||||||
|
for r in changing:
|
||||||
|
f.write(f"| {r['current_case_number'][:55]} | {r['proposed_bare']} | {r['proceeding_type']} |\n")
|
||||||
|
return csv_path, md_path
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply(approved_csv: Path, ts: str) -> dict:
|
||||||
|
from legal_mcp.services import db
|
||||||
|
with approved_csv.open(encoding="utf-8") as f:
|
||||||
|
approved = [r for r in csv.DictReader(f)
|
||||||
|
if r.get("will_change") == "yes" and r.get("proposed_bare")]
|
||||||
|
if not approved:
|
||||||
|
return {"applied": 0, "note": "no approved changing rows"}
|
||||||
|
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
backup = AUDIT_DIR / f"fu2b-backup-{ts}.csv"
|
||||||
|
pool = await db.get_pool()
|
||||||
|
applied = 0
|
||||||
|
with backup.open("w", newline="", encoding="utf-8") as bf:
|
||||||
|
bw = csv.writer(bf)
|
||||||
|
bw.writerow(["id", "old_case_number"])
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
for r in approved:
|
||||||
|
old = await conn.fetchval("SELECT case_number FROM case_law WHERE id=$1", r["id"])
|
||||||
|
if old is None:
|
||||||
|
continue
|
||||||
|
bw.writerow([r["id"], old])
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE case_law SET case_number=$2 WHERE id=$1 "
|
||||||
|
"AND source_kind='internal_committee'",
|
||||||
|
r["id"], r["proposed_bare"])
|
||||||
|
applied += 1
|
||||||
|
return {"applied": applied, "backup": str(backup)}
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="FU-2b internal case_number reconciliation")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="apply approved changes (default: dry-run)")
|
||||||
|
parser.add_argument("--approved", type=str, help="path to chair-approved CSV (required with --apply)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
ts = _ts()
|
||||||
|
|
||||||
|
if not args.apply:
|
||||||
|
rows = await _build_reconciliation()
|
||||||
|
csv_path, md_path = _write_table(rows, ts)
|
||||||
|
changing = sum(1 for r in rows if r["will_change"] == "yes")
|
||||||
|
flagged = sum(1 for r in rows if r["extract_flag"] != "OK" or r["consistency"] == "MISMATCH" or r["dup_check"])
|
||||||
|
print(f"DRY-RUN: {len(rows)} rows | will_change={changing} | flagged={flagged}")
|
||||||
|
print(f" table: {md_path}")
|
||||||
|
print(f" csv: {csv_path}")
|
||||||
|
print("Review the table with the chair, then run --apply --approved <reviewed.csv>.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not args.approved:
|
||||||
|
print("ERROR: --apply requires --approved <csv> (the chair-reviewed table).", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
result = await _apply(Path(args.approved), ts)
|
||||||
|
print(f"APPLIED: {result}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(asyncio.run(main()))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the unit tests**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_fu2b_reconcile.py -v`
|
||||||
|
Expected: ALL pass (extraction + flags + consistency).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
chmod +x scripts/fu2b_reconcile_internal_case_numbers.py
|
||||||
|
git add scripts/fu2b_reconcile_internal_case_numbers.py
|
||||||
|
git commit -m "feat(fu2b): chair-gated internal case_number reconciliation script (GAP-07/08)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Dry-run against the DB → produce the chair table
|
||||||
|
|
||||||
|
**Files:** Produces `data/audit/fu2b-reconciliation-<ts>.{csv,md}`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the dry-run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai && set -a && source ~/.env 2>/dev/null && set +a
|
||||||
|
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
|
||||||
|
$PY scripts/fu2b_reconcile_internal_case_numbers.py
|
||||||
|
```
|
||||||
|
Expected output: `DRY-RUN: 56 rows | will_change=~52 | flagged=~1` (the ~1 = the 8047/23 DUP_CHECK pair → 2 rows flagged). Note the exact numbers.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Sanity-check the produced table**
|
||||||
|
|
||||||
|
Open `data/audit/fu2b-reconciliation-<ts>.md`. Verify:
|
||||||
|
- `will_change` rows: each `current_case_number` (full citation) → a clean `proposed_bare` matching the number inside it.
|
||||||
|
- `flagged` section: should contain the `8047-23` DUP_CHECK pair (ערר + בל"מ) and ideally nothing else (0 MULTI_NUMBER, 0 MISMATCH expected per the analysis).
|
||||||
|
- If MULTI_NUMBER / MISMATCH rows appear unexpectedly, STOP and report them (the analysis predicted 0; an unexpected flag means the data changed and needs investigation before chair review).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit the produced table as a review artifact**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add data/audit/fu2b-reconciliation-*.md data/audit/fu2b-reconciliation-*.csv
|
||||||
|
git commit -m "chore(fu2b): dry-run reconciliation table for chair review (GAP-07/08)"
|
||||||
|
```
|
||||||
|
(If `data/audit/` is gitignored, skip the commit and report the path instead — the table still exists on disk for review.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: SCRIPTS.md + PR
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register the script in `scripts/SCRIPTS.md`**
|
||||||
|
|
||||||
|
Add a row to the active-scripts table (match the file's existing table format) describing `fu2b_reconcile_internal_case_numbers.py`: purpose (FU-2b internal case_number reconciliation, GAP-07/08), status (active, chair-gated), usage (dry-run default / `--apply --approved`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Full suite + commit + push + PR**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/ -q # report summary (expect all pass)
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add scripts/SCRIPTS.md
|
||||||
|
git commit -m "docs(scripts): register fu2b reconciliation script (FU-2b)"
|
||||||
|
git push -u origin fix/fu2b-identifier-reconciliation
|
||||||
|
```
|
||||||
|
Then create the PR via the Gitea REST API (token from `~/.git-credentials`) and merge per the standing PR+merge rule. The PR delivers the **tooling + dry-run table**; the production `--apply` is the separate gated step below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: [HUMAN GATE] Chair review + gated apply (NOT automated)
|
||||||
|
|
||||||
|
> This task is the chair-approval gate. It is NOT executed by an implementer subagent.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Present `data/audit/fu2b-reconciliation-<ts>.md` to the controller, who presents it to Dafna: the ~52 proposed changes + the `8047-23` ערר/בל"מ DUP_CHECK pair. Dafna confirms the mapping and adjudicates whether 8047/23 is two distinct proceedings (keep both) or a mis-tagged duplicate (manual delete, separate).
|
||||||
|
- [ ] **Step 2:** Save the reviewed table as `data/audit/fu2b-approved-<ts>.csv` (rows Dafna approved; `will_change=yes` only for those).
|
||||||
|
- [ ] **Step 3:** Run the gated apply against the DB:
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai && set -a && source ~/.env && set +a
|
||||||
|
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
|
||||||
|
$PY scripts/fu2b_reconcile_internal_case_numbers.py --apply --approved data/audit/fu2b-approved-<ts>.csv
|
||||||
|
```
|
||||||
|
- [ ] **Step 4:** Verify: re-run dry-run → `will_change=0` (idempotent); spot-check `get_case_by_number` still resolves a migrated case; confirm a backup CSV was written (revert path). Mark TaskMaster #67 done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **GAP-07/08 (internal)** → Task 2 script + Task 3 dry-run + Task 5 gated apply. Canonical form per X1 (month preserved) — `_extract_bare` replaces only `/`→`-` on the single extracted token, never strips/pads a month.
|
||||||
|
- **Reversible:** `_apply` writes `fu2b-backup-<ts>.csv` (id, old_case_number) before each UPDATE.
|
||||||
|
- **Chair gate:** `--apply` requires `--approved <csv>`; production apply is Task 5 (human), not part of the PR merge.
|
||||||
|
- **Determinism / safety:** 0/>1 token → flagged, never guessed; consistency + DUP_CHECK flags surface the 8047 edge.
|
||||||
|
- **Scope:** `source_kind='internal_committee'` only (the UPDATE has the `AND source_kind='internal_committee'` guard); external → #68.
|
||||||
|
- **FK-safe:** verified all 11 `case_law` FKs use `id` (UUID).
|
||||||
|
- **Type consistency:** `_extract_bare(case_number)->(bare|None,flag)`, `_consistency_flag(bare,citation)->str` — names match tests (Task 1) and script (Task 2).
|
||||||
326
docs/superpowers/plans/2026-05-31-fu8a-process-to-code-guards.md
Normal file
326
docs/superpowers/plans/2026-05-31-fu8a-process-to-code-guards.md
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
# FU-8a: Process→Code Guards — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make two process barriers enforceable in code: `sync_agents_across_companies.py --verify` exits non-zero on any drift (incl. adapter_type mismatch, loud not silent), and a fitness-function test fails the suite if the repo gains raw Paperclip HTTP calls or direct `agent_wakeup_requests` inserts.
|
||||||
|
|
||||||
|
**Architecture:** GAP-21 — extract the drift loop into a pure `build_drift_report(...)` and a pure `_verify_exit_code(...)`, then make `--verify` exit `1` on drift. GAP-22 — a self-contained pytest fitness function that scans `web/`, `mcp-server/src/`, `scripts/` for forbidden Paperclip-access patterns with an explicit allowlist. Both pure-code; repo pre-scanned clean (0 existing violations).
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, asyncpg (sync script), pytest offline, `.venv` at `mcp-server/.venv`.
|
||||||
|
|
||||||
|
**Spec:** [docs/superpowers/specs/2026-05-31-fu8a-process-to-code-guards-design.md](../specs/2026-05-31-fu8a-process-to-code-guards-design.md)
|
||||||
|
|
||||||
|
**Run tests:** `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_sync_verify_gate.py tests/test_paperclip_access_guard.py -v`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Modify** `scripts/sync_agents_across_companies.py` — extract `build_drift_report(...)` + `_verify_exit_code(...)` (pure); `--verify` exits non-zero on drift; adapter_type mismatch + missing-in-mirror counted as drift.
|
||||||
|
- **Create** `mcp-server/tests/test_sync_verify_gate.py` — offline tests for the two pure functions (imports the script via importlib, like the FU-2b test).
|
||||||
|
- **Create** `mcp-server/tests/test_paperclip_access_guard.py` — the fitness-function guard (scan + fixtures + real-repo assertion).
|
||||||
|
- **Modify** `scripts/SCRIPTS.md` — note the new `--verify` gate semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: GAP-21 — `--verify` becomes an enforceable drift gate
|
||||||
|
|
||||||
|
**Files:** Modify `scripts/sync_agents_across_companies.py`; Create `mcp-server/tests/test_sync_verify_gate.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""FU-8a / GAP-21: sync --verify drift-gate logic (offline)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "sync_agents_across_companies.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("sync_agents", _SCRIPT)
|
||||||
|
sync = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(sync)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent(name, adapter="claude_code", cfg=None):
|
||||||
|
return {"id": f"id-{name}", "name": name, "adapter_type": adapter,
|
||||||
|
"adapter_config": cfg or {"model": "x"}, "runtime_config": {}, "metadata": {},
|
||||||
|
"budget_monthly_cents": 0, "icon": "", "title": "", "role": "", "agent_api_keys": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_clean_is_zero():
|
||||||
|
assert sync._verify_exit_code(plan=[], mismatches=[], missing=[]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_drift_is_nonzero():
|
||||||
|
assert sync._verify_exit_code(plan=[("m", "mi", {"x": 1})], mismatches=[], missing=[]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_adapter_mismatch_is_nonzero():
|
||||||
|
# adapter_type mismatch must count as drift (not silent skip)
|
||||||
|
assert sync._verify_exit_code(plan=[], mismatches=["עוזר משפטי"], missing=[]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_missing_is_nonzero():
|
||||||
|
assert sync._verify_exit_code(plan=[], mismatches=[], missing=["סוכן"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_drift_report_flags_adapter_mismatch():
|
||||||
|
master = [_agent("A", adapter="claude_code")]
|
||||||
|
mirror_by_name = {"A": _agent("A", adapter="deepseek_local")}
|
||||||
|
rep = sync.build_drift_report(master, mirror_by_name, mirror_skills=set(), only=None)
|
||||||
|
assert "A" in rep["mismatches"]
|
||||||
|
assert rep["plan"] == [] # mismatch short-circuits the diff
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_drift_report_flags_missing_and_plan():
|
||||||
|
master = [_agent("A"), _agent("B")]
|
||||||
|
# A missing in mirror; B present but differing config
|
||||||
|
mirror_by_name = {"B": _agent("B", cfg={"model": "different"})}
|
||||||
|
rep = sync.build_drift_report(master, mirror_by_name, mirror_skills=set(), only=None)
|
||||||
|
assert "A" in rep["missing"]
|
||||||
|
assert any(p[0]["name"] == "B" for p in rep["plan"])
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_sync_verify_gate.py -v`
|
||||||
|
Expected: FAIL — `AttributeError: module 'sync_agents' has no attribute '_verify_exit_code'` / `build_drift_report`.
|
||||||
|
(Note: the script imports `asyncpg` at module top — confirm it imports cleanly under importlib; it does not connect at import time.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the two pure functions**
|
||||||
|
|
||||||
|
In `scripts/sync_agents_across_companies.py`, add ABOVE `async def main()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_drift_report(master_agents, mirror_by_name, mirror_skills, only=None) -> dict:
|
||||||
|
"""Pure drift computation (no DB, no printing). Returns:
|
||||||
|
{"plan": [(master, mirror, diff), ...], "mismatches": [name, ...], "missing": [name, ...]}.
|
||||||
|
adapter_type mismatch and missing-in-mirror are recorded as drift, not skipped silently.
|
||||||
|
"""
|
||||||
|
plan, mismatches, missing = [], [], []
|
||||||
|
for m in master_agents:
|
||||||
|
if only and m["name"] != only:
|
||||||
|
continue
|
||||||
|
mirror = mirror_by_name.get(m["name"])
|
||||||
|
if not mirror:
|
||||||
|
missing.append(m["name"])
|
||||||
|
continue
|
||||||
|
if m["adapter_type"] != mirror["adapter_type"]:
|
||||||
|
mismatches.append(m["name"])
|
||||||
|
continue
|
||||||
|
diff = compute_diff(m, mirror, mirror_skills)
|
||||||
|
if diff:
|
||||||
|
plan.append((m, mirror, diff))
|
||||||
|
return {"plan": plan, "mismatches": mismatches, "missing": missing}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_exit_code(plan, mismatches, missing) -> int:
|
||||||
|
"""0 iff fully in sync; 1 if any drift (needs-sync / adapter mismatch / missing-in-mirror)."""
|
||||||
|
return 1 if (plan or mismatches or missing) else 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Rewire `main()`'s drift loop + `--verify` to use them**
|
||||||
|
|
||||||
|
In `main()`, REPLACE the inline drift loop (the `plan = []` block through the `for m in master_agents:` loop that builds `plan`) with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
print(f"=== Drift report ===")
|
||||||
|
report = build_drift_report(master_agents, mirror_by_name, mirror_skills, only=args.only)
|
||||||
|
plan = report["plan"]
|
||||||
|
for name in report["missing"]:
|
||||||
|
print(f" ⚠ {name:14s} — NOT FOUND in mirror (we never auto-create) — DRIFT")
|
||||||
|
for name in report["mismatches"]:
|
||||||
|
m = next(a for a in master_agents if a["name"] == name)
|
||||||
|
mi = mirror_by_name[name]
|
||||||
|
print(f" ❌ {name:14s} — adapter_type mismatch ({m['adapter_type']} vs {mi['adapter_type']}) "
|
||||||
|
f"— DRIFT (apply skips it; fix manually in both companies)")
|
||||||
|
for master, mirror, diff in plan:
|
||||||
|
print_diff(master["name"], diff, master["id"], mirror["id"])
|
||||||
|
```
|
||||||
|
|
||||||
|
And REPLACE the `if args.verify:` block with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if args.verify:
|
||||||
|
code = _verify_exit_code(plan, report["mismatches"], report["missing"])
|
||||||
|
total_drift = len(plan) + len(report["mismatches"]) + len(report["missing"])
|
||||||
|
print(f"\nSummary: {len(plan)} need sync, {len(report['mismatches'])} adapter-mismatch, "
|
||||||
|
f"{len(report['missing'])} missing-in-mirror → {'DRIFT' if code else 'IN SYNC'}")
|
||||||
|
sys.exit(code)
|
||||||
|
```
|
||||||
|
|
||||||
|
(The `--apply` path still uses `plan` and still does NOT touch adapter_type-mismatch agents — only `--verify`'s exit code changes + the loud reporting.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests + import check**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_sync_verify_gate.py -v` → all PASS.
|
||||||
|
Run: `.venv/bin/python -c "import importlib.util,pathlib; p=pathlib.Path('scripts/sync_agents_across_companies.py'); s=importlib.util.spec_from_file_location('s',p); m=importlib.util.module_from_spec(s); s.loader.exec_module(m); print('imports')"` (from repo root) → `imports`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add scripts/sync_agents_across_companies.py mcp-server/tests/test_sync_verify_gate.py
|
||||||
|
git commit -m "feat(sync): --verify exits non-zero on drift; adapter mismatch = loud drift (GAP-21, FU-8a)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: GAP-22 — Paperclip-access fitness function
|
||||||
|
|
||||||
|
**Files:** Create `mcp-server/tests/test_paperclip_access_guard.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the guard + its tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""FU-8a / GAP-22: fitness function — forbid un-sanctioned Paperclip access.
|
||||||
|
|
||||||
|
Fails if any scanned source (outside the allowlist) reaches the Paperclip API
|
||||||
|
with a raw HTTP client or inserts directly into agent_wakeup_requests. The
|
||||||
|
sanctioned paths are web/paperclip_api.py::pc_request (Python) and scripts/pc.sh
|
||||||
|
(bash); wakeup must go through POST /api/agents/{id}/wakeup.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
SCAN_ROOTS = [REPO / "web", REPO / "mcp-server" / "src", REPO / "scripts"]
|
||||||
|
|
||||||
|
# Files exempt from the HTTP-to-Paperclip rule (the sanctioned helpers + legacy DB-read client).
|
||||||
|
ALLOWLIST = {
|
||||||
|
REPO / "web" / "paperclip_api.py", # the sanctioned pc_request helper
|
||||||
|
REPO / "scripts" / "pc.sh", # the sanctioned bash wrapper
|
||||||
|
REPO / "web" / "paperclip_client.py", # legacy: DB reads only (no raw http, no wakeup insert)
|
||||||
|
}
|
||||||
|
|
||||||
|
_PC_URL = re.compile(r"PAPERCLIP_API_URL|127\.0\.0\.1:3100|localhost:3100|pc\.nautilus\.marcusgroup\.org")
|
||||||
|
_HTTP_CLIENT = re.compile(r"\bhttpx\b|\brequests\.(get|post|put|patch|delete)\b|\baiohttp\b|\bcurl\b")
|
||||||
|
_WAKEUP_INSERT = re.compile(r"insert\s+into\s+agent_wakeup_requests", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_text(text: str) -> list[str]:
|
||||||
|
"""Return violation reasons for a single file's text."""
|
||||||
|
reasons = []
|
||||||
|
if _WAKEUP_INSERT.search(text):
|
||||||
|
reasons.append("direct INSERT INTO agent_wakeup_requests — use the wakeup API")
|
||||||
|
# raw HTTP to Paperclip: both a paperclip-URL token and an http-client token present
|
||||||
|
if _PC_URL.search(text) and _HTTP_CLIENT.search(text):
|
||||||
|
reasons.append("raw HTTP client + Paperclip URL — use web/paperclip_api.pc_request or scripts/pc.sh")
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_source_files():
|
||||||
|
for root in SCAN_ROOTS:
|
||||||
|
if not root.exists():
|
||||||
|
continue
|
||||||
|
for ext in ("*.py", "*.sh"):
|
||||||
|
for f in root.rglob(ext):
|
||||||
|
if f in ALLOWLIST or "/.venv/" in str(f) or "/tests/" in str(f):
|
||||||
|
continue
|
||||||
|
yield f
|
||||||
|
|
||||||
|
|
||||||
|
def find_violations() -> list[tuple[str, str]]:
|
||||||
|
out = []
|
||||||
|
for f in _iter_source_files():
|
||||||
|
try:
|
||||||
|
text = f.read_text(encoding="utf-8")
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
for reason in _scan_text(text):
|
||||||
|
out.append((str(f.relative_to(REPO)), reason))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── the guard catches positives, ignores sanctioned negatives ──────────
|
||||||
|
def test_scan_flags_raw_http_to_paperclip():
|
||||||
|
bad = 'import httpx\nasync def f():\n await httpx.post(f"{PAPERCLIP_API_URL}/x")\n'
|
||||||
|
assert _scan_text(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_flags_wakeup_insert():
|
||||||
|
bad = "await conn.execute('INSERT INTO agent_wakeup_requests (id) VALUES ($1)', x)"
|
||||||
|
assert _scan_text(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_ignores_sanctioned_helper_shape():
|
||||||
|
ok = 'url = f"{PAPERCLIP_API_URL}{path}"\n# the only place httpx is allowed for paperclip\n'
|
||||||
|
# this shape WOULD flag if not allowlisted — proving the allowlist is what protects it
|
||||||
|
assert _scan_text(ok) # raw text matches; the file is protected by ALLOWLIST, not by content
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_ignores_plain_code():
|
||||||
|
assert _scan_text("def add(a, b):\n return a + b\n") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── the real repo must be clean (pre-scanned 2026-05-31: 0 violations) ──
|
||||||
|
def test_repo_has_no_paperclip_access_violations():
|
||||||
|
violations = find_violations()
|
||||||
|
assert violations == [], "Un-sanctioned Paperclip access found:\n" + "\n".join(
|
||||||
|
f" {f}: {r}" for f, r in violations)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the guard tests**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_paperclip_access_guard.py -v`
|
||||||
|
Expected: ALL PASS — including `test_repo_has_no_paperclip_access_violations` (repo is clean).
|
||||||
|
If `test_repo_has_no_paperclip_access_violations` FAILS, it found a real violation: either fix the offending code to use the sanctioned helper, or (if it's a genuine sanctioned location) add it to `ALLOWLIST` with a comment justifying it. Report any such case.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/tests/test_paperclip_access_guard.py
|
||||||
|
git commit -m "feat(guard): fitness function blocking raw Paperclip access (GAP-22, FU-8a)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: SCRIPTS.md + full suite + smoke + PR
|
||||||
|
|
||||||
|
- [ ] **Step 1: Note the `--verify` gate semantics in SCRIPTS.md**
|
||||||
|
|
||||||
|
In `scripts/SCRIPTS.md`, in the `sync_agents_across_companies.py` row, append to its Purpose cell: "**`--verify` יוצא exit≠0 על drift** (כולל adapter_type-mismatch — מדווח רם, נספר כ-drift) — שמיש כ-gate ל-cron/CI (GAP-21/FU-8a)."
|
||||||
|
|
||||||
|
- [ ] **Step 2: Full offline suite**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/ -q`
|
||||||
|
Expected: all pass (prior suite + the new GAP-21/GAP-22 tests). Report the summary line.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Smoke — run `--verify` against the live Paperclip DB (read-only)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai && set -a && source ~/.env 2>/dev/null && set +a
|
||||||
|
PAPERCLIP_BOARD_API_KEY="${PAPERCLIP_BOARD_API_KEY:-}" \
|
||||||
|
/home/chaim/legal-ai/mcp-server/.venv/bin/python scripts/sync_agents_across_companies.py --verify; echo "exit=$?"
|
||||||
|
```
|
||||||
|
Report the output + exit code. Expected: prints a drift report; `exit=0` if agents are in sync, `exit=1` if drift exists (either is a valid result — it proves the gate works). The script only READS in `--verify` (no mutation).
|
||||||
|
(If the script needs `PAPERCLIP_DB_URL`/board key and they're absent, report that the smoke needs the Paperclip env; the offline unit tests already validate the gate logic.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit + PR**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add scripts/SCRIPTS.md
|
||||||
|
git commit -m "docs(scripts): note sync --verify drift-gate semantics (FU-8a)"
|
||||||
|
git push -u origin fix/fu8a-process-to-code-guards
|
||||||
|
```
|
||||||
|
Create the PR via the Gitea REST API (token from `~/.git-credentials`) and merge per the standing PR+merge rule.
|
||||||
|
|
||||||
|
- [ ] **Step 5: TaskMaster #66 → done** (controller; verify via MCP). GAP-23 remains in #69.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **GAP-21** → Task 1: `build_drift_report` + `_verify_exit_code` (pure, tested); `--verify` exits 1 on drift; adapter mismatch loud + counted. `--apply` behavior unchanged.
|
||||||
|
- **GAP-22** → Task 2: fitness function; tested on positive fixtures + sanctioned negatives + the real repo (clean). Allowlist explicit (`paperclip_api.py`, `pc.sh`, legacy `paperclip_client.py`).
|
||||||
|
- **Repo pre-scanned clean** — Task 2 Step 2's repo assertion passes today; if it ever fails, that's the guard doing its job.
|
||||||
|
- **No production-data risk** — pure-code; smoke `--verify` is read-only.
|
||||||
|
- **Type consistency:** `build_drift_report(...)->{plan,mismatches,missing}`, `_verify_exit_code(plan,mismatches,missing)->int`, `find_violations()->[(file,reason)]`, `_scan_text(text)->[reason]` — names match across tasks + tests.
|
||||||
|
- **GAP-23 out of scope** (#69 / FU-8b).
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
# X11 Citation Corroboration — Phase 1 (Signal) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build the citation-corroboration **signal** — classify each incoming citation's *treatment*, link it to the *specific halacha* it supports, and expose per-halacha corroboration — **without yet changing the approval gate** (that is Phase 2).
|
||||||
|
|
||||||
|
**Architecture:** A new schema version (V24) adds a `treatment` column to `precedent_internal_citations` and a `halacha_citation_corroboration` link table. A new service `corroboration.py` does three deterministic-or-LLM steps: (1) classify treatment of a citing passage via Opus 4.8 (INV-COR2), (2) match a citing passage to one halacha via pgvector cosine over `halachot.embedding` (INV-COR3), (3) aggregate distinct positive citations per halacha (INV-COR4). A read-only MCP tool reports the result with full provenance (INV-COR6). Auto-approval (INV-COR4/G10) and enrichment are **Phase 2** (separate plan).
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, asyncpg + pgvector, FastMCP (`@mcp.tool()`), `claude_session.query_json(model=, effort=)`, `embeddings.embed_texts`, pytest (offline deterministic tests mirroring `tests/test_fu2b_reconcile.py`).
|
||||||
|
|
||||||
|
**Spec:** [docs/spec/X11-citation-corroboration.md](../../spec/X11-citation-corroboration.md) (INV-COR1–COR6). Prerequisite (clean identity graph) is **done** (Shafer merge + corpus scan).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Create: `mcp-server/src/legal_mcp/services/corroboration.py` — the corroboration service (classify · match · aggregate · persist). One responsibility: turn the citation graph into per-halacha corroboration rows.
|
||||||
|
- Modify: `mcp-server/src/legal_mcp/services/db.py` — add `SCHEMA_V24_SQL` + helpers `store_corroboration`, `list_corroboration_for_halacha`.
|
||||||
|
- Modify: `mcp-server/src/legal_mcp/server.py` — register read-only MCP tool `halacha_corroboration`.
|
||||||
|
- Create: `mcp-server/tests/test_corroboration.py` — offline deterministic tests (treatment parse, aggregation, independence rule).
|
||||||
|
|
||||||
|
**Treatment vocabulary (INV-COR2):** positive = `{followed, explained}`; negative = `{distinguished, criticized, questioned, overruled}`; neutral = `{mentioned}`. Defined once in `corroboration.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Schema V24 — treatment column + corroboration link table
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mcp-server/src/legal_mcp/services/db.py` (add `SCHEMA_V24_SQL` constant near the other `SCHEMA_V23_SQL`; register it in `_run_schema_migrations`, db.py:54-ish)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the schema constant**
|
||||||
|
|
||||||
|
Add after the `SCHEMA_V23_SQL = """..."""` block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SCHEMA_V24_SQL = """
|
||||||
|
-- X11: citation corroboration (treatment + halacha-level link)
|
||||||
|
ALTER TABLE precedent_internal_citations
|
||||||
|
ADD COLUMN IF NOT EXISTS treatment TEXT DEFAULT '';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS halacha_citation_corroboration (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
halacha_id UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
|
||||||
|
citing_case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
|
||||||
|
citing_decision_id UUID REFERENCES decisions(id) ON DELETE SET NULL,
|
||||||
|
source_citation_id UUID NOT NULL, -- the precedent_internal_citations / case_law_citations row
|
||||||
|
treatment TEXT NOT NULL, -- followed/explained/distinguished/criticized/questioned/overruled/mentioned
|
||||||
|
match_score NUMERIC(4,3) DEFAULT 0, -- cosine(context, rule_statement)
|
||||||
|
match_context TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
UNIQUE (halacha_id, source_citation_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hcc_halacha ON halacha_citation_corroboration(halacha_id);
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Register it in `_run_schema_migrations`**
|
||||||
|
|
||||||
|
In `_run_schema_migrations` (db.py), after `await conn.execute(SCHEMA_V23_SQL)` add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await conn.execute(SCHEMA_V24_SQL)
|
||||||
|
```
|
||||||
|
|
||||||
|
And update the log line to `"Database schema initialized (v1-v24)"`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Apply + verify against the dev DB**
|
||||||
|
|
||||||
|
Run (from `mcp-server/`):
|
||||||
|
```bash
|
||||||
|
DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data .venv/bin/python -c "
|
||||||
|
import asyncio; from legal_mcp.services import db
|
||||||
|
async def m():
|
||||||
|
pool=await db.get_pool()
|
||||||
|
cols=await pool.fetch(\"SELECT column_name FROM information_schema.columns WHERE table_name='precedent_internal_citations' AND column_name='treatment'\")
|
||||||
|
t=await pool.fetchval(\"SELECT to_regclass('halacha_citation_corroboration')\")
|
||||||
|
print('treatment col:', bool(cols), '| table:', t)
|
||||||
|
asyncio.run(m())"
|
||||||
|
```
|
||||||
|
Expected: `treatment col: True | table: halacha_citation_corroboration`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add mcp-server/src/legal_mcp/services/db.py
|
||||||
|
git commit -m "feat(db): V24 — citation treatment column + halacha corroboration link table (X11)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Treatment classifier (deterministic parse, unit-tested)
|
||||||
|
|
||||||
|
The LLM call classifies a citing passage; the **parse/validate** of its output is the deterministic, unit-tested unit (mirrors `halacha_extractor._coerce_halacha`).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `mcp-server/src/legal_mcp/services/corroboration.py`
|
||||||
|
- Test: `mcp-server/tests/test_corroboration.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/test_corroboration.py
|
||||||
|
from __future__ import annotations
|
||||||
|
import pytest
|
||||||
|
from legal_mcp.services import corroboration as cor
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
({"treatment": "followed"}, "followed"),
|
||||||
|
({"treatment": "OVERRULED"}, "overruled"), # case-insensitive
|
||||||
|
({"treatment": "bananas"}, "mentioned"), # unknown -> neutral default
|
||||||
|
({}, "mentioned"), # missing -> neutral default
|
||||||
|
])
|
||||||
|
def test_coerce_treatment(raw, expected):
|
||||||
|
assert cor._coerce_treatment(raw) == expected
|
||||||
|
|
||||||
|
def test_treatment_polarity():
|
||||||
|
assert cor.is_positive("followed") and cor.is_positive("explained")
|
||||||
|
assert cor.is_negative("distinguished") and cor.is_negative("overruled")
|
||||||
|
assert not cor.is_positive("mentioned") and not cor.is_negative("mentioned")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q`
|
||||||
|
Expected: FAIL — `ModuleNotFoundError: corroboration` / `_coerce_treatment` undefined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src/legal_mcp/services/corroboration.py
|
||||||
|
"""X11 citation corroboration — classify treatment, match to halacha, aggregate.
|
||||||
|
|
||||||
|
Phase 1: builds the SIGNAL only (no approval changes). See docs/spec/X11.
|
||||||
|
All LLM calls go through the local claude_session bridge (Opus 4.8 @ xhigh),
|
||||||
|
same architectural rule as the other extractors (local MCP only).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from legal_mcp import config
|
||||||
|
from legal_mcp.config import parse_llm_json
|
||||||
|
from legal_mcp.services import claude_session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TREATMENT_POSITIVE = {"followed", "explained"}
|
||||||
|
TREATMENT_NEGATIVE = {"distinguished", "criticized", "questioned", "overruled"}
|
||||||
|
TREATMENT_NEUTRAL = {"mentioned"}
|
||||||
|
_VALID_TREATMENT = TREATMENT_POSITIVE | TREATMENT_NEGATIVE | TREATMENT_NEUTRAL
|
||||||
|
|
||||||
|
def is_positive(t: str) -> bool: return t in TREATMENT_POSITIVE
|
||||||
|
def is_negative(t: str) -> bool: return t in TREATMENT_NEGATIVE
|
||||||
|
|
||||||
|
def _coerce_treatment(raw: dict) -> str:
|
||||||
|
t = str((raw or {}).get("treatment", "")).strip().lower()
|
||||||
|
return t if t in _VALID_TREATMENT else "mentioned"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q`
|
||||||
|
Expected: PASS (3 params + polarity).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the LLM classifier call (integration, not unit-tested)**
|
||||||
|
|
||||||
|
Append to `corroboration.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_TREATMENT_PROMPT = """אתה משפטן בכיר. נתון ציטוט של פסק/החלטה קודמים בתוך החלטה מאוחרת.
|
||||||
|
סווג כיצד ההחלטה המאוחרת **מטפלת** בתקדים המצוטט, לפי אחת מהקטגוריות:
|
||||||
|
- followed — אימצה והחילה את ההלכה.
|
||||||
|
- explained — הסבירה/הזכירה בלי לחלוק.
|
||||||
|
- distinguished — אבחנה (קבעה שלא חל בנסיבות).
|
||||||
|
- criticized — מתחה ביקורת בלי לבטל.
|
||||||
|
- questioned — הטילה ספק.
|
||||||
|
- overruled — דחתה/ביטלה את ההלכה.
|
||||||
|
- mentioned — אזכור-אגב בלי טיפול.
|
||||||
|
החזר JSON בלבד: {"treatment": "<קטגוריה>"}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def classify_treatment(cited_citation: str, context: str) -> str:
|
||||||
|
"""Return one treatment label for how `context` treats `cited_citation`."""
|
||||||
|
user = f"תקדים מצוטט: {cited_citation}\n\n--- ההקשר המצטט ---\n{context}\n--- סוף ---"
|
||||||
|
try:
|
||||||
|
result = await claude_session.query_json(
|
||||||
|
user, system=_TREATMENT_PROMPT,
|
||||||
|
model=config.HALACHA_EXTRACT_MODEL or None,
|
||||||
|
effort=config.HALACHA_EXTRACT_EFFORT or None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("classify_treatment failed: %s", e)
|
||||||
|
return "mentioned"
|
||||||
|
return _coerce_treatment(result if isinstance(result, dict) else {})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/tests/test_corroboration.py
|
||||||
|
git commit -m "feat(corroboration): treatment classifier + polarity (INV-COR2, X11)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Halacha matcher — pgvector cosine, threshold (INV-COR3)
|
||||||
|
|
||||||
|
The matcher links a citing passage to the single best halacha of the cited precedent. The **threshold decision** is the deterministic unit; the pgvector lookup is integration.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mcp-server/src/legal_mcp/services/corroboration.py`
|
||||||
|
- Modify: `mcp-server/src/legal_mcp/services/db.py` (add `nearest_halacha_for_vector`)
|
||||||
|
- Test: `mcp-server/tests/test_corroboration.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test (threshold gate is the unit)**
|
||||||
|
|
||||||
|
Append to `tests/test_corroboration.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_match_accepts_above_threshold():
|
||||||
|
# (halacha_id, similarity) above floor -> accepted
|
||||||
|
assert cor.accept_match(("h1", 0.62), floor=0.50) == "h1"
|
||||||
|
|
||||||
|
def test_match_rejects_below_threshold():
|
||||||
|
# below floor -> None (INV-COR3: don't attach to a different legal point)
|
||||||
|
assert cor.accept_match(("h1", 0.41), floor=0.50) is None
|
||||||
|
|
||||||
|
def test_match_rejects_empty():
|
||||||
|
assert cor.accept_match(None, floor=0.50) is None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py::test_match_accepts_above_threshold -q`
|
||||||
|
Expected: FAIL — `accept_match` undefined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the threshold gate + env floor**
|
||||||
|
|
||||||
|
Add to `config.py` (near `HALACHA_EXTRACT_*`):
|
||||||
|
```python
|
||||||
|
HALACHA_CORROBORATION_MATCH_FLOOR = float(os.environ.get("HALACHA_CORROBORATION_MATCH_FLOOR", "0.50"))
|
||||||
|
HALACHA_CORROBORATION_MIN_CITES = int(os.environ.get("HALACHA_CORROBORATION_MIN_CITES", "2"))
|
||||||
|
```
|
||||||
|
Add to `corroboration.py`:
|
||||||
|
```python
|
||||||
|
def accept_match(best: tuple[str, float] | None, floor: float = config.HALACHA_CORROBORATION_MATCH_FLOOR) -> str | None:
|
||||||
|
"""Return the halacha_id iff similarity clears the floor (INV-COR3)."""
|
||||||
|
if not best:
|
||||||
|
return None
|
||||||
|
halacha_id, sim = best
|
||||||
|
return halacha_id if sim >= floor else None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q`
|
||||||
|
Expected: PASS (all, incl. Task 2).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the pgvector lookup (integration)**
|
||||||
|
|
||||||
|
Add to `db.py`:
|
||||||
|
```python
|
||||||
|
async def nearest_halacha_for_vector(case_law_id: UUID, vec: list[float]) -> tuple[str, float] | None:
|
||||||
|
"""Best-matching halacha of `case_law_id` for a context embedding (cosine)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT id::text AS id, 1 - (embedding <=> $2) AS sim "
|
||||||
|
"FROM halachot WHERE case_law_id = $1 AND embedding IS NOT NULL "
|
||||||
|
"ORDER BY embedding <=> $2 LIMIT 1",
|
||||||
|
case_law_id, vec,
|
||||||
|
)
|
||||||
|
return (row["id"], float(row["sim"])) if row else None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/src/legal_mcp/services/db.py mcp-server/src/legal_mcp/config.py mcp-server/tests/test_corroboration.py
|
||||||
|
git commit -m "feat(corroboration): halacha matcher + cosine threshold (INV-COR3, X11)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Aggregator — distinct positive citations, negative-flag (INV-COR4)
|
||||||
|
|
||||||
|
Pure function: given per-citation results for one halacha, count **distinct** positive citing sources and detect any negative treatment.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mcp-server/src/legal_mcp/services/corroboration.py`
|
||||||
|
- Test: `mcp-server/tests/test_corroboration.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Append to `tests/test_corroboration.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _link(src, treatment):
|
||||||
|
return {"source_id": src, "treatment": treatment}
|
||||||
|
|
||||||
|
def test_aggregate_counts_distinct_positive():
|
||||||
|
links = [_link("d1","followed"), _link("d1","explained"), _link("d2","followed")]
|
||||||
|
agg = cor.aggregate(links, min_cites=2)
|
||||||
|
assert agg["positive_sources"] == 2 # d1 counted once (INV-COR4 independence)
|
||||||
|
assert agg["has_negative"] is False
|
||||||
|
assert agg["corroborated"] is True
|
||||||
|
|
||||||
|
def test_aggregate_negative_blocks():
|
||||||
|
links = [_link("d1","followed"), _link("d2","followed"), _link("d3","distinguished")]
|
||||||
|
agg = cor.aggregate(links, min_cites=2)
|
||||||
|
assert agg["has_negative"] is True
|
||||||
|
assert agg["corroborated"] is False # any negative -> not corroborated (INV-COR2)
|
||||||
|
|
||||||
|
def test_aggregate_below_threshold():
|
||||||
|
agg = cor.aggregate([_link("d1","followed")], min_cites=2)
|
||||||
|
assert agg["corroborated"] is False # single source insufficient (INV-COR4)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py::test_aggregate_counts_distinct_positive -q`
|
||||||
|
Expected: FAIL — `aggregate` undefined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
Add to `corroboration.py`:
|
||||||
|
```python
|
||||||
|
def aggregate(links: list[dict], min_cites: int = config.HALACHA_CORROBORATION_MIN_CITES) -> dict:
|
||||||
|
"""Aggregate per-halacha corroboration (INV-COR4/COR2).
|
||||||
|
|
||||||
|
links: [{"source_id": str, "treatment": str}, ...] already matched to ONE halacha.
|
||||||
|
positive_sources = count of DISTINCT source_id whose treatment is positive.
|
||||||
|
has_negative = any negative treatment present.
|
||||||
|
corroborated = positive_sources >= min_cites AND not has_negative.
|
||||||
|
"""
|
||||||
|
positive = {l["source_id"] for l in links if is_positive(l["treatment"])}
|
||||||
|
has_negative = any(is_negative(l["treatment"]) for l in links)
|
||||||
|
return {
|
||||||
|
"positive_sources": len(positive),
|
||||||
|
"has_negative": has_negative,
|
||||||
|
"corroborated": len(positive) >= min_cites and not has_negative,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q`
|
||||||
|
Expected: PASS (all).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/tests/test_corroboration.py
|
||||||
|
git commit -m "feat(corroboration): aggregator — distinct positive + negative-flag (INV-COR4, X11)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Orchestration + persistence (build the signal for one precedent)
|
||||||
|
|
||||||
|
Wires Tasks 2–4 over a precedent's incoming citations and stores `halacha_citation_corroboration` rows (INV-COR6 provenance). Integration (no new offline unit).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mcp-server/src/legal_mcp/services/corroboration.py`
|
||||||
|
- Modify: `mcp-server/src/legal_mcp/services/db.py` (add `store_corroboration`, `incoming_citations_for_precedent`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add DB helpers**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# db.py
|
||||||
|
async def incoming_citations_for_precedent(case_law_id: UUID) -> list[dict]:
|
||||||
|
"""All incoming citations (both graphs) with their context + source id."""
|
||||||
|
pool = await get_pool()
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT id::text AS source_id, source_case_law_id::text AS citing_case_law_id, "
|
||||||
|
" NULL::text AS citing_decision_id, match_context AS context "
|
||||||
|
"FROM precedent_internal_citations WHERE cited_case_law_id = $1 "
|
||||||
|
"UNION ALL "
|
||||||
|
"SELECT id::text, NULL, decision_id::text, context_text "
|
||||||
|
"FROM case_law_citations WHERE case_law_id = $1",
|
||||||
|
case_law_id,
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
async def store_corroboration(halacha_id: str, source_id: str, citing_case_law_id, citing_decision_id, treatment: str, score: float, context: str) -> None:
|
||||||
|
pool = await get_pool()
|
||||||
|
await pool.execute(
|
||||||
|
"INSERT INTO halacha_citation_corroboration "
|
||||||
|
"(halacha_id, citing_case_law_id, citing_decision_id, source_citation_id, treatment, match_score, match_context) "
|
||||||
|
"VALUES ($1,$2,$3,$4,$5,$6,$7) "
|
||||||
|
"ON CONFLICT (halacha_id, source_citation_id) DO UPDATE SET "
|
||||||
|
"treatment=EXCLUDED.treatment, match_score=EXCLUDED.match_score",
|
||||||
|
halacha_id, citing_case_law_id, citing_decision_id, source_id, treatment, score, context,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the orchestrator**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# corroboration.py
|
||||||
|
from uuid import UUID
|
||||||
|
from legal_mcp.services import db, embeddings
|
||||||
|
|
||||||
|
async def build_for_precedent(case_law_id: str | UUID) -> dict:
|
||||||
|
"""For one cited precedent: classify+match+store each incoming citation. Idempotent."""
|
||||||
|
if isinstance(case_law_id, str):
|
||||||
|
case_law_id = UUID(case_law_id)
|
||||||
|
cits = await db.incoming_citations_for_precedent(case_law_id)
|
||||||
|
linked = 0
|
||||||
|
for c in cits:
|
||||||
|
ctx = (c.get("context") or "").strip()
|
||||||
|
if not ctx:
|
||||||
|
continue
|
||||||
|
vecs = await embeddings.embed_texts([ctx], input_type="query")
|
||||||
|
best = await db.nearest_halacha_for_vector(case_law_id, vecs[0])
|
||||||
|
halacha_id = accept_match(best)
|
||||||
|
if not halacha_id:
|
||||||
|
continue
|
||||||
|
treatment = await classify_treatment(c.get("citing_case_law_id") or c.get("citing_decision_id") or "", ctx)
|
||||||
|
await db.store_corroboration(
|
||||||
|
halacha_id, c["source_id"],
|
||||||
|
c.get("citing_case_law_id"), c.get("citing_decision_id"),
|
||||||
|
treatment, best[1], ctx,
|
||||||
|
)
|
||||||
|
linked += 1
|
||||||
|
return {"citations": len(cits), "linked": linked}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Integration smoke-test on Shafer (the fixed precedent, 7 citations)**
|
||||||
|
|
||||||
|
Run (from `mcp-server/`):
|
||||||
|
```bash
|
||||||
|
DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data .venv/bin/python -c "
|
||||||
|
import asyncio; from legal_mcp.services import corroboration as cor
|
||||||
|
print(asyncio.run(cor.build_for_precedent('9024da7b-f408-4b6f-808f-c514a83728e4')))"
|
||||||
|
```
|
||||||
|
Expected: `{'citations': 7, 'linked': <0..7>}` and no exception. Inspect `halacha_citation_corroboration` rows for treatment/score sanity.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/src/legal_mcp/services/db.py
|
||||||
|
git commit -m "feat(corroboration): orchestrator + persistence over both citation graphs (X11)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Read-only MCP tool `halacha_corroboration`
|
||||||
|
|
||||||
|
Exposes per-halacha corroboration + provenance to the chair/agents (INV-COR6). **No approval change** — reporting only.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mcp-server/src/legal_mcp/services/db.py` (add `list_corroboration_for_halacha`)
|
||||||
|
- Modify: `mcp-server/src/legal_mcp/server.py` (register tool)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the DB read**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# db.py
|
||||||
|
async def list_corroboration_for_halacha(halacha_id: UUID) -> list[dict]:
|
||||||
|
pool = await get_pool()
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT treatment, match_score, match_context, citing_case_law_id::text, "
|
||||||
|
" citing_decision_id::text, created_at "
|
||||||
|
"FROM halacha_citation_corroboration WHERE halacha_id = $1 "
|
||||||
|
"ORDER BY match_score DESC", halacha_id,
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Register the MCP tool** (copy an existing `@mcp.tool()` idiom in `server.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@mcp.tool()
|
||||||
|
async def halacha_corroboration(halacha_id: str) -> dict:
|
||||||
|
"""החזר את ה-corroboration של הלכה: הציטוטים שמתקפים אותה, הטיפול, וסיכום (X11, read-only)."""
|
||||||
|
from uuid import UUID
|
||||||
|
from legal_mcp.services import corroboration as cor, db
|
||||||
|
links = await db.list_corroboration_for_halacha(UUID(halacha_id))
|
||||||
|
agg = cor.aggregate(
|
||||||
|
[{"source_id": (l["citing_case_law_id"] or l["citing_decision_id"] or ""), "treatment": l["treatment"]} for l in links]
|
||||||
|
)
|
||||||
|
return {"halacha_id": halacha_id, "summary": agg, "citations": links}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the tool loads** (restart MCP server is required to pick up new tools)
|
||||||
|
|
||||||
|
Run: `cd mcp-server && .venv/bin/python -c "from legal_mcp import server; print('halacha_corroboration' in [t.name for t in server.mcp._tool_manager.list_tools()])"`
|
||||||
|
Expected: `True` (adjust the introspection call to the FastMCP version if needed; the point is the import succeeds and the tool registers).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add mcp-server/src/legal_mcp/services/db.py mcp-server/src/legal_mcp/server.py
|
||||||
|
git commit -m "feat(mcp): halacha_corroboration read-only tool (INV-COR6, X11)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope (Phase 2 — separate plan)
|
||||||
|
|
||||||
|
- **Auto-approval wiring (INV-COR4/COR5 + G10 behavior):** make `corroborated == True` set `halachot.review_status='approved'` with `reviewer='corroborated (≥N judicial citations)'`, leaving the uncorroborated/negative tail in the chair gate. **Sensitive** — gated on Dafna validating the signal from Phase 1 first.
|
||||||
|
- **Enrichment (INV-COR3 secondary):** sharpen `rule_statement` from citing framing.
|
||||||
|
- **Backfill:** run `build_for_precedent` across all precedents with halachot + incoming citations.
|
||||||
|
- **Treatment backfill of `case_law_citations.citation_type`** (currently default `'support'`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:** INV-COR2 (Task 2 classifier + polarity), INV-COR3 (Task 3 match floor + pgvector), INV-COR4 (Task 4 distinct positive + min_cites), INV-COR6 (Task 5 store provenance + Task 6 report). INV-COR1 (principle, no code) and INV-COR5 (chair-gate preserved) are honored by **not** changing approval in Phase 1 — explicitly deferred to Phase 2. ✔
|
||||||
|
**Placeholders:** none — every code step has concrete content; the LLM-dependent steps carry the exact prompt + I/O contract; tuning values (`MATCH_FLOOR`, `MIN_CITES`) are real env-defaults, not TBDs.
|
||||||
|
**Type consistency:** `accept_match` returns `halacha_id|None`; `aggregate` consumes `[{"source_id","treatment"}]`; `_coerce_treatment`/`is_positive`/`is_negative` share the `_VALID_TREATMENT` sets. `build_for_precedent` uses `accept_match` + `classify_treatment` + `store_corroboration` with matching signatures. ✔
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
# X11 Citation Corroboration — Phase 2 (Wire the approval gate) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Turn the Phase 1 **signal** into an **approval action**. A halacha that is *corroborated* by ≥N distinct positive judicial citations (0 negatives) is auto-approved with citation provenance; a halacha that a later citing court *overruled* is demoted back to the chair gate. Then **backfill** the signal+approval across the whole corpus.
|
||||||
|
|
||||||
|
**Gate cleared:** Phase 1's "Out of scope" deferred auto-approval as *"Sensitive — gated on Dafna validating the signal from Phase 1 first."* Dafna validated the signal and approved enabling it (2026-06-01). This plan builds the **active** wiring (default ON, env-tunable kill-switch).
|
||||||
|
|
||||||
|
**Architecture:** No schema change — Phase 1's `halacha_citation_corroboration` table already holds the provenance. We add:
|
||||||
|
1. a pure decision function `approval_action(agg, has_overruled)` (unit-tested, INV-COR2/COR4),
|
||||||
|
2. DB transitions that move *only* the legal states (`pending_review → approved` on corroboration; `approved → pending_review` on overruled) — never touching `published`/`rejected`,
|
||||||
|
3. `reconcile_approvals(case_law_id)` called at the tail of `build_for_precedent`,
|
||||||
|
4. a corpus `build_all()` backfill driver + a **write** MCP tool `corroboration_rebuild`.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, asyncpg, FastMCP, `claude_session` (local Opus 4.8), pytest (offline deterministic).
|
||||||
|
|
||||||
|
**Spec:** [docs/spec/X11-citation-corroboration.md](../../spec/X11-citation-corroboration.md) §4 step 5, §5 (INV-COR2/COR4/COR5/COR6), §6 (INV-G10 amendment).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Invariant mapping (what each rule forces here)
|
||||||
|
|
||||||
|
- **INV-COR4** — auto-approve requires `positive_sources ≥ N` distinct sources ∧ `has_negative == False`. `aggregate()` (Phase 1) already computes this; Phase 2 only *acts* on `corroborated == True`.
|
||||||
|
- **INV-COR2** — negative treatment never approves; **overruled** demotes. We split "negative" (blocks approval — already handled by `aggregate`) from **overruled** (actively *demotes an already-approved* halacha back to the chair).
|
||||||
|
- **INV-COR5** — the chair gate is preserved for the uncorroborated tail and all negatives. We only transition the two legal states; uncorroborated halachot are never touched.
|
||||||
|
- **INV-COR6** — provenance is retained: `reviewer` records the corroboration basis; the `halacha_citation_corroboration` rows remain the auditable evidence.
|
||||||
|
- **INV-G10 (amended §6)** — the human gate's *authority source* is cumulative judicial treatment for the corroborated subset; chair gate stays mandatory for the tail. Auto-approval is therefore not "AI judgment" but recorded human (citing-court) judgment (INV-COR1).
|
||||||
|
|
||||||
|
**Demotion scope decision (precise reading of §4 step 5):** *any* negative blocks auto-approval (via `aggregate.has_negative`), but only **overruled** actively demotes a halacha that is already approved. `distinguished`/`criticized`/`questioned` block new auto-approval but do not un-approve an existing chair/confidence approval — that stronger action is reserved for `overruled`, and surfaced to the chair via the read tool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Config kill-switch
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/config.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** After `HALACHA_CORROBORATION_MIN_CITES` (config.py:69) add:
|
||||||
|
```python
|
||||||
|
# X11 Phase 2: gate corroboration → approval. Default ON (Dafna validated the
|
||||||
|
# Phase 1 signal, 2026-06-01). Set to "false" to disable the auto-approve/demote
|
||||||
|
# wiring while keeping the signal (Phase 1) intact.
|
||||||
|
HALACHA_CORROBORATION_AUTO_APPROVE = os.environ.get(
|
||||||
|
"HALACHA_CORROBORATION_AUTO_APPROVE", "true"
|
||||||
|
).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit** `feat(config): HALACHA_CORROBORATION_AUTO_APPROVE kill-switch (X11 Phase 2)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Pure decision function `approval_action` (TDD)
|
||||||
|
|
||||||
|
The whole approval policy distilled to one deterministic, offline-testable function.
|
||||||
|
|
||||||
|
**Files:** Modify `corroboration.py`; Test `tests/test_corroboration.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing test** — append to `tests/test_corroboration.py`:
|
||||||
|
```python
|
||||||
|
def test_approval_action_corroborated_approves():
|
||||||
|
agg = {"positive_sources": 2, "has_negative": False, "corroborated": True}
|
||||||
|
assert cor.approval_action(agg, has_overruled=False) == "approve"
|
||||||
|
|
||||||
|
def test_approval_action_overruled_demotes_even_if_corroborated():
|
||||||
|
# overruled wins over a positive count (INV-COR2 strong form)
|
||||||
|
agg = {"positive_sources": 3, "has_negative": True, "corroborated": False}
|
||||||
|
assert cor.approval_action(agg, has_overruled=True) == "demote"
|
||||||
|
|
||||||
|
def test_approval_action_single_source_noop():
|
||||||
|
agg = {"positive_sources": 1, "has_negative": False, "corroborated": False}
|
||||||
|
assert cor.approval_action(agg, has_overruled=False) is None
|
||||||
|
|
||||||
|
def test_approval_action_negative_nonoverruled_noop():
|
||||||
|
# distinguished blocks approval but does not demote (no overruled)
|
||||||
|
agg = {"positive_sources": 2, "has_negative": True, "corroborated": False}
|
||||||
|
assert cor.approval_action(agg, has_overruled=False) is None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Run to verify FAIL (`approval_action` undefined).
|
||||||
|
|
||||||
|
- [ ] **Step 3:** Implement in `corroboration.py`:
|
||||||
|
```python
|
||||||
|
def approval_action(agg: dict, has_overruled: bool) -> str | None:
|
||||||
|
"""Decide the corroboration→approval action for ONE halacha (INV-COR2/COR4).
|
||||||
|
|
||||||
|
- 'demote' : a later court overruled it → back to the chair gate (overruled
|
||||||
|
outranks any positive count).
|
||||||
|
- 'approve' : corroborated (≥N distinct positives, 0 negatives).
|
||||||
|
- None : leave as-is (single source, non-overruled negative, or tail).
|
||||||
|
"""
|
||||||
|
if has_overruled:
|
||||||
|
return "demote"
|
||||||
|
if agg.get("corroborated"):
|
||||||
|
return "approve"
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4:** Run to verify PASS (all). **Commit** `feat(corroboration): approval_action decision fn (INV-COR2/COR4, X11 Phase 2)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: DB transitions (legal states only)
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/services/db.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add near `update_halacha` (db.py:3480-ish):
|
||||||
|
```python
|
||||||
|
async def approve_halacha_by_corroboration(
|
||||||
|
halacha_id: UUID, n_sources: int, min_cites: int,
|
||||||
|
) -> bool:
|
||||||
|
"""Approve a halacha on citation corroboration — ONLY if it is currently
|
||||||
|
awaiting the chair (pending_review). Never touches 'published'/'rejected'/
|
||||||
|
already-'approved' (INV-COR5: chair gate preserved for everything else).
|
||||||
|
Returns True iff a row transitioned."""
|
||||||
|
pool = await get_pool()
|
||||||
|
reviewer = f"corroborated ({n_sources} judicial citations ≥ {min_cites})"
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"UPDATE halachot SET review_status='approved', reviewer=$2, "
|
||||||
|
"reviewed_at=now(), updated_at=now() "
|
||||||
|
"WHERE id=$1 AND review_status='pending_review' RETURNING id",
|
||||||
|
halacha_id, reviewer,
|
||||||
|
)
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def demote_halacha_overruled(halacha_id: UUID) -> bool:
|
||||||
|
"""Demote an APPROVED halacha back to the chair gate because a later citing
|
||||||
|
court overruled it (INV-COR2). Only acts on 'approved' → 'pending_review';
|
||||||
|
leaves 'published'/'rejected'/'pending_review' untouched. The reviewer note
|
||||||
|
records why it is back in the queue. Returns True iff a row transitioned."""
|
||||||
|
pool = await get_pool()
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"UPDATE halachot SET review_status='pending_review', "
|
||||||
|
"reviewer='flagged: overruled by later citation (X11)', "
|
||||||
|
"reviewed_at=NULL, updated_at=now() "
|
||||||
|
"WHERE id=$1 AND review_status='approved' RETURNING id",
|
||||||
|
halacha_id,
|
||||||
|
)
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def list_corroboration_grouped(case_law_id: UUID) -> dict[str, list[dict]]:
|
||||||
|
"""Per-halacha corroboration links for a cited precedent, in the
|
||||||
|
{source_id, treatment} shape `aggregate()` consumes. Distinct citing source
|
||||||
|
keyed by case_law/decision id (falls back to the citation row id)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT hcc.halacha_id::text AS halacha_id, "
|
||||||
|
" COALESCE(hcc.citing_case_law_id::text, hcc.citing_decision_id::text, "
|
||||||
|
" hcc.source_citation_id::text) AS source_id, "
|
||||||
|
" hcc.treatment "
|
||||||
|
"FROM halacha_citation_corroboration hcc "
|
||||||
|
"JOIN halachot h ON h.id = hcc.halacha_id "
|
||||||
|
"WHERE h.case_law_id = $1",
|
||||||
|
case_law_id,
|
||||||
|
)
|
||||||
|
out: dict[str, list[dict]] = {}
|
||||||
|
for r in rows:
|
||||||
|
out.setdefault(r["halacha_id"], []).append(
|
||||||
|
{"source_id": r["source_id"], "treatment": r["treatment"]}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def precedents_with_halachot_and_incoming_citations() -> list[str]:
|
||||||
|
"""case_law ids that have at least one halacha AND at least one incoming
|
||||||
|
citation (either graph) — the backfill target set."""
|
||||||
|
pool = await get_pool()
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT c.id::text FROM case_law c "
|
||||||
|
"WHERE EXISTS (SELECT 1 FROM halachot h WHERE h.case_law_id=c.id) "
|
||||||
|
" AND (EXISTS (SELECT 1 FROM precedent_internal_citations p "
|
||||||
|
" WHERE p.cited_case_law_id=c.id) "
|
||||||
|
" OR EXISTS (SELECT 1 FROM case_law_citations cc "
|
||||||
|
" WHERE cc.case_law_id=c.id))",
|
||||||
|
)
|
||||||
|
return [r["id"] for r in rows]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit** `feat(db): corroboration approve/demote transitions + backfill query (X11 Phase 2)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `reconcile_approvals` + wire into `build_for_precedent` + `build_all`
|
||||||
|
|
||||||
|
**Files:** Modify `corroboration.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add to `corroboration.py`:
|
||||||
|
```python
|
||||||
|
async def reconcile_approvals(case_law_id: str | UUID) -> dict:
|
||||||
|
"""Apply the corroboration→approval policy for every halacha of a precedent.
|
||||||
|
No-op (returns disabled) when the kill-switch is off. INV-COR2/COR4/COR5."""
|
||||||
|
if not config.HALACHA_CORROBORATION_AUTO_APPROVE:
|
||||||
|
return {"approved": 0, "demoted": 0, "disabled": True}
|
||||||
|
if isinstance(case_law_id, str):
|
||||||
|
case_law_id = UUID(case_law_id)
|
||||||
|
grouped = await db.list_corroboration_grouped(case_law_id)
|
||||||
|
approved = demoted = 0
|
||||||
|
for halacha_id, links in grouped.items():
|
||||||
|
agg = aggregate(links)
|
||||||
|
has_overruled = any(l["treatment"] == "overruled" for l in links)
|
||||||
|
action = approval_action(agg, has_overruled)
|
||||||
|
if action == "approve":
|
||||||
|
if await db.approve_halacha_by_corroboration(
|
||||||
|
UUID(halacha_id), agg["positive_sources"],
|
||||||
|
config.HALACHA_CORROBORATION_MIN_CITES,
|
||||||
|
):
|
||||||
|
approved += 1
|
||||||
|
elif action == "demote":
|
||||||
|
if await db.demote_halacha_overruled(UUID(halacha_id)):
|
||||||
|
demoted += 1
|
||||||
|
return {"approved": approved, "demoted": demoted, "disabled": False}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** At the end of `build_for_precedent`, replace the `return` with:
|
||||||
|
```python
|
||||||
|
appr = await reconcile_approvals(case_law_id)
|
||||||
|
return {"citations": len(cits), "linked": linked,
|
||||||
|
"approved": appr["approved"], "demoted": appr["demoted"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:** Add the corpus driver:
|
||||||
|
```python
|
||||||
|
async def build_all() -> dict:
|
||||||
|
"""Backfill: build the signal + apply approvals for every precedent that has
|
||||||
|
halachot and incoming citations. Idempotent (ON CONFLICT on the link table;
|
||||||
|
transitions only fire on the legal state)."""
|
||||||
|
ids = await db.precedents_with_halachot_and_incoming_citations()
|
||||||
|
totals = {"precedents": 0, "citations": 0, "linked": 0,
|
||||||
|
"approved": 0, "demoted": 0}
|
||||||
|
for cid in ids:
|
||||||
|
r = await build_for_precedent(cid)
|
||||||
|
totals["precedents"] += 1
|
||||||
|
for k in ("citations", "linked", "approved", "demoted"):
|
||||||
|
totals[k] += r.get(k, 0)
|
||||||
|
logger.info("corroboration backfill %s: %s", cid, r)
|
||||||
|
return totals
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit** `feat(corroboration): reconcile_approvals + build_all backfill (X11 Phase 2)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Write MCP tool `corroboration_rebuild`
|
||||||
|
|
||||||
|
**Files:** Modify `mcp-server/src/legal_mcp/server.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add near `halacha_corroboration` (server.py:926):
|
||||||
|
```python
|
||||||
|
@mcp.tool()
|
||||||
|
async def corroboration_rebuild(case_law_id: str = "") -> dict:
|
||||||
|
"""בנה/רענן את ה-corroboration ויישם אישור-אוטומטי. ריק = כל הקורפוס (backfill);
|
||||||
|
מזהה-תקדים = תקדים בודד. כותב halacha_citation_corroboration + מעדכן review_status
|
||||||
|
(corroborated→approved, overruled→pending_review). X11 Phase 2."""
|
||||||
|
from legal_mcp.services import corroboration as cor
|
||||||
|
if case_law_id.strip():
|
||||||
|
return await cor.build_for_precedent(case_law_id.strip())
|
||||||
|
return await cor.build_all()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Verify import/registration:
|
||||||
|
```bash
|
||||||
|
cd mcp-server && .venv/bin/python -c "from legal_mcp import server; print('corroboration_rebuild' in [t.name for t in server.mcp._tool_manager.list_tools()])"
|
||||||
|
```
|
||||||
|
Expected `True`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit** `feat(mcp): corroboration_rebuild write tool (X11 Phase 2)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Backfill the corpus + verify
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Snapshot approved/pending counts before.
|
||||||
|
- [ ] **Step 2:** Run `build_all()` from the venv (`DOTENV_PATH=/home/chaim/.env DATA_DIR=…`). Expect ~12 precedents, no exception, a small number of `approved`/`demoted`.
|
||||||
|
- [ ] **Step 3:** Verify: every halacha approved-by-corroboration has `reviewer LIKE 'corroborated %'`; no `published`/`rejected` changed; corroboration rows carry treatment+score. Spot-check one approved halacha via `halacha_corroboration`.
|
||||||
|
- [ ] **Step 4: Commit** any data-audit note under `data/audit/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope (Phase 2 backlog — deliberately deferred)
|
||||||
|
|
||||||
|
- **Enrichment (INV-COR3 secondary):** sharpen `rule_statement` from citing framing — **proposal-only**, must not silently rewrite an approved rule. Bigger design; separate plan.
|
||||||
|
- **Treatment backfill of `case_law_citations.citation_type`** (default `'support'`) — orthogonal to corroboration (which classifies treatment fresh per citation into its own column).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:** INV-COR2 (overruled demote split from generic negative-block; Task 2/3/4), INV-COR4 (acts only on `corroborated`; Task 2/4), INV-COR5 (only legal-state transitions, tail untouched; Task 3 WHERE clauses), INV-COR6 (reviewer provenance + retained link rows; Task 3), INV-G10 amended (authority = citing courts; not AI; Task 2 comment). ✔
|
||||||
|
**Safety:** kill-switch default ON but env-disable-able; transitions are directional and bounded by `review_status` WHERE clauses (cannot touch chair-final states); demotion moves toward *more* human review. ✔
|
||||||
|
**Idempotency:** link table `ON CONFLICT` (Phase 1); approve only fires on `pending_review`, demote only on `approved` → re-runs converge. ✔
|
||||||
150
docs/superpowers/specs/2026-05-30-fu1-unified-ingest-design.md
Normal file
150
docs/superpowers/specs/2026-05-30-fu1-unified-ingest-design.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# FU-1 — איחוד מסלול-הקליטה (Unified Ingest Path) — עיצוב
|
||||||
|
|
||||||
|
**סטטוס:** מאושר-לעיצוב · **תאריך:** 2026-05-30 · **ענף:** TBD (ייפתח בביצוע)
|
||||||
|
**מכסה:** GAP-01, GAP-02, GAP-04, GAP-05 · **מספק:** INV-ING1, INV-ING3, INV-G2, INV-G4
|
||||||
|
**מקורות:** [docs/spec/01-ingest.md](../../spec/01-ingest.md), [docs/spec/gap-audit.md](../../spec/gap-audit.md) · **משימה:** TaskMaster #59 (legal-ai)
|
||||||
|
**סוג-עבודה:** pure-code · **מיגרציה:** אין (אומת מול DB 2026-05-30 — ראה §6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. הבעיה
|
||||||
|
|
||||||
|
שתי פונקציות-קליטה מקבילות לישויות-אחיות, שמשכפלות את צעדי 2–10 של הפייפליין ומתפצלות
|
||||||
|
בפרטים:
|
||||||
|
|
||||||
|
- `services/precedent_library.py::ingest_precedent` (פסיקה חיצונית, `source_kind='external_upload'`)
|
||||||
|
- `services/internal_decisions.py::ingest_internal_decision` (החלטות-ועדה, `source_kind='internal_committee'`)
|
||||||
|
|
||||||
|
מסלולים מקבילים גוררים drift — צעד שקיים באחד וחסר באחר. הביטוי הקונקרטי: **GAP-02** —
|
||||||
|
המסלול הפנימי מתזמן רק `request_halacha_extraction` ולא `request_metadata_extraction`,
|
||||||
|
ולכן החלטות-ועדה נקלטו בלי metadata. שש אסימטריות נוספות (GAP-01/04/05) מתועדות ב-01-ingest §4.
|
||||||
|
|
||||||
|
## 2. ההכרעה האדריכלית (מאומתת)
|
||||||
|
|
||||||
|
**Template Method skeleton + Strategy via config object.** פונקציה קנונית אחת מריצה את
|
||||||
|
שלד-הפייפליין (סדר-צעדים אכיף); כל מה שמשתנה לפי סוג נישא ב-config object מוזרק (`IntakeSpec`).
|
||||||
|
שתי הפונקציות הציבוריות נשמרות כ-API בעל-שם ומאצילות לליבה.
|
||||||
|
|
||||||
|
| החלטה | נימוק | מקורות (≥3) |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| מסלול קנוני יחיד (לא 2 מקבילים, לא ליבה-משותפת בין 2 כניסות) | Template Method: "אלגוריתמים כמעט-זהים עם הבדלים קטנים" → שלד אחד אוכף סדר-צעדים, צעד-חסר נעשה בלתי-אפשרי | refactoring.guru (Template Method); SourceMaking (Strategy); ADF parameterized-pipelines |
|
||||||
|
| שמירת `ingest_precedent`/`ingest_internal_decision` כ-API ציבורי | Fowler FlagArgument: "separate methods communicate more clearly"; ליבה משותפת מוסתרת כשהלוגיקה שזורה | martinfowler.com/bliki/FlagArgument; ardalis; luzkan smells |
|
||||||
|
| ווריאציה ב-config object (`IntakeSpec`), לא boolean-flags | flag-argument הוא code smell; config object נותן בהירות + הרחבה | Fowler; ardalis; dev.to flag-anti-pattern |
|
||||||
|
| `validate` כ-callable, `enum_fields` כ-data | callable להטרוגני (Strategy idiomatic ב-Python first-class), data להומוגני ("אל תכפה הכל ל-strategy") | Strategy/Wikipedia; Functional-Strategy dev.to; Strategy-in-Python Medium |
|
||||||
|
| `create_record` כ-callable מוזרק, לא `if source_kind` | Replace Conditional with Polymorphism + factory injection ("tell, don't ask") | refactoring.guru (Replace-Conditional); code-maze (Factory+DI); c-sharpcorner |
|
||||||
|
|
||||||
|
## 3. מבנה מודולים
|
||||||
|
|
||||||
|
**מודול חדש:** `mcp-server/src/legal_mcp/services/ingest.py`
|
||||||
|
|
||||||
|
```
|
||||||
|
services/ingest.py ← חדש (בית המסלול הקנוני)
|
||||||
|
├── IntakeSpec (frozen dataclass — מתאר-הסוג)
|
||||||
|
├── async ingest_document(spec, *, file_path|text, inputs, progress) ← ליבה: צעדים 1–10
|
||||||
|
├── _stage_file(src, root, subdir) (אחיד — מאוחד משני הקבצים)
|
||||||
|
├── _coerce_date / _safe_filename (אחיד — היום משוכפל)
|
||||||
|
└── _embed_pages(case_law_id, pdf, n) (עובר מ-precedent_library.py — צעד 7 אחיד)
|
||||||
|
```
|
||||||
|
|
||||||
|
**API ציבורי — חתימה ללא שינוי לקוראים:**
|
||||||
|
- `precedent_library.py::ingest_precedent(...)` → בונה `_EXTERNAL_SPEC`, קורא `ingest.ingest_document(...)`.
|
||||||
|
- `internal_decisions.py::ingest_internal_decision(...)` → בונה `_INTERNAL_SPEC`, קורא `ingest.ingest_document(...)`.
|
||||||
|
|
||||||
|
**לא זז (גבול FU-2):** `db.create_external_case_law` / `db.create_internal_committee_decision`
|
||||||
|
נשארות נפרדות; מנותבות דרך `IntakeSpec.create_record`. כל שאר הפונקציות בשני קבצי-השירות
|
||||||
|
(search_*, migrate_*, reextract_*, process_pending_extractions, enrich_*) **לא נוגעים בהן**.
|
||||||
|
|
||||||
|
**הקוראים שלא משתנים:** MCP tools (`tools/precedent_library.py`, `tools/internal_decisions.py`)
|
||||||
|
וה-HTTP API ב-`web/` ממשיכים לקרוא לאותן שתי פונקציות ציבוריות.
|
||||||
|
|
||||||
|
## 4. ה-IntakeSpec
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IntakeSpec:
|
||||||
|
source_kind: str # 'external_upload' | 'internal_committee'
|
||||||
|
id_field: str # 'citation' | 'case_number' (לוג/שגיאות)
|
||||||
|
staging_root: Path # PRECEDENT_LIBRARY_DIR | INTERNAL_DECISIONS_DIR
|
||||||
|
staging_subdir: Callable[[dict], str] # inputs → subdir (source_type | district | 'other')
|
||||||
|
validate: Callable[[dict], None] # מרים ValueError (citation-guard / chair_name-חובה)
|
||||||
|
enum_fields: dict[str, frozenset[str]] # נאכף לשני הסוגים (GAP-04)
|
||||||
|
derive: Callable[[dict], dict] # שדות-נגזרים (district, proceeding_type); identity לחיצוני
|
||||||
|
display_name_fallback: str # שם-השדה כשחסר case_name ('citation'|'case_number')
|
||||||
|
create_record: Callable[..., Awaitable[dict]] # create_external_case_law | create_internal_committee_decision
|
||||||
|
```
|
||||||
|
|
||||||
|
הליבה `ingest_document` **לא יודעת** איזה סוג רץ — רק מפעילה את ה-hooks בנקודות מוגדרות.
|
||||||
|
|
||||||
|
## 5. הפייפליין הקנוני (צעדים 1–10, לפי 01-ingest §2)
|
||||||
|
|
||||||
|
סדר-הביצוע בפועל (ה-DB-create מוקדם — נדרש `case_law_id` לפני אחסון chunks; תואם את הקוד הקיים):
|
||||||
|
|
||||||
|
| # | צעד | אחיד? | מקור-וריאציה |
|
||||||
|
|---|------|-------|---------------|
|
||||||
|
| 1 | ולידציית-קלט + enums | מנגנון אחיד | `spec.validate` + `spec.enum_fields` |
|
||||||
|
| 2 | גזירת-שדות | מנגנון אחיד | `spec.derive` (identity לחיצוני) |
|
||||||
|
| 3 | Stage file | מנגנון אחיד | `spec.staging_root` + `spec.staging_subdir` |
|
||||||
|
| 4 | Extract text (טקסט-ריק = כשל מדווח) | ✅ מלא | — (internal גם מקבל `text` ישיר, בלי קובץ) |
|
||||||
|
| 5 | Strip Nevo preamble | ✅ מלא | — |
|
||||||
|
| 6 | **DB create → `case_law_id`** (ספציפי-לסוג) | מנותב | `spec.create_record` (+ `display_name_fallback`) |
|
||||||
|
| 7 | Chunk (hierarchical/flat לפי `PARENT_DOC_RETRIEVAL_ENABLED`) | ✅ מלא | — (flag, לא סוג) |
|
||||||
|
| 8 | Embed children + Store chunks | ✅ מלא | — |
|
||||||
|
| 9 | **Multimodal page-image embed** (flag+PDF+page_count>0) | ✅ מלא | — (**GAP-05 fix**: היה רק בחיצוני) |
|
||||||
|
| 10 | **Queue metadata extraction** | ✅ מלא | — (**GAP-02 fix**: היה רק בחיצוני) |
|
||||||
|
| 11 | Queue halacha extraction | ✅ מלא | — |
|
||||||
|
| 12 | Set statuses (extraction=completed, halacha=pending) | ✅ מלא | — |
|
||||||
|
|
||||||
|
> הערה: 01-ingest §2 ממספר 1–10 בלי למנות מפורשות את ה-DB-create; כאן הוא צעד 6 כי הוא קודם
|
||||||
|
> ל-chunking בקוד בפועל. שגיאת-עיבוד אחרי create → `extraction_status=failed` (כמו היום).
|
||||||
|
|
||||||
|
**אילוץ `claude_session`:** הליבה רק **מתזמנת** (`request_*_extraction` — כתיבת-DB טהורה).
|
||||||
|
אין import של `halacha_extractor`/`precedent_metadata_extractor` במסלול-הקליטה — נשמר כפי שהיום.
|
||||||
|
|
||||||
|
## 6. שינויי-התנהגות וסיכון
|
||||||
|
|
||||||
|
| שינוי | השפעה | סיכון |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| **GAP-02**: internal עכשיו מתזמן metadata | החלטות-ועדה חדשות יקבלו headnote/summary/tags | אין — תיקון; רשומות קיימות כבר מלאות (0/56 חסר) |
|
||||||
|
| **GAP-04**: ולידציית-enums על internal | קלט עם practice_area לא-חוקי יידחה בקליטה | נמוך — כל 56 הקיימות חוקיות; בודקים שקוראי-internal מעבירים ערכים חוקיים |
|
||||||
|
| **GAP-05 multimodal**: internal PDF עכשיו מטמיע עמודים | החלטות-ועדה חדשות PDF יקבלו page-images | אין (non-fatal); קיימות → backfill ב-**TaskMaster 61.2 (FU-3)** |
|
||||||
|
| **GAP-05 fallback/staging/derive/guard**: מאוחדים | התנהגות זהה, מסלול אחד | אין — citation-guard נשמר ב-`_EXTERNAL_SPEC.validate` |
|
||||||
|
|
||||||
|
**אין מיגרציה (אומת מול DB 2026-05-30):** internal_committee = 56 רשומות; metadata חסר = **0**;
|
||||||
|
enums לא-חוקיים = **0**; multimodal: 14/56 יש (42 חסר → FU-3 #61.2). הריפקטור משנה רק התנהגות
|
||||||
|
*קדימה*; אינו נוגע בנתונים שמורים.
|
||||||
|
|
||||||
|
**Drift מתועד (זניח, מכוון — מסקירת-קוד סופית):**
|
||||||
|
- **empty-chunks early-return:** כשה-chunker מחזיר ריק על טקסט לא-ריק (נדיר), המקור הציב
|
||||||
|
`halacha_status=completed` ויצא בלי לתזמן; הקנוני נופל הלאה ומתזמן את שני החילוצים עם
|
||||||
|
`halacha_status=pending`. עקבי עם INV-ING3 (תיזמון אחיד) — שיפור, לא רגרסיה.
|
||||||
|
- **thumbnails של multimodal** להחלטות-ועדה יושבים תחת `precedent-library/thumbnails/`
|
||||||
|
(ממופתח לפי `case_law_id`) — מכוון, מתועד ב-docstring של `spec_thumb_dir`.
|
||||||
|
- **`queue_halachot`** הוסר כליל (wrapper + `migrate_from_style_corpus`) — הדגל איבד משמעות
|
||||||
|
תחת INV-ING3; אומת שאין caller שמעביר אותו.
|
||||||
|
|
||||||
|
## 7. אסטרטגיית בדיקה
|
||||||
|
|
||||||
|
pytest offline עם monkeypatch לכל גבולות-ה-I/O (db, embeddings, chunker, extractor) — כתבנית
|
||||||
|
[tests/test_search_domain_scope.py](../../../mcp-server/tests/test_search_domain_scope.py)
|
||||||
|
ו-[tests/test_precedent_corpus_isolation.py](../../../mcp-server/tests/test_precedent_corpus_isolation.py).
|
||||||
|
קובץ חדש: `mcp-server/tests/test_unified_ingest.py`. רץ עם `.venv` המקומי.
|
||||||
|
|
||||||
|
מקרי-בדיקה (TDD — נכשלים לפני, עוברים אחרי):
|
||||||
|
1. **regression GAP-02** — `ingest_internal_decision` מתזמן גם metadata **וגם** halacha (לוכד את הבאג המקורי).
|
||||||
|
2. שני הסוגים זורמים דרך `ingest.ingest_document` (לא דרך גוף-קוד נפרד).
|
||||||
|
3. ולידציית-enum דוחה `practice_area` לא-חוקי בשני הסוגים (GAP-04).
|
||||||
|
4. citation-guard עדיין חוסם ציטוט `ערר`/`בל"מ` במסלול החיצוני.
|
||||||
|
5. staging-subdir נפתר נכון (source_type לחיצוני, district לפנימי, 'other' ל-fallback).
|
||||||
|
6. מסלול-`text` (פנימי, בלי קובץ) ומסלול-`file_path` שניהם עובדים.
|
||||||
|
7. multimodal מותנה flag+PDF+page_count — **לא** בסוג-ה-intake; PDF פנימי → מטמיע, text → לא.
|
||||||
|
8. fallback לשם-תצוגה: חסר case_name → נופל למזהה הקנוני הנכון לכל סוג.
|
||||||
|
9. אידמפוטנטיות-חתימה: ערכי-החזרה של שתי הפונקציות הציבוריות נשמרים (תאימות-קוראים).
|
||||||
|
|
||||||
|
## 8. סדר-ביצוע
|
||||||
|
|
||||||
|
1. כתיבת `test_unified_ingest.py` (אדום).
|
||||||
|
2. `services/ingest.py` — `IntakeSpec` + `ingest_document` + הזזת `_embed_pages` + helpers אחידים.
|
||||||
|
3. `_EXTERNAL_SPEC` + צמצום `ingest_precedent` ל-wrapper.
|
||||||
|
4. `_INTERNAL_SPEC` + צמצום `ingest_internal_decision` ל-wrapper.
|
||||||
|
5. הרצת הבדיקות (ירוק) + lint.
|
||||||
|
6. בדיקת-עשן: import של שני קבצי-השירות + ה-MCP tools (ללא שבירת חתימות).
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# FU-2a — Idempotent Ingest + Write-Time Normalization + `searchable` Flag — עיצוב
|
||||||
|
|
||||||
|
**סטטוס:** מאושר-לעיצוב · **תאריך:** 2026-05-30 · **ענף:** TBD
|
||||||
|
**מכסה:** GAP-03, GAP-06, GAP-13 · **מספק:** INV-ING2, INV-G3, INV-G1, INV-ID1, INV-DM1
|
||||||
|
**מקורות:** [01-ingest.md](../../spec/01-ingest.md), [02-data-model.md](../../spec/02-data-model.md), [X1-identifiers.md](../../spec/X1-identifiers.md), [gap-audit.md](../../spec/gap-audit.md)
|
||||||
|
**משימה:** TaskMaster #60 · **תלוי ב:** FU-1 (#59) · **סוג:** pure-code (schema-additive)
|
||||||
|
**מיגרציה:** אין מיגרציית-מזהים (GAP-07/08 פוצלו ל-#67 / FU-2b). דגל `searchable` נגזר ו-recompute-בלבד.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. היקף ומה מחוץ להיקף
|
||||||
|
|
||||||
|
FU-2 פוצל (החלטת-יו"ר 2026-05-30) לאחר שבדיקת-DB גילתה נתונים מבולגנים מהצפוי:
|
||||||
|
~52/56 רשומות `internal_committee` מחזיקות **ציטוט מלא** ב-`case_number`, יש ≥1 כפילות
|
||||||
|
(`8047-23`), ו-GAP-07 ("with-month canonical") דורש את המספר הרשמי שהוקצה — ידע-יו"ר.
|
||||||
|
|
||||||
|
- **בהיקף (FU-2a, כאן):** GAP-03 (upsert idempotent), GAP-06 (נרמול-בכתיבה), GAP-13 (`searchable`).
|
||||||
|
הכל **pure-code / schema-additive**, משנה התנהגות *קדימה*, אפס מוטציה של מזהים קיימים.
|
||||||
|
- **מחוץ להיקף (FU-2b, #67):** GAP-07 (תיאום מזהים מעורבים), GAP-08 (ניקוי ציטוט-כמזהה) —
|
||||||
|
מיגרציית-נתונים שמערבת dedup, סקירת-יו"ר per-record, גיבוי, reversibility.
|
||||||
|
|
||||||
|
**אינטראקציה FU-2a↔FU-2b (מתועד):** נרמול-בכתיבה חל רק על כתיבות *חדשות*. רשומות-עבר עם
|
||||||
|
ציטוט-מלא לא משתנות עד FU-2b. קליטה-חוזרת של רשומת-עבר מבולגנת תיצור רשומה *נקייה* חדשה
|
||||||
|
(לא תתנגש על המחרוזת השונה) — FU-2b יאחד. זה עקבי עם forward-only של FU-1.
|
||||||
|
|
||||||
|
## 2. הכרעות אדריכליות (מאומתות ≥3 מקורות)
|
||||||
|
|
||||||
|
| החלטה | נימוק | מקורות |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| `INSERT … ON CONFLICT DO UPDATE` במקום SELECT-then-INSERT/UPDATE | אטומי, נטול race תחת Read-Committed; ה-SELECT-then-write הוא read-modify-write קלאסי | PostgreSQL INSERT docs; QueryPlane; on-systems.tech |
|
||||||
|
| **לחזור על predicate של ה-partial-index ב-ON CONFLICT** | V15 משתמש ב-partial unique indexes; Postgres דורש את ה-predicate ב-conflict target | PostgreSQL INSERT docs (§ON CONFLICT); QueryPlane gotchas |
|
||||||
|
| נרמול case_number **בכתיבה**, type-aware | נרמול הוא אחריות-גבול-קלט; ערכים שווי-משמעות → פלט זהה. פסיקה-חיצונית: הציטוט *הוא* המזהה → לא לחתוך | DDD value-objects (Medium/dev.to); gojko.net |
|
||||||
|
| דגל `searchable` **materialized** ונגזר-מחדש, לא מוסק בכל query | reify את חוזה-השלמות; חייב להיות נראה ל-health-check (לא הסקה סמויה) | DevIQ MISU; functional-architecture.org; Stemmler |
|
||||||
|
|
||||||
|
## 3. הקבצים
|
||||||
|
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/services/db.py`:
|
||||||
|
- `create_external_case_law` — להמיר ל-`ON CONFLICT` (target: `(case_number) WHERE source_kind <> 'internal_committee'`); זה גם מטפל בקידום `cited_only`→`external_upload` (אותו partial-index). לא לחתוך את ה-citation (זהו המזהה).
|
||||||
|
- `create_internal_committee_decision` — להמיר ל-`ON CONFLICT (case_number, proceeding_type) WHERE source_kind = 'internal_committee'`; לנרמל `case_number` בכניסה.
|
||||||
|
- `create_case` — לנרמל `case_number` בכניסה (כתיבה).
|
||||||
|
- הוספת helper `_canonical_case_number(s)` (שם מפורש; עוטף את הטרנספורם הדטרמיניסטי trim·prefix-strip·/→- של X1). `_normalize_case_number` הקיים (read-time) נשאר כ-shim.
|
||||||
|
- מיגרציית-schema **V21**: `ALTER TABLE case_law ADD COLUMN searchable boolean NOT NULL DEFAULT false`.
|
||||||
|
- פונקציה `recompute_searchable(case_law_id|all)` — נגזרת מחוזה-השלמות; נקראת בסיום-קליטה ובסיום-חילוץ-metadata.
|
||||||
|
- **Modify** `mcp-server/src/legal_mcp/services/ingest.py` — בסיום הצלחת הקליטה, לקרוא `db.recompute_searchable(case_law_id)` (אחיד לכל סוג; אחרי setting statuses).
|
||||||
|
- **Test** `mcp-server/tests/test_idempotent_ingest.py` (חדש) — offline, monkeypatched.
|
||||||
|
|
||||||
|
**גבול:** אין שינוי לחתימות הציבוריות של `ingest_precedent`/`ingest_internal_decision` (FU-1).
|
||||||
|
הנרמול וה-upsert יושבים בשכבת-ה-DB (גבול-הכתיבה), שקופים לקוראים.
|
||||||
|
|
||||||
|
## 4. נרמול type-aware (GAP-06)
|
||||||
|
|
||||||
|
`_canonical_case_number(s)` — דטרמיניסטי, תואם X1 §1, **לא מוסיף/מסיר חודש**:
|
||||||
|
```
|
||||||
|
trim → strip prefix לפני הספרה הראשונה → להחליף '/' ב-'-'
|
||||||
|
```
|
||||||
|
|
||||||
|
| נקודת-כתיבה | מדיניות | נימוק |
|
||||||
|
|--------------|---------|--------|
|
||||||
|
| `create_internal_committee_decision` | `_canonical_case_number(case_number)` | המזהה הקנוני = מספר-בסיס מנורמל |
|
||||||
|
| `create_case` | `_canonical_case_number(case_number)` | תיק פעיל — אותו כלל |
|
||||||
|
| `create_external_case_law` | `.strip()` בלבד (ללא prefix-strip) | פסיקה חיצונית: ה-citation הוא המזהה הקנוני (X1 §1); חיתוך היה הורס אותו |
|
||||||
|
|
||||||
|
> נרמול מטפל ב-prefix+separator בלבד. קלט שהוא ציטוט-מלא (party names, נבו) **לא** מנוקה ל-bare
|
||||||
|
> ע"י הנרמול — זה GAP-08/FU-2b. FU-2a מבטיח שקלט נקי-יחסית נשמר בצורה קנונית.
|
||||||
|
|
||||||
|
## 5. Idempotent upsert (GAP-03)
|
||||||
|
|
||||||
|
שתי פונקציות-ה-create עוברות מ-SELECT-then-INSERT/UPDATE ל-`INSERT … ON CONFLICT … DO UPDATE`,
|
||||||
|
עם **חזרה על ה-predicate** של ה-partial-index (V15):
|
||||||
|
|
||||||
|
- **internal:** `ON CONFLICT (case_number, proceeding_type) WHERE source_kind = 'internal_committee' DO UPDATE SET …`
|
||||||
|
- **external:** `ON CONFLICT (case_number) WHERE source_kind <> 'internal_committee' DO UPDATE SET …`
|
||||||
|
— מחליף את לוגיקת ה-SELECT הקיימת, **כולל** קידום `cited_only`→`external_upload` (אותה partial-
|
||||||
|
index חלה על שניהם; ה-DO UPDATE מקדם את source_kind וממלא שדות חסרים).
|
||||||
|
|
||||||
|
**`DO UPDATE` ממוקד:** רק שדות-קלט לא-ריקים דורסים (לשמר ערכים קיימים; `COALESCE(EXCLUDED.x, case_law.x)`),
|
||||||
|
ולא לדרוס מטא-דאטה שמולא ע"י חילוץ-LLM. אם ל-`case_law` יש טריגרי-`updated_at` — לסנן עם `WHERE`
|
||||||
|
על שינוי בפועל (gotcha מהמחקר). re-embed בקליטה-חוזרת = INV-ING4, שייך ל-FU-3 — כאן רק upsert-הרשומה.
|
||||||
|
|
||||||
|
## 6. דגל `searchable` (GAP-13)
|
||||||
|
|
||||||
|
עמודה חדשה `case_law.searchable boolean NOT NULL DEFAULT false`. **נגזרת** מחוזה-השלמות
|
||||||
|
(02-data-model §2a / INV-DM1), לא מוסקת ב-query:
|
||||||
|
|
||||||
|
```
|
||||||
|
searchable = (
|
||||||
|
case_number/citation קנוני לא-ריק
|
||||||
|
AND case_name<>'' AND practice_area<>'' AND source_kind<>''
|
||||||
|
AND EXISTS(precedent_chunk עם embedding NOT NULL)
|
||||||
|
AND extraction_status='completed'
|
||||||
|
AND (headnote<>'' OR summary<>'' OR jsonb_array_length(subject_tags)>0)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `recompute_searchable(case_law_id)` נקראת בסיום-קליטה (ingest.py) ובסיום `precedent_metadata_extractor`.
|
||||||
|
- **Backfill (recompute-בלבד, הפיך):** מיגרציה V21 מריצה `recompute_searchable(all)` פעם אחת על רשומות
|
||||||
|
קיימות. זו גזירה הפיכה (ניתן להריץ שוב כל רגע) — אינה נוגעת במזהים, לא חלק מ-FU-2b.
|
||||||
|
- שכבת-החיפוש (`search_*`) תסונן ל-`searchable=true` — **שינוי-התנהגות מתועד** (ראה §7).
|
||||||
|
- health-check יחשוף `count(*) FILTER (WHERE NOT searchable)` (זרע ל-GAP-14/FU-5).
|
||||||
|
|
||||||
|
## 7. שינויי-התנהגות וסיכון
|
||||||
|
|
||||||
|
| שינוי | השפעה | סיכון |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| upsert ON CONFLICT | קליטה-חוזרת = update אטומי, לא כפילות; קידום cited_only נשמר | נמוך — מאומת מול partial-index הקיים |
|
||||||
|
| נרמול-בכתיבה (internal/cases) | קלט חדש נשמר כ-bare מנורמל | נמוך — type-aware; external לא נחתך |
|
||||||
|
| `searchable` מסנן חיפוש | רשומות שלא עומדות בחוזה-השלמות **לא יוחזרו** | ⚠️ בינוני — backfill עלול לסמן רשומות-עבר כ-non-searchable. **אימות:** להריץ recompute ב-dry-run ולדווח כמה ירדו מהחיפוש *לפני* הפעלת הסינון |
|
||||||
|
| backfill searchable | דגל נגזר על רשומות קיימות | נמוך — הפיך, recompute-בלבד, לא נוגע במזהים |
|
||||||
|
|
||||||
|
**אזהרת-backlog:** ה-rows עם ציטוט-מלא-כמזהה (FU-2b) עשויים בכל זאת לעמוד בחוזה-השלמות (יש להם
|
||||||
|
chunks+metadata), כך שהסינון לא בהכרח מפיל אותם. ה-dry-run ב-§7 יכמת זאת לפני הפעלה.
|
||||||
|
|
||||||
|
## 8. אסטרטגיית בדיקה
|
||||||
|
|
||||||
|
`tests/test_idempotent_ingest.py` — offline, monkeypatch ל-DB pool (או בדיקת-SQL מול sqlite-fallback אם קיים בפרויקט; אחרת monkeypatch כמו FU-1). מקרים:
|
||||||
|
1. `_canonical_case_number`: `"ערר 8137/24"`→`"8137-24"`, `"8126-03-25"`→`"8126-03-25"` (חודש נשמר), `" עע\"מ 1/20 "`→`"1-20"`.
|
||||||
|
2. נרמול type-aware: internal מנרמל; external **לא** חותך citation.
|
||||||
|
3. upsert: קליטה כפולה של אותו (case_number, proceeding_type) internal = רשומה אחת (לא שתיים).
|
||||||
|
4. upsert: קידום `cited_only`→`external_upload` על אותו case_number = עדכון, לא כפילות.
|
||||||
|
5. `DO UPDATE` ממוקד: מטא-דאטה קיים לא נדרס ע"י קלט ריק (COALESCE).
|
||||||
|
6. `recompute_searchable`: רשומה מלאה→true; חסרת-embedding/metadata/extraction→false.
|
||||||
|
7. ingest קורא recompute_searchable בסיום (שני הסוגים).
|
||||||
|
|
||||||
|
> בדיקת ON CONFLICT האמיתית דורשת Postgres. אם אין מסלול-בדיקה מול DB אמיתי בפרויקט,
|
||||||
|
> הבדיקות יאמתו את בניית-ה-SQL ואת הלוגיקה הטהורה (normalize, completeness predicate) ב-offline,
|
||||||
|
> ושכבת-ה-SQL תיבדק ב-smoke מול ה-DB המקומי (5433) ידנית בסיום, מתועד בתוכנית.
|
||||||
|
|
||||||
|
## 9. סדר-ביצוע
|
||||||
|
|
||||||
|
1. בדיקות אדומות (`test_idempotent_ingest.py`).
|
||||||
|
2. `_canonical_case_number` + נרמול-בכתיבה ב-3 פונקציות ה-create.
|
||||||
|
3. המרת שתי create ל-`ON CONFLICT … DO UPDATE` (עם predicate חוזר + COALESCE ממוקד).
|
||||||
|
4. מיגרציה V21: עמודה `searchable` + `recompute_searchable` + backfill recompute.
|
||||||
|
5. קריאה ל-`recompute_searchable` מ-ingest.py; חשיפת `count FILTER (WHERE NOT searchable)` ב-health-check.
|
||||||
|
6. **dry-run** של backfill מול DB 5433 → לדווח כמה רשומות יסומנו `searchable=false` ומאילו source_kind.
|
||||||
|
7. **שער החלטה (gated):** סינון `searchable=true` בשכבת-החיפוש מופעל **רק אם** ה-dry-run מראה
|
||||||
|
שאף רשומה לגיטימית לא יורדת מהחיפוש. אם רשומות-עבר לגיטימיות היו נופלות (למשל מבולגנות-FU-2b
|
||||||
|
שעדיין שמישות) — לדחות את הפעלת-הסינון לפולואו-אפ אחרי FU-2b, ולהשאיר את העמודה+health-check בלבד.
|
||||||
|
(להציף את ממצא ה-dry-run למשתמש לפני הפעלה — שינוי חיפוש הוא פעולה גלויה.)
|
||||||
|
8. בדיקות ירוקות + smoke מול DB מקומי + lint.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# FU-3 — Re-Index on Content Change — עיצוב
|
||||||
|
|
||||||
|
**סטטוס:** מאושר-לעיצוב · **תאריך:** 2026-05-30 · **ענף:** TBD
|
||||||
|
**מכסה:** GAP-09 · **מספק:** INV-DM3, INV-G6, INV-ING4 (freshness) · **משימה:** TaskMaster #61
|
||||||
|
**תלוי ב:** FU-1 (#59) · **סוג:** pure-code + backfill-hash זול (אפס re-embed בריצה רגילה)
|
||||||
|
**מיגרציה:** V23 additive (2 עמודות-hash) + backfill-hash דטרמיניסטי הפיך. אין re-embed המוני.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. הבעיה (מאומת בקוד)
|
||||||
|
|
||||||
|
`embedding` אינו עמודת `GENERATED` (בניגוד ל-tsvectors שמתעדכנים אוטומטית בשינוי-תוכן). חילוץ
|
||||||
|
embedding דורש קריאת-API, ולכן אי-אפשר להפוך אותו ל-GENERATED. הממצא של מיפוי-הקוד:
|
||||||
|
|
||||||
|
- **re-ingest דרך `ingest_document` כבר מבצע re-index נכון** — `_chunk_embed_store` רץ ללא-תנאי
|
||||||
|
ו-`store_precedent_chunks(_hierarchical)` הן DELETE-then-INSERT. אז המסלול המלא תקין.
|
||||||
|
- **3 פערים אמיתיים:** (א) אין **גילוי שינוי-תוכן** (אין `content_hash`/`updated_at` ב-case_law);
|
||||||
|
(ב) אין **נקודת re-index עצמאית** — כדי להטמיע מחדש חייבים לקלוט מחדש את ה**קובץ**, אך רשומות
|
||||||
|
רבות (למשל 42 החלטות-ועדה) נקלטו מ-`text` בלי קובץ; (ג) אין **גילוי-drift** בין תוכן ל-embeddings.
|
||||||
|
|
||||||
|
**אכיפת INV-G6** ("re-index בכל שינוי-תוכן") כשהטמעה אינה GENERATED = **גילוי (hash) + כלי-reindex
|
||||||
|
מתוכן-שמור + health-check** — בדיוק כדפוס ה-drift של FU-7 (detect-don't-auto-magic).
|
||||||
|
|
||||||
|
## 2. הכרעות אדריכליות (מאומתות ≥3 מקורות)
|
||||||
|
|
||||||
|
| החלטה | נימוק | מקורות |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| `content_hash` (SHA-256 של full_text) לגילוי-שינוי, לא timestamp | hash תוכן הוא הדפוס המומלץ כשאין timestamp מהימן; דטרמיניסטי + collision-safe | Hash-based change detection (DeepWiki); Andy Dote content-hash; moby#9391 |
|
||||||
|
| re-index **מ-full_text שמור**, לא מ-re-extract/re-OCR | OCR לא-דטרמיניסטי; להשתמש בטקסט השמור (תואם [[feedback_no_reocr_retrofit]]) | RAG re-embed-on-edit (Medium); particula incremental update |
|
||||||
|
| detect→re-embed **רק שהשתנה** (לא rebuild מלא) + health-check staleness | incremental sync; ניטור recall כשהאינדקס מתיישן | apxml RAG updates; Pinecone/Weaviate (gap-audit) |
|
||||||
|
| backfill = hash בלבד (לא re-embed) — רשומות קיימות כבר מוטמעות | זול, הפיך, אפס עלות-API; re-embed רק כשתוכן באמת השתנה | — (נגזר מהמצב: 80 רשומות כבר embedded) |
|
||||||
|
|
||||||
|
## 3. הקבצים
|
||||||
|
|
||||||
|
- **Modify** `services/db.py`: V23 (`content_hash`, `indexed_hash` ב-case_law); `_content_hash(text)`;
|
||||||
|
כתיבת `content_hash` בכניסת `create_external_case_law`/`create_internal_committee_decision`/`create_case`;
|
||||||
|
`mark_indexed(case_law_id)` (מעתיק content_hash→indexed_hash); `recompute_content_hashes()` (backfill);
|
||||||
|
`list_stale_case_law()` (drift query).
|
||||||
|
- **Modify** `services/ingest.py`: אחרי `_chunk_embed_store` המוצלח → `mark_indexed(case_law_id)`; הוספת
|
||||||
|
`reindex_case_law(case_law_id)` — טוען row, chunk+embed+store מ-full_text שמור, ואז `mark_indexed`.
|
||||||
|
- **Modify** `services/metrics.py`: חשיפת `stale_embedding_case_law` count.
|
||||||
|
- **Add** MCP tool `precedent_reindex(case_law_id)` (wrapper דק ל-`ingest.reindex_case_law`) — מאפשר
|
||||||
|
הפעלה ידנית; voyage-API בלבד (אין CLI/LLM → בטוח גם בקונטיינר).
|
||||||
|
- **Test** `tests/test_reindex_on_change.py` (חדש).
|
||||||
|
|
||||||
|
**גבול:** אין שינוי לחתימות ציבוריות. `reindex_case_law` הוא **תוסף**; המסלול הקיים לא משתנה.
|
||||||
|
|
||||||
|
## 4. content_hash + indexed_hash
|
||||||
|
|
||||||
|
- `_content_hash(text) -> str`: `hashlib.sha256(text.encode()).hexdigest()`; על `""`/None → `""`.
|
||||||
|
- `content_hash` = hash של ה-full_text **הנוכחי**, נכתב בכל כתיבת-row (ב-create_*; גבול-הכתיבה כמו נרמול FU-2a).
|
||||||
|
- `indexed_hash` = ה-hash שעליו נבנו ה-chunks/embeddings **הנוכחיים**, נכתב ב-`mark_indexed` אחרי
|
||||||
|
store מוצלח (ב-ingest + ב-reindex).
|
||||||
|
- **טרי** ⇔ `content_hash = indexed_hash`. **stale** ⇔ `content_hash IS DISTINCT FROM indexed_hash`
|
||||||
|
(כולל indexed_hash=NULL = "מעולם לא הוטמע מהתוכן הזה").
|
||||||
|
|
||||||
|
## 5. `reindex_case_law(case_law_id)` (GAP-09 enforcement)
|
||||||
|
|
||||||
|
```
|
||||||
|
load case_law row → full_text (שמור)
|
||||||
|
→ _chunk_embed_store(case_law_id, full_text, page_offsets=None, ...) # אותו מסלול קנוני
|
||||||
|
→ mark_indexed(case_law_id) # indexed_hash = content_hash
|
||||||
|
return {chunks, reindexed: true}
|
||||||
|
```
|
||||||
|
- **לא** קורא ל-extractor/OCR ולא ל-LLM — רק chunk (טקסט שמור) + embed (voyage) + store. תואם
|
||||||
|
[[feedback_no_reocr_retrofit]] ו-claude_session (אין CLI).
|
||||||
|
- multimodal: מדלג (page-images דורשים PDF; רשומות-טקסט אין להן — ראה §7). אם בעתיד יש קובץ — המסלול
|
||||||
|
המלא של ingest מטפל.
|
||||||
|
- idempotent (store = DELETE-then-INSERT; mark_indexed דטרמיניסטי).
|
||||||
|
|
||||||
|
## 6. גילוי-drift + health-check
|
||||||
|
|
||||||
|
- `list_stale_case_law()` → רשומות עם full_text לא-ריק ו-`content_hash IS DISTINCT FROM indexed_hash`.
|
||||||
|
- health-check (metrics.py) חושף `stale_embedding_case_law` count (INV-G6 observability; אחות ל-
|
||||||
|
`non_searchable_case_law`/`cases_with_stale_blocks` מ-FU-2a/FU-7).
|
||||||
|
|
||||||
|
## 7. #61.2 (multimodal backfill) — נסגר כלא-ישים
|
||||||
|
|
||||||
|
בדיקת-DB (2026-05-30): 42 החלטות-ועדה ללא page-images — **כולן** `document_id=NULL` ו-full_text
|
||||||
|
קיים, ואין PDF מקור בדיסק (`data/internal-decisions/` מכיל קובץ אחד). page-images דורשים **רינדור
|
||||||
|
PDF**; לרשומות-טקסט אין PDF → **בלתי-אפשרי**. לכן #61.2 נסגר כ-not-applicable. (אם יועלה PDF לאחת —
|
||||||
|
מסלול-ה-ingest הרגיל יטפל ב-multimodal.) FU-3 core מטמיע-מחדש את ה**טקסט** של כל 42 במידת-הצורך.
|
||||||
|
|
||||||
|
## 8. שינויי-התנהגות וסיכון
|
||||||
|
|
||||||
|
| שינוי | השפעה | סיכון |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| content_hash בכתיבה | כל קליטה חדשה נושאת hash; טרי-מעצם-הקליטה | נמוך — additive |
|
||||||
|
| mark_indexed ב-ingest | רשומות חדשות = טרי (content=indexed) | נמוך |
|
||||||
|
| reindex_case_law | re-embed מתוכן שמור; עלות-API לפי-בקשה | נמוך — תוסף, ידני/מבוקר; לא רץ אוטומטית בהמוני |
|
||||||
|
| backfill hashes | content_hash לכולם; indexed_hash=content רק אם יש chunks, אחרת NULL | נמוך — הפיך, אפס re-embed |
|
||||||
|
| health-check stale count | חשיפת drift | נמוך — read-only |
|
||||||
|
|
||||||
|
## 9. אסטרטגיית בדיקה
|
||||||
|
|
||||||
|
`tests/test_reindex_on_change.py` — offline, monkeypatch. מקרים:
|
||||||
|
1. `_content_hash`: דטרמיניסטי; `""`/None→`""`; טקסט שונה→hash שונה.
|
||||||
|
2. stale-predicate: content≠indexed → stale; שווים → טרי; indexed=NULL → stale.
|
||||||
|
3. `mark_indexed` מריץ UPDATE שמעתיק content_hash→indexed_hash (monkeypatch conn).
|
||||||
|
4. `reindex_case_law`: טוען full_text, קורא _chunk_embed_store ו-mark_indexed (monkeypatch), לא קורא extractor/LLM.
|
||||||
|
5. create_* כותב content_hash (monkeypatch — assert ה-hash מועבר ל-INSERT/upsert).
|
||||||
|
|
||||||
|
> בדיקות-DB אמיתיות (V23, backfill, drift query) — smoke מול DB מקומי (5433) בסיום, כמו FU-2a/FU-7.
|
||||||
|
|
||||||
|
## 10. סדר-ביצוע
|
||||||
|
|
||||||
|
1. בדיקות אדומות.
|
||||||
|
2. V23 (`content_hash`,`indexed_hash`) + `_content_hash` + `mark_indexed` + כתיבת content_hash ב-create_*.
|
||||||
|
3. `reindex_case_law` ב-ingest.py + קריאת `mark_indexed` אחרי `_chunk_embed_store` בקליטה.
|
||||||
|
4. `list_stale_case_law` + health-check `stale_embedding_case_law`.
|
||||||
|
5. MCP tool `precedent_reindex`.
|
||||||
|
6. backfill (DB smoke): `recompute_content_hashes()` — content_hash לכולם, indexed_hash=content אם יש chunks.
|
||||||
|
7. בדיקות ירוקות + smoke מול DB + lint + סגירת #61.2 + TaskMaster #61.
|
||||||
122
docs/superpowers/specs/2026-05-30-fu7-audit-provenance-design.md
Normal file
122
docs/superpowers/specs/2026-05-30-fu7-audit-provenance-design.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# FU-7 — Audit-Trail + Provenance — עיצוב
|
||||||
|
|
||||||
|
**סטטוס:** מאושר-לעיצוב · **תאריך:** 2026-05-30 · **ענף:** TBD
|
||||||
|
**מכסה:** GAP-17, GAP-18, GAP-19, GAP-20 · **מספק:** INV-AUD1, INV-AUD2, INV-AUD3, INV-EX1, INV-G9
|
||||||
|
**מקורות:** [X5-audit-provenance.md](../../spec/X5-audit-provenance.md), [06-export.md](../../spec/06-export.md), [gap-audit.md](../../spec/gap-audit.md)
|
||||||
|
**משימה:** TaskMaster #65 · **תלוי ב:** FU-1 (#59) · **סוג:** pure-code (schema-additive קל)
|
||||||
|
**מיגרציה:** אין. כל השינויים forward-only; backfill קל אופציונלי (provenance של בלוקים קיימים לא נאכף רטרואקטיבית).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. מטרה והיקף
|
||||||
|
|
||||||
|
X5 §4 קובע את המנגנון הקנוני: **שימוש חוזר ב-`audit_log.log_action` עם `details` JSONB** —
|
||||||
|
לא טבלה חדשה (כלל-הנדסה "סימטריה"). FU-7 ממיר את `audit_log` מ"כמעט-ריק" ל-audit-trail מקצה-לקצה,
|
||||||
|
מוסיף provenance בלוק→מקורות, אוכף ציטוט→קורפוס, ומגלה drift בין DOCX-החי לבלוקים.
|
||||||
|
|
||||||
|
| GAP | בעיה (מאומת בקוד) | יעד FU-7 |
|
||||||
|
|-----|--------------------|----------|
|
||||||
|
| GAP-18 | `log_action` נכתב רק ב-`case_subtype_override` (cases.py:203) | קריאות `log_action` ב-4 פעולות משנות-מצב: upload, extract_claims, write_block, export |
|
||||||
|
| GAP-19 | `decision_blocks` נושא `model_used` בלבד — אין קישור לקטעי-מקור | רשומת provenance ב-`audit_log.details` עם source ids שהזינו את הגנרציה |
|
||||||
|
| GAP-20 | אין אכיפה שציטוט פתיר לקורפוס | ולידציה דטרמיניסטית של `case_law_id` בציטוטים → flag לבלתי-פתירים |
|
||||||
|
| GAP-17 | `active_draft_path` הופך SoT אחרי revise/apply בלי re-sync לבלוקים | דגל `blocks_stale` דטרמיניסטי + חשיפת drift ב-health-check (לא re-sync שביר) |
|
||||||
|
|
||||||
|
## 2. הכרעות אדריכליות (מאומתות ≥3 מקורות)
|
||||||
|
|
||||||
|
| החלטה | נימוק | מקורות |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| provenance כ-**event ב-`audit_log` append-only** (details payload), לא עמודה/טבלה חדשה | דפוס lineage בוגר: entity-key + event-type + actor + source-ids; X5 §4 (סימטריה) | Snowflake data-lineage; OvalEdge provenance; DesignGurus append-only audit |
|
||||||
|
| GAP-17 = **detect + flag**, מקור-אמת=בלוקים, לא auto-resync | auto-remediation דורש rollback אמין; reparse DOCX→blocks שביר (edits שוברים מבנה) | Flux GitOps drift; Terraform drift (env0); Spacelift |
|
||||||
|
| GAP-20 = **ולידציה מבנית** של `case_law_id` פתיר, לא NLP של ציטוט חופשי | NLP-ציטוט עברי חופף ל-`extract_internal_citations` הקיים; INV-AUD3 מנוסח סביב פתירוּת `case_law_id` | X5 INV-AUD3; RAG attribution (Lewis 2020); ISO 8000 |
|
||||||
|
| audit כ-**non-fatal** (כשל-audit מתעד warning, לא מפיל פעולה) | git הוא שכבת-השלמות (X5 §2.1); audit_log הוא observability "מי/מה/מתי" | X5 §2.1; דפוס audit fire-safe |
|
||||||
|
|
||||||
|
## 3. הקבצים
|
||||||
|
|
||||||
|
- **Modify** `tools/audit.py` — אין שינוי לחתימת `log_action`; להוסיף helper `log_action_safe(...)` שעוטף ב-try/except (warning, non-fatal) כדי שכשל-audit לא יפיל את הפעולה.
|
||||||
|
- **Modify** `tools/documents.py` — `document_upload` (~:14) + `extract_claims` (~:300): קריאת `log_action_safe`.
|
||||||
|
- **Modify** `services/block_writer.py` — `write_block`/`store_block` (~:1010): לאסוף source ids מ-context builders + לכתוב audit `write_block` עם provenance.
|
||||||
|
- **Modify** `tools/drafting.py` — `export_docx` (~:384): audit `export_docx`; `revise_draft` (~:647) + `apply_user_edit` (~:569): סימון `blocks_stale=true`.
|
||||||
|
- **Modify** `services/db.py` — מיגרציה V22: עמודת `cases.blocks_stale boolean DEFAULT false`; helper `mark_blocks_stale(case_id, val)`; helper `resolve_citation_case_law_ids(ids)` (בדיקת קיום); helper `audit_provenance_query` (קריאה — לא חובה).
|
||||||
|
- **Modify** `services/qa_validator.py` (או היכן שרץ QA) — בדיקת ציטוט→קורפוס: לכל `case_law_id` בציטוטי-הבלוק, אם לא פתיר → ממצא-QA (warning) + audit `citation_unresolved`.
|
||||||
|
- **Modify** health-check (metrics.py / processing_status) — חשיפת `cases_with_stale_blocks` count.
|
||||||
|
- **Test** `tests/test_audit_provenance.py` (חדש) — offline, monkeypatched.
|
||||||
|
|
||||||
|
**גבול:** אין שינוי לחתימות ציבוריות; אין מיגרציית-נתונים. provenance של בלוקים *קיימים* לא נאכף
|
||||||
|
רטרואקטיבית (forward-only) — תואם FU-1/FU-2a.
|
||||||
|
|
||||||
|
## 4. GAP-18 — audit על כל פעולה משנה-מצב
|
||||||
|
|
||||||
|
`log_action_safe(action, case_id=, document_id=, details=, user=)` — עטיפת `log_action` ב-try/except
|
||||||
|
(כשל → `logger.warning`, ה-action ממשיך). נקודות-הקריאה:
|
||||||
|
|
||||||
|
| פעולה | action | details |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| document_upload | `"document_upload"` | `{title, doc_type, classification}` |
|
||||||
|
| extract_claims | `"extract_claims"` | `{docs_processed, claims_count}` |
|
||||||
|
| write_block (GAP-19) | `"write_block"` | `{decision_id, block_id, model_used, generation_type, source_document_ids, retrieved_case_law_ids, claim_ids}` |
|
||||||
|
| export_docx | `"export_docx"` | `{path, file_size, block_count}` |
|
||||||
|
|
||||||
|
## 5. GAP-19 — provenance בלוק→מקורות
|
||||||
|
|
||||||
|
`write_block` כבר אוסף הקשר מ-`_build_source_context` (document chunks), `_build_precedents_context`
|
||||||
|
(`para_results`/`caselaw_rows` → `case_law_id`s), `_build_claims_context` (claim ids). היעד: לאסוף את
|
||||||
|
המזהים הללו ל-dict `sources = {document_ids, case_law_ids, claim_ids}` ולכלול אותו ברשומת ה-audit
|
||||||
|
`write_block` (§4). כך `audit_log` עונה "מאיזו פסיקה/מסמך נולד הבלוק" — בלי עמודה/טבלה חדשה.
|
||||||
|
מפתח-הקישור: `details.decision_id`+`details.block_id` (audit_log עצמו keyed ב-case_id/document_id).
|
||||||
|
|
||||||
|
## 6. GAP-20 — ציטוט→קורפוס נאכף
|
||||||
|
|
||||||
|
`resolve_citation_case_law_ids(ids) -> {resolved: [...], unresolved: [...]}` — בדיקת `EXISTS` מול
|
||||||
|
`case_law`. בנקודת ה-QA (לפני export, משתלב עם שערי FU-6): לאסוף את כל ה-`case_law_id` מציטוטי-הבלוקים
|
||||||
|
(`decision_paragraphs.citations` אם מאוכלס, אחרת מ-provenance של §5), ולהריץ resolve. בלתי-פתירים →
|
||||||
|
**ממצא-QA (warning, לא חוסם-קריטי)** + audit `citation_unresolved`. אכיפה מבנית בלבד (case_law_id),
|
||||||
|
לא חילוץ-NLP של ציטוט חופשי.
|
||||||
|
|
||||||
|
> **הערה:** `decision_paragraphs` אינו מאוכלס כיום ע"י אף כלי (ממצא Explore). לכן ולידציית-הציטוט
|
||||||
|
> פועלת על ה-`case_law_id`s שנרשמו ב-provenance (§5); אם/כאשר decision_paragraphs יאוכלס — אותה
|
||||||
|
> ולידציה חלה עליו. זה שומר את ה-GAP סגור בלי לבנות צינור-ציטוטים חדש (מחוץ-להיקף).
|
||||||
|
|
||||||
|
## 7. GAP-17 — drift בין DOCX-חי לבלוקים
|
||||||
|
|
||||||
|
מקור-אמת = `decision_blocks` (INV-EX1). אחרי `revise_draft`/`apply_user_edit` שהופכים את
|
||||||
|
`active_draft_path` ל-SoT-בפועל בלי re-sync, מסמנים `cases.blocks_stale=true` (חוזה מפורש: "הבלוקים
|
||||||
|
ידועים כלא-מסונכרנים מול ה-DOCX-החי"). `export_docx` מ-blocks מאפס `blocks_stale=false` (הבלוקים שוב SoT).
|
||||||
|
health-check חושף `cases_with_stale_blocks`. **לא** מבצעים reparse DOCX→blocks (שביר).
|
||||||
|
|
||||||
|
| נקודה | פעולה על blocks_stale |
|
||||||
|
|-------|------------------------|
|
||||||
|
| revise_draft / apply_user_edit | `= true` (DOCX-חי חרג מהבלוקים) |
|
||||||
|
| export_docx (מ-blocks) | `= false` (בלוקים = SoT שוב) |
|
||||||
|
| write_block / save_block_content | `= false` (בלוק עודכן ב-DB) |
|
||||||
|
|
||||||
|
## 8. שינויי-התנהגות וסיכון
|
||||||
|
|
||||||
|
| שינוי | השפעה | סיכון |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| audit על 4 פעולות | audit_log מתמלא; observability | נמוך — non-fatal, לא משנה תוצאת-פעולה |
|
||||||
|
| provenance ב-write_block audit | רשומת מקור לכל גנרציה חדשה | נמוך — forward-only; בלוקים קיימים לא מושפעים |
|
||||||
|
| ציטוט-QA warning | ציטוט בלתי-פתיר מסומן לאימות-יו"ר | נמוך — warning, לא חוסם export (לא קריטי) |
|
||||||
|
| `blocks_stale` flag | חשיפת drift; אינו חוסם | נמוך — דגל אינפורמטיבי; V22 additive |
|
||||||
|
|
||||||
|
## 9. אסטרטגיית בדיקה
|
||||||
|
|
||||||
|
`tests/test_audit_provenance.py` — offline, monkeypatch DB pool. מקרים:
|
||||||
|
1. `log_action_safe` בולע כשל-DB (warning) ולא מרים.
|
||||||
|
2. כל אחת מ-4 הפעולות קוראת ל-audit עם ה-action הנכון (monkeypatch log_action, assert call).
|
||||||
|
3. write_block audit כולל `source_document_ids`/`retrieved_case_law_ids` מה-context.
|
||||||
|
4. `resolve_citation_case_law_ids`: מפריד resolved/unresolved נכון (monkeypatch EXISTS).
|
||||||
|
5. ציטוט בלתי-פתיר → ממצא-QA warning (לא חוסם-קריטי).
|
||||||
|
6. `blocks_stale`: revise/apply → true; export-from-blocks → false.
|
||||||
|
7. health-check חושף `cases_with_stale_blocks`.
|
||||||
|
|
||||||
|
> בדיקות-DB אמיתיות (audit_log INSERT, V22, EXISTS) — smoke מול DB מקומי (5433) בסיום, כמו FU-2a.
|
||||||
|
|
||||||
|
## 10. סדר-ביצוע
|
||||||
|
|
||||||
|
1. בדיקות אדומות.
|
||||||
|
2. `log_action_safe` + מיגרציה V22 (`blocks_stale`) + helpers (`mark_blocks_stale`, `resolve_citation_case_law_ids`).
|
||||||
|
3. GAP-18: 4 קריאות audit (upload, extract_claims, export_docx + write_block בסיס).
|
||||||
|
4. GAP-19: איסוף source ids ב-write_block → provenance ב-audit.
|
||||||
|
5. GAP-20: ולידציית-ציטוט ב-QA + audit `citation_unresolved`.
|
||||||
|
6. GAP-17: `blocks_stale` ב-revise/apply/export/write_block + health-check.
|
||||||
|
7. בדיקות ירוקות + smoke מול DB + lint + TaskMaster.
|
||||||
168
docs/superpowers/specs/2026-05-30-system-spec-design.md
Normal file
168
docs/superpowers/specs/2026-05-30-system-spec-design.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# מסמך-עיצוב אב — ספ המערכת והשכבה החסרה (System Spec & Integrity Layer)
|
||||||
|
|
||||||
|
**תאריך:** 2026-05-30
|
||||||
|
**סטטוס:** עיצוב מאושר (Design approved) — ממתין לכתיבת קבצי הספ
|
||||||
|
**בעלים:** חיים מרכוס
|
||||||
|
**הקשר:** מהלך-יסוד להגדרת "מהו תקין" במערכת *עוזר משפטי*, ולסגירת כשל-שורש חוזר.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. הבעיה — כשל-השורש החוזר
|
||||||
|
|
||||||
|
מה שנחווה כ"כל פעם משהו אחר לא מדויק" אינו אוסף תקלות אקראיות אלא **כשל אחד שחוזר בתחפושות**. ראיות שצפו (30.5.2026):
|
||||||
|
|
||||||
|
| תסמין | שורש |
|
||||||
|
|--------|------|
|
||||||
|
| `8126-25` לא נמצא (האמיתי `8126-03-25`); קומיט "tolerant case_number lookup" | אין מפתח קנוני — מתקנים תסמין בקריאה |
|
||||||
|
| 3 החלטות "סופר" ב-3 פורמטים שונים (`8126/24`, ציטוט-מלא-כ-case_number) | אין חוזה-נתונים אחיד |
|
||||||
|
| ערן סופר 8046/24 עלתה בלי metadata (headnote/summary/tags ריקים) | מסלול ה-ingest הפנימי לא מתזמן חילוץ metadata — אסימטרי למסלול החיצוני |
|
||||||
|
| 10/19 הלכות מאושרות, התגלה במקרה | שער ידני שקוף בלי נראות backlog |
|
||||||
|
| משימות #56, #57 | אי-עקביות בין רכיבים (דליפה חוצת-קורפוסים, chunker) |
|
||||||
|
|
||||||
|
**אבחנה:** המערכת גדלה בקצב *הוספת יכולות* מהר יותר מקצב *שמירת עקביות* — מסלולים/כלים/קורפוסים מקבילים שנוספים בבידוד ומתפצלים (drift), בלי שכבה שמגדירה ואוכפת "תקין". כל פגם מתגלה בדיעבד, אחד-אחד.
|
||||||
|
|
||||||
|
**התרופה:** לא לתקן 10 דברים — להוסיף **שכבה אחת חסרה**: חוקה + חוזה-שלמות + בדיקת-בריאות אחת + איחוד מסלולי ה-ingest. זה הופך כשל מ"מתפרץ במקום אקראי" ל"נחסם בכניסה, גלוי בדשבורד".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ייעוד המערכת (מאושר ע"י חיים)
|
||||||
|
|
||||||
|
> מערכת AI שמסייעת ליו"ר ועדת הערר לתכנון ובנייה (מחוז ירושלים, עו"ד דפנה תמיר) לנסח **החלטות מעין-שיפוטיות כתובות ומנומקות** — מסמכים משפטיים פורמליים שעומדים לביקורת שיפוטית — תוך שמירה על **הקול, השיקול והאחריות של היו"ר**.
|
||||||
|
|
||||||
|
- **משרת:** יו"ר הוועדה (משתמש-על) והסוכנים הפועלים בשמה.
|
||||||
|
- **מחזור-חיים:** ניהול תיקים → בסיס ידע (3 קורפוסים) → אחזור סמנטי (RAG) → סיוע-כתיבה (12 בלוקים, סגנון דפנה) → ייצוא DOCX.
|
||||||
|
- **3 סוגי עררים:** רישוי ובנייה (1xxx, חם), היטל השבחה (8xxx, קר), פיצויים ס'197 (9xxx, קר).
|
||||||
|
- **ה"למה" העמוק:** המערכת מסייעת — היו"ר מכריעה (שערים קריטיים ידניים בכוונה); מנוע צבירת-ידע (לומד מהחלטות סופיות ומפידבק); רב-חברתי (CMP/CMPA).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. עקרונות-עבודה למהלך
|
||||||
|
|
||||||
|
1. **אסור להניח שהקיים תקין.** כל מה שמופה בקוד/בקורפוס = "טענה לבדיקה", לא "אמת". "תקין" נגזר ממקורות חיצוניים, לא מהמערכת שתחת חשד.
|
||||||
|
2. **פרוטוקול אימות 3-מקורות:** כל invariant/חוק בספ מגובה ב-**≥3 מקורות סמכותיים מוכרים** בעלי ידע מקצועי מוכח. כשאין 3 → מסומן `⚠ UNVERIFIED` ומועלה לחיים, לא מוכרע לבד.
|
||||||
|
3. **מנגנון:** מחקר עצמאי → טיוטה לביקורת.
|
||||||
|
4. **מודל-שיתוף:** על החלטות טכניות/אדריכליות אני חוקר ומכריע מקצועית ומציג תוצאה מוגמרת. שואל את חיים רק במקום שבו *הוא* הסמכות — כוונה, עדיפויות עסקיות, עובדות משפטיות-דומייניות.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. פירוק ל-5 תת-פרויקטים (לפי תלות)
|
||||||
|
|
||||||
|
| # | תת-פרויקט | תוצר | תלות |
|
||||||
|
|---|-----------|------|------|
|
||||||
|
| 1 | **ספ המערכת + חוקה** | spec-set ב-`docs/spec/` המגדיר מודל קנוני + invariants | — |
|
||||||
|
| 2 | **מפת הפערים (Audit)** | סריקה אמפירית מול הספ → רשימת משימות | תת-פרויקט 1 |
|
||||||
|
| 3 | **שכבת שלמות-נתונים** | חוזה-שלמות באכיפת-קוד + בדיקת-בריאות אחת + **איחוד מסלולי ingest** | 1, 2 |
|
||||||
|
| 4 | **בדיקה חוזרת** | הרצת בריאות/audit אחרי התיקון | 3 |
|
||||||
|
| 5 | **סוכני-תהליך** | add-feature / fix-feature / spec-guardian — מכירים את הספ, "עושים שיעורי בית", לומדים ומתעדכנים | 1 (3) |
|
||||||
|
|
||||||
|
כל תת-פרויקט יקבל מחזור spec→plan→implementation משלו. מסמך זה מפרט את **תת-פרויקט 1** במלואו ומקבע את ההחלטות העקרוניות לכולם.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. מבנה הספ-set (תת-פרויקט 1)
|
||||||
|
|
||||||
|
מיקום: **`docs/spec/`** (ספ חי). ארגון קבצי-תחום: **לפי מחזור-חיים** (גישה A) — חושף ישירות אסימטריות-זרימה.
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/spec/
|
||||||
|
├── 00-constitution.md ← ייעוד · invariants גלובליים · כללי-הנדסה · אינדקס · תבנית-invariant · פרוטוקול-אימות
|
||||||
|
│ ── מחזור-החיים ──
|
||||||
|
├── 01-ingest.md ← קליטה מאוחדת: מסמכי-תיק / פסיקה חיצונית / החלטות-ועדה — חוזה מסלול-יחיד
|
||||||
|
├── 02-data-model.md ← אחסון: ישויות (cases, case_law, documents, chunks, halachot…) + חוזה-שלמות לכל ישות
|
||||||
|
├── 03-retrieval.md ← 3 קורפוסים + כלי-חיפוש · hybrid/RRF · attribution · eval harness · invariants
|
||||||
|
├── 04-analysis-writing.md ← חילוץ טענות · 12 בלוקים · סגנון דפנה (מצטט block-schema.md וכו')
|
||||||
|
├── 05-qa-review.md ← שערי QA + שערים אנושיים (אישור הלכה, בחירת תוצאה, פידבק) כ-invariant
|
||||||
|
├── 06-export.md ← ייצוא DOCX לפי תבנית דפנה
|
||||||
|
├── 07-learning.md ← Hermes · לקחים · לולאת פידבק היו"ר · צמיחת קורפוס (quality-at-source)
|
||||||
|
│ ── חוצי-שלבים ──
|
||||||
|
├── X1-identifiers.md ← מודל מזהים קנוני: נרמול case_number **בכתיבה** · cases מול case_law · פורמטי ציטוט
|
||||||
|
├── X2-multi-company.md ← CMP/CMPA · 14 סוכנים · כללי sync
|
||||||
|
├── X3-integration-deploy.md ← Paperclip (wakeup, ניתוב comments, webhooks) · Coolify/pm2
|
||||||
|
├── X4-agents.md ← מפת הסוכנים (דומיין + סוכני-התהליך מתת-פרויקט 5)
|
||||||
|
└── X5-audit-provenance.md ← audit-trail לשימוש ב-AI · עקיבוּת כל מקור מצוטט · שלמות-רשומה (CEPEJ/NCSC/ISO 15489)
|
||||||
|
```
|
||||||
|
|
||||||
|
**עקרונות:** כל קובץ עצמאי, ממוקד, agent-readable, יעד ≤~500 שורות (תפיחה = סימן לפיצול). `00-constitution.md` = שער-כניסה יחיד. מסמכים קיימים (`architecture.md`, `product-specification.md`, `block-schema.md`…) לא נמחקים ולא משוכפלים — מצוטטים כ"מקור" ומאומתים מול הסמכויות; סתירה = ממצא ל-audit.
|
||||||
|
|
||||||
|
### תבנית-invariant (מבנה אחיד לכל חוק בספ)
|
||||||
|
```
|
||||||
|
### INV-<תחום><מספר>: <כותרת קצרה>
|
||||||
|
**כלל:** <ניסוח נורמטיבי חד — מה חייב להתקיים>
|
||||||
|
**מקורות:** <≥3 סמכויות> | סטטוס: verified / ⚠ UNVERIFIED
|
||||||
|
**אכיפה:** <היכן/איך נאכף — schema, ולידציית-כתיבה, בדיקת-בריאות, שער>
|
||||||
|
**הפרה ידועה:** <דוגמה מהמערכת, אם יש — מקשר ל-audit>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ה-Invariants הגלובליים (לב `00-constitution.md`)
|
||||||
|
|
||||||
|
כל אחד מגובה ב-≥3 סמכויות (פירוט ב-§9). אלה החוקים שמייבשים את כשל-השורש:
|
||||||
|
|
||||||
|
| # | Invariant | סמכויות |
|
||||||
|
|---|-----------|---------|
|
||||||
|
| **G1** | מזהה קנוני, **מנורמל בכתיבה** (לא תיקון-סלחני בקריאה בלבד) | SSOT/normalization · Codd 1NF · Kleppmann |
|
||||||
|
| **G2** | מקור-אמת יחיד; **אין מסלולי-קוד מקבילים שמתפצלים** — אחים חולקים מסלול קנוני אחד; derived data משוחזר | Kleppmann (system of record) · Fowler (canonical model) · SSOT |
|
||||||
|
| **G3** | ingest **אחיד ו-idempotent** (upsert על מפתח דטרמיניסטי) | Kleppmann · Stripe/CDC idempotency · ISO 8000 |
|
||||||
|
| **G4** | **חוזה-שלמות:** שדות חובה מולאו לפני שרשומה "שמישה/ניתנת-לחיפוש"; נבדק מול spec מפורש | ISO 8000 · DAMA (completeness) · ISO 15489 (reliability) |
|
||||||
|
| **G5** | metadata מלא לכל פריט מואנדקס + **הפרדת-קורפוס נאכפת בכל מסלול-query** | Pinecone (multitenancy) · RAG attribution · ISO 8000 |
|
||||||
|
| **G6** | **re-index בכל שינוי תוכן** (אין embeddings מיושנים) | Pinecone · Weaviate · RAG freshness |
|
||||||
|
| **G7** | מיזוג **לפי דירוג (RRF)**, לא סכום-ציונים גולמי בין retrievers | Elastic · Weaviate · OpenSearch/Azure (corrob.) |
|
||||||
|
| **G8** | איכות-אחזור **נמדדת (precision+recall)**, לא מונחת | Manning (IR textbook) · RAG eval literature |
|
||||||
|
| **G9** | כל פלט **עקיב למקורו** + audit-trail לשימוש ב-AI | CEPEJ (user control) · NCSC · ISO 15489 |
|
||||||
|
| **G10** | המערכת מסייעת; **שערים אנושיים** (אישור הלכה/תוצאה/פידבק) הם invariant, לא רשות | NCSC · CEPEJ · FJC |
|
||||||
|
| **G11** | **תוכן החלטה מנומקת:** רקע ניטרלי · ללא כפילות · מענה לטענות המפסיד · מבחן-השופט · טענות מקוריות | FJC (Writing Manual) · South Bucks (adequacy) · חוק 1958 (חובת הנמקה) |
|
||||||
|
|
||||||
|
### כללי-הנדסה (constitution — מונעים הישנות)
|
||||||
|
- **סימטריה:** אסור להוסיף מסלול מקביל ליכולת קיימת — מרחיבים את המסלול הקנוני. (נגזר מ-G2)
|
||||||
|
- **נרמול לא תיקון-תסמין:** מתקנים נתון במקור (קנוני), לא מטליאים בקריאה. (נגזר מ-G1)
|
||||||
|
- **Quality-at-source:** שלמות נאכפת קרוב ככל האפשר לקליטה. (Fowler/Data-Mesh)
|
||||||
|
- **אין בליעה שקטה:** רשומה חסרה מסומנת ומדווחת, לא מתקבלת בשקט. (תואם feedback קיים)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. פרוטוקול-אימות ומודל-שיתוף (ייכנס ל-`00-constitution.md`)
|
||||||
|
|
||||||
|
- כל invariant נושא `מקורות` + `סטטוס: verified / ⚠ UNVERIFIED`.
|
||||||
|
- `⚠ UNVERIFIED` (פחות מ-3 מקורות) → לא מוכרע לבד; מועלה לחיים.
|
||||||
|
- החלטות טכניות → מחקר עצמאי + הכרעה מקצועית + הצגת תוצאה. שאלה לחיים רק במקום שהוא הסמכות.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. פריטים פתוחים — אימות-מקור-ראשוני נדרש
|
||||||
|
(החוקר אימת מסגרת; הפריטים הישראליים דורשים אימות לפני ציטוט כ-סמכות, בשלב כתיבת `04`/`05`/`X5`)
|
||||||
|
1. מספר הסעיף המדויק בחוק לתיקון סדרי המינהל (החלטות והנמקות) תשי"ט-1958 (וכן תיקון תשכ"ט-1969).
|
||||||
|
2. ציטוט מדויק מ-ברק-ארז, *משפט מינהלי*.
|
||||||
|
3. אסמכתאות פסיקה: בג"ץ 143/56; עע"ם 2994/21 (מעמד ועדת ערר כגוף תכנוני-מקצועי).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. נספח מקורות סמכותיים (מאומתים במחקר 30.5.2026)
|
||||||
|
|
||||||
|
**ממשל-AI שיפוטי + מבנה החלטה מנומקת**
|
||||||
|
- NCSC / JTC — *Court Technology Standards* + *Principles & Practices for AI Use in Courts*. https://www.ncsc.org/our-centers-projects/joint-technology-committee/court-technology-standards
|
||||||
|
- Federal Judicial Center — *Judicial Writing Manual* (2d ed.). https://www.fjc.gov/content/judicial-writing-manual-pocket-guide-judges-second-edition
|
||||||
|
- Council of Europe / CEPEJ — *European Ethical Charter on the use of AI in judicial systems* (2018).
|
||||||
|
- *South Buckinghamshire DC v Porter (No 2)* [2004] UKHL 33 (adequacy of reasons). https://publications.parliament.uk/pa/ld200304/ldjudgmt/jd040701/south-1.htm
|
||||||
|
- חוק לתיקון סדרי המינהל (החלטות והנמקות), תשי"ט-1958. https://www.nevo.co.il/law_html/law00/98603.htm
|
||||||
|
- Kevin D. Ashley — *Artificial Intelligence and Legal Analytics* (CUP).
|
||||||
|
|
||||||
|
**אחזור / RAG / IR**
|
||||||
|
- Lewis et al. (2020) — *Retrieval-Augmented Generation* (NeurIPS). https://arxiv.org/abs/2005.11401
|
||||||
|
- Manning, Raghavan & Schütze — *Introduction to Information Retrieval* (CUP, 2008). https://nlp.stanford.edu/IR-book/
|
||||||
|
- Elastic — *Reciprocal Rank Fusion*. https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion
|
||||||
|
- Pinecone — *Implement multitenancy*. https://docs.pinecone.io/guides/index-data/implement-multitenancy
|
||||||
|
- Weaviate — *Hybrid Search Explained*. https://weaviate.io/blog/hybrid-search-explained
|
||||||
|
|
||||||
|
**שלמות-נתונים / איכות / רשומות**
|
||||||
|
- DAMA-DMBOK2 + DAMA-UK — *Six Primary Dimensions for Data Quality* (2013).
|
||||||
|
- ISO 8000 — Data quality (8000-8/61/110).
|
||||||
|
- ISO 15489-1:2016 — Records management (authenticity/reliability/integrity/usability).
|
||||||
|
- Martin Kleppmann — *Designing Data-Intensive Applications* (O'Reilly, 2017).
|
||||||
|
- E.F. Codd — Relational model & normalization (CACM 13(6), 1970).
|
||||||
|
- Martin Fowler — Canonical Data Model / Data Mesh (quality-at-source).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. השלב הבא
|
||||||
|
לאחר ביקורת חיים על מסמך זה → invoke `writing-plans` לבניית תוכנית-יישום מפורטת לתת-פרויקט 1 (כתיבת קבצי הספ-set, החל מ-`00-constitution.md`).
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# FU-2b — תיאום מזהי `case_number` (Identifier Reconciliation) — עיצוב
|
||||||
|
|
||||||
|
**סטטוס:** מאושר-לעיצוב · **תאריך:** 2026-05-31 · **ענף:** TBD
|
||||||
|
**מכסה:** GAP-07, GAP-08 (scope: `internal_committee` בלבד) · **מספק:** INV-ID1, INV-ID2, INV-DM2
|
||||||
|
**משימה:** TaskMaster #67 · **תלוי ב:** FU-2a (#60, פונקציית הנרמול) · **סוג:** **data-migration + chair-gate**
|
||||||
|
**מחוץ-להיקף:** external_upload → **#68 / FU-2c** (נתונים סותרים, ראה §1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. הבעיה והיקף (מאומת מול DB, 2026-05-31)
|
||||||
|
|
||||||
|
`internal_committee` הוא הקורפוס שבו `case_number` חייב להיות **מספר-ועדה מנורמל** (X1 §1), אך
|
||||||
|
~52/56 רשומות מחזיקות **ציטוט-מלא** בשדה-המזהה (GAP-08 — "החלטות סופר"), בניגוד ל-INV-ID2
|
||||||
|
(ציטוט = שדה-תצוגה נגזר, לעולם לא מזהה).
|
||||||
|
|
||||||
|
**ממצאי-נתונים שמעצבים את המיגרציה:**
|
||||||
|
- **חילוץ דטרמיניסטי ונקי:** כל 56 הרשומות → בדיוק token-מספר אחד (regex `[0-9]{2,6}(?:[-/][0-9]{1,2}){1,2}`). 0 רב-משמעיים, 0 בלתי-פתירים.
|
||||||
|
- **עקביות מושלמת:** ב-55/56 המספר המחולץ **מופיע** ב-`citation_formatted`; **0 סתירות**. (1 רשומה בלי citation_formatted — כבר bare.)
|
||||||
|
- **0 התנגשויות-מפתח** על (bare, proceeding_type) → **אין dedup**.
|
||||||
|
- **אין בעיית with/without-month:** ה"צורות הכפולות" (1024-24 מול 1024-25 וכו') הן **שנים שונות** = תיקים שונים, לא padding.
|
||||||
|
- **edge יחיד ליו"ר:** `8047/23` קיים פעמיים — אחת `proceeding_type=ערר`, אחת `בל"מ` (48 chunks כל אחת). לפי X1 אלו **שתי רשומות מובחנות** (ערר מול בל"מ), אך זהות chunk-count מצדיקה אימות-יו"ר שאינן כפילות מתויגת-שגוי.
|
||||||
|
|
||||||
|
**external מופרד (#68):** ב-external נמצאה **סתירה** (`case_number=25226-04-25` מול
|
||||||
|
`citation_formatted=1975/24`) — ה-citation_formatted נוצר בנפרד ואינו ground-truth אמין; דורש
|
||||||
|
טיפול נפרד. בנוסף, זהות פסיקה-חיצונית היא טבעית הציטוט (אין מספר-ועדה). מחוץ ל-FU-2b.
|
||||||
|
|
||||||
|
## 2. ההכרעה (מבוססת X1 + ממצאי-נתונים)
|
||||||
|
|
||||||
|
הצורה הקנונית של `case_number` ל-internal = **trim · prefix-strip · `/`→`-`** על המספר הרשמי,
|
||||||
|
**בלי להמציא/להסיר חודש** (X1 §1; מקורות: Codd 1NF · Kleppmann DDIA · SSOT — verified ב-X1).
|
||||||
|
המיגרציה **דטרמיניסטית** (לא LLM): מחלצת את ה-token המספרי היחיד ומנרמלת. הציטוט כבר חי
|
||||||
|
ב-`citation_formatted` — אין מה לנגוע בו.
|
||||||
|
|
||||||
|
**דפוס-בטיחות (chair-gated reversible migration):** גיבוי-לפני-שינוי → dry-run שמפיק טבלת-תיאום
|
||||||
|
→ **שער-אישור-יו"ר** → apply מפורש → אימות. זהו דפוס סטנדרטי למיגרציה בלתי-הפיכה על נתוני-ייצור.
|
||||||
|
|
||||||
|
## 3. הרכיבים
|
||||||
|
|
||||||
|
- **סקריפט** `scripts/fu2b_reconcile_internal_case_numbers.py` (לא MCP tool — מיגרציה חד-פעמית מבוקרת):
|
||||||
|
- `--dry-run` (ברירת-מחדל): מפיק טבלת-תיאום `data/audit/fu2b-reconciliation-<ts>.csv` +
|
||||||
|
`.md` קריא ליו"ר. עמודות: `id, current_case_number, proposed_bare, proceeding_type,
|
||||||
|
citation_formatted, consistency_ok, flag`.
|
||||||
|
- `--apply`: דורש קובץ-אישור (ראה §4); מגבה ואז מבצע.
|
||||||
|
- מעבד **רק** `source_kind='internal_committee'` ו**רק** רשומות שבהן `proposed_bare != case_number`
|
||||||
|
(idempotent — already-bare לא נוגעים).
|
||||||
|
- **חילוץ:** `_extract_bare(case_number) -> str|None` — regex token יחיד + `_canonical_case_number`
|
||||||
|
(מ-FU-2a, db.py) לנרמול הסופי. אם 0 או >1 tokens → `None` + flag `NEEDS_CHAIR`.
|
||||||
|
- **consistency guard:** אם `proposed_bare` **לא** מופיע ב-`citation_formatted` → flag `MISMATCH` (לא
|
||||||
|
יוחל אוטומטית; ליו"ר). (כיום 0 כאלה, אך הסקריפט בודק בזמן-ריצה.)
|
||||||
|
- **גיבוי:** לפני apply, כתיבת `data/audit/fu2b-backup-<ts>.csv` = `(id, old_case_number)` לכל רשומה
|
||||||
|
שתשונה → revert-script טריוויאלי.
|
||||||
|
- **edge 8047/23:** הסקריפט **לא** ממזג; מסמן את הזוג ב-flag `DUP_CHECK` בטבלה. ההכרעה (מובחנות מול
|
||||||
|
כפילות) היא של היו"ר; אם כפילות — מחיקה ידנית נפרדת (לא חלק מה-apply הדטרמיניסטי).
|
||||||
|
|
||||||
|
## 4. שער-אישור-היו"ר (chair gate)
|
||||||
|
|
||||||
|
1. הרצת `--dry-run` → טבלת-תיאום (`.md`) + סיכום (כמה ישתנו, אילו flags).
|
||||||
|
2. **הצגה לדפנה**: הטבלה (52 שורות: ציטוט-נוכחי → bare מוצע) + ה-edge של 8047/23. היא מסמנת
|
||||||
|
שורות שגויות (אם יש) ומכריעה על 8047/23.
|
||||||
|
3. תיקון flags לפי הערותיה (אם יש), ואז `--apply --approved data/audit/fu2b-approved-<ts>.csv`
|
||||||
|
(קובץ-האישור = הטבלה לאחר סקירתה; הסקריפט מחיל רק שורות שאושרו).
|
||||||
|
4. אימות אחרי apply: כל internal `case_number` תואם regex bare; 0 ציטוטים בשדה-המזהה;
|
||||||
|
`search`/`get_case_by_number` עדיין פותרים (FU-2a tolerant-read + הנרמול).
|
||||||
|
|
||||||
|
## 5. אינטראקציה עם FU-2a (forward-consistency)
|
||||||
|
|
||||||
|
FU-2a `_canonical_case_number` מנרמל prefix+separator אך **אינו מחלץ מספר מתוך ציטוט-מלא**. לכן
|
||||||
|
אם קליטה עתידית תעביר ציטוט-מלא כ-`case_number`, ייווצר שוב מזהה מלוכלך. **הערכת-סיכון:** נמוכה —
|
||||||
|
טופס-ההעלאה וה-MCP tool מעבירים שדה-`case_number` נפרד (בד"כ נקי). **החלטה:** FU-2b הוא ניקוי-נתונים
|
||||||
|
בלבד; הקשחת-כתיבה (חילוץ-token גם ב-create) **לא בהיקף** — תיפתח רק אם יתגלה caller שמעביר ציטוט.
|
||||||
|
(מתועד; לא לשנות התנהגות-כתיבה בלי ראיה.)
|
||||||
|
|
||||||
|
## 6. שינויי-התנהגות וסיכון
|
||||||
|
|
||||||
|
| שינוי | השפעה | סיכון |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| `case_number` של ~52 internal → bare | חיפוש exact-match על המספר עובד; (case_number,proceeding_type) נקי | נמוך — דטרמיניסטי, גיבוי, שער-יו"ר, 0 collisions |
|
||||||
|
| 8047/23 edge | אולי מחיקת רשומה כפולה | בינוני — **רק** בהחלטת-יו"ר, מחיקה ידנית נפרדת, לא ב-apply האוטומטי |
|
||||||
|
| citation_formatted | **לא משתנה** (כבר תקין) | אין |
|
||||||
|
| FK/relations | `case_law_relations`/`precedent_internal_citations` מפנים ל-`id` (UUID), לא ל-case_number | אין — שינוי case_number לא שובר קשרים |
|
||||||
|
| chunks/embeddings | מפתח-זר `case_law_id` (UUID) — לא תלוי ב-case_number | אין — re-index לא נדרש |
|
||||||
|
|
||||||
|
## 7. אסטרטגיית בדיקה
|
||||||
|
|
||||||
|
- **בדיקות-יחידה offline** (`tests/test_fu2b_reconcile.py`): `_extract_bare` — token יחיד→bare מנורמל;
|
||||||
|
ציטוט מלא→המספר הנכון (דוגמאות אמיתיות: `"ערר (...) 403/17 אהרון ברק..."`→`403-17`,
|
||||||
|
`"...8136-10-24 שחר..."`→`8136-10-24` חודש נשמר); 0/רב-token→None+flag; consistency guard.
|
||||||
|
- **dry-run מול DB מקומי**: הטבלה מופקת, מספר-השורות-לשינוי = ~52, 0 MISMATCH, 1 DUP_CHECK (8047).
|
||||||
|
- **apply בסביבת-בדיקה**: על עותק/תיק-בדיקה — אימות idempotency (הרצה שנייה = 0 שינויים) + revert מהגיבוי.
|
||||||
|
- ה-apply בייצור רץ **רק אחרי אישור-יו"ר** (לא חלק מה-CI/PR; ידני ומבוקר).
|
||||||
|
|
||||||
|
## 8. סדר-ביצוע
|
||||||
|
|
||||||
|
1. בדיקות אדומות ל-`_extract_bare` + consistency guard.
|
||||||
|
2. `_extract_bare` + הסקריפט (`--dry-run` בלבד תחילה) + הפקת טבלת-תיאום + גיבוי.
|
||||||
|
3. בדיקות ירוקות + dry-run מול DB → הפקת הטבלה.
|
||||||
|
4. **עצירה: הצגת הטבלה + 8047/23 ליו"ר (דפנה)** — שער-אישור.
|
||||||
|
5. (אחרי אישור) מימוש `--apply --approved` + אימות + revert-script.
|
||||||
|
6. הרצת apply בייצור (מבוקר) + אימות-אחרי + TaskMaster #67.
|
||||||
|
|
||||||
|
> צעדים 1–3 לא דורשים את דפנה (אני מכין הכל). צעד 4 הוא שער-האישור. צעדים 5–6 אחרי אישורה.
|
||||||
92
docs/superpowers/specs/2026-05-31-fu5-eval-harness-design.md
Normal file
92
docs/superpowers/specs/2026-05-31-fu5-eval-harness-design.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# FU-5 — Retrieval Eval Harness + Backlog Visibility (design)
|
||||||
|
|
||||||
|
**Task:** #63 (legal-ai tag) · **Covers:** GAP-11, GAP-14 · **Provides:** INV-RET4, G8, INV-QA1, G10
|
||||||
|
**Status:** approved 2026-05-31 (gold-set strategy = hybrid, chair decision). Technical architecture
|
||||||
|
decided per `feedback_research_architecture_decisions` (chair adjudicates domain, not architecture).
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
1. **GAP-11 (INV-RET4/G8):** retrieval quality is never measured. Only `telemetry.log_search_bg`
|
||||||
|
records queries (observation, not evaluation). No gold-set, no precision/recall. Every RRF-weight
|
||||||
|
/ `k` / embedder change is tuned "by feel".
|
||||||
|
2. **GAP-14 (INV-QA1/G10):** the halacha review backlog (`review_status='pending_review'`) is
|
||||||
|
invisible — the 10/19-approved gap was found by accident. The human gate has no visibility.
|
||||||
|
|
||||||
|
## Two independent units
|
||||||
|
|
||||||
|
### Unit A — Retrieval eval harness (GAP-11)
|
||||||
|
|
||||||
|
**Existing leverage:** `search_relevance_feedback` already captures a real ground-truth signal —
|
||||||
|
when a finalized decision cites a precedent, `infer_relevance_from_citations` marks it
|
||||||
|
`relevance_score=3` against the `search_logs` where it appeared (telemetry.py). This bootstraps the
|
||||||
|
gold-set without hand-labeling.
|
||||||
|
|
||||||
|
**A1. Gold-set — versioned file `data/eval/gold-set.jsonl`** (single SoT; reviewable/diffable/
|
||||||
|
chair-editable). One JSON object per line:
|
||||||
|
```json
|
||||||
|
{"id":"g001","query":"...","practice_area":"betterment_levy",
|
||||||
|
"corpus":"precedent_library|internal_decisions",
|
||||||
|
"relevant_case_law_ids":["uuid",...],"source":"bootstrap|chair","note":""}
|
||||||
|
```
|
||||||
|
|
||||||
|
**A2. Bootstrap generator — `scripts/eval_gold_bootstrap.py`** (host-side, mcp-server venv):
|
||||||
|
reads `search_relevance_feedback` (score=3) ⨝ `search_logs`, groups by normalized query →
|
||||||
|
relevant `case_law_id` set, emits `source=bootstrap` entries. Idempotent: re-run regenerates the
|
||||||
|
bootstrap section; never overwrites `source=chair` rows. **Chair gate:** Dafna reviews the file,
|
||||||
|
corrects/augments, promotes entries to `source=chair`.
|
||||||
|
|
||||||
|
**A3. Harness — `scripts/eval_retrieval.py`** (host-side, mcp-server venv; needs POSTGRES + VOYAGE):
|
||||||
|
runs the **production retrieval path** (same service functions the MCP search tools call) for each
|
||||||
|
gold query, computes per-query **precision@k, recall@k, MRR, nDCG@k** (k∈{5,10}); relevant = gold
|
||||||
|
ids. Aggregates mean overall + per corpus + per practice_area. Writes
|
||||||
|
`data/eval/eval-report-<ts>.{json,md}`, prints a summary, and a delta vs the committed
|
||||||
|
`data/eval/baseline.json`. `--update-baseline` rewrites the snapshot.
|
||||||
|
|
||||||
|
**"CI gate" — realized as discipline, not automation.** Retrieval needs the prod DB + Voyage API;
|
||||||
|
no CI runner has that access. The gate is: re-runnable harness + committed `baseline.json` + a
|
||||||
|
documented "run before/after any retrieval-layer change, attach the delta" rule (SCRIPTS.md). A true
|
||||||
|
automated CI gate would require a separate frozen corpus fixture — out of scope, noted as future.
|
||||||
|
|
||||||
|
**Scope:** the two precedent corpora (`search_precedent_library` + `search_internal_decisions`),
|
||||||
|
where the citation signal exists. `search_decisions`/`search_case_documents` return case-document
|
||||||
|
chunks (not `case_law`) and carry no citation ground-truth — deliberately out of scope.
|
||||||
|
|
||||||
|
**Metrics rationale:** precision@k + recall@k are spec-required (INV-RET4). MRR (first-relevant
|
||||||
|
rank) and nDCG@k (graded, position-weighted) are standard IR complements (Manning et al., 2008) —
|
||||||
|
nDCG matches the telemetry docstring's stated nDCG@10 aspiration.
|
||||||
|
|
||||||
|
### Unit B — Backlog visibility (GAP-14) — pure code
|
||||||
|
|
||||||
|
Expose the halacha review backlog where health is already surfaced:
|
||||||
|
- **`metrics.get_dashboard()`** (mcp-server/src/legal_mcp/services/metrics.py) — add
|
||||||
|
`halacha_backlog: {pending_review, approved, rejected, published, total, oldest_pending_at}` from
|
||||||
|
`halachot.review_status` + `min(created_at) where pending_review`. Surfaces through the
|
||||||
|
`get_metrics` MCP tool (agents + dashboard).
|
||||||
|
- **`/api/system/diagnostics`** (web/app.py) — add the same `halacha_backlog` block to the health
|
||||||
|
snapshot.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Unit | Kind | Deploy |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| `scripts/eval_gold_bootstrap.py` | A2 | new, host-side | none |
|
||||||
|
| `scripts/eval_retrieval.py` | A3 | new, host-side | none |
|
||||||
|
| `data/eval/gold-set.jsonl` | A1 | data (on disk; chair-reviewed) | none |
|
||||||
|
| `data/eval/baseline.json` | A3 | committed snapshot | none |
|
||||||
|
| `mcp-server/src/legal_mcp/services/metrics.py` | B | edit `get_dashboard` | Coolify |
|
||||||
|
| `web/app.py` | B | edit diagnostics | Coolify |
|
||||||
|
| `scripts/SCRIPTS.md` | A | doc | none |
|
||||||
|
|
||||||
|
## Test strategy
|
||||||
|
|
||||||
|
- Bootstrap: idempotent (re-run = same bootstrap rows; chair rows untouched); 0 chair rows clobbered.
|
||||||
|
- Harness: metric math unit-verified offline on a synthetic (ranking, relevant-set) fixture
|
||||||
|
(precision@k / recall@k / MRR / nDCG@k against hand-computed values) before any DB run.
|
||||||
|
- Unit B: `get_metrics` (no case_number) returns `halacha_backlog` with counts summing to total;
|
||||||
|
diagnostics endpoint returns the same block. Verified against prod counts.
|
||||||
|
|
||||||
|
## Chair gate (domain — the only thing requiring Dafna)
|
||||||
|
|
||||||
|
After bootstrap produces `gold-set.jsonl`, Dafna reviews: are these queries representative, and are
|
||||||
|
the marked precedents the *correct* answers? Her edits make the gold-set authoritative. Until then
|
||||||
|
the baseline is "provisional (bootstrap-only)".
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# FU-8a — מחסומי-תהליך → מחסומי-קוד (Process Barriers → Code Guards) — עיצוב
|
||||||
|
|
||||||
|
**סטטוס:** מאושר-לעיצוב · **תאריך:** 2026-05-31 · **ענף:** TBD
|
||||||
|
**מכסה:** GAP-21, GAP-22 · **מספק:** INV-MC1, INV-INT1, INV-INT3 · **משימה:** TaskMaster #66
|
||||||
|
**תלוי ב:** — · **סוג:** pure-code · **מחוץ-להיקף:** GAP-23 (חיווט ספ→סוכנים) → #69 / FU-8b.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. הבעיה
|
||||||
|
|
||||||
|
שני מחסומים שהיום נשענים על **נוהל אנושי** ולא על **קוד**, ולכן ניתנים להפרה שקטה:
|
||||||
|
|
||||||
|
- **GAP-21 (INV-MC1):** סנכרון-סוכנים חוצה-חברות (`sync_agents_across_companies.py`) ידני ולא-נאכף.
|
||||||
|
ב-`--verify` (script:397) הוא **יוצא 0 גם כשיש drift**, ו-adapter_type-mismatch (script:388)
|
||||||
|
מודפס כ-"SKIPPING" ו**נבלע** — אין סיגנל-כשל שניתן לתלות בו gate.
|
||||||
|
- **GAP-22 (INV-INT1/INT3):** אין מחסום-קוד נגד עקיפת ה-helpers המאושרים של Paperclip — קריאת
|
||||||
|
`httpx`/`requests` גולמית ל-API של Paperclip (במקום `web/paperclip_api.pc_request`) או `INSERT`
|
||||||
|
ישיר ל-`agent_wakeup_requests` (במקום ה-wakeup API). היום זה כלל-נוהל ב-CLAUDE.md/HEARTBEAT בלבד.
|
||||||
|
|
||||||
|
## 2. ההכרעה (מאומתת ≥3 מקורות)
|
||||||
|
|
||||||
|
**Architectural fitness functions** — הופכים החלטת-ארכיטקטורה מ"הסכמה חברתית" ל**כלל נאכף שנתפס
|
||||||
|
ברגע ההפרה**, כ-assertion דמוי-טסט ב-CI. שני המחסומים מיושמים ככאלה:
|
||||||
|
|
||||||
|
| החלטה | נימוק | מקורות |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| GAP-21: `--verify` יוצא **non-zero על drift**; adapter_type-mismatch = **drift** (לא silent skip) + דיווח רם | drift-verify הוא gate רק אם הוא יוצא non-zero (Terraform 0/2); "alert, don't skip silently" | InfoQ fitness-functions; Firefly CI-drift; Spacelift drift |
|
||||||
|
| GAP-22: **fitness-function (pytest שסורק את ה-repo)**, לא import-linter | הכלל הוא דפוס-*שימוש*/מחרוזת (http ל-URL, מחרוזת-SQL), לא גבול-import; fitness-function כ-test-assertion ב-CI הוא הכלי | InfoQ; Lukas Niessen; aipatternbook |
|
||||||
|
| לרוץ ב-**חבילת-הטסטים הקיימת** (לא CI חדש) | אין CI-lint בפרויקט (רק deploy.yaml); חבילת ה-pytest היא שער-האיכות הקיים | (נגזר מהמצב) |
|
||||||
|
|
||||||
|
## 3. הרכיבים
|
||||||
|
|
||||||
|
- **Modify** `scripts/sync_agents_across_companies.py` (GAP-21):
|
||||||
|
- `--verify` יחזיר **exit 1** כש-`plan` לא-ריק **או** כשיש adapter_type-mismatch (כיום `return` שקט).
|
||||||
|
- adapter_type-mismatch: נספר ל-`mismatches` ומדווח רם (`❌`), לא רק "SKIPPING"; נכלל בסיגנל-הכשל
|
||||||
|
של `--verify`. (ה-skip עצמו ב-`--apply` נשמר — לא מסנכרנים adapter_type אוטומטית — אבל `--verify`
|
||||||
|
**נכשל** כדי לאלץ טיפול ידני.)
|
||||||
|
- **Create** `mcp-server/tests/test_paperclip_access_guard.py` (GAP-22) — fitness-function שסורק את
|
||||||
|
עץ-המקור (`web/`, `mcp-server/src/`, `scripts/`, `plugin-legal-ai/` אם רלוונטי) ו**נכשל** אם נמצא:
|
||||||
|
1. קריאת-HTTP גולמית ל-Paperclip — `httpx`/`requests`/`aiohttp` עם `PAPERCLIP_API_URL` או
|
||||||
|
`localhost:3100`/`pc.nautilus` — **מחוץ** ל-`web/paperclip_api.py` (ה-helper המאושר).
|
||||||
|
2. `INSERT INTO agent_wakeup_requests` (כל קובץ) — חייב לעבור דרך wakeup API.
|
||||||
|
3. `curl ... $PAPERCLIP_API_URL` ב-shell — מחוץ ל-`scripts/pc.sh`.
|
||||||
|
- מימוש: סריקת-טקסט ממוקדת (regex) עם **allowlist** מפורש (הקבצים המאושרים) + הודעת-כשל שמסבירה
|
||||||
|
את ה-helper הנכון. (AST מלא מיותר — הדפוסים הם מחרוזות-URL/SQL, לא מבנה-קוד.)
|
||||||
|
- **Create** `scripts/check_paperclip_access.py` (GAP-22, אופציונלי-דק) — wrapper הניתן להרצה ידנית/CI
|
||||||
|
שמריץ את אותה לוגיקת-סריקה (מייבא מהטסט או חולק helper); exit non-zero על הפרה. (אם הטסט מספיק —
|
||||||
|
לדלג, YAGNI.)
|
||||||
|
|
||||||
|
## 4. שינויי-התנהגות וסיכון
|
||||||
|
|
||||||
|
| שינוי | השפעה | סיכון |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| `--verify` יוצא 1 על drift | הופך ל-gate שמיש (אפשר לתלות בו cron/CI) | נמוך — לא משנה `--apply`; משנה רק exit-code של verify |
|
||||||
|
| adapter_type-mismatch רם + נכלל ב-fail | drift של adapter לא נבלע | נמוך — דיווח; ה-skip ב-apply נשמר |
|
||||||
|
| fitness-function guard | הפרה עתידית תיכשל בטסטים | נמוך — **ה-repo נסרק 2026-05-31: 0 הפרות קיימות** (אין httpx גולמי ל-Paperclip, אין INSERT ל-agent_wakeup_requests, אין curl גולמי). הגדר הוא גדר-קדימה נקי, אפס תיקוני-קוד קיימים |
|
||||||
|
| allowlist | קבצים מאושרים (paperclip_api.py, pc.sh) פטורים | נמוך — מפורש ומתועד |
|
||||||
|
|
||||||
|
## 5. אסטרטגיית בדיקה
|
||||||
|
|
||||||
|
- **GAP-22 fitness-function** נבדק על עצמו: הטסט מאתר דפוס-הפרה מוזרק (fixture עם httpx ל-Paperclip)
|
||||||
|
ומאשר שהוא נתפס; ומאשר שה-helpers המאושרים (paperclip_api.py) **לא** מסומנים. כלומר הטסט בודק
|
||||||
|
את הסורק על דוגמאות חיוביות+שליליות, ואז מריץ אותו על ה-repo האמיתי (חייב לעבור — אחרת יש הפרה
|
||||||
|
קיימת לתקן).
|
||||||
|
- **GAP-21** — בדיקת-יחידה ללוגיקת ה-exit/mismatch: מתוך master/mirror סינתטיים, `--verify` עם drift
|
||||||
|
מחזיר 1; ללא drift מחזיר 0; adapter_type-mismatch → 1 + הודעה. (refactor של לוגיקת-ההכרעה לפונקציה
|
||||||
|
טהורה `_verify_exit_code(plan, mismatches)` שניתנת לבדיקה offline בלי DB/Paperclip.)
|
||||||
|
- חבילה מלאה ירוקה; smoke: `sync...py --verify` מול ה-state הנוכחי (לדווח אם drift קיים).
|
||||||
|
|
||||||
|
## 6. סדר-ביצוע
|
||||||
|
|
||||||
|
1. בדיקות אדומות: `_verify_exit_code` (GAP-21) + סורק ה-guard על fixtures (GAP-22).
|
||||||
|
2. GAP-21: refactor `--verify` ל-`_verify_exit_code` + ספירת mismatches + exit 1 + דיווח רם.
|
||||||
|
3. GAP-22: סורק (`tests/test_paperclip_access_guard.py`) + allowlist; **הרצה על ה-repo** וטיפול בהפרות קיימות (תיקון או allowlist מנומק).
|
||||||
|
4. (אופציונלי) `scripts/check_paperclip_access.py` אם רוצים הרצה עצמאית.
|
||||||
|
5. חבילה ירוקה + smoke (`--verify`) + SCRIPTS.md (אם נוסף סקריפט) + PR+merge + TaskMaster #66.
|
||||||
|
|
||||||
|
> **GAP-23 (#69)** — חיווט הספ ל-HEARTBEAT/סוכנים — מחוץ-להיקף (משנה התנהגות-ייצור, דורש החלטה).
|
||||||
@@ -42,6 +42,38 @@ POSTGRES_URL = os.environ.get(
|
|||||||
# Redis
|
# Redis
|
||||||
REDIS_URL = os.environ.get("REDIS_URL", "redis://127.0.0.1:6380/0")
|
REDIS_URL = os.environ.get("REDIS_URL", "redis://127.0.0.1:6380/0")
|
||||||
|
|
||||||
|
# Claude CLI — model + effort for halacha extraction.
|
||||||
|
# All LLM calls go through the local `claude -p` CLI (claude_session.py).
|
||||||
|
# By default the CLI uses the developer's session default model with no
|
||||||
|
# explicit effort. For halacha extraction we pin Opus 4.8 @ xhigh: the
|
||||||
|
# 2026-05-31 A/B (scripts/ab_halacha_opus48.py) showed it cuts over-extraction
|
||||||
|
# (~124→51 on שטיין) at 100% quote-verification with honest confidence
|
||||||
|
# calibration. Env-overridable so the model/effort can be tuned without a
|
||||||
|
# code change (set to "" to fall back to the CLI default). Other extractors
|
||||||
|
# (claims, metadata, block-writing, QA) keep the CLI default unless similarly
|
||||||
|
# pinned.
|
||||||
|
HALACHA_EXTRACT_MODEL = os.environ.get("HALACHA_EXTRACT_MODEL", "claude-opus-4-8")
|
||||||
|
HALACHA_EXTRACT_EFFORT = os.environ.get("HALACHA_EXTRACT_EFFORT", "xhigh")
|
||||||
|
# Effort for BULK queue-drain extraction (process_pending over many precedents).
|
||||||
|
# xhigh is the quality sweet-spot for a single precedent but very slow at scale
|
||||||
|
# (a 64-chunk case ≈ 20 min). Bulk drains use a lighter effort to cut wall-clock;
|
||||||
|
# interactive single re-extraction keeps HALACHA_EXTRACT_EFFORT (xhigh). Tune via
|
||||||
|
# env (set to 'xhigh' to make bulk match single, or 'medium' for max speed).
|
||||||
|
HALACHA_BULK_EXTRACT_EFFORT = os.environ.get("HALACHA_BULK_EXTRACT_EFFORT", "high")
|
||||||
|
# Concurrent chunks WITHIN a single extraction. Each `claude -p` @ xhigh holds
|
||||||
|
# ~300MB RSS + heavy CPU; cross-process overlap (agent retries) on top of this
|
||||||
|
# froze the box on 2026-05-31 (hard reboot). A global advisory lock now caps
|
||||||
|
# the system to ONE extraction at a time; this caps the chunks within it.
|
||||||
|
HALACHA_CHUNK_CONCURRENCY = int(os.environ.get("HALACHA_CHUNK_CONCURRENCY", "3"))
|
||||||
|
HALACHA_CORROBORATION_MATCH_FLOOR = float(os.environ.get("HALACHA_CORROBORATION_MATCH_FLOOR", "0.50"))
|
||||||
|
HALACHA_CORROBORATION_MIN_CITES = int(os.environ.get("HALACHA_CORROBORATION_MIN_CITES", "2"))
|
||||||
|
# X11 Phase 2: gate corroboration → approval. Default ON (Dafna validated the
|
||||||
|
# Phase 1 signal, 2026-06-01). Set to "false" to disable the auto-approve/demote
|
||||||
|
# wiring while keeping the Phase 1 signal intact.
|
||||||
|
HALACHA_CORROBORATION_AUTO_APPROVE = os.environ.get(
|
||||||
|
"HALACHA_CORROBORATION_AUTO_APPROVE", "true"
|
||||||
|
).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
# Voyage AI
|
# Voyage AI
|
||||||
VOYAGE_API_KEY = os.environ.get("VOYAGE_API_KEY", "")
|
VOYAGE_API_KEY = os.environ.get("VOYAGE_API_KEY", "")
|
||||||
VOYAGE_MODEL = os.environ.get("VOYAGE_MODEL", "voyage-law-2")
|
VOYAGE_MODEL = os.environ.get("VOYAGE_MODEL", "voyage-law-2")
|
||||||
@@ -112,6 +144,42 @@ HALACHA_AUTO_APPROVE_THRESHOLD = float(
|
|||||||
os.environ.get("HALACHA_AUTO_APPROVE_THRESHOLD", "0.80")
|
os.environ.get("HALACHA_AUTO_APPROVE_THRESHOLD", "0.80")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Halacha dedup-on-insert — within-precedent semantic cosine ceiling. Before
|
||||||
|
# storing a halacha, store_halachot_for_chunk skips it if its rule-embedding has
|
||||||
|
# cosine >= this value against an already-stored halacha of the SAME precedent
|
||||||
|
# (exact normalized supporting_quote is always skipped regardless). 0.93 is the
|
||||||
|
# conservative auto-skip floor: the 2026-06-03 cleanup showed the 0.90-0.95 band
|
||||||
|
# is "almost entirely" same-rule-reworded, but auto-skip is unreviewed so we sit
|
||||||
|
# just above the manual-cleanup 0.90 to avoid dropping a genuinely distinct
|
||||||
|
# principle. Set > 1.0 to disable semantic dedup (exact-quote dedup still runs).
|
||||||
|
HALACHA_DEDUP_COSINE = float(os.environ.get("HALACHA_DEDUP_COSINE", "0.93"))
|
||||||
|
|
||||||
|
# Halacha dedup TAIL band (#82.3) — the [BAND_COSINE, DEDUP_COSINE) range is too
|
||||||
|
# low to auto-skip but suspicious. A halacha whose nearest same-precedent
|
||||||
|
# neighbor sits in this band AND has high LEXICAL overlap (Jaccard/Levenshtein
|
||||||
|
# on rule_statement) is flagged 'near_duplicate' (blocks auto-approve → review),
|
||||||
|
# not skipped — catching paraphrases the cosine threshold misses without
|
||||||
|
# dropping a possibly-distinct principle unreviewed. 0.83 from the same cleanup.
|
||||||
|
HALACHA_DEDUP_BAND_COSINE = float(os.environ.get("HALACHA_DEDUP_BAND_COSINE", "0.83"))
|
||||||
|
|
||||||
|
# Halacha NLI entailment validator (#81.3) — after extraction, a claude_session
|
||||||
|
# judge checks each halacha's rule_statement is entailed by its supporting_quote.
|
||||||
|
# Non-entailed (neutral/contradiction) → quality flag 'nli_unsupported' that
|
||||||
|
# blocks auto-approve. Runs through the local CLI (zero cost); fails OPEN if the
|
||||||
|
# CLI is unavailable (e.g. container). 'low' effort — entailment is a simple call.
|
||||||
|
HALACHA_NLI_ENABLED = os.environ.get("HALACHA_NLI_ENABLED", "true").lower() == "true"
|
||||||
|
HALACHA_NLI_MODEL = os.environ.get("HALACHA_NLI_MODEL", HALACHA_EXTRACT_MODEL)
|
||||||
|
HALACHA_NLI_EFFORT = os.environ.get("HALACHA_NLI_EFFORT", "low")
|
||||||
|
|
||||||
|
# Halacha over-extraction consolidation (#81.5) — after a precedent finishes
|
||||||
|
# extracting, a claude_session pass folds facets of the SAME legal question
|
||||||
|
# (below the #82 dedup cosine) into one canonical; the rest are marked rejected
|
||||||
|
# (reversible). Cross-chunk safety net for over-splitting. Runs through the local
|
||||||
|
# CLI (zero cost); fails OPEN. 'high' effort — folding needs careful judgment.
|
||||||
|
HALACHA_CONSOLIDATE_ENABLED = os.environ.get("HALACHA_CONSOLIDATE_ENABLED", "true").lower() == "true"
|
||||||
|
HALACHA_CONSOLIDATE_MODEL = os.environ.get("HALACHA_CONSOLIDATE_MODEL", HALACHA_EXTRACT_MODEL)
|
||||||
|
HALACHA_CONSOLIDATE_EFFORT = os.environ.get("HALACHA_CONSOLIDATE_EFFORT", "high")
|
||||||
|
|
||||||
# Google Cloud Vision (OCR for scanned PDFs)
|
# Google Cloud Vision (OCR for scanned PDFs)
|
||||||
GOOGLE_CLOUD_VISION_API_KEY = os.environ.get("GOOGLE_CLOUD_VISION_API_KEY", "")
|
GOOGLE_CLOUD_VISION_API_KEY = os.environ.get("GOOGLE_CLOUD_VISION_API_KEY", "")
|
||||||
|
|
||||||
|
|||||||
@@ -84,10 +84,24 @@ async def case_create(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# INV-TOOL5 / GAP-53: hard cap on list/search result sizes (OWASP API4:2023 —
|
||||||
|
# Unrestricted Resource Consumption). Non-positive is treated as "max", not "all".
|
||||||
|
_MAX_LIMIT = 200
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_limit(limit: int, hard_max: int = _MAX_LIMIT) -> int:
|
||||||
|
"""Clamp a caller-supplied result limit to [1, hard_max]."""
|
||||||
|
try:
|
||||||
|
n = int(limit)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return hard_max
|
||||||
|
return hard_max if n <= 0 else min(n, hard_max)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def case_list(status: str = "", limit: int = 50) -> str:
|
async def case_list(status: str = "", limit: int = 50) -> str:
|
||||||
"""רשימת תיקי ערר. סינון אופציונלי לפי סטטוס (new/in_progress/drafted/reviewed/final)."""
|
"""רשימת תיקי ערר. סינון אופציונלי לפי סטטוס (new/in_progress/drafted/reviewed/final)."""
|
||||||
return await cases.case_list(status, limit)
|
return await cases.case_list(status, _clamp_limit(limit))
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -108,7 +122,7 @@ async def case_update(
|
|||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
expected_outcome: str = "",
|
expected_outcome: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""עדכון פרטי תיק. expected_outcome: rejection/partial_acceptance/full_acceptance/betterment_levy."""
|
"""עדכון פרטי תיק. expected_outcome: rejection/partial_acceptance/full_acceptance (betterment_levy הוא practice_area, לא תוצאה)."""
|
||||||
return await cases.case_update(
|
return await cases.case_update(
|
||||||
case_number, status, title, subject, notes,
|
case_number, status, title, subject, notes,
|
||||||
hearing_date, decision_date, tags, expected_outcome,
|
hearing_date, decision_date, tags, expected_outcome,
|
||||||
@@ -156,13 +170,33 @@ async def precedent_remove(precedent_id: str) -> str:
|
|||||||
return await precedents.precedent_remove(precedent_id)
|
return await precedents.precedent_remove(precedent_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_case_precedents(
|
||||||
|
query: str, practice_area: str = "", limit: int = 10,
|
||||||
|
) -> str:
|
||||||
|
"""חיפוש בציטוטי-פסיקה שדפנה צירפה ידנית לתיקים (טבלת case_precedents) —
|
||||||
|
קורפוס "case-attached". זה **לא** ספריית-הפסיקה הסמכותית.
|
||||||
|
|
||||||
|
GAP-49 (INV-TOOL2): שם קודם היה `precedent_search_library` — הפוך וכמעט-זהה
|
||||||
|
ל-`search_precedent_library` (הספרייה הסמכותית), מה שסיכן ציטוט מהמקור הלא-נכון.
|
||||||
|
אל תצטט מכאן כמקור-סמכות ל-CREAC; לזה השתמש ב-`search_precedent_library`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: מחרוזת חיפוש (מול citation ו-quote)
|
||||||
|
practice_area: סינון תחום משפטי (אופציונלי)
|
||||||
|
limit: תקרת תוצאות
|
||||||
|
"""
|
||||||
|
return await precedents.search_case_precedents(query, practice_area, _clamp_limit(limit))
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def precedent_search_library(
|
async def precedent_search_library(
|
||||||
query: str, practice_area: str = "", limit: int = 10,
|
query: str, practice_area: str = "", limit: int = 10,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""חיפוש בציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
"""DEPRECATED (GAP-49) — שם-מטעה. השתמש ב-`search_case_precedents` (ציטוטים
|
||||||
שונה מ-search_precedent_library שמחפש בקורפוס הפסיקה הסמכותית."""
|
מצורפים-לתיק) או ב-`search_precedent_library` (ספריית-הפסיקה הסמכותית).
|
||||||
return await precedents.precedent_search_library(query, practice_area, limit)
|
Alias זמני לתאימות-לאחור — מנתב ל-search_case_precedents."""
|
||||||
|
return await precedents.search_case_precedents(query, practice_area, _clamp_limit(limit))
|
||||||
|
|
||||||
|
|
||||||
# ── External Precedent Library — authoritative case-law corpus ─────
|
# ── External Precedent Library — authoritative case-law corpus ─────
|
||||||
@@ -214,7 +248,7 @@ async def precedent_library_list(
|
|||||||
"""
|
"""
|
||||||
return await plib.precedent_library_list(
|
return await plib.precedent_library_list(
|
||||||
practice_area, court, precedent_level, source_type, search,
|
practice_area, court, precedent_level, source_type, search,
|
||||||
source_kind, limit,
|
source_kind, _clamp_limit(limit),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -258,6 +292,12 @@ async def precedent_extract_metadata(case_law_id: str) -> str:
|
|||||||
return await plib.precedent_extract_metadata(case_law_id)
|
return await plib.precedent_extract_metadata(case_law_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def precedent_reindex(case_law_id: str) -> str:
|
||||||
|
"""re-chunk + re-embed פסיקה קיימת מה-full_text השמור (FU-3/GAP-09). אינו מריץ OCR/LLM — רק chunking + voyage embeddings. idempotent."""
|
||||||
|
return await plib.precedent_reindex(case_law_id)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def style_corpus_enrich(corpus_id: str, overwrite: bool = False) -> str:
|
async def style_corpus_enrich(corpus_id: str, overwrite: bool = False) -> str:
|
||||||
"""חילוץ מטא-דאטה (summary, outcome, key_principles, appeal_subtype) להחלטה בקורפוס הסגנון של דפנה. ברירת מחדל: ממלא רק שדות ריקים. שלח `overwrite=true` כדי לרענן."""
|
"""חילוץ מטא-דאטה (summary, outcome, key_principles, appeal_subtype) להחלטה בקורפוס הסגנון של דפנה. ברירת מחדל: ממלא רק שדות ריקים. שלח `overwrite=true` כדי לרענן."""
|
||||||
@@ -267,13 +307,19 @@ async def style_corpus_enrich(corpus_id: str, overwrite: bool = False) -> str:
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def style_corpus_pending_enrichment(limit: int = 50) -> str:
|
async def style_corpus_pending_enrichment(limit: int = 50) -> str:
|
||||||
"""רשימת החלטות בקורפוס הסגנון שעדיין חסרות summary/outcome/key_principles — מועמדות לחילוץ."""
|
"""רשימת החלטות בקורפוס הסגנון שעדיין חסרות summary/outcome/key_principles — מועמדות לחילוץ."""
|
||||||
return await train_tools.list_corpus_pending_enrichment(limit)
|
return await train_tools.list_corpus_pending_enrichment(_clamp_limit(limit))
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def extraction_status() -> str:
|
||||||
|
"""סטטוס תור-החילוץ — כמה פסיקות ממתינות לחילוץ metadata/halacha + גיל הבקשה הוותיקה. read-only (חושף את התור ש-precedent_process_pending מרוקן)."""
|
||||||
|
return await plib.extraction_status()
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str:
|
async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str:
|
||||||
"""ריקון תור בקשות חילוץ שנשלחו מ-UI. kind: 'metadata' או 'halacha'. מריץ extractor מקומית עם CLI על כל פריט בתור, ומנקה את הסימון אחרי הצלחה."""
|
"""ריקון תור בקשות חילוץ שנשלחו מ-UI. kind: 'metadata' או 'halacha'. מריץ extractor מקומית עם CLI על כל פריט בתור, ומנקה את הסימון אחרי הצלחה."""
|
||||||
return await plib.precedent_process_pending(kind, limit)
|
return await plib.precedent_process_pending(kind, _clamp_limit(limit))
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -290,7 +336,7 @@ async def search_precedent_library(
|
|||||||
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית. מחזיר הלכות (מאושרות בלבד) + קטעי טקסט. השתמש כש-legal-writer צריך לצטט פסיקה מחייבת בבלוק י (CREAC: rule + explanation)."""
|
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית. מחזיר הלכות (מאושרות בלבד) + קטעי טקסט. השתמש כש-legal-writer צריך לצטט פסיקה מחייבת בבלוק י (CREAC: rule + explanation)."""
|
||||||
return await plib.search_precedent_library(
|
return await plib.search_precedent_library(
|
||||||
query, practice_area, court, precedent_level, appeal_subtype,
|
query, practice_area, court, precedent_level, appeal_subtype,
|
||||||
None, subject_tag, limit, include_halachot,
|
None, subject_tag, _clamp_limit(limit), include_halachot,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -314,7 +360,7 @@ async def halacha_review(
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def halachot_pending(limit: int = 100) -> str:
|
async def halachot_pending(limit: int = 100) -> str:
|
||||||
"""תור ההלכות הממתינות לאישור."""
|
"""תור ההלכות הממתינות לאישור."""
|
||||||
return await plib.halachot_pending(limit)
|
return await plib.halachot_pending(_clamp_limit(limit))
|
||||||
|
|
||||||
|
|
||||||
# Documents
|
# Documents
|
||||||
@@ -433,7 +479,7 @@ async def search_decisions(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים — מסונן לפי תחום משפטי."""
|
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים — מסונן לפי תחום משפטי."""
|
||||||
return await search.search_decisions(
|
return await search.search_decisions(
|
||||||
query, limit, section_type, practice_area, appeal_subtype, case_number,
|
query, _clamp_limit(limit), section_type, practice_area, appeal_subtype, case_number,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -444,7 +490,7 @@ async def search_case_documents(
|
|||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""חיפוש סמנטי בתוך מסמכי תיק ספציפי."""
|
"""חיפוש סמנטי בתוך מסמכי תיק ספציפי."""
|
||||||
return await search.search_case_documents(case_number, query, limit)
|
return await search.search_case_documents(case_number, query, _clamp_limit(limit))
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -457,7 +503,7 @@ async def find_similar_cases(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""מציאת תיקים דומים על בסיס תיאור — מסונן לפי תחום משפטי."""
|
"""מציאת תיקים דומים על בסיס תיאור — מסונן לפי תחום משפטי."""
|
||||||
return await search.find_similar_cases(
|
return await search.find_similar_cases(
|
||||||
description, limit, practice_area, appeal_subtype, case_number,
|
description, _clamp_limit(limit), practice_area, appeal_subtype, case_number,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -490,7 +536,7 @@ async def search_internal_decisions(
|
|||||||
כשרוצים להרחיב מעבר לטקסט המקורי. default False.
|
כשרוצים להרחיב מעבר לטקסט המקורי. default False.
|
||||||
"""
|
"""
|
||||||
return await search.search_internal_decisions(
|
return await search.search_internal_decisions(
|
||||||
query, practice_area, appeal_subtype, district, chair_name, limit, include_halachot,
|
query, practice_area, appeal_subtype, district, chair_name, _clamp_limit(limit), include_halachot,
|
||||||
include_cited_by=include_cited_by,
|
include_cited_by=include_cited_by,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -502,13 +548,25 @@ async def get_style_guide() -> str:
|
|||||||
return await drafting.get_style_guide()
|
return await drafting.get_style_guide()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def style_distance(case_number: str) -> str:
|
||||||
|
"""מדד מרחק-סגנון (T7) — האם הטיוטה מתכנסת לסגנון דפנה: סטיית יחסי-זהב,
|
||||||
|
ספירת אנטי-דפוסים, ושיעור-השינוי draft→final מפנקס-ההתאמה. ללא LLM."""
|
||||||
|
import json as _json
|
||||||
|
from legal_mcp.services import style_distance as _sd
|
||||||
|
result = await _sd.style_distance(case_number)
|
||||||
|
return _json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def draft_section(
|
async def draft_section(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
section: str,
|
section: str,
|
||||||
instructions: str = "",
|
instructions: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""הרכבת הקשר מלא לניסוח סעיף (עובדות + תקדימים + סגנון)."""
|
"""DEPRECATED (GAP-50/INV-TOOL2) — הרכבת הקשר לניסוח לפי **סעיף** (granularity ישן).
|
||||||
|
העדף את `get_block_context(case_number, block_id)` — הקשר לפי-בלוק, התואם
|
||||||
|
לארכיטקטורת 12-הבלוקים הקנונית. נשמר זמנית לתאימות-לאחור."""
|
||||||
return await drafting.draft_section(case_number, section, instructions)
|
return await drafting.draft_section(case_number, section, instructions)
|
||||||
|
|
||||||
|
|
||||||
@@ -565,6 +623,12 @@ async def extract_appraiser_facts(case_number: str) -> str:
|
|||||||
return await drafting.extract_appraiser_facts(case_number)
|
return await drafting.extract_appraiser_facts(case_number)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_appraiser_facts(case_number: str) -> str:
|
||||||
|
"""קריאת עובדות-השמאי שכבר חולצו (facts + סתירות) — ללא חילוץ-מחדש יקר. ה-get המקביל ל-extract_appraiser_facts."""
|
||||||
|
return await drafting.get_appraiser_facts(case_number)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
||||||
"""כתיבת ארבעת הבלוקים לטיוטת ביניים (רקע, תכניות+היתרים, טענות, הליכים) — אותו skill וטמפלט."""
|
"""כתיבת ארבעת הבלוקים לטיוטת ביניים (רקע, תכניות+היתרים, טענות, הליכים) — אותו skill וטמפלט."""
|
||||||
@@ -648,7 +712,7 @@ async def set_outcome(
|
|||||||
outcome: str,
|
outcome: str,
|
||||||
reasoning: str = "",
|
reasoning: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""הזנת תוצאה לתיק: rejected (דחייה), accepted (קבלה), partial (קבלה חלקית). אם אין נימוק — מפעיל סיעור מוחות."""
|
"""הזנת תוצאה לתיק: rejection (דחייה), partial_acceptance (קבלה חלקית), full_acceptance (קבלה מלאה). ערכי-legacy ממופים. אם אין נימוק — מפעיל סיעור מוחות."""
|
||||||
return await workflow.set_outcome(case_number, outcome, reasoning)
|
return await workflow.set_outcome(case_number, outcome, reasoning)
|
||||||
|
|
||||||
|
|
||||||
@@ -807,7 +871,7 @@ async def missing_precedent_list(
|
|||||||
case_number=case_number,
|
case_number=case_number,
|
||||||
status=status,
|
status=status,
|
||||||
legal_topic=legal_topic,
|
legal_topic=legal_topic,
|
||||||
limit=limit,
|
limit=_clamp_limit(limit),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -872,7 +936,7 @@ async def list_internal_citations(
|
|||||||
return await cit_tools.list_internal_citations(
|
return await cit_tools.list_internal_citations(
|
||||||
case_law_id=case_law_id,
|
case_law_id=case_law_id,
|
||||||
linked_only=linked_only,
|
linked_only=linked_only,
|
||||||
limit=limit,
|
limit=_clamp_limit(limit),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -888,7 +952,7 @@ async def list_incoming_citations(
|
|||||||
"""
|
"""
|
||||||
return await cit_tools.list_incoming_citations(
|
return await cit_tools.list_incoming_citations(
|
||||||
case_law_id=case_law_id,
|
case_law_id=case_law_id,
|
||||||
limit=limit,
|
limit=_clamp_limit(limit),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -911,9 +975,33 @@ async def list_chair_feedback(
|
|||||||
case_number: str = "",
|
case_number: str = "",
|
||||||
category: str = "",
|
category: str = "",
|
||||||
unresolved_only: bool = True,
|
unresolved_only: bool = True,
|
||||||
|
limit: int = 100,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""הצגת הערות יו"ר שתועדו — אפשר לסנן לפי תיק, קטגוריה, מטופלות."""
|
"""הצגת הערות יו"ר שתועדו — אפשר לסנן לפי תיק, קטגוריה, מטופלות."""
|
||||||
return await workflow.list_chair_feedback(case_number, category, unresolved_only)
|
return await workflow.list_chair_feedback(case_number, category, unresolved_only, _clamp_limit(limit))
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def halacha_corroboration(halacha_id: str) -> dict:
|
||||||
|
"""החזר את ה-corroboration של הלכה: הציטוטים שמתקפים אותה, הטיפול, וסיכום (X11, read-only)."""
|
||||||
|
from uuid import UUID
|
||||||
|
from legal_mcp.services import corroboration as cor, db
|
||||||
|
links = await db.list_corroboration_for_halacha(UUID(halacha_id))
|
||||||
|
agg = cor.aggregate(
|
||||||
|
[{"source_id": (l["citing_case_law_id"] or l["citing_decision_id"] or ""), "treatment": l["treatment"]} for l in links]
|
||||||
|
)
|
||||||
|
return {"halacha_id": halacha_id, "summary": agg, "citations": links}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def corroboration_rebuild(case_law_id: str = "") -> dict:
|
||||||
|
"""בנה/רענן את ה-corroboration ויישם אישור-אוטומטי. ריק = כל הקורפוס (backfill);
|
||||||
|
מזהה-תקדים = תקדים בודד. כותב halacha_citation_corroboration ומעדכן review_status
|
||||||
|
(corroborated→approved, overruled→pending_review). X11 Phase 2."""
|
||||||
|
from legal_mcp.services import corroboration as cor
|
||||||
|
if case_law_id.strip():
|
||||||
|
return await cor.build_for_precedent(case_law_id.strip())
|
||||||
|
return await cor.build_all()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -44,6 +44,26 @@ async def log_action(
|
|||||||
json.dumps(details or {}, ensure_ascii=False)[:200])
|
json.dumps(details or {}, ensure_ascii=False)[:200])
|
||||||
|
|
||||||
|
|
||||||
|
async def log_action_safe(
|
||||||
|
action: str,
|
||||||
|
case_id: "UUID | None" = None,
|
||||||
|
document_id: "UUID | None" = None,
|
||||||
|
details: dict | None = None,
|
||||||
|
user: str = "system",
|
||||||
|
) -> None:
|
||||||
|
"""Non-fatal audit: never let an audit-log failure break the caller's action.
|
||||||
|
|
||||||
|
The authoritative integrity trail is git (X5 §2.1); audit_log is the
|
||||||
|
'who/what/when' observability layer, so a write failure is logged as a
|
||||||
|
warning and swallowed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await log_action(action, case_id=case_id, document_id=document_id,
|
||||||
|
details=details, user=user)
|
||||||
|
except Exception as e: # noqa: BLE001 — observability must not break the op
|
||||||
|
logger.warning("audit log_action failed (non-fatal) for %s: %s", action, e)
|
||||||
|
|
||||||
|
|
||||||
async def get_audit_log(
|
async def get_audit_log(
|
||||||
case_id: UUID | None = None,
|
case_id: UUID | None = None,
|
||||||
action: str | None = None,
|
action: str | None = None,
|
||||||
|
|||||||
@@ -19,8 +19,14 @@ from datetime import date
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import db, embeddings, claude_session
|
from legal_mcp.services import db, embeddings, claude_session, audit
|
||||||
from legal_mcp.services.lessons import get_content_checklist, get_methodology_summary
|
from legal_mcp.services.lessons import (
|
||||||
|
OUTCOME_LABELS_HE,
|
||||||
|
PRACTICE_AREA_OVERRIDES,
|
||||||
|
canonical_outcome,
|
||||||
|
get_content_checklist,
|
||||||
|
get_methodology_summary,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -242,8 +248,12 @@ BLOCK_PROMPTS = {
|
|||||||
## חומרי מקור:
|
## חומרי מקור:
|
||||||
{source_context}
|
{source_context}
|
||||||
|
|
||||||
## פסיקה רלוונטית (צטט מכאן ומהידע הכללי שלך):
|
## דוגמאות-סגנון מהחלטות דפנה — מבנה וקול בלבד:
|
||||||
{precedents_context}
|
⚠️ אלה דוגמאות ל**איך** דפנה כותבת (מבנה, קצב, תנועות-הנמקה, ביטויים) — **לא מקור-תוכן**. הכלל המבחין: נוסחה/בוילרפלייט קבוע (פתיח דוקטרינלי, תבנית-סיום) → מותר להעתיק; ניתוח/טענות ספציפיים → **הכלל את הדפוס והתאם לתיק שלפניך**, אל תעתיק; מהות משפטית (הלכה/עובדה) מתיק אחר → **אסור** להעתיק.
|
||||||
|
{daphna_style_exemplars}
|
||||||
|
|
||||||
|
## פסיקה רלוונטית לציטוט (צטט מכאן ומהידע הכללי שלך):
|
||||||
|
{case_law_citations}
|
||||||
|
|
||||||
## סגנון דפנה:
|
## סגנון דפנה:
|
||||||
{style_context}""",
|
{style_context}""",
|
||||||
@@ -270,10 +280,11 @@ BLOCK_PROMPTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Discussion structure by outcome
|
# Discussion structure by outcome
|
||||||
|
# GAP-51: keyed by canonical outcomes (rejection/partial_acceptance/full_acceptance).
|
||||||
STRUCTURE_GUIDANCE = {
|
STRUCTURE_GUIDANCE = {
|
||||||
"rejected": "דחייה — שכבות הגנה (concentric circles): טענה ראשית → נדחית, טענה חלופית → נדחית, חיזוק.",
|
"rejection": "דחייה — שכבות הגנה (concentric circles): טענה ראשית → נדחית, טענה חלופית → נדחית, חיזוק.",
|
||||||
"accepted": "קבלה — נימוק-נימוק: כל נימוק = CREAC מלא, בניית שכנוע הדרגתי.",
|
"full_acceptance": "קבלה — נימוק-נימוק: כל נימוק = CREAC מלא, בניית שכנוע הדרגתי.",
|
||||||
"partial": "קבלה חלקית — מיפוי מתחים: מה מתקבל ולמה, מה נדחה ולמה, איזון.",
|
"partial_acceptance": "קבלה חלקית — מיפוי מתחים: מה מתקבל ולמה, מה נדחה ולמה, איזון.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -305,7 +316,9 @@ async def write_block(
|
|||||||
# Template blocks
|
# Template blocks
|
||||||
if block_id in TEMPLATE_WRITERS:
|
if block_id in TEMPLATE_WRITERS:
|
||||||
content = TEMPLATE_WRITERS[block_id](case, decision)
|
content = TEMPLATE_WRITERS[block_id](case, decision)
|
||||||
return _build_result(block_id, content, block_cfg)
|
r = _build_result(block_id, content, block_cfg)
|
||||||
|
r["sources"] = {"document_ids": [], "claim_ids": [], "case_law_ids": []}
|
||||||
|
return r
|
||||||
|
|
||||||
# AI-generated blocks
|
# AI-generated blocks
|
||||||
prompt_template = BLOCK_PROMPTS.get(block_id)
|
prompt_template = BLOCK_PROMPTS.get(block_id)
|
||||||
@@ -318,15 +331,22 @@ async def write_block(
|
|||||||
claims_context = await _build_claims_context(case_id)
|
claims_context = await _build_claims_context(case_id)
|
||||||
direction_context = _build_direction_context(decision)
|
direction_context = _build_direction_context(decision)
|
||||||
plans_context = await _build_plans_context(case_id)
|
plans_context = await _build_plans_context(case_id)
|
||||||
precedents_context = await _build_precedents_context(case_id, block_id)
|
daphna_style_exemplars, case_law_citations, _precedent_case_law_ids = (
|
||||||
style_context = await _build_style_context()
|
await _build_precedents_context(case_id, block_id)
|
||||||
|
)
|
||||||
|
style_context = await _build_style_context(case.get("practice_area", ""))
|
||||||
discussion_context = await _build_previous_blocks_context(case_id, decision)
|
discussion_context = await _build_previous_blocks_context(case_id, decision)
|
||||||
appraiser_facts_context = await _build_appraiser_facts_context(case_id)
|
appraiser_facts_context = await _build_appraiser_facts_context(case_id)
|
||||||
appraiser_conflicts_context = await _build_appraiser_conflicts_context(case_id)
|
appraiser_conflicts_context = await _build_appraiser_conflicts_context(case_id)
|
||||||
post_hearing_context = await _build_post_hearing_context(case_id)
|
post_hearing_context = await _build_post_hearing_context(case_id)
|
||||||
|
|
||||||
outcome = (decision or {}).get("outcome", "rejected")
|
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
|
||||||
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
||||||
|
if case.get("practice_area") == "betterment_levy":
|
||||||
|
structure_guidance = (
|
||||||
|
structure_guidance + " | היטל השבחה: "
|
||||||
|
+ " ".join(PRACTICE_AREA_OVERRIDES["betterment_levy"]["discussion_rules"])
|
||||||
|
).strip()
|
||||||
|
|
||||||
# Content checklist — tells block-yod WHAT topics to cover
|
# Content checklist — tells block-yod WHAT topics to cover
|
||||||
content_checklist = ""
|
content_checklist = ""
|
||||||
@@ -349,7 +369,8 @@ async def write_block(
|
|||||||
claims_context=claims_context,
|
claims_context=claims_context,
|
||||||
direction_context=direction_context,
|
direction_context=direction_context,
|
||||||
plans_context=plans_context,
|
plans_context=plans_context,
|
||||||
precedents_context=precedents_context,
|
daphna_style_exemplars=daphna_style_exemplars,
|
||||||
|
case_law_citations=case_law_citations,
|
||||||
style_context=style_context,
|
style_context=style_context,
|
||||||
discussion_context=discussion_context,
|
discussion_context=discussion_context,
|
||||||
structure_guidance=structure_guidance,
|
structure_guidance=structure_guidance,
|
||||||
@@ -391,7 +412,11 @@ async def write_block(
|
|||||||
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
||||||
content = await claude_session.query(prompt, timeout=timeout)
|
content = await claude_session.query(prompt, timeout=timeout)
|
||||||
|
|
||||||
return _build_result(block_id, content, block_cfg)
|
sources = await _collect_block_sources(case_id, block_id)
|
||||||
|
sources["case_law_ids"] = _precedent_case_law_ids
|
||||||
|
result = _build_result(block_id, content, block_cfg)
|
||||||
|
result["sources"] = sources
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
||||||
@@ -408,11 +433,32 @@ def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_block_sources(case_id: UUID, block_id: str) -> dict:
|
||||||
|
"""Deterministic source ids available to a block's generation (GAP-19).
|
||||||
|
|
||||||
|
document_ids: case documents matching the block's allowed doc-types.
|
||||||
|
claim_ids: extracted claims for the case. (case_law_ids are captured
|
||||||
|
separately from the precedent search inside write_block.)
|
||||||
|
"""
|
||||||
|
allowed = _BLOCK_DOC_TYPES.get(block_id, []) # [] = all docs; None = no source docs
|
||||||
|
if allowed is None:
|
||||||
|
docs = [] # mirror _build_source_context: this block consumes no raw source docs
|
||||||
|
else:
|
||||||
|
docs = await db.list_documents(case_id)
|
||||||
|
if allowed:
|
||||||
|
docs = [d for d in docs if d.get("doc_type") in allowed]
|
||||||
|
claims = await db.get_claims(case_id)
|
||||||
|
return {
|
||||||
|
"document_ids": [str(d["id"]) for d in docs],
|
||||||
|
"claim_ids": [str(c["id"]) for c in claims],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Context builders ──────────────────────────────────────────────
|
# ── Context builders ──────────────────────────────────────────────
|
||||||
|
|
||||||
def _build_case_context(case: dict, decision: dict | None) -> str:
|
def _build_case_context(case: dict, decision: dict | None) -> str:
|
||||||
outcome = (decision or {}).get("outcome", "")
|
outcome = canonical_outcome((decision or {}).get("outcome", ""))
|
||||||
outcome_heb = {"rejected": "דחייה", "accepted": "קבלה", "partial": "קבלה חלקית"}.get(outcome, "")
|
outcome_heb = OUTCOME_LABELS_HE.get(outcome, "")
|
||||||
return f"""- מספר תיק: {case['case_number']}
|
return f"""- מספר תיק: {case['case_number']}
|
||||||
- כותרת: {case.get('title', '')}
|
- כותרת: {case.get('title', '')}
|
||||||
- עוררים: {', '.join(case.get('appellants', []))}
|
- עוררים: {', '.join(case.get('appellants', []))}
|
||||||
@@ -668,33 +714,64 @@ async def _build_post_hearing_context(case_id: UUID) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
async def _build_precedents_context(case_id: UUID, block_id: str) -> str:
|
async def _build_precedents_context(
|
||||||
"""Search for similar precedent paragraphs from other decisions and case law."""
|
case_id: UUID, block_id: str,
|
||||||
parts = []
|
) -> tuple[str, str, list[str]]:
|
||||||
|
"""Two SEPARATE streams (INV-LRN5 — keep style apart from substance):
|
||||||
|
1. style_exemplars — Dafna's own block-level paragraphs (HOW she writes; structure/voice).
|
||||||
|
2. case_law_citations — precedent case-law (substantive material to quote).
|
||||||
|
Returns (style_exemplars, case_law_citations, case_law_ids).
|
||||||
|
"""
|
||||||
|
style_parts: list[str] = []
|
||||||
|
caselaw_parts: list[str] = []
|
||||||
|
case_law_ids: list[str] = []
|
||||||
|
# block → golden-ratio section, for targeted exemplar retrieval (T2)
|
||||||
|
_BLOCK_SECTION = {
|
||||||
|
"block-vav": "background", "block-zayin": "claims",
|
||||||
|
"block-yod": "discussion", "block-yod-alef": "summary",
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
case = await db.get_case(case_id)
|
case = await db.get_case(case_id)
|
||||||
case_number = case.get("case_number", "") if case else ""
|
case_number = case.get("case_number", "") if case else ""
|
||||||
subject = case.get("subject", "") if case else ""
|
subject = case.get("subject", "") if case else ""
|
||||||
|
practice_area = case.get("practice_area", "") if case else ""
|
||||||
|
decision = await db.get_decision_by_case(case_id)
|
||||||
|
outcome = (decision or {}).get("outcome", "")
|
||||||
query = f"דיון משפטי בנושא {subject}" if subject else "דיון משפטי ועדת ערר"
|
query = f"דיון משפטי בנושא {subject}" if subject else "דיון משפטי ועדת ערר"
|
||||||
query_emb = await embeddings.embed_query(query)
|
query_emb = await embeddings.embed_query(query)
|
||||||
|
section = _BLOCK_SECTION.get(block_id)
|
||||||
|
|
||||||
# Search 1: paragraph_embeddings (from other decisions by Dafna)
|
# Stream 1a (PRIMARY): Dafna's own block-level prose from her corpus
|
||||||
|
# (style_exemplars) — matched by section + outcome + practice_area (T2/T3).
|
||||||
|
if section:
|
||||||
|
exemplars = await db.search_style_exemplars(
|
||||||
|
query_embedding=query_emb, section=section,
|
||||||
|
outcome=outcome or None, practice_area=practice_area or None, limit=6,
|
||||||
|
)
|
||||||
|
exemplars = [e for e in exemplars if e.get("decision_number", "") != case_number]
|
||||||
|
for e in exemplars[:4]:
|
||||||
|
style_parts.append(
|
||||||
|
f"[דוגמת-סגנון (מבנה/קול בלבד — התאם, אל תעתיק תוכן) — "
|
||||||
|
f"{e.get('decision_number', '?')}, {section}, "
|
||||||
|
f"outcome={e.get('outcome') or '—'}]\n{e['paragraph_text'][:1100]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream 1b: paragraphs from pipeline cases (legacy path; may be empty)
|
||||||
para_results = await db.search_similar_paragraphs(
|
para_results = await db.search_similar_paragraphs(
|
||||||
query_embedding=query_emb, limit=10, block_type="block-yod",
|
query_embedding=query_emb, limit=10, block_type="block-yod",
|
||||||
)
|
)
|
||||||
# Filter out same case
|
|
||||||
para_results = [r for r in para_results if r.get("case_number", "") != case_number]
|
para_results = [r for r in para_results if r.get("case_number", "") != case_number]
|
||||||
for r in para_results[:4]:
|
for r in para_results[:2]:
|
||||||
parts.append(
|
style_parts.append(
|
||||||
f"[החלטת {r.get('case_number', '?')} — {r.get('case_title', '')}, "
|
f"[דוגמת-סגנון — החלטת {r.get('case_number', '?')} "
|
||||||
f"בלוק {r.get('block_type', '')}]\n{r['content'][:500]}"
|
f"{r.get('case_title', '')}, בלוק {r.get('block_type', '')}]\n{r['content'][:500]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Search 2: case_law_embeddings (precedent case law)
|
# Stream 2: case_law_embeddings — substantive precedent (citations)
|
||||||
pool = await db.get_pool()
|
pool = await db.get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
caselaw_rows = await conn.fetch(
|
caselaw_rows = await conn.fetch(
|
||||||
"""SELECT cl.case_number, cl.case_name, cl.court, cl.summary, cl.key_quote,
|
"""SELECT cl.id, cl.case_number, cl.case_name, cl.court, cl.summary, cl.key_quote,
|
||||||
1 - (cle.embedding <=> $1) AS score
|
1 - (cle.embedding <=> $1) AS score
|
||||||
FROM case_law_embeddings cle
|
FROM case_law_embeddings cle
|
||||||
JOIN case_law cl ON cl.id = cle.case_law_id
|
JOIN case_law cl ON cl.id = cle.case_law_id
|
||||||
@@ -703,9 +780,10 @@ async def _build_precedents_context(case_id: UUID, block_id: str) -> str:
|
|||||||
query_emb,
|
query_emb,
|
||||||
)
|
)
|
||||||
for r in caselaw_rows[:3]:
|
for r in caselaw_rows[:3]:
|
||||||
|
case_law_ids.append(str(r["id"]))
|
||||||
text = r["key_quote"] or r["summary"] or ""
|
text = r["key_quote"] or r["summary"] or ""
|
||||||
if text:
|
if text:
|
||||||
parts.append(
|
caselaw_parts.append(
|
||||||
f"[פסיקה: {r['case_number']} {r['case_name']} ({r.get('court', '')})] "
|
f"[פסיקה: {r['case_number']} {r['case_name']} ({r.get('court', '')})] "
|
||||||
f"score={r['score']:.3f}\n{text[:400]}"
|
f"score={r['score']:.3f}\n{text[:400]}"
|
||||||
)
|
)
|
||||||
@@ -713,16 +791,63 @@ async def _build_precedents_context(case_id: UUID, block_id: str) -> str:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to fetch precedents: %s", e)
|
logger.warning("Failed to fetch precedents: %s", e)
|
||||||
|
|
||||||
return "\n\n".join(parts) if parts else "(אין תקדימים)"
|
return (
|
||||||
|
"\n\n".join(style_parts) if style_parts else "(אין דוגמאות-סגנון)",
|
||||||
|
"\n\n".join(caselaw_parts) if caselaw_parts else "(אין פסיקה רלוונטית)",
|
||||||
|
case_law_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _build_style_context() -> str:
|
# Cache for the abstract voice profile (read once per process).
|
||||||
"""Build comprehensive style guide from DB patterns + SKILL.md rules.
|
_VOICE_FINGERPRINT_CACHE: str | None = None
|
||||||
|
|
||||||
Per Anthropic: explicit style instructions reduce generic output.
|
# Style-acquisition policy (INV-LRN5): how to USE the style material below.
|
||||||
|
_COPY_POLICY = """## מדיניות-סגנון (איך להשתמש בחומר שלהלן) — חובה:
|
||||||
|
**היעד: לכתוב בקול ובשיטה של דפנה — לא להעתיק.** הפרופיל שלהלן הוא ההכללה של *איך* דפנה כותבת; הַחֵל אותו על העובדות של התיק שלפניך.
|
||||||
|
- **תוכן קבוע/נוסחאי** (פתיח דוקטרינלי, תבנית-סיום, ביטויי-מעבר) → מותר להשתמש כלשונו.
|
||||||
|
- **ניתוח/טענות ספציפיים** → הכלל את הדפוס והתאם לתיק; אל תעתיק ניסוח מתיק אחר.
|
||||||
|
- **מהות משפטית (הלכה/עובדה/תקדים) מתיק אחר** → אסור לגרור לכאן; המהות באה מחומרי-המקור והפסיקה של *התיק הזה* בלבד.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _load_voice_fingerprint() -> str:
|
||||||
|
"""Load the abstract authorial-style profile (daphna-voice-fingerprint.md).
|
||||||
|
|
||||||
|
This is the PRIMARY style channel (Authorial Style Profiling): the generalized
|
||||||
|
'how Dafna writes', injected so the writer adapts it rather than copying exemplars.
|
||||||
|
Read-only consumption of a learning artifact (Writing↔Acquisition separation).
|
||||||
|
"""
|
||||||
|
global _VOICE_FINGERPRINT_CACHE
|
||||||
|
if _VOICE_FINGERPRINT_CACHE is not None:
|
||||||
|
return _VOICE_FINGERPRINT_CACHE
|
||||||
|
try:
|
||||||
|
path = config.DATA_DIR.parent / "docs" / "daphna-voice-fingerprint.md"
|
||||||
|
_VOICE_FINGERPRINT_CACHE = path.read_text(encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("voice-fingerprint not loaded: %s", e)
|
||||||
|
_VOICE_FINGERPRINT_CACHE = ""
|
||||||
|
return _VOICE_FINGERPRINT_CACHE
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_style_context(practice_area: str = "") -> str:
|
||||||
|
"""Build comprehensive style guide: abstract voice profile (primary) +
|
||||||
|
SKILL.md rules + DB patterns + accumulated chair learnings.
|
||||||
|
|
||||||
|
Per Anthropic: explicit style instructions reduce generic output. The voice
|
||||||
|
fingerprint is the primary abstract-profile channel (T0 / INV-LRN4-5).
|
||||||
|
Accumulated learnings (T15) — the chair's /methodology edits and /training
|
||||||
|
decision_lessons — are appended LAST and marked authoritative, so everything
|
||||||
|
we have learned to date reaches the writer (not just hardcoded defaults).
|
||||||
"""
|
"""
|
||||||
lines = []
|
lines = []
|
||||||
|
|
||||||
|
# Copy-policy first, then the abstract voice profile (the PRIMARY channel).
|
||||||
|
lines.append(_COPY_POLICY)
|
||||||
|
fingerprint = _load_voice_fingerprint()
|
||||||
|
if fingerprint:
|
||||||
|
lines.append("## פרופיל-הקול של דפנה (טביעת-אצבע — המנגנון המרכזי):\n")
|
||||||
|
lines.append(fingerprint)
|
||||||
|
|
||||||
# Core style rules (from SKILL.md analysis)
|
# Core style rules (from SKILL.md analysis)
|
||||||
lines.append("""## כללי סגנון דפנה תמיר — חובה:
|
lines.append("""## כללי סגנון דפנה תמיר — חובה:
|
||||||
|
|
||||||
@@ -780,6 +905,41 @@ async def _build_style_context() -> str:
|
|||||||
for item in items[:8]:
|
for item in items[:8]:
|
||||||
lines.append(f"- {item['pattern_text']}")
|
lines.append(f"- {item['pattern_text']}")
|
||||||
|
|
||||||
|
# ── למידה מצטברת (T15) — עריכות היו"ר ב-/methodology + לקחי /training ──
|
||||||
|
# גובר על ברירות-המחדל לעיל. כך כל מה שלמדנו עד היום מגיע לכותב.
|
||||||
|
learned: list[str] = []
|
||||||
|
try:
|
||||||
|
for cat, label in (
|
||||||
|
("golden_ratios", "יחסי-זהב (אחוזי-סעיפים)"),
|
||||||
|
("discussion_rules", "כללי-דיון"),
|
||||||
|
("content_checklists", "צ׳קליסטים"),
|
||||||
|
("transition_phrases", "ביטויי-מעבר"),
|
||||||
|
("anti_patterns", "אנטי-דפוסים (להימנע)"),
|
||||||
|
):
|
||||||
|
ov = await db.get_methodology_overrides(cat)
|
||||||
|
if ov:
|
||||||
|
learned.append(f"\n**{label} — ערכי היו\"ר (גוברים על ברירת-המחדל):**")
|
||||||
|
for k, v in ov.items():
|
||||||
|
learned.append(f"- {k}: {json.dumps(v, ensure_ascii=False)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("methodology overrides not loaded: %s", e)
|
||||||
|
try:
|
||||||
|
lessons = await db.get_recent_decision_lessons(limit=15, practice_area=practice_area)
|
||||||
|
if lessons:
|
||||||
|
learned.append("\n**לקחים מהחלטות קודמות (decision_lessons):**")
|
||||||
|
for ls in lessons:
|
||||||
|
src = ls.get("decision_number") or ls.get("source") or ""
|
||||||
|
learned.append(f"- [{ls.get('category', '')}] {ls['lesson_text']}" + (f" ({src})" if src else ""))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("decision_lessons not loaded: %s", e)
|
||||||
|
|
||||||
|
if learned:
|
||||||
|
lines.append(
|
||||||
|
"\n## ⭐ למידה מצטברת — חובה, גובר על כל ברירת-מחדל לעיל "
|
||||||
|
"(עריכות היו\"ר ב-/methodology + לקחי /training):"
|
||||||
|
)
|
||||||
|
lines.extend(learned)
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -841,15 +1001,22 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
|
|||||||
claims_context = await _build_claims_context(case_id)
|
claims_context = await _build_claims_context(case_id)
|
||||||
direction_context = _build_direction_context(decision)
|
direction_context = _build_direction_context(decision)
|
||||||
plans_context = await _build_plans_context(case_id)
|
plans_context = await _build_plans_context(case_id)
|
||||||
precedents_context = await _build_precedents_context(case_id, block_id)
|
daphna_style_exemplars, case_law_citations, _ = (
|
||||||
style_context = await _build_style_context()
|
await _build_precedents_context(case_id, block_id)
|
||||||
|
)
|
||||||
|
style_context = await _build_style_context(case.get("practice_area", ""))
|
||||||
discussion_context = await _build_previous_blocks_context(case_id, decision)
|
discussion_context = await _build_previous_blocks_context(case_id, decision)
|
||||||
appraiser_facts_context = await _build_appraiser_facts_context(case_id)
|
appraiser_facts_context = await _build_appraiser_facts_context(case_id)
|
||||||
appraiser_conflicts_context = await _build_appraiser_conflicts_context(case_id)
|
appraiser_conflicts_context = await _build_appraiser_conflicts_context(case_id)
|
||||||
post_hearing_context = await _build_post_hearing_context(case_id)
|
post_hearing_context = await _build_post_hearing_context(case_id)
|
||||||
|
|
||||||
outcome = (decision or {}).get("outcome", "rejected")
|
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
|
||||||
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
||||||
|
if case.get("practice_area") == "betterment_levy":
|
||||||
|
structure_guidance = (
|
||||||
|
structure_guidance + " | היטל השבחה: "
|
||||||
|
+ " ".join(PRACTICE_AREA_OVERRIDES["betterment_levy"]["discussion_rules"])
|
||||||
|
).strip()
|
||||||
|
|
||||||
# Content checklist + methodology for block-yod
|
# Content checklist + methodology for block-yod
|
||||||
content_checklist = ""
|
content_checklist = ""
|
||||||
@@ -868,7 +1035,8 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
|
|||||||
claims_context=claims_context,
|
claims_context=claims_context,
|
||||||
direction_context=direction_context,
|
direction_context=direction_context,
|
||||||
plans_context=plans_context,
|
plans_context=plans_context,
|
||||||
precedents_context=precedents_context,
|
daphna_style_exemplars=daphna_style_exemplars,
|
||||||
|
case_law_citations=case_law_citations,
|
||||||
style_context=style_context,
|
style_context=style_context,
|
||||||
discussion_context=discussion_context,
|
discussion_context=discussion_context,
|
||||||
structure_guidance=structure_guidance,
|
structure_guidance=structure_guidance,
|
||||||
@@ -896,7 +1064,8 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
|
|||||||
"source_documents": source_context,
|
"source_documents": source_context,
|
||||||
"claims": claims_context,
|
"claims": claims_context,
|
||||||
"direction": direction_context,
|
"direction": direction_context,
|
||||||
"precedents": precedents_context,
|
"precedents": case_law_citations,
|
||||||
|
"style_exemplars": daphna_style_exemplars,
|
||||||
"style_guide": style_context,
|
"style_guide": style_context,
|
||||||
"previous_blocks": discussion_context,
|
"previous_blocks": discussion_context,
|
||||||
}
|
}
|
||||||
@@ -919,36 +1088,39 @@ async def save_block_content(case_id: UUID, block_id: str, content: str) -> dict
|
|||||||
result["generation_type"] = "claude-code"
|
result["generation_type"] = "claude-code"
|
||||||
result["model_used"] = "claude-code"
|
result["model_used"] = "claude-code"
|
||||||
|
|
||||||
await store_block(UUID(decision["id"]), result)
|
await store_block(UUID(decision["id"]), result) # store_block syncs the file (#35)
|
||||||
|
await db.mark_blocks_stale(case_id, False)
|
||||||
# Also write/update the draft file on disk
|
|
||||||
await _update_draft_file(case_id, UUID(decision["id"]))
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def _update_draft_file(case_id: UUID, decision_id: UUID) -> None:
|
async def _update_draft_file(decision_id: UUID) -> None:
|
||||||
"""Rebuild drafts/decision.md from all blocks in DB."""
|
"""Rebuild drafts/decision.md from all blocks in DB — the single
|
||||||
from pathlib import Path
|
regenerate-draft hook (lessons #35 / GAP-88). Called after EVERY
|
||||||
|
decision_blocks mutation (store_block, renumber) so the on-disk file never
|
||||||
case = await db.get_case(case_id)
|
drifts from the DB. legal-qa validates against the DB; export and the chair
|
||||||
if not case:
|
read the file — keeping them identical kills the "QA fails twice on the same
|
||||||
return
|
already-fixed issue" loop (CMPA-62). Resolves case from decision_id so no
|
||||||
|
caller has to thread case_id through."""
|
||||||
case_dir = config.find_case_dir(case["case_number"])
|
|
||||||
draft_dir = case_dir / "drafts"
|
|
||||||
draft_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
pool = await db.get_pool()
|
pool = await db.get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
|
case_row = await conn.fetchrow(
|
||||||
|
"SELECT c.case_number FROM decisions d JOIN cases c ON c.id = d.case_id "
|
||||||
|
"WHERE d.id = $1",
|
||||||
|
decision_id,
|
||||||
|
)
|
||||||
|
if not case_row:
|
||||||
|
return
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"SELECT content FROM decision_blocks WHERE decision_id = $1 AND content != '' ORDER BY block_index",
|
"SELECT content FROM decision_blocks WHERE decision_id = $1 AND content != '' ORDER BY block_index",
|
||||||
decision_id,
|
decision_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
draft_dir = config.find_case_dir(case_row["case_number"]) / "drafts"
|
||||||
|
draft_dir.mkdir(parents=True, exist_ok=True)
|
||||||
draft_path = draft_dir / "decision.md"
|
draft_path = draft_dir / "decision.md"
|
||||||
draft_path.write_text("\n\n".join(row["content"] for row in rows if row["content"]), encoding="utf-8")
|
draft_path.write_text("\n\n".join(row["content"] for row in rows if row["content"]), encoding="utf-8")
|
||||||
logger.info("Draft file updated: %s (%d blocks)", draft_path, len(rows))
|
logger.info("Draft file synced: %s (%d blocks)", draft_path, len(rows))
|
||||||
|
|
||||||
|
|
||||||
# ── Renumbering ───────────────────────────────────────────────────
|
# ── Renumbering ───────────────────────────────────────────────────
|
||||||
@@ -1002,6 +1174,11 @@ async def renumber_all_blocks(decision_id: UUID) -> dict:
|
|||||||
)
|
)
|
||||||
updated += 1
|
updated += 1
|
||||||
|
|
||||||
|
# #35 — renumber mutates content via raw UPDATE (bypasses store_block), so
|
||||||
|
# sync the draft file here too, otherwise the file keeps stale numbering.
|
||||||
|
if updated:
|
||||||
|
await _update_draft_file(decision_id)
|
||||||
|
|
||||||
return {"total_paragraphs": current_num - 1, "blocks_updated": updated}
|
return {"total_paragraphs": current_num - 1, "blocks_updated": updated}
|
||||||
|
|
||||||
|
|
||||||
@@ -1034,6 +1211,9 @@ async def store_block(decision_id: UUID, block_result: dict) -> None:
|
|||||||
block_result["model_used"],
|
block_result["model_used"],
|
||||||
block_result["temperature"],
|
block_result["temperature"],
|
||||||
)
|
)
|
||||||
|
# #35 — regenerate the on-disk draft on every persist so DB and file stay
|
||||||
|
# identical (legal-qa reads DB; export/chair read the file).
|
||||||
|
await _update_draft_file(decision_id)
|
||||||
|
|
||||||
|
|
||||||
async def write_and_store_block(
|
async def write_and_store_block(
|
||||||
@@ -1049,4 +1229,15 @@ async def write_and_store_block(
|
|||||||
|
|
||||||
result = await write_block(case_id, block_id, instructions)
|
result = await write_block(case_id, block_id, instructions)
|
||||||
await store_block(UUID(decision["id"]), result)
|
await store_block(UUID(decision["id"]), result)
|
||||||
|
await audit.log_action_safe(
|
||||||
|
"write_block", case_id=case_id,
|
||||||
|
details={
|
||||||
|
"decision_id": str(decision["id"]),
|
||||||
|
"block_id": block_id,
|
||||||
|
"model_used": result.get("model_used"),
|
||||||
|
"generation_type": result.get("generation_type"),
|
||||||
|
"sources": result.get("sources", {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await db.mark_blocks_stale(case_id, False)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -104,6 +104,14 @@ def _assign_pages(chunks: list[Chunk], text: str, page_offsets: list[int]) -> No
|
|||||||
# used to carve tiny boundary chunks ("דיון). במסגרת ה") that polluted search.
|
# used to carve tiny boundary chunks ("דיון). במסגרת ה") that polluted search.
|
||||||
MIN_SECTION_CHARS = 60
|
MIN_SECTION_CHARS = 60
|
||||||
|
|
||||||
|
# A split chunk shorter than this (stripped chars) must not stand alone — it
|
||||||
|
# rides with adjacent content instead. This is the chunk-level analogue of
|
||||||
|
# MIN_SECTION_CHARS and matches the query-time filter that hides <50-char
|
||||||
|
# chunks. Without it, a section that opens with a short header line ("דיון",
|
||||||
|
# "טענות המשיבים") followed by a paragraph larger than chunk_size flushed the
|
||||||
|
# header as its own tiny chunk (#79, follow-up to #55).
|
||||||
|
MIN_CHUNK_CHARS = 50
|
||||||
|
|
||||||
|
|
||||||
def _split_into_sections(text: str) -> list[tuple[str, str]]:
|
def _split_into_sections(text: str) -> list[tuple[str, str]]:
|
||||||
"""Split text into (section_type, text) pairs based on Hebrew headers.
|
"""Split text into (section_type, text) pairs based on Hebrew headers.
|
||||||
@@ -168,11 +176,20 @@ def _split_section(text: str, chunk_size: int, overlap: int) -> list[str]:
|
|||||||
chunks: list[str] = []
|
chunks: list[str] = []
|
||||||
current: list[str] = []
|
current: list[str] = []
|
||||||
current_tokens = 0
|
current_tokens = 0
|
||||||
|
current_chars = 0
|
||||||
|
|
||||||
for para in paragraphs:
|
for para in paragraphs:
|
||||||
para_tokens = _estimate_tokens(para)
|
para_tokens = _estimate_tokens(para)
|
||||||
|
|
||||||
if current_tokens + para_tokens > chunk_size and current:
|
# Don't flush a buffer that is still below MIN_CHUNK_CHARS — let it
|
||||||
|
# absorb this paragraph even if that overflows chunk_size. A short
|
||||||
|
# header line ("דיון") must ride with the following paragraph rather
|
||||||
|
# than be emitted as a tiny fragment chunk (#79).
|
||||||
|
if (
|
||||||
|
current_tokens + para_tokens > chunk_size
|
||||||
|
and current
|
||||||
|
and current_chars >= MIN_CHUNK_CHARS
|
||||||
|
):
|
||||||
chunks.append("\n".join(current))
|
chunks.append("\n".join(current))
|
||||||
# Keep overlap
|
# Keep overlap
|
||||||
overlap_paras: list[str] = []
|
overlap_paras: list[str] = []
|
||||||
@@ -185,13 +202,21 @@ def _split_section(text: str, chunk_size: int, overlap: int) -> list[str]:
|
|||||||
overlap_tokens += pt
|
overlap_tokens += pt
|
||||||
current = overlap_paras
|
current = overlap_paras
|
||||||
current_tokens = overlap_tokens
|
current_tokens = overlap_tokens
|
||||||
|
current_chars = sum(len(p) for p in current)
|
||||||
|
|
||||||
current.append(para)
|
current.append(para)
|
||||||
current_tokens += para_tokens
|
current_tokens += para_tokens
|
||||||
|
current_chars += len(para)
|
||||||
|
|
||||||
if current:
|
if current:
|
||||||
chunks.append("\n".join(current))
|
chunks.append("\n".join(current))
|
||||||
|
|
||||||
|
# Fold a trailing tiny chunk back into its predecessor — a short trailing
|
||||||
|
# line (e.g. a stray quote fragment) shouldn't stand alone either (#79).
|
||||||
|
if len(chunks) >= 2 and len(chunks[-1].strip()) < MIN_CHUNK_CHARS:
|
||||||
|
tail = chunks.pop()
|
||||||
|
chunks[-1] = f"{chunks[-1]}\n{tail}"
|
||||||
|
|
||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from legal_mcp.config import parse_llm_json
|
from legal_mcp.config import parse_llm_json
|
||||||
|
|
||||||
@@ -40,6 +41,38 @@ logger = logging.getLogger(__name__)
|
|||||||
DEFAULT_TIMEOUT = 1800
|
DEFAULT_TIMEOUT = 1800
|
||||||
LONG_TIMEOUT = 3600 # opus block writing on full case context
|
LONG_TIMEOUT = 3600 # opus block writing on full case context
|
||||||
|
|
||||||
|
# #85 — two complementary hardenings for the same symptom (`claude -p` failing
|
||||||
|
# with a fast non-zero exit + empty stderr on large/slow cold prompts: CEO
|
||||||
|
# write_interim_draft, learning_loop distillation):
|
||||||
|
#
|
||||||
|
# 1. CLEAN ENV (defensive): a running Claude Code session exports markers into
|
||||||
|
# child processes; a *nested* ``claude -p`` inherits them. Stripping them lets
|
||||||
|
# every nested invocation launch as a clean top-level session. Could not be
|
||||||
|
# reproduced deterministically, so it's a suspect, not a proven cause. Auth/
|
||||||
|
# config (CLAUDE_CONFIG_DIR, ANTHROPIC_*, PATH, HOME) are kept.
|
||||||
|
# 2. RETRY (the real fix): the SAME large prompt that exits 1 once succeeds on a
|
||||||
|
# plain retry — the bail is transient. Retry with linear backoff. Timeouts and
|
||||||
|
# "CLI not found" stay deterministic and are NOT retried.
|
||||||
|
# See TaskMaster legal-ai #85.
|
||||||
|
_SESSION_MARKER_PREFIXES = ("CLAUDECODE", "CLAUDE_CODE_", "CLAUDE_AGENT_")
|
||||||
|
_SESSION_MARKER_EXACT = frozenset({"AI_AGENT", "CLAUDE_EFFORT"})
|
||||||
|
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
RETRY_BACKOFF_BASE = 5 # seconds; sleep = base * attempt_number
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_subprocess_env() -> dict[str, str]:
|
||||||
|
"""Copy the current env minus Claude Code session markers.
|
||||||
|
|
||||||
|
Lets a nested ``claude -p`` start fresh instead of detecting it is
|
||||||
|
already inside a Claude Code session (#85).
|
||||||
|
"""
|
||||||
|
env = dict(os.environ)
|
||||||
|
for key in list(env):
|
||||||
|
if key in _SESSION_MARKER_EXACT or key.startswith(_SESSION_MARKER_PREFIXES):
|
||||||
|
del env[key]
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
async def query(
|
async def query(
|
||||||
prompt: str,
|
prompt: str,
|
||||||
@@ -47,6 +80,8 @@ async def query(
|
|||||||
max_turns: int = 1,
|
max_turns: int = 1,
|
||||||
*,
|
*,
|
||||||
system: str | None = None,
|
system: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
effort: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Send a prompt to Claude Code headless and return the text response.
|
"""Send a prompt to Claude Code headless and return the text response.
|
||||||
|
|
||||||
@@ -62,6 +97,13 @@ async def query(
|
|||||||
CLI doesn't expose API-level caching. The parameter exists so
|
CLI doesn't expose API-level caching. The parameter exists so
|
||||||
extractors can structure their calls cleanly today, and to make
|
extractors can structure their calls cleanly today, and to make
|
||||||
a future SDK-backed path drop-in.
|
a future SDK-backed path drop-in.
|
||||||
|
model: Optional model alias/id (e.g. ``claude-opus-4-8``). When set,
|
||||||
|
passed as ``--model``; otherwise the CLI's session default is
|
||||||
|
used. Lets quality-sensitive extractors (halacha) pin a stronger
|
||||||
|
model without changing the default for every caller.
|
||||||
|
effort: Optional effort level (``low``/``medium``/``high``/``xhigh``/
|
||||||
|
``max``). When set, passed as ``--effort``. Pairs with ``model``;
|
||||||
|
an empty string is treated as "unset" (CLI default).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The text response from Claude.
|
The text response from Claude.
|
||||||
@@ -80,54 +122,79 @@ async def query(
|
|||||||
"--output-format", "json",
|
"--output-format", "json",
|
||||||
"--max-turns", str(max_turns),
|
"--max-turns", str(max_turns),
|
||||||
]
|
]
|
||||||
|
if model:
|
||||||
|
cmd += ["--model", model]
|
||||||
|
if effort:
|
||||||
|
cmd += ["--effort", effort]
|
||||||
|
|
||||||
try:
|
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
|
||||||
proc = await asyncio.create_subprocess_exec(
|
last_err = "unknown error"
|
||||||
*cmd,
|
|
||||||
stdin=asyncio.subprocess.PIPE,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Claude CLI not found. This module only works when invoked "
|
|
||||||
"from the local MCP server — see the architectural rule in "
|
|
||||||
"the module docstring. If this error came from a FastAPI "
|
|
||||||
"endpoint in the container, refactor the call into an MCP "
|
|
||||||
"tool that the chair triggers from Claude Code."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
for attempt in range(1, MAX_RETRIES + 1):
|
||||||
stdout_b, stderr_b = await asyncio.wait_for(
|
|
||||||
proc.communicate(input=full_prompt.encode("utf-8")),
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
# wait_for cancellation alone leaves the child running.
|
|
||||||
try:
|
try:
|
||||||
proc.kill()
|
proc = await asyncio.create_subprocess_exec(
|
||||||
await proc.wait()
|
*cmd,
|
||||||
except ProcessLookupError:
|
stdin=asyncio.subprocess.PIPE,
|
||||||
pass
|
stdout=asyncio.subprocess.PIPE,
|
||||||
raise RuntimeError(f"Claude CLI timed out after {timeout}s")
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=_clean_subprocess_env(),
|
||||||
|
cwd=os.path.expanduser("~"),
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Deterministic — never retry.
|
||||||
|
raise RuntimeError(
|
||||||
|
"Claude CLI not found. This module only works when invoked "
|
||||||
|
"from the local MCP server — see the architectural rule in "
|
||||||
|
"the module docstring. If this error came from a FastAPI "
|
||||||
|
"endpoint in the container, refactor the call into an MCP "
|
||||||
|
"tool that the chair triggers from Claude Code."
|
||||||
|
)
|
||||||
|
|
||||||
if proc.returncode != 0:
|
try:
|
||||||
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
|
stdout_b, stderr_b = await asyncio.wait_for(
|
||||||
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
|
proc.communicate(input=full_prompt.encode("utf-8")),
|
||||||
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}{size_info}")
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# wait_for cancellation alone leaves the child running. A timeout is
|
||||||
|
# a real ceiling, not a transient blip — don't retry.
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
raise RuntimeError(f"Claude CLI timed out after {timeout}s")
|
||||||
|
|
||||||
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
if proc.returncode != 0:
|
||||||
if not stdout:
|
# The CLI sometimes writes its diagnostic to stdout (or nowhere)
|
||||||
raise RuntimeError("Claude CLI returned empty response")
|
# rather than stderr (#85) — surface whichever is present.
|
||||||
|
stderr = stderr_b.decode("utf-8", errors="replace").strip()
|
||||||
|
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
||||||
|
last_err = f"exit {proc.returncode}: {(stderr or stdout or 'no output')[:500]}"
|
||||||
|
else:
|
||||||
|
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
||||||
|
if stdout:
|
||||||
|
# claude -p --output-format json returns {"type":"result","result":"..."}
|
||||||
|
try:
|
||||||
|
data = json.loads(stdout)
|
||||||
|
if isinstance(data, dict) and "result" in data:
|
||||||
|
return data["result"]
|
||||||
|
return stdout
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return stdout
|
||||||
|
last_err = "empty response"
|
||||||
|
|
||||||
# claude -p --output-format json returns {"type":"result","result":"..."}
|
# Transient failure — retry with linear backoff unless this was the last try.
|
||||||
try:
|
if attempt < MAX_RETRIES:
|
||||||
data = json.loads(stdout)
|
logger.warning(
|
||||||
if isinstance(data, dict) and "result" in data:
|
"claude -p attempt %d/%d failed (%s%s) — retrying in %ds",
|
||||||
return data["result"]
|
attempt, MAX_RETRIES, last_err, size_info, RETRY_BACKOFF_BASE * attempt,
|
||||||
return stdout
|
)
|
||||||
except json.JSONDecodeError:
|
await asyncio.sleep(RETRY_BACKOFF_BASE * attempt)
|
||||||
return stdout
|
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Claude CLI failed after {MAX_RETRIES} attempts ({last_err}){size_info}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def query_json(
|
async def query_json(
|
||||||
@@ -135,12 +202,15 @@ async def query_json(
|
|||||||
timeout: int = DEFAULT_TIMEOUT,
|
timeout: int = DEFAULT_TIMEOUT,
|
||||||
*,
|
*,
|
||||||
system: str | None = None,
|
system: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
effort: str | None = None,
|
||||||
) -> dict | list | None:
|
) -> dict | list | None:
|
||||||
"""Send a prompt and parse the response as JSON.
|
"""Send a prompt and parse the response as JSON.
|
||||||
|
|
||||||
Uses parse_llm_json for robust parsing (handles markdown wrapping, truncation).
|
Uses parse_llm_json for robust parsing (handles markdown wrapping, truncation).
|
||||||
|
``model``/``effort`` are forwarded to :func:`query` (see its docstring).
|
||||||
"""
|
"""
|
||||||
raw = await query(prompt, timeout=timeout, system=system)
|
raw = await query(prompt, timeout=timeout, system=system, model=model, effort=effort)
|
||||||
return parse_llm_json(raw)
|
return parse_llm_json(raw)
|
||||||
|
|
||||||
|
|
||||||
@@ -216,6 +286,7 @@ async def query_streaming(
|
|||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
|
env=_clean_subprocess_env(),
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
yield {
|
yield {
|
||||||
|
|||||||
165
mcp-server/src/legal_mcp/services/corroboration.py
Normal file
165
mcp-server/src/legal_mcp/services/corroboration.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""X11 citation corroboration — classify treatment, match to halacha, aggregate.
|
||||||
|
|
||||||
|
Phase 1: builds the SIGNAL only (no approval changes). See docs/spec/X11.
|
||||||
|
All LLM calls go through the local claude_session bridge (Opus 4.8 @ xhigh),
|
||||||
|
same architectural rule as the other extractors (local MCP only).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
from legal_mcp import config
|
||||||
|
from legal_mcp.config import parse_llm_json
|
||||||
|
from legal_mcp.services import claude_session
|
||||||
|
from legal_mcp.services import db, embeddings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TREATMENT_POSITIVE = {"followed", "explained"}
|
||||||
|
TREATMENT_NEGATIVE = {"distinguished", "criticized", "questioned", "overruled"}
|
||||||
|
TREATMENT_NEUTRAL = {"mentioned"}
|
||||||
|
_VALID_TREATMENT = TREATMENT_POSITIVE | TREATMENT_NEGATIVE | TREATMENT_NEUTRAL
|
||||||
|
|
||||||
|
def is_positive(t: str) -> bool: return t in TREATMENT_POSITIVE
|
||||||
|
def is_negative(t: str) -> bool: return t in TREATMENT_NEGATIVE
|
||||||
|
|
||||||
|
def _coerce_treatment(raw: dict) -> str:
|
||||||
|
t = str((raw or {}).get("treatment", "")).strip().lower()
|
||||||
|
return t if t in _VALID_TREATMENT else "mentioned"
|
||||||
|
|
||||||
|
|
||||||
|
def accept_match(best: tuple[str, float] | None, floor: float = config.HALACHA_CORROBORATION_MATCH_FLOOR) -> str | None:
|
||||||
|
"""Return the halacha_id iff similarity clears the floor (INV-COR3)."""
|
||||||
|
if not best:
|
||||||
|
return None
|
||||||
|
halacha_id, sim = best
|
||||||
|
return halacha_id if sim >= floor else None
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate(links: list[dict], min_cites: int = config.HALACHA_CORROBORATION_MIN_CITES) -> dict:
|
||||||
|
"""Aggregate per-halacha corroboration (INV-COR4/COR2).
|
||||||
|
|
||||||
|
links: [{"source_id": str, "treatment": str}, ...] already matched to ONE halacha.
|
||||||
|
positive_sources = count of DISTINCT source_id whose treatment is positive.
|
||||||
|
has_negative = any negative treatment present.
|
||||||
|
corroborated = positive_sources >= min_cites AND not has_negative.
|
||||||
|
"""
|
||||||
|
positive = {l["source_id"] for l in links if is_positive(l["treatment"])}
|
||||||
|
has_negative = any(is_negative(l["treatment"]) for l in links)
|
||||||
|
return {
|
||||||
|
"positive_sources": len(positive),
|
||||||
|
"has_negative": has_negative,
|
||||||
|
"corroborated": len(positive) >= min_cites and not has_negative,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def approval_action(agg: dict, has_overruled: bool) -> str | None:
|
||||||
|
"""Decide the corroboration→approval action for ONE halacha (INV-COR2/COR4).
|
||||||
|
|
||||||
|
- 'demote' : a later court overruled it → back to the chair gate (overruled
|
||||||
|
outranks any positive count, INV-COR2 strong form).
|
||||||
|
- 'approve' : corroborated (≥N distinct positives, 0 negatives — INV-COR4).
|
||||||
|
- None : leave as-is (single source, non-overruled negative, or the
|
||||||
|
uncorroborated tail — INV-COR5 keeps the chair gate).
|
||||||
|
"""
|
||||||
|
if has_overruled:
|
||||||
|
return "demote"
|
||||||
|
if agg.get("corroborated"):
|
||||||
|
return "approve"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_TREATMENT_PROMPT = """אתה משפטן בכיר. נתון ציטוט של פסק/החלטה קודמים בתוך החלטה מאוחרת.
|
||||||
|
סווג כיצד ההחלטה המאוחרת **מטפלת** בתקדים המצוטט, לפי אחת מהקטגוריות:
|
||||||
|
- followed — אימצה והחילה את ההלכה.
|
||||||
|
- explained — הסבירה/הזכירה בלי לחלוק.
|
||||||
|
- distinguished — אבחנה (קבעה שלא חל בנסיבות).
|
||||||
|
- criticized — מתחה ביקורת בלי לבטל.
|
||||||
|
- questioned — הטילה ספק.
|
||||||
|
- overruled — דחתה/ביטלה את ההלכה.
|
||||||
|
- mentioned — אזכור-אגב בלי טיפול.
|
||||||
|
החזר JSON בלבד: {"treatment": "<קטגוריה>"}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def classify_treatment(cited_citation: str, context: str) -> str:
|
||||||
|
"""Return one treatment label for how `context` treats `cited_citation`."""
|
||||||
|
user = f"תקדים מצוטט: {cited_citation}\n\n--- ההקשר המצטט ---\n{context}\n--- סוף ---"
|
||||||
|
try:
|
||||||
|
result = await claude_session.query_json(
|
||||||
|
user, system=_TREATMENT_PROMPT,
|
||||||
|
model=config.HALACHA_EXTRACT_MODEL or None,
|
||||||
|
effort=config.HALACHA_EXTRACT_EFFORT or None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("classify_treatment failed: %s", e)
|
||||||
|
return "mentioned"
|
||||||
|
return _coerce_treatment(result if isinstance(result, dict) else {})
|
||||||
|
|
||||||
|
|
||||||
|
async def build_for_precedent(case_law_id: str | UUID) -> dict:
|
||||||
|
"""For one cited precedent: classify+match+store each incoming citation. Idempotent."""
|
||||||
|
if isinstance(case_law_id, str):
|
||||||
|
case_law_id = UUID(case_law_id)
|
||||||
|
cits = await db.incoming_citations_for_precedent(case_law_id)
|
||||||
|
linked = 0
|
||||||
|
for c in cits:
|
||||||
|
ctx = (c.get("context") or "").strip()
|
||||||
|
if not ctx:
|
||||||
|
continue
|
||||||
|
vecs = await embeddings.embed_texts([ctx], input_type="query")
|
||||||
|
best = await db.nearest_halacha_for_vector(case_law_id, vecs[0])
|
||||||
|
halacha_id = accept_match(best)
|
||||||
|
if not halacha_id:
|
||||||
|
continue
|
||||||
|
treatment = await classify_treatment(c.get("citing_case_law_id") or c.get("citing_decision_id") or "", ctx)
|
||||||
|
await db.store_corroboration(
|
||||||
|
halacha_id, c["source_id"],
|
||||||
|
c.get("citing_case_law_id"), c.get("citing_decision_id"),
|
||||||
|
treatment, best[1], ctx,
|
||||||
|
)
|
||||||
|
linked += 1
|
||||||
|
appr = await reconcile_approvals(case_law_id)
|
||||||
|
return {"citations": len(cits), "linked": linked,
|
||||||
|
"approved": appr["approved"], "demoted": appr["demoted"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def reconcile_approvals(case_law_id: str | UUID) -> dict:
|
||||||
|
"""Apply the corroboration→approval policy to every halacha of a precedent
|
||||||
|
(INV-COR2/COR4/COR5). No-op when the kill-switch is off. Idempotent: approve
|
||||||
|
only fires on ``pending_review``, demote only on ``approved``, so re-runs
|
||||||
|
converge."""
|
||||||
|
if not config.HALACHA_CORROBORATION_AUTO_APPROVE:
|
||||||
|
return {"approved": 0, "demoted": 0, "disabled": True}
|
||||||
|
if isinstance(case_law_id, str):
|
||||||
|
case_law_id = UUID(case_law_id)
|
||||||
|
grouped = await db.list_corroboration_grouped(case_law_id)
|
||||||
|
approved = demoted = 0
|
||||||
|
for halacha_id, links in grouped.items():
|
||||||
|
agg = aggregate(links)
|
||||||
|
has_overruled = any(l["treatment"] == "overruled" for l in links)
|
||||||
|
action = approval_action(agg, has_overruled)
|
||||||
|
if action == "approve":
|
||||||
|
if await db.approve_halacha_by_corroboration(
|
||||||
|
UUID(halacha_id), agg["positive_sources"],
|
||||||
|
config.HALACHA_CORROBORATION_MIN_CITES,
|
||||||
|
):
|
||||||
|
approved += 1
|
||||||
|
elif action == "demote":
|
||||||
|
if await db.demote_halacha_overruled(UUID(halacha_id)):
|
||||||
|
demoted += 1
|
||||||
|
return {"approved": approved, "demoted": demoted, "disabled": False}
|
||||||
|
|
||||||
|
|
||||||
|
async def build_all() -> dict:
|
||||||
|
"""Backfill: build the signal + apply approvals for every precedent that has
|
||||||
|
halachot and incoming citations. Idempotent (link table ``ON CONFLICT`` +
|
||||||
|
state-gated transitions)."""
|
||||||
|
ids = await db.precedents_with_halachot_and_incoming_citations()
|
||||||
|
totals = {"precedents": 0, "citations": 0, "linked": 0,
|
||||||
|
"approved": 0, "demoted": 0}
|
||||||
|
for cid in ids:
|
||||||
|
r = await build_for_precedent(cid)
|
||||||
|
totals["precedents"] += 1
|
||||||
|
for k in ("citations", "linked", "approved", "demoted"):
|
||||||
|
totals[k] += r.get(k, 0)
|
||||||
|
logger.info("corroboration backfill %s: %s", cid, r)
|
||||||
|
return totals
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,84 @@ def _suppress_paragraph_numbering(paragraph) -> None:
|
|||||||
pPr.append(numPr)
|
pPr.append(numPr)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_decision_numbering(doc) -> int:
|
||||||
|
"""T9 — define a single continuous decimal list (RTL) and return its numId.
|
||||||
|
|
||||||
|
Dafna's decisions are ALWAYS sequentially numbered (1. 2. 3. ...). The template
|
||||||
|
ships no numbering definition, so previously the body paragraphs were stripped of
|
||||||
|
their manual "N." prefix and styled "List Paragraph" — which carries NO numPr,
|
||||||
|
yielding UNNUMBERED output. Here we inject one decimal abstractNum + num into the
|
||||||
|
numbering part once per document; body paragraphs then reference it (real Word
|
||||||
|
auto-numbering → renumbers automatically, copy-pastes cleanly).
|
||||||
|
"""
|
||||||
|
cached = getattr(doc, "_decision_num_id", None)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
numbering = doc.part.numbering_part.element # <w:numbering>
|
||||||
|
|
||||||
|
def _next_id(tag: str, attr: str) -> int:
|
||||||
|
ids = [int(el.get(qn(attr))) for el in numbering.findall(qn(tag))
|
||||||
|
if el.get(qn(attr)) and el.get(qn(attr)).isdigit()]
|
||||||
|
return (max(ids) + 1) if ids else 1
|
||||||
|
|
||||||
|
abstract_id = _next_id("w:abstractNum", "w:abstractNumId")
|
||||||
|
num_id = _next_id("w:num", "w:numId")
|
||||||
|
|
||||||
|
abstract = OxmlElement("w:abstractNum")
|
||||||
|
abstract.set(qn("w:abstractNumId"), str(abstract_id))
|
||||||
|
mlt = OxmlElement("w:multiLevelType")
|
||||||
|
mlt.set(qn("w:val"), "singleLevel")
|
||||||
|
abstract.append(mlt)
|
||||||
|
lvl = OxmlElement("w:lvl")
|
||||||
|
lvl.set(qn("w:ilvl"), "0")
|
||||||
|
for tag, val in (("w:start", "1"), ("w:numFmt", "decimal"),
|
||||||
|
("w:lvlText", "%1."), ("w:lvlJc", "right")):
|
||||||
|
el = OxmlElement(tag)
|
||||||
|
el.set(qn("w:val"), val)
|
||||||
|
lvl.append(el)
|
||||||
|
lvl_ppr = OxmlElement("w:pPr")
|
||||||
|
ind = OxmlElement("w:ind")
|
||||||
|
ind.set(qn("w:start"), "720")
|
||||||
|
ind.set(qn("w:hanging"), "360")
|
||||||
|
lvl_ppr.append(ind)
|
||||||
|
lvl.append(lvl_ppr)
|
||||||
|
abstract.append(lvl)
|
||||||
|
|
||||||
|
num = OxmlElement("w:num")
|
||||||
|
num.set(qn("w:numId"), str(num_id))
|
||||||
|
anum_ref = OxmlElement("w:abstractNumId")
|
||||||
|
anum_ref.set(qn("w:val"), str(abstract_id))
|
||||||
|
num.append(anum_ref)
|
||||||
|
|
||||||
|
# abstractNum elements must precede num elements in <w:numbering>.
|
||||||
|
last_abstract = numbering.findall(qn("w:abstractNum"))
|
||||||
|
if last_abstract:
|
||||||
|
last_abstract[-1].addnext(abstract)
|
||||||
|
else:
|
||||||
|
numbering.insert(0, abstract)
|
||||||
|
numbering.append(num)
|
||||||
|
|
||||||
|
doc._decision_num_id = num_id
|
||||||
|
return num_id
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_list_numbering(paragraph, num_id: int) -> None:
|
||||||
|
"""Attach paragraph to the continuous decision list (real auto-numbering)."""
|
||||||
|
pPr = paragraph._p.get_or_add_pPr()
|
||||||
|
existing = pPr.find(qn("w:numPr"))
|
||||||
|
if existing is not None:
|
||||||
|
pPr.remove(existing)
|
||||||
|
numPr = OxmlElement("w:numPr")
|
||||||
|
ilvl = OxmlElement("w:ilvl")
|
||||||
|
ilvl.set(qn("w:val"), "0")
|
||||||
|
nid = OxmlElement("w:numId")
|
||||||
|
nid.set(qn("w:val"), str(num_id))
|
||||||
|
numPr.append(ilvl)
|
||||||
|
numPr.append(nid)
|
||||||
|
pPr.append(numPr)
|
||||||
|
|
||||||
|
|
||||||
def _clear_body(doc) -> None:
|
def _clear_body(doc) -> None:
|
||||||
"""Remove all paragraphs in the document body while keeping sectPr.
|
"""Remove all paragraphs in the document body while keeping sectPr.
|
||||||
|
|
||||||
@@ -485,12 +563,15 @@ def _write_block_to_docx(doc, block_id: str, title: str, content: str) -> None:
|
|||||||
_add_image_placeholder(doc, stripped.strip("[]📷 "))
|
_add_image_placeholder(doc, stripped.strip("[]📷 "))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Numbered body paragraph ("1. text") → List Paragraph with auto-num.
|
# Numbered body paragraph ("1. text") → real Word auto-numbering (T9).
|
||||||
# The literal prefix is dropped; Word renders "1. 2. 3. ..." via numId.
|
# The literal prefix is dropped and a numPr referencing the document's
|
||||||
|
# continuous decimal list is attached, so Word renders "1. 2. 3. ..."
|
||||||
|
# itself (renumbers on edit, copy-pastes without stray digits).
|
||||||
num_match = _NUM_PREFIX_RE.match(stripped)
|
num_match = _NUM_PREFIX_RE.match(stripped)
|
||||||
if num_match:
|
if num_match:
|
||||||
body_text = num_match.group(2).strip()
|
body_text = num_match.group(2).strip()
|
||||||
_add_styled_paragraph(doc, body_text, style="List Paragraph")
|
para = _add_styled_paragraph(doc, body_text, style="List Paragraph")
|
||||||
|
_apply_list_numbering(para, _ensure_decision_numbering(doc))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
_add_styled_paragraph(doc, stripped, style="Normal")
|
_add_styled_paragraph(doc, stripped, style="Normal")
|
||||||
|
|||||||
@@ -262,8 +262,15 @@ def _ocr_with_google_vision(image_bytes: bytes, page_num: int) -> str:
|
|||||||
def _extract_doc(path: Path) -> str:
|
def _extract_doc(path: Path) -> str:
|
||||||
"""Extract text from legacy .doc file by converting to .docx via LibreOffice."""
|
"""Extract text from legacy .doc file by converting to .docx via LibreOffice."""
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
# Isolate the LibreOffice user profile per call: headless soffice
|
||||||
|
# locks a single shared profile, so concurrent .doc conversions would
|
||||||
|
# otherwise fail with a profile-lock error.
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["libreoffice", "--headless", "--convert-to", "docx", str(path), "--outdir", tmp_dir],
|
[
|
||||||
|
"libreoffice",
|
||||||
|
f"-env:UserInstallation=file://{tmp_dir}/lo-profile",
|
||||||
|
"--headless", "--convert-to", "docx", str(path), "--outdir", tmp_dir,
|
||||||
|
],
|
||||||
capture_output=True, text=True, timeout=120,
|
capture_output=True, text=True, timeout=120,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
@@ -351,8 +358,28 @@ def render_pages_for_multimodal(
|
|||||||
_NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:",
|
_NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:",
|
||||||
"כתבי עת:", "הועתק מנבו")
|
"כתבי עת:", "הועתק מנבו")
|
||||||
|
|
||||||
|
# Markers for where the actual decision body begins (everything before is Nevo
|
||||||
|
# preamble: bibliography + מיני-רציו). Two families:
|
||||||
|
# - ועדת ערר / district openings (בפנינו / הערר שבנדון / ...)
|
||||||
|
# - COURT-RULING openings (#86.1): a פסק-דין header or the authoring judge's
|
||||||
|
# line. Without these, Nevo court judgments — exactly the ones carrying a
|
||||||
|
# מיני-רציו — slipped through unstripped (e.g. בג"ץ 1764/05).
|
||||||
|
#
|
||||||
|
# #86.2 hardening — two over-strip bugs found while backfilling:
|
||||||
|
# 1. ``פסק-דין`` headers are often markdown-wrapped (``**פסק דין**``); the old
|
||||||
|
# ``^פסק[- ]דין`` required the keyword to be the very first char of the line
|
||||||
|
# and allowed only one separator, so it missed the header and fell through
|
||||||
|
# to a citation 32K deep (עמ"נ 50567-07-21). We now tolerate leading
|
||||||
|
# markdown/whitespace and 0-3 separators.
|
||||||
|
# 2. Bare ``השופט``/``הנשיא`` matched *citations* ("השופט מ' חשין, פסקה 23"),
|
||||||
|
# stripping real decision body. The authoring-judge line ends with a COLON
|
||||||
|
# ("השופט י' עמית:"); citations use a comma. We now require the colon.
|
||||||
_DECISION_START = re.compile(
|
_DECISION_START = re.compile(
|
||||||
r"^(בפנינו|לפנינו|הערר שבנדון|ועדת הערר לתכנון|רקע עובדתי|עסקינן)",
|
r"^[ \t>*_#]{0,6}(?:"
|
||||||
|
r"בפנינו|לפנינו|לפניי|הערר שבנדון|ועדת הערר לתכנון|רקע עובדתי|עסקינן|"
|
||||||
|
r"פסק[ \t\-]{0,3}די(?:ן|נו)|" # פסק-דין / פסק דין / **פסק דין** header (final-nun ן vs דינו)
|
||||||
|
r"(?:כב(?:וד)?['׳\"]?\s*)?(?:ה?שופט[ת]?|ה?נשיא[ה]?|המשנה לנשיא)\s+[^\n,]{1,40}:" # author line → colon
|
||||||
|
r")",
|
||||||
re.MULTILINE,
|
re.MULTILINE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -362,7 +389,9 @@ def strip_nevo_preamble(text: str) -> str:
|
|||||||
|
|
||||||
Returns the original text unchanged if no preamble is detected.
|
Returns the original text unchanged if no preamble is detected.
|
||||||
"""
|
"""
|
||||||
head = text[:400]
|
# Window wide enough to catch the Nevo markers even when a long court/parties
|
||||||
|
# header precedes them (court rulings push חקיקה שאוזכרה:/מיני-רציו: down).
|
||||||
|
head = text[:1500]
|
||||||
if not any(marker in head for marker in _NEVO_MARKERS):
|
if not any(marker in head for marker in _NEVO_MARKERS):
|
||||||
return text
|
return text
|
||||||
m = _DECISION_START.search(text)
|
m = _DECISION_START.search(text)
|
||||||
@@ -371,3 +400,41 @@ def strip_nevo_preamble(text: str) -> str:
|
|||||||
logger.debug("Stripped %d chars of Nevo preamble", m.start())
|
logger.debug("Stripped %d chars of Nevo preamble", m.start())
|
||||||
return stripped
|
return stripped
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
_RATIO_MARKER = "מיני-רציו:"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_nevo_ratio(text: str) -> str:
|
||||||
|
"""Return the Nevo מיני-רציו block (editorial holdings summary), or ''.
|
||||||
|
|
||||||
|
The mini-ratio is Nevo's own headnote — a concise, professionally-written
|
||||||
|
list of the holdings. We capture it *before* :func:`strip_nevo_preamble`
|
||||||
|
discards it, to serve as a free gold-set for benchmarking how well our
|
||||||
|
halacha extractor covers the real holdings (#86.3).
|
||||||
|
|
||||||
|
The block runs from the ``מיני-רציו:`` marker to whichever comes first:
|
||||||
|
the decision body (``_DECISION_START``) or the next preamble marker
|
||||||
|
(bibliography / legislation). Returns '' when there is no mini-ratio.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
start = text.find(_RATIO_MARKER)
|
||||||
|
if start == -1:
|
||||||
|
return ""
|
||||||
|
body = text[start + len(_RATIO_MARKER):]
|
||||||
|
|
||||||
|
# End at the earliest of: decision body start, or a following preamble
|
||||||
|
# marker (ספרות: / חקיקה שאוזכרה: / ...). Both are measured relative to
|
||||||
|
# the ratio body so we never run past it into the judgment itself.
|
||||||
|
end = len(body)
|
||||||
|
dm = _DECISION_START.search(body)
|
||||||
|
if dm:
|
||||||
|
end = min(end, dm.start())
|
||||||
|
for marker in _NEVO_MARKERS:
|
||||||
|
if marker == _RATIO_MARKER:
|
||||||
|
continue
|
||||||
|
pos = body.find(marker)
|
||||||
|
if pos != -1:
|
||||||
|
end = min(end, pos)
|
||||||
|
return body[:end].strip()
|
||||||
|
|||||||
@@ -26,14 +26,28 @@ from uuid import UUID
|
|||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.config import parse_llm_json
|
from legal_mcp.config import parse_llm_json
|
||||||
from legal_mcp.services import claude_session, db, embeddings, proofreader
|
from legal_mcp.services import (
|
||||||
|
claude_session, db, embeddings, halacha_quality, proofreader,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Concurrency model mirrors claims_extractor — each ``claude -p`` subprocess
|
# Concurrency model mirrors claims_extractor — each ``claude -p`` subprocess
|
||||||
# holds ~300 MB RSS, so we cap parallel chunks to keep the box healthy.
|
# holds ~300 MB RSS, so we cap parallel chunks to keep the box healthy.
|
||||||
CHUNK_CONCURRENCY = 3
|
# Env-tunable (HALACHA_CHUNK_CONCURRENCY) — see config.py.
|
||||||
|
CHUNK_CONCURRENCY = config.HALACHA_CHUNK_CONCURRENCY
|
||||||
|
|
||||||
|
# Global cross-process serialization key for halacha extraction. Every
|
||||||
|
# extraction (whichever process/agent/driver launched it) takes a PostgreSQL
|
||||||
|
# advisory lock on this key first; if another extraction already holds it the
|
||||||
|
# call returns ``status='busy'`` and the request stays pending for the next
|
||||||
|
# drain. This makes "one extraction at a time" hold across SEPARATE OS
|
||||||
|
# processes (agent fallback retries spawn independent `python -c` drivers — an
|
||||||
|
# in-process Semaphore cannot see them). Root cause of the 2026-05-31 freeze:
|
||||||
|
# 4-5 overlapping driver processes × CHUNK_CONCURRENCY each → 12-16 concurrent
|
||||||
|
# xhigh `claude -p` procs → load 69 → hard reboot.
|
||||||
|
_HALACHA_EXTRACT_LOCK_KEY = 0x48414C41 # 'HALA'
|
||||||
CHUNK_RETRY_ATTEMPTS = 1
|
CHUNK_RETRY_ATTEMPTS = 1
|
||||||
|
|
||||||
# If at least this fraction of chunks crash and the precedent yields zero
|
# If at least this fraction of chunks crash and the precedent yields zero
|
||||||
@@ -73,9 +87,10 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
|
|||||||
|
|
||||||
לא-הלכה (אין לחלץ):
|
לא-הלכה (אין לחלץ):
|
||||||
- אמרת אגב (obiter dicta) — הערות שאינן הכרחיות להכרעה.
|
- אמרת אגב (obiter dicta) — הערות שאינן הכרחיות להכרעה.
|
||||||
- ממצאים עובדתיים ספציפיים לתיק ("העורר לא הוכיח X").
|
- **סוגיה שהערכאה לא הכריעה בה** — אם בית המשפט אומר במפורש "אין צורך להכריע", "מבלי לקבוע מסמרות", "איני רואה לקבוע מסמרות", "למעלה מן הצורך", "אגב אורחא" — זו אינה הלכה. מבחן ההיפוך (Wambaugh): אם שלילת הכלל לא הייתה משנה את תוצאת הפסק — זו אמרת אגב, לא הלכה.
|
||||||
|
- ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים/מספרים קונקרטיים) — חלץ את **העיקרון המופשט** בלבד, לא את יישומו על עובדות התיק.
|
||||||
- ציטוטי הלכות מפסקי דין אחרים שלא אומצו במפורש בפסק זה.
|
- ציטוטי הלכות מפסקי דין אחרים שלא אומצו במפורש בפסק זה.
|
||||||
- הצהרות על דין קיים שאינן מיושמות בהכרעה.
|
- הצהרות על דין קיים שאינן מיושמות בהכרעה, וכן ניסוח שהוא העתק של הציטוט ללא הפשטה.
|
||||||
|
|
||||||
הבחנה קריטית: כאשר הפסק מצטט הלכה מפסק קודם, חלץ אותה רק אם בית המשפט בפסק הנוכחי **מאמץ ומחיל** אותה (לא רק מזכיר אותה ברקע).
|
הבחנה קריטית: כאשר הפסק מצטט הלכה מפסק קודם, חלץ אותה רק אם בית המשפט בפסק הנוכחי **מאמץ ומחיל** אותה (לא רק מזכיר אותה ברקע).
|
||||||
|
|
||||||
@@ -109,10 +124,10 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
|
|||||||
]
|
]
|
||||||
|
|
||||||
## כללי איכות
|
## כללי איכות
|
||||||
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תמציא הלכה.
|
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת ו**שלמה** מהקלט (משפט שלם, לא חתוך באמצע). אם אין ציטוט מתאים — אל תמציא הלכה.
|
||||||
2. **מספר הלכות** — פסק רגיל מכיל 1-4 הלכות מחייבות. אל תמתח את הרשימה. אם אין הלכה — החזר [].
|
2. **מספר הלכות** — פסק רגיל מכיל 1-4 הלכות מחייבות. אל תמתח את הרשימה. אם אין הלכה — החזר [].
|
||||||
3. **לא לפצל יתר על המידה** — אם שני סעיפים מבטאים את אותו עיקרון, אחד את הניסוח.
|
3. **לא לפצל יתר על המידה — קריטי** — כל הלכה = שאלה משפטית מובחנת אחת. אם כמה סעיפים מבטאים פנים שונים של אותה שאלה משפטית — אחד אותם לכלל אחד (בחר את הניסוח הכללי/המחייב ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים.
|
||||||
4. **שפה** — rule_statement בעברית משפטית מקצועית, לא צמצום מילולי של הציטוט.
|
4. **שפה והפשטה** — rule_statement בעברית משפטית מקצועית בגוף שלישי, כעיקרון בר-הכללה לתיקים עתידיים — **לא** צמצום מילולי של הציטוט ולא קביעה התלויה בעובדות התיק.
|
||||||
5. **subject_tags** — 2-5 תגיות בעברית, snake_case (חניה, קווי_בניין, שיקול_דעת, פגם_פרוצדורלי, סמכות, מועדים, פגיעה_במקרקעין, ירידת_ערך).
|
5. **subject_tags** — 2-5 תגיות בעברית, snake_case (חניה, קווי_בניין, שיקול_דעת, פגם_פרוצדורלי, סמכות, מועדים, פגיעה_במקרקעין, ירידת_ערך).
|
||||||
6. **confidence** — 0..1. מתחת ל-0.7 = ספק לגבי היות זה הלכה מחייבת.
|
6. **confidence** — 0..1. מתחת ל-0.7 = ספק לגבי היות זה הלכה מחייבת.
|
||||||
"""
|
"""
|
||||||
@@ -131,8 +146,9 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
|
|||||||
- **מסקנה מנומקת ומשכנעת** (rule_type=`persuasive`) — מסקנה שלמה של הפנל בסוגיה, עם ההיגיון התומך, ניתנת לציטוט כאסמכתא משכנעת.
|
- **מסקנה מנומקת ומשכנעת** (rule_type=`persuasive`) — מסקנה שלמה של הפנל בסוגיה, עם ההיגיון התומך, ניתנת לציטוט כאסמכתא משכנעת.
|
||||||
|
|
||||||
**אין לחלץ:**
|
**אין לחלץ:**
|
||||||
- ממצאים עובדתיים ספציפיים לתיק ("העורר לא הוכיח X").
|
- ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים קונקרטיים) — חלץ את העיקרון/היישום בניסוח בר-הכללה בלבד.
|
||||||
- ציטוטים מפסקי דין אחרים ללא ניתוח של הפנל.
|
- סוגיה שהפנל לא הכריע בה ("אין צורך להכריע", "מבלי לקבוע מסמרות", "למעלה מן הצורך").
|
||||||
|
- ציטוטים מפסקי דין אחרים ללא ניתוח של הפנל, וכן ניסוח שהוא העתק של הציטוט ללא הפשטה.
|
||||||
- אמרות אגב חסרות חשיבות.
|
- אמרות אגב חסרות חשיבות.
|
||||||
|
|
||||||
## תחומים אפשריים (practice_areas) — תחומי ועדת הערר בלבד
|
## תחומים אפשריים (practice_areas) — תחומי ועדת הערר בלבד
|
||||||
@@ -158,9 +174,9 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
|
|||||||
|
|
||||||
## כללי איכות
|
## כללי איכות
|
||||||
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה.
|
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה.
|
||||||
2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אם אין מה לחלץ — החזר [].
|
2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אל תמתח את הרשימה. אם אין מה לחלץ — החזר [].
|
||||||
3. **rule_type מדויק** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. persuasive = מסקנה כללית בעלת ערך כאסמכתא.
|
3. **rule_type מדויק** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. persuasive = מסקנה כללית בעלת ערך כאסמכתא.
|
||||||
4. **לא לפצל יתר על המידה** — שני סעיפים זהים מבחינה רעיונית = פריט אחד.
|
4. **לא לפצל יתר על המידה — קריטי** — כל פריט = שאלה משפטית מובחנת אחת. פנים שונים של אותה שאלה = פריט אחד (בחר את הניסוח הכללי ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים.
|
||||||
5. **שפה** — עברית משפטית מקצועית, גוף שלישי.
|
5. **שפה** — עברית משפטית מקצועית, גוף שלישי.
|
||||||
6. **subject_tags** — 2-5 תגיות בעברית, snake_case.
|
6. **subject_tags** — 2-5 תגיות בעברית, snake_case.
|
||||||
7. **confidence** — 0..1. דייק.
|
7. **confidence** — 0..1. דייק.
|
||||||
@@ -268,6 +284,92 @@ def _coerce_halacha(raw: dict, is_binding: bool = True) -> dict | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _nli_check(items: list[dict]) -> list[str]:
|
||||||
|
"""Entailment verdict per item (rule ⊨ quote) via claude_session — #81.3.
|
||||||
|
|
||||||
|
Local CLI, zero cost. FAILS OPEN: any error returns all-'entailed' so a
|
||||||
|
flaky/unavailable judge (e.g. in the container) never blocks a halacha.
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
raw = await claude_session.query_json(
|
||||||
|
halacha_quality.build_nli_prompt(items),
|
||||||
|
system=halacha_quality.NLI_SYSTEM,
|
||||||
|
model=config.HALACHA_NLI_MODEL or None,
|
||||||
|
effort=config.HALACHA_NLI_EFFORT or None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("halacha NLI check failed (fail-open, no flags): %s", e)
|
||||||
|
return ["entailed"] * len(items)
|
||||||
|
return halacha_quality.parse_nli_verdicts(raw, len(items))
|
||||||
|
|
||||||
|
|
||||||
|
def _consolidation_priority(r: dict):
|
||||||
|
"""Canonical = the row to KEEP within a fold group (lower sorts first)."""
|
||||||
|
status_rank = {"approved": 0, "published": 0, "pending_review": 1}.get(
|
||||||
|
r.get("review_status"), 2)
|
||||||
|
return (
|
||||||
|
status_rank,
|
||||||
|
-float(r.get("confidence") or 0.0),
|
||||||
|
0 if r.get("quote_verified") else 1,
|
||||||
|
-len(r.get("rule_statement") or ""),
|
||||||
|
str(r["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _consolidate_precedent(case_law_id: UUID) -> int:
|
||||||
|
"""#81.5 — fold facets of the SAME legal question into one canonical.
|
||||||
|
|
||||||
|
Per-precedent claude_session pass (local CLI, zero cost). Keeps the best row
|
||||||
|
of each fold group; marks the rest ``rejected`` (reversible — out of the
|
||||||
|
active corpus AND the review queue, but recoverable). FOLD-ONLY. Fails OPEN:
|
||||||
|
any error / parse failure → 0 folds (never touches data on doubt).
|
||||||
|
"""
|
||||||
|
if not config.HALACHA_CONSOLIDATE_ENABLED:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
rows = [
|
||||||
|
r for r in await db.list_halachot(case_law_id=case_law_id, limit=10_000)
|
||||||
|
if r.get("review_status") != "rejected"
|
||||||
|
]
|
||||||
|
if len(rows) < 2:
|
||||||
|
return 0
|
||||||
|
by_idx = {r["halacha_index"]: r for r in rows}
|
||||||
|
raw = await claude_session.query_json(
|
||||||
|
halacha_quality.build_consolidation_prompt(rows),
|
||||||
|
system=halacha_quality.CONSOLIDATE_SYSTEM,
|
||||||
|
model=config.HALACHA_CONSOLIDATE_MODEL or None,
|
||||||
|
effort=config.HALACHA_CONSOLIDATE_EFFORT or None,
|
||||||
|
)
|
||||||
|
groups = halacha_quality.parse_fold_groups(raw)
|
||||||
|
if not groups:
|
||||||
|
return 0
|
||||||
|
canonicals: set[str] = set()
|
||||||
|
losers: set[str] = set()
|
||||||
|
for g in groups:
|
||||||
|
members = [by_idx[i] for i in g if i in by_idx]
|
||||||
|
if len(members) < 2:
|
||||||
|
continue
|
||||||
|
members.sort(key=_consolidation_priority)
|
||||||
|
canonicals.add(str(members[0]["id"]))
|
||||||
|
for m in members[1:]:
|
||||||
|
losers.add(str(m["id"]))
|
||||||
|
# Never reject a row that is the canonical of any group.
|
||||||
|
loser_ids = [i for i in losers if i not in canonicals]
|
||||||
|
if not loser_ids:
|
||||||
|
return 0
|
||||||
|
return await db.update_halachot_batch(
|
||||||
|
loser_ids, "rejected", reviewer="auto-consolidated (#81.5 facet-fold)",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"halacha consolidation failed for %s (fail-open, no folds): %s",
|
||||||
|
case_law_id, e,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
async def _extract_chunk(
|
async def _extract_chunk(
|
||||||
chunk_text: str,
|
chunk_text: str,
|
||||||
section_type: str,
|
section_type: str,
|
||||||
@@ -275,6 +377,7 @@ async def _extract_chunk(
|
|||||||
chunk_total: int,
|
chunk_total: int,
|
||||||
context: str,
|
context: str,
|
||||||
is_binding: bool,
|
is_binding: bool,
|
||||||
|
effort: str | None = None,
|
||||||
) -> tuple[list[dict], bool]:
|
) -> tuple[list[dict], bool]:
|
||||||
"""Run the halacha extractor on one chunk with retry.
|
"""Run the halacha extractor on one chunk with retry.
|
||||||
|
|
||||||
@@ -304,7 +407,12 @@ async def _extract_chunk(
|
|||||||
last_err: Exception | None = None
|
last_err: Exception | None = None
|
||||||
for attempt in range(CHUNK_RETRY_ATTEMPTS + 1):
|
for attempt in range(CHUNK_RETRY_ATTEMPTS + 1):
|
||||||
try:
|
try:
|
||||||
result = await claude_session.query_json(user_msg, system=base_prompt)
|
result = await claude_session.query_json(
|
||||||
|
user_msg,
|
||||||
|
system=base_prompt,
|
||||||
|
model=config.HALACHA_EXTRACT_MODEL or None,
|
||||||
|
effort=(effort or config.HALACHA_EXTRACT_EFFORT) or None,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_err = e
|
last_err = e
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -325,11 +433,25 @@ async def _extract_chunk(
|
|||||||
return [], False
|
return [], False
|
||||||
|
|
||||||
|
|
||||||
async def extract(case_law_id: UUID | str) -> dict:
|
async def extract(case_law_id: UUID | str, force: bool = False,
|
||||||
"""Extract halachot from an uploaded precedent and store them.
|
effort: str | None = None) -> dict:
|
||||||
|
"""Extract halachot from an uploaded precedent — globally serialized.
|
||||||
|
|
||||||
Idempotent: replaces any existing halachot for this case_law_id.
|
``effort`` overrides the per-chunk LLM effort (default
|
||||||
All inserted rows start as ``review_status='pending_review'``.
|
``config.HALACHA_EXTRACT_EFFORT`` = xhigh). Bulk queue-drains pass the
|
||||||
|
lighter ``config.HALACHA_BULK_EXTRACT_EFFORT`` to cut wall-clock at scale.
|
||||||
|
|
||||||
|
``force=False`` (default) RESUMES: chunks already extracted (checkpointed)
|
||||||
|
are skipped, so a crash/interrupt never loses completed work or re-pays for
|
||||||
|
it. ``force=True`` wipes prior halachot + checkpoints and re-extracts all
|
||||||
|
(used by explicit re-extraction).
|
||||||
|
|
||||||
|
Takes a PostgreSQL advisory lock so only ONE extraction runs at a time
|
||||||
|
across ALL processes (agent retries + batch ``process_pending`` spawn
|
||||||
|
independent OS drivers; an in-process Semaphore can't see them). If another
|
||||||
|
extraction already holds the lock this returns ``status='busy'`` and the
|
||||||
|
precedent stays pending for the next drain — no second xhigh run piles on
|
||||||
|
(this is the fix for the 2026-05-31 box freeze).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
``{"status": "...", "extracted": N, "verified": M, "stored": K, ...}``
|
``{"status": "...", "extracted": N, "verified": M, "stored": K, ...}``
|
||||||
@@ -337,6 +459,41 @@ async def extract(case_law_id: UUID | str) -> dict:
|
|||||||
if isinstance(case_law_id, str):
|
if isinstance(case_law_id, str):
|
||||||
case_law_id = UUID(case_law_id)
|
case_law_id = UUID(case_law_id)
|
||||||
|
|
||||||
|
pool = await db.get_pool()
|
||||||
|
lock_conn = await pool.acquire()
|
||||||
|
try:
|
||||||
|
got = await lock_conn.fetchval(
|
||||||
|
"SELECT pg_try_advisory_lock($1)", _HALACHA_EXTRACT_LOCK_KEY,
|
||||||
|
)
|
||||||
|
if not got:
|
||||||
|
logger.warning(
|
||||||
|
"halacha extract: global lock held by another extraction — "
|
||||||
|
"skipping %s (stays pending for next drain)", case_law_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "busy", "extracted": 0, "stored": 0,
|
||||||
|
"case_law_id": str(case_law_id),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return await _extract_impl(case_law_id, force=force, effort=effort)
|
||||||
|
finally:
|
||||||
|
await lock_conn.fetchval(
|
||||||
|
"SELECT pg_advisory_unlock($1)", _HALACHA_EXTRACT_LOCK_KEY,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await pool.release(lock_conn)
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_impl(case_law_id: UUID, force: bool = False,
|
||||||
|
effort: str | None = None) -> dict:
|
||||||
|
"""Core extraction (caller holds the global advisory lock for the duration).
|
||||||
|
|
||||||
|
Crash-safe + resumable: each chunk's halachot are stored AND the chunk is
|
||||||
|
checkpointed (``precedent_chunks.halacha_extracted_at``) the moment it
|
||||||
|
finishes. A crash/interrupt loses at most the in-flight chunk; a re-run
|
||||||
|
resumes — already-done chunks are skipped, failed/pending chunks retried.
|
||||||
|
``force=True`` wipes prior halachot + checkpoints and re-extracts all.
|
||||||
|
"""
|
||||||
record = await db.get_case_law(case_law_id)
|
record = await db.get_case_law(case_law_id)
|
||||||
if not record:
|
if not record:
|
||||||
return {"status": "not_found", "extracted": 0, "stored": 0}
|
return {"status": "not_found", "extracted": 0, "stored": 0}
|
||||||
@@ -363,111 +520,162 @@ async def extract(case_law_id: UUID | str) -> dict:
|
|||||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
await db.set_case_law_halacha_status(case_law_id, "completed")
|
||||||
return {"status": "no_chunks", "extracted": 0, "stored": 0}
|
return {"status": "no_chunks", "extracted": 0, "stored": 0}
|
||||||
|
|
||||||
await db.set_case_law_halacha_status(case_law_id, "processing")
|
# force = clean slate; otherwise resume (skip already-checkpointed chunks).
|
||||||
await db.delete_halachot(case_law_id)
|
if force:
|
||||||
|
await db.reset_halacha_extraction(case_law_id)
|
||||||
|
for c in chunks:
|
||||||
|
c["halacha_extracted_at"] = None
|
||||||
|
|
||||||
|
await db.set_case_law_halacha_status(case_law_id, "processing")
|
||||||
|
|
||||||
|
pending = [c for c in chunks if c.get("halacha_extracted_at") is None]
|
||||||
|
|
||||||
|
# Legacy guard: a precedent extracted before V25 has halachot but NO chunk
|
||||||
|
# checkpoints. Re-extracting (append-per-chunk) would DUPLICATE them. If
|
||||||
|
# nothing is checkpointed yet but halachot already exist, backfill the
|
||||||
|
# checkpoints and treat as complete instead of re-extracting.
|
||||||
|
if not force and len(pending) == len(chunks):
|
||||||
|
already = await db.list_halachot(case_law_id=case_law_id, limit=1)
|
||||||
|
if already:
|
||||||
|
await db.mark_all_chunks_extracted(case_law_id)
|
||||||
|
total = len(await db.list_halachot(case_law_id=case_law_id, limit=10_000))
|
||||||
|
await db.set_case_law_halacha_status(case_law_id, "completed")
|
||||||
|
logger.info(
|
||||||
|
"halacha_extractor: case_law=%s legacy-backfill — %d existing "
|
||||||
|
"halachot, checkpoints backfilled (no re-extract).",
|
||||||
|
case_law_id, total,
|
||||||
|
)
|
||||||
|
return {"status": "completed", "extracted": total, "stored": total,
|
||||||
|
"legacy_backfill": True, "total_chunks": len(chunks)}
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
# Resume found nothing left — every chunk already extracted.
|
||||||
|
total = len(await db.list_halachot(case_law_id=case_law_id, limit=10_000))
|
||||||
|
await db.set_case_law_halacha_status(case_law_id, "completed")
|
||||||
|
return {"status": "completed", "extracted": total, "stored": total,
|
||||||
|
"resumed": True, "total_chunks": len(chunks)}
|
||||||
|
|
||||||
|
full_text = record.get("full_text") or ""
|
||||||
citation = record.get("case_number", "")
|
citation = record.get("case_number", "")
|
||||||
court = record.get("court", "")
|
court = record.get("court", "")
|
||||||
date_str = str(record.get("date") or "")
|
date_str = str(record.get("date") or "")
|
||||||
context = f"מקור: {citation} — {court}, {date_str}"
|
context = f"מקור: {citation} — {court}, {date_str}"
|
||||||
|
idx_by_id = {c["id"]: i for i, c in enumerate(chunks)}
|
||||||
|
|
||||||
sem = asyncio.Semaphore(CHUNK_CONCURRENCY)
|
sem = asyncio.Semaphore(CHUNK_CONCURRENCY)
|
||||||
|
store_lock = asyncio.Lock() # serialize per-chunk stores (index continuity)
|
||||||
|
stored_total = 0
|
||||||
|
failed_chunks = 0
|
||||||
|
|
||||||
async def _bounded(idx: int, chunk_row: dict) -> tuple[list[dict], bool]:
|
async def _process(chunk_row: dict) -> None:
|
||||||
|
nonlocal stored_total, failed_chunks
|
||||||
async with sem:
|
async with sem:
|
||||||
return await _extract_chunk(
|
items, ok = await _extract_chunk(
|
||||||
chunk_row["content"], chunk_row["section_type"],
|
chunk_row["content"], chunk_row["section_type"],
|
||||||
idx, len(chunks), context, is_binding,
|
idx_by_id[chunk_row["id"]], len(chunks), context, is_binding,
|
||||||
|
effort,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
failed_chunks += 1 # leave chunk un-checkpointed → retried on resume
|
||||||
|
return
|
||||||
|
cleaned: list[dict] = []
|
||||||
|
for raw in items:
|
||||||
|
coerced = _coerce_halacha(raw, is_binding=is_binding)
|
||||||
|
if coerced is None:
|
||||||
|
continue
|
||||||
|
coerced["quote_verified"] = _verify_quote(
|
||||||
|
coerced["supporting_quote"], full_text,
|
||||||
|
)
|
||||||
|
# Strict-rubric quality gate (docs/halacha-strict-rubric.md):
|
||||||
|
# flags block auto-approval (route to pending_review); a court
|
||||||
|
# non-decision is re-typed obiter so it never reads as a holding.
|
||||||
|
flags = halacha_quality.compute_quality_flags(
|
||||||
|
coerced["rule_statement"], coerced["supporting_quote"],
|
||||||
|
coerced["reasoning_summary"], coerced["quote_verified"],
|
||||||
|
coerced["rule_type"],
|
||||||
|
)
|
||||||
|
coerced["quality_flags"] = flags
|
||||||
|
if halacha_quality.FLAG_NON_DECISION in flags and coerced["rule_type"] != "obiter":
|
||||||
|
coerced["rule_type"] = "obiter"
|
||||||
|
# #81.4 — a binding-labeled rule that reads as a case-application is
|
||||||
|
# re-typed application (it carries FLAG_APPLICATION either way).
|
||||||
|
elif (halacha_quality.FLAG_APPLICATION in flags
|
||||||
|
and coerced["rule_type"] == "binding"):
|
||||||
|
coerced["rule_type"] = "application"
|
||||||
|
cleaned.append(coerced)
|
||||||
|
# #81.3 NLI entailment — one batched judge call per chunk (fail-open).
|
||||||
|
if config.HALACHA_NLI_ENABLED and cleaned:
|
||||||
|
verdicts = await _nli_check(cleaned)
|
||||||
|
for h, v in zip(cleaned, verdicts):
|
||||||
|
if v != "entailed" and halacha_quality.FLAG_NLI_UNSUPPORTED not in h["quality_flags"]:
|
||||||
|
h["quality_flags"].append(halacha_quality.FLAG_NLI_UNSUPPORTED)
|
||||||
|
if cleaned:
|
||||||
|
embed_inputs = [
|
||||||
|
f"{h['rule_statement']} — {h['reasoning_summary']}".strip(" —")
|
||||||
|
for h in cleaned
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
vectors = await embeddings.embed_texts(embed_inputs, input_type="document")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("halacha_extractor: embeddings failed: %s", e)
|
||||||
|
vectors = [None] * len(cleaned)
|
||||||
|
for h, vec in zip(cleaned, vectors):
|
||||||
|
h["embedding"] = vec
|
||||||
|
# Store this chunk's halachot AND checkpoint the chunk, atomically.
|
||||||
|
async with store_lock:
|
||||||
|
stored_total += await db.store_halachot_for_chunk(
|
||||||
|
case_law_id, chunk_row["id"], cleaned,
|
||||||
)
|
)
|
||||||
|
|
||||||
chunk_results = await asyncio.gather(
|
await asyncio.gather(*[_process(c) for c in pending])
|
||||||
*[_bounded(i, c) for i, c in enumerate(chunks)]
|
|
||||||
)
|
|
||||||
raw_halachot: list[dict] = []
|
|
||||||
failed_chunks = 0
|
|
||||||
for items, ok in chunk_results:
|
|
||||||
raw_halachot.extend(items)
|
|
||||||
if not ok:
|
|
||||||
failed_chunks += 1
|
|
||||||
|
|
||||||
# If most chunks failed (rate limit storm, claude_session crash, etc.)
|
# Decide final status from what's LEFT (re-read checkpoints).
|
||||||
# do NOT touch the DB status — leave it 'processing' so the caller can
|
after = await db.list_precedent_chunks(case_law_id, section_types=EXTRACTABLE_SECTIONS)
|
||||||
# retry without the request falling out of the queue. The caller
|
if not after:
|
||||||
# (`process_pending_extractions`) is responsible for either retrying or
|
after = await db.list_precedent_chunks(case_law_id)
|
||||||
# finalising the status as 'failed' after retries are exhausted. This
|
still_pending = sum(1 for c in after if c.get("halacha_extracted_at") is None)
|
||||||
# is the bug that produced 317/10's silent `no_halachot` after a
|
total = len(await db.list_halachot(case_law_id=case_law_id, limit=10_000))
|
||||||
# 129-chunk neighbour saturated the API.
|
|
||||||
failure_rate = failed_chunks / len(chunks) if chunks else 0
|
if still_pending:
|
||||||
if failure_rate >= EXTRACTION_FAILURE_THRESHOLD and not raw_halachot:
|
# Some chunks failed this run. Leave status 'processing' so a resume
|
||||||
logger.error(
|
# continues them (no progress is lost — done chunks are checkpointed).
|
||||||
"halacha_extractor: case_law=%s extraction_failed — "
|
if total == 0 and failed_chunks >= len(pending) * EXTRACTION_FAILURE_THRESHOLD:
|
||||||
"%d/%d chunks failed (rate=%.0f%%), no halachot retrieved. "
|
logger.error(
|
||||||
"DB status left as 'processing' for caller-level retry.",
|
"halacha_extractor: case_law=%s extraction_failed — %d/%d pending "
|
||||||
case_law_id, failed_chunks, len(chunks), failure_rate * 100,
|
"chunks failed, 0 stored. status left 'processing' for retry.",
|
||||||
|
case_law_id, failed_chunks, len(pending),
|
||||||
|
)
|
||||||
|
return {"status": "extraction_failed", "extracted": 0, "stored": 0,
|
||||||
|
"failed_chunks": failed_chunks, "pending_chunks": still_pending,
|
||||||
|
"total_chunks": len(chunks)}
|
||||||
|
logger.warning(
|
||||||
|
"halacha_extractor: case_law=%s partial — %d chunks still pending, "
|
||||||
|
"%d halachot stored so far. status 'processing' (resume to finish).",
|
||||||
|
case_law_id, still_pending, total,
|
||||||
)
|
)
|
||||||
return {
|
return {"status": "partial", "extracted": total, "stored": stored_total,
|
||||||
"status": "extraction_failed",
|
"pending_chunks": still_pending, "total_chunks": len(chunks)}
|
||||||
"extracted": 0,
|
|
||||||
"stored": 0,
|
|
||||||
"failed_chunks": failed_chunks,
|
|
||||||
"total_chunks": len(chunks),
|
|
||||||
}
|
|
||||||
|
|
||||||
if not raw_halachot:
|
# All chunks done. #81.5: fold cross-chunk facets of one legal question
|
||||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
# (the prompt dedups within a chunk; this catches across chunks).
|
||||||
return {
|
folded = await _consolidate_precedent(case_law_id)
|
||||||
"status": "no_halachot",
|
|
||||||
"extracted": 0,
|
|
||||||
"stored": 0,
|
|
||||||
"failed_chunks": failed_chunks,
|
|
||||||
"total_chunks": len(chunks),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Validate against the full text of the precedent for the quote check.
|
stored = total
|
||||||
full_text = record.get("full_text") or ""
|
verified = sum(1 for h in await db.list_halachot(case_law_id=case_law_id, limit=10_000)
|
||||||
|
if h.get("quote_verified"))
|
||||||
cleaned: list[dict] = []
|
|
||||||
for raw in raw_halachot:
|
|
||||||
coerced = _coerce_halacha(raw, is_binding=is_binding)
|
|
||||||
if coerced is None:
|
|
||||||
continue
|
|
||||||
coerced["quote_verified"] = _verify_quote(
|
|
||||||
coerced["supporting_quote"], full_text,
|
|
||||||
)
|
|
||||||
cleaned.append(coerced)
|
|
||||||
|
|
||||||
if not cleaned:
|
|
||||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
|
||||||
return {"status": "no_valid_halachot", "extracted": len(raw_halachot), "stored": 0}
|
|
||||||
|
|
||||||
# Embed rule_statement + reasoning_summary so semantic search hits the
|
|
||||||
# rule directly rather than the surrounding chunk centroid.
|
|
||||||
embed_inputs = [
|
|
||||||
f"{h['rule_statement']} — {h['reasoning_summary']}".strip(" —")
|
|
||||||
for h in cleaned
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
vectors = await embeddings.embed_texts(embed_inputs, input_type="document")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("halacha_extractor: embeddings failed: %s", e)
|
|
||||||
vectors = [None] * len(cleaned)
|
|
||||||
|
|
||||||
for halacha, vec in zip(cleaned, vectors):
|
|
||||||
halacha["embedding"] = vec
|
|
||||||
|
|
||||||
stored = await db.store_halachot(case_law_id, cleaned)
|
|
||||||
|
|
||||||
verified = sum(1 for h in cleaned if h["quote_verified"])
|
|
||||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
await db.set_case_law_halacha_status(case_law_id, "completed")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"halacha_extractor: case_law=%s extracted=%d cleaned=%d verified=%d stored=%d",
|
"halacha_extractor: case_law=%s completed — %d halachot stored "
|
||||||
case_law_id, len(raw_halachot), len(cleaned), verified, stored,
|
"(%d new this run), %d quote-verified, %d folded, %d chunks",
|
||||||
|
case_law_id, total, stored_total, verified, folded, len(chunks),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"extracted": len(raw_halachot),
|
"extracted": total,
|
||||||
"valid": len(cleaned),
|
|
||||||
"verified": verified,
|
"verified": verified,
|
||||||
|
"folded": folded,
|
||||||
"stored": stored,
|
"stored": stored,
|
||||||
|
"stored_this_run": stored_total,
|
||||||
|
"total_chunks": len(chunks),
|
||||||
}
|
}
|
||||||
|
|||||||
360
mcp-server/src/legal_mcp/services/halacha_quality.py
Normal file
360
mcp-server/src/legal_mcp/services/halacha_quality.py
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
"""Pure quality validators + dedup helpers for halacha extraction.
|
||||||
|
|
||||||
|
These encode the "strict rules" rubric (docs/halacha-strict-rubric.md) that
|
||||||
|
drove the 2026-06-03 corpus cleanup (1454→534), so that future extraction
|
||||||
|
comes out clean instead of accumulating duplicates, obiter dicta, truncated
|
||||||
|
quotes and thin restatements that clog the review queue.
|
||||||
|
|
||||||
|
Everything here is a PURE function (no DB, no LLM) so it is fully unit-tested.
|
||||||
|
The DB-touching dedup-on-insert (uses these helpers) lives in
|
||||||
|
``db.store_halachot_for_chunk``.
|
||||||
|
|
||||||
|
Flags produced by :func:`compute_quality_flags` BLOCK auto-approval (the item
|
||||||
|
routes to ``pending_review`` regardless of confidence) but never delete — the
|
||||||
|
chair still sees flagged items, just out of the auto-approved stream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# ── Hebrew text normalization (shared with the extractor's quote check) ──
|
||||||
|
|
||||||
|
_HEB_QUOTE_VARIANTS = "\"'׳״‘’“”«»„′″"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text(text: str) -> str:
|
||||||
|
"""Collapse whitespace and unify Hebrew quote-mark variants for matching.
|
||||||
|
|
||||||
|
Kept dependency-free (the extractor previously routed through
|
||||||
|
``proofreader._fix_hebrew_quotes``; here we inline a quote-class collapse so
|
||||||
|
this module stays pure and importable from anywhere).
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
# Unify the half-dozen quote/gershayim variants to a single ASCII quote.
|
||||||
|
unified = re.sub(f"[{re.escape(_HEB_QUOTE_VARIANTS)}]", '"', text)
|
||||||
|
return re.sub(r"\s+", " ", unified).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Non-decision / obiter detection (Wambaugh: the court did not decide) ──
|
||||||
|
#
|
||||||
|
# High-precision markers only. Phrases like "לכאורה" / "ניתן להניח" alone are
|
||||||
|
# too common to flag reliably, so we require the explicit "declined to rule"
|
||||||
|
# formulations the rubric calibration confirmed on שפר (idx 32: "איני רואה
|
||||||
|
# לקבוע מסמרות") and on 8027-25 (idx 18-19: "אין צורך להכריע").
|
||||||
|
|
||||||
|
NON_DECISION_MARKERS = (
|
||||||
|
"אין צורך להכריע",
|
||||||
|
"איני נדרש להכריע",
|
||||||
|
"איננו נדרשים להכריע",
|
||||||
|
"אין אנו נדרשים להכריע",
|
||||||
|
"מתייתר הצורך להכריע",
|
||||||
|
"אין צורך לקבוע מסמרות",
|
||||||
|
"מבלי לקבוע מסמרות",
|
||||||
|
"איני רואה לקבוע מסמרות",
|
||||||
|
"איננו רואים לקבוע מסמרות",
|
||||||
|
"אין לקבוע מסמרות",
|
||||||
|
"אין מקום לקבוע מסמרות",
|
||||||
|
"לא ראינו לקבוע מסמרות",
|
||||||
|
"למעלה מן הצורך",
|
||||||
|
"למעלה מהצורך",
|
||||||
|
"למעלה מן הדרוש",
|
||||||
|
"מעבר לנדרש",
|
||||||
|
"אגב אורחא",
|
||||||
|
"אגב אורחה",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_non_decision(*texts: str) -> str | None:
|
||||||
|
"""Return the first non-decision marker found across ``texts`` (or None).
|
||||||
|
|
||||||
|
Scans rule_statement + reasoning_summary + supporting_quote — the court's
|
||||||
|
own hedge usually sits in the quote/reasoning, not the abstracted rule.
|
||||||
|
"""
|
||||||
|
joined = normalize_text(" ".join(t for t in texts if t))
|
||||||
|
for marker in NON_DECISION_MARKERS:
|
||||||
|
if marker in joined:
|
||||||
|
return marker
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Truncated / incomplete supporting-quote detection ──
|
||||||
|
#
|
||||||
|
# Conservative: only flag a CLEAR mid-word cut — the quote's last whitespace-
|
||||||
|
# delimited token is a single Hebrew letter (a dangling construct/prefix such
|
||||||
|
# as the "...על ה" in 8099-02-17 idx 6). A complete clause ends in a full word,
|
||||||
|
# so this does not fire on quotes that merely lack a trailing period (the
|
||||||
|
# calibration showed ~1/3 of valid quotes drop the final period legitimately).
|
||||||
|
|
||||||
|
_HEB_LETTER = "א-ת"
|
||||||
|
|
||||||
|
|
||||||
|
def is_quote_truncated(quote: str) -> bool:
|
||||||
|
norm = normalize_text(quote)
|
||||||
|
if not norm:
|
||||||
|
return True
|
||||||
|
tokens = norm.split(" ")
|
||||||
|
last = tokens[-1].strip('".,;:)]')
|
||||||
|
# dangling single Hebrew letter at the end == cut mid-word
|
||||||
|
if len(last) == 1 and re.match(f"[{_HEB_LETTER}]", last):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Thin restatement: rule_statement adds nothing over the quote ──
|
||||||
|
#
|
||||||
|
# Flag when the rule is essentially a copy of the quote: high token overlap AND
|
||||||
|
# the rule is no longer than the quote. A genuine halacha ABSTRACTS the rule, so
|
||||||
|
# it introduces wording the verbatim quote lacks and/or generalizes (longer or
|
||||||
|
# differently phrased).
|
||||||
|
|
||||||
|
_THIN_OVERLAP = 0.85
|
||||||
|
_THIN_LEN_RATIO = 1.10
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens(text: str) -> set[str]:
|
||||||
|
norm = normalize_text(text)
|
||||||
|
return {t for t in re.split(r"[^א-ת0-9]+", norm) if len(t) > 1}
|
||||||
|
|
||||||
|
|
||||||
|
def is_thin_restatement(rule_statement: str, supporting_quote: str) -> bool:
|
||||||
|
rule_t = _tokens(rule_statement)
|
||||||
|
quote_t = _tokens(supporting_quote)
|
||||||
|
if not rule_t or not quote_t:
|
||||||
|
return False
|
||||||
|
overlap = len(rule_t & quote_t) / len(rule_t)
|
||||||
|
len_ratio = len(normalize_text(rule_statement)) / max(1, len(normalize_text(supporting_quote)))
|
||||||
|
return overlap >= _THIN_OVERLAP and len_ratio <= _THIN_LEN_RATIO
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fact-dependent application: not a generalizable holding (#81.4) ──
|
||||||
|
#
|
||||||
|
# The strict rubric's cut_application (docs/halacha-strict-rubric.md §3, §27):
|
||||||
|
# a determination that rests on the case's specific facts/parties/amounts is an
|
||||||
|
# illustration, not a holding — it must not enter the corpus as a binding rule.
|
||||||
|
# The extractor already classifies ``rule_type='application'``; this is a
|
||||||
|
# HIGH-PRECISION secondary catch for rules the model mislabeled as binding,
|
||||||
|
# using only the unambiguous "applied to THIS case" deixis (bare party words
|
||||||
|
# like "המערער" appear in genuine rules too, so they are deliberately excluded).
|
||||||
|
|
||||||
|
_FACT_DEPENDENT_MARKERS = (
|
||||||
|
"במקרה דנן",
|
||||||
|
"במקרה שבפנינו",
|
||||||
|
"במקרה שלפנינו",
|
||||||
|
"במקרה שלפניי",
|
||||||
|
"בענייננו",
|
||||||
|
"בנדון דידן",
|
||||||
|
"בנדון דנן",
|
||||||
|
"במקרה שלנו",
|
||||||
|
"בנסיבות המקרה שלפנינו",
|
||||||
|
"בנסיבות תיק זה",
|
||||||
|
"בתיק שלפנינו",
|
||||||
|
"בערר שלפנינו",
|
||||||
|
"בערר דנן",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_fact_dependent(rule_statement: str) -> bool:
|
||||||
|
"""True when the rule is phrased as an application to THIS case (not a holding)."""
|
||||||
|
norm = normalize_text(rule_statement)
|
||||||
|
return any(marker in norm for marker in _FACT_DEPENDENT_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Lexical near-duplicate signal (the 0.83–0.90 cosine tail) — #82.3 ──
|
||||||
|
#
|
||||||
|
# Embedding cosine alone misses paraphrases that float just below the dedup
|
||||||
|
# threshold (0.93). A secondary lexical signal — Jaccard over word-shingles +
|
||||||
|
# normalized Levenshtein on the rule_statement — catches "same rule, reworded"
|
||||||
|
# in that band without lowering the global cosine threshold. Hybrid
|
||||||
|
# lexical+semantic beats either alone (arXiv:1805.11611). Pure functions.
|
||||||
|
|
||||||
|
def _shingles(text: str, k: int = 2) -> set[str]:
|
||||||
|
words = [w for w in re.split(r"[^א-ת0-9]+", normalize_text(text)) if w]
|
||||||
|
if len(words) < k:
|
||||||
|
return {" ".join(words)} if words else set()
|
||||||
|
return {" ".join(words[i : i + k]) for i in range(len(words) - k + 1)}
|
||||||
|
|
||||||
|
|
||||||
|
def jaccard_shingles(a: str, b: str, k: int = 2) -> float:
|
||||||
|
sa, sb = _shingles(a, k), _shingles(b, k)
|
||||||
|
if not sa or not sb:
|
||||||
|
return 0.0
|
||||||
|
return len(sa & sb) / len(sa | sb)
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_levenshtein(a: str, b: str) -> float:
|
||||||
|
"""1.0 == identical, 0.0 == fully different (edit distance / max len)."""
|
||||||
|
a, b = normalize_text(a), normalize_text(b)
|
||||||
|
if not a and not b:
|
||||||
|
return 1.0
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
# classic DP edit distance (rule_statements are short — a few hundred chars)
|
||||||
|
prev = list(range(len(b) + 1))
|
||||||
|
for i, ca in enumerate(a, 1):
|
||||||
|
cur = [i]
|
||||||
|
for j, cb in enumerate(b, 1):
|
||||||
|
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
|
||||||
|
prev = cur
|
||||||
|
return 1.0 - prev[-1] / max(len(a), len(b))
|
||||||
|
|
||||||
|
|
||||||
|
_LEX_JACCARD_MIN = 0.55
|
||||||
|
_LEX_LEVENSHTEIN_MIN = 0.70
|
||||||
|
|
||||||
|
|
||||||
|
def lexical_near_duplicate(
|
||||||
|
a: str, b: str, jaccard_min: float = _LEX_JACCARD_MIN,
|
||||||
|
levenshtein_min: float = _LEX_LEVENSHTEIN_MIN,
|
||||||
|
) -> bool:
|
||||||
|
"""High lexical overlap → likely the same rule reworded (for the cosine tail)."""
|
||||||
|
return (jaccard_shingles(a, b) >= jaccard_min
|
||||||
|
or normalized_levenshtein(a, b) >= levenshtein_min)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Aggregate ──
|
||||||
|
|
||||||
|
FLAG_NON_DECISION = "non_decision"
|
||||||
|
FLAG_TRUNCATED_QUOTE = "truncated_quote"
|
||||||
|
FLAG_THIN_RESTATEMENT = "thin_restatement"
|
||||||
|
FLAG_QUOTE_UNVERIFIED = "quote_unverified"
|
||||||
|
FLAG_NLI_UNSUPPORTED = "nli_unsupported" # rule not entailed by its quote (#81.3)
|
||||||
|
FLAG_APPLICATION = "application" # fact-dependent, not a holding (#81.4)
|
||||||
|
FLAG_NEAR_DUPLICATE = "near_duplicate" # cosine-tail lexical dup (#82.3)
|
||||||
|
|
||||||
|
|
||||||
|
# ── NLI entailment check (rule_statement ⊨ supporting_quote) — #81.3 ──
|
||||||
|
#
|
||||||
|
# Pure prompt-builder + verdict-parser; the LLM call itself runs through
|
||||||
|
# claude_session in halacha_extractor (local CLI, zero cost). A rule that the
|
||||||
|
# quote does not actually support (neutral) or contradicts is the model
|
||||||
|
# over-reaching beyond its source — flag it (blocks auto-approve). EVERYTHING
|
||||||
|
# here fails OPEN: any parse ambiguity resolves to "entailed" so a flaky judge
|
||||||
|
# never blocks a genuine halacha.
|
||||||
|
|
||||||
|
NLI_SYSTEM = (
|
||||||
|
"אתה בודק היסק (entailment) משפטי. לכל זוג {כלל, ציטוט} החלט האם **הכלל נובע מהציטוט** — "
|
||||||
|
"כלומר הציטוט תומך בכלל ואינו מרחיב מעבר למה שנכתב בו. שלוש תוויות בלבד:\n"
|
||||||
|
"- entailed = הכלל נתמך במלואו בציטוט.\n"
|
||||||
|
"- neutral = הציטוט אינו תומך בכלל (הכלל מרחיב/מוסיף מעבר לציטוט).\n"
|
||||||
|
"- contradiction = הכלל סותר את הציטוט.\n"
|
||||||
|
'החזר JSON array בלבד באורך מספר הזוגות, לדוגמה: ["entailed","neutral",...]. '
|
||||||
|
"ללא markdown, ללא הסבר."
|
||||||
|
)
|
||||||
|
|
||||||
|
_NLI_LABELS = {"entailed", "neutral", "contradiction"}
|
||||||
|
|
||||||
|
|
||||||
|
def build_nli_prompt(items: list[dict]) -> str:
|
||||||
|
"""Build the user message: a numbered list of {rule, quote} pairs."""
|
||||||
|
blocks = []
|
||||||
|
for i, h in enumerate(items, 1):
|
||||||
|
rule = (h.get("rule_statement") or "").strip()
|
||||||
|
quote = (h.get("supporting_quote") or "").strip()
|
||||||
|
blocks.append(f"### זוג {i}\nכלל: {rule}\nציטוט: {quote}")
|
||||||
|
return "\n\n".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nli_verdicts(raw, n: int) -> list[str]:
|
||||||
|
"""Coerce the judge's output into exactly ``n`` labels — fail-open.
|
||||||
|
|
||||||
|
Any shape mismatch / unknown label resolves to 'entailed' so a flaky or
|
||||||
|
unavailable judge never blocks a halacha.
|
||||||
|
"""
|
||||||
|
if not isinstance(raw, list) or len(raw) != n:
|
||||||
|
return ["entailed"] * n
|
||||||
|
out: list[str] = []
|
||||||
|
for item in raw:
|
||||||
|
v = item.get("verdict") if isinstance(item, dict) else item
|
||||||
|
v = str(v or "").strip().lower()
|
||||||
|
out.append(v if v in _NLI_LABELS else "entailed")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── Over-extraction consolidation (fold facets of one legal question) — #81.5 ──
|
||||||
|
#
|
||||||
|
# #82 dedup-on-insert removes near-EXACT dups (cosine ≥ 0.93). #81.5 handles the
|
||||||
|
# remaining over-extraction: facets of the SAME legal question, phrased
|
||||||
|
# differently, that sit BELOW the dedup threshold (the שפר 14-vs-4 / 403-17→89
|
||||||
|
# granularity gap). A per-precedent claude_session pass groups such facets; the
|
||||||
|
# extractor keeps one canonical per group and marks the rest rejected (reversible,
|
||||||
|
# out of the active corpus + review queue). FOLD-ONLY — never merges distinct
|
||||||
|
# legal questions, never invents. Fails OPEN (parse error → no folds).
|
||||||
|
|
||||||
|
CONSOLIDATE_SYSTEM = (
|
||||||
|
"אתה מאחד פנים-כפולים של הלכות שחולצו מאותו פסק דין. בהינתן רשימה ממוספרת של הלכות, "
|
||||||
|
"זהה קבוצות של הלכות שהן **אותה שאלה משפטית** בניסוחים או פנים שונים. "
|
||||||
|
"כללים: (1) אַחֵד רק הלכות שעונות על אותה שאלה משפטית בדיוק; (2) **אל תאַחֵד** הלכות "
|
||||||
|
"שעונות על שאלות משפטיות שונות (גם אם קרובות בנושא); (3) הלכה ייחודית — אל תכלול בשום קבוצה. "
|
||||||
|
'החזר JSON array של קבוצות, כל קבוצה = array של מספרי-האינדקס שיש לאַחֵד (לפחות 2 חברים). '
|
||||||
|
"לדוגמה: [[2,5,9],[14,18]]. אם אין מה לאַחֵד החזר []. ללא markdown, ללא הסבר."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_consolidation_prompt(items: list[dict]) -> str:
|
||||||
|
"""Numbered list of a precedent's halachot (index + rule + reasoning)."""
|
||||||
|
blocks = []
|
||||||
|
for h in items:
|
||||||
|
idx = h.get("halacha_index")
|
||||||
|
rule = (h.get("rule_statement") or "").strip()
|
||||||
|
reason = (h.get("reasoning_summary") or "").strip()
|
||||||
|
line = f"[{idx}] {rule}"
|
||||||
|
if reason:
|
||||||
|
line += f" (היגיון: {reason})"
|
||||||
|
blocks.append(line)
|
||||||
|
return "\n".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_fold_groups(raw) -> list[list[int]]:
|
||||||
|
"""Coerce judge output into a list of fold-groups (≥2 int indices each).
|
||||||
|
|
||||||
|
Fails SAFE: any malformed shape → [] (no folding). Non-int / <2-member
|
||||||
|
groups are dropped.
|
||||||
|
"""
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
groups: list[list[int]] = []
|
||||||
|
for g in raw:
|
||||||
|
if not isinstance(g, list):
|
||||||
|
continue
|
||||||
|
members: list[int] = []
|
||||||
|
for x in g:
|
||||||
|
try:
|
||||||
|
members.append(int(x))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
# dedup within group, preserve order
|
||||||
|
seen: set[int] = set()
|
||||||
|
members = [m for m in members if not (m in seen or seen.add(m))]
|
||||||
|
if len(members) >= 2:
|
||||||
|
groups.append(members)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def compute_quality_flags(
|
||||||
|
rule_statement: str,
|
||||||
|
supporting_quote: str,
|
||||||
|
reasoning_summary: str = "",
|
||||||
|
quote_verified: bool = True,
|
||||||
|
rule_type: str = "binding",
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return the list of quality flags for one halacha (empty == clean).
|
||||||
|
|
||||||
|
Any non-empty result blocks auto-approval (routes to pending_review).
|
||||||
|
"""
|
||||||
|
flags: list[str] = []
|
||||||
|
if detect_non_decision(rule_statement, reasoning_summary, supporting_quote):
|
||||||
|
flags.append(FLAG_NON_DECISION)
|
||||||
|
if is_quote_truncated(supporting_quote):
|
||||||
|
flags.append(FLAG_TRUNCATED_QUOTE)
|
||||||
|
if is_thin_restatement(rule_statement, supporting_quote):
|
||||||
|
flags.append(FLAG_THIN_RESTATEMENT)
|
||||||
|
if not quote_verified:
|
||||||
|
flags.append(FLAG_QUOTE_UNVERIFIED)
|
||||||
|
# #81.4 — an application (fact-dependent) item is an illustration, not a
|
||||||
|
# generalizable holding: never auto-approve it. Trust the model's
|
||||||
|
# rule_type='application' and add a high-precision deixis catch.
|
||||||
|
if rule_type == "application" or is_fact_dependent(rule_statement):
|
||||||
|
flags.append(FLAG_APPLICATION)
|
||||||
|
return flags
|
||||||
295
mcp-server/src/legal_mcp/services/ingest.py
Normal file
295
mcp-server/src/legal_mcp/services/ingest.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
"""Canonical ingest pipeline (FU-1).
|
||||||
|
|
||||||
|
One pipeline for all sibling-entity intake types (external precedent,
|
||||||
|
internal committee decision). Per-type variation rides on an ``IntakeSpec``
|
||||||
|
config object — never a parallel function. See
|
||||||
|
docs/spec/01-ingest.md and docs/superpowers/specs/2026-05-30-fu1-unified-ingest-design.md.
|
||||||
|
|
||||||
|
claude_session rule preserved: this module only QUEUES extraction
|
||||||
|
(``request_*_extraction`` = pure DB writes). It never imports
|
||||||
|
halacha_extractor / precedent_metadata_extractor, so it is safe to call
|
||||||
|
from the FastAPI container where the ``claude`` CLI is unavailable.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from legal_mcp import config
|
||||||
|
from legal_mcp.services import chunker, db, embeddings, extractor
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ProgressCb = Callable[[str, int, str], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
async def _noop_progress(_status: str, _percent: int, _msg: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IntakeSpec:
|
||||||
|
"""Describes everything that varies between intake types."""
|
||||||
|
source_kind: str
|
||||||
|
id_field: str
|
||||||
|
staging_root: Path
|
||||||
|
staging_subdir: Callable[[dict], str]
|
||||||
|
validate: Callable[[dict], None]
|
||||||
|
enum_fields: dict[str, frozenset[str]]
|
||||||
|
derive: Callable[[dict], dict]
|
||||||
|
display_name_fallback: str
|
||||||
|
create_record: Callable[..., Awaitable[dict]]
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_date(value) -> date | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value[:10])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(name: str) -> str:
|
||||||
|
base = Path(name).name
|
||||||
|
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"upload-{uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_file(src_path: Path, root: Path, subdir: str) -> Path:
|
||||||
|
dest_dir = root / (subdir or "other")
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = dest_dir / f"{uuid4().hex[:8]}_{_safe_filename(src_path.name)}"
|
||||||
|
shutil.copy2(src_path, dest)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_enums(spec: IntakeSpec, inputs: dict) -> None:
|
||||||
|
for field_name, allowed in spec.enum_fields.items():
|
||||||
|
value = inputs.get(field_name, "") or ""
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(f"invalid {field_name}: {value!r}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _embed_pages(case_law_id: UUID, pdf_path: Path, page_count: int) -> dict:
|
||||||
|
"""Render PDF pages → embed via voyage-multimodal → store. Non-fatal caller."""
|
||||||
|
thumb_dir = spec_thumb_dir(case_law_id)
|
||||||
|
rendered = await asyncio.to_thread(
|
||||||
|
extractor.render_pages_for_multimodal,
|
||||||
|
pdf_path, config.MULTIMODAL_DPI, config.MULTIMODAL_THUMB_DPI, thumb_dir,
|
||||||
|
)
|
||||||
|
images = [pil for pil, _ in rendered]
|
||||||
|
thumbs = [t for _, t in rendered]
|
||||||
|
img_embs = await embeddings.embed_images(images)
|
||||||
|
|
||||||
|
page_records = []
|
||||||
|
for i, (emb, thumb) in enumerate(zip(img_embs, thumbs)):
|
||||||
|
rel_thumb = None
|
||||||
|
if thumb is not None:
|
||||||
|
try:
|
||||||
|
rel_thumb = str(thumb.relative_to(config.DATA_DIR))
|
||||||
|
except ValueError:
|
||||||
|
rel_thumb = str(thumb)
|
||||||
|
page_records.append({
|
||||||
|
"page_number": i + 1, "embedding": emb, "image_thumbnail_path": rel_thumb,
|
||||||
|
})
|
||||||
|
stored = await db.store_precedent_image_embeddings(
|
||||||
|
case_law_id, page_records, model_name=config.MULTIMODAL_MODEL,
|
||||||
|
)
|
||||||
|
logger.info("Multimodal: stored %d page-image embeddings for case_law %s", stored, case_law_id)
|
||||||
|
return {"pages_embedded": stored}
|
||||||
|
|
||||||
|
|
||||||
|
def spec_thumb_dir(case_law_id: UUID) -> Path:
|
||||||
|
"""Thumbnails live under the precedent-library tree regardless of intake type."""
|
||||||
|
return Path(config.DATA_DIR) / "precedent-library" / "thumbnails" / str(case_law_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_document(
|
||||||
|
spec: IntakeSpec,
|
||||||
|
*,
|
||||||
|
inputs: dict,
|
||||||
|
file_path: str | Path | None = None,
|
||||||
|
text: str | None = None,
|
||||||
|
document_id: UUID | None = None,
|
||||||
|
progress: ProgressCb | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Run the canonical 12-step pipeline for one intake item.
|
||||||
|
|
||||||
|
``inputs`` carries the type-specific record fields (citation/case_number,
|
||||||
|
case_name, court, practice_area, etc.). ``spec`` decides how they are
|
||||||
|
validated, staged, derived, and which DB-create runs. Returns a dict with
|
||||||
|
at least: status, case_law_id, chunks.
|
||||||
|
"""
|
||||||
|
progress = progress or _noop_progress
|
||||||
|
|
||||||
|
# Step 1: input validation (type-specific) + enums (uniform mechanism).
|
||||||
|
if not file_path and text is None:
|
||||||
|
raise ValueError("either file_path or text is required")
|
||||||
|
spec.validate(inputs)
|
||||||
|
_validate_enums(spec, inputs)
|
||||||
|
|
||||||
|
# Step 2: field derivation (identity for external).
|
||||||
|
inputs = {**inputs, **spec.derive(inputs)}
|
||||||
|
|
||||||
|
# Steps 3-5: stage (if file) + extract + strip.
|
||||||
|
page_count = 0
|
||||||
|
page_offsets = None
|
||||||
|
staged: Path | None = None
|
||||||
|
if file_path:
|
||||||
|
src = Path(file_path)
|
||||||
|
if not src.is_file():
|
||||||
|
raise FileNotFoundError(f"file not found: {src}")
|
||||||
|
await progress("staging", 5, "מעתיק את הקובץ לאחסון")
|
||||||
|
staged = _stage_file(src, spec.staging_root, spec.staging_subdir(inputs))
|
||||||
|
await progress("extracting", 15, "מחלץ טקסט מהקובץ")
|
||||||
|
try:
|
||||||
|
raw_text, page_count, page_offsets = await extractor.extract_text(str(staged))
|
||||||
|
except Exception as e:
|
||||||
|
await progress("failed", 100, f"כשל בחילוץ טקסט: {e}")
|
||||||
|
raise
|
||||||
|
raw_text = (raw_text or "")
|
||||||
|
else:
|
||||||
|
raw_text = (text or "")
|
||||||
|
# Capture the Nevo מיני-רציו (editorial holdings summary) BEFORE stripping
|
||||||
|
# it out — it is a free professional gold-set for benchmarking halacha
|
||||||
|
# extraction (#86.3). Stored on the case_law row below once we have its id.
|
||||||
|
nevo_ratio = extractor.extract_nevo_ratio(raw_text)
|
||||||
|
raw_text = extractor.strip_nevo_preamble(raw_text).strip()
|
||||||
|
if not raw_text:
|
||||||
|
await progress("failed", 100, "לא נמצא טקסט בקובץ")
|
||||||
|
raise ValueError("no extractable text in file")
|
||||||
|
|
||||||
|
# Step 6: DB create (type-specific, routed — get case_law_id).
|
||||||
|
await progress("storing_metadata", 25, "שומר את הרשומה במסד הנתונים")
|
||||||
|
display_name = (inputs.get("case_name") or "").strip() or (
|
||||||
|
inputs.get(spec.display_name_fallback) or ""
|
||||||
|
).strip()
|
||||||
|
record = await spec.create_record(
|
||||||
|
full_text=raw_text,
|
||||||
|
case_name=display_name,
|
||||||
|
decision_date=_coerce_date(inputs.get("decision_date")),
|
||||||
|
document_id=document_id,
|
||||||
|
**{k: v for k, v in inputs.items()
|
||||||
|
if k not in {"case_name", "decision_date", "file_path", "text"}},
|
||||||
|
)
|
||||||
|
case_law_id = UUID(str(record["id"]))
|
||||||
|
|
||||||
|
# Persist the captured mini-ratio (best-effort; never block ingest on it).
|
||||||
|
if nevo_ratio:
|
||||||
|
try:
|
||||||
|
await db.update_case_law(case_law_id, nevo_ratio=nevo_ratio)
|
||||||
|
except Exception as e: # noqa: BLE001 — additive metadata, non-fatal
|
||||||
|
logger.warning("could not store nevo_ratio for %s: %s", case_law_id, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stored_chunks = await _chunk_embed_store(case_law_id, raw_text, page_offsets, page_count, progress)
|
||||||
|
await db.mark_indexed(case_law_id)
|
||||||
|
|
||||||
|
# Step 9: multimodal — uniform: flag + PDF + page_count, NOT intake type.
|
||||||
|
if (config.MULTIMODAL_ENABLED and page_count > 0
|
||||||
|
and staged is not None and staged.suffix.lower() == ".pdf"):
|
||||||
|
try:
|
||||||
|
await progress("embedding_images", 70, f"מטמיע {page_count} עמודי תמונה (multimodal)")
|
||||||
|
await _embed_pages(case_law_id, staged, page_count)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Multimodal embedding failed (non-fatal): %s", e)
|
||||||
|
|
||||||
|
# Steps 10-12: queue BOTH extractions (GAP-02 fix) + statuses.
|
||||||
|
await db.set_case_law_extraction_status(case_law_id, "completed")
|
||||||
|
await db.set_case_law_halacha_status(case_law_id, "pending")
|
||||||
|
await db.request_metadata_extraction(case_law_id)
|
||||||
|
await db.request_halacha_extraction(case_law_id)
|
||||||
|
await db.recompute_searchable(case_law_id)
|
||||||
|
|
||||||
|
await progress("completed", 100,
|
||||||
|
f"נקלט: {stored_chunks} chunks. חילוץ הלכות ומטא-דאטה ממתינים בתור.")
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"case_law_id": str(case_law_id),
|
||||||
|
"chunks": stored_chunks,
|
||||||
|
"halachot": 0,
|
||||||
|
"halachot_pending": True,
|
||||||
|
"metadata_filled": [],
|
||||||
|
"pages": page_count,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("ingest_document failed (%s): %s", spec.source_kind, e)
|
||||||
|
await db.set_case_law_extraction_status(case_law_id, "failed")
|
||||||
|
await progress("failed", 100, f"כשל בעיבוד: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _chunk_embed_store(case_law_id, text, page_offsets, page_count, progress) -> int:
|
||||||
|
"""Steps 7-8: chunk (hierarchical/flat by flag) → embed children → store."""
|
||||||
|
if config.PARENT_DOC_RETRIEVAL_ENABLED:
|
||||||
|
await progress("chunking", 40, f"מחלק את הטקסט ל-chunks היררכיים ({page_count} עמ')")
|
||||||
|
h_chunks = chunker.chunk_document_hierarchical(text, page_offsets=page_offsets)
|
||||||
|
if not h_chunks:
|
||||||
|
return 0
|
||||||
|
children = [c for c in h_chunks if c.role == "child"]
|
||||||
|
parents = [c for c in h_chunks if c.role == "parent"]
|
||||||
|
await progress("embedding", 55, f"מייצר embeddings ל-{len(children)} children ({len(parents)} parents)")
|
||||||
|
child_vectors = await embeddings.embed_texts([c.content for c in children], input_type="document")
|
||||||
|
chunk_dicts: list[dict] = []
|
||||||
|
for p in parents:
|
||||||
|
chunk_dicts.append({
|
||||||
|
"role": "parent", "local_id": p.local_id, "parent_local_id": None,
|
||||||
|
"chunk_index": p.chunk_index, "content": p.content,
|
||||||
|
"section_type": p.section_type, "page_number": p.page_number, "embedding": None,
|
||||||
|
})
|
||||||
|
for c, v in zip(children, child_vectors):
|
||||||
|
chunk_dicts.append({
|
||||||
|
"role": "child", "local_id": c.local_id, "parent_local_id": c.parent_local_id,
|
||||||
|
"chunk_index": c.chunk_index, "content": c.content,
|
||||||
|
"section_type": c.section_type, "page_number": c.page_number, "embedding": v,
|
||||||
|
})
|
||||||
|
counts = await db.store_precedent_chunks_hierarchical(case_law_id, chunk_dicts)
|
||||||
|
return counts["children"]
|
||||||
|
else:
|
||||||
|
await progress("chunking", 40, f"מחלק את הטקסט ל-chunks ({page_count} עמ')")
|
||||||
|
chunks = chunker.chunk_document(text, page_offsets=page_offsets)
|
||||||
|
if not chunks:
|
||||||
|
return 0
|
||||||
|
await progress("embedding", 55, f"מייצר embeddings ל-{len(chunks)} chunks")
|
||||||
|
chunk_vectors = await embeddings.embed_texts([c.content for c in chunks], input_type="document")
|
||||||
|
chunk_dicts = [
|
||||||
|
{"chunk_index": c.chunk_index, "content": c.content,
|
||||||
|
"section_type": c.section_type, "page_number": c.page_number, "embedding": v}
|
||||||
|
for c, v in zip(chunks, chunk_vectors)
|
||||||
|
]
|
||||||
|
return await db.store_precedent_chunks(case_law_id, chunk_dicts)
|
||||||
|
|
||||||
|
|
||||||
|
async def reindex_case_law(
|
||||||
|
case_law_id: "UUID | str",
|
||||||
|
progress: ProgressCb | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Re-chunk + re-embed an existing case_law row from its STORED full_text (GAP-09).
|
||||||
|
|
||||||
|
No re-extract / no re-OCR (uses the stored text — see feedback_no_reocr_retrofit)
|
||||||
|
and no LLM/CLI (only chunker + voyage embeddings), so it is safe to run anywhere.
|
||||||
|
Idempotent: store_precedent_chunks(_hierarchical) is DELETE-then-INSERT.
|
||||||
|
"""
|
||||||
|
progress = progress or _noop_progress
|
||||||
|
cid = case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
|
||||||
|
row = await db.get_case_law(cid)
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"case_law not found: {cid}")
|
||||||
|
text = (row.get("full_text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("case_law has no stored full_text to re-index")
|
||||||
|
stored = await _chunk_embed_store(cid, text, None, 0, progress)
|
||||||
|
await db.mark_indexed(cid)
|
||||||
|
await progress("completed", 100, f"הוטמע מחדש: {stored} chunks")
|
||||||
|
return {"status": "completed", "case_law_id": str(cid), "chunks": stored, "reindexed": True}
|
||||||
@@ -16,21 +16,19 @@ Judicial decisions (Supreme Court, Administrative Court) stay in external_upload
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import chunker, db, embeddings, extractor
|
from legal_mcp.services import db, embeddings, ingest
|
||||||
from legal_mcp.services.practice_area import derive_proceeding_type
|
from legal_mcp.services.practice_area import derive_proceeding_type
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions"
|
INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions"
|
||||||
|
|
||||||
_VALID_DISTRICTS = {"", "ירושלים", "מרכז", "תל אביב", "צפון", "דרום", "ארצי"}
|
_VALID_PRACTICE_AREAS = frozenset({"", "rishuy_uvniya", "betterment_levy", "compensation_197"})
|
||||||
|
_VALID_DISTRICTS = frozenset({"", "ירושלים", "מרכז", "תל אביב", "צפון", "דרום", "ארצי"})
|
||||||
|
|
||||||
_COURT_TO_DISTRICT = [
|
_COURT_TO_DISTRICT = [
|
||||||
("ירושלים", "ירושלים"),
|
("ירושלים", "ירושלים"),
|
||||||
@@ -45,24 +43,6 @@ _COURT_TO_DISTRICT = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _coerce_date(value) -> date | None:
|
|
||||||
if value is None or value == "":
|
|
||||||
return None
|
|
||||||
if isinstance(value, date):
|
|
||||||
return value
|
|
||||||
if isinstance(value, str):
|
|
||||||
try:
|
|
||||||
return date.fromisoformat(value[:10])
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_filename(name: str) -> str:
|
|
||||||
base = Path(name).name
|
|
||||||
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"internal-{uuid4().hex[:8]}"
|
|
||||||
|
|
||||||
|
|
||||||
def _district_from_court(court: str) -> str:
|
def _district_from_court(court: str) -> str:
|
||||||
for keyword, district in _COURT_TO_DISTRICT:
|
for keyword, district in _COURT_TO_DISTRICT:
|
||||||
if keyword in court:
|
if keyword in court:
|
||||||
@@ -70,6 +50,51 @@ def _district_from_court(court: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _internal_validate(inputs: dict) -> None:
|
||||||
|
if not (inputs.get("case_number") or "").strip():
|
||||||
|
raise ValueError("case_number is required")
|
||||||
|
|
||||||
|
|
||||||
|
def _internal_derive(inputs: dict) -> dict:
|
||||||
|
district = (inputs.get("district") or "").strip() or _district_from_court(inputs.get("court") or "")
|
||||||
|
proc = (inputs.get("proceeding_type") or "").strip() or derive_proceeding_type(
|
||||||
|
appeal_subtype=inputs.get("appeal_subtype") or "", subject=inputs.get("case_name") or "",
|
||||||
|
)
|
||||||
|
return {"district": district, "proceeding_type": proc}
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_internal_record(**kw) -> dict:
|
||||||
|
return await db.create_internal_committee_decision(
|
||||||
|
case_number=kw["case_number"].strip(),
|
||||||
|
case_name=kw["case_name"],
|
||||||
|
full_text=kw["full_text"],
|
||||||
|
court=(kw.get("court") or "").strip(),
|
||||||
|
decision_date=kw.get("decision_date"),
|
||||||
|
chair_name=(kw.get("chair_name") or "").strip(),
|
||||||
|
district=kw.get("district", ""),
|
||||||
|
practice_area=kw.get("practice_area", ""),
|
||||||
|
appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
|
||||||
|
subject_tags=list(kw.get("subject_tags") or []),
|
||||||
|
summary=(kw.get("summary") or "").strip(),
|
||||||
|
is_binding=kw.get("is_binding", True),
|
||||||
|
document_id=kw.get("document_id"),
|
||||||
|
proceeding_type=kw.get("proceeding_type") or "ערר",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_INTERNAL_SPEC = ingest.IntakeSpec(
|
||||||
|
source_kind="internal_committee",
|
||||||
|
id_field="case_number",
|
||||||
|
staging_root=INTERNAL_DECISIONS_DIR,
|
||||||
|
staging_subdir=lambda inputs: (inputs.get("district") or "other"),
|
||||||
|
validate=_internal_validate,
|
||||||
|
enum_fields={"practice_area": _VALID_PRACTICE_AREAS, "district": _VALID_DISTRICTS},
|
||||||
|
derive=_internal_derive,
|
||||||
|
display_name_fallback="case_number",
|
||||||
|
create_record=_create_internal_record,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def ingest_internal_decision(
|
async def ingest_internal_decision(
|
||||||
*,
|
*,
|
||||||
case_number: str,
|
case_number: str,
|
||||||
@@ -86,141 +111,25 @@ async def ingest_internal_decision(
|
|||||||
file_path: str | Path | None = None,
|
file_path: str | Path | None = None,
|
||||||
text: str | None = None,
|
text: str | None = None,
|
||||||
document_id: UUID | None = None,
|
document_id: UUID | None = None,
|
||||||
queue_halachot: bool = True,
|
|
||||||
proceeding_type: str = "",
|
proceeding_type: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Ingest an appeals-committee decision into the internal corpus.
|
"""Ingest one appeals-committee decision. Thin wrapper over the canonical pipeline."""
|
||||||
|
inputs = {
|
||||||
Either file_path or text must be provided.
|
"case_number": case_number, "case_name": case_name, "court": court,
|
||||||
If district is empty, it is inferred from court.
|
"decision_date": decision_date, "chair_name": chair_name, "district": district,
|
||||||
If proceeding_type is empty, it is derived from appeal_subtype/case_name.
|
"practice_area": practice_area, "appeal_subtype": appeal_subtype,
|
||||||
Returns: {"status": "completed", "case_law_id": "...", "chunks": N}
|
"subject_tags": subject_tags, "summary": summary, "is_binding": is_binding,
|
||||||
"""
|
"proceeding_type": proceeding_type,
|
||||||
if not file_path and not text:
|
}
|
||||||
raise ValueError("either file_path or text is required")
|
out = await ingest.ingest_document(
|
||||||
if not case_number.strip():
|
_INTERNAL_SPEC, inputs=inputs, file_path=file_path, text=text,
|
||||||
raise ValueError("case_number is required")
|
|
||||||
|
|
||||||
resolved_district = district.strip() or _district_from_court(court)
|
|
||||||
resolved_proc = proceeding_type.strip() or derive_proceeding_type(
|
|
||||||
appeal_subtype=appeal_subtype, subject=case_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
if file_path:
|
|
||||||
src = Path(file_path)
|
|
||||||
if not src.is_file():
|
|
||||||
raise FileNotFoundError(f"file not found: {src}")
|
|
||||||
dest_dir = INTERNAL_DECISIONS_DIR / (resolved_district or "other")
|
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
staged = dest_dir / f"{uuid4().hex[:8]}_{_safe_filename(src.name)}"
|
|
||||||
shutil.copy2(src, staged)
|
|
||||||
raw_text, page_count, page_offsets = await extractor.extract_text(str(staged))
|
|
||||||
raw_text = extractor.strip_nevo_preamble(raw_text or "").strip()
|
|
||||||
if not raw_text:
|
|
||||||
raise ValueError("no extractable text in file")
|
|
||||||
else:
|
|
||||||
raw_text = (text or "").strip()
|
|
||||||
if not raw_text:
|
|
||||||
raise ValueError("text is empty")
|
|
||||||
page_count = 0
|
|
||||||
page_offsets = None
|
|
||||||
|
|
||||||
record = await db.create_internal_committee_decision(
|
|
||||||
case_number=case_number.strip(),
|
|
||||||
case_name=(case_name.strip() or case_number.strip()),
|
|
||||||
full_text=raw_text,
|
|
||||||
court=court.strip(),
|
|
||||||
decision_date=_coerce_date(decision_date),
|
|
||||||
chair_name=chair_name.strip(),
|
|
||||||
district=resolved_district,
|
|
||||||
practice_area=practice_area,
|
|
||||||
appeal_subtype=appeal_subtype.strip(),
|
|
||||||
subject_tags=list(subject_tags or []),
|
|
||||||
summary=summary.strip(),
|
|
||||||
is_binding=is_binding,
|
|
||||||
document_id=document_id,
|
document_id=document_id,
|
||||||
proceeding_type=resolved_proc,
|
|
||||||
)
|
)
|
||||||
case_law_id = UUID(str(record["id"]))
|
return {"status": out["status"], "case_law_id": out["case_law_id"],
|
||||||
|
"chunks": out["chunks"], "halachot_pending": True}
|
||||||
try:
|
|
||||||
# Parent-doc retrieval (TaskMaster #48) — same gated branch as
|
|
||||||
# ingest_precedent. Internal committee decisions are typically
|
|
||||||
# longer than external court rulings (full transcript + ruling),
|
|
||||||
# so the parent-doc benefit is even larger here.
|
|
||||||
if config.PARENT_DOC_RETRIEVAL_ENABLED:
|
|
||||||
h_chunks = chunker.chunk_document_hierarchical(
|
|
||||||
raw_text, page_offsets=page_offsets,
|
|
||||||
)
|
|
||||||
if not h_chunks:
|
|
||||||
await db.set_case_law_extraction_status(case_law_id, "completed")
|
|
||||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
|
||||||
return {"status": "completed", "case_law_id": str(case_law_id), "chunks": 0}
|
|
||||||
children = [c for c in h_chunks if c.role == "child"]
|
|
||||||
parents = [c for c in h_chunks if c.role == "parent"]
|
|
||||||
child_vectors = await embeddings.embed_texts(
|
|
||||||
[c.content for c in children], input_type="document",
|
|
||||||
)
|
|
||||||
chunk_dicts: list[dict] = []
|
|
||||||
for p in parents:
|
|
||||||
chunk_dicts.append({
|
|
||||||
"role": "parent", "local_id": p.local_id, "parent_local_id": None,
|
|
||||||
"chunk_index": p.chunk_index, "content": p.content,
|
|
||||||
"section_type": p.section_type, "page_number": p.page_number,
|
|
||||||
"embedding": None,
|
|
||||||
})
|
|
||||||
for c, v in zip(children, child_vectors):
|
|
||||||
chunk_dicts.append({
|
|
||||||
"role": "child", "local_id": c.local_id,
|
|
||||||
"parent_local_id": c.parent_local_id,
|
|
||||||
"chunk_index": c.chunk_index, "content": c.content,
|
|
||||||
"section_type": c.section_type, "page_number": c.page_number,
|
|
||||||
"embedding": v,
|
|
||||||
})
|
|
||||||
counts = await db.store_precedent_chunks_hierarchical(
|
|
||||||
case_law_id, chunk_dicts,
|
|
||||||
)
|
|
||||||
stored = counts["children"]
|
|
||||||
else:
|
|
||||||
chunks = chunker.chunk_document(raw_text, page_offsets=page_offsets)
|
|
||||||
if not chunks:
|
|
||||||
await db.set_case_law_extraction_status(case_law_id, "completed")
|
|
||||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
|
||||||
return {"status": "completed", "case_law_id": str(case_law_id), "chunks": 0}
|
|
||||||
|
|
||||||
chunk_texts = [c.content for c in chunks]
|
|
||||||
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
|
|
||||||
chunk_dicts = [
|
|
||||||
{
|
|
||||||
"chunk_index": c.chunk_index,
|
|
||||||
"content": c.content,
|
|
||||||
"section_type": c.section_type,
|
|
||||||
"page_number": c.page_number,
|
|
||||||
"embedding": v,
|
|
||||||
}
|
|
||||||
for c, v in zip(chunks, chunk_vectors)
|
|
||||||
]
|
|
||||||
stored = await db.store_precedent_chunks(case_law_id, chunk_dicts)
|
|
||||||
|
|
||||||
await db.set_case_law_extraction_status(case_law_id, "completed")
|
|
||||||
await db.set_case_law_halacha_status(case_law_id, "pending")
|
|
||||||
if queue_halachot:
|
|
||||||
await db.request_halacha_extraction(case_law_id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "completed",
|
|
||||||
"case_law_id": str(case_law_id),
|
|
||||||
"chunks": stored,
|
|
||||||
"halachot_pending": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
logger.exception("ingest_internal_decision failed for %s", case_number)
|
|
||||||
await db.set_case_law_extraction_status(case_law_id, "failed")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def migrate_from_style_corpus(dry_run: bool = False, queue_halachot: bool = True) -> dict:
|
async def migrate_from_style_corpus(dry_run: bool = False) -> dict:
|
||||||
"""Re-index all style_corpus entries as searchable internal committee decisions.
|
"""Re-index all style_corpus entries as searchable internal committee decisions.
|
||||||
|
|
||||||
Does NOT delete style_corpus rows — they remain for style analysis.
|
Does NOT delete style_corpus rows — they remain for style analysis.
|
||||||
@@ -278,7 +187,6 @@ async def migrate_from_style_corpus(dry_run: bool = False, queue_halachot: bool
|
|||||||
appeal_subtype=subtype,
|
appeal_subtype=subtype,
|
||||||
subject_tags=subject_tags,
|
subject_tags=subject_tags,
|
||||||
text=row["full_text"],
|
text=row["full_text"],
|
||||||
queue_halachot=queue_halachot,
|
|
||||||
)
|
)
|
||||||
results["ingested"] += 1
|
results["ingested"] += 1
|
||||||
logger.info("Migrated style_corpus entry: %s", case_number)
|
logger.info("Migrated style_corpus entry: %s", case_number)
|
||||||
|
|||||||
@@ -51,26 +51,25 @@ def compute_diff_stats(draft_text: str, final_text: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
LESSONS_PROMPT = """אתה מנתח שינויים בהחלטות משפטיות. קיבלת טיוטה (שנוצרה ע"י AI) וגרסה סופית (שעברה עריכת דפנה).
|
LESSONS_PROMPT = """אתה מנתח את הפער בין טיוטה (AI) לגרסה סופית שדפנה תמיר חתמה, כדי ללמוד **איך דפנה כותבת ומנתחת** — לא את ההלכה הספציפית.
|
||||||
|
|
||||||
|
## הבחנה קריטית (INV-LRN5 — טוהר-הקול):
|
||||||
|
לכל שינוי קבע `domain`:
|
||||||
|
- **style_method** — *איך* דפנה כותבת/חושבת: ניסוח, קצב, מבנה, תנועות-הנמקה, ביטויי-מעבר, טון, סדר-טיפול. **זה מה שלומדים** (ניתן להכללה לכל תיק).
|
||||||
|
- **substance** — תוכן ספציפי-לתיק: הלכה, עובדה, תקדים, מספר. **לא לומדים** (לא ניתן לגרור לתיק אחר).
|
||||||
|
|
||||||
## משימה:
|
## משימה:
|
||||||
1. זהה את השינויים המהותיים (לא הקלדה/פורמט)
|
1. זהה שינויים מהותיים (לא הקלדה/פורמט/מספור-אוטומטי).
|
||||||
2. סווג כל שינוי:
|
2. לכל שינוי: `type` (expression_change / structure_change / content_addition / content_removal / tone_change / reasoning_move / error_fix) + `domain` (style_method / substance).
|
||||||
- expression_change — ביטוי שהוחלף (הצע כלקח לעתיד)
|
3. הסק לקח **מופשט** (על השיטה/הקול, לא על התוכן) — רק עבור style_method.
|
||||||
- structure_change — שינוי מבני (סדר, חלוקה)
|
|
||||||
- content_addition — תוכן שנוסף (מה חסר?)
|
|
||||||
- content_removal — תוכן שהוסר (מה מיותר?)
|
|
||||||
- tone_change — שינוי טון (רשמי יותר/פחות)
|
|
||||||
- error_fix — תיקון שגיאה עובדתית/משפטית
|
|
||||||
3. הסק לקחים שניתן להפעיל בהחלטות עתידיות
|
|
||||||
|
|
||||||
## פלט JSON:
|
## פלט JSON:
|
||||||
{
|
{
|
||||||
"changes": [
|
"changes": [
|
||||||
{"type": "...", "description": "תיאור השינוי", "draft_text": "...", "final_text": "...", "lesson": "לקח לעתיד"}
|
{"type": "...", "domain": "style_method|substance", "block": "block-yod", "description": "...", "draft_text": "...", "final_text": "...", "lesson": "לקח מופשט (style_method בלבד)"}
|
||||||
],
|
],
|
||||||
"new_expressions": ["ביטוי חדש שדפנה הוסיפה"],
|
"new_expressions": ["ביטוי-מעבר/נוסחה חדשים (style_method בלבד — לא הלכות)"],
|
||||||
"overall_assessment": "הערכה כללית (1-2 משפטים)"
|
"overall_assessment": "1-2 משפטים"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -114,42 +113,53 @@ async def process_final_version(
|
|||||||
if not decision:
|
if not decision:
|
||||||
raise ValueError(f"No decision for case {case_id}")
|
raise ValueError(f"No decision for case {case_id}")
|
||||||
|
|
||||||
# Get draft text (combine all blocks)
|
# Prefer the immutable snapshot captured at mark-final (T5/INV-LRN4); fall back
|
||||||
|
# to the live blocks (which may have been edited after sign-off).
|
||||||
pool = await db.get_pool()
|
pool = await db.get_pool()
|
||||||
|
pair_id = None
|
||||||
|
draft_text = ""
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
rows = await conn.fetch(
|
pair = await conn.fetchrow(
|
||||||
"""SELECT content FROM decision_blocks
|
"""SELECT id, draft_text FROM draft_final_pairs
|
||||||
WHERE decision_id = $1 AND word_count > 0
|
WHERE case_id = $1 AND status = 'final_received'
|
||||||
ORDER BY block_index""",
|
ORDER BY created_at DESC LIMIT 1""",
|
||||||
UUID(decision["id"]),
|
case_id,
|
||||||
)
|
)
|
||||||
draft_text = "\n\n".join(r["content"] for r in rows if r["content"])
|
if pair:
|
||||||
|
pair_id = pair["id"]
|
||||||
|
draft_text = pair["draft_text"] or ""
|
||||||
|
if not draft_text:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT content FROM decision_blocks
|
||||||
|
WHERE decision_id = $1 AND word_count > 0
|
||||||
|
ORDER BY block_index""",
|
||||||
|
UUID(decision["id"]),
|
||||||
|
)
|
||||||
|
draft_text = "\n\n".join(r["content"] for r in rows if r["content"])
|
||||||
|
|
||||||
if not draft_text:
|
if not draft_text:
|
||||||
raise ValueError("No draft content to compare")
|
raise ValueError("No draft content to compare")
|
||||||
|
|
||||||
# Compute stats
|
# Compute stats (pure) + AI distillation (style/method vs substance)
|
||||||
diff_stats = compute_diff_stats(draft_text, final_text)
|
diff_stats = compute_diff_stats(draft_text, final_text)
|
||||||
|
|
||||||
# Analyze changes with AI
|
|
||||||
analysis = await analyze_changes(draft_text, final_text)
|
analysis = await analyze_changes(draft_text, final_text)
|
||||||
|
|
||||||
# Store new expressions as style patterns
|
# INV-LRN1: do NOT auto-commit learnings into writer-consumed channels.
|
||||||
for expr in analysis.get("new_expressions", []):
|
# The distillation is a PROPOSAL stored on the pair; the chair/curator approves
|
||||||
if expr and len(expr) > 3:
|
# it (→ decision_lessons / appeal_type_rules, surfaced by T15) via the gate.
|
||||||
await db.upsert_style_pattern(
|
# (Previously this auto-upserted every new_expression as a style_pattern —
|
||||||
pattern_type="characteristic_phrase",
|
# that both bypassed the gate and contaminated style with substance. Removed.)
|
||||||
pattern_text=expr,
|
if pair_id is not None:
|
||||||
context="למד מגרסה סופית",
|
await db.update_draft_final_pair(
|
||||||
)
|
UUID(str(pair_id)),
|
||||||
|
final_text=final_text,
|
||||||
|
diff_stats=diff_stats,
|
||||||
|
analysis=analysis,
|
||||||
|
status="analyzed",
|
||||||
|
)
|
||||||
|
|
||||||
# Update decision status
|
# Update decision + case status
|
||||||
await db.update_decision(
|
await db.update_decision(UUID(decision["id"]), status="final")
|
||||||
UUID(decision["id"]),
|
|
||||||
status="final",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update case status
|
|
||||||
case = await db.get_case(case_id)
|
case = await db.get_case(case_id)
|
||||||
if case:
|
if case:
|
||||||
await db.update_case(case_id, status="final")
|
await db.update_case(case_id, status="final")
|
||||||
@@ -157,6 +167,7 @@ async def process_final_version(
|
|||||||
return {
|
return {
|
||||||
"diff_stats": diff_stats,
|
"diff_stats": diff_stats,
|
||||||
"analysis": analysis,
|
"analysis": analysis,
|
||||||
|
"pair_id": str(pair_id) if pair_id else None,
|
||||||
"lessons_count": len(analysis.get("changes", [])),
|
"lessons_count": len(analysis.get("changes", [])),
|
||||||
"new_expressions": len(analysis.get("new_expressions", [])),
|
"new_expressions": len(analysis.get("new_expressions", [])),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,32 @@ Based on analysis of: Hecht 1180-1181 (rejection) and Beit HaKerem 1126/25+1141/
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# ── Valid outcome values ────────────────────────────────────────────
|
# ── Valid outcome values ────────────────────────────────────────────
|
||||||
|
# GAP-51 / INV-TOOL2: canonical = 3 real outcomes. `betterment_levy` is a
|
||||||
|
# practice_area (not an outcome) — its writing-guidance lives in
|
||||||
|
# PRACTICE_AREA_OVERRIDES below and is applied on top of the chosen outcome.
|
||||||
|
|
||||||
VALID_OUTCOMES = ("rejection", "partial_acceptance", "full_acceptance", "betterment_levy")
|
VALID_OUTCOMES = ("rejection", "partial_acceptance", "full_acceptance")
|
||||||
|
|
||||||
|
# Hebrew display labels — SSoT (אנגלית ב-DB, עברית ב-UI). Replaces the inline
|
||||||
|
# maps that lived in block_writer.py and workflow.py.
|
||||||
|
OUTCOME_LABELS_HE = {
|
||||||
|
"rejection": "דחייה",
|
||||||
|
"partial_acceptance": "קבלה חלקית",
|
||||||
|
"full_acceptance": "קבלה מלאה",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backward-compat: legacy set_outcome vocabulary → canonical. Used by callers
|
||||||
|
# that may still pass the old values (rejected/accepted/partial).
|
||||||
|
LEGACY_OUTCOME_MAP = {
|
||||||
|
"rejected": "rejection",
|
||||||
|
"accepted": "full_acceptance",
|
||||||
|
"partial": "partial_acceptance",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_outcome(outcome: str) -> str:
|
||||||
|
"""Normalize any outcome string to the canonical vocabulary (GAP-51)."""
|
||||||
|
return LEGACY_OUTCOME_MAP.get(outcome, outcome)
|
||||||
|
|
||||||
# ── Golden Ratios (section % of total) ─────────────────────────────
|
# ── Golden Ratios (section % of total) ─────────────────────────────
|
||||||
|
|
||||||
@@ -16,9 +40,25 @@ GOLDEN_RATIOS: dict[str, dict[str, tuple[int, int]]] = {
|
|||||||
"rejection": {"background": (15, 25), "claims": (30, 40), "discussion": (37, 50), "summary": (2, 9)},
|
"rejection": {"background": (15, 25), "claims": (30, 40), "discussion": (37, 50), "summary": (2, 9)},
|
||||||
"full_acceptance": {"background": (30, 40), "claims": (20, 30), "discussion": (35, 45), "summary": (3, 5)},
|
"full_acceptance": {"background": (30, 40), "claims": (20, 30), "discussion": (35, 45), "summary": (3, 5)},
|
||||||
"partial_acceptance": {"background": (25, 35), "claims": (25, 30), "discussion": (40, 47), "summary": (2, 3)},
|
"partial_acceptance": {"background": (25, 35), "claims": (25, 30), "discussion": (40, 47), "summary": (2, 3)},
|
||||||
"betterment_levy": {"background": (6, 18), "claims": (13, 25), "discussion": (32, 48), "summary": (3, 4)},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Anti-patterns (what Dafna avoids) — detectable signals for style-distance (T7) ──
|
||||||
|
# Derived from daphna-voice-fingerprint.md §3 (corrected 2026-06-06). NOTE: a leading
|
||||||
|
# "N." per paragraph is NOT an anti-pattern — it is the REQUIRED signal the DOCX
|
||||||
|
# exporter converts to real Word auto-numbering (docx_exporter._ensure_decision_numbering).
|
||||||
|
# The real anti-patterns are mid-paragraph mini-lists, markdown, and bullets.
|
||||||
|
ANTI_PATTERNS: list[dict] = [
|
||||||
|
{"name": "inline_numbered_fragments",
|
||||||
|
"regex": r"\([0-9]\)[^\n]{0,200}\([0-9]\)",
|
||||||
|
"note": "פיצול טיעון לרשימת-מיני (1)...(2) בתוך פסקת-אנליזה"},
|
||||||
|
{"name": "markdown_headers",
|
||||||
|
"regex": r"(?m)^#{1,6}\s",
|
||||||
|
"note": "כותרות markdown — אינן בהחלטה הסופית"},
|
||||||
|
{"name": "bullet_lists",
|
||||||
|
"regex": r"(?m)^\s*[-*•]\s",
|
||||||
|
"note": "רשימות תבליטים באנליזה — דפנה כותבת נרטיב רציף"},
|
||||||
|
]
|
||||||
|
|
||||||
# ── Paragraph length guidance (word counts) ────────────────────────
|
# ── Paragraph length guidance (word counts) ────────────────────────
|
||||||
|
|
||||||
PARAGRAPH_LENGTHS = {
|
PARAGRAPH_LENGTHS = {
|
||||||
@@ -71,16 +111,6 @@ OPENING_STRATEGIES = {
|
|||||||
"ואז 'כל הנקודות לעיל עומדות לפנינו...' → מעבר לניתוח"
|
"ואז 'כל הנקודות לעיל עומדות לפנינו...' → מעבר לניתוח"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"betterment_levy": {
|
|
||||||
"style": "direct_factual",
|
|
||||||
"paragraphs": (1, 3),
|
|
||||||
"description": (
|
|
||||||
"פתיחה ישירה ועובדתית: 'בפנינו ערר על דרישת תשלום היטל השבחה מיום [תאריך] "
|
|
||||||
"בסך של [סכום] ₪' → רקע קצר (נכס, תכנית משביחה, מימוש) → "
|
|
||||||
"תמצית טענות הצדדים (עוררים + משיבה בנפרד). "
|
|
||||||
"אין הקשר תכנוני רחב. הפתיחה = עובדות בלבד."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Summary strategies by outcome ──────────────────────────────────
|
# ── Summary strategies by outcome ──────────────────────────────────
|
||||||
@@ -105,18 +135,6 @@ SUMMARY_STRATEGIES = {
|
|||||||
"כל ההנמקה כבר בדיון — הסיכום = רק מה מתקבל, מה נדחה, ותנאים"
|
"כל ההנמקה כבר בדיון — הסיכום = רק מה מתקבל, מה נדחה, ותנאים"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"betterment_levy": {
|
|
||||||
"heading": "various",
|
|
||||||
"format": "dry_operative",
|
|
||||||
"description": (
|
|
||||||
"סיום יבש ואופרטיבי. כותרת משתנה: 'סוף דבר' / 'לאור כל האמור לעיל' / ללא כותרת. "
|
|
||||||
"תוכן: 'הערר נדחה/מתקבל' + הוצאות ('כל צד ישא בהוצאותיו' / חיוב בסכום). "
|
|
||||||
"אם מתקבל: הוראות אופרטיביות (החזר, שומה מתוקנת, תנאים). "
|
|
||||||
"חתימה: 'ניתנה פה אחד היום, [תאריך עברי], [תאריך לועזי].' "
|
|
||||||
"לעיתים: 'התיק ייסגר.' / 'עומדת זכות ערר כדין.' "
|
|
||||||
"אין פסקה חמה. אין חזרה על נימוקים."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Discussion structure rules ─────────────────────────────────────
|
# ── Discussion structure rules ─────────────────────────────────────
|
||||||
@@ -140,14 +158,6 @@ DISCUSSION_RULES: dict[str, list[str]] = {
|
|||||||
"full_acceptance": [
|
"full_acceptance": [
|
||||||
"מבנה ישיר: נקודות עיקריות → ניתוח → מסקנה.",
|
"מבנה ישיר: נקודות עיקריות → ניתוח → מסקנה.",
|
||||||
],
|
],
|
||||||
"betterment_levy": [
|
|
||||||
"פתיחת דיון: מסקנה מוקדמת ('לאחר שבחנו... מצאנו כי דין הערר להידחות/להתקבל').",
|
|
||||||
"תקן ביקורת: ציון רף ההתערבות בשומה מכרעת (בר\"ם 3644/13 גלר) — אבחנה בין שמאי למשפטי.",
|
|
||||||
"הצגת הלכה פסוקה: ציטוט ארוך מפס\"ד מרכזי → 'ברוח הדברים לעיל נבחן את טענות הצדדים'.",
|
|
||||||
"טיפול שיטתי: כל טענה/סוגיה בנפרד → ניתוח → מסקנת ביניים.",
|
|
||||||
"ביטויים: 'אין בידינו לקבל', 'לא מצאנו מקום להתערב', 'קביעה נכונה שאין מקום להתערב בה'.",
|
|
||||||
"'על מנת לא לצאת בחסר' — לנקודות obiter dicta בסוף הדיון.",
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Citation technique ─────────────────────────────────────────────
|
# ── Citation technique ─────────────────────────────────────────────
|
||||||
@@ -270,8 +280,49 @@ DECISION_TEMPLATES: dict[str, str] = {
|
|||||||
ניתנה היום, {date}
|
ניתנה היום, {date}
|
||||||
דפנה תמיר, יו"ר ועדת הערר
|
דפנה תמיר, יו"ר ועדת הערר
|
||||||
""",
|
""",
|
||||||
|
}
|
||||||
|
|
||||||
"betterment_levy": _HEADER + """## א. רקע עובדתי
|
|
||||||
|
# ── Practice-area writing overrides (GAP-51) ───────────────────────
|
||||||
|
# `betterment_levy` is a practice_area, NOT an outcome. A betterment-levy case
|
||||||
|
# still has a real outcome (rejection / partial / full), but its writing style
|
||||||
|
# is distinct (dry, factual, no warm closing). These overrides are layered on
|
||||||
|
# top of the chosen outcome's guidance by the accessors below.
|
||||||
|
|
||||||
|
PRACTICE_AREA_OVERRIDES: dict[str, dict] = {
|
||||||
|
"betterment_levy": {
|
||||||
|
"golden_ratios": {"background": (6, 18), "claims": (13, 25), "discussion": (32, 48), "summary": (3, 4)},
|
||||||
|
"opening_strategy": {
|
||||||
|
"style": "direct_factual",
|
||||||
|
"paragraphs": (1, 3),
|
||||||
|
"description": (
|
||||||
|
"פתיחה ישירה ועובדתית: 'בפנינו ערר על דרישת תשלום היטל השבחה מיום [תאריך] "
|
||||||
|
"בסך של [סכום] ₪' → רקע קצר (נכס, תכנית משביחה, מימוש) → "
|
||||||
|
"תמצית טענות הצדדים (עוררים + משיבה בנפרד). "
|
||||||
|
"אין הקשר תכנוני רחב. הפתיחה = עובדות בלבד."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"summary_strategy": {
|
||||||
|
"heading": "various",
|
||||||
|
"format": "dry_operative",
|
||||||
|
"description": (
|
||||||
|
"סיום יבש ואופרטיבי. כותרת משתנה: 'סוף דבר' / 'לאור כל האמור לעיל' / ללא כותרת. "
|
||||||
|
"תוכן: 'הערר נדחה/מתקבל' + הוצאות ('כל צד ישא בהוצאותיו' / חיוב בסכום). "
|
||||||
|
"אם מתקבל: הוראות אופרטיביות (החזר, שומה מתוקנת, תנאים). "
|
||||||
|
"חתימה: 'ניתנה פה אחד היום, [תאריך עברי], [תאריך לועזי].' "
|
||||||
|
"לעיתים: 'התיק ייסגר.' / 'עומדת זכות ערר כדין.' "
|
||||||
|
"אין פסקה חמה. אין חזרה על נימוקים."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"discussion_rules": [
|
||||||
|
"פתיחת דיון: מסקנה מוקדמת ('לאחר שבחנו... מצאנו כי דין הערר להידחות/להתקבל').",
|
||||||
|
"תקן ביקורת: ציון רף ההתערבות בשומה מכרעת (בר\"ם 3644/13 גלר) — אבחנה בין שמאי למשפטי.",
|
||||||
|
"הצגת הלכה פסוקה: ציטוט ארוך מפס\"ד מרכזי → 'ברוח הדברים לעיל נבחן את טענות הצדדים'.",
|
||||||
|
"טיפול שיטתי: כל טענה/סוגיה בנפרד → ניתוח → מסקנת ביניים.",
|
||||||
|
"ביטויים: 'אין בידינו לקבל', 'לא מצאנו מקום להתערב', 'קביעה נכונה שאין מקום להתערב בה'.",
|
||||||
|
"'על מנת לא לצאת בחסר' — לנקודות obiter dicta בסוף הדיון.",
|
||||||
|
],
|
||||||
|
"decision_template": _HEADER + """## א. רקע עובדתי
|
||||||
<!-- {ratios_background} -->
|
<!-- {ratios_background} -->
|
||||||
|
|
||||||
[תיאור הרקע העובדתי של הערר]
|
[תיאור הרקע העובדתי של הערר]
|
||||||
@@ -301,18 +352,31 @@ DECISION_TEMPLATES: dict[str, str] = {
|
|||||||
ניתנה היום, {date}
|
ניתנה היום, {date}
|
||||||
דפנה תמיר, יו"ר ועדת הערר
|
דפנה תמיר, יו"ר ועדת הערר
|
||||||
""",
|
""",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Helper function ────────────────────────────────────────────────
|
# ── Helper function ────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_lessons_for_outcome(outcome: str) -> dict:
|
def get_lessons_for_outcome(outcome: str, practice_area: str = "") -> dict:
|
||||||
"""Assemble all relevant lessons for a given expected outcome."""
|
"""Assemble all relevant lessons for an outcome, with practice_area overrides.
|
||||||
|
|
||||||
|
GAP-51: ``betterment_levy`` is a practice_area — when given, its writing
|
||||||
|
overrides (golden ratios, opening/summary strategy, discussion rules) are
|
||||||
|
layered on top of the chosen outcome.
|
||||||
|
"""
|
||||||
|
outcome = canonical_outcome(outcome)
|
||||||
if outcome not in VALID_OUTCOMES:
|
if outcome not in VALID_OUTCOMES:
|
||||||
return {"error": f"outcome must be one of: {', '.join(VALID_OUTCOMES)}"}
|
return {"error": f"outcome must be one of: {', '.join(VALID_OUTCOMES)}"}
|
||||||
|
|
||||||
ratios = GOLDEN_RATIOS[outcome]
|
override = PRACTICE_AREA_OVERRIDES.get(practice_area, {})
|
||||||
rules = DISCUSSION_RULES.get("universal", []) + DISCUSSION_RULES.get(outcome, [])
|
ratios = override.get("golden_ratios") or GOLDEN_RATIOS[outcome]
|
||||||
|
opening = override.get("opening_strategy") or OPENING_STRATEGIES[outcome]
|
||||||
|
summary = override.get("summary_strategy") or SUMMARY_STRATEGIES[outcome]
|
||||||
|
rules = (
|
||||||
|
DISCUSSION_RULES.get("universal", [])
|
||||||
|
+ (override.get("discussion_rules") or DISCUSSION_RULES.get(outcome, []))
|
||||||
|
)
|
||||||
|
|
||||||
# Filter transition phrases: universal + outcome-specific
|
# Filter transition phrases: universal + outcome-specific
|
||||||
phrases = [
|
phrases = [
|
||||||
@@ -322,11 +386,12 @@ def get_lessons_for_outcome(outcome: str) -> dict:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"outcome": outcome,
|
"outcome": outcome,
|
||||||
|
"practice_area": practice_area,
|
||||||
"golden_ratios": {
|
"golden_ratios": {
|
||||||
k: f"{v[0]}-{v[1]}%" for k, v in ratios.items()
|
k: f"{v[0]}-{v[1]}%" for k, v in ratios.items()
|
||||||
},
|
},
|
||||||
"opening_strategy": OPENING_STRATEGIES[outcome],
|
"opening_strategy": opening,
|
||||||
"summary_strategy": SUMMARY_STRATEGIES[outcome],
|
"summary_strategy": summary,
|
||||||
"discussion_rules": rules,
|
"discussion_rules": rules,
|
||||||
"citation_guidance": CITATION_GUIDANCE,
|
"citation_guidance": CITATION_GUIDANCE,
|
||||||
"transition_phrases": [
|
"transition_phrases": [
|
||||||
@@ -339,9 +404,11 @@ def get_lessons_for_outcome(outcome: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def format_ratios_comment(outcome: str, section: str) -> str:
|
def format_ratios_comment(outcome: str, section: str, practice_area: str = "") -> str:
|
||||||
"""Format golden ratio as an HTML comment for templates."""
|
"""Format golden ratio as an HTML comment for templates (practice_area-aware)."""
|
||||||
ratios = GOLDEN_RATIOS.get(outcome, {})
|
outcome = canonical_outcome(outcome)
|
||||||
|
override = PRACTICE_AREA_OVERRIDES.get(practice_area, {})
|
||||||
|
ratios = override.get("golden_ratios") or GOLDEN_RATIOS.get(outcome, {})
|
||||||
if section in ratios:
|
if section in ratios:
|
||||||
lo, hi = ratios[section]
|
lo, hi = ratios[section]
|
||||||
return f"יעד: {lo}-{hi}% מסך ההחלטה"
|
return f"יעד: {lo}-{hi}% מסך ההחלטה"
|
||||||
|
|||||||
@@ -103,6 +103,51 @@ async def get_case_metrics(case_id: UUID) -> dict:
|
|||||||
return metrics
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
async def halacha_backlog(conn) -> dict:
|
||||||
|
"""תור אישור-ההלכות (GAP-14 / INV-QA1 / G10) — נראות ה-backlog האנושי.
|
||||||
|
|
||||||
|
הלכות נכנסות כ-`pending_review` ובלתי-נראות לחיפוש עד אישור היו"ר; בלי ספירה
|
||||||
|
גלויה, אישור-חסר נשאר סמוי (10/19 התגלה במקרה). מקבל connection פתוח כדי
|
||||||
|
שאפשר יהיה לשלב בסנאפ-שוט קיים (get_dashboard, /api/system/diagnostics).
|
||||||
|
"""
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT review_status, COUNT(*) AS n FROM halachot GROUP BY review_status"
|
||||||
|
)
|
||||||
|
counts = {r["review_status"]: r["n"] for r in rows}
|
||||||
|
oldest = await conn.fetchval(
|
||||||
|
"SELECT MIN(created_at) FROM halachot WHERE review_status = 'pending_review'"
|
||||||
|
)
|
||||||
|
# #84.7 — split the pending bucket: how many are genuine candidates (clean)
|
||||||
|
# vs flagged 'needs extraction fix', and the breakdown by flag, so the chair
|
||||||
|
# sees how much of the backlog is real review vs extraction noise.
|
||||||
|
pending_clean = await conn.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM halachot WHERE review_status = 'pending_review' "
|
||||||
|
"AND COALESCE(array_length(quality_flags, 1), 0) = 0"
|
||||||
|
)
|
||||||
|
flag_rows = await conn.fetch(
|
||||||
|
"SELECT flag, COUNT(*) AS n FROM ("
|
||||||
|
" SELECT unnest(quality_flags) AS flag FROM halachot "
|
||||||
|
" WHERE review_status = 'pending_review'"
|
||||||
|
") t GROUP BY flag ORDER BY n DESC"
|
||||||
|
)
|
||||||
|
pending_total = counts.get("pending_review", 0)
|
||||||
|
reviewed = counts.get("approved", 0) + counts.get("rejected", 0) + counts.get("published", 0)
|
||||||
|
return {
|
||||||
|
"pending_review": pending_total,
|
||||||
|
"pending_clean": pending_clean, # real review candidates (#84.1)
|
||||||
|
"pending_flagged": pending_total - pending_clean, # needs-fix bucket
|
||||||
|
"approved": counts.get("approved", 0),
|
||||||
|
"rejected": counts.get("rejected", 0),
|
||||||
|
"deferred": counts.get("deferred", 0),
|
||||||
|
"published": counts.get("published", 0),
|
||||||
|
"total": sum(counts.values()),
|
||||||
|
"reviewed_total": reviewed,
|
||||||
|
"approve_ratio": round(counts.get("approved", 0) / reviewed, 3) if reviewed else None,
|
||||||
|
"pending_by_flag": {r["flag"]: r["n"] for r in flag_rows},
|
||||||
|
"oldest_pending_at": oldest.isoformat() if oldest else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_dashboard() -> dict:
|
async def get_dashboard() -> dict:
|
||||||
"""דשבורד כולל — סיכום מדדים על כל התיקים."""
|
"""דשבורד כולל — סיכום מדדים על כל התיקים."""
|
||||||
pool = await db.get_pool()
|
pool = await db.get_pool()
|
||||||
@@ -123,6 +168,15 @@ async def get_dashboard() -> dict:
|
|||||||
total_corpus = await conn.fetchval("SELECT COUNT(*) FROM style_corpus")
|
total_corpus = await conn.fetchval("SELECT COUNT(*) FROM style_corpus")
|
||||||
total_patterns = await conn.fetchval("SELECT COUNT(*) FROM style_patterns")
|
total_patterns = await conn.fetchval("SELECT COUNT(*) FROM style_patterns")
|
||||||
total_case_law = await conn.fetchval("SELECT COUNT(*) FROM case_law")
|
total_case_law = await conn.fetchval("SELECT COUNT(*) FROM case_law")
|
||||||
|
non_searchable_case_law = await conn.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM case_law WHERE NOT searchable"
|
||||||
|
)
|
||||||
|
cases_with_stale_blocks = await conn.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM cases WHERE blocks_stale"
|
||||||
|
)
|
||||||
|
stale_embedding_case_law = await conn.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM case_law "
|
||||||
|
"WHERE coalesce(full_text,'') <> '' AND content_hash IS DISTINCT FROM indexed_hash")
|
||||||
|
|
||||||
# QA summary
|
# QA summary
|
||||||
qa_total = await conn.fetchval("SELECT COUNT(DISTINCT case_id) FROM qa_results")
|
qa_total = await conn.fetchval("SELECT COUNT(DISTINCT case_id) FROM qa_results")
|
||||||
@@ -143,6 +197,9 @@ async def get_dashboard() -> dict:
|
|||||||
"SELECT AVG(total_words) FROM decisions WHERE total_words > 0"
|
"SELECT AVG(total_words) FROM decisions WHERE total_words > 0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Halacha review backlog (GAP-14 / INV-QA1 / G10)
|
||||||
|
backlog = await halacha_backlog(conn)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"summary": {
|
"summary": {
|
||||||
"total_cases": total_cases,
|
"total_cases": total_cases,
|
||||||
@@ -154,8 +211,12 @@ async def get_dashboard() -> dict:
|
|||||||
"style_corpus": total_corpus,
|
"style_corpus": total_corpus,
|
||||||
"style_patterns": total_patterns,
|
"style_patterns": total_patterns,
|
||||||
"case_law_entries": total_case_law,
|
"case_law_entries": total_case_law,
|
||||||
|
"non_searchable_case_law": non_searchable_case_law,
|
||||||
|
"cases_with_stale_blocks": cases_with_stale_blocks,
|
||||||
|
"stale_embedding_case_law": stale_embedding_case_law,
|
||||||
},
|
},
|
||||||
"cases_by_status": cases_by_status,
|
"cases_by_status": cases_by_status,
|
||||||
|
"halacha_backlog": backlog,
|
||||||
"qa": {
|
"qa": {
|
||||||
"cases_validated": qa_total,
|
"cases_validated": qa_total,
|
||||||
"cases_passed": qa_passed,
|
"cases_passed": qa_passed,
|
||||||
|
|||||||
@@ -15,15 +15,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Awaitable, Callable
|
from typing import Awaitable, Callable
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import chunker, db, embeddings, extractor, hybrid_search, rerank # noqa: F401
|
from legal_mcp.services import db, embeddings, hybrid_search, ingest # noqa: F401
|
||||||
|
|
||||||
# Note: halacha_extractor and precedent_metadata_extractor are NOT imported
|
# Note: halacha_extractor and precedent_metadata_extractor are NOT imported
|
||||||
# at module load. They are imported lazily inside the dedicated re-extract
|
# at module load. They are imported lazily inside the dedicated re-extract
|
||||||
@@ -40,8 +37,8 @@ ProgressCb = Callable[[str, int, str], Awaitable[None]]
|
|||||||
PRECEDENT_LIBRARY_DIR = Path(config.DATA_DIR) / "precedent-library"
|
PRECEDENT_LIBRARY_DIR = Path(config.DATA_DIR) / "precedent-library"
|
||||||
|
|
||||||
|
|
||||||
_VALID_PRACTICE_AREAS = {"", "rishuy_uvniya", "betterment_levy", "compensation_197"}
|
_VALID_PRACTICE_AREAS = frozenset({"", "rishuy_uvniya", "betterment_levy", "compensation_197"})
|
||||||
_VALID_SOURCE_TYPES = {"", "court_ruling", "appeals_committee"}
|
_VALID_SOURCE_TYPES = frozenset({"", "court_ruling", "appeals_committee"})
|
||||||
_VALID_PRECEDENT_LEVELS = {
|
_VALID_PRECEDENT_LEVELS = {
|
||||||
"", "עליון", "מנהלי", "ועדת_ערר_ארצית", "ועדת_ערר_מחוזית",
|
"", "עליון", "מנהלי", "ועדת_ערר_ארצית", "ועדת_ערר_מחוזית",
|
||||||
"supreme", "administrative", "national_appeals_committee", "district_appeals_committee",
|
"supreme", "administrative", "national_appeals_committee", "district_appeals_committee",
|
||||||
@@ -52,37 +49,54 @@ async def _noop_progress(_status: str, _percent: int, _msg: str) -> None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _safe_filename(name: str) -> str:
|
def _external_validate(inputs: dict) -> None:
|
||||||
"""Strip path separators and unsafe chars from a user-provided name."""
|
citation = (inputs.get("citation") or "").strip()
|
||||||
base = Path(name).name
|
if not citation:
|
||||||
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"upload-{uuid4().hex[:8]}"
|
raise ValueError("citation is required")
|
||||||
|
if citation.startswith(("ערר ", "ערר(", 'בל"מ ', 'בל"מ(', "ARAR ")):
|
||||||
|
raise ValueError(
|
||||||
|
"ציטוט שמתחיל ב-'ערר' או 'בל\"מ' הוא החלטת ועדת ערר. "
|
||||||
|
"השתמש ב-internal_decision_upload (דורש chair_name + district), "
|
||||||
|
"לא ב-precedent_library_upload."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _stage_file(src_path: Path, source_type: str) -> Path:
|
def _external_staging_subdir(inputs: dict) -> str:
|
||||||
"""Copy the uploaded file into data/precedent-library/<source_type>/.
|
st = inputs.get("source_type") or ""
|
||||||
|
return st if st in {"court_ruling", "appeals_committee"} else "other"
|
||||||
Returns the destination path. Source file is not deleted (caller decides).
|
|
||||||
"""
|
|
||||||
sub = source_type if source_type in {"court_ruling", "appeals_committee"} else "other"
|
|
||||||
dest_dir = PRECEDENT_LIBRARY_DIR / sub
|
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
safe_name = _safe_filename(src_path.name)
|
|
||||||
dest = dest_dir / f"{uuid4().hex[:8]}_{safe_name}"
|
|
||||||
shutil.copy2(src_path, dest)
|
|
||||||
return dest
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_date(value) -> date | None:
|
async def _create_external_record(**kw) -> dict:
|
||||||
if value is None or value == "":
|
"""Adapter: maps canonical inputs (citation) to create_external_case_law(case_number)."""
|
||||||
return None
|
return await db.create_external_case_law(
|
||||||
if isinstance(value, date):
|
case_number=kw["citation"].strip(),
|
||||||
return value
|
case_name=kw["case_name"],
|
||||||
if isinstance(value, str):
|
full_text=kw["full_text"],
|
||||||
try:
|
court=(kw.get("court") or "").strip(),
|
||||||
return date.fromisoformat(value[:10])
|
decision_date=kw.get("decision_date"),
|
||||||
except ValueError:
|
practice_area=kw.get("practice_area", ""),
|
||||||
return None
|
appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
|
||||||
return None
|
subject_tags=list(kw.get("subject_tags") or []),
|
||||||
|
summary=(kw.get("summary") or "").strip(),
|
||||||
|
headnote=(kw.get("headnote") or "").strip(),
|
||||||
|
source_type=kw.get("source_type", ""),
|
||||||
|
precedent_level=kw.get("precedent_level", ""),
|
||||||
|
is_binding=kw.get("is_binding", True),
|
||||||
|
document_id=kw.get("document_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_EXTERNAL_SPEC = ingest.IntakeSpec(
|
||||||
|
source_kind="external_upload",
|
||||||
|
id_field="citation",
|
||||||
|
staging_root=PRECEDENT_LIBRARY_DIR,
|
||||||
|
staging_subdir=_external_staging_subdir,
|
||||||
|
validate=_external_validate,
|
||||||
|
enum_fields={"practice_area": _VALID_PRACTICE_AREAS, "source_type": _VALID_SOURCE_TYPES},
|
||||||
|
derive=lambda inputs: {},
|
||||||
|
display_name_fallback="citation",
|
||||||
|
create_record=_create_external_record,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def ingest_precedent(
|
async def ingest_precedent(
|
||||||
@@ -101,220 +115,20 @@ async def ingest_precedent(
|
|||||||
headnote: str = "",
|
headnote: str = "",
|
||||||
summary: str = "",
|
summary: str = "",
|
||||||
document_id: UUID | None = None,
|
document_id: UUID | None = None,
|
||||||
progress: ProgressCb | None = None,
|
progress: ingest.ProgressCb | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Ingest a single uploaded precedent through the full pipeline.
|
"""Ingest one external precedent. Thin wrapper over the canonical pipeline."""
|
||||||
|
inputs = {
|
||||||
Required: file_path + citation. Everything else has a sensible default.
|
"citation": citation, "case_name": case_name, "court": court,
|
||||||
|
"decision_date": decision_date, "source_type": source_type,
|
||||||
Returns:
|
"precedent_level": precedent_level, "practice_area": practice_area,
|
||||||
``{"status": "...", "case_law_id": "...", "chunks": N, "halachot": M}``
|
"appeal_subtype": appeal_subtype, "subject_tags": subject_tags,
|
||||||
"""
|
"is_binding": is_binding, "headnote": headnote, "summary": summary,
|
||||||
progress = progress or _noop_progress
|
}
|
||||||
src = Path(file_path)
|
return await ingest.ingest_document(
|
||||||
if not src.is_file():
|
_EXTERNAL_SPEC, inputs=inputs, file_path=file_path,
|
||||||
raise FileNotFoundError(f"file not found: {src}")
|
document_id=document_id, progress=progress,
|
||||||
if not citation.strip():
|
|
||||||
raise ValueError("citation is required")
|
|
||||||
# Citation guard at service level (catches both MCP and HTTP API paths).
|
|
||||||
# Appeals-committee decisions must go through ingest_internal_decision
|
|
||||||
# which records chair_name+district. The MCP wrapper has the same guard
|
|
||||||
# for an earlier, friendlier error message — but this is the source of
|
|
||||||
# truth. See TaskMaster #30(ב) and DB constraint case_law_external_arar_check.
|
|
||||||
_norm = citation.strip()
|
|
||||||
if _norm.startswith(("ערר ", "ערר(", "בל\"מ ", "בל\"מ(", "ARAR ")):
|
|
||||||
raise ValueError(
|
|
||||||
"ציטוט שמתחיל ב-'ערר' או 'בל\"מ' הוא החלטת ועדת ערר. "
|
|
||||||
"השתמש ב-internal_decision_upload (דורש chair_name + district), "
|
|
||||||
"לא ב-precedent_library_upload."
|
|
||||||
)
|
|
||||||
if practice_area not in _VALID_PRACTICE_AREAS:
|
|
||||||
raise ValueError(f"invalid practice_area: {practice_area!r}")
|
|
||||||
if source_type not in _VALID_SOURCE_TYPES:
|
|
||||||
raise ValueError(f"invalid source_type: {source_type!r}")
|
|
||||||
|
|
||||||
await progress("staging", 5, "מעתיק את הקובץ לאחסון")
|
|
||||||
|
|
||||||
staged = _stage_file(src, source_type)
|
|
||||||
|
|
||||||
await progress("extracting", 15, "מחלץ טקסט מהקובץ")
|
|
||||||
try:
|
|
||||||
text, page_count, page_offsets = await extractor.extract_text(str(staged))
|
|
||||||
except Exception as e:
|
|
||||||
await progress("failed", 100, f"כשל בחילוץ טקסט: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
text = (text or "").strip()
|
|
||||||
if not text:
|
|
||||||
await progress("failed", 100, "לא נמצא טקסט בקובץ")
|
|
||||||
raise ValueError("no extractable text in file")
|
|
||||||
|
|
||||||
# Strip any Nevo preamble that might wrap court rulings downloaded from Nevo.
|
|
||||||
text = extractor.strip_nevo_preamble(text)
|
|
||||||
|
|
||||||
await progress("storing_metadata", 25, "שומר את הפסיקה במסד הנתונים")
|
|
||||||
record = await db.create_external_case_law(
|
|
||||||
case_number=citation.strip(),
|
|
||||||
case_name=case_name.strip() or citation.strip(),
|
|
||||||
full_text=text,
|
|
||||||
court=court.strip(),
|
|
||||||
decision_date=_coerce_date(decision_date),
|
|
||||||
practice_area=practice_area,
|
|
||||||
appeal_subtype=appeal_subtype.strip(),
|
|
||||||
subject_tags=list(subject_tags or []),
|
|
||||||
summary=summary.strip(),
|
|
||||||
headnote=headnote.strip(),
|
|
||||||
source_type=source_type,
|
|
||||||
precedent_level=precedent_level,
|
|
||||||
is_binding=is_binding,
|
|
||||||
document_id=document_id,
|
|
||||||
)
|
)
|
||||||
case_law_id = UUID(str(record["id"]))
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Parent-doc retrieval (TaskMaster #48): when enabled, emit
|
|
||||||
# two tiers (parents + children). Only children are embedded
|
|
||||||
# and indexed; parents carry retrieval context. When disabled,
|
|
||||||
# fall back to legacy single-tier chunking — identical
|
|
||||||
# behaviour to pre-V17.
|
|
||||||
if config.PARENT_DOC_RETRIEVAL_ENABLED:
|
|
||||||
await progress(
|
|
||||||
"chunking", 40,
|
|
||||||
f"מחלק את הטקסט ל-chunks היררכיים ({page_count} עמ')",
|
|
||||||
)
|
|
||||||
h_chunks = chunker.chunk_document_hierarchical(
|
|
||||||
text, page_offsets=page_offsets,
|
|
||||||
)
|
|
||||||
if not h_chunks:
|
|
||||||
await db.set_case_law_extraction_status(case_law_id, "completed")
|
|
||||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
|
||||||
await progress("completed", 100, "אין טקסט לעיבוד")
|
|
||||||
return {
|
|
||||||
"status": "completed",
|
|
||||||
"case_law_id": str(case_law_id),
|
|
||||||
"chunks": 0,
|
|
||||||
"halachot": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
children = [c for c in h_chunks if c.role == "child"]
|
|
||||||
parents = [c for c in h_chunks if c.role == "parent"]
|
|
||||||
await progress(
|
|
||||||
"embedding", 55,
|
|
||||||
f"מייצר embeddings ל-{len(children)} children "
|
|
||||||
f"({len(parents)} parents)",
|
|
||||||
)
|
|
||||||
child_texts = [c.content for c in children]
|
|
||||||
child_vectors = await embeddings.embed_texts(
|
|
||||||
child_texts, input_type="document",
|
|
||||||
)
|
|
||||||
# Build flat dict list for the two-pass writer.
|
|
||||||
chunk_dicts: list[dict] = []
|
|
||||||
for p in parents:
|
|
||||||
chunk_dicts.append({
|
|
||||||
"role": "parent",
|
|
||||||
"local_id": p.local_id,
|
|
||||||
"parent_local_id": None,
|
|
||||||
"chunk_index": p.chunk_index,
|
|
||||||
"content": p.content,
|
|
||||||
"section_type": p.section_type,
|
|
||||||
"page_number": p.page_number,
|
|
||||||
"embedding": None,
|
|
||||||
})
|
|
||||||
for c, v in zip(children, child_vectors):
|
|
||||||
chunk_dicts.append({
|
|
||||||
"role": "child",
|
|
||||||
"local_id": c.local_id,
|
|
||||||
"parent_local_id": c.parent_local_id,
|
|
||||||
"chunk_index": c.chunk_index,
|
|
||||||
"content": c.content,
|
|
||||||
"section_type": c.section_type,
|
|
||||||
"page_number": c.page_number,
|
|
||||||
"embedding": v,
|
|
||||||
})
|
|
||||||
counts = await db.store_precedent_chunks_hierarchical(
|
|
||||||
case_law_id, chunk_dicts,
|
|
||||||
)
|
|
||||||
stored_chunks = counts["children"]
|
|
||||||
else:
|
|
||||||
await progress(
|
|
||||||
"chunking", 40, f"מחלק את הטקסט ל-chunks ({page_count} עמ')",
|
|
||||||
)
|
|
||||||
chunks = chunker.chunk_document(text, page_offsets=page_offsets)
|
|
||||||
if not chunks:
|
|
||||||
await db.set_case_law_extraction_status(case_law_id, "completed")
|
|
||||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
|
||||||
await progress("completed", 100, "אין טקסט לעיבוד")
|
|
||||||
return {
|
|
||||||
"status": "completed",
|
|
||||||
"case_law_id": str(case_law_id),
|
|
||||||
"chunks": 0,
|
|
||||||
"halachot": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
await progress("embedding", 55, f"מייצר embeddings ל-{len(chunks)} chunks")
|
|
||||||
chunk_texts = [c.content for c in chunks]
|
|
||||||
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
|
|
||||||
|
|
||||||
chunk_dicts = [
|
|
||||||
{
|
|
||||||
"chunk_index": c.chunk_index,
|
|
||||||
"content": c.content,
|
|
||||||
"section_type": c.section_type,
|
|
||||||
"page_number": c.page_number,
|
|
||||||
"embedding": v,
|
|
||||||
}
|
|
||||||
for c, v in zip(chunks, chunk_vectors)
|
|
||||||
]
|
|
||||||
stored_chunks = await db.store_precedent_chunks(case_law_id, chunk_dicts)
|
|
||||||
|
|
||||||
# Multimodal page-image embeddings (V9). Gated by feature flag.
|
|
||||||
# Non-fatal: text path already succeeded. Only PDFs.
|
|
||||||
if config.MULTIMODAL_ENABLED and page_count > 0 and staged.suffix.lower() == ".pdf":
|
|
||||||
try:
|
|
||||||
await progress(
|
|
||||||
"embedding_images", 70,
|
|
||||||
f"מטמיע {page_count} עמודי תמונה (multimodal)",
|
|
||||||
)
|
|
||||||
await _embed_precedent_pages(case_law_id, staged, page_count)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Precedent multimodal embedding failed (non-fatal): %s", e)
|
|
||||||
|
|
||||||
# Pipeline split: the container does the non-LLM half (extract +
|
|
||||||
# chunk + embed + store). LLM-driven extraction (metadata, halachot)
|
|
||||||
# runs separately via the MCP tool `precedent_process_pending` from
|
|
||||||
# local Claude Code, where `claude` CLI is available.
|
|
||||||
#
|
|
||||||
# We auto-queue both extractions so the chair doesn't need to click
|
|
||||||
# any button — the moment they (or me) run `precedent_process_pending`
|
|
||||||
# in chat, both kinds get processed.
|
|
||||||
await db.set_case_law_extraction_status(case_law_id, "completed")
|
|
||||||
await db.set_case_law_halacha_status(case_law_id, "pending")
|
|
||||||
await db.request_metadata_extraction(case_law_id)
|
|
||||||
await db.request_halacha_extraction(case_law_id)
|
|
||||||
|
|
||||||
await progress(
|
|
||||||
"completed",
|
|
||||||
100,
|
|
||||||
f"הוכנס לספרייה: {stored_chunks} chunks. "
|
|
||||||
f"חילוץ הלכות ומטא-דאטה ממתינים בתור — "
|
|
||||||
f"להפעיל מ-Claude Code: precedent_process_pending.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "completed",
|
|
||||||
"case_law_id": str(case_law_id),
|
|
||||||
"chunks": stored_chunks,
|
|
||||||
"halachot": 0,
|
|
||||||
"halachot_pending": True,
|
|
||||||
"metadata_filled": [],
|
|
||||||
"pages": page_count,
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("precedent_library.ingest_precedent failed: %s", e)
|
|
||||||
await db.set_case_law_extraction_status(case_law_id, "failed")
|
|
||||||
await progress("failed", 100, f"כשל בעיבוד: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def reextract_halachot(
|
async def reextract_halachot(
|
||||||
@@ -342,7 +156,10 @@ async def reextract_halachot(
|
|||||||
# bad data. See note in db.request_metadata_extraction.
|
# bad data. See note in db.request_metadata_extraction.
|
||||||
|
|
||||||
await progress("extracting_halachot", 50, "מחלץ הלכות מחדש")
|
await progress("extracting_halachot", 50, "מחלץ הלכות מחדש")
|
||||||
result = await halacha_extractor.extract(case_law_id)
|
# Explicit re-extraction = clean slate (force): wipe prior halachot +
|
||||||
|
# per-chunk checkpoints and redo all. (Queue draining / resume uses the
|
||||||
|
# default force=False so an interrupted run continues where it stopped.)
|
||||||
|
result = await halacha_extractor.extract(case_law_id, force=True)
|
||||||
# Clear the queue timestamp on completion so the UI badge / worker queue
|
# Clear the queue timestamp on completion so the UI badge / worker queue
|
||||||
# don't keep showing this row. The queue worker (process_pending_extractions)
|
# don't keep showing this row. The queue worker (process_pending_extractions)
|
||||||
# already does this; mirror it here so per-record extraction drains too.
|
# already does this; mirror it here so per-record extraction drains too.
|
||||||
@@ -402,7 +219,12 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
|
|||||||
async def _run_once(cid: UUID) -> dict:
|
async def _run_once(cid: UUID) -> dict:
|
||||||
if kind == "metadata":
|
if kind == "metadata":
|
||||||
return await precedent_metadata_extractor.extract_and_apply(cid)
|
return await precedent_metadata_extractor.extract_and_apply(cid)
|
||||||
return await halacha_extractor.extract(cid)
|
# Bulk queue-drain → lighter effort (config.HALACHA_BULK_EXTRACT_EFFORT,
|
||||||
|
# default 'high') to cut wall-clock at scale. Resume (force=False) so an
|
||||||
|
# interrupted drain continues per-chunk. Single re-extract stays xhigh.
|
||||||
|
return await halacha_extractor.extract(
|
||||||
|
cid, effort=config.HALACHA_BULK_EXTRACT_EFFORT,
|
||||||
|
)
|
||||||
|
|
||||||
results: list[dict] = []
|
results: list[dict] = []
|
||||||
processed = 0
|
processed = 0
|
||||||
@@ -413,6 +235,12 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
|
|||||||
attempts = 0
|
attempts = 0
|
||||||
result: dict = {}
|
result: dict = {}
|
||||||
try:
|
try:
|
||||||
|
# Flip to 'processing' so the UI badge shows live progress while
|
||||||
|
# this row is being worked (metadata has no per-chunk status of
|
||||||
|
# its own — this is the only signal). Halacha already sets its own
|
||||||
|
# 'processing' inside the extractor.
|
||||||
|
if kind == "metadata":
|
||||||
|
await db.set_case_law_metadata_status(cid, "processing")
|
||||||
result = await _run_once(cid)
|
result = await _run_once(cid)
|
||||||
# Retry only on systematic extraction failure (rate-limit storm).
|
# Retry only on systematic extraction failure (rate-limit storm).
|
||||||
# Don't retry on 'no_halachot' — that means Claude looked and
|
# Don't retry on 'no_halachot' — that means Claude looked and
|
||||||
@@ -437,9 +265,15 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
|
|||||||
# Finalise: success or terminal failure both clear the request
|
# Finalise: success or terminal failure both clear the request
|
||||||
# so the queue moves on. (Use 'failed' DB state for terminal
|
# so the queue moves on. (Use 'failed' DB state for terminal
|
||||||
# extraction_failed so the UI shows the warning chip.)
|
# extraction_failed so the UI shows the warning chip.)
|
||||||
if kind == "halacha" and result.get("status") == "extraction_failed":
|
if kind == "halacha":
|
||||||
await db.set_case_law_halacha_status(cid, "failed")
|
if result.get("status") == "extraction_failed":
|
||||||
await db.clear_extraction_request(cid, kind=kind)
|
await db.set_case_law_halacha_status(cid, "failed")
|
||||||
|
await db.clear_extraction_request(cid, kind=kind)
|
||||||
|
else:
|
||||||
|
# metadata — set terminal 'completed' status (also clears the
|
||||||
|
# request timestamp) so the UI badge settles instead of
|
||||||
|
# lingering on 'processing'.
|
||||||
|
await db.set_case_law_metadata_status(cid, "completed")
|
||||||
processed += 1
|
processed += 1
|
||||||
results.append({
|
results.append({
|
||||||
"case_law_id": str(cid),
|
"case_law_id": str(cid),
|
||||||
@@ -451,6 +285,15 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("process_pending_extractions failed for %s: %s", cid, e)
|
logger.exception("process_pending_extractions failed for %s: %s", cid, e)
|
||||||
|
# Don't clear the request — it stays for the next run. But for
|
||||||
|
# metadata, revert the badge from 'processing' back to 'pending'
|
||||||
|
# (the timestamp is preserved) so the row shows "בתור" rather than
|
||||||
|
# a stuck "מחלץ" until the retry picks it up.
|
||||||
|
if kind == "metadata":
|
||||||
|
try:
|
||||||
|
await db.set_case_law_metadata_status(cid, "pending")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("failed to revert metadata status for %s", cid)
|
||||||
results.append({
|
results.append({
|
||||||
"case_law_id": str(cid),
|
"case_law_id": str(cid),
|
||||||
"case_number": row.get("case_number", ""),
|
"case_number": row.get("case_number", ""),
|
||||||
@@ -458,7 +301,6 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
|
|||||||
"error": str(e),
|
"error": str(e),
|
||||||
"retry_attempts": attempts,
|
"retry_attempts": attempts,
|
||||||
})
|
})
|
||||||
# Don't clear the request — it stays for the next run.
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
@@ -492,12 +334,18 @@ async def reextract_metadata(
|
|||||||
raise ValueError("precedent not found")
|
raise ValueError("precedent not found")
|
||||||
# See note in db.request_metadata_extraction — opened to all source kinds.
|
# See note in db.request_metadata_extraction — opened to all source kinds.
|
||||||
|
|
||||||
|
# Mark 'processing' so a concurrent UI poll shows the live badge.
|
||||||
|
await db.set_case_law_metadata_status(case_law_id, "processing")
|
||||||
await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (תקציר, תגיות)")
|
await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (תקציר, תגיות)")
|
||||||
result = await precedent_metadata_extractor.extract_and_apply(case_law_id)
|
result = await precedent_metadata_extractor.extract_and_apply(case_law_id)
|
||||||
# Clear the queue timestamp so the UI / worker stop showing this row.
|
# Settle to terminal 'completed' (also NULLs the queue timestamp) so the
|
||||||
# See note in reextract_halachot.
|
# UI / worker stop showing this row. See note in reextract_halachot.
|
||||||
if result.get("status") in ("completed", "no_changes"):
|
if result.get("status") in ("completed", "no_changes"):
|
||||||
await db.clear_extraction_request(case_law_id, kind="metadata")
|
await db.set_case_law_metadata_status(case_law_id, "completed")
|
||||||
|
else:
|
||||||
|
# e.g. 'no_metadata' (no full_text) — don't leave the badge stuck on
|
||||||
|
# 'processing'; revert to 'pending' (preserves any queue timestamp).
|
||||||
|
await db.set_case_law_metadata_status(case_law_id, "pending")
|
||||||
fields = result.get("fields") or []
|
fields = result.get("fields") or []
|
||||||
msg = (
|
msg = (
|
||||||
f"מולאו {len(fields)} שדות: {', '.join(fields)}"
|
f"מולאו {len(fields)} שדות: {', '.join(fields)}"
|
||||||
@@ -586,48 +434,3 @@ async def search_library(
|
|||||||
subject_tag=subject_tag,
|
subject_tag=subject_tag,
|
||||||
include_halachot=include_halachot,
|
include_halachot=include_halachot,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _embed_precedent_pages(
|
|
||||||
case_law_id: UUID,
|
|
||||||
pdf_path: Path,
|
|
||||||
page_count: int,
|
|
||||||
) -> dict:
|
|
||||||
"""Render precedent PDF pages → embed via voyage-multimodal → store.
|
|
||||||
|
|
||||||
Thumbnails go to
|
|
||||||
``data/precedent-library/thumbnails/{case_law_id}/p{N:03d}.jpg``.
|
|
||||||
"""
|
|
||||||
thumb_dir = PRECEDENT_LIBRARY_DIR / "thumbnails" / str(case_law_id)
|
|
||||||
rendered = await asyncio.to_thread(
|
|
||||||
extractor.render_pages_for_multimodal,
|
|
||||||
pdf_path,
|
|
||||||
config.MULTIMODAL_DPI,
|
|
||||||
config.MULTIMODAL_THUMB_DPI,
|
|
||||||
thumb_dir,
|
|
||||||
)
|
|
||||||
images = [pil for pil, _ in rendered]
|
|
||||||
thumbs = [t for _, t in rendered]
|
|
||||||
img_embs = await embeddings.embed_images(images)
|
|
||||||
|
|
||||||
page_records = []
|
|
||||||
for i, (emb, thumb) in enumerate(zip(img_embs, thumbs)):
|
|
||||||
rel_thumb = None
|
|
||||||
if thumb is not None:
|
|
||||||
try:
|
|
||||||
rel_thumb = str(thumb.relative_to(config.DATA_DIR))
|
|
||||||
except ValueError:
|
|
||||||
rel_thumb = str(thumb)
|
|
||||||
page_records.append({
|
|
||||||
"page_number": i + 1,
|
|
||||||
"embedding": emb,
|
|
||||||
"image_thumbnail_path": rel_thumb,
|
|
||||||
})
|
|
||||||
stored = await db.store_precedent_image_embeddings(
|
|
||||||
case_law_id, page_records, model_name=config.MULTIMODAL_MODEL,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Multimodal: stored %d page-image embeddings for case_law %s",
|
|
||||||
stored, case_law_id,
|
|
||||||
)
|
|
||||||
return {"pages_embedded": stored}
|
|
||||||
|
|||||||
@@ -368,6 +368,8 @@ async def extract_and_apply(
|
|||||||
if not suggested:
|
if not suggested:
|
||||||
return {"status": "no_metadata", "fields": []}
|
return {"status": "no_metadata", "fields": []}
|
||||||
result = await apply_to_record(case_law_id, suggested, overwrite_case_number=overwrite_case_number)
|
result = await apply_to_record(case_law_id, suggested, overwrite_case_number=overwrite_case_number)
|
||||||
|
if result["updated"]:
|
||||||
|
await db.recompute_searchable(case_law_id)
|
||||||
return {
|
return {
|
||||||
"status": "completed" if result["updated"] else "no_changes",
|
"status": "completed" if result["updated"] else "no_changes",
|
||||||
"fields": result["fields"],
|
"fields": result["fields"],
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ def check_neutral_background(blocks: list[dict]) -> dict:
|
|||||||
"""בדיקת ניטרליות בלוק הרקע (ו)."""
|
"""בדיקת ניטרליות בלוק הרקע (ו)."""
|
||||||
vav = next((b for b in blocks if b["block_id"] == "block-vav"), None)
|
vav = next((b for b in blocks if b["block_id"] == "block-vav"), None)
|
||||||
if not vav or not vav.get("content"):
|
if not vav or not vav.get("content"):
|
||||||
return {"name": "neutral_background", "passed": True, "errors": [], "severity": "critical"}
|
return {"name": "neutral_background", "passed": True, "errors": [], "severity": "warning"}
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
lines = vav["content"].split("\n")
|
lines = vav["content"].split("\n")
|
||||||
@@ -104,7 +104,7 @@ CLAIMS_CHECK_PROMPT = """אתה בודק איכות החלטות משפטיות.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict:
|
async def check_claims_coverage(blocks: list[dict], claims: list[dict], outcome: str = "") -> dict:
|
||||||
"""בדיקה סמנטית (Claude) שכל טענה נענתה בדיון."""
|
"""בדיקה סמנטית (Claude) שכל טענה נענתה בדיון."""
|
||||||
yod = next((b for b in blocks if b["block_id"] == "block-yod"), None)
|
yod = next((b for b in blocks if b["block_id"] == "block-yod"), None)
|
||||||
if not yod or not yod.get("content"):
|
if not yod or not yod.get("content"):
|
||||||
@@ -114,16 +114,26 @@ async def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict:
|
|||||||
if not claims:
|
if not claims:
|
||||||
return {"name": "claims_coverage", "passed": True, "errors": [], "severity": "critical"}
|
return {"name": "claims_coverage", "passed": True, "errors": [], "severity": "critical"}
|
||||||
|
|
||||||
# Filter: only APPELLANT claims from original pleadings.
|
# #87/GAP-87 — only the appellant's claims from the APPEAL PLEADING itself
|
||||||
# Committee/permit_applicant claims are defensive positions, not claims
|
# must be addressed. claim_type: 'claim'=כתב ערר (mandatory), 'response'=כתב
|
||||||
# that need to be "addressed" in the discussion.
|
# תשובה, 'reply'=תגובה/השלמת-טיעון/תכתובת (supplementary correspondence — NOT
|
||||||
|
# a standalone duty to answer, especially on full acceptance). Counting reply/
|
||||||
|
# correspondence claims as "unanswered" produced false QA fails (1033-25).
|
||||||
source_claims = [
|
source_claims = [
|
||||||
c for c in claims
|
c for c in claims
|
||||||
if c.get("source_document", "") != "block-zayin"
|
if c.get("source_document", "") != "block-zayin"
|
||||||
and c.get("party_role") in ("appellant", "respondent")
|
and c.get("claim_type") == "claim"
|
||||||
|
and c.get("party_role") == "appellant"
|
||||||
]
|
]
|
||||||
if not source_claims:
|
if not source_claims:
|
||||||
# Fallback: all non-block-zayin claims
|
# Fallback: appellant/respondent pleadings, excluding supplementary replies.
|
||||||
|
source_claims = [
|
||||||
|
c for c in claims
|
||||||
|
if c.get("source_document", "") != "block-zayin"
|
||||||
|
and c.get("claim_type") != "reply"
|
||||||
|
and c.get("party_role") in ("appellant", "respondent")
|
||||||
|
]
|
||||||
|
if not source_claims:
|
||||||
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"]
|
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"]
|
||||||
if not source_claims:
|
if not source_claims:
|
||||||
source_claims = claims
|
source_claims = claims
|
||||||
@@ -165,9 +175,14 @@ async def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict:
|
|||||||
total = len(source_claims)
|
total = len(source_claims)
|
||||||
covered = len(addressed) + len(partial)
|
covered = len(addressed) + len(partial)
|
||||||
|
|
||||||
|
# On full acceptance the appellant prevailed in full — not every sub-claim
|
||||||
|
# needs individual treatment (the chair noted this for correspondence claims,
|
||||||
|
# 1033-25). Relax the missing-tolerance accordingly.
|
||||||
|
allowed_missing_ratio = 0.4 if outcome == "full_acceptance" else 0.2
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"name": "claims_coverage",
|
"name": "claims_coverage",
|
||||||
"passed": len(missing) <= total * 0.2, # Allow up to 20% missing
|
"passed": len(missing) <= total * allowed_missing_ratio,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
"severity": "critical",
|
"severity": "critical",
|
||||||
"details": f"{covered}/{total} טענות נענו ({covered/total*100:.0f}%), {len(partial)} חלקית, {len(missing)} חסרות",
|
"details": f"{covered}/{total} טענות נענו ({covered/total*100:.0f}%), {len(partial)} חלקית, {len(missing)} חסרות",
|
||||||
@@ -287,6 +302,50 @@ def check_sequential_numbering(blocks: list[dict]) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def check_citation_resolution(case_id: UUID, decision_id=None) -> dict:
|
||||||
|
"""GAP-20/INV-AUD3: every cited case_law_id must resolve to the corpus.
|
||||||
|
|
||||||
|
Reads case_law_ids from the decision's write_block audit provenance and
|
||||||
|
verifies each resolves. Unresolvable → NON-BLOCKING warning + audit event.
|
||||||
|
"""
|
||||||
|
from legal_mcp.services import audit
|
||||||
|
|
||||||
|
rows = await audit.get_audit_log(case_id=case_id, action="write_block", limit=200)
|
||||||
|
ids = set()
|
||||||
|
for r in rows:
|
||||||
|
details = r.get("details") or {}
|
||||||
|
if isinstance(details, str):
|
||||||
|
try:
|
||||||
|
details = json.loads(details)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
details = {}
|
||||||
|
for raw in (details.get("sources") or {}).get("case_law_ids", []):
|
||||||
|
try:
|
||||||
|
ids.add(UUID(str(raw)))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
return {"name": "citation_resolution", "passed": True, "errors": [], "severity": "warning"}
|
||||||
|
|
||||||
|
res = await db.resolve_citation_case_law_ids(list(ids))
|
||||||
|
if not res["unresolved"]:
|
||||||
|
return {"name": "citation_resolution", "passed": True, "errors": [], "severity": "warning"}
|
||||||
|
|
||||||
|
await audit.log_action_safe(
|
||||||
|
"citation_unresolved", case_id=case_id,
|
||||||
|
details={"unresolved": [str(x) for x in res["unresolved"]]},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"name": "citation_resolution",
|
||||||
|
"passed": False,
|
||||||
|
"severity": "warning",
|
||||||
|
"errors": [
|
||||||
|
f"{len(res['unresolved'])} ציטוטים אינם פתירים לקורפוס — דורש אימות יו\"ר",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Main validation ───────────────────────────────────────────────
|
# ── Main validation ───────────────────────────────────────────────
|
||||||
|
|
||||||
async def validate_decision(case_id: UUID) -> dict:
|
async def validate_decision(case_id: UUID) -> dict:
|
||||||
@@ -317,8 +376,10 @@ async def validate_decision(case_id: UUID) -> dict:
|
|||||||
# Get claims
|
# Get claims
|
||||||
claims = await db.get_claims(case_id)
|
claims = await db.get_claims(case_id)
|
||||||
|
|
||||||
# Determine appeal type
|
# Determine appeal type + outcome (outcome relaxes claims coverage on full acceptance — #87)
|
||||||
appeal_type = case.get("appeal_type", "licensing")
|
appeal_type = case.get("appeal_type", "licensing")
|
||||||
|
from legal_mcp.services.lessons import canonical_outcome
|
||||||
|
outcome = canonical_outcome(decision.get("outcome", "") or "")
|
||||||
|
|
||||||
# Run all checks
|
# Run all checks
|
||||||
# Run sync checks
|
# Run sync checks
|
||||||
@@ -326,7 +387,7 @@ async def validate_decision(case_id: UUID) -> dict:
|
|||||||
check_neutral_background(blocks),
|
check_neutral_background(blocks),
|
||||||
]
|
]
|
||||||
# Async check: claims coverage with Claude
|
# Async check: claims coverage with Claude
|
||||||
results.append(await check_claims_coverage(blocks, claims))
|
results.append(await check_claims_coverage(blocks, claims, outcome))
|
||||||
# More sync checks
|
# More sync checks
|
||||||
results.extend([
|
results.extend([
|
||||||
check_weight_compliance(blocks, appeal_type),
|
check_weight_compliance(blocks, appeal_type),
|
||||||
@@ -334,6 +395,8 @@ async def validate_decision(case_id: UUID) -> dict:
|
|||||||
check_no_duplication(blocks),
|
check_no_duplication(blocks),
|
||||||
check_sequential_numbering(blocks),
|
check_sequential_numbering(blocks),
|
||||||
])
|
])
|
||||||
|
# Async, non-blocking warning: citation→corpus resolution (GAP-20/INV-AUD3)
|
||||||
|
results.append(await check_citation_resolution(case_id, decision["id"]))
|
||||||
|
|
||||||
critical_failures = sum(1 for r in results if not r["passed"] and r["severity"] == "critical")
|
critical_failures = sum(1 for r in results if not r["passed"] and r["severity"] == "critical")
|
||||||
all_passed = all(r["passed"] for r in results)
|
all_passed = all(r["passed"] for r in results)
|
||||||
|
|||||||
@@ -109,26 +109,30 @@ SYNTHESIS_PROMPT = """\
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def analyze_corpus(appeal_subtype: str = "") -> dict:
|
async def analyze_corpus(appeal_subtype: str = "", limit: int = 0) -> dict:
|
||||||
"""Analyze the style corpus and extract/update patterns.
|
"""Analyze the style corpus and extract/update patterns.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
appeal_subtype: filter by appeal subtype (e.g. 'betterment_levy', 'building_permit').
|
appeal_subtype: filter by appeal subtype (e.g. 'betterment_levy', 'building_permit').
|
||||||
Empty string = all decisions.
|
Empty string = all decisions.
|
||||||
|
limit: max decisions to analyze. 0 = ALL (T8 — full 48/48 coverage feeds the
|
||||||
|
author-features the writer's profile relies on; the old LIMIT 20 silently
|
||||||
|
dropped a third of Dafna's corpus).
|
||||||
|
|
||||||
Returns summary of patterns found.
|
Returns summary of patterns found.
|
||||||
"""
|
"""
|
||||||
pool = await db.get_pool()
|
pool = await db.get_pool()
|
||||||
|
lim_sql = f" LIMIT {int(limit)}" if limit and limit > 0 else ""
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
if appeal_subtype:
|
if appeal_subtype:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"SELECT full_text, decision_number FROM style_corpus "
|
"SELECT full_text, decision_number FROM style_corpus "
|
||||||
"WHERE appeal_subtype = $1 ORDER BY decision_date DESC LIMIT 20",
|
"WHERE appeal_subtype = $1 ORDER BY decision_date DESC" + lim_sql,
|
||||||
appeal_subtype,
|
appeal_subtype,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"SELECT full_text, decision_number FROM style_corpus ORDER BY decision_date DESC LIMIT 20"
|
"SELECT full_text, decision_number FROM style_corpus ORDER BY decision_date DESC" + lim_sql
|
||||||
)
|
)
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
|
|||||||
182
mcp-server/src/legal_mcp/services/style_distance.py
Normal file
182
mcp-server/src/legal_mcp/services/style_distance.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"""מדד מרחק-סגנון (T7) — האם הטיוטות מתכנסות לדפנה לאורך זמן.
|
||||||
|
|
||||||
|
שלושה רכיבים, כולם ללא LLM (דטרמיניסטי, זול):
|
||||||
|
1. golden_ratio_adherence — סטיית אחוזי-הסעיפים מ-GOLDEN_RATIOS לפי תוצאה.
|
||||||
|
2. anti_pattern_hits — ספירת אנטי-דפוסים (מ-lessons.ANTI_PATTERNS) בטקסט הטיוטה.
|
||||||
|
3. draft_to_final_diff — change_percent מ-draft_final_pairs (ככל שיורד → מתכנס).
|
||||||
|
|
||||||
|
זהו מטא-אות על בריאות-הלמידה (INV-LRN4) — נצרך ע"י לוח-מחוונים / QA, לא ע"י הכותב.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import db
|
||||||
|
from legal_mcp.services.lessons import ANTI_PATTERNS, GOLDEN_RATIOS, canonical_outcome
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# block_id → golden-ratio section
|
||||||
|
_BLOCK_TO_SECTION = {
|
||||||
|
"block-vav": "background",
|
||||||
|
"block-zayin": "claims",
|
||||||
|
"block-yod": "discussion",
|
||||||
|
"block-yod-alef": "summary",
|
||||||
|
}
|
||||||
|
|
||||||
|
# chunker section_type → golden-ratio section (for corpus measurement, T10)
|
||||||
|
_CHUNK_SECTION_TO_GOLDEN = {
|
||||||
|
"facts": "background", "intro": "background",
|
||||||
|
"appellant_claims": "claims", "respondent_claims": "claims",
|
||||||
|
"legal_analysis": "discussion",
|
||||||
|
"conclusion": "summary", "ruling": "summary",
|
||||||
|
}
|
||||||
|
|
||||||
|
_CORPUS_RATIOS_CACHE: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def measure_corpus_ratios() -> dict:
|
||||||
|
"""Measure ACTUAL section %-of-total from Dafna's style_corpus, averaged per
|
||||||
|
outcome — the empirical counterpart to lessons.GOLDEN_RATIOS (T10). Splits each
|
||||||
|
decision via chunker (accurate, not the filtered exemplars). Cached for the
|
||||||
|
process. Returns {outcome: {"n": int, "sections": {sec: pct}}}."""
|
||||||
|
global _CORPUS_RATIOS_CACHE
|
||||||
|
if _CORPUS_RATIOS_CACHE is not None:
|
||||||
|
return _CORPUS_RATIOS_CACHE
|
||||||
|
|
||||||
|
from legal_mcp.services.chunker import _split_into_sections
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("SELECT full_text, outcome FROM style_corpus WHERE full_text <> ''")
|
||||||
|
|
||||||
|
# Per-outcome AND an "_all" aggregate. style_corpus.outcome is currently
|
||||||
|
# unpopulated for the imported corpus, so per-outcome may be empty — "_all"
|
||||||
|
# is the meaningful signal today, and per-outcome becomes live once outcomes
|
||||||
|
# are backfilled. No silent loss: callers see which buckets have data via n.
|
||||||
|
by_outcome: dict[str, list[dict]] = {}
|
||||||
|
for r in rows:
|
||||||
|
sect_words: dict[str, int] = {}
|
||||||
|
for stype, stext in _split_into_sections(r["full_text"]):
|
||||||
|
g = _CHUNK_SECTION_TO_GOLDEN.get(stype)
|
||||||
|
if g:
|
||||||
|
sect_words[g] = sect_words.get(g, 0) + len(stext.split())
|
||||||
|
total = sum(sect_words.values())
|
||||||
|
if total < 100: # sections didn't parse — skip
|
||||||
|
continue
|
||||||
|
pct = {s: w / total * 100 for s, w in sect_words.items()}
|
||||||
|
by_outcome.setdefault("_all", []).append(pct)
|
||||||
|
outcome = canonical_outcome(r["outcome"] or "")
|
||||||
|
if outcome:
|
||||||
|
by_outcome.setdefault(outcome, []).append(pct)
|
||||||
|
|
||||||
|
result: dict = {}
|
||||||
|
for outcome, decs in by_outcome.items():
|
||||||
|
avg = {}
|
||||||
|
for sec in ("background", "claims", "discussion", "summary"):
|
||||||
|
vals = [d.get(sec, 0.0) for d in decs]
|
||||||
|
if vals:
|
||||||
|
avg[sec] = round(sum(vals) / len(vals), 1)
|
||||||
|
result[outcome] = {"n": len(decs), "sections": avg}
|
||||||
|
_CORPUS_RATIOS_CACHE = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def count_anti_patterns(text: str) -> dict:
|
||||||
|
"""Count each anti-pattern occurrence in text. Lower = closer to Dafna."""
|
||||||
|
hits = {}
|
||||||
|
total = 0
|
||||||
|
for ap in ANTI_PATTERNS:
|
||||||
|
n = len(re.findall(ap["regex"], text or ""))
|
||||||
|
if n:
|
||||||
|
hits[ap["name"]] = {"count": n, "note": ap["note"]}
|
||||||
|
total += n
|
||||||
|
return {"total": total, "by_pattern": hits}
|
||||||
|
|
||||||
|
|
||||||
|
def golden_ratio_adherence(block_word_counts: dict[str, int], outcome: str) -> dict:
|
||||||
|
"""% of total per section vs GOLDEN_RATIOS target range. deviation=0 ⇒ within range."""
|
||||||
|
outcome = canonical_outcome(outcome)
|
||||||
|
targets = GOLDEN_RATIOS.get(outcome)
|
||||||
|
total = sum(block_word_counts.values())
|
||||||
|
if not targets or total == 0:
|
||||||
|
return {"outcome": outcome, "total_words": total, "sections": {}, "max_deviation": None}
|
||||||
|
|
||||||
|
sections = {}
|
||||||
|
max_dev = 0.0
|
||||||
|
for block_id, section in _BLOCK_TO_SECTION.items():
|
||||||
|
if section not in targets:
|
||||||
|
continue
|
||||||
|
pct = round(block_word_counts.get(block_id, 0) / total * 100, 1)
|
||||||
|
lo, hi = targets[section]
|
||||||
|
if pct < lo:
|
||||||
|
dev = round(lo - pct, 1)
|
||||||
|
elif pct > hi:
|
||||||
|
dev = round(pct - hi, 1)
|
||||||
|
else:
|
||||||
|
dev = 0.0
|
||||||
|
max_dev = max(max_dev, dev)
|
||||||
|
sections[section] = {"actual_pct": pct, "target": [lo, hi], "deviation_pp": dev}
|
||||||
|
return {"outcome": outcome, "total_words": total, "sections": sections, "max_deviation": max_dev}
|
||||||
|
|
||||||
|
|
||||||
|
async def style_distance(case_number: str) -> dict:
|
||||||
|
"""Assemble the 3 style-distance components for one case (T7)."""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
return {"error": f"case {case_number} not found"}
|
||||||
|
case_id = UUID(case["id"])
|
||||||
|
decision = await db.get_decision_by_case(case_id)
|
||||||
|
outcome = (decision or {}).get("outcome", "rejection")
|
||||||
|
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
block_rows = []
|
||||||
|
draft_text = ""
|
||||||
|
if decision:
|
||||||
|
block_rows = await conn.fetch(
|
||||||
|
"SELECT block_id, content, word_count FROM decision_blocks "
|
||||||
|
"WHERE decision_id = $1 ORDER BY block_index",
|
||||||
|
UUID(decision["id"]),
|
||||||
|
)
|
||||||
|
draft_text = "\n\n".join(b["content"] for b in block_rows if b["content"])
|
||||||
|
pair = await conn.fetchrow(
|
||||||
|
"SELECT draft_text, diff_stats, status FROM draft_final_pairs "
|
||||||
|
"WHERE case_id = $1 ORDER BY created_at DESC LIMIT 1",
|
||||||
|
case_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prefer the immutable snapshot's draft text when present.
|
||||||
|
if pair and pair["draft_text"]:
|
||||||
|
draft_text = pair["draft_text"]
|
||||||
|
|
||||||
|
word_counts = {b["block_id"]: (b["word_count"] or 0) for b in block_rows}
|
||||||
|
ratios = golden_ratio_adherence(word_counts, outcome)
|
||||||
|
anti = count_anti_patterns(draft_text)
|
||||||
|
|
||||||
|
diff = None
|
||||||
|
if pair and pair["diff_stats"]:
|
||||||
|
raw = pair["diff_stats"]
|
||||||
|
if isinstance(raw, str):
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
raw = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
raw = None
|
||||||
|
diff = raw
|
||||||
|
|
||||||
|
return {
|
||||||
|
"case_number": case_number,
|
||||||
|
"outcome": canonical_outcome(outcome),
|
||||||
|
"golden_ratio_adherence": ratios,
|
||||||
|
"anti_pattern_hits": anti,
|
||||||
|
"draft_to_final_diff": diff,
|
||||||
|
"pair_status": pair["status"] if pair else None,
|
||||||
|
"summary": {
|
||||||
|
"ratio_max_deviation_pp": ratios.get("max_deviation"),
|
||||||
|
"anti_pattern_total": anti["total"],
|
||||||
|
"change_percent": (diff or {}).get("change_percent") if diff else None,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import httpx
|
|||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
|
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
|
||||||
|
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -153,6 +154,13 @@ async def case_create(
|
|||||||
ריק = יוסק אוטומטית ממספר התיק
|
ריק = יוסק אוטומטית ממספר התיק
|
||||||
proceeding_type: 'ערר' / 'בל"מ'. ריק = יוסק מ-appeal_subtype/subject.
|
proceeding_type: 'ערר' / 'בל"מ'. ריק = יוסק מ-appeal_subtype/subject.
|
||||||
"""
|
"""
|
||||||
|
# INV-TOOL3 / GAP-52: idempotent on case_number (already UNIQUE in schema).
|
||||||
|
# Re-creating an existing case returns it instead of raising a unique-violation.
|
||||||
|
_existing = await db.get_case_by_number(case_number)
|
||||||
|
if _existing:
|
||||||
|
_existing["idempotent_existing"] = True
|
||||||
|
return ok(_existing)
|
||||||
|
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
|
|
||||||
h_date = None
|
h_date = None
|
||||||
@@ -250,7 +258,7 @@ async def case_create(
|
|||||||
# silently producing a case with no remote.
|
# silently producing a case with no remote.
|
||||||
case["gitea"] = await _setup_gitea_remote(case_number, title, case_dir)
|
case["gitea"] = await _setup_gitea_remote(case_number, title, case_dir)
|
||||||
|
|
||||||
return json.dumps(case, default=str, ensure_ascii=False, indent=2)
|
return ok(case)
|
||||||
|
|
||||||
|
|
||||||
async def case_list(status: str = "", limit: int = 50) -> str:
|
async def case_list(status: str = "", limit: int = 50) -> str:
|
||||||
@@ -265,8 +273,8 @@ async def case_list(status: str = "", limit: int = 50) -> str:
|
|||||||
"""
|
"""
|
||||||
cases = await db.list_cases(status=status or None, limit=limit)
|
cases = await db.list_cases(status=status or None, limit=limit)
|
||||||
if not cases:
|
if not cases:
|
||||||
return "אין תיקים."
|
return empty("אין תיקים.")
|
||||||
return json.dumps(cases, default=str, ensure_ascii=False, indent=2)
|
return ok(cases)
|
||||||
|
|
||||||
|
|
||||||
async def case_get(case_number: str) -> str:
|
async def case_get(case_number: str) -> str:
|
||||||
@@ -277,11 +285,11 @@ async def case_get(case_number: str) -> str:
|
|||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
docs = await db.list_documents(UUID(case["id"]))
|
docs = await db.list_documents(UUID(case["id"]))
|
||||||
case["documents"] = docs
|
case["documents"] = docs
|
||||||
return json.dumps(case, default=str, ensure_ascii=False, indent=2)
|
return ok(case)
|
||||||
|
|
||||||
|
|
||||||
async def case_update(
|
async def case_update(
|
||||||
@@ -331,7 +339,7 @@ async def case_update(
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
fields = {}
|
fields = {}
|
||||||
if status:
|
if status:
|
||||||
@@ -388,7 +396,7 @@ async def case_update(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # git not available — non-critical
|
pass # git not available — non-critical
|
||||||
|
|
||||||
return json.dumps(updated, default=str, ensure_ascii=False, indent=2)
|
return ok(updated)
|
||||||
|
|
||||||
|
|
||||||
async def case_delete(case_number: str, remove_files: bool = False) -> str:
|
async def case_delete(case_number: str, remove_files: bool = False) -> str:
|
||||||
@@ -401,28 +409,25 @@ async def case_delete(case_number: str, remove_files: bool = False) -> str:
|
|||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return json.dumps(
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
{"deleted": False, "reason": f"תיק {case_number} לא נמצא."},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
ok = await db.delete_case(case_id)
|
deleted = await db.delete_case(case_id)
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"deleted": ok,
|
"deleted": deleted,
|
||||||
"case_number": case_number,
|
"case_number": case_number,
|
||||||
"case_id": str(case_id),
|
"case_id": str(case_id),
|
||||||
"removed_files": False,
|
"removed_files": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok and remove_files:
|
if deleted and remove_files:
|
||||||
case_dir = config.find_case_dir(case_number)
|
case_dir = config.find_case_dir(case_number)
|
||||||
if case_dir.exists():
|
if case_dir.exists():
|
||||||
shutil.rmtree(case_dir, ignore_errors=True)
|
shutil.rmtree(case_dir, ignore_errors=True)
|
||||||
result["removed_files"] = True
|
result["removed_files"] = True
|
||||||
|
|
||||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
return ok(result)
|
||||||
|
|
||||||
|
|
||||||
async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
|
async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
|
||||||
@@ -449,27 +454,24 @@ async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if final_path is None:
|
if final_path is None:
|
||||||
return json.dumps({
|
return err(
|
||||||
"status": "not_found",
|
"ההחלטה הסופית עדיין לא סומנה כ'סופית' ב-UI. "
|
||||||
"case_number": case_number,
|
"דפנה צריכה ללחוץ 'סמן כסופי' על קובץ הטיוטה הנכון.",
|
||||||
"expected_path": str(exports_dir / f"{final_stem}.docx"),
|
data={
|
||||||
"tried_extensions": [".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"],
|
"case_number": case_number,
|
||||||
"hint": (
|
"expected_path": str(exports_dir / f"{final_stem}.docx"),
|
||||||
"ההחלטה הסופית עדיין לא סומנה כ'סופית' ב-UI. "
|
"tried_extensions": [".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"],
|
||||||
"דפנה צריכה ללחוץ 'סמן כסופי' על קובץ הטיוטה הנכון."
|
},
|
||||||
),
|
)
|
||||||
}, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
text, page_count, _ = await extractor.extract_text(str(final_path))
|
text, page_count, _ = await extractor.extract_text(str(final_path))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("case_get_final_text: extraction failed for %s", case_number)
|
logger.exception("case_get_final_text: extraction failed for %s", case_number)
|
||||||
return json.dumps({
|
return err(
|
||||||
"status": "error",
|
f"חילוץ הטקסט נכשל: {e}",
|
||||||
"case_number": case_number,
|
data={"case_number": case_number, "file_path": str(final_path)},
|
||||||
"file_path": str(final_path),
|
)
|
||||||
"error": str(e),
|
|
||||||
}, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
text = text or ""
|
text = text or ""
|
||||||
truncated = False
|
truncated = False
|
||||||
@@ -477,12 +479,11 @@ async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
|
|||||||
text = text[:max_chars]
|
text = text[:max_chars]
|
||||||
truncated = True
|
truncated = True
|
||||||
|
|
||||||
return json.dumps({
|
return ok({
|
||||||
"status": "ok",
|
|
||||||
"case_number": case_number,
|
"case_number": case_number,
|
||||||
"file_path": str(final_path),
|
"file_path": str(final_path),
|
||||||
"text_length": len(text),
|
"text_length": len(text),
|
||||||
"page_count": page_count,
|
"page_count": page_count,
|
||||||
"truncated": truncated,
|
"truncated": truncated,
|
||||||
"text": text,
|
"text": text,
|
||||||
}, ensure_ascii=False, indent=2)
|
})
|
||||||
|
|||||||
@@ -23,18 +23,10 @@ missing decision so that newer rows now link to it).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import citation_extractor
|
from legal_mcp.services import citation_extractor
|
||||||
|
from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
def _ok(payload) -> str:
|
|
||||||
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
|
||||||
|
|
||||||
|
|
||||||
def _err(msg: str) -> str:
|
|
||||||
return json.dumps({"error": msg}, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
async def extract_internal_citations(
|
async def extract_internal_citations(
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import db, git_sync, processor
|
from legal_mcp.services import audit, db, git_sync, processor
|
||||||
|
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
|
|
||||||
async def document_upload(
|
async def document_upload(
|
||||||
@@ -27,16 +29,27 @@ async def document_upload(
|
|||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
source = Path(file_path)
|
source = Path(file_path)
|
||||||
if not source.exists():
|
if not source.exists():
|
||||||
return f"קובץ לא נמצא: {file_path}"
|
return err(f"קובץ לא נמצא: {file_path}")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
if not title:
|
if not title:
|
||||||
title = source.stem
|
title = source.stem
|
||||||
|
|
||||||
|
# INV-TOOL3 / GAP-52: idempotent on (case_id, file content hash). Re-uploading
|
||||||
|
# the same bytes returns the existing document and skips re-copy + re-OCR +
|
||||||
|
# re-embed (the expensive part).
|
||||||
|
content_hash = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||||
|
existing_doc = await db.get_document_by_hash(case_id, content_hash)
|
||||||
|
if existing_doc:
|
||||||
|
return ok({
|
||||||
|
"document": existing_doc,
|
||||||
|
"idempotent_existing": True,
|
||||||
|
}, message=f"הקובץ כבר הועלה לתיק {case_number} (זהה ב-hash) — מוחזר הקיים, ללא עיבוד מחדש.")
|
||||||
|
|
||||||
# Copy file to case directory
|
# Copy file to case directory
|
||||||
case_dir = config.find_case_dir(case_number) / "documents" / "originals"
|
case_dir = config.find_case_dir(case_number) / "documents" / "originals"
|
||||||
case_dir.mkdir(parents=True, exist_ok=True)
|
case_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -52,6 +65,7 @@ async def document_upload(
|
|||||||
doc_type=initial_doc_type,
|
doc_type=initial_doc_type,
|
||||||
title=title,
|
title=title,
|
||||||
file_path=str(dest),
|
file_path=str(dest),
|
||||||
|
content_hash=content_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process document (extract → classify → chunk → embed → store)
|
# Process document (extract → classify → chunk → embed → store)
|
||||||
@@ -87,10 +101,14 @@ async def document_upload(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # git not available in container — non-critical
|
pass # git not available in container — non-critical
|
||||||
|
|
||||||
return json.dumps({
|
await audit.log_action_safe(
|
||||||
|
"document_upload", case_id=case_id, document_id=UUID(doc["id"]),
|
||||||
|
details={"title": title, "doc_type": actual_doc_type},
|
||||||
|
)
|
||||||
|
return ok({
|
||||||
"document": doc,
|
"document": doc,
|
||||||
"processing": result,
|
"processing": result,
|
||||||
}, default=str, ensure_ascii=False, indent=2)
|
})
|
||||||
|
|
||||||
|
|
||||||
async def document_upload_training(
|
async def document_upload_training(
|
||||||
@@ -120,7 +138,7 @@ async def document_upload_training(
|
|||||||
|
|
||||||
source = Path(file_path)
|
source = Path(file_path)
|
||||||
if not source.exists():
|
if not source.exists():
|
||||||
return f"קובץ לא נמצא: {file_path}"
|
return err(f"קובץ לא נמצא: {file_path}")
|
||||||
|
|
||||||
if not title:
|
if not title:
|
||||||
title = source.stem
|
title = source.stem
|
||||||
@@ -195,13 +213,13 @@ async def document_upload_training(
|
|||||||
]
|
]
|
||||||
await db.store_chunks(doc_id, None, chunk_dicts)
|
await db.store_chunks(doc_id, None, chunk_dicts)
|
||||||
|
|
||||||
return json.dumps({
|
return ok({
|
||||||
"corpus_id": str(corpus_id),
|
"corpus_id": str(corpus_id),
|
||||||
"title": title,
|
"title": title,
|
||||||
"pages": page_count,
|
"pages": page_count,
|
||||||
"text_length": len(text),
|
"text_length": len(text),
|
||||||
"chunks": len(chunks) if chunks else 0,
|
"chunks": len(chunks) if chunks else 0,
|
||||||
}, default=str, ensure_ascii=False, indent=2)
|
})
|
||||||
|
|
||||||
|
|
||||||
async def document_get_text(case_number: str, doc_title: str = "") -> str:
|
async def document_get_text(case_number: str, doc_title: str = "") -> str:
|
||||||
@@ -213,16 +231,16 @@ async def document_get_text(case_number: str, doc_title: str = "") -> str:
|
|||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
docs = await db.list_documents(UUID(case["id"]))
|
docs = await db.list_documents(UUID(case["id"]))
|
||||||
if not docs:
|
if not docs:
|
||||||
return f"אין מסמכים בתיק {case_number}."
|
return empty(f"אין מסמכים בתיק {case_number}.")
|
||||||
|
|
||||||
if doc_title:
|
if doc_title:
|
||||||
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
|
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
|
||||||
if not docs:
|
if not docs:
|
||||||
return f"מסמך '{doc_title}' לא נמצא בתיק."
|
return err(f"מסמך '{doc_title}' לא נמצא בתיק.")
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
@@ -233,7 +251,7 @@ async def document_get_text(case_number: str, doc_title: str = "") -> str:
|
|||||||
"text": text[:10000] if text else "(ללא טקסט)",
|
"text": text[:10000] if text else "(ללא טקסט)",
|
||||||
})
|
})
|
||||||
|
|
||||||
return json.dumps(results, ensure_ascii=False, indent=2)
|
return ok(results)
|
||||||
|
|
||||||
|
|
||||||
async def document_list(case_number: str) -> str:
|
async def document_list(case_number: str) -> str:
|
||||||
@@ -244,13 +262,13 @@ async def document_list(case_number: str) -> str:
|
|||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
docs = await db.list_documents(UUID(case["id"]))
|
docs = await db.list_documents(UUID(case["id"]))
|
||||||
if not docs:
|
if not docs:
|
||||||
return f"אין מסמכים בתיק {case_number}."
|
return empty(f"אין מסמכים בתיק {case_number}.")
|
||||||
|
|
||||||
return json.dumps(docs, default=str, ensure_ascii=False, indent=2)
|
return ok(docs)
|
||||||
|
|
||||||
|
|
||||||
async def extract_references(
|
async def extract_references(
|
||||||
@@ -267,12 +285,12 @@ async def extract_references(
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
docs = await db.list_documents(case_id)
|
docs = await db.list_documents(case_id)
|
||||||
if not docs:
|
if not docs:
|
||||||
return f"אין מסמכים בתיק {case_number}."
|
return empty(f"אין מסמכים בתיק {case_number}.")
|
||||||
|
|
||||||
if doc_title:
|
if doc_title:
|
||||||
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
|
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
|
||||||
@@ -294,7 +312,7 @@ async def extract_references(
|
|||||||
"legislation": refs["legislation"],
|
"legislation": refs["legislation"],
|
||||||
})
|
})
|
||||||
|
|
||||||
return json.dumps(results, default=str, ensure_ascii=False, indent=2)
|
return ok(results)
|
||||||
|
|
||||||
|
|
||||||
async def extract_claims(
|
async def extract_claims(
|
||||||
@@ -313,12 +331,12 @@ async def extract_claims(
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
docs = await db.list_documents(case_id)
|
docs = await db.list_documents(case_id)
|
||||||
if not docs:
|
if not docs:
|
||||||
return f"אין מסמכים בתיק {case_number}."
|
return empty(f"אין מסמכים בתיק {case_number}.")
|
||||||
|
|
||||||
# Filter to claims documents (appeal, response) or specific doc
|
# Filter to claims documents (appeal, response) or specific doc
|
||||||
if doc_title:
|
if doc_title:
|
||||||
@@ -327,7 +345,7 @@ async def extract_claims(
|
|||||||
docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection")]
|
docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection")]
|
||||||
|
|
||||||
if not docs:
|
if not docs:
|
||||||
return "לא נמצאו כתבי טענות בתיק."
|
return empty("לא נמצאו כתבי טענות בתיק.")
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
@@ -344,7 +362,11 @@ async def extract_claims(
|
|||||||
)
|
)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
||||||
return json.dumps(results, default=str, ensure_ascii=False, indent=2)
|
await audit.log_action_safe(
|
||||||
|
"extract_claims", case_id=case_id,
|
||||||
|
details={"docs_processed": len(docs), "results": len(results)},
|
||||||
|
)
|
||||||
|
return ok(results)
|
||||||
|
|
||||||
|
|
||||||
async def get_claims(case_number: str, party_role: str = "") -> str:
|
async def get_claims(case_number: str, party_role: str = "") -> str:
|
||||||
@@ -356,7 +378,7 @@ async def get_claims(case_number: str, party_role: str = "") -> str:
|
|||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
claims = await db.get_claims(
|
claims = await db.get_claims(
|
||||||
UUID(case["id"]),
|
UUID(case["id"]),
|
||||||
@@ -364,7 +386,7 @@ async def get_claims(case_number: str, party_role: str = "") -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not claims:
|
if not claims:
|
||||||
return f"אין טענות בתיק {case_number}."
|
return empty(f"אין טענות בתיק {case_number}.")
|
||||||
|
|
||||||
# Format for display
|
# Format for display
|
||||||
role_hebrew = {
|
role_hebrew = {
|
||||||
@@ -382,7 +404,7 @@ async def get_claims(case_number: str, party_role: str = "") -> str:
|
|||||||
"source": c.get("source_document", ""),
|
"source": c.get("source_document", ""),
|
||||||
})
|
})
|
||||||
|
|
||||||
return json.dumps(formatted, default=str, ensure_ascii=False, indent=2)
|
return ok(formatted)
|
||||||
|
|
||||||
|
|
||||||
# Whitelist of doc_type values; mirrors web/app.py:DOC_TYPE_NAMES.
|
# Whitelist of doc_type values; mirrors web/app.py:DOC_TYPE_NAMES.
|
||||||
@@ -417,37 +439,26 @@ async def document_update(
|
|||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return json.dumps({"status": "error",
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
"message": f"תיק {case_number} לא נמצא."},
|
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc_uuid = UUID(doc_id)
|
doc_uuid = UUID(doc_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return json.dumps({"status": "error",
|
return err(f"doc_id לא תקין: {doc_id}")
|
||||||
"message": f"doc_id לא תקין: {doc_id}"},
|
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
doc = await db.get_document(doc_uuid)
|
doc = await db.get_document(doc_uuid)
|
||||||
if not doc:
|
if not doc:
|
||||||
return json.dumps({"status": "error",
|
return err(f"מסמך {doc_id} לא נמצא.")
|
||||||
"message": f"מסמך {doc_id} לא נמצא."},
|
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
if doc.get("case_id") != case["id"]:
|
if doc.get("case_id") != case["id"]:
|
||||||
return json.dumps({"status": "error",
|
return err(f"מסמך {doc_id} לא שייך לתיק {case_number}.")
|
||||||
"message": f"מסמך {doc_id} לא שייך לתיק {case_number}."},
|
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
updates: dict = {}
|
updates: dict = {}
|
||||||
|
|
||||||
if doc_type:
|
if doc_type:
|
||||||
if doc_type not in ALLOWED_DOC_TYPES:
|
if doc_type not in ALLOWED_DOC_TYPES:
|
||||||
return json.dumps({
|
return err(f"doc_type לא תקין: {doc_type}",
|
||||||
"status": "error",
|
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
|
||||||
"message": f"doc_type לא תקין: {doc_type}",
|
|
||||||
"allowed": sorted(ALLOWED_DOC_TYPES),
|
|
||||||
}, ensure_ascii=False, indent=2)
|
|
||||||
updates["doc_type"] = doc_type
|
updates["doc_type"] = doc_type
|
||||||
|
|
||||||
# appraiser_side is optional. The MCP tool can't distinguish "skip" from
|
# appraiser_side is optional. The MCP tool can't distinguish "skip" from
|
||||||
@@ -455,11 +466,8 @@ async def document_update(
|
|||||||
# To clear, the operator must edit metadata directly (rare).
|
# To clear, the operator must edit metadata directly (rare).
|
||||||
if appraiser_side:
|
if appraiser_side:
|
||||||
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
|
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
|
||||||
return json.dumps({
|
return err(f"appraiser_side לא תקין: {appraiser_side}",
|
||||||
"status": "error",
|
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
|
||||||
"message": f"appraiser_side לא תקין: {appraiser_side}",
|
|
||||||
"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s),
|
|
||||||
}, ensure_ascii=False, indent=2)
|
|
||||||
metadata = doc.get("metadata") or {}
|
metadata = doc.get("metadata") or {}
|
||||||
if isinstance(metadata, str):
|
if isinstance(metadata, str):
|
||||||
metadata = json.loads(metadata)
|
metadata = json.loads(metadata)
|
||||||
@@ -467,14 +475,12 @@ async def document_update(
|
|||||||
updates["metadata"] = metadata
|
updates["metadata"] = metadata
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
return json.dumps({"status": "noop", "message": "אין שינוי לבצע."},
|
return ok({"noop": True}, message="אין שינוי לבצע.")
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
await db.update_document(doc_uuid, **updates)
|
await db.update_document(doc_uuid, **updates)
|
||||||
fresh = await db.get_document(doc_uuid)
|
fresh = await db.get_document(doc_uuid)
|
||||||
return json.dumps({
|
return ok({
|
||||||
"status": "completed",
|
|
||||||
"doc_id": doc_id,
|
"doc_id": doc_id,
|
||||||
"doc_type": fresh.get("doc_type"),
|
"doc_type": fresh.get("doc_type"),
|
||||||
"metadata": fresh.get("metadata"),
|
"metadata": fresh.get("metadata"),
|
||||||
}, default=str, ensure_ascii=False, indent=2)
|
})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import db, embeddings, git_sync, research_md
|
from legal_mcp.services import audit, db, embeddings, git_sync, research_md
|
||||||
from legal_mcp.services.lessons import (
|
from legal_mcp.services.lessons import (
|
||||||
CITATION_GUIDANCE,
|
CITATION_GUIDANCE,
|
||||||
DECISION_TEMPLATES,
|
DECISION_TEMPLATES,
|
||||||
@@ -15,12 +15,15 @@ from legal_mcp.services.lessons import (
|
|||||||
GOLDEN_RATIOS,
|
GOLDEN_RATIOS,
|
||||||
OPENING_STRATEGIES,
|
OPENING_STRATEGIES,
|
||||||
PARAGRAPH_LENGTHS,
|
PARAGRAPH_LENGTHS,
|
||||||
|
PRACTICE_AREA_OVERRIDES,
|
||||||
SUMMARY_STRATEGIES,
|
SUMMARY_STRATEGIES,
|
||||||
TRANSITION_PHRASES,
|
TRANSITION_PHRASES,
|
||||||
VALID_OUTCOMES,
|
VALID_OUTCOMES,
|
||||||
|
canonical_outcome,
|
||||||
format_ratios_comment,
|
format_ratios_comment,
|
||||||
get_lessons_for_outcome,
|
get_lessons_for_outcome,
|
||||||
)
|
)
|
||||||
|
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
# Fallback template for cases without expected_outcome
|
# Fallback template for cases without expected_outcome
|
||||||
DECISION_TEMPLATE = """# החלטה
|
DECISION_TEMPLATE = """# החלטה
|
||||||
@@ -156,8 +159,52 @@ async def get_style_guide() -> str:
|
|||||||
f"| {r['discussion'][0]}-{r['discussion'][1]}% "
|
f"| {r['discussion'][0]}-{r['discussion'][1]}% "
|
||||||
f"| {r['summary'][0]}-{r['summary'][1]}% |\n"
|
f"| {r['summary'][0]}-{r['summary'][1]}% |\n"
|
||||||
)
|
)
|
||||||
|
# GAP-51: betterment_levy is a practice_area override (applied on top of the outcome), not an outcome.
|
||||||
|
_bl = PRACTICE_AREA_OVERRIDES["betterment_levy"]["golden_ratios"]
|
||||||
|
result += (
|
||||||
|
f"| {outcome_labels['betterment_levy']} (override לפי תחום) "
|
||||||
|
f"| {_bl['background'][0]}-{_bl['background'][1]}% "
|
||||||
|
f"| {_bl['claims'][0]}-{_bl['claims'][1]}% "
|
||||||
|
f"| {_bl['discussion'][0]}-{_bl['discussion'][1]}% "
|
||||||
|
f"| {_bl['summary'][0]}-{_bl['summary'][1]}% |\n"
|
||||||
|
)
|
||||||
result += "\n"
|
result += "\n"
|
||||||
|
|
||||||
|
# T10 — measured-from-corpus ratios alongside the targets, ⚠️ flags a gap
|
||||||
|
# (actual average outside the target range → revisit the target or the corpus).
|
||||||
|
try:
|
||||||
|
from legal_mcp.services.style_distance import measure_corpus_ratios
|
||||||
|
measured = await measure_corpus_ratios()
|
||||||
|
if measured:
|
||||||
|
result += "### נמדד מהקורפוס בפועל (ממוצע) — ⚠️ = פער מהיעד\n\n"
|
||||||
|
result += "| קבוצה | רקע | טענות | דיון | סיכום |\n|---|------|-------|------|-------|\n"
|
||||||
|
# Per-outcome rows (flagged vs that outcome's target), when outcomes exist.
|
||||||
|
for outcome in VALID_OUTCOMES:
|
||||||
|
m = measured.get(outcome)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
tgt = GOLDEN_RATIOS[outcome]
|
||||||
|
cells = []
|
||||||
|
for sec in ("background", "claims", "discussion", "summary"):
|
||||||
|
val = m["sections"].get(sec)
|
||||||
|
if val is None:
|
||||||
|
cells.append("—")
|
||||||
|
continue
|
||||||
|
lo, hi = tgt[sec]
|
||||||
|
cells.append(f"{val}%" + ("" if lo <= val <= hi else " ⚠️"))
|
||||||
|
result += f"| {outcome_labels[outcome]} (n={m['n']}) | " + " | ".join(cells) + " |\n"
|
||||||
|
# "_all" aggregate — the meaningful row today (corpus outcome unpopulated);
|
||||||
|
# shown informationally (no single target to flag against).
|
||||||
|
allm = measured.get("_all")
|
||||||
|
if allm:
|
||||||
|
cells = [f"{allm['sections'].get(s, '—')}%" if allm['sections'].get(s) is not None else "—"
|
||||||
|
for s in ("background", "claims", "discussion", "summary")]
|
||||||
|
result += f"| כל ההחלטות (n={allm['n']}) | " + " | ".join(cells) + " |\n"
|
||||||
|
result += ("\n_⚠️ = הממוצע בפועל חורג מטווח-היעד; שקול לעדכן יעד ב-/methodology או לבדוק את הקורפוס. "
|
||||||
|
"פיצול לפי-תוצאה יופיע כש-`style_corpus.outcome` יאוכלס._\n\n")
|
||||||
|
except Exception as e: # surfaced, not swallowed
|
||||||
|
result += f"_מדידת יחסי-זהב מהקורפוס נכשלה: {e}_\n\n"
|
||||||
|
|
||||||
# Opening and summary strategies
|
# Opening and summary strategies
|
||||||
result += "## אסטרטגיות פתיחה וסיכום לפי תוצאה\n\n"
|
result += "## אסטרטגיות פתיחה וסיכום לפי תוצאה\n\n"
|
||||||
for outcome in VALID_OUTCOMES:
|
for outcome in VALID_OUTCOMES:
|
||||||
@@ -167,7 +214,14 @@ async def get_style_guide() -> str:
|
|||||||
result += f"- **פתיחה:** {opening['description']} ({opening['paragraphs'][0]}-{opening['paragraphs'][1]} פסקאות)\n"
|
result += f"- **פתיחה:** {opening['description']} ({opening['paragraphs'][0]}-{opening['paragraphs'][1]} פסקאות)\n"
|
||||||
result += f"- **סיכום ({summary['heading']}):** {summary['description']}\n\n"
|
result += f"- **סיכום ({summary['heading']}):** {summary['description']}\n\n"
|
||||||
|
|
||||||
return result
|
# GAP-51: betterment_levy override (practice_area, applied over the outcome)
|
||||||
|
_bo = PRACTICE_AREA_OVERRIDES["betterment_levy"]
|
||||||
|
_op, _sm = _bo["opening_strategy"], _bo["summary_strategy"]
|
||||||
|
result += f"### {outcome_labels['betterment_levy']} (override לפי תחום)\n"
|
||||||
|
result += f"- **פתיחה:** {_op['description']} ({_op['paragraphs'][0]}-{_op['paragraphs'][1]} פסקאות)\n"
|
||||||
|
result += f"- **סיכום ({_sm['heading']}):** {_sm['description']}\n\n"
|
||||||
|
|
||||||
|
return ok(result)
|
||||||
|
|
||||||
|
|
||||||
async def draft_section(
|
async def draft_section(
|
||||||
@@ -175,16 +229,24 @@ async def draft_section(
|
|||||||
section: str,
|
section: str,
|
||||||
instructions: str = "",
|
instructions: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""הרכבת הקשר מלא לניסוח סעיף בהחלטה - כולל עובדות מהמסמכים, תקדימים רלוונטיים ודפוסי סגנון.
|
"""DEPRECATED (GAP-50/INV-TOOL2): העדף את get_block_context — הקשר לפי-בלוק,
|
||||||
|
התואם לארכיטקטורת 12-הבלוקים הקנונית. כלי זה מרכיב הקשר לפי "סעיף"
|
||||||
|
(granularity ישן וחופף ל-get_block_context) ונשמר זמנית לתאימות-לאחור.
|
||||||
|
|
||||||
|
הרכבת הקשר מלא לניסוח סעיף בהחלטה - כולל עובדות מהמסמכים, תקדימים רלוונטיים ודפוסי סגנון.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
case_number: מספר תיק הערר
|
case_number: מספר תיק הערר
|
||||||
section: סוג הסעיף (facts, appellant_claims, respondent_claims, legal_analysis, conclusion, ruling)
|
section: סוג הסעיף (facts, appellant_claims, respondent_claims, legal_analysis, conclusion, ruling)
|
||||||
instructions: הנחיות נוספות לניסוח
|
instructions: הנחיות נוספות לניסוח
|
||||||
|
|
||||||
|
כל קטע ב-case_documents/precedents מלווה ב-provenance: document_id, page
|
||||||
|
(מספר עמוד במסמך-המקור, אם קיים) ו-score — כדי שניתן יהיה לעקוב אחורה
|
||||||
|
אל המקור ולצטטו, ולא לסמוך על התוכן ללא מקור.
|
||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
expected_outcome = case.get("expected_outcome", "")
|
expected_outcome = case.get("expected_outcome", "")
|
||||||
@@ -227,10 +289,16 @@ async def draft_section(
|
|||||||
},
|
},
|
||||||
"section": section,
|
"section": section,
|
||||||
"instructions": instructions,
|
"instructions": instructions,
|
||||||
|
# GAP-47 (INV-TOOL4/G9): surface provenance — document_id + page —
|
||||||
|
# so the writer can cite chunks back to their source (already fetched
|
||||||
|
# by search_similar, was previously discarded).
|
||||||
"case_documents": [
|
"case_documents": [
|
||||||
{
|
{
|
||||||
"document": c["document_title"],
|
"document": c["document_title"],
|
||||||
|
"document_id": str(c["document_id"]),
|
||||||
|
"page": c.get("page_number"),
|
||||||
"section_type": c["section_type"],
|
"section_type": c["section_type"],
|
||||||
|
"score": round(c.get("score", 0.0), 4),
|
||||||
"content": c["content"],
|
"content": c["content"],
|
||||||
}
|
}
|
||||||
for c in case_chunks
|
for c in case_chunks
|
||||||
@@ -239,6 +307,9 @@ async def draft_section(
|
|||||||
{
|
{
|
||||||
"case_number": c["case_number"],
|
"case_number": c["case_number"],
|
||||||
"document": c["document_title"],
|
"document": c["document_title"],
|
||||||
|
"document_id": str(c["document_id"]),
|
||||||
|
"page": c.get("page_number"),
|
||||||
|
"score": round(c.get("score", 0.0), 4),
|
||||||
"content": c["content"][:500],
|
"content": c["content"][:500],
|
||||||
}
|
}
|
||||||
for c in precedent_chunks[:3]
|
for c in precedent_chunks[:3]
|
||||||
@@ -278,7 +349,7 @@ async def draft_section(
|
|||||||
|
|
||||||
context["drafting_guidance"] = guidance
|
context["drafting_guidance"] = guidance
|
||||||
|
|
||||||
return json.dumps(context, ensure_ascii=False, indent=2)
|
return ok(context)
|
||||||
|
|
||||||
|
|
||||||
async def get_chair_directions(case_number: str) -> str:
|
async def get_chair_directions(case_number: str) -> str:
|
||||||
@@ -304,7 +375,7 @@ async def get_chair_directions(case_number: str) -> str:
|
|||||||
case_dir = config.find_case_dir(case_number)
|
case_dir = config.find_case_dir(case_number)
|
||||||
file_path = case_dir / "documents" / "research" / "analysis-and-research.md"
|
file_path = case_dir / "documents" / "research" / "analysis-and-research.md"
|
||||||
result = research_md.extract_chair_directions(file_path)
|
result = research_md.extract_chair_directions(file_path)
|
||||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
return ok(result)
|
||||||
|
|
||||||
|
|
||||||
async def get_decision_template(case_number: str) -> str:
|
async def get_decision_template(case_number: str) -> str:
|
||||||
@@ -317,9 +388,11 @@ async def get_decision_template(case_number: str) -> str:
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
expected_outcome = case.get("expected_outcome", "")
|
# GAP-51: canonicalize outcome + apply betterment_levy practice_area override.
|
||||||
|
expected_outcome = canonical_outcome(case.get("expected_outcome", ""))
|
||||||
|
practice_area = case.get("practice_area", "")
|
||||||
|
|
||||||
format_args = dict(
|
format_args = dict(
|
||||||
case_number=case["case_number"],
|
case_number=case["case_number"],
|
||||||
@@ -332,23 +405,28 @@ async def get_decision_template(case_number: str) -> str:
|
|||||||
|
|
||||||
# Use outcome-specific template if available
|
# Use outcome-specific template if available
|
||||||
if expected_outcome and expected_outcome in DECISION_TEMPLATES:
|
if expected_outcome and expected_outcome in DECISION_TEMPLATES:
|
||||||
# Add ratio comments
|
# Add ratio comments (practice_area-aware)
|
||||||
format_args["ratios_background"] = format_ratios_comment(expected_outcome, "background")
|
format_args["ratios_background"] = format_ratios_comment(expected_outcome, "background", practice_area)
|
||||||
format_args["ratios_claims"] = format_ratios_comment(expected_outcome, "claims")
|
format_args["ratios_claims"] = format_ratios_comment(expected_outcome, "claims", practice_area)
|
||||||
format_args["ratios_discussion"] = format_ratios_comment(expected_outcome, "discussion")
|
format_args["ratios_discussion"] = format_ratios_comment(expected_outcome, "discussion", practice_area)
|
||||||
format_args["ratios_summary"] = format_ratios_comment(expected_outcome, "summary")
|
format_args["ratios_summary"] = format_ratios_comment(expected_outcome, "summary", practice_area)
|
||||||
|
|
||||||
template = DECISION_TEMPLATES[expected_outcome].format(**format_args)
|
# betterment_levy practice_area supplies its own template; else use the outcome's.
|
||||||
|
override = PRACTICE_AREA_OVERRIDES.get(practice_area, {})
|
||||||
|
template_src = override.get("decision_template") or DECISION_TEMPLATES[expected_outcome]
|
||||||
|
template = template_src.format(**format_args)
|
||||||
|
|
||||||
# Add guidance header
|
# Add guidance header (override-aware via get_lessons_for_outcome)
|
||||||
opening = OPENING_STRATEGIES[expected_outcome]
|
lessons_o = get_lessons_for_outcome(expected_outcome, practice_area)
|
||||||
summary = SUMMARY_STRATEGIES[expected_outcome]
|
opening = lessons_o["opening_strategy"]
|
||||||
|
summary = lessons_o["summary_strategy"]
|
||||||
header = (
|
header = (
|
||||||
f"<!-- תבנית מותאמת ל: {expected_outcome} -->\n"
|
f"<!-- תבנית מותאמת ל: {expected_outcome}"
|
||||||
|
f"{' / ' + practice_area if practice_area in PRACTICE_AREA_OVERRIDES else ''} -->\n"
|
||||||
f"<!-- פתיחת דיון: {opening['description']} -->\n"
|
f"<!-- פתיחת דיון: {opening['description']} -->\n"
|
||||||
f"<!-- סיכום: {summary['description']} -->\n\n"
|
f"<!-- סיכום: {summary['description']} -->\n\n"
|
||||||
)
|
)
|
||||||
return header + template
|
return ok(header + template)
|
||||||
else:
|
else:
|
||||||
# Fallback to generic template
|
# Fallback to generic template
|
||||||
template = DECISION_TEMPLATE.format(**format_args)
|
template = DECISION_TEMPLATE.format(**format_args)
|
||||||
@@ -357,7 +435,7 @@ async def get_decision_template(case_number: str) -> str:
|
|||||||
"<!-- לא הוגדרה תוצאה צפויה. הגדר expected_outcome בתיק לקבלת תבנית מותאמת. -->\n"
|
"<!-- לא הוגדרה תוצאה צפויה. הגדר expected_outcome בתיק לקבלת תבנית מותאמת. -->\n"
|
||||||
f"<!-- ערכים אפשריים: {', '.join(VALID_OUTCOMES)} -->\n\n"
|
f"<!-- ערכים אפשריים: {', '.join(VALID_OUTCOMES)} -->\n\n"
|
||||||
) + template
|
) + template
|
||||||
return template
|
return ok(template)
|
||||||
|
|
||||||
|
|
||||||
async def validate_decision(case_number: str) -> str:
|
async def validate_decision(case_number: str) -> str:
|
||||||
@@ -370,15 +448,15 @@ async def validate_decision(case_number: str) -> str:
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await qa_validator.validate_decision(case_id)
|
result = await qa_validator.validate_decision(case_id)
|
||||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
return ok(result)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
async def export_docx(case_number: str, output_path: str = "") -> str:
|
async def export_docx(case_number: str, output_path: str = "") -> str:
|
||||||
@@ -395,28 +473,45 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
|
# INV-EX3 / INV-QA3: a decision cannot be exported while critical QA gates
|
||||||
|
# fail (or before QA has been run at all). Gate on the STORED qa_results —
|
||||||
|
# cheap SELECT, no LLM re-run.
|
||||||
|
if not await db.qa_run_exists(case_id):
|
||||||
|
return err("ייצוא נחסם: בקרת איכות (QA) טרם רצה על התיק. "
|
||||||
|
"הרץ validate_decision לפני ייצוא.")
|
||||||
|
|
||||||
|
critical = await db.get_critical_qa_failures(case_id)
|
||||||
|
if critical:
|
||||||
|
gate_names = ", ".join(r["check_name"] for r in critical)
|
||||||
|
return err(
|
||||||
|
f"ייצוא נחסם: שערי QA קריטיים נכשלו ({gate_names}). "
|
||||||
|
f"תקן את הליקויים והרץ validate_decision מחדש לפני ייצוא.",
|
||||||
|
data={"failed_gates": [r["check_name"] for r in critical]},
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
path = await docx_exporter.export_decision(case_id, output_path or None)
|
path = await docx_exporter.export_decision(case_id, output_path or None)
|
||||||
# Register this export as the new source of truth
|
# Register this export as the new source of truth
|
||||||
await db.set_active_draft_path(case_id, path)
|
await db.set_active_draft_path(case_id, path)
|
||||||
|
await audit.log_action_safe(
|
||||||
|
"export_docx", case_id=case_id,
|
||||||
|
details={"path": str(path)},
|
||||||
|
)
|
||||||
|
await db.mark_blocks_stale(case_id, False)
|
||||||
case_dir = config.find_case_dir(case_number)
|
case_dir = config.find_case_dir(case_number)
|
||||||
if case_dir.exists():
|
if case_dir.exists():
|
||||||
git_sync.commit_and_push(case_dir, f"ייצוא DOCX: {Path(path).name}")
|
git_sync.commit_and_push(case_dir, f"ייצוא DOCX: {Path(path).name}")
|
||||||
return json.dumps({
|
return ok({
|
||||||
"status": "completed",
|
|
||||||
"path": path,
|
"path": path,
|
||||||
"active_draft_path": path,
|
"active_draft_path": path,
|
||||||
"message": f"DOCX נוצר: {path}",
|
"message": f"DOCX נוצר: {path}",
|
||||||
}, ensure_ascii=False, indent=2)
|
})
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return json.dumps({
|
return err(str(e))
|
||||||
"status": "error",
|
|
||||||
"message": str(e),
|
|
||||||
}, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Interim draft (pre-ruling) ────────────────────────────────────
|
# ── Interim draft (pre-ruling) ────────────────────────────────────
|
||||||
@@ -440,16 +535,40 @@ async def extract_appraiser_facts(case_number: str) -> str:
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return json.dumps({"status": "error",
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
"message": f"תיק {case_number} לא נמצא."},
|
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
try:
|
try:
|
||||||
result = await appraiser_facts_extractor.extract_appraiser_facts(case_id)
|
result = await appraiser_facts_extractor.extract_appraiser_facts(case_id)
|
||||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
return ok(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"status": "error", "message": str(e)},
|
return err(str(e))
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
|
async def get_appraiser_facts(case_number: str) -> str:
|
||||||
|
"""קריאת עובדות-השמאי שכבר חולצו לתיק — ללא הרצת חילוץ מחדש (INV-TOOL4 / GAP-44).
|
||||||
|
|
||||||
|
ה-get המקביל ל-extract_appraiser_facts: מחזיר את העובדות השמורות בטבלת
|
||||||
|
appraiser_facts + סתירות מזוהות בין שמאים, בלי קריאת-LLM יקרה ולא-דטרמיניסטית.
|
||||||
|
מחזיר facts ריק אם החילוץ טרם רץ (status=ok, count=0) — לא שגיאה.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: מספר תיק הערר
|
||||||
|
"""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
case_id = UUID(case["id"])
|
||||||
|
try:
|
||||||
|
facts = await db.list_appraiser_facts(case_id)
|
||||||
|
conflicts = await db.detect_appraiser_conflicts(case_id)
|
||||||
|
return ok({
|
||||||
|
"case_number": case_number,
|
||||||
|
"count": len(facts),
|
||||||
|
"facts": facts,
|
||||||
|
"conflicts": conflicts,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
||||||
@@ -468,9 +587,7 @@ async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return json.dumps({"status": "error",
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
"message": f"תיק {case_number} לא נמצא."},
|
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
# Make sure appraiser facts exist before writing block-tet (which depends on them).
|
# Make sure appraiser facts exist before writing block-tet (which depends on them).
|
||||||
@@ -499,13 +616,12 @@ async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
|||||||
"error": str(e),
|
"error": str(e),
|
||||||
})
|
})
|
||||||
|
|
||||||
return json.dumps({
|
return ok({
|
||||||
"status": "completed",
|
|
||||||
"blocks": results,
|
"blocks": results,
|
||||||
"appraiser_facts_run": facts_run,
|
"appraiser_facts_run": facts_run,
|
||||||
"total_words": sum(r.get("word_count", 0) for r in results),
|
"total_words": sum(r.get("word_count", 0) for r in results),
|
||||||
"completed": sum(1 for r in results if r["status"] == "completed"),
|
"completed": sum(1 for r in results if r["status"] == "completed"),
|
||||||
}, default=str, ensure_ascii=False, indent=2)
|
})
|
||||||
|
|
||||||
|
|
||||||
async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
||||||
@@ -521,9 +637,7 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return json.dumps({"status": "error",
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
"message": f"תיק {case_number} לא נמצא."},
|
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -534,16 +648,14 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
|||||||
case_dir = config.find_case_dir(case_number)
|
case_dir = config.find_case_dir(case_number)
|
||||||
if case_dir.exists():
|
if case_dir.exists():
|
||||||
git_sync.commit_and_push(case_dir, f"טיוטת ביניים: {Path(path).name}")
|
git_sync.commit_and_push(case_dir, f"טיוטת ביניים: {Path(path).name}")
|
||||||
return json.dumps({
|
return ok({
|
||||||
"status": "completed",
|
|
||||||
"mode": "interim",
|
"mode": "interim",
|
||||||
"path": path,
|
"path": path,
|
||||||
"active_draft_path": path,
|
"active_draft_path": path,
|
||||||
"message": f"טיוטת ביניים נוצרה: {path}",
|
"message": f"טיוטת ביניים נוצרה: {path}",
|
||||||
}, ensure_ascii=False, indent=2)
|
})
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return json.dumps({"status": "error", "message": str(e)},
|
return err(str(e))
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
||||||
@@ -562,35 +674,30 @@ async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return json.dumps({"status": "error",
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
"message": f"תיק {case_number} לא נמצא."},
|
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
export_dir = config.find_case_dir(case_number) / "exports"
|
export_dir = config.find_case_dir(case_number) / "exports"
|
||||||
edit_path = export_dir / edit_filename if "/" not in edit_filename else Path(edit_filename)
|
edit_path = export_dir / edit_filename if "/" not in edit_filename else Path(edit_filename)
|
||||||
if not edit_path.exists():
|
if not edit_path.exists():
|
||||||
return json.dumps({"status": "error",
|
return err(f"קובץ לא נמצא: {edit_path}")
|
||||||
"message": f"קובץ לא נמצא: {edit_path}"},
|
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
retrofit_result = docx_retrofit.retrofit_bookmarks(edit_path)
|
retrofit_result = docx_retrofit.retrofit_bookmarks(edit_path)
|
||||||
await db.set_active_draft_path(case_id, str(edit_path))
|
await db.set_active_draft_path(case_id, str(edit_path))
|
||||||
|
await db.mark_blocks_stale(case_id, True)
|
||||||
case_dir = config.find_case_dir(case_number)
|
case_dir = config.find_case_dir(case_number)
|
||||||
if case_dir.exists():
|
if case_dir.exists():
|
||||||
git_sync.commit_and_push(case_dir, f"גרסת עריכה: {edit_path.name}")
|
git_sync.commit_and_push(case_dir, f"גרסת עריכה: {edit_path.name}")
|
||||||
return json.dumps({
|
return ok({
|
||||||
"status": "completed",
|
|
||||||
"active_draft_path": str(edit_path),
|
"active_draft_path": str(edit_path),
|
||||||
"bookmarks_added": retrofit_result.get("bookmarks_added", []),
|
"bookmarks_added": retrofit_result.get("bookmarks_added", []),
|
||||||
"missing_blocks": retrofit_result.get("missing_blocks", []),
|
"missing_blocks": retrofit_result.get("missing_blocks", []),
|
||||||
"structural_fallback": retrofit_result.get("structural_fallback", []),
|
"structural_fallback": retrofit_result.get("structural_fallback", []),
|
||||||
"existing_bookmarks": retrofit_result.get("existing_bookmarks", []),
|
"existing_bookmarks": retrofit_result.get("existing_bookmarks", []),
|
||||||
}, ensure_ascii=False, indent=2)
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"status": "error", "message": str(e)},
|
return err(str(e))
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_bookmarks(case_number: str) -> str:
|
async def list_bookmarks(case_number: str) -> str:
|
||||||
@@ -602,26 +709,20 @@ async def list_bookmarks(case_number: str) -> str:
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return json.dumps({"status": "error",
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
"message": f"תיק {case_number} לא נמצא."},
|
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
active_path = await db.get_active_draft_path(UUID(case["id"]))
|
active_path = await db.get_active_draft_path(UUID(case["id"]))
|
||||||
if not active_path or not Path(active_path).exists():
|
if not active_path or not Path(active_path).exists():
|
||||||
return json.dumps({"status": "no_active_draft",
|
return empty("לא נמצא active_draft. הרץ ייצוא או העלה עריכה.")
|
||||||
"message": "לא נמצא active_draft. הרץ ייצוא או העלה עריכה."},
|
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
names = docx_reviser.list_bookmarks(active_path)
|
names = docx_reviser.list_bookmarks(active_path)
|
||||||
return json.dumps({
|
return ok({
|
||||||
"status": "completed",
|
|
||||||
"active_draft_path": active_path,
|
"active_draft_path": active_path,
|
||||||
"bookmarks": names,
|
"bookmarks": names,
|
||||||
}, ensure_ascii=False, indent=2)
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"status": "error", "message": str(e)},
|
return err(str(e))
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
async def revise_draft(case_number: str, revisions_json: str,
|
async def revise_draft(case_number: str, revisions_json: str,
|
||||||
@@ -643,22 +744,17 @@ async def revise_draft(case_number: str, revisions_json: str,
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return json.dumps({"status": "error",
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
"message": f"תיק {case_number} לא נמצא."},
|
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
active_path = await db.get_active_draft_path(case_id)
|
active_path = await db.get_active_draft_path(case_id)
|
||||||
if not active_path or not Path(active_path).exists():
|
if not active_path or not Path(active_path).exists():
|
||||||
return json.dumps({"status": "error",
|
return err("אין active_draft. הרץ ייצוא או apply_user_edit קודם.")
|
||||||
"message": "אין active_draft. הרץ ייצוא או apply_user_edit קודם."},
|
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = json.loads(revisions_json) if isinstance(revisions_json, str) else revisions_json
|
raw = json.loads(revisions_json) if isinstance(revisions_json, str) else revisions_json
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return json.dumps({"status": "error", "message": f"JSON לא תקף: {e}"},
|
return err(f"JSON לא תקף: {e}")
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
revisions = []
|
revisions = []
|
||||||
for item in raw:
|
for item in raw:
|
||||||
@@ -690,14 +786,14 @@ async def revise_draft(case_number: str, revisions_json: str,
|
|||||||
active_path, output_path, revisions, author=author,
|
active_path, output_path, revisions, author=author,
|
||||||
)
|
)
|
||||||
await db.set_active_draft_path(case_id, str(output_path))
|
await db.set_active_draft_path(case_id, str(output_path))
|
||||||
|
await db.mark_blocks_stale(case_id, True)
|
||||||
case_dir = config.find_case_dir(case_number)
|
case_dir = config.find_case_dir(case_number)
|
||||||
if case_dir.exists():
|
if case_dir.exists():
|
||||||
git_sync.commit_and_push(
|
git_sync.commit_and_push(
|
||||||
case_dir,
|
case_dir,
|
||||||
f"revise: טיוטה-v{next_ver} ({result.applied} שינויים, {result.failed} נכשלו)",
|
f"revise: טיוטה-v{next_ver} ({result.applied} שינויים, {result.failed} נכשלו)",
|
||||||
)
|
)
|
||||||
return json.dumps({
|
return ok({
|
||||||
"status": "completed",
|
|
||||||
"output_path": str(output_path),
|
"output_path": str(output_path),
|
||||||
"version": next_ver,
|
"version": next_ver,
|
||||||
"applied": result.applied,
|
"applied": result.applied,
|
||||||
@@ -707,10 +803,9 @@ async def revise_draft(case_number: str, revisions_json: str,
|
|||||||
{"id": r.id, "status": r.status, "error": r.error}
|
{"id": r.id, "status": r.status, "error": r.error}
|
||||||
for r in result.results
|
for r in result.results
|
||||||
],
|
],
|
||||||
}, ensure_ascii=False, indent=2)
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"status": "error", "message": str(e)},
|
return err(str(e))
|
||||||
ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_block_context(case_number: str, block_id: str, instructions: str = "") -> str:
|
async def get_block_context(case_number: str, block_id: str, instructions: str = "") -> str:
|
||||||
@@ -725,14 +820,14 @@ async def get_block_context(case_number: str, block_id: str, instructions: str =
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
try:
|
try:
|
||||||
ctx = await block_writer.get_block_context(case_id, block_id, instructions)
|
ctx = await block_writer.get_block_context(case_id, block_id, instructions)
|
||||||
return json.dumps(ctx, default=str, ensure_ascii=False, indent=2)
|
return ok(ctx)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return str(e)
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
async def save_block_content(case_number: str, block_id: str, content: str) -> str:
|
async def save_block_content(case_number: str, block_id: str, content: str) -> str:
|
||||||
@@ -747,14 +842,14 @@ async def save_block_content(case_number: str, block_id: str, content: str) -> s
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
try:
|
try:
|
||||||
result = await block_writer.save_block_content(case_id, block_id, content)
|
result = await block_writer.save_block_content(case_id, block_id, content)
|
||||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
return ok(result)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return str(e)
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
async def analyze_style(appeal_subtype: str = "") -> str:
|
async def analyze_style(appeal_subtype: str = "") -> str:
|
||||||
@@ -767,7 +862,7 @@ async def analyze_style(appeal_subtype: str = "") -> str:
|
|||||||
from legal_mcp.services.style_analyzer import analyze_corpus
|
from legal_mcp.services.style_analyzer import analyze_corpus
|
||||||
|
|
||||||
result = await analyze_corpus(appeal_subtype)
|
result = await analyze_corpus(appeal_subtype)
|
||||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
return ok(result)
|
||||||
|
|
||||||
|
|
||||||
async def write_block(
|
async def write_block(
|
||||||
@@ -786,15 +881,15 @@ async def write_block(
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await block_writer.write_and_store_block(case_id, block_id, instructions)
|
result = await block_writer.write_and_store_block(case_id, block_id, instructions)
|
||||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
return ok(result)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return str(e)
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
async def write_all_blocks(
|
async def write_all_blocks(
|
||||||
@@ -814,7 +909,7 @@ async def write_all_blocks(
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
@@ -848,8 +943,8 @@ async def write_all_blocks(
|
|||||||
break
|
break
|
||||||
|
|
||||||
total_words = sum(r.get("word_count", 0) for r in results)
|
total_words = sum(r.get("word_count", 0) for r in results)
|
||||||
return json.dumps({
|
return ok({
|
||||||
"blocks": results,
|
"blocks": results,
|
||||||
"total_words": total_words,
|
"total_words": total_words,
|
||||||
"completed": sum(1 for r in results if r["status"] == "completed"),
|
"completed": sum(1 for r in results if r["status"] == "completed"),
|
||||||
}, default=str, ensure_ascii=False, indent=2)
|
})
|
||||||
|
|||||||
58
mcp-server/src/legal_mcp/tools/envelope.py
Normal file
58
mcp-server/src/legal_mcp/tools/envelope.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""מעטפת-תשובה קנונית לכלי-ה-MCP (GAP-48 / INV-TOOL1).
|
||||||
|
|
||||||
|
מקור-אמת יחיד לצורת-הפלט של כל כלי. כל כלי שעבר מיגרציה מחזיר מחרוזת-JSON
|
||||||
|
אחידה ``{status, data, message}`` — לא list-לפעמים, string-לפעמים, ``{error}``
|
||||||
|
לפעמים. שלושת המצבים מובחנים מפורשות (INV-TOOL1):
|
||||||
|
|
||||||
|
status — "ok" הצלחה עם payload ב-``data``.
|
||||||
|
"empty" שאילתה תקינה שלא החזירה תוצאות (מובחן מ-error).
|
||||||
|
"error" הקריאה נכשלה; ``message`` מסביר.
|
||||||
|
data — ה-payload (list/dict/scalar) או null.
|
||||||
|
message — הערה קריאה-לאדם (עברית), בעיקר ל-empty/error.
|
||||||
|
|
||||||
|
מחליף את ה-``_ok``/``_err`` שהשתכפלו ב-5 קבצי-כלים עם 3 מוסכמות שונות (G2).
|
||||||
|
|
||||||
|
צרכן-API ב-``web/`` שמעביר פלט-כלי ל-HTTP חייב **לפרק** את המעטפת
|
||||||
|
(להחזיר ``data``) כדי לשמר את חוזה-ה-UI↔API (X6) — ראה ``envelope_unwrap``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _dump(obj: dict) -> str:
|
||||||
|
return json.dumps(obj, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def ok(data: Any = None, message: str = "") -> str:
|
||||||
|
"""הצלחה עם payload."""
|
||||||
|
return _dump({"status": "ok", "data": data, "message": message})
|
||||||
|
|
||||||
|
|
||||||
|
def empty(message: str = "", data: Any = None) -> str:
|
||||||
|
"""שאילתה תקינה ללא תוצאות — מובחן מהצלחה (data לא-ריק) ומשגיאה."""
|
||||||
|
return _dump({"status": "empty", "data": [] if data is None else data, "message": message})
|
||||||
|
|
||||||
|
|
||||||
|
def err(message: str, *, data: Any = None) -> str:
|
||||||
|
"""כשל בקריאה."""
|
||||||
|
return _dump({"status": "error", "data": data, "message": message})
|
||||||
|
|
||||||
|
|
||||||
|
def envelope_unwrap(parsed: Any) -> Any:
|
||||||
|
"""פירוק מעטפת לצורך צרכן-API שמשמר חוזה-HTTP ישן.
|
||||||
|
|
||||||
|
בהינתן dict-מעטפת מפורסר (``json.loads`` של פלט-כלי שעבר מיגרציה):
|
||||||
|
status=="ok" → מחזיר ``data`` (ה-payload המקורי, למשל list).
|
||||||
|
status in {empty,error}→ מחזיר ``{"message": message}`` (תאימות-לאחור
|
||||||
|
לצורה שצרכני-ה-API החזירו על מחרוזת-פרוזה).
|
||||||
|
קלט שאינו מעטפת (אין מפתח ``status``) מוחזר כמות-שהוא — בטוח לתקופת-המעבר
|
||||||
|
שבה רק חלק מהכלים עברו מיגרציה.
|
||||||
|
"""
|
||||||
|
if isinstance(parsed, dict) and "status" in parsed and "data" in parsed:
|
||||||
|
if parsed["status"] == "ok":
|
||||||
|
return parsed["data"]
|
||||||
|
return {"message": parsed.get("message", "")}
|
||||||
|
return parsed
|
||||||
@@ -14,9 +14,8 @@ decisions and enforces the required metadata at the tool boundary.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from legal_mcp.services import internal_decisions as int_svc
|
from legal_mcp.services import internal_decisions as int_svc
|
||||||
|
from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
# Valid Hebrew district names (matches _COURT_TO_DISTRICT in service)
|
# Valid Hebrew district names (matches _COURT_TO_DISTRICT in service)
|
||||||
VALID_DISTRICTS = {"ירושלים", "מרכז", "תל אביב", "תל-אביב", "צפון", "דרום", "חיפה", "ארצי"}
|
VALID_DISTRICTS = {"ירושלים", "מרכז", "תל אביב", "תל-אביב", "צפון", "דרום", "חיפה", "ארצי"}
|
||||||
@@ -26,14 +25,6 @@ VALID_DISTRICTS = {"ירושלים", "מרכז", "תל אביב", "תל-אביב
|
|||||||
VALID_PROCEEDING_TYPES = {"ערר", 'בל"מ'}
|
VALID_PROCEEDING_TYPES = {"ערר", 'בל"מ'}
|
||||||
|
|
||||||
|
|
||||||
def _ok(payload) -> str:
|
|
||||||
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
|
||||||
|
|
||||||
|
|
||||||
def _err(msg: str) -> str:
|
|
||||||
return json.dumps({"error": msg}, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
async def internal_decision_upload(
|
async def internal_decision_upload(
|
||||||
file_path: str,
|
file_path: str,
|
||||||
case_number: str,
|
case_number: str,
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import argument_aggregator, db
|
from legal_mcp.services import argument_aggregator, db
|
||||||
|
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
|
|
||||||
async def aggregate_claims_to_arguments(
|
async def aggregate_claims_to_arguments(
|
||||||
@@ -20,17 +20,14 @@ async def aggregate_claims_to_arguments(
|
|||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return json.dumps(
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
{"status": "error", "message": f"תיק {case_number} לא נמצא."},
|
|
||||||
ensure_ascii=False, indent=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
result = await argument_aggregator.aggregate_claims_to_arguments(
|
result = await argument_aggregator.aggregate_claims_to_arguments(
|
||||||
case_id, force=force,
|
case_id, force=force,
|
||||||
)
|
)
|
||||||
result["case_number"] = case_number
|
result["case_number"] = case_number
|
||||||
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
return ok(result)
|
||||||
|
|
||||||
|
|
||||||
async def get_legal_arguments(
|
async def get_legal_arguments(
|
||||||
@@ -46,21 +43,16 @@ async def get_legal_arguments(
|
|||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return json.dumps(
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
{"status": "error", "message": f"תיק {case_number} לא נמצא."},
|
|
||||||
ensure_ascii=False, indent=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
args = await argument_aggregator.get_legal_arguments(case_id, party=party)
|
args = await argument_aggregator.get_legal_arguments(case_id, party=party)
|
||||||
|
|
||||||
if not args:
|
if not args:
|
||||||
return json.dumps({
|
return empty(
|
||||||
"status": "empty",
|
"לא נמצאו טיעונים מאוגדים. הרץ aggregate_claims_to_arguments תחילה.",
|
||||||
"case_number": case_number,
|
data={"case_number": case_number, "arguments": []},
|
||||||
"message": "לא נמצאו טיעונים מאוגדים. הרץ aggregate_claims_to_arguments תחילה.",
|
)
|
||||||
"arguments": [],
|
|
||||||
}, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
# Group by party for nicer display.
|
# Group by party for nicer display.
|
||||||
party_he = {
|
party_he = {
|
||||||
@@ -75,9 +67,8 @@ async def get_legal_arguments(
|
|||||||
label = party_he.get(a["party"], a["party"])
|
label = party_he.get(a["party"], a["party"])
|
||||||
by_party.setdefault(label, []).append(a)
|
by_party.setdefault(label, []).append(a)
|
||||||
|
|
||||||
return json.dumps({
|
return ok({
|
||||||
"status": "ok",
|
|
||||||
"case_number": case_number,
|
"case_number": case_number,
|
||||||
"total": len(args),
|
"total": len(args),
|
||||||
"by_party": by_party,
|
"by_party": by_party,
|
||||||
}, ensure_ascii=False, indent=2, default=str)
|
})
|
||||||
|
|||||||
@@ -18,18 +18,10 @@ Three tools:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import db
|
from legal_mcp.services import db
|
||||||
|
from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
def _ok(payload) -> str:
|
|
||||||
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
|
||||||
|
|
||||||
|
|
||||||
def _err(msg: str) -> str:
|
|
||||||
return json.dumps({"error": msg}, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_case_id(case_number: str) -> UUID | None:
|
async def _resolve_case_id(case_number: str) -> UUID | None:
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
This is distinct from:
|
This is distinct from:
|
||||||
|
|
||||||
- ``precedents`` (case_precedents table) — chair-attached quotes scoped to
|
- ``precedents`` (case_precedents table) — chair-attached quotes scoped to
|
||||||
a specific case section. Use ``precedent_search_library`` for that.
|
a specific case section. Use ``search_case_precedents`` for that (GAP-49:
|
||||||
|
renamed from the misleading ``precedent_search_library``).
|
||||||
- ``style_corpus`` (Daphna's prior decisions) — searched via
|
- ``style_corpus`` (Daphna's prior decisions) — searched via
|
||||||
``search_decisions`` for style/voice.
|
``search_decisions`` for style/voice.
|
||||||
|
|
||||||
@@ -17,19 +18,11 @@ the chair approves them — per project review policy.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
import time
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import db, precedent_library, telemetry
|
from legal_mcp.services import db, precedent_library, telemetry
|
||||||
|
from legal_mcp.tools.envelope import empty, err as _err, ok as _ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
def _ok(payload) -> str:
|
|
||||||
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
|
||||||
|
|
||||||
|
|
||||||
def _err(msg: str) -> str:
|
|
||||||
return json.dumps({"error": msg}, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
async def precedent_library_upload(
|
async def precedent_library_upload(
|
||||||
@@ -215,6 +208,37 @@ async def precedent_extract_metadata(case_law_id: str) -> str:
|
|||||||
return _ok(result)
|
return _ok(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def precedent_reindex(case_law_id: str) -> str:
|
||||||
|
"""re-chunk + re-embed פסיקה קיימת מה-full_text השמור (FU-3/GAP-09).
|
||||||
|
|
||||||
|
לתיקון drift של embeddings או אחרי שינוי-תוכן. אינו מריץ OCR/LLM — רק
|
||||||
|
chunking + voyage embeddings. idempotent (מוחק ובונה chunks מחדש).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cid = UUID(case_law_id)
|
||||||
|
except ValueError:
|
||||||
|
return _err("case_law_id לא תקין")
|
||||||
|
try:
|
||||||
|
from legal_mcp.services import ingest
|
||||||
|
result = await ingest.reindex_case_law(cid)
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def extraction_status() -> str:
|
||||||
|
"""סטטוס תור-החילוץ — כמה פסיקות ממתינות לחילוץ metadata/halacha (INV-TOOL4 / GAP-45).
|
||||||
|
|
||||||
|
חושף את התור ש-precedent_process_pending מרוקן: עומק-תור + גיל הבקשה
|
||||||
|
הוותיקה ביותר לכל סוג. read-only — אינו מרוקן את התור.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
status = await db.extraction_queue_status()
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok(status)
|
||||||
|
|
||||||
|
|
||||||
async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str:
|
async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str:
|
||||||
"""ריקון תור בקשות חילוץ שנערמו ע"י כפתורי ה-UI. kind: 'metadata' או 'halacha'.
|
"""ריקון תור בקשות חילוץ שנערמו ע"י כפתורי ה-UI. kind: 'metadata' או 'halacha'.
|
||||||
|
|
||||||
@@ -262,7 +286,7 @@ async def search_precedent_library(
|
|||||||
Returns: רשימה מדורגת. כל פריט הוא {"type": "halacha"|"passage", "score", ...}.
|
Returns: רשימה מדורגת. כל פריט הוא {"type": "halacha"|"passage", "score", ...}.
|
||||||
"""
|
"""
|
||||||
if not query or len(query.strip()) < 2:
|
if not query or len(query.strip()) < 2:
|
||||||
return json.dumps([], ensure_ascii=False)
|
return empty("שאילתה קצרה מדי (פחות מ-2 תווים).")
|
||||||
q = query.strip()
|
q = query.strip()
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
results = await precedent_library.search_library(
|
results = await precedent_library.search_library(
|
||||||
@@ -332,7 +356,22 @@ async def halacha_review(
|
|||||||
return _ok(row)
|
return _ok(row)
|
||||||
|
|
||||||
|
|
||||||
async def halachot_pending(limit: int = 100) -> str:
|
async def halachot_pending(limit: int = 100, include_low_quality: bool = False) -> str:
|
||||||
"""תור ההלכות הממתינות לאישור (review_status='pending_review')."""
|
"""תור ההלכות הממתינות לאישור (review_status='pending_review').
|
||||||
rows = await db.list_halachot(review_status="pending_review", limit=limit)
|
|
||||||
|
כברירת-מחדל (#84.1, #84.3) התור **מסונן** — הלכות עם דגל-איכות כלשהו
|
||||||
|
(application / ציטוט-לא-מאומת / קטוע / obiter / restatement דק / לא-נתמך /
|
||||||
|
near-duplicate) מוסתרות (הן שייכות ל'דורש תיקון-חילוץ', לא לתור-האישור),
|
||||||
|
ו**ממוין לפי עדיפות** (טופלו-לרעה תחילה, אז הכי לא-ודאיים, אז הישנים).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: מספר מקסימלי.
|
||||||
|
include_low_quality: True כדי לחשוף גם פריטים מסומני-איכות (בקט 'דורש תיקון').
|
||||||
|
"""
|
||||||
|
rows = await db.list_halachot(
|
||||||
|
review_status="pending_review",
|
||||||
|
limit=limit,
|
||||||
|
exclude_low_quality=not include_low_quality,
|
||||||
|
order_by_priority=True,
|
||||||
|
)
|
||||||
return _ok(rows)
|
return _ok(rows)
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ free-text citations the chair attaches during the compose phase.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import db
|
from legal_mcp.services import db
|
||||||
|
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
|
|
||||||
async def precedent_attach(
|
async def precedent_attach(
|
||||||
@@ -34,14 +33,22 @@ async def precedent_attach(
|
|||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False)
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
pdf_uuid: UUID | None = None
|
pdf_uuid: UUID | None = None
|
||||||
if pdf_document_id:
|
if pdf_document_id:
|
||||||
try:
|
try:
|
||||||
pdf_uuid = UUID(pdf_document_id)
|
pdf_uuid = UUID(pdf_document_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return json.dumps({"error": "pdf_document_id לא תקין"}, ensure_ascii=False)
|
return err("pdf_document_id לא תקין")
|
||||||
|
|
||||||
|
# INV-TOOL3 / GAP-52: idempotent on (case_id, section_id, citation, quote).
|
||||||
|
# Re-attaching the same quote to the same section returns the existing row.
|
||||||
|
for _p in await db.list_case_precedents(UUID(case["id"])):
|
||||||
|
if (_p.get("citation") == citation and _p.get("quote") == quote
|
||||||
|
and (_p.get("section_id") or None) == (section_id or None)):
|
||||||
|
_p["idempotent_existing"] = True
|
||||||
|
return ok(_p)
|
||||||
|
|
||||||
row = await db.create_case_precedent(
|
row = await db.create_case_precedent(
|
||||||
case_id=UUID(case["id"]),
|
case_id=UUID(case["id"]),
|
||||||
@@ -52,17 +59,17 @@ async def precedent_attach(
|
|||||||
pdf_document_id=pdf_uuid,
|
pdf_document_id=pdf_uuid,
|
||||||
practice_area=case.get("practice_area"),
|
practice_area=case.get("practice_area"),
|
||||||
)
|
)
|
||||||
return json.dumps(row, ensure_ascii=False, indent=2, default=str)
|
return ok(row)
|
||||||
|
|
||||||
|
|
||||||
async def precedent_list(case_number: str) -> str:
|
async def precedent_list(case_number: str) -> str:
|
||||||
"""רשימת כל הפסיקות שצורפו לתיק, ממוינות לפי סעיף ואז לפי זמן יצירה."""
|
"""רשימת כל הפסיקות שצורפו לתיק, ממוינות לפי סעיף ואז לפי זמן יצירה."""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False)
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
rows = await db.list_case_precedents(UUID(case["id"]))
|
rows = await db.list_case_precedents(UUID(case["id"]))
|
||||||
return json.dumps(rows, ensure_ascii=False, indent=2, default=str)
|
return ok(rows)
|
||||||
|
|
||||||
|
|
||||||
async def precedent_remove(precedent_id: str) -> str:
|
async def precedent_remove(precedent_id: str) -> str:
|
||||||
@@ -70,18 +77,18 @@ async def precedent_remove(precedent_id: str) -> str:
|
|||||||
try:
|
try:
|
||||||
pid = UUID(precedent_id)
|
pid = UUID(precedent_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return json.dumps({"error": "precedent_id לא תקין"}, ensure_ascii=False)
|
return err("precedent_id לא תקין")
|
||||||
|
|
||||||
ok = await db.delete_case_precedent(pid)
|
deleted = await db.delete_case_precedent(pid)
|
||||||
return json.dumps(
|
return ok({"deleted": deleted, "precedent_id": precedent_id})
|
||||||
{"deleted": ok, "precedent_id": precedent_id}, ensure_ascii=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def precedent_search_library(
|
async def search_case_precedents(
|
||||||
query: str, practice_area: str = "", limit: int = 10,
|
query: str, practice_area: str = "", limit: int = 10,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""חיפוש בספרייה הרוחבית — כל הפסיקות שצורפו אי-פעם בכל התיקים.
|
"""חיפוש רוחבי בציטוטי-הפסיקה שצורפו ידנית לתיקים (case_precedents) — קורפוס
|
||||||
|
"case-attached". GAP-49 (INV-TOOL2): שם קודם `precedent_search_library` (מטעה).
|
||||||
|
זו **אינה** ספריית-הפסיקה הסמכותית — לזו השתמש ב-`search_precedent_library`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: מחרוזת חיפוש (מתחרה מול citation ומול quote)
|
query: מחרוזת חיפוש (מתחרה מול citation ומול quote)
|
||||||
@@ -89,7 +96,7 @@ async def precedent_search_library(
|
|||||||
limit: מספר תוצאות מקסימלי
|
limit: מספר תוצאות מקסימלי
|
||||||
"""
|
"""
|
||||||
if not query or len(query.strip()) < 2:
|
if not query or len(query.strip()) < 2:
|
||||||
return json.dumps([], ensure_ascii=False)
|
return empty("שאילתה קצרה מדי (פחות מ-2 תווים).")
|
||||||
|
|
||||||
rows = await db.search_precedent_library(query.strip(), practice_area, limit)
|
rows = await db.search_precedent_library(query.strip(), practice_area, limit)
|
||||||
return json.dumps(rows, ensure_ascii=False, indent=2, default=str)
|
return ok(rows)
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import db, embeddings, hybrid_search, telemetry
|
from legal_mcp.services import db, embeddings, hybrid_search, practice_area as pa, telemetry
|
||||||
|
from legal_mcp.tools.envelope import empty, err, ok
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -30,7 +30,9 @@ async def search_decisions(
|
|||||||
appeal_subtype: סוג ערר לסינון (building_permit/betterment_levy/compensation_197)
|
appeal_subtype: סוג ערר לסינון (building_permit/betterment_levy/compensation_197)
|
||||||
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
|
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
|
||||||
"""
|
"""
|
||||||
# Auto-resolve practice_area from case_number if available
|
# Auto-resolve practice_area from case_number if available (GAP-12 / INV-RET1):
|
||||||
|
# explicit practice_area wins; otherwise derive from the case so the search is
|
||||||
|
# scoped to the case's legal domain. Case-less search stays cross-domain.
|
||||||
resolved_case_id: UUID | None = None
|
resolved_case_id: UUID | None = None
|
||||||
if case_number and not practice_area:
|
if case_number and not practice_area:
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
@@ -42,6 +44,22 @@ async def search_decisions(
|
|||||||
except (KeyError, ValueError, TypeError):
|
except (KeyError, ValueError, TypeError):
|
||||||
resolved_case_id = None
|
resolved_case_id = None
|
||||||
|
|
||||||
|
# Case row had no practice_area — fall back to deriving from the
|
||||||
|
# case-number prefix (1xxx/8xxx/9xxx). Returns "" for unknown prefixes.
|
||||||
|
if not practice_area:
|
||||||
|
practice_area = pa.derive_domain_practice_area(case_number)
|
||||||
|
|
||||||
|
# Still undeterminable: a case is present but we cannot scope the
|
||||||
|
# search to its domain. This is a data anomaly — BLOCK rather than
|
||||||
|
# silently running a cross-domain search for a specific case.
|
||||||
|
if not practice_area:
|
||||||
|
return err(
|
||||||
|
f"לא ניתן לקבוע את התחום המשפטי (practice_area) של תיק "
|
||||||
|
f"{case_number}. לתיק אין practice_area מוגדר ולא ניתן להסיק אותו "
|
||||||
|
f"ממספר התיק. זוהי אנומליית נתונים — נא להגדיר את ה-practice_area "
|
||||||
|
f"של התיק (למשל דרך case_update) לפני הרצת חיפוש מסונן לתיק זה."
|
||||||
|
)
|
||||||
|
|
||||||
if not practice_area:
|
if not practice_area:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"search_decisions called without practice_area filter — "
|
"search_decisions called without practice_area filter — "
|
||||||
@@ -70,7 +88,7 @@ async def search_decisions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return "לא נמצאו תוצאות."
|
return empty("לא נמצאו תוצאות.")
|
||||||
|
|
||||||
formatted = []
|
formatted = []
|
||||||
for r in results:
|
for r in results:
|
||||||
@@ -85,7 +103,7 @@ async def search_decisions(
|
|||||||
"image_thumbnail": r.get("image_thumbnail_path"),
|
"image_thumbnail": r.get("image_thumbnail_path"),
|
||||||
})
|
})
|
||||||
|
|
||||||
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
return ok(formatted)
|
||||||
|
|
||||||
|
|
||||||
async def search_case_documents(
|
async def search_case_documents(
|
||||||
@@ -102,7 +120,7 @@ async def search_case_documents(
|
|||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
case_uuid = UUID(case["id"])
|
case_uuid = UUID(case["id"])
|
||||||
query_emb = await embeddings.embed_query(query)
|
query_emb = await embeddings.embed_query(query)
|
||||||
@@ -125,7 +143,7 @@ async def search_case_documents(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return f"לא נמצאו תוצאות בתיק {case_number}."
|
return empty(f"לא נמצאו תוצאות בתיק {case_number}.")
|
||||||
|
|
||||||
formatted = []
|
formatted = []
|
||||||
for r in results:
|
for r in results:
|
||||||
@@ -139,7 +157,7 @@ async def search_case_documents(
|
|||||||
"image_thumbnail": r.get("image_thumbnail_path"),
|
"image_thumbnail": r.get("image_thumbnail_path"),
|
||||||
})
|
})
|
||||||
|
|
||||||
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
return ok(formatted)
|
||||||
|
|
||||||
|
|
||||||
async def find_similar_cases(
|
async def find_similar_cases(
|
||||||
@@ -198,7 +216,7 @@ async def find_similar_cases(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return "לא נמצאו תיקים דומים."
|
return empty("לא נמצאו תיקים דומים.")
|
||||||
|
|
||||||
# Deduplicate by case_number, keep best score per case.
|
# Deduplicate by case_number, keep best score per case.
|
||||||
# image-only rows still carry case_number from the join.
|
# image-only rows still carry case_number from the join.
|
||||||
@@ -222,7 +240,7 @@ async def find_similar_cases(
|
|||||||
"match_type": r.get("match_type", "text"),
|
"match_type": r.get("match_type", "text"),
|
||||||
})
|
})
|
||||||
|
|
||||||
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
return ok(formatted)
|
||||||
|
|
||||||
|
|
||||||
async def search_internal_decisions(
|
async def search_internal_decisions(
|
||||||
@@ -278,7 +296,7 @@ async def search_internal_decisions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return "לא נמצאו החלטות ועדת ערר רלוונטיות."
|
return empty("לא נמצאו החלטות ועדת ערר רלוונטיות.")
|
||||||
|
|
||||||
# Cap primary results back to ``limit`` (we over-fetched only to seed
|
# Cap primary results back to ``limit`` (we over-fetched only to seed
|
||||||
# the citation expansion below — the user asked for ``limit`` items).
|
# the citation expansion below — the user asked for ``limit`` items).
|
||||||
@@ -316,7 +334,7 @@ async def search_internal_decisions(
|
|||||||
for row in cited_rows:
|
for row in cited_rows:
|
||||||
formatted.append(_format_internal_row(row, match_type="cited_by"))
|
formatted.append(_format_internal_row(row, match_type="cited_by"))
|
||||||
|
|
||||||
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
return ok(formatted)
|
||||||
|
|
||||||
|
|
||||||
def _format_internal_row(r: dict, *, match_type: str = "primary") -> dict:
|
def _format_internal_row(r: dict, *, match_type: str = "primary") -> dict:
|
||||||
|
|||||||
@@ -15,18 +15,10 @@ CLI is available, and the row gets enriched.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import db, style_metadata_extractor
|
from legal_mcp.services import db, style_metadata_extractor
|
||||||
|
from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
def _ok(payload) -> str:
|
|
||||||
return json.dumps({"ok": True, **payload}, ensure_ascii=False, default=str)
|
|
||||||
|
|
||||||
|
|
||||||
def _err(msg: str) -> str:
|
|
||||||
return json.dumps({"ok": False, "error": msg}, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
async def extract_decision_metadata(corpus_id: str, overwrite: bool = False) -> str:
|
async def extract_decision_metadata(corpus_id: str, overwrite: bool = False) -> str:
|
||||||
|
|||||||
@@ -2,11 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import db
|
from legal_mcp.services import db
|
||||||
|
from legal_mcp.services.lessons import (
|
||||||
|
OUTCOME_LABELS_HE,
|
||||||
|
VALID_OUTCOMES,
|
||||||
|
canonical_outcome,
|
||||||
|
)
|
||||||
|
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -19,7 +24,7 @@ async def workflow_status(case_number: str) -> str:
|
|||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
docs = await db.list_documents(case_id)
|
docs = await db.list_documents(case_id)
|
||||||
@@ -64,7 +69,7 @@ async def workflow_status(case_number: str) -> str:
|
|||||||
"next_steps": _suggest_next_steps(case, docs, has_draft),
|
"next_steps": _suggest_next_steps(case, docs, has_draft),
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.dumps(status, ensure_ascii=False, indent=2)
|
return ok(status)
|
||||||
|
|
||||||
|
|
||||||
def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]:
|
def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]:
|
||||||
@@ -109,12 +114,12 @@ async def get_metrics(case_number: str = "") -> str:
|
|||||||
if case_number:
|
if case_number:
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
result = await metrics.get_case_metrics(UUID(case["id"]))
|
result = await metrics.get_case_metrics(UUID(case["id"]))
|
||||||
else:
|
else:
|
||||||
result = await metrics.get_dashboard()
|
result = await metrics.get_dashboard()
|
||||||
|
|
||||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
return ok(result)
|
||||||
|
|
||||||
|
|
||||||
async def processing_status() -> str:
|
async def processing_status() -> str:
|
||||||
@@ -130,14 +135,14 @@ async def processing_status() -> str:
|
|||||||
corpus_count = await conn.fetchval("SELECT COUNT(*) FROM style_corpus")
|
corpus_count = await conn.fetchval("SELECT COUNT(*) FROM style_corpus")
|
||||||
pattern_count = await conn.fetchval("SELECT COUNT(*) FROM style_patterns")
|
pattern_count = await conn.fetchval("SELECT COUNT(*) FROM style_patterns")
|
||||||
|
|
||||||
return json.dumps({
|
return ok({
|
||||||
"cases": case_count,
|
"cases": case_count,
|
||||||
"documents": doc_count,
|
"documents": doc_count,
|
||||||
"pending_processing": pending_count,
|
"pending_processing": pending_count,
|
||||||
"chunks": chunk_count,
|
"chunks": chunk_count,
|
||||||
"style_corpus_entries": corpus_count,
|
"style_corpus_entries": corpus_count,
|
||||||
"style_patterns": pattern_count,
|
"style_patterns": pattern_count,
|
||||||
}, ensure_ascii=False, indent=2)
|
})
|
||||||
|
|
||||||
|
|
||||||
# ── Outcome & Brainstorming ───────────────────────────────────────
|
# ── Outcome & Brainstorming ───────────────────────────────────────
|
||||||
@@ -151,18 +156,20 @@ async def set_outcome(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
case_number: מספר תיק הערר
|
case_number: מספר תיק הערר
|
||||||
outcome: תוצאה — rejected (דחייה), accepted (קבלה), partial (קבלה חלקית)
|
outcome: תוצאה — rejection (דחייה) / partial_acceptance (קבלה חלקית) /
|
||||||
|
full_acceptance (קבלה מלאה). ערכי-legacy (rejected/accepted/partial) ממופים אוטומטית.
|
||||||
reasoning: נימוק (אופציונלי). אם ריק — מפעיל סיעור מוחות.
|
reasoning: נימוק (אופציונלי). אם ריק — מפעיל סיעור מוחות.
|
||||||
"""
|
"""
|
||||||
from legal_mcp.services import brainstorm
|
from legal_mcp.services import brainstorm
|
||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
valid_outcomes = ("rejected", "accepted", "partial")
|
# GAP-51: accept legacy vocabulary (rejected/accepted/partial), store canonical.
|
||||||
if outcome not in valid_outcomes:
|
outcome = canonical_outcome(outcome)
|
||||||
return f"תוצאה לא תקינה. אפשרויות: {', '.join(valid_outcomes)}"
|
if outcome not in VALID_OUTCOMES:
|
||||||
|
return err(f"תוצאה לא תקינה. אפשרויות: {', '.join(VALID_OUTCOMES)}")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
@@ -187,7 +194,7 @@ async def set_outcome(
|
|||||||
# Update case status
|
# Update case status
|
||||||
await db.update_case(case_id, status="in_progress", expected_outcome=outcome)
|
await db.update_case(case_id, status="in_progress", expected_outcome=outcome)
|
||||||
|
|
||||||
outcome_hebrew = {"rejected": "דחייה", "accepted": "קבלה", "partial": "קבלה חלקית"}.get(outcome, outcome)
|
outcome_hebrew = OUTCOME_LABELS_HE.get(outcome, outcome)
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"decision_id": decision["id"],
|
"decision_id": decision["id"],
|
||||||
@@ -204,7 +211,7 @@ async def set_outcome(
|
|||||||
result["message"] = f"תוצאה נשמרה: {outcome_hebrew}. ניתן להתחיל כתיבת טיוטה."
|
result["message"] = f"תוצאה נשמרה: {outcome_hebrew}. ניתן להתחיל כתיבת טיוטה."
|
||||||
result["next_step"] = "draft"
|
result["next_step"] = "draft"
|
||||||
|
|
||||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
return ok(result)
|
||||||
|
|
||||||
|
|
||||||
async def brainstorm_directions(
|
async def brainstorm_directions(
|
||||||
@@ -219,14 +226,14 @@ async def brainstorm_directions(
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
# Get existing decision for outcome
|
# Get existing decision for outcome
|
||||||
decision = await db.get_decision_by_case(case_id)
|
decision = await db.get_decision_by_case(case_id)
|
||||||
if not decision:
|
if not decision:
|
||||||
return "לא הוזנה תוצאה לתיק. הפעל set_outcome קודם."
|
return err("לא הוזנה תוצאה לתיק. הפעל set_outcome קודם.")
|
||||||
|
|
||||||
outcome = decision.get("outcome", "")
|
outcome = decision.get("outcome", "")
|
||||||
reasoning = decision.get("outcome_reasoning", "")
|
reasoning = decision.get("outcome_reasoning", "")
|
||||||
@@ -239,7 +246,7 @@ async def brainstorm_directions(
|
|||||||
direction_doc={"brainstorm": directions, "approved": False},
|
direction_doc={"brainstorm": directions, "approved": False},
|
||||||
)
|
)
|
||||||
|
|
||||||
return json.dumps(directions, default=str, ensure_ascii=False, indent=2)
|
return ok(directions)
|
||||||
|
|
||||||
|
|
||||||
async def approve_direction(
|
async def approve_direction(
|
||||||
@@ -258,18 +265,18 @@ async def approve_direction(
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
decision = await db.get_decision_by_case(case_id)
|
decision = await db.get_decision_by_case(case_id)
|
||||||
if not decision:
|
if not decision:
|
||||||
return "לא הוזנה תוצאה לתיק."
|
return err("לא הוזנה תוצאה לתיק.")
|
||||||
|
|
||||||
direction_data = decision.get("direction_doc") or {}
|
direction_data = decision.get("direction_doc") or {}
|
||||||
brainstorm_result = direction_data.get("brainstorm", {})
|
brainstorm_result = direction_data.get("brainstorm", {})
|
||||||
|
|
||||||
if not brainstorm_result.get("directions"):
|
if not brainstorm_result.get("directions"):
|
||||||
return "לא בוצע סיעור מוחות. הפעל brainstorm_directions קודם."
|
return err("לא בוצע סיעור מוחות. הפעל brainstorm_directions קודם.")
|
||||||
|
|
||||||
direction_doc = brainstorm.build_direction_doc(
|
direction_doc = brainstorm.build_direction_doc(
|
||||||
outcome=decision.get("outcome", ""),
|
outcome=decision.get("outcome", ""),
|
||||||
@@ -281,11 +288,8 @@ async def approve_direction(
|
|||||||
|
|
||||||
await db.update_decision(UUID(decision["id"]), direction_doc=direction_doc)
|
await db.update_decision(UUID(decision["id"]), direction_doc=direction_doc)
|
||||||
|
|
||||||
return json.dumps({
|
return ok({"direction": direction_doc},
|
||||||
"status": "approved",
|
message="כיוון אושר. ניתן להתחיל כתיבת טיוטה.")
|
||||||
"message": "כיוון אושר. ניתן להתחיל כתיבת טיוטה.",
|
|
||||||
"direction": direction_doc,
|
|
||||||
}, default=str, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
async def ingest_final_version(
|
async def ingest_final_version(
|
||||||
@@ -304,7 +308,7 @@ async def ingest_final_version(
|
|||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
@@ -314,12 +318,12 @@ async def ingest_final_version(
|
|||||||
final_text, _, _ = await extractor.extract_text(file_path)
|
final_text, _, _ = await extractor.extract_text(file_path)
|
||||||
|
|
||||||
if not final_text:
|
if not final_text:
|
||||||
return "לא סופק טקסט — יש לספק file_path או final_text."
|
return err("לא סופק טקסט — יש לספק file_path או final_text.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await learning_loop.process_final_version(case_id, final_text)
|
result = await learning_loop.process_final_version(case_id, final_text)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
|
return err(str(e))
|
||||||
|
|
||||||
# Auto-ingest into internal committee decisions corpus (best-effort).
|
# Auto-ingest into internal committee decisions corpus (best-effort).
|
||||||
try:
|
try:
|
||||||
@@ -339,7 +343,7 @@ async def ingest_final_version(
|
|||||||
logger.warning("ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e)
|
logger.warning("ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e)
|
||||||
result["internal_corpus_ingested"] = False
|
result["internal_corpus_ingested"] = False
|
||||||
|
|
||||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
return ok(result)
|
||||||
|
|
||||||
|
|
||||||
# ── Chair feedback tools ──────────────────────────────────────────
|
# ── Chair feedback tools ──────────────────────────────────────────
|
||||||
@@ -369,7 +373,7 @@ async def record_chair_feedback(
|
|||||||
"factual_error", "style", "other",
|
"factual_error", "style", "other",
|
||||||
]
|
]
|
||||||
if category not in valid_categories:
|
if category not in valid_categories:
|
||||||
return f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}"
|
return err(f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}")
|
||||||
|
|
||||||
feedback_id = await db.record_chair_feedback(
|
feedback_id = await db.record_chair_feedback(
|
||||||
case_id=case_id,
|
case_id=case_id,
|
||||||
@@ -379,21 +383,20 @@ async def record_chair_feedback(
|
|||||||
lesson_extracted=lesson_extracted,
|
lesson_extracted=lesson_extracted,
|
||||||
)
|
)
|
||||||
|
|
||||||
return json.dumps({
|
return ok({
|
||||||
"status": "ok",
|
|
||||||
"feedback_id": str(feedback_id),
|
"feedback_id": str(feedback_id),
|
||||||
"message": f"הערה נרשמה בהצלחה. קטגוריה: {category}.",
|
|
||||||
"next_steps": [
|
"next_steps": [
|
||||||
"כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback",
|
"כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback",
|
||||||
"כדי לסמן כמטופל: resolve_chair_feedback",
|
"כדי לסמן כמטופל: resolve_chair_feedback",
|
||||||
],
|
],
|
||||||
}, ensure_ascii=False, indent=2)
|
}, message=f"הערה נרשמה בהצלחה. קטגוריה: {category}.")
|
||||||
|
|
||||||
|
|
||||||
async def list_chair_feedback(
|
async def list_chair_feedback(
|
||||||
case_number: str = "",
|
case_number: str = "",
|
||||||
category: str = "",
|
category: str = "",
|
||||||
unresolved_only: bool = True,
|
unresolved_only: bool = True,
|
||||||
|
limit: int = 100,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""הצגת הערות יו"ר שתועדו, עם אפשרות סינון.
|
"""הצגת הערות יו"ר שתועדו, עם אפשרות סינון.
|
||||||
|
|
||||||
@@ -401,6 +404,7 @@ async def list_chair_feedback(
|
|||||||
case_number: סינון לפי תיק (אם ריק — כל ההערות)
|
case_number: סינון לפי תיק (אם ריק — כל ההערות)
|
||||||
category: סינון לפי קטגוריה
|
category: סינון לפי קטגוריה
|
||||||
unresolved_only: האם להציג רק הערות שלא טופלו (ברירת מחדל: כן)
|
unresolved_only: האם להציג רק הערות שלא טופלו (ברירת מחדל: כן)
|
||||||
|
limit: תקרת תוצאות (INV-TOOL5 / GAP-53)
|
||||||
"""
|
"""
|
||||||
case_id = None
|
case_id = None
|
||||||
if case_number:
|
if case_number:
|
||||||
@@ -412,10 +416,11 @@ async def list_chair_feedback(
|
|||||||
case_id=case_id,
|
case_id=case_id,
|
||||||
category=category or None,
|
category=category or None,
|
||||||
unresolved_only=unresolved_only,
|
unresolved_only=unresolved_only,
|
||||||
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not feedbacks:
|
if not feedbacks:
|
||||||
return "אין הערות שמתאימות לסינון."
|
return empty("אין הערות שמתאימות לסינון.")
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for fb in feedbacks:
|
for fb in feedbacks:
|
||||||
@@ -430,7 +435,7 @@ async def list_chair_feedback(
|
|||||||
"date": fb["created_at"].isoformat() if fb.get("created_at") else None,
|
"date": fb["created_at"].isoformat() if fb.get("created_at") else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
return json.dumps({
|
return ok({
|
||||||
"total": len(items),
|
"total": len(items),
|
||||||
"feedbacks": items,
|
"feedbacks": items,
|
||||||
}, ensure_ascii=False, indent=2, default=str)
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user