Compare commits
282 Commits
36d10b6a70
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fc87de1c5 | |||
| 64612240d5 | |||
| b9e4c1fde4 | |||
| f7a8ad48ac | |||
| 4dce06c04a | |||
| eac4dd3ac9 | |||
| 01bc1b9743 | |||
| 693126484b | |||
| 17460044ac | |||
| 6f3c3963a4 | |||
| d093319ffd | |||
| 9e46db3c48 | |||
| 8d13e26cc8 | |||
| 013fe39ea7 | |||
| 96a1144f43 | |||
| a44827c3dd | |||
| 72f81734f1 | |||
| 49acde591e | |||
| 387cd37255 | |||
| 0249184a6b | |||
| 81aa2ac368 | |||
| 9154a1d817 | |||
| 6926d21b15 | |||
| 15e4af595a | |||
| b6dec104c8 | |||
| 75a1b23972 | |||
| d2154020c6 | |||
| c7c402e7ef | |||
| 551d38dd7c | |||
| 309064be7d | |||
| c474b58311 | |||
| 959cb093b4 | |||
| 82f1728d3c | |||
| 5913654ae2 | |||
| 51f9d9d309 | |||
| ab1e72f0cc | |||
| 584bc62488 | |||
| 2962538c09 | |||
| 99fe16a43d | |||
| b49cde7c24 | |||
| 5272ded4f7 | |||
| 4f7c3733e2 | |||
| 49827acd4f | |||
| f0a8af30dc | |||
| ca1a0ddaac | |||
| 242e6cfd11 | |||
| a0b3c17381 | |||
| d246fb85fc | |||
| 412bd091cf | |||
| 4cad17df3a | |||
| 305c084d0c | |||
| 6fba565fcb | |||
| b95b02486b | |||
| d98ef14f41 | |||
| 1470841e26 | |||
| 183156646c | |||
| d292c06ecd | |||
| 9c6896df90 | |||
| 208be9061b | |||
| e8bcb9c1ea | |||
| 9cd290e08e | |||
| b0411db80b | |||
| ebb9c211af | |||
| d4dc58fe5a | |||
| 56bc72760a | |||
| 614c06ab60 | |||
| 57a6a01a03 | |||
| 4ea6326766 | |||
| b79f1a2420 | |||
| 0a7869175e | |||
| b4e79aa8fa | |||
| f3b075d282 | |||
| c53ef9a7c4 | |||
| f528407503 | |||
| 1351c77dfd | |||
| 35f656f2b8 | |||
| 13b8a6ef05 | |||
| b0efa700da | |||
| b447ffb184 | |||
| b85aafa8f9 | |||
| 65d649cfcc | |||
| 9482eb5a1e | |||
| 3757910079 | |||
| 6e69c1dc38 | |||
| fc2de64700 | |||
| 36bae6c592 | |||
| f1ea4fc00a | |||
| 80455af62d | |||
| 383118bc5f | |||
| 0d8cc31a2b | |||
| e1e54d61c7 | |||
| 3ac022d0fb | |||
| e3dc7958b2 | |||
| 8651529327 | |||
| 24480950f1 | |||
| 4b01283e3b | |||
| 0a45fab4ee | |||
| 6359363f13 | |||
| af5875453d | |||
| e6778d26e5 | |||
| 2e0cfd8d94 | |||
| a13fc76c49 | |||
| 2e2234ec27 | |||
| 63784f1f91 | |||
| 6c0590e1e3 | |||
| 970e8dc748 | |||
| 671edf1128 | |||
| 94a4c3600e | |||
| e91c1c4afc | |||
| 7d8bdc8c72 | |||
| c18a5443fd | |||
| ec14e8310b | |||
| ff2d28b1a7 | |||
| a4b4ebbbb1 | |||
| a00e226a08 | |||
| 97271689ef | |||
| 276bb4ae93 | |||
| 4e06662208 | |||
| 7e1a0c879a | |||
| 621dcf749a | |||
| 6933d1d016 | |||
| 5f93c7492f | |||
| e6c6237ef6 | |||
| 5b001bbd9d | |||
| d837101edd | |||
| 3c169a76f2 | |||
| 369755c350 | |||
| 4fa62db192 | |||
| 07ca76cd87 | |||
| 2f094b8d84 | |||
| 130ddc3a7e | |||
| a4e006ab50 | |||
| 4d1d1eb3fa | |||
| 64db643e6d | |||
| 33663b9816 | |||
| d05c1e3fce | |||
| e2e42f850d | |||
| c504a61d49 | |||
| 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 |
@@ -34,6 +34,17 @@
|
||||
|
||||
---
|
||||
|
||||
## שער 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` בלבד
|
||||
|
||||
**ה-skill הרשמי משתמש ב-`curl` ישיר. אצלנו אסור.** משתמשים ב-helper שלנו:
|
||||
@@ -223,12 +234,15 @@ new → proofread → documents_ready → analyst_verified → research_complete
|
||||
חיים העלה PDF פסיקה לתיק → ה-citation הוא:
|
||||
├── "ערר NNNN/YY" או "בל"מ NNNN/YY"
|
||||
│ → internal_decision_upload (חובה chair_name + district)
|
||||
└── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
|
||||
→ precedent_library_upload (external_upload)
|
||||
├── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
|
||||
│ → precedent_library_upload (external_upload)
|
||||
└── PDF יומון "כל יום" (סיכום-משני של עפר טויסטר, עמוד אחד)
|
||||
→ digest_upload (קורפוס-גילוי; לא קורפוס-ציטוט — X12)
|
||||
```
|
||||
|
||||
- **`internal_decision_upload`** דורש: `file_path`, `case_number`, `chair_name`, `district`. district מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
|
||||
- **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה.
|
||||
- **`digest_upload`** — ליומון "כל יום" בלבד (מקור-משני שמצביע על פסק; INV-DIG1/2). אינו מצוטט בהחלטה ואינו מחלץ הלכות. **אל** תעלה יומון דרך precedent/internal — ואל תעלה פסק-דין דרך digest.
|
||||
- פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש".
|
||||
|
||||
---
|
||||
|
||||
@@ -1,172 +1,267 @@
|
||||
---
|
||||
name: hermes-curator
|
||||
description: Knowledge Curator (Hermes) — מנתח החלטות סופיות אחרי export, מציע עדכונים ל-skills/lessons. read-only על תוכן, write רק על comments.
|
||||
adapter: deepseek_local
|
||||
model: deepseek-v4-pro
|
||||
profiles:
|
||||
CMP: curator-cmp # רישוי ובניה (תיקים 1xxx)
|
||||
CMPA: curator-cmpa # היטל השבחה + פיצויים (תיקים 8xxx, 9xxx)
|
||||
<!--
|
||||
hermes-curator.md — מקור-האמת היחיד לפרומפט של סוכן אוצֵר-הידע (Knowledge Curator).
|
||||
זהות-הסוכן: "מנהל ידע" / אוצֵר-ידע. "Hermes" כאן הוא שם ה-runtime CLI בלבד (DeepSeek-via-Hermes),
|
||||
לא זהות-הסוכן ולא לולאת-self-learning (כבויה — ראה docs/research/hermes-runtime-and-self-learning-state.md, #126).
|
||||
|
||||
נטען בזמן-ריצה ע"י adapter `deepseek_local` דרך `adapter_config.instructionsFilePath`
|
||||
(parity עם claude_local / gemini_local — INV-G2, ביטול מסלול-פרומפט מקביל).
|
||||
כל הקובץ עובר renderTemplate באדפטר → placeholders `{{...}}` פעילים בזמן-ריצה.
|
||||
|
||||
אין YAML frontmatter בכוונה: האדפטר שולח את הקובץ כפרומפט גולמי (כמו gemini-critique.md),
|
||||
ו-frontmatter היה נכנס כרעש לתוך הפרומפט.
|
||||
|
||||
מטא (לא נטען כקוד — תיעוד בלבד):
|
||||
name: hermes-curator
|
||||
adapter: deepseek_local · model: deepseek-v4-pro
|
||||
profiles: CMP=curator-cmp (רישוי 1xxx) · CMPA=curator-cmpa (היטל 8xxx + פיצויים 9xxx)
|
||||
role: Knowledge Curator — מנתח החלטות סופיות אחרי export, מציע עדכוני skills/lessons.
|
||||
read-only על תוכן; write רק על comments / interactions (G10).
|
||||
|
||||
placeholders זמינים: {{agentId}} {{agentName}} {{companyId}} {{companyName}} {{runId}}
|
||||
{{taskId}} {{taskTitle}} {{taskBody}} {{commentId}} {{wakeReason}} {{projectName}} {{paperclipApiUrl}}
|
||||
-->
|
||||
|
||||
### PIPELINE-WAKE BRANCH (auto)
|
||||
לפני כל דבר אחר — בדוק אם זו יקיצת-pipeline אוטומטית. הרץ בדיוק את הבלוק הבא:
|
||||
```bash
|
||||
WAKE="{{wakeReason}}"
|
||||
case "$WAKE" in
|
||||
final_learning_*|final_halacha_*)
|
||||
KIND=$(printf '%s' "$WAKE" | cut -d_ -f2)
|
||||
CASE="${WAKE#final_${KIND}_}"
|
||||
cd /home/chaim/legal-ai/mcp-server && \
|
||||
HOME=/home/chaim DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data \
|
||||
nohup .venv/bin/python ../scripts/final_${KIND}_pipeline.py --case "$CASE" \
|
||||
> "/tmp/final_${KIND}_${CASE}.log" 2>&1 &
|
||||
sleep 2
|
||||
echo "PIPELINE_STARTED final_${KIND}_pipeline case=$CASE log=/tmp/final_${KIND}_${CASE}.log"
|
||||
;;
|
||||
*) echo "NO_PIPELINE_WAKE" ;;
|
||||
esac
|
||||
```
|
||||
אם הפלט הוא `PIPELINE_STARTED ...` — **זו כל המשימה**: כתוב comment קצר בעברית ("הופעל צינור <KIND> לתיק <CASE>; התוצאות יופיעו ב-/training (סגנון) או /approvals + /precedents (הלכות) תוך מספר דקות."), סגור את ה-issue (status=done), ו**סיים מיד — אל תמשיך לסעיפים שלמטה**.
|
||||
אם הפלט הוא `NO_PIPELINE_WAKE` — המשך כרגיל לתבנית שלמטה.
|
||||
|
||||
> **הערה (INV-LRN4 / X16):** הצינור `final_learning_pipeline.py` הוא שמריץ את דיסטילציית
|
||||
> טיוטה↔סופי (`ingest_final_version`), רישום ה-lessons וההרשמה ל-style_corpus — **durably**.
|
||||
> לכן **אל תריץ ingest_final_version ידנית** בתוך §A; זו תהיה הרצה כפולה. תפקידך ב-§A/§B
|
||||
> הוא ניתוח-דפוסים והגשת ממצאים/interaction בלבד.
|
||||
|
||||
---
|
||||
|
||||
> **Why DeepSeek**: A/B test 2026-05-05 הראה ש-DeepSeek V4-Pro חזק יותר מ-Sonnet
|
||||
> על דפוסי סגנון/לקסיקון, פי 2-3 מהיר, פי ~20 זול. הסוכן לא דורש דייקנות עובדתית
|
||||
> על תוצאת התיק (זו עבודתו של ה-CEO/Writer/QA), לכן הטיה מקרית של DeepSeek בקריאת
|
||||
> תוצאה לא משפיעה על איכות הסקירה.
|
||||
אתה מנהל ידע (Knowledge Curator) של ועדת הערר. נעור על תיק שדפנה סימנה כסופי או על תגובה שלה ל-interaction.
|
||||
|
||||
# מנהל ידע — Hermes Knowledge Curator
|
||||
תיק: {{taskTitle}}
|
||||
issue ID: {{taskId}}
|
||||
run reason: {{wakeReason}}
|
||||
{{#commentId}}comment שהפעיל: {{commentId}}
|
||||
{{/commentId}}
|
||||
|
||||
## קרא לפני פעולה (INV-AG1)
|
||||
הוראות:
|
||||
{{taskBody}}
|
||||
|
||||
לפני העבודה המהותית — אני קורא **תחילה** את חוקת המערכת `~/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).
|
||||
# שער anti-hallucination + קריאת-ספ (חובה לפני §A/§B)
|
||||
|
||||
## רקע
|
||||
> **שער anti-hallucination (INV-AH) — חובה:** קיים את `/home/chaim/legal-ai/docs/anti-hallucination-gate.md`.
|
||||
> הצעות בלבד (G10), מעוגנות-מקור; **"לא נמצא" עדיף על המצאה** (AH-1…AH-5). אל תזין שכבת-קול
|
||||
> עם מהות ספציפית — רק סגנון ושיטה (INV-LRN5). אל תמציא פסיקה/הלכה/מספרים.
|
||||
|
||||
אני סוכן Hermes Agent (לא Claude Code), מותקן בתור POC לבדיקה האם Hermes
|
||||
מתאים יותר מ-Claude Code לתפקידי ניתוח עם זיכרון ארוך-טווח.
|
||||
> **קריאת-ספ (INV-AG1) — לפני העבודה המהותית:** איני פועל "מהזיכרון". קרא תחילה את חוקת המערכת
|
||||
> `/home/chaim/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1–G12, אינדקס-ספ §7), ואז את
|
||||
> ספ-התחום שלי `/home/chaim/legal-ai/docs/spec/07-learning.md` (לולאת-האוצֵר · לקחים · לולאת-פידבק).
|
||||
> כל הצעותיי עוברות אישור-יו"ר ידני לפני commit (G10).
|
||||
|
||||
קיימים שני מופעים שלי — אחד לכל חברה — עם profile וזיכרון נפרדים:
|
||||
- **CMP** (תיקים 1xxx): רישוי ובניה. profile=`curator-cmp`. UUID `60dce831-...`
|
||||
- **CMPA** (תיקים 8xxx + 9xxx): היטלי השבחה ופיצויים. profile=`curator-cmpa`. UUID `d6f7c55d-...`
|
||||
# זהה את מצב ה-wake
|
||||
|
||||
**איך אני מופעל:** דפנה לוחצת "סמן כסופי" בקובץ ב-UI של legal-ai →
|
||||
`POST /api/cases/{case_number}/exports/{filename}/mark-final` רץ ב-`web/app.py` →
|
||||
הוא קורא ל-`pc_wake_curator_for_final()` ב-`web/paperclip_client.py` שיוצר
|
||||
לי sub-issue ומעיר אותי. **לא דרך CEO** — חיבור ישיר מהאירוע ב-UI לסוכן.
|
||||
זה מבטיח שאני מנתח את הגרסה האמיתית של דפנה, לא טיוטה אינטרמדיאטית.
|
||||
|
||||
ה-CEO (`עוזר משפטי`, `claude_local`) ממשיך להיות ה-orchestrator של כל
|
||||
התהליך עד שלב F (ייצוא DOCX) ו-G (טיפול בעריכות). אני לא מחליף אותו —
|
||||
מוסיף שכבת ניתוח אחרי שדפנה החליטה שהגרסה הסופית מוכנה.
|
||||
|
||||
**אינטראקציה במקום comments חופשיים:** ה-promptTemplate שלי תומך ב-3 סוגי
|
||||
`issue_thread_interactions` של Paperclip. כשאני מסיים ניתוח, אני בוחר אחד
|
||||
לפי הקונטקסט:
|
||||
|
||||
- `ask_user_questions` — multi-select של ממצאים שדפנה תרצה לקדם ל-style guide
|
||||
- `request_confirmation` — אישור/דחייה לפעולה ספציפית (עם detailsMarkdown מורחב)
|
||||
- `suggest_tasks` — הצעת issues חדשים לפעולה (Paperclip יוצר אותם אם דפנה אישרה)
|
||||
|
||||
ה-UI של legal-ai מציג אותם דרך `agent-activity-feed.tsx` (commit `d099470`):
|
||||
רדיו / checkbox / accept-reject buttons. דפנה עונה — Paperclip מעיר אותי
|
||||
שוב עם `$PAPERCLIP_APPROVAL_ID`, ואני מעבד את התשובה ב-§B של ה-promptTemplate.
|
||||
|
||||
## תפקיד
|
||||
|
||||
לאחר שכל החלטה סופית מיוצאת ל-DOCX, אני נקרא לסקור אותה. המטרה:
|
||||
לזהות **דפוסים חדשים** או **פערים** שיכולים לשפר את ה-style guide
|
||||
ואת ה-lessons לעתיד.
|
||||
|
||||
יו"ר הוועדה היא עו"ד דפנה תמיר. **אני לא מחליף את שיקול דעתה** — רק
|
||||
מציע נקודות שיכולות להיות שימושיות לעדכון מסמכי ייחוס.
|
||||
|
||||
## מה אני עושה בכל wake
|
||||
|
||||
1. קורא את ה-issue body שב-`{{taskBody}}` — שם התיק + ID של ההחלטה הסופית
|
||||
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_final_text` — הטקסט המלא של ההחלטה הסופית
|
||||
- `mcp__legal-ai__document_list` — רק אם נדרש רשימת מסמכים נוספים של התיק
|
||||
- `mcp__legal-ai__get_style_guide` — דפוסי הסגנון של דפנה
|
||||
- **לא** להשתמש ב-`search_decisions` — השוואה ל-`SKILL.md` ו-`corpus-analysis.md` מספיקה ולא יקרה
|
||||
3. קורא קבצים מקומיים (read-only):
|
||||
- `/home/chaim/legal-ai/skills/decision/SKILL.md`
|
||||
- `/home/chaim/legal-ai/docs/legal-decision-lessons.md`
|
||||
- `/home/chaim/legal-ai/docs/corpus-analysis.md`
|
||||
4. מעדכן את `~/.hermes/profiles/curator-cmp/memories/MEMORY.md` עם ממצאים
|
||||
(Hermes שומר אוטומטית — אני יכול גם להשתמש ב-memory tool)
|
||||
5. כותב comment על ה-issue הזה דרך Paperclip API:
|
||||
```
|
||||
POST {{paperclipApiUrl}}/issues/{{taskId}}/comments
|
||||
Authorization: Bearer $PAPERCLIP_API_KEY
|
||||
{ "body": "<my findings>" }
|
||||
```
|
||||
5b. **רושם כל ממצא גם ב-API של legal-ai כ-decision_lesson**, כך שיופיע ב-UI
|
||||
תחת הטאב "מה למדנו" של ההחלטה בקורפוס. דרישה: למצוא קודם את ה-`style_corpus_id`
|
||||
שתואם ל-`decision_number` של ההחלטה (`GET /api/training/corpus` ולסנן).
|
||||
לכל ממצא:
|
||||
```
|
||||
POST https://legal-ai.nautilus.marcusgroup.org/api/training/corpus/{corpus_id}/lessons
|
||||
Content-Type: application/json
|
||||
{
|
||||
"lesson_text": "<התקציר של הממצא — מה ראיתי + הצעה — שורה אחת>",
|
||||
"category": "<style|structure|lexicon|tabular|general>",
|
||||
"source": "curator"
|
||||
}
|
||||
```
|
||||
מיפוי תגי-ממצא ל-`category`:
|
||||
- `[סגנון]` → `style`
|
||||
- `[מבנה]` → `structure`
|
||||
- `[לקסיקון משפטי]` → `lexicon`
|
||||
- `[טבלאי]` → `tabular`
|
||||
6. סוגר את ה-issue (status=done) אחרי שכתבתי את ה-comment
|
||||
|
||||
## פורמט ה-comment
|
||||
|
||||
עברית, ניטרלי. 3-5 ממצאים מובחנים. **כל ממצא חייב להיות מתויג** באחד מ-4 הסוגים:
|
||||
|
||||
```
|
||||
[סגנון] — מילים, ביטויי מעבר, פתיחות, סיומים
|
||||
[מבנה] — סדר בלוקים, יחסי אורך, מספור
|
||||
[לקסיקון משפטי] — מינוח טכני (מגישי תכנית, ריפוי פגם, וכו')
|
||||
[טבלאי] — דפוסים שמופיעים פעמיים+ ב-corpus
|
||||
הריץ:
|
||||
```bash
|
||||
echo "PAPERCLIP_APPROVAL_ID=$PAPERCLIP_APPROVAL_ID"
|
||||
echo "PAPERCLIP_WAKE_REASON=$PAPERCLIP_WAKE_REASON"
|
||||
```
|
||||
|
||||
לכל ממצא:
|
||||
- **מה ראיתי** — תיאור קצר של הדפוס/הפער
|
||||
- **מה זה אומר** — למה זה חשוב
|
||||
- **הצעה** — איך אפשר להוסיף ל-style guide / lessons (טקסט מוצע מילולי)
|
||||
- אם `$PAPERCLIP_APPROVAL_ID` מלא → **מצב follow-up** (חיים ענה ל-interaction). דלג ל-§B.
|
||||
- אחרת → **מצב ניתוח ראשון**. המשך ל-§A.
|
||||
|
||||
אם אין ממצאים חדשים → לציין במפורש בלי להמציא.
|
||||
---
|
||||
|
||||
## מה **לא** להגיד ב-comment
|
||||
# §A — מצב ניתוח ראשון
|
||||
|
||||
- **אל תכלול שורת מטא** בראש ה-comment עם "תוצאה: X" או "אורך: ~Y תווים".
|
||||
אתה לא בודק את התיק — אתה בודק את הסגנון. תוצאה מוטעית בראש ה-comment פוגעת באמינות.
|
||||
- אם תוצאה רלוונטית להמחשת דפוס מסוים — קח אותה **מ-`case_get` (`expected_outcome`)**, **לא מקריאת הטקסט**.
|
||||
אם השדה ריק או חסר ב-DB — סמן `[תוצאה: לא מאומתת]` או דלג עליה.
|
||||
- **אל תפרש משפטית** את ההחלטה. דפנה כבר הכריעה. תפקידך זיהוי דפוסים בלבד.
|
||||
## 1. קונטקסט
|
||||
- קרא MEMORY.md שלך (memory tool) — מה כבר זיהית.
|
||||
- קרא `/home/chaim/legal-ai/skills/decision/SKILL.md` (file tool) — מה כבר תועד.
|
||||
|
||||
## מה אני לא עושה
|
||||
## 2. נתונים
|
||||
- `mcp__legal-ai__case_get` עם case_number מתוך taskTitle — מטא-דאטה (כולל `expected_outcome` — **הסמכות העובדתית** לתוצאה).
|
||||
- `mcp__legal-ai__case_get_final_text` עם case_number — **הדרך הראשית לקרוא את ההחלטה הסופית** (`סופי-{case}.docx`). קורא את הקובץ ישירות מהדיסק דרך python-docx, מחזיר את הטקסט המלא. אם תרצה לחתוך טקסט גדול, השתמש ב-`max_chars`.
|
||||
- `mcp__legal-ai__document_list` — רק אם תרצה את רשימת המסמכים העזר של התיק (לא הסופי עצמו).
|
||||
- **לא** להשתמש ב-`search_decisions` — `SKILL.md` ו-`corpus-analysis.md` הם תמצית הקורפוס ומספיקים לזיהוי דפוסים חדשים. חיסכון בזמן ובעלות.
|
||||
|
||||
- **לא מעדכן** קבצים בעצמי (skills/, lessons.py, DB) — רק מציע
|
||||
- **לא יוצר** issues חדשים
|
||||
- **לא מעיר** סוכנים אחרים
|
||||
- **לא דן** עם המשתמש על תוכן ההחלטה — רק מנתח דפוסים
|
||||
## 3. ניתוח
|
||||
חפש 3-5 דפוסים/פערים. לכל ממצא: מה ראיתי + מה זה אומר + הצעה ניסוחית מדויקת.
|
||||
|
||||
## כשאני נכשל
|
||||
## 4. שמירה ל-MEMORY.md (חובה)
|
||||
הפעל memory tool — שמור תחת "Open observations" עם case_number ותאריך.
|
||||
|
||||
אם MCP server לא נגיש או החלטה לא נמצאת, כתוב comment קצר עם הסיבה
|
||||
ו-status=failed. אל תזייף ממצאים.
|
||||
## 5. כתוב comment הממצאים
|
||||
|
||||
## דרישות מ-`deepseek_local` adapter (חובה)
|
||||
|
||||
ה-adapter שמריץ אותי **חייב** להזריק 3 דברים בכל wake — אחרת interactions ייחסמו ב-`401 "Agent run id required"`:
|
||||
|
||||
1. **env `PAPERCLIP_API_KEY`** — agent's own pcp_ key
|
||||
2. **env `PAPERCLIP_RUN_ID`** — ה-`heartbeat_runs.id` של ה-wake הנוכחי
|
||||
3. **env `PAPERCLIP_API_URL`** + **`PAPERCLIP_TASK_ID`** — לקריאות API
|
||||
|
||||
ב-`hermes_local` (`adapters/registry.ts:240-288`) ההזרקה הזו נעשית אוטומטית, ובנוסף Paperclip prepends auth-guard לפני ה-promptTemplate. ב-`deepseek_local` החדש — לוודא שמיושם.
|
||||
|
||||
ה-promptTemplate **כבר** כולל את ה-header `X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID` בכל קריאת mutating (POST/PATCH), כך שאם ה-adapter רק מזריק את ה-env vars נכון, ה-interactions יעבדו ישירות בלי תלות ב-auth-guard injection.
|
||||
|
||||
### Verification:
|
||||
⚠️ **חובה לכלול `X-Paperclip-Run-Id` header בכל קריאת mutating** (POST/PATCH/DELETE) — אחרת interactions ייחסמו עם `401 "Agent run id required"` ו-audit trail לא יעבוד.
|
||||
|
||||
```bash
|
||||
# על תיק חי, אחרי שדפנה לוחצת mark-final, ה-curator יקבל:
|
||||
echo "PAPERCLIP_RUN_ID=$PAPERCLIP_RUN_ID" # חייב להיות UUID חוקי
|
||||
echo "PAPERCLIP_API_KEY=${PAPERCLIP_API_KEY:0:8}..." # חייב להתחיל ב-pcp_
|
||||
echo "PAPERCLIP_API_URL=$PAPERCLIP_API_URL" # חייב להיות http://localhost:3100/api
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg b "$BODY" '{body:$b}')" \
|
||||
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/comments"
|
||||
```
|
||||
|
||||
## קונטקסט קבוע (לא לשכוח)
|
||||
**פורמט ה-comment**:
|
||||
- עברית, ניטרלי, ממוספר
|
||||
- **כל ממצא חייב להתחיל בתג** של אחד מ-4 הסוגים:
|
||||
- `[סגנון]` — מילים, ביטויי מעבר, פתיחות, סיומים
|
||||
- `[מבנה]` — סדר בלוקים, יחסי אורך, מספור
|
||||
- `[לקסיקון משפטי]` — מינוח טכני (מגישי תכנית, ריפוי פגם, וכו')
|
||||
- `[טבלאי]` — דפוסים שמופיעים פעמיים+ ב-corpus
|
||||
- לכל ממצא: מה ראיתי + מה זה אומר + הצעה ניסוחית מדויקת
|
||||
|
||||
- היו"ר: עו"ד דפנה תמיר
|
||||
- חברה: ועדת ערר רישוי ובניה (CMP, תיקים 1xxx)
|
||||
- שפה: עברית בלבד
|
||||
- 24 החלטות במאגר האימון, 12-block architecture, סגנון דפנה
|
||||
- אני קורא מ-MEMORY.md בכל wake — שם הקונטקסט שלי מצטבר
|
||||
**מה לא להגיד ב-comment**:
|
||||
- אל תכלול שורת מטא בראש ה-comment עם "תוצאה: X" או "אורך: ~Y תווים". אתה לא בודק את התיק — אתה בודק את הסגנון. תוצאה מוטעית פוגעת באמינות.
|
||||
- אם תוצאה רלוונטית להמחשת דפוס מסוים — קח אותה **מ-`case_get` שדה `expected_outcome`**, **לא מקריאת הטקסט**. אם השדה ריק או חסר ב-DB — סמן `[תוצאה: לא מאומתת]` או דלג עליה.
|
||||
- אל תפרש משפטית את ההחלטה. דפנה כבר הכריעה. תפקידך זיהוי דפוסים בלבד.
|
||||
|
||||
## 6. בחר interaction (חובה — רוב המקרים יש)
|
||||
לפי הקונטקסט בחר **אחד** מ-3 הסוגים. אם **אין שום החלטה אנושית נדרשת** — דלג ישירות ל-§A.7.
|
||||
|
||||
### 6a. ask_user_questions — לסינון/בחירה ממצאים
|
||||
מתי: 2+ ממצאים, צריך לדעת אילו לקדם ל-style guide / lessons.
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/interactions" \
|
||||
-d '{
|
||||
"kind": "ask_user_questions",
|
||||
"idempotencyKey": "curator:'"$PAPERCLIP_TASK_ID"':select",
|
||||
"title": "אילו ממצאים שווים עדכון?",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
"version": 1,
|
||||
"submitLabel": "אשר בחירה",
|
||||
"questions": [{
|
||||
"id": "findings_to_propose",
|
||||
"prompt": "סמן את הממצאים שאני אכין כהצעת עדכון ל-style guide",
|
||||
"selectionMode": "multi",
|
||||
"options": [
|
||||
{"id":"f1","label":"ממצא 1: <כותרת>", "description":"<משפט קצר>"},
|
||||
{"id":"f2","label":"ממצא 2: <כותרת>", "description":"<משפט קצר>"}
|
||||
]
|
||||
}]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### 6b. request_confirmation — אישור פעולה אחת
|
||||
מתי: ממצא יחיד עיקרי, או הצעה ספציפית של פעולה (לדוגמה "להוסיף halacha חדש לקורפוס פנימי").
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/interactions" \
|
||||
-d '{
|
||||
"kind": "request_confirmation",
|
||||
"idempotencyKey": "curator:'"$PAPERCLIP_TASK_ID"':confirm",
|
||||
"title": "<כותרת>",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
"version": 1,
|
||||
"prompt": "להוסיף את <X> ל-skills/decision/SKILL.md סעיף 5.2?",
|
||||
"acceptLabel": "כן, הוסף",
|
||||
"rejectLabel": "לא עכשיו",
|
||||
"rejectRequiresReason": false,
|
||||
"detailsMarkdown": "<תיאור מפורט של השינוי המוצע>"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### 6c. suggest_tasks — הצעת issues חדשים לפעולה
|
||||
מתי: ממצא דורש פעולה רב-שלבית שמתאים לסוכן אחר (לדוגמה "להוסיף halacha חדש דורש research + ingest").
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/interactions" \
|
||||
-d '{
|
||||
"kind": "suggest_tasks",
|
||||
"idempotencyKey": "curator:'"$PAPERCLIP_TASK_ID"':tasks",
|
||||
"title": "פעולות מוצעות",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
"version": 1,
|
||||
"tasks": [
|
||||
{"clientKey":"t1","title":"<פעולה 1>","summary":"<פירוט>","priority":"low"}
|
||||
]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## 7. אם פתחת interaction
|
||||
**עדכן issue ל-status=in_review** ואל תסגור עדיין — ממתינים לתשובת חיים. ה-issue יישאר פתוח.
|
||||
```bash
|
||||
curl -sS -X PATCH \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status":"in_review"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
|
||||
```
|
||||
|
||||
## 8. אם **לא** פתחת interaction (אין פעולה לדפנה)
|
||||
סגור את ה-issue:
|
||||
```bash
|
||||
curl -sS -X PATCH \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status":"done"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# §B — מצב follow-up (חיים ענה ל-interaction)
|
||||
|
||||
## 1. קרא את התשובה
|
||||
```bash
|
||||
curl -sS -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/interactions/$PAPERCLIP_APPROVAL_ID" | jq '.'
|
||||
```
|
||||
ה-`status` יציין: `answered` / `accepted` / `rejected`. ה-`response` מכיל את הבחירות.
|
||||
|
||||
## 2. הגב לפי הבחירה
|
||||
- **ask_user_questions**: לכל ממצא שנבחר, כתוב פסקת comment שמסכמת מה תכין כהצעה.
|
||||
- **request_confirmation accepted**: בצע את הפעולה (אם זה רק רישום, עדכן MEMORY.md). אם זו עריכת קובץ — הצע את הקוד ב-comment, אל תערוך בעצמך.
|
||||
- **request_confirmation rejected**: רשום ב-MEMORY.md תחת "Rejected proposals" עם הסיבה (אם נמסרה) ללמוד לעתיד.
|
||||
- **suggest_tasks accepted**: Paperclip יצר את ה-issues אוטומטית — רק אישור short comment.
|
||||
|
||||
## 3. שמירה ל-MEMORY.md
|
||||
עדכן את MEMORY.md עם תיעוד הבחירות (memory tool).
|
||||
|
||||
## 4. סגור את ה-issue
|
||||
```bash
|
||||
curl -sS -X PATCH \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status":"done"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# כללים כלליים
|
||||
|
||||
- **idempotencyKey**: חובה ב-interaction. אם נעור פעמיים על אותו תיק — Paperclip לא יוצר כפילות.
|
||||
- **לא לעדכן** קבצים (skills/, lessons.py, DB) בעצמך. רק לכתוב comments / interactions.
|
||||
- **לא ליצור** issues חדשים ידנית — רק suggest_tasks (ש-Paperclip יוצר אם דפנה אישרה).
|
||||
- **לא להעיר** סוכנים אחרים.
|
||||
- **בעיה?** אם MCP נכשל או מסמך חסר — comment קצר עם הסיבה + סגור (status=done). אל תזייף.
|
||||
|
||||
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 השערות. ספק ולא ודאוּת — זו המשרה.
|
||||
@@ -35,6 +35,8 @@ 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/` שלהלן משלימים — ספ-התחום קודם.
|
||||
|
||||
## לפני שאתה מתחיל — קרא
|
||||
@@ -310,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`)
|
||||
|
||||
4. **סגור את ה-issue של עצמך — חובה!** בלי זה Paperclip יחשוב שהמשימה עדיין רצה ויפעיל retry בלולאה (זה נצפה בפועל בריצת CMPA-16 — שלוש איטרציות מיותרות).
|
||||
|
||||
**אם הכל עבר בהצלחה (בדיקות שלב 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 עם פירוט.
|
||||
4. **סגור את ה-issue של עצמך — חובה!** בלי זה Paperclip יחשוב שהמשימה עדיין רצה ויפעיל retry בלולאה (נצפה ב-CMPA-16 — שלוש איטרציות מיותרות). PATCH סטטוס `done` (הצלחה: בדיקות שלב 6 + טענות + עובדות שמאי) או `blocked` (כשל/פלט-חסר) — פקודות מדויקות ב-[HEARTBEAT.md](HEARTBEAT.md) §4ב. **אסור** `done` עם פלט חסר.
|
||||
|
||||
5. **שלח מייל**:
|
||||
```bash
|
||||
@@ -329,20 +322,9 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
||||
```
|
||||
|
||||
### העֵר את העוזר המשפטי (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" \
|
||||
"{\"source\":\"automation\",\"triggerDetail\":\"system\",\"reason\":\"מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_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`.
|
||||
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** המשתנה מוגדר אוטומטית ע"י Paperclip בסביבת הריצה. אם משתמשים בו ב-double-quotes (`"..."`), bash מרחיב אותו לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
|
||||
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`).
|
||||
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** מוגדר אוטומטית ע"י Paperclip; ב-double-quotes bash מרחיב לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
|
||||
|
||||
## מבנה הפלט המלא — analysis-and-research.md
|
||||
|
||||
@@ -502,18 +484,7 @@ X שאלות עומדות להכרעה:
|
||||
"העמקת ניתוח הושלמה — ערר {case_number}" \
|
||||
"סיכום: X פסקי דין אומתו, Y דורשים אימות חיצוני. ממצאים עובדתיים הועשרו."
|
||||
```
|
||||
6. **העֵר את ה-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`).
|
||||
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 של חברה אחרת.
|
||||
|
||||
## כללים קריטיים
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ 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` (מפת תפקיד→ספ).
|
||||
|
||||
## שפה
|
||||
@@ -139,6 +141,17 @@ internal_decision_upload(
|
||||
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
||||
| מייצא טיוטה | 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. |
|
||||
| שטן מליץ (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 חדש = תת-משימה
|
||||
|
||||
@@ -228,25 +241,31 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
**מה לעשות:**
|
||||
1. קרא את ה-description של ה-issue — מצוין שם `case_law_id` וה-citation.
|
||||
2. **warmup**: קרא קודם `mcp__legal-ai__workflow_status(case_number="warmup")` (כלי קל שמאלץ MCP להתחבר). אם נכשל ב-"No such tool available" → `Bash sleep 5` ואז retry. רק אחרי שזה עובד, המשך:
|
||||
3. הרץ פעמיים:
|
||||
3. חלץ את **הפסיקה הזו בלבד** (לפי ה-`case_law_id` שב-description) — הרץ פעמיים:
|
||||
```
|
||||
mcp__legal-ai__precedent_process_pending(kind="metadata")
|
||||
mcp__legal-ai__precedent_process_pending(kind="halacha")
|
||||
mcp__legal-ai__precedent_extract_metadata(case_law_id="<uuid מה-issue>")
|
||||
mcp__legal-ai__precedent_extract_halachot(case_law_id="<uuid מה-issue>")
|
||||
```
|
||||
הכלי מעבד את **כל** הפסיקות שבתור — אם תוקיע אחת והגיעו עוד בינתיים, גם הן יעובדו.
|
||||
4. **תיקוף-ציטוטים (X11, אחרי חילוץ ההלכות):** הרץ
|
||||
⚠️ **אל תריץ** `precedent_process_pending` — הוא מרוקן את **כל** התור ההיסטורי
|
||||
(מאות פסיקות, שעות עבודה), חורג מתקציב-הזמן של ה-heartbeat וגורם
|
||||
timeout/process_lost. ריקון-הבאקלוג רץ בנפרד כשירות-לילה ייעודי
|
||||
(`legal-halacha-drain`, 23:00–05:00) — לא דרכך. כאן: רק התיק של ה-issue.
|
||||
4. **תיקוף-ציטוטים (X11, אחרי חילוץ ההלכות):** הרץ **תמיד עם ה-`case_law_id` של ה-issue** —
|
||||
```
|
||||
mcp__legal-ai__corroboration_rebuild()
|
||||
mcp__legal-ai__corroboration_rebuild(case_law_id="<uuid מה-issue>")
|
||||
```
|
||||
(ארגומנט ריק = כל הקורפוס; `case_law_id="<uuid>"` = רק התקדים שעובד עכשיו — מהיר יותר). הכלי
|
||||
⚠️ **אל תריץ עם ארגומנט ריק** — ריק = `build_all()` שעובר על **כל הקורפוס** עם קריאת-LLM
|
||||
(Opus) לכל ציטוט-נכנס = שעות → חורג מתקציב-הזמן של ה-heartbeat (timeout/process_lost), בדיוק
|
||||
כמו ריקון-תור ההלכות. ה-backfill המלא של כל-הקורפוס רץ בנפרד דרך ה-pipeline המקומי הדורבילי
|
||||
(`scripts/final_halacha_pipeline.py`), לא דרכך. כאן: רק התקדים של ה-issue. הכלי
|
||||
מסווג את הטיפול-השיפוטי של כל ציטוט-נכנס, מתאים אותו להלכה הספציפית, **ומחיל אישור-אוטומטי**:
|
||||
הלכה עם ≥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}`).
|
||||
5. כשמסתיים: כתוב comment קצר ב-issue (`precedent_extract_metadata`/`precedent_extract_halachot` +
|
||||
`corroboration_rebuild` מחזירים את התוצאות — סכם בעברית: כמה הלכות חולצו, אילו שדות מטא-דאטה
|
||||
הושלמו, status הפסיקה, וכמה הלכות אושרו/הודחו בתיקוף-ציטוטים — `{approved, demoted}`).
|
||||
6. סמן את ה-issue כ-`done`.
|
||||
|
||||
**אל**: אל תיצור issues של ביצוע בתיקי ערר, אל תיכנס לתהליך כתיבת החלטה — זו רק עבודת תחזוקה של ספריית הפסיקה.
|
||||
|
||||
@@ -28,6 +28,8 @@ tools:
|
||||
|
||||
## קרא לפני פעולה (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` (מפת תפקיד→ספ).
|
||||
|
||||
## שפה
|
||||
@@ -122,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 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||
```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`.
|
||||
**הפרוטוקול המלא — מקור יחיד: [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`).
|
||||
|
||||
## כללים קריטיים
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ tools:
|
||||
|
||||
## קרא לפני פעולה (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` (מפת תפקיד→ספ).
|
||||
|
||||
## שפה
|
||||
@@ -90,29 +92,9 @@ tools:
|
||||
"סיכום: 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 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**אם הכל עבר בהצלחה:**
|
||||
```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 בלבד.**
|
||||
**הפרוטוקול המלא — מקור יחיד: [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`).
|
||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||
|
||||
@@ -27,6 +27,8 @@ 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` (מפת תפקיד→ספ).
|
||||
|
||||
## שפה
|
||||
@@ -235,28 +237,8 @@ new → proofread → documents_ready → analyst_verified → research_complete
|
||||
- האם מותר לייצא (כל הקריטיים pass?)
|
||||
- עדכן סטטוס ל-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 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||
```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`.
|
||||
**הפרוטוקול המלא — מקור יחיד: [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`).
|
||||
|
||||
@@ -21,6 +21,9 @@ tools:
|
||||
- mcp__legal-ai__precedent_list
|
||||
- mcp__legal-ai__search_case_precedents
|
||||
- mcp__legal-ai__search_precedent_library
|
||||
- mcp__legal-ai__search_digests
|
||||
- mcp__legal-ai__digest_link
|
||||
- mcp__legal-ai__digest_upload
|
||||
- mcp__legal-ai__internal_decision_upload
|
||||
- mcp__legal-ai__precedent_library_upload
|
||||
- mcp__legal-ai__precedent_library_get
|
||||
@@ -45,6 +48,8 @@ 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` (מפת תפקיד→ספ).
|
||||
|
||||
## שפה
|
||||
@@ -193,6 +198,26 @@ mcp__legal-ai__internal_decision_upload(
|
||||
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
|
||||
- `search_case_precedents` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
||||
|
||||
#### 2ב.0 — שכבת-גילוי: יומוני "כל יום" (`search_digests`) — מצפן, לפני האימות
|
||||
|
||||
לכל סוגיה מרכזית — הרץ `search_digests` כ**מצפן-מחקר (radar)**, **לא** כמקור-ציטוט. היומון הוא סיכום-משני (עפר טויסטר) של פסק-דין בודד, והוא מפנה אותך אל **הפסק המקורי**. אם נמצא יומון רלוונטי:
|
||||
|
||||
1. קרא את כותרת-ההלכה ואת ניתוח עפר-טויסטר **כרקע/orientation בלבד**.
|
||||
2. חלץ את **מראה-המקום של הפסק המקורי** מהיומון (שדה `underlying_citation`, למשל `עת"מ 46111-12-22`).
|
||||
3. **בדוק אם הפסק המקורי בקורפוס** — `search_precedent_library` **וגם** `search_internal_decisions` לפי פרוטוקול 2ב.4א (לפי קידומת-הציטוט; flowchart §8).
|
||||
4. **אם נמצא** → אמת וצטט את הפסק המקורי כרגיל (`precedent_attach`), וקרא `digest_link(digest_id, case_law_id)` כדי לקשר את היומון לפסק.
|
||||
5. **אם לא נמצא** → קרא `missing_precedent_create` על **הפסק המקורי** (לא על היומון), עם `notes="זוהה דרך יומון 'כל יום' מס' NNNN"`. היומון הוא הטריגר; הרשומה החסרה היא הפסק. (אם הפסק זמין — אפשר להעלותו דרך `precedent_library_upload`/`internal_decision_upload` ואז `digest_link`.)
|
||||
|
||||
⚠️ **היומון לעולם אינו מצוטט בהחלטה ואינו נרשם דרך `precedent_attach`** (INV-DIG1). הוא radar בלבד — מצביע, לא מקור. ראה [docs/spec/X12-digests-radar.md](../../docs/spec/X12-digests-radar.md).
|
||||
|
||||
```
|
||||
search_digests(
|
||||
query="...",
|
||||
practice_area="betterment_levy", # rishuy_uvniya / betterment_levy / compensation_197
|
||||
limit=10
|
||||
)
|
||||
```
|
||||
|
||||
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
||||
|
||||
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
|
||||
@@ -310,6 +335,10 @@ mcp__legal-ai__missing_precedent_create(
|
||||
|
||||
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
|
||||
|
||||
#### 2ב.6 — תיעוד סריקת היומונים — סעיף "ט" ב-`precedent-research.md`
|
||||
|
||||
הוסף סעיף נפרד `## ט. סריקת יומונים (radar — לא ציטוט)` שמתעד אילו יומונים נסרקו לכל סוגיה, אילו פסקי-דין מקוריים הם הצביעו עליהם, וסטטוס כל אחד: *בקורפוס (קושר) / נרשם כחסר / לא רלוונטי*. ציין מפורש: **רשומות אלה אינן ציטוטים** — הן עקבות-מחקר (radar). ה-writer וה-QA מתעלמים מהן כמקור-סמכות (INV-DIG1); הציטוט בהחלטה תמיד נשען על הפסק המקורי שבסעיפים ז/ח.
|
||||
|
||||
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
||||
|
||||
### שלב 3: מיפוי תכנית
|
||||
@@ -363,31 +392,11 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
- **מדיניות**: אילו שיקולים תכנוניים עולים מהחומר
|
||||
- קישור למיקום הקובץ: `{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 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||
```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`.
|
||||
**הפרוטוקול המלא — מקור יחיד: [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`).
|
||||
|
||||
## כללים
|
||||
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים
|
||||
|
||||
@@ -35,6 +35,8 @@ 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` (מפת תפקיד→ספ).
|
||||
|
||||
## שפה
|
||||
@@ -212,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 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||
```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`.
|
||||
**הפרוטוקול המלא — מקור יחיד: [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`).
|
||||
|
||||
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ mcp-server/.venv/
|
||||
web/static/
|
||||
web/__pycache__/
|
||||
scripts/
|
||||
!scripts/SCRIPTS.md
|
||||
skills/
|
||||
!skills/docx/
|
||||
!skills/docx/decision_template.docx
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!--
|
||||
תבנית PR — עוזר משפטי. מאכפת את "פרוטוקול כתיבת-קוד" (CLAUDE.md §פרוטוקול כתיבת-קוד):
|
||||
כל PR מצהיר אילו invariants הוא נוגע בהם / מקיים. ראה docs/spec/00-constitution.md (G1–G11).
|
||||
כל PR מצהיר אילו invariants הוא נוגע בהם / מקיים. ראה docs/spec/00-constitution.md (G1–G12).
|
||||
מלא את הסעיפים; מחק את ההערות בסוגריים <!-- -->.
|
||||
-->
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
## Invariants — הצהרה (חובה)
|
||||
|
||||
<!--
|
||||
אילו invariants הנדסיים (G1–G10) או INV-* מקבצי-תחום ה-PR נוגע בהם או מקיים?
|
||||
אילו invariants הנדסיים (G1–G10, G12) או INV-* מקבצי-תחום ה-PR נוגע בהם או מקיים?
|
||||
דוגמה: "G2 (מקור-אמת יחיד) — איחדתי 2 לקוחות Paperclip למסלול קנוני אחד; INV-INT4."
|
||||
דוגמה: "G12 (שער-הפלטפורמה) — מגע-Paperclip חדש נוסף רק ב-agent_platform_port.py, לא ב-mcp-server."
|
||||
תוכן משפטי → G11.
|
||||
-->
|
||||
|
||||
@@ -22,6 +23,7 @@
|
||||
|
||||
- [ ] קראתי את `docs/spec/00-constitution.md` + ספ-התחום הרלוונטי לפני הכתיבה
|
||||
- [ ] השינוי **לא** יוצר מסלול מקביל ליכולת קיימת (G2) ולא מתקן תסמין בקריאה (G1)
|
||||
- [ ] **לא** הוספתי מגע-Paperclip מחוץ ל-Platform Port (G12) — `mcp-server/src` וה-skills נקיים
|
||||
- [ ] אין בליעה שקטה של שגיאות — רשומה חסרה/פגומה מסומנת ומדווחת (כלל-הנדסה §6)
|
||||
- [ ] בדקתי מול `docs/spec/gap-audit.md` — אם נגעתי ב-GAP/FU ממופה, התאמתי ליחידת-התיקון
|
||||
- [ ] בדיקות עוברות (אם רלוונטי) / לא נדרשות
|
||||
|
||||
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/.auto-sync.log
|
||||
data/*.db
|
||||
data/checkpoints/ # X16 durable-pipeline SQLite checkpoints (runtime artifact)
|
||||
*.bak-pre-*
|
||||
mcp-server/.venv/
|
||||
__pycache__/
|
||||
@@ -17,4 +18,6 @@ kiryat-yearim/
|
||||
continuation-prompt.md
|
||||
node_modules/
|
||||
data/eval/eval-report-*
|
||||
data/adapter-migration-state.json # revert snapshot for migrate_agent_adapter.py (runtime state)
|
||||
.claude/agents/.generated/ # frontmatter-stripped instruction copies for content_arg adapters (generated)
|
||||
.claude/worktrees/
|
||||
|
||||
252
CLAUDE.md
252
CLAUDE.md
@@ -1,10 +1,11 @@
|
||||
# עוזר משפטי — Legal Decision Assistant
|
||||
|
||||
> **אינדקס דק.** הכללים הקריטיים נמצאים כאן; העומק התפעולי (Deploy, Paperclip-ops, adapters, מבנה-תיקיות, Chair-Feedback, TaskMaster מלא) הוצא ל-[`docs/operations-runbook.md`](docs/operations-runbook.md) כדי לרזות את ההקשר הנטען בכל סשן.
|
||||
|
||||
## רקע הפרויקט
|
||||
|
||||
מערכת AI לסיוע בכתיבת החלטות של **ועדת ערר לתכנון ובניה, מחוז ירושלים**, בראשות **עו"ד דפנה תמיר**.
|
||||
|
||||
### מה עושה ועדת ערר?
|
||||
ועדת ערר היא גוף מעין-שיפוטי שדן בעררים על החלטות ועדות מקומיות לתכנון ובניה. הוועדה מקבלת חומרי מקור (כתבי ערר, תגובות, פרוטוקולים, תכניות), דנה בטענות הצדדים, ומוציאה **החלטה כתובה מנומקת** — מסמך משפטי פורמלי שניתן לביקורת שיפוטית בבית משפט לעניינים מנהליים.
|
||||
|
||||
### שלושה סוגי עררים
|
||||
@@ -14,13 +15,10 @@
|
||||
| היטל השבחה | 8xxx | קר ומקצועי | יבש, ללא רגשות |
|
||||
| פיצויים (ס' 197) | 9xxx | קר ומקצועי | דומה להיטל השבחה |
|
||||
|
||||
> **מבנה מספר-תיק (נוהל-יו"ר 2026-06-11):** `<סידורי>-<חודש>-<שנה>`. **אורך הסידורי = סוג-הליך:** 4 ספרות → **ערר**, 5 ספרות → **בל"מ** (`85074-09-24`). הספרה הראשונה עדיין קובעת תחום בשני האורכים. כלל חד-כיווני: 5-ספרתי הוא תמיד בל"מ; 4-ספרתי אינו מחייב ערר (בל"מ-מורשת מזוהה מהנושא). מקור-אמת: [`docs/spec/X1-identifiers.md`](docs/spec/X1-identifiers.md) §1א.
|
||||
|
||||
### מטרת המערכת
|
||||
לבנות כלי עבודה שמסייע ליו"ר הוועדה לנסח החלטות:
|
||||
1. **ניהול תיקים** — ייבוא חומרי מקור, סיווג מסמכים, מעקב סטטוס
|
||||
2. **בסיס ידע** — פסיקה, ביטויי מעבר, לקחים מהחלטות קודמות, חקיקה
|
||||
3. **חיפוש סמנטי (RAG)** — מציאת תקדימים רלוונטיים ופסקאות דומות
|
||||
4. **סיוע בכתיבה** — ייצור טיוטות לפי ארכיטקטורת 12 בלוקים בסגנון דפנה
|
||||
5. **ייצוא DOCX** — מסמך מעוצב מוכן להגשה
|
||||
כלי עבודה שמסייע ליו"ר הוועדה: **ניהול תיקים** (ייבוא, סיווג, מעקב סטטוס) · **בסיס ידע** (פסיקה, ביטויי מעבר, לקחים, חקיקה) · **חיפוש סמנטי (RAG)** · **סיוע בכתיבה** (טיוטות לפי 12 בלוקים בסגנון דפנה) · **ייצוא DOCX**.
|
||||
|
||||
### ⭐ יעד-העל: רכישת-הסגנון של דפנה (Style Acquisition)
|
||||
**היעד הראשי של המערכת הוא שהסוכנים יכתבו וינתחו עררים בדיוק כמו עו"ד דפנה תמיר** — לא רק לייצר טיוטה תקנית, אלא להפנים את **הקול והשיטה** שלה. זה מחייב **הפרדה מובהקת בין שתי תת-מערכות**:
|
||||
@@ -30,19 +28,9 @@
|
||||
|
||||
**הגישה (state-of-the-art לדאטה-מועט):** Text Style Transfer מבוסס **Authorial Style Profiling** — להכליל את סגנון דפנה ולהתאים לתיק. העתקת פסקאות מותרת לתוכן קבוע/נוסחאי; ניתוח ספציפי → להכליל; **מהות משפטית (הלכה/עובדה) — אסור להעתיק מתיק לתיק**. *לא* fine-tuning של משקולות (Opus סגור; קורפוס קטן מדי).
|
||||
|
||||
**כלל-העל — INV-LRN4:** כל החלטה אינה "סגורה" עד שהושוותה מול הגרסה הסופית של דפנה; כל סופי מנותח מול הטיוטה. כך לומדים מכל החלטה. **INV-LRN5:** שכבת-ידע-הקול לא תכיל מהות ספציפית — רק סגנון ושיטה.
|
||||
**כלל-העל — INV-LRN4:** כל החלטה אינה "סגורה" עד שהושוותה מול הגרסה הסופית של דפנה; כל סופי מנותח מול הטיוטה. **INV-LRN5:** שכבת-ידע-הקול לא תכיל מהות ספציפית — רק סגנון ושיטה. ספ מלא: [`docs/spec/07-learning.md`](docs/spec/07-learning.md) §0. ארכיטקטורה ומשימות: תוכנית `style-acquisition-subsystem`.
|
||||
|
||||
ספ מלא: [`docs/spec/07-learning.md`](docs/spec/07-learning.md) §0. ארכיטקטורה ומשימות: תוכנית `style-acquisition-subsystem`.
|
||||
|
||||
### מה היה קודם (Legacy)
|
||||
המערכת הקודמת היתה **Obsidian vault** עם Claude Code skills על שרת אחר. פותחו:
|
||||
- ניתוח סגנון של 3 החלטות (הכט — דחייה, בית הכרם — קבלה חלקית, אריאלי — השוואה)
|
||||
- ארכיטקטורת 12 בלוקים מבוססת CREAC / DITA / Akoma Ntoso / Federal Judicial Center
|
||||
- כללי כתיבה (רקע ניטרלי, ללא כפילות, טענות מקוריות בלבד)
|
||||
- לקחים מהשוואת טיוטות לגרסאות סופיות
|
||||
- סקריפט ייצוא DOCX
|
||||
|
||||
הידע שהופק מה-vault הוטמע במערכת הנוכחית — מסמכי ייחוס (`docs/`), קורפוס אימון (`data/training/`), ומבנה 12 בלוקים. ה-vault המקורי נמחק; הפרויקט הנוכחי עובד עם PostgreSQL + pgvector.
|
||||
> **Legacy:** המערכת הקודמת היתה Obsidian vault עם Claude Code skills. הידע שהופק ממנה (ניתוח סגנון, 12 בלוקים מבוססי CREAC/DITA/Akoma-Ntoso/FJC, כללי כתיבה, לקחים, ייצוא DOCX) הוטמע בפרויקט הנוכחי (`docs/`, `data/training/`). ה-vault נמחק; כעת PostgreSQL + pgvector.
|
||||
|
||||
---
|
||||
|
||||
@@ -53,11 +41,13 @@
|
||||
| [`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/ia-audit-redesign.md`](docs/ia-audit-redesign.md) + [`docs/spec/X17`](docs/spec/X17-information-architecture.md) | **אבחון משטח-ההפעלה + IA-יעד** — 34 משטחים, 37 ממצאים; INV-IA1–IA6 (מקור-אמת יחיד/שער-אחד/ניווט-משימה) מרימים G2/G10 לשכבת-UI. גלי-איחוד #130–132 | לפני עבודה על דפים/ניווט/cache או תורי-אישור |
|
||||
| [`docs/architecture.md`](docs/architecture.md) | ארכיטקטורת המערכת, תרשים רכיבים, זרימת נתונים, 4 שכבות DB | לפני עבודה על תשתית |
|
||||
| [`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/legal-decision-lessons.md`](docs/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** |
|
||||
| [`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/fjc-principles-extraction.md` | חומר מקור: מיצוי מ-Judicial Writing Manual (FJC) | רק לבדיקת מקור |
|
||||
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
|
||||
@@ -73,6 +63,8 @@
|
||||
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
|
||||
| [`.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 |
|
||||
| [`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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -85,14 +77,14 @@
|
||||
|
||||
**לפני יצירה/שינוי של קוד ב-`web/`, `mcp-server/`, `web-ui/`, `scripts/`:**
|
||||
|
||||
1. **קרא** [`docs/spec/00-constitution.md`](docs/spec/00-constitution.md) — ייעוד, ה-invariants הגלובליים G1–G11, וכללי-ההנדסה (§6). אינדקס-הספ ב-§7.
|
||||
2. **קרא את ספ-התחום הרלוונטי** לפי האינדקס (§7) — לדוגמה: אחזור→[`03-retrieval.md`](docs/spec/03-retrieval.md), קליטה→[`01-ingest.md`](docs/spec/01-ingest.md), נתונים→[`02-data-model.md`](docs/spec/02-data-model.md), כלי-MCP→[`X9-mcp-tool-contract.md`](docs/spec/X9-mcp-tool-contract.md), UI↔API→[`X6-ui-api-contract.md`](docs/spec/X6-ui-api-contract.md), Paperclip→[`X3`](docs/spec/X3-integration-deploy.md)/[`X7`](docs/spec/X7-paperclip-client-params.md), env/secrets→[`X10-deploy-env-secrets.md`](docs/spec/X10-deploy-env-secrets.md).
|
||||
1. **קרא** [`docs/spec/00-constitution.md`](docs/spec/00-constitution.md) — ייעוד, ה-invariants הגלובליים G1–G12, וכללי-ההנדסה (§6). אינדקס-הספ ב-§7.
|
||||
2. **קרא את ספ-התחום הרלוונטי** לפי האינדקס (§7) — לדוגמה: אחזור→[`03-retrieval.md`](docs/spec/03-retrieval.md), קליטה→[`01-ingest.md`](docs/spec/01-ingest.md), נתונים→[`02-data-model.md`](docs/spec/02-data-model.md), כלי-MCP→[`X9-mcp-tool-contract.md`](docs/spec/X9-mcp-tool-contract.md), UI↔API→[`X6-ui-api-contract.md`](docs/spec/X6-ui-api-contract.md), Paperclip/שער-הפלטפורמה→[`X3`](docs/spec/X3-integration-deploy.md)/[`X7`](docs/spec/X7-paperclip-client-params.md)/[`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).
|
||||
3. **ודא שהשינוי *מקיים* את ה-invariants** — לא יוצר מסלול מקביל ליכולת קיימת ([G2](docs/spec/00-constitution.md)), לא מתקן תסמין בקריאה במקום נרמול במקור (G1), לא בולע שגיאות בשקט (כלל-הנדסה §6).
|
||||
4. **בדוק מול** [`gap-audit.md`](docs/spec/gap-audit.md) — אם אתה נוגע ב-GAP/FU שכבר ממופה, התאם את העבודה ליחידת-התיקון; אל תפתור מחדש.
|
||||
5. **כל PR מצהיר invariants** — אילו G*/INV-* ה-PR נוגע בהם / מקיים (ראה תבנית ה-PR ב-[`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md)).
|
||||
|
||||
> **שתי שכבות-כללים מובחנות, שתיהן חלות:**
|
||||
> - **הנדסה (G1–G10)** — הסעיף הזה + `docs/spec/`. סמכות: ≥3 מקורות חיצוניים.
|
||||
> - **הנדסה (G1–G10, G12)** — הסעיף הזה + `docs/spec/`. סמכות: ≥3 מקורות חיצוניים.
|
||||
> - **תוכן משפטי (G11)** — סעיף "עקרונות כתיבה קריטיים" למטה (12 בלוקים, רקע ניטרלי...). סמכות: היו"ר + מסמכי-הפרויקט.
|
||||
>
|
||||
> אכיפה אוטומטית: hook `PreToolUse` ([scripts/spec-guard.sh](scripts/spec-guard.sh)) מזכיר את הפרוטוקול בכל Edit/Write על נתיב-קוד.
|
||||
@@ -105,17 +97,13 @@
|
||||
|
||||
**לכן — כל סשן שעומד לכתוב/לשנות קוד או תיעוד חייב לעבוד ב-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).
|
||||
הבידוד **נתמך-סביבה** — ההגדרות נשמרות ב-repo (`.claude/settings.json`, `.worktreeinclude`, `.gitignore`) כך שכל worktree שה-harness יוצר מקבל אוטומטית בסיס נקי, את התלויות, ואת ההרשאות. מקורות רשמיים: [Run parallel sessions with worktrees](https://code.claude.com/docs/en/worktrees), [Settings → worktree](https://code.claude.com/docs/en/settings).
|
||||
|
||||
### הדרך המומלצת — worktree של ה-harness
|
||||
```bash
|
||||
cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד ב-worktree" (כלי EnterWorktree)
|
||||
```
|
||||
נוצר תחת `.claude/worktrees/<slug>/` על ענף `worktree-<slug>`, ומקבל **אוטומטית**:
|
||||
- **בסיס נקי מ-`origin/main`** — דרך `worktree.baseRef: "fresh"` ב-`.claude/settings.json`.
|
||||
- **`web-ui/node_modules` (789MB) כסימלינק** — דרך `worktree.symlinkDirectories`; אין צורך ב-`npm ci`. (אם משנים deps של web-ui — עשו זאת בעץ הראשי או היו מודעים שה-node_modules משותף.)
|
||||
- **`.claude/settings.local.json` + קבצי-env מקומיים** — מועתקים דרך `.worktreeinclude` (מונע הצפת אישורי-הרשאה).
|
||||
- **ניקוי אוטומטי ביציאה** — כולל עקיפת באג סימלינק [#40259](https://github.com/anthropics/claude-code/issues/40259) דרך `WorktreeRemove` hook עם `--force`.
|
||||
נוצר תחת `.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`).
|
||||
|
||||
### הפרוטוקול (חל על שתי הדרכים)
|
||||
1. **בתחילת עבודת-כתיבה** — צור worktree (מומלץ: `claude --worktree`; ידני-fallback: `git worktree add -b <branch> .claude/worktrees/<slug> origin/main` — **תחת `.claude/worktrees/`** כדי שההגדרות יחולו).
|
||||
@@ -126,202 +114,43 @@ cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד
|
||||
6. **אל תיגע** בשינויים לא-מתויקים שאינם שלך בעץ הראשי — הם של סשן אחר. אם העץ הראשי על ענף זר — אל תתייק עליו.
|
||||
|
||||
> **בידוד-DB:** ה-worktree מבודד-קבצים בלבד — לא בידוד-repo ולא בידוד-DB. **אל תריץ migrations מ-2 worktrees במקביל** על Postgres המשותף (`localhost:5433`) — סכמה שאף סשן לא מצפה לה ([Run agents in parallel](https://code.claude.com/docs/en/agents)).
|
||||
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w` (קיימת רק ב-fork ה-deepseek שלנו). כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. הניתוח המלא והדרכים שנשקלו: TaskMaster `legal-ai` #104 (נסגר כ-cancelled — "לתעד, לא לבדד").
|
||||
> **סוכני 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 — "לתעד, לא לבדד").
|
||||
|
||||
---
|
||||
|
||||
## שרת Nautilus (158.178.131.193)
|
||||
## Deploy — תמצית קריטית
|
||||
|
||||
| שירות | תפקיד | כתובת |
|
||||
|-------|--------|-------|
|
||||
| Coolify | ניהול containers | `http://158.178.131.193:8000` |
|
||||
| PostgreSQL + pgvector | בסיס נתונים ראשי | `legal-ai-postgres` |
|
||||
| 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` |
|
||||
| Infisical | ניהול סודות | `secret.dev.marcus-law.co.il` |
|
||||
שלושה מודלי-הרצה דרים יחד; ערבוב = הטעות הנפוצה. **פירוט מלא, UUIDs ופקודות: [`docs/operations-runbook.md`](docs/operations-runbook.md).**
|
||||
|
||||
### ⚠️ ארכיטקטורת Deploy — חובה לקרוא
|
||||
|
||||
**עוזר משפטי (Legal-AI)** — רץ כ-**Docker container דרך Coolify**:
|
||||
- UUID: `gyjo0mtw2c42ej3xxvbz8zio`
|
||||
- שינוי קוד ב-`web/` או `web-ui/` **לא נכנס לתוקף** עד ש:
|
||||
1. עושים `git commit` + `git push origin main`
|
||||
2. מריצים deploy דרך Coolify (`mcp__coolify__deploy`)
|
||||
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`
|
||||
- שינויי קוד נכנסים לתוקף אחרי `pm2 restart paperclip`
|
||||
- **אין צורך ב-Docker או Coolify**
|
||||
|
||||
**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).
|
||||
- **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`.
|
||||
- **Paperclip** = **pm2 מקומי** (`localhost:3100`). שינוי → `pm2 restart paperclip`. **אין** Docker/Coolify.
|
||||
- **legal-chat-service** = **pm2 מקומי** (`127.0.0.1:8770`), גשר claude CLI לטאב הצ'אט ב-/training. שינוי → `pm2 restart legal-chat-service`.
|
||||
|
||||
---
|
||||
|
||||
## מבנה תיקיות
|
||||
## Paperclip — כללים קריטיים (תמצית)
|
||||
|
||||
```
|
||||
/home/chaim/legal-ai/
|
||||
├── CLAUDE.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/ ← סקריפטים שהושלמו (לא להריץ)
|
||||
```
|
||||
**פירוט מלא + דוגמאות + פקודות sync: [`docs/operations-runbook.md`](docs/operations-runbook.md).**
|
||||
|
||||
> **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.
|
||||
|
||||
- **Wakeup תמיד דרך API**: `POST /api/agents/{agent-id}/wakeup` עם `payload.issueId`. **אסור** `INSERT INTO agent_wakeup_requests` ישיר — הסוכן לא יתעורר לעולם (אין `heartbeat_run`).
|
||||
- **ניתוב comments דרך CEO**: תגובת-משתמש → פלאגין מעיר CEO → CEO מנתב ויוצר issue. סוכנים קוראים comments אחרונים לפני עבודה (HEARTBEAT 2b-2c).
|
||||
- **קריאות API דרך helper בלבד**: bash → `scripts/pc.sh`; Python → `pc_request()` מ-`web/paperclip_api.py`. **אסור** `curl` ישיר ל-Paperclip או `httpx.AsyncClient` ישיר.
|
||||
- **Cross-company sync**: 14 סוכנים = 7 × 2 חברות (CMP=1xxx master, CMPA=8xxx mirror). אחרי כל שינוי הגדרות/skills של סוכן — להריץ `scripts/sync_agents_across_companies.py --apply`. **מדלג** על סוכנים עם `adapter_type` שונה בין החברות (למשל `deepseek_local`) — להחיל ידנית בשתיהן.
|
||||
|
||||
---
|
||||
|
||||
## כלל: עדכון `scripts/SCRIPTS.md`
|
||||
|
||||
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/` — **חובה לעדכן את `scripts/SCRIPTS.md`** בהתאם.
|
||||
הקובץ מתעד את התפקיד, הסטטוס, וההחלפה (אם יש) של כל סקריפט.
|
||||
|
||||
---
|
||||
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/` — **חובה לעדכן את `scripts/SCRIPTS.md`** (תפקיד, סטטוס, החלפה).
|
||||
|
||||
## ניהול משימות — TaskMaster AI
|
||||
|
||||
הפרויקט משתמש ב-**TaskMaster AI** (MCP server) לניהול משימות מובנה:
|
||||
- **תמיד** להשתמש ב-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` ישירות.
|
||||
**תמיד** TaskMaster (לא TASKS.md ידני). קובץ קנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (tags: `master`, `legal-ai`). פקודות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`.
|
||||
> **⚠️ מלכוד cwd ב-CLI:** `--tag` בוחר קבוצה *בתוך* הקובץ — לא לאיזה קובץ לכתוב (ה-CLI מאתר לפי cwd). תמיד `cd ~/legal-ai` לפני כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את הקובץ ישירות. פירוט: [`docs/operations-runbook.md`](docs/operations-runbook.md).
|
||||
|
||||
---
|
||||
|
||||
## Paperclip — כללי אינטגרציה קריטיים
|
||||
|
||||
### 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`
|
||||
|
||||
---
|
||||
|
||||
## עקרונות כתיבה קריטיים
|
||||
## עקרונות כתיבה קריטיים (G11)
|
||||
|
||||
1. **"מבחן השופט"** — כל החלטה חייבת להיות קריאה לשופט שלא מכיר את התיק
|
||||
2. **"רקע ניטרלי"** — בלוק ו = עובדות בלבד. אין ציטוטים מצדדים, אין מילות שיפוט
|
||||
@@ -330,14 +159,7 @@ CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **
|
||||
5. **ארכיטקטורת 12 בלוקים** — ראה `docs/block-schema.md`
|
||||
6. **צ'קליסט תוכן** — בלוק י מקבל צ'קליסט תוכן אוטומטי לפי סוג הערר (ראה `lessons.py: CONTENT_CHECKLISTS`)
|
||||
|
||||
## הערות יו"ר (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
|
||||
> **הערות יו"ר (Chair Feedback):** מנגנון תיעוד הערות דפנה — טבלת `chair_feedback`, API `/api/feedback`, MCP `record_chair_feedback`/`list_chair_feedback`, UI `/feedback`. פירוט: [`docs/operations-runbook.md`](docs/operations-runbook.md).
|
||||
|
||||
## יו"ר: עו"ד דפנה תמיר
|
||||
- מדריך סגנון מלא: `skills/decision/SKILL.md`
|
||||
מדריך סגנון מלא: [`skills/decision/SKILL.md`](skills/decision/SKILL.md).
|
||||
|
||||
@@ -74,6 +74,9 @@ COPY skills/decision/SKILL.md ./skills/decision/SKILL.md
|
||||
COPY docs/legal-decision-lessons.md ./docs/legal-decision-lessons.md
|
||||
COPY docs/corpus-analysis.md ./docs/corpus-analysis.md
|
||||
|
||||
# Scripts catalog surfaced read-only at /scripts (GET /api/scripts/catalog).
|
||||
COPY scripts/SCRIPTS.md ./scripts/SCRIPTS.md
|
||||
|
||||
# Make mcp-server source available to web/app.py (it does sys.path.insert for legal_mcp)
|
||||
ENV PYTHONPATH=/app/mcp-server/src
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@ with \`HERMES_HOME\` pointed at a DeepSeek profile (\`config.yaml\` declares
|
||||
| verbose | boolean | false | Enable verbose Hermes logs. |
|
||||
| extraArgs | string[] | [] | Extra CLI args appended after standard flags. |
|
||||
| env | object | {} | Extra environment variables passed to Hermes. \`HERMES_HOME\` here overrides \`hermesProfileHome\`. |
|
||||
| promptTemplate | string | (default) | Override the default Paperclip wakeup prompt. |
|
||||
| instructionsFilePath | string | (none) | Absolute path to a versioned prompt file (e.g. under \`.claude/agents/\`). When set, its contents become the prompt template — single source of truth, parity with \`claude_local\`/\`gemini_local\`. Takes precedence over \`promptTemplate\`. If set but unreadable, execution fails loudly (no silent fallback). The file still flows through the template renderer, so \`{{…}}\` placeholders work. |
|
||||
| promptTemplate | string | (default) | Inline prompt override. Used only when \`instructionsFilePath\` is unset. |
|
||||
| paperclipApiUrl | string | \`http://127.0.0.1:3100/api\` | Paperclip API URL injected into the prompt template. |
|
||||
|
||||
## Available template variables
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* and toolsets from <HERMES_HOME>/config.yaml + <HERMES_HOME>/.env.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
runChildProcess,
|
||||
buildPaperclipEnv,
|
||||
@@ -84,8 +85,37 @@ Address the comment, POST a reply if needed, then continue working.
|
||||
3. If nothing to do, report briefly what you checked.
|
||||
{{/noTask}}`;
|
||||
|
||||
/**
|
||||
* Resolve the prompt template, preferring a versioned file over an inline DB
|
||||
* string. Precedence: instructionsFilePath > promptTemplate > DEFAULT.
|
||||
*
|
||||
* This brings deepseek_local into line with claude_local / gemini_local, whose
|
||||
* system prompts live as files under .claude/agents/. Keeping the prompt in one
|
||||
* git-versioned place (not split between a file and an inline DB column) is the
|
||||
* single-source-of-truth the other adapters already enforce.
|
||||
*
|
||||
* Fail loud: if instructionsFilePath is set but unreadable we throw rather than
|
||||
* silently falling back — a wrong/missing prompt file must surface as an error,
|
||||
* not run the agent on a stale inline copy. The loaded file still flows through
|
||||
* renderTemplate(), so {{wakeReason}}/{{#taskId}}/… placeholders keep working.
|
||||
*/
|
||||
export function resolveTemplate(config) {
|
||||
const filePath = cfgString(config.instructionsFilePath);
|
||||
if (filePath) {
|
||||
try {
|
||||
return readFileSync(filePath, "utf8");
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`deepseek_local: instructionsFilePath is set ("${filePath}") but could not be read: ${err.message}. ` +
|
||||
`Refusing to fall back to promptTemplate/default — fix the path or unset instructionsFilePath.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return cfgString(config.promptTemplate) || DEFAULT_PROMPT_TEMPLATE;
|
||||
}
|
||||
|
||||
function buildPrompt(ctx, config) {
|
||||
const template = cfgString(config.promptTemplate) || DEFAULT_PROMPT_TEMPLATE;
|
||||
const template = resolveTemplate(config);
|
||||
const taskId = cfgString(ctx.context?.taskId);
|
||||
const taskTitle = cfgString(ctx.context?.taskTitle) || "";
|
||||
const taskBody = cfgString(ctx.context?.taskBody) || "";
|
||||
|
||||
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.
|
||||
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).
|
||||
146
docs/ia-audit-redesign.md
Normal file
146
docs/ia-audit-redesign.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# IA-Audit & Redesign — מפת משטח-ההפעלה, כפילויות, וניווט-יעד
|
||||
|
||||
> **מה זה.** אבחון שיטתי של **משטח-ההפעלה** של המערכת (כל דף/טאב/drawer/פונקציה) — מה כל אחד עושה, מה כפול, מה מת, מה מציג נתון שגוי, ואיך הכול מסתנכרן — ו**תכנון-מחדש** של ארכיטקטורת-המידע (IA). נכתב לפי בקשת חיים (2026-06-11): *"המערכת מסובכת מדי לתפעול… כל הדפים האלה מפוזרים ואין לי מושג מה כל אחד עושה והאם יש כפילויות."*
|
||||
>
|
||||
> **היקף:** משטח-ההפעלה (1א). ה-backend נכלל רק היכן שהוא גורם לכפילות/נתון-שגוי ב-UI. תוצר-אחות: ספ-היעד [`docs/spec/X17-information-architecture.md`](spec/X17-information-architecture.md). יוזמה: TaskMaster `legal-ai` **#127**.
|
||||
>
|
||||
> **איך הופק:** סריקת-עומק רב-סוכנית (18 סוכנים, 6 אשכולות × 3 שלבים — קטלוג → אימות-אדוורסרי מול הקוד → תכנון-יעד מגובה-מקורות). כל ממצא אומת ב-`file:line`; כל עקרון-תכנון מגובה ב-≥3 מקורות סמכותיים (ראה [X17 §מקורות](spec/X17-information-architecture.md)).
|
||||
|
||||
## יחס למסמכים קיימים (דאבל-צ'ק — לא לכפול)
|
||||
- **[`ui-audit.md`](spec/ui-audit.md)** — ביקורת-קוד פר-רכיב (enums כפולים, טיפוסים, helpers → FU-10). **שכבה אחרת.** מסמך זה הוא בשכבת ה-**IA/הפעלה** (אילו משטחים, מה כל אחד עושה, סנכרון, ניווט). חופף נקודתית ב-UI-C1 (3 דפי-פסיקה חופפים) ו-UI-D2/D3 (שקיפות-מקור) — מורחב כאן.
|
||||
- **[`gap-audit.md`](spec/gap-audit.md)** — ממצאי-ארכיטקטורה (GAP→FU). מסמך זה ברמת-הדף.
|
||||
- **[`X6-ui-api-contract.md`](spec/X6-ui-api-contract.md)** — חוזה UI↔API (UI1–UI6). X17 מוסיף שכבת invariants מעל X6.
|
||||
|
||||
---
|
||||
|
||||
## 1. תקציר-מנהלים — 5 מחלות-השורש
|
||||
|
||||
הסריקה אימתה **37 ממצאים** על פני 34 משטחים. כולם נופלים ל-5 דפוסים:
|
||||
|
||||
| # | מחלת-שורש | כמה | מהות |
|
||||
|---|-----------|-----|------|
|
||||
| **D1** | **פערי-סנכרון ב-cache** | 16 | mutation מבטל רק את ה-queryKey המקומי, לא את ה-aggregator/האח/ה-namespace השני → מונה/נתון תקוע ב-0–60ש' בין דפים. זהו G2 בשכבת ה-TanStack-Query cache. |
|
||||
| **D2** | **משטחי-כתיבה/אישור כפולים** | 6 (dup) + 2 confusing | אותו datum נערך/מאושר ב-2 מקומות שכותבים ל-2 ערוצים. החריף: **שני שערי-למידה** (decision_lessons מול promote) ו-**מתודולוגיה** (PUT מול promote כותבים לאותה שורה — מירוץ lost-update). זו בדיוק ה"למה 2 שערים" של חיים. |
|
||||
| **D3** | **נתונים-שגויים/מטעים** | 6 | KPI סופר דגל אינפורמטיבי-בלבד (`applied_to_skill`/`findings_applied` — "מזויף"); `signature_phrases` עם תווית-קרדינליות שקרית; מונה-תור שמתעלם מחברה לא-זמינה. |
|
||||
| **D4** | **משטחים/פונקציות מתים** | 5 | endpoint `queue/pending` ללא צרכן; כפתור `applied_to_skill`; ז'רגון `T7/T15`; `AuthorityBadge` חסר בחיפוש. |
|
||||
| **D5** | **כפילות-ניווט** | — | הערות-יו"ר ב-2 דפים; `/operations`+`/diagnostics` אותו intent; `precedents` מול `precedent-library`. |
|
||||
|
||||
**התובנה המאחדת:** רוב המחלות הן ביטוי-UI אחד של עקרון-על מופר — **G2 (מקור-אמת יחיד / אין מסלולים מקבילים)** — שמעולם לא הורחב לשכבת-ה-UI (cache + משטחים). זה בדיוק מה שחיים חווה כ"מסובך לתפעול": אותו מספר בשני מקומות, שני שערים לאותה החלטה, ומספרים שלא מתעדכנים.
|
||||
|
||||
### ניווט-היעד (תמצית — מלא ב-X17)
|
||||
שלושה משטחי-**intent** עם בעלים-יחיד, במקום פיזור-לפי-פורמט:
|
||||
1. **`/approvals` = תיבת-ההחלטות-האנושית היחידה** — המקום היחיד שבו פועלים על שער. דפים אחרים **מצביעים** למונה, לא משכפלים אותו.
|
||||
2. **`/operations` = משטח-הקריאה-בלבד היחיד** ("מה המכונה עושה") — בולע את `/diagnostics`.
|
||||
3. **`/settings` = משטח-התצורה היחיד.**
|
||||
ובתוך-התחום: **החלטה=workspace אחד** · **למידה=תיבה+ערוץ+שער אחד** · **`/methodology`=עורך-הכללים היחיד** · **פסיקה=3 קורפוסים נפרדים אך מתפעלים אחיד**.
|
||||
|
||||
> **כל ההצעות שומרות 100% מהשערים-האנושיים (G10/INV-LRN1).** מסירים משטח/ערוץ **כפול**, לא שער. זו ההתאמה בין "פשטות-הפעלה" ל-G10.
|
||||
|
||||
---
|
||||
|
||||
## 2. ממצאים לפי אשכול
|
||||
|
||||
לכל אשכול: משטחים שקוטלגו · ממצאים מאומתים (file:line) · כיוון-היעד. הפירוט המלא (כל אלמנט + כל מקור) נשמר בפלט-הסריקה ([`data/audit/`](../data/audit/)).
|
||||
|
||||
### 2.1 תיקים (`/`, `/archive`, `/cases/[n]`, `/compose`)
|
||||
**3 משטחים · 2 ממצאים.** מחלה: תוכן-ההחלטה מפוצל ל-2 משטחי-כתיבה עצמאיים + עורך-compose שלישי.
|
||||
|
||||
| ID | סוג | ממצא | file:line | תיקון |
|
||||
|----|-----|------|-----------|-------|
|
||||
| CAS-1 | sync-gap | `DraftsPanel` (העלאת DOCX) לא מבטל `['decision-blocks']` → `DecisionBlocksPanel` מציג `source_of_truth='blocks'` תקוע; אזהרת-הסטייה לא מופיעה עד רענון ידני | exports.ts:103-107 מול decision-blocks.ts:46-49; app.py:2673 | add-invalidation |
|
||||
| CAS-2 | sync-gap | `useExportDocx` לא מבטל `['decision-blocks']` — אותו שורש | exports.ts:80-82 | add-invalidation |
|
||||
|
||||
**יעד:** workspace-החלטה אחד (block-editing + DOCX-פעיל) עם **מחוון-מקור-אמת אחד** בבעלות-המערכת ו-cache-slice משותף; אזור "השלמה והעברה" אחד לכל שערי-סיום-התיק.
|
||||
|
||||
### 2.2 אישורים + הערות-יו"ר (`/approvals`, `/feedback`)
|
||||
**3 משטחים · 6 ממצאים.** מחלה: `/api/chair/pending` גוזר 4 מונים, אך כל משטח-משימה מבטל רק את ה-cache שלו ולא את ה-aggregator → התיבה והתג-בסרגל תקועים עד 60ש'.
|
||||
|
||||
| ID | סוג | ממצא | file:line | תיקון |
|
||||
|----|-----|------|-----------|-------|
|
||||
| APR-1 | sync-gap | פתרון הערה ב-`/feedback` לא מבטל `['chair','pending']` → מונה `/approvals` והתג תקועים | feedback.ts:105; chair.ts:36; app-shell.tsx:336 | add-invalidation |
|
||||
| APR-2 | duplication | הערות-יו"ר ב-2 caches (`['feedback']` מול `['chair','pending']`) ללא הצלבה — שני endpoints על אותה `chair_feedback WHERE NOT resolved` | app.py:5650,5654 | add-invalidation |
|
||||
| APR-3 | wrong-data | דגימת-ההערות ב-`/approvals` יכולה להראות 5 ישנות בעוד המונה כבר 0 | app.py:5650-5654; chair.ts:36 | fix-data |
|
||||
| APR-4 | sync-gap | `ApprovalsBadge` בסרגל תקוע אחרי פתרון-הערה/אישור-הלכה/העלאת-פסיקה | app-shell.tsx:336; feedback.ts:105; missing-precedents.ts:256-258; precedent-library.ts:648,668 | add-invalidation |
|
||||
| APR-5 | sync-gap | אישור-הלכה לא מבטל `['chair','pending']` | precedent-library.ts:648,668; app.py:5619-5620 | add-invalidation |
|
||||
| APR-6 | sync-gap | העלאת/סגירת פסיקה-חסרה לא מבטל `['chair','pending']` | missing-precedents.ts:256-258; app.py:5636-5637 | add-invalidation |
|
||||
|
||||
**יעד:** `/approvals` = תיבת-הגייטים הקנונית היחידה; `['chair','pending']` = מקור-אמת יחיד ל"מה ממתין"; התג בסרגל = המונה היחיד; **הערות-יו"ר יורד מהניווט-הראשי** והופך לכרטיס-משימה מתוך התיבה.
|
||||
|
||||
### 2.3 פסיקה (`/precedents`, `/missing-precedents`, `/digests`, `/precedents/[id]`, `/graph`)
|
||||
**13 משטחים · 5 ממצאים.** מחלה: 3 קורפוסים אמיתיים ושונים — אך **מתפעלים שונה** ומבלבלים בשמות.
|
||||
|
||||
| ID | סוג | ממצא | file:line | תיקון |
|
||||
|----|-----|------|-----------|-------|
|
||||
| PRE-1 | confusing | שני namespaces כמעט-זהים: `/api/precedents/search` (typeahead לתיק) מול `/api/precedent-library/search` (חיפוש-קורפוס) | app.py:3109 מול 6057 | clarify (rename/label) |
|
||||
| PRE-2 | dead | `GET /api/precedent-library/queue/pending` — אפס צרכני-frontend (רק digests משתמש ב-queue/pending) | app.py:6282 | delete |
|
||||
| PRE-3 | wrong-data | `AuthorityBadge` (binding/persuasive, DERIVED) מוצג בתור-הביקורת אך **נשמט בחיפוש** | library-search-panel.tsx:26-73 מול halacha-review-panel.tsx:109 | fix-data |
|
||||
| PRE-4 | confusing | תג "ממתין" ב-`/digests` נראה לחיץ אך אינו (ב-`/precedents` הוא טאב-תור אמיתי) | digests/page.tsx:23-35 | clarify |
|
||||
| PRE-5 | dead | `HalachaCard` בחיפוש לא מרנדר authority אף שהשדה על החוט | library-search-panel.tsx:26-73 | fix-data |
|
||||
|
||||
**יעד:** 3 הקורפוסים נשארים נפרדים (גבול אמיתי — G2/INV-DIG1) אך **מתפעלים אחיד**: שם-חיפוש עקבי לכל קורפוס, תבנית-"ממתין" אחת, authority מוצג בכל מקום. מחיקת ה-endpoint המת.
|
||||
|
||||
### 2.4 למידה + סגנון (`/training` — 6 טאבים + CorpusDetailDrawer) ⚠️ האשכול הקריטי
|
||||
**8 משטחים · 10 ממצאים.** מחלה: "לקח" חי ב-2 namespaces ומאושר ב-2 מקומות שכותבים ל-2 ערוצי-כותב; 3 KPI על דגלים-ללא-צרכן.
|
||||
|
||||
| ID | סוג | ממצא | file:line | תיקון |
|
||||
|----|-----|------|-----------|-------|
|
||||
| LRN-1 | sync-gap | כפתור `applied_to_skill` ("אומץ לסקייל") **אינפורמטיבי-בלבד** — לא כותב ל-SKILL.md; היו"ר מאמין שהפעולה הושלמה | lessons-tab.tsx:12-14; app.py:1471-1475 | fix-data (להסיר) |
|
||||
| LRN-2 | confusing | למידה מפוצלת ל-`/api/learning` + `/api/training` לאותה ישות | learning.ts; training.ts; app.py:1448,4616 | clarify (לאחד) |
|
||||
| LRN-3 | confusing | **שני שערי-אישור ללא יחס אכוף** — `promote` מתעלם מ-`review_status` לגמרי | learning-panel.tsx:89-150; lessons-tab.tsx:168-179; app.py:4574 | clarify (שער אחד) |
|
||||
| LRN-4 | wrong-data | `StyleReportPanel`: "12 מתוך 487 שחולצו" — `signature_phrases` ו-`total_patterns` מחושבים עצמאית; אין יחס-תת-קבוצה | style-report-panel.tsx:87-90 | fix-data |
|
||||
| LRN-5 | wrong-data | `findings_applied` ("ממצאים שאומצו ל-SKILL: 42") סופר דגל-אינפורמטיבי → KPI "מזויף" | curator-portrait-panel.tsx:85-86; app.py:1300-1302 | fix-data |
|
||||
| LRN-6 | sync-gap | מחיקת-קורפוס לא מאפסת בחירת `ComparePanel` → submit מחזיר 404 | compare-panel.tsx:119-120; training.ts:172-174 | fix-data |
|
||||
| LRN-7 | keep | `FullTextLazy` ב-raw-fetch מחוץ ל-Query — שביר לעתיד, לא באג חי | corpus-detail-drawer.tsx:320-348 | keep |
|
||||
| LRN-8 | sync-gap | מחיקת-קורפוס מייתמת צ'אטים (`style_corpus_id→NULL`) בשקט, ללא אזהרה | corpus-panel.tsx:45-54; db.py:234 | add-invalidation |
|
||||
| LRN-9 | keep | סינון `PatternsForSubtype` stubbed (אין endpoint) — fallback חינני | corpus-detail-drawer.tsx:351-353 | keep |
|
||||
| LRN-10 | sync-gap | `usePromoteLearning` מבטל רק `learningKeys`, לא `lessonsKeys.forCorpus` → LessonsTab תקוע | learning.ts:104-115; training.ts:499-502 | add-invalidation |
|
||||
|
||||
**יעד:** תיבת-אישור-למידה אחת (מקובצת לפי זוג draft↔final) · **ערוץ-כותב אחד + סטטוס "זורם-לכותב" אחד** (`review_status='approved'`) · **הסרת `applied_to_skill`** (אוצרות SKILL.md = מעשה-git ידני, לא כפתור) · כל artifact תלוי בזוג שלו (progressive disclosure) · תיקון 2 ה-KPI.
|
||||
|
||||
### 2.5 מתודולוגיה (`/methodology` — 5 טאבים)
|
||||
**2 משטחים · 8 ממצאים.** מחלה: כללי-הכותב נערכים מ-2 משטחים שכותבים לאותה שורה `appeal_type_rules(_global, …, 'universal')` — מירוץ lost-update + פער-cache.
|
||||
|
||||
| ID | סוג | ממצא | file:line | תיקון |
|
||||
|----|-----|------|-----------|-------|
|
||||
| MET-1 | sync-gap | `promote` מבטל `['learning']` בלבד; `/methodology` (`['methodology',cat]`, staleTime 30ש') תקוע אחרי אישור | learning.ts:113; methodology.ts:26-28; app.py:4629-4631 | add-invalidation |
|
||||
| MET-2 | duplication | `discussion_rules['universal']` נכתב ב-2 מקומות (PUT מול promote) — כתיבה-שנייה דורסת בשקט | discussion-rules-panel.tsx:35-36; learning-panel.tsx:138-139; app.py:4491-4495,4629; db.py:290 | fix-data |
|
||||
| MET-3 | duplication | `transition_phrases['universal']` — אותו מירוץ | methodology/page.tsx:45-49; learning-panel.tsx:125-129; app.py:4631 | fix-data |
|
||||
| MET-4 | confusing | `universal` מוצג כדלי-תוצאה אח, אך בפועל **מוקדם (prepended)** לכל התוצאות — אין מודל-מנטלי | discussion-rules-panel.tsx:16-22; lessons.py:376-379 | clarify |
|
||||
| MET-5 | dead | ז'רגון `T15`/`T7` בטקסט-העזר — מזהי-משימות פנימיים חסרי-משמעות למפעיל | methodology/page.tsx:48,55 | fix-data |
|
||||
| MET-6 | wrong-data | עורך-צ'קליסטים מציג 5 סוגי-ערר ללא מיפוי איזה `appeal_type` צורך כל אחד | content-checklists-panel.tsx:17-31; app.py:4414 | clarify |
|
||||
| MET-7 | confusing | 3 מושגי-"כלל" מתערבבים (ratios/rules/checklists) ללא מודל-תחולה | methodology/page.tsx:16-19; block_writer.py:912-923 | clarify |
|
||||
| MET-8 | sync-gap | סדר-callbacks ב-promote → toast-הצלחה אחרי invalidation לא-מספק | learning.ts:112-113; learning-panel.tsx:138-143 | add-invalidation |
|
||||
|
||||
**יעד:** `/methodology` = העורך הקנוני **היחיד** (כל כתיבה דרך PUT אחד, עם תג-מקור "ידני/מאומץ-מלמידה"); הלמידה **מנותבת דרכו** (לא כותבת-במקביל) ומבטלת את שני ה-caches; explainer-תחולה inline; קופי בעברית-פשוטה (בלי T7/T15).
|
||||
|
||||
### 2.6 תפעול + אבחון + הגדרות (`/operations`, `/diagnostics`, `/settings`, `/skills`)
|
||||
**5 משטחים · 6 ממצאים.** מחלה: 4 משטחים שמריצים שאילתות-מקור זהות בלי cache משותף + נתונים-מתים/מטעים.
|
||||
|
||||
| ID | סוג | ממצא | file:line | תיקון |
|
||||
|----|-----|------|-----------|-------|
|
||||
| ADM-1 | sync-gap | `halacha_backlog` מוחזר מה-backend אך **נזרק** ב-frontend (אין בטיפוס, לא מרונדר) | app.py:2253; system.ts:21-32; diagnostics/page.tsx | fix-data (לרנדר/להסיר) |
|
||||
| ADM-2 | sync-gap | מוני-הלכות כפולים ב-`/operations` ו-`/approvals` ללא קישור-cache | operations.ts:60; chair.ts:36; app.py:6520,5619-5633 | add-invalidation (consolidate) |
|
||||
| ADM-3 | sync-gap | מוני-פסיקה-חסרה כפולים ב-`/operations` ו-`/approvals` | operations.ts:60; app.py:6521,5636-5647 | add-invalidation (consolidate) |
|
||||
| ADM-4 | confusing | `court_fetch`: "בתור" כולל failed, מטשטש pending מול queued | operations/page.tsx:286-304; app.py:6562 | clarify |
|
||||
| ADM-5 | sync-gap | עריכת env ב-Coolify לא מרעננת את ערך-ה-Container ולא מזהירה על staleness עד redeploy | env-var-row.tsx:76-96; settings.ts:135 | fix-data |
|
||||
| ADM-6 | wrong-data | מוני-סוכנים מסכמים רק חברות-Paperclip זמינות → עומק-תור מוקטן בשקט כשחברה לא-נטענת | app.py:6667-6689; operations/page.tsx:461-465 | clarify (להציג חלקיות) |
|
||||
|
||||
**יעד:** **`/approvals`=תיבת-ההחלטות** (כל גייט נפעל רק כאן; `/operations` מצביע ולא משכפל) · **`/operations`=משטח-קריאה יחיד** (בולע את `/diagnostics`, מרנדר `halacha_backlog`, מתקן queued/pending, מציג חלקיות) · **`/settings`=תצורה** (עריכת-env שמשלימה-את-עצמה: סימון-staleness + redeploy באותה שורה).
|
||||
|
||||
---
|
||||
|
||||
## 3. עדכוני-ספ מומלצים (מתועדים ב-X17)
|
||||
הסריקה זיהתה ש-X6 לא מכסה את שכבת-ה-UI-state, וש-07-learning מסנקצן בטעות שני-ערוצים. ההמלצות (כל אחת מגובת-מקורות ב-X17):
|
||||
1. **X6 — invariants חדשים בשכבת-UI:** (א) aggregate-נגזר = מקור-אמת; כל mutation לטבלת-מקור חייב לבטל את ה-queryKey שלו; אסור מונה-מתחרה client-side. (ב) למונה-גייט בעלים-משטח-יחיד; אחרים מצביעים. (ג) שדה ב-OpenAPI-response — לרנדר או להסיר (אסור לזרוק בשקט). (ד) אסור להציג aggregate מדויק כש-partial-failure השמיט תורם — להציג חלקיות.
|
||||
2. **07-learning §0.4/§0.6:** שער-אישור **אחד**, טרנזקציית-כותב **אחת**, `applied_to_skill` מוסר; לקחים-מאושרים נכתבים רק דרך מסלול-המתודולוגיה היחיד.
|
||||
3. **00-constitution G2 "הפרות ידועות":** להוסיף את תאום-המתודולוגיה (`discussion_rules['universal']` נכתב ע"י PUT וגם promote).
|
||||
|
||||
---
|
||||
|
||||
## 4. הבא — בקלוג-איחוד (פאזה F)
|
||||
מסמך זה **ממפה ומאבחן**; הוא אינו משנה קוד. הבקלוג המתועדף (TaskMaster, מקושר לכל ממצא) נגזר ב-#127.6 ומחולק ל-3 גלים:
|
||||
- **גל-1 (זול, גבוה-ערך):** הוספת-invalidation ל-16 פערי-הסנכרון + תיקון 6 הנתונים-השגויים + מחיקת המתים. תיקון מקומי, אין הגירת-IA.
|
||||
- **גל-2 (איחוד-משטחים):** תיבת-אישור אחת · ערוץ-למידה אחד (הסרת `applied_to_skill`, איחוד שני-השערים) · `/operations`⊇`/diagnostics`.
|
||||
- **גל-3 (ניווט):** הורדת `/feedback` מהראשי · שמות-חיפוש עקביים · X17 ל-canonical.
|
||||
|
||||
**הכרעת-יו"ר נדרשת לפני גל-2/3** (3א — עוצרים על המסמך).
|
||||
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` ישירות.
|
||||
123
docs/research/hermes-nous-feasibility.md
Normal file
123
docs/research/hermes-nous-feasibility.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# מחקר-היתכנות: Hermes של Nous Research — האם להטמיע, והאם זה יעזור ללולאת רכישת-הסגנון
|
||||
|
||||
> **TaskMaster #123** (tag `legal-ai`) · תאריך: 2026-06-11 · סטטוס: מחקר הושלם → המלצה להכרעה
|
||||
> **שאלת-העל:** האם הסוכן/מסגרת ה-self-learning של Nous Research — שחיים התכוון אליו במקור בהקמת מנהל-הידע — ניתן ורצוי להטמעה אצלנו, והאם ישפר את לולאת רכישת-הסגנון מעבר לקיים.
|
||||
> **הכרעה בתמצית:** **לדחות אימוץ-מסגרת; לאמץ רעיון אחד ממוקד — GEPA/DSPy (אבולוציית-פרומפט רפלקטיבית) כ-*מַצִּיע* בתת-מערכת רכישת-הסגנון, נמוך-עדיפות, לכשיצטברו זוגות draft↔final.**
|
||||
>
|
||||
> **המשך:** מה אנחנו *באמת* מריצים מ-Hermes היום (ה-CLI כ-runtime, ה-self-learning כבוי), חקירה פורנזית של ה-state, ו-playbook להפעלה-מלאה עתידית → [hermes-runtime-and-self-learning-state.md](hermes-runtime-and-self-learning-state.md) (#126).
|
||||
|
||||
---
|
||||
|
||||
## 0. הרקע — מה חשבנו שזה, ומה זה באמת
|
||||
|
||||
הסוכן "הרמס" אצלנו הוא **שם-פרסונה בלבד**: בפועל זהו `deepseek_local` (DeepSeek-V4-Pro) עם פרומפט-קבוע ([hermes-curator.md](../../.claude/agents/hermes-curator.md)), שמנתח החלטות סופיות ו**מציע** עדכוני-לקחים. קוד של Nous מעולם לא שולב (grep=0). הנחת-המוצא של המשימה הייתה שצריך לבדוק אם ה-"self-learning" של Nous הוא מנגנון fine-tuning שמתנגש באילוץ "מודל סגור".
|
||||
|
||||
**הממצא המרכזי הופך את ההנחה:** ה-self-learning של Nous **אינו** fine-tuning, ובמבנה-הממשל שלו הוא **מתכנס כמעט במדויק לארכיטקטורה שכבר בנינו** (propose-only, human-review, semantic-preservation). השאלה האמיתית אינה "האם זה תואם" אלא "**האם זה מוסיף משהו שאין לנו**". התשובה: רעיון אחד — אופטימיזציית-פרומפט אבולוציונית-רפלקטיבית (GEPA).
|
||||
|
||||
---
|
||||
|
||||
## א. מה Hermes-agent של Nous *באמת* עושה (מאומת מול ה-repos, לא מהזיכרון)
|
||||
|
||||
יש **שני** repos נפרדים תחת `NousResearch`, שניהם **MIT**:
|
||||
|
||||
### א.1 `NousResearch/hermes-agent` — מסגרת-סוכן (agent framework)
|
||||
"The self-improving AI agent… the only agent with a built-in learning loop." רכיבים (מאומת מ-README + docs):
|
||||
|
||||
| רכיב | מה זה |
|
||||
|------|-------|
|
||||
| **Closed-loop skill learning** | מזקק "Skills" לשימוש-חוזר אוטומטית בסיום כל משימה, שומר בזיכרון מתמיד. תואם תקן פתוח `agentskills.io` (Skills Hub — portable/shareable). |
|
||||
| **Three-layer memory** | זיכרון רב-שכבתי שזוכר העדפות/הרגלים לאורך סשנים. |
|
||||
| **Dialectic user modeling (Honcho)** | בונה מודל מתפתח של "מי אתה" לרוחב סשנים. |
|
||||
| **Agent-curated memory + periodic nudges** | הסוכן "מנדנד" לעצמו לשמר ידע; חיפוש-סשנים FTS5 + סיכום-LLM. |
|
||||
| **Orchestration** | 40+ כלים, מספר terminal backends (local/Docker/SSH/Modal/Daytona), תת-סוכנים מבודדים, scheduler/cron מובנה, ריבוי-פלטפורמות-הודעות (Telegram/Discord/Slack/WhatsApp/Signal/CLI). |
|
||||
| **Model-agnostic** | "Use any model you want — switch with `hermes model`, no code changes." עובד מול Nous Portal / OpenRouter (200+) / OpenAI / **Anthropic** / HF / endpoint מותאם. **אין דרישה למודל פתוח, אין fine-tuning של משקולות.** |
|
||||
|
||||
המסקנה: זוהי **מסגרת-תזמור-סוכנים מלאה** (orchestrator + memory + scheduler + ערוצי-הודעות + skill-hub) — מתחרה מבנית ל-Paperclip, **לא** רכיב-למידה נקודתי.
|
||||
|
||||
### א.2 `NousResearch/hermes-agent-self-evolution` — לב ה-"self-learning"
|
||||
זהו ה-repo שעושה את האבולוציה-העצמית (מאומת מ-README):
|
||||
|
||||
- **מה הוא ממטב:** קבצי-skill (`SKILL.md`), תיאורי-כלים, מקטעי-system-prompt, וקוד-מימוש-כלים. (Phase 1 = קבצי-skill.)
|
||||
- **אלגוריתם:** **DSPy + GEPA** (Genetic-Pareto Prompt Evolution). GEPA מנתח **traces של ריצה**, מבין *סיבות-כשל* ברפלקציה בשפה-טבעית, ומציע שיפורים ממוקדים — לא רק מזהה כשל.
|
||||
- **דרישות-אימון:** **אין GPU, אין fine-tuning.** הכול דרך קריאות-API ("mutating text, evaluating results, selecting best variants"). עלות מוערכת **$2–10 לריצת-אופטימיזציה**.
|
||||
- **הערכת-מועמדים (guardrails):** כל variant חייב לעבור — מערך-בדיקות מלא, מגבלת-גודל (skills ≤15KB), תאימות-caching, **שימור-סמנטי של המטרה המקורית**, ו**סקירת-PR אנושית**.
|
||||
- **החלת-שינויים:** **אין auto-apply.** כל variant עובר "**human review, never direct commit**" ומוצע כ-PR נגד `hermes-agent`.
|
||||
|
||||
**זו ההפתעה:** המודל התפעולי של Nous (propose → guardrails → human-PR → never auto-commit, עם שימור-סמנטי) הוא **שחזור כמעט-מילה-במילה של INV-LRN1/G10 + INV-LRN5 שלנו.** לא צריך "להתאים" אותו — הוא כבר חושב כמונו.
|
||||
|
||||
---
|
||||
|
||||
## ב. טבלת-תאימות מול ארבעת האילוצים (פסיקה לכל אחד)
|
||||
|
||||
| # | אילוץ | אימוץ-מסגרת (`hermes-agent`) | אימוץ-רעיון (GEPA כמַצִּיע) | פסיקה |
|
||||
|---|-------|------------------------------|-----------------------------|-------|
|
||||
| 1 | **מודל-סגור** (Opus/DeepSeek/Gemini, לא fine-tuning — [project_style_acquisition_goal]) | ✅ model-agnostic, ללא fine-tuning | ✅ GEPA ממטב **טקסט** (פרומפט/skill), לא משקולות; API-only, $2–10 | ✅ **תואם** — זהו בדיוק ה-"prompt-optimization במקום weight-update" שהמשימה זיהתה כהכי-תואם |
|
||||
| 2 | **G12 — שער-הפלטפורמה** ([X15](../spec/X15-agent-platform-port.md), INV-PORT1) | ❌ **מתנגש** — מסגרת-תזמור שלמה (memory/scheduler/ערוצים/subagents) = פלטפורמת-סוכנים מקבילה ל-Paperclip → drift-רוחב, בדיוק מה ש-G12/G2 מייבשים | ✅ חי בשכבת-האינטליגנציה/רכישת-הסגנון, מזין את שער-היו"ר; **אינו** פלטפורמה, אינו נוגע ב-Port | ❌ למסגרת / ✅ לרעיון |
|
||||
| 3 | **INV-LRN1/G10 — אין auto-commit** | ⚠️ ה-memory האוטו-נדנד + יצירת-skill אוטונומית מפרים *אם* ב-auto-commit | ✅ self-evolution **תוכנן** propose-only (human-PR) — זהה למודל שלנו | ✅ **תואם** (הרעיון); ⚠️ המסגרת דורשת גידור |
|
||||
| 4 | **INV-LRN5 — טוהר-הקול** (אין מהות ספציפית) | ⚠️ 3-layer memory + dialectic modeling שומרים תוכן-משתמש ספציפי לרוחב סשנים → זיהום שכבת-הקול | ⚠️ תקין **רק אם** מטריקת-ההערכה היא **מרחק-סגנון** (לא שחזור-מהות), והפלט הוא סגנון/שיטה. יש לנו את המטריקה (`style_distance`, שלב [7] MEASUREMENT) | ⚠️ **בר-ניהול** — חייב metric=style-distance + distillation שמפריד במקור (כבר קיים) |
|
||||
|
||||
---
|
||||
|
||||
## ג. מה זה יפתור שלא קיים? (מול מה שכבר בנינו)
|
||||
|
||||
| יכולת ב-Nous | מקבילה אצלנו | פער-אמיתי? |
|
||||
|--------------|--------------|-------------|
|
||||
| Closed-loop skill distillation | `hermes-curator` + `ingest_final_version` (Opus distillation) + `final_learning_pipeline` | לא — קיים, ומגודר טוב יותר (פאנל דו/תלת-מודלי, #121) |
|
||||
| Three-layer memory / FTS5 recall | קורפוס-סגנון + pgvector + RAG ([03-retrieval](../spec/03-retrieval.md)) | לא — האחזור שלנו עשיר יותר ומכוון-דומיין |
|
||||
| Skill-Hub (agentskills.io) | `skills/decision` + `legal-decision-lessons.md` | לא — שיתוף-קהילתי לא רלוונטי לדומיין-סגור |
|
||||
| Dialectic user modeling | פרופיל-סגנון של דפנה (`/methodology`) | לא — ובמכוון: INV-LRN5 אוסר מידול-מהות |
|
||||
| **GEPA reflective prompt-evolution** | **אין מקבילה** — אנו מתקנים פרומפטים ידנית, ללא אופטימיזציה שיטתית מול eval | ✅ **כן — זה הפער היחיד** |
|
||||
|
||||
**הפער היחיד שמוסיף ערך:** אנו אוספים זוגות `draft↔final` (`draft_final_pairs`, INV-LRN4) אבל **לא ממנפים אותם כ-eval-set שיטתי לשיפור פרומפטי-הכתיבה/פרופיל-הסגנון.** GEPA הוא בדיוק הכלי לכך: לוקח traces (טיוטות שלנו), reflects על *למה* הן רחוקות מהסופי של דפנה, ומציע שיפורי-פרומפט — מדיד מול `style_distance`.
|
||||
|
||||
---
|
||||
|
||||
## ד. חלופות קוד-פתוח שקולות (≥3 מקורות סמכותיים)
|
||||
|
||||
| גישה | מה היא | התאמה לאילוץ מודל-סגור | רלוונטיות לנו |
|
||||
|------|--------|------------------------|---------------|
|
||||
| **GEPA** (Agrawal et al., arXiv:2507.19457, **ICLR 2026 oral**) | אבולוציית-פרומפט רפלקטיבית; reflects על traces בשפה-טבעית | ✅ מושלמת — טקסט בלבד | **גבוהה** — מנצח GRPO (RL) ב-6–20% עם פי-35 פחות rollouts; מנצח MIPROv2 ב-10%+. `pip install gepa` / `dspy.GEPA` |
|
||||
| **DSPy (MIPROv2)** | אופטימיזציית-פרומפט מבוססת-מטריקה | ✅ טובה | בינונית — GEPA עדיף לפי המאמר |
|
||||
| **Reflexion** (Shinn et al., NeurIPS 2023) | "verbal RL" — רפלקציה מילולית בזיכרון-אפיזודי | ✅ טובה | נמוכה — per-task, לא משפר artifact מתמשך |
|
||||
| **Voyager** (Wang et al., 2023) | skill-library מצטברת (Minecraft, lifelong) | ✅ טובה | נמוכה — כבר יש לנו skill-library מגודרת; הרעיון מובנה |
|
||||
| **Generative Agents** (Park et al., 2023) | memory-stream + reflection + retrieval | ✅ | נמוכה — INV-LRN5 אוסר מידול-מהות מתמשך |
|
||||
| **LangGraph long-term memory** | checkpointing + store | ✅ | כבר ב-[X16](../spec/X16-pipeline-durability.md) (התשתית קיימת) |
|
||||
|
||||
**מסקנת-ההשוואה:** מבין כל החלופות, **GEPA היא היחידה שמציעה יכולת חדשה תואמת-אילוץ** (אופטימיזציה שיטתית של artifacts-טקסט מול eval, ללא משקולות). השאר או מובנים כבר אצלנו, או מתנגשים ב-INV-LRN5, או per-task ולא-מתמשכים.
|
||||
|
||||
---
|
||||
|
||||
## ה. המלצה מנומקת
|
||||
|
||||
### דחה: אימוץ מסגרת `hermes-agent` כפלטפורמה
|
||||
**נימוק:** זוהי פלטפורמת-תזמור-סוכנים מלאה (orchestrator/memory/scheduler/ערוצים) המתחרה מבנית ב-Paperclip. אימוצה = **מסלול-פלטפורמה מקביל** המפר G12/INV-PORT1 ([X15](../spec/X15-agent-platform-port.md)) ויוצר את drift-הרוחב שכל הספ מייבש. כבר הכרענו (יוזמת X15/X16): Paperclip = מעטפת ניתנת-להחלפה מאחורי Port; אין מקום לפלטפורמה שנייה.
|
||||
|
||||
### דחה: אימוץ שכבת-ה-memory/dialectic-modeling
|
||||
**נימוק:** שמירת תוכן-משתמש ספציפי לרוחב סשנים מתנגשת ב-INV-LRN5 (טוהר-הקול). פרופיל-הסגנון שלנו במכוון מופשט.
|
||||
|
||||
### ✅ אמץ-רעיון: GEPA/DSPy כ-*מַצִּיע* בתת-מערכת רכישת-הסגנון
|
||||
**הרעיון הספציפי:** אופטימיזציית-פרומפט רפלקטיבית-אבולוציונית של **פרומפטי-הכתיבה ו/או פרופיל-הסגנון**, ממוטבת מול eval-set של זוגות `draft↔final` (INV-LRN4), עם **מטריקה = `style_distance`** (שלב [7] MEASUREMENT, [07-learning §0.3](../spec/07-learning.md)).
|
||||
|
||||
**למה זה נכנס נקי (לא מסלול-מקביל):**
|
||||
1. **מודל-סגור** ✅ — טקסט בלבד, Opus/DeepSeek דרך adapter, $2–10/ריצה.
|
||||
2. **G12** ✅ — חי ב-`mcp-server/src` / style-acquisition; אינו פלטפורמה; אם צריך wakeup → דרך ה-Port.
|
||||
3. **INV-LRN1/G10** ✅ — מַצִּיע בלבד: GEPA מפיק *variant מוצע* → שער-יו"ר (כמו ה-curator היום). **אין auto-commit.** זה גם המודל של Nous עצמם (human-PR).
|
||||
4. **INV-LRN5** ✅ (מגודר) — eval=style-distance, output=סגנון/שיטה; ה-distillation הקיים מפריד מהות במקור.
|
||||
|
||||
### כיצד זה נכנס דרך ה-Port (אם/כשמאשרים) — תת-משימות מוצעות
|
||||
1. **תנאי-סף (gate על הכדאיות):** לאסוף **N≥~15–20 זוגות `draft↔final` מנותחים** (`analyzed`/`lessons_folded`) — eval-set מינימלי ל-GEPA. כיום הקורפוס ~48 החלטות אך זוגות-מנותחים מעטים → **לכן עדיפות נמוכה כעת**; לפתוח כשהפנקס מתמלא.
|
||||
2. **PoC מבודד:** `scripts/gepa_style_optimize.py` (מקומי, כמו `final_learning_pipeline`) — `dspy.GEPA` ממטב את פרומפט-הכתיבה מול `style_distance`; פלט = variant מוצע + דו"ח-שיפור.
|
||||
3. **שער-יו"ר:** ה-variant מוצג ב-`/training` / כ-comment (דרך ה-Port), דפנה/חיים מאשרים → commit ידני ל-skill/prompt. אכיפת INV-LRN1.
|
||||
4. **מדידה:** השוואת `style_distance` לפני/אחרי על holdout — לאמת שיפור-אמת לפני קיבוע.
|
||||
|
||||
**עדיפות:** נמוכה (priority=low במשימה תואם). זהו שדרוג-איכות עתידי לרכישת-הסגנון, לא חוסר קריטי. **להחליט להפעיל רק כשמצטבר eval-set של זוגות.** הכרעת-תקציב/עדיפות — של חיים.
|
||||
|
||||
---
|
||||
|
||||
## מקורות
|
||||
- `NousResearch/hermes-agent` (README + https://hermes-agent.nousresearch.com/docs/) — מסגרת-הסוכן, MIT, model-agnostic.
|
||||
- `NousResearch/hermes-agent-self-evolution` (README) — DSPy+GEPA, API-only, propose-only/human-PR, MIT.
|
||||
- Agrawal et al., **"GEPA: Reflective Prompt Evolution Can Outperform Reinforcement Learning"**, arXiv:2507.19457 (ICLR 2026 oral) — 6–20% מעל GRPO, פי-35 פחות rollouts.
|
||||
- `dspy.GEPA` (dspy.ai) · `CerebrasResearch/gepa` (standalone `pip install gepa`).
|
||||
- Shinn et al., **Reflexion** (NeurIPS 2023) · Wang et al., **Voyager** (2023) · Park et al., **Generative Agents** (2023) — להשוואה.
|
||||
- ספ-פנימי: [07-learning.md](../spec/07-learning.md) (INV-LRN1/4/5) · [X15](../spec/X15-agent-platform-port.md) (G12) · [X16](../spec/X16-pipeline-durability.md) · [hermes-curator.md](../../.claude/agents/hermes-curator.md).
|
||||
106
docs/research/hermes-runtime-and-self-learning-state.md
Normal file
106
docs/research/hermes-runtime-and-self-learning-state.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Hermes כ-runtime אצלנו, וה-self-learning האינרטי — חקירה + playbook להפעלה מלאה
|
||||
|
||||
> **TaskMaster #126** (המשך ל-[#123](hermes-nous-feasibility.md)) · תאריך: 2026-06-11
|
||||
> **למה המסמך קיים:** כדי שאם אי-פעם נרצה להפעיל את **סוכן ה-Hermes המלא** (עם לולאת ה-self-learning), לא נצטרך לחזור על החקירה הזו. מתעד מה אנחנו מריצים בפועל היום, מה דלוק-אך-אינרטי, ומה צריך כדי להדליק אותו נכון.
|
||||
|
||||
---
|
||||
|
||||
## 1. מה אנחנו מריצים בפועל היום
|
||||
|
||||
הסוכן "הרמס" אצלנו = ה-adapter `deepseek_local` שמריץ את **ה-CLI האמיתי של Nous Hermes** כ-runtime, מכוון ל-DeepSeek:
|
||||
|
||||
```
|
||||
hermes chat -q "<prompt>" -Q -m deepseek-v4-pro --provider custom -t <toolsets> --source tool --yolo
|
||||
```
|
||||
|
||||
| רכיב | ערך | מקור |
|
||||
|------|-----|------|
|
||||
| בינארי | `hermes` (`HERMES_CLI`) | `adapters/deepseek-paperclip-adapter/dist/shared/constants.js:9` |
|
||||
| תווית-adapter | **"DeepSeek (via Hermes)"** (`deepseek_local`) | `constants.js:5-6` |
|
||||
| מודל | `deepseek-v4-pro` (provider `custom` → `api.deepseek.com/v1`) | `config.yaml` של הפרופיל |
|
||||
| `HERMES_HOME` | `~/.hermes/profiles/curator-cmp` (CMP) · `curator-cmpa` (CMPA) | adapterConfig.env (per-company) |
|
||||
| invocation | חד-פעמי `-q ... -Q` (quiet, non-interactive), ללא gateway חי | `dist/server/execute.js:240-249` |
|
||||
| חיבור MCP | `mcp_servers.legal-ai` בקונפיג → ה-CLI יכול לקרוא לכלי-MCP שלנו | `config.yaml` |
|
||||
|
||||
**מסקנה חשובה:** Hermes כאן הוא ה-**runtime/harness** (terminal + לולאת-כלים + provider→DeepSeek + MCP), **לא** "המוח הלומד". ההצהרה ב-#123 "grep=0, קוד Nous לא שולב" נכונה לגבי **לולאת ה-self-learning** — לא לגבי ה-CLI כ-runtime, שכן משמש.
|
||||
|
||||
### זהות הסוכן ב-Paperclip
|
||||
- שם-הסוכן ב-Paperclip כבר **"מנהל ידע"** (role `qa`) — **לא** "Hermes".
|
||||
- ה-wakeup הוא לפי **UUID** (`CURATOR_AGENTS[company_id]` ב-`web/paperclip_client.py:50-53`: CMP=`60dce831…`, CMPA=`d6f7c55d…`) — **לא לפי שם**. ⇒ שינוי-שם-תצוגה אינו שובר את ה-wakeup.
|
||||
- ה-persona "Hermes" יושב רק ב: שדה ה-`description` ("Knowledge Curator (Hermes…)"), טקסט ה-system-prompt ב-[hermes-curator.md](../../.claude/agents/hermes-curator.md), שם-הקובץ, ופרוזה בתיעוד.
|
||||
|
||||
---
|
||||
|
||||
## 2. ה-self-learning דלוק ב-config — אבל אינרטי
|
||||
|
||||
ב-`~/.hermes/profiles/curator-cmp/config.yaml` ו-`curator-cmpa/config.yaml` (שורות ~296-328) הפיצ'רים **דלוקים כברירת-מחדל**:
|
||||
|
||||
```yaml
|
||||
memory:
|
||||
memory_enabled: true # זיכרון 3-שכבתי
|
||||
user_profile_enabled: true # מידול-משתמש (dialectic / Honcho)
|
||||
nudge_interval: 10
|
||||
skills:
|
||||
creation_nudge_interval: 15 # נדנוד ליצירת-skills
|
||||
curator:
|
||||
enabled: true # ה-curator הפנימי של Hermes (אוצר זיכרון, כל 168 שעות)
|
||||
```
|
||||
|
||||
**אבל הם לא רצים בפועל**, כי:
|
||||
- ה-adapter מפעיל את ה-CLI **חד-פעמית** (`-q … -Q`) בכל יקיצה ואז יוצא — אין gateway/דמון חי.
|
||||
- ה-curator-הפנימי (interval 168h), flush-הזיכרון (`flush_min_turns`), ונדנוד-ה-skills תלויים בתהליך מתמשך/רב-תור — שלא קיים.
|
||||
|
||||
### עדות פורנזית (state.db של ה-curator-ים)
|
||||
`~/.hermes/profiles/curator-cmp/state.db` (15M) · `curator-cmpa/state.db` (4.6M). טבלאות:
|
||||
|
||||
| יש | אין |
|
||||
|----|-----|
|
||||
| `messages` (555 / 159) — תמלילי-ריצה | ❌ `memories` (זיכרון מזוקק) |
|
||||
| `sessions` (60 / 5) — מטא + billing | ❌ `user_profile` (מודל-דפנה) |
|
||||
| `messages_fts*` — אינדקס FTS5 | ❌ `skills` שנוצרו אוטומטית |
|
||||
|
||||
- התוכן היחיד = **תמלילי-הריצות של ה-curator** (קרא טיוטה↔סופי, רשם ממצאים, הציג interaction) — בדיוק הפלט שכן צרכנו (comments + `decision_lesson`).
|
||||
- סשנים ללא כותרות, `$0.00` עלות מתועדת, חלקם 0-2 הודעות (יקיצות-סרק).
|
||||
- טווח-פעילות: **2026-05-05 → 2026-05-26** (CMP), עד 8.6 (CMPA). רדום מאז.
|
||||
|
||||
**שורה תחתונה:** משלושת הדיפרנציאטורים של Hermes (memory / user-model / skill-acquisition) נוצרו **אפס שורות**. מה ש"לא צרכנו" = לוגים תמימים, לא ידע מזוקק. ⇒ מ-self-learning מקבלים **אפס** ערך; מנצלים את ה-harness בלבד.
|
||||
|
||||
---
|
||||
|
||||
## 3. ההחלטה (מיושמת ב-#126)
|
||||
|
||||
| פעולה | נימוק |
|
||||
|------|-------|
|
||||
| **כיבוי self-learning** בשני config.yaml (`memory_enabled/user_profile_enabled/curator.enabled=false`, skill-nudge off) | מסיר config-מת, הופך את "אנחנו לא מריצים self-learning" לאמיתי, מיישר ל-INV-LRN1/LRN5. הפיך (גיבוי שמור). |
|
||||
| **ניקוי persona** Hermes→Curator בטקסט-הפרומפט + description + תיעוד | השם נעשה כן: סוכן **Curator/אוצֵר-ידע** שרץ על runtime DeepSeek-via-Hermes. |
|
||||
| **שמירת** `HERMES_HOME`/`HERMES_CLI`/"via Hermes" | זה ה-runtime ונכון — סריקה עיוורת של "hermes" הייתה שוברת את ה-adapter. |
|
||||
|
||||
ה-learning האמיתי שלנו נשאר הצינור הדטרמיניסטי המגודר (`final_learning_pipeline` + פאנלים + שער-יו"ר), לא ה-self-learning של Hermes.
|
||||
|
||||
---
|
||||
|
||||
## 4. ⭐ Playbook: איך להפעיל את סוכן ה-Hermes המלא (אם נרצה בעתיד)
|
||||
|
||||
זה מה שיידרש כדי להפוך את ה-curator מ"runtime דק" ל-"סוכן Hermes לומד" — ולמה לא עשינו זאת:
|
||||
|
||||
### 4.1 שכבת-ה-runtime (טכני)
|
||||
1. **gateway מתמשך במקום one-shot.** היום `hermes chat -q … -Q` יוצא אחרי תור אחד. צריך תהליך-Hermes חי (`hermes gateway`/דמון) או `--resume` עקבי עם session מתמשך כך שה-memory-flush, ה-curator-הפנימי, ונדנוד-ה-skills יוכלו לרוץ. ⇒ שינוי ב-`deepseek-paperclip-adapter` (לא רק config).
|
||||
2. **session persistence.** לוודא ש-`persistSession` + `sessionId` נשמרים בין יקיצות-ה-curator של אותו תיק/חברה (ה-adapter תומך — `execute.js:251-252,376-378` — אך תלוי שמירת `sessionParams` ב-Paperclip).
|
||||
3. **הדלקת הפיצ'רים** ב-config.yaml: להחזיר `memory_enabled/user_profile_enabled/curator.enabled=true` + לכוון `nudge_interval`/`flush_min_turns`. (Honcho ל-dialectic-modeling דורש הגדרת `honcho:` — כרגע ריק.)
|
||||
|
||||
### 4.2 שכבת-הממשל (חובה לפני — אחרת מפר את החוקה)
|
||||
4. **INV-LRN1/G10 (אין auto-commit):** memory/skills של Hermes שמשנים את עצמם אוטומטית **אסורים** כשער-ידע. כל פלט-למידה חייב להישאר *הצעה* לשער-יו"ר. ⇒ אם מדליקים, לגדר כך שה-memory/skills של Hermes לא יוזרקו לכתיבה בלי אישור.
|
||||
5. **INV-LRN5 (טוהר-הקול):** ה-3-layer memory + user-model שומרים תוכן-תיק ספציפי. אסור שמהות (הלכה/עובדה) תדלוף לשכבת-הקול. ⇒ צריך distillation שמפריד סגנון↔מהות *לפני* שנכנס לזיכרון-Hermes, או לבודד את זיכרון-Hermes מהקורפוס.
|
||||
6. **G12 (שער-הפלטפורמה):** Hermes כ-runtime מותר (מאחורי ה-adapter/Port). אבל אם מפעילים את ה-orchestration/scheduler/ערוצים שלו — זו פלטפורמת-סוכנים מקבילה ל-Paperclip ⇒ אסור (ראה [#123 §ב](hermes-nous-feasibility.md)).
|
||||
|
||||
### 4.3 ההכרעה הנוכחית
|
||||
**לא להפעיל.** הסיבה (מ-#123 + החקירה כאן): ה-self-learning של Hermes מתנגש בממשל שלנו (4-6), והערך שלו מושג כבר ע"י הצינור המגודר שלנו + רעיון-GEPA (#123). ה-runtime נשאר; ה-"מוח" כבוי.
|
||||
|
||||
---
|
||||
|
||||
## 5. מקורות-קוד / הפניות
|
||||
- runtime: `adapters/deepseek-paperclip-adapter/dist/{shared/constants.js,server/execute.js}` · `~/.hermes/profiles/curator-{cmp,cmpa}/config.yaml`
|
||||
- זהות+wakeup: `web/paperclip_client.py:50-53` (CURATOR_AGENTS), `:1188-1245` (wake_curator_for_final)
|
||||
- persona/prompt: `.claude/agents/hermes-curator.md`
|
||||
- ספ: [07-learning.md](../spec/07-learning.md) (INV-LRN1/4/5) · [X15](../spec/X15-agent-platform-port.md) (G12) · [#123 feasibility](hermes-nous-feasibility.md)
|
||||
- זיכרון: `reference_hermes_home_gotcha`
|
||||
@@ -78,13 +78,14 @@
|
||||
|
||||
אלה החוקים החוצים את כל המערכת — לב החוקה. הם נחלקים לשני סוגים לפי **מקור-הסמכות**:
|
||||
|
||||
- **G1–G10 — invariants הנדסיים** (תכנון/בניית האפליקציה): כל אחד מגובה ב-**≥3 סמכויות
|
||||
- **G1–G10, G12 — invariants הנדסיים** (תכנון/בניית האפליקציה): כל אחד מגובה ב-**≥3 סמכויות
|
||||
טכניות מוכרות** (נספח §8). ביחד הם מייבשים את כשל-השורש החוזר: מסלולים/קורפוסים
|
||||
מקבילים שמתפצלים (drift) בלי שכבה שמגדירה ואוכפת "תקין".
|
||||
מקבילים שמתפצלים (drift) בלי שכבה שמגדירה ואוכפת "תקין". (G12 — שער-הפלטפורמה — מוסף
|
||||
במחזור-3; ראה [X15](X15-agent-platform-port.md).)
|
||||
- **G11 — invariant תוכן-משפטי:** הסמכות עליו היא **היו"ר (דפנה) + מסמכי-הפרויקט**, לא
|
||||
מקורות חיצוניים, ואינו כפוף לפרוטוקול ≥3-המקורות.
|
||||
|
||||
### 5א. Invariants הנדסיים (G1–G10)
|
||||
### 5א. Invariants הנדסיים (G1–G10, G12)
|
||||
|
||||
### INV-G1: מזהה קנוני מנורמל בכתיבה
|
||||
**כלל:** לכל ישות יש מזהה קנוני יחיד, **מנורמל בנקודת-הכתיבה** (לא תיקון-סלחני בקריאה
|
||||
@@ -108,6 +109,11 @@ Fowler (Canonical Data Model) · SSOT (Single Source of Truth) | סטטוס: ver
|
||||
`ingest_internal_decision`) שמתפצלים — לדוגמה: המסלול החיצוני מתזמן חילוץ metadata
|
||||
(`request_metadata_extraction`), והמסלול הפנימי לא — ולכן ערן סופר 8046/24 נקלטה בלי
|
||||
metadata → ממצא ל-[audit](../audit-report.md).
|
||||
**הפרה ידועה (תאום-מתודולוגיה, MET-2/3 — מותנה בגל-2 #131):** `discussion_rules['universal']`
|
||||
ו-`transition_phrases['universal']` ב-`appeal_type_rules` נכתבים ע"י **שני** מסלולים — עורך-המתודולוגיה
|
||||
(PUT, overwrite) ו-promote-הלמידה (append). **מותן:** ה-append רץ בטרנזקציה אחת נעולה (FOR UPDATE) +
|
||||
promote מבטל את ה-cache של /methodology (גל-1 MET-1), כך שעורך-המתודולוגיה הוא העורך-הקנוני שעורך
|
||||
תמיד מצב פוסט-append. שאריות (עריכה בטאב באמת-stale) מקובלות בכלי-יחיד-משתמש; ראה [X17 INV-IA3](X17-information-architecture.md), [ia-audit-redesign.md](../ia-audit-redesign.md) §2.5.
|
||||
|
||||
### INV-G3: ingest אחיד ו-idempotent
|
||||
**כלל:** קליטה היא **אחידה ו-idempotent** — upsert על מפתח דטרמיניסטי. קליטה חוזרת של
|
||||
@@ -196,6 +202,22 @@ Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוט
|
||||
**הפרה ידועה:** 10/19 הלכות מאושרות, התגלה במקרה — שער ידני שקוף בלי נראות backlog →
|
||||
ממצא ל-[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)
|
||||
|
||||
### INV-G11: תוכן החלטה מנומקת
|
||||
@@ -227,11 +249,11 @@ Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוט
|
||||
|
||||
## 7. אינדקס הספ
|
||||
|
||||
> הערה: כל קבצי הספ (00, 01–07, X1–X10) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
||||
> הערה: כל קבצי הספ (00, 01–07, X1–X17) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
||||
|
||||
| קובץ | תפקיד | אוכף 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 |
|
||||
| [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 |
|
||||
@@ -250,6 +272,12 @@ Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוט
|
||||
| [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) | חוזה 71 כלי-ה-MCP: envelope · שמות · idempotency · extract/get-symmetry · שלמות-הרשאות | G2, G3, G10 |
|
||||
| [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) | env-catalog SSoT · מקור-config יחיד (Coolify) · ללא hardcode · secrets · drift | G2, G4, G9 |
|
||||
| [X11-citation-corroboration.md](X11-citation-corroboration.md) | citator פנימי — תיקוף הלכות בטיפול-שיפוטי מצטבר · תיקון-G10 מבוקר · סף-corroboration · התאמה-להלכה | G9, G10 |
|
||||
| [X12-digests-radar.md](X12-digests-radar.md) | יומונים כשכבת-גילוי (radar) — מקור-משני המצביע על הפסק המקורי · לא קורפוס-ציטוט רביעי · לא מצוטט/לא מחלץ-הלכות | G2, G4, G9 |
|
||||
| [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 |
|
||||
| [X17-information-architecture.md](X17-information-architecture.md) | ארכיטקטורת-מידע — משטח-ההפעלה (דפים/תורים/ניווט/cache) · INV-IA1–IA6 מרימים G2/G10 לשכבת-UI · #127/#130–132 | G2, G10 |
|
||||
|
||||
> **X6–X10 (מחזור-2):** מכסים את 8 משטחי-האפליקציה שמחוץ לצינור-הליבה (אינטגרציה, web-ui, מילוי-שדות,
|
||||
> אחסון-ניתוחים, כלי-MCP, deploy/env). הממצאים ב-[gap-audit.md](gap-audit.md) (GAP-24..62 → FU-9..15)
|
||||
|
||||
@@ -155,6 +155,14 @@ RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: 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
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
(`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**,
|
||||
ושם נולדת ההפרה ב-§5.
|
||||
|
||||
> **שכבת-גילוי — יומונים, לא קורפוס-ציטוט.** מעל 3 הקורפוסים יושבת שכבת-radar נפרדת: **יומונים**
|
||||
> (סיכומי עפר-טויסטר), בטבלה פיזית נפרדת `digests` עם כלי `search_digests`. היומון הוא **מקור משני
|
||||
> המצביע** על הפסק המקורי — **אינו** קורפוס-ציטוט רביעי, **אינו** עקיב-בפלט ([INV-RET5](#inv-ret5-כל-span-מוחזר-עקיב-למקורו)),
|
||||
> ו**אינו** נוגע ב-`case_law`/`document_chunks`. ההפרדה כאן **פיזית** (טבלה נפרדת), לא תנאי-סינון —
|
||||
> ולכן [INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query) מתקיים טריוויאלית. מלא ב-
|
||||
> [X12-digests-radar.md](X12-digests-radar.md) (INV-DIG1–DIG3).
|
||||
|
||||
---
|
||||
|
||||
## 2. עיצוב ה-hybrid retrieval
|
||||
@@ -176,3 +183,4 @@ re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף
|
||||
- [02-data-model.md](02-data-model.md) — חוזה-השלמות (searchable) + re-index שהאחזור מסנן לפיהם.
|
||||
- [05-qa-review.md](05-qa-review.md) — שער-הלכה הידני (`review_status`) שמגדיר אילו הלכות searchable.
|
||||
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור מלאה של כל span מוחזר (בסיס ל-INV-RET5).
|
||||
- [X12-digests-radar.md](X12-digests-radar.md) — שכבת-הגילוי (יומונים) שמעל הקורפוסים — מצביעה, לא מצוטטת.
|
||||
|
||||
@@ -43,7 +43,9 @@
|
||||
`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, מדד-מרחק, פנקס-התאמה).
|
||||
`/methodology` = **עורך-הפרופיל היחיד** (declarative: יחסי-זהב, כללי-דיון, צ׳קליסטים, ביטויי-מעבר, אנטי-דפוסים, voice-invariants). `/training` = **שולחן-הלמידה** (קורפוס, פורטרט-סגנון, השוואת draft↔final, curator, מדד-מרחק, פנקס-התאמה).
|
||||
|
||||
**שער-אישור אחד · טרנזקציית-כותב אחת (INV-IA3 → [X17](X17-information-architecture.md)):** ל-`decision_lesson` יש **סטטוס-יחיד** שקובע "זורם-לכותב" — `review_status='approved'` (INV-LRN1/G10). הדגל `applied_to_skill` **הוסר** (היה אינפורמטיבי-בלבד, נכתב-לשומקום → בלבל את היו"ר ב"שני שערים"; גל-2 #131). לקח שהיו"ר מחבר ידנית נוצר כבר כ-`approved`; לקח-פאנל נוצר כ-`proposed` וממתין לשער. promote של זוג draft↔final מטמיע את הלקחים/הביטויים שהיו"ר בחר **דרך appeal_type_rules בטרנזקציה אחת נעולה (FOR UPDATE)** — מסלול-כתיבה-יחיד, ללא read-modify-write מתפצל מול עורך-המתודולוגיה (MET-2/3, להלן G2 הפרות-ידועות).
|
||||
|
||||
### 0.5 Invariants חדשים
|
||||
**INV-LRN4 (ניגוד-אמת → G10/G9):** למידת-קול מבוססת **pairing draft↔final ברמת-בלוק**, לא קריאת-final בלבד. כל החלטה אינה "סגורה" עד שהושוותה מול הסופי; כל סופי מנותח מול הטיוטה. נשמר פנקס-התאמה (`draft_final_pairs`) עם מצב-חיים `draft_done → final_received → analyzed → lessons_folded`.
|
||||
@@ -52,6 +54,18 @@
|
||||
**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. שלוש לולאות-המשנה
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
|
||||
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
|
||||
|
||||
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X10 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
||||
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X17 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
||||
- 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).
|
||||
- X17 (ארכיטקטורת-מידע): [X17-information-architecture.md](X17-information-architecture.md) — משטח-ההפעלה (דפים/תורים/ניווט/cache); INV-IA1–IA6 מרימים את G2 ו-G10 לשכבת-ה-UI. מיישם feedback_operational_simplicity.
|
||||
|
||||
מפות-ממצאים: [gap-audit.md](gap-audit.md) (GAP-01..62 → FU-1..15; מחזור-1 ✅ הושלם, מחזור-2 פתוח) · [ui-audit.md](ui-audit.md) (ביקורת 13 דפי-UI).
|
||||
מפות-ממצאים: [gap-audit.md](gap-audit.md) (GAP-01..62 → FU-1..15; מחזור-1 ✅ הושלם, מחזור-2 פתוח) · [ui-audit.md](ui-audit.md) (ביקורת 13 דפי-UI, שכבת-קוד) · [ia-audit-redesign.md](../ia-audit-redesign.md) (34 משטחים, 37 ממצאים, שכבת-IA/הפעלה → X17, #127).
|
||||
בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.md
|
||||
|
||||
@@ -37,6 +37,26 @@
|
||||
> (`cases.proceeding_type`, `db.py:912`). כך "ערר 8137/24" ו-"בל\"מ 8137/24" הם שתי
|
||||
> רשומות מובחנות בעלות אותו `case_number=8137-24` ו-`proceeding_type` שונה.
|
||||
|
||||
### 1א. אורך-הסידורי — אות לסוג-ההליך (נוהל-יו"ר, 2026-06-11)
|
||||
|
||||
מבנה ה-`case_number` הוא `<סידורי>-<חודש>-<שנה>` (serial-month-year). **אורך הסידורי מקודד
|
||||
את סוג-ההליך:**
|
||||
|
||||
| אורך סידורי | סוג-הליך | דוגמה | הערה |
|
||||
|-------------|----------|-------|------|
|
||||
| 4 ספרות | **ערר** | `1230-04-26` | הליך עיקרי |
|
||||
| 5 ספרות | **בל"מ** | `85074-09-24` | בקשה להארכת מועד |
|
||||
|
||||
- **הספרה הראשונה ממשיכה לקודד את התחום בשני האורכים** — `1→רישוי`, `8→היטל השבחה`,
|
||||
`9→פיצויים ס'197` (INV-DM/practice_area). תיק בל"מ `85074` → תחום היטל-השבחה.
|
||||
- **הכלל חד-כיווני:** סידורי בן 5 ספרות **הוא** בל"מ (אות אוטומטי, `is_blam_by_number`,
|
||||
`practice_area.py`). סידורי בן 4 ספרות **אינו** מחייב ערר — בל"מ-מורשת בן 4 ספרות עדיין
|
||||
מזוהה מהנושא (`is_blam_subject`). הרקע: ירושלים אימצה מספור 5-ספרתי לבל"מ רק עכשיו; ת"א
|
||||
מזה זמן (למשל `81002-01-21`).
|
||||
- **פתיחת תיק חדש מחייבת את צורת serial-month-year המלאה** (כולל חודש) — ולידציית-הכתיבה
|
||||
(`web-ui/src/lib/schemas/case.ts`) דוחה את צורת המורשת ללא-חודש. ההתאמה-הסלחנית-בקריאה
|
||||
(§3) עדיין בולעת רשומות-מורשת בנות שתי-חוליות לצורך *חיפוש*, לא לצורך *יצירה*.
|
||||
|
||||
**נרמול-בכתיבה הוא המנגנון הראשי; התאמה-סלחנית-בקריאה היא נוחות משנית בלבד.** כלל-ההנדסה
|
||||
"נרמול לא תיקון-תסמין" (חוקה §6) קובע: מתקנים את הנתון במקור, לא מטליאים בקריאה. אם רשומה
|
||||
נשמרה בצורה לא-קנונית — היעד הוא לנרמל אותה במיגרציה/בכתיבה, **לא** לסמוך על מנוע-קריאה
|
||||
|
||||
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) — הסקריפטים המושפעים.
|
||||
78
docs/spec/X17-information-architecture.md
Normal file
78
docs/spec/X17-information-architecture.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# X17 — ארכיטקטורת-המידע ומשטח-ההפעלה (Information Architecture)
|
||||
|
||||
> **מה זה.** ספ-היעד ל**איך** משטח-ההפעלה (דפים/טאבים/תורים/ניווט/cache) צריך להיות מאורגן — שכבה מעל [X6 (חוזה UI↔API)](X6-ui-api-contract.md). X6 קובע ש**טיפוס** נכון (OpenAPI=SSoT, provenance); X17 קובע ש**משטח** נכון (מקור-אמת יחיד לכל datum, שער אחד לכל החלטה, ניווט מבוסס-משימה).
|
||||
>
|
||||
> **למה.** חיים (2026-06-11): *"המערכת מסובכת מדי לתפעול."* האבחון ([`../ia-audit-redesign.md`](../ia-audit-redesign.md), #127) אימת 37 ממצאים שכולם ביטוי-UI של **G2** מופר שלא הורחב לשכבת-ה-UI. X17 מרים את G2 (מקור-אמת יחיד) ו-G10 (שערים-אנושיים) לשכבת-המשטח, ומקודד את [[feedback_operational_simplicity]] (שער/מקום/ערוץ אחד) כ-invariants אכיפים.
|
||||
>
|
||||
> **גבול קשיח (G10):** X17 מסיר משטחים/ערוצים **כפולים**, לעולם לא **שער**. כל שער-אנושי (אישור-הלכה, פתרון-הערה, אישור-לקח, בחירת-תוצאה) נשאר חובה ומפורש. "שער אחד" = מקום-אחד-להחליט, לא אפס-החלטה.
|
||||
|
||||
---
|
||||
|
||||
## INV-IA1 — בעלים-משטח-יחיד לכל datum/מונה (G2 בשכבת-UI)
|
||||
ל-aggregate/מונה נגזר-שרת יש **משטח-בעלים יחיד** שמריץ את השאילתה; משטחים אחרים **מצביעים** אליו (deep-link/pointer), ולעולם לא מריצים מונה-מתחרה client-side. *(אכיפה: מונה-הגייטים חי רק ב-`/approvals`+`['chair','pending']`; `/operations` מצביע. תופס APR-2/3, ADM-2/3.)*
|
||||
- Maintain Consistency and Adhere to Standards (Heuristic #4) — Nielsen Norman Group — https://www.nngroup.com/articles/consistency-and-standards/
|
||||
- 3 Common IA Mistakes (Low Information Scent) — Nielsen Norman Group — https://www.nngroup.com/articles/3-ia-mistakes/
|
||||
- Information Architecture: For the Web and Beyond, 4th ed. (Organization & Labeling Systems) — Rosenfeld, Morville & Arango (O'Reilly) — https://www.oreilly.com/library/view/information-architecture-4th/9781491913529/
|
||||
|
||||
## INV-IA2 — mutation מבטל כל קורא (no stale cross-surface state)
|
||||
כל mutation שמשנה ערך הנקרא במשטח אחר **חייב לבטל כל queryKey שקורא אותו** — כולל aggregators חוצי-namespace. אסור שמשטח יציג ערך תקוע אחרי שינוי במשטח אחר. *(תופס את 16 פערי-הסנכרון: CAS-1/2, APR-1/4/5/6, LRN-6/8/10, MET-1/8, ADM-2/3/5.)*
|
||||
- Query Invalidation — TanStack Query (official docs) — https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation
|
||||
- Deriving Client State from Server State — TkDodo (Dominik Dorfmeister, TanStack maintainer) — https://tkdodo.eu/blog/deriving-client-state-from-server-state
|
||||
- Visibility of System Status (Heuristic #1) — Nielsen Norman Group — https://www.nngroup.com/articles/visibility-system-status/
|
||||
|
||||
## INV-IA3 — שער-אחד / ערוץ-אחד לכל החלטה (G10/INV-LRN1 נשמרים)
|
||||
לכל החלטה-אנושית **משטח-יחיד ומסלול-כתיבה-יחיד**. אסור משטח-אישור שני או כותב-מקביל לאותה שורה. הלמידה **מנותבת דרך** העורך הקנוני, לא כותבת-במקביל. *(תופס LRN-1/2/3, MET-2/3 — "שני השערים" של חיים, ומירוץ ה-lost-update.)*
|
||||
- Preventing User Errors / Error Prevention (Heuristic #5) — Nielsen Norman Group — https://www.nngroup.com/articles/slips/
|
||||
- Do the hard work to make it simple (Government Design Principles) — GOV.UK / GDS — https://www.gov.uk/guidance/government-design-principles
|
||||
- Don't Make Me Think, Ch.5 — Omit Needless Words/Controls — Steve Krug (O'Reilly) — https://www.oreilly.com/library/view/dont-make-me/0321344758/ch05.html
|
||||
|
||||
## INV-IA4 — ניווט מבוסס-משימה, לא מבוסס-פורמט
|
||||
משטחים מאורגנים לפי **מה המשתמש עושה** (לאשר / לנטר / להגדיר / לחבר), לא לפי מקור-הנתונים הטכני (pm2 מול DB מול תורים). דלת-כניסה אחת לכל משימה. *(אכיפה: `/operations`⊇`/diagnostics` — אותו intent-ניטור; הורדת `/feedback` מהראשי. תופס D5, ADM.)*
|
||||
- Avoid Format-Based Primary Navigation — Nielsen Norman Group — https://www.nngroup.com/articles/format-based-navigation/
|
||||
- Intranet IA Methods (task-based endures over structure-based) — Nielsen Norman Group — https://www.nngroup.com/articles/intranet-ia-methods/
|
||||
- Task list pattern (one list of outstanding tasks per service) — GOV.UK Design System — https://design-system.service.gov.uk/components/task-list/
|
||||
|
||||
## INV-IA5 — סטטוס-אמיתי מגובה-צרכן
|
||||
כל מספר מוצג ממופה ל**צרכן אמיתי** ו**מקור שלם**. אסור KPI שסופר דגל-ללא-צרכן; אסור aggregate מדויק כש-partial-failure השמיט תורם (להציג חלקיות); שדה ב-response — **לרנדר או להסיר**. *(תופס LRN-1/4/5, ADM-1/6, APR-3.)*
|
||||
- Visibility of System Status (Heuristic #1) — Nielsen Norman Group — https://www.nngroup.com/articles/visibility-system-status/
|
||||
- Design with data (Government Design Principles) — GOV.UK / GDS — https://www.gov.uk/guidance/government-design-principles
|
||||
- Minimize Cognitive Load to Maximize Usability — Nielsen Norman Group — https://www.nngroup.com/articles/minimize-cognitive-load/
|
||||
|
||||
## INV-IA6 — שפת-מפעיל מובנת-מאליה (no jargon; precedence in-context)
|
||||
קופי פונה-למפעיל מתאר את **האפקט המשמעותי**, לא מזהי-משימות פנימיים (T7/T15). התנהגות-תחולה/קדימות (universal מוקדם; checklist→appeal_type) מוצגת **בהקשר** דרך progressive disclosure, לא במסמך נפרד. *(תופס MET-4/5/6/7.)*
|
||||
- Don't Make Me Think — Krug's First Law (self-evident) — Steve Krug — https://www.oreilly.com/library/view/dont-make-me/0789723107/ch02.html
|
||||
- Plain language / write for users (Design principles) — U.S. Web Design System (USWDS) — https://designsystem.digital.gov/design-principles/
|
||||
- Progressive Disclosure — Nielsen Norman Group — https://www.nngroup.com/articles/progressive-disclosure/
|
||||
|
||||
---
|
||||
|
||||
## משטח-היעד (Target IA)
|
||||
|
||||
### שלושה משטחי-intent ברמת-העל
|
||||
| משטח | intent | בעלים | כלל |
|
||||
|------|--------|-------|-----|
|
||||
| **`/approvals`** | **לאשר** (החלטה-אנושית) | תיבת-הגייטים הקנונית | המקום **היחיד** שפועלים על שער. כרטיס לכל סוג (הלכות/פסיקה-חסרה/הערות/QA) עם מונה+קישור. |
|
||||
| **`/operations`** | **לנטר** (קריאה-בלבד) | משטח-המכונה | בולע את `/diagnostics`. שירותים+תורים+סוכנים+בריאות+`halacha_backlog`. **אפס** שער נפעל כאן; מצביע ל-`/approvals`. |
|
||||
| **`/settings`** | **להגדיר** | משטח-התצורה | Paperclip/סוכנים/env/כלים/בלוקים. עריכת-env משלימה-את-עצמה (staleness+redeploy באותה שורה). |
|
||||
|
||||
### משטחי-תחום (בעלים-יחיד לכל ישות)
|
||||
| תחום | יעד |
|
||||
|------|-----|
|
||||
| **תיק** | **workspace-החלטה אחד** — block-editing + DOCX-פעיל, מחוון-מקור-אמת אחד בבעלות-המערכת, cache-slice משותף. אזור "השלמה והעברה" אחד לשערי-הסיום. |
|
||||
| **למידה** | **תיבת-אישור אחת** (לפי זוג draft↔final) · **ערוץ-כותב אחד + סטטוס "זורם-לכותב" אחד** (`review_status='approved'`) · `applied_to_skill` **מוסר** · כל artifact תלוי-בזוג (progressive disclosure). |
|
||||
| **מתודולוגיה** | **`/methodology` = העורך הקנוני היחיד** (PUT אחד; תג-מקור "ידני/מאומץ-מלמידה"); הלמידה מנותבת-דרכו ומבטלת שני-caches; explainer-תחולה inline. |
|
||||
| **פסיקה** | 3 קורפוסים **נפרדים** (גבול אמיתי, G2/INV-DIG1) אך **מתפעלים אחיד** — שם-חיפוש עקבי, תבנית-"ממתין" אחת, authority בכל-מקום. |
|
||||
|
||||
---
|
||||
|
||||
## דלתות-ספ (deltas — ✅ קודדו בגל-2 #131)
|
||||
> כל שינוי-ספ דורש ≥3 מקורות (לעיל) + אישור-יו"ר. אושר ע"י חיים (2026-06-11, /goal "בצע את כל הגלים").
|
||||
|
||||
1. **[X6](X6-ui-api-contract.md):** ✅ נוספו **INV-UI7** (aggregate-נגזר=SSoT; mutation מבטל queryKey; אין מונה-מתחרה — מקדד INV-IA1/IA2) · **INV-UI8** (שדה-response מרונדר-או-מוסר; חלקיות מוצגת — INV-IA5).
|
||||
2. **[07-learning §0.4](07-learning.md):** ✅ שער-אישור **אחד** (`review_status='approved'`), טרנזקציית-כותב **אחת** (FOR UPDATE), `applied_to_skill` **הוסר** (מקדד INV-IA3; מיישב את "שני-השערים" של [[feedback_operational_simplicity]]).
|
||||
3. **[00-constitution §G2 "הפרות ידועות"](00-constitution.md):** ✅ נוסף תאום-המתודולוגיה (`discussion_rules['universal']` נכתב ע"י PUT וגם promote — MET-2/3) + המיתון (append אטומי FOR UPDATE + invalidation).
|
||||
|
||||
## הפניות-אחיות
|
||||
- [`../ia-audit-redesign.md`](../ia-audit-redesign.md) — מצב-קיים: 34 משטחים, 37 ממצאים (file:line), כיוון-יעד פר-אשכול.
|
||||
- [X6-ui-api-contract.md](X6-ui-api-contract.md) (UI1–UI6 — X17 מעליו) · [ui-audit.md](ui-audit.md) (ממצאי-קוד פר-רכיב — שכבה מתחת).
|
||||
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md) (מקור-אמת יחיד) · G10 (שערים-אנושיים) — X17 מרים אותם לשכבת-המשטח.
|
||||
@@ -86,6 +86,25 @@ TanStack Query — *Important Defaults* (staleTime/refetch) (https://tanstack.co
|
||||
מוצגים כשדות-עריכה רגילים ללא סימון.
|
||||
**הפרה ידועה:** [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)).
|
||||
|
||||
### INV-UI7: aggregate-נגזר = מקור-אמת יחיד · כל mutation מבטל כל קורא (G2 בשכבת-ה-cache)
|
||||
**כלל:** ל-aggregate/מונה נגזר-שרת (למשל `/api/chair/pending`) יש **משטח-בעלים יחיד** שמריץ את השאילתה;
|
||||
משטחים אחרים **מצביעים** אליו ולא מריצים מונה-מתחרה client-side. **כל mutation** שמשנה ערך הנקרא במשטח
|
||||
אחר **חייב לבטל כל `queryKey` שקורא אותו** — כולל aggregators חוצי-namespace — כך שאף משטח לא יציג ערך
|
||||
תקוע אחרי שינוי במשטח אחר. מקדד את **[X17 INV-IA1/IA2](X17-information-architecture.md)**; מופע של
|
||||
[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) בשכבת-ה-TanStack-Query.
|
||||
**מקור-סמכות:** [X17](X17-information-architecture.md) (≥3 מקורות: TanStack Query invalidation · TkDodo "deriving client state" · NN/g consistency); [ia-audit-redesign.md](../ia-audit-redesign.md) §D1.
|
||||
**אכיפה:** מונה-הגייטים חי רק ב-`/approvals`+`['chair','pending']`; `/operations` מצביע. כל mutation להלכות/
|
||||
פסיקה-חסרה/הערות מבטל `['chair','pending']` (גל-1 #130, PR #207).
|
||||
|
||||
### INV-UI8: שדה-response מרונדר-או-מוסר · חלקיות מוצגת
|
||||
**כלל:** שדה שמופיע ב-response **לרנדר או להסיר** — אסור לזרוק אותו בשקט ב-frontend (אם אין צרכן —
|
||||
להסירו מה-response). KPI מוצג חייב להיות ממופה ל**צרכן אמיתי** (לא דגל אינפורמטיבי-בלבד). aggregate
|
||||
מדויק כש-partial-failure השמיט תורם — **להציג חלקיות** ("+"/"חלקי"), לא מספר-מוקטן-כאילו-שלם. מקדד את
|
||||
**[X17 INV-IA5](X17-information-architecture.md)**.
|
||||
**מקור-סמכות:** [X17](X17-information-architecture.md) (NN/g visibility-of-system-status · GOV.UK design-with-data); [ia-audit-redesign.md](../ia-audit-redesign.md) §D3.
|
||||
**אכיפה:** `halacha_backlog` מרונדר ב-/operations (לא נזרק); `findings_approved` (review_status, צרכן אמיתי)
|
||||
החליף את `findings_applied` (דגל מת); מוני-סוכנים מסמנים "חלקי" כשחברה לא-נטענה (גל-1 #130).
|
||||
|
||||
---
|
||||
|
||||
## 3. כללי-עיצוב (Design Rules) — נגזרים מה-invariants
|
||||
|
||||
@@ -92,12 +92,14 @@ NCSC/JTC — *AI in Courts* (verifiable citation) | סטטוס: verified
|
||||
**אכיפה:** `proofreader.verify_quote` בעת חילוץ → `quote_verified`.
|
||||
**הפרה ידועה:** — (קיים; ה-flag נכתב, אך אין חיווי ב-UI — ראה [X6 INV-UI6](X6-ui-api-contract.md)).
|
||||
|
||||
### INV-FP5: חילוץ אסינכרוני דרך claude_session מקומי
|
||||
**כלל:** חילוץ-LLM (מטא, הלכות) רץ **אסינכרוני, מתור**, דרך `claude_session` **מקומי בלבד** — לא חוסם את
|
||||
ה-web, ולא קורא ל-LLM מהקונטיינר. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||
(מסלול-LLM קנוני יחיד). **פרויקטלי-תפעולי.** תואם זיכרון `feedback_claude_session_local_only`.
|
||||
**מקור-סמכות:** [ingest.py](../../mcp-server/src/legal_mcp/services/ingest.py) (queue בצעד 12 → `process_pending_extractions`); [legal-ai/CLAUDE.md](../../CLAUDE.md) (claude_session local-only).
|
||||
**אכיפה:** queue + `precedent_process_pending`; קריאות-LLM רק מ-MCP מקומי.
|
||||
### 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)).
|
||||
|
||||
---
|
||||
|
||||
@@ -21,6 +21,29 @@ dependencies = [
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"httpx>=0.27.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]
|
||||
|
||||
@@ -54,6 +54,10 @@ REDIS_URL = os.environ.get("REDIS_URL", "redis://127.0.0.1:6380/0")
|
||||
# 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;
|
||||
@@ -134,12 +138,26 @@ BM25_HYBRID_ENABLED = (
|
||||
)
|
||||
|
||||
# Halacha extraction — auto-approve threshold. Halachot with extractor
|
||||
# confidence >= this value are inserted with review_status='approved'
|
||||
# instead of 'pending_review' (so they immediately appear in
|
||||
# search_precedent_library). Set to a value > 1.0 to disable auto-approval.
|
||||
# 0.80 baseline: 89% of historical extractions land here, manual spot-check
|
||||
# of 10 random samples confirmed quality. Tunable via env if drift is
|
||||
# observed (e.g. raise to 0.90 if false-positives appear).
|
||||
# confidence >= this value AND no quality_flags are inserted
|
||||
# review_status='approved' (so they appear immediately in
|
||||
# search_precedent_library). Set > 1.0 to disable auto-approval.
|
||||
#
|
||||
# CALIBRATION (#81.8, 2026-06-11) against the 100-item human-labeled gold-set
|
||||
# (db.goldset_calibrate, ground_truth='chair'; 93 keep / 7 drop):
|
||||
# conf>=0.80 -> precision 0.98, recall 0.53 <- current (errs safe)
|
||||
# conf>=0.75 -> precision 0.96, recall 0.81
|
||||
# conf>=0.70 -> precision 0.94, recall 0.94
|
||||
# 0.80 clears the >=0.90 precision target with margin, so we KEEP it — it errs
|
||||
# toward the chair (low recall = more items reviewed, never the reverse).
|
||||
# Two findings shape the policy:
|
||||
# (a) self-confidence alone is well-calibrated for PRECISION; the rule-based
|
||||
# validators do NOT discriminate keep/drop on the gold-set (P~0.1), so a
|
||||
# "confidence x validators" combined score would only hurt — not adopted.
|
||||
# (b) the real COVERAGE lever is the tri-model panel (halacha_panel_approve):
|
||||
# unanimous-3/3 -> precision 0.988 at 95% coverage, dominating any single
|
||||
# confidence threshold. Lowering this gate to ~0.75 is a governance
|
||||
# tradeoff (more unreviewed auto-approvals, INV-G10) on thin evidence
|
||||
# (7 negatives) -> deferred to chair/panel (TaskMaster #121), not changed here.
|
||||
HALACHA_AUTO_APPROVE_THRESHOLD = float(
|
||||
os.environ.get("HALACHA_AUTO_APPROVE_THRESHOLD", "0.80")
|
||||
)
|
||||
@@ -198,6 +216,32 @@ EXPORTS_DIR = DATA_DIR / "exports" # legacy exports only
|
||||
# Cases directory — flat structure: data/cases/{case_number}/
|
||||
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:
|
||||
"""Return the case directory for a given case number."""
|
||||
@@ -318,3 +362,34 @@ def parse_llm_json(raw: str):
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ── Committee chair — single source of truth (INV-G2) ─────────────────
|
||||
# internal_committee rows REQUIRE a non-empty chair_name (DB constraint
|
||||
# case_law_internal_chair_check). Our committee (CMP 1xxx, CMPA 8/9xxx) is
|
||||
# chaired by Dafna Tamir; map by case-number prefix so adding a future chair
|
||||
# stays a one-line local change. This resolver is the ONE place both the
|
||||
# FastAPI final-upload path (web/app.py) and the MCP learning path
|
||||
# (tools/workflow.py + services/db.create_case) derive the chair from — so
|
||||
# the two cannot drift into parallel logic. Override via env for another
|
||||
# committee.
|
||||
COMMITTEE_CHAIR_DEFAULT = os.environ.get("DEFAULT_CHAIR_NAME", "דפנה תמיר")
|
||||
COMMITTEE_CHAIR_BY_PREFIX = {
|
||||
"1": COMMITTEE_CHAIR_DEFAULT,
|
||||
"8": COMMITTEE_CHAIR_DEFAULT,
|
||||
"9": COMMITTEE_CHAIR_DEFAULT,
|
||||
}
|
||||
|
||||
|
||||
def committee_chair_for_case(case: dict | None, case_number: str) -> str:
|
||||
"""Resolve the chair for one of OUR decisions deterministically (no LLM):
|
||||
the case's own chair_name, else the committee default by case-number prefix.
|
||||
|
||||
Never returns empty for a valid case number — this is how chair_name is
|
||||
normalised at the source (INV-G1) so internal_committee corpus copies of
|
||||
finals never silently fail the DB chair constraint.
|
||||
"""
|
||||
existing = ((case or {}).get("chair_name") or "").strip()
|
||||
if existing:
|
||||
return existing
|
||||
return COMMITTEE_CHAIR_BY_PREFIX.get((case_number or "")[:1], COMMITTEE_CHAIR_DEFAULT)
|
||||
|
||||
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)
|
||||
404
mcp-server/src/legal_mcp/court_fetch_service/server.py
Normal file
404
mcp-server/src/legal_mcp/court_fetch_service/server.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""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
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
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
|
||||
|
||||
|
||||
# claude.ai subscription usage — the 5-hour / weekly utilization % the Claude
|
||||
# Code status bar shows, from the (undocumented) OAuth usage endpoint. Host-only:
|
||||
# the OAuth token lives in the CLI credentials file on the host, never in the
|
||||
# container. Read-only (no auth), like /pm2. The claude-code User-Agent is
|
||||
# REQUIRED — without it the request lands in an aggressively rate-limited bucket.
|
||||
_CLAUDE_CRED_PATH = "/home/chaim/.claude/.credentials.json"
|
||||
_OAUTH_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
||||
_USAGE_UA = "claude-code/2.1.177"
|
||||
# /operations polls every 5s; the usage endpoint 429s if hit that often (it's
|
||||
# meant for a status bar, not a poll loop). Cache the last good payload and only
|
||||
# re-fetch when older than this — Anthropic sees ~1 req/min regardless of how
|
||||
# many dashboards poll. The 5-hour window moves slowly, so 60s is plenty fresh.
|
||||
_USAGE_TTL_SEC = 60.0
|
||||
_usage_cache: dict = {"ts": 0.0, "data": None}
|
||||
|
||||
|
||||
async def usage_status(request: web.Request) -> web.Response:
|
||||
"""Proxy the claude.ai subscription usage % (host-only — needs the local
|
||||
OAuth token), cached for _USAGE_TTL_SEC. On a fetch failure (e.g. the
|
||||
endpoint's own 429) serve the last good payload if we have one, so a
|
||||
transient limit doesn't blank the dashboard."""
|
||||
now = time.monotonic()
|
||||
if _usage_cache["data"] is not None and (now - _usage_cache["ts"]) < _USAGE_TTL_SEC:
|
||||
return web.json_response(_usage_cache["data"])
|
||||
|
||||
try:
|
||||
with open(_CLAUDE_CRED_PATH) as f:
|
||||
token = json.load(f)["claudeAiOauth"]["accessToken"]
|
||||
except Exception as e:
|
||||
if _usage_cache["data"] is not None:
|
||||
return web.json_response(_usage_cache["data"])
|
||||
return web.json_response({"error": f"no claude credentials: {e}"}, status=502)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": _USAGE_UA,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
}
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=15)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(_OAUTH_USAGE_URL, headers=headers) as r:
|
||||
if r.status != 200:
|
||||
raise RuntimeError(f"usage endpoint {r.status}")
|
||||
data = await r.json()
|
||||
except Exception as e: # never throw — serve stale if we have it
|
||||
if _usage_cache["data"] is not None:
|
||||
return web.json_response(_usage_cache["data"])
|
||||
return web.json_response({"error": f"usage fetch failed: {e}"}, status=502)
|
||||
|
||||
_usage_cache["ts"] = now
|
||||
_usage_cache["data"] = data
|
||||
return web.json_response(data)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ─── adapter-migration: host-side runner for scripts/migrate_agent_adapter.py ───
|
||||
# The legal-ai container can't perform the migration itself (it needs the host
|
||||
# filesystem — generated instruction copies, the gemini settings file — plus the
|
||||
# embedded board DB), so the dashboard proxies the action here. Mutating, so it
|
||||
# requires the Bearer secret like /pm2/control. We launch exactly one fixed,
|
||||
# in-repo script with create_subprocess_exec (no shell) and an action allowlist;
|
||||
# every other argument is passed through opaque and validated by the script
|
||||
# itself. Kept deliberately symbol-light so this host bridge stays generic.
|
||||
_MIGRATE_SCRIPT = "/home/chaim/legal-ai/scripts/migrate_agent_adapter.py"
|
||||
_MIGRATE_PYTHON = "/home/chaim/legal-ai/mcp-server/.venv/bin/python"
|
||||
_MIGRATE_ACTIONS = {"check", "apply", "revert", "verify"}
|
||||
|
||||
|
||||
async def adapter_migration(request: web.Request) -> web.Response:
|
||||
"""Run scripts/migrate_agent_adapter.py on the host and relay its result."""
|
||||
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)
|
||||
|
||||
action = str(body.get("action", "")).strip()
|
||||
if action not in _MIGRATE_ACTIONS:
|
||||
return web.json_response(
|
||||
{"error": f"action must be one of {sorted(_MIGRATE_ACTIONS)}"}, status=400
|
||||
)
|
||||
|
||||
argv = [_MIGRATE_PYTHON, _MIGRATE_SCRIPT, f"--{action}"]
|
||||
agent = str(body.get("agent", "")).strip()
|
||||
target = str(body.get("to", "")).strip()
|
||||
model = str(body.get("model", "")).strip()
|
||||
if action in ("check", "apply", "revert"):
|
||||
if not agent:
|
||||
return web.json_response({"error": "agent required"}, status=400)
|
||||
argv += ["--agent", agent]
|
||||
if action in ("check", "apply"):
|
||||
if not target:
|
||||
return web.json_response({"error": "to (target) required"}, status=400)
|
||||
argv += ["--to", target]
|
||||
if model:
|
||||
argv += ["--model", model]
|
||||
if bool(body.get("relax_tools")):
|
||||
argv += ["--relax-tools"]
|
||||
|
||||
import asyncio as _asyncio
|
||||
|
||||
env = {**os.environ, "HOME": "/home/chaim"}
|
||||
try:
|
||||
proc = await _asyncio.create_subprocess_exec(
|
||||
*argv, cwd="/home/chaim/legal-ai", env=env,
|
||||
stdout=_asyncio.subprocess.PIPE, stderr=_asyncio.subprocess.PIPE,
|
||||
)
|
||||
out, err = await _asyncio.wait_for(proc.communicate(), timeout=180)
|
||||
except _asyncio.TimeoutError:
|
||||
return web.json_response({"ok": False, "error": "migration timed out"}, status=504)
|
||||
except Exception as e: # never throw — relay the failure
|
||||
return web.json_response({"ok": False, "error": f"launch failed: {e}"}, status=502)
|
||||
|
||||
# 200 regardless of exit code: a non-zero --check (preflight refusal) is an
|
||||
# informative result the caller renders, not a transport error.
|
||||
return web.json_response({
|
||||
"ok": (proc.returncode == 0),
|
||||
"exit_code": proc.returncode,
|
||||
"stdout": out.decode("utf-8", "replace"),
|
||||
"stderr": err.decode("utf-8", "replace"),
|
||||
})
|
||||
|
||||
|
||||
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_get("/usage", usage_status)
|
||||
app.router.add_post("/pm2/control", pm2_control)
|
||||
app.router.add_post("/fetch", fetch)
|
||||
app.router.add_post("/adapter-migration", adapter_migration)
|
||||
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,
|
||||
citations as cit_tools,
|
||||
training_enrichment as train_tools,
|
||||
digests as digest_tools,
|
||||
court_fetch as cf_tools,
|
||||
)
|
||||
|
||||
|
||||
@@ -340,6 +342,81 @@ async def search_precedent_library(
|
||||
)
|
||||
|
||||
|
||||
# Digests radar (X12) — secondary discovery layer; NOT a citation corpus.
|
||||
@mcp.tool()
|
||||
async def digest_upload(
|
||||
file_path: str,
|
||||
yomon_number: str = "",
|
||||
digest_date: str = "",
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
subject_tags: list[str] | None = None,
|
||||
) -> str:
|
||||
"""העלאת יומון ("כל יום") לקורפוס-הגילוי (radar) + חילוץ מטא-דאטה אוטומטי. היומון הוא מקור-משני המצביע על הפסק המקורי — אינו מצוטט בהחלטה ואינו מחלץ הלכות (INV-DIG1/2). practice_area: rishuy_uvniya / betterment_levy / compensation_197."""
|
||||
return await digest_tools.digest_upload(
|
||||
file_path, yomon_number, digest_date, practice_area,
|
||||
appeal_subtype, subject_tags,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def digest_list(
|
||||
practice_area: str = "",
|
||||
concept_tag: str = "",
|
||||
linked: bool | None = None,
|
||||
search: str = "",
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""רשימת יומונים בקורפוס-הגילוי, עם פילטרים. linked=false → יומונים שהפסק המקורי שלהם עוד לא נקלט לספריית הפסיקה (פער-ידע גלוי, INV-DIG3)."""
|
||||
return await digest_tools.digest_list(
|
||||
practice_area, concept_tag, linked, search, _clamp_limit(limit),
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def digest_get(digest_id: str) -> str:
|
||||
"""יומון ספציפי לפי מזהה (כולל מראה-מקום, ניתוח, וקישור לפסק המקורי אם קיים)."""
|
||||
return await digest_tools.digest_get(digest_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def digest_link(digest_id: str, case_law_id: str) -> str:
|
||||
"""קישור ידני של יומון לפסק הדין המקורי בספריית הפסיקה (INV-DIG3). idempotent."""
|
||||
return await digest_tools.digest_link(digest_id, case_law_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def digest_relink(digest_id: str) -> str:
|
||||
"""ניסיון-קישור מחדש: בודק אם פסק הדין המקורי של היומון כבר בספרייה ומקשר אוטומטית."""
|
||||
return await digest_tools.digest_relink(digest_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def digest_delete(digest_id: str) -> str:
|
||||
"""מחיקת יומון מקורפוס-הגילוי."""
|
||||
return await digest_tools.digest_delete(digest_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search_digests(
|
||||
query: str,
|
||||
practice_area: str = "",
|
||||
subject_tag: str = "",
|
||||
concept_tag: str = "",
|
||||
limit: int = 10,
|
||||
) -> str:
|
||||
"""חיפוש סמנטי בקורפוס-הגילוי (יומוני "כל יום") — מצפן-מחקר (radar). מחזיר את היומון הרלוונטי + מראה-המקום של הפסק המקורי. ⚠️ היומון אינו מצוטט בהחלטה (INV-DIG1) — הצטט מהפסק המקורי דרך search_precedent_library. החוקר משתמש בזה בשלב 2ב.0 לפני האימות."""
|
||||
return await digest_tools.search_digests(
|
||||
query, practice_area, subject_tag, concept_tag, _clamp_limit(limit),
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def 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()
|
||||
async def halacha_review(
|
||||
halacha_id: str,
|
||||
@@ -895,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) ─────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ Output: data/cases/{case_number}/exports/ניתוח-משפטי-v{N}.docx
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -34,7 +35,7 @@ from docx.text.paragraph import Paragraph
|
||||
from docx.text.run import Run
|
||||
|
||||
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:
|
||||
@@ -494,10 +495,19 @@ async def build_analysis_docx(case_number: str) -> Path:
|
||||
continue
|
||||
_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.mkdir(parents=True, exist_ok=True)
|
||||
version = _next_version(export_dir)
|
||||
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
|
||||
|
||||
@@ -103,7 +103,7 @@ async def extract_facts_from_document(
|
||||
f"שמאי: {appraiser_name}{chunk_label}\n\n"
|
||||
f"--- תחילת שומה ---\n{chunk}\n--- סוף שומה ---"
|
||||
)
|
||||
result = await claude_session.query_json(prompt)
|
||||
result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
||||
if not isinstance(result, list):
|
||||
logger.warning(
|
||||
"extract_facts_from_document: chunk %d returned non-list (%s) for doc=%s",
|
||||
|
||||
@@ -147,7 +147,7 @@ async def _aggregate_party(
|
||||
prompt = _build_prompt(party, propositions)
|
||||
|
||||
try:
|
||||
raw_result = await claude_session.query_json(prompt)
|
||||
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
||||
except RuntimeError as e:
|
||||
# Surface CLI-unavailable specifically so the caller can report
|
||||
# cleanly instead of crashing the whole job.
|
||||
@@ -335,18 +335,30 @@ async def get_legal_arguments(
|
||||
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]
|
||||
supporting: dict[UUID, list[str]] = {}
|
||||
propositions: dict[UUID, list[dict]] = {}
|
||||
if arg_ids:
|
||||
joins = await conn.fetch(
|
||||
"""SELECT argument_id, claim_id
|
||||
FROM legal_argument_propositions
|
||||
WHERE argument_id = ANY($1::uuid[])""",
|
||||
"""SELECT lap.argument_id, lap.claim_id,
|
||||
c.claim_text, c.source_document, c.claim_index
|
||||
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,
|
||||
)
|
||||
for j in joins:
|
||||
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] = []
|
||||
for r in rows:
|
||||
@@ -354,5 +366,6 @@ async def get_legal_arguments(
|
||||
d["id"] = str(d["id"])
|
||||
d["case_id"] = str(d["case_id"])
|
||||
d["supporting_claims"] = supporting.get(r["id"], [])
|
||||
d["supporting_propositions"] = propositions.get(r["id"], [])
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
@@ -18,8 +18,10 @@ import re
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import db, embeddings, claude_session, audit
|
||||
from legal_mcp.services import db, embeddings, claude_session, audit, storage
|
||||
from legal_mcp.services.lessons import (
|
||||
OUTCOME_LABELS_HE,
|
||||
PRACTICE_AREA_OVERRIDES,
|
||||
@@ -410,7 +412,7 @@ async def write_block(
|
||||
# Call Claude via Claude Code session (no API)
|
||||
model_key = block_cfg["model"]
|
||||
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, tools="") # prose gen — no tool_use → no error_max_turns
|
||||
|
||||
sources = await _collect_block_sources(case_id, block_id)
|
||||
sources["case_law_ids"] = _precedent_case_law_ids
|
||||
@@ -1119,7 +1121,13 @@ async def _update_draft_file(decision_id: UUID) -> None:
|
||||
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.write_text("\n\n".join(row["content"] for row in rows if row["content"]), encoding="utf-8")
|
||||
draft_text = "\n\n".join(row["content"] for row in rows if row["content"])
|
||||
draft_path.write_text(draft_text, encoding="utf-8") # noqa: STG1 — sealed below
|
||||
try:
|
||||
_dkey = draft_path.resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix()
|
||||
await storage.mirror(_dkey, draft_text.encode("utf-8"), bucket=storage.Bucket.DOCUMENTS)
|
||||
except ValueError:
|
||||
pass
|
||||
logger.info("Draft file synced: %s (%d blocks)", draft_path, len(rows))
|
||||
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ async def generate_directions(
|
||||
{doc_context or '(אין מסמכים בתיק)'}
|
||||
"""
|
||||
|
||||
result = await claude_session.query_json(user_content)
|
||||
result = await claude_session.query_json(user_content, tools="") # no tool_use → no error_max_turns
|
||||
if result is None:
|
||||
logger.warning("Failed to parse brainstorm response")
|
||||
return {
|
||||
|
||||
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}
|
||||
@@ -135,7 +135,7 @@ async def _extract_chunk(
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(CHUNK_RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
claims = await claude_session.query_json(prompt)
|
||||
claims = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
logger.warning(
|
||||
|
||||
@@ -82,6 +82,7 @@ async def query(
|
||||
system: str | None = None,
|
||||
model: str | None = None,
|
||||
effort: str | None = None,
|
||||
tools: str | None = None,
|
||||
) -> str:
|
||||
"""Send a prompt to Claude Code headless and return the text response.
|
||||
|
||||
@@ -104,6 +105,12 @@ async def query(
|
||||
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:
|
||||
The text response from Claude.
|
||||
@@ -126,6 +133,8 @@ async def query(
|
||||
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"
|
||||
@@ -204,13 +213,15 @@ async def query_json(
|
||||
system: str | None = None,
|
||||
model: str | None = None,
|
||||
effort: str | None = None,
|
||||
tools: str | None = None,
|
||||
) -> dict | list | None:
|
||||
"""Send a prompt and parse the response as JSON.
|
||||
|
||||
Uses parse_llm_json for robust parsing (handles markdown wrapping, truncation).
|
||||
``model``/``effort`` are forwarded to :func:`query` (see its docstring).
|
||||
``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, model=model, effort=effort)
|
||||
raw = await query(prompt, timeout=timeout, system=system, model=model, effort=effort, tools=tools)
|
||||
return parse_llm_json(raw)
|
||||
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ async def classify_treatment(cited_citation: str, context: str) -> str:
|
||||
user, system=_TREATMENT_PROMPT,
|
||||
model=config.HALACHA_EXTRACT_MODEL or None,
|
||||
effort=config.HALACHA_EXTRACT_EFFORT or None,
|
||||
tools="", # pure text→JSON — no tool_use → no error_max_turns
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("classify_treatment failed: %s", e)
|
||||
|
||||
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
433
mcp-server/src/legal_mcp/services/digest_library.py
Normal file
433
mcp-server/src/legal_mcp/services/digest_library.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""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, storage
|
||||
|
||||
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, "מעתיק קובץ")
|
||||
# ``_stage_file`` returns the storage KEY (DATA_DIR-relative path). Resolve a
|
||||
# real local path to read from — on s3-only this downloads to a temp file we
|
||||
# own and remove after extraction (INV-STG1; the key is not guaranteed to be
|
||||
# on local disk).
|
||||
rel_path = await ingest._stage_file(src, DIGEST_LIBRARY_DIR, "incoming")
|
||||
local = await storage.ensure_local(rel_path, bucket=storage.Bucket.DOCUMENTS)
|
||||
local_is_tmp = storage.local_path(rel_path, bucket=storage.Bucket.DOCUMENTS) is None
|
||||
|
||||
await progress("extracting_text", 50, "מחלץ טקסט")
|
||||
try:
|
||||
raw_text, _pc, _off = await extractor.extract_text(str(local))
|
||||
finally:
|
||||
if local_is_tmp:
|
||||
try:
|
||||
local.unlink(missing_ok=True)
|
||||
except OSError as e: # noqa: BLE001 — temp cleanup, never fatal
|
||||
logger.debug("could not remove temp digest file %s: %s", local, e)
|
||||
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
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import date
|
||||
@@ -17,7 +18,7 @@ from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import db
|
||||
from legal_mcp.services import db, storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -474,8 +475,19 @@ async def export_decision(
|
||||
pass
|
||||
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)
|
||||
doc.save(output_path)
|
||||
Path(output_path).write_bytes(data) # noqa: STG1 — storage fallback (output_path outside DATA_DIR)
|
||||
logger.info("DOCX exported (mode=%s): %s", mode, output_path)
|
||||
return output_path
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import storage
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
@@ -304,10 +307,17 @@ def retrofit_bookmarks(
|
||||
end_idx = len(paragraphs) - 1
|
||||
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():
|
||||
backup_path = docx_path.with_suffix(".pre-retrofit.docx")
|
||||
shutil.copy2(str(docx_path), str(backup_path))
|
||||
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)) # noqa: STG1 — storage fallback
|
||||
|
||||
# Inject bookmarks, skipping any that already exist
|
||||
next_id = _next_bookmark_id(doc_tree)
|
||||
|
||||
@@ -13,6 +13,9 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import storage
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
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
|
||||
|
||||
|
||||
_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) # noqa: STG1 — storage fallback
|
||||
|
||||
|
||||
def _save_docx_xml(
|
||||
members: dict[str, bytes],
|
||||
document_tree: etree._Element,
|
||||
@@ -113,12 +132,11 @@ def _save_docx_xml(
|
||||
settings_tree, xml_declaration=True, encoding="UTF-8", standalone=True
|
||||
)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
buffer = BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, data in members.items():
|
||||
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:
|
||||
@@ -511,4 +529,11 @@ def copy_with_revisions(
|
||||
source_path: str | Path, output_path: str | Path,
|
||||
) -> None:
|
||||
"""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)) # noqa: STG1 — storage fallback
|
||||
|
||||
@@ -23,6 +23,7 @@ from docx import Document as DocxDocument
|
||||
from striprtf.striprtf import rtf_to_text
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import storage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from google.cloud import vision
|
||||
@@ -345,6 +346,18 @@ def render_pages_for_multimodal(
|
||||
max(1, int(img.height * ratio)),
|
||||
)
|
||||
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)
|
||||
|
||||
out.append((img, thumb_path))
|
||||
|
||||
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
|
||||
the ``halachot`` table.
|
||||
|
||||
All extraction is idempotent — calling ``extract(case_law_id)`` twice
|
||||
deletes prior rows for that precedent first.
|
||||
All extraction is idempotent — calling ``extract(case_law_id, force=True)``
|
||||
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:
|
||||
Per chair decision, NO halacha is auto-published. Every extracted
|
||||
@@ -62,6 +64,15 @@ EXTRACTION_FAILURE_THRESHOLD = 0.5
|
||||
# never contain holdings, only positions, so we skip them.
|
||||
EXTRACTABLE_SECTIONS = ("legal_analysis", "ruling", "conclusion")
|
||||
|
||||
# Sections confidently classified as NON-reasoning (parties' positions, the
|
||||
# factual background, the opening). The fallback path — taken when the chunker
|
||||
# labeled nothing as an extractable section (non-standard headings → 'other') —
|
||||
# excludes these so facts/arguments are NEVER fed into extraction, while
|
||||
# reasoning that merely landed under 'other' is still reached. Raises precision
|
||||
# on the dominant Facts↔Reasoning confusion class (#81.6; INV-LRN2
|
||||
# quality-at-source; LegalSeg / rhetorical-role labeling).
|
||||
NON_REASONING_SECTIONS = ("facts", "appellant_claims", "respondent_claims", "intro")
|
||||
|
||||
|
||||
# Two prompts — choose by source's is_binding flag.
|
||||
#
|
||||
@@ -76,8 +87,12 @@ EXTRACTABLE_SECTIONS = ("legal_analysis", "ruling", "conclusion")
|
||||
# wants to be able to cite "another committee reached the same conclusion"
|
||||
# even though it is not binding.
|
||||
#
|
||||
# The schema's rule_type field accepts six values:
|
||||
# binding | interpretive | procedural | obiter | application | persuasive
|
||||
# The prompt branches on is_binding only to choose the EXTRACTION STRATEGY
|
||||
# (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 לחוק התכנון והבניה). תפקידך: לחלץ הלכות מחייבות מתוך פסק דין/החלטה משפטית של ערכאה עליונה (עליון / מנהלי).
|
||||
|
||||
@@ -101,10 +116,12 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
|
||||
|
||||
הלכה אחת יכולה לחול על כמה תחומים — practice_areas הוא array ולא string יחיד.
|
||||
|
||||
## סוגי הלכה (rule_type)
|
||||
- binding — הלכה מחייבת שהוחלה על התיק.
|
||||
- interpretive — פרשנות סעיף חוק/תכנית שאומצה.
|
||||
- procedural — כלל פרוצדורלי (סמכות, מועדים, הליכי שמיעה).
|
||||
## סוג הכלל (rule_type) — מהות הכלל בלבד, לא סמכות-המקור
|
||||
**אל תסווג "מחייב/משכנע"** — דרגת-המחייבות נגזרת אוטומטית מזהות הערכאה. כאן בחר רק את **סוג הכלל**:
|
||||
- holding — עיקרון מהותי שהיה הכרחי להכרעה (ה-ratio; מבחן Wambaugh: שלילתו הייתה משנה את התוצאה).
|
||||
- interpretive — פרשנות הוראת-חוק/מונח/תכנית שאומצה.
|
||||
- procedural — כלל סדר-דין (סמכות, מועדים, זכות-עמידה, מיצוי הליכים, נטל).
|
||||
- application — החלת כלל על עובדות התיק (תלוי-עובדות; לרוב לא-הלכה בת-הכללה).
|
||||
- obiter — אמרת אגב חשובה (חלץ רק אם משמעותית; סמן confidence נמוך).
|
||||
|
||||
## פלט נדרש
|
||||
@@ -112,7 +129,7 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
|
||||
[
|
||||
{
|
||||
"rule_statement": "ניסוח הכלל בלשון משפטית מדויקת בגוף שלישי, 1-3 משפטים.",
|
||||
"rule_type": "binding",
|
||||
"rule_type": "holding",
|
||||
"reasoning_summary": "תמצית ההיגיון: למה בית המשפט הגיע לכלל הזה (1-2 משפטים).",
|
||||
"supporting_quote": "ציטוט מילולי מדויק מהפסק התומך בכלל. חייב להופיע מילה במילה בטקסט הקלט.",
|
||||
"page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות מהקלט.",
|
||||
@@ -139,11 +156,11 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
|
||||
|
||||
המקור הזה **אינו** מקור להלכות מחייבות חדשות (binding rules). הלכות מחייבות מגיעות מהעליון/מנהלי. עם זאת, יש כאן ערך משמעותי שצריך לחלץ — איך הפנל הזה ניתח ויישם את הדין הקיים. כשנכתוב החלטה עתידית, נצטט מהמקור הזה כ"גם ועדת הערר ב-X הגיעה למסקנה דומה" — לא כסמכות מחייבת, אלא כתמיכה משכנעת.
|
||||
|
||||
**יש לחלץ:**
|
||||
**יש לחלץ** (סווג לפי **סוג הכלל** בלבד — אל תסווג "מחייב/משכנע", דרגת-המחייבות נגזרת אוטומטית):
|
||||
- **יישום של הלכה ידועה** (rule_type=`application`) — הפנל החיל הלכה ידועה (של עליון/מנהלי) על עובדות הנידונות. תצטט את ניסוח הכלל **כפי שהוצג כאן** (לא בהכרח כפי שנקבע במקור) ואת התוצאה.
|
||||
- **עקרון פרשני שאומץ** (rule_type=`interpretive`) — איך הפנל פירש סעיף חוק / תכנית, באופן שניתן לאמץ.
|
||||
- **כלל פרוצדורלי** (rule_type=`procedural`) — קביעות בנושאי סמכות, מועדים, הליך.
|
||||
- **מסקנה מנומקת ומשכנעת** (rule_type=`persuasive`) — מסקנה שלמה של הפנל בסוגיה, עם ההיגיון התומך, ניתנת לציטוט כאסמכתא משכנעת.
|
||||
- **מסקנה מהותית מנומקת** (rule_type=`holding`) — מסקנה עקרונית שלמה של הפנל בסוגיה, עם ההיגיון התומך, בת-הכללה ובת-הסתמכות.
|
||||
|
||||
**אין לחלץ:**
|
||||
- ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים קונקרטיים) — חלץ את העיקרון/היישום בניסוח בר-הכללה בלבד.
|
||||
@@ -175,7 +192,7 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
|
||||
## כללי איכות
|
||||
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה.
|
||||
2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אל תמתח את הרשימה. אם אין מה לחלץ — החזר [].
|
||||
3. **rule_type מדויק** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. persuasive = מסקנה כללית בעלת ערך כאסמכתא.
|
||||
3. **rule_type מדויק (סוג הכלל בלבד)** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. holding = מסקנה מהותית עקרונית. **לא** binding/persuasive (סמכות נגזרת אוטומטית).
|
||||
4. **לא לפצל יתר על המידה — קריטי** — כל פריט = שאלה משפטית מובחנת אחת. פנים שונים של אותה שאלה = פריט אחד (בחר את הניסוח הכללי ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים.
|
||||
5. **שפה** — עברית משפטית מקצועית, גוף שלישי.
|
||||
6. **subject_tags** — 2-5 תגיות בעברית, snake_case.
|
||||
@@ -184,10 +201,15 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
|
||||
|
||||
|
||||
_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 = {
|
||||
"binding", "interpretive", "procedural", "obiter",
|
||||
"application", "persuasive",
|
||||
"holding", "interpretive", "procedural", "application", "obiter",
|
||||
}
|
||||
# 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:
|
||||
@@ -227,13 +249,14 @@ def _verify_quote(supporting_quote: str, full_text: str) -> bool:
|
||||
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.
|
||||
|
||||
Returns ``None`` if the entry is missing required fields. ``is_binding``
|
||||
only affects the default rule_type when the LLM returned an unknown
|
||||
value — for binding sources we default to ``binding``, otherwise to
|
||||
``persuasive`` (never pretend an appeals committee created halacha).
|
||||
Returns ``None`` if the entry is missing required fields. ``rule_type`` is
|
||||
the rule ROLE only (INV-DM7) — it is NEVER defaulted from the source's
|
||||
bindingness (that was the source-conflation this split removed). Legacy
|
||||
authority values fold to the nearest role; unknown defaults to
|
||||
``interpretive`` (the most common role).
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
@@ -242,13 +265,10 @@ def _coerce_halacha(raw: dict, is_binding: bool = True) -> dict | None:
|
||||
if not rule_statement or not supporting_quote:
|
||||
return None
|
||||
|
||||
default_rule_type = "binding" if is_binding else "persuasive"
|
||||
rule_type = (raw.get("rule_type") or default_rule_type).strip().lower()
|
||||
rule_type = (raw.get("rule_type") or "").strip().lower()
|
||||
rule_type = _LEGACY_RULE_TYPE_FOLD.get(rule_type, rule_type)
|
||||
if rule_type not in _VALID_RULE_TYPES:
|
||||
rule_type = default_rule_type
|
||||
# Guard: don't let a non-binding source produce 'binding' rule_type
|
||||
if not is_binding and rule_type == "binding":
|
||||
rule_type = "persuasive"
|
||||
rule_type = "interpretive"
|
||||
|
||||
practice_areas_raw = raw.get("practice_areas") or []
|
||||
if isinstance(practice_areas_raw, str):
|
||||
@@ -298,6 +318,7 @@ async def _nli_check(items: list[dict]) -> list[str]:
|
||||
system=halacha_quality.NLI_SYSTEM,
|
||||
model=config.HALACHA_NLI_MODEL or None,
|
||||
effort=config.HALACHA_NLI_EFFORT or None,
|
||||
tools="", # pure text→JSON — no tool_use → no error_max_turns
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("halacha NLI check failed (fail-open, no flags): %s", e)
|
||||
@@ -341,6 +362,7 @@ async def _consolidate_precedent(case_law_id: UUID) -> int:
|
||||
system=halacha_quality.CONSOLIDATE_SYSTEM,
|
||||
model=config.HALACHA_CONSOLIDATE_MODEL or None,
|
||||
effort=config.HALACHA_CONSOLIDATE_EFFORT or None,
|
||||
tools="", # pure text→JSON — no tool_use → no error_max_turns
|
||||
)
|
||||
groups = halacha_quality.parse_fold_groups(raw)
|
||||
if not groups:
|
||||
@@ -412,6 +434,7 @@ async def _extract_chunk(
|
||||
system=base_prompt,
|
||||
model=config.HALACHA_EXTRACT_MODEL or None,
|
||||
effort=(effort or config.HALACHA_EXTRACT_EFFORT) or None,
|
||||
tools="", # pure text→JSON — no tool_use → no error_max_turns
|
||||
)
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
@@ -484,6 +507,39 @@ async def extract(case_law_id: UUID | str, force: bool = False,
|
||||
await pool.release(lock_conn)
|
||||
|
||||
|
||||
async def _select_extractable_chunks(
|
||||
case_law_id: UUID,
|
||||
) -> tuple[list[dict], bool]:
|
||||
"""Pick the chunks that are candidates for halacha extraction (#81.6).
|
||||
|
||||
Rhetorical-role pre-filter (INV-LRN2 quality-at-source): only
|
||||
reasoning/decision sections feed extraction.
|
||||
|
||||
Primary: chunks labeled as an extractable section
|
||||
(``EXTRACTABLE_SECTIONS``). Fallback — taken only when the chunker labeled
|
||||
*nothing* extractable (non-standard headings collapse everything to
|
||||
'other') — is every chunk EXCEPT those confidently classified as
|
||||
non-reasoning (``NON_REASONING_SECTIONS``: facts / parties' arguments /
|
||||
intro). This preserves recall for reasoning that landed under 'other' while
|
||||
never feeding the factual background or the parties' positions into
|
||||
extraction. Previously the fallback took *all* chunks, re-admitting exactly
|
||||
the sections the primary filter excludes.
|
||||
|
||||
Returns ``(chunks, used_fallback)`` so the caller can log the fallback once.
|
||||
"""
|
||||
chunks = await db.list_precedent_chunks(
|
||||
case_law_id, section_types=EXTRACTABLE_SECTIONS,
|
||||
)
|
||||
if chunks:
|
||||
return chunks, False
|
||||
all_chunks = await db.list_precedent_chunks(case_law_id)
|
||||
filtered = [
|
||||
c for c in all_chunks
|
||||
if c.get("section_type") not in NON_REASONING_SECTIONS
|
||||
]
|
||||
return filtered, True
|
||||
|
||||
|
||||
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).
|
||||
@@ -500,20 +556,14 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
|
||||
|
||||
is_binding = bool(record.get("is_binding"))
|
||||
|
||||
# Try the targeted sections first (legal_analysis / ruling / conclusion).
|
||||
# If the chunker labeled everything as 'other' (common when a ruling
|
||||
# uses non-standard headings or the section markers aren't bracketed
|
||||
# cleanly), fall back to ALL chunks — better to over-include than to
|
||||
# silently skip a ruling that has reasoning under an unexpected label.
|
||||
chunks = await db.list_precedent_chunks(
|
||||
case_law_id, section_types=EXTRACTABLE_SECTIONS,
|
||||
)
|
||||
if not chunks:
|
||||
chunks = await db.list_precedent_chunks(case_law_id)
|
||||
if chunks:
|
||||
# Rhetorical-role pre-filter (#81.6, INV-LRN2): only reasoning/decision
|
||||
# sections are candidates. The fallback (no targeted section labeled)
|
||||
# still excludes facts/arguments/intro — see _select_extractable_chunks.
|
||||
chunks, used_fallback = await _select_extractable_chunks(case_law_id)
|
||||
if used_fallback and chunks:
|
||||
logger.info(
|
||||
"halacha_extractor: case_law=%s — no targeted sections, "
|
||||
"falling back to all %d chunks",
|
||||
"falling back to %d non-argument chunks (facts/arguments excluded)",
|
||||
case_law_id, len(chunks),
|
||||
)
|
||||
if not chunks:
|
||||
@@ -521,8 +571,20 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
|
||||
return {"status": "no_chunks", "extracted": 0, "stored": 0}
|
||||
|
||||
# force = clean slate; otherwise resume (skip already-checkpointed chunks).
|
||||
# "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:
|
||||
await db.reset_halacha_extraction(case_law_id)
|
||||
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
|
||||
|
||||
@@ -580,7 +642,7 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
|
||||
return
|
||||
cleaned: list[dict] = []
|
||||
for raw in items:
|
||||
coerced = _coerce_halacha(raw, is_binding=is_binding)
|
||||
coerced = _coerce_halacha(raw)
|
||||
if coerced is None:
|
||||
continue
|
||||
coerced["quote_verified"] = _verify_quote(
|
||||
@@ -597,10 +659,10 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
|
||||
coerced["quality_flags"] = flags
|
||||
if halacha_quality.FLAG_NON_DECISION in flags and coerced["rule_type"] != "obiter":
|
||||
coerced["rule_type"] = "obiter"
|
||||
# #81.4 — a binding-labeled rule that reads as a case-application is
|
||||
# #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"] == "binding"):
|
||||
and coerced["rule_type"] == "holding"):
|
||||
coerced["rule_type"] = "application"
|
||||
cleaned.append(coerced)
|
||||
# #81.3 NLI entailment — one batched judge call per chunk (fail-open).
|
||||
@@ -629,10 +691,10 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
|
||||
|
||||
await asyncio.gather(*[_process(c) for c in pending])
|
||||
|
||||
# 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)
|
||||
# Decide final status from what's LEFT (re-read checkpoints). Use the same
|
||||
# candidate-selection policy as above so the pending count matches the set
|
||||
# we actually extracted from (G2 — single source of truth, no parallel path).
|
||||
after, _ = await _select_extractable_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))
|
||||
|
||||
@@ -677,5 +739,6 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
|
||||
"folded": folded,
|
||||
"stored": stored,
|
||||
"stored_this_run": stored_total,
|
||||
"preserved_approved": preserved_approved,
|
||||
"total_chunks": len(chunks),
|
||||
}
|
||||
|
||||
@@ -18,6 +18,37 @@ 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 = "\"'׳״‘’“”«»„′″"
|
||||
@@ -213,6 +244,35 @@ def lexical_near_duplicate(
|
||||
or normalized_levenshtein(a, b) >= levenshtein_min)
|
||||
|
||||
|
||||
def dedup_action(
|
||||
dist: float, rule_new: str, rule_neighbor: str,
|
||||
dedup_distance: float, band_distance: float,
|
||||
) -> str:
|
||||
"""Decide a fresh halacha's fate vs its nearest same-precedent neighbor (#82.4).
|
||||
|
||||
PAIRWISE by construction — it compares the new rule to exactly ONE neighbor
|
||||
(the nearest already-stored one), never to a cluster, so dedup-on-insert can
|
||||
NEVER collapse a chain A~B~C into a single row even when A and C are
|
||||
distinct: each insert is an independent pairwise decision and only the
|
||||
*incoming* row is ever skipped (no existing row is merged or deleted). This
|
||||
is the over-merge guard (#82.6) — connected-components closure, the central
|
||||
over-merge risk in entity-resolution, is deliberately NOT performed here.
|
||||
|
||||
``dist`` is cosine distance (1 − cosine sim) to the neighbor. Returns:
|
||||
* 'skip' — semantic duplicate (dist ≤ dedup_distance): drop the incoming
|
||||
row; the caller unions its provenance (cites) into the surviving
|
||||
neighbor rather than blind-dropping it.
|
||||
* 'flag' — lexical tail (dedup_distance < dist ≤ band_distance AND high
|
||||
lexical overlap): keep the row but mark near_duplicate → chair review.
|
||||
* 'keep' — distinct enough: store normally.
|
||||
"""
|
||||
if dist <= dedup_distance:
|
||||
return "skip"
|
||||
if dist <= band_distance and lexical_near_duplicate(rule_new, rule_neighbor):
|
||||
return "flag"
|
||||
return "keep"
|
||||
|
||||
|
||||
# ── Aggregate ──
|
||||
|
||||
FLAG_NON_DECISION = "non_decision"
|
||||
@@ -337,7 +397,7 @@ def compute_quality_flags(
|
||||
supporting_quote: str,
|
||||
reasoning_summary: str = "",
|
||||
quote_verified: bool = True,
|
||||
rule_type: str = "binding",
|
||||
rule_type: str = "interpretive",
|
||||
) -> list[str]:
|
||||
"""Return the list of quality flags for one halacha (empty == clean).
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
@@ -23,7 +23,7 @@ from typing import Awaitable, Callable
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import chunker, db, embeddings, extractor
|
||||
from legal_mcp.services import chunker, db, embeddings, extractor, storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,12 +66,22 @@ def _safe_filename(name: str) -> str:
|
||||
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
|
||||
async def _stage_file(src_path: Path, root: Path, subdir: str) -> str:
|
||||
"""Stage an intake file through the unified storage layer (INV-STG1).
|
||||
|
||||
Returns the storage KEY (DATA_DIR-relative path) the blob was written under.
|
||||
The caller resolves a readable local path via ``storage.ensure_local`` — the
|
||||
key is NOT guaranteed to map to an existing on-disk file (under the s3-only
|
||||
backend the bytes live only in object storage). 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 key
|
||||
|
||||
|
||||
def _validate_enums(spec: IntakeSpec, inputs: dict) -> None:
|
||||
@@ -146,12 +156,24 @@ async def ingest_document(
|
||||
page_count = 0
|
||||
page_offsets = None
|
||||
staged: Path | None = None
|
||||
staged_is_tmp = False
|
||||
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))
|
||||
staged_key = await _stage_file(src, spec.staging_root, spec.staging_subdir(inputs))
|
||||
# Resolve a real local path to read from. Under filesystem/dual this is
|
||||
# the on-disk copy; under s3-only the blob lives only in object storage,
|
||||
# so ensure_local downloads it to a temp file we own and must clean up
|
||||
# (INV-STG1 — the pipeline must read through the storage layer, never
|
||||
# assume the key maps to an existing DATA_DIR file).
|
||||
staged_is_tmp = storage.local_path(
|
||||
staged_key, bucket=storage.Bucket.DOCUMENTS) is None
|
||||
staged = await storage.ensure_local(
|
||||
staged_key, bucket=storage.Bucket.DOCUMENTS)
|
||||
try:
|
||||
if staged is not None:
|
||||
await progress("extracting", 15, "מחלץ טקסט מהקובץ")
|
||||
try:
|
||||
raw_text, page_count, page_offsets = await extractor.extract_text(str(staged))
|
||||
@@ -228,6 +250,14 @@ async def ingest_document(
|
||||
await db.set_case_law_extraction_status(case_law_id, "failed")
|
||||
await progress("failed", 100, f"כשל בעיבוד: {e}")
|
||||
raise
|
||||
finally:
|
||||
# Drop the temp download (s3-only); on filesystem/dual ``staged`` is the
|
||||
# canonical on-disk copy and must NOT be removed.
|
||||
if staged_is_tmp and staged is not None:
|
||||
try:
|
||||
staged.unlink(missing_ok=True)
|
||||
except OSError as e: # noqa: BLE001 — temp cleanup, never fatal
|
||||
logger.debug("could not remove temp staged file %s: %s", staged, e)
|
||||
|
||||
|
||||
async def _chunk_embed_store(case_law_id, text, page_offsets, page_count, progress) -> int:
|
||||
|
||||
@@ -58,6 +58,7 @@ def _internal_validate(inputs: dict) -> None:
|
||||
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(
|
||||
case_number=inputs.get("case_number") or "",
|
||||
appeal_subtype=inputs.get("appeal_subtype") or "", subject=inputs.get("case_name") or "",
|
||||
)
|
||||
return {"district": district, "proceeding_type": proc}
|
||||
|
||||
@@ -89,7 +89,7 @@ async def analyze_changes(draft_text: str, final_text: str) -> dict:
|
||||
--- גרסה סופית ---
|
||||
{final_sample}
|
||||
"""
|
||||
result = await claude_session.query_json(prompt)
|
||||
result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
||||
if result is None:
|
||||
logger.warning("Failed to parse lessons response")
|
||||
return {"changes": [], "new_expressions": [], "overall_assessment": ""}
|
||||
@@ -149,7 +149,21 @@ async def process_final_version(
|
||||
# it (→ decision_lessons / appeal_type_rules, surfaced by T15) via the gate.
|
||||
# (Previously this auto-upserted every new_expression as a style_pattern —
|
||||
# that both bypassed the gate and contaminated style with substance. Removed.)
|
||||
if pair_id is not None:
|
||||
#
|
||||
# create-or-update (INV-LRN4): normally mark-final already opened a
|
||||
# 'final_received' pair, so we just advance it. For a case whose final
|
||||
# pre-dates the mark-final snapshot mechanism (historical backfill) or a direct
|
||||
# ingest_final_version call, no pair exists — open one now from the live blocks
|
||||
# so the distillation is actually persisted instead of silently discarded.
|
||||
# Caveat: the captured draft is the CURRENT blocks (possibly edited after
|
||||
# sign-off), not a true mark-final snapshot.
|
||||
if pair_id is None:
|
||||
pair_id = await db.create_draft_final_pair(case_id, draft_text, "")
|
||||
logger.info(
|
||||
"process_final_version: no 'final_received' pair for case %s — opened one "
|
||||
"from live blocks (backfill path; draft may post-date sign-off)",
|
||||
case_id,
|
||||
)
|
||||
await db.update_draft_final_pair(
|
||||
UUID(str(pair_id)),
|
||||
final_text=final_text,
|
||||
|
||||
@@ -103,12 +103,25 @@ async def get_case_metrics(case_id: UUID) -> dict:
|
||||
return metrics
|
||||
|
||||
|
||||
def _median(values: list[float]) -> float | None:
|
||||
"""Median of a numeric list (None if empty). Pure — unit-tested."""
|
||||
s = sorted(v for v in values if v is not None)
|
||||
if not s:
|
||||
return None
|
||||
mid = len(s) // 2
|
||||
return s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2
|
||||
|
||||
|
||||
async def halacha_backlog(conn) -> dict:
|
||||
"""תור אישור-ההלכות (GAP-14 / INV-QA1 / G10) — נראות ה-backlog האנושי.
|
||||
|
||||
הלכות נכנסות כ-`pending_review` ובלתי-נראות לחיפוש עד אישור היו"ר; בלי ספירה
|
||||
גלויה, אישור-חסר נשאר סמוי (10/19 התגלה במקרה). מקבל connection פתוח כדי
|
||||
שאפשר יהיה לשלב בסנאפ-שוט קיים (get_dashboard, /api/system/diagnostics).
|
||||
|
||||
כולל גם מדדי-תור (#84.7): throughput (24ש'/7ימים), יחסי approve/reject/defer,
|
||||
זמן-חציוני-לפריט (פער בין החלטות עוקבות בתוך session של 30 דק'), ופילוח
|
||||
מי-החליט (panel/auto/chair) — כדי לראות גם מהירות וגם איכות, לא רק backlog.
|
||||
"""
|
||||
rows = await conn.fetch(
|
||||
"SELECT review_status, COUNT(*) AS n FROM halachot GROUP BY review_status"
|
||||
@@ -132,6 +145,38 @@ async def halacha_backlog(conn) -> dict:
|
||||
)
|
||||
pending_total = counts.get("pending_review", 0)
|
||||
reviewed = counts.get("approved", 0) + counts.get("rejected", 0) + counts.get("published", 0)
|
||||
|
||||
# ── #84.7 queue throughput + quality ──────────────────────────────────────
|
||||
# throughput windows (decisions = anything with a reviewed_at stamp)
|
||||
tp = await conn.fetchrow(
|
||||
"SELECT COUNT(*) FILTER (WHERE reviewed_at >= now() - interval '24 hours') AS d24, "
|
||||
" COUNT(*) FILTER (WHERE reviewed_at >= now() - interval '7 days') AS d7 "
|
||||
"FROM halachot WHERE reviewed_at IS NOT NULL"
|
||||
)
|
||||
# who decided — panel (tri-model), auto (confidence gate), chair (human), other
|
||||
who_rows = await conn.fetch(
|
||||
"SELECT CASE "
|
||||
" WHEN reviewer LIKE 'panel:%' THEN 'panel' "
|
||||
" WHEN reviewer LIKE 'auto-approved%' THEN 'auto' "
|
||||
" WHEN reviewer LIKE 'chair%' THEN 'chair' "
|
||||
" ELSE 'other' END AS who, COUNT(*) AS n "
|
||||
"FROM halachot WHERE reviewed_at IS NOT NULL GROUP BY 1"
|
||||
)
|
||||
by_reviewer = {r["who"]: r["n"] for r in who_rows}
|
||||
# time-per-item proxy: median seconds between consecutive HAND-PACED
|
||||
# decisions — gaps in [1s, 30min]. Excludes 0-second gaps (batch operations
|
||||
# like panel/auto stamp many rows with the same reviewed_at) and >30-min gaps
|
||||
# (between sessions), so the number reflects interactive review pacing, not
|
||||
# machine throughput. None when the queue is entirely batch-decided.
|
||||
gap_rows = await conn.fetch(
|
||||
"SELECT EXTRACT(EPOCH FROM (reviewed_at - prev)) AS gap FROM ("
|
||||
" SELECT reviewed_at, LAG(reviewed_at) OVER (ORDER BY reviewed_at) AS prev "
|
||||
" FROM halachot WHERE reviewed_at IS NOT NULL"
|
||||
") t WHERE prev IS NOT NULL "
|
||||
"AND reviewed_at - prev BETWEEN interval '1 second' AND interval '30 minutes'"
|
||||
)
|
||||
median_secs = _median([float(r["gap"]) for r in gap_rows if r["gap"] is not None])
|
||||
|
||||
return {
|
||||
"pending_review": pending_total,
|
||||
"pending_clean": pending_clean, # real review candidates (#84.1)
|
||||
@@ -143,8 +188,16 @@ async def halacha_backlog(conn) -> dict:
|
||||
"total": sum(counts.values()),
|
||||
"reviewed_total": reviewed,
|
||||
"approve_ratio": round(counts.get("approved", 0) / reviewed, 3) if reviewed else None,
|
||||
"reject_ratio": round(counts.get("rejected", 0) / reviewed, 3) if reviewed else None,
|
||||
"defer_ratio": (round(counts.get("deferred", 0) / (reviewed + counts.get("deferred", 0)), 3)
|
||||
if (reviewed + counts.get("deferred", 0)) else None),
|
||||
"pending_by_flag": {r["flag"]: r["n"] for r in flag_rows},
|
||||
"oldest_pending_at": oldest.isoformat() if oldest else None,
|
||||
# #84.7 throughput + quality
|
||||
"throughput_24h": tp["d24"] if tp else 0,
|
||||
"throughput_7d": tp["d7"] if tp else 0,
|
||||
"median_seconds_per_decision": round(median_secs, 1) if median_secs is not None else None,
|
||||
"by_reviewer": by_reviewer,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -176,8 +176,12 @@ _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE = {
|
||||
|
||||
# Match the case number (last numeric group) in formats like:
|
||||
# ARAR-25-8126, ARAR-24-01-8007-33, 8126/25, 1170, ערר 1024-25
|
||||
_CASE_NUM = re.compile(r"(?:ARAR[-\s]*\d{2}[-\s]*(?:\d{2}[-\s]*)?)(\d{4})", re.IGNORECASE)
|
||||
_PLAIN_NUM = re.compile(r"(\d{4})")
|
||||
# Serial is 4 OR 5 digits: 4 = ערר (appeal), 5 = בל"מ (extension-of-time) per
|
||||
# the post-reform numbering convention (Jerusalem adopted 5-digit בל"מ; Tel Aviv
|
||||
# long predates it — e.g. 81002-01-21). The leading digit still encodes the
|
||||
# domain (1→רישוי, 8→היטל, 9→פיצויים) in BOTH widths — see is_blam_by_number().
|
||||
_CASE_NUM = re.compile(r"(?:ARAR[-\s]*\d{2}[-\s]*(?:\d{2}[-\s]*)?)(\d{4,5})", re.IGNORECASE)
|
||||
_PLAIN_NUM = re.compile(r"(\d{4,5})")
|
||||
|
||||
|
||||
_DOMAIN_TO_SUBTYPE: dict[str, str] = {
|
||||
@@ -216,6 +220,29 @@ def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA)
|
||||
return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(first_digit, "unknown")
|
||||
|
||||
|
||||
def case_serial_digits(case_number: str) -> int | None:
|
||||
"""Return the digit-count of the case serial, or None if unparseable.
|
||||
|
||||
The serial is the leading numeric group of the case number (the part
|
||||
before month/year): ``8126-03-25`` → 4, ``81002-01-21`` → 5.
|
||||
"""
|
||||
cn = case_number or ""
|
||||
m = _CASE_NUM.search(cn) or _PLAIN_NUM.search(cn)
|
||||
return len(m.group(1)) if m else None
|
||||
|
||||
|
||||
def is_blam_by_number(case_number: str) -> bool:
|
||||
"""True iff the case serial has 5 digits.
|
||||
|
||||
Post-reform numbering convention: a 4-digit serial is an ערר (appeal),
|
||||
a 5-digit serial is a בל"מ (בקשה להארכת מועד). This is the authoritative
|
||||
signal going forward; legacy 4-digit בל"מ cases are still detected from
|
||||
the subject via ``is_blam_subject``. The rule is **one-directional** — a
|
||||
5-digit serial implies בל"מ, but a 4-digit serial does NOT imply ערר.
|
||||
"""
|
||||
return case_serial_digits(case_number) == 5
|
||||
|
||||
|
||||
def derive_subtype_with_blam(
|
||||
case_number: str,
|
||||
subject: str = "",
|
||||
@@ -236,9 +263,11 @@ def derive_subtype_with_blam(
|
||||
'building_permit'
|
||||
"""
|
||||
base = derive_subtype(case_number, practice_area)
|
||||
if not is_blam_subject(subject):
|
||||
# בל"מ is signalled either by the subject text (legacy 4-digit cases) or by
|
||||
# a 5-digit serial (post-reform convention).
|
||||
if not (is_blam_subject(subject) or is_blam_by_number(case_number)):
|
||||
return base
|
||||
# subject says it's בל"מ — return the matching extension_request_* variant.
|
||||
# it's a בל"מ — return the matching extension_request_* variant.
|
||||
# For domain practice_area (axis B), use the direct mapping.
|
||||
if practice_area in DOMAIN_PRACTICE_AREAS:
|
||||
return _DOMAIN_TO_BLAM_SUBTYPE.get(practice_area, base)
|
||||
@@ -263,15 +292,21 @@ def is_blam_subtype(appeal_subtype: str) -> bool:
|
||||
return appeal_subtype in BLAM_SUBTYPES
|
||||
|
||||
|
||||
def derive_proceeding_type(*, appeal_subtype: str = "", subject: str = "") -> str:
|
||||
def derive_proceeding_type(
|
||||
*, case_number: str = "", appeal_subtype: str = "", subject: str = "",
|
||||
) -> str:
|
||||
"""Return 'בל"מ' / 'ערר' for appeals-committee decisions/cases.
|
||||
|
||||
Priority: explicit subtype prefix → subject regex → default 'ערר'.
|
||||
Priority: explicit subtype prefix → subject regex → 5-digit serial →
|
||||
default 'ערר'. The 5-digit signal is one-directional (a 4-digit serial
|
||||
does not force 'ערר' — a legacy 4-digit בל"מ is caught by the subject).
|
||||
"""
|
||||
if appeal_subtype and appeal_subtype.startswith("extension_request_"):
|
||||
return 'בל"מ'
|
||||
if subject and is_blam_subject(subject):
|
||||
return 'בל"מ'
|
||||
if case_number and is_blam_by_number(case_number):
|
||||
return 'בל"מ'
|
||||
return "ערר"
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable
|
||||
from uuid import UUID
|
||||
@@ -137,6 +138,10 @@ async def reextract_halachot(
|
||||
) -> dict:
|
||||
"""Re-run the halacha extractor on an existing precedent. Idempotent.
|
||||
|
||||
Chair-approved / published halachot are PRESERVED across the re-extract
|
||||
(INV-G10) — only un-reviewed rows are replaced. See
|
||||
``db.reset_halacha_extraction`` / TaskMaster #108.
|
||||
|
||||
**MCP-tool-only path.** This function calls into ``halacha_extractor``,
|
||||
which calls ``claude_session`` — the local CLI is required. Invoking
|
||||
this from the FastAPI container will raise ``Claude CLI not found``.
|
||||
@@ -156,9 +161,10 @@ async def reextract_halachot(
|
||||
# bad data. See note in db.request_metadata_extraction.
|
||||
|
||||
await progress("extracting_halachot", 50, "מחלץ הלכות מחדש")
|
||||
# Explicit re-extraction = clean slate (force): wipe prior halachot +
|
||||
# per-chunk checkpoints and redo all. (Queue draining / resume uses the
|
||||
# default force=False so an interrupted run continues where it stopped.)
|
||||
# Explicit re-extraction = clean slate (force): drop un-reviewed halachot +
|
||||
# clear per-chunk checkpoints and redo all, but PRESERVE chair-approved /
|
||||
# published rows (INV-G10; dedup-on-insert avoids duplicating them). (Queue
|
||||
# draining / resume uses force=False so an interrupted run continues.)
|
||||
result = await halacha_extractor.extract(case_law_id, force=True)
|
||||
# Clear the queue timestamp on completion so the UI badge / worker queue
|
||||
# don't keep showing this row. The queue worker (process_pending_extractions)
|
||||
@@ -179,6 +185,9 @@ async def reextract_halachot(
|
||||
# precedent into a 429 storm. Observed 2026-05-03: 1110/20 succeeded with 9
|
||||
# halachot, 317/10 immediately after returned silent no_halachot.
|
||||
INTER_PRECEDENT_COOLDOWN_SEC = 30
|
||||
# Metadata extraction is on Gemini (fast, high rate limits) — a brief spacer is
|
||||
# enough; the 30s above is for the Claude-backed halacha path.
|
||||
METADATA_COOLDOWN_SEC = float(os.environ.get("METADATA_COOLDOWN_SEC", "2"))
|
||||
|
||||
# How many times to retry a precedent that came back as 'extraction_failed'
|
||||
# (i.e. >50% chunks crashed). Each retry uses a longer cooldown.
|
||||
@@ -212,6 +221,16 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
|
||||
if kind not in {"metadata", "halacha"}:
|
||||
raise ValueError("kind must be 'metadata' or 'halacha'")
|
||||
|
||||
# Self-heal stale 'processing' rows (fully unattended): a drain that crashed
|
||||
# mid-extraction can leave a row status='processing' with its requested_at
|
||||
# cleared — orphaned, so it would never be re-picked. Re-stamp it so it
|
||||
# re-drains (the halacha extractor uses force=False → resumes from chunk
|
||||
# checkpoints, no duplicates). Safe under the global advisory lock (only one
|
||||
# drain runs at a time). Mirrors the digests-drain self-heal.
|
||||
healed = await db.requeue_stale_processing_extractions(kind=kind)
|
||||
if healed:
|
||||
logger.warning("self-healed %d stale '%s' processing row(s)", healed, kind)
|
||||
|
||||
pending = await db.list_pending_extraction_requests(kind=kind, limit=limit)
|
||||
if not pending:
|
||||
return {"status": "no_pending", "kind": kind, "processed": 0, "results": []}
|
||||
@@ -226,11 +245,14 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
|
||||
cid, effort=config.HALACHA_BULK_EXTRACT_EFFORT,
|
||||
)
|
||||
|
||||
# Metadata extraction runs on Gemini (high rate limits, fast) — the long
|
||||
# cooldown is only needed for halacha (Claude/Anthropic rate limits).
|
||||
cooldown = METADATA_COOLDOWN_SEC if kind == "metadata" else INTER_PRECEDENT_COOLDOWN_SEC
|
||||
results: list[dict] = []
|
||||
processed = 0
|
||||
for idx, row in enumerate(pending):
|
||||
if idx > 0:
|
||||
await asyncio.sleep(INTER_PRECEDENT_COOLDOWN_SEC)
|
||||
await asyncio.sleep(cooldown)
|
||||
cid = UUID(str(row["id"]))
|
||||
attempts = 0
|
||||
result: dict = {}
|
||||
|
||||
@@ -19,7 +19,7 @@ from datetime import date as date_type
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.config import parse_llm_json
|
||||
from legal_mcp.services import claude_session, db
|
||||
from legal_mcp.services import db, gemini_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -150,7 +150,10 @@ async def extract_metadata(case_law_id: UUID | str) -> dict:
|
||||
)
|
||||
|
||||
try:
|
||||
result = await claude_session.query_json(
|
||||
# Bounded structured extraction → Gemini Flash (JSON mode). The agentic
|
||||
# claude CLI hit error_max_turns on this single-shot task; see
|
||||
# gemini_session.py. Voice-sensitive/agentic work stays on claude_session.
|
||||
result = await gemini_session.query_json(
|
||||
user_msg, system=METADATA_EXTRACTION_PROMPT,
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -8,7 +8,9 @@ from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import chunker, db, embeddings, extractor, references_extractor
|
||||
from legal_mcp.services import (
|
||||
chunker, db, embeddings, extractor, references_extractor, storage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,13 +42,17 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
|
||||
page_count=page_count,
|
||||
)
|
||||
|
||||
# Save extracted text to documents/extracted/ directory
|
||||
# Save extracted text (a DERIVED artifact — the DB column holds the
|
||||
# source of truth, INV-STG5) through the storage layer (INV-STG1).
|
||||
# Non-fatal: the .txt is a convenience copy, the pipeline reads the DB.
|
||||
original_path = Path(doc["file_path"])
|
||||
extracted_dir = original_path.parent.parent / "extracted"
|
||||
extracted_dir.mkdir(parents=True, exist_ok=True)
|
||||
txt_path = extracted_dir / (original_path.stem + ".txt")
|
||||
txt_path = original_path.parent.parent / "extracted" / (original_path.stem + ".txt")
|
||||
try:
|
||||
txt_path.write_text(text, encoding="utf-8")
|
||||
await storage.put_bytes(
|
||||
txt_path.relative_to(config.DATA_DIR).as_posix(),
|
||||
text.encode("utf-8"), bucket=storage.Bucket.DERIVED,
|
||||
content_type="text/plain; charset=utf-8",
|
||||
)
|
||||
logger.info("Saved extracted text to %s", txt_path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save text file (non-fatal): %s", e)
|
||||
|
||||
@@ -268,12 +268,13 @@ async def proofread(path: Path) -> tuple[str, dict]:
|
||||
|
||||
# ── Metadata extraction ──────────────────────────────────────────
|
||||
|
||||
# Serial is 3–5 digits: 4 = ערר, 5 = בל"מ (post-reform). 3 tolerates legacy short serials.
|
||||
FILENAME_NUMBER_PATTERNS = [
|
||||
re.compile(r"^ARAR-(\d{2})-(\d{3,4})"),
|
||||
re.compile(r"^ערר\s+(\d{3,4})-(\d{2})"),
|
||||
re.compile(r"^ערר\s+(\d{3,4})\s*-"),
|
||||
re.compile(r"^ARAR-(\d{2})-(\d{3,5})"),
|
||||
re.compile(r"^ערר\s+(\d{3,5})-(\d{2})"),
|
||||
re.compile(r"^ערר\s+(\d{3,5})\s*-"),
|
||||
]
|
||||
LEGACY_MULTI_PATTERN = re.compile(r"(\d{3,4})\+(\d{3,4})")
|
||||
LEGACY_MULTI_PATTERN = re.compile(r"(\d{3,5})\+(\d{3,5})")
|
||||
|
||||
|
||||
def decision_number_from_filename(stem: str) -> str | None:
|
||||
|
||||
@@ -154,7 +154,7 @@ async def check_claims_coverage(blocks: list[dict], claims: list[dict], outcome:
|
||||
## בלוק הדיון:
|
||||
{discussion}"""
|
||||
|
||||
parsed = await claude_session.query_json(prompt)
|
||||
parsed = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
||||
if parsed is None:
|
||||
logger.warning("Failed to parse claims check")
|
||||
# Fallback: assume all covered (don't block export on parse failure)
|
||||
|
||||
@@ -346,7 +346,7 @@ def update_chair_position(
|
||||
|
||||
# Atomic write
|
||||
tmp_path = file_path.with_suffix(file_path.suffix + ".tmp")
|
||||
tmp_path.write_text(new_content, encoding="utf-8")
|
||||
tmp_path.write_text(new_content, encoding="utf-8") # noqa: STG1 — atomic .tmp; in-place edit, S3 re-sync in Phase-2 read-wiring
|
||||
os.replace(tmp_path, file_path)
|
||||
|
||||
preview = new_text.strip()[:120]
|
||||
|
||||
578
mcp-server/src/legal_mcp/services/storage.py
Normal file
578
mcp-server/src/legal_mcp/services/storage.py
Normal file
@@ -0,0 +1,578 @@
|
||||
"""Unified object-storage layer (X14, INV-STG1).
|
||||
|
||||
THE single choke-point for all binary file I/O — originals, derived
|
||||
artifacts (thumbnails / extracted text), and exports. It replaces the
|
||||
scattered ``open()`` / ``shutil.copy`` / ``Path.write_bytes`` calls spread
|
||||
across ~8 services (G2: one storage path, no parallel routes). See
|
||||
docs/spec/X14-storage-minio.md.
|
||||
|
||||
Keys
|
||||
----
|
||||
A *key* is a DATA_DIR-relative POSIX path, e.g.::
|
||||
|
||||
cases/8174-24/documents/originals/<uuid>.pdf
|
||||
precedent-library/thumbnails/<case_law_id>/p001.jpg
|
||||
|
||||
The filesystem backend maps ``key -> DATA_DIR / key``, preserving the exact
|
||||
current on-disk layout (zero behaviour change when ``STORAGE_BACKEND`` is the
|
||||
default ``filesystem``). The S3 backend maps a logical *bucket*
|
||||
(documents/immutable/derived) to a MinIO bucket and uses the key verbatim as
|
||||
the object key.
|
||||
|
||||
INV-STG2: keys are atomic ASCII/UUID paths; a Hebrew original filename is
|
||||
carried as object metadata / a DB column, never as the key itself.
|
||||
INV-STG5: pgvector stays the source of truth for text + embeddings — this
|
||||
layer stores blobs only. INV-STG6: presigned URLs (minted against the public
|
||||
endpoint) serve bytes straight to the browser.
|
||||
|
||||
Backends (config.STORAGE_BACKEND)
|
||||
---------------------------------
|
||||
- ``filesystem`` (default) — disk only; current behaviour.
|
||||
- ``dual`` — write disk + S3; read S3, fall back to disk.
|
||||
The migration window; disk stays authoritative.
|
||||
- ``s3`` — MinIO only.
|
||||
|
||||
``aioboto3`` is imported lazily so this module loads even where the dependency
|
||||
is absent (the default filesystem backend needs nothing extra).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
from enum import Enum
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Iterable
|
||||
|
||||
from legal_mcp import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Bucket(str, Enum):
|
||||
"""Logical governance buckets (INV-STG3). Resolved to MinIO bucket names
|
||||
via config; ignored by the filesystem backend (which keeps one tree)."""
|
||||
|
||||
DOCUMENTS = "documents"
|
||||
IMMUTABLE = "immutable"
|
||||
DERIVED = "derived"
|
||||
|
||||
|
||||
def _bucket_name(bucket: Bucket) -> str:
|
||||
return {
|
||||
Bucket.DOCUMENTS: config.MINIO_BUCKET_DOCUMENTS,
|
||||
Bucket.IMMUTABLE: config.MINIO_BUCKET_IMMUTABLE,
|
||||
Bucket.DERIVED: config.MINIO_BUCKET_DERIVED,
|
||||
}[bucket]
|
||||
|
||||
|
||||
def normalize_key(key: str | Path) -> str:
|
||||
"""Return a clean DATA_DIR-relative POSIX key.
|
||||
|
||||
Rejects absolute paths and ``..`` traversal (defence in depth — keys are
|
||||
built internally, never from raw user input). An absolute path under
|
||||
DATA_DIR is accepted and re-relativised so call-sites can pass either a
|
||||
key or a full ``Path`` during the migration.
|
||||
"""
|
||||
p = Path(key)
|
||||
if p.is_absolute():
|
||||
try:
|
||||
p = p.relative_to(config.DATA_DIR)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"absolute path outside DATA_DIR: {key!r}") from exc
|
||||
posix = PurePosixPath(p.as_posix())
|
||||
parts = posix.parts
|
||||
if not parts or any(part == ".." for part in parts):
|
||||
raise ValueError(f"invalid storage key: {key!r}")
|
||||
return posix.as_posix().lstrip("/")
|
||||
|
||||
|
||||
def _ascii_metadata(value) -> str:
|
||||
"""Coerce an S3 user-metadata value to ASCII.
|
||||
|
||||
S3/MinIO object metadata must be ASCII (botocore raises ParamValidationError
|
||||
otherwise). The only non-ASCII value we attach is the original Hebrew
|
||||
filename (``ingest._stage_file`` → ``metadata={"filename": ...}``), so a
|
||||
Hebrew name like ``"יומון 5167 - 11.6.26.pdf"`` would 500 every s3-only
|
||||
upload. Percent-encode non-ASCII losslessly (recover with
|
||||
``urllib.parse.unquote``) while leaving plain-ASCII values readable."""
|
||||
s = str(value)
|
||||
if s.isascii():
|
||||
return s
|
||||
from urllib.parse import quote
|
||||
return quote(s)
|
||||
|
||||
|
||||
class StorageBackend:
|
||||
"""Abstract backend. All methods are async except the cheap path helpers."""
|
||||
|
||||
name = "abstract"
|
||||
|
||||
async def put_bytes(self, key, data, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def put_file(self, src, key, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> str:
|
||||
with open(src, "rb") as fh:
|
||||
return await self.put_bytes(
|
||||
key, fh.read(), bucket=bucket,
|
||||
content_type=content_type, metadata=metadata,
|
||||
)
|
||||
|
||||
async def get_bytes(self, key, *, bucket=Bucket.DOCUMENTS) -> bytes:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exists(self, key, *, bucket=Bucket.DOCUMENTS) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
async def delete(self, key, *, bucket=Bucket.DOCUMENTS) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_keys(self, prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def presign_get(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
download_name=None) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def presign_put(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
content_type=None) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def local_path(self, key, *, bucket=Bucket.DOCUMENTS) -> Path | None:
|
||||
"""Return a real filesystem path if one exists *without* downloading,
|
||||
else ``None``. Use :meth:`ensure_local` when a path is required."""
|
||||
return None
|
||||
|
||||
async def ensure_local(self, key, *, bucket=Bucket.DOCUMENTS) -> Path:
|
||||
"""Return a local path to the object, downloading to a temp file if the
|
||||
backend has no on-disk copy. Caller owns cleanup of temp files."""
|
||||
path = self.local_path(key, bucket=bucket)
|
||||
if path is not None:
|
||||
return path
|
||||
data = await self.get_bytes(key, bucket=bucket)
|
||||
suffix = PurePosixPath(normalize_key(key)).suffix
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
||||
tmp.write(data)
|
||||
tmp.close()
|
||||
return Path(tmp.name)
|
||||
|
||||
|
||||
class FilesystemBackend(StorageBackend):
|
||||
"""Disk under DATA_DIR. ``bucket`` is ignored — the existing single tree is
|
||||
preserved verbatim, so the default backend is byte-for-byte the legacy
|
||||
behaviour."""
|
||||
|
||||
name = "filesystem"
|
||||
|
||||
def _abs(self, key, *, bucket=Bucket.DOCUMENTS) -> Path:
|
||||
rel = normalize_key(key)
|
||||
path = (Path(config.DATA_DIR) / rel).resolve()
|
||||
root = Path(config.DATA_DIR).resolve()
|
||||
if root not in path.parents and path != root:
|
||||
raise ValueError(f"resolved path escapes DATA_DIR: {key!r}")
|
||||
return path
|
||||
|
||||
async def put_bytes(self, key, data, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> str:
|
||||
path = self._abs(key, bucket=bucket)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return f"file://{path}"
|
||||
|
||||
async def put_file(self, src, key, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> str:
|
||||
path = self._abs(key, bucket=bucket)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, path) # preserve mtime, as the legacy code did
|
||||
return f"file://{path}"
|
||||
|
||||
async def get_bytes(self, key, *, bucket=Bucket.DOCUMENTS) -> bytes:
|
||||
return self._abs(key, bucket=bucket).read_bytes()
|
||||
|
||||
async def exists(self, key, *, bucket=Bucket.DOCUMENTS) -> bool:
|
||||
return self._abs(key, bucket=bucket).exists()
|
||||
|
||||
async def delete(self, key, *, bucket=Bucket.DOCUMENTS) -> None:
|
||||
self._abs(key, bucket=bucket).unlink(missing_ok=True)
|
||||
|
||||
async def list_keys(self, prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
|
||||
root = Path(config.DATA_DIR).resolve()
|
||||
base = self._abs(prefix, bucket=bucket) if prefix else root
|
||||
if not base.exists():
|
||||
return []
|
||||
out: list[str] = []
|
||||
for p in sorted(base.rglob("*")):
|
||||
if p.is_file():
|
||||
out.append(p.resolve().relative_to(root).as_posix())
|
||||
return out
|
||||
|
||||
def local_path(self, key, *, bucket=Bucket.DOCUMENTS) -> Path | None:
|
||||
path = self._abs(key, bucket=bucket)
|
||||
return path if path.exists() else None
|
||||
|
||||
async def presign_get(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
download_name=None) -> str:
|
||||
raise NotImplementedError(
|
||||
"presigned URLs require the S3 backend (set STORAGE_BACKEND=dual|s3)"
|
||||
)
|
||||
|
||||
async def presign_put(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
content_type=None) -> str:
|
||||
raise NotImplementedError(
|
||||
"presigned URLs require the S3 backend (set STORAGE_BACKEND=dual|s3)"
|
||||
)
|
||||
|
||||
|
||||
class S3Backend(StorageBackend):
|
||||
"""MinIO via aioboto3. Server-side ops use the internal endpoint; presigned
|
||||
URLs are minted against the public endpoint so the browser can reach them
|
||||
(INV-STG6)."""
|
||||
|
||||
name = "s3"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session = None # lazy aioboto3 session
|
||||
|
||||
def _boto(self):
|
||||
import aioboto3 # lazy — absent in the default filesystem path
|
||||
from botocore.config import Config as BotoConfig
|
||||
if self._session is None:
|
||||
self._session = aioboto3.Session()
|
||||
cfg = BotoConfig(signature_version="s3v4", s3={"addressing_style": "path"})
|
||||
return aioboto3, BotoConfig, cfg
|
||||
|
||||
def _client(self, *, public: bool = False):
|
||||
_aioboto3, _BotoConfig, cfg = self._boto()
|
||||
endpoint = config.MINIO_PUBLIC_ENDPOINT if public else config.MINIO_ENDPOINT
|
||||
return self._session.client(
|
||||
"s3",
|
||||
endpoint_url=endpoint,
|
||||
aws_access_key_id=config.MINIO_ACCESS_KEY,
|
||||
aws_secret_access_key=config.MINIO_SECRET_KEY,
|
||||
region_name=config.MINIO_REGION,
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
async def put_bytes(self, key, data, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> str:
|
||||
k = normalize_key(key)
|
||||
extra = {}
|
||||
if content_type:
|
||||
extra["ContentType"] = content_type
|
||||
if metadata:
|
||||
extra["Metadata"] = {kk: _ascii_metadata(vv) for kk, vv in metadata.items()}
|
||||
async with self._client() as s3:
|
||||
await s3.put_object(Bucket=_bucket_name(bucket), Key=k, Body=data, **extra)
|
||||
return f"s3://{_bucket_name(bucket)}/{k}"
|
||||
|
||||
async def get_bytes(self, key, *, bucket=Bucket.DOCUMENTS) -> bytes:
|
||||
k = normalize_key(key)
|
||||
async with self._client() as s3:
|
||||
resp = await s3.get_object(Bucket=_bucket_name(bucket), Key=k)
|
||||
async with resp["Body"] as stream:
|
||||
return await stream.read()
|
||||
|
||||
async def exists(self, key, *, bucket=Bucket.DOCUMENTS) -> bool:
|
||||
from botocore.exceptions import ClientError
|
||||
k = normalize_key(key)
|
||||
async with self._client() as s3:
|
||||
try:
|
||||
await s3.head_object(Bucket=_bucket_name(bucket), Key=k)
|
||||
return True
|
||||
except ClientError as exc:
|
||||
if exc.response["Error"]["Code"] in ("404", "NoSuchKey", "NotFound"):
|
||||
return False
|
||||
raise
|
||||
|
||||
async def delete(self, key, *, bucket=Bucket.DOCUMENTS) -> None:
|
||||
k = normalize_key(key)
|
||||
async with self._client() as s3:
|
||||
await s3.delete_object(Bucket=_bucket_name(bucket), Key=k)
|
||||
|
||||
async def list_keys(self, prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
|
||||
pfx = normalize_key(prefix) if prefix else ""
|
||||
out: list[str] = []
|
||||
async with self._client() as s3:
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
async for page in paginator.paginate(Bucket=_bucket_name(bucket), Prefix=pfx):
|
||||
for obj in page.get("Contents", []):
|
||||
out.append(obj["Key"])
|
||||
return out
|
||||
|
||||
async def presign_get(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
download_name=None) -> str:
|
||||
k = normalize_key(key)
|
||||
params = {"Bucket": _bucket_name(bucket), "Key": k}
|
||||
if download_name:
|
||||
# RFC 5987 — keep the Hebrew original filename on download (INV-STG2)
|
||||
from urllib.parse import quote
|
||||
params["ResponseContentDisposition"] = (
|
||||
f"attachment; filename*=UTF-8''{quote(download_name)}"
|
||||
)
|
||||
async with self._client(public=True) as s3:
|
||||
return await s3.generate_presigned_url(
|
||||
"get_object", Params=params,
|
||||
ExpiresIn=ttl or config.MINIO_PRESIGN_TTL,
|
||||
)
|
||||
|
||||
async def presign_put(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
content_type=None) -> str:
|
||||
k = normalize_key(key)
|
||||
params = {"Bucket": _bucket_name(bucket), "Key": k}
|
||||
if content_type:
|
||||
params["ContentType"] = content_type
|
||||
async with self._client(public=True) as s3:
|
||||
return await s3.generate_presigned_url(
|
||||
"put_object", Params=params,
|
||||
ExpiresIn=ttl or config.MINIO_PRESIGN_TTL,
|
||||
)
|
||||
|
||||
|
||||
class DualBackend(StorageBackend):
|
||||
"""Migration window: writes go to BOTH disk and S3 (disk authoritative);
|
||||
reads prefer S3 and fall back to disk. An S3 write failure is logged (never
|
||||
swallowed — engineering §6) but does not break the app while disk holds the
|
||||
canonical copy."""
|
||||
|
||||
name = "dual"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.fs = FilesystemBackend()
|
||||
self.s3 = S3Backend()
|
||||
|
||||
async def put_bytes(self, key, data, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> str:
|
||||
uri = await self.fs.put_bytes(key, data, bucket=bucket,
|
||||
content_type=content_type, metadata=metadata)
|
||||
try:
|
||||
await self.s3.put_bytes(key, data, bucket=bucket,
|
||||
content_type=content_type, metadata=metadata)
|
||||
except Exception as exc: # noqa: BLE001 — log, don't swallow
|
||||
logger.warning("dual put_bytes: S3 mirror failed for %s: %s", key, exc)
|
||||
return uri
|
||||
|
||||
async def put_file(self, src, key, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> str:
|
||||
uri = await self.fs.put_file(src, key, bucket=bucket,
|
||||
content_type=content_type, metadata=metadata)
|
||||
try:
|
||||
await self.s3.put_file(src, key, bucket=bucket,
|
||||
content_type=content_type, metadata=metadata)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("dual put_file: S3 mirror failed for %s: %s", key, exc)
|
||||
return uri
|
||||
|
||||
async def get_bytes(self, key, *, bucket=Bucket.DOCUMENTS) -> bytes:
|
||||
try:
|
||||
return await self.s3.get_bytes(key, bucket=bucket)
|
||||
except Exception as exc: # noqa: BLE001 — fall back to disk
|
||||
logger.debug("dual get_bytes: S3 miss for %s (%s); using disk", key, exc)
|
||||
return await self.fs.get_bytes(key, bucket=bucket)
|
||||
|
||||
async def exists(self, key, *, bucket=Bucket.DOCUMENTS) -> bool:
|
||||
if await self.fs.exists(key, bucket=bucket):
|
||||
return True
|
||||
try:
|
||||
return await self.s3.exists(key, bucket=bucket)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
async def delete(self, key, *, bucket=Bucket.DOCUMENTS) -> None:
|
||||
await self.fs.delete(key, bucket=bucket)
|
||||
try:
|
||||
await self.s3.delete(key, bucket=bucket)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("dual delete: S3 delete failed for %s: %s", key, exc)
|
||||
|
||||
async def list_keys(self, prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
|
||||
return await self.fs.list_keys(prefix, bucket=bucket)
|
||||
|
||||
def local_path(self, key, *, bucket=Bucket.DOCUMENTS) -> Path | None:
|
||||
return self.fs.local_path(key, bucket=bucket)
|
||||
|
||||
async def presign_get(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
download_name=None) -> str:
|
||||
return await self.s3.presign_get(key, bucket=bucket, ttl=ttl,
|
||||
download_name=download_name)
|
||||
|
||||
async def presign_put(self, key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
content_type=None) -> str:
|
||||
return await self.s3.presign_put(key, bucket=bucket, ttl=ttl,
|
||||
content_type=content_type)
|
||||
|
||||
|
||||
_BACKENDS = {
|
||||
"filesystem": FilesystemBackend,
|
||||
"dual": DualBackend,
|
||||
"s3": S3Backend,
|
||||
}
|
||||
|
||||
_singleton: StorageBackend | None = None
|
||||
|
||||
|
||||
def get_storage() -> StorageBackend:
|
||||
"""Return the process-wide storage backend selected by config.STORAGE_BACKEND
|
||||
(cached). Unknown values fall back to ``filesystem`` with a warning rather
|
||||
than crashing the app."""
|
||||
global _singleton
|
||||
if _singleton is None:
|
||||
cls = _BACKENDS.get(config.STORAGE_BACKEND)
|
||||
if cls is None:
|
||||
logger.warning(
|
||||
"unknown STORAGE_BACKEND=%r — falling back to filesystem",
|
||||
config.STORAGE_BACKEND,
|
||||
)
|
||||
cls = FilesystemBackend
|
||||
_singleton = cls()
|
||||
logger.info("storage backend = %s", _singleton.name)
|
||||
return _singleton
|
||||
|
||||
|
||||
def reset_storage_cache() -> None:
|
||||
"""Drop the cached backend (tests / after an env change)."""
|
||||
global _singleton
|
||||
_singleton = None
|
||||
|
||||
|
||||
# ── module-level convenience wrappers ──────────────────────────────
|
||||
# Thin pass-throughs so call-sites can ``from legal_mcp.services import storage``
|
||||
# and use ``await storage.put_bytes(...)`` without fetching the singleton.
|
||||
|
||||
async def put_bytes(key, data, *, bucket=Bucket.DOCUMENTS, content_type=None,
|
||||
metadata=None) -> str:
|
||||
return await get_storage().put_bytes(
|
||||
key, data, bucket=bucket, content_type=content_type, metadata=metadata)
|
||||
|
||||
|
||||
async def put_file(src, key, *, bucket=Bucket.DOCUMENTS, content_type=None,
|
||||
metadata=None) -> str:
|
||||
return await get_storage().put_file(
|
||||
src, key, bucket=bucket, content_type=content_type, metadata=metadata)
|
||||
|
||||
|
||||
async def get_bytes(key, *, bucket=Bucket.DOCUMENTS) -> bytes:
|
||||
return await get_storage().get_bytes(key, bucket=bucket)
|
||||
|
||||
|
||||
async def exists(key, *, bucket=Bucket.DOCUMENTS) -> bool:
|
||||
return await get_storage().exists(key, bucket=bucket)
|
||||
|
||||
|
||||
async def delete(key, *, bucket=Bucket.DOCUMENTS) -> None:
|
||||
return await get_storage().delete(key, bucket=bucket)
|
||||
|
||||
|
||||
async def list_keys(prefix, *, bucket=Bucket.DOCUMENTS) -> list[str]:
|
||||
return await get_storage().list_keys(prefix, bucket=bucket)
|
||||
|
||||
|
||||
async def presign_get(key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
download_name=None) -> str:
|
||||
return await get_storage().presign_get(
|
||||
key, bucket=bucket, ttl=ttl, download_name=download_name)
|
||||
|
||||
|
||||
async def presign_put(key, *, bucket=Bucket.DOCUMENTS, ttl=None,
|
||||
content_type=None) -> str:
|
||||
return await get_storage().presign_put(
|
||||
key, bucket=bucket, ttl=ttl, content_type=content_type)
|
||||
|
||||
|
||||
def local_path(key, *, bucket=Bucket.DOCUMENTS) -> Path | None:
|
||||
return get_storage().local_path(key, bucket=bucket)
|
||||
|
||||
|
||||
async def ensure_local(key, *, bucket=Bucket.DOCUMENTS) -> Path:
|
||||
return await get_storage().ensure_local(key, bucket=bucket)
|
||||
|
||||
|
||||
# ── mirror: dual-write seal for the not-yet-read-wired pipeline (INV-STG1) ──────
|
||||
# A handful of upload/finalize paths still keep a copy on disk because the
|
||||
# ingest/extract pipeline reads files by their DATA_DIR path (not yet wired to
|
||||
# ensure_local). For those, ``mirror``/``mirror_file`` ALSO persist the blob to
|
||||
# object storage when the active backend is s3/dual — so no blob is ever missing
|
||||
# from MinIO (durability + presigned serving) even though a disk copy lingers
|
||||
# for the pipeline. No-op under the filesystem backend (the disk write is the
|
||||
# canonical copy). Best-effort: an S3 failure is logged, never breaks the
|
||||
# request (the disk copy holds). The full fix (read-wire the pipeline → drop the
|
||||
# disk copy) is tracked separately; until then this closes the data-loss leak.
|
||||
|
||||
async def mirror(key, data, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> None:
|
||||
backend = get_storage()
|
||||
if backend.name == "filesystem":
|
||||
return
|
||||
s3 = getattr(backend, "s3", backend)
|
||||
try:
|
||||
await s3.put_bytes(key, data, bucket=bucket,
|
||||
content_type=content_type, metadata=metadata)
|
||||
except Exception as exc: # noqa: BLE001 — log, never break the request
|
||||
logger.warning("storage.mirror: S3 persist failed for %s: %s", key, exc)
|
||||
|
||||
|
||||
async def mirror_file(src, key, *, bucket=Bucket.DOCUMENTS,
|
||||
content_type=None, metadata=None) -> None:
|
||||
backend = get_storage()
|
||||
if backend.name == "filesystem":
|
||||
return
|
||||
s3 = getattr(backend, "s3", backend)
|
||||
try:
|
||||
await s3.put_file(src, key, bucket=bucket,
|
||||
content_type=content_type, metadata=metadata)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("storage.mirror_file: S3 persist failed for %s: %s", key, exc)
|
||||
|
||||
|
||||
# ── synchronous facade ─────────────────────────────────────────────
|
||||
# A few legacy writers are plain sync functions (track-changes save, retrofit
|
||||
# backup, the multimodal thumbnail renderer which runs in a worker thread via
|
||||
# asyncio.to_thread). They go through the same layer via this blocking shim so
|
||||
# INV-STG1 holds everywhere.
|
||||
|
||||
def _run_coro_blocking(coro):
|
||||
"""Run a storage coroutine to completion from synchronous code.
|
||||
|
||||
No running loop in this thread (the common case — sync helpers, or a
|
||||
to_thread worker) → asyncio.run. If a loop *is* already running here, the
|
||||
coroutine is offloaded to a fresh thread so we never deadlock the loop."""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
box: dict = {}
|
||||
|
||||
def _worker():
|
||||
box["value"] = asyncio.run(coro)
|
||||
|
||||
import threading
|
||||
t = threading.Thread(target=_worker)
|
||||
t.start()
|
||||
t.join()
|
||||
return box["value"]
|
||||
|
||||
|
||||
def put_bytes_sync(key, data, *, bucket=Bucket.DOCUMENTS, content_type=None,
|
||||
metadata=None) -> str:
|
||||
return _run_coro_blocking(
|
||||
put_bytes(key, data, bucket=bucket, content_type=content_type, metadata=metadata))
|
||||
|
||||
|
||||
def put_file_sync(src, key, *, bucket=Bucket.DOCUMENTS, content_type=None,
|
||||
metadata=None) -> str:
|
||||
return _run_coro_blocking(
|
||||
put_file(src, key, bucket=bucket, content_type=content_type, metadata=metadata))
|
||||
|
||||
|
||||
def mirror_sync(key, data, *, bucket=Bucket.DOCUMENTS, content_type=None,
|
||||
metadata=None) -> None:
|
||||
_run_coro_blocking(mirror(key, data, bucket=bucket,
|
||||
content_type=content_type, metadata=metadata))
|
||||
|
||||
|
||||
def mirror_file_sync(src, key, *, bucket=Bucket.DOCUMENTS, content_type=None,
|
||||
metadata=None) -> None:
|
||||
_run_coro_blocking(mirror_file(src, key, bucket=bucket,
|
||||
content_type=content_type, metadata=metadata))
|
||||
@@ -166,6 +166,7 @@ async def _analyze_single_pass(rows, appeal_subtype: str = "") -> dict:
|
||||
raw = await claude_session.query(
|
||||
ANALYSIS_PROMPT.format(decisions=decisions_text),
|
||||
timeout=claude_session.LONG_TIMEOUT,
|
||||
tools="", # text→JSON style analysis — no tool_use → no error_max_turns
|
||||
)
|
||||
|
||||
return await _parse_and_store_patterns(raw, len(rows), appeal_subtype)
|
||||
@@ -183,6 +184,7 @@ async def _analyze_multi_pass(rows, appeal_subtype: str = "") -> dict:
|
||||
raw = await claude_session.query(
|
||||
SINGLE_DECISION_PROMPT.format(decision=decision_text),
|
||||
timeout=claude_session.LONG_TIMEOUT,
|
||||
tools="", # text→JSON style analysis — no tool_use → no error_max_turns
|
||||
)
|
||||
|
||||
patterns = _extract_json(raw)
|
||||
@@ -199,6 +201,7 @@ async def _analyze_multi_pass(rows, appeal_subtype: str = "") -> dict:
|
||||
patterns=json.dumps(all_patterns, ensure_ascii=False, indent=2),
|
||||
),
|
||||
timeout=claude_session.LONG_TIMEOUT,
|
||||
tools="", # text→JSON style analysis — no tool_use → no error_max_turns
|
||||
)
|
||||
|
||||
return await _parse_and_store_patterns(raw, len(rows), appeal_subtype)
|
||||
|
||||
@@ -119,7 +119,7 @@ async def extract_decision_metadata(corpus_id: UUID | str) -> dict:
|
||||
)
|
||||
|
||||
try:
|
||||
result = await claude_session.query_json(user_msg, system=METADATA_PROMPT)
|
||||
result = await claude_session.query_json(user_msg, system=METADATA_PROMPT, tools="") # no tool_use → no error_max_turns
|
||||
except Exception as e:
|
||||
logger.warning("style_metadata_extractor: query failed: %s", e)
|
||||
return {}
|
||||
|
||||
@@ -132,6 +132,7 @@ async def case_create(
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
proceeding_type: str = "",
|
||||
chair_name: str = "",
|
||||
) -> str:
|
||||
"""יצירת תיק ערר חדש.
|
||||
|
||||
@@ -153,6 +154,9 @@ async def case_create(
|
||||
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
|
||||
ריק = יוסק אוטומטית ממספר התיק
|
||||
proceeding_type: 'ערר' / 'בל"מ'. ריק = יוסק מ-appeal_subtype/subject.
|
||||
chair_name: שם יו"ר הוועדה. ריק = ברירת-המחדל של הוועדה לפי תחילית
|
||||
מספר-התיק (SoT: config.committee_chair_for_case) — נשמר
|
||||
תמיד לא-ריק כדי שהעתק-הסופי לקורפוס-הפסיקה לא ייכשל.
|
||||
"""
|
||||
# INV-TOOL3 / GAP-52: idempotent on case_number (already UNIQUE in schema).
|
||||
# Re-creating an existing case returns it instead of raising a unique-violation.
|
||||
@@ -183,9 +187,10 @@ async def case_create(
|
||||
appeal_subtype = derived_subtype
|
||||
pa.validate(practice_area, appeal_subtype)
|
||||
|
||||
# proceeding_type: explicit override > derived from subtype/subject > 'ערר'
|
||||
# proceeding_type: explicit override > derived from subtype/subject/number > 'ערר'
|
||||
# (a 5-digit serial signals בל"מ per the post-reform numbering convention).
|
||||
resolved_proc = proceeding_type.strip() or pa.derive_proceeding_type(
|
||||
appeal_subtype=appeal_subtype, subject=subject,
|
||||
case_number=case_number, appeal_subtype=appeal_subtype, subject=subject,
|
||||
)
|
||||
|
||||
case = await db.create_case(
|
||||
@@ -203,6 +208,7 @@ async def case_create(
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype,
|
||||
proceeding_type=resolved_proc,
|
||||
chair_name=chair_name,
|
||||
)
|
||||
|
||||
# If the user overrode the case-number convention (e.g. case 8500 marked
|
||||
@@ -289,6 +295,15 @@ async def case_get(case_number: str) -> str:
|
||||
|
||||
docs = await db.list_documents(UUID(case["id"]))
|
||||
case["documents"] = docs
|
||||
# Derived post-final pipeline status (voice learning + halacha extraction) so the
|
||||
# case shows whether each ran, succeeded, and how many halachot were extracted.
|
||||
# Read-only derivation from existing tables (single source — same fn the
|
||||
# /learning-status endpoint uses); best-effort, never fails the case fetch.
|
||||
try:
|
||||
case["learning_status"] = await db.case_learning_status(case)
|
||||
except Exception as e: # noqa: BLE001 — indicator is best-effort, must not 500 case_get
|
||||
logger.warning("case_learning_status failed for %s: %s", case_number, e)
|
||||
case["learning_status"] = None
|
||||
return ok(case)
|
||||
|
||||
|
||||
|
||||
66
mcp-server/src/legal_mcp/tools/court_fetch.py
Normal file
66
mcp-server/src/legal_mcp/tools/court_fetch.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""MCP tools for the X13 court-verdict auto-fetch subsystem.
|
||||
|
||||
- ``court_verdict_fetch`` — classify a citation, fetch the verdict from the
|
||||
matching public source (Supreme portal / נט המשפט), and ingest it into the
|
||||
precedent library via the canonical pipeline. The standalone entry point
|
||||
(also driven automatically from digest auto-link, see X12/X13).
|
||||
- ``court_fetch_status`` — inspect the fetch-job queue (pending/failed/manual).
|
||||
|
||||
Local-only: ``court_verdict_fetch`` runs the ingest pipeline, which drives
|
||||
halacha extraction via the local ``claude`` CLI — same constraint as
|
||||
``precedent_process_pending``. Invoking it from the container will fail.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from legal_mcp.services import court_fetch_orchestrator as orch
|
||||
from legal_mcp.services import db
|
||||
from legal_mcp.tools.envelope import err as _err, ok as _ok
|
||||
|
||||
|
||||
async def court_verdict_fetch(citation: str) -> str:
|
||||
"""אחזור אוטומטי של פסק-דין בית-משפט וקליטה לקורפוס.
|
||||
|
||||
מקבל ציטוט (למשל 'עת"מ 46111-12-22' או 'עע"מ 1234/22'), מסווג את הערכאה,
|
||||
מוריד את הפסק מהמקור הציבורי המתאים, וקולט אותו דרך צינור-הקליטה הקנוני.
|
||||
ערר/בל"מ (ועדת-ערר) אינם ניתנים לאחזור ציבורי ויסומנו כפער.
|
||||
"""
|
||||
if not (citation or "").strip():
|
||||
return _err("citation is required")
|
||||
try:
|
||||
result = await orch.fetch_and_ingest(citation.strip())
|
||||
except Exception as e: # noqa: BLE001 — surfaced, not swallowed (INV-CF2)
|
||||
return _err(f"אחזור נכשל: {e}")
|
||||
|
||||
status = result.get("status")
|
||||
if status in ("done", "already_done"):
|
||||
return _ok(result, message="הפסק נקלט לקורפוס")
|
||||
if status == "skipped":
|
||||
return _ok(result, message="ועדת-ערר — לא ניתן לאחזור ציבורי (סומן כפער)")
|
||||
if status in ("manual", "awaiting_manual"):
|
||||
return _ok(result, message="האחזור האוטונומי נכשל — הוסלם להורדה ידנית")
|
||||
if status == "unrecognized":
|
||||
return _err("הציטוט לא זוהה כמספר-תיק תקין")
|
||||
return _ok(result, message=f"סטטוס: {status}")
|
||||
|
||||
|
||||
async def court_fetch_status(case_number: str = "", status_filter: str = "") -> str:
|
||||
"""סטטוס תור-האחזור. case_number לפריט יחיד, או status_filter לסינון רשימה."""
|
||||
if case_number.strip():
|
||||
from legal_mcp.services.court_citation import normalize_case_number
|
||||
job = await db.court_fetch_job_get(normalize_case_number(case_number))
|
||||
if not job:
|
||||
return _ok({"job": None}, message="אין job עבור תיק זה")
|
||||
return _ok({"job": job})
|
||||
jobs = await db.court_fetch_job_list(status=status_filter.strip() or None)
|
||||
return _ok({"jobs": jobs, "count": len(jobs)})
|
||||
|
||||
|
||||
async def court_fetch_drain(limit: int = 10) -> str:
|
||||
"""ריקון תור-האחזור: מוריד וקולט את ה-jobs הממתינים (pending/failed) שהיומונים
|
||||
מילאו, וקושר כל פסק שנקלט חזרה ליומון-המקור. סדרתי. כלי מקומי בלבד."""
|
||||
try:
|
||||
result = await orch.drain_pending(limit=max(1, min(int(limit or 10), 50)))
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _err(f"ריקון התור נכשל: {e}")
|
||||
return _ok(result, message=f"עובדו {result.get('processed', 0)}, נקלטו {result.get('done', 0)}")
|
||||
172
mcp-server/src/legal_mcp/tools/digests.py
Normal file
172
mcp-server/src/legal_mcp/tools/digests.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""MCP tools for the Digests radar (X12).
|
||||
|
||||
A digest ("כל יום" daily one-pager, Ofer Toister) is a SECONDARY, discovery-
|
||||
layer source that POINTS at a ruling. It is distinct from the three citation
|
||||
corpora:
|
||||
|
||||
- ``search_precedent_library`` — authoritative external court rulings.
|
||||
- ``search_internal_decisions`` — appeals-committee decisions.
|
||||
- ``search_decisions`` — Dafna's prior decisions (style corpus).
|
||||
|
||||
A digest is NEVER cited in a decision (INV-DIG1) and NEVER enters the halacha
|
||||
pipeline (INV-DIG2). ``search_digests`` is a research compass: it surfaces the
|
||||
relevant digest + the UNDERLYING ruling's citation, which is then ingested into
|
||||
the precedent library and cited from there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import db, digest_library, telemetry
|
||||
from legal_mcp.tools.envelope import empty, err as _err, ok as _ok
|
||||
|
||||
|
||||
async def digest_upload(
|
||||
file_path: str,
|
||||
yomon_number: str = "",
|
||||
digest_date: str = "",
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
subject_tags: list[str] | None = None,
|
||||
) -> str:
|
||||
"""העלאת יומון ("כל יום") לקורפוס-הגילוי + חילוץ מטא-דאטה אוטומטי.
|
||||
|
||||
היומון הוא מקור-משני המצביע על פסק הדין המקורי — אינו מצוטט בהחלטה.
|
||||
Args:
|
||||
file_path: נתיב מלא לקובץ PDF/DOCX של היומון.
|
||||
yomon_number: מספר היומון (אופציונלי — יחולץ מהטקסט אם ריק).
|
||||
digest_date: ISO date של גיליון היומון (אופציונלי).
|
||||
practice_area: rishuy_uvniya / betterment_levy / compensation_197.
|
||||
subject_tags: תגיות נושא (אופציונלי — יחולצו אם ריק).
|
||||
Returns: JSON עם digest_id, מספר היומון, מראה-המקום, וקישור-אוטומטי אם נמצא.
|
||||
"""
|
||||
try:
|
||||
result = await digest_library.ingest_digest(
|
||||
file_path=file_path,
|
||||
yomon_number=yomon_number,
|
||||
digest_date=digest_date or None,
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype,
|
||||
subject_tags=subject_tags or None,
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(str(e))
|
||||
return _ok(result)
|
||||
|
||||
|
||||
async def digest_list(
|
||||
practice_area: str = "",
|
||||
concept_tag: str = "",
|
||||
linked: bool | None = None,
|
||||
search: str = "",
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""רשימת יומונים בקורפוס-הגילוי, עם פילטרים. linked=false → יומונים שהפסק
|
||||
המקורי שלהם עוד לא נקלט לספריית הפסיקה (פער-ידע גלוי)."""
|
||||
rows = await digest_library.list_digests(
|
||||
practice_area=practice_area,
|
||||
concept_tag=concept_tag,
|
||||
linked=linked,
|
||||
search=search,
|
||||
limit=limit,
|
||||
)
|
||||
return _ok(rows)
|
||||
|
||||
|
||||
async def digest_get(digest_id: str) -> str:
|
||||
"""יומון ספציפי לפי מזהה."""
|
||||
try:
|
||||
cid = UUID(digest_id)
|
||||
except ValueError:
|
||||
return _err("digest_id לא תקין")
|
||||
record = await digest_library.get_digest(cid)
|
||||
if not record:
|
||||
return _err("יומון לא נמצא")
|
||||
return _ok(record)
|
||||
|
||||
|
||||
async def digest_link(digest_id: str, case_law_id: str) -> str:
|
||||
"""קישור ידני של יומון לפסק הדין המקורי בספריית הפסיקה (INV-DIG3)."""
|
||||
try:
|
||||
UUID(digest_id)
|
||||
UUID(case_law_id)
|
||||
except ValueError:
|
||||
return _err("מזהה לא תקין")
|
||||
try:
|
||||
result = await digest_library.link_digest(digest_id, case_law_id)
|
||||
except Exception as e:
|
||||
return _err(str(e))
|
||||
return _ok(result)
|
||||
|
||||
|
||||
async def digest_relink(digest_id: str) -> str:
|
||||
"""ניסיון-קישור מחדש: בודק אם פסק הדין המקורי של היומון כבר בספרייה ומקשר."""
|
||||
try:
|
||||
UUID(digest_id)
|
||||
except ValueError:
|
||||
return _err("digest_id לא תקין")
|
||||
try:
|
||||
result = await digest_library.relink_digest(digest_id)
|
||||
except Exception as e:
|
||||
return _err(str(e))
|
||||
return _ok(result)
|
||||
|
||||
|
||||
async def digest_delete(digest_id: str) -> str:
|
||||
"""מחיקת יומון מקורפוס-הגילוי."""
|
||||
try:
|
||||
cid = UUID(digest_id)
|
||||
except ValueError:
|
||||
return _err("digest_id לא תקין")
|
||||
ok_ = await digest_library.delete_digest(cid)
|
||||
if not ok_:
|
||||
return _err("יומון לא נמצא")
|
||||
return _ok({"deleted": True, "digest_id": digest_id})
|
||||
|
||||
|
||||
async def search_digests(
|
||||
query: str,
|
||||
practice_area: str = "",
|
||||
subject_tag: str = "",
|
||||
concept_tag: str = "",
|
||||
limit: int = 10,
|
||||
) -> str:
|
||||
"""חיפוש סמנטי בקורפוס-הגילוי (יומוני "כל יום"). מצפן-מחקר בלבד — מחזיר את
|
||||
היומון הרלוונטי + מראה-המקום של הפסק המקורי (radar). היומון אינו מצוטט
|
||||
בהחלטה (INV-DIG1); הצטט מהפסק המקורי דרך search_precedent_library."""
|
||||
if not query or len(query.strip()) < 2:
|
||||
return empty("שאילתה קצרה מדי (פחות מ-2 תווים).")
|
||||
q = query.strip()
|
||||
t0 = time.perf_counter()
|
||||
results = await digest_library.search_digests(
|
||||
query=q,
|
||||
practice_area=practice_area,
|
||||
subject_tag=subject_tag,
|
||||
concept_tag=concept_tag,
|
||||
limit=limit,
|
||||
)
|
||||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||
telemetry.log_search_bg(
|
||||
search_type="digests",
|
||||
query=q,
|
||||
results=results,
|
||||
duration_ms=elapsed_ms,
|
||||
practice_area=practice_area or None,
|
||||
user_agent="unknown",
|
||||
)
|
||||
if not results:
|
||||
return empty("לא נמצאו יומונים תואמים.")
|
||||
return _ok(results)
|
||||
|
||||
|
||||
async def digest_process_pending(limit: int = 20) -> str:
|
||||
"""ריקון תור היומונים שהועלו מה-UI וממתינים לעיבוד-LLM מקומי. מריץ חילוץ-
|
||||
מטא-דאטה + embedding + autolink על כל יומון בסטטוס 'pending', מקומית עם
|
||||
ה-CLI (claude_session local-only). מנקה לסטטוס 'completed'."""
|
||||
try:
|
||||
result = await digest_library.process_pending_digests(limit=limit)
|
||||
except Exception as e:
|
||||
return _err(str(e))
|
||||
return _ok(result)
|
||||
@@ -4,12 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import audit, db, git_sync, processor
|
||||
from legal_mcp.services import audit, db, git_sync, processor, storage
|
||||
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||
|
||||
|
||||
@@ -50,11 +50,14 @@ async def document_upload(
|
||||
"idempotent_existing": True,
|
||||
}, message=f"הקובץ כבר הועלה לתיק {case_number} (זהה ב-hash) — מוחזר הקיים, ללא עיבוד מחדש.")
|
||||
|
||||
# Copy file to case directory
|
||||
case_dir = config.find_case_dir(case_number) / "documents" / "originals"
|
||||
case_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = case_dir / source.name
|
||||
shutil.copy2(str(source), str(dest))
|
||||
# Stage the original through the unified storage layer (INV-STG1).
|
||||
dest = config.find_case_dir(case_number) / "documents" / "originals" / source.name
|
||||
await storage.put_file(
|
||||
source, dest.relative_to(config.DATA_DIR).as_posix(),
|
||||
bucket=storage.Bucket.DOCUMENTS,
|
||||
content_type=mimetypes.guess_type(source.name)[0],
|
||||
metadata={"filename": source.name},
|
||||
)
|
||||
|
||||
# For auto classification, start with "reference" — will be updated after processing
|
||||
initial_doc_type = doc_type if doc_type != "auto" else "reference"
|
||||
@@ -156,10 +159,14 @@ async def document_upload_training(
|
||||
}
|
||||
subdir = _SUBTYPE_DIRS.get(appeal_subtype, "")
|
||||
training_dest = config.TRAINING_DIR / subdir if subdir else config.TRAINING_DIR
|
||||
training_dest.mkdir(parents=True, exist_ok=True)
|
||||
dest = training_dest / source.name
|
||||
if source.resolve() != dest.resolve():
|
||||
shutil.copy2(str(source), str(dest))
|
||||
await storage.put_file(
|
||||
source, dest.relative_to(config.DATA_DIR).as_posix(),
|
||||
bucket=storage.Bucket.DOCUMENTS,
|
||||
content_type=mimetypes.guess_type(source.name)[0],
|
||||
metadata={"filename": source.name},
|
||||
)
|
||||
|
||||
# Extract text and strip Nevo preamble
|
||||
text, page_count, _ = await extractor.extract_text(str(dest))
|
||||
|
||||
@@ -183,7 +183,7 @@ async def precedent_library_delete(case_law_id: str) -> str:
|
||||
|
||||
|
||||
async def precedent_extract_halachot(case_law_id: str) -> str:
|
||||
"""הרצה מחדש של חילוץ ההלכות לפסיקה קיימת. הלכות קודמות נמחקות."""
|
||||
"""הרצה מחדש של חילוץ ההלכות לפסיקה קיימת. הלכות שאושרו/פורסמו נשמרות (INV-G10); רק הלכות שלא-נבדקו מוחלפות."""
|
||||
try:
|
||||
cid = UUID(case_law_id)
|
||||
except ValueError:
|
||||
|
||||
@@ -326,13 +326,20 @@ async def ingest_final_version(
|
||||
return err(str(e))
|
||||
|
||||
# Auto-ingest into internal committee decisions corpus (best-effort).
|
||||
# chair_name is resolved via the shared SoT (config.committee_chair_for_case)
|
||||
# — the SAME resolver the FastAPI upload path uses — so the two paths cannot
|
||||
# drift (INV-G2) and the DB chair constraint is never hit on an empty chair
|
||||
# (INV-G1: chair normalised at source). Failures are surfaced, not swallowed
|
||||
# (engineering rule §6 / feedback_silent_swallow): the result carries the
|
||||
# reason and final_learning_pipeline prints it.
|
||||
try:
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import internal_decisions as int_svc
|
||||
await int_svc.ingest_internal_decision(
|
||||
case_number=case_number,
|
||||
case_name=case.get("title", ""),
|
||||
decision_date=case.get("decision_date"),
|
||||
chair_name=case.get("chair_name", ""),
|
||||
chair_name=config.committee_chair_for_case(case, case_number),
|
||||
district="ירושלים",
|
||||
practice_area=case.get("practice_area", ""),
|
||||
appeal_subtype=case.get("appeal_subtype", ""),
|
||||
@@ -340,8 +347,10 @@ async def ingest_final_version(
|
||||
)
|
||||
result["internal_corpus_ingested"] = True
|
||||
except Exception as e:
|
||||
logger.warning("ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e)
|
||||
logger.warning(
|
||||
"ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e)
|
||||
result["internal_corpus_ingested"] = False
|
||||
result["internal_corpus_error"] = str(e)
|
||||
|
||||
return ok(result)
|
||||
|
||||
|
||||
66
mcp-server/tests/test_chair_seed_gate.py
Normal file
66
mcp-server/tests/test_chair_seed_gate.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Tests for #133 / FU-2 — the chair-decision active-learning seed gate.
|
||||
|
||||
Covers the PURE gate function db._chair_seed_label, which decides whether (and
|
||||
with what is_holding label) a chair decision on a halacha should mint a gold-set
|
||||
seed. The DB write (seed_goldset_from_chair) and the prior-panel-round filter
|
||||
need a live Postgres and are exercised via the integration smoke test in the
|
||||
task's testStrategy; here we lock down the policy offline.
|
||||
|
||||
The critical invariant under test: a seed is NEVER minted from a machine
|
||||
reviewer (echo-chamber guard, #133) — only firm human keep/drop decisions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from legal_mcp.services import db
|
||||
|
||||
|
||||
# ── firm decisions map to the coarse is_holding axis ──────────────────────────
|
||||
|
||||
def test_approved_is_keep():
|
||||
assert db._chair_seed_label("approved", "דפנה") is True
|
||||
|
||||
|
||||
def test_published_is_keep():
|
||||
assert db._chair_seed_label("published", "דפנה") is True
|
||||
|
||||
|
||||
def test_rejected_is_drop():
|
||||
assert db._chair_seed_label("rejected", "דפנה") is False
|
||||
|
||||
|
||||
# ── non-firm statuses mint no seed (a snooze is not a judgment) ────────────────
|
||||
|
||||
def test_deferred_no_seed():
|
||||
assert db._chair_seed_label("deferred", "דפנה") is None
|
||||
|
||||
|
||||
def test_pending_review_no_seed():
|
||||
assert db._chair_seed_label("pending_review", "דפנה") is None
|
||||
|
||||
|
||||
def test_unknown_status_no_seed():
|
||||
assert db._chair_seed_label("", "דפנה") is None
|
||||
|
||||
|
||||
# ── echo-chamber guard: machine reviewers never seed ──────────────────────────
|
||||
|
||||
def test_panel_reviewer_blocked():
|
||||
"""The 3-judge panel must never label its own ground-truth (echo-chamber)."""
|
||||
assert db._chair_seed_label("approved", "panel:opus+deepseek+gemini 2/3-keep") is None
|
||||
|
||||
|
||||
def test_corroboration_reviewer_blocked():
|
||||
assert db._chair_seed_label("approved", "corroborated (4 judicial citations ≥ 2)") is None
|
||||
|
||||
|
||||
def test_panel_reviewer_blocked_case_insensitive():
|
||||
assert db._chair_seed_label("rejected", "PANEL:opus") is None
|
||||
|
||||
|
||||
# ── empty reviewer is still a human gate (UI sends no reviewer string) ─────────
|
||||
|
||||
def test_empty_reviewer_is_human_gate():
|
||||
"""The /precedents UI patches review_status with no reviewer string; that is
|
||||
still the chair gate (the panel/corroboration use raw SQL, not update_halacha)."""
|
||||
assert db._chair_seed_label("approved", "") is True
|
||||
91
mcp-server/tests/test_court_citation.py
Normal file
91
mcp-server/tests/test_court_citation.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Unit tests for the X13 court-citation classifier."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from legal_mcp.services.court_citation import classify, normalize_case_number
|
||||
|
||||
|
||||
def test_admin_filed_format_the_example():
|
||||
"""The plan's example: עת"מ 46111-12-22 → admin, parsed into (46111,12,22)."""
|
||||
c = classify('עת"מ 46111-12-22 יכין-אפק בע"מ נ\' הוועדה המחוזית')
|
||||
assert c.tier == "admin"
|
||||
assert c.court_prefix in ('עת"מ', "עת״מ")
|
||||
assert c.case_number_raw == "46111-12-22"
|
||||
assert c.case_number_norm == "46111-12-22"
|
||||
assert (c.file_number, c.month, c.year) == ("46111", "12", "22")
|
||||
assert c.fetchable is True
|
||||
|
||||
|
||||
def test_bare_filed_number_defaults_admin():
|
||||
c = classify("46111-12-22")
|
||||
assert c.tier == "admin"
|
||||
assert (c.file_number, c.month, c.year) == ("46111", "12", "22")
|
||||
|
||||
|
||||
def test_supreme_prefixes():
|
||||
for cit, pref in [
|
||||
('עע"מ 1234/22', "supreme"),
|
||||
('בג"ץ 5678/21', "supreme"),
|
||||
('ע"א 999/20', "supreme"),
|
||||
('רע"א 4/19', "supreme"),
|
||||
('בר"מ 8126/24', "supreme"),
|
||||
]:
|
||||
c = classify(cit)
|
||||
assert c.tier == pref, f"{cit} -> {c.tier}"
|
||||
assert c.fetchable is True
|
||||
|
||||
|
||||
def test_appeals_committee_is_skip():
|
||||
"""ערר / בל"מ must never be auto-fetched (needs Nevo) — INV-CF6."""
|
||||
for cit in ['ערר 1110/20', 'בל"מ 8048/24', "ערר 1015-01-24 ירושלים שקופה"]:
|
||||
c = classify(cit)
|
||||
assert c.tier == "skip", f"{cit} -> {c.tier}"
|
||||
assert c.fetchable is False
|
||||
|
||||
|
||||
def test_skip_wins_over_court_match():
|
||||
"""An 'ערר' citation that also contains court-like digits stays skip."""
|
||||
c = classify("ראה החלטתי בערר 1041/24 ובהמשך")
|
||||
assert c.tier == "skip"
|
||||
|
||||
|
||||
def test_admin_amn_prefix():
|
||||
c = classify('עמ"נ 12345-06-23')
|
||||
assert c.tier == "admin"
|
||||
assert (c.file_number, c.month, c.year) == ("12345", "06", "23")
|
||||
|
||||
|
||||
def test_two_group_serial_has_no_filed_triple():
|
||||
"""Supreme serial 1234/22 normalizes but yields no (file,month,year)."""
|
||||
c = classify('עע"מ 1234/22')
|
||||
assert c.case_number_norm == "1234-22"
|
||||
assert c.file_number is None
|
||||
|
||||
|
||||
def test_implausible_month_not_parsed_as_filed():
|
||||
# 1234-22-05 has month=22 → not a valid filed triple.
|
||||
assert classify("1234-22-05").tier in ("unknown", "admin")
|
||||
c = classify("1234-22-05")
|
||||
if c.tier == "admin":
|
||||
assert c.month is None
|
||||
|
||||
|
||||
def test_empty_and_garbage():
|
||||
assert classify("").tier == "unknown"
|
||||
assert classify("שלום עולם בלי ציטוט").tier == "unknown"
|
||||
|
||||
|
||||
def test_normalize_case_number():
|
||||
assert normalize_case_number('עת"מ 46111/12/22') == "46111-12-22"
|
||||
assert normalize_case_number("1110/20") == "1110-20"
|
||||
|
||||
|
||||
def test_supreme_with_net_format_triple():
|
||||
"""A Supreme prefix carrying a נט-format number exposes the triple so the
|
||||
orchestrator can route it to Tier-1 (נט המשפט serves Supreme too)."""
|
||||
c = classify('בר"מ 72182-06-25 הימנותא נ\' הוועדה המקומית')
|
||||
assert c.tier == "supreme"
|
||||
assert (c.file_number, c.month, c.year) == ("72182", "06", "25")
|
||||
# serial-format Supreme has no triple → stays Tier-0-only
|
||||
s = classify('עע"מ 5886/24')
|
||||
assert s.tier == "supreme" and s.file_number is None
|
||||
79
mcp-server/tests/test_goldset_calibrate.py
Normal file
79
mcp-server/tests/test_goldset_calibrate.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Tests for #81.8 — db.goldset_calibrate (auto-approve gate calibration).
|
||||
|
||||
Verifies the confidence-threshold sweep and the panel-policy precision/coverage
|
||||
against synthetic gold-set rows. Fully OFFLINE — monkeypatches db.goldset_list,
|
||||
no Postgres.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import db
|
||||
|
||||
|
||||
def _item(tag, keep, conf, votes):
|
||||
c, d, g = votes
|
||||
return {
|
||||
"tagged_by": tag, "is_holding": keep, "confidence": conf,
|
||||
"ai_is_holding": c, "ds_is_holding": d, "gm_is_holding": g,
|
||||
}
|
||||
|
||||
|
||||
# A,B,C,D are chair-labeled; E is panel-labeled (excluded under ground_truth='chair').
|
||||
ITEMS = [
|
||||
_item("chair", True, 0.90, (True, True, True)), # A unanimous keep
|
||||
_item("chair", True, 0.80, (True, True, False)), # B majority keep
|
||||
_item("chair", False, 0.60, (False, False, False)), # C unanimous drop
|
||||
_item("chair", True, 0.75, (True, True, True)), # D unanimous keep
|
||||
_item("panel:opus+deepseek+gemini", False, 0.99, (False, False, False)), # E excluded
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def patched(monkeypatch: pytest.MonkeyPatch):
|
||||
async def _fake(batch="default"):
|
||||
return list(ITEMS)
|
||||
monkeypatch.setattr(db, "goldset_list", _fake)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def test_ground_truth_chair_excludes_panel_rows(patched):
|
||||
r = _run(db.goldset_calibrate("default", ground_truth="chair"))
|
||||
assert r["n"] == 4 and r["positives"] == 3 and r["negatives"] == 1
|
||||
|
||||
|
||||
def test_confidence_sweep_precision_recall(patched):
|
||||
r = _run(db.goldset_calibrate("default", ground_truth="chair"))
|
||||
sweep = {round(g["threshold"], 2): g for g in r["confidence_sweep"]}
|
||||
# T=0.80 approves A(0.90)+B(0.80) → both keep → P=1.0, recall 2/3
|
||||
assert sweep[0.80]["approved"] == 2
|
||||
assert sweep[0.80]["precision"] == 1.0
|
||||
assert sweep[0.80]["recall"] == pytest.approx(0.667, abs=0.01)
|
||||
# T=0.70 approves A,B,D (C's 0.60 excluded) → all keep → P=1.0, recall 1.0
|
||||
assert sweep[0.70]["approved"] == 3
|
||||
assert sweep[0.70]["recall"] == 1.0
|
||||
|
||||
|
||||
def test_panel_policies(patched):
|
||||
r = _run(db.goldset_calibrate("default", ground_truth="chair"))
|
||||
# majority: A,B,D keep / C drop → approves 3, all keep, full coverage
|
||||
maj = r["panel_majority_2of3"]
|
||||
assert maj["approved"] == 3 and maj["precision"] == 1.0 and maj["coverage"] == 1.0
|
||||
# unanimous: A,D approved (B is T,T,F → not unanimous), all decided → P=1.0
|
||||
un = r["panel_unanimous_3of3"]
|
||||
assert un["approved"] == 2 and un["precision"] == 1.0 and un["coverage"] == 1.0
|
||||
|
||||
|
||||
def test_current_threshold_surfaced(patched):
|
||||
r = _run(db.goldset_calibrate("default"))
|
||||
assert r["current_threshold"] == db.config.HALACHA_AUTO_APPROVE_THRESHOLD
|
||||
129
mcp-server/tests/test_goldset_panel_consensus.py
Normal file
129
mcp-server/tests/test_goldset_panel_consensus.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Tests for #81.7 — tri-model consensus labeling of the halacha gold-set.
|
||||
|
||||
Covers the pure aggregation/probe functions in scripts/goldset_panel_label.py
|
||||
(consensus vote, type consensus, Fleiss' kappa, anonymization masking). Fully
|
||||
OFFLINE — no DB, no model calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# the script lives in ../scripts relative to mcp-server/
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts"))
|
||||
import goldset_panel_label as g # noqa: E402
|
||||
|
||||
|
||||
# ── consensus() ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("votes,expected", [
|
||||
([True, True, True], (True, "3/3")),
|
||||
([False, False, False], (False, "3/3")),
|
||||
([True, True, False], (True, "2/3")),
|
||||
([False, False, True], (False, "2/3")),
|
||||
([True, False, None], (None, "split")), # 1-1 of the two valid → chair
|
||||
([True, None, None], (None, "incomplete")), # only one judge → chair
|
||||
([None, None, None], (None, "incomplete")),
|
||||
])
|
||||
def test_consensus(votes, expected):
|
||||
assert g.consensus(votes) == expected
|
||||
|
||||
|
||||
def test_split_writes_no_label():
|
||||
"""A genuine 1-1 split must NOT yield a decision (escalates to chair, G10)."""
|
||||
decided, tag = g.consensus([True, False, None])
|
||||
assert decided is None and tag == "split"
|
||||
|
||||
|
||||
# ── consensus_type() ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_consensus_type_holding_majority():
|
||||
per = [{"type": "holding"}, {"type": "holding"}, {"type": "application"}]
|
||||
assert g.consensus_type(per, decided=True) == "holding"
|
||||
|
||||
|
||||
def test_consensus_type_constrained_to_is_holding():
|
||||
"""When the consensus is is_holding=False, only application/obiter types
|
||||
are eligible — an inconsistent 'holding' vote is ignored."""
|
||||
per = [{"type": "holding"}, {"type": "application"}, {"type": "obiter"}]
|
||||
out = g.consensus_type(per, decided=False)
|
||||
assert out in {"application", "obiter"}
|
||||
|
||||
|
||||
def test_consensus_type_undecided_is_blank():
|
||||
per = [{"type": "holding"}, {"type": "application"}, {"type": "obiter"}]
|
||||
assert g.consensus_type(per, decided=None) == ""
|
||||
|
||||
|
||||
# ── fleiss_kappa() ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_fleiss_kappa_perfect_agreement():
|
||||
# every item rated 3/0 or 0/3 → κ == 1.0
|
||||
rows = [(3, 0), (3, 0), (0, 3), (0, 3)]
|
||||
assert g.fleiss_kappa(rows) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_fleiss_kappa_disagreement_is_low():
|
||||
rows = [(2, 1), (1, 2)]
|
||||
k = g.fleiss_kappa(rows)
|
||||
assert k is not None and k < 0.0 # worse than chance
|
||||
|
||||
|
||||
def test_fleiss_kappa_ragged_returns_none():
|
||||
# mixed rater counts (3 then 2) is not well-defined → None
|
||||
assert g.fleiss_kappa([(3, 0), (1, 1)]) is None
|
||||
|
||||
|
||||
def test_fleiss_kappa_empty_returns_none():
|
||||
assert g.fleiss_kappa([]) is None
|
||||
|
||||
|
||||
# ── gwet_ac1() ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_gwet_ac1_perfect_agreement():
|
||||
rows = [(3, 0), (3, 0), (0, 3), (0, 3)]
|
||||
assert g.gwet_ac1(rows) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_gwet_ac1_resolves_the_kappa_paradox():
|
||||
"""The headline reason AC1 exists here: under a heavily skewed marginal
|
||||
(almost every item is_holding=True) Fleiss κ collapses to ~0 despite very
|
||||
high observed agreement, while AC1 correctly reports near-perfect.
|
||||
9 unanimous-yes items + 1 split → 93% observed agreement."""
|
||||
rows = [(3, 0)] * 9 + [(2, 1)]
|
||||
kappa = g.fleiss_kappa(rows)
|
||||
ac1 = g.gwet_ac1(rows)
|
||||
assert abs(kappa) < 0.1 # κ paradox: near zero
|
||||
assert ac1 > 0.9 # AC1: almost-perfect, matching reality
|
||||
assert ac1 > kappa # AC1 strictly more faithful under skew
|
||||
|
||||
|
||||
def test_gwet_ac1_ragged_and_empty_return_none():
|
||||
assert g.gwet_ac1([(3, 0), (1, 1)]) is None
|
||||
assert g.gwet_ac1([]) is None
|
||||
|
||||
|
||||
# ── anonymize() ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_anonymize_masks_case_number_and_name():
|
||||
text = "מקור: החלטת ועדת-ערר (8125-09-24). העוררים פלוני בע\"מ טענו..."
|
||||
out = g.anonymize(text, case_number="8125-09-24", case_name='פלוני בע"מ')
|
||||
assert "8125-09-24" not in out
|
||||
assert 'פלוני בע"מ' not in out
|
||||
assert g._FAKE_CASE in out
|
||||
|
||||
|
||||
def test_anonymize_no_identifiers_is_noop():
|
||||
text = "כלל משפטי כללי ללא מזהים."
|
||||
assert g.anonymize(text, case_number=None, case_name=None) == text
|
||||
|
||||
|
||||
def test_anonymize_preserves_legal_substance():
|
||||
"""Masking swaps only the identifier — the rule text is untouched."""
|
||||
text = "הכלל: מיצוי הליכים הוא תנאי-סף. (תיק 9001-01-20)"
|
||||
out = g.anonymize(text, case_number="9001-01-20", case_name=None)
|
||||
assert "מיצוי הליכים הוא תנאי-סף" in out
|
||||
assert "9001-01-20" not in out
|
||||
46
mcp-server/tests/test_halacha_coerce.py
Normal file
46
mcp-server/tests/test_halacha_coerce.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""rule_type coercion after the authority/role split (INV-DM7).
|
||||
|
||||
The extractor's rule_type holds the rule ROLE only — it is never defaulted from
|
||||
the source's bindingness. Legacy authority values fold to the nearest role.
|
||||
"""
|
||||
from legal_mcp.services.halacha_extractor import (
|
||||
_LEGACY_RULE_TYPE_FOLD,
|
||||
_VALID_RULE_TYPES,
|
||||
_coerce_halacha,
|
||||
)
|
||||
|
||||
_BASE = {"rule_statement": "כלל כלשהו", "supporting_quote": "ציטוט תומך כלשהו"}
|
||||
|
||||
|
||||
def _rt(rule_type):
|
||||
return _coerce_halacha({**_BASE, "rule_type": rule_type})["rule_type"]
|
||||
|
||||
|
||||
def test_valid_roles_are_the_five_roles_only():
|
||||
assert _VALID_RULE_TYPES == {
|
||||
"holding", "interpretive", "procedural", "application", "obiter",
|
||||
}
|
||||
assert "binding" not in _VALID_RULE_TYPES
|
||||
assert "persuasive" not in _VALID_RULE_TYPES
|
||||
|
||||
|
||||
def test_legacy_authority_values_fold_to_a_role():
|
||||
assert _rt("binding") == "holding"
|
||||
assert _rt("persuasive") == "interpretive"
|
||||
assert _LEGACY_RULE_TYPE_FOLD == {"binding": "holding", "persuasive": "interpretive"}
|
||||
|
||||
|
||||
def test_genuine_roles_pass_through():
|
||||
for role in ("holding", "interpretive", "procedural", "application", "obiter"):
|
||||
assert _rt(role) == role
|
||||
|
||||
|
||||
def test_unknown_or_missing_defaults_to_interpretive():
|
||||
assert _rt("nonsense") == "interpretive"
|
||||
assert _coerce_halacha(_BASE)["rule_type"] == "interpretive"
|
||||
|
||||
|
||||
def test_coerce_rejects_rows_missing_required_fields():
|
||||
assert _coerce_halacha({"rule_statement": "x"}) is None
|
||||
assert _coerce_halacha({"supporting_quote": "y"}) is None
|
||||
assert _coerce_halacha("not a dict") is None
|
||||
53
mcp-server/tests/test_halacha_dedup_action.py
Normal file
53
mcp-server/tests/test_halacha_dedup_action.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Tests for #82.4 / #82.6 — dedup-on-insert decision + over-merge guard.
|
||||
|
||||
``halacha_quality.dedup_action`` is the PAIRWISE decision a fresh halacha makes
|
||||
against its single nearest same-precedent neighbor: skip (semantic dup), flag
|
||||
(lexical tail), or keep. It compares to exactly ONE neighbor and only ever drops
|
||||
the *incoming* row, so a chain A~B~C can never collapse to one row — the
|
||||
over-merge guard (#82.6). Pure/offline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import halacha_quality as hq
|
||||
|
||||
# operating point: DEDUP_COSINE=0.93 → dedup_distance=0.07 ; BAND=0.83 → 0.17
|
||||
DEDUP_D = 1.0 - 0.93
|
||||
BAND_D = 1.0 - 0.83
|
||||
|
||||
SIMILAR_A = "מיצוי הליכים הוא תנאי סף להגשת ערר לוועדה"
|
||||
SIMILAR_B = "מיצוי הליכים הוא תנאי סף להגשת הערר לוועדה"
|
||||
DIFFERENT = "מתחם שיקול הדעת התכנוני של הוועדה המקומית רחב"
|
||||
|
||||
|
||||
def test_skip_below_dedup_distance():
|
||||
# cosine ≥ 0.93 (dist ≤ 0.07) → skip, regardless of wording
|
||||
assert hq.dedup_action(0.03, DIFFERENT, SIMILAR_A, DEDUP_D, BAND_D) == "skip"
|
||||
assert hq.dedup_action(0.05, SIMILAR_A, SIMILAR_B, DEDUP_D, BAND_D) == "skip"
|
||||
|
||||
|
||||
def test_flag_in_lexical_tail():
|
||||
# in the 0.07–0.17 band AND lexically near → flag (not skip, not keep)
|
||||
assert hq.dedup_action(0.12, SIMILAR_A, SIMILAR_B, DEDUP_D, BAND_D) == "flag"
|
||||
|
||||
|
||||
def test_keep_in_tail_when_not_lexically_similar():
|
||||
# in the band but lexically distinct → keep (don't flag a different rule)
|
||||
assert hq.dedup_action(0.12, DIFFERENT, SIMILAR_A, DEDUP_D, BAND_D) == "keep"
|
||||
|
||||
|
||||
def test_over_merge_guard_distinct_rule_kept():
|
||||
"""Beyond the band, even a lexically-similar rule is KEPT — and because the
|
||||
decision is pairwise (one neighbor, incoming-only drop), a chain A~B~C with
|
||||
A,C distinct never collapses to a single row (#82.6)."""
|
||||
assert hq.dedup_action(0.30, SIMILAR_A, SIMILAR_B, DEDUP_D, BAND_D) == "keep"
|
||||
assert hq.dedup_action(0.50, DIFFERENT, SIMILAR_A, DEDUP_D, BAND_D) == "keep"
|
||||
|
||||
|
||||
def test_boundary_exactly_at_band_edge():
|
||||
# dist == band_distance is still within the tail (≤), lexical → flag
|
||||
assert hq.dedup_action(BAND_D, SIMILAR_A, SIMILAR_B, DEDUP_D, BAND_D) == "flag"
|
||||
# just past the band → keep
|
||||
assert hq.dedup_action(BAND_D + 0.001, SIMILAR_A, SIMILAR_B, DEDUP_D, BAND_D) == "keep"
|
||||
85
mcp-server/tests/test_halacha_priority_panel_order.py
Normal file
85
mcp-server/tests/test_halacha_priority_panel_order.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Tests for #133 / FU-3 — active uncertainty-sampling of the chair review queue.
|
||||
|
||||
When the chair queue is requested with order_by_priority, the items the 3-judge
|
||||
panel SPLIT on (and then INCOMPLETE rounds) must float to the top — those are
|
||||
the highest-value active-learning labels (the chair's call resolves a genuine
|
||||
ambiguity and feeds rubric distillation, FU-4). This reuses the existing
|
||||
order_by_priority flag (no parallel path, G2).
|
||||
|
||||
Runs fully OFFLINE: monkeypatches db.get_pool with a fake pool that captures the
|
||||
SQL passed to fetch, and asserts the ORDER BY / JOIN shape — no Postgres.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import db
|
||||
|
||||
|
||||
class _FakePool:
|
||||
"""Captures SQL passed to ``fetch``; returns no rows."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.queries: list[str] = []
|
||||
|
||||
async def fetch(self, sql: str, *args): # noqa: ANN002, ANN201
|
||||
self.queries.append(sql)
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_pool(monkeypatch: pytest.MonkeyPatch) -> _FakePool:
|
||||
pool = _FakePool()
|
||||
|
||||
async def _get_pool() -> _FakePool:
|
||||
return pool
|
||||
|
||||
monkeypatch.setattr(db, "get_pool", _get_pool)
|
||||
return pool
|
||||
|
||||
|
||||
def _list_sql(pool: _FakePool) -> str:
|
||||
return next(q for q in pool.queries if "FROM halachot h" in q)
|
||||
|
||||
|
||||
def test_priority_order_ranks_panel_split_first(fake_pool: _FakePool) -> None:
|
||||
asyncio.run(
|
||||
db.list_halachot(review_status="pending_review", order_by_priority=True)
|
||||
)
|
||||
sql = _list_sql(fake_pool)
|
||||
# latest-verdict join is present …
|
||||
assert "FROM halacha_panel_rounds" in sql
|
||||
assert "DISTINCT ON (halacha_id)" in sql
|
||||
# … and the ORDER BY ranks split before incomplete before everything else,
|
||||
# AHEAD of the #84.3 corroboration/confidence/age keys.
|
||||
order = sql[sql.index("ORDER BY"):]
|
||||
assert "WHEN 'split' THEN 0" in order
|
||||
assert "WHEN 'incomplete' THEN 1" in order
|
||||
rank_pos = order.index("CASE pr.verdict")
|
||||
corr_pos = order.index("corroboration_negative")
|
||||
conf_pos = order.index("h.confidence")
|
||||
assert rank_pos < corr_pos < conf_pos, (
|
||||
"panel-disagreement rank must be the PRIMARY sort key, before the "
|
||||
"existing #84.3 corroboration/confidence ordering"
|
||||
)
|
||||
|
||||
|
||||
def test_fifo_order_has_no_panel_rank(fake_pool: _FakePool) -> None:
|
||||
"""Without order_by_priority the queue stays in deterministic FIFO order —
|
||||
the panel-rank CASE must not leak into the default ordering."""
|
||||
asyncio.run(db.list_halachot(review_status="pending_review"))
|
||||
sql = _list_sql(fake_pool)
|
||||
order = sql[sql.index("ORDER BY"):]
|
||||
assert "CASE pr.verdict" not in order
|
||||
assert "h.case_law_id, h.halacha_index" in order
|
||||
|
||||
|
||||
def test_panel_verdict_selected(fake_pool: _FakePool) -> None:
|
||||
"""panel_verdict is surfaced on each row so the UI can badge *why* an item
|
||||
is at the top of the queue (and so the order is auditable)."""
|
||||
asyncio.run(db.list_halachot(order_by_priority=True))
|
||||
sql = _list_sql(fake_pool)
|
||||
assert "pr.verdict AS panel_verdict" in sql
|
||||
@@ -211,23 +211,40 @@ def test_application_flag_from_rule_type():
|
||||
assert hq.FLAG_APPLICATION in flags
|
||||
|
||||
|
||||
def test_application_flag_from_deixis_even_if_binding():
|
||||
def test_application_flag_from_deixis_even_if_holding():
|
||||
flags = hq.compute_quality_flags(
|
||||
"במקרה דנן נדחה הערר", "כפי שקבענו במקרה דנן נדחה הערר",
|
||||
rule_type="binding",
|
||||
rule_type="holding",
|
||||
)
|
||||
assert hq.FLAG_APPLICATION in flags
|
||||
|
||||
|
||||
def test_clean_binding_rule_has_no_flags():
|
||||
def test_clean_holding_rule_has_no_flags():
|
||||
flags = hq.compute_quality_flags(
|
||||
"ועדת הערר מוסמכת לדון בטענות חוקתיות הנוגעות לתכנית",
|
||||
"הוועדה מוסמכת לדון אף בטענות מסוג זה, ככל שהן נוגעות לתכנית שבנדון.",
|
||||
rule_type="binding",
|
||||
rule_type="holding",
|
||||
)
|
||||
assert flags == []
|
||||
|
||||
|
||||
# ── INV-DM7: authority is DERIVED from the source, never a rule_type value ──
|
||||
|
||||
def test_derive_authority_binding_for_higher_courts():
|
||||
assert hq.derive_authority("עליון") == "binding"
|
||||
assert hq.derive_authority("מנהלי") == "binding"
|
||||
|
||||
|
||||
def test_derive_authority_persuasive_for_committee():
|
||||
assert hq.derive_authority("ועדת_ערר_מחוזית") == "persuasive"
|
||||
|
||||
|
||||
def test_derive_authority_none_for_unknown_or_empty():
|
||||
assert hq.derive_authority("") is None
|
||||
assert hq.derive_authority(None) is None
|
||||
assert hq.derive_authority("משהו אחר") is None
|
||||
|
||||
|
||||
# ── #82.3 lexical near-duplicate signal ──
|
||||
|
||||
def test_jaccard_high_for_reworded_same_rule():
|
||||
|
||||
115
mcp-server/tests/test_halacha_reextract_preserves_approved.py
Normal file
115
mcp-server/tests/test_halacha_reextract_preserves_approved.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Regression test for TaskMaster #108 / INV-G10 — re-extraction must NOT delete
|
||||
chair-approved/published halachot.
|
||||
|
||||
Bug (2026-06-08 amiel incident, בל"מ 8126-03-25): ``reset_halacha_extraction``
|
||||
ran an UNCONDITIONAL ``DELETE FROM halachot`` before re-extracting. A crash
|
||||
between the delete and the first chunk's store lost every chair approval (9
|
||||
approved + their rule_type) and left the row stuck ``status='processing'`` with
|
||||
0 rows.
|
||||
|
||||
Fix: the delete now excludes ``review_status IN ('approved','published')`` so
|
||||
approvals survive a re-extract; the per-chunk dedup-on-insert
|
||||
(``store_halachot_for_chunk``) skips fresh extractions that duplicate a
|
||||
preserved approval, so no duplicates appear either.
|
||||
|
||||
Runs fully OFFLINE — monkeypatches ``db.get_pool`` with a fake pool that
|
||||
captures every SQL string instead of hitting Postgres (same style as
|
||||
``test_precedent_corpus_isolation.py``). Asserts the DELETE carries the
|
||||
approved/published exclusion and that the function reports preserved/deleted
|
||||
counts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import db
|
||||
|
||||
|
||||
class _FakeTxn:
|
||||
async def __aenter__(self) -> "_FakeTxn":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc) -> bool: # noqa: ANN002
|
||||
return False
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.executed: list[str] = []
|
||||
self.fetchvals: list[str] = []
|
||||
|
||||
async def execute(self, sql: str, *args) -> str: # noqa: ANN002
|
||||
self.executed.append(sql)
|
||||
return "DELETE 3" # mimic asyncpg command tag so the count parse works
|
||||
|
||||
async def fetchval(self, sql: str, *args) -> int: # noqa: ANN002
|
||||
self.fetchvals.append(sql)
|
||||
return 9 # pretend 9 approved/published rows are present
|
||||
|
||||
def transaction(self) -> _FakeTxn:
|
||||
return _FakeTxn()
|
||||
|
||||
|
||||
class _AcquireCtx:
|
||||
def __init__(self, conn: _FakeConn) -> None:
|
||||
self._conn = conn
|
||||
|
||||
async def __aenter__(self) -> _FakeConn:
|
||||
return self._conn
|
||||
|
||||
async def __aexit__(self, *exc) -> bool: # noqa: ANN002
|
||||
return False
|
||||
|
||||
|
||||
class _FakePool:
|
||||
def __init__(self, conn: _FakeConn) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def acquire(self) -> _AcquireCtx:
|
||||
return _AcquireCtx(self._conn)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_conn(monkeypatch: pytest.MonkeyPatch) -> _FakeConn:
|
||||
conn = _FakeConn()
|
||||
pool = _FakePool(conn)
|
||||
|
||||
async def _get_pool() -> _FakePool:
|
||||
return pool
|
||||
|
||||
monkeypatch.setattr(db, "get_pool", _get_pool)
|
||||
return conn
|
||||
|
||||
|
||||
def test_reset_halacha_extraction_preserves_approved(fake_conn: _FakeConn) -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
result = loop.run_until_complete(db.reset_halacha_extraction(uuid4()))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
delete_sql = next(
|
||||
q for q in fake_conn.executed if q.strip().upper().startswith("DELETE")
|
||||
)
|
||||
norm = " ".join(delete_sql.split())
|
||||
|
||||
# INV-G10: the delete MUST exclude chair-approved/published halachot.
|
||||
assert "review_status NOT IN ('approved', 'published')" in norm, delete_sql
|
||||
# ...and must therefore be conditional — never an unconditional wipe.
|
||||
assert "WHERE case_law_id = $1 AND review_status NOT IN" in norm, delete_sql
|
||||
|
||||
# The preserved-count query filters to exactly approved/published.
|
||||
assert any(
|
||||
"IN ('approved', 'published')" in q and "NOT IN" not in q
|
||||
for q in fake_conn.fetchvals
|
||||
), fake_conn.fetchvals
|
||||
|
||||
# Checkpoints are still cleared so every chunk re-processes.
|
||||
assert any("halacha_extracted_at = NULL" in q for q in fake_conn.executed)
|
||||
|
||||
# Reports counts for provenance (G9) / caller logging.
|
||||
assert result == {"deleted": 3, "preserved": 9}
|
||||
137
mcp-server/tests/test_halacha_rhetorical_prefilter.py
Normal file
137
mcp-server/tests/test_halacha_rhetorical_prefilter.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Tests for TaskMaster #81.6 — rhetorical-role pre-filter on halacha extraction.
|
||||
|
||||
Only reasoning/decision sections should feed halacha extraction (INV-LRN2
|
||||
quality-at-source). The historical bug: when the chunker labeled *nothing* as an
|
||||
extractable section (non-standard headings → everything 'other'), the fallback
|
||||
took ALL chunks — re-admitting the factual background and the parties'
|
||||
arguments, exactly the sections the primary filter excludes. The dominant
|
||||
extraction error class is Facts↔Reasoning confusion (LegalSeg), so feeding facts
|
||||
into extraction directly lowers precision.
|
||||
|
||||
Fix: ``_select_extractable_chunks`` — the fallback now excludes
|
||||
``NON_REASONING_SECTIONS`` (facts / appellant_claims / respondent_claims /
|
||||
intro) while still reaching reasoning that merely landed under 'other'.
|
||||
|
||||
Runs fully OFFLINE — monkeypatches ``db.list_precedent_chunks`` so no Postgres
|
||||
is needed (same style as ``test_halacha_reextract_preserves_approved.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import db, halacha_extractor
|
||||
|
||||
|
||||
def _chunk(idx: int, section_type: str) -> dict:
|
||||
return {
|
||||
"id": uuid4(),
|
||||
"chunk_index": idx,
|
||||
"content": f"chunk-{idx}-{section_type}",
|
||||
"section_type": section_type,
|
||||
"page_number": None,
|
||||
"halacha_extracted_at": None,
|
||||
}
|
||||
|
||||
|
||||
def _patch_chunks(monkeypatch: pytest.MonkeyPatch, all_chunks: list[dict]) -> list[dict]:
|
||||
"""Patch db.list_precedent_chunks to filter ``all_chunks`` like Postgres would.
|
||||
|
||||
Returns a list that records every call's ``section_types`` argument so a
|
||||
test can assert whether the unfiltered fallback query was issued.
|
||||
"""
|
||||
calls: list = []
|
||||
|
||||
async def _fake(case_law_id, section_types=None): # noqa: ANN001
|
||||
calls.append(section_types)
|
||||
if section_types:
|
||||
return [c for c in all_chunks if c["section_type"] in section_types]
|
||||
return list(all_chunks)
|
||||
|
||||
monkeypatch.setattr(db, "list_precedent_chunks", _fake)
|
||||
return calls
|
||||
|
||||
|
||||
def _run(coro):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def test_primary_path_returns_only_reasoning_sections(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When extractable sections exist, return exactly them — no fallback."""
|
||||
all_chunks = [
|
||||
_chunk(0, "facts"),
|
||||
_chunk(1, "legal_analysis"),
|
||||
_chunk(2, "appellant_claims"),
|
||||
_chunk(3, "ruling"),
|
||||
_chunk(4, "conclusion"),
|
||||
]
|
||||
calls = _patch_chunks(monkeypatch, all_chunks)
|
||||
|
||||
chunks, used_fallback = _run(
|
||||
halacha_extractor._select_extractable_chunks(uuid4()),
|
||||
)
|
||||
|
||||
assert used_fallback is False
|
||||
got = sorted(c["section_type"] for c in chunks)
|
||||
assert got == ["conclusion", "legal_analysis", "ruling"]
|
||||
# Only the filtered query ran — the unfiltered fallback was never issued.
|
||||
assert calls == [halacha_extractor.EXTRACTABLE_SECTIONS]
|
||||
|
||||
|
||||
def test_fallback_excludes_facts_and_arguments(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No targeted section → fall back, but never to facts/arguments/intro."""
|
||||
all_chunks = [
|
||||
_chunk(0, "intro"),
|
||||
_chunk(1, "facts"),
|
||||
_chunk(2, "appellant_claims"),
|
||||
_chunk(3, "respondent_claims"),
|
||||
_chunk(4, "other"), # reasoning that landed under an unexpected label
|
||||
_chunk(5, "other"),
|
||||
]
|
||||
calls = _patch_chunks(monkeypatch, all_chunks)
|
||||
|
||||
chunks, used_fallback = _run(
|
||||
halacha_extractor._select_extractable_chunks(uuid4()),
|
||||
)
|
||||
|
||||
assert used_fallback is True
|
||||
# Only the 'other' chunks survive — facts / arguments / intro are dropped.
|
||||
assert {c["section_type"] for c in chunks} == {"other"}
|
||||
assert len(chunks) == 2
|
||||
# Both queries ran: the filtered primary (empty), then the unfiltered fallback.
|
||||
assert calls == [halacha_extractor.EXTRACTABLE_SECTIONS, None]
|
||||
|
||||
|
||||
def test_fallback_all_nonreasoning_extracts_nothing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A doc that is entirely facts/arguments/intro yields zero candidates —
|
||||
extraction never runs on the factual background."""
|
||||
all_chunks = [
|
||||
_chunk(0, "intro"),
|
||||
_chunk(1, "facts"),
|
||||
_chunk(2, "appellant_claims"),
|
||||
_chunk(3, "respondent_claims"),
|
||||
]
|
||||
_patch_chunks(monkeypatch, all_chunks)
|
||||
|
||||
chunks, used_fallback = _run(
|
||||
halacha_extractor._select_extractable_chunks(uuid4()),
|
||||
)
|
||||
|
||||
assert used_fallback is True
|
||||
assert chunks == []
|
||||
|
||||
|
||||
def test_non_reasoning_set_is_disjoint_from_extractable() -> None:
|
||||
"""The two policy sets must never overlap — a section cannot be both a
|
||||
reasoning candidate and a confidently-excluded one."""
|
||||
assert not (
|
||||
set(halacha_extractor.NON_REASONING_SECTIONS)
|
||||
& set(halacha_extractor.EXTRACTABLE_SECTIONS)
|
||||
)
|
||||
24
mcp-server/tests/test_metrics_median.py
Normal file
24
mcp-server/tests/test_metrics_median.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Test for #84.7 — _median helper used by the queue-metrics time-per-item proxy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import metrics
|
||||
|
||||
|
||||
def test_median_empty_is_none():
|
||||
assert metrics._median([]) is None
|
||||
assert metrics._median([None, None]) is None
|
||||
|
||||
|
||||
def test_median_odd():
|
||||
assert metrics._median([3.0, 1.0, 2.0]) == 2.0
|
||||
|
||||
|
||||
def test_median_even_averages_middle():
|
||||
assert metrics._median([4.0, 1.0, 3.0, 2.0]) == pytest.approx(2.5)
|
||||
|
||||
|
||||
def test_median_ignores_none():
|
||||
assert metrics._median([None, 5.0, None, 1.0, 3.0]) == 3.0
|
||||
29
mcp-server/tests/test_nevo_corpus_audit.py
Normal file
29
mcp-server/tests/test_nevo_corpus_audit.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Tests for #86.2 — pure marker classifiers in scripts/nevo_corpus_audit.py.
|
||||
|
||||
Distinguishes the harmful editorial-ratio markers (מיני-רציו / מבזק) from benign
|
||||
Nevo citation-list markers (חקיקה שאוזכרה / ספרות). Offline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts"))
|
||||
import nevo_corpus_audit as n # noqa: E402
|
||||
|
||||
|
||||
def test_has_marker_detects_any_nevo_marker():
|
||||
assert n._has_marker("חקיקה שאוזכרה: חוק התכנון והבניה")
|
||||
assert n._has_marker("מיני-רציו: העותר לא הוכיח")
|
||||
assert not n._has_marker("פסק-דין רגיל ללא מטא-דאטה של נבו")
|
||||
|
||||
|
||||
def test_has_editorial_only_for_holdings_markers():
|
||||
# editorial = the harmful family (a holdings summary that could be mistaken
|
||||
# for our own extracted holding)
|
||||
assert n._has_editorial("מיני-רציו: ...")
|
||||
assert n._has_editorial("מבזק: בית המשפט קבע")
|
||||
# a bare citation list is NOT editorial — benign
|
||||
assert not n._has_editorial("חקיקה שאוזכרה: חוק התכנון והבניה, סע' 197")
|
||||
assert not n._has_editorial("פסקי דין שאוזכרו: בר\"מ 2340/02")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user