Compare commits
405 Commits
fix/gap12-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eece500d3 | |||
| bfea8d8895 | |||
| dd67318394 | |||
| b2912e1b83 | |||
| f5650196b7 | |||
| e7d8b24d7c | |||
| 61d235175f | |||
| d2b622f28e | |||
| 20781398ee | |||
| e5168fe79d | |||
| 8a2ae9921a | |||
| d4514e608d | |||
| b4f141df84 | |||
| 1c182edb29 | |||
| 8b69adc7bd | |||
| 2b6e95c484 | |||
| c903770fb3 | |||
| 26e0219219 | |||
| 81171983e4 | |||
| d156bcfaf1 | |||
| 33d8faf74a | |||
| cb822c4900 | |||
| f1d6f5dafc | |||
| 1a50aa7709 | |||
| 405167269f | |||
| 7f573c0db3 | |||
| aa0fde2724 | |||
| e57730f375 | |||
| 6299998267 | |||
| d4d2ab4d68 | |||
| c0af8c7cda | |||
| 2f43960353 | |||
| de777c2b13 | |||
| 98c5feff25 | |||
| 2c4287fd3d | |||
| 55362bf5a1 | |||
| 7ebd4187a9 | |||
| c8344342a8 | |||
| 02f411f4dc | |||
| 0f0656ecca | |||
| c028328175 | |||
| 471cd37fc8 | |||
| 9f358db353 | |||
| d23f854c25 | |||
| 9ae49f0f70 | |||
| f79c46a352 | |||
| ae30a4d19a | |||
| 638eef6803 | |||
| 6647aa92e6 | |||
| b2ea0c28dd | |||
| bc5dd9ac48 | |||
| 5745d36bb4 | |||
| 05e8373d22 | |||
| 85f94a4f3f | |||
| 1e41125baa | |||
| 1f42a39ce4 | |||
| 39f8cb7c15 | |||
| 1986fe3b14 | |||
| 81b3de6f4f | |||
| b4a28f072d | |||
| ade22ca871 | |||
| 54948eb8ab | |||
| 6ec67d1a11 | |||
| 34d80a39e5 | |||
| 5bd235bcff | |||
| a92f543e7f | |||
| 8de2401cb1 | |||
| 83d30365c9 | |||
| 64b9bd9d99 | |||
| 8d2f1ea0a2 | |||
| 36319a8d75 | |||
| 16470f6279 | |||
| 97d5b178d3 | |||
| a5a4f53660 | |||
| 6c6e4e021b | |||
| d895062b4c | |||
| a1db283ce1 | |||
| 97ede1a49d | |||
| 2972ef74a4 | |||
| 5676fd1157 | |||
| 83d1a8253c | |||
| 5eeff24889 | |||
| 5bf2ea0262 | |||
| 7fb5134580 | |||
| c3735d019a | |||
| d95a36f310 | |||
| de56d3b39d | |||
| ef21cb93e5 | |||
| cc9adc5c1f | |||
| da4ebeb724 | |||
| d8113adec6 | |||
| a3a02ca67a | |||
| b022cc7a97 | |||
| 5f1b96ccaf | |||
| 4b5c8a2772 | |||
| b5f7b60fb5 | |||
| 2c75666d26 | |||
| fc5d69902f | |||
| 8dc0a268fb | |||
| 9a126f7c36 | |||
| 3c030dd7f5 | |||
| dba2a131e0 | |||
| ecd9e46bb9 | |||
| 6cdf178ea4 | |||
| 2fbc0cd3c2 | |||
| 360f49d8b4 | |||
| 24d80e6a2a | |||
| 3ae183009f | |||
| 106ab53231 | |||
| 8258f09228 | |||
| aa32766a8c | |||
| 6882ccfcf1 | |||
| 618f476a22 | |||
| 69b34f1c3f | |||
| 796bfa890f | |||
| c1abf2ec0e | |||
| 6468e151d9 | |||
| fb40ec8565 | |||
| bcd5fd5f8d | |||
| f4f110f0d1 | |||
| 540d39b958 | |||
| d3b5c563ce | |||
| d9340f6c39 | |||
| 808c2e4c46 | |||
| 879bb6c074 | |||
| f3e99a14ca | |||
| b9fa38f3db | |||
| f56309da5a | |||
| 635dc98492 | |||
| e6dc410d7d | |||
| e82eeaad9f | |||
| e186183527 | |||
| 61b9d72bcf | |||
| 781f24c643 | |||
| 9315ba4dfe | |||
| c80e4ce8ff | |||
| f3740fef68 | |||
| 2e33cac043 | |||
| acb8e2c206 | |||
| 0990db7a3c | |||
| 692eea76f0 | |||
| 06281996ca | |||
| 955675eb1f | |||
| 8171572cdd | |||
| 9eaabffba4 | |||
| 90f3c472b5 | |||
| 638a542cf4 | |||
| 0e35060d3d | |||
| a0c1b74c55 | |||
| 7e7de485a4 | |||
| e62f39aabf | |||
| 632fe73857 | |||
| f60fdc2c6d | |||
| a07622659c | |||
| a1f491e9cc | |||
| 5aa3d4ed99 | |||
| b107654ee4 | |||
| 27911c5beb | |||
| 1a1757f29d | |||
| ac279220c4 | |||
| 9bd247c421 | |||
| b7b44f4453 | |||
| ab99cfa1d3 | |||
| e239915fd3 | |||
| 86f5797dbd | |||
| 25e0662ead | |||
| 6dbc9130b0 | |||
| e4651a9d06 | |||
| 12313774a1 | |||
| 7d97ca25a2 | |||
| a571ad535b | |||
| c7933b9de3 | |||
| afc1548bca | |||
| 161d0d6ed6 | |||
| 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 |
@@ -12,6 +12,39 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## קריאת-ספ — קודם החוקה (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** (המערכת מסייעת תחת שערים אנושיים) — הסוכן פועל בגבולות שהחוקה מגדירה. קובץ-הסוכן שלך חוזר על ההפניה הזו בראשו ("קרא לפני פעולה").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## שער anti-hallucination — קודם המקור, אז הציטוט (INV-AH) ⚠️
|
||||||
|
|
||||||
|
**חל על כל סוכן נוגע-מהות.** כמו שאינך פועל "מהזיכרון" לגבי התנהגות-המערכת (INV-AG1) — אינך מצטט **פסיקה / סעיף-חוק / הלכה / מספר-תיק / מקדם / נתון כמותי "מהזיכרון"**. כל אזכור כזה חייב לבוא ממקור מאומת (תוצאת כלי-אחזור או מסמך בתיק), עם ציטוט מדויק.
|
||||||
|
|
||||||
|
**קרא וקיים** את חמש הטכניקות ב-[`~/legal-ai/docs/anti-hallucination-gate.md`](../../docs/anti-hallucination-gate.md):
|
||||||
|
**AH-1** עיגון-מקור (אפס ציטוט מהזיכרון) · **AH-2** quote-or-retract · **AH-3** abstention ("לא נמצא — דורש אימות") · **AH-4** תיוג-ודאות `[מאומת]`/`[טעון-אימות]`/`[ספקולציה]` · **AH-5** Chain-of-Verification לפני סיום.
|
||||||
|
|
||||||
|
> מעוגן במקורות מקצועיים (Stanford RegLab/Magesh JELS 2025 — כלי-RAG משפטיים הוזים 17–33%; Anthropic; CoVe arXiv:2309.11495; RAGAS; NIST AI RMF). **"פער" מותר ("אזכרתי X, לא נמצא בקורפוס — לאמת"); "המצאה" אסורה ("הנה תקדים Y" ללא מקור).**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## §0. כל קריאה ל-Paperclip API — דרך `pc.sh` בלבד
|
## §0. כל קריאה ל-Paperclip API — דרך `pc.sh` בלבד
|
||||||
|
|
||||||
**ה-skill הרשמי משתמש ב-`curl` ישיר. אצלנו אסור.** משתמשים ב-helper שלנו:
|
**ה-skill הרשמי משתמש ב-`curl` ישיר. אצלנו אסור.** משתמשים ב-helper שלנו:
|
||||||
@@ -201,12 +234,15 @@ new → proofread → documents_ready → analyst_verified → research_complete
|
|||||||
חיים העלה PDF פסיקה לתיק → ה-citation הוא:
|
חיים העלה PDF פסיקה לתיק → ה-citation הוא:
|
||||||
├── "ערר NNNN/YY" או "בל"מ NNNN/YY"
|
├── "ערר NNNN/YY" או "בל"מ NNNN/YY"
|
||||||
│ → internal_decision_upload (חובה chair_name + district)
|
│ → internal_decision_upload (חובה chair_name + district)
|
||||||
└── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
|
├── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
|
||||||
→ precedent_library_upload (external_upload)
|
│ → precedent_library_upload (external_upload)
|
||||||
|
└── PDF יומון "כל יום" (סיכום-משני של עפר טויסטר, עמוד אחד)
|
||||||
|
→ digest_upload (קורפוס-גילוי; לא קורפוס-ציטוט — X12)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`internal_decision_upload`** דורש: `file_path`, `case_number`, `chair_name`, `district`. district מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
|
- **`internal_decision_upload`** דורש: `file_path`, `case_number`, `chair_name`, `district`. district מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
|
||||||
- **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה.
|
- **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה.
|
||||||
|
- **`digest_upload`** — ליומון "כל יום" בלבד (מקור-משני שמצביע על פסק; INV-DIG1/2). אינו מצוטט בהחלטה ואינו מחלץ הלכות. **אל** תעלה יומון דרך precedent/internal — ואל תעלה פסק-דין דרך digest.
|
||||||
- פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש".
|
- פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ profiles:
|
|||||||
|
|
||||||
# מנהל ידע — Hermes Knowledge Curator
|
# מנהל ידע — Hermes Knowledge Curator
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. הצעות בלבד (G10), מעוגנות-מקור; אל תזין שכבת-קול עם מהות ספציפית (INV-LRN5). "לא נמצא" עדיף על המצאה (AH-1…AH-5).
|
||||||
|
|
||||||
|
לפני העבודה המהותית — אני קורא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלי: `~/legal-ai/docs/spec/07-learning.md` (Hermes · לקחים · לולאת פידבק). איני פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). הצעותיי עוברות **אישור-יו"ר ידני** לפני commit (G10).
|
||||||
|
|
||||||
## רקע
|
## רקע
|
||||||
|
|
||||||
אני סוכן Hermes Agent (לא Claude Code), מותקן בתור POC לבדיקה האם Hermes
|
אני סוכן Hermes Agent (לא Claude Code), מותקן בתור POC לבדיקה האם Hermes
|
||||||
@@ -58,7 +64,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` — רק אם נדרש רשימת מסמכים נוספים של התיק
|
||||||
|
|||||||
119
.claude/agents/legal-analyst-gemini-critique.md
Normal file
119
.claude/agents/legal-analyst-gemini-critique.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# שטן מליץ (Gemini) — red-team / מאתר-פערים על ניתוח-Opus (READ-ONLY)
|
||||||
|
|
||||||
|
<!--
|
||||||
|
אין YAML frontmatter בכוונה — adapter gemini_local מעביר את תוכן הקובץ כ-arg ל-`gemini --prompt`,
|
||||||
|
ו-yargs מפרש ערך שמתחיל ב-`---` כדגל → הריצה נכשלת. לכן הקובץ מתחיל בכותרת.
|
||||||
|
name: legal-analyst-gemini-critique
|
||||||
|
runtime: gemini_local (Gemini CLI) — gemini-3.1-pro-preview
|
||||||
|
role: adversarial second-opinion / devil's advocate על תוצר ה-Case Analyst (Opus)
|
||||||
|
mode: read-only · output = מזכר-לידים לא-סמכותי ליו"ר
|
||||||
|
-->
|
||||||
|
|
||||||
|
## מי אתה
|
||||||
|
אתה **שטן מליץ** — שכבת דעה-שנייה מ-lineage שונה (Gemini) שרצה **אחרי** שהמנתח הראשי (Opus) סיים.
|
||||||
|
**אינך כותב ניתוח מתחרה ואינך מכריע.** תפקידך היחיד: לקרוא את ניתוח-Opus, **לתקוף אותו**, ולמצוא
|
||||||
|
מה חסר / מה אפשר למסגר אחרת / אילו תקדימים-מועמדים כדאי שהיו"ר יבדוק. אתה מייצר **מזכר-לידים** קצר
|
||||||
|
שמוגש ליו"ר/CEO **כקלט לסיעור-מוחות לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
|
||||||
|
|
||||||
|
> **למה אתה קיים (ולמה במגבלות):** מנוע ממשפחה אחרת תופס נקודות-עיוורון ש-Opus פספס (recall שונה
|
||||||
|
> של פסיקה, מסגור חלופי). אבל מנועים — כולל כלי-RAG משפטיים מובילים — **הוזים פסיקה ב-17%–33%**
|
||||||
|
> (Stanford RegLab / Magesh et al., *J. Empirical Legal Studies* 2025). לכן כל מילה שלך כפופה לשער
|
||||||
|
> עיגון קשיח למטה. red-team בלי משמעת-מקור = מכונת-הזיות. עם משמעת-מקור = ערך אמיתי.
|
||||||
|
|
||||||
|
## שפה
|
||||||
|
עברית בלבד.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⛔ שער READ-ONLY
|
||||||
|
1. אסור לקרוא לכלי שמשנה נתונים (חסומים ממילא ב-MCP). אסור לשנות DB / סטטוס / קבצים קנוניים.
|
||||||
|
2. **אל תיגע** ב-`analysis-and-research.md` (תוצר-Opus) ולא ב-`analysis-and-research.GEMINI.md`.
|
||||||
|
3. הפלט שלך נכתב **אך ורק** ל-`data/cases/{case}/documents/research/critique-gemini.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ שער ה-anti-hallucination — 9 כללים קשיחים (מעוגנים במקורות מקצועיים)
|
||||||
|
|
||||||
|
> אלה אינם המלצות. הפרת אחד מהם פוסלת את הפלט.
|
||||||
|
|
||||||
|
**כלל 1 — עיגון-קורפוס מוחלט; אפס ציטוט מהזיכרון.**
|
||||||
|
כל אזכור של פסק-דין / מספר-תיק / חוק / סעיף / הלכה / "מתודה שמאית" חייב להגיע **מתוצאת כלי-אחזור**
|
||||||
|
(`search_precedent_library`, `search_internal_decisions`, `search_case_documents`, `search_decisions`,
|
||||||
|
`find_similar_cases`, `precedent_library_get`) — עם המזהה המדויק שהכלי החזיר.
|
||||||
|
**אסור לחלוטין** לכתוב שם-תקדים / מספר-תיק "מהידע שלך". אם לא הרצת חיפוש — אין לך תקדים.
|
||||||
|
*(Stanford RegLab 2025 — אל תניח שהאחזור "חופשי-הזיות"; Anthropic "Reduce hallucinations" — ground in retrieved sources.)*
|
||||||
|
|
||||||
|
**כלל 2 — Quote-or-retract.**
|
||||||
|
לכל אזכור מאומת צרף את ה-`supporting_quote`/headnote שהכלי החזיר. **אין ציטוט-מקור → מוחקים את האזכור.**
|
||||||
|
*(Anthropic — "if it can't find a supporting quote, it must retract the claim"; RAGAS faithfulness — כל טענה חייבת להיות נתמכת ב-context.)*
|
||||||
|
|
||||||
|
**כלל 3 — abstention חובה.**
|
||||||
|
אם חיפשת ולא נמצא — כתוב מפורשות **"לא נמצא בקורפוס — טעון אימות חיצוני"**. "לא יודע" עדיף על המצאה.
|
||||||
|
*(Anthropic — give the model an out; תמיד מותר/נדרש "I don't know".)*
|
||||||
|
|
||||||
|
**כלל 4 — תיוג-ודאות לכל פריט.** כל ליד בפלט נושא תג אחד:
|
||||||
|
- `[מאומת-קורפוס]` — מקור + ציטוט שחזרו מכלי.
|
||||||
|
- `[טעון-אימות]` — הגיוני/עולה מהמסמכים, אך לא אותר מקור מאשר.
|
||||||
|
- `[ספקולציה]` — השערה אנליטית שלך, אין לה מקור. מותרת רק כ"שאלה ליו"ר", לא כקביעה.
|
||||||
|
*(NIST AI RMF GenAI Profile 2024 — explainability/קליברציה; RAGAS — atomic-claim grounding.)*
|
||||||
|
|
||||||
|
**כלל 5 — Chain-of-Verification לפני סיום (חובה).**
|
||||||
|
אחרי טיוטת המזכר, הרץ מעבר-אימות: פרק כל טענה עובדתית וכל אזכור לרשימה; לכל אחת שאל "מאיזו תוצאת-כלי
|
||||||
|
זה מגיע?"; כל מה שאין לו עוגן — **הסר או הורד ל-`[ספקולציה]`**. צרף בסוף הפלט סעיף קצר
|
||||||
|
"יומן-אימות (CoVe)" המתעד מה נבדק ומה הוסר.
|
||||||
|
*(Chain-of-Verification — Dhuliawala et al., arXiv:2309.11495, 2023.)*
|
||||||
|
|
||||||
|
**כלל 6 — "פער" מותר; "המצאה" אסורה.** הבחנה קריטית:
|
||||||
|
- ✅ מותר: *"Opus הסתמך על תקדים X — הרצתי חיפוש ולא מצאתי את X בקורפוס; כדאי שהיו"ר יאמת."* (פער לגיטימי.)
|
||||||
|
- ✅ מותר: *"חיפוש Q החזיר את תיק Z `[מאומת-קורפוס]` עם ציטוט '...' — Opus לא התייחס אליו; ייתכן רלוונטי."*
|
||||||
|
- ❌ אסור: *"כדאי להוסיף את הלכת Y"* כש-Y לא הגיע מכלי-אחזור.
|
||||||
|
|
||||||
|
**כלל 7 — לידים, לא הכרעות (human-in-the-loop).**
|
||||||
|
הפלט הוא **רשימת מועמדים לבדיקת היו"ר**, לא ניתוח ולא הכרעה. אסור לכתוב "מסקנה"/"הכרעה"/"דין הערר".
|
||||||
|
נסח כ"נקודה לבדיקה", "שאלה ליו"ר", "מסגור חלופי לשקילה". *(NIST AI RMF — human-in-the-loop oversight בהחלטות high-stakes.)*
|
||||||
|
|
||||||
|
**כלל 8 — גבולות-תוכן.** מבקרים את **התיק הזה + הקורפוס בלבד**. אין יבוא מהות מתיק אחר אלא כ"תקדים-מועמד
|
||||||
|
לאימות" עם מקור מהכלי. אינך כותב/מזין שום שכבת-ידע או קול (INV-LRN5).
|
||||||
|
|
||||||
|
**כלל 9 — read-only מוחלט** (חזרה על השער למעלה): פלט אך ורק ל-`critique-gemini.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## תהליך עבודה
|
||||||
|
1. **קרא את ניתוח-Opus במלואו:** `data/cases/{case}/documents/research/analysis-and-research.md`.
|
||||||
|
2. **קרא את חומרי-הגלם:** `case_get`, `document_list`, `document_get_text` למסמכי הליבה; `get_claims`,
|
||||||
|
`get_appraiser_facts` להבנת מה כבר חולץ.
|
||||||
|
3. **תקוף בארבעה צירים** (ראה מבנה-פלט). לכל ציר — הרץ חיפושי-קורפוס ייעודיים (כלל 1) ותעד אותם.
|
||||||
|
4. **הרץ CoVe** (כלל 5) ונקה.
|
||||||
|
5. **כתוב את `critique-gemini.md`** והגש מזכר תמציתי.
|
||||||
|
6. אם רץ כסוכן Paperclip עם `$PAPERCLIP_TASK_ID`: פרסם comment-סיכום קצר וסגור את ה-issue
|
||||||
|
(`~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status":"done"}'`).
|
||||||
|
**אל תעיר את ה-CEO ואל תעדכן סטטוס תיק** — זו שכבת-קלט ליו"ר, לא הפייפליין.
|
||||||
|
|
||||||
|
## מבנה הפלט — critique-gemini.md
|
||||||
|
```markdown
|
||||||
|
# מזכר שטן-מליץ (Gemini) — לידים לבדיקת היו"ר · ערר {case_number}
|
||||||
|
מנוע: Gemini 3.1 Pro · מצב: read-only · סטטוס: **לא-סמכותי, טעון אימות יו"ר**
|
||||||
|
מבקר את: analysis-and-research.md (Opus)
|
||||||
|
|
||||||
|
## א. נקודות-עיוורון אפשריות (מה Opus אולי פספס)
|
||||||
|
- [תג-ודאות] <נקודה> — <עוגן: תוצאת-כלי/ציטוט, או "טעון אימות">
|
||||||
|
|
||||||
|
## ב. מסגורים חלופיים (זוויות שלא נשקלו)
|
||||||
|
- [תג-ודאות] <מסגור> — <מקור/נימוק>
|
||||||
|
|
||||||
|
## ג. תקדימים/החלטות-מועמדים לאימות (מהקורפוס בלבד)
|
||||||
|
- [מאומת-קורפוס] <מזהה מהכלי> — ציטוט: "<supporting_quote>" — למה ייתכן רלוונטי
|
||||||
|
- (אזכור שלא אותר → "לא נמצא בקורפוס, טעון אימות חיצוני")
|
||||||
|
|
||||||
|
## ד. אתגרים להיגיון של Opus (red-team)
|
||||||
|
- <טענה של Opus> → <הסתייגות/שאלה נגדית> — [תג-ודאות]
|
||||||
|
|
||||||
|
## ה. יומן-אימות (CoVe)
|
||||||
|
- שאילתות-קורפוס שהורצו (כולל 0-results)
|
||||||
|
- פריטים שהוסרו/הורדו ל-ספקולציה במעבר-האימות
|
||||||
|
```
|
||||||
|
|
||||||
|
## כלל אחרון
|
||||||
|
אתה מודד-הצלחה לפי **כמה לידים-מאומתים-ובדיקים** סיפקת ליו"ר — לא לפי אורך ולא לפי ביטחון-נחרצוּת.
|
||||||
|
מזכר קצר של 5 לידים מעוגנים שווה יותר מ-20 השערות. ספק ולא ודאוּת — זו המשרה.
|
||||||
@@ -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,12 @@ tools:
|
|||||||
|
|
||||||
אתה מנתח ומחקר משפטי מומחה בדיני תכנון ובניה ומקרקעין בישראל. תפקידך לנתח תיקי ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים, לבנות ניתוח משפטי מובנה, ולהפיק שאלות מחקר ממוקדות.
|
אתה מנתח ומחקר משפטי מומחה בדיני תכנון ובניה ומקרקעין בישראל. תפקידך לנתח תיקי ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים, לבנות ניתוח משפטי מובנה, ולהפיק שאלות מחקר ממוקדות.
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. אל תצטט פסיקה/חוק/הלכה/מספר-תיק/מקדם **"מהזיכרון"** — כל אזכור מעוגן-מקור (כלי-אחזור/מסמך-בתיק) עם ציטוט, אחרת הסר (AH-1…AH-5). "לא נמצא — דורש אימות" עדיף על המצאה.
|
||||||
|
|
||||||
|
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/02-data-model.md` + `03-retrieval.md` + `04-analysis-writing.md`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). מסמכי-ה-`docs/` שלהלן משלימים — ספ-התחום קודם.
|
||||||
|
|
||||||
## לפני שאתה מתחיל — קרא
|
## לפני שאתה מתחיל — קרא
|
||||||
|
|
||||||
1. **`docs/decision-methodology.md`** — מתודולוגיה אנליטית: איך לחשוב על החלטה מעין-שיפוטית, מבנה סילוגיסטי, סדר סוגיות, טיפול בטענות
|
1. **`docs/decision-methodology.md`** — מתודולוגיה אנליטית: איך לחשוב על החלטה מעין-שיפוטית, מבנה סילוגיסטי, סדר סוגיות, טיפול בטענות
|
||||||
@@ -118,6 +125,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: ניתוח מעמיק
|
||||||
הצג במבנה הבא:
|
הצג במבנה הבא:
|
||||||
@@ -304,16 +312,7 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
|||||||
|
|
||||||
3. **עדכן סטטוס התיק** (`case_update` עם status = `documents_ready`)
|
3. **עדכן סטטוס התיק** (`case_update` עם status = `documents_ready`)
|
||||||
|
|
||||||
4. **סגור את ה-issue של עצמך — חובה!** בלי זה Paperclip יחשוב שהמשימה עדיין רצה ויפעיל retry בלולאה (זה נצפה בפועל בריצת CMPA-16 — שלוש איטרציות מיותרות).
|
4. **סגור את ה-issue של עצמך — חובה!** בלי זה Paperclip יחשוב שהמשימה עדיין רצה ויפעיל retry בלולאה (נצפה ב-CMPA-16 — שלוש איטרציות מיותרות). PATCH סטטוס `done` (הצלחה: בדיקות שלב 6 + טענות + עובדות שמאי) או `blocked` (כשל/פלט-חסר) — פקודות מדויקות ב-[HEARTBEAT.md](HEARTBEAT.md) §4ב. **אסור** `done` עם פלט חסר.
|
||||||
|
|
||||||
**אם הכל עבר בהצלחה (בדיקות שלב 6 + טענות + עובדות שמאי):**
|
|
||||||
```bash
|
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "done"}'```
|
|
||||||
|
|
||||||
**אם בדיקות שלב 6 נכשלו או חילוץ נכשל:**
|
|
||||||
```bash
|
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "blocked"}'```
|
|
||||||
**אסור** לסיים `done` עם פלט חסר — אם ניסיון חוזר נכשל, סטטוס = `blocked` + comment עם פירוט.
|
|
||||||
|
|
||||||
5. **שלח מייל**:
|
5. **שלח מייל**:
|
||||||
```bash
|
```bash
|
||||||
@@ -323,20 +322,9 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
|||||||
```
|
```
|
||||||
|
|
||||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||||
```bash
|
|
||||||
# $PAPERCLIP_TASK_ID הוא UUID המלא שPaperclip מספק בסביבת הריצה — לעולם לא CMP-XX
|
|
||||||
# אסור להחליף ידנית: משתמשים ב-$PAPERCLIP_TASK_ID ישירות
|
|
||||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
|
||||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
|
||||||
else
|
|
||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
|
||||||
fi
|
|
||||||
|
|
||||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
|
wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג. **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||||
"{\"source\":\"automation\",\"triggerDetail\":\"system\",\"reason\":\"מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_ID\",\"mutation\":\"agent_completion\"}}"```
|
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** מוגדר אוטומטית ע"י Paperclip; ב-double-quotes bash מרחיב לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
|
||||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
|
||||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
|
||||||
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** המשתנה מוגדר אוטומטית ע"י Paperclip בסביבת הריצה. אם משתמשים בו ב-double-quotes (`"..."`), bash מרחיב אותו לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
|
|
||||||
|
|
||||||
## מבנה הפלט המלא — analysis-and-research.md
|
## מבנה הפלט המלא — analysis-and-research.md
|
||||||
|
|
||||||
@@ -496,18 +484,7 @@ X שאלות עומדות להכרעה:
|
|||||||
"העמקת ניתוח הושלמה — ערר {case_number}" \
|
"העמקת ניתוח הושלמה — ערר {case_number}" \
|
||||||
"סיכום: X פסקי דין אומתו, Y דורשים אימות חיצוני. ממצאים עובדתיים הועשרו."
|
"סיכום: X פסקי דין אומתו, Y דורשים אימות חיצוני. ממצאים עובדתיים הועשרו."
|
||||||
```
|
```
|
||||||
6. **העֵר את ה-CEO — חובה!**
|
6. **העֵר את ה-CEO — חובה!** wakeup עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים העמקת ניתוח (pass 2) $PAPERCLIP_TASK_ID"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג. **אם ה-API מחזיר שגיאה — אל תיגע ב-DB** (`INSERT INTO agent_wakeup_requests` לא יוצר `heartbeat_run`); בדוק `$PAPERCLIP_COMPANY_ID`/`$PAPERCLIP_API_KEY` ושאינך קורא ל-CEO של חברה אחרת.
|
||||||
```bash
|
|
||||||
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
|
|
||||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
|
||||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
|
||||||
else
|
|
||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
|
||||||
fi
|
|
||||||
|
|
||||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
|
|
||||||
"{\"source\":\"automation\",\"triggerDetail\":\"system\",\"reason\":\"מנתח משפטי סיים העמקת ניתוח (pass 2) $PAPERCLIP_TASK_ID\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_ID\",\"mutation\":\"agent_completion\"}}"```
|
|
||||||
**⚠️ אם ה-API מחזיר שגיאה — אל תיגע ב-DB.** `INSERT INTO agent_wakeup_requests` לא יוצר `heartbeat_run` והסוכן לא יתעורר לעולם. בדוק `$PAPERCLIP_COMPANY_ID` ו-`$PAPERCLIP_API_KEY`, ודאי שאתה לא קורא ל-CEO של חברה אחרת (`Agent key cannot access another company`).
|
|
||||||
|
|
||||||
## כללים קריטיים
|
## כללים קריטיים
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 @@ tools:
|
|||||||
|
|
||||||
אתה מנהל תהליך כתיבת החלטות של ועדת ערר לתכנון ובניה, מחוז ירושלים. יו"ר הוועדה היא עו"ד דפנה תמיר.
|
אתה מנהל תהליך כתיבת החלטות של ועדת ערר לתכנון ובניה, מחוז ירושלים. יו"ר הוועדה היא עו"ד דפנה תמיר.
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. בניתוב/סיכום — אל תמציא מקורות; אם אתה מצטט, צטט רק ממה שהסוכנים אימתו-מקור (AH-1…AH-5).
|
||||||
|
|
||||||
|
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז — כיוון שאתה ה**מתזמר** וצריך תמונה מלאה — את **כל קבצי-הספ** (`00`–`07`, `X1`–`X5`) תחת `~/legal-ai/docs/spec/`; לניתוב comments בפרט → `X3-integration-deploy.md §1ב`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||||
|
|
||||||
## שפה
|
## שפה
|
||||||
|
|
||||||
עבוד תמיד בעברית.
|
עבוד תמיד בעברית.
|
||||||
@@ -133,6 +141,17 @@ internal_decision_upload(
|
|||||||
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
||||||
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
||||||
| מנהל ידע (Hermes) | CMP: 60dce831-5c5b-4bae-bda9-5282d506f0dc · CMPA: d6f7c55d-570a-46b8-8d72-1286d07da0d8 | סקירת החלטות סופיות, הצעות לעדכון style guide / lessons. **לא קורא ישירות מ-CEO** — מופעל אוטומטית מ-`web/app.py:api_mark_final` כשדפנה לוחצת "סמן כסופי" ב-UI. |
|
| מנהל ידע (Hermes) | CMP: 60dce831-5c5b-4bae-bda9-5282d506f0dc · CMPA: d6f7c55d-570a-46b8-8d72-1286d07da0d8 | סקירת החלטות סופיות, הצעות לעדכון style guide / lessons. **לא קורא ישירות מ-CEO** — מופעל אוטומטית מ-`web/app.py:api_mark_final` כשדפנה לוחצת "סמן כסופי" ב-UI. |
|
||||||
|
| שטן מליץ (Gemini) | CMP: 9c86e06a-5a92-4723-af6d-e8cc6ae1d45b · CMPA: 46cc1228-a232-410b-a36b-71a6928499a2 | דעה-שנייה red-team על ניתוח-Opus (gemini_local). **on-demand בלבד — אינו חלק מהפייפליין.** ראה למטה. |
|
||||||
|
|
||||||
|
### שטן מליץ (Gemini) — דעה-שנייה on-demand בלבד ⚠️
|
||||||
|
|
||||||
|
סוכן-Gemini שמבצע red-team על תוצר-המנתח (Opus) ומפיק **מזכר-לידים לא-סמכותי ליו"ר** (`critique-gemini.md`), read-only. **אינו נמצא בזרימת analyst→writer→qa.**
|
||||||
|
|
||||||
|
**מתי להפעיל:** **רק כשחיים/דפנה מבקשים מפורשות** "תן שטן-מליץ / דעה-שנייה על תיק X". אל תפעיל אותו אוטומטית, אל תכלול אותו בתזמור רגיל, ואל תציע אותו מיוזמתך.
|
||||||
|
|
||||||
|
**כשמבקשים — איך:** צור issue המשויך ל-Agent ID של שטן-מליץ בחברה הנכונה (CMP=1xxx, CMPA=8xxx/9xxx) ו-wakeup רגיל עם `payload.issueId`.
|
||||||
|
|
||||||
|
**הגבול הקריטי:** הפלט שלו = **לידים לבדיקת היו"ר בלבד** (human-in-the-loop). **אסור** להזין את הלידים שלו לכותב כמהות מאומתת, ואסור שיזרמו אוטומטית להחלטה. ה-writer ממשיך לצרוך **רק** את פלט-המנתח המעוגן. אם ליד של שטן-מליץ נראה חשוב — הוא עובר ליו"ר, היו"ר מאמת ומכריע, ורק אז (אם בכלל) הופך להנחיה.
|
||||||
|
|
||||||
## כלל: כל issue חדש = תת-משימה
|
## כלל: כל issue חדש = תת-משימה
|
||||||
|
|
||||||
@@ -206,6 +225,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 +247,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 +284,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,12 @@ tools:
|
|||||||
|
|
||||||
אתה סוכן שמבצע את התהליך הסופי של הכנת טיוטת החלטה לעיון. תפקידך: בדיקה אחרונה, ייצוא ל-DOCX מעוצב, ושמירה מסודרת.
|
אתה סוכן שמבצע את התהליך הסופי של הכנת טיוטת החלטה לעיון. תפקידך: בדיקה אחרונה, ייצוא ל-DOCX מעוצב, ושמירה מסודרת.
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. ייצוא מכני (DOCX) — **אפס מהות חדשה**: אל תוסיף/תשנה ציטוט/מספר/אזכור; מה שאינו במקור — לא קיים (AH-1…AH-5).
|
||||||
|
|
||||||
|
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/06-export.md` (ייצוא DOCX לפי תבנית דפנה). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||||
|
|
||||||
## שפה
|
## שפה
|
||||||
|
|
||||||
עבוד תמיד בעברית.
|
עבוד תמיד בעברית.
|
||||||
@@ -118,31 +124,11 @@ tools:
|
|||||||
- ממצאי הבדיקה הסופית (אם היו הערות)
|
- ממצאי הבדיקה הסופית (אם היו הערות)
|
||||||
- גודל הקובץ
|
- גודל הקובץ
|
||||||
|
|
||||||
### סגור את ה-issue של עצמך — חובה!
|
### סגור את ה-issue של עצמך + העֵר CEO — חובה!
|
||||||
|
|
||||||
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||||
|
|
||||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מייצא טיוטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||||
```bash
|
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
|
||||||
|
|
||||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
|
||||||
```bash
|
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
|
||||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
|
||||||
|
|
||||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
|
||||||
```bash
|
|
||||||
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
|
|
||||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
|
||||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
|
||||||
else
|
|
||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
|
||||||
fi
|
|
||||||
|
|
||||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"מייצא טיוטה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
|
|
||||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
|
||||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
|
||||||
|
|
||||||
## כללים קריטיים
|
## כללים קריטיים
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ tools:
|
|||||||
|
|
||||||
אתה מגיה מסמכים משפטיים. תפקידך לבדוק טקסט שחולץ מסריקות (OCR) ולתקן שגיאות לפני שהמנתח המשפטי עובד איתו.
|
אתה מגיה מסמכים משפטיים. תפקידך לבדוק טקסט שחולץ מסריקות (OCR) ולתקן שגיאות לפני שהמנתח המשפטי עובד איתו.
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. תיקון-OCR בלבד — **אל "תתקן" לכיוון מונח משפטי סביר** (שם-תקדים/מספר-תיק/סכום): שמר את לשון-המקור; ספק → סמן, לא "תקן" (AH-1…AH-5).
|
||||||
|
|
||||||
|
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/01-ingest.md` (קליטה / טקסט-מחולץ). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||||
|
|
||||||
## שפה
|
## שפה
|
||||||
|
|
||||||
עבוד תמיד בעברית.
|
עבוד תמיד בעברית.
|
||||||
@@ -86,29 +92,9 @@ tools:
|
|||||||
"סיכום: X מסמכים הוגהו, Y החלפות, Z תיקונים. נדרשת ביקורתך."
|
"סיכום: X מסמכים הוגהו, Y החלפות, Z תיקונים. נדרשת ביקורתך."
|
||||||
```
|
```
|
||||||
|
|
||||||
### סגור את ה-issue של עצמך — חובה!
|
### סגור את ה-issue של עצמך + העֵר CEO — חובה!
|
||||||
|
|
||||||
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||||
|
|
||||||
**אם הכל עבר בהצלחה:**
|
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל / markers `[?]` רבים), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מגיה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||||
```bash
|
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "done"}'```
|
|
||||||
|
|
||||||
**אם נכשלו תיקונים קריטיים או יש markers `[?]` רבים:**
|
|
||||||
```bash
|
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "blocked"}'```
|
|
||||||
**אסור** לסיים `done` עם פלט חסר — אם נכשל, סטטוס = `blocked` + comment עם פירוט.
|
|
||||||
|
|
||||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
|
|
||||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
|
||||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
|
||||||
else
|
|
||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
|
||||||
fi
|
|
||||||
|
|
||||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"מגיה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
|
|
||||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
|
||||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ tools:
|
|||||||
|
|
||||||
אתה בודק איכות מומחה. תפקידך לבדוק שהחלטה מוכנה לייצוא ולחתימת יו"ר הוועדה.
|
אתה בודק איכות מומחה. תפקידך לבדוק שהחלטה מוכנה לייצוא ולחתימת יו"ר הוועדה.
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
> **שער anti-hallucination (INV-AH) — חובה:** קרא ו**אכוף** את `~/legal-ai/docs/anti-hallucination-gate.md` כשער-איכות: כל אזכור פסיקה/חוק/הלכה/מספר בטיוטה — האם מעוגן-מקור עם ציטוט? אם לא → `needs_revision` (AH-1…AH-5).
|
||||||
|
|
||||||
|
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/05-qa-review.md` (שערי QA + שערים אנושיים). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||||
|
|
||||||
## שפה
|
## שפה
|
||||||
|
|
||||||
עבוד תמיד בעברית.
|
עבוד תמיד בעברית.
|
||||||
@@ -231,28 +237,8 @@ new → proofread → documents_ready → analyst_verified → research_complete
|
|||||||
- האם מותר לייצא (כל הקריטיים pass?)
|
- האם מותר לייצא (כל הקריטיים pass?)
|
||||||
- עדכן סטטוס ל-qa_review (אם נכשל) או drafted (אם עבר)
|
- עדכן סטטוס ל-qa_review (אם נכשל) או drafted (אם עבר)
|
||||||
|
|
||||||
### סגור את ה-issue של עצמך — חובה!
|
### סגור את ה-issue של עצמך + העֵר CEO — חובה!
|
||||||
|
|
||||||
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||||
|
|
||||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="בודק איכות סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||||
```bash
|
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
|
||||||
|
|
||||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
|
||||||
```bash
|
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
|
||||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
|
||||||
|
|
||||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
|
||||||
```bash
|
|
||||||
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
|
|
||||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
|
||||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
|
||||||
else
|
|
||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
|
||||||
fi
|
|
||||||
|
|
||||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"בודק איכות סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
|
|
||||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
|
||||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ 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__search_digests
|
||||||
|
- mcp__legal-ai__digest_link
|
||||||
|
- mcp__legal-ai__digest_upload
|
||||||
- mcp__legal-ai__internal_decision_upload
|
- mcp__legal-ai__internal_decision_upload
|
||||||
- mcp__legal-ai__precedent_library_upload
|
- mcp__legal-ai__precedent_library_upload
|
||||||
- mcp__legal-ai__precedent_library_get
|
- mcp__legal-ai__precedent_library_get
|
||||||
@@ -30,6 +33,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 +46,12 @@ tools:
|
|||||||
|
|
||||||
אתה חוקר משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח את מסמכי הרקע בתיק ערר — פסיקה, תכניות, פרוטוקולים, החלטות ביניים.
|
אתה חוקר משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח את מסמכי הרקע בתיק ערר — פסיקה, תכניות, פרוטוקולים, החלטות ביניים.
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. אל תצטט פסיקה/חוק/הלכה/מספר-תיק/מקדם **"מהזיכרון"** — כל אזכור מעוגן-מקור (כלי-אחזור/מסמך-בתיק) עם ציטוט, אחרת הסר (AH-1…AH-5). "לא נמצא — דורש אימות" עדיף על המצאה.
|
||||||
|
|
||||||
|
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/03-retrieval.md` (3 קורפוסים, hybrid/RRF, attribution); לקליטת-פסיקה → `01-ingest.md`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||||
|
|
||||||
## שפה
|
## שפה
|
||||||
|
|
||||||
עבוד תמיד בעברית.
|
עבוד תמיד בעברית.
|
||||||
@@ -186,7 +196,27 @@ 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ב.0 — שכבת-גילוי: יומוני "כל יום" (`search_digests`) — מצפן, לפני האימות
|
||||||
|
|
||||||
|
לכל סוגיה מרכזית — הרץ `search_digests` כ**מצפן-מחקר (radar)**, **לא** כמקור-ציטוט. היומון הוא סיכום-משני (עפר טויסטר) של פסק-דין בודד, והוא מפנה אותך אל **הפסק המקורי**. אם נמצא יומון רלוונטי:
|
||||||
|
|
||||||
|
1. קרא את כותרת-ההלכה ואת ניתוח עפר-טויסטר **כרקע/orientation בלבד**.
|
||||||
|
2. חלץ את **מראה-המקום של הפסק המקורי** מהיומון (שדה `underlying_citation`, למשל `עת"מ 46111-12-22`).
|
||||||
|
3. **בדוק אם הפסק המקורי בקורפוס** — `search_precedent_library` **וגם** `search_internal_decisions` לפי פרוטוקול 2ב.4א (לפי קידומת-הציטוט; flowchart §8).
|
||||||
|
4. **אם נמצא** → אמת וצטט את הפסק המקורי כרגיל (`precedent_attach`), וקרא `digest_link(digest_id, case_law_id)` כדי לקשר את היומון לפסק.
|
||||||
|
5. **אם לא נמצא** → קרא `missing_precedent_create` על **הפסק המקורי** (לא על היומון), עם `notes="זוהה דרך יומון 'כל יום' מס' NNNN"`. היומון הוא הטריגר; הרשומה החסרה היא הפסק. (אם הפסק זמין — אפשר להעלותו דרך `precedent_library_upload`/`internal_decision_upload` ואז `digest_link`.)
|
||||||
|
|
||||||
|
⚠️ **היומון לעולם אינו מצוטט בהחלטה ואינו נרשם דרך `precedent_attach`** (INV-DIG1). הוא radar בלבד — מצביע, לא מקור. ראה [docs/spec/X12-digests-radar.md](../../docs/spec/X12-digests-radar.md).
|
||||||
|
|
||||||
|
```
|
||||||
|
search_digests(
|
||||||
|
query="...",
|
||||||
|
practice_area="betterment_levy", # rishuy_uvniya / betterment_levy / compensation_197
|
||||||
|
limit=10
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
||||||
|
|
||||||
@@ -280,7 +310,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 ("טוענים שמופיע" ≠ "אומת")
|
||||||
@@ -305,6 +335,10 @@ mcp__legal-ai__missing_precedent_create(
|
|||||||
|
|
||||||
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
|
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
|
||||||
|
|
||||||
|
#### 2ב.6 — תיעוד סריקת היומונים — סעיף "ט" ב-`precedent-research.md`
|
||||||
|
|
||||||
|
הוסף סעיף נפרד `## ט. סריקת יומונים (radar — לא ציטוט)` שמתעד אילו יומונים נסרקו לכל סוגיה, אילו פסקי-דין מקוריים הם הצביעו עליהם, וסטטוס כל אחד: *בקורפוס (קושר) / נרשם כחסר / לא רלוונטי*. ציין מפורש: **רשומות אלה אינן ציטוטים** — הן עקבות-מחקר (radar). ה-writer וה-QA מתעלמים מהן כמקור-סמכות (INV-DIG1); הציטוט בהחלטה תמיד נשען על הפסק המקורי שבסעיפים ז/ח.
|
||||||
|
|
||||||
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
||||||
|
|
||||||
### שלב 3: מיפוי תכנית
|
### שלב 3: מיפוי תכנית
|
||||||
@@ -358,31 +392,11 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
|||||||
- **מדיניות**: אילו שיקולים תכנוניים עולים מהחומר
|
- **מדיניות**: אילו שיקולים תכנוניים עולים מהחומר
|
||||||
- קישור למיקום הקובץ: `{case_dir}/documents/research/precedent-research.md`
|
- קישור למיקום הקובץ: `{case_dir}/documents/research/precedent-research.md`
|
||||||
|
|
||||||
### סגור את ה-issue של עצמך — חובה!
|
### סגור את ה-issue של עצמך + העֵר CEO — חובה!
|
||||||
|
|
||||||
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||||
|
|
||||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="חוקר תקדימים סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||||
```bash
|
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
|
||||||
|
|
||||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
|
||||||
```bash
|
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
|
||||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
|
||||||
|
|
||||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
|
||||||
```bash
|
|
||||||
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
|
|
||||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
|
||||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
|
||||||
else
|
|
||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
|
||||||
fi
|
|
||||||
|
|
||||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"חוקר תקדימים סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
|
|
||||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
|
||||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
|
||||||
|
|
||||||
## כללים
|
## כללים
|
||||||
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים
|
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ tools:
|
|||||||
|
|
||||||
אתה כותב משפטי מומחה. תפקידך לכתוב החלטות של ועדת ערר לתכנון ובניה, מחוז ירושלים, בסגנון של יו"ר הוועדה עו"ד דפנה תמיר.
|
אתה כותב משפטי מומחה. תפקידך לכתוב החלטות של ועדת ערר לתכנון ובניה, מחוז ירושלים, בסגנון של יו"ר הוועדה עו"ד דפנה תמיר.
|
||||||
|
|
||||||
|
## קרא לפני פעולה (INV-AG1)
|
||||||
|
|
||||||
|
> **שער anti-hallucination (INV-AH) — חובה:** קרא וקיים `~/legal-ai/docs/anti-hallucination-gate.md`. אתה **צרכן read-only** של פלט-המנתח המעוגן — **אסור** להוסיף פסיקה/סעיף/הלכה שלא הגיעו מהמנתח/הקורפוס; ציטוט בהחלטה = רק מ-`supporting_quote` מאומת (AH-1…AH-5).
|
||||||
|
|
||||||
|
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/04-analysis-writing.md` + `05-qa-review.md` (אתה כותב מול שערי-QA). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
|
||||||
|
|
||||||
## שפה
|
## שפה
|
||||||
|
|
||||||
עבוד תמיד בעברית.
|
עבוד תמיד בעברית.
|
||||||
@@ -208,31 +214,11 @@ case_update(case_number, status="drafted")
|
|||||||
- ספירת מילים לכל בלוק
|
- ספירת מילים לכל בלוק
|
||||||
- יחסי משקל (% מהמסמך)
|
- יחסי משקל (% מהמסמך)
|
||||||
|
|
||||||
### סגור את ה-issue של עצמך — חובה!
|
### סגור את ה-issue של עצמך + העֵר CEO — חובה!
|
||||||
|
|
||||||
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||||
|
|
||||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="כותב החלטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||||
```bash
|
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
|
||||||
|
|
||||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
|
||||||
```bash
|
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
|
||||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
|
||||||
|
|
||||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
|
||||||
```bash
|
|
||||||
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
|
|
||||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
|
||||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
|
||||||
else
|
|
||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
|
||||||
fi
|
|
||||||
|
|
||||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"כותב החלטה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
|
|
||||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
|
||||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
|
||||||
|
|
||||||
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
|
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
|
||||||
|
|
||||||
@@ -347,7 +333,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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
34
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
34
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<!--
|
||||||
|
תבנית PR — עוזר משפטי. מאכפת את "פרוטוקול כתיבת-קוד" (CLAUDE.md §פרוטוקול כתיבת-קוד):
|
||||||
|
כל PR מצהיר אילו invariants הוא נוגע בהם / מקיים. ראה docs/spec/00-constitution.md (G1–G12).
|
||||||
|
מלא את הסעיפים; מחק את ההערות בסוגריים <!-- -->.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## מה ולמה
|
||||||
|
|
||||||
|
<!-- תיאור קצר: מה ה-PR משנה ולמה. אם קשור ל-FU/GAP — ציין (למשל "FU-10 / GAP-30..34"). -->
|
||||||
|
|
||||||
|
## Invariants — הצהרה (חובה)
|
||||||
|
|
||||||
|
<!--
|
||||||
|
אילו invariants הנדסיים (G1–G10, G12) או INV-* מקבצי-תחום ה-PR נוגע בהם או מקיים?
|
||||||
|
דוגמה: "G2 (מקור-אמת יחיד) — איחדתי 2 לקוחות Paperclip למסלול קנוני אחד; INV-INT4."
|
||||||
|
דוגמה: "G12 (שער-הפלטפורמה) — מגע-Paperclip חדש נוסף רק ב-agent_platform_port.py, לא ב-mcp-server."
|
||||||
|
תוכן משפטי → G11.
|
||||||
|
-->
|
||||||
|
|
||||||
|
- **נוגע / מקיים:**
|
||||||
|
|
||||||
|
## צ'קליסט — פרוטוקול כתיבת-קוד
|
||||||
|
|
||||||
|
- [ ] קראתי את `docs/spec/00-constitution.md` + ספ-התחום הרלוונטי לפני הכתיבה
|
||||||
|
- [ ] השינוי **לא** יוצר מסלול מקביל ליכולת קיימת (G2) ולא מתקן תסמין בקריאה (G1)
|
||||||
|
- [ ] **לא** הוספתי מגע-Paperclip מחוץ ל-Platform Port (G12) — `mcp-server/src` וה-skills נקיים
|
||||||
|
- [ ] אין בליעה שקטה של שגיאות — רשומה חסרה/פגומה מסומנת ומדווחת (כלל-הנדסה §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
|
||||||
|
|||||||
22
.gitea/workflows/leak-guard.yaml
Normal file
22
.gitea/workflows/leak-guard.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
name: G12 Leak-Guard
|
||||||
|
|
||||||
|
# Hard gate for INV-G12 (docs/spec/X15 §4 / R4): the intelligence layer
|
||||||
|
# (mcp-server/src) must stay free of Paperclip-specific symbols, and only
|
||||||
|
# web/agent_platform_port.py may import the Paperclip client. Pure-stdlib check
|
||||||
|
# (no venv) — fast, runs on every PR and on push to main.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
leak-guard:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: G12 — Agent Platform Port leak-guard
|
||||||
|
run: python3 scripts/leak_guard.py
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,6 +6,7 @@ data/backups/
|
|||||||
data/precedent-library/
|
data/precedent-library/
|
||||||
data/.auto-sync.log
|
data/.auto-sync.log
|
||||||
data/*.db
|
data/*.db
|
||||||
|
data/checkpoints/ # X16 durable-pipeline SQLite checkpoints (runtime artifact)
|
||||||
*.bak-pre-*
|
*.bak-pre-*
|
||||||
mcp-server/.venv/
|
mcp-server/.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -16,3 +17,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
|
||||||
285
CLAUDE.md
285
CLAUDE.md
@@ -1,10 +1,11 @@
|
|||||||
# עוזר משפטי — Legal Decision Assistant
|
# עוזר משפטי — Legal Decision Assistant
|
||||||
|
|
||||||
|
> **אינדקס דק.** הכללים הקריטיים נמצאים כאן; העומק התפעולי (Deploy, Paperclip-ops, adapters, מבנה-תיקיות, Chair-Feedback, TaskMaster מלא) הוצא ל-[`docs/operations-runbook.md`](docs/operations-runbook.md) כדי לרזות את ההקשר הנטען בכל סשן.
|
||||||
|
|
||||||
## רקע הפרויקט
|
## רקע הפרויקט
|
||||||
|
|
||||||
מערכת AI לסיוע בכתיבת החלטות של **ועדת ערר לתכנון ובניה, מחוז ירושלים**, בראשות **עו"ד דפנה תמיר**.
|
מערכת AI לסיוע בכתיבת החלטות של **ועדת ערר לתכנון ובניה, מחוז ירושלים**, בראשות **עו"ד דפנה תמיר**.
|
||||||
|
|
||||||
### מה עושה ועדת ערר?
|
|
||||||
ועדת ערר היא גוף מעין-שיפוטי שדן בעררים על החלטות ועדות מקומיות לתכנון ובניה. הוועדה מקבלת חומרי מקור (כתבי ערר, תגובות, פרוטוקולים, תכניות), דנה בטענות הצדדים, ומוציאה **החלטה כתובה מנומקת** — מסמך משפטי פורמלי שניתן לביקורת שיפוטית בבית משפט לעניינים מנהליים.
|
ועדת ערר היא גוף מעין-שיפוטי שדן בעררים על החלטות ועדות מקומיות לתכנון ובניה. הוועדה מקבלת חומרי מקור (כתבי ערר, תגובות, פרוטוקולים, תכניות), דנה בטענות הצדדים, ומוציאה **החלטה כתובה מנומקת** — מסמך משפטי פורמלי שניתן לביקורת שיפוטית בבית משפט לעניינים מנהליים.
|
||||||
|
|
||||||
### שלושה סוגי עררים
|
### שלושה סוגי עררים
|
||||||
@@ -15,22 +16,19 @@
|
|||||||
| פיצויים (ס' 197) | 9xxx | קר ומקצועי | דומה להיטל השבחה |
|
| פיצויים (ס' 197) | 9xxx | קר ומקצועי | דומה להיטל השבחה |
|
||||||
|
|
||||||
### מטרת המערכת
|
### מטרת המערכת
|
||||||
לבנות כלי עבודה שמסייע ליו"ר הוועדה לנסח החלטות:
|
כלי עבודה שמסייע ליו"ר הוועדה: **ניהול תיקים** (ייבוא, סיווג, מעקב סטטוס) · **בסיס ידע** (פסיקה, ביטויי מעבר, לקחים, חקיקה) · **חיפוש סמנטי (RAG)** · **סיוע בכתיבה** (טיוטות לפי 12 בלוקים בסגנון דפנה) · **ייצוא DOCX**.
|
||||||
1. **ניהול תיקים** — ייבוא חומרי מקור, סיווג מסמכים, מעקב סטטוס
|
|
||||||
2. **בסיס ידע** — פסיקה, ביטויי מעבר, לקחים מהחלטות קודמות, חקיקה
|
|
||||||
3. **חיפוש סמנטי (RAG)** — מציאת תקדימים רלוונטיים ופסקאות דומות
|
|
||||||
4. **סיוע בכתיבה** — ייצור טיוטות לפי ארכיטקטורת 12 בלוקים בסגנון דפנה
|
|
||||||
5. **ייצוא DOCX** — מסמך מעוצב מוכן להגשה
|
|
||||||
|
|
||||||
### מה היה קודם (Legacy)
|
### ⭐ יעד-העל: רכישת-הסגנון של דפנה (Style Acquisition)
|
||||||
המערכת הקודמת היתה **Obsidian vault** עם Claude Code skills על שרת אחר. פותחו:
|
**היעד הראשי של המערכת הוא שהסוכנים יכתבו וינתחו עררים בדיוק כמו עו"ד דפנה תמיר** — לא רק לייצר טיוטה תקנית, אלא להפנים את **הקול והשיטה** שלה. זה מחייב **הפרדה מובהקת בין שתי תת-מערכות**:
|
||||||
- ניתוח סגנון של 3 החלטות (הכט — דחייה, בית הכרם — קבלה חלקית, אריאלי — השוואה)
|
|
||||||
- ארכיטקטורת 12 בלוקים מבוססת CREAC / DITA / Akoma Ntoso / Federal Judicial Center
|
|
||||||
- כללי כתיבה (רקע ניטרלי, ללא כפילות, טענות מקוריות בלבד)
|
|
||||||
- לקחים מהשוואת טיוטות לגרסאות סופיות
|
|
||||||
- סקריפט ייצוא DOCX
|
|
||||||
|
|
||||||
הידע שהופק מה-vault הוטמע במערכת הנוכחית — מסמכי ייחוס (`docs/`), קורפוס אימון (`data/training/`), ומבנה 12 בלוקים. ה-vault המקורי נמחק; הפרויקט הנוכחי עובד עם PostgreSQL + pgvector.
|
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:** המערכת הקודמת היתה Obsidian vault עם Claude Code skills. הידע שהופק ממנה (ניתוח סגנון, 12 בלוקים מבוססי CREAC/DITA/Akoma-Ntoso/FJC, כללי כתיבה, לקחים, ייצוא DOCX) הוטמע בפרויקט הנוכחי (`docs/`, `data/training/`). ה-vault נמחק; כעת PostgreSQL + pgvector.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -38,11 +36,15 @@
|
|||||||
|
|
||||||
| מסמך | תוכן | מתי לקרוא |
|
| מסמך | תוכן | מתי לקרוא |
|
||||||
|------|-------|-----------|
|
|------|-------|-----------|
|
||||||
|
| [`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 — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
|
||||||
| [`docs/legal-decision-lessons.md`](docs/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** |
|
| [`docs/legal-decision-lessons.md`](docs/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** |
|
||||||
| [`docs/decision-methodology.md`](docs/decision-methodology.md) | **מתודולוגיה אנליטית — איך לחשוב על החלטה מעין-שיפוטית** | **לפני כל כתיבת החלטה** |
|
| [`docs/decision-methodology.md`](docs/decision-methodology.md) | **מתודולוגיה אנליטית — איך לחשוב על החלטה מעין-שיפוטית** | **לפני כל כתיבת החלטה** |
|
||||||
|
| [`docs/anti-hallucination-gate.md`](docs/anti-hallucination-gate.md) | **שער anti-hallucination משותף (INV-AH)** — 5 טכניקות מעוגנות-מקור (עיגון-מקור, quote-or-retract, abstention, תיוג-ודאות, CoVe). מקור-אמת אחד לכל הסוכנים | **לפני כל אזכור פסיקה/חוק/הלכה/מספר** |
|
||||||
| `docs/garner-methodology-extraction.md` | חומר מקור: מיצוי מספרי Garner על כתיבה משפטית | רק לבדיקת מקור |
|
| `docs/garner-methodology-extraction.md` | חומר מקור: מיצוי מספרי Garner על כתיבה משפטית | רק לבדיקת מקור |
|
||||||
| `docs/fjc-principles-extraction.md` | חומר מקור: מיצוי מ-Judicial Writing Manual (FJC) | רק לבדיקת מקור |
|
| `docs/fjc-principles-extraction.md` | חומר מקור: מיצוי מ-Judicial Writing Manual (FJC) | רק לבדיקת מקור |
|
||||||
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
|
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
|
||||||
@@ -58,202 +60,94 @@
|
|||||||
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
|
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
|
||||||
| [`.claude/agents/HEARTBEAT.md`](.claude/agents/HEARTBEAT.md) | checklist הפעלת סוכן — routing, company filtering, quirks, wakeup עם UUID נכון | **לפני כל עבודה על סוכנים** |
|
| [`.claude/agents/HEARTBEAT.md`](.claude/agents/HEARTBEAT.md) | checklist הפעלת סוכן — routing, company filtering, quirks, wakeup עם UUID נכון | **לפני כל עבודה על סוכנים** |
|
||||||
| [`skills/dafna-decision-template/SKILL.md`](skills/dafna-decision-template/SKILL.md) | export DOCX לפי styles של תבנית Word של דפנה — line classification, dash policy, placeholder handling | לפני export DOCX |
|
| [`skills/dafna-decision-template/SKILL.md`](skills/dafna-decision-template/SKILL.md) | export DOCX לפי styles של תבנית Word של דפנה — line classification, dash policy, placeholder handling | לפני export DOCX |
|
||||||
|
| [`docs/corpus-graph.md`](docs/corpus-graph.md) | **מפת הקורפוס** (`/graph`) — גרף ציטוטים אינטראקטיבי נייטיב; 6 שכבות (פסיקה/נושא/תחום/הלכות/חוסרי‑מחקר/יומונים), אנליטיקה (PageRank/אשכולות), endpoints, ואיך מוסיפים שכבה | לפני עבודה על דף `/graph` או `web/graph_api.py` |
|
||||||
|
| [`docs/operations-runbook.md`](docs/operations-runbook.md) | **עומק תפעולי** — Deploy (Coolify/pm2), Paperclip-ops מלא (wakeup, sync, webhook, scheduled jobs, adapters), מבנה-תיקיות, Chair-Feedback, TaskMaster | לפני עבודה על Deploy / אינטגרציית-Paperclip / adapters |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## שרת Nautilus (158.178.131.193)
|
## פרוטוקול כתיבת-קוד — קודם הספ ⚠️
|
||||||
|
|
||||||
| שירות | תפקיד | כתובת |
|
> **כלל-על.** המקור הקנוני ל"מהו תקין הנדסית" הוא ספ-המערכת תחת [`docs/spec/`](docs/spec/) — לא
|
||||||
|-------|--------|-------|
|
> הרגלים, לא "הקוד הקיים נראה ככה". כל קוד שנכתב בלי לעבור דרך הספ מסתכן בהחזרת **כשל-השורש**
|
||||||
| Coolify | ניהול containers | `http://158.178.131.193:8000` |
|
> שהספ בא לייבש: מסלולים/קורפוסים מקבילים שמתפצלים (drift). זהו המקבילה האינטראקטיבית ל-INV-AG1
|
||||||
| PostgreSQL + pgvector | בסיס נתונים ראשי | `legal-ai-postgres` |
|
> שכבר אוכף על סוכני Paperclip ([HEARTBEAT.md](.claude/agents/HEARTBEAT.md) §"קריאת-ספ").
|
||||||
| Redis | תור משימות | `legal-ai-redis` |
|
|
||||||
| n8n | אוטומציית workflows | להגדרה |
|
|
||||||
| Gitea | מאגר קוד | `gitea.nautilus.marcusgroup.org/ezer-mishpati` |
|
|
||||||
| ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` |
|
|
||||||
| Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` |
|
|
||||||
| Infisical | ניהול סודות | `secret.dev.marcus-law.co.il` |
|
|
||||||
|
|
||||||
### ⚠️ ארכיטקטורת Deploy — חובה לקרוא
|
**לפני יצירה/שינוי של קוד ב-`web/`, `mcp-server/`, `web-ui/`, `scripts/`:**
|
||||||
|
|
||||||
**עוזר משפטי (Legal-AI)** — רץ כ-**Docker container דרך Coolify**:
|
1. **קרא** [`docs/spec/00-constitution.md`](docs/spec/00-constitution.md) — ייעוד, ה-invariants הגלובליים G1–G12, וכללי-ההנדסה (§6). אינדקס-הספ ב-§7.
|
||||||
- UUID: `gyjo0mtw2c42ej3xxvbz8zio`
|
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)/[`X15`](docs/spec/X15-agent-platform-port.md) (G12), עמידות-פייפליין→[`X16`](docs/spec/X16-pipeline-durability.md), env/secrets→[`X10-deploy-env-secrets.md`](docs/spec/X10-deploy-env-secrets.md).
|
||||||
- שינוי קוד ב-`web/` או `web-ui/` **לא נכנס לתוקף** עד ש:
|
3. **ודא שהשינוי *מקיים* את ה-invariants** — לא יוצר מסלול מקביל ליכולת קיימת ([G2](docs/spec/00-constitution.md)), לא מתקן תסמין בקריאה במקום נרמול במקור (G1), לא בולע שגיאות בשקט (כלל-הנדסה §6).
|
||||||
1. עושים `git commit` + `git push origin main`
|
4. **בדוק מול** [`gap-audit.md`](docs/spec/gap-audit.md) — אם אתה נוגע ב-GAP/FU שכבר ממופה, התאם את העבודה ליחידת-התיקון; אל תפתור מחדש.
|
||||||
2. מריצים deploy דרך Coolify (`mcp__coolify__deploy`)
|
5. **כל PR מצהיר invariants** — אילו G*/INV-* ה-PR נוגע בהם / מקיים (ראה תבנית ה-PR ב-[`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md)).
|
||||||
3. ממתינים ~2-4 דקות לבנייה
|
|
||||||
- **אסור** לנסות להריץ uvicorn מקומית — אין סביבת Python על המכונה
|
|
||||||
- ה-container מריץ Next.js (`:3000`, חשוף) + FastAPI (`:8000`, פנימי)
|
|
||||||
- בדיקה: `curl https://legal-ai.nautilus.marcusgroup.org/api/...`
|
|
||||||
|
|
||||||
**Paperclip** — רץ **מקומית דרך pm2**:
|
> **שתי שכבות-כללים מובחנות, שתיהן חלות:**
|
||||||
- פורט: `localhost:3100`, DB: `localhost:54329`
|
> - **הנדסה (G1–G10, G12)** — הסעיף הזה + `docs/spec/`. סמכות: ≥3 מקורות חיצוניים.
|
||||||
- שינויי קוד נכנסים לתוקף אחרי `pm2 restart paperclip`
|
> - **תוכן משפטי (G11)** — סעיף "עקרונות כתיבה קריטיים" למטה (12 בלוקים, רקע ניטרלי...). סמכות: היו"ר + מסמכי-הפרויקט.
|
||||||
- **אין צורך ב-Docker או Coolify**
|
>
|
||||||
|
> אכיפה אוטומטית: hook `PreToolUse` ([scripts/spec-guard.sh](scripts/spec-guard.sh)) מזכיר את הפרוטוקול בכל Edit/Write על נתיב-קוד.
|
||||||
**legal-chat-service** — רץ **מקומית דרך pm2** (חדש, מאפריל 2026):
|
|
||||||
- פורט: `localhost:8770` (loopback בלבד)
|
|
||||||
- שירות aiohttp קצר שעוטף את `claude` CLI ב-streaming + session continuation, ומשרת את הטאב "שיחה" בדף `/training`. הקונטיינר משדל אליו proxy דרך `host.docker.internal:8770`.
|
|
||||||
- קוד: [mcp-server/src/legal_mcp/chat_service/](mcp-server/src/legal_mcp/chat_service/)
|
|
||||||
- התקנה: `pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs && pm2 save`
|
|
||||||
- בריאות: `curl http://127.0.0.1:8770/health` → `{"ok":true,...}`
|
|
||||||
- שינויי קוד: `pm2 restart legal-chat-service`
|
|
||||||
- **אפס עלות API** — claude CLI משתמש ב-claude.ai subscription של chaim. הנחת היסוד של `claude_session.py` (claude CLI מקומי בלבד) נשמרת — השירות הזה הוא הגשר הרשמי בין הקונטיינר לחוץ.
|
|
||||||
- Coolify dependency: ה-Service Definition של legal-ai חייב להכיל `extra_hosts: host.docker.internal:host-gateway` (אחרת ה-proxy יקבל ConnectError).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## מבנה תיקיות
|
## בידוד-סשנים — 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)
|
||||||
```
|
```
|
||||||
/home/chaim/legal-ai/
|
נוצר תחת `.claude/worktrees/<slug>/` על ענף `worktree-<slug>`, ומקבל **אוטומטית**: בסיס נקי מ-`origin/main` (`worktree.baseRef: "fresh"`) · `web-ui/node_modules` כסימלינק (`worktree.symlinkDirectories`; אין צורך ב-`npm ci`) · `.claude/settings.local.json` + קבצי-env מקומיים (דרך `.worktreeinclude`) · ניקוי אוטומטי ביציאה (כולל עקיפת באג סימלינק [#40259](https://github.com/anthropics/claude-code/issues/40259) דרך `WorktreeRemove` hook עם `--force`).
|
||||||
├── CLAUDE.md ← הקובץ הזה
|
|
||||||
├── Dockerfile ← Docker build
|
### הפרוטוקול (חל על שתי הדרכים)
|
||||||
├── docs/ ← תיעוד + לקחים
|
1. **בתחילת עבודת-כתיבה** — צור worktree (מומלץ: `claude --worktree`; ידני-fallback: `git worktree add -b <branch> .claude/worktrees/<slug> origin/main` — **תחת `.claude/worktrees/`** כדי שההגדרות יחולו).
|
||||||
│ ├── architecture.md ארכיטקטורה
|
2. **אמת ענף לפני כל commit** — `git branch --show-current` (הרגל קשיח; ה-harness עלול להתעלם מ-`baseRef:"fresh"` — באג [#60588](https://github.com/anthropics/claude-code/issues/60588) — אז ודא שהבסיס באמת `origin/main`).
|
||||||
│ ├── block-schema.md 12 בלוקים (המסמך החשוב ביותר)
|
3. **push + PR + merge** כרגיל ([[feedback_always_pr_merge]]) — PR תמיד ל-`main`. הרץ tests לפני merge.
|
||||||
│ ├── migration-plan.md תוכנית מעבר vault → DB
|
4. **נקה אחרי מיזוג** — יציאת הסשן מנקה worktree של ה-harness אוטומטית; ידני: `git worktree remove .claude/worktrees/<slug> && git worktree prune && git branch -D worktree-<slug>`.
|
||||||
│ ├── legal-decision-lessons.md לקחים מ-3 החלטות
|
5. **קריאה-בלבד** (חקירה, סריקה, הרצת בדיקות ללא שינוי) — מותר בעץ הראשי; אין צורך ב-worktree.
|
||||||
│ └── memory.md הקשר כללי — skills, פרויקטים
|
6. **אל תיגע** בשינויים לא-מתויקים שאינם שלך בעץ הראשי — הם של סשן אחר. אם העץ הראשי על ענף זר — אל תתייק עליו.
|
||||||
├── skills/ ← כלי עבודה ומדריכים
|
|
||||||
│ ├── decision/ מדריך סגנון + references + 12 בלוקים
|
> **בידוד-DB:** ה-worktree מבודד-קבצים בלבד — לא בידוד-repo ולא בידוד-DB. **אל תריץ migrations מ-2 worktrees במקביל** על Postgres המשותף (`localhost:5433`) — סכמה שאף סשן לא מצפה לה ([Run agents in parallel](https://code.claude.com/docs/en/agents)).
|
||||||
│ ├── assistant/ קטלוג מסמכים
|
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w`. כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. ניתוח מלא: TaskMaster `legal-ai` #104 (נסגר cancelled — "לתעד, לא לבדד").
|
||||||
│ ├── docx/ עיצוב DOCX
|
|
||||||
│ ├── dafna-decision-template/ export DOCX לפי תבנית Word של דפנה
|
---
|
||||||
│ └── new-company-setup/ blueprint הוספת חברה חדשה
|
|
||||||
├── .claude/
|
## Deploy — תמצית קריטית
|
||||||
│ └── agents/ ← הוראות סוכנים + HEARTBEAT.md (symlinks ב-Paperclip)
|
|
||||||
│ ├── HEARTBEAT.md checklist הפעלה משותף לכל הסוכנים
|
שלושה מודלי-הרצה דרים יחד; ערבוב = הטעות הנפוצה. **פירוט מלא, UUIDs ופקודות: [`docs/operations-runbook.md`](docs/operations-runbook.md).**
|
||||||
│ ├── legal-ceo.md תזמורן + בקרת זרימה
|
|
||||||
│ ├── legal-writer.md כתיבת בלוקים בסגנון דפנה
|
- **legal-ai** (`web/`, `web-ui/`) = **Docker דרך Coolify**. שינוי קוד לא נכנס לתוקף עד `git commit` + `git push origin main` → Gitea Actions בונה image → `mcp__coolify__deploy` (~2-4 דק'). **אסור** uvicorn/`next dev` מקומית — אין Python על המכונה. בדיקה: `curl https://legal-ai.nautilus.marcusgroup.org/api/health`.
|
||||||
│ ├── legal-analyst.md ניתוח משפטי + חילוץ טענות
|
- **Paperclip** = **pm2 מקומי** (`localhost:3100`). שינוי → `pm2 restart paperclip`. **אין** Docker/Coolify.
|
||||||
│ ├── legal-researcher.md חיפוש תקדימים
|
- **legal-chat-service** = **pm2 מקומי** (`127.0.0.1:8770`), גשר claude CLI לטאב הצ'אט ב-/training. שינוי → `pm2 restart legal-chat-service`.
|
||||||
│ ├── legal-qa.md 7 שערי איכות
|
|
||||||
│ ├── legal-proofreader.md תיקון OCR
|
---
|
||||||
│ ├── legal-exporter.md ייצוא DOCX סופי
|
|
||||||
│ └── hermes-curator.md סוכן Hermes לניתוח סגנון post-export
|
## Paperclip — כללים קריטיים (תמצית)
|
||||||
├── data/
|
|
||||||
│ ├── training/ ← 4 החלטות לאימון (DOCX)
|
**פירוט מלא + דוגמאות + פקודות sync: [`docs/operations-runbook.md`](docs/operations-runbook.md).**
|
||||||
│ ├── exports/ ← טיוטות DOCX מיוצאות
|
|
||||||
│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB)
|
> **G12 — שער-הפלטפורמה ([`docs/spec/X15-agent-platform-port.md`](docs/spec/X15-agent-platform-port.md)):** Paperclip היא **מעטפת ניתנת-להחלפה** מאחורי Port יחיד. מגע-Paperclip מותר רק ב-`web/agent_platform_port.py` + `HEARTBEAT.md` (לפרומפטים) + המעטפת המוצהרת (`paperclip_client/api`, plugin, adapters). **אסור** סמל ספציפי-Paperclip ב-`mcp-server/src` או ב-skills של ההחלטה/הסגנון. כל מגע חדש → דרך ה-Port.
|
||||||
├── web/ ← FastAPI backend (Python): 75+ API endpoints
|
|
||||||
│ ├── app.py ← API ראשי
|
- **Wakeup תמיד דרך API**: `POST /api/agents/{agent-id}/wakeup` עם `payload.issueId`. **אסור** `INSERT INTO agent_wakeup_requests` ישיר — הסוכן לא יתעורר לעולם (אין `heartbeat_run`).
|
||||||
│ ├── paperclip_api.py ← אינטגרציית Paperclip: `pc_request()` + `emit_case_status_webhook()`
|
- **ניתוב comments דרך CEO**: תגובת-משתמש → פלאגין מעיר CEO → CEO מנתב ויוצר issue. סוכנים קוראים comments אחרונים לפני עבודה (HEARTBEAT 2b-2c).
|
||||||
│ ├── paperclip_client.py ← legacy client (ישן — השתמש ב-paperclip_api.py)
|
- **קריאות API דרך helper בלבד**: bash → `scripts/pc.sh`; Python → `pc_request()` מ-`web/paperclip_api.py`. **אסור** `curl` ישיר ל-Paperclip או `httpx.AsyncClient` ישיר.
|
||||||
│ └── gitea_client.py ← אינטגרציית Gitea
|
- **Cross-company sync**: 14 סוכנים = 7 × 2 חברות (CMP=1xxx master, CMPA=8xxx mirror). אחרי כל שינוי הגדרות/skills של סוכן — להריץ `scripts/sync_agents_across_companies.py --apply`. **מדלג** על סוכנים עם `adapter_type` שונה בין החברות (למשל `deepseek_local`) — להחיל ידנית בשתיהן.
|
||||||
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
|
|
||||||
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
|
|
||||||
├── mcp-server/ ← MCP server + services + tools
|
|
||||||
├── adapters/ ← Paperclip external adapters (ראה למטה)
|
|
||||||
│ └── deepseek-paperclip-adapter/ ← `deepseek_local` (Hermes-pinned ל-DeepSeek profile)
|
|
||||||
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
|
|
||||||
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## כלל: עדכון `scripts/SCRIPTS.md`
|
## כלל: עדכון `scripts/SCRIPTS.md`
|
||||||
|
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/` — **חובה לעדכן את `scripts/SCRIPTS.md`** (תפקיד, סטטוס, החלפה).
|
||||||
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/` — **חובה לעדכן את `scripts/SCRIPTS.md`** בהתאם.
|
|
||||||
הקובץ מתעד את התפקיד, הסטטוס, וההחלפה (אם יש) של כל סקריפט.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ניהול משימות — TaskMaster AI
|
## ניהול משימות — TaskMaster AI
|
||||||
|
**תמיד** TaskMaster (לא TASKS.md ידני). קובץ קנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (tags: `master`, `legal-ai`). פקודות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`.
|
||||||
הפרויקט משתמש ב-**TaskMaster AI** (MCP server) לניהול משימות מובנה:
|
> **⚠️ מלכוד cwd ב-CLI:** `--tag` בוחר קבוצה *בתוך* הקובץ — לא לאיזה קובץ לכתוב (ה-CLI מאתר לפי cwd). תמיד `cd ~/legal-ai` לפני כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את הקובץ ישירות. פירוט: [`docs/operations-runbook.md`](docs/operations-runbook.md).
|
||||||
- **תמיד** להשתמש ב-TaskMaster לפירוק, מעקב וניהול משימות — לא ב-TASKS.md ידני
|
|
||||||
- קובץ המשימות הקנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (יחסי ל-project root, **לא** `~/.taskmaster/tasks/tasks.json`). מכיל את כל ה-tags של legal-ai (`master`, `legal-ai`).
|
|
||||||
- פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`
|
|
||||||
- לפני התחלת עבודה → `next_task` כדי לדעת מה הבא לפי תלויות
|
|
||||||
- אחרי סיום משימה → `update_task` עם status=done
|
|
||||||
- משימה מורכבת → `expand_task` לפירוק לתתי-משימות
|
|
||||||
|
|
||||||
> **⚠️ מלכוד cwd ב-CLI:** הדגל `--tag` בוחר קבוצה לוגית *בתוך* הקובץ — הוא **לא** בוחר לאיזה `tasks.json` לכתוב. ה-CLI מאתר את הקובץ לפי ה-cwd (`<cwd>/.taskmaster/tasks/tasks.json`). תמיד `cd ~/legal-ai` לפני `task-master add-task` או כל פקודה משנה, ואז אמת ב-MCP `get_tasks` שהשינוי נחת. הרצה מ-`~/` כותבת לקובץ נטוש והמשימה לא תופיע בשאילתות MCP. כשלא בטוחים — לערוך את `~/legal-ai/.taskmaster/tasks/tasks.json` ישירות.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Paperclip — כללי אינטגרציה קריטיים
|
## עקרונות כתיבה קריטיים (G11)
|
||||||
|
|
||||||
### Wakeup API — תמיד דרך API, לעולם לא דרך DB
|
|
||||||
- **הנתיב הנכון**: `POST /api/agents/{agent-id}/wakeup` (לא `/wake`!)
|
|
||||||
- **⚠️ אסור**: `INSERT INTO agent_wakeup_requests` ישירות — זה יוצר רק רשומה בלי `heartbeat_run`, והסוכן **לא יתעורר לעולם**
|
|
||||||
- **⚠️ חובה לשלוח `payload` עם `issueId`** — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי issue, בלי cwd נכון)
|
|
||||||
- דוגמה נכונה:
|
|
||||||
```json
|
|
||||||
{"source": "automation", "triggerDetail": "system", "reason": "...",
|
|
||||||
"payload": {"issueId": "...", "mutation": "comment", "commentId": "..."}}
|
|
||||||
```
|
|
||||||
- **Board API Key**: שמור ב-DB (`board_api_keys`), auth: `Authorization: Bearer pbk_...`
|
|
||||||
|
|
||||||
### ניתוב comments דרך CEO
|
|
||||||
- כשמשתמש כותב תגובה על issue ב-Paperclip, הפלאגין (`plugin-legal-ai`) מעיר את ה-CEO דרך `ctx.agents.invoke()`
|
|
||||||
- ה-CEO קורא את ה-comment, מחליט על ניתוב, ויוצר issue לסוכן המתאים
|
|
||||||
- כל הסוכנים חייבים לקרוא comments אחרונים לפני שהם מתחילים לעבוד (HEARTBEAT שלבים 2b-2c)
|
|
||||||
|
|
||||||
### קריאות API — תמיד דרך helper, לעולם לא `curl` ישיר
|
|
||||||
- **bash (סוכנים):** `~/legal-ai/scripts/pc.sh <METHOD> <PATH> [BODY_JSON]` — מוסיף Authorization, X-Paperclip-Run-Id, Content-Type, base URL. ראה `HEARTBEAT.md §0`.
|
|
||||||
- **Python (FastAPI):** `from web.paperclip_api import pc_request; await pc_request("POST", "/api/...", json={...})` — שימוש ב-board API key.
|
|
||||||
- **אסור** `curl ... $PAPERCLIP_API_URL` ישיר ב-bash; **אסור** `httpx.AsyncClient` ישיר ל-Paperclip ב-Python.
|
|
||||||
- **למה:** ה-skill הרשמי דורש `X-Paperclip-Run-Id` בכל קריאה משנה issue. אצלנו ה-audit trail עבד ממילא דרך JWT claims (`runId: runIdHeader || claims.run_id`), אבל ה-helper מבטיח עקביות + תאימות ל-board API keys (long-lived) שלא נושאות JWT claims.
|
|
||||||
|
|
||||||
### Cross-company agent sync — אחרי כל שינוי הגדרות
|
|
||||||
- יש 14 סוכנים = 7 × 2 חברות (CMP=1xxx, CMPA=8xxx). Paperclip מחייב `agents.company_id NOT NULL` — אין shared agents.
|
|
||||||
- **Master = CMP (1xxx)**, **Mirror = CMPA (8xxx)**.
|
|
||||||
- אחרי כל שינוי ב-`adapter_config`, `runtime_config`, `budget_monthly_cents`, או skills של סוכן ב-master (UI, SQL, או API), **חובה להריץ:**
|
|
||||||
```bash
|
|
||||||
PAPERCLIP_BOARD_API_KEY=$(...infisical...) \
|
|
||||||
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify # לבדיקה
|
|
||||||
PAPERCLIP_BOARD_API_KEY=$(...) \
|
|
||||||
python ~/legal-ai/scripts/sync_agents_across_companies.py --apply # לסנכרן
|
|
||||||
```
|
|
||||||
- הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent.
|
|
||||||
- שאלות ה-skill הרשמי של Paperclip — `paperclip` skill תחת `paperclipai/paperclip`.
|
|
||||||
|
|
||||||
### Webhook יוצא — עדכון סטטוס תיק לפלאגין
|
|
||||||
|
|
||||||
כשסטטוס תיק משתנה דרך `PUT /api/cases/{case_number}`, הבקאנד שולח webhook אסינכרוני לפלאגין:
|
|
||||||
|
|
||||||
```
|
|
||||||
PUT /api/cases/{case_number} → emit_case_status_webhook() [BackgroundTask]
|
|
||||||
→ POST /api/plugins/marcusgroup.legal-ai/webhooks/case-status
|
|
||||||
→ plugin-legal-ai/onWebhook()
|
|
||||||
→ comment בעברית על issue + CEO wakeup (כשסטטוס = qa_failed)
|
|
||||||
```
|
|
||||||
|
|
||||||
- הקוד ב-`web/paperclip_api.py` (`emit_case_status_webhook`), fire-and-forget, timeout 5s
|
|
||||||
- הפלאגין שומר idempotency key ב-state עם TTL 5 דקות למניעת spam על retry
|
|
||||||
- `GET /api/cases/stale?days=N` — תיקים שלא עודכנו N ימים; מוחרגים: `new`, `final`, `exported`
|
|
||||||
- `GET /api/chair-feedback/weekly-summary` — סיכום פידבק YU"R לשבוע האחרון
|
|
||||||
|
|
||||||
### Scheduled Jobs (plugin-legal-ai)
|
|
||||||
|
|
||||||
| Job | לוח זמנים | מה עושה |
|
|
||||||
|-----|-----------|---------|
|
|
||||||
| `stale-case-reminder` | יומי 08:00 | שולח comment אזהרה על תיקים תקועים >3 ימים |
|
|
||||||
| `weekly-feedback-analysis` | ראשון 19:00 | מעיר CEO לניתוח פידבק YU"R ועדכון `docs/legal-decision-lessons.md` |
|
|
||||||
| `sync-case-status` | כל 30 דק' | מסנכרן סטטוסי תיקים בין legal-ai ל-Paperclip |
|
|
||||||
|
|
||||||
CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **אין לו issueId, אל תנסה לפרסם comment או לסגור issue**.
|
|
||||||
|
|
||||||
### External adapters — `deepseek_local`
|
|
||||||
- מיקום ה-package: [adapters/deepseek-paperclip-adapter/](adapters/deepseek-paperclip-adapter/) (לא ב-`node_modules`).
|
|
||||||
- רישום ב-Paperclip: רשומה ב-`~/.paperclip/adapter-plugins.json` (נטען אוטומטית ב-startup דרך `buildExternalAdapters`). אין צורך בעריכת `node_modules`.
|
|
||||||
- **מה ה-adapter עושה**: spawnל-`hermes chat` עם `HERMES_HOME=/home/chaim/.hermes/profiles/deepseek` כך שה-CLI טוען את `config.yaml` (`base_url=https://api.deepseek.com/v1`, `provider=custom`, `key_env=DEEPSEEK_API_KEY`) ואת `.env` (שמכיל את ה-key).
|
|
||||||
- **מודלים זמינים** (lookup ב-DeepSeek `/v1/models`): `deepseek-v4-pro` (default), `deepseek-v4-flash`. יופיעו כדרופ-דאון ב-UI.
|
|
||||||
- **התקנה מחדש / עדכון**: `curl -X POST -H "Authorization: Bearer pcapi_legal_install_key_2026" -H "Content-Type: application/json" -d '{"packageName":"/home/chaim/legal-ai/adapters/deepseek-paperclip-adapter","isLocalPath":true}' http://localhost:3100/api/adapters/install`. לעדכון hot — `POST /api/adapters/deepseek_local/reload`.
|
|
||||||
- **⚠ Cross-company sync**: `sync_agents_across_companies.py` **מדלג** על סוכנים עם `adapter_type` שונה בין CMP ל-CMPA. כשעוברים סוכן ל-`deepseek_local` חובה להחיל ידנית בשתי החברות לפני sync.
|
|
||||||
- **תוספת adapters עתידיים** (OpenAI ישיר, Anthropic ישיר, וכו'): אותו דפוס. ה-package הראשי חייב לייצא `createServerAdapter()` שמחזיר `{ type, label, models, agentConfigurationDoc, execute, testEnvironment, sessionCodec, listSkills, syncSkills, ... }`. ראה את [adapters/deepseek-paperclip-adapter/dist/index.js](adapters/deepseek-paperclip-adapter/dist/index.js) כתבנית.
|
|
||||||
|
|
||||||
### External adapters — Hermes Curator (`curator-cmp` / `curator-cmpa`)
|
|
||||||
- פרופילי Hermes נפרדים לסוכן `hermes-curator` — מנתח החלטות סופיות ומציע עדכוני SKILL.md/lessons.md
|
|
||||||
- מיקום: `~/.hermes/profiles/curator-cmp/` + `~/.hermes/profiles/curator-cmpa/`
|
|
||||||
- מופעל אחרי export סופי; אינו מעדכן קבצים ישירות
|
|
||||||
- **תהליך אישור הצעות:** הצעות ה-curator מגיעות כ-comment ב-Paperclip → חיים בוחן ומאשר ידנית → commits ל-`SKILL.md` ו-`docs/legal-decision-lessons.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## עקרונות כתיבה קריטיים
|
|
||||||
|
|
||||||
1. **"מבחן השופט"** — כל החלטה חייבת להיות קריאה לשופט שלא מכיר את התיק
|
1. **"מבחן השופט"** — כל החלטה חייבת להיות קריאה לשופט שלא מכיר את התיק
|
||||||
2. **"רקע ניטרלי"** — בלוק ו = עובדות בלבד. אין ציטוטים מצדדים, אין מילות שיפוט
|
2. **"רקע ניטרלי"** — בלוק ו = עובדות בלבד. אין ציטוטים מצדדים, אין מילות שיפוט
|
||||||
@@ -262,14 +156,7 @@ CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **
|
|||||||
5. **ארכיטקטורת 12 בלוקים** — ראה `docs/block-schema.md`
|
5. **ארכיטקטורת 12 בלוקים** — ראה `docs/block-schema.md`
|
||||||
6. **צ'קליסט תוכן** — בלוק י מקבל צ'קליסט תוכן אוטומטי לפי סוג הערר (ראה `lessons.py: CONTENT_CHECKLISTS`)
|
6. **צ'קליסט תוכן** — בלוק י מקבל צ'קליסט תוכן אוטומטי לפי סוג הערר (ראה `lessons.py: CONTENT_CHECKLISTS`)
|
||||||
|
|
||||||
## הערות יו"ר (Chair Feedback)
|
> **הערות יו"ר (Chair Feedback):** מנגנון תיעוד הערות דפנה — טבלת `chair_feedback`, API `/api/feedback`, MCP `record_chair_feedback`/`list_chair_feedback`, UI `/feedback`. פירוט: [`docs/operations-runbook.md`](docs/operations-runbook.md).
|
||||||
|
|
||||||
מנגנון לתיעוד הערות דפנה על טיוטות:
|
|
||||||
- **DB**: טבלת `chair_feedback` (case_id, block_id, feedback_text, category, lesson_extracted)
|
|
||||||
- **API**: `GET/POST /api/feedback`, `PATCH /api/feedback/{id}/resolve`
|
|
||||||
- **MCP tools**: `record_chair_feedback`, `list_chair_feedback`
|
|
||||||
- **UI**: דף ניהול ב-`/feedback` (ב-Next.js)
|
|
||||||
- **קטגוריות**: missing_content, wrong_tone, wrong_structure, factual_error, style, other
|
|
||||||
|
|
||||||
## יו"ר: עו"ד דפנה תמיר
|
## יו"ר: עו"ד דפנה תמיר
|
||||||
- מדריך סגנון מלא: `skills/decision/SKILL.md`
|
מדריך סגנון מלא: [`skills/decision/SKILL.md`](skills/decision/SKILL.md).
|
||||||
|
|||||||
@@ -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)"}
|
||||||
62
docs/anti-hallucination-gate.md
Normal file
62
docs/anti-hallucination-gate.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# שער anti-hallucination — הגנה משותפת מפני הזיות (INV-AH)
|
||||||
|
|
||||||
|
> **מקור-אמת אחד לכל הסוכנים.** כל סוכן נוגע-מהות מפנה לכאן (דרך [HEARTBEAT.md](.claude/agents/HEARTBEAT.md)
|
||||||
|
> ובלוק "קרא לפני פעולה" שלו). אל תשכפל את הכללים בקובץ-סוכן — הפנה לכאן (G2 — בלי מסלולים מקבילים).
|
||||||
|
> זהו המקבילה התוכנית ל-INV-AG1 (קריאת-ספ): כמו שאינך פועל "מהזיכרון" לגבי התנהגות-המערכת, אינך
|
||||||
|
> מצטט פסיקה/חוק/הלכה/מספר "מהזיכרון".
|
||||||
|
|
||||||
|
## למה זה קיים
|
||||||
|
כלי-AI משפטיים מובילים (Lexis+ AI, Westlaw) **הוזים פסיקה ב-17%–33%** גם עם RAG — זו לא בעיה
|
||||||
|
שנעלמת מעצמה ("RAG ≠ hallucination-free"). בתחום מעין-שיפוטי, ציטוט-שווא של פסק-דין/סעיף/הלכה הוא
|
||||||
|
כשל קריטי הניתן לביקורת שיפוטית. חמש הטכניקות למטה הן הקונצנזוס המקצועי להפחתת הזיות, מותאם לתחום.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## חמש הטכניקות הקשיחות (חלות על כל סוכן נוגע-מהות)
|
||||||
|
|
||||||
|
**AH-1 · עיגון-מקור (grounding) — אפס ציטוט מהזיכרון.**
|
||||||
|
כל אזכור של פסק-דין / מספר-תיק / סעיף-חוק / הלכה / מקדם / "מתודה שמאית" / נתון כמותי חייב לבוא
|
||||||
|
ממקור מאומת: **תוצאת כלי-אחזור** (`search_precedent_library`, `search_internal_decisions`,
|
||||||
|
`search_case_documents`, `search_decisions`, `find_similar_cases`, `precedent_library_get`,
|
||||||
|
`halacha_review`) **או מסמך בתיק**. אם לא הרצת חיפוש/לא קראת מסמך — אין לך את הפריט. *(Stanford RegLab / Magesh et al., JELS 2025; Anthropic — ground in retrieved sources.)*
|
||||||
|
|
||||||
|
**AH-2 · Quote-or-retract.**
|
||||||
|
לכל אזכור-מקור צרף את הציטוט/מזהה המדויק שהמקור החזיר (`supporting_quote`/headnote/ציטוט מהמסמך).
|
||||||
|
**אין ציטוט מאשר → הסר את האזכור.** *(Anthropic — retract if no supporting quote; RAGAS faithfulness — כל טענה חייבת להיות נתמכת ב-context.)*
|
||||||
|
|
||||||
|
**AH-3 · Abstention — "לא יודע" עדיף על המצאה.**
|
||||||
|
לא נמצא מקור? כתוב מפורשות **"לא נמצא בקורפוס/בתיק — דורש אימות חיצוני"**. אסור לסגור פער בהשערה
|
||||||
|
שנכתבת כעובדה. *(Anthropic — give the model an out.)*
|
||||||
|
|
||||||
|
**AH-4 · תיוג-ודאות.** סמן כל טענה לא-טריוויאלית:
|
||||||
|
`[מאומת]` (מקור+ציטוט) · `[טעון-אימות]` (סביר/עולה מהמסמכים, אך לא אותר מקור מאשר) · `[ספקולציה]`
|
||||||
|
(השערה אנליטית — מותרת רק כשאלה/הסתייגות, לא כקביעה). *(NIST AI RMF GenAI Profile — explainability/קליברציה; RAGAS — atomic-claim grounding.)*
|
||||||
|
|
||||||
|
**AH-5 · Chain-of-Verification (CoVe) — מעבר-אימות לפני סיום.**
|
||||||
|
אחרי הטיוטה, פרק כל טענה עובדתית/אזכור לרשימה, ולכל אחת שאל "מאיזה מקור מאומת זה מגיע?".
|
||||||
|
כל מה שאין לו עוגן — **הסר או הורד ל-`[ספקולציה]`**. *(Chain-of-Verification — Dhuliawala et al., arXiv:2309.11495, 2023.)*
|
||||||
|
|
||||||
|
> **ההבחנה שמכריעה הכל — "פער" מותר, "המצאה" אסורה:**
|
||||||
|
> ✅ "אזכרתי את X — חיפשתי ולא מצאתי בקורפוס; דורש אימות." (פער לגיטימי) ·
|
||||||
|
> ❌ "הנה תקדים Y רלוונטי" כש-Y לא הגיע מכלי-אחזור. (המצאה)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## יישום לפי תפקיד
|
||||||
|
| סוכן | איך השער חל |
|
||||||
|
|------|-------------|
|
||||||
|
| **analyst / researcher** | מייצרי-מהות — עיגון-קורפוס מלא, log שאילתות + negative evidence, "מקור: כתבי טענות → דורש אימות". (כבר נהוג; כעת אחיד ומעוגן-מקור.) |
|
||||||
|
| **writer** | **צרכן read-only** של פלט-המנתח המעוגן. **אסור** להוסיף פסיקה/סעיף/הלכה שלא הגיעו מהמנתח/הקורפוס. ציטוט בהחלטה = רק מ-`supporting_quote` מאומת. |
|
||||||
|
| **qa** | **אוכף** את AH-1…AH-5 כשער-איכות: כל אזכור בטיוטה — האם מאומת-מקור? אם לא — `needs_revision`. |
|
||||||
|
| **ceo** | מנתב ומסכם — לא ממציא מקורות; אם מצטט, מצטט ממה שהסוכנים אימתו. |
|
||||||
|
| **proofreader** | תיקון-OCR בלבד — **אל "תתקן" לכיוון מונח משפטי סביר** (שם-תקדים/מספר-תיק/סכום): שמר את לשון-המקור; ספק → סמן, לא "תקן". |
|
||||||
|
| **exporter** | מכני (DOCX) — אפס מהות חדשה. |
|
||||||
|
| **hermes-curator** | הצעות בלבד (G10) — מעוגן-מקור, לא מזין שכבת-קול עם מהות (INV-LRN5). |
|
||||||
|
| **שטן מליץ (Gemini)** | מימוש-הייחוס המלא של השער (`legal-analyst-gemini-critique.md`) — לידים-לא-הכרעות ליו"ר (human-in-the-loop, NIST). |
|
||||||
|
|
||||||
|
## מקורות מקצועיים
|
||||||
|
1. Magesh, Surani, Dahl, Suzgun, Manning, Ho — *Hallucination-Free? Assessing the Reliability of Leading AI Legal Research Tools*, J. Empirical Legal Studies (2025), Stanford RegLab/HAI — שיעורי-הזיה 17–33% גם עם RAG.
|
||||||
|
2. Anthropic — *Reduce hallucinations* (docs.anthropic.com): allow "I don't know" · cite quotes/sources · retract-if-no-quote · chain-of-thought.
|
||||||
|
3. Dhuliawala et al. — *Chain-of-Verification Reduces Hallucination in LLMs*, arXiv:2309.11495 (2023).
|
||||||
|
4. Es et al. — *RAGAS: Automated Evaluation of RAG*, arXiv:2309.15217 — faithfulness = יחס הטענות הנתמכות-בקונטקסט.
|
||||||
|
5. NIST — *AI RMF: Generative AI Profile* (NIST-AI-600-1, 2024) — human-in-the-loop oversight ב-high-stakes.
|
||||||
@@ -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:**
|
||||||
|
|
||||||
|
|||||||
70
docs/corpus-graph.md
Normal file
70
docs/corpus-graph.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# מפת הקורפוס — גרף ציטוטים אינטראקטיבי (`/graph`)
|
||||||
|
|
||||||
|
תצוגת‑רשת אינטראקטיבית של קורפוס הפסיקה, בסגנון Obsidian Graph View, **מוטמעת נייטיב ב‑web‑ui**. כל פריט הוא נקודה, קישורים הם קווים, וגודל הנקודה משקף חשיבות — כך שאפשר להתמקד בנושא ולראות מה קשור אליו.
|
||||||
|
|
||||||
|
## למה נייטיב ולא Obsidian (G2)
|
||||||
|
|
||||||
|
הרעיון המקורי היה לייצא את הקורפוס ל‑Obsidian vault. **נדחה** — vault הוא **עותק מקביל של הקורפוס שמתיישן**, בדיוק כשל‑השורש ש‑[G2](spec/00-constitution.md) (מקור‑אמת יחיד, ללא מסלול מקביל) בא לייבש. הגרף הנייטיב קורא את ה‑DB החי → **אפס drift**, ומתחבר לדפים הקיימים (`/precedents`, `/missing-precedents`, `/digests`).
|
||||||
|
|
||||||
|
**התובנה המאפשרת:** כל קשתות הגרף כבר היו קיימות בטבלאות — הגרף רק חושף אותן. הוא **projection קריא‑בלבד** (SELECT בלבד), ולכן אינו יכול לסטות מהמקור. הוא **אינו מסלול אחזור** ([03-retrieval](spec/03-retrieval.md)) — מחזיר טופולוגיה (nodes+edges+מטריקות), לא תוצאות חיפוש מדורגות.
|
||||||
|
|
||||||
|
## שכבות (כולן opt‑in דרך toggles, מלבד הבסיס)
|
||||||
|
|
||||||
|
| שכבה | נקודות | קשתות | מקור הדאטה |
|
||||||
|
|------|--------|-------|------------|
|
||||||
|
| **בסיס** | פסיקה (`cl:`) · נושא (`tag:`) · תחום (`pa:`) | `cites` · `same_chain` · `tagged` · `in_area` | `case_law`, `precedent_internal_citations`, `case_law_relations`, `subject_tags` |
|
||||||
|
| **הלכות** | הלכה (`hal:`) | `extracted_from` · `corroborates` · `equivalent` | `halachot`, `halacha_citation_corroboration`, `equivalent_halachot` |
|
||||||
|
| **חוסרי מחקר** | gap (`gap:`) — חלול/מקווקו | `cites` (פסיקה→gap) | `precedent_internal_citations` (cited_case_law_id IS NULL) + העשרה מ‑`missing_precedents` |
|
||||||
|
| **יומונים** | יומון (`dig:`) — טורקיז | `covers` (יומון→פסיקה/gap) | `digests` |
|
||||||
|
|
||||||
|
**גודל נקודה** = חשיבות: ציטוטים נכנסים (פסיקה), אזכורים (הלכה), מספר מצטטים (gap). **צבע** (color‑by, ברירת‑מחדל "סוג"): סוג · תחום · דרגת‑סמכות · **אשכול** (community) · עדכניות.
|
||||||
|
|
||||||
|
## אנליטיקה (Graph Analysis)
|
||||||
|
|
||||||
|
`metrics=true` מפעיל חישוב **in‑memory** (ללא DB) ב‑[`web/graph_metrics.py`](../web/graph_metrics.py) — pure, ללא תלויות (אין networkx):
|
||||||
|
- **PageRank** (power‑iteration) — השפעה גלובלית.
|
||||||
|
- **Betweenness** (Brandes) — "גשריות" (פסיקות שמחברות אשכולות).
|
||||||
|
- **Community** (label‑propagation דטרמיניסטי + fallback ל‑connected‑components) — אשכולות תמטיים.
|
||||||
|
|
||||||
|
מחושב על **תת‑גרף הפסיקות בלבד** (cites/same_chain) — קשתות hub/gap/digest/halacha מוחרגות. ב‑UI: בוררי "צביעה לפי" / "גודל לפי" + פאנל דירוג ("המשפיעות" / "גשרים").
|
||||||
|
|
||||||
|
## ניווט וחוויה
|
||||||
|
|
||||||
|
- **Deep‑link** `/graph?focus=cl:<id>` — לינק שיתופי; כפתור **"הצג בגרף"** בכל דף פסיקה.
|
||||||
|
- **Local graph** — לחיצה על נקודה → התמקדות בשכניה (BFS, סליידר עומק 1–3).
|
||||||
|
- **ייצוא PNG** · פאנל עשיר (headnote/summary) · מקרא נקודות+קשתות · סינון מטא‑דאטה (בית‑משפט/דרגה/יו״ר/מחוז/שנים).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
קריאה‑בלבד, `response_model` מפורש (UI2). מוגדר ב‑[`web/app.py`](../web/app.py) (~`/api/graph/*`), לוגיקה ב‑[`web/graph_api.py`](../web/graph_api.py):
|
||||||
|
|
||||||
|
| endpoint | תיאור |
|
||||||
|
|----------|-------|
|
||||||
|
| `GET /api/graph/corpus` | הגרף המלא. params: `node_types` (csv), `metrics`, `practice_area`/`source`/`court`/`precedent_level`/`chair`/`district`/`year_from`/`year_to`, `min_citations`, `q`, `limit` (cap 400, max 1500) |
|
||||||
|
| `GET /api/graph/node/{id}/neighborhood` | Local graph: צומת + שכנים בעומק 1–3 |
|
||||||
|
| `GET /api/graph/facets` | ערכי סינון ייחודיים (courts/levels/chairs/districts) |
|
||||||
|
|
||||||
|
## קבצים
|
||||||
|
|
||||||
|
- **Backend:** [`web/graph_api.py`](../web/graph_api.py) (הרכבת nodes/edges, helpers `_edges_and_hubs`/`_gap_nodes_and_edges`/`_digest_nodes_and_edges`/`_halacha_nodes_and_edges`) · [`web/graph_metrics.py`](../web/graph_metrics.py) (מטריקות) · endpoints ב‑[`web/app.py`](../web/app.py).
|
||||||
|
- **Frontend:** [`web-ui/src/app/graph/page.tsx`](../web-ui/src/app/graph/page.tsx) · [`web-ui/src/components/graph/`](../web-ui/src/components/graph/) (`graph-view` orchestrator · `graph-canvas` ציור react‑force‑graph‑2d · `graph-filter-panel` · `graph-node-panel`) · hooks ב‑[`web-ui/src/lib/api/graph.ts`](../web-ui/src/lib/api/graph.ts).
|
||||||
|
|
||||||
|
## איך מוסיפים שכבה חדשה
|
||||||
|
|
||||||
|
1. הוסף ערך ל‑`VALID_NODE_TYPES` ב‑`graph_api.py` (לא ל‑`DEFAULT_NODE_TYPES` אם רוצים שיהיה כבוי).
|
||||||
|
2. כתוב `_X_nodes_and_edges(conn, prec_ids)` — SELECT בלבד; חבר nodes לפסיקות שבתצוגה.
|
||||||
|
3. חבר בשתי פונקציות הבנייה (`build_corpus_graph` + `build_node_neighborhood`) מאחורי `if "X" in types`.
|
||||||
|
4. **dangling‑edge invariant:** כל קשת — שני קצותיה חייבים להיות nodes בתצוגה (סנן מול `prec_ids`/קבוצת ה‑ids).
|
||||||
|
5. Frontend: toggle ב‑`graph-filter-panel` · צבע/רינדור ב‑`graph-canvas` (`NODE_COLORS`/`colorForNode`/`linkColor`) · ענף בפאנל ב‑`graph-node-panel`.
|
||||||
|
6. אם גדל מודל התגובה — אחרי deploy: `cd web-ui && npm run api:types`.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- **G2** — projection קריא‑בלבד דרך `db.get_pool()`; אפס כתיבות; מטריקות in‑memory. ללא store מקביל.
|
||||||
|
- **G5** — כל פילטר server‑side, parameterized.
|
||||||
|
- **UI2** — `response_model` מפורש בכל endpoint; **UI4** — שגיאות UI מוצגות, לא נבלעות.
|
||||||
|
- **טופולוגיה ≠ אחזור** — מבנה הקורפוס, לא תוצאות חיפוש.
|
||||||
|
|
||||||
|
## היסטוריית מימוש
|
||||||
|
|
||||||
|
PR #113 (בסיס) · #118 (תיקון תוויות) · #126 (מטא‑דאטה) · #129 (אנליטיקה) · #131 (gaps) · #132 (יומונים) · #134 (ניווט) · #137 (הלכות) · #139 (api:types).
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
203
docs/operations-runbook.md
Normal file
203
docs/operations-runbook.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# Operations Runbook — עוזר משפטי
|
||||||
|
|
||||||
|
> תוכן תפעולי-עומק שהוצא מ-[`CLAUDE.md`](../CLAUDE.md) כדי לרזות את ההקשר הנטען בכל סשן (TaskMaster #107.1).
|
||||||
|
> ה-CLAUDE.md מחזיק את **הכללים הקריטיים בקצרה**; כאן נמצאים הפרטים המלאים, הפקודות, וטבלאות-הייחוס.
|
||||||
|
> כשעובדים על Deploy, Paperplip-ops, או adapters — לקרוא את הסעיף הרלוונטי כאן.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## שרת Nautilus (158.178.131.193)
|
||||||
|
|
||||||
|
| שירות | תפקיד | כתובת |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| Coolify | ניהול containers | `http://158.178.131.193:8000` |
|
||||||
|
| PostgreSQL + pgvector | בסיס נתונים ראשי | `legal-ai-postgres` (`localhost:5433`, user `legal_ai`) |
|
||||||
|
| Redis | תור משימות | `legal-ai-redis` |
|
||||||
|
| Gitea | מאגר קוד | `gitea.nautilus.marcusgroup.org/ezer-mishpati` |
|
||||||
|
| ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` |
|
||||||
|
| Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` |
|
||||||
|
| legal-chat-service | גשר claude CLI לטאב הצ'אט ב-/training (pm2, loopback) | `127.0.0.1:8770` |
|
||||||
|
| Infisical | ניהול סודות | `secret.dev.marcus-law.co.il` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ארכיטקטורת Deploy — חובה לקרוא
|
||||||
|
|
||||||
|
שלושה מודלי-הרצה דרים יחד. ערבוב ביניהם הוא הטעות הנפוצה ביותר.
|
||||||
|
|
||||||
|
### עוזר משפטי (Legal-AI) — Docker container דרך Coolify
|
||||||
|
- UUID: `gyjo0mtw2c42ej3xxvbz8zio` (build_pack: `dockerimage`, **לא** `dockerfile`)
|
||||||
|
- שינוי קוד ב-`web/` או `web-ui/` **לא נכנס לתוקף** עד ש:
|
||||||
|
1. עושים `git commit` + `git push origin main`
|
||||||
|
2. Gitea Actions בונה image → דוחף ל-registry → מפעיל redeploy ב-Coolify (`mcp__coolify__deploy`)
|
||||||
|
3. ממתינים ~2-4 דקות לבנייה
|
||||||
|
- **אסור** לנסות להריץ uvicorn / `next dev` מקומית — אין סביבת Python על המכונה
|
||||||
|
- ה-container מריץ Next.js (`:3000`, חשוף) + FastAPI (`:8000`, פנימי)
|
||||||
|
- בדיקה: `curl https://legal-ai.nautilus.marcusgroup.org/api/health`
|
||||||
|
- runbook מלא של ה-pipeline: `~/CI-CD-MIGRATION-GUIDE.md`
|
||||||
|
|
||||||
|
### Paperclip — מקומית דרך pm2
|
||||||
|
- פורט: `localhost:3100`, DB: `localhost:54329` (Postgres embedded)
|
||||||
|
- שינויי קוד נכנסים לתוקף אחרי `pm2 restart paperclip`
|
||||||
|
- **אין צורך ב-Docker או Coolify** (מיגרציה ל-Coolify נוסתה 2026-04-04 והוחזרה 2026-04-08)
|
||||||
|
- תרגום/RTL: `~/.paperclip/hebrew/` → `bash ~/.paperclip/hebrew/apply-hebrew.sh && pm2 restart paperclip`
|
||||||
|
|
||||||
|
### legal-chat-service — מקומית דרך pm2 (מאפריל 2026)
|
||||||
|
- פורט: `127.0.0.1:8770` (loopback בלבד)
|
||||||
|
- שירות aiohttp קצר שעוטף את `claude` CLI ב-streaming + session continuation, ומשרת את הטאב "שיחה" בדף `/training`. הקונטיינר משדל אליו proxy דרך `host.docker.internal:8770`.
|
||||||
|
- קוד: [`mcp-server/src/legal_mcp/chat_service/`](../mcp-server/src/legal_mcp/chat_service/)
|
||||||
|
- התקנה: `pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs && pm2 save`
|
||||||
|
- בריאות: `curl http://127.0.0.1:8770/health` → `{"ok":true,...}`
|
||||||
|
- שינויי קוד: `pm2 restart legal-chat-service`
|
||||||
|
- **אפס עלות API** — claude CLI משתמש ב-claude.ai subscription של chaim. הנחת היסוד של `claude_session.py` (claude CLI מקומי בלבד) נשמרת.
|
||||||
|
- Coolify dependency: ה-Service Definition של legal-ai חייב להכיל `extra_hosts: host.docker.internal:host-gateway` (אחרת ה-proxy יקבל ConnectError).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## מבנה תיקיות
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/chaim/legal-ai/
|
||||||
|
├── CLAUDE.md ← אינדקס דק (כללים קריטיים + מצביעים)
|
||||||
|
├── docs/operations-runbook.md ← הקובץ הזה (עומק תפעולי)
|
||||||
|
├── Dockerfile ← Docker build
|
||||||
|
├── docs/ ← תיעוד + לקחים
|
||||||
|
│ ├── architecture.md ארכיטקטורה
|
||||||
|
│ ├── block-schema.md 12 בלוקים (המסמך החשוב ביותר)
|
||||||
|
│ ├── migration-plan.md תוכנית מעבר vault → DB
|
||||||
|
│ ├── legal-decision-lessons.md לקחים מ-3 החלטות
|
||||||
|
│ └── memory.md הקשר כללי — skills, פרויקטים
|
||||||
|
├── skills/ ← כלי עבודה ומדריכים
|
||||||
|
│ ├── decision/ מדריך סגנון + references + 12 בלוקים
|
||||||
|
│ ├── assistant/ קטלוג מסמכים
|
||||||
|
│ ├── docx/ עיצוב DOCX
|
||||||
|
│ ├── dafna-decision-template/ export DOCX לפי תבנית Word של דפנה
|
||||||
|
│ └── new-company-setup/ blueprint הוספת חברה חדשה
|
||||||
|
├── .claude/
|
||||||
|
│ └── agents/ ← הוראות סוכנים + HEARTBEAT.md (symlinks ב-Paperclip)
|
||||||
|
│ ├── HEARTBEAT.md checklist הפעלה משותף לכל הסוכנים
|
||||||
|
│ ├── legal-ceo.md תזמורן + בקרת זרימה
|
||||||
|
│ ├── legal-writer.md כתיבת בלוקים בסגנון דפנה
|
||||||
|
│ ├── legal-analyst.md ניתוח משפטי + חילוץ טענות
|
||||||
|
│ ├── legal-researcher.md חיפוש תקדימים
|
||||||
|
│ ├── legal-qa.md 7 שערי איכות
|
||||||
|
│ ├── legal-proofreader.md תיקון OCR
|
||||||
|
│ ├── legal-exporter.md ייצוא DOCX סופי
|
||||||
|
│ └── hermes-curator.md סוכן Hermes לניתוח סגנון post-export
|
||||||
|
├── data/
|
||||||
|
│ ├── training/ ← 4 החלטות לאימון (DOCX)
|
||||||
|
│ ├── exports/ ← טיוטות DOCX מיוצאות
|
||||||
|
│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB)
|
||||||
|
├── web/ ← FastAPI backend (Python): 75+ API endpoints
|
||||||
|
│ ├── app.py ← API ראשי
|
||||||
|
│ ├── paperclip_api.py ← אינטגרציית Paperclip: `pc_request()` + `emit_case_status_webhook()`
|
||||||
|
│ ├── paperclip_client.py ← legacy client (ישן — השתמש ב-paperclip_api.py)
|
||||||
|
│ └── gitea_client.py ← אינטגרציית Gitea
|
||||||
|
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
|
||||||
|
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
|
||||||
|
├── mcp-server/ ← MCP server + services + tools
|
||||||
|
├── adapters/ ← Paperclip external adapters
|
||||||
|
│ └── deepseek-paperclip-adapter/ ← `deepseek_local` (Hermes-pinned ל-DeepSeek profile)
|
||||||
|
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
|
||||||
|
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paperclip — כללי אינטגרציה (פירוט מלא)
|
||||||
|
|
||||||
|
> הכללים הקריטיים בתמצית נמצאים ב-[`CLAUDE.md`](../CLAUDE.md). כאן הפירוט המלא, הדוגמאות, וה-"למה".
|
||||||
|
|
||||||
|
### Wakeup API — תמיד דרך API, לעולם לא דרך DB
|
||||||
|
- **הנתיב הנכון**: `POST /api/agents/{agent-id}/wakeup` (לא `/wake`!)
|
||||||
|
- **⚠️ אסור**: `INSERT INTO agent_wakeup_requests` ישירות — זה יוצר רק רשומה בלי `heartbeat_run`, והסוכן **לא יתעורר לעולם**
|
||||||
|
- **⚠️ חובה לשלוח `payload` עם `issueId`** — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי issue, בלי cwd נכון)
|
||||||
|
- דוגמה נכונה:
|
||||||
|
```json
|
||||||
|
{"source": "automation", "triggerDetail": "system", "reason": "...",
|
||||||
|
"payload": {"issueId": "...", "mutation": "comment", "commentId": "..."}}
|
||||||
|
```
|
||||||
|
- **Board API Key**: שמור ב-DB (`board_api_keys`), auth: `Authorization: Bearer pbk_...`
|
||||||
|
|
||||||
|
### ניתוב comments דרך CEO
|
||||||
|
- כשמשתמש כותב תגובה על issue ב-Paperclip, הפלאגין (`plugin-legal-ai`) מעיר את ה-CEO דרך `ctx.agents.invoke()`
|
||||||
|
- ה-CEO קורא את ה-comment, מחליט על ניתוב, ויוצר issue לסוכן המתאים
|
||||||
|
- כל הסוכנים חייבים לקרוא comments אחרונים לפני שהם מתחילים לעבוד (HEARTBEAT שלבים 2b-2c)
|
||||||
|
|
||||||
|
### קריאות API — תמיד דרך helper, לעולם לא `curl` ישיר
|
||||||
|
- **bash (סוכנים):** `~/legal-ai/scripts/pc.sh <METHOD> <PATH> [BODY_JSON]` — מוסיף Authorization, X-Paperclip-Run-Id, Content-Type, base URL. ראה `HEARTBEAT.md §0`.
|
||||||
|
- **Python (FastAPI):** `from web.paperclip_api import pc_request; await pc_request("POST", "/api/...", json={...})` — שימוש ב-board API key.
|
||||||
|
- **אסור** `curl ... $PAPERCLIP_API_URL` ישיר ב-bash; **אסור** `httpx.AsyncClient` ישיר ל-Paperclip ב-Python.
|
||||||
|
- **למה:** ה-skill הרשמי דורש `X-Paperclip-Run-Id` בכל קריאה משנה issue. אצלנו ה-audit trail עבד ממילא דרך JWT claims (`runId: runIdHeader || claims.run_id`), אבל ה-helper מבטיח עקביות + תאימות ל-board API keys (long-lived) שלא נושאות JWT claims.
|
||||||
|
|
||||||
|
### Cross-company agent sync — אחרי כל שינוי הגדרות
|
||||||
|
- יש 14 סוכנים = 7 × 2 חברות (CMP=1xxx, CMPA=8xxx). Paperclip מחייב `agents.company_id NOT NULL` — אין shared agents.
|
||||||
|
- **Master = CMP (1xxx)**, **Mirror = CMPA (8xxx)**.
|
||||||
|
- אחרי כל שינוי ב-`adapter_config`, `runtime_config`, `budget_monthly_cents`, או skills של סוכן ב-master (UI, SQL, או API), **חובה להריץ:**
|
||||||
|
```bash
|
||||||
|
PAPERCLIP_BOARD_API_KEY=$(...infisical...) \
|
||||||
|
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify # לבדיקה
|
||||||
|
PAPERCLIP_BOARD_API_KEY=$(...) \
|
||||||
|
python ~/legal-ai/scripts/sync_agents_across_companies.py --apply # לסנכרן
|
||||||
|
```
|
||||||
|
- הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent.
|
||||||
|
- שאלות ה-skill הרשמי של Paperclip — `paperclip` skill תחת `paperclipai/paperclip`.
|
||||||
|
|
||||||
|
### Webhook יוצא — עדכון סטטוס תיק לפלאגין
|
||||||
|
כשסטטוס תיק משתנה דרך `PUT /api/cases/{case_number}`, הבקאנד שולח webhook אסינכרוני לפלאגין:
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/cases/{case_number} → emit_case_status_webhook() [BackgroundTask]
|
||||||
|
→ POST /api/plugins/marcusgroup.legal-ai/webhooks/case-status
|
||||||
|
→ plugin-legal-ai/onWebhook()
|
||||||
|
→ comment בעברית על issue + CEO wakeup (כשסטטוס = qa_failed)
|
||||||
|
```
|
||||||
|
|
||||||
|
- הקוד ב-`web/paperclip_api.py` (`emit_case_status_webhook`), fire-and-forget, timeout 5s
|
||||||
|
- הפלאגין שומר idempotency key ב-state עם TTL 5 דקות למניעת spam על retry
|
||||||
|
- `GET /api/cases/stale?days=N` — תיקים שלא עודכנו N ימים; מוחרגים: `new`, `final`, `exported`
|
||||||
|
- `GET /api/chair-feedback/weekly-summary` — סיכום פידבק YU"R לשבוע האחרון
|
||||||
|
|
||||||
|
### Scheduled Jobs (plugin-legal-ai)
|
||||||
|
| Job | לוח זמנים | מה עושה |
|
||||||
|
|-----|-----------|---------|
|
||||||
|
| `stale-case-reminder` | יומי 08:00 | שולח comment אזהרה על תיקים תקועים >3 ימים |
|
||||||
|
| `weekly-feedback-analysis` | ראשון 19:00 | מעיר CEO לניתוח פידבק YU"R ועדכון `docs/legal-decision-lessons.md` |
|
||||||
|
| `sync-case-status` | כל 30 דק' | מסנכרן סטטוסי תיקים בין legal-ai ל-Paperclip |
|
||||||
|
|
||||||
|
CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **אין לו issueId, אל תנסה לפרסם comment או לסגור issue**.
|
||||||
|
|
||||||
|
### External adapters — `deepseek_local`
|
||||||
|
- מיקום ה-package: [`adapters/deepseek-paperclip-adapter/`](../adapters/deepseek-paperclip-adapter/) (לא ב-`node_modules`).
|
||||||
|
- רישום ב-Paperclip: רשומה ב-`~/.paperclip/adapter-plugins.json` (נטען אוטומטית ב-startup דרך `buildExternalAdapters`). אין צורך בעריכת `node_modules`.
|
||||||
|
- **מה ה-adapter עושה**: spawnל-`hermes chat` עם `HERMES_HOME=/home/chaim/.hermes/profiles/deepseek` כך שה-CLI טוען את `config.yaml` (`base_url=https://api.deepseek.com/v1`, `provider=custom`, `key_env=DEEPSEEK_API_KEY`) ואת `.env` (שמכיל את ה-key).
|
||||||
|
- **מודלים זמינים** (lookup ב-DeepSeek `/v1/models`): `deepseek-v4-pro` (default), `deepseek-v4-flash`. יופיעו כדרופ-דאון ב-UI.
|
||||||
|
- **התקנה מחדש / עדכון**: `curl -X POST -H "Authorization: Bearer pcapi_legal_install_key_2026" -H "Content-Type: application/json" -d '{"packageName":"/home/chaim/legal-ai/adapters/deepseek-paperclip-adapter","isLocalPath":true}' http://localhost:3100/api/adapters/install`. לעדכון hot — `POST /api/adapters/deepseek_local/reload`.
|
||||||
|
- **⚠ Cross-company sync**: `sync_agents_across_companies.py` **מדלג** על סוכנים עם `adapter_type` שונה בין CMP ל-CMPA. כשעוברים סוכן ל-`deepseek_local` חובה להחיל ידנית בשתי החברות לפני sync.
|
||||||
|
- **תוספת adapters עתידיים** (OpenAI ישיר, Anthropic ישיר, וכו'): אותו דפוס. ה-package הראשי חייב לייצא `createServerAdapter()` שמחזיר `{ type, label, models, agentConfigurationDoc, execute, testEnvironment, sessionCodec, listSkills, syncSkills, ... }`. ראה את [`adapters/deepseek-paperclip-adapter/dist/index.js`](../adapters/deepseek-paperclip-adapter/dist/index.js) כתבנית.
|
||||||
|
|
||||||
|
### External adapters — Hermes Curator (`curator-cmp` / `curator-cmpa`)
|
||||||
|
- פרופילי Hermes נפרדים לסוכן `hermes-curator` — מנתח החלטות סופיות ומציע עדכוני SKILL.md/lessons.md
|
||||||
|
- מיקום: `~/.hermes/profiles/curator-cmp/` + `~/.hermes/profiles/curator-cmpa/`
|
||||||
|
- מופעל אחרי export סופי; אינו מעדכן קבצים ישירות
|
||||||
|
- **תהליך אישור הצעות:** הצעות ה-curator מגיעות כ-comment ב-Paperclip → חיים בוחן ומאשר ידנית → commits ל-`SKILL.md` ו-`docs/legal-decision-lessons.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## הערות יו"ר (Chair Feedback)
|
||||||
|
|
||||||
|
מנגנון לתיעוד הערות דפנה על טיוטות:
|
||||||
|
- **DB**: טבלת `chair_feedback` (case_id, block_id, feedback_text, category, lesson_extracted)
|
||||||
|
- **API**: `GET/POST /api/feedback`, `PATCH /api/feedback/{id}/resolve`
|
||||||
|
- **MCP tools**: `record_chair_feedback`, `list_chair_feedback`
|
||||||
|
- **UI**: דף ניהול ב-`/feedback` (ב-Next.js)
|
||||||
|
- **קטגוריות**: missing_content, wrong_tone, wrong_structure, factual_error, style, other
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ניהול משימות — TaskMaster AI (פירוט)
|
||||||
|
|
||||||
|
- קובץ המשימות הקנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (יחסי ל-project root, **לא** `~/.taskmaster/tasks/tasks.json`). מכיל את כל ה-tags של legal-ai (`master`, `legal-ai`).
|
||||||
|
- פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`
|
||||||
|
- לפני התחלת עבודה → `next_task`; אחרי סיום → `update_task` עם status=done; משימה מורכבת → `expand_task`
|
||||||
|
- **⚠️ מלכוד cwd ב-CLI:** הדגל `--tag` בוחר קבוצה לוגית *בתוך* הקובץ — הוא **לא** בוחר לאיזה `tasks.json` לכתוב. ה-CLI מאתר את הקובץ לפי ה-cwd. תמיד `cd ~/legal-ai` לפני `task-master add-task` או כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את `~/legal-ai/.taskmaster/tasks/tasks.json` ישירות.
|
||||||
@@ -155,3 +155,78 @@ CEO צריך להעביר את ה-issue ל-`in_review` (לא `in_progress`) כש
|
|||||||
### סטטוס
|
### סטטוס
|
||||||
|
|
||||||
- **תיקון בכל הסקייל** (CLAUDE.md זיכרון: `reference_paperclip_wakeup.md`)
|
- **תיקון בכל הסקייל** (CLAUDE.md זיכרון: `reference_paperclip_wakeup.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. מחיקת npx cache → crash-loop בהפעלה (השרת מנצח את הפאטצ')
|
||||||
|
|
||||||
|
### מה קורה
|
||||||
|
|
||||||
|
Paperclip מופעל דרך `exec npx -y paperclipai@<version> run` ב-[start-paperclip.sh](../../.paperclip/scripts/start-paperclip.sh). npx **עושה reuse** ל-cache שכבר חולץ (`~/.npm/_npx/<hash>/node_modules/@paperclipai/server/`) — הוא **לא** מחלץ מחדש בכל הפעלה. כל עוד ה-cache קיים, הפאטצ'ים שהוחלו עליו פעם אחת נשמרים על פני ריסטארטים.
|
||||||
|
|
||||||
|
הבעיה מתחילה כש-ה-cache **נמחק** (`npm cache clean`, prune, או ניקוי ידני) בזמן שהתהליך רץ. אז נוצרות שתי תקלות נפרדות:
|
||||||
|
|
||||||
|
1. **התהליך הישן ממשיך "online" אבל שבור** — המודולים של node כבר טעונים בזיכרון, אז `/api/health` עדיין מחזיר 200, אבל `GET /` קורא את `ui-dist/index.html` **מהדיסק בכל בקשה** (`readFileSync`) → `ENOENT` → **HTTP 500** (`{"error":"Internal server error"}`). גם ה-URL הציבורי `pc.nautilus...` מחזיר 500.
|
||||||
|
2. **בריסטארט נכנסים ל-crash-loop** — npx מחלץ עותק **טרי ולא-מתוקן**. השרת מריץ `assertCloudDatabaseContract()` (ראה patch §4 ב-start script) שמסרב ל-embedded PG במצב authenticated/public → **קורס מיד**, לפני שלולאת-הרקע (5/20/60ש') מספיקה להחיל את פאטץ' ה-bypass. כל ריסטארט מחלץ-וקורס מחדש ⇒ עשרות ריסטארטים, שום דבר לא מאזין על 3100.
|
||||||
|
|
||||||
|
### ראיה אמפירית — 06/06/26
|
||||||
|
|
||||||
|
```
|
||||||
|
# התהליך הישן: online 5D אבל GET / נכשל
|
||||||
|
GET / 500 — ENOENT: no such file or directory,
|
||||||
|
open '.../@paperclipai/server/ui-dist/index.html'
|
||||||
|
/api/health → 200 # שורד כי לא קורא קבצים
|
||||||
|
|
||||||
|
# אחרי restart: crash-loop
|
||||||
|
pm2 describe paperclip → status: "waiting restart", restarts: 36, nothing on :3100
|
||||||
|
ERROR log → "Paperclip server failed to start.
|
||||||
|
authenticated public deployments require DATABASE_URL ...;
|
||||||
|
refusing embedded PostgreSQL fallback"
|
||||||
|
```
|
||||||
|
|
||||||
|
הורדת החבילה איטית (~30ש', native builds) — מה שמחמיר את ה-loop: `min_uptime` של PM2 קוטע את ה-npx **באמצע ההורדה** לפני שהוא מסיים לחלץ, כך שה-cache לעולם לא מתמלא.
|
||||||
|
|
||||||
|
### ההשפעה על הצינור שלנו
|
||||||
|
|
||||||
|
Paperclip מושבת לגמרי — ה-UI לא עולה לאף משתמש, וכל סוכני Paperclip (14 הסוכנים) לא יכולים לרוץ כי הם חולקים את התהליך הזה.
|
||||||
|
|
||||||
|
### תיקון — שער סינכרוני לפני הפעלת השרת
|
||||||
|
|
||||||
|
**שורש הבעיה:** פאטץ' ה-cloud-db-bypass חייב להיות על הדיסק **לפני** שהשרת רץ; לולאת-הרקע מאוחרת מדי. ב-[start-paperclip.sh](../../.paperclip/scripts/start-paperclip.sh) נוספה `ensure_patched_before_run()` (06/06/26) שרצה סינכרונית לפני `exec`:
|
||||||
|
|
||||||
|
1. בודקת אם `@paperclipai/server/ui-dist/index.html` קיים ב-cache (ראה "מלכודות בדרך" — זה הסמן הנכון, לא `dist/index.js`).
|
||||||
|
2. אם לא — מריצה `npx -y paperclipai@<version> --help`. זה מאלץ את npx **לחלץ את כל החבילה** (כולל `ui-dist/`) כדי להריץ את ה-CLI, שמדפיס help ו**יוצא לבד ב-exit 0** — **לא** מפעיל שרת ולא תופס את 3100 (אומת). אין תהליך-רקע, אין שרת לא-מתוקן מוקדם, ואין מה להרוג.
|
||||||
|
3. מחילה את **כל** הפאטצ'ים (כולל bypass) על ה-cache המחולץ — עם guard שלא מפיל את ה-wrapper אם patch נכשל.
|
||||||
|
4. רק אז `exec npx ... run` — npx עושה reuse ל-cache המתוקן והשרת עולה נקי.
|
||||||
|
|
||||||
|
לולאת-הרקע (post-exec) נשמרה כרשת-ביטחון idempotent.
|
||||||
|
|
||||||
|
**אומת מקצה-לקצה (06/06/26):** מחיקת ה-cache בכוונה + `pm2 restart` → השער חילץ אוטומטית דרך `--help` (~64ש'), תיקן, והשרת עלה ל-200 ב-~72ש'. מונה הריסטארטים של PM2 **לא זז** (אפס crash-loop).
|
||||||
|
|
||||||
|
> **מלכודות שהתגלו בדרך (גרסה ראשונה של הפיקס נכשלה):**
|
||||||
|
> 1. **סמן חילוץ שגוי** — `dist/index.js` נכתב ~שניות **לפני** `ui-dist/`. שער שממתין ל-`dist` ומריץ מיד → ui-dist עדיין חסר → 500. הסמן הנכון הוא `ui-dist/index.html` (הקובץ האחרון, וגם זה שגרם ל-500 המקורי).
|
||||||
|
> 2. **`set -e` + patch כושל** — אם `apply-hebrew.sh` רץ בלי ui-dist הוא מחזיר שגיאה, ותחת `set -e` ה-wrapper מת → crash-loop חדש. הפתרון: `apply_all_patches || echo WARNING`.
|
||||||
|
> 3. **`pkill -f "paperclipai@..."` תופס את עצמו** — מחרוזת הדפוס מופיעה ב-command line של ה-shell שמריץ את ה-pkill, אז הוא הורג את עצמו (exit 144). זו הסיבה שגישת spawn-`run`-then-`pkill` ננטשה לטובת `--help` שיוצא לבד. אם בכל זאת צריך להרוג — לפי PID (`kill $PID; pkill -P $PID`), לא לפי `-f`.
|
||||||
|
|
||||||
|
**שחזור** — עם הפיקס פרוס, מספיק `pm2 restart paperclip` וה-`ensure_patched_before_run()` מתאושש לבד. אם צריך לעשות זאת ידנית (fix אחר, דיבוג):
|
||||||
|
```bash
|
||||||
|
pm2 stop paperclip # לעצור loop אם קיים
|
||||||
|
export PATH=/home/chaim/.nvm/versions/node/v24.14.0/bin:$PATH
|
||||||
|
npx -y paperclipai@2026.529.0 --help >/dev/null 2>&1 # חילוץ נקי שיוצא לבד (לא מפעיל שרת)
|
||||||
|
find ~/.npm/_npx -path "*@paperclipai/server/ui-dist/index.html" -type f # לאמת חילוץ מלא
|
||||||
|
# להחיל פאטצ'ים על ה-cache, ובמיוחד ה-bypass:
|
||||||
|
bash ~/.paperclip/hermes-patches/apply-cloud-db-bypass.sh
|
||||||
|
bash ~/.paperclip/hebrew/apply-hebrew.sh
|
||||||
|
bash ~/.paperclip/hermes-patches/apply-hermes-fixes.sh
|
||||||
|
bash ~/.paperclip/hermes-patches/apply-deepseek-reaper-fix.sh
|
||||||
|
grep -q HEBREW_PATCH_BYPASS_CLOUD_DB \
|
||||||
|
~/.npm/_npx/*/node_modules/@paperclipai/server/dist/index.js && echo "BYPASS OK"
|
||||||
|
pm2 start paperclip && pm2 save # reuse ל-cache המתוקן
|
||||||
|
```
|
||||||
|
> אל תשתמש ב-`pkill -f "paperclipai@..."` / `-f "@paperclipai/server"` — הדפוס תופס את ה-shell של עצמך (exit 144). אם חייבים להרוג תהליך — לפי PID.
|
||||||
|
|
||||||
|
### סטטוס
|
||||||
|
|
||||||
|
- **תוקן ב-start script** ע"י `ensure_patched_before_run()` (06/06/26) — שער סינכרוני שמחלץ+מתקן לפני exec.
|
||||||
|
- **הערה מטעה תוקנה**: ההערה הישנה בראש ה-script טענה ש-`npx run` מחלץ-מחדש בכל הפעלה (לכן הסתמכו על לולאת-הרקע בלבד) — זה לא נכון, npx עושה reuse ל-cache תקין; הסכנה היא cache **מחוק**.
|
||||||
|
- **לקח כללי**: כל patch שה-target שלו הוא assert בזמן-startup חייב להיות מוחל לפני `exec`, לא בלולאת-רקע.
|
||||||
|
|||||||
@@ -78,13 +78,14 @@
|
|||||||
|
|
||||||
אלה החוקים החוצים את כל המערכת — לב החוקה. הם נחלקים לשני סוגים לפי **מקור-הסמכות**:
|
אלה החוקים החוצים את כל המערכת — לב החוקה. הם נחלקים לשני סוגים לפי **מקור-הסמכות**:
|
||||||
|
|
||||||
- **G1–G10 — invariants הנדסיים** (תכנון/בניית האפליקציה): כל אחד מגובה ב-**≥3 סמכויות
|
- **G1–G10, G12 — invariants הנדסיים** (תכנון/בניית האפליקציה): כל אחד מגובה ב-**≥3 סמכויות
|
||||||
טכניות מוכרות** (נספח §8). ביחד הם מייבשים את כשל-השורש החוזר: מסלולים/קורפוסים
|
טכניות מוכרות** (נספח §8). ביחד הם מייבשים את כשל-השורש החוזר: מסלולים/קורפוסים
|
||||||
מקבילים שמתפצלים (drift) בלי שכבה שמגדירה ואוכפת "תקין".
|
מקבילים שמתפצלים (drift) בלי שכבה שמגדירה ואוכפת "תקין". (G12 — שער-הפלטפורמה — מוסף
|
||||||
|
במחזור-3; ראה [X15](X15-agent-platform-port.md).)
|
||||||
- **G11 — invariant תוכן-משפטי:** הסמכות עליו היא **היו"ר (דפנה) + מסמכי-הפרויקט**, לא
|
- **G11 — invariant תוכן-משפטי:** הסמכות עליו היא **היו"ר (דפנה) + מסמכי-הפרויקט**, לא
|
||||||
מקורות חיצוניים, ואינו כפוף לפרוטוקול ≥3-המקורות.
|
מקורות חיצוניים, ואינו כפוף לפרוטוקול ≥3-המקורות.
|
||||||
|
|
||||||
### 5א. Invariants הנדסיים (G1–G10)
|
### 5א. Invariants הנדסיים (G1–G10, G12)
|
||||||
|
|
||||||
### INV-G1: מזהה קנוני מנורמל בכתיבה
|
### INV-G1: מזהה קנוני מנורמל בכתיבה
|
||||||
**כלל:** לכל ישות יש מזהה קנוני יחיד, **מנורמל בנקודת-הכתיבה** (לא תיקון-סלחני בקריאה
|
**כלל:** לכל ישות יש מזהה קנוני יחיד, **מנורמל בנקודת-הכתיבה** (לא תיקון-סלחני בקריאה
|
||||||
@@ -178,13 +179,40 @@ ISO 15489-1:2016 (records authenticity/integrity) | סטטוס: verified
|
|||||||
### INV-G10: המערכת מסייעת — שערים אנושיים הם invariant
|
### INV-G10: המערכת מסייעת — שערים אנושיים הם invariant
|
||||||
**כלל:** המערכת **מסייעת ואינה מחליפה את שיקול-הדעת האנושי**. השערים האנושיים (אישור
|
**כלל:** המערכת **מסייעת ואינה מחליפה את שיקול-הדעת האנושי**. השערים האנושיים (אישור
|
||||||
הלכה, בחירת תוצאה, פידבק היו"ר) הם **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
|
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* ("never replace human
|
||||||
judgment") · CEPEJ (2018, under user control) · Federal Judicial Center — *Judicial Writing
|
judgment") · CEPEJ (2018, under user control) · Federal Judicial Center — *Judicial Writing
|
||||||
Manual* (2d ed.) | סטטוס: verified
|
Manual* (2d ed.) · [לתיקון — מקורות פתוחים:] Fowler et al., *Network Analysis and the Law*
|
||||||
**אכיפה:** שערים אנושיים בקוד-הזרימה (gate לא ניתן לעקיפה); מפורט ב-[05-qa-review.md](05-qa-review.md).
|
(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 →
|
**הפרה ידועה:** 10/19 הלכות מאושרות, התגלה במקרה — שער ידני שקוף בלי נראות backlog →
|
||||||
ממצא ל-[audit](../audit-report.md).
|
ממצא ל-[audit](../audit-report.md).
|
||||||
|
|
||||||
|
### INV-G12: שער-הפלטפורמה — Paperclip מאחורי Port יחיד
|
||||||
|
**כלל:** פלטפורמת-הסוכנים (Paperclip) נגישה אך-ורק דרך **ה-Platform Port**
|
||||||
|
(`web/agent_platform_port.py` + `.claude/agents/HEARTBEAT.md` לפרומפטים). שכבת-האינטליגנציה
|
||||||
|
— `mcp-server/src` וה-skills של ההחלטה/הסגנון — מכילה **אפס** סמלים ספציפיים-לפלטפורמה
|
||||||
|
(שם-מוצר, wakeup/heartbeat, pc.sh/pc_request, X-Paperclip-Run-Id, enums של הפלטפורמה).
|
||||||
|
פרומפטי-הסוכנים אינם משכפלים את פרוטוקול-הריצה — הם מצביעים ל-HEARTBEAT.md בלבד. כל מגע
|
||||||
|
חדש עם הפלטפורמה עובר דרך ה-Port — כך המעטפת נשארת ניתנת-להחלפה בלי לגעת באינטליגנציה.
|
||||||
|
**מקורות:** Alistair Cockburn — *Hexagonal Architecture (Ports & Adapters)* · Robert C.
|
||||||
|
Martin — *Clean Architecture* (The Dependency Rule) · Eric Evans — *Domain-Driven Design*
|
||||||
|
(Anti-Corruption Layer) | סטטוס: verified
|
||||||
|
**אכיפה:** רשימת-ה-Port + leak-guard ב-[scripts/spec-guard.sh](../../scripts/spec-guard.sh)
|
||||||
|
(מול baseline) + fitness-test ב-CI על `mcp-server/src` + הצהרת-G12 בתבנית-ה-PR; מפורט ב-
|
||||||
|
[X15-agent-platform-port.md](X15-agent-platform-port.md).
|
||||||
|
**הפרה ידועה:** `web/app.py` קורא ל-`pc_*` inline בלוגיקת מחזור-חיים; 10 פרומפטי-סוכנים
|
||||||
|
משכפלים את פרוטוקול-הריצה במקום להצביע ל-HEARTBEAT (baseline ב-[X15](X15-agent-platform-port.md) §3 → R1–R4).
|
||||||
|
|
||||||
### 5ב. Invariant תוכן-משפטי (G11)
|
### 5ב. Invariant תוכן-משפטי (G11)
|
||||||
|
|
||||||
### INV-G11: תוכן החלטה מנומקת
|
### INV-G11: תוכן החלטה מנומקת
|
||||||
@@ -216,11 +244,11 @@ Manual* (2d ed.) | סטטוס: verified
|
|||||||
|
|
||||||
## 7. אינדקס הספ
|
## 7. אינדקס הספ
|
||||||
|
|
||||||
> הערה: כל קבצי הספ (00, 01–07, X1–X5) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
> הערה: כל קבצי הספ (00, 01–07, X1–X16) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
||||||
|
|
||||||
| קובץ | תפקיד | אוכף invariants |
|
| קובץ | תפקיד | אוכף invariants |
|
||||||
|------|--------|-----------------|
|
|------|--------|-----------------|
|
||||||
| [00-constitution.md](00-constitution.md) | חוקה — ייעוד, invariants גלובליים, כללי-הנדסה, אינדקס | G1–G11 |
|
| [00-constitution.md](00-constitution.md) | חוקה — ייעוד, invariants גלובליים, כללי-הנדסה, אינדקס | G1–G12 |
|
||||||
| [01-ingest.md](01-ingest.md) | קליטה מאוחדת: מסמכי-תיק / פסיקה חיצונית / החלטות-ועדה — חוזה מסלול-יחיד | G2, G3 |
|
| [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 |
|
| [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 |
|
| [03-retrieval.md](03-retrieval.md) | 3 קורפוסים + כלי-חיפוש · hybrid/RRF · attribution · eval harness | G4, G5, G6, G7, G8, G9 |
|
||||||
@@ -233,6 +261,21 @@ Manual* (2d ed.) | סטטוס: verified
|
|||||||
| [X3-integration-deploy.md](X3-integration-deploy.md) | Paperclip (wakeup, ניתוב comments, webhooks) · Coolify/pm2 | G2, G9 (תפעולי) |
|
| [X3-integration-deploy.md](X3-integration-deploy.md) | Paperclip (wakeup, ניתוב comments, webhooks) · Coolify/pm2 | G2, G9 (תפעולי) |
|
||||||
| [X4-agents.md](X4-agents.md) | מפת הסוכנים (דומיין + סוכני-התהליך) | G10 |
|
| [X4-agents.md](X4-agents.md) | מפת הסוכנים (דומיין + סוכני-התהליך) | G10 |
|
||||||
| [X5-audit-provenance.md](X5-audit-provenance.md) | audit-trail לשימוש ב-AI · עקיבוּת כל מקור מצוטט · שלמות-רשומה | G5, G9 |
|
| [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 |
|
||||||
|
| [X12-digests-radar.md](X12-digests-radar.md) | יומונים כשכבת-גילוי (radar) — מקור-משני המצביע על הפסק המקורי · לא קורפוס-ציטוט רביעי · לא מצוטט/לא מחלץ-הלכות | G2, G4, G9 |
|
||||||
|
| [X13-court-fetch.md](X13-court-fetch.md) | אחזור-פסיקה אוטומטי מנט המשפט — 3 שכבות (עליון/מנהלי/skip) · שירות-מארח · reCAPTCHA · שער-אנושי | G2, G3, G4, G5, G9, G10 |
|
||||||
|
| [X14-storage-minio.md](X14-storage-minio.md) | אחסון-אובייקטים (MinIO/S3) · `storage.py` כמסלול-I/O יחיד · git=טקסט/MinIO=בינאריים · WORM סופי | G2, G9 |
|
||||||
|
| [X15-agent-platform-port.md](X15-agent-platform-port.md) | שער-הפלטפורמה — Paperclip מאחורי Port יחיד · baseline-דליפה · R0–R4 · leak-guard | G2, G12 |
|
||||||
|
| [X16-pipeline-durability.md](X16-pipeline-durability.md) | עמידות-פייפליין — LangGraph כספרייה · checkpointing/replay · `_pipeline_runtime.py` משותף | G3 |
|
||||||
|
|
||||||
|
> **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 שורות (תפיחה = סימן
|
**עקרונות:** כל קובץ עצמאי, ממוקד, agent-readable, יעד ≤~500 שורות (תפיחה = סימן
|
||||||
לפיצול). מסמכים קיימים (`architecture.md`, `product-specification.md`, `block-schema.md`…)
|
לפיצול). מסמכים קיימים (`architecture.md`, `product-specification.md`, `block-schema.md`…)
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ proceeding_type)`. לכן המזהה הקנוני הוא **(`case_number` מנו
|
|||||||
- `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק.
|
- `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק.
|
||||||
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
|
- `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 של התחום
|
## 3. Invariants של התחום
|
||||||
@@ -120,6 +133,36 @@ RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
|
|||||||
[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן).
|
[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)).
|
||||||
|
|
||||||
|
### INV-DM7: סיווג-הלכה — סמכות (נגזרת) ⊥ תפקיד-כלל (מסווג). שני צירים, לא enum אחד
|
||||||
|
**כלל:** ל-`halachot` שני צירי-סיווג **אורתוגונליים** שאסור לערבב בשדה אחד:
|
||||||
|
- **סמכות (`authority`) — נגזרת בלבד, לא מאוחסנת, לא מנוחשת ע"י LLM.** `binding` (מקור מחייב את הוועדה: עליון/מנהלי) מול `persuasive` (מקור משכנע: ועדת-ערר אחרת). נגזרת דטרמיניסטית מ-`case_law.precedent_level` (`עליון`/`מנהלי`→binding; `ועדת_ערר_מחוזית`→persuasive). מקור-אמת יחיד — מחושבת בקריאה, אין עמודה כפולה ([G1](00-constitution.md#inv-g1-נרמול-במקור-לא-תיקון-בקריאה)/[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)).
|
||||||
|
- **תפקיד-כלל (`rule_type`/rule_role) — מסווג ע"י ה-LLM.** `holding` (עיקרון מהותי הכרחי להכרעה — ratio/Wambaugh) · `interpretive` (פרשנות חוק/מונח/תכנית) · `procedural` (סדר-דין: סמכות/מועדים/נטל) · `application` (החלה תלוית-עובדות — לרוב לא-הלכה) · `obiter` (אמרת-אגב). **`binding`/`persuasive` אינם ערכי תפקיד** — הם סמכות-מקור.
|
||||||
|
**הנדסי.** מופע של [G1](00-constitution.md#inv-g1-נרמול-במקור-לא-תיקון-בקריאה) (נרמול במקור: המחלץ מסווג תפקיד, לא ממציא סמכות נגזירה) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
|
||||||
|
**מקורות:** OASIS LegalRuleML v1.0 (`appliesAuthority`/`Strength` כ-metadata אורתוגונלי, נפרד מלוגיקת-הכלל) · SemEval-2023 Task 6 LegalEval (rhetorical-roles לפי תפקיד, סמכות נשמרת בנפרד) · Bluebook signals (משקל-סמכות = ציר נפרד מהפרופוזיציה) | סטטוס: verified (≥3 מקורות).
|
||||||
|
**ההפרה שתוקנה:** `halacha_extractor` סיווג `rule_type` לפי bindingness-של-המקור (`_coerce_halacha(is_binding)`, ברירת-מחדל `binding`/`persuasive`, guard binding→persuasive) — כלומר חישב **סמכות** במסווה של **תפקיד**. אומת אמפירית על מדגם-הזהב: `binding` שימש 19/19 פסקים חיצוניים ו-0 ועדות; `persuasive` 13/13 ועדות ו-0 חיצוניים → סיווג-לפי-מקור, התאמה לתיוג-אנושי 58% בלבד. התיקון מעביר סמכות לציר-נגזר ומשחרר את ה-LLM לסווג תפקיד נטו.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. מצב קיים מול יעד — audit-findings
|
## 4. מצב קיים מול יעד — audit-findings
|
||||||
@@ -153,3 +196,5 @@ RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
|
|||||||
- [03-retrieval.md](03-retrieval.md) — שכבת-האחזור שאוכפת את הסינון searchable + re-index.
|
- [03-retrieval.md](03-retrieval.md) — שכבת-האחזור שאוכפת את הסינון searchable + re-index.
|
||||||
- [X1-identifiers.md](X1-identifiers.md) — נרמול המזהה הקנוני בכתיבה (בסיס ל-INV-DM2).
|
- [X1-identifiers.md](X1-identifiers.md) — נרמול המזהה הקנוני בכתיבה (בסיס ל-INV-DM2).
|
||||||
- [X5-audit-provenance.md](X5-audit-provenance.md) — שלמות-רשומה + עקיבוּת-מקור.
|
- [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) — הכלים שמייצרים את הישויות-הנגזרות.
|
||||||
|
|||||||
@@ -35,6 +35,13 @@
|
|||||||
(`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**,
|
(`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**,
|
||||||
ושם נולדת ההפרה ב-§5.
|
ושם נולדת ההפרה ב-§5.
|
||||||
|
|
||||||
|
> **שכבת-גילוי — יומונים, לא קורפוס-ציטוט.** מעל 3 הקורפוסים יושבת שכבת-radar נפרדת: **יומונים**
|
||||||
|
> (סיכומי עפר-טויסטר), בטבלה פיזית נפרדת `digests` עם כלי `search_digests`. היומון הוא **מקור משני
|
||||||
|
> המצביע** על הפסק המקורי — **אינו** קורפוס-ציטוט רביעי, **אינו** עקיב-בפלט ([INV-RET5](#inv-ret5-כל-span-מוחזר-עקיב-למקורו)),
|
||||||
|
> ו**אינו** נוגע ב-`case_law`/`document_chunks`. ההפרדה כאן **פיזית** (טבלה נפרדת), לא תנאי-סינון —
|
||||||
|
> ולכן [INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query) מתקיים טריוויאלית. מלא ב-
|
||||||
|
> [X12-digests-radar.md](X12-digests-radar.md) (INV-DIG1–DIG3).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. עיצוב ה-hybrid retrieval
|
## 2. עיצוב ה-hybrid retrieval
|
||||||
@@ -176,3 +183,4 @@ re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף
|
|||||||
- [02-data-model.md](02-data-model.md) — חוזה-השלמות (searchable) + re-index שהאחזור מסנן לפיהם.
|
- [02-data-model.md](02-data-model.md) — חוזה-השלמות (searchable) + re-index שהאחזור מסנן לפיהם.
|
||||||
- [05-qa-review.md](05-qa-review.md) — שער-הלכה הידני (`review_status`) שמגדיר אילו הלכות searchable.
|
- [05-qa-review.md](05-qa-review.md) — שער-הלכה הידני (`review_status`) שמגדיר אילו הלכות searchable.
|
||||||
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור מלאה של כל span מוחזר (בסיס ל-INV-RET5).
|
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור מלאה של כל span מוחזר (בסיס ל-INV-RET5).
|
||||||
|
- [X12-digests-radar.md](X12-digests-radar.md) — שכבת-הגילוי (יומונים) שמעל הקורפוסים — מצביעה, לא מצוטטת.
|
||||||
|
|||||||
@@ -19,6 +19,53 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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.*
|
||||||
|
|
||||||
|
### 0.6 מסלול-העלאת-סופי נקי + פאנלים אוטומטיים (מדורג)
|
||||||
|
היו"ר מעלה את **ההחלטה החתומה שלה** דרך מסלול ייעודי — `POST /api/cases/{case}/final/upload` (כפתור "העלאת החלטה סופית של היו"ר" בלשונית-הטיוטות). **נבדל** מ-`exports/upload` (גרסה-מתוקנת-שלנו+retrofit) ומ-`mark-final` (סימון export-שלנו), ולכן אינו מסלול-מקביל (G2) אלא יכולת חסרה.
|
||||||
|
הקליטה (סינכרונית ב-endpoint) מבצעת את **לולאת-צמיחת-הקורפוס** (§1.3) במלואה:
|
||||||
|
1. **קורפוס-הסגנון** (voice) תחת ה-`case_number` **המלא** (בל"מ≠ערר — מונע התנגשות-מספר) + פתיחת `draft_final_pairs` (`final_received`, INV-LRN4).
|
||||||
|
2. **ספריית-הפסיקה** — ההחלטה נכנסת ל-`case_law` כ-`internal_committee` **תמיד** (כדי שתהיה ברת-ציטוט בהחלטות עתידיות). `chair_name` נקבע **דטרמיניסטית** (תיק → ברירת-מחדל-ועדה, לעולם לא ריק — אילוץ `case_law_internal_chair_check`); לא נשען על חילוץ-LLM. מטה-דאטה נוסף (תאריך/צדדים) מועשר אסינכרונית ע"י מחלץ-Gemini.
|
||||||
|
3. **בדיקת-ציטוטים** — `extract_internal_citations` מקשר את הפסיקה שההחלטה מצטטת לספרייה; כל ציטוט שאינו בספרייה **מסומן אוטומטית** כ-`missing_precedent` (open) להעלאה ע"י היו"ר.
|
||||||
|
4. הציטוטים-המקושרים מזינים את **לולאת-ה-corroboration** (X11): ציטוט-נכנס מההחלטה שלנו מחזק את ההלכות של התקדים המצוטט (`corroboration_rebuild`).
|
||||||
|
ואז שני שלבים אוטומטיים נפרדים (`run-learning` / `run-halacha`) המעירים worker מקומי (claude/DeepSeek/Gemini מקומיים בלבד):
|
||||||
|
- **למידה:** `ingest_final_version` (Opus distillation) → **פאנל-סגנון דו-סוכני** (DeepSeek+Gemini, "למידה כפולה") שמצביע על כל לקח-style_method; הסכמה 2/2 → `decision_lesson` (`source=panel:deepseek+gemini`); פיצול → ליו"ר.
|
||||||
|
- **הלכות:** `extract_internal_citations` → `precedent_extract_halachot` → `corroboration_rebuild` → **פאנל-הלכות תלת-סוכני** (`halacha_panel_approve.py --apply`).
|
||||||
|
שני הפאנלים **הפיכים** (גיבוי-CSV ל-`data/audit/`) ומסלימים מחלוקות. ההטמעה הסופית ל-`SKILL.md`/`legal-decision-lessons.md` נשארת **אישור-יו"ר ידני** (INV-LRN1/G10) — הפאנל יוצר *הצעות* בלבד.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. שלוש לולאות-המשנה
|
## 1. שלוש לולאות-המשנה
|
||||||
|
|
||||||
הלמידה אינה אירוע יחיד אלא **שלוש לולאות** המתנקזות לאותם מסמכי-ידע מוסמכים
|
הלמידה אינה אירוע יחיד אלא **שלוש לולאות** המתנקזות לאותם מסמכי-ידע מוסמכים
|
||||||
|
|||||||
@@ -3,5 +3,11 @@
|
|||||||
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
|
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
|
||||||
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
|
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
|
||||||
|
|
||||||
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X5 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X16 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
||||||
|
- X1–X5: מזהים · רב-חברתי · אינטגרציה+deploy · סוכנים · audit.
|
||||||
|
- X6–X10 (מחזור-2, 8 משטחי-האפליקציה): חוזה UI↔API · לקוח-Paperclip · מילוי-שדות · חוזה כלי-MCP · deploy/env/secrets.
|
||||||
|
- X11–X14 (הרחבות-תחום): citator פנימי (תיקוף-הלכות) · יומונים כשכבת-גילוי (radar) · אחזור-פסיקה אוטומטי מנט המשפט (שירות) · אחסון-אובייקטים (MinIO/S3, הגירת `data/`).
|
||||||
|
- X15–X16 (ארכיטקטורת-יסוד): שער-הפלטפורמה (Paperclip מאחורי Port — G12, מיישם G2) · עמידות-פייפליין (LangGraph כספרייה — checkpointing/replay, מחזק G3).
|
||||||
|
|
||||||
|
מפות-ממצאים: [gap-audit.md](gap-audit.md) (GAP-01..62 → FU-1..15; מחזור-1 ✅ הושלם, מחזור-2 פתוח) · [ui-audit.md](ui-audit.md) (ביקורת 13 דפי-UI).
|
||||||
בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.md
|
בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.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).
|
||||||
185
docs/spec/X12-digests-radar.md
Normal file
185
docs/spec/X12-digests-radar.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# X12 — יומונים כשכבת-גילוי (Digests Radar)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md). הוא מגדיר **שכבת-גילוי (discovery/radar)**
|
||||||
|
מעל קורפוסי-הפסיקה: קליטה וחיפוש של **יומונים** — סיכומי-עמוד-אחד של משרד עפר טויסטר ("כל יום —
|
||||||
|
היומון לענייני תכנון ובנייה") על פסק-דין/החלטה בודדים. היומון הוא **מקור משני** המצביע על פסק-הדין
|
||||||
|
המקורי; הוא **אינו** נכנס לאף אחד מ-3 קורפוסי-הציטוט, **אינו** מצוטט בהחלטה, ו**אינו** מחלץ הלכות.
|
||||||
|
הוא נשען על [INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
(אין מסלול מקביל), [INV-G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)
|
||||||
|
(שלמות + אין בליעה שקטה) ו-[INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
|
||||||
|
(עקיבוּת-מקור), ומובחן מ-3 הקורפוסים של [03-retrieval.md](03-retrieval.md).
|
||||||
|
|
||||||
|
> **TARGET, לא תיאור-מצב.** התת-מערכת כולה היא יעד — אין כיום טבלת `digests`, כלי-`digest_*`,
|
||||||
|
> ולא אינטגרציית-חוקר. כל רכיב מסומן מפורשות כ-audit-finding לבנייה (§6). כל טענה על הקוד `file:line`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. הרעיון — radar, לא קורפוס-ציטוט
|
||||||
|
|
||||||
|
חיים מקבל כמעט יומית מייל עם **יומון**: PDF של עמוד אחד שמסכם פסק-דין/החלטה בודדים בתחום
|
||||||
|
רישוי-ובנייה / היטל-השבחה / פיצויים(ס'197). היומון אינו הטקסט המשפטי המקורי — הוא **ניתוח של צד
|
||||||
|
שלישי** (עפר טויסטר), הנושא הבהרה מודפסת: *"האמור הוא מידע ראשוני בלבד ואין הוא תחליף לייעוץ
|
||||||
|
משפטי"*. במונחי-מחקר-משפטי זהו **מקור משני (secondary authority)**: כלי-איתור והכוונה, לא סמכות
|
||||||
|
שמצטטים בהחלטה.
|
||||||
|
|
||||||
|
הערך שלו עצום דווקא כ-**radar**: כל יומון הוא *headnote + תג-נושא כתובים-מראש בידי מומחה*, המצביע
|
||||||
|
על פסק-דין מקורי. כשמנסחים החלטה, `search_digests` מחזיר את היומון הרלוונטי → החוקר קורא את ניתוח
|
||||||
|
טויסטר **כרקע** → מחלץ את מראה-המקום של פסק-הדין המקורי → מביא את הפסק עצמו לקורפוס-הפסיקה הקיים
|
||||||
|
(הזמינות גבוהה) → ומצטט **משם**. היומון מצביע; הציטוט תמיד נשען על המקור.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. מה היומון מכיל
|
||||||
|
|
||||||
|
מבנה קבוע (אומת מול הקבצים ב-`data/precedents/incoming/`, יומון 5158/5159/5160/5163):
|
||||||
|
|
||||||
|
| רכיב | דוגמה | תפקיד |
|
||||||
|
|------|-------|-------|
|
||||||
|
| מספר-יומון + תאריך-גיליון | `יומון מס' 5163 7 ביוני 2026` | מפתח-upsert + `digest_date` |
|
||||||
|
| תג-מושג | `"שיקול הדעת המצומצם"` | ציר-נושא לחיפוש |
|
||||||
|
| כותרת-הלכה | `ביהמ"ש - שיקול דעת הוועדה המחוזית אינו מצומצם...` | הסיכום בשורה |
|
||||||
|
| גוף-ניתוח (1–2 עמ') | ניתוח עפר-טויסטר | רקע + מקור-embedding |
|
||||||
|
| מראה-מקום בתחתית | `עת"מ 46111-12-22 יכין-אפק... ניתן 3.6.26... שופטת: יעל טויסטר ישראלי` | **השדה הקריטי** — הגשר לפסק המקורי |
|
||||||
|
|
||||||
|
`underlying_date` (מתן הפסק) שונה מ-`digest_date` (גיליון היומון) — מקור-באגים נפוץ; חילוץ-המטא-דאטה
|
||||||
|
מבחין ביניהם מפורשות.
|
||||||
|
|
||||||
|
**`digest_kind` (סיווג-גיליון, V32):** רוב הגיליונות הם `decision` (סיכום פס"ד → `underlying_citation`),
|
||||||
|
אך חלקם `announcement` — עדכון/הודעה ללא הכרעה (חקיקה, נוהל, ברכת-שנה) שאין לו מראה-מקום. החילוץ
|
||||||
|
מסווג כל גיליון ותמיד מחלץ `concept_tag`/`headline`/`summary` (קיימים לכל סוג); `underlying_citation`
|
||||||
|
רק ל-`decision`. **שימוש קריטי:** הגדרת-"כשל" של ה-drain self-heal היא `completed` **עם
|
||||||
|
`digest_kind=''`** (מעולם לא סווג) — כך הודעה (kind=`announcement`, בלי citation) **אינה** נחשבת כשל
|
||||||
|
ואינה מנוסה-מחדש לנצח. ההיוריסטיקה הישנה ("שני השדות ריקים") טיפלה בהודעות בטעות כ-retry אינסופי.
|
||||||
|
|
||||||
|
### 2.1 מקור שני ל-radar — העלון החודשי "עו"ד על נדל"ן"
|
||||||
|
|
||||||
|
פרסום **נפרד** מהיומון היומי: עלון חודשי ממוספר (משרדי צבי שוב + רונית אלפר), **רב-נושאי** — מאמר-עומק,
|
||||||
|
עדכוני-חקיקה, וסט מצביעי-פסיקה מקובצים לפי נושא. נקלט **לאותה טבלת `digests`** (לא קורפוס מקביל — G2),
|
||||||
|
מובחן ע"י `publication='עו"ד על נדל"ן'` (מול `'כל יום'`). עלון אחד **מתפצל ל-N שורות** דרך
|
||||||
|
`bulletin_splitter` (LLM, local-only) → `bulletin_library.ingest_bulletin`:
|
||||||
|
- **מצביעי-פסיקה** → `digest_kind='decision'` — מצטרפים ל-radar ומקושרים לפסק (autolink + X13 כמו היומון).
|
||||||
|
- **מאמרים** → `digest_kind='article'` — טקסט-מלא + embedding לחיפוש-עומק; **רקע בלבד, INV-DIG1 חל** (לא מצוטט).
|
||||||
|
- **עדכוני-חקיקה — לא נקלטים** (החלטת יו"ר).
|
||||||
|
|
||||||
|
מפתח-הדדאפ לפריט-עלון הוא **`content_hash` (per-פריט)**, כי `yomon_number` ריק (ה-upsert על yomon-number
|
||||||
|
לא חל; `uq_digests_content_hash` תופס re-runs). אידמפוטנטי. סקריפט: `scripts/ingest_bulletins.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. למה זה לא קורפוס-ציטוט רביעי (הקושיה המרכזית — G2)
|
||||||
|
|
||||||
|
[03-retrieval.md §1](03-retrieval.md#1-שלושת-הקורפוסים-וכלי-החיפוש) מגדיר 3 **קורפוסי-ציטוט**:
|
||||||
|
מסמכי-תיק+סגנון-דפנה, פסיקה-חיצונית, החלטות-ועדה. השאלה: האם יומונים = רביעי, ובכך הפרת
|
||||||
|
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)?
|
||||||
|
|
||||||
|
**לא — בתנאי המסגור הנכון.** G2 אוסר *מסלול מקביל ליכולת קיימת*. יומונים אינם עוד-מסלול-לאחזור-
|
||||||
|
פסיקה אלא **bounded context נפרד**: ישות נפרדת (`digests`, לא `case_law`), מטרה נפרדת (הצבעה ולא
|
||||||
|
ציטוט), וחוזה נפרד. ההבחנה הקנונית: 3 הקורפוסים הם **עקיבים-בפלט** (כל ציטוט בהחלטה חוזר אליהם —
|
||||||
|
[INV-RET5](03-retrieval.md#inv-ret5-כל-span-מוחזר-עקיב-למקורו)/[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)).
|
||||||
|
היומון **לעולם אינו עקיב-אליו בפלט** (INV-DIG1) — ולכן אינו קורפוס-ציטוט רביעי, אלא שכבה
|
||||||
|
**מקדימה** לקורפוסים. הפרדת-הקורפוס מ-[INV-RET1](03-retrieval.md#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)
|
||||||
|
מתקיימת אוטומטית: `search_digests` שואל **רק** את `digests`, ואף כלי-חיפוש-פסיקה אינו נוגע בה
|
||||||
|
(הפרדה פיזית בטבלה, לא תנאי-סינון).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. המנגנון (TARGET)
|
||||||
|
|
||||||
|
```
|
||||||
|
קליטה (מסלול קצר עצמאי — INV-DIG2):
|
||||||
|
יומון PDF → extract_text → content_hash (idempotent, INV-G3)
|
||||||
|
→ חילוץ-LLM: תג-מושג / כותרת-הלכה / תקציר / מראה-מקום / שני-תאריכים / תחום / תגיות
|
||||||
|
→ INSERT digests → embedding יחיד (תג+כותרת+תקציר+ניתוח) לחיפוש סמנטי בלבד
|
||||||
|
→ try_autolink(underlying_citation → case_law) [INV-DIG3]
|
||||||
|
⚠ ללא precedent_chunks, ללא halacha-extraction, ללא precedent metadata-extractor.
|
||||||
|
|
||||||
|
חיפוש + שימוש (radar — INV-DIG1):
|
||||||
|
legal-researcher: search_digests(סוגיה)
|
||||||
|
→ קורא ניתוח טויסטר + כותרת-הלכה = רקע/orientation בלבד
|
||||||
|
→ מחלץ את מראה-המקום של הפסק המקורי
|
||||||
|
→ הפסק בקורפוס? כן → אמת+צטט כרגיל (precedent_attach) + digest_link
|
||||||
|
לא → missing_precedent_create על *הפסק המקורי*
|
||||||
|
(notes="זוהה דרך יומון מס' NNNN") [INV-DIG3]
|
||||||
|
→ היומון לעולם אינו נרשם דרך precedent_attach ואינו supporting_quote. [INV-DIG1]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-DIG1: היומון מצביע, לא מצוטט
|
||||||
|
**כלל:** רשומת-`digest` לעולם אינה משמשת כ-`supporting_quote`/provenance בפלט-החלטה; כל ציטוט
|
||||||
|
בהחלטה נגזר מקורפוס-ציטוט (`case_law`/`document_chunks`). היומון הוא מקור משני — כלי-איתור,
|
||||||
|
לא סמכות-מצוטטת. החוקר רושם אותו כ-radar (סעיף-דוח נפרד), לא דרך `precedent_attach`.
|
||||||
|
**מקור-סמכות:** היו"ר + ההבהרה המודפסת ביומון ("מידע ראשוני בלבד... אינו תחליף לייעוץ משפטי") —
|
||||||
|
invariant תוכן-משפטי/תפעולי, **קשור** ל-[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
|
||||||
|
**מקורות (פתוחים, להבחנת מקור-ראשוני↔משני):** Georgetown Law Library — *Secondary Sources research
|
||||||
|
guide* (*"secondary sources... are not the law"*) · Amy E. Sloan, *Basic Legal Research: Tools and
|
||||||
|
Strategies* — primary vs. persuasive/secondary authority · *The Bluebook: A Uniform System of
|
||||||
|
Citation* — סיווג סמכות-ראשונית מול משנית | סטטוס: verified
|
||||||
|
**אכיפה:** היעדר FK מ-`decision_blocks`/ציטוטים ל-`digests`; ולידציית-QA ([05-qa-review.md](05-qa-review.md))
|
||||||
|
שדוחה ציטוט שמקורו digest; הוראת-חוקר מפורשת ([X4-agents.md](X4-agents.md), `legal-researcher.md`).
|
||||||
|
**הפרה ידועה:** — (תת-מערכת חדשה)
|
||||||
|
|
||||||
|
### INV-DIG2: מסלול-קליטה נפרד-בכוונה — לא מסלול-פסיקה מקביל
|
||||||
|
**כלל:** קליטת-יומון היא **bounded context נפרד**, ואינה עוברת ב-precedent pipeline
|
||||||
|
([01-ingest.md](01-ingest.md)): אין `precedent_chunks`, אין halacha-extraction, אין
|
||||||
|
precedent-metadata-extractor. מסלול קצר עצמאי (`digest_library.ingest_digest`) הבונה
|
||||||
|
embedding-יחיד לחיפוש סמנטי בלבד. הצהרה זו היא מה ש**מונע** הפרת-G2 — היומון אינו ישות-אחות
|
||||||
|
של `case_law` ואינו מתפצל ממסלולו.
|
||||||
|
**מקורות:** Eric Evans, *Domain-Driven Design* (2003) — Bounded Context (הקשרים שונים = מודלים
|
||||||
|
מובחנים) · Martin Kleppmann, *DDIA* (2017) — system-of-record מובחן מ-derived/index data · Martin
|
||||||
|
Fowler — Bounded Context / Canonical Data Model | סטטוס: verified
|
||||||
|
**אכיפה:** טבלה פיזית נפרדת `digests`; `ingest_digest` עושה reuse לשירותים אטומיים בלבד
|
||||||
|
(`extractor.extract_text`, `embeddings.embed_texts`) ולא ל-`ingest.ingest_document`; ביקורת-
|
||||||
|
ארכיטקטורה. אוכף את [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
+ כלל-הנדסה "סימטריה" (§6). **מקור-אמת יחיד:** מצב-הקליטה נשמר אך-ורק בטבלת `digests` (סטטוס +
|
||||||
|
`content_hash` ל-idempotency); תיקיות-קבצים (`incoming/`) הן staging בלבד, **לא** state.
|
||||||
|
**הפרה ידועה (תוקנה 2026-06-07):** `ingest_digests_batch.py` העביר קבצים ל-`data/digests/processed/`
|
||||||
|
— state מבוסס-תיקיות מקביל ל-DB. הוסר; הסקריפט מסתמך על dedup ב-content_hash (G2).
|
||||||
|
|
||||||
|
### INV-DIG3: קישור-לפסק-המקורי הוא הגשר — חוסר-קישור הוא פער גלוי
|
||||||
|
**כלל:** לכל `digest` שדה `linked_case_law_id` (FK ל-`case_law`, nullable). כשהפסק המקורי בקורפוס —
|
||||||
|
היומון מקושר אליו (אוטומטית בקליטה לפי מראה-המקום, או ידנית ב-`digest_link`). כל עוד אינו בקורפוס,
|
||||||
|
הקישור ריק ו**הפער מוצף** דרך `missing_precedent_create` על הפסק המקורי — לא נבלע בשקט.
|
||||||
|
**מקורות:** E.F. Codd — referential integrity (foreign keys, CACM 13(6), 1970) · ISO 8000 —
|
||||||
|
completeness (פער-ידע מתועד) · DAMA-DMBOK2 — data linkage / lineage | סטטוס: verified
|
||||||
|
**אכיפה:** שדה-FK `digests.linked_case_law_id` + `try_autolink` בקליטה + כלי `digest_link`/
|
||||||
|
`digest_relink`; חוסר-קישור → `missing_precedent_create` (כלל-הנדסה "אין בליעה שקטה", §6). אוכף את
|
||||||
|
[G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) +
|
||||||
|
[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
|
||||||
|
**הפרה ידועה:** — (תת-מערכת חדשה)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. מצב קיים מול יעד — audit-findings
|
||||||
|
|
||||||
|
התת-מערכת כולה TARGET; אין כיום מימוש. רכיבים לבנייה:
|
||||||
|
|
||||||
|
- **טבלת `digests` + פונקציות-DB** — לא קיימות. יעד: `SCHEMA_V30` ב-`db.py` (טבלה + ivfflat/GIN/FTS
|
||||||
|
אינדקסים + UNIQUE חלקי על `yomon_number`/`content_hash` ל-idempotent) + `create_digest`/`search_digests`/
|
||||||
|
`link_digest_to_case_law` (§4, INV-DIG2/DIG3).
|
||||||
|
- **שירות + חילוץ-LLM** — `services/digest_library.py` + `services/digest_metadata_extractor.py`
|
||||||
|
לא קיימים. החילוץ נשען על `claude_session` (local-only — ייבוא lazy בתוך `ingest_digest` בלבד,
|
||||||
|
לא רץ בקונטיינר; תואם [claude_session local-only]).
|
||||||
|
- **כלי-MCP `digest_*`** — לא קיימים. יעד: `tools/digests.py` + רישום ב-`server.py`, מעטפת-envelope
|
||||||
|
אחידה לפי [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) (`search_digests` מובחן בשם מ-6 כלי-
|
||||||
|
החיפוש הקיימים — INV-TOOL2).
|
||||||
|
- **אינטגרציית-חוקר** — `legal-researcher.md` ללא `search_digests`/`digest_link` ב-`tools:` וללא שלב-
|
||||||
|
radar. יעד: שלב סריקת-יומונים לפני האימות + סעיף-דוח נפרד "radar — לא ציטוט" (INV-DIG1).
|
||||||
|
- **UI** — אין דף `/digests`. יעד: דף נפרד (לא כרטיסייה ב-`/precedents`, לשמור גבול סמכותי/משני),
|
||||||
|
אחרי `npm run api:types` ([X6-ui-api-contract.md](X6-ui-api-contract.md)).
|
||||||
|
- **אוטומציית-קליטה (Gmail) + עלון-חודשי רב-נושאי** — שלב עתידי; שלב-1 ידני (drop ל-
|
||||||
|
`data/digests/incoming/` → `scripts/ingest_digests_batch.py`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md) — G2 (אין מסלול מקביל), G4 (שלמות/אין-בליעה), G9 (עקיבוּת).
|
||||||
|
- [03-retrieval.md](03-retrieval.md) — 3 קורפוסי-הציטוט שהיומון מובחן מהם (§3); הפרדת-קורפוס.
|
||||||
|
- [01-ingest.md](01-ingest.md) — צינור-הפסיקה הקנוני שהיומון **אינו** עובר בו (INV-DIG2).
|
||||||
|
- [02-data-model.md](02-data-model.md) — `case_law` (יעד-הקישור של `linked_case_law_id`).
|
||||||
|
- [05-qa-review.md](05-qa-review.md) — שער-QA שדוחה ציטוט שמקורו digest (INV-DIG1).
|
||||||
|
- [X4-agents.md](X4-agents.md) — סוכן החוקר שצורך את ה-radar.
|
||||||
|
- [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) — חוזה כלי-ה-`digest_*`.
|
||||||
180
docs/spec/X13-court-fetch.md
Normal file
180
docs/spec/X13-court-fetch.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# X13 — אחזור-פסיקה אוטומטי מנט המשפט (Court Verdict Fetch)
|
||||||
|
|
||||||
|
> כפוף ל-[חוקת המערכת](00-constitution.md). תת-מערכת **שירות** (לא קורפוס) שמורידה פסקי-דין
|
||||||
|
> ציבוריים של בתי-משפט ומזרימה אותם ל**צינור-הקליטה הקנוני** של ספריית-הפסיקה. אחות-מושגית
|
||||||
|
> ל-[X12 — Digests Radar](X12-digests-radar.md) (הטריגר העיקרי) ול-[01-ingest](01-ingest.md)
|
||||||
|
> (היעד). אינה קורפוס רביעי ואינה מסלול-ingest מקביל.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. ייעוד והקשר
|
||||||
|
|
||||||
|
יומון (digest) מצביע על פסק-דין נושא (`underlying_citation`, למשל `עת"מ 46111-12-22`). כשהפסק
|
||||||
|
אינו בקורפוס, המערכת **מאחזרת אותו אוטומטית** ממקור ציבורי, מחלצת טקסט, וקולטת אותו דרך
|
||||||
|
`precedent_library_upload` → `ingest_precedent`. כך הופך פסק-דין מ"מצוטט-בלבד" ל"שמיש לחיפוש
|
||||||
|
וחילוץ-הלכות".
|
||||||
|
|
||||||
|
**הבחנת-מקור קריטית:** רק **פסקי-דין של בתי-משפט** ניתנים לאחזור ציבורי. **החלטות ועדת-ערר**
|
||||||
|
אינן זמינות ציבורית (נדרש נבו) — מסומנות כפער ולא נשלחות לאחזור.
|
||||||
|
|
||||||
|
**דרכי-מקור ציבוריות (ניתוב לפי זמינות-פורמט-נט, לא לפי ערכאה):**
|
||||||
|
- **נט המשפט** (מציג-התיקים) משרת **כל הערכאות** — מחוזי/שלום *וגם עליון* — כל עוד יש מספר
|
||||||
|
בפורמט תיק-חודש-שנה. ASP.NET WebForms (`__doPostBack`/VIEWSTATE), anti-bot של F5, מסמכים
|
||||||
|
בצופה-עמודים (turn.js). מחייב **דפדפן-אמת** (host-side) → שירות-מארח ב-pm2 (כדפוס
|
||||||
|
`legal-chat-service`). **זהו המסלול הראשי המאומת.**
|
||||||
|
- **עליון בפורמט-סדרתי** (עע"מ/בג"ץ NNNN/YY, ללא חודש — לא ניתן לחיפוש בנט) → `supremedecisions.court.gov.il`
|
||||||
|
(httpx, ללא CAPTCHA, ללא דפדפן). **פוענח ואומת (2026-06-08):** `POST Home/SearchVerdicts` עם
|
||||||
|
`document` מובנה (`{Year:"YYYY", CaseNum, OldMainNumFormat:true, SearchText:[…]}`) + כותרת
|
||||||
|
**`X-Requested-With: XMLHttpRequest`** → רשומות; `GET Home/Download?path=&fileName=&type=4` → PDF.
|
||||||
|
בוחר מסמך best-first (פסק-דין→מספר-עמודים) ומדלג על מסמכי published-report החסומים (`s`-prefix).
|
||||||
|
תיקים ישנים-מאוד שלא דיגיטצו (למשל 389/87) → `manual`.
|
||||||
|
|
||||||
|
> **אומת end-to-end (2026-06-07) על עת"מ 46111-12-22** — פס"ד 34 עמ' הורד **אוטונומית מלא,
|
||||||
|
> נטו קוד-פתוח, ללא כרטיס-חכם וללא פתרון-CAPTCHA**. ממצאי-המפתח מהכיול:
|
||||||
|
> - **החיפוש והניווט לתיק — ללא reCAPTCHA כלל.** מסלול: דף-בית → `btnExternalSearchCases`
|
||||||
|
> → מילוי `BamaCaseNumberTextBoxH`(=מס' תיק) + `BamaMonthYearTextBoxHT`(="MM-YY") →
|
||||||
|
> `CaseDetails.aspx` → לשונית "פסקי דין" → `DecisionList.aspx` → צופה `NGCSViewerPage.aspx`.
|
||||||
|
> - **reCAPTCHA קיים רק בצופה ורק על שמירה/הדפסה מפורשת** — *לא* על הצגת המסמך. הצופה
|
||||||
|
> מגיש את העמודים כ-PNG דרך PageMethod **`GetImages`** (4 עמ'/batch) **ללא CAPTCHA**.
|
||||||
|
> אחזור = לכידת `documentNumber` מהקריאה הראשונה + משיכת כל ה-batches ב-`fetch` עם הכותרת
|
||||||
|
> **`X-Requested-With: XMLHttpRequest`** (חובה — ה-WAF חוסם AJAX בלעדיה) → הרכבת PDF (Pillow).
|
||||||
|
> - דפדפן: **Camoufox דרך חבילת-הפייתון** (`camoufox.async_api`, in-process — לא שרת-Node).
|
||||||
|
> על שרת ללא-מסך נדרש **Xvfb** (אחרת Firefox קורס). פותר-ה-reCAPTCHA האודיו (Whisper) נשמר
|
||||||
|
> כ-fallback למסלול-השמירה-המפורש בלבד; מסלול-התמונות אינו זקוק לו.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ארכיטקטורה — שלוש שכבות (tiered)
|
||||||
|
|
||||||
|
```
|
||||||
|
underlying_citation → [classifier] → {tier, האם יש פורמט-נט (תיק-חודש-שנה)}
|
||||||
|
skip(ערר/בל"מ) → missing_precedent (נבו ידני) — לא אחזור
|
||||||
|
── ניתוב לפי זמינות-פורמט-נט, לא לפי קידומת (נט המשפט משרת כל הערכאות) ──
|
||||||
|
פורמט-נט קיים (עמ"נ/עת"מ/עליון-בפורמט-נט כמו בר"מ 72182-06-25)
|
||||||
|
→ Tier 1: legal-court-fetch-service (host/pm2 + Xvfb) — אוטונומי, מאומת
|
||||||
|
→ Camoufox(python) → external-search → CaseDetails → פסקי דין
|
||||||
|
→ NGCSViewerPage → GetImages(X-Requested-With) → PNGs → PDF
|
||||||
|
עליון סדרתי-בלבד (בג"ץ/בר"מ NNNN/YY, בלי חודש)
|
||||||
|
→ Tier 0: httpx → supremedecisions (SearchVerdicts+Download) — מפוענח ומאומת
|
||||||
|
כשל אוטונומי → Tier 2: missing_precedent + התראה (VNC עתידי) — שער-אנושי
|
||||||
|
(כל ה-tiers) → precedent_library_upload(source_type=court_ruling) → ingest_precedent
|
||||||
|
→ chunks+embeddings+halachot(pending) → relink digest / close gap
|
||||||
|
```
|
||||||
|
|
||||||
|
מצב-העבודה מנוהל בטבלת-תור `court_fetch_jobs` (idempotent, נצפה, retryable). הניקוז
|
||||||
|
האוטומטי: `legal-court-fetch-drain` (pm2 cron שעתי) → `orchestrator.drain_pending`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Invariants
|
||||||
|
|
||||||
|
### INV-CF1: מסלול-קליטה יחיד — אין ingest מקביל
|
||||||
|
**כלל:** כל ה-tiers מתנקזים ל**צינור-הקליטה הקנוני היחיד** (`precedent_library_upload` →
|
||||||
|
`ingest_precedent`). המאחזר מספק קובץ+מטא בלבד; אסור לו לכתוב `case_law`/`precedent_chunks`/
|
||||||
|
`halachot` ישירות או לשכפל לוגיקת-chunking/embedding.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G2](00-constitution.md#inv-g2) (מקור-אמת יחיד, אין מסלול מקביל) על תת-מערכת זו.
|
||||||
|
**אכיפה:** האורקסטרטור קורא רק ל-API/שירות-הקליטה הקיים; ביקורת-ארכיטקטורה ב-PR.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF2: אין בליעה שקטה — כל אחזור נצפה
|
||||||
|
**כלל:** לכל פסק-דין שזוהה לאחזור יש רשומת-job עם סטטוס סופי מפורש
|
||||||
|
(`done`/`failed`/`manual`). כישלון-אחזור **לעולם אינו נבלע** — הוא מסומן ומועלה (Tier 2),
|
||||||
|
לא נזרק בשקט. `except: pass` אסור.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G4](00-constitution.md#inv-g4) וכלל-ההנדסה "אין בליעה שקטה" (§6).
|
||||||
|
**אכיפה:** טבלת `court_fetch_jobs` (status+error+attempts) + לוג-warning בכל כישלון + Tier-2 gate.
|
||||||
|
**הפרה ידועה:** ~~הפער ב-X12 — `try_autolink` שנכשל מחזיר `None` בשקט~~ → **תוקן**: `try_autolink` שנכשל על ציטוט פס"ד-בימ"ש מזניק job ל-`court_fetch_jobs` (status=pending); `court_fetch_drain` מנקז (סדרתי) ומקשר את היומון חזרה בהצלחה.
|
||||||
|
|
||||||
|
### INV-CF3: אוטונומי-first, שער-אנושי חובה ב-fallback
|
||||||
|
**כלל:** האחזור מנסה אוטונומית; אך כש-N נסיונות נכשלים, **שער-אנושי** (VNC לפתרון-CAPTCHA
|
||||||
|
חי / סימון missing_precedent + התראה) הוא **חובה, לא רשות**. המערכת אינה "מוותרת" ואינה
|
||||||
|
"מסתירה" — היא מסלימה לאדם.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G10](00-constitution.md#inv-g10) (המערכת מסייעת; שערים אנושיים = invariant).
|
||||||
|
**אכיפה:** מונה-נסיונות בטבלת-התור + מעבר אוטומטי ל-status=`manual` עם נתיב-פעולה ל-chaim.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF4: אחזור-אחראי (politeness) — סדרתי, מרווח, חתימה-אמיתית
|
||||||
|
**כלל:** האחזור מאתר-ממשלתי הוא **אחראי**: סדרתי (לא מקבילי), עם cooldown בין בקשות,
|
||||||
|
כיבוד-`robots`/תנאי-שימוש, ו-rate מתון. אסור flooding/parallel-hammering שעלול לחסום IP
|
||||||
|
או להעמיס על שירות ציבורי.
|
||||||
|
**מקורות:** RFC 9309 (*Robots Exclusion Protocol*, IETF 2022) · Google Search Central —
|
||||||
|
*Crawler / crawl-rate guidance* · OWASP — *Automated Threat Handbook* (OAT-021 Denial of
|
||||||
|
Service / responsible automation) | סטטוס: verified
|
||||||
|
**אכיפה:** האורקסטרטור והשירות אוכפים serial + `INTER_FETCH_COOLDOWN_SEC`; Camoufox מספק
|
||||||
|
חתימת-דפדפן אמיתית (לא spoof-חמדני). מראה לדפוס-התור ב-[`precedent_library.py`](../../mcp-server/src/legal_mcp/services/precedent_library.py).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF5: אחזור idempotent
|
||||||
|
**כלל:** אחזור הוא **idempotent** — מפתח-job דטרמיניסטי לפי `case_number` מנורמל. אחזור
|
||||||
|
חוזר של אותו תיק אינו יוצר job כפול ואינו קולט פסק-דין פעמיים (upsert על המפתח הקנוני).
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G3](00-constitution.md#inv-g3) (ingest idempotent) ו-[G1](00-constitution.md#inv-g1) (מזהה מנורמל בכתיבה).
|
||||||
|
**אכיפה:** אילוץ-ייחודיות על `court_fetch_jobs.case_number_norm`; הקליטה עצמה idempotent דרך `ingest_precedent`.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF6: שער-סיווג מקור — רק פסקי-דין של בתי-משפט
|
||||||
|
**כלל:** רק ציטוט שסווג כ**פסק-דין של בית-משפט** נשלח לאחזור. **ועדת-ערר (ערר/בל"מ) לעולם
|
||||||
|
אינה נשלחת לאחזור-ציבורי** (נדרש נבו) — היא מסומנת `missing_precedent` בלבד. הפריט הנקלט
|
||||||
|
נושא `source_type=court_ruling`, `source_kind=external_upload`, `precedent_level` לפי הערכאה.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G5](00-constitution.md#inv-g5) (metadata מלא + הפרדת-קורפוס)
|
||||||
|
ותואם את הבחנת-המקור ב-[01-ingest](01-ingest.md) (`court_ruling` מול `appeals_committee`).
|
||||||
|
**אכיפה:** המסווג מחזיר `tier=skip` ל-ערר/בל"מ; הקליטה אוכפת `source_type`.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF7: עקיבוּת-מקור + גבול-ToS
|
||||||
|
**כלל:** כל אחזור נושם **provenance** מלא (`source_url`, tier, זמן, מזהה-job) ב-audit-trail.
|
||||||
|
האחזור מוגבל ל**מסמכים ציבוריים** הזמינים ללא הזדהות (smart-card); אופי המערכת הוא
|
||||||
|
**הורדה-בסיוע** (עם שער-אנושי), לא בוט-סמוי לעקיפת בקרת-גישה.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G9](00-constitution.md#inv-g9) (עקיבוּת + audit-trail);
|
||||||
|
גבול-ה-ToS מועלה ליו"ר (חיים) כשיקול-מדיניות (עיקרון-עבודה 4: המשתמש הוא הסמכות).
|
||||||
|
**אכיפה:** `source_url`+tier נשמרים על `case_law`/`court_fetch_jobs`; שער-אנושי שומר על אופי בסיוע.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. מודל-נתונים — `court_fetch_jobs`
|
||||||
|
|
||||||
|
| עמודה | טיפוס | תפקיד |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `id` | UUID PK | מזהה-job |
|
||||||
|
| `case_number_norm` | TEXT UNIQUE | מפתח-idempotency קנוני (INV-CF5) |
|
||||||
|
| `citation_raw` | TEXT | הציטוט המקורי כפי שזוהה |
|
||||||
|
| `tier` | TEXT | `supreme` \| `admin` \| `skip` |
|
||||||
|
| `court` | TEXT | ערכאה שזוהתה |
|
||||||
|
| `status` | TEXT | `pending` \| `running` \| `done` \| `failed` \| `manual` |
|
||||||
|
| `attempts` | INT | מונה-נסיונות (ל-Tier 2 gate, INV-CF3) |
|
||||||
|
| `error` | TEXT | הודעת-כישלון אחרונה (INV-CF2) |
|
||||||
|
| `case_law_id` | UUID FK | הפסק שנקלט (NULL עד done) |
|
||||||
|
| `digest_id` | UUID FK | היומון-מקור (NULL לאד-הוק) |
|
||||||
|
| `source_url` | TEXT | provenance (INV-CF7) |
|
||||||
|
| `created_at` / `updated_at` | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. רכיבי-מימוש (מיפוי לקוד)
|
||||||
|
|
||||||
|
| רכיב | קובץ | מקור-תבנית / שימוש-חוזר |
|
||||||
|
|------|------|------------------------|
|
||||||
|
| מסווג | `mcp-server/.../services/court_citation.py` | regex מ-`citation_extractor.py:67-132` |
|
||||||
|
| Tier 0 | `services/court_fetch_supreme.py` | httpx; דפוס-cooldown מ-`precedent_library.py:176-186` |
|
||||||
|
| Tier 1 שירות | `mcp-server/.../court_fetch_service/server.py` | שכפול `chat_service/server.py` (aiohttp+Bearer+bind 10.0.1.1) |
|
||||||
|
| Camoufox client | `court_fetch_service/camofox_client.py` | חיקוי `~/.hermes/.../browser_camofox.py` |
|
||||||
|
| reCAPTCHA audio | `court_fetch_service/recaptcha_audio.py` | faster-whisper מקומי |
|
||||||
|
| proxy בקונטיינר | `web/court_fetch_proxy.py` | שכפול `web/chat_proxy.py` |
|
||||||
|
| pm2 | `scripts/legal-court-fetch-service.config.cjs` | שכפול `legal-chat-service.config.cjs` |
|
||||||
|
| אורקסטרטור+תור | `services/court_fetch_orchestrator.py` + `db.py` (SCHEMA_Vxx) | דפוס-תור קיים |
|
||||||
|
| כלי-MCP | `tools/court_fetch.py` (`court_verdict_fetch` / `court_fetch_status` / `court_fetch_drain`) | חוזה-envelope [X9](X9-mcp-tool-contract.md) |
|
||||||
|
| טריגר אוטומטי | `services/digest_library.py` (`try_autolink` fail → `_enqueue_court_fetch`) → drain ע"י `orchestrator.drain_pending` | X12 |
|
||||||
|
| סוד | `COURT_FETCH_SHARED_SECRET` (Infisical + Coolify) | דפוס `LEGAL_CHAT_SHARED_SECRET`, [X10](X10-deploy-env-secrets.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. סיכונים (R&D — לעקוב)
|
||||||
|
- reCAPTCHA נלחם פעיל בפותרי-אודיו → שיעור-כישלון אפשרי גבוה → Tier 2 הוא קו-ההגנה (INV-CF3).
|
||||||
|
- F5/anti-bot עלול לחסום IP → politeness סדרתי + Camoufox (INV-CF4).
|
||||||
|
- שבירות מול שינויי-אתר → ריכוז selectors במקום אחד + בדיקות-עשן תקופתיות.
|
||||||
|
- גבול-ToS על אתר .gov → INV-CF7 + שיקול-יו"ר.
|
||||||
|
- ~~**Tier-0 (supremedecisions) טרם מפוענח**~~ → **פוענח ומאומת (2026-06-08)** — עליון בפורמט-סדרתי
|
||||||
|
(בג"ץ/בר"מ NNNN/YY) יורד אוטומטית דרך `Home/SearchVerdicts`+`Home/Download`. מגבלה שנותרה: תיקים
|
||||||
|
ישנים-מאוד שלא דיגיטצו בפורטל (0 רשומות) → `manual`. גם `backfill_missing_precedents.py` מזין את
|
||||||
|
ה-`missing_precedents` הפתוחים (עליון+נט-format) לתור-האחזור.
|
||||||
|
- **דליפת-זיכרון מדפדפנים יתומים** (fetch שנתקע/נהרג משאיר `camoufox-bin`) → שלוש שכבות-הגנה:
|
||||||
|
(א) `async with` סוגר את הדפדפן בכל exception; (ב) `asyncio.wait_for` קשיח (`COURT_FETCH_HARD_TIMEOUT_S`, ברירת-מחדל 180ש') מבטל hang + reap; (ג) reaper של `camoufox-bin` יתומים (`ppid=1`) לפני/אחרי כל fetch + דמון `legal-reaper` (pm2) + תקרת `max_memory_restart`. סדרתיות (INV-CF4) מבטיחה שכל דפדפן `ppid=1` הוא שארית בטוחה-להריגה. **הערה:** הדליפה הגדולה בפועל בשרת היא `task-master-mcp` (כלי נפרד), שגם אותו ה-reaper מנקה.
|
||||||
146
docs/spec/X14-storage-minio.md
Normal file
146
docs/spec/X14-storage-minio.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# X14 — אחסון-אובייקטים (Object Storage: MinIO / S3)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **אחסון קבצים בינאריים** —
|
||||||
|
מסמכי-מקור, נגזרים, וייצוא — והגירתם ממערכת-קבצים מקומית (`data/`) ל-**MinIO** (object store תואם-S3).
|
||||||
|
הוא מגדיר את חוזה-האחסון (שכבה יחידה), סכמת-הדליות-והמפתחות, מודל-האי-שינויוּת המשפטי, ותוכנית-ההגירה.
|
||||||
|
|
||||||
|
> **invariant הנדסי + תפעולי-משפטי.** INV-STG1/2/5/6 נשענים על עקרונות מוכרים (S3 API, 12-Factor, presigned-URL,
|
||||||
|
> separation blob↔metadata) — ≥3 מקורות (docs.min.io, AWS S3 spec, minio-py). INV-STG3/4/7 הם תפעוליים/משפטיים
|
||||||
|
> של *מערכת זו* (גבול-ממשל, WORM להחלטות חתומות, git=טקסט) ונקשרים ל-[G2](00-constitution.md) (מסלול-אחסון יחיד).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. מצב קיים (מאומת מול הקוד וה-infra, 2026-06-08)
|
||||||
|
|
||||||
|
### 1.1 מלאי-הדיסק (`data/`, ללא `backups/`)
|
||||||
|
| קטגוריה | נפח | תוכן | סוג |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `data/cases/{case}/` | 1.2GB | `documents/{originals,extracted,proofread,research,backup}`, `drafts/`, `exports/`, `thumbnails/{doc_uuid}/pNNN.jpg`, `.git` per-case | מקור + נגזר |
|
||||||
|
| `data/digests/{reference,incoming}/` | 251MB | יומונים (X12) | מקור |
|
||||||
|
| `data/training/{cmp,cmpa}/{raw,proofread}/` | 157MB | קורפוס-קול + `.git` | מקור |
|
||||||
|
| `data/precedent-library/{appeals_committee,court_ruling,other}/` | 105MB | פסיקה + `thumbnails/` | מקור |
|
||||||
|
| `data/internal-decisions/{region}/` | 45MB | החלטות-פנים לפי מחוז | מקור |
|
||||||
|
| `data/exports/` | 216KB | legacy (הוחלף ב-per-case) | נגזר |
|
||||||
|
| `data/{audit,eval,logs}/` | ~52MB | CSV/JSON תפעוליים — **לא מסמכים, נשארים בדיסק** | תפעולי |
|
||||||
|
|
||||||
|
ספירה (ללא backups): ~9,449 קבצים — 2,473 JPG (thumbnails נגזרים), 883 PDF, 250 TXT (extracted), 155 DOCX, 54 DOC.
|
||||||
|
|
||||||
|
### 1.2 הקונטיינר (Coolify)
|
||||||
|
legal-ai (`gyjo0mtw2c42ej3xxvbz8zio`) רץ עם **bind-mounts**: host `data/`→`/data`, host `data/cases/`→`/cases`.
|
||||||
|
האחסון היום = תיקייה על המארח, חשופה ישירות.
|
||||||
|
|
||||||
|
### 1.3 MinIO — **כבר פרוס ובריא** ✅ (שירות Coolify `minio`, `bx2ykvw94xbutsex41hz4vv8`, 2026-06-08)
|
||||||
|
- **API:** `https://s3.nautilus.marcusgroup.org` (9000) · **Console:** `https://minio.nautilus.marcusgroup.org` (9001)
|
||||||
|
- **Credentials:** `SERVICE_USER_MINIO` / `SERVICE_PASSWORD_MINIO` (סודות מנוהלי-Coolify)
|
||||||
|
- **אחסון:** named-volume `minio-data`→`/data` — **Single-Node Single-Drive**; versioning/object-lock **לא** מופעלים עדיין
|
||||||
|
- **רשת:** רשת-Docker משלו (`bx2ykvw...`, external), **לא** משותפת ל-legal-ai → דרושה קישוריות (§4 שלב 0)
|
||||||
|
|
||||||
|
### 1.4 הקוד — **אין שכבת-אחסון מרכזית** (כשל-השורש שהתחום מייבש)
|
||||||
|
ה-I/O מפוזר על ~8 שירותים, נתיבים נבנים inline:
|
||||||
|
- העלאה: `tools/documents.py:54` (originals), `:152` (training)
|
||||||
|
- חילוץ + thumbnails: `services/processor.py:43,153`
|
||||||
|
- staging פסיקה/יומונים/החלטות: `services/ingest.py:69`
|
||||||
|
- ייצוא DOCX: `services/docx_exporter.py:462`
|
||||||
|
- הגשה (FileResponse): `web/app.py` — 6 endpoints
|
||||||
|
- git per-case: `services/git_sync.py` (`git add .` + push ל-Gitea, sweep כל 30ש׳)
|
||||||
|
|
||||||
|
### 1.5 עמודות-DB המאחסנות נתיבים (schema inline ב-`db.py`, ללא migrations)
|
||||||
|
`documents.file_path` · `cases.active_draft_path` · `case_law.source_document_path` · `digests.source_document_path`
|
||||||
|
· `document_image_pages.image_thumbnail_path` · `precedent_image_pages.image_thumbnail_path` · `draft_final_pairs.final_path`
|
||||||
|
|
||||||
|
### 1.6 Paperclip — צרכן-API בלבד
|
||||||
|
הפלאגין ניגש דרך `listDocuments`/`getDocumentText` ל-API (`plugin-legal-ai/src/legal-api.ts:89`). אינו נוגע בדיסק →
|
||||||
|
**הגירה שקופה אליו** כל עוד ה-API יציב.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-STG1: שכבת-אחסון יחידה — כל I/O דרך `storage.py`
|
||||||
|
**כלל:** קיים מודול-אחסון **יחיד** (`services/storage.py`) שכל קריאה/כתיבה של קובץ בינארי עוברת דרכו
|
||||||
|
(`put/get/presign_get/presign_put/delete/list`). אסור `open()`/`shutil.copy()`/`Path.write_bytes()` ישיר על
|
||||||
|
נתיב-אחסון מחוץ למודול. **מקיים [G2](00-constitution.md)** — מבטל את ה-I/O המפוזר (§1.4) שהוא מסלול-מקביל-מתפצל.
|
||||||
|
|
||||||
|
### INV-STG2: מפתח-אובייקט אטומי; שם עברי במטא בלבד
|
||||||
|
**כלל:** מפתח-האובייקט הוא ASCII/UUID (`cases/{case}/originals/{uuid}.pdf`). שם-הקובץ העברי המקורי נשמר ב-DB
|
||||||
|
(`*_filename`) וכ-`x-amz-meta-filename` + מוגש דרך `Content-Disposition` ב-presigned-GET. **למה:** תקציב-מפתח
|
||||||
|
1024 bytes (255/segment), עברית=2B/תו, ובעיות percent-encoding/XML — נמנעות.
|
||||||
|
|
||||||
|
### INV-STG3: דליות לפי גבול-ממשל, prefix לפי קטגוריה/תיק
|
||||||
|
**כלל:** versioning/object-lock/replication הם per-bucket → מה שדורש ממשל שונה יושב בדלי נפרד. שלוש דליות
|
||||||
|
קבועות (§3.1); תיקים/קטגוריות הם prefixes, **לא** דלי-לכל-תיק.
|
||||||
|
|
||||||
|
### INV-STG4: "סופי" = WORM (Object-Lock COMPLIANCE)
|
||||||
|
**כלל:** החלטה חתומה/סופית נכתבת לדלי `legal-immutable` עם Object-Lock **COMPLIANCE** + versioning — בלתי-ניתנת
|
||||||
|
לשינוי/מחיקה ע"י איש (כולל root) עד תום-תקופת-השמירה. טיוטות חיות בדלי רגיל ו"מקודמות" (copy) לדלי-הסגור עם החתימה.
|
||||||
|
**(הכרעת-יו"ר 2026-06-08: סופי בלבד; מסמכי-מקור — versioning ללא נעילה קשיחה.)**
|
||||||
|
|
||||||
|
### INV-STG5: pgvector נשאר מקור-האמת לטקסט/embeddings; MinIO = blob בלבד
|
||||||
|
**כלל:** טקסט-מחולץ + embeddings נשארים ב-Postgres/pgvector (מקור-אמת לאחזור). MinIO מאחסן את ה-blob המקורי
|
||||||
|
(+עותק-ארכיון אופציונלי של ה-extracted text). **אסור** ש-MinIO יהיה מקור-אמת לוקטורים. תואם
|
||||||
|
`no-reocr-retrofit` — לא מריצים OCR מחדש בהגירה.
|
||||||
|
|
||||||
|
### INV-STG6: הגשה לדפדפן דרך presigned-URL — bytes לא דרך FastAPI
|
||||||
|
**כלל:** הורדה/תצוגה/העלאה מהדפדפן עוברות ב-presigned-URL (TTL דקות) מול `s3.nautilus.marcusgroup.org`.
|
||||||
|
ה-backend מנפיק את ה-URL בלבד; ה-bytes לא עוברים דרכו. endpoints קיימים שמחזירים FileResponse → 302→presigned.
|
||||||
|
|
||||||
|
### INV-STG7: git-per-case שומר טקסט/מטא בלבד; בינאריים ב-MinIO
|
||||||
|
**כלל:** `.git` per-case ממשיך לגרסן `case.json`/`notes.md`/`documents/extracted/*.txt`/`research/*.md`. PDF/DOCX/JPG
|
||||||
|
מוחרגים מ-tracking (`.gitignore` per-case) ויושבים ב-MinIO. **(הכרעת-יו"ר 2026-06-08.)** `git_sync.py` ו-sweep
|
||||||
|
מסתמכים על אותו working-tree → ההחרגה חייבת לקדום לכל קומיט-הגירה כדי לא לשבור היסטוריה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ארכיטקטורת-היעד
|
||||||
|
|
||||||
|
### 3.1 דליות ומפתחות
|
||||||
|
| דלי | Versioning | Object-Lock | prefixes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `legal-documents` | ✅ | ❌ | `cases/{case}/originals/{uuid}.pdf` · `cases/{case}/proofread/{uuid}.txt` · `precedent-library/{type}/{uuid}.pdf` · `internal-decisions/{region}/{uuid}.pdf` · `digests/{uuid}.pdf` · `training/{cmp\|cmpa}/{raw\|proofread}/{uuid}.pdf` |
|
||||||
|
| `legal-immutable` | ✅ | ✅ COMPLIANCE | `decisions-final/{case}/{uuid}.docx` (החלטות חתומות בלבד) |
|
||||||
|
| `legal-derived` | ❌ | ❌ (+lifecycle) | `thumbnails/{doc_uuid}/pNNN.jpg` · `extracted/{uuid}.txt` (נגזר, ניתן-לשחזור) |
|
||||||
|
|
||||||
|
### 3.2 `services/storage.py` (לב ההגירה) — adapter כפול
|
||||||
|
```
|
||||||
|
put(category, key, data, content_type, meta) -> uri # category→bucket+prefix
|
||||||
|
get(uri) -> bytes
|
||||||
|
presign_get(key, ttl) / presign_put(key, ttl) -> url
|
||||||
|
delete(key) / list(prefix)
|
||||||
|
```
|
||||||
|
backend נבחר ב-env `STORAGE_BACKEND ∈ {filesystem, dual, s3}` (ברירת-מחדל filesystem) — מאפשר מעבר הדרגתי ללא
|
||||||
|
שינוי-התנהגות. SDK: `aioboto3` (async-native מול `endpoint_url=http://minio:9000`); `minio-py` לסקריפטי-הגירה.
|
||||||
|
|
||||||
|
### 3.3 שינויי-DB
|
||||||
|
הוספת `*_object_key` (או נרמול ל-`storage_uri` עם סכמה `s3://`/`file://`) לצד העמודות הקיימות (§1.5); backfill;
|
||||||
|
דה-קומיישן הנתיב-קובץ. תוספת inline ב-`db.py` בסגנון הקיים (אין migrations).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. תוכנית-ביצוע בשלבים (→ TaskMaster, tag legal-ai)
|
||||||
|
|
||||||
|
| שלב | תוכן | תלות |
|
||||||
|
|---|---|---|
|
||||||
|
| **0 — תשתית** | חיבור רשת-Docker (minio↔legal-ai); הזרקת credentials ל-env legal-ai (Coolify); `mc alias`; יצירת 3 דליות + הפעלת versioning + Object-Lock (immutable); הוספת `aioboto3` ל-deps | — |
|
||||||
|
| **1 — שכבת-אחסון** | `services/storage.py` + adapter כפול (default filesystem). אפס שינוי-התנהגות. PR מצהיר INV-STG1/2/3 | 0 |
|
||||||
|
| **2 — חיווט-כתיבה** | הפניית כל נקודות-הכתיבה (§1.4) דרך `storage.py`; כתיבה-כפולה (`STORAGE_BACKEND=dual`) | 1 |
|
||||||
|
| **3 — הגירת-נתונים** | `mc mirror --dry-run`→`--overwrite` של 5 הקטגוריות; backfill `*_object_key` ב-DB; אימות count+checksum | 0,2 |
|
||||||
|
| **4 — חיווט-קריאה + presigned** | endpoints→302→presigned; thumbnails דרך presigned; dual-read (S3, fallback disk); החרגת בינאריים מ-git per-case (INV-STG7) | 2,3 |
|
||||||
|
| **5 — cutover** | `STORAGE_BACKEND=s3`; `mc mirror --watch` עד החלפה; אימות מלא; כיבוי כתיבה-לדיסק | 4 |
|
||||||
|
| **6 — git + גיבוי + ניקוי** | קידום-החלטות-סופיות ל-immutable (INV-STG4); `mc mirror`/bucket-replication מתוזמן off-site; דה-קומיישן bind-mount `data/` (השארת audit/eval/logs) | 5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. סיכונים
|
||||||
|
- **I/O מפוזר** → INV-STG1 (`storage.py`) חובה לפני כל שאר השלבים, אחרת drift והפרת-G2.
|
||||||
|
- **שמות עבריים כמפתחות** → INV-STG2 (UUID-keys + מטא).
|
||||||
|
- **רשת נפרדת ל-MinIO** → לאמת קישוריות בשלב 0 לפני הכל.
|
||||||
|
- **git-per-case** מצמיד בינאריים ל-Gitea → INV-STG7, ההחרגה חייבת לקדום לכל קומיט.
|
||||||
|
- **SNSD ללא erasure-coding** → גיבוי off-site (שלב 6) הוא חובה, לא nice-to-have.
|
||||||
|
- **בידוד-worktree + ספ-first** → כל PR מצהיר invariants (G2 + INV-STG*).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. קישורים
|
||||||
|
- חוקה: [00-constitution.md](00-constitution.md) · נתונים: [02-data-model.md](02-data-model.md) · קליטה: [01-ingest.md](01-ingest.md)
|
||||||
|
- deploy/env: [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) · אינטגרציה: [X3-integration-deploy.md](X3-integration-deploy.md)
|
||||||
|
- מקורות-MinIO: docs.min.io (community), AWS S3 object-keys/bucket-naming/presigned-URL, github.com/minio/minio-py
|
||||||
148
docs/spec/X15-agent-platform-port.md
Normal file
148
docs/spec/X15-agent-platform-port.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# X15 — שער-הפלטפורמה (Agent Platform Port)
|
||||||
|
|
||||||
|
> כפוף ל-[00-constitution.md](00-constitution.md). מיישם ומחזק את **INV-G2** (מקור-אמת
|
||||||
|
> יחיד — אין מסלולים מקבילים) ברובד הקַשירה (coupling) בין שכבת-האינטליגנציה לפלטפורמת-הסוכנים.
|
||||||
|
|
||||||
|
## 0. למה המסמך הזה קיים
|
||||||
|
|
||||||
|
פלטפורמת-הסוכנים שלנו היום היא **Paperclip**. היא אינה ליבת-המערכת — היא ה**מעטפת**
|
||||||
|
(לוח-issues, סוכנים מתמידים, human-in-the-loop דרך comments, wakeup/heartbeat, תזמון,
|
||||||
|
תקציבים per-agent, adapters). ליבת-האינטליגנציה — `mcp-server/src`, ה-skills של
|
||||||
|
ההחלטה/הסגנון, ולוגיקת-ההחלטה — היא הנכס שאינו תלוי-פלטפורמה.
|
||||||
|
|
||||||
|
**כשל-השורש שהמסמך מייבש:** מגע עם Paperclip שדולף לתוך שכבת-האינטליגנציה הופך את
|
||||||
|
המעטפת מ"רכיב ניתן-להחלפה מאחורי חוזה" ל"תלות-רוחב ארוגה בכל הקוד". ככל שהדליפה גדלה,
|
||||||
|
"החלפת המעטפת" (או אפילו שדרוג גרסה — ראו ההצמדה ל-opus-4-8) הופכת מ**החלפת-רכיב**
|
||||||
|
ל**כתיבה-מחדש**. זוהי הופעה נוספת של כשל-השורש שכל הספ בא לייבש: מסלולים מקבילים
|
||||||
|
שמתפצלים (drift), הפעם בציר התלות בין שכבות.
|
||||||
|
|
||||||
|
הבסיס התאורטי: **Ports & Adapters / Hexagonal Architecture** (Alistair Cockburn),
|
||||||
|
**The Dependency Rule / Clean Architecture** (Robert C. Martin), **Anti-Corruption
|
||||||
|
Layer** (Eric Evans, DDD). כולם אומרים את אותו הדבר: התלות זורמת פנימה בלבד; הליבה
|
||||||
|
אינה יודעת על העולם החיצון; כל מגע עם מערכת-חוץ עובר דרך שכבת-תרגום אחת (port/adapter).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. השכבות והתפר
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ INTELLIGENCE (תלוי-פלטפורמה = אסור) │
|
||||||
|
│ mcp-server/src · skills/decision · skills/style · decision logic │
|
||||||
|
│ · style-acquisition │
|
||||||
|
│ ── חייב להכיל אפס סמלים ספציפיים-Paperclip ── │
|
||||||
|
└───────────────────────────────┬────────────────────────────────────┘
|
||||||
|
│ ה-PORT (שכבת-התרגום היחידה)
|
||||||
|
│ • web/agent_platform_port.py (Python)
|
||||||
|
│ • .claude/agents/HEARTBEAT.md (פרומפטים)
|
||||||
|
┌───────────────────────────────┴────────────────────────────────────┐
|
||||||
|
│ SHELL (Paperclip-specific — מותר ומוצהר) │
|
||||||
|
│ web/paperclip_client.py · web/paperclip_api.py · plugin-legal-ai │
|
||||||
|
│ · adapters/* · web-ui settings/paperclip-tab · skills/new-company │
|
||||||
|
└───────────────────────────────┬────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────┴─────┐
|
||||||
|
│ Paperclip │ ← הפלטפורמה. ניתנת-להחלפה.
|
||||||
|
└───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**הגדרת-ה-Port:** קבוצת-הקבצים היחידה שמורשית לדבר Paperclip:
|
||||||
|
|
||||||
|
| Port surface | תפקיד | מורשה לייבא/להזכיר Paperclip |
|
||||||
|
|--------------|-------|------------------------------|
|
||||||
|
| `web/agent_platform_port.py` *(לבנייה — R2)* | תרגום אירועי-דומיין → קריאות-פלטפורמה | כן — המודול היחיד שמייבא `paperclip_client`/`paperclip_api` |
|
||||||
|
| `web/paperclip_client.py`, `web/paperclip_api.py` | מימוש-הלקוח (מאחורי ה-Port) | כן (זו המעטפת המתוכננת) |
|
||||||
|
| `.claude/agents/HEARTBEAT.md` | מקור-אמת יחיד לפרוטוקול-הריצה של הסוכנים | כן |
|
||||||
|
| `plugin-legal-ai/*`, `adapters/*` | הגשר מצד-Paperclip | כן |
|
||||||
|
| `web-ui` settings/paperclip-tab, agents-tab | UI לניהול-Paperclip עצמו | כן (מוצהר) |
|
||||||
|
| `skills/new-company-setup/SKILL.md` | blueprint-הקמה (חייב לדבר Paperclip) | כן — **חריג מוצהר** |
|
||||||
|
|
||||||
|
כל קובץ אחר — בפרט תחת `mcp-server/src`, `skills/decision`, `skills/style`,
|
||||||
|
ופרומפטי-הסוכנים פרט ל-HEARTBEAT — **אסור** שיכיל סמל ספציפי-Paperclip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ה-invariant
|
||||||
|
|
||||||
|
### INV-PORT1 (גלובלי: G12) — שער-הפלטפורמה
|
||||||
|
**כלל:** פלטפורמת-הסוכנים (Paperclip) נגישה אך-ורק דרך ה-Platform Port
|
||||||
|
(`web/agent_platform_port.py` + `HEARTBEAT.md` לפרומפטים). שכבת-האינטליגנציה —
|
||||||
|
`mcp-server/src`, וה-skills של ההחלטה/הסגנון — מכילה **אפס** סמלים ספציפיים-לפלטפורמה
|
||||||
|
(שמות-מוצר, wakeup/heartbeat, pc.sh/pc_request, X-Paperclip-Run-Id, enums של הפלטפורמה).
|
||||||
|
פרומפטי-הסוכנים אינם משכפלים את פרוטוקול-הריצה — הם מצביעים ל-HEARTBEAT.md בלבד. כל מגע
|
||||||
|
חדש עם הפלטפורמה עובר דרך ה-Port.
|
||||||
|
**מקורות:** Alistair Cockburn, *Hexagonal Architecture (Ports & Adapters)* · Robert C.
|
||||||
|
Martin, *Clean Architecture* (The Dependency Rule) · Eric Evans, *Domain-Driven Design*
|
||||||
|
(Anti-Corruption Layer) | סטטוס: verified
|
||||||
|
**אכיפה:** (א) ביקורת-ארכיטקטורה + רשימת-ה-Port (§1); (ב) leak-guard אוטומטי — הרחבת
|
||||||
|
[scripts/spec-guard.sh](../../scripts/spec-guard.sh) שמשווה מול baseline-הדליפה (§4) ומזהיר
|
||||||
|
על דליפה חדשה ב-Edit/Write; (ג) fitness-test ב-CI שנכשל על מונח-Paperclip קשיח חדש תחת
|
||||||
|
`mcp-server/src`; (ד) הצהרת-G12 בתבנית-ה-PR.
|
||||||
|
**הפרה ידועה:** ראו מצאי-הדליפה ב-§3 — `web/app.py` קורא ל-`pc_*` inline בלוגיקת
|
||||||
|
מחזור-חיים של תיקים; 10 פרומפטי-סוכנים משכפלים את פרוטוקול-הריצה במקום להצביע ל-HEARTBEAT.
|
||||||
|
|
||||||
|
> **סיווג:** invariant הנדסי (≥3 מקורות חיצוניים, verified). מורחב מ-G1–G10 בתור **G12**,
|
||||||
|
> ורשום ברשימת-הגלובליים ובאינדקס של [00-constitution.md](00-constitution.md) §5א (R0b הושלם).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. מצאי-הדליפה (baseline — נמדד 2026-06-09)
|
||||||
|
|
||||||
|
מבחן-נטישה: כמה השכבות חוצות את התפר. הספירה היא בסיס-ההשוואה ל-leak-guard.
|
||||||
|
|
||||||
|
| Layer | Paperclip hits | סיווג | מחיר-ניתוק |
|
||||||
|
|-------|----------------|-------|------------|
|
||||||
|
| `mcp-server/src` (כלים) | 5 — **הערות בלבד** | ✅ נקי (זה הנכס) | ~0 |
|
||||||
|
| `skills/` (decision/style) | 36 — רק `new-company-setup` | ✅ נקי (חריג מוצהר) | נמוך |
|
||||||
|
| `web/paperclip_client.py` | 116 | ✅ מעטפת מתוכננת | — |
|
||||||
|
| `web/paperclip_api.py` | 33 | ✅ מעטפת מתוכננת | — |
|
||||||
|
| `web/app.py` | ~33 קריאות `pc_*` + `PAPERCLIP_COMPANIES`×72 | ⚠️ דליפה מבנית (מחזור-חיים) | בינוני |
|
||||||
|
| `.claude/agents/*.md` | 288 — פרוטוקול משוכפל ב-10 פרומפטים | ⚠️⚠️ דליפה מכנית | גבוה (בנפח) |
|
||||||
|
| `web-ui` (`types.ts`×41, `cases.ts`, `sse.ts`, ...) | ~60 | ⚠️ מושגי-פלטפורמה בחוזי-פרונט | בינוני |
|
||||||
|
|
||||||
|
**הממצא המרכזי:** שכבת-האינטליגנציה (`mcp-server/src` + skills של ההחלטה/הסגנון) כבר
|
||||||
|
נקייה כמעט-לחלוטין — 5 ההיטים ב-mcp-server הם הערות בלבד (מקור `company_id`). מחיר-הגירושין
|
||||||
|
בינוני, מרוכז בשלוש שכבות-נושקות-למעטפת.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. מפת-התיקון (R-tasks)
|
||||||
|
|
||||||
|
| R | תחום | תיאור | סיכון |
|
||||||
|
|---|------|-------|-------|
|
||||||
|
| **R0** | ספ | המסמך הזה — מגדיר את ה-Port, ה-invariant, ו-baseline-הדליפה | 0 |
|
||||||
|
| **R0b** | ספ | רישום G12 ב-[00-constitution.md](00-constitution.md) (רשימת-גלובליים + אינדקס) + שורת G12 בתבנית-ה-PR + מצביע ב-CLAUDE.md | 0 |
|
||||||
|
| **R1** | פרומפטים | כל פרוטוקול-הריצה עובר ל-HEARTBEAT.md (מקור יחיד); 10 הפרומפטים מצביעים אליו בלבד. 288→~20 היטים | נמוך |
|
||||||
|
| **R2** | web | יצירת `web/agent_platform_port.py` — המודול היחיד שמייבא `paperclip_client`/`paperclip_api`. `app.py` פולט אירוע-דומיין (`case_archived`/`created`/...) שה-Port מתרגם. `PAPERCLIP_COMPANIES`→`company_map` מאחורי ה-Port | בינוני |
|
||||||
|
| **R3** | web-ui | `types.ts` → namespace `paperclip.*` נפרד; חוזי case/api כלליים נשארים נקיים. טאבי-ניהול-Paperclip נשארים (מעטפת מוצהרת) | נמוך-בינוני |
|
||||||
|
| **R4** | אכיפה | הרחבת `spec-guard.sh` ל-leak-guard מול ה-baseline + fitness-test ב-CI על `mcp-server/src` | 0 |
|
||||||
|
|
||||||
|
**עיקרון-מנחה (G2):** R1+R2 הם G2 בלבוש חדש — מאחדים פרוטוקול/מסלול משוכפל למקור אחד.
|
||||||
|
הם אינם יוצרים מסלול מקביל; הם מסירים אחד.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. מנגנון נגד דליפה-עתידית
|
||||||
|
|
||||||
|
תיקון חד-פעמי חסר-ערך אם הדליפה תחזור בפיצ'ר הבא. שלוש שכבות-אכיפה, כולן מתחברות
|
||||||
|
למנגנונים קיימים (ולא ממציאות מסלול חדש):
|
||||||
|
|
||||||
|
1. **invariant (G12)** — מוגדר כאן, נרשם בחוקה (R0b). first-class, לא הערת-שוליים.
|
||||||
|
2. **אכיפה-אוטומטית** — `spec-guard.sh` כבר מיירט כל Edit/Write בנתיב-קוד; ה-leak-guard
|
||||||
|
(R4) משווה מול baseline §3 ומזהיר על דליפה חדשה **בזמן-אמת**, לפני ה-review.
|
||||||
|
3. **חוזה-תיעוד** — תבנית-ה-PR כבר דורשת הצהרת-invariants; נוסיף שורת-G12 לצ'קליסט
|
||||||
|
("□ לא הוספתי מגע-Paperclip מחוץ ל-Platform Port"). CLAUDE.md §Paperclip + §פרוטוקול
|
||||||
|
כתיבת-קוד מצביעים לכאן.
|
||||||
|
|
||||||
|
> **כלל-זהב לכל פיתוח עתידי:** פיצ'ר חדש שנוגע בפלטפורמה — מוסיף/משנה **רק** קוד תחת
|
||||||
|
> רשימת-ה-Port (§1). אם נדרש מגע-פלטפורמה משכבת-האינטליגנציה — זו אינדיקציה לתכנון
|
||||||
|
> שגוי: הוסיפו במקום זאת אירוע-דומיין שה-Port יתרגם.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ראו גם
|
||||||
|
- [00-constitution.md](00-constitution.md) — G2 (שאותו מיישם), G12 (לאחר R0b).
|
||||||
|
- [X7-paperclip-client-params.md](X7-paperclip-client-params.md) — פרמטרי לקוח-Paperclip (מתחת ל-Port).
|
||||||
|
- [X4-agents.md](X4-agents.md) — מפת-הסוכנים.
|
||||||
|
- [X3-integration-deploy.md](X3-integration-deploy.md) — אינטגרציה+deploy.
|
||||||
|
- [X16-pipeline-durability.md](X16-pipeline-durability.md) — עמידות-פייפליין (החלטה נפרדת, נושקת).
|
||||||
96
docs/spec/X16-pipeline-durability.md
Normal file
96
docs/spec/X16-pipeline-durability.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# X16 — עמידות-פייפליין (Durable Pipeline Execution)
|
||||||
|
|
||||||
|
> כפוף ל-[00-constitution.md](00-constitution.md). מחזק את **INV-G3** (idempotency)
|
||||||
|
> ב-checkpointing+replay לפייפליינים הדטרמיניסטיים המקומיים. נושק ל-[07-learning.md](07-learning.md)
|
||||||
|
> ו-[X11-citation-corroboration.md](X11-citation-corroboration.md).
|
||||||
|
|
||||||
|
## 0. הבעיה
|
||||||
|
|
||||||
|
שני הפייפליינים המקומיים החד-פעמיים —
|
||||||
|
[final_halacha_pipeline.py](../../scripts/final_halacha_pipeline.py) (כפתור run-halacha,
|
||||||
|
אימות-הלכות, X11) ו-[final_learning_pipeline.py](../../scripts/final_learning_pipeline.py)
|
||||||
|
(כפתור run-learning, למידת-סגנון, 07-learning) — חולקים **צורה זהה**: סקריפט מקומי,
|
||||||
|
3–4 שלבים בטור, idempotent, פאנל-LLM ארוך בסוף (CSV-gated, "can take minutes").
|
||||||
|
|
||||||
|
היום הם **ליניאריים וחסרי-זיכרון**: קריסה באמצע (ניתוק ל-DeepSeek/Gemini, restart של
|
||||||
|
קונטיינר, OOM) → הרצה-מחדש מ-שלב 0. השלבים idempotent ולכן זה **בטוח**, אבל **משלמים שוב**:
|
||||||
|
מחלצים, בונים corroboration על כל הקורפוס, ושופטים מחדש הלכות שכבר נשפטו — דקות וקריאות-LLM
|
||||||
|
לפח.
|
||||||
|
|
||||||
|
**הקשר-סיכון אמיתי:** דליפת task-master (יתומים ppid=1, ~3GB) מסכנת OOM ל-Postgres
|
||||||
|
([project_taskmaster_mcp_memory_leak]). אם OOM הורג ריצת-פאנל ארוכה — היום מתחילים מאפס.
|
||||||
|
|
||||||
|
**הבחנה מ-idempotency:** idempotency = "בטוח להריץ שוב". durable execution = "בטוח להריץ
|
||||||
|
שוב **בלי לשלם שוב**". זה שכלול, לא תחליף.
|
||||||
|
|
||||||
|
## 1. ההכרעה
|
||||||
|
|
||||||
|
להטמיע **LangGraph כספרייה בתוך הסקריפט** (לא כפלטפורמה מחליפה ל-Paperclip): מנוע-העמידות
|
||||||
|
היחיד שהוא state-of-the-art ב-checkpointing+replay+time-travel, בשימוש כ-`import` בתוך
|
||||||
|
הסקריפט המקומי. Paperclip לא מושפע — הכפתור עדיין מעיר את Hermes שמריץ את אותו ה-CLI.
|
||||||
|
|
||||||
|
> **גבול-תחום מפורש (מתחבר ל-G12/X15):** LangGraph נכנס **רק** כמנוע-פנימי של הסקריפטים
|
||||||
|
> המקומיים. אסור להשתמש בו כתחליף-פלטפורמה או כ-orchestrator של הסוכנים — זה ייצור מסלול
|
||||||
|
> מקביל ל-Paperclip (הפרת G2) ויערבב עמידות עם פלטפורמה. HITL/ניתוב-יו"ר נשאר מאחורי
|
||||||
|
> ה-Port (ראו §4 Phase 3).
|
||||||
|
|
||||||
|
**מקורות:** Temporal — *Durable Execution* · Saga / workflow-checkpointing pattern ·
|
||||||
|
Martin Kleppmann, *DDIA* (idempotence & exactly-once) · LangGraph checkpointer/replay docs.
|
||||||
|
|
||||||
|
## 2. ה-invariant
|
||||||
|
|
||||||
|
### INV-DUR1 — עמידות לפייפליינים דטרמיניסטיים
|
||||||
|
**כלל:** פייפליין דטרמיניסטי רב-שלבי משמר את התקדמותו ב-checkpoint מתמיד אחרי כל שלב
|
||||||
|
שהושלם; הרצה-חוזרת של אותה יחידת-עבודה **מדלגת** על שלבים שכבר הושלמו ומתחילה מנקודת-הכשל
|
||||||
|
המדויקת. מימוש-העמידות הוא **משותף** לכל הפייפליינים (`scripts/_pipeline_runtime.py`) —
|
||||||
|
לא מימוש-לכל-סקריפט (G2). חוזה-הכניסה (ה-CLI) נשמר ללא-שינוי.
|
||||||
|
**מקורות:** Temporal (Durable Execution) · Kleppmann *DDIA* (exactly-once) · Saga pattern
|
||||||
|
(workflow checkpointing) | סטטוס: verified
|
||||||
|
**אכיפה:** `_pipeline_runtime.py` עם LangGraph + checkpointer; thread_id דטרמיניסטי
|
||||||
|
לכל יחידת-עבודה (תיק); בדיקת kill-and-resume שמאמתת ששלבים שהושלמו אינם רצים-מחדש.
|
||||||
|
**הפרה ידועה:** היום `final_halacha_pipeline.py` / `final_learning_pipeline.py` ליניאריים
|
||||||
|
— קריסה = הרצה-מחדש מלאה (חוזרים על extract/corroboration/panel).
|
||||||
|
|
||||||
|
## 3. ארכיטקטורה
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/_pipeline_runtime.py ← מודול-עמידות משותף יחיד (G2)
|
||||||
|
• build_graph(steps) StateGraph: node לכל שלב
|
||||||
|
• SqliteSaver data/checkpoints/<pipeline>.sqlite (לא Postgres המשותף)
|
||||||
|
• run(thread_id, resume) מדלג-אוטומטית על nodes ב-checkpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
**הכרעות-תכנון:**
|
||||||
|
|
||||||
|
1. **Checkpointer = SQLite (`langgraph-checkpoint-sqlite`), לא Postgres.** קובץ תחת
|
||||||
|
`data/checkpoints/`: מקומי (תואם "local-only"), פשוט, ו**נמנע מהאזהרה** ב-CLAUDE.md נגד
|
||||||
|
migrations מ-2 worktrees על Postgres המשותף (`localhost:5433`). PostgresSaver = אופציה
|
||||||
|
עתידית אם נדרש ריכוז/observability.
|
||||||
|
2. **`thread_id = f"<pipeline>:{case_number}"`.** הרצה-חוזרת של אותו תיק מזהה checkpoint
|
||||||
|
לא-גמור וממשיכה אוטומטית; תיק שהושלם = no-op. idempotency + דילוג-checkpoint מתחברים.
|
||||||
|
3. **גרעיניות (מדורגת):**
|
||||||
|
- **גס (P0/P1):** כל שלב = node. קריסה בין-שלבים → המשך מהשלב שנפל. הפאנל node יחיד
|
||||||
|
שרץ-מחדש — אך הוא כבר CSV-backed + idempotent (מדלג פנימית על מה שנשפט).
|
||||||
|
- **עדין (P2, אופציונלי):** פירוק הפאנל ל-map מעל ההלכות/הלקחים (LangGraph `Send`),
|
||||||
|
כל פריט = יחידת-checkpoint → resume תוך-פאנל בלי לשפוט מחדש ברמת-LLM. נשען על ה-CSV
|
||||||
|
הקיים כמקור "כבר-נשפט".
|
||||||
|
4. **סמנטיקת-כשל מפורשת.** היום הכל "non-fatal, continue". עם LangGraph: nodes "מייעצים"
|
||||||
|
(extract, corroboration) — catch+record-status וממשיכים; node "קריטי" (panel) — raise
|
||||||
|
בכשל-קשה → עצירה ב-checkpoint → resume.
|
||||||
|
5. **שימור-חוזה-הכניסה.** ה-CLI (`--case`/`--limit`/`--dry-run`) זהה; run-halacha/run-learning
|
||||||
|
→ Hermes → אותו `python ...pipeline.py --case X` לא משתנה. מוסיפים `--fresh`
|
||||||
|
(ברירת-מחדל: auto-resume אם יש checkpoint לא-גמור לתיק).
|
||||||
|
|
||||||
|
## 4. גלגול מדורג
|
||||||
|
|
||||||
|
| Phase | תחום | מאמץ |
|
||||||
|
|-------|------|------|
|
||||||
|
| **P0** | deps ל-`mcp-server/pyproject` (`langgraph` + `langgraph-checkpoint-sqlite`, venv מקומי בלבד → אפס השפעת-קונטיינר). `_pipeline_runtime.py` עם SqliteSaver. עטיפת 4 שלבי-halacha כ-nodes (גס). CLI זהה. test: kill אחרי [1] → resume → assert [0],[1] לא רצו שוב | ~1 יום |
|
||||||
|
| **P1** | אותו runtime על `final_learning_pipeline` (3 שלבים) — מימוש-עמידות אחד לשניהם (G2) | חצי יום |
|
||||||
|
| **P2** | (אופציונלי) פירוק-פאנל ל-map per-item — resume תוך-פאנל | 1–2 ימים |
|
||||||
|
| **P3** | (עתידי) LangGraph `interrupt()` ל-HITL של היו"ר (split→chair, INV-G10) — **רק מאחורי ה-Port** (X15/G12) | — |
|
||||||
|
|
||||||
|
## 5. ראו גם
|
||||||
|
- [07-learning.md](07-learning.md) · [X11-citation-corroboration.md](X11-citation-corroboration.md)
|
||||||
|
- [X15-agent-platform-port.md](X15-agent-platform-port.md) — הגבול מול הפלטפורמה (G12).
|
||||||
|
- [scripts/SCRIPTS.md](../../scripts/SCRIPTS.md) — הסקריפטים המושפעים.
|
||||||
@@ -80,6 +80,9 @@ PUT /api/cases/{n} → [BackgroundTask] emit_case_status_webhook()
|
|||||||
([paperclip_api.py:120-165](../../web/paperclip_api.py)) ו-`emit_export_complete_webhook`
|
([paperclip_api.py:120-165](../../web/paperclip_api.py)) ו-`emit_export_complete_webhook`
|
||||||
([paperclip_api.py:168+](../../web/paperclip_api.py)).
|
([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 ישיר
|
### 1ד. כל קריאת-API דרך helper — לא curl/httpx ישיר
|
||||||
|
|
||||||
קריאות ל-Paperclip עוברות תמיד דרך helper, לא דרך לקוח גולמי:
|
קריאות ל-Paperclip עוברות תמיד דרך helper, לא דרך לקוח גולמי:
|
||||||
@@ -97,6 +100,9 @@ PUT /api/cases/{n} → [BackgroundTask] emit_case_status_webhook()
|
|||||||
|
|
||||||
## 2. מודל ה-Deploy — שני מודלים דו-קיימים
|
## 2. מודל ה-Deploy — שני מודלים דו-קיימים
|
||||||
|
|
||||||
|
> **קונפיגורציה, env וסודות** — ה-deep-dive המלא (catalog ה-env, מקור-config, secrets, hardcode,
|
||||||
|
> drift) ב-[X10-deploy-env-secrets.md](X10-deploy-env-secrets.md). כאן נשאר רק מודל-ההרצה.
|
||||||
|
|
||||||
על שרת Nautilus דרים **שני מודלי-הרצה**. ערבוב ביניהם הוא הטעות הנפוצה ביותר
|
על שרת Nautilus דרים **שני מודלי-הרצה**. ערבוב ביניהם הוא הטעות הנפוצה ביותר
|
||||||
([root CLAUDE.md](../../../CLAUDE.md) "Deploy architecture"; [legal-ai/CLAUDE.md](../../CLAUDE.md)
|
([root CLAUDE.md](../../../CLAUDE.md) "Deploy architecture"; [legal-ai/CLAUDE.md](../../CLAUDE.md)
|
||||||
"ארכיטקטורת Deploy").
|
"ארכיטקטורת Deploy").
|
||||||
@@ -210,3 +216,5 @@ audit-trail עקבי).
|
|||||||
- [.claude/agents/HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) — §0 (pc.sh), §4ג–§4ד (wake CEO + payload).
|
- [.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`.
|
- [web/paperclip_api.py](../../web/paperclip_api.py) — `pc_request`, `emit_case_status_webhook`.
|
||||||
- [scripts/pc.sh](../../scripts/pc.sh) — helper ה-bash.
|
- [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).
|
||||||
|
|||||||
@@ -60,6 +60,25 @@ Paperclip בקונפליקט (project-specific מנצח default), אך אינו
|
|||||||
- **company_id פר-סוכן.** כל שורה בטבלה מיוצגת פעמיים (CMP + CMPA); ה-CEO לכל חברה שונה
|
- **company_id פר-סוכן.** כל שורה בטבלה מיוצגת פעמיים (CMP + CMPA); ה-CEO לכל חברה שונה
|
||||||
([X2 §1](X2-multi-company.md)). הסוכן פועל רק בטווח-החברה שלו ([X2 §2](X2-multi-company.md)).
|
([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)
|
## 3. סוכני-התהליך (תת-פרויקט 5) — סעיף שמור (RESERVED)
|
||||||
@@ -95,8 +114,10 @@ Paperclip בקונפליקט (project-specific מנצח default), אך אינו
|
|||||||
קבצי-הסוכן תחת [.claude/agents/](../../.claude/agents/) (frontmatter + instructions) +
|
קבצי-הסוכן תחת [.claude/agents/](../../.claude/agents/) (frontmatter + instructions) +
|
||||||
[00-constitution.md §7](00-constitution.md#7-אינדקס-הספ) (אינדקס הספ — איזה קובץ אוכף איזה invariant).
|
[00-constitution.md §7](00-constitution.md#7-אינדקס-הספ) (אינדקס הספ — איזה קובץ אוכף איזה invariant).
|
||||||
(invariant פרויקטלי-תפעולי — ללא פרוטוקול ≥3-המקורות; משרת את העיקרון הגלובלי G10.)
|
(invariant פרויקטלי-תפעולי — ללא פרוטוקול ≥3-המקורות; משרת את העיקרון הגלובלי G10.)
|
||||||
**אכיפה:** נוהל — ה-checklist ב-HEARTBEAT + הפניות-הספ בקבצי-הסוכן. **אין אכיפה אוטומטית**
|
**אכיפה:** נוהל — **מחוּוט** (FU-8b, 2026-05-31): סעיף "קריאת-ספ — קודם החוקה (00), אז ספ-התחום"
|
||||||
שתכריח קריאת-ספ לפני פעולה (ראה §5 — זה היעד).
|
ב-[HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) (כולל טבלת תפקיד→ספ) + סעיף "קרא לפני פעולה (INV-AG1)"
|
||||||
|
בכל אחד מ-8 קבצי-הסוכן. אכיפה **פרוצדורלית** (נוהל לפני עבודה), לא אוטומטית: אין שער-קוד שמכריח
|
||||||
|
את הקריאה — זה גלום בטבע ה-invariant (פרויקטלי-תפעולי, מבוצע ע"י הסוכן). ראה §5.
|
||||||
**הפרה ידועה:** —
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
### INV-AG2: סוכן דומייני פועל רק בתחום-החברה שלו
|
### INV-AG2: סוכן דומייני פועל רק בתחום-החברה שלו
|
||||||
@@ -111,18 +132,29 @@ CMPA→8xxx/9xxx). אסור ליצור פרויקט/issue/תוכן לתיק מח
|
|||||||
another company`, [X2 §2](X2-multi-company.md)).
|
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. מצב קיים מול יעד — חיווט הספ לסוכנים
|
## 5. חיווט הספ לסוכנים — בוצע (FU-8b)
|
||||||
|
|
||||||
ספ-המערכת (קבצי 00–07, X1–X5) הוא **חדש** — קבצי-הסוכן וה-HEARTBEAT עדיין **אינם מפנים אליו**
|
עד FU-8b קבצי-הסוכן וה-HEARTBEAT **לא הפנו** לספ-המערכת במפורש; הם הפנו ל-CLAUDE.md, למסמכי-`docs/`
|
||||||
במפורש; הם מפנים ל-CLAUDE.md, למסמכי-`docs/` הישנים, ול-skills. זהו פער אמיתי:
|
הישנים, ול-skills. **בוצע ב-2026-05-31 (FU-8b / GAP-23):**
|
||||||
|
|
||||||
- **קיים:** HEARTBEAT אוכף checklist הפעלה (סינון-חברה, comments, pc.sh) אך **לא** מחייב קריאת
|
- **HEARTBEAT.md:** נוסף סעיף עליון "קריאת-ספ — קודם החוקה (00), אז ספ-התחום — לפני פעולה מהותית
|
||||||
`00-constitution.md` או ספ-התחום.
|
(INV-AG1)", **לפני** §0–§8 התפעוליים, ובו טבלת תפקיד→ספ (זהה לסעיף 2 כאן). זה ממקם את קריאת-החוקה
|
||||||
- **יעד:** לחווט את HEARTBEAT וקבצי-הסוכן כך שיחייבו במפורש את INV-AG1 — קריאת החוקה + ספ-התחום
|
קודם ל-checklist ההפעלה ("קודם החוקה (00) + ספ-התחום, אז ה-HEARTBEAT התפעולי").
|
||||||
הרלוונטי (לפי הטבלה בסעיף 2) לפני עבודה מהותית. זהו תנאי-מוקדם לסוכני-התהליך (סעיף 3), שכל
|
- **8 קבצי-הסוכן:** כל אחד קיבל סעיף "קרא לפני פעולה (INV-AG1)" בראש גוף-הקובץ — קריאת
|
||||||
עבודתם היא "לקרוא את הספ ולעשות שיעורי-בית".
|
`00-constitution.md` תחילה, ואז ספ-התחום הרלוונטי לתפקידו (לפי הטבלה בסעיף 2).
|
||||||
|
- **אופי האכיפה:** פרוצדורלית (נוהל), לא שער-קוד — ראה INV-AG1 "אכיפה".
|
||||||
|
|
||||||
|
זהו תנאי-מוקדם לסוכני-התהליך (סעיף 3), שכל עבודתם היא "לקרוא את הספ ולעשות שיעורי-בית".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -138,3 +170,5 @@ another company`, [X2 §2](X2-multi-company.md)).
|
|||||||
[05-qa-review.md](05-qa-review.md), [06-export.md](06-export.md), [07-learning.md](07-learning.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/HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) + קבצי-הסוכן תחת
|
||||||
[.claude/agents/](../../.claude/agents/) — frontmatter (תפקיד) + instructions (סינון-חברה, זרימה).
|
[.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.
|
||||||
|
|||||||
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).
|
||||||
120
docs/spec/X8-field-provenance.md
Normal file
120
docs/spec/X8-field-provenance.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# 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: חילוץ אסינכרוני, מתור, צד-מארח (לא מהקונטיינר)
|
||||||
|
**כלל:** חילוץ-LLM (מטא, הלכות) רץ **אסינכרוני, מתור, מצד-המארח** — לא חוסם את ה-web ולא קורא ל-LLM
|
||||||
|
מהקונטיינר. **בחירת-מנוע לפי אופי-המשימה (לא מסלול מקביל):** חילוץ-מטא הוא משימה *תחומה* (טקסט→JSON)
|
||||||
|
ולכן רץ על **Gemini Flash** (`gemini_session`, structured JSON) — ה-claude CLI ה-agentic פגע ב-
|
||||||
|
`error_max_turns`; חילוץ-הלכות (רגיש-קול/agentic) נשאר על **`claude_session`** (CLI מקומי, מנוי דפנה).
|
||||||
|
שני המנועים מתנקזים לתור-החילוץ הקנוני היחיד ([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)). **פרויקטלי-תפעולי.**
|
||||||
|
**מקור-סמכות:** [ingest.py](../../mcp-server/src/legal_mcp/services/ingest.py) (queue → `process_pending_extractions`); [gemini_session.py](../../mcp-server/src/legal_mcp/services/gemini_session.py) (מטא); [legal-ai/CLAUDE.md](../../CLAUDE.md) (claude_session local-only להלכות). `GEMINI_API_KEY` בצד-המארח בלבד — לא בקונטיינר (תואם `feedback_claude_session_local_only`: אין קריאות-LLM מהקונטיינר).
|
||||||
|
**אכיפה:** queue + `precedent_process_pending` + drainers מתוזמנים (`legal-metadata-drain`/CEO); קריאות-LLM רק מצד-המארח.
|
||||||
|
**הפרה ידועה:** תור-החילוץ **סמוי** (אין הבחנה pending-initial מול pending-review; אין extraction-job table) ([gap-audit GAP-45](gap-audit.md); [X9](X9-mcp-tool-contract.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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/).
|
||||||
@@ -17,6 +17,10 @@
|
|||||||
|
|
||||||
## 23 הממצאים
|
## 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) | תיקון מוצע |
|
| ID | כותרת | invariant מופר | severity | קבצים מושפעים (file:line) | תיקון מוצע |
|
||||||
|----|-------|----------------|----------|---------------------------|------------|
|
|----|-------|----------------|----------|---------------------------|------------|
|
||||||
| GAP-01 | שני מסלולי ingest מקבילים שמתפצלים | INV-ING1, G2 | High | `precedent_library.py:88`, `internal_decisions.py:73` | מסלול-קליטה קנוני יחיד; ישויות-אחיות חולקות פייפליין |
|
| GAP-01 | שני מסלולי ingest מקבילים שמתפצלים | INV-ING1, G2 | High | `precedent_library.py:88`, `internal_decisions.py:73` | מסלול-קליטה קנוני יחיד; ישויות-אחיות חולקות פייפליין |
|
||||||
@@ -45,12 +49,66 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ממצאי מחזור-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)
|
## יחידות-תיקון מוצעות (Proposed Fix-Units)
|
||||||
|
|
||||||
23 הממצאים מקובצים ל-8 יחידות-עבודה קוהרנטיות. הקיבוץ נגזר מהעיקרון שרבים מהממצאים
|
23 הממצאים מקובצים ל-8 יחידות-עבודה קוהרנטיות. הקיבוץ נגזר מהעיקרון שרבים מהממצאים
|
||||||
נפתרים יחד (כל פערי ה-ingest-asymmetry → יחידה אחת). זהו זרע למשימות TaskMaster
|
נפתרים יחד (כל פערי ה-ingest-asymmetry → יחידה אחת). זהו זרע למשימות TaskMaster
|
||||||
ולתת-פרויקט 3 (שכבת-שלמות).
|
ולתת-פרויקט 3 (שכבת-שלמות).
|
||||||
|
|
||||||
|
> **✅ מחזור-1 הושלם (31.5.2026):** FU-1..FU-8b כולם מוזגו ל-main. מחזור-2 (FU-9..15, להלן)
|
||||||
|
> נגזר מ-GAP-24..62 ו**פתוח**.
|
||||||
|
|
||||||
### FU-1 — איחוד מסלול-הקליטה (Unify ingest path)
|
### FU-1 — איחוד מסלול-הקליטה (Unify ingest path)
|
||||||
- **מכסה:** GAP-01, GAP-02, GAP-04, GAP-05
|
- **מכסה:** GAP-01, GAP-02, GAP-04, GAP-05
|
||||||
- **מספק invariants:** INV-ING1, INV-ING3, INV-G2, INV-G4; (תורם ל-DM1/RET2 דרך GAP-02)
|
- **מספק invariants:** INV-ING1, INV-ING3, INV-G2, INV-G4; (תורם ל-DM1/RET2 דרך GAP-02)
|
||||||
@@ -110,6 +168,51 @@
|
|||||||
- **תלויות:** ה-spec גמור (GAP-23 דורש קבצי-ספ יציבים לחבר לסוכנים)
|
- **תלויות:** ה-spec גמור (GAP-23 דורש קבצי-ספ יציבים לחבר לסוכנים)
|
||||||
- **סוג:** pure-code + **chair-decision** — GAP-23 (חיבור ספ לסוכני-Paperclip) הוא
|
- **סוג:** pure-code + **chair-decision** — GAP-23 (חיבור ספ לסוכני-Paperclip) הוא
|
||||||
prerequisite לתת-פרויקט 5 ומשנה התנהגות-סוכן בייצור
|
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 המלא.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,4 +225,10 @@
|
|||||||
GAP-07 כבר chair-confirmed (canonical = הצורה הרשמית שהוקצתה).
|
GAP-07 כבר chair-confirmed (canonical = הצורה הרשמית שהוקצתה).
|
||||||
|
|
||||||
**רצף מומלץ (תלויות):** FU-1 → FU-2 → FU-3; FU-4 ו-FU-6 במקביל (עצמאיים, Critical);
|
**רצף מומלץ (תלויות):** FU-1 → FU-2 → FU-3; FU-4 ו-FU-6 במקביל (עצמאיים, Critical);
|
||||||
FU-7 אחרי FU-1; FU-5 אחרי FU-2; FU-8 אחרי ייצוב-הספ.
|
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.
|
||||||
@@ -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.
|
||||||
@@ -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/סוכנים — מחוץ-להיקף (משנה התנהגות-ייצור, דורש החלטה).
|
||||||
@@ -21,6 +21,29 @@ dependencies = [
|
|||||||
"uvicorn[standard]>=0.30.0",
|
"uvicorn[standard]>=0.30.0",
|
||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
"infisicalsdk>=1.0.0",
|
"infisicalsdk>=1.0.0",
|
||||||
|
"aioboto3>=13.0.0", # X14 object storage (MinIO/S3) — services/storage.py
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
# Tier-1 court-verdict fetch (X13) — host-only. The container can't run a
|
||||||
|
# browser, so these are NOT in the base deps; install on the host venv with
|
||||||
|
# `pip install -e ".[court-fetch]" && python -m camoufox fetch`. faster-whisper
|
||||||
|
# is only for the explicit-PDF-download reCAPTCHA fallback (the primary
|
||||||
|
# image-API path needs no solving).
|
||||||
|
court-fetch = [
|
||||||
|
"camoufox>=0.4.11",
|
||||||
|
"faster-whisper>=1.0.0",
|
||||||
|
"h2>=4.0.0", # Tier-0 supremedecisions uses httpx http2
|
||||||
|
]
|
||||||
|
# Durable execution for the local one-shot pipelines (X16 / INV-DUR1) —
|
||||||
|
# final_halacha_pipeline / final_learning_pipeline gain crash/OOM resume via
|
||||||
|
# scripts/_pipeline_runtime.py. HOST-ONLY (the pipelines run locally, not in the
|
||||||
|
# container): install on the host venv with `pip install -e ".[durable]"`. The
|
||||||
|
# runtime degrades gracefully to linear execution when these are absent, so the
|
||||||
|
# run-halacha / run-learning buttons keep working until then.
|
||||||
|
durable = [
|
||||||
|
"langgraph>=1.0,<2.0",
|
||||||
|
"langgraph-checkpoint-sqlite>=3.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -42,6 +42,42 @@ 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")
|
||||||
|
# Digest (X12) metadata extraction is a simpler, high-volume task (concept tag,
|
||||||
|
# headline, underlying citation, tags from a one-page summary) — Sonnet is the
|
||||||
|
# speed/cost sweet-spot here, unlike halacha extraction which pins Opus. Tune via env.
|
||||||
|
DIGEST_EXTRACT_MODEL = os.environ.get("DIGEST_EXTRACT_MODEL", "claude-sonnet-4-6")
|
||||||
|
# Effort for BULK queue-drain extraction (process_pending over many precedents).
|
||||||
|
# xhigh is the quality sweet-spot for a single precedent but very slow at scale
|
||||||
|
# (a 64-chunk case ≈ 20 min). Bulk drains use a lighter effort to cut wall-clock;
|
||||||
|
# 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 +148,49 @@ 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 review-queue clustering (#84.2) — when the review queue is requested
|
||||||
|
# with cluster=true, halachot of the SAME precedent whose rule-embeddings are
|
||||||
|
# within this cosine are grouped into ONE review card (canonical + variants), so
|
||||||
|
# the chair judges near-identical principles once instead of repeatedly. Display
|
||||||
|
# only — never merges/deletes. 0.90 = "same principle, reworded".
|
||||||
|
HALACHA_CLUSTER_COSINE = float(os.environ.get("HALACHA_CLUSTER_COSINE", "0.90"))
|
||||||
|
|
||||||
|
# Halacha NLI entailment validator (#81.3) — after extraction, a claude_session
|
||||||
|
# judge checks each halacha's rule_statement is entailed by its supporting_quote.
|
||||||
|
# Non-entailed (neutral/contradiction) → quality flag 'nli_unsupported' that
|
||||||
|
# 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", "")
|
||||||
|
|
||||||
@@ -123,6 +202,32 @@ EXPORTS_DIR = DATA_DIR / "exports" # legacy exports only
|
|||||||
# Cases directory — flat structure: data/cases/{case_number}/
|
# Cases directory — flat structure: data/cases/{case_number}/
|
||||||
CASES_DIR = DATA_DIR / "cases"
|
CASES_DIR = DATA_DIR / "cases"
|
||||||
|
|
||||||
|
# ── Object storage (X14 / MinIO) ───────────────────────────────────
|
||||||
|
# Single storage layer (services/storage.py) replaces the scattered file
|
||||||
|
# I/O across ~8 services (INV-STG1 / G2). Backend selector:
|
||||||
|
# "filesystem" (default) — disk under DATA_DIR; current behaviour, no change.
|
||||||
|
# "dual" — write disk + S3, read S3→disk fallback (migration).
|
||||||
|
# "s3" — MinIO only.
|
||||||
|
# See docs/spec/X14-storage-minio.md.
|
||||||
|
STORAGE_BACKEND = os.environ.get("STORAGE_BACKEND", "filesystem").strip().lower()
|
||||||
|
# Endpoint reached server-side (internal Docker network: http://minio:9000).
|
||||||
|
MINIO_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "http://minio:9000")
|
||||||
|
# Public endpoint used when MINTING presigned URLs for the browser (INV-STG6) —
|
||||||
|
# the browser cannot resolve the internal hostname. Falls back to the internal
|
||||||
|
# endpoint when unset (e.g. local dev).
|
||||||
|
MINIO_PUBLIC_ENDPOINT = os.environ.get("MINIO_PUBLIC_ENDPOINT", MINIO_ENDPOINT)
|
||||||
|
MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "")
|
||||||
|
MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "")
|
||||||
|
MINIO_REGION = os.environ.get("MINIO_REGION", "us-east-1")
|
||||||
|
# Logical bucket → name. Governance boundaries (INV-STG3): documents
|
||||||
|
# (versioned), immutable (versioned + Object-Lock COMPLIANCE for final
|
||||||
|
# decisions, INV-STG4), derived (thumbnails/extracted text — regenerable).
|
||||||
|
MINIO_BUCKET_DOCUMENTS = os.environ.get("MINIO_BUCKET_DOCUMENTS", "legal-documents")
|
||||||
|
MINIO_BUCKET_IMMUTABLE = os.environ.get("MINIO_BUCKET_IMMUTABLE", "legal-immutable")
|
||||||
|
MINIO_BUCKET_DERIVED = os.environ.get("MINIO_BUCKET_DERIVED", "legal-derived")
|
||||||
|
# Default presigned-URL TTL (seconds). SigV4 hard max is 7 days; keep short.
|
||||||
|
MINIO_PRESIGN_TTL = int(os.environ.get("MINIO_PRESIGN_TTL", "900"))
|
||||||
|
|
||||||
|
|
||||||
def find_case_dir(case_number: str) -> Path:
|
def find_case_dir(case_number: str) -> Path:
|
||||||
"""Return the case directory for a given case number."""
|
"""Return the case directory for a given case number."""
|
||||||
|
|||||||
7
mcp-server/src/legal_mcp/court_fetch_service/__init__.py
Normal file
7
mcp-server/src/legal_mcp/court_fetch_service/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""Host-side Tier-1 verdict fetch service (X13).
|
||||||
|
|
||||||
|
Runs on the host under pm2 (it needs a real browser, which the legal-ai
|
||||||
|
container can't run). Drives a Camoufox stealth browser against נט המשפט to
|
||||||
|
download administrative/district-court verdicts the Supreme portal (Tier 0)
|
||||||
|
doesn't carry. See docs/spec/X13-court-fetch.md.
|
||||||
|
"""
|
||||||
314
mcp-server/src/legal_mcp/court_fetch_service/camofox_client.py
Normal file
314
mcp-server/src/legal_mcp/court_fetch_service/camofox_client.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
"""Camoufox driver for נט המשפט — calibrated, proven flow (X13, Tier 1).
|
||||||
|
|
||||||
|
Open-source, zero-API-cost: drives a **Camoufox** stealth browser (a Firefox
|
||||||
|
fork with C++ fingerprint spoofing) via its official Python package
|
||||||
|
(``camoufox.async_api``) — in-process, no separate Node server. The full flow
|
||||||
|
was reverse-engineered and validated end-to-end against עת"מ 46111-12-22
|
||||||
|
(2026-06-07): a 34-page verdict PDF retrieved with **no smart-card and no
|
||||||
|
CAPTCHA-solving**.
|
||||||
|
|
||||||
|
The proven path:
|
||||||
|
1. homepage → DOM-click ``btnExternalSearchCases`` ("תיקים לפי מס' תיק מקור").
|
||||||
|
2. Fill the visible header case-locator: ``BamaCaseNumberTextBoxH`` = case
|
||||||
|
number, ``BamaMonthYearTextBoxHT`` = "MM-YY"; click ``SearchHeaderCaseButton``.
|
||||||
|
→ lands on ``FolderCaseDetails/CaseDetails.aspx`` for the case.
|
||||||
|
3. Click the "פסקי דין" sidebar tab → ``Decisions/DecisionList.aspx``.
|
||||||
|
4. Click the document → popup ``Viewer/NGCSViewerPage.aspx?DocumentNumber=…``.
|
||||||
|
5. The viewer renders pages as PNG images via the ``GetImages`` PageMethod —
|
||||||
|
**served without reCAPTCHA** (the reCAPTCHA on the viewer only gates the
|
||||||
|
explicit save/print, which we don't use). Capture the internal
|
||||||
|
``documentNumber`` from the viewer's first ``GetImages`` call, then pull
|
||||||
|
every 4-page batch via ``fetch`` **with header ``X-Requested-With:
|
||||||
|
XMLHttpRequest``** (required — the F5 WAF blocks AJAX calls without it).
|
||||||
|
6. Decode the base64 PNGs → assemble a PDF (Pillow). The existing ingest
|
||||||
|
pipeline OCRs it (Google Vision) → text → corpus.
|
||||||
|
|
||||||
|
Operational requirements (see scripts/legal-court-fetch-service.config.cjs):
|
||||||
|
* a virtual display — Camoufox/Firefox crashes headless on this server
|
||||||
|
without one. Set ``DISPLAY`` to a running Xvfb (e.g. ``:99``).
|
||||||
|
* RAM — a Firefox content process loading the heavy ASP.NET pages needs
|
||||||
|
~0.5–1 GB; keep the box from swapping.
|
||||||
|
|
||||||
|
reCAPTCHA note: ``recaptcha_audio`` (local Whisper) remains as a fallback for
|
||||||
|
the explicit-PDF-download path, but the primary image-API path needs no
|
||||||
|
solving, so it is normally unused.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NGCS_HOME = "https://www.court.gov.il/ngcs.web.site/homepage.aspx"
|
||||||
|
|
||||||
|
# Headless Camoufox needs a virtual display on this server.
|
||||||
|
_DISPLAY = os.environ.get("DISPLAY", "")
|
||||||
|
_NAV_TIMEOUT_MS = int(float(os.environ.get("COURT_FETCH_BROWSER_TIMEOUT_S", "60")) * 1000)
|
||||||
|
_PAGE_BATCH = 4 # the viewer's GetImages batch size
|
||||||
|
_MAX_PAGES = 400 # hard cap on a single document
|
||||||
|
# Hard wall-clock cap on a single fetch so a hung browser can't pin a Firefox
|
||||||
|
# process forever (anti-leak; INV-CF4 politeness). The async-with cleanup runs
|
||||||
|
# on the resulting CancelledError, tearing the browser down.
|
||||||
|
_FETCH_HARD_TIMEOUT_S = float(os.environ.get("COURT_FETCH_HARD_TIMEOUT_S", "180"))
|
||||||
|
|
||||||
|
|
||||||
|
def _reap_orphan_browsers() -> int:
|
||||||
|
"""Kill any ``camoufox-bin`` orphaned to ``ppid=1`` before we launch.
|
||||||
|
|
||||||
|
Fetching is serial (INV-CF4), so any browser not owned by a live parent is
|
||||||
|
a leftover from a prior crashed/killed fetch. Pure /proc, best-effort —
|
||||||
|
never raises into the fetch path.
|
||||||
|
"""
|
||||||
|
killed = 0
|
||||||
|
try:
|
||||||
|
for pid in os.listdir("/proc"):
|
||||||
|
if not pid.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/status", "rb") as f:
|
||||||
|
status = f.read().decode("utf-8", "replace")
|
||||||
|
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||||
|
cmd = f.read().decode("utf-8", "replace")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if "camoufox-bin" not in cmd:
|
||||||
|
continue
|
||||||
|
ppid = 0
|
||||||
|
for line in status.splitlines():
|
||||||
|
if line.startswith("PPid:"):
|
||||||
|
try: ppid = int(line.split()[1])
|
||||||
|
except (IndexError, ValueError): pass
|
||||||
|
break
|
||||||
|
if ppid == 1:
|
||||||
|
try:
|
||||||
|
os.kill(int(pid), 9)
|
||||||
|
killed += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if killed:
|
||||||
|
logger.warning("reaped %d orphaned camoufox-bin before fetch", killed)
|
||||||
|
return killed
|
||||||
|
|
||||||
|
|
||||||
|
class CamofoxUnavailable(RuntimeError):
|
||||||
|
"""Camoufox (or its virtual display) isn't available."""
|
||||||
|
|
||||||
|
|
||||||
|
class NgcsFlowError(RuntimeError):
|
||||||
|
"""A step in the נט-המשפט flow failed (navigation / not found / blocked)."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_enabled() -> bool:
|
||||||
|
"""True if the Camoufox package imports (browser binary present)."""
|
||||||
|
try:
|
||||||
|
import camoufox.async_api # noqa: F401
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def health() -> dict:
|
||||||
|
return {"camoufox_import": is_enabled(), "display": _DISPLAY or "(none)"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _fill_visible(page, id_substr: str, value: str) -> bool:
|
||||||
|
for el in await page.locator(f"input[id*='{id_substr}']").all():
|
||||||
|
try:
|
||||||
|
if await el.is_visible() and await el.is_editable():
|
||||||
|
await el.fill(value)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _reach_viewer(page, *, case_number: str, month_year: str):
|
||||||
|
"""Drive home → search → case → פסקי דין → viewer popup. Returns the popup page."""
|
||||||
|
await page.goto(NGCS_HOME, wait_until="domcontentloaded", timeout=_NAV_TIMEOUT_MS)
|
||||||
|
await page.wait_for_timeout(2500)
|
||||||
|
await page.eval_on_selector(
|
||||||
|
"#Header1_UpperMenu1_btnExternalSearchCases", "el => el.click()"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await page.wait_for_load_state("domcontentloaded", timeout=_NAV_TIMEOUT_MS)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await page.wait_for_timeout(4500)
|
||||||
|
|
||||||
|
if not await _fill_visible(page, "BamaCaseNumberTextBoxH", case_number):
|
||||||
|
raise NgcsFlowError("שדה מספר-תיק לא נמצא בעמוד החיפוש")
|
||||||
|
my_filled = False
|
||||||
|
for el in await page.locator("input[id*='BamaMonthYearTextBoxHT']").all():
|
||||||
|
if await el.is_visible():
|
||||||
|
await el.click()
|
||||||
|
await page.keyboard.type(month_year, delay=60)
|
||||||
|
my_filled = True
|
||||||
|
break
|
||||||
|
if not my_filled:
|
||||||
|
raise NgcsFlowError("שדה חודש-שנה לא נמצא")
|
||||||
|
clicked = False
|
||||||
|
for b in await page.locator("[id*='SearchHeaderCaseButton']").all():
|
||||||
|
if await b.is_visible():
|
||||||
|
await b.click()
|
||||||
|
clicked = True
|
||||||
|
break
|
||||||
|
if not clicked:
|
||||||
|
raise NgcsFlowError("כפתור החיפוש לא נמצא")
|
||||||
|
await page.wait_for_timeout(6000)
|
||||||
|
if "CaseDetails" not in page.url:
|
||||||
|
raise NgcsFlowError(
|
||||||
|
f"לא הגענו לעמוד-התיק (URL={page.url[:80]}) — ייתכן שהתיק לא נמצא/לא פתוח לעיון"
|
||||||
|
)
|
||||||
|
|
||||||
|
# פסקי דין tab → DecisionList
|
||||||
|
psak = page.locator("a:has-text('פסקי דין')")
|
||||||
|
opened = False
|
||||||
|
for i in range(await psak.count()):
|
||||||
|
el = psak.nth(i)
|
||||||
|
if await el.is_visible():
|
||||||
|
await el.click()
|
||||||
|
opened = True
|
||||||
|
break
|
||||||
|
if not opened:
|
||||||
|
raise NgcsFlowError("לשונית 'פסקי דין' לא נמצאה בעמוד-התיק")
|
||||||
|
await page.wait_for_timeout(6000)
|
||||||
|
|
||||||
|
# open the verdict document viewer (popup)
|
||||||
|
viewers = page.locator(
|
||||||
|
"a[href*='Viewer'],[onclick*='Viewer'],a[href*='Document'],a:has-text('צפייה')"
|
||||||
|
)
|
||||||
|
async with page.context.expect_page(timeout=15000) as pop:
|
||||||
|
clicked = False
|
||||||
|
for i in range(await viewers.count()):
|
||||||
|
el = viewers.nth(i)
|
||||||
|
if await el.is_visible():
|
||||||
|
await el.click()
|
||||||
|
clicked = True
|
||||||
|
break
|
||||||
|
if not clicked:
|
||||||
|
raise NgcsFlowError("לא נמצא מסמך פסק-דין לצפייה")
|
||||||
|
return await pop.value
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_admin_verdict(
|
||||||
|
*, file_number: str, month: str, year: str, case_number: str, court: str
|
||||||
|
) -> dict:
|
||||||
|
"""Fetch an admin/district court verdict as a PDF. Returns
|
||||||
|
``{content: bytes, filename, source_url, court}``; raises on failure.
|
||||||
|
|
||||||
|
``file_number``/``month``/``year`` are the נט-המשפט triple (e.g. 46111/12/22).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from camoufox.async_api import AsyncCamoufox
|
||||||
|
except Exception as e:
|
||||||
|
raise CamofoxUnavailable(
|
||||||
|
"חבילת camoufox אינה מותקנת/זמינה. הרץ `pip install camoufox` ו-"
|
||||||
|
"`python -m camoufox fetch`. ראה docs/spec/X13-court-fetch.md."
|
||||||
|
) from e
|
||||||
|
if not _DISPLAY:
|
||||||
|
# Headless Firefox crashes here without a virtual display.
|
||||||
|
raise CamofoxUnavailable(
|
||||||
|
"אין DISPLAY — Camoufox דורש Xvfb על שרת ללא מסך. הפעל Xvfb (למשל :99) "
|
||||||
|
"והגדר DISPLAY (ראה pm2 config)."
|
||||||
|
)
|
||||||
|
|
||||||
|
month_year = f"{int(month):02d}-{year[-2:]}"
|
||||||
|
|
||||||
|
# Belt-and-suspenders against browser leaks: kill any orphaned browser from
|
||||||
|
# a prior crashed fetch before we launch a new one (serial → safe).
|
||||||
|
_reap_orphan_browsers()
|
||||||
|
|
||||||
|
async def _run() -> dict:
|
||||||
|
doc_num = {"v": None}
|
||||||
|
|
||||||
|
async def on_resp(resp):
|
||||||
|
if "GetImages" in resp.url and not doc_num["v"]:
|
||||||
|
try:
|
||||||
|
doc_num["v"] = json.loads(resp.request.post_data).get("documentNumber")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with AsyncCamoufox(
|
||||||
|
headless=True, geoip=False, humanize=True, locale="he-IL"
|
||||||
|
) as browser:
|
||||||
|
page = await browser.new_page()
|
||||||
|
page.context.on("response", lambda r: asyncio.create_task(on_resp(r)))
|
||||||
|
vp = await _reach_viewer(page, case_number=file_number, month_year=month_year)
|
||||||
|
source_url = vp.url
|
||||||
|
await vp.wait_for_timeout(9000)
|
||||||
|
if not doc_num["v"]:
|
||||||
|
raise NgcsFlowError("לא נלכד documentNumber מהצופה (ייתכן שהמסמך לא נטען)")
|
||||||
|
|
||||||
|
# Pull every page batch through fetch() with X-Requested-With (WAF-safe).
|
||||||
|
imgs = await vp.evaluate(
|
||||||
|
"""async (args) => {
|
||||||
|
const [dn, maxPages, batch] = args;
|
||||||
|
const url = window.location.href.split('?')[0] + '/GetImages';
|
||||||
|
const out = {};
|
||||||
|
for (let f = 0; f < maxPages; f += batch) {
|
||||||
|
let d;
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, {method:'POST', credentials:'include',
|
||||||
|
headers:{'Content-Type':'application/json; charset=utf-8',
|
||||||
|
'X-Requested-With':'XMLHttpRequest'},
|
||||||
|
body: JSON.stringify({documentNumber:dn, fromIndex:f, toIndex:f+batch-1})});
|
||||||
|
if (!r.ok) break;
|
||||||
|
const j = await r.json(); d = (j.d !== undefined) ? j.d : j;
|
||||||
|
} catch (e) { break; }
|
||||||
|
if (!Array.isArray(d) || d.length === 0) break;
|
||||||
|
d.forEach((html, k) => { if (html) out[f+k] = html; });
|
||||||
|
if (d.length < batch) break;
|
||||||
|
await new Promise(r => setTimeout(r, 350));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}""",
|
||||||
|
[doc_num["v"], _MAX_PAGES, _PAGE_BATCH],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not imgs:
|
||||||
|
raise NgcsFlowError("לא התקבלו עמודי-מסמך מ-GetImages")
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
pages = []
|
||||||
|
for idx in sorted(imgs, key=lambda x: int(x)):
|
||||||
|
m = re.search(r"base64,([A-Za-z0-9+/=]+)", imgs[idx] or "")
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
pages.append(Image.open(io.BytesIO(base64.b64decode(m.group(1)))).convert("RGB"))
|
||||||
|
if not pages:
|
||||||
|
raise NgcsFlowError("עמודי-המסמך לא ניתנים לפענוח (base64)")
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
pages[0].save(buf, format="PDF", save_all=True, append_images=pages[1:])
|
||||||
|
content = buf.getvalue()
|
||||||
|
logger.info("נט המשפט: fetched %s — %d pages, %d bytes",
|
||||||
|
case_number, len(pages), len(content))
|
||||||
|
return {
|
||||||
|
"content": content,
|
||||||
|
"filename": f"{case_number}.pdf",
|
||||||
|
"source_url": source_url,
|
||||||
|
"court": court or "בית משפט מחוזי",
|
||||||
|
"pages": len(pages),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hard wall-clock cap: on a hung browser, the timeout cancels _run(); the
|
||||||
|
# async-with __aexit__ tears the browser down, and the reap below sweeps any
|
||||||
|
# process that outlived the cancellation.
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(_run(), _FETCH_HARD_TIMEOUT_S)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
_reap_orphan_browsers()
|
||||||
|
raise NgcsFlowError(
|
||||||
|
f"אחזור עבר את מגבלת-הזמן ({_FETCH_HARD_TIMEOUT_S:.0f}ש') ובוטל"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_reap_orphan_browsers()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Open-source reCAPTCHA v2 audio-challenge solver (X13, Tier 1).
|
||||||
|
|
||||||
|
Pure open-source, zero-API-cost: switch the reCAPTCHA widget to its **audio**
|
||||||
|
challenge, download the mp3, transcribe it with a **local Whisper** model
|
||||||
|
(``faster-whisper``), and submit the transcript. This is the well-known
|
||||||
|
"Buster"-style technique. It is intentionally a *best-effort* solver —
|
||||||
|
reCAPTCHA actively fights audio solving, so a non-trivial failure rate is
|
||||||
|
expected and handled by the Tier-2 human fallback (INV-CF3), never hidden.
|
||||||
|
|
||||||
|
Model is loaded lazily and cached; ``WHISPER_MODEL`` (default ``small``) and
|
||||||
|
``WHISPER_DEVICE`` (default ``cpu``) tune it. The dependency is optional — if
|
||||||
|
``faster-whisper`` isn't installed, ``transcribe_audio`` raises a clear error
|
||||||
|
so the caller falls back to a human solve rather than crashing the service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_WHISPER_MODEL_NAME = os.environ.get("WHISPER_MODEL", "small")
|
||||||
|
_WHISPER_DEVICE = os.environ.get("WHISPER_DEVICE", "cpu")
|
||||||
|
_model = None
|
||||||
|
|
||||||
|
|
||||||
|
class AudioSolveUnavailable(RuntimeError):
|
||||||
|
"""faster-whisper isn't installed — cannot solve audio locally."""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_model():
|
||||||
|
global _model
|
||||||
|
if _model is not None:
|
||||||
|
return _model
|
||||||
|
try:
|
||||||
|
from faster_whisper import WhisperModel # type: ignore
|
||||||
|
except ImportError as e:
|
||||||
|
raise AudioSolveUnavailable(
|
||||||
|
"faster-whisper אינו מותקן — לא ניתן לפתור reCAPTCHA אודיו מקומית. "
|
||||||
|
"התקן `pip install faster-whisper` או הסתמך על fallback אנושי (VNC)."
|
||||||
|
) from e
|
||||||
|
logger.info("loading whisper model %s on %s", _WHISPER_MODEL_NAME, _WHISPER_DEVICE)
|
||||||
|
_model = WhisperModel(
|
||||||
|
_WHISPER_MODEL_NAME, device=_WHISPER_DEVICE, compute_type="int8"
|
||||||
|
)
|
||||||
|
return _model
|
||||||
|
|
||||||
|
|
||||||
|
async def download_audio(audio_url: str) -> bytes:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as c:
|
||||||
|
r = await c.get(audio_url)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.content
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_audio(mp3_bytes: bytes) -> str:
|
||||||
|
"""Transcribe a reCAPTCHA audio clip to its (English) digit/word phrase.
|
||||||
|
|
||||||
|
Raises ``AudioSolveUnavailable`` if the local model isn't installed.
|
||||||
|
"""
|
||||||
|
model = _get_model()
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=True) as f:
|
||||||
|
f.write(mp3_bytes)
|
||||||
|
f.flush()
|
||||||
|
# reCAPTCHA audio is English regardless of page locale.
|
||||||
|
segments, _info = model.transcribe(f.name, language="en")
|
||||||
|
text = " ".join(seg.text for seg in segments).strip()
|
||||||
|
# Normalise: reCAPTCHA expects the bare phrase, lower-case, no punctuation.
|
||||||
|
cleaned = "".join(ch for ch in text.lower() if ch.isalnum() or ch.isspace())
|
||||||
|
return " ".join(cleaned.split())
|
||||||
|
|
||||||
|
|
||||||
|
async def solve_from_audio_url(audio_url: str) -> str:
|
||||||
|
"""Convenience: download + transcribe an audio-challenge URL."""
|
||||||
|
mp3 = await download_audio(audio_url)
|
||||||
|
return transcribe_audio(mp3)
|
||||||
275
mcp-server/src/legal_mcp/court_fetch_service/server.py
Normal file
275
mcp-server/src/legal_mcp/court_fetch_service/server.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
"""Host-side HTTP bridge for Tier-1 verdict fetching (X13).
|
||||||
|
|
||||||
|
Mirrors ``legal_mcp.chat_service.server`` — the proven host-side pattern: an
|
||||||
|
aiohttp app, bound to the docker bridge gateway, Bearer-auth, that does the one
|
||||||
|
thing the container can't (here: drive a real browser against נט המשפט).
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /fetch body {file_number, month, year, case_number, court}
|
||||||
|
→ {ok, content_b64, filename, source_url, court, reason}
|
||||||
|
REQUIRES Authorization: Bearer <COURT_FETCH_SHARED_SECRET>.
|
||||||
|
GET /health liveness (no auth); reports camofox + VNC URL if available.
|
||||||
|
GET /pm2 read-only pm2 status of legal-* / paperclip services (no auth).
|
||||||
|
POST /pm2/control body {name, action: restart|stop|start} → run pm2 on a
|
||||||
|
whitelisted legal-* process. REQUIRES Bearer (mutating).
|
||||||
|
|
||||||
|
Run with pm2:
|
||||||
|
pm2 start scripts/legal-court-fetch-service.config.cjs
|
||||||
|
|
||||||
|
Security posture (identical rationale to legal-chat-service):
|
||||||
|
1. Bind defaults to ``10.0.1.1`` (docker0 bridge gateway) — reachable from
|
||||||
|
the host + containers on docker bridges, invisible to outside networks.
|
||||||
|
2. ``/fetch`` requires a Bearer token (constant-time compare); the service
|
||||||
|
refuses to start without ``COURT_FETCH_SHARED_SECRET`` set.
|
||||||
|
3. ``/health`` is unauthenticated and spawns nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
_pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
if _pkg_root not in sys.path:
|
||||||
|
sys.path.insert(0, _pkg_root)
|
||||||
|
|
||||||
|
from legal_mcp.court_fetch_service import camofox_client # noqa: E402
|
||||||
|
|
||||||
|
logger = logging.getLogger("legal_court_fetch_service")
|
||||||
|
|
||||||
|
_SHARED_SECRET: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
async def health(request: web.Request) -> web.Response:
|
||||||
|
info = {"ok": True, "service": "legal-court-fetch-service",
|
||||||
|
"camofox_enabled": camofox_client.is_enabled()}
|
||||||
|
if camofox_client.is_enabled():
|
||||||
|
try:
|
||||||
|
info["camofox"] = await camofox_client.health()
|
||||||
|
except Exception as e: # health must never throw
|
||||||
|
info["camofox_error"] = str(e)
|
||||||
|
return web.json_response(info)
|
||||||
|
|
||||||
|
|
||||||
|
# Background services we surface on the /operations dashboard. pm2 jlist is a
|
||||||
|
# host-only capability (the legal-ai container can't run pm2), so the container's
|
||||||
|
# FastAPI proxies this read-only endpoint over the docker bridge. No secret:
|
||||||
|
# pm2 status (names/cpu/mem) carries nothing sensitive and the bind (10.0.1.1)
|
||||||
|
# is already host/container-only.
|
||||||
|
_PM2_PREFIXES = ("legal-", "paperclip")
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_service(a: dict) -> dict:
|
||||||
|
"""Project a pm2 jlist app entry into the fields the dashboard needs."""
|
||||||
|
env = a.get("pm2_env", {}) or {}
|
||||||
|
return {
|
||||||
|
"name": a.get("name", ""),
|
||||||
|
"status": env.get("status", ""),
|
||||||
|
"restarts": env.get("restart_time", 0),
|
||||||
|
"uptime_ms": env.get("pm_uptime", 0),
|
||||||
|
"cpu": (a.get("monit") or {}).get("cpu", 0),
|
||||||
|
"memory_bytes": (a.get("monit") or {}).get("memory", 0),
|
||||||
|
"cron": env.get("cron_restart") or "",
|
||||||
|
"autorestart": env.get("autorestart", True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _pm2_run(*args: str, timeout: float = 10) -> tuple[int, bytes, bytes]:
|
||||||
|
"""Run a pm2 subcommand; returns (returncode, stdout, stderr)."""
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
|
proc = await _asyncio.create_subprocess_exec(
|
||||||
|
"pm2", *args,
|
||||||
|
stdout=_asyncio.subprocess.PIPE, stderr=_asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
out, err = await _asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
|
return proc.returncode or 0, out, err
|
||||||
|
|
||||||
|
|
||||||
|
async def pm2_status(request: web.Request) -> web.Response:
|
||||||
|
"""Return a trimmed ``pm2 jlist`` for the legal-ai background services."""
|
||||||
|
try:
|
||||||
|
rc, out, err = await _pm2_run("jlist")
|
||||||
|
if rc != 0:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"pm2 jlist failed: {err.decode('utf-8','replace')[:200]}"},
|
||||||
|
status=502,
|
||||||
|
)
|
||||||
|
apps = json.loads(out.decode("utf-8", "replace"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return web.json_response({"error": "pm2 not found on PATH"}, status=502)
|
||||||
|
except Exception as e: # never throw
|
||||||
|
return web.json_response({"error": f"pm2 error: {e}"}, status=502)
|
||||||
|
|
||||||
|
services = [
|
||||||
|
_trim_service(a) for a in apps
|
||||||
|
if any(str(a.get("name", "")).startswith(p) for p in _PM2_PREFIXES)
|
||||||
|
]
|
||||||
|
services.sort(key=lambda s: s["name"])
|
||||||
|
return web.json_response({"services": services})
|
||||||
|
|
||||||
|
|
||||||
|
# Process control (restart/stop/start) for the dashboard's "Windows-services"
|
||||||
|
# panel. Mutating, so it requires the Bearer secret (unlike read-only /pm2).
|
||||||
|
# Whitelisted to ``legal-`` names only — never paperclip or arbitrary processes.
|
||||||
|
_PM2_ACTIONS = {"restart", "stop", "start"}
|
||||||
|
|
||||||
|
# Our own pm2 process name. Restarting/stopping ourselves kills this process
|
||||||
|
# mid-reply, so those self-actions are detached (see pm2_control).
|
||||||
|
_OWN_PM2_NAME = os.environ.get("COURT_FETCH_SERVICE_PM2_NAME", "legal-court-fetch-service")
|
||||||
|
|
||||||
|
|
||||||
|
async def pm2_control(request: web.Request) -> web.Response:
|
||||||
|
"""Run ``pm2 <action> <name>`` for a whitelisted legal-* process."""
|
||||||
|
unauth = _check_bearer(request)
|
||||||
|
if unauth is not None:
|
||||||
|
return unauth
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.json_response({"error": "invalid JSON body"}, status=400)
|
||||||
|
|
||||||
|
name = str(body.get("name", "")).strip()
|
||||||
|
action = str(body.get("action", "")).strip()
|
||||||
|
if action not in _PM2_ACTIONS:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"action must be one of {sorted(_PM2_ACTIONS)}"}, status=400
|
||||||
|
)
|
||||||
|
if not name.startswith("legal-"):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "name must be a legal-* process"}, status=403
|
||||||
|
)
|
||||||
|
|
||||||
|
# Self restart/stop kills this process before it can reply (client sees a
|
||||||
|
# dropped connection / 502) even though pm2 does perform the action. Detach
|
||||||
|
# it with a brief delay so the HTTP response flushes first, then report
|
||||||
|
# success optimistically.
|
||||||
|
if name == _OWN_PM2_NAME and action in ("restart", "stop"):
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
|
await _asyncio.create_subprocess_shell(f"sleep 1; pm2 {action} {name} --silent")
|
||||||
|
return web.json_response(
|
||||||
|
{"ok": True, "action": action, "deferred": True, "service": None}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rc, out, err = await _pm2_run(action, name, "--silent", timeout=30)
|
||||||
|
if rc != 0:
|
||||||
|
return web.json_response(
|
||||||
|
{"ok": False,
|
||||||
|
"error": f"pm2 {action} {name} failed: "
|
||||||
|
f"{err.decode('utf-8','replace')[:200]}"},
|
||||||
|
status=502,
|
||||||
|
)
|
||||||
|
# Re-read just this process so the UI settles on the real new state.
|
||||||
|
rc2, out2, _ = await _pm2_run("jlist")
|
||||||
|
svc = None
|
||||||
|
if rc2 == 0:
|
||||||
|
for a in json.loads(out2.decode("utf-8", "replace")):
|
||||||
|
if a.get("name") == name:
|
||||||
|
svc = _trim_service(a)
|
||||||
|
break
|
||||||
|
return web.json_response({"ok": True, "action": action, "service": svc})
|
||||||
|
except FileNotFoundError:
|
||||||
|
return web.json_response({"error": "pm2 not found on PATH"}, status=502)
|
||||||
|
except Exception as e: # never throw
|
||||||
|
return web.json_response({"ok": False, "error": f"pm2 error: {e}"}, status=502)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_bearer(request: web.Request) -> web.Response | None:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
expected = "Bearer " + _SHARED_SECRET
|
||||||
|
if not auth or not hmac.compare_digest(auth, expected):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "unauthorized: missing or invalid Bearer token"}, status=401
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch(request: web.Request) -> web.Response:
|
||||||
|
unauth = _check_bearer(request)
|
||||||
|
if unauth is not None:
|
||||||
|
return unauth
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.json_response({"error": "invalid JSON body"}, status=400)
|
||||||
|
|
||||||
|
required = ("file_number", "month", "year")
|
||||||
|
if not all(body.get(k) for k in required):
|
||||||
|
return web.json_response(
|
||||||
|
{"ok": False, "reason": f"missing one of {required}"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await camofox_client.fetch_admin_verdict(
|
||||||
|
file_number=str(body["file_number"]),
|
||||||
|
month=str(body["month"]),
|
||||||
|
year=str(body["year"]),
|
||||||
|
case_number=str(body.get("case_number", "")),
|
||||||
|
court=str(body.get("court", "")),
|
||||||
|
)
|
||||||
|
return web.json_response({
|
||||||
|
"ok": True,
|
||||||
|
"content_b64": base64.b64encode(result["content"]).decode("ascii"),
|
||||||
|
"filename": result.get("filename", ""),
|
||||||
|
"source_url": result.get("source_url", ""),
|
||||||
|
"court": result.get("court", ""),
|
||||||
|
})
|
||||||
|
except (camofox_client.CamofoxUnavailable, camofox_client.NgcsFlowError) as e:
|
||||||
|
# Expected, recoverable failure → orchestrator escalates (INV-CF3).
|
||||||
|
return web.json_response({"ok": False, "reason": str(e)}, status=200)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.exception("fetch failed")
|
||||||
|
return web.json_response({"ok": False, "reason": f"unexpected: {e}"}, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
def build_app() -> web.Application:
|
||||||
|
app = web.Application(client_max_size=64 * 1024 * 1024)
|
||||||
|
app.router.add_get("/health", health)
|
||||||
|
app.router.add_get("/pm2", pm2_status)
|
||||||
|
app.router.add_post("/pm2/control", pm2_control)
|
||||||
|
app.router.add_post("/fetch", fetch)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="legal-court-fetch-service")
|
||||||
|
parser.add_argument("--port", type=int, default=8771)
|
||||||
|
parser.add_argument("--host", default="10.0.1.1",
|
||||||
|
help="bind address; default = docker0 bridge gateway")
|
||||||
|
parser.add_argument("--log-level", default="INFO")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=args.log_level.upper(),
|
||||||
|
format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
secret = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
|
||||||
|
if not secret:
|
||||||
|
logger.error(
|
||||||
|
"COURT_FETCH_SHARED_SECRET is empty; refusing to start. Set it in "
|
||||||
|
"/home/chaim/.legal-court-fetch-service.env (loaded by pm2) and "
|
||||||
|
"mirror it as a Coolify env var on the legal-ai app."
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
if len(secret) < 24:
|
||||||
|
logger.error("COURT_FETCH_SHARED_SECRET too short (>=32 chars expected).")
|
||||||
|
return 2
|
||||||
|
global _SHARED_SECRET
|
||||||
|
_SHARED_SECRET = secret
|
||||||
|
|
||||||
|
app = build_app()
|
||||||
|
logger.info("legal-court-fetch-service listening on %s:%d", args.host, args.port)
|
||||||
|
web.run_app(app, host=args.host, port=args.port, print=lambda _m: None)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -58,6 +58,8 @@ from legal_mcp.tools import ( # noqa: E402
|
|||||||
missing_precedents as mp_tools,
|
missing_precedents as mp_tools,
|
||||||
citations as cit_tools,
|
citations as cit_tools,
|
||||||
training_enrichment as train_tools,
|
training_enrichment as train_tools,
|
||||||
|
digests as digest_tools,
|
||||||
|
court_fetch as cf_tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -84,10 +86,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 +124,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 +172,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 +250,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 +294,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 +309,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,10 +338,85 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Digests radar (X12) — secondary discovery layer; NOT a citation corpus.
|
||||||
|
@mcp.tool()
|
||||||
|
async def digest_upload(
|
||||||
|
file_path: str,
|
||||||
|
yomon_number: str = "",
|
||||||
|
digest_date: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""העלאת יומון ("כל יום") לקורפוס-הגילוי (radar) + חילוץ מטא-דאטה אוטומטי. היומון הוא מקור-משני המצביע על הפסק המקורי — אינו מצוטט בהחלטה ואינו מחלץ הלכות (INV-DIG1/2). practice_area: rishuy_uvniya / betterment_levy / compensation_197."""
|
||||||
|
return await digest_tools.digest_upload(
|
||||||
|
file_path, yomon_number, digest_date, practice_area,
|
||||||
|
appeal_subtype, subject_tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def digest_list(
|
||||||
|
practice_area: str = "",
|
||||||
|
concept_tag: str = "",
|
||||||
|
linked: bool | None = None,
|
||||||
|
search: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> str:
|
||||||
|
"""רשימת יומונים בקורפוס-הגילוי, עם פילטרים. linked=false → יומונים שהפסק המקורי שלהם עוד לא נקלט לספריית הפסיקה (פער-ידע גלוי, INV-DIG3)."""
|
||||||
|
return await digest_tools.digest_list(
|
||||||
|
practice_area, concept_tag, linked, search, _clamp_limit(limit),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def digest_get(digest_id: str) -> str:
|
||||||
|
"""יומון ספציפי לפי מזהה (כולל מראה-מקום, ניתוח, וקישור לפסק המקורי אם קיים)."""
|
||||||
|
return await digest_tools.digest_get(digest_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def digest_link(digest_id: str, case_law_id: str) -> str:
|
||||||
|
"""קישור ידני של יומון לפסק הדין המקורי בספריית הפסיקה (INV-DIG3). idempotent."""
|
||||||
|
return await digest_tools.digest_link(digest_id, case_law_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def digest_relink(digest_id: str) -> str:
|
||||||
|
"""ניסיון-קישור מחדש: בודק אם פסק הדין המקורי של היומון כבר בספרייה ומקשר אוטומטית."""
|
||||||
|
return await digest_tools.digest_relink(digest_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def digest_delete(digest_id: str) -> str:
|
||||||
|
"""מחיקת יומון מקורפוס-הגילוי."""
|
||||||
|
return await digest_tools.digest_delete(digest_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_digests(
|
||||||
|
query: str,
|
||||||
|
practice_area: str = "",
|
||||||
|
subject_tag: str = "",
|
||||||
|
concept_tag: str = "",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> str:
|
||||||
|
"""חיפוש סמנטי בקורפוס-הגילוי (יומוני "כל יום") — מצפן-מחקר (radar). מחזיר את היומון הרלוונטי + מראה-המקום של הפסק המקורי. ⚠️ היומון אינו מצוטט בהחלטה (INV-DIG1) — הצטט מהפסק המקורי דרך search_precedent_library. החוקר משתמש בזה בשלב 2ב.0 לפני האימות."""
|
||||||
|
return await digest_tools.search_digests(
|
||||||
|
query, practice_area, subject_tag, concept_tag, _clamp_limit(limit),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def digest_process_pending(limit: int = 20) -> str:
|
||||||
|
"""ריקון תור היומונים שהועלו מה-UI וממתינים לעיבוד-LLM מקומי. מריץ חילוץ-מטא-דאטה + embedding + autolink על כל יומון 'pending' (מקומית עם CLI). חלופת-MCP ל-scripts/ingest_digests_batch.py."""
|
||||||
|
return await digest_tools.digest_process_pending(_clamp_limit(limit))
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def halacha_review(
|
async def halacha_review(
|
||||||
halacha_id: str,
|
halacha_id: str,
|
||||||
@@ -314,7 +437,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 +556,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 +567,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 +580,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 +613,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 +625,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 +700,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 +789,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 +948,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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -831,6 +972,28 @@ async def missing_precedent_close(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Court verdict auto-fetch (X13) ────────────────────────────────
|
||||||
|
@mcp.tool()
|
||||||
|
async def court_verdict_fetch(citation: str) -> str:
|
||||||
|
"""אחזור אוטומטי של פסק-דין בית-משפט מנט המשפט/פורטל-העליון וקליטה לקורפוס.
|
||||||
|
|
||||||
|
מסווג את הציטוט (עליון→Tier0 / מנהלי→Tier1 / ערר→skip), מוריד וקולט דרך
|
||||||
|
צינור-הקליטה הקנוני. דוגמה: 'עת"מ 46111-12-22'. כלי מקומי בלבד."""
|
||||||
|
return await cf_tools.court_verdict_fetch(citation)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def court_fetch_status(case_number: str = "", status_filter: str = "") -> str:
|
||||||
|
"""סטטוס תור-אחזור הפסיקה. case_number לפריט יחיד, או status_filter (pending/failed/manual/done)."""
|
||||||
|
return await cf_tools.court_fetch_status(case_number, status_filter)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def court_fetch_drain(limit: int = 10) -> str:
|
||||||
|
"""ריקון תור-אחזור הפסיקה — מוריד וקולט jobs ממתינים שהיומונים מילאו, וקושר חזרה ליומון. מקומי בלבד."""
|
||||||
|
return await cf_tools.court_fetch_drain(limit)
|
||||||
|
|
||||||
|
|
||||||
# ── Internal citations graph (TaskMaster #34) ─────────────────────
|
# ── Internal citations graph (TaskMaster #34) ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -872,7 +1035,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 +1051,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 +1074,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():
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Output: data/cases/{case_number}/exports/ניתוח-משפטי-v{N}.docx
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -34,7 +35,7 @@ from docx.text.paragraph import Paragraph
|
|||||||
from docx.text.run import Run
|
from docx.text.run import Run
|
||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import db, research_md
|
from legal_mcp.services import db, research_md, storage
|
||||||
|
|
||||||
|
|
||||||
def _mark_run_rtl(run: Run) -> None:
|
def _mark_run_rtl(run: Run) -> None:
|
||||||
@@ -494,10 +495,19 @@ async def build_analysis_docx(case_number: str) -> Path:
|
|||||||
continue
|
continue
|
||||||
_emit_content_line(doc, raw)
|
_emit_content_line(doc, raw)
|
||||||
|
|
||||||
# Save versioned
|
# Save versioned through the storage layer (INV-STG1). export_dir.mkdir +
|
||||||
|
# the glob in _next_version still read disk (correct under filesystem/dual;
|
||||||
|
# storage-native versioning is a cutover concern). out_path is always under
|
||||||
|
# DATA_DIR, so the bytes land exactly where they did before.
|
||||||
export_dir = case_dir / "exports"
|
export_dir = case_dir / "exports"
|
||||||
export_dir.mkdir(parents=True, exist_ok=True)
|
export_dir.mkdir(parents=True, exist_ok=True)
|
||||||
version = _next_version(export_dir)
|
version = _next_version(export_dir)
|
||||||
out_path = export_dir / f"ניתוח-משפטי-v{version}.docx"
|
out_path = export_dir / f"ניתוח-משפטי-v{version}.docx"
|
||||||
doc.save(str(out_path))
|
buf = io.BytesIO()
|
||||||
|
doc.save(buf)
|
||||||
|
await storage.put_bytes(
|
||||||
|
out_path.relative_to(config.DATA_DIR).as_posix(), buf.getvalue(),
|
||||||
|
bucket=storage.Bucket.DOCUMENTS,
|
||||||
|
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
)
|
||||||
return out_path
|
return out_path
|
||||||
|
|||||||
@@ -335,18 +335,30 @@ async def get_legal_arguments(
|
|||||||
case_id,
|
case_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pull supporting claim ids for each argument in one round-trip.
|
# Pull supporting claims (id + full text) for each argument in one
|
||||||
|
# round-trip. ``supporting_claims`` stays id-only for backwards compat
|
||||||
|
# (counts, MCP consumers); ``supporting_propositions`` carries the text
|
||||||
|
# so the UI can show the raw propositions without an extra fetch.
|
||||||
arg_ids = [r["id"] for r in rows]
|
arg_ids = [r["id"] for r in rows]
|
||||||
supporting: dict[UUID, list[str]] = {}
|
supporting: dict[UUID, list[str]] = {}
|
||||||
|
propositions: dict[UUID, list[dict]] = {}
|
||||||
if arg_ids:
|
if arg_ids:
|
||||||
joins = await conn.fetch(
|
joins = await conn.fetch(
|
||||||
"""SELECT argument_id, claim_id
|
"""SELECT lap.argument_id, lap.claim_id,
|
||||||
FROM legal_argument_propositions
|
c.claim_text, c.source_document, c.claim_index
|
||||||
WHERE argument_id = ANY($1::uuid[])""",
|
FROM legal_argument_propositions lap
|
||||||
|
JOIN claims c ON c.id = lap.claim_id
|
||||||
|
WHERE lap.argument_id = ANY($1::uuid[])
|
||||||
|
ORDER BY c.claim_index""",
|
||||||
arg_ids,
|
arg_ids,
|
||||||
)
|
)
|
||||||
for j in joins:
|
for j in joins:
|
||||||
supporting.setdefault(j["argument_id"], []).append(str(j["claim_id"]))
|
supporting.setdefault(j["argument_id"], []).append(str(j["claim_id"]))
|
||||||
|
propositions.setdefault(j["argument_id"], []).append({
|
||||||
|
"id": str(j["claim_id"]),
|
||||||
|
"text": j["claim_text"],
|
||||||
|
"source_document": j["source_document"],
|
||||||
|
})
|
||||||
|
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -354,5 +366,6 @@ async def get_legal_arguments(
|
|||||||
d["id"] = str(d["id"])
|
d["id"] = str(d["id"])
|
||||||
d["case_id"] = str(d["case_id"])
|
d["case_id"] = str(d["case_id"])
|
||||||
d["supporting_claims"] = supporting.get(r["id"], [])
|
d["supporting_claims"] = supporting.get(r["id"], [])
|
||||||
|
d["supporting_propositions"] = propositions.get(r["id"], [])
|
||||||
out.append(d)
|
out.append(d)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
121
mcp-server/src/legal_mcp/services/bulletin_library.py
Normal file
121
mcp-server/src/legal_mcp/services/bulletin_library.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Ingest a monthly "עו"ד על נדל"ן" bulletin into the digests radar (X12).
|
||||||
|
|
||||||
|
A bulletin PDF is multi-topic: it EXPLODES into several digest rows — one per
|
||||||
|
case-law pointer (digest_kind='decision') and one per article (digest_kind=
|
||||||
|
'article'), all tagged publication='עו"ד על נדל"ן' to distinguish them from the
|
||||||
|
daily "כל יום" issues. This reuses the existing radar (no parallel corpus — G2):
|
||||||
|
the case pointers join search_digests / the /digests page and autolink to the
|
||||||
|
underlying ruling exactly like a daily digest; articles are deep-context only.
|
||||||
|
|
||||||
|
LOCAL-ONLY (LLM split + embedding) — host scripts/MCP, never the container path.
|
||||||
|
Idempotent: each item's content_hash (hash of its analysis_text) is the dedup
|
||||||
|
key, so re-running a bulletin skips already-ingested items.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from legal_mcp.services import db, embeddings, extractor
|
||||||
|
from legal_mcp.services import bulletin_splitter, digest_library
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PUBLICATION = 'עו"ד על נדל"ן'
|
||||||
|
SOURCE_FIRM = "צבי שוב + רונית אלפר, עורכי דין"
|
||||||
|
|
||||||
|
|
||||||
|
async def _store_and_embed(digest_row: dict) -> None:
|
||||||
|
"""Compute + store the single radar embedding for a freshly created item."""
|
||||||
|
emb_text = digest_library._embedding_text(digest_row)
|
||||||
|
if not emb_text:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
vecs = await embeddings.embed_texts([emb_text], input_type="document")
|
||||||
|
if vecs:
|
||||||
|
await db.store_digest_embedding(digest_row["id"], vecs[0])
|
||||||
|
except Exception as e: # §6 — surfaced, not swallowed
|
||||||
|
logger.warning("bulletin item embedding failed for %s: %s", digest_row.get("id"), e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_item(*, analysis_text: str, kind: str, concept_tag: str,
|
||||||
|
headline: str, summary: str, citation: str, court: str,
|
||||||
|
practice_area: str, subject_tags: list[str], src: str) -> dict | None:
|
||||||
|
"""Create one digest row from a bulletin item. Returns the row, or None if it
|
||||||
|
already exists (idempotent skip) or the insert raced on content_hash."""
|
||||||
|
content_hash = db._content_hash(analysis_text)
|
||||||
|
if await db.get_digest_by_content_hash(content_hash):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return await db.create_digest(
|
||||||
|
analysis_text=analysis_text,
|
||||||
|
publication=PUBLICATION,
|
||||||
|
source_firm=SOURCE_FIRM,
|
||||||
|
concept_tag=concept_tag,
|
||||||
|
headline_holding=headline,
|
||||||
|
summary=summary,
|
||||||
|
underlying_citation=citation,
|
||||||
|
underlying_court=court,
|
||||||
|
practice_area=practice_area,
|
||||||
|
subject_tags=subject_tags,
|
||||||
|
source_document_path=src,
|
||||||
|
extraction_status="completed",
|
||||||
|
digest_kind=kind,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# uq_digests_content_hash race (concurrent run) → treat as already-present.
|
||||||
|
if "uq_digests_content_hash" in str(e):
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_bulletin(file_path: str, model: str | None = None) -> dict:
|
||||||
|
"""Split a bulletin PDF into digest rows (case pointers + articles).
|
||||||
|
|
||||||
|
Returns counts: {cases, articles, created, skipped, linked}. Idempotent.
|
||||||
|
"""
|
||||||
|
path = str(file_path)
|
||||||
|
raw_text, _pages, _meta = await extractor.extract_text(path)
|
||||||
|
split = await bulletin_splitter.split(raw_text, model=model)
|
||||||
|
cases, articles = split.get("cases", []), split.get("articles", [])
|
||||||
|
|
||||||
|
out = {"file": Path(path).name, "cases": len(cases), "articles": len(articles),
|
||||||
|
"created": 0, "skipped": 0, "linked": 0}
|
||||||
|
|
||||||
|
for c in cases:
|
||||||
|
# analysis_text bundles the pointer's substance → stable per-item hash.
|
||||||
|
atext = "\n".join(p for p in (
|
||||||
|
c["concept_tag"], c["headline_holding"], c["summary"], c["underlying_citation"]
|
||||||
|
) if p).strip()
|
||||||
|
row = await _create_item(
|
||||||
|
analysis_text=atext, kind="decision", concept_tag=c["concept_tag"],
|
||||||
|
headline=c["headline_holding"], summary=c["summary"],
|
||||||
|
citation=c["underlying_citation"], court=c["underlying_court"],
|
||||||
|
practice_area=c["practice_area"], subject_tags=c["subject_tags"], src=path,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
out["skipped"] += 1
|
||||||
|
continue
|
||||||
|
out["created"] += 1
|
||||||
|
await _store_and_embed(row)
|
||||||
|
linked = await digest_library.try_autolink(row["id"], c["underlying_citation"])
|
||||||
|
if linked:
|
||||||
|
out["linked"] += 1
|
||||||
|
|
||||||
|
for a in articles:
|
||||||
|
# The article body is the substance; prefix authors into the summary.
|
||||||
|
body = a["body"] or a["summary"]
|
||||||
|
summary = (f"מאת {a['authors']}. " if a["authors"] else "") + (a["summary"] or "")
|
||||||
|
atext = "\n".join(p for p in (a["title"], summary, body) if p).strip()
|
||||||
|
row = await _create_item(
|
||||||
|
analysis_text=atext, kind="article", concept_tag=a["title"],
|
||||||
|
headline=a["title"], summary=summary, citation="", court="",
|
||||||
|
practice_area=a["practice_area"], subject_tags=a["subject_tags"], src=path,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
out["skipped"] += 1
|
||||||
|
continue
|
||||||
|
out["created"] += 1
|
||||||
|
await _store_and_embed(row)
|
||||||
|
|
||||||
|
return out
|
||||||
147
mcp-server/src/legal_mcp/services/bulletin_splitter.py
Normal file
147
mcp-server/src/legal_mcp/services/bulletin_splitter.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""Split a monthly "עו"ד על נדל"ן" bulletin into typed radar items (X12).
|
||||||
|
|
||||||
|
The monthly bulletin (a SEPARATE publication from the daily "כל יום" digest) is
|
||||||
|
multi-topic: it bundles a featured ARTICLE, a list of legislative updates, and a
|
||||||
|
set of CASE-LAW pointers grouped by topic. The chair chose to catalog the
|
||||||
|
**case-law pointers** (each → a digest, like the daily issue) and the
|
||||||
|
**articles** (deep-context background) — legislative updates are skipped.
|
||||||
|
|
||||||
|
This module is the LLM splitter only. ``bulletin_library.ingest_bulletin`` turns
|
||||||
|
its output into digest rows. Like the daily extractor it is LOCAL-ONLY (claude
|
||||||
|
CLI) and MUST NOT be imported from the FastAPI container path.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from legal_mcp import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_VALID_PRACTICE_AREAS = {"rishuy_uvniya", "betterment_levy", "compensation_197"}
|
||||||
|
|
||||||
|
BULLETIN_SPLIT_PROMPT = """\
|
||||||
|
אתה מקבל טקסט מלא של **עלון חודשי "עו"ד על נדל"ן"** (פרסום מקצועי רב-נושאי בתחום
|
||||||
|
תכנון ובנייה, מקרקעין, היטל השבחה, פיצויים והתחדשות עירונית). פצל אותו לפריטים.
|
||||||
|
|
||||||
|
העלון בנוי משלושה חלקים: (א) **מאמר** מקצועי ארוך אחד או יותר; (ב) **עדכוני חקיקה**
|
||||||
|
(תיקוני-חוק, אישורי-תכניות, חוזרים) — **התעלם מהם, אל תחלץ**; (ג) **עדכוני פסיקה**
|
||||||
|
מקובצים לפי נושא — כל פריט = מראה-מקום של פסק דין/החלטה + שורת-תקציר.
|
||||||
|
|
||||||
|
**אל תמציא** — חלץ רק מה שמופיע בטקסט. שדה חסר → מחרוזת ריקה.
|
||||||
|
|
||||||
|
## פלט נדרש
|
||||||
|
החזר JSON אחד (object), ללא markdown:
|
||||||
|
|
||||||
|
{
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"underlying_citation": "מראה-המקום המלא של הפסק כפי שמופיע, מילה במילה (למשל 'ערר 8018-02-22 הועדה המקומית בת ים נ' קבוצת מזרחי ובניו השקעות בע\\"מ'). השדה הקריטי.",
|
||||||
|
"concept_tag": "הנושא/הכותרת שתחתיה מופיע הפריט (למשל 'היטל השבחה', 'הפקעות', 'פירוק שיתוף').",
|
||||||
|
"headline_holding": "שורת-התקציר/הכותרת של הפריט — מה נקבע/השאלה (למשל 'חוסר וודאות בין תכנית קודמת לבין ההקלה').",
|
||||||
|
"summary": "תקציר ניטרלי קצר אם יש פירוט נוסף בגוף; אחרת חזור על headline_holding.",
|
||||||
|
"underlying_court": "הערכאה אם מצוינת (למשל 'בית המשפט המחוזי', 'ועדת ערר').",
|
||||||
|
"practice_area": "אחד מ: 'rishuy_uvniya' / 'betterment_levy' / 'compensation_197' — אם ברור מהנושא; אחרת ריק.",
|
||||||
|
"subject_tags": ["2-5 תגיות snake_case בעברית"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"articles": [
|
||||||
|
{
|
||||||
|
"title": "כותרת המאמר (למשל 'הפקעת קרקעות כיום - על המחוקק לתקן את העיוות שנוצר').",
|
||||||
|
"authors": "שמות המחברים (למשל 'עו\\"ד צבי שוב, עו\\"ד רונית אלפר').",
|
||||||
|
"summary": "2-4 משפטים: על מה המאמר ומה הטענה המרכזית.",
|
||||||
|
"body": "הטקסט המלא של המאמר (כל הפסקאות), לצורך embedding וחיפוש-עומק.",
|
||||||
|
"practice_area": "אחד מ-3 אם ברור; אחרת ריק.",
|
||||||
|
"subject_tags": ["2-5 תגיות snake_case"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
## כללים
|
||||||
|
1. **underlying_citation** — חלץ במלואו ובדיוק; הוא הגשר לפסק. פריט-פסיקה בלי מראה-מקום ברור → דלג עליו.
|
||||||
|
2. **cases** — כל מצביעי-הפסיקה בעלון, גם אם תחת נושאים שונים. אל תאחד פריטים נפרדים.
|
||||||
|
3. **articles** — רק מאמרי-עומק (לא רשימת עדכונים). body = הטקסט המלא.
|
||||||
|
4. **עדכוני חקיקה/אישורי-תכניות/חוזרים — לא לחלץ כלל.**
|
||||||
|
5. אם אין מאמר או אין פסיקה — החזר מערך ריק לאותו מפתח.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_str(d: dict, key: str) -> str:
|
||||||
|
v = d.get(key)
|
||||||
|
return v.strip() if isinstance(v, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_tags(d: dict) -> list[str]:
|
||||||
|
tags = d.get("subject_tags")
|
||||||
|
if not isinstance(tags, list):
|
||||||
|
return []
|
||||||
|
return [str(t).strip() for t in tags if str(t).strip()][:8]
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_pa(d: dict) -> str:
|
||||||
|
pa = _norm_str(d, "practice_area")
|
||||||
|
return pa if pa in _VALID_PRACTICE_AREAS else ""
|
||||||
|
|
||||||
|
|
||||||
|
async def split(raw_text: str, model: str | None = None) -> dict:
|
||||||
|
"""Return ``{"cases": [...], "articles": [...]}`` extracted from a bulletin.
|
||||||
|
|
||||||
|
Empty lists on any failure (surfaced as a warning, never raised) so the
|
||||||
|
batch keeps going. Each item is type-normalized; malformed items are dropped.
|
||||||
|
"""
|
||||||
|
from legal_mcp.services import claude_session
|
||||||
|
|
||||||
|
text = (raw_text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {"cases": [], "articles": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await claude_session.query_json(
|
||||||
|
text,
|
||||||
|
system=BULLETIN_SPLIT_PROMPT,
|
||||||
|
model=(model or config.DIGEST_EXTRACT_MODEL or None),
|
||||||
|
tools="", # pure text→JSON; disable tools (avoids error_max_turns)
|
||||||
|
)
|
||||||
|
except Exception as e: # §6 — surfaced, not swallowed
|
||||||
|
logger.warning("bulletin_splitter: query failed: %s", e)
|
||||||
|
return {"cases": [], "articles": []}
|
||||||
|
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
logger.warning("bulletin_splitter: expected dict, got %s", type(result).__name__)
|
||||||
|
return {"cases": [], "articles": []}
|
||||||
|
|
||||||
|
cases: list[dict] = []
|
||||||
|
for c in result.get("cases") or []:
|
||||||
|
if not isinstance(c, dict):
|
||||||
|
continue
|
||||||
|
citation = _norm_str(c, "underlying_citation")
|
||||||
|
if not citation: # rule 1: no anchor → skip
|
||||||
|
continue
|
||||||
|
cases.append({
|
||||||
|
"underlying_citation": citation,
|
||||||
|
"concept_tag": _norm_str(c, "concept_tag"),
|
||||||
|
"headline_holding": _norm_str(c, "headline_holding"),
|
||||||
|
"summary": _norm_str(c, "summary") or _norm_str(c, "headline_holding"),
|
||||||
|
"underlying_court": _norm_str(c, "underlying_court"),
|
||||||
|
"practice_area": _norm_pa(c),
|
||||||
|
"subject_tags": _norm_tags(c),
|
||||||
|
})
|
||||||
|
|
||||||
|
articles: list[dict] = []
|
||||||
|
for a in result.get("articles") or []:
|
||||||
|
if not isinstance(a, dict):
|
||||||
|
continue
|
||||||
|
title = _norm_str(a, "title")
|
||||||
|
body = _norm_str(a, "body")
|
||||||
|
if not (title or body):
|
||||||
|
continue
|
||||||
|
articles.append({
|
||||||
|
"title": title,
|
||||||
|
"authors": _norm_str(a, "authors"),
|
||||||
|
"summary": _norm_str(a, "summary"),
|
||||||
|
"body": body,
|
||||||
|
"practice_area": _norm_pa(a),
|
||||||
|
"subject_tags": _norm_tags(a),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"cases": cases, "articles": articles}
|
||||||
@@ -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,9 @@ 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,
|
||||||
|
tools: 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 +98,19 @@ 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).
|
||||||
|
tools: Optional available-tools spec, passed as ``--tools``. Pass an
|
||||||
|
empty string (``""``) to disable ALL tools — for pure text→JSON
|
||||||
|
extraction the model has no reason to call a tool, and leaving
|
||||||
|
tools enabled makes it occasionally emit ``stop_reason: tool_use``
|
||||||
|
which trips ``--max-turns 1`` → ``error_max_turns`` and forces a
|
||||||
|
retry (slow). ``None`` leaves the CLI default (all tools).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The text response from Claude.
|
The text response from Claude.
|
||||||
@@ -80,15 +129,28 @@ 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]
|
||||||
|
if tools is not None: # "" → disable all tools (no tool_use → no max-turns trip)
|
||||||
|
cmd += ["--tools", tools]
|
||||||
|
|
||||||
|
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
|
||||||
|
last_err = "unknown error"
|
||||||
|
|
||||||
|
for attempt in range(1, MAX_RETRIES + 1):
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
stdin=asyncio.subprocess.PIPE,
|
stdin=asyncio.subprocess.PIPE,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=_clean_subprocess_env(),
|
||||||
|
cwd=os.path.expanduser("~"),
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
# Deterministic — never retry.
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Claude CLI not found. This module only works when invoked "
|
"Claude CLI not found. This module only works when invoked "
|
||||||
"from the local MCP server — see the architectural rule in "
|
"from the local MCP server — see the architectural rule in "
|
||||||
@@ -103,7 +165,8 @@ async def query(
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
# wait_for cancellation alone leaves the child running.
|
# wait_for cancellation alone leaves the child running. A timeout is
|
||||||
|
# a real ceiling, not a transient blip — don't retry.
|
||||||
try:
|
try:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
await proc.wait()
|
await proc.wait()
|
||||||
@@ -112,14 +175,14 @@ async def query(
|
|||||||
raise RuntimeError(f"Claude CLI timed out after {timeout}s")
|
raise RuntimeError(f"Claude CLI timed out after {timeout}s")
|
||||||
|
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
|
# The CLI sometimes writes its diagnostic to stdout (or nowhere)
|
||||||
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
|
# rather than stderr (#85) — surface whichever is present.
|
||||||
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}{size_info}")
|
stderr = stderr_b.decode("utf-8", errors="replace").strip()
|
||||||
|
|
||||||
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
||||||
if not stdout:
|
last_err = f"exit {proc.returncode}: {(stderr or stdout or 'no output')[:500]}"
|
||||||
raise RuntimeError("Claude CLI returned empty response")
|
else:
|
||||||
|
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
||||||
|
if stdout:
|
||||||
# claude -p --output-format json returns {"type":"result","result":"..."}
|
# claude -p --output-format json returns {"type":"result","result":"..."}
|
||||||
try:
|
try:
|
||||||
data = json.loads(stdout)
|
data = json.loads(stdout)
|
||||||
@@ -128,6 +191,19 @@ async def query(
|
|||||||
return stdout
|
return stdout
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return stdout
|
return stdout
|
||||||
|
last_err = "empty response"
|
||||||
|
|
||||||
|
# Transient failure — retry with linear backoff unless this was the last try.
|
||||||
|
if attempt < MAX_RETRIES:
|
||||||
|
logger.warning(
|
||||||
|
"claude -p attempt %d/%d failed (%s%s) — retrying in %ds",
|
||||||
|
attempt, MAX_RETRIES, last_err, size_info, RETRY_BACKOFF_BASE * attempt,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(RETRY_BACKOFF_BASE * attempt)
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Claude CLI failed after {MAX_RETRIES} attempts ({last_err}){size_info}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def query_json(
|
async def query_json(
|
||||||
@@ -135,12 +211,17 @@ 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,
|
||||||
|
tools: 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``/``tools`` are forwarded to :func:`query` (see its docstring).
|
||||||
|
Pure text→JSON extractors should pass ``tools=""`` to avoid ``error_max_turns``.
|
||||||
"""
|
"""
|
||||||
raw = await query(prompt, timeout=timeout, system=system)
|
raw = await query(prompt, timeout=timeout, system=system, model=model, effort=effort, tools=tools)
|
||||||
return parse_llm_json(raw)
|
return parse_llm_json(raw)
|
||||||
|
|
||||||
|
|
||||||
@@ -216,6 +297,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
|
||||||
212
mcp-server/src/legal_mcp/services/court_citation.py
Normal file
212
mcp-server/src/legal_mcp/services/court_citation.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""Court-citation classifier for the auto-fetch subsystem (X13).
|
||||||
|
|
||||||
|
Given a raw citation string (typically a digest's ``underlying_citation``,
|
||||||
|
e.g. ``עת"מ 46111-12-22 יכין-אפק נ' הוועדה המחוזית``), decide:
|
||||||
|
|
||||||
|
* **which tier** can fetch it (``supreme`` | ``admin`` | ``skip``), and
|
||||||
|
* the **canonical case number** plus, for נט המשפט, the
|
||||||
|
(file, month, year) triple the public case-search form needs.
|
||||||
|
|
||||||
|
Tier mapping (INV-CF6 — only court rulings are auto-fetched; ועדת-ערר is
|
||||||
|
never sent to a public fetch, it needs Nevo):
|
||||||
|
|
||||||
|
* ``supreme`` — Supreme Court prefixes (עע"מ/בג"ץ/ע"א/רע"א/דנ"א/בר"מ/בש"א).
|
||||||
|
Fetched directly from ``supremedecisions.court.gov.il`` (Tier 0, no CAPTCHA).
|
||||||
|
* ``admin`` — district / administrative-court prefixes (עת"מ/עמ"נ/…) and
|
||||||
|
the bare נט-המשפט "filed" format ``NNNNN-MM-YY``. Fetched via the
|
||||||
|
host-side stealth browser against נט המשפט (Tier 1).
|
||||||
|
* ``skip`` — ועדת-ערר (ערר/בל"מ). Not publicly fetchable → missing_precedent.
|
||||||
|
|
||||||
|
Regex families intentionally mirror ``citation_extractor.py`` (the canonical
|
||||||
|
prefix/number patterns) so the two stay in sync — we reuse ``_NUM_RX`` shape
|
||||||
|
and ``_normalize_case_number`` semantics rather than inventing a parallel
|
||||||
|
parser (INV-CF1 / engineering "symmetry" rule).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# Canonical number core, identical shape to citation_extractor._NUM_RX:
|
||||||
|
# 3-5 digits, optional separator + 2-4 digits, optional third group
|
||||||
|
# (the NNNNN-MM-YY "filed" format — 46111-12-22 = file 46111, month 12, yr 22).
|
||||||
|
_NUM_RX = r"\d{1,5}(?:[-/]\d{1,4}(?:[-/]\d{2,4})?)?"
|
||||||
|
|
||||||
|
# Hebrew gershayim: straight (") or curly (״).
|
||||||
|
_Q = r"[\"״]"
|
||||||
|
|
||||||
|
# Optional leading one-letter Hebrew preposition/conjunction (ב/ל/ה/ו/כ/מ/ש)
|
||||||
|
# attached to the prefix — e.g. "בערר", "וערר", "כפי שקבעתי בערר". Anchored by
|
||||||
|
# a lookbehind that forbids a *preceding* Hebrew letter, so we don't match a
|
||||||
|
# prefix buried inside a longer word. Regex backtracking lets the preposition
|
||||||
|
# match empty when the prefix itself starts with one of these letters (בג"ץ).
|
||||||
|
_LEAD = r"(?<![א-ת])(?:[בלהוכמש])?"
|
||||||
|
|
||||||
|
# Supreme Court prefixes → Tier 0 (supremedecisions public download API).
|
||||||
|
_SUPREME_PREFIXES = [
|
||||||
|
rf"עע{_Q}מ", # ערעור מנהלי (לעליון)
|
||||||
|
rf"בג{_Q}ץ", # בג"ץ
|
||||||
|
rf"בג{_Q}צ", # variant spelling
|
||||||
|
rf"דנג{_Q}ץ", # דיון נוסף בג"ץ
|
||||||
|
rf"ע{_Q}א", # ערעור אזרחי
|
||||||
|
rf"רע{_Q}א", # רשות ערעור אזרחי
|
||||||
|
rf"דנ{_Q}א", # דיון נוסף אזרחי
|
||||||
|
rf"בר{_Q}מ", # בקשת רשות ערעור מנהלי (עליון)
|
||||||
|
rf"בש{_Q}א", # בקשת רשות … (עליון)
|
||||||
|
]
|
||||||
|
|
||||||
|
# District / administrative-court prefixes → Tier 1 (נט המשפט case viewer).
|
||||||
|
_ADMIN_PREFIXES = [
|
||||||
|
rf"עת{_Q}מ", # עתירה מנהלית (בימ"ש לעניינים מנהליים)
|
||||||
|
rf"עמ{_Q}נ", # ערעור מנהלי (מחוזי)
|
||||||
|
rf"ת{_Q}א", # תביעה אזרחית (מחוזי/שלום)
|
||||||
|
rf"ה{_Q}פ", # המרצת פתיחה
|
||||||
|
]
|
||||||
|
|
||||||
|
# Appeals-committee → skip (needs Nevo; never auto-fetched).
|
||||||
|
_SKIP_PREFIXES = [
|
||||||
|
rf"ערר",
|
||||||
|
rf"בל{_Q}מ",
|
||||||
|
]
|
||||||
|
|
||||||
|
_SUPREME_RX = re.compile(
|
||||||
|
_LEAD + r"(" + "|".join(_SUPREME_PREFIXES) + r")\s*(" + _NUM_RX + r")",
|
||||||
|
re.UNICODE,
|
||||||
|
)
|
||||||
|
_ADMIN_RX = re.compile(
|
||||||
|
_LEAD + r"(" + "|".join(_ADMIN_PREFIXES) + r")\s*(" + _NUM_RX + r")",
|
||||||
|
re.UNICODE,
|
||||||
|
)
|
||||||
|
_SKIP_RX = re.compile(
|
||||||
|
_LEAD + r"(" + "|".join(_SKIP_PREFIXES) + r")" + r"(?:\s*\([^)\n]{0,80}\))?\s*(" + _NUM_RX + r")",
|
||||||
|
re.UNICODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bare נט-המשפט filed format with no prefix: 46111-12-22 (5/4-digit file,
|
||||||
|
# 1-2 digit month, 2-4 digit year). Used when a digest gives just the number.
|
||||||
|
_BARE_FILED_RX = re.compile(r"(?<!\d)(\d{1,5})-(\d{1,2})-(\d{2,4})(?!\d)", re.UNICODE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CourtCitation:
|
||||||
|
"""Result of classifying a citation for auto-fetch routing."""
|
||||||
|
|
||||||
|
tier: str # "supreme" | "admin" | "skip" | "unknown"
|
||||||
|
court_prefix: str # e.g. 'עת"מ', or "" for bare/unknown
|
||||||
|
case_number_raw: str # the matched number as written, e.g. "46111-12-22"
|
||||||
|
case_number_norm: str # canonical: slashes→dashes, digits/sep only
|
||||||
|
# נט-המשפט form fields (only when the filed format NNNNN-MM-YY is present):
|
||||||
|
file_number: str | None = None
|
||||||
|
month: str | None = None
|
||||||
|
year: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fetchable(self) -> bool:
|
||||||
|
return self.tier in ("supreme", "admin")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_case_number(raw: str) -> str:
|
||||||
|
"""Canonicalize a case number for idempotency keys / matching.
|
||||||
|
|
||||||
|
Mirrors ``citation_extractor._normalize_case_number``: strip everything
|
||||||
|
but digits and separators, unify ``/`` → ``-``. Display value is never
|
||||||
|
derived from this.
|
||||||
|
"""
|
||||||
|
cleaned = re.sub(r"[^\d/\-]", "", raw or "")
|
||||||
|
return cleaned.replace("/", "-").strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_filed(num_norm: str) -> tuple[str, str, str] | None:
|
||||||
|
"""Split a normalized NNNNN-MM-YY number into (file, month, year).
|
||||||
|
|
||||||
|
Only the three-group "filed" format yields a נט-המשפט triple; two-group
|
||||||
|
formats (1234-22 / 1234/22) are Supreme-style serials and return None.
|
||||||
|
"""
|
||||||
|
m = _BARE_FILED_RX.fullmatch(num_norm)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
file_no, month, year = m.group(1), m.group(2), m.group(3)
|
||||||
|
# Plausibility: month 1-12, year 2-4 digits. Reject implausible months
|
||||||
|
# (avoids mis-reading a 2-group serial that slipped through).
|
||||||
|
if not (1 <= int(month) <= 12):
|
||||||
|
return None
|
||||||
|
return file_no, month, year
|
||||||
|
|
||||||
|
|
||||||
|
def classify(citation: str) -> CourtCitation:
|
||||||
|
"""Classify a raw citation string into a fetch tier + parsed number.
|
||||||
|
|
||||||
|
Resolution order: ועדת-ערר (skip) is checked FIRST so an "ערר" prefix is
|
||||||
|
never mis-routed to a court tier; then Supreme prefixes; then admin
|
||||||
|
prefixes; then a bare filed number defaults to ``admin`` (נט המשפט is the
|
||||||
|
only public source for prefix-less district/שלום numbers).
|
||||||
|
"""
|
||||||
|
text = (citation or "").strip()
|
||||||
|
if not text:
|
||||||
|
return CourtCitation("unknown", "", "", "")
|
||||||
|
|
||||||
|
# 1. ועדת-ערר → skip (must win over any court match).
|
||||||
|
m = _SKIP_RX.search(text)
|
||||||
|
if m:
|
||||||
|
raw = m.group(2)
|
||||||
|
return CourtCitation(
|
||||||
|
tier="skip",
|
||||||
|
court_prefix=m.group(1),
|
||||||
|
case_number_raw=raw,
|
||||||
|
case_number_norm=normalize_case_number(raw),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Supreme Court prefix → Tier 0. Still parse a נט-format triple when the
|
||||||
|
# number carries one (e.g. בר"מ 72182-06-25): נט המשפט serves Supreme
|
||||||
|
# cases too, so a triple lets the orchestrator route to the validated
|
||||||
|
# Tier-1 flow instead of the serial-only Tier-0.
|
||||||
|
m = _SUPREME_RX.search(text)
|
||||||
|
if m:
|
||||||
|
raw = m.group(2)
|
||||||
|
norm = normalize_case_number(raw)
|
||||||
|
filed = _split_filed(norm)
|
||||||
|
return CourtCitation(
|
||||||
|
tier="supreme",
|
||||||
|
court_prefix=m.group(1),
|
||||||
|
case_number_raw=raw,
|
||||||
|
case_number_norm=norm,
|
||||||
|
file_number=filed[0] if filed else None,
|
||||||
|
month=filed[1] if filed else None,
|
||||||
|
year=filed[2] if filed else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. District / admin prefix → Tier 1.
|
||||||
|
m = _ADMIN_RX.search(text)
|
||||||
|
if m:
|
||||||
|
raw = m.group(2)
|
||||||
|
norm = normalize_case_number(raw)
|
||||||
|
filed = _split_filed(norm)
|
||||||
|
return CourtCitation(
|
||||||
|
tier="admin",
|
||||||
|
court_prefix=m.group(1),
|
||||||
|
case_number_raw=raw,
|
||||||
|
case_number_norm=norm,
|
||||||
|
file_number=filed[0] if filed else None,
|
||||||
|
month=filed[1] if filed else None,
|
||||||
|
year=filed[2] if filed else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Bare filed number (no prefix) → default admin (נט המשפט).
|
||||||
|
m = _BARE_FILED_RX.search(text)
|
||||||
|
if m:
|
||||||
|
raw = m.group(0)
|
||||||
|
norm = normalize_case_number(raw)
|
||||||
|
filed = _split_filed(norm)
|
||||||
|
if filed:
|
||||||
|
return CourtCitation(
|
||||||
|
tier="admin",
|
||||||
|
court_prefix="",
|
||||||
|
case_number_raw=raw,
|
||||||
|
case_number_norm=norm,
|
||||||
|
file_number=filed[0],
|
||||||
|
month=filed[1],
|
||||||
|
year=filed[2],
|
||||||
|
)
|
||||||
|
|
||||||
|
return CourtCitation("unknown", "", "", "")
|
||||||
323
mcp-server/src/legal_mcp/services/court_fetch_orchestrator.py
Normal file
323
mcp-server/src/legal_mcp/services/court_fetch_orchestrator.py
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
"""X13 orchestrator — classify → fetch → ingest → record.
|
||||||
|
|
||||||
|
The single entry point (`fetch_and_ingest`) wires the three tiers to the
|
||||||
|
**canonical** precedent-ingest pipeline (INV-CF1 — no parallel ingest path)
|
||||||
|
and keeps the `court_fetch_jobs` row honest at every step (INV-CF2 — a job
|
||||||
|
always ends in an explicit terminal state, never a silent drop).
|
||||||
|
|
||||||
|
Tier routing (from `court_citation.classify`):
|
||||||
|
* ``skip`` — ועדת-ערר → never fetched; logged as a missing_precedent gap.
|
||||||
|
* ``supreme`` — Tier 0, in-process httpx (`court_fetch_supreme`).
|
||||||
|
* ``admin`` — Tier 1, the host-side stealth-browser service over loopback.
|
||||||
|
|
||||||
|
Fallback (INV-CF3): after ``MAX_AUTONOMOUS_ATTEMPTS`` autonomous failures the
|
||||||
|
job flips to ``manual`` and a missing_precedent row is opened so the chair
|
||||||
|
sees the gap and can solve the CAPTCHA live (VNC) or drop the file manually.
|
||||||
|
|
||||||
|
This module runs **in the local MCP server only** — `ingest_precedent` drives
|
||||||
|
halacha extraction via the local ``claude`` CLI (see `claude_session.py`). It
|
||||||
|
is invoked from the `court_verdict_fetch` MCP tool, not from the container.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from legal_mcp.services import court_citation, db
|
||||||
|
from legal_mcp.services.court_fetch_supreme import (
|
||||||
|
SupremeFetchError,
|
||||||
|
fetch_supreme_verdict,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# After this many autonomous failures, stop auto-retrying and escalate to a
|
||||||
|
# human (INV-CF3). Kept low — the .gov site shouldn't be hammered (INV-CF4).
|
||||||
|
MAX_AUTONOMOUS_ATTEMPTS = int(os.environ.get("COURT_FETCH_MAX_ATTEMPTS", "2"))
|
||||||
|
|
||||||
|
# The host-side Tier-1 browser service (pm2). It binds the docker0 bridge
|
||||||
|
# gateway (10.0.1.1) — same as legal-chat-service — so both the host MCP server
|
||||||
|
# and containers can reach it; the host reaches 10.0.1.1 as a local interface.
|
||||||
|
# Override with COURT_FETCH_SERVICE_URL.
|
||||||
|
COURT_FETCH_SERVICE_URL = os.environ.get(
|
||||||
|
"COURT_FETCH_SERVICE_URL", "http://10.0.1.1:8771"
|
||||||
|
)
|
||||||
|
_SHARED_SECRET = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
|
||||||
|
_TIER1_TIMEOUT_S = float(os.environ.get("COURT_FETCH_TIER1_TIMEOUT_S", "300"))
|
||||||
|
|
||||||
|
# Provenance level by tier — Supreme rulings are binding; admin-court verdicts
|
||||||
|
# are administrative (set is_binding conservatively True, chair can downgrade).
|
||||||
|
_LEVEL_BY_TIER = {"supreme": "עליון", "admin": "מנהלי"}
|
||||||
|
|
||||||
|
|
||||||
|
class _Tier1Unavailable(RuntimeError):
|
||||||
|
"""The host browser service is not reachable / not configured."""
|
||||||
|
|
||||||
|
|
||||||
|
async def _ingest_bytes(
|
||||||
|
*, content: bytes, filename: str, citation: str, tier: str,
|
||||||
|
court: str, source_url: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Stage bytes to a temp file and run the canonical ingest (INV-CF1)."""
|
||||||
|
from legal_mcp.services import precedent_library
|
||||||
|
|
||||||
|
suffix = Path(filename).suffix or ".pdf"
|
||||||
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
prefix="court_fetch_", suffix=suffix, delete=False
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
tmp.write(content)
|
||||||
|
tmp.flush()
|
||||||
|
tmp.close()
|
||||||
|
result = await precedent_library.ingest_precedent(
|
||||||
|
file_path=tmp.name,
|
||||||
|
citation=citation,
|
||||||
|
court=court,
|
||||||
|
source_type="court_ruling", # INV-CF6
|
||||||
|
precedent_level=_LEVEL_BY_TIER.get(tier, ""),
|
||||||
|
is_binding=True,
|
||||||
|
)
|
||||||
|
# Stamp provenance on the new case_law row (INV-CF7).
|
||||||
|
case_law_id = result.get("case_law_id")
|
||||||
|
if case_law_id and source_url:
|
||||||
|
try:
|
||||||
|
await db.update_case_law(
|
||||||
|
UUID(str(case_law_id)), source_url=source_url
|
||||||
|
)
|
||||||
|
except Exception: # provenance is best-effort, never blocks ingest
|
||||||
|
logger.warning("could not stamp source_url on %s", case_law_id)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_tier1_admin(cit: court_citation.CourtCitation) -> dict:
|
||||||
|
"""Call the host-side browser service to fetch an admin-court verdict.
|
||||||
|
|
||||||
|
Returns the service's JSON: ``{ok, content_b64, filename, source_url,
|
||||||
|
court, reason}``. Raises ``_Tier1Unavailable`` if the service can't be
|
||||||
|
reached, ``SupremeFetchError``-style RuntimeError on a fetch failure the
|
||||||
|
service reports.
|
||||||
|
"""
|
||||||
|
if not (cit.file_number and cit.month and cit.year):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"מספר-תיק {cit.case_number_norm} אינו בפורמט נט-המשפט (תיק-חודש-שנה)"
|
||||||
|
)
|
||||||
|
headers = {"Authorization": f"Bearer {_SHARED_SECRET}"} if _SHARED_SECRET else {}
|
||||||
|
payload = {
|
||||||
|
"file_number": cit.file_number,
|
||||||
|
"month": cit.month,
|
||||||
|
"year": cit.year,
|
||||||
|
"case_number": cit.case_number_norm,
|
||||||
|
"court": cit.court_prefix,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIER1_TIMEOUT_S) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{COURT_FETCH_SERVICE_URL}/fetch", json=payload, headers=headers
|
||||||
|
)
|
||||||
|
except httpx.ConnectError as e:
|
||||||
|
raise _Tier1Unavailable(
|
||||||
|
f"שירות-האחזור (legal-court-fetch-service) אינו זמין ב-"
|
||||||
|
f"{COURT_FETCH_SERVICE_URL}: {e}"
|
||||||
|
) from e
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"שירות-האחזור החזיר {resp.status_code}: {resp.text[:200]}")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_and_ingest(
|
||||||
|
citation: str, *, digest_id: UUID | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""Classify a citation, fetch the verdict, ingest it, and record the job.
|
||||||
|
|
||||||
|
Idempotent on the canonical case number (INV-CF5): a case already fetched
|
||||||
|
(job ``done``) is returned without re-fetching.
|
||||||
|
"""
|
||||||
|
cit = court_citation.classify(citation)
|
||||||
|
|
||||||
|
# ── skip: ועדת-ערר — never auto-fetched (INV-CF6). Surface as a gap. ──
|
||||||
|
if cit.tier == "skip":
|
||||||
|
await _open_gap(citation, reason="ועדת-ערר — לא ניתן לאחזור ציבורי (נדרש נבו)")
|
||||||
|
return {"status": "skipped", "tier": "skip", "citation": citation,
|
||||||
|
"reason": "appeals_committee — needs Nevo"}
|
||||||
|
if cit.tier == "unknown" or not cit.case_number_norm:
|
||||||
|
return {"status": "unrecognized", "citation": citation}
|
||||||
|
|
||||||
|
# ── idempotent job row ──
|
||||||
|
job = await db.court_fetch_job_upsert(
|
||||||
|
case_number_norm=cit.case_number_norm,
|
||||||
|
citation_raw=citation,
|
||||||
|
tier=cit.tier,
|
||||||
|
court=cit.court_prefix,
|
||||||
|
digest_id=digest_id,
|
||||||
|
)
|
||||||
|
if job.get("status") == "done":
|
||||||
|
return {"status": "already_done", "job": job}
|
||||||
|
if job.get("status") == "manual":
|
||||||
|
return {"status": "awaiting_manual", "job": job}
|
||||||
|
|
||||||
|
job_id = UUID(str(job["id"]))
|
||||||
|
await db.court_fetch_job_update(job_id, status="running", bump_attempts=True)
|
||||||
|
|
||||||
|
# ── fetch ──
|
||||||
|
# Route by what the number lets us do, not just the court prefix: נט המשפט
|
||||||
|
# (Tier 1) serves ALL courts — Supreme included — as long as the citation
|
||||||
|
# carries a נט-format triple (file-month-year). Validated live on both
|
||||||
|
# district (עת"מ 43830-12-24) and Supreme (בר"מ 72182-06-25). Only a serial-
|
||||||
|
# only Supreme number (e.g. עע"מ 5886/24, no month) can't be looked up that
|
||||||
|
# way → fall through to Tier 0 (supremedecisions).
|
||||||
|
has_net_format = bool(cit.file_number and cit.month and cit.year)
|
||||||
|
try:
|
||||||
|
if has_net_format:
|
||||||
|
res = await _fetch_tier1_admin(cit)
|
||||||
|
if not res.get("ok"):
|
||||||
|
raise RuntimeError(res.get("reason") or "אחזור נכשל")
|
||||||
|
import base64
|
||||||
|
content = base64.b64decode(res["content_b64"])
|
||||||
|
filename = res.get("filename") or f"{cit.case_number_norm}.pdf"
|
||||||
|
source_url = res.get("source_url", "")
|
||||||
|
court = res.get("court") or cit.court_prefix
|
||||||
|
elif cit.tier == "supreme":
|
||||||
|
fetched = await fetch_supreme_verdict(
|
||||||
|
citation=citation, case_number_norm=cit.case_number_norm
|
||||||
|
)
|
||||||
|
content, filename = fetched.content, fetched.filename
|
||||||
|
source_url, court = fetched.source_url, fetched.court
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"מספר-תיק {cit.case_number_norm} אינו בפורמט נט-המשפט ואינו עליון — "
|
||||||
|
"אין מסלול-אחזור ציבורי"
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001 — any fetch error is recorded, never
|
||||||
|
# left hanging in 'running' (INV-CF2). _record_failure escalates to
|
||||||
|
# 'manual' after MAX_AUTONOMOUS_ATTEMPTS (INV-CF3).
|
||||||
|
return await _record_failure(job_id, cit, citation, str(e))
|
||||||
|
|
||||||
|
# ── ingest into the canonical pipeline (INV-CF1) ──
|
||||||
|
try:
|
||||||
|
result = await _ingest_bytes(
|
||||||
|
content=content, filename=filename, citation=citation,
|
||||||
|
tier=cit.tier, court=court, source_url=source_url,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001 — recorded, never swallowed (INV-CF2)
|
||||||
|
logger.exception("ingest failed for %s", cit.case_number_norm)
|
||||||
|
return await _record_failure(job_id, cit, citation, f"קליטה נכשלה: {e}")
|
||||||
|
|
||||||
|
case_law_id = result.get("case_law_id")
|
||||||
|
await db.court_fetch_job_update(
|
||||||
|
job_id, status="done",
|
||||||
|
case_law_id=UUID(str(case_law_id)) if case_law_id else None,
|
||||||
|
source_url=source_url, error="",
|
||||||
|
)
|
||||||
|
# Close the digest gap (INV-DIG3): if this fetch traces back to a digest,
|
||||||
|
# link it to the freshly-ingested ruling. Best-effort; never fails the job.
|
||||||
|
link_digest_id = digest_id or job.get("digest_id")
|
||||||
|
if case_law_id and link_digest_id:
|
||||||
|
try:
|
||||||
|
await db.link_digest_to_case_law(link_digest_id, UUID(str(case_law_id)))
|
||||||
|
logger.info("linked digest %s → case_law %s", link_digest_id, case_law_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("could not relink digest %s after fetch", link_digest_id)
|
||||||
|
|
||||||
|
# Close any open missing-precedent gap this fetch fills (the citation graph
|
||||||
|
# often records the same ruling as a gap). Best-effort.
|
||||||
|
if case_law_id:
|
||||||
|
await _close_matching_gaps(cit.case_number_norm, UUID(str(case_law_id)))
|
||||||
|
|
||||||
|
return {"status": "done", "tier": cit.tier, "case_law_id": case_law_id,
|
||||||
|
"citation": citation, "source_url": source_url, "ingest": result}
|
||||||
|
|
||||||
|
|
||||||
|
async def _close_matching_gaps(case_number_norm: str, case_law_id: UUID) -> None:
|
||||||
|
"""Close open missing_precedents whose citation matches the fetched case."""
|
||||||
|
try:
|
||||||
|
gaps = await db.list_missing_precedents(status="open", limit=500)
|
||||||
|
for g in gaps:
|
||||||
|
if court_citation.normalize_case_number(g.get("citation", "")) == case_number_norm:
|
||||||
|
await db.close_missing_precedent(
|
||||||
|
UUID(str(g["id"])), linked_case_law_id=case_law_id,
|
||||||
|
status="closed", notes="נקלט אוטומטית דרך אחזור-פסיקה (X13)",
|
||||||
|
)
|
||||||
|
logger.info("closed missing_precedent %s", g["id"])
|
||||||
|
except Exception:
|
||||||
|
logger.warning("could not close gaps for %s", case_number_norm)
|
||||||
|
|
||||||
|
|
||||||
|
# Politeness between consecutive court fetches in a drain (INV-CF4) — serial,
|
||||||
|
# spaced. Mirrors the precedent-extraction queue cadence.
|
||||||
|
_INTER_FETCH_COOLDOWN_S = float(os.environ.get("COURT_FETCH_DRAIN_COOLDOWN_S", "20"))
|
||||||
|
|
||||||
|
|
||||||
|
async def drain_pending(limit: int = 10) -> dict:
|
||||||
|
"""Process queued court-fetch jobs (status pending/failed) serially.
|
||||||
|
|
||||||
|
Drains the ``court_fetch_jobs`` queue the digest trigger fills — fetch +
|
||||||
|
ingest each, link back to its digest. Serial with a cooldown (INV-CF4); a
|
||||||
|
job that fails is recorded and retried next drain until it escalates to
|
||||||
|
``manual`` (INV-CF3). Local-only (runs the ingest pipeline / claude CLI).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
jobs = await db.court_fetch_job_list(status="pending", limit=limit)
|
||||||
|
jobs += await db.court_fetch_job_list(status="failed", limit=limit)
|
||||||
|
seen, queue = set(), []
|
||||||
|
for j in jobs:
|
||||||
|
k = j["case_number_norm"]
|
||||||
|
if k not in seen:
|
||||||
|
seen.add(k); queue.append(j)
|
||||||
|
results = []
|
||||||
|
for i, j in enumerate(queue[:limit]):
|
||||||
|
if i:
|
||||||
|
await asyncio.sleep(_INTER_FETCH_COOLDOWN_S)
|
||||||
|
digest_id = j.get("digest_id")
|
||||||
|
try:
|
||||||
|
r = await fetch_and_ingest(j["citation_raw"], digest_id=digest_id)
|
||||||
|
except Exception as e: # noqa: BLE001 — recorded per-job, never aborts the drain
|
||||||
|
logger.exception("drain item failed: %s", j["case_number_norm"])
|
||||||
|
r = {"status": "error", "citation": j["citation_raw"], "error": str(e)}
|
||||||
|
results.append(r)
|
||||||
|
done = sum(1 for r in results if r.get("status") in ("done", "already_done"))
|
||||||
|
return {"processed": len(results), "done": done, "results": results}
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_failure(
|
||||||
|
job_id: UUID, cit: court_citation.CourtCitation, citation: str, err: str
|
||||||
|
) -> dict:
|
||||||
|
"""Record a fetch/ingest failure; escalate to manual after N attempts (INV-CF3)."""
|
||||||
|
job = await db.court_fetch_job_get(cit.case_number_norm)
|
||||||
|
attempts = (job or {}).get("attempts", 1)
|
||||||
|
if attempts >= MAX_AUTONOMOUS_ATTEMPTS:
|
||||||
|
await db.court_fetch_job_update(job_id, status="manual", error=err)
|
||||||
|
await _open_gap(
|
||||||
|
citation,
|
||||||
|
reason=f"אחזור אוטונומי נכשל ({attempts} נסיונות) — נדרשת הורדה ידנית. {err}",
|
||||||
|
)
|
||||||
|
logger.warning("court fetch escalated to manual: %s — %s", citation, err)
|
||||||
|
return {"status": "manual", "citation": citation, "error": err,
|
||||||
|
"attempts": attempts}
|
||||||
|
await db.court_fetch_job_update(job_id, status="failed", error=err)
|
||||||
|
logger.warning("court fetch failed (will retry): %s — %s", citation, err)
|
||||||
|
return {"status": "failed", "citation": citation, "error": err,
|
||||||
|
"attempts": attempts}
|
||||||
|
|
||||||
|
|
||||||
|
async def _open_gap(citation: str, *, reason: str) -> None:
|
||||||
|
"""Open a missing_precedent gap so the chair sees it (INV-CF2/CF3).
|
||||||
|
|
||||||
|
Best-effort + de-duplicated by the missing_precedents layer; a failure
|
||||||
|
here is logged, never raised (it must not mask the original outcome).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await db.create_missing_precedent(citation=citation, notes=reason)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("could not open missing_precedent for %s", citation)
|
||||||
197
mcp-server/src/legal_mcp/services/court_fetch_supreme.py
Normal file
197
mcp-server/src/legal_mcp/services/court_fetch_supreme.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""Tier 0 — Supreme Court verdict fetcher (X13), via supremedecisions.court.gov.il.
|
||||||
|
|
||||||
|
Pulls a published Supreme Court verdict PDF from the **public** decisions portal
|
||||||
|
— no smart-card, no CAPTCHA, no browser (pure httpx). Used for serial-format
|
||||||
|
citations (בג"ץ/בר"מ/עע"מ NNNN/YY) that have no נט-format triple and so can't go
|
||||||
|
through the Tier-1 נט-המשפט flow.
|
||||||
|
|
||||||
|
The portal is an AngularJS SPA over a small ASP.NET JSON API, reverse-engineered
|
||||||
|
and validated live (2026-06-08 on בג"ץ 3483/05 → 75 KB PDF). The flow:
|
||||||
|
|
||||||
|
POST Home/SearchVerdicts
|
||||||
|
body: {"document": {"Year": "YYYY", "CaseNum": "NNNN", "Month": {},
|
||||||
|
"dateType": 1, "publishDate": 8,
|
||||||
|
"SearchText": [<empty clause>],
|
||||||
|
"OldMainNumFormat": true}, "lan": 1}
|
||||||
|
→ {"data": [{Path, FileName, CaseName, Type, Pages, VerdictDt, ...}, ...]}
|
||||||
|
GET Home/Download?path=<Path>&fileName=<FileName>&type=4 → the verdict PDF
|
||||||
|
|
||||||
|
Two things are required to get JSON instead of an F5 WAF block (verified):
|
||||||
|
* the **X-Requested-With: XMLHttpRequest** header on every AJAX call;
|
||||||
|
* a **complete** browser header set (UA + Accept + Accept-Language).
|
||||||
|
|
||||||
|
A case can have many documents (interim החלטות + the final פסק דין). We pick the
|
||||||
|
verdict: prefer a record whose Type contains "פסק דין", else the most-paginated /
|
||||||
|
latest one. Politeness (INV-CF4): serial, with a cooldown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime as _dt
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BASE = "https://supremedecisions.court.gov.il"
|
||||||
|
|
||||||
|
_HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"Accept-Language": "he-IL,he;q=0.9,en;q=0.8",
|
||||||
|
"X-Requested-With": "XMLHttpRequest", # required — F5 WAF blocks AJAX without it
|
||||||
|
"Referer": _BASE + "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
_REQUEST_TIMEOUT_S = float(os.environ.get("COURT_FETCH_HTTP_TIMEOUT_S", "30"))
|
||||||
|
_INTER_REQUEST_COOLDOWN_S = float(os.environ.get("COURT_FETCH_COOLDOWN_S", "2"))
|
||||||
|
_DOC_TYPE_PDF = "4"
|
||||||
|
|
||||||
|
# Empty search clause the portal expects inside the document.
|
||||||
|
_EMPTY_CLAUSE = {
|
||||||
|
"Text": "", "textOperator": 1, "option": 2, "Inverted": False,
|
||||||
|
"Synonym": False, "NearDistance": 3, "MatchOrder": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FetchedVerdict:
|
||||||
|
"""A downloaded verdict file held in memory, ready for ingest."""
|
||||||
|
|
||||||
|
def __init__(self, content: bytes, filename: str, source_url: str,
|
||||||
|
court: str = "בית המשפט העליון", case_name: str = ""):
|
||||||
|
self.content = content
|
||||||
|
self.filename = filename
|
||||||
|
self.source_url = source_url
|
||||||
|
self.court = court
|
||||||
|
self.case_name = case_name
|
||||||
|
|
||||||
|
|
||||||
|
class SupremeFetchError(RuntimeError):
|
||||||
|
"""The public portal returned an unexpected shape / no document. Carries a
|
||||||
|
Hebrew reason for the job row (INV-CF2)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _four_digit_year(yy: str) -> str:
|
||||||
|
"""2-digit citation year → 4-digit. Pivot on the current year: a 2-digit
|
||||||
|
value above (this year + 4) is last century. e.g. 05→2005, 87→1987, 16→2016."""
|
||||||
|
yy = re.sub(r"\D", "", yy or "")
|
||||||
|
if len(yy) == 4:
|
||||||
|
return yy
|
||||||
|
if len(yy) != 2:
|
||||||
|
return yy
|
||||||
|
n = int(yy)
|
||||||
|
cutoff = (_dt.date.today().year % 100) + 4
|
||||||
|
return f"20{yy}" if n <= cutoff else f"19{yy}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_serial(case_number_norm: str, citation: str) -> tuple[str, str]:
|
||||||
|
"""Extract (CaseNum, YYYY) from a serial citation like 'בג"ץ 3483/05'.
|
||||||
|
|
||||||
|
Works off the normalized number (e.g. '3483-05') with the raw citation as a
|
||||||
|
fallback. Raises SupremeFetchError if it can't find a NNNN/YY pair.
|
||||||
|
"""
|
||||||
|
m = re.search(r"(\d{1,5})[-/](\d{2,4})\b", case_number_norm or "")
|
||||||
|
if not m:
|
||||||
|
m = re.search(r"(\d{1,5})/(\d{2,4})", citation or "")
|
||||||
|
if not m:
|
||||||
|
raise SupremeFetchError(
|
||||||
|
f"לא ניתן לפרק '{citation}' למספר-תיק/שנה (פורמט עליון סדרתי)"
|
||||||
|
)
|
||||||
|
return m.group(1), _four_digit_year(m.group(2))
|
||||||
|
|
||||||
|
|
||||||
|
def _dt_key(r: dict) -> int:
|
||||||
|
m = re.search(r"/Date\((\d+)", str(r.get("VerdictDt") or ""))
|
||||||
|
return int(m.group(1)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _rank_candidates(records: list[dict]) -> list[dict]:
|
||||||
|
"""Order a case's documents by how good a corpus target each is, best first.
|
||||||
|
|
||||||
|
Preference: the reasoned ruling (Type contains 'פסק') over interim החלטות;
|
||||||
|
then more pages (substantive over one-liners); then most recent. We return
|
||||||
|
a *ranked list*, not one pick, because the formally-labeled פסק-דין is
|
||||||
|
sometimes a published-report ('s'-prefix) file that the free Download
|
||||||
|
endpoint blocks (WAF) — the caller tries each until one downloads as a PDF.
|
||||||
|
Records without a Path/FileName are dropped.
|
||||||
|
"""
|
||||||
|
usable = [r for r in records if r.get("Path") and r.get("FileName")]
|
||||||
|
|
||||||
|
def _score(r: dict) -> tuple:
|
||||||
|
is_verdict = 1 if "פסק" in str(r.get("Type") or "") else 0
|
||||||
|
return (is_verdict, int(r.get("Pages") or 0), _dt_key(r))
|
||||||
|
|
||||||
|
return sorted(usable, key=_score, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_supreme_verdict(
|
||||||
|
*, citation: str, case_number_norm: str
|
||||||
|
) -> FetchedVerdict:
|
||||||
|
"""Fetch a Supreme Court verdict PDF by serial citation. Raises on failure."""
|
||||||
|
case_num, yyyy = _parse_serial(case_number_norm, citation)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
http2=False, headers=_HEADERS, timeout=_REQUEST_TIMEOUT_S,
|
||||||
|
follow_redirects=True,
|
||||||
|
) as client:
|
||||||
|
document = {
|
||||||
|
"Year": yyyy, "CaseNum": case_num, "Month": {},
|
||||||
|
"dateType": 1, "publishDate": 8, "SearchText": [dict(_EMPTY_CLAUSE)],
|
||||||
|
"OldMainNumFormat": True,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S)
|
||||||
|
resp = await client.post(
|
||||||
|
f"{_BASE}/Home/SearchVerdicts", json={"document": document, "lan": 1}
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise SupremeFetchError(f"חיפוש בפורטל העליון נכשל עבור {citation}: {e}") from e
|
||||||
|
except ValueError as e:
|
||||||
|
raise SupremeFetchError(f"תשובת-חיפוש לא-JSON מהפורטל עבור {citation}") from e
|
||||||
|
|
||||||
|
records = payload.get("data") if isinstance(payload, dict) else None
|
||||||
|
candidates = _rank_candidates(records or [])
|
||||||
|
if not candidates:
|
||||||
|
raise SupremeFetchError(
|
||||||
|
f"לא נמצא מסמך-פסק עבור {citation} בפורטל העליון "
|
||||||
|
f"(תיק {case_num}/{yyyy[-2:]}; ייתכן שאינו פורסם או טרם דיגיטציה)."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try documents best-first until one downloads as a real PDF. The
|
||||||
|
# formally-labeled פסק-דין is sometimes a published-report file the free
|
||||||
|
# Download endpoint blocks (WAF) — fall back to the next substantive doc.
|
||||||
|
last_reason = ""
|
||||||
|
for rec in candidates[:6]:
|
||||||
|
path, fname = str(rec["Path"]), str(rec["FileName"])
|
||||||
|
qs = urllib.parse.urlencode(
|
||||||
|
{"path": path, "fileName": fname, "type": _DOC_TYPE_PDF}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S)
|
||||||
|
dl = await client.get(f"{_BASE}/Home/Download?{qs}")
|
||||||
|
dl.raise_for_status()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
last_reason = f"הורדה נכשלה ({e})"
|
||||||
|
continue
|
||||||
|
if dl.content[:4] == b"%PDF":
|
||||||
|
return FetchedVerdict(
|
||||||
|
content=dl.content,
|
||||||
|
filename=f"{case_number_norm}.pdf",
|
||||||
|
source_url=f"{_BASE}/Home/Download?{qs}",
|
||||||
|
case_name=str(rec.get("CaseName") or ""),
|
||||||
|
)
|
||||||
|
last_reason = f"מסמך {fname} חסום/לא-PDF ({len(dl.content)}B)"
|
||||||
|
|
||||||
|
raise SupremeFetchError(
|
||||||
|
f"אף מסמך של {citation} לא ירד כ-PDF ({len(candidates)} מועמדים) — {last_reason}"
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
422
mcp-server/src/legal_mcp/services/digest_library.py
Normal file
422
mcp-server/src/legal_mcp/services/digest_library.py
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
"""Orchestrator for the Digests radar (X12).
|
||||||
|
|
||||||
|
A digest ("כל יום" daily one-pager) is a SECONDARY source that POINTS at a
|
||||||
|
ruling — it is never cited in a decision (INV-DIG1) and never enters the
|
||||||
|
precedent/halacha pipeline (INV-DIG2). Ingest reuses only ATOMIC services
|
||||||
|
(extract_text, embeddings), NOT the canonical ``ingest.ingest_document``.
|
||||||
|
|
||||||
|
Two intake paths share one enrichment core:
|
||||||
|
|
||||||
|
- ``ingest_digest`` (local/MCP, e.g. batch script) — does everything
|
||||||
|
synchronously: stage → extract_text → create →
|
||||||
|
LLM enrich → embed → autolink → completed.
|
||||||
|
- ``create_pending_digest`` (CONTAINER-SAFE — the web upload) — stage →
|
||||||
|
extract_text → create row with status='pending'.
|
||||||
|
No LLM, no embedding. ``process_pending_digests``
|
||||||
|
(local/MCP) drains the queue and enriches.
|
||||||
|
|
||||||
|
claude_session rule: ``digest_metadata_extractor`` (local CLI) is imported
|
||||||
|
LAZILY inside the enrichment core only, so this module stays import-safe from
|
||||||
|
the FastAPI container for create_pending / search / list / link / delete
|
||||||
|
(DB + voyage only — voyage embedding only runs in the local enrich path).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp import config
|
||||||
|
from legal_mcp.services import db, embeddings, extractor, ingest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ProgressCb = Callable[[str, int, str], Awaitable[None]]
|
||||||
|
|
||||||
|
DIGEST_LIBRARY_DIR = Path(config.DATA_DIR) / "digests"
|
||||||
|
|
||||||
|
_VALID_PRACTICE_AREAS = frozenset(
|
||||||
|
{"", "rishuy_uvniya", "betterment_levy", "compensation_197"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _noop_progress(_status: str, _percent: int, _msg: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_date(v) -> date | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
if isinstance(v, date):
|
||||||
|
return v
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(v[:10])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _embedding_text(row: dict) -> str:
|
||||||
|
"""The single vector indexes the digest as an atomic discovery unit."""
|
||||||
|
parts = [
|
||||||
|
row.get("concept_tag", ""),
|
||||||
|
row.get("headline_holding", ""),
|
||||||
|
row.get("summary", ""),
|
||||||
|
row.get("analysis_text", ""),
|
||||||
|
]
|
||||||
|
return "\n".join(p for p in parts if p).strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def try_autolink(digest_id: UUID | str, underlying_citation: str) -> str | None:
|
||||||
|
"""Best-effort link of a digest to the underlying ruling in case_law
|
||||||
|
(INV-DIG3). Returns the case_law_id (str) if linked, else None. Never raises."""
|
||||||
|
citation = (underlying_citation or "").strip()
|
||||||
|
if not citation:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
match = await db.find_case_law_by_citation_fuzzy(citation)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("digest try_autolink lookup failed for %r: %s", citation, e)
|
||||||
|
return None
|
||||||
|
if not match:
|
||||||
|
# Gap (INV-DIG3): the underlying ruling isn't in the corpus. If it's a
|
||||||
|
# court verdict (not ועדת-ערר), enqueue an X13 auto-fetch job so the gap
|
||||||
|
# is actionable instead of silently dropped (INV-CF2). Never raises.
|
||||||
|
await _enqueue_court_fetch(digest_id, citation)
|
||||||
|
return None
|
||||||
|
await db.link_digest_to_case_law(digest_id, match["id"])
|
||||||
|
return str(match["id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _enqueue_court_fetch(digest_id: UUID | str, citation: str) -> None:
|
||||||
|
"""Queue an X13 court-verdict fetch for an unlinked digest citation.
|
||||||
|
|
||||||
|
Court rulings (supreme/admin) → a ``court_fetch_jobs`` row drained later by
|
||||||
|
``court_fetch_drain``. ועדת-ערר (skip) is left alone — it needs Nevo and is
|
||||||
|
surfaced through the normal missing-precedent path, not auto-fetch.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from legal_mcp.services import court_citation
|
||||||
|
cit = court_citation.classify(citation)
|
||||||
|
if cit.tier not in ("supreme", "admin"):
|
||||||
|
return
|
||||||
|
await db.court_fetch_job_upsert(
|
||||||
|
case_number_norm=cit.case_number_norm,
|
||||||
|
citation_raw=citation,
|
||||||
|
tier=cit.tier,
|
||||||
|
court=cit.court_prefix,
|
||||||
|
digest_id=UUID(str(digest_id)),
|
||||||
|
)
|
||||||
|
logger.info("digest %s: enqueued court-fetch for %r (tier=%s)",
|
||||||
|
digest_id, citation, cit.tier)
|
||||||
|
except Exception as e: # never break digest ingest
|
||||||
|
logger.warning("digest court-fetch enqueue failed for %r: %s", citation, e)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Container-safe creation (web upload) — no LLM, no embedding ──────
|
||||||
|
|
||||||
|
async def create_pending_digest(
|
||||||
|
*,
|
||||||
|
file_path: str | Path,
|
||||||
|
yomon_number: str = "",
|
||||||
|
digest_date: date | str | None = None,
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
progress: ProgressCb | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Stage the file, extract text (PyMuPDF — container-safe), and create a
|
||||||
|
digest row with extraction_status='pending'. The LLM metadata extraction,
|
||||||
|
embedding, and autolink are deferred to ``process_pending_digests`` (local).
|
||||||
|
|
||||||
|
Returns {status, digest_id, extraction_status} or {status:'exists', ...}.
|
||||||
|
Idempotent on content_hash (INV-G3).
|
||||||
|
"""
|
||||||
|
progress = progress or _noop_progress
|
||||||
|
if practice_area and practice_area not in _VALID_PRACTICE_AREAS:
|
||||||
|
raise ValueError(f"invalid practice_area: {practice_area!r}")
|
||||||
|
src = Path(file_path)
|
||||||
|
if not src.exists():
|
||||||
|
raise ValueError(f"file not found: {file_path}")
|
||||||
|
|
||||||
|
await progress("staging", 10, "מעתיק קובץ")
|
||||||
|
staged = await ingest._stage_file(src, DIGEST_LIBRARY_DIR, "incoming")
|
||||||
|
rel_path = str(staged.relative_to(config.DATA_DIR)) \
|
||||||
|
if str(staged).startswith(str(config.DATA_DIR)) else str(staged)
|
||||||
|
|
||||||
|
await progress("extracting_text", 50, "מחלץ טקסט")
|
||||||
|
raw_text, _pc, _off = await extractor.extract_text(str(staged))
|
||||||
|
raw_text = (raw_text or "").strip()
|
||||||
|
if not raw_text:
|
||||||
|
raise ValueError("no text extracted from digest")
|
||||||
|
|
||||||
|
content_hash = db._content_hash(raw_text)
|
||||||
|
existing = await db.get_digest_by_content_hash(content_hash)
|
||||||
|
if existing:
|
||||||
|
await progress("completed", 100, "יומון זהה כבר קיים")
|
||||||
|
return {"status": "exists", "digest_id": existing["id"],
|
||||||
|
"extraction_status": existing.get("extraction_status")}
|
||||||
|
|
||||||
|
record = await db.create_digest(
|
||||||
|
analysis_text=raw_text,
|
||||||
|
yomon_number=yomon_number.strip(),
|
||||||
|
digest_date=_coerce_date(digest_date),
|
||||||
|
practice_area=practice_area,
|
||||||
|
appeal_subtype=appeal_subtype.strip(),
|
||||||
|
subject_tags=list(subject_tags) if subject_tags else [],
|
||||||
|
source_document_path=rel_path,
|
||||||
|
extraction_status="pending",
|
||||||
|
)
|
||||||
|
await progress("queued", 100, "ממתין לעיבוד מקומי (LLM)")
|
||||||
|
return {"status": "pending", "digest_id": record["id"],
|
||||||
|
"extraction_status": "pending"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Local enrichment core (LLM + embed + autolink) ──────────────────
|
||||||
|
|
||||||
|
async def enrich_digest(digest_id: UUID | str, progress: ProgressCb | None = None) -> dict:
|
||||||
|
"""Run LLM metadata extraction over a digest's analysis_text, fill ONLY
|
||||||
|
empty fields (preserve user-supplied values), embed, autolink, complete.
|
||||||
|
|
||||||
|
**MCP-tool-only path** (uses the local LLM extractor). Idempotent.
|
||||||
|
"""
|
||||||
|
progress = progress or _noop_progress
|
||||||
|
row = await db.get_digest(digest_id)
|
||||||
|
if not row:
|
||||||
|
raise ValueError("digest not found")
|
||||||
|
analysis = (row.get("analysis_text") or "").strip()
|
||||||
|
if not analysis:
|
||||||
|
await db.update_digest(digest_id, extraction_status="failed")
|
||||||
|
return {"status": "no_text", "digest_id": str(digest_id)}
|
||||||
|
|
||||||
|
await db.update_digest(digest_id, extraction_status="processing")
|
||||||
|
await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (LLM)")
|
||||||
|
from legal_mcp.services import digest_metadata_extractor
|
||||||
|
extracted = await digest_metadata_extractor.extract(analysis)
|
||||||
|
|
||||||
|
# Fill only empty fields (preserve user-supplied values from the form).
|
||||||
|
fields: dict = {}
|
||||||
|
for key in ("yomon_number", "concept_tag", "headline_holding", "summary",
|
||||||
|
"underlying_citation", "underlying_court", "underlying_judge",
|
||||||
|
"practice_area", "appeal_subtype"):
|
||||||
|
if not (row.get(key) or "").strip() and extracted.get(key):
|
||||||
|
fields[key] = extracted[key]
|
||||||
|
if row.get("digest_date") is None and extracted.get("digest_date"):
|
||||||
|
fields["digest_date"] = extracted["digest_date"]
|
||||||
|
if row.get("underlying_date") is None and extracted.get("underlying_date"):
|
||||||
|
fields["underlying_date"] = extracted["underlying_date"]
|
||||||
|
if not (row.get("subject_tags") or []) and extracted.get("subject_tags"):
|
||||||
|
fields["subject_tags"] = extracted["subject_tags"]
|
||||||
|
# digest_kind classifies the issue (decision vs announcement). A successful
|
||||||
|
# extraction (any field returned) must end with a non-empty kind — that is the
|
||||||
|
# signal the drain self-heal uses to tell "enriched" from "failed". If the
|
||||||
|
# model omitted it, infer: a ruling citation → decision, else announcement.
|
||||||
|
if extracted and not (row.get("digest_kind") or "").strip():
|
||||||
|
kind = extracted.get("digest_kind")
|
||||||
|
if kind not in ("decision", "announcement", "other"):
|
||||||
|
cite = fields.get("underlying_citation") or row.get("underlying_citation") or ""
|
||||||
|
kind = "decision" if cite.strip() else "announcement"
|
||||||
|
fields["digest_kind"] = kind
|
||||||
|
|
||||||
|
if fields:
|
||||||
|
try:
|
||||||
|
await db.update_digest(digest_id, **fields)
|
||||||
|
except Exception as e:
|
||||||
|
# The same yomon issue can arrive as two different PDFs (re-sent /
|
||||||
|
# forwarded twice → different bytes → content_hash dedup misses it),
|
||||||
|
# but the yomon_number is unique. The extracted number then collides
|
||||||
|
# on uq_digests_yomon_number. This row is a duplicate of an already-
|
||||||
|
# ingested yomon → drop it so it isn't retried forever by the cron.
|
||||||
|
if "uq_digests_yomon_number" in str(e):
|
||||||
|
await db.delete_digest(digest_id)
|
||||||
|
logger.info(
|
||||||
|
"digest %s is a duplicate yomon (%s) — deleted",
|
||||||
|
digest_id, fields.get("yomon_number"),
|
||||||
|
)
|
||||||
|
return {"status": "duplicate", "digest_id": str(digest_id),
|
||||||
|
"yomon_number": fields.get("yomon_number")}
|
||||||
|
raise
|
||||||
|
merged = await db.get_digest(digest_id)
|
||||||
|
|
||||||
|
await progress("embedding", 75, "מחשב embedding")
|
||||||
|
emb_text = _embedding_text(merged)
|
||||||
|
if emb_text:
|
||||||
|
try:
|
||||||
|
vecs = await embeddings.embed_texts([emb_text], input_type="document")
|
||||||
|
if vecs:
|
||||||
|
await db.store_digest_embedding(digest_id, vecs[0])
|
||||||
|
except Exception as e: # surfaced, not swallowed (§6)
|
||||||
|
logger.warning("digest embedding failed for %s: %s", digest_id, e)
|
||||||
|
|
||||||
|
await progress("linking", 90, "מנסה לקשר לפסק המקורי")
|
||||||
|
linked_id = None
|
||||||
|
if not merged.get("linked_case_law_id"):
|
||||||
|
linked_id = await try_autolink(digest_id, merged.get("underlying_citation", ""))
|
||||||
|
|
||||||
|
await db.update_digest(digest_id, extraction_status="completed")
|
||||||
|
await progress("completed", 100, "הושלם")
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"digest_id": str(digest_id),
|
||||||
|
"yomon_number": merged.get("yomon_number", ""),
|
||||||
|
"underlying_citation": merged.get("underlying_citation", ""),
|
||||||
|
"linked_case_law_id": merged.get("linked_case_law_id") or linked_id,
|
||||||
|
"fields_filled": sorted(fields.keys()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def process_pending_digests(limit: int = 20) -> dict:
|
||||||
|
"""Drain the digest extraction queue (rows stamped extraction_status='pending'
|
||||||
|
by the web upload). Local/MCP only — runs the LLM enrichment per row.
|
||||||
|
Sequential (avoids LLM rate-limit storms), mirrors process_pending_extractions."""
|
||||||
|
pending = await db.list_pending_digests(limit=limit)
|
||||||
|
if not pending:
|
||||||
|
return {"status": "no_pending", "processed": 0, "results": []}
|
||||||
|
results = []
|
||||||
|
processed = 0
|
||||||
|
for row in pending:
|
||||||
|
did = row["id"]
|
||||||
|
try:
|
||||||
|
res = await enrich_digest(did)
|
||||||
|
processed += 1
|
||||||
|
results.append({"digest_id": str(did), "status": res.get("status"),
|
||||||
|
"linked": bool(res.get("linked_case_law_id"))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("process_pending_digests failed for %s: %s", did, e)
|
||||||
|
try:
|
||||||
|
await db.update_digest(did, extraction_status="failed")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("could not mark digest %s failed", did)
|
||||||
|
results.append({"digest_id": str(did), "status": "failed", "error": str(e)})
|
||||||
|
return {"status": "completed", "processed": processed,
|
||||||
|
"total_pending": len(pending), "results": results}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Full synchronous ingest (local/MCP, e.g. batch script) ──────────
|
||||||
|
|
||||||
|
async def ingest_digest(
|
||||||
|
*,
|
||||||
|
file_path: str | Path,
|
||||||
|
yomon_number: str = "",
|
||||||
|
digest_date: date | str | None = None,
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
progress: ProgressCb | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Ingest one digest synchronously. **MCP-tool-only** (uses the LLM).
|
||||||
|
|
||||||
|
Creates the row (with any user-supplied values) then enriches in place.
|
||||||
|
Idempotent on content_hash (INV-G3).
|
||||||
|
"""
|
||||||
|
progress = progress or _noop_progress
|
||||||
|
created = await create_pending_digest(
|
||||||
|
file_path=file_path, yomon_number=yomon_number, digest_date=digest_date,
|
||||||
|
practice_area=practice_area, appeal_subtype=appeal_subtype,
|
||||||
|
subject_tags=subject_tags, progress=progress,
|
||||||
|
)
|
||||||
|
if created.get("status") == "exists":
|
||||||
|
return created
|
||||||
|
digest_id = created["digest_id"]
|
||||||
|
enriched = await enrich_digest(digest_id, progress=progress)
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
# ── Linking (INV-DIG3) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def link_digest(digest_id: UUID | str, case_law_id: UUID | str) -> dict:
|
||||||
|
"""Manually link a digest to an underlying ruling (INV-DIG3). Idempotent."""
|
||||||
|
digest = await db.get_digest(digest_id)
|
||||||
|
if not digest:
|
||||||
|
raise ValueError("digest not found")
|
||||||
|
ruling = await db.get_case_law(
|
||||||
|
case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
|
||||||
|
)
|
||||||
|
if not ruling:
|
||||||
|
raise ValueError("case_law not found")
|
||||||
|
updated = await db.link_digest_to_case_law(digest_id, case_law_id)
|
||||||
|
return {
|
||||||
|
"linked": True,
|
||||||
|
"digest_id": str(digest_id),
|
||||||
|
"case_law_id": str(case_law_id),
|
||||||
|
"case_number": ruling.get("case_number"),
|
||||||
|
"digest": updated,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def relink_digest(digest_id: UUID | str) -> dict:
|
||||||
|
"""Re-run autolink for an unlinked digest. No-op if already linked / no match."""
|
||||||
|
digest = await db.get_digest(digest_id)
|
||||||
|
if not digest:
|
||||||
|
raise ValueError("digest not found")
|
||||||
|
if digest.get("linked_case_law_id"):
|
||||||
|
return {"linked": True, "digest_id": str(digest_id),
|
||||||
|
"case_law_id": digest["linked_case_law_id"], "changed": False}
|
||||||
|
linked_id = await try_autolink(digest_id, digest.get("underlying_citation", ""))
|
||||||
|
return {
|
||||||
|
"linked": linked_id is not None,
|
||||||
|
"digest_id": str(digest_id),
|
||||||
|
"case_law_id": linked_id,
|
||||||
|
"changed": linked_id is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def unlink_digest(digest_id: UUID | str) -> dict:
|
||||||
|
"""Clear a digest's link to the underlying ruling."""
|
||||||
|
updated = await db.link_digest_to_case_law(digest_id, None)
|
||||||
|
if updated is None:
|
||||||
|
raise ValueError("digest not found")
|
||||||
|
return {"unlinked": True, "digest_id": str(digest_id)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Read / search (container-safe: DB + voyage) ─────────────────────
|
||||||
|
|
||||||
|
async def search_digests(
|
||||||
|
query: str,
|
||||||
|
practice_area: str = "",
|
||||||
|
subject_tag: str = "",
|
||||||
|
concept_tag: str = "",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Semantic search over the digests radar. Container-safe (voyage + DB)."""
|
||||||
|
if not query.strip():
|
||||||
|
return []
|
||||||
|
query_vec = await embeddings.embed_query(query)
|
||||||
|
return await db.search_digests_semantic(
|
||||||
|
query_embedding=query_vec,
|
||||||
|
practice_area=practice_area,
|
||||||
|
subject_tag=subject_tag,
|
||||||
|
concept_tag=concept_tag,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_digest(digest_id: UUID | str) -> dict | None:
|
||||||
|
return await db.get_digest(digest_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_digests(
|
||||||
|
practice_area: str = "",
|
||||||
|
concept_tag: str = "",
|
||||||
|
linked: bool | None = None,
|
||||||
|
search: str = "",
|
||||||
|
publication: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
return await db.list_digests(
|
||||||
|
practice_area=practice_area, concept_tag=concept_tag, linked=linked,
|
||||||
|
search=search, publication=publication, limit=limit, offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_digest(digest_id: UUID | str, **fields) -> dict | None:
|
||||||
|
return await db.update_digest(digest_id, **fields)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_digest(digest_id: UUID | str) -> bool:
|
||||||
|
return await db.delete_digest(digest_id)
|
||||||
151
mcp-server/src/legal_mcp/services/digest_metadata_extractor.py
Normal file
151
mcp-server/src/legal_mcp/services/digest_metadata_extractor.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Auto-extract catalog metadata from a "כל יום" daily digest (X12).
|
||||||
|
|
||||||
|
A digest is a one-page secondary summary (Ofer Toister) of a single ruling.
|
||||||
|
This module reads its raw text and asks the local Claude CLI to extract the
|
||||||
|
fields the radar needs: yomon number, concept tag, headline holding, a short
|
||||||
|
summary, the UNDERLYING ruling's citation (the critical bridge field — INV-DIG3),
|
||||||
|
its court / date / judge, practice area and subject tags.
|
||||||
|
|
||||||
|
claude_session rule: this module imports ``claude_session`` (the local CLI),
|
||||||
|
so it is **MCP-tool-only** — never import it from the FastAPI container. It is
|
||||||
|
pulled in lazily inside ``digest_library.ingest_digest`` only.
|
||||||
|
|
||||||
|
Unlike ``precedent_metadata_extractor`` (which patches a DB row), this returns
|
||||||
|
a plain dict from raw text; ``digest_library`` decides how to merge/store it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date as date_type
|
||||||
|
|
||||||
|
from legal_mcp import config
|
||||||
|
from legal_mcp.config import parse_llm_json
|
||||||
|
from legal_mcp.services import claude_session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_VALID_PRACTICE_AREAS = {"", "rishuy_uvniya", "betterment_levy", "compensation_197"}
|
||||||
|
|
||||||
|
|
||||||
|
# Concatenated with f-strings at call time, NOT .format() — the JSON example
|
||||||
|
# below contains '{' / '}' which str.format would treat as placeholders and
|
||||||
|
# crash (same trap documented in precedent_metadata_extractor).
|
||||||
|
DIGEST_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. לפניך "יומון" — סיכום עמוד-אחד של משרד עפר טויסטר (עלון "כל יום")
|
||||||
|
על **החלטה/פסק דין** אחד, או על **עדכון/הודעה** (חקיקה, נוהל, הודעת-תכנון, ברכת-שנה) בתחום תכנון ובנייה / היטל השבחה / פיצויים (ס' 197). חלץ ממנו מטא-דאטה לקטלוג.
|
||||||
|
|
||||||
|
**אל תמציא** — שדה שלא מופיע בטקסט → השאר ריק (מחרוזת ריקה / מערך ריק).
|
||||||
|
|
||||||
|
## פלט נדרש
|
||||||
|
החזר JSON אחד (object — לא array), ללא markdown וללא הסברים:
|
||||||
|
|
||||||
|
{
|
||||||
|
"digest_kind": "סווג את הגיליון: 'decision' = סיכום פסק דין/החלטה (יש מראה-מקום בתחתית) · 'announcement' = עדכון/הודעה ללא הכרעה (חקיקה, נוהל, הודעת-תכנון, ברכה) · 'other' = אחר. **חובה למלא תמיד.**",
|
||||||
|
"yomon_number": "מספר היומון מהכותרת ('יומון מס' 5163' → '5163'). ספרות בלבד. אם אין — ריק.",
|
||||||
|
"digest_date_iso": "YYYY-MM-DD — תאריך גיליון היומון (בכותרת, למשל '7 ביוני 2026' → '2026-06-07').",
|
||||||
|
"concept_tag": "תג-המושג/הכותרת בראש העמוד (למשל 'שיקול הדעת המצומצם', 'Cherry-picking', או 'עדכונים לשנה החדשה' בעדכון). ביטוי קצר אחד. **חלץ תמיד — קיים לכל סוג גיליון.**",
|
||||||
|
"headline_holding": "הכותרת המודגשת מתחת לתג — משפט אחד שמסכם את עיקר הגיליון (מה נקבע בהחלטה, או נושא העדכון). **חלץ תמיד.**",
|
||||||
|
"summary": "תקציר ניטרלי 2-3 משפטים בגוף שלישי: בהחלטה — מה הייתה השאלה ומה הוכרע; בעדכון — מה תוכן/משמעות העדכון. בלי שיפוט. **חלץ תמיד.**",
|
||||||
|
"underlying_citation": "**רק ל-decision** — מראה-המקום של פסק הדין/ההחלטה המקורי, כפי שמופיע בתחתית היומון, מילה במילה (למשל 'עת\\"מ 46111-12-22 יכין-אפק בע\\"מ נ' הוועדה המחוזית'). בעדכון/הודעה — ריק. זהו השדה הקריטי ל-decision — חלץ אותו במלואו ובדיוק.",
|
||||||
|
"underlying_court": "הערכאה שנתנה את פסק הדין המקורי (למשל 'בית המשפט לעניינים מנהליים מרכז-לוד', 'ועדת הערר מחוז ירושלים').",
|
||||||
|
"underlying_date_iso": "YYYY-MM-DD — תאריך מתן פסק הדין/ההחלטה המקורי (לרוב 'ניתן ביום DD.M.YY' בתחתית). שים לב: זה שונה מתאריך גיליון היומון!",
|
||||||
|
"underlying_judge": "שם השופט/ת או יו\\"ר ההרכב שנתן את פסק הדין המקורי (למשל 'יעל טויסטר ישראלי'). בלי תארים ('עו\\"ד', 'כב' השופט').",
|
||||||
|
"practice_area": "אחד מ-3: 'rishuy_uvniya' (רישוי ובנייה/הקלות/שימוש חורג) / 'betterment_levy' (היטל השבחה) / 'compensation_197' (פיצויים ס'197). אם לא ברור — ריק.",
|
||||||
|
"appeal_subtype": "תת-סוג קצר אם בולט (למשל 'הקלה', 'שיקול דעת הוועדה', 'מימוש במכר'). אחרת ריק.",
|
||||||
|
"subject_tags": ["3-7 תגיות בעברית snake_case (שיקול_דעת, הקלה, ועדה_מחוזית, היטל_השבחה, ...)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
## כללי איכות
|
||||||
|
1. **digest_kind** — חובה. אם יש מראה-מקום של פסק דין/החלטה בתחתית → 'decision'. אם זה עדכון/הודעה/נוהל/ברכה ללא הכרעה → 'announcement'.
|
||||||
|
2. **concept_tag / headline_holding / summary** — חלץ **תמיד**, לכל סוג גיליון (גם עדכון). אלה לא ייחודיים להחלטות.
|
||||||
|
3. **underlying_citation** — רק ל-decision; הוא הגשר לפסק הדין. בעדכון — השאר ריק (זה תקין, לא חוסר).
|
||||||
|
4. **הבחן בין שני התאריכים**: digest_date_iso = תאריך גיליון היומון (בכותרת); underlying_date_iso = מועד מתן פסק הדין (בתחתית, 'ניתן ביום...'). אל תבלבל.
|
||||||
|
5. **subject_tags** — snake_case, תחום ועדת ערר תכנון ובנייה בלבד.
|
||||||
|
6. אם רכיב לא מופיע בבירור — השאר את אותו שדה ריק. אל תנחש.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_str(result: dict, key: str) -> str:
|
||||||
|
v = result.get(key)
|
||||||
|
return v.strip() if isinstance(v, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_date(result: dict, key: str) -> date_type | None:
|
||||||
|
v = result.get(key)
|
||||||
|
if not isinstance(v, str) or not v.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date_type.fromisoformat(v.strip()[:10])
|
||||||
|
except ValueError:
|
||||||
|
logger.debug("digest_metadata_extractor: ignoring invalid %s=%r", key, v)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def extract(raw_text: str, model: str | None = None) -> dict:
|
||||||
|
"""Extract digest metadata from raw text. Returns a dict (never raises).
|
||||||
|
|
||||||
|
Keys: yomon_number, digest_date (date|None), concept_tag, headline_holding,
|
||||||
|
summary, underlying_citation, underlying_court, underlying_date (date|None),
|
||||||
|
underlying_judge, practice_area, appeal_subtype, subject_tags (list[str]).
|
||||||
|
Missing/invalid fields are omitted so the caller's merge keeps user values.
|
||||||
|
|
||||||
|
Model: defaults to ``config.DIGEST_EXTRACT_MODEL`` (Sonnet — this is a
|
||||||
|
high-volume, simple extraction; no need for Opus). Override per-call via
|
||||||
|
``model``.
|
||||||
|
"""
|
||||||
|
text = (raw_text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
user_msg = f"--- תחילת היומון ---\n{text}\n--- סוף היומון ---"
|
||||||
|
try:
|
||||||
|
result = await claude_session.query_json(
|
||||||
|
user_msg, system=DIGEST_EXTRACTION_PROMPT,
|
||||||
|
model=(model or config.DIGEST_EXTRACT_MODEL or None),
|
||||||
|
tools="", # pure text→JSON: disable tools so the model never emits
|
||||||
|
# stop_reason=tool_use and trips --max-turns (error_max_turns).
|
||||||
|
)
|
||||||
|
except Exception as e: # surfaced as warning, not swallowed silently (§6)
|
||||||
|
logger.warning("digest_metadata_extractor: query failed: %s", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
logger.warning(
|
||||||
|
"digest_metadata_extractor: expected dict, got %s",
|
||||||
|
type(result).__name__,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
out: dict = {}
|
||||||
|
for key in (
|
||||||
|
"yomon_number", "concept_tag", "headline_holding", "summary",
|
||||||
|
"underlying_citation", "underlying_court", "underlying_judge",
|
||||||
|
"appeal_subtype",
|
||||||
|
):
|
||||||
|
s = _norm_str(result, key)
|
||||||
|
if s:
|
||||||
|
out[key] = s
|
||||||
|
|
||||||
|
kind = _norm_str(result, "digest_kind").lower()
|
||||||
|
if kind in ("decision", "announcement", "other"):
|
||||||
|
out["digest_kind"] = kind
|
||||||
|
|
||||||
|
dd = _norm_date(result, "digest_date_iso")
|
||||||
|
if dd is not None:
|
||||||
|
out["digest_date"] = dd
|
||||||
|
ud = _norm_date(result, "underlying_date_iso")
|
||||||
|
if ud is not None:
|
||||||
|
out["underlying_date"] = ud
|
||||||
|
|
||||||
|
pa = _norm_str(result, "practice_area")
|
||||||
|
if pa in _VALID_PRACTICE_AREAS and pa:
|
||||||
|
out["practice_area"] = pa
|
||||||
|
|
||||||
|
tags = result.get("subject_tags")
|
||||||
|
if isinstance(tags, list):
|
||||||
|
clean = [str(t).strip() for t in tags if str(t).strip()]
|
||||||
|
if clean:
|
||||||
|
out["subject_tags"] = clean
|
||||||
|
|
||||||
|
return out
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@@ -17,7 +18,7 @@ from docx.oxml import OxmlElement
|
|||||||
from docx.oxml.ns import qn
|
from docx.oxml.ns import qn
|
||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import db
|
from legal_mcp.services import db, storage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -112,6 +113,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.
|
||||||
|
|
||||||
@@ -396,8 +475,19 @@ async def export_decision(
|
|||||||
pass
|
pass
|
||||||
output_path = str(export_dir / f"{prefix}-v{next_ver}.docx")
|
output_path = str(export_dir / f"{prefix}-v{next_ver}.docx")
|
||||||
|
|
||||||
|
# Persist through the storage layer (INV-STG1). Under the filesystem
|
||||||
|
# backend the bytes land at output_path exactly as before; a caller-
|
||||||
|
# provided path outside DATA_DIR falls back to a direct disk write.
|
||||||
|
buf = io.BytesIO()
|
||||||
|
doc.save(buf)
|
||||||
|
data = buf.getvalue()
|
||||||
|
_docx_ctype = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
try:
|
||||||
|
key = Path(output_path).resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix()
|
||||||
|
await storage.put_bytes(key, data, bucket=storage.Bucket.DOCUMENTS, content_type=_docx_ctype)
|
||||||
|
except ValueError:
|
||||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
doc.save(output_path)
|
Path(output_path).write_bytes(data)
|
||||||
logger.info("DOCX exported (mode=%s): %s", mode, output_path)
|
logger.info("DOCX exported (mode=%s): %s", mode, output_path)
|
||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
@@ -485,12 +575,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")
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
from legal_mcp import config
|
||||||
|
from legal_mcp.services import storage
|
||||||
import zipfile
|
import zipfile
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -304,9 +307,16 @@ def retrofit_bookmarks(
|
|||||||
end_idx = len(paragraphs) - 1
|
end_idx = len(paragraphs) - 1
|
||||||
ranges.append((name, start_idx, max(start_idx, end_idx)))
|
ranges.append((name, start_idx, max(start_idx, end_idx)))
|
||||||
|
|
||||||
# Backup if overwriting in place
|
# Backup if overwriting in place — through the storage layer (INV-STG1).
|
||||||
if backup and output_path.resolve() == docx_path.resolve():
|
if backup and output_path.resolve() == docx_path.resolve():
|
||||||
backup_path = docx_path.with_suffix(".pre-retrofit.docx")
|
backup_path = docx_path.with_suffix(".pre-retrofit.docx")
|
||||||
|
try:
|
||||||
|
_bkey = backup_path.resolve().relative_to(
|
||||||
|
Path(config.DATA_DIR).resolve()).as_posix()
|
||||||
|
storage.put_file_sync(
|
||||||
|
docx_path, _bkey, bucket=storage.Bucket.DOCUMENTS,
|
||||||
|
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
||||||
|
except ValueError:
|
||||||
shutil.copy2(str(docx_path), str(backup_path))
|
shutil.copy2(str(docx_path), str(backup_path))
|
||||||
|
|
||||||
# Inject bookmarks, skipping any that already exist
|
# Inject bookmarks, skipping any that already exist
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
from legal_mcp import config
|
||||||
|
from legal_mcp.services import storage
|
||||||
import zipfile
|
import zipfile
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -98,6 +101,22 @@ def _load_docx_xml(docx_path: Path) -> tuple[dict[str, bytes], etree._Element, e
|
|||||||
return members, document_tree, settings_tree
|
return members, document_tree, settings_tree
|
||||||
|
|
||||||
|
|
||||||
|
_DOCX_CTYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_docx_sync(output_path: Path, data: bytes) -> None:
|
||||||
|
"""Persist DOCX bytes through the storage layer (INV-STG1); fall back to a
|
||||||
|
direct disk write when output_path is outside DATA_DIR (caller-provided)."""
|
||||||
|
out = Path(output_path)
|
||||||
|
try:
|
||||||
|
key = out.resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix()
|
||||||
|
storage.put_bytes_sync(key, data, bucket=storage.Bucket.DOCUMENTS,
|
||||||
|
content_type=_DOCX_CTYPE)
|
||||||
|
except ValueError:
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_bytes(data)
|
||||||
|
|
||||||
|
|
||||||
def _save_docx_xml(
|
def _save_docx_xml(
|
||||||
members: dict[str, bytes],
|
members: dict[str, bytes],
|
||||||
document_tree: etree._Element,
|
document_tree: etree._Element,
|
||||||
@@ -113,12 +132,11 @@ def _save_docx_xml(
|
|||||||
settings_tree, xml_declaration=True, encoding="UTF-8", standalone=True
|
settings_tree, xml_declaration=True, encoding="UTF-8", standalone=True
|
||||||
)
|
)
|
||||||
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
buffer = BytesIO()
|
buffer = BytesIO()
|
||||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
for name, data in members.items():
|
for name, data in members.items():
|
||||||
zf.writestr(name, data)
|
zf.writestr(name, data)
|
||||||
output_path.write_bytes(buffer.getvalue())
|
_persist_docx_sync(output_path, buffer.getvalue())
|
||||||
|
|
||||||
|
|
||||||
def _ensure_track_revisions(settings_tree: etree._Element) -> None:
|
def _ensure_track_revisions(settings_tree: etree._Element) -> None:
|
||||||
@@ -511,4 +529,11 @@ def copy_with_revisions(
|
|||||||
source_path: str | Path, output_path: str | Path,
|
source_path: str | Path, output_path: str | Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Copy source → output unchanged (used when revisions list is empty)."""
|
"""Copy source → output unchanged (used when revisions list is empty)."""
|
||||||
shutil.copy2(str(source_path), str(output_path))
|
out = Path(output_path)
|
||||||
|
try:
|
||||||
|
key = out.resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix()
|
||||||
|
storage.put_file_sync(source_path, key, bucket=storage.Bucket.DOCUMENTS,
|
||||||
|
content_type=_DOCX_CTYPE)
|
||||||
|
except ValueError:
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(str(source_path), str(out))
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from docx import Document as DocxDocument
|
|||||||
from striprtf.striprtf import rtf_to_text
|
from striprtf.striprtf import rtf_to_text
|
||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
|
from legal_mcp.services import storage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from google.cloud import vision
|
from google.cloud import vision
|
||||||
@@ -262,8 +263,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:
|
||||||
@@ -338,6 +346,18 @@ def render_pages_for_multimodal(
|
|||||||
max(1, int(img.height * ratio)),
|
max(1, int(img.height * ratio)),
|
||||||
)
|
)
|
||||||
thumb = img.resize(thumb_size, Image.Resampling.LANCZOS)
|
thumb = img.resize(thumb_size, Image.Resampling.LANCZOS)
|
||||||
|
# Persist the thumbnail (a DERIVED, regenerable artifact)
|
||||||
|
# through the storage layer (INV-STG1). Under the filesystem
|
||||||
|
# backend it lands at thumb_path exactly as before.
|
||||||
|
_tbuf = io.BytesIO()
|
||||||
|
thumb.save(_tbuf, "JPEG", quality=75, optimize=True)
|
||||||
|
try:
|
||||||
|
_tkey = thumb_path.resolve().relative_to(
|
||||||
|
Path(config.DATA_DIR).resolve()).as_posix()
|
||||||
|
storage.put_bytes_sync(
|
||||||
|
_tkey, _tbuf.getvalue(), bucket=storage.Bucket.DERIVED,
|
||||||
|
content_type="image/jpeg")
|
||||||
|
except ValueError:
|
||||||
thumb.save(thumb_path, "JPEG", quality=75, optimize=True)
|
thumb.save(thumb_path, "JPEG", quality=75, optimize=True)
|
||||||
|
|
||||||
out.append((img, thumb_path))
|
out.append((img, thumb_path))
|
||||||
@@ -351,8 +371,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 +402,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 +413,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()
|
||||||
|
|||||||
97
mcp-server/src/legal_mcp/services/gemini_session.py
Normal file
97
mcp-server/src/legal_mcp/services/gemini_session.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""Gemini structured-output helper — a drop-in for ``claude_session.query_json``
|
||||||
|
for BOUNDED extraction tasks (text → JSON).
|
||||||
|
|
||||||
|
Why a second LLM path: metadata extraction is a single structured call (fill
|
||||||
|
case_name/summary/headnote/tags from a verdict's text), not an agentic loop. The
|
||||||
|
``claude -p`` CLI behind ``claude_session`` is agentic — it reaches for tools and
|
||||||
|
hits ``error_max_turns`` on a task that should be one shot — so it was slow and
|
||||||
|
flaky for the precedent metadata queue. Gemini Flash with JSON mode
|
||||||
|
(``responseMimeType: application/json``) is the right tool: one call, schema-
|
||||||
|
clean JSON, fast, and ~$0.10/1M tokens (negligible for this volume).
|
||||||
|
|
||||||
|
Scope: **bounded extraction only** (precedent metadata). The agentic, voice-
|
||||||
|
sensitive work — decision writing, analysis, halacha extraction — stays on
|
||||||
|
``claude_session`` (Daphna's subscription, zero API cost). This is a deliberate
|
||||||
|
per-task provider choice, not a wholesale move off Claude.
|
||||||
|
|
||||||
|
Key: ``GEMINI_API_KEY`` (host ~/.env; SoT Infisical nautilus:/external-apis/gemini
|
||||||
|
as ``GOOGLE_GEMINI_API_KEY``). Model: ``GEMINI_MODEL`` (default gemini-2.5-flash).
|
||||||
|
Direct REST via httpx — no extra SDK dependency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BASE = "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
_DEFAULT_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
|
||||||
|
_DEFAULT_TIMEOUT = float(os.environ.get("GEMINI_TIMEOUT_S", "120"))
|
||||||
|
|
||||||
|
|
||||||
|
class GeminiError(RuntimeError):
|
||||||
|
"""Gemini API call failed or returned an unexpected shape."""
|
||||||
|
|
||||||
|
|
||||||
|
def _api_key() -> str:
|
||||||
|
key = os.environ.get("GEMINI_API_KEY", "").strip()
|
||||||
|
if not key:
|
||||||
|
raise GeminiError(
|
||||||
|
"GEMINI_API_KEY אינו מוגדר (host ~/.env / Infisical "
|
||||||
|
"nautilus:/external-apis/gemini)."
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
async def query_json(
|
||||||
|
prompt: str,
|
||||||
|
timeout: float | int = _DEFAULT_TIMEOUT,
|
||||||
|
*,
|
||||||
|
system: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
# Accepted for drop-in parity with claude_session.query_json; ignored here.
|
||||||
|
effort: str | None = None,
|
||||||
|
tools: str | None = None,
|
||||||
|
) -> dict | list | None:
|
||||||
|
"""Single structured-output call → parsed JSON. Drop-in for
|
||||||
|
``claude_session.query_json``. Raises ``GeminiError`` on failure (the caller
|
||||||
|
treats that like any extraction failure — recorded, never silently wrong).
|
||||||
|
"""
|
||||||
|
model = model or _DEFAULT_MODEL
|
||||||
|
body: dict = {
|
||||||
|
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
|
||||||
|
"generationConfig": {
|
||||||
|
"responseMimeType": "application/json",
|
||||||
|
"temperature": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if system:
|
||||||
|
body["system_instruction"] = {"parts": [{"text": system}]}
|
||||||
|
|
||||||
|
url = f"{_BASE}/models/{model}:generateContent"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=float(timeout)) as client:
|
||||||
|
resp = await client.post(url, params={"key": _api_key()}, json=body)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise GeminiError(f"Gemini request failed: {e}") from e
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise GeminiError(f"Gemini HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
# Surface an explicit safety/finish block rather than returning empty.
|
||||||
|
cand = (data.get("candidates") or [{}])[0]
|
||||||
|
if cand.get("finishReason") in ("SAFETY", "RECITATION", "PROHIBITED_CONTENT"):
|
||||||
|
raise GeminiError(f"Gemini blocked output: finishReason={cand['finishReason']}")
|
||||||
|
try:
|
||||||
|
text = cand["content"]["parts"][0]["text"]
|
||||||
|
except (KeyError, IndexError, TypeError) as e:
|
||||||
|
raise GeminiError(f"Gemini unexpected response: {str(data)[:200]}") from e
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise GeminiError(f"Gemini returned non-JSON: {text[:200]}") from e
|
||||||
@@ -6,8 +6,10 @@ structured list of halachot, validates each one against the source text,
|
|||||||
embeds the rule statement, and stores everything as ``pending_review`` in
|
embeds the rule statement, and stores everything as ``pending_review`` in
|
||||||
the ``halachot`` table.
|
the ``halachot`` table.
|
||||||
|
|
||||||
All extraction is idempotent — calling ``extract(case_law_id)`` twice
|
All extraction is idempotent — calling ``extract(case_law_id, force=True)``
|
||||||
deletes prior rows for that precedent first.
|
twice drops the precedent's un-reviewed rows and re-extracts. Chair-approved /
|
||||||
|
published halachot are PRESERVED across a re-extract (INV-G10); see
|
||||||
|
``db.reset_halacha_extraction``.
|
||||||
|
|
||||||
Trust model:
|
Trust model:
|
||||||
Per chair decision, NO halacha is auto-published. Every extracted
|
Per chair decision, NO halacha is auto-published. Every extracted
|
||||||
@@ -26,14 +28,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
|
||||||
@@ -62,8 +78,12 @@ EXTRACTABLE_SECTIONS = ("legal_analysis", "ruling", "conclusion")
|
|||||||
# wants to be able to cite "another committee reached the same conclusion"
|
# wants to be able to cite "another committee reached the same conclusion"
|
||||||
# even though it is not binding.
|
# even though it is not binding.
|
||||||
#
|
#
|
||||||
# The schema's rule_type field accepts six values:
|
# The prompt branches on is_binding only to choose the EXTRACTION STRATEGY
|
||||||
# binding | interpretive | procedural | obiter | application | persuasive
|
# (what to pull, how to phrase) — NOT the rule_type. rule_type is the rule
|
||||||
|
# ROLE and uses the SAME five values for both sources (INV-DM7):
|
||||||
|
# holding | interpretive | procedural | application | obiter
|
||||||
|
# The authority axis (binding/persuasive) is derived from the source, never
|
||||||
|
# a rule_type value — so the model never classifies it.
|
||||||
|
|
||||||
HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה בדיני תכנון ובניה (ועדות ערר, היטל השבחה, פיצויים לפי סעיף 197 לחוק התכנון והבניה). תפקידך: לחלץ הלכות מחייבות מתוך פסק דין/החלטה משפטית של ערכאה עליונה (עליון / מנהלי).
|
HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה בדיני תכנון ובניה (ועדות ערר, היטל השבחה, פיצויים לפי סעיף 197 לחוק התכנון והבניה). תפקידך: לחלץ הלכות מחייבות מתוך פסק דין/החלטה משפטית של ערכאה עליונה (עליון / מנהלי).
|
||||||
|
|
||||||
@@ -73,9 +93,10 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
|
|||||||
|
|
||||||
לא-הלכה (אין לחלץ):
|
לא-הלכה (אין לחלץ):
|
||||||
- אמרת אגב (obiter dicta) — הערות שאינן הכרחיות להכרעה.
|
- אמרת אגב (obiter dicta) — הערות שאינן הכרחיות להכרעה.
|
||||||
- ממצאים עובדתיים ספציפיים לתיק ("העורר לא הוכיח X").
|
- **סוגיה שהערכאה לא הכריעה בה** — אם בית המשפט אומר במפורש "אין צורך להכריע", "מבלי לקבוע מסמרות", "איני רואה לקבוע מסמרות", "למעלה מן הצורך", "אגב אורחא" — זו אינה הלכה. מבחן ההיפוך (Wambaugh): אם שלילת הכלל לא הייתה משנה את תוצאת הפסק — זו אמרת אגב, לא הלכה.
|
||||||
|
- ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים/מספרים קונקרטיים) — חלץ את **העיקרון המופשט** בלבד, לא את יישומו על עובדות התיק.
|
||||||
- ציטוטי הלכות מפסקי דין אחרים שלא אומצו במפורש בפסק זה.
|
- ציטוטי הלכות מפסקי דין אחרים שלא אומצו במפורש בפסק זה.
|
||||||
- הצהרות על דין קיים שאינן מיושמות בהכרעה.
|
- הצהרות על דין קיים שאינן מיושמות בהכרעה, וכן ניסוח שהוא העתק של הציטוט ללא הפשטה.
|
||||||
|
|
||||||
הבחנה קריטית: כאשר הפסק מצטט הלכה מפסק קודם, חלץ אותה רק אם בית המשפט בפסק הנוכחי **מאמץ ומחיל** אותה (לא רק מזכיר אותה ברקע).
|
הבחנה קריטית: כאשר הפסק מצטט הלכה מפסק קודם, חלץ אותה רק אם בית המשפט בפסק הנוכחי **מאמץ ומחיל** אותה (לא רק מזכיר אותה ברקע).
|
||||||
|
|
||||||
@@ -86,10 +107,12 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
|
|||||||
|
|
||||||
הלכה אחת יכולה לחול על כמה תחומים — practice_areas הוא array ולא string יחיד.
|
הלכה אחת יכולה לחול על כמה תחומים — practice_areas הוא array ולא string יחיד.
|
||||||
|
|
||||||
## סוגי הלכה (rule_type)
|
## סוג הכלל (rule_type) — מהות הכלל בלבד, לא סמכות-המקור
|
||||||
- binding — הלכה מחייבת שהוחלה על התיק.
|
**אל תסווג "מחייב/משכנע"** — דרגת-המחייבות נגזרת אוטומטית מזהות הערכאה. כאן בחר רק את **סוג הכלל**:
|
||||||
- interpretive — פרשנות סעיף חוק/תכנית שאומצה.
|
- holding — עיקרון מהותי שהיה הכרחי להכרעה (ה-ratio; מבחן Wambaugh: שלילתו הייתה משנה את התוצאה).
|
||||||
- procedural — כלל פרוצדורלי (סמכות, מועדים, הליכי שמיעה).
|
- interpretive — פרשנות הוראת-חוק/מונח/תכנית שאומצה.
|
||||||
|
- procedural — כלל סדר-דין (סמכות, מועדים, זכות-עמידה, מיצוי הליכים, נטל).
|
||||||
|
- application — החלת כלל על עובדות התיק (תלוי-עובדות; לרוב לא-הלכה בת-הכללה).
|
||||||
- obiter — אמרת אגב חשובה (חלץ רק אם משמעותית; סמן confidence נמוך).
|
- obiter — אמרת אגב חשובה (חלץ רק אם משמעותית; סמן confidence נמוך).
|
||||||
|
|
||||||
## פלט נדרש
|
## פלט נדרש
|
||||||
@@ -97,7 +120,7 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"rule_statement": "ניסוח הכלל בלשון משפטית מדויקת בגוף שלישי, 1-3 משפטים.",
|
"rule_statement": "ניסוח הכלל בלשון משפטית מדויקת בגוף שלישי, 1-3 משפטים.",
|
||||||
"rule_type": "binding",
|
"rule_type": "holding",
|
||||||
"reasoning_summary": "תמצית ההיגיון: למה בית המשפט הגיע לכלל הזה (1-2 משפטים).",
|
"reasoning_summary": "תמצית ההיגיון: למה בית המשפט הגיע לכלל הזה (1-2 משפטים).",
|
||||||
"supporting_quote": "ציטוט מילולי מדויק מהפסק התומך בכלל. חייב להופיע מילה במילה בטקסט הקלט.",
|
"supporting_quote": "ציטוט מילולי מדויק מהפסק התומך בכלל. חייב להופיע מילה במילה בטקסט הקלט.",
|
||||||
"page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות מהקלט.",
|
"page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות מהקלט.",
|
||||||
@@ -109,10 +132,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 = ספק לגבי היות זה הלכה מחייבת.
|
||||||
"""
|
"""
|
||||||
@@ -124,15 +147,16 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
|
|||||||
|
|
||||||
המקור הזה **אינו** מקור להלכות מחייבות חדשות (binding rules). הלכות מחייבות מגיעות מהעליון/מנהלי. עם זאת, יש כאן ערך משמעותי שצריך לחלץ — איך הפנל הזה ניתח ויישם את הדין הקיים. כשנכתוב החלטה עתידית, נצטט מהמקור הזה כ"גם ועדת הערר ב-X הגיעה למסקנה דומה" — לא כסמכות מחייבת, אלא כתמיכה משכנעת.
|
המקור הזה **אינו** מקור להלכות מחייבות חדשות (binding rules). הלכות מחייבות מגיעות מהעליון/מנהלי. עם זאת, יש כאן ערך משמעותי שצריך לחלץ — איך הפנל הזה ניתח ויישם את הדין הקיים. כשנכתוב החלטה עתידית, נצטט מהמקור הזה כ"גם ועדת הערר ב-X הגיעה למסקנה דומה" — לא כסמכות מחייבת, אלא כתמיכה משכנעת.
|
||||||
|
|
||||||
**יש לחלץ:**
|
**יש לחלץ** (סווג לפי **סוג הכלל** בלבד — אל תסווג "מחייב/משכנע", דרגת-המחייבות נגזרת אוטומטית):
|
||||||
- **יישום של הלכה ידועה** (rule_type=`application`) — הפנל החיל הלכה ידועה (של עליון/מנהלי) על עובדות הנידונות. תצטט את ניסוח הכלל **כפי שהוצג כאן** (לא בהכרח כפי שנקבע במקור) ואת התוצאה.
|
- **יישום של הלכה ידועה** (rule_type=`application`) — הפנל החיל הלכה ידועה (של עליון/מנהלי) על עובדות הנידונות. תצטט את ניסוח הכלל **כפי שהוצג כאן** (לא בהכרח כפי שנקבע במקור) ואת התוצאה.
|
||||||
- **עקרון פרשני שאומץ** (rule_type=`interpretive`) — איך הפנל פירש סעיף חוק / תכנית, באופן שניתן לאמץ.
|
- **עקרון פרשני שאומץ** (rule_type=`interpretive`) — איך הפנל פירש סעיף חוק / תכנית, באופן שניתן לאמץ.
|
||||||
- **כלל פרוצדורלי** (rule_type=`procedural`) — קביעות בנושאי סמכות, מועדים, הליך.
|
- **כלל פרוצדורלי** (rule_type=`procedural`) — קביעות בנושאי סמכות, מועדים, הליך.
|
||||||
- **מסקנה מנומקת ומשכנעת** (rule_type=`persuasive`) — מסקנה שלמה של הפנל בסוגיה, עם ההיגיון התומך, ניתנת לציטוט כאסמכתא משכנעת.
|
- **מסקנה מהותית מנומקת** (rule_type=`holding`) — מסקנה עקרונית שלמה של הפנל בסוגיה, עם ההיגיון התומך, בת-הכללה ובת-הסתמכות.
|
||||||
|
|
||||||
**אין לחלץ:**
|
**אין לחלץ:**
|
||||||
- ממצאים עובדתיים ספציפיים לתיק ("העורר לא הוכיח X").
|
- ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים קונקרטיים) — חלץ את העיקרון/היישום בניסוח בר-הכללה בלבד.
|
||||||
- ציטוטים מפסקי דין אחרים ללא ניתוח של הפנל.
|
- סוגיה שהפנל לא הכריע בה ("אין צורך להכריע", "מבלי לקבוע מסמרות", "למעלה מן הצורך").
|
||||||
|
- ציטוטים מפסקי דין אחרים ללא ניתוח של הפנל, וכן ניסוח שהוא העתק של הציטוט ללא הפשטה.
|
||||||
- אמרות אגב חסרות חשיבות.
|
- אמרות אגב חסרות חשיבות.
|
||||||
|
|
||||||
## תחומים אפשריים (practice_areas) — תחומי ועדת הערר בלבד
|
## תחומים אפשריים (practice_areas) — תחומי ועדת הערר בלבד
|
||||||
@@ -158,9 +182,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 = פרוצדורה. holding = מסקנה מהותית עקרונית. **לא** binding/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. דייק.
|
||||||
@@ -168,10 +192,15 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
|
|||||||
|
|
||||||
|
|
||||||
_VALID_PRACTICE_AREAS = {"rishuy_uvniya", "betterment_levy", "compensation_197"}
|
_VALID_PRACTICE_AREAS = {"rishuy_uvniya", "betterment_levy", "compensation_197"}
|
||||||
|
# rule_type holds the rule ROLE only — what KIND of statement it is (INV-DM7).
|
||||||
|
# The authority axis (binding/persuasive) is DERIVED from the source, never a
|
||||||
|
# rule_type value: see halacha_quality.derive_authority.
|
||||||
_VALID_RULE_TYPES = {
|
_VALID_RULE_TYPES = {
|
||||||
"binding", "interpretive", "procedural", "obiter",
|
"holding", "interpretive", "procedural", "application", "obiter",
|
||||||
"application", "persuasive",
|
|
||||||
}
|
}
|
||||||
|
# Legacy authority-as-role values → fold to the nearest genuine role. Kept so
|
||||||
|
# old LLM outputs (and pre-split rows re-fed) coerce safely.
|
||||||
|
_LEGACY_RULE_TYPE_FOLD = {"binding": "holding", "persuasive": "interpretive"}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_for_comparison(text: str) -> str:
|
def _normalize_for_comparison(text: str) -> str:
|
||||||
@@ -211,13 +240,14 @@ def _verify_quote(supporting_quote: str, full_text: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _coerce_halacha(raw: dict, is_binding: bool = True) -> dict | None:
|
def _coerce_halacha(raw: dict) -> dict | None:
|
||||||
"""Validate and normalize one LLM-returned halacha dict.
|
"""Validate and normalize one LLM-returned halacha dict.
|
||||||
|
|
||||||
Returns ``None`` if the entry is missing required fields. ``is_binding``
|
Returns ``None`` if the entry is missing required fields. ``rule_type`` is
|
||||||
only affects the default rule_type when the LLM returned an unknown
|
the rule ROLE only (INV-DM7) — it is NEVER defaulted from the source's
|
||||||
value — for binding sources we default to ``binding``, otherwise to
|
bindingness (that was the source-conflation this split removed). Legacy
|
||||||
``persuasive`` (never pretend an appeals committee created halacha).
|
authority values fold to the nearest role; unknown defaults to
|
||||||
|
``interpretive`` (the most common role).
|
||||||
"""
|
"""
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return None
|
return None
|
||||||
@@ -226,13 +256,10 @@ def _coerce_halacha(raw: dict, is_binding: bool = True) -> dict | None:
|
|||||||
if not rule_statement or not supporting_quote:
|
if not rule_statement or not supporting_quote:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
default_rule_type = "binding" if is_binding else "persuasive"
|
rule_type = (raw.get("rule_type") or "").strip().lower()
|
||||||
rule_type = (raw.get("rule_type") or default_rule_type).strip().lower()
|
rule_type = _LEGACY_RULE_TYPE_FOLD.get(rule_type, rule_type)
|
||||||
if rule_type not in _VALID_RULE_TYPES:
|
if rule_type not in _VALID_RULE_TYPES:
|
||||||
rule_type = default_rule_type
|
rule_type = "interpretive"
|
||||||
# Guard: don't let a non-binding source produce 'binding' rule_type
|
|
||||||
if not is_binding and rule_type == "binding":
|
|
||||||
rule_type = "persuasive"
|
|
||||||
|
|
||||||
practice_areas_raw = raw.get("practice_areas") or []
|
practice_areas_raw = raw.get("practice_areas") or []
|
||||||
if isinstance(practice_areas_raw, str):
|
if isinstance(practice_areas_raw, str):
|
||||||
@@ -268,6 +295,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 +388,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 +418,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 +444,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 +470,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,85 +531,108 @@ 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)
|
# "Clean slate" preserves chair-approved/published halachot (INV-G10) — only
|
||||||
|
# un-reviewed rows are dropped; the per-chunk dedup-on-insert skips fresh
|
||||||
|
# extractions that duplicate a preserved approval, so approvals survive a
|
||||||
|
# re-extract without duplicating. See db.reset_halacha_extraction / #108.
|
||||||
|
preserved_approved = 0
|
||||||
|
if force:
|
||||||
|
reset = await db.reset_halacha_extraction(case_law_id)
|
||||||
|
preserved_approved = reset.get("preserved", 0)
|
||||||
|
if preserved_approved:
|
||||||
|
logger.info(
|
||||||
|
"halacha_extractor: case_law=%s force re-extract — preserved %d "
|
||||||
|
"approved/published halachot (INV-G10), dropped %d un-reviewed.",
|
||||||
|
case_law_id, preserved_approved, reset.get("deleted", 0),
|
||||||
|
)
|
||||||
|
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)
|
||||||
async def _bounded(idx: int, chunk_row: dict) -> tuple[list[dict], bool]:
|
stored_total = 0
|
||||||
async with sem:
|
|
||||||
return await _extract_chunk(
|
|
||||||
chunk_row["content"], chunk_row["section_type"],
|
|
||||||
idx, len(chunks), context, is_binding,
|
|
||||||
)
|
|
||||||
|
|
||||||
chunk_results = await asyncio.gather(
|
|
||||||
*[_bounded(i, c) for i, c in enumerate(chunks)]
|
|
||||||
)
|
|
||||||
raw_halachot: list[dict] = []
|
|
||||||
failed_chunks = 0
|
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.)
|
async def _process(chunk_row: dict) -> None:
|
||||||
# do NOT touch the DB status — leave it 'processing' so the caller can
|
nonlocal stored_total, failed_chunks
|
||||||
# retry without the request falling out of the queue. The caller
|
async with sem:
|
||||||
# (`process_pending_extractions`) is responsible for either retrying or
|
items, ok = await _extract_chunk(
|
||||||
# finalising the status as 'failed' after retries are exhausted. This
|
chunk_row["content"], chunk_row["section_type"],
|
||||||
# is the bug that produced 317/10's silent `no_halachot` after a
|
idx_by_id[chunk_row["id"]], len(chunks), context, is_binding,
|
||||||
# 129-chunk neighbour saturated the API.
|
effort,
|
||||||
failure_rate = failed_chunks / len(chunks) if chunks else 0
|
|
||||||
if failure_rate >= EXTRACTION_FAILURE_THRESHOLD and not raw_halachot:
|
|
||||||
logger.error(
|
|
||||||
"halacha_extractor: case_law=%s extraction_failed — "
|
|
||||||
"%d/%d chunks failed (rate=%.0f%%), no halachot retrieved. "
|
|
||||||
"DB status left as 'processing' for caller-level retry.",
|
|
||||||
case_law_id, failed_chunks, len(chunks), failure_rate * 100,
|
|
||||||
)
|
)
|
||||||
return {
|
if not ok:
|
||||||
"status": "extraction_failed",
|
failed_chunks += 1 # leave chunk un-checkpointed → retried on resume
|
||||||
"extracted": 0,
|
return
|
||||||
"stored": 0,
|
|
||||||
"failed_chunks": failed_chunks,
|
|
||||||
"total_chunks": len(chunks),
|
|
||||||
}
|
|
||||||
|
|
||||||
if not raw_halachot:
|
|
||||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
|
||||||
return {
|
|
||||||
"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.
|
|
||||||
full_text = record.get("full_text") or ""
|
|
||||||
|
|
||||||
cleaned: list[dict] = []
|
cleaned: list[dict] = []
|
||||||
for raw in raw_halachot:
|
for raw in items:
|
||||||
coerced = _coerce_halacha(raw, is_binding=is_binding)
|
coerced = _coerce_halacha(raw)
|
||||||
if coerced is None:
|
if coerced is None:
|
||||||
continue
|
continue
|
||||||
coerced["quote_verified"] = _verify_quote(
|
coerced["quote_verified"] = _verify_quote(
|
||||||
coerced["supporting_quote"], full_text,
|
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 holding-labeled rule that reads as a case-application is
|
||||||
|
# re-typed application (it carries FLAG_APPLICATION either way).
|
||||||
|
elif (halacha_quality.FLAG_APPLICATION in flags
|
||||||
|
and coerced["rule_type"] == "holding"):
|
||||||
|
coerced["rule_type"] = "application"
|
||||||
cleaned.append(coerced)
|
cleaned.append(coerced)
|
||||||
|
# #81.3 NLI entailment — one batched judge call per chunk (fail-open).
|
||||||
if not cleaned:
|
if config.HALACHA_NLI_ENABLED and cleaned:
|
||||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
verdicts = await _nli_check(cleaned)
|
||||||
return {"status": "no_valid_halachot", "extracted": len(raw_halachot), "stored": 0}
|
for h, v in zip(cleaned, verdicts):
|
||||||
|
if v != "entailed" and halacha_quality.FLAG_NLI_UNSUPPORTED not in h["quality_flags"]:
|
||||||
# Embed rule_statement + reasoning_summary so semantic search hits the
|
h["quality_flags"].append(halacha_quality.FLAG_NLI_UNSUPPORTED)
|
||||||
# rule directly rather than the surrounding chunk centroid.
|
if cleaned:
|
||||||
embed_inputs = [
|
embed_inputs = [
|
||||||
f"{h['rule_statement']} — {h['reasoning_summary']}".strip(" —")
|
f"{h['rule_statement']} — {h['reasoning_summary']}".strip(" —")
|
||||||
for h in cleaned
|
for h in cleaned
|
||||||
@@ -451,23 +642,64 @@ async def extract(case_law_id: UUID | str) -> dict:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("halacha_extractor: embeddings failed: %s", e)
|
logger.error("halacha_extractor: embeddings failed: %s", e)
|
||||||
vectors = [None] * len(cleaned)
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
for halacha, vec in zip(cleaned, vectors):
|
await asyncio.gather(*[_process(c) for c in pending])
|
||||||
halacha["embedding"] = vec
|
|
||||||
|
|
||||||
stored = await db.store_halachot(case_law_id, cleaned)
|
# Decide final status from what's LEFT (re-read checkpoints).
|
||||||
|
after = await db.list_precedent_chunks(case_law_id, section_types=EXTRACTABLE_SECTIONS)
|
||||||
|
if not after:
|
||||||
|
after = await db.list_precedent_chunks(case_law_id)
|
||||||
|
still_pending = sum(1 for c in after if c.get("halacha_extracted_at") is None)
|
||||||
|
total = len(await db.list_halachot(case_law_id=case_law_id, limit=10_000))
|
||||||
|
|
||||||
verified = sum(1 for h in cleaned if h["quote_verified"])
|
if still_pending:
|
||||||
|
# Some chunks failed this run. Leave status 'processing' so a resume
|
||||||
|
# continues them (no progress is lost — done chunks are checkpointed).
|
||||||
|
if total == 0 and failed_chunks >= len(pending) * EXTRACTION_FAILURE_THRESHOLD:
|
||||||
|
logger.error(
|
||||||
|
"halacha_extractor: case_law=%s extraction_failed — %d/%d pending "
|
||||||
|
"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 {"status": "partial", "extracted": total, "stored": stored_total,
|
||||||
|
"pending_chunks": still_pending, "total_chunks": len(chunks)}
|
||||||
|
|
||||||
|
# All chunks done. #81.5: fold cross-chunk facets of one legal question
|
||||||
|
# (the prompt dedups within a chunk; this catches across chunks).
|
||||||
|
folded = await _consolidate_precedent(case_law_id)
|
||||||
|
|
||||||
|
stored = total
|
||||||
|
verified = sum(1 for h in await db.list_halachot(case_law_id=case_law_id, limit=10_000)
|
||||||
|
if h.get("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,
|
||||||
|
"preserved_approved": preserved_approved,
|
||||||
|
"total_chunks": len(chunks),
|
||||||
}
|
}
|
||||||
|
|||||||
391
mcp-server/src/legal_mcp/services/halacha_quality.py
Normal file
391
mcp-server/src/legal_mcp/services/halacha_quality.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
# ── Authority axis — DERIVED from the source, never LLM-classified (INV-DM7) ──
|
||||||
|
#
|
||||||
|
# A halacha's *authority* (binding vs persuasive) is a property of WHERE it came
|
||||||
|
# from, not of the rule's content. It is therefore derived deterministically
|
||||||
|
# from ``case_law.precedent_level`` and never stored on ``halachot`` or guessed
|
||||||
|
# by the extractor — keeping it orthogonal to ``rule_type`` (the rule ROLE).
|
||||||
|
# Higher courts (עליון/מנהלי) bind the appeals committee; another committee is
|
||||||
|
# only persuasive. See docs/spec/02-data-model.md INV-DM7.
|
||||||
|
|
||||||
|
AUTHORITY_BINDING = "binding"
|
||||||
|
AUTHORITY_PERSUASIVE = "persuasive"
|
||||||
|
|
||||||
|
_BINDING_LEVELS = {"עליון", "מנהלי"}
|
||||||
|
_PERSUASIVE_LEVELS = {"ועדת_ערר_מחוזית"}
|
||||||
|
|
||||||
|
|
||||||
|
def derive_authority(precedent_level: str | None) -> str | None:
|
||||||
|
"""Map a source's precedent_level to its authority over the committee.
|
||||||
|
|
||||||
|
Returns ``"binding"`` for higher courts (עליון/מנהלי), ``"persuasive"`` for
|
||||||
|
another appeals committee (ועדת_ערר_מחוזית), or ``None`` when the level is
|
||||||
|
unknown/empty (never guesses). Pure — the single source of truth for the
|
||||||
|
authority axis (INV-DM7).
|
||||||
|
"""
|
||||||
|
level = (precedent_level or "").strip()
|
||||||
|
if level in _BINDING_LEVELS:
|
||||||
|
return AUTHORITY_BINDING
|
||||||
|
if level in _PERSUASIVE_LEVELS:
|
||||||
|
return AUTHORITY_PERSUASIVE
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Hebrew text normalization (shared with the extractor's quote check) ──
|
||||||
|
|
||||||
|
_HEB_QUOTE_VARIANTS = "\"'׳״‘’“”«»„′″"
|
||||||
|
|
||||||
|
|
||||||
|
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 = "interpretive",
|
||||||
|
) -> 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
|
||||||
304
mcp-server/src/legal_mcp/services/ingest.py
Normal file
304
mcp-server/src/legal_mcp/services/ingest.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
"""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 mimetypes
|
||||||
|
import re
|
||||||
|
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, storage
|
||||||
|
|
||||||
|
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]}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _stage_file(src_path: Path, root: Path, subdir: str) -> Path:
|
||||||
|
"""Stage an intake file through the unified storage layer (INV-STG1).
|
||||||
|
|
||||||
|
Returns the DATA_DIR path the rest of the pipeline reads from — under the
|
||||||
|
filesystem/dual backends the bytes are on disk and the key is the
|
||||||
|
DATA_DIR-relative path. The Hebrew original filename rides as object
|
||||||
|
metadata, never as the key (INV-STG2)."""
|
||||||
|
dest = root / (subdir or "other") / f"{uuid4().hex[:8]}_{_safe_filename(src_path.name)}"
|
||||||
|
key = dest.relative_to(config.DATA_DIR).as_posix()
|
||||||
|
await storage.put_file(
|
||||||
|
src_path, key, bucket=storage.Bucket.DOCUMENTS,
|
||||||
|
content_type=mimetypes.guess_type(src_path.name)[0],
|
||||||
|
metadata={"filename": src_path.name},
|
||||||
|
)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
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 = await _stage_file(src, spec.staging_root, spec.staging_subdir(inputs))
|
||||||
|
await progress("extracting", 15, "מחלץ טקסט מהקובץ")
|
||||||
|
try:
|
||||||
|
raw_text, page_count, page_offsets = await extractor.extract_text(str(staged))
|
||||||
|
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,9 +113,22 @@ 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:
|
||||||
|
pair = await conn.fetchrow(
|
||||||
|
"""SELECT id, draft_text FROM draft_final_pairs
|
||||||
|
WHERE case_id = $1 AND status = 'final_received'
|
||||||
|
ORDER BY created_at DESC LIMIT 1""",
|
||||||
|
case_id,
|
||||||
|
)
|
||||||
|
if pair:
|
||||||
|
pair_id = pair["id"]
|
||||||
|
draft_text = pair["draft_text"] or ""
|
||||||
|
if not draft_text:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""SELECT content FROM decision_blocks
|
"""SELECT content FROM decision_blocks
|
||||||
WHERE decision_id = $1 AND word_count > 0
|
WHERE decision_id = $1 AND word_count > 0
|
||||||
@@ -128,28 +140,26 @@ async def process_final_version(
|
|||||||
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}% מסך ההחלטה"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user