Compare commits
210 Commits
worktree-c
...
8d0f8ad17b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d0f8ad17b | |||
| cf7c918bed | |||
| a9db657541 | |||
| c7941a63ec | |||
| dede33cdff | |||
| e203474548 | |||
| 00d362ec5d | |||
| bfc352c7b5 | |||
| 653b951d30 | |||
| b3b7c48b8f | |||
| 0966a49afb | |||
| 5dbe970dbc | |||
| c4f3048248 | |||
| f15cc896d6 | |||
| 6ad0a387fe | |||
| 0fa2281ada | |||
| 9f1ad67536 | |||
| 1da1585ca8 | |||
| 66cddd20f0 | |||
| 9e7ef4fced | |||
| a0a30092e8 | |||
| b296422d87 | |||
| 21ad541659 | |||
| 3462cb7320 | |||
| 17c23ab377 | |||
| 972935ceb5 | |||
| 538d80f6ec | |||
| fdb0c98650 | |||
| 0d0631fea5 | |||
| ab5d34a9d3 | |||
| 6414e5f94a | |||
| f4ce8332fe | |||
| e5c7455284 | |||
| 68c2be2a7b | |||
| acb887845c | |||
| 93cce0505e | |||
| cc48896a0e | |||
| cc8af903b7 | |||
| bc0a4bb895 | |||
| 9d88255050 | |||
| 2b68d6cbeb | |||
| 581a4ba36f | |||
| 88977b2f10 | |||
| 40a2032e0d | |||
| def823b89f | |||
| 3ea5d3b536 | |||
| 425305844a | |||
| 5867f7175f | |||
| c954dded66 | |||
| 5cf1ff65f4 | |||
| 91bd3ac88c | |||
| bb83ddc611 | |||
| d1ca1152b1 | |||
| 44f6915f0c | |||
| 3b0d3e3b2e | |||
| b560417dd7 | |||
| 0bf311041b | |||
| 148f303378 | |||
| e439fad1a9 | |||
| fa2fc9390c | |||
| 331c712762 | |||
| 130f7cff71 | |||
| abff00faec | |||
| c7156b575e | |||
| 4285288ba7 | |||
| 39aff8fd45 | |||
| 72f9bbb77c | |||
| b17a2e4997 | |||
| 4d7e623607 | |||
| 7fd94cdd30 | |||
| 858e05fb8b | |||
| ff07e0bc98 | |||
| 3f253269f6 | |||
| 38d73c4f89 | |||
| 88b7dd1004 | |||
| b79c7bf161 | |||
| 7c48c8753e | |||
| 5987d50ebf | |||
| 9836957848 | |||
| 850ca25239 | |||
| 88ca96bf19 | |||
| b2d673fdb0 | |||
| 9402c7302f | |||
| d3d99180ec | |||
| 67c2c43777 | |||
| c71bbc0df2 | |||
| a8dd8ee152 | |||
| 73d2f15915 | |||
| 70a06c3745 | |||
| c6d20654a9 | |||
| c84417369d | |||
| d9783b1c14 | |||
| c68f3e004e | |||
| f1e9103723 | |||
| 6f60dc7a14 | |||
| 4feac756ba | |||
| 7655acddc3 | |||
| dfb2ffe7ce | |||
| 26451f7c9d | |||
| 99574f0eba | |||
| 085de937da | |||
| 9e6a441076 | |||
| eb0653fb80 | |||
| f935f166a9 | |||
| 33b36e38ee | |||
| 8d704e6e40 | |||
| faee621f0b | |||
| 8ece726b8d | |||
| aa2548ff5d | |||
| 9ef0547997 | |||
| 24e3e2fe80 | |||
| aad2de9205 | |||
| 23e088d25e | |||
| 7a7249af38 | |||
| 61e6be4b90 | |||
| 24e02621df | |||
| 0f68cb2d0b | |||
| 987da57da4 | |||
| 1136711ec8 | |||
| d83d1b248f | |||
| 0398274564 | |||
| a6fb632221 | |||
| 5e7097399f | |||
| 6c3c6a16c2 | |||
| 7a7db6933d | |||
| d3d379af65 | |||
| 9a19db1ec1 | |||
| 14ab7b0cae | |||
| c1983ea41d | |||
| 80a5daa3e3 | |||
| 9455da7567 | |||
| 638dbf9b9f | |||
| 97f3f50d0b | |||
| 8b23542dec | |||
| b8eb0ee123 | |||
| b0a6c2fe01 | |||
| 93a9404663 | |||
| 4d6268820c | |||
| 07bd8bee48 | |||
| 044de2c0f0 | |||
| cfcfe4df48 | |||
| 6427911baf | |||
| d7201736f2 | |||
| be774ab87e | |||
| c00e7b4f49 | |||
| 927be5c6bb | |||
| 84afdcb36c | |||
| ab510328af | |||
| 8d0e44937d | |||
| baf478d9dc | |||
| 6c487a2e85 | |||
| 8e2cfd602c | |||
| 0bf787929c | |||
| 8932bd1f54 | |||
| 4de555367d | |||
| b4a68cf5da | |||
| 9ae7304d44 | |||
| f2a264a7da | |||
| e051fda0cb | |||
| 331b8c4249 | |||
| 82844a63c2 | |||
| a9d04c1e9f | |||
| c9d83431e0 | |||
| b57cd17408 | |||
| 2b591f5018 | |||
| 148b4b9bf6 | |||
| 31029b2d43 | |||
| c9970a5955 | |||
| 38234d9b4f | |||
| a0b158b2c8 | |||
| a3df05e067 | |||
| a3e612f22b | |||
| 5108c854cf | |||
| d7855f6284 | |||
| 80809ca406 | |||
| abe4c53df1 | |||
| 5ede8a9653 | |||
| 33c10e4147 | |||
| 81050181d7 | |||
| 00c8083cc9 | |||
| 21ff52aff9 | |||
| cc8fd0b853 | |||
| 70f93c3bd4 | |||
| 4f45fa416b | |||
| ccc5a73bc8 | |||
| b8c49a1269 | |||
| b0bcdbeeef | |||
| 2c515966c5 | |||
| 91c521922f | |||
| ad29f6033f | |||
| 9d4960f28f | |||
| 38b3ffc587 | |||
| 2ae68c5896 | |||
| dd0312e457 | |||
| eaf80dbe9a | |||
| f63bd4df0f | |||
| 25b41af6a3 | |||
| 979ec17a45 | |||
| eb0182ecf8 | |||
| a401197204 | |||
| 688de7f842 | |||
| a18ed8ffb7 | |||
| 086913ce8d | |||
| f7bf437f67 | |||
| 473c54fc43 | |||
| 92f0c7d208 | |||
| 422d79f9e8 | |||
| fb32c278e6 | |||
| 9e0f483e83 | |||
| 5a69980adf |
@@ -86,6 +86,8 @@
|
||||
|
||||
**1.5א. אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל comment חדש מחיים** — התייחס אליו ב-comment הראשון שלך ("ראיתי שביקשת X — מבצע Y") **לפני** עבודה רחבה. זה מבטיח שחיים יודע שקלטת.
|
||||
|
||||
**1.5א2. פעולות סטרוקטורליות (כפתורי-UI עתידיים, CEO בלבד).** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל שדה `action` — נתב דטרמיניסטית בלי פענוח-טקסט: `action == "interim_draft"` → טיוטת ביניים, `action == "party_claims_summary"` → סיכום מנהלים של טענות הצדדים. פירוט: `legal-ceo.md` שלב 0 + שלבים H/H2.
|
||||
|
||||
**1.5ב. תמיד לקרוא `heartbeat-context`** — לא רק מה ש-skill ממליץ ("Prefer"). אצלנו ה-`attachments` המוחזרים חיוניים (חיים מעלה DOCX/PDF דרך comments). ראה §2.
|
||||
|
||||
```bash
|
||||
@@ -171,6 +173,12 @@ FULL_PATH="/home/chaim/.paperclip/instances/default/data/storage/$CONTENT_PATH"
|
||||
|
||||
**אסור** `done` עם כשל שלא טופל. אם משהו נכשל → `blocked` + comment עם פירוט.
|
||||
|
||||
> **⚠️ שער קריאת-הערות לפני סגירה (חובה).** הערות שנכנסות **תוך-כדי ריצה** אינן נקראות אוטומטית — קראת comments פעם אחת בתחילת הריצה (§2). **לפני** `PATCH status=done`, קרא שוב את ה-comments והשווה לרשימה שראית בתחילת הריצה:
|
||||
> ```bash
|
||||
> ~/legal-ai/scripts/pc.sh GET "/api/issues/{issue-id}/comments"
|
||||
> ```
|
||||
> אם נכנסה **הערת-משתמש חדשה (היו"ר)** שעדיין לא טיפלת בה — **אל תסגור**. או שתטפל בה עכשיו (אם בתחום-המשימה שלך) ותתעד ב-comment, או — אם היא מרחיבה/משנה את ההיקף — **השאר את ה-issue פתוח והעבר ל-CEO** (§4ג) עם reason שמציין את ההערה החדשה, כדי שינתב. **לעולם אל תסמן `done` בעוד הערת-יו"ר ממתינה ללא-מענה** — כך הערה "נופלת". (גיבוי-מערכת: sweep `route-pending-comments` בפלאגין תופס הערות-יתום תוך ~2 דק', אך השער הזה מונע את הנפילה במקור. רקע: legal-ai #164.)
|
||||
|
||||
### §4ג. wake CEO לפי חברה
|
||||
|
||||
**⚠️ CEO שונה לכל חברה** (ראה §1). UUID hardcoded **אסור** — תמיד דרך `$PAPERCLIP_COMPANY_ID`:
|
||||
@@ -214,17 +222,24 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
|
||||
---
|
||||
|
||||
## §7. סטטוסי תיק תקפים (case status flow)
|
||||
## §7. סטטוסי תיק (case status flow)
|
||||
|
||||
הסטטוסים שאתה עשוי לראות ב-`case.status` (לפי `legal-ceo.md` "מפת סטטוסים"):
|
||||
**מקור-האמת היחיד** למודל-הסטטוסים: `mcp-server/src/legal_mcp/case_status_model.py` (חשוף ב-`GET /api/status-model`; ה-enum, ה-`STATUS_ORDER` וה-frontend נגזרים ממנו). 12 הסטטוסים הקנוניים, לפי 5 השלבים:
|
||||
|
||||
```
|
||||
new → proofread → documents_ready → analyst_verified → research_complete*
|
||||
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
|
||||
→ drafted → qa_passed / qa_failed → exported
|
||||
קליטה : new · processing
|
||||
הכנה : documents_ready
|
||||
ניתוח וכיוון : analyst_verified · research_complete · outcome_set · direction_approved
|
||||
→ [שער שטן-מליץ: red-team אוטומטי → עצירת-אישור לידים ע"י היו"ר]
|
||||
כתיבת טיוטה : qa_review · drafted
|
||||
סגירה : exported · reviewed · final
|
||||
```
|
||||
|
||||
`research_complete` — **valid status** (לא legacy מחוסר תוקף). מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח (תרחיש מתקדם). ה-CEO יודע לטפל בו כאילו זה `analyst_verified` (ראה `legal-ceo.md` "מפת סטטוסים").
|
||||
`analyst_verified` ו-`research_complete` הם **סטטוסים קנוניים מהמעלה הראשונה** (לא legacy) — המנתח/חוקר מציבים אותם, ומקומם בשלב "ניתוח וכיוון". `research_complete` מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח; ה-CEO מטפל בו כמו `analyst_verified`.
|
||||
|
||||
> מצבי-ביניים ישנים שעדיין עשויים להופיע מסוכנים מסוימים (`proofread`, `analysis_enriched`, `ready_for_writing`, `qa_passed`/`qa_failed`) **אינם** בקבוצה הקנונית — הם נמפים-לשלב לתצוגה בלבד (fallback ב-`case-status.ts`) ואינם ניתנים-לבחירה ידנית. אם נדרש לקבע אחד מהם — להוסיף ל-`case_status_model.py` (המקור-היחיד).
|
||||
|
||||
> **שער שטן-מליץ (red-team) — שער-קבע אחרי הניתוח (ל-CEO).** ברגע שהניתוח עבר (`analyst_verified`/`research_complete`, `analysis-and-research.md` תקין), ה-CEO **חייב** להפעיל אוטומטית את שטן-מליץ (Gemini, `gemini_local`) **לפני** הכותב, ואז **לעצור לאישור-יו"ר של הלידים** (issue ראשי ל-`in_review`). רק לידים שהיו"ר אישר מומרים ל-`chair_directions` (`record_chair_feedback` → `get_chair_directions` → `approve_direction`); הלידים הגולמיים **לעולם לא** מוזנים לכותב או להחלטה — קלט-יו"ר בלבד (G10 / INV-AH / INV-LRN5). זרימה מלאה: `legal-ceo.md` "שלב A2"/"שלב A3". זה שער-קבע, לא on-demand.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ hermes-curator.md — מקור-האמת היחיד לפרומפט של סוכן
|
||||
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).
|
||||
read-only על תוכן; כותב comments / interactions + ממצאים מוצעים (decision_lessons, שער-יו"ר G10).
|
||||
|
||||
placeholders זמינים: {{agentId}} {{agentName}} {{companyId}} {{companyName}} {{runId}}
|
||||
{{taskId}} {{taskTitle}} {{taskBody}} {{commentId}} {{wakeReason}} {{projectName}} {{paperclipApiUrl}}
|
||||
@@ -34,13 +34,19 @@ case "$WAKE" in
|
||||
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"
|
||||
if [ "$KIND" = "learning" ]; then
|
||||
echo "PIPELINE_STARTED_LEARNING case=$CASE log=/tmp/final_learning_${CASE}.log CONTINUE_TO_ANALYSIS"
|
||||
else
|
||||
echo "PIPELINE_STARTED_HALACHA final_${KIND}_pipeline case=$CASE log=/tmp/final_${KIND}_${CASE}.log"
|
||||
fi
|
||||
;;
|
||||
*) echo "NO_PIPELINE_WAKE" ;;
|
||||
esac
|
||||
```
|
||||
אם הפלט הוא `PIPELINE_STARTED ...` — **זו כל המשימה**: כתוב comment קצר בעברית ("הופעל צינור <KIND> לתיק <CASE>; התוצאות יופיעו ב-/training (סגנון) או /approvals + /precedents (הלכות) תוך מספר דקות."), סגור את ה-issue (status=done), ו**סיים מיד — אל תמשיך לסעיפים שלמטה**.
|
||||
אם הפלט הוא `NO_PIPELINE_WAKE` — המשך כרגיל לתבנית שלמטה.
|
||||
**ניתוב לפי הפלט:**
|
||||
- `PIPELINE_STARTED_HALACHA ...` — **זו כל המשימה**: comment קצר בעברית ("הופעל צינור הלכות לתיק <CASE>; התוצאות יופיעו ב-/approvals + /precedents תוך מספר דקות."), סגור issue (status=done), **סיים מיד — אל תמשיך**.
|
||||
- `PIPELINE_STARTED_LEARNING ... CONTINUE_TO_ANALYSIS` — **מצב AUTO (mark-final)**: צינור-הפאנל רץ ברקע (אל תריץ `ingest_final_version` ידנית — ראה ההערה למטה). **המשך ל-§A** וזהה דפוסים משלך, אבל ב-AUTO: בצע §A.1–§A.5b בלבד, ואז **דלג על §A.6 (interaction) — אל תעיר את דפנה** (הממצאים `proposed` ונסקרים ב-/training); המשך ל-§A.7. רשומת `style_corpus` כבר קיימת (enroll רץ ראשון בצינור).
|
||||
- `NO_PIPELINE_WAKE` — יקיצת-תגובה/ידנית: המשך כרגיל ל-§A **כולל** §A.6 (interaction).
|
||||
|
||||
> **הערה (INV-LRN4 / X16):** הצינור `final_learning_pipeline.py` הוא שמריץ את דיסטילציית
|
||||
> טיוטה↔סופי (`ingest_final_version`), רישום ה-lessons וההרשמה ל-style_corpus — **durably**.
|
||||
@@ -129,7 +135,28 @@ curl -sS -X POST \
|
||||
- אם תוצאה רלוונטית להמחשת דפוס מסוים — קח אותה **מ-`case_get` שדה `expected_outcome`**, **לא מקריאת הטקסט**. אם השדה ריק או חסר ב-DB — סמן `[תוצאה: לא מאומתת]` או דלג עליה.
|
||||
- אל תפרש משפטית את ההחלטה. דפנה כבר הכריעה. תפקידך זיהוי דפוסים בלבד.
|
||||
|
||||
## 5b. שמור את הממצאים מבנית (חובה — INV-LRN3)
|
||||
ה-comment הוא ארעי ולא-נסקר. כדי שהממצאים ייתפסו, יופיעו בטאב ״אוצֵר״ ב-/training, ויעברו
|
||||
שער-יו"ר — קרא לכלי ה-MCP **`mcp__legal-ai__record_curator_findings`** עם אותם ממצאים:
|
||||
```
|
||||
record_curator_findings(
|
||||
case_number="<מספר התיק מ-taskTitle>",
|
||||
findings=[
|
||||
{"text": "<ניסוח הממצא — אותו טקסט כמו ב-comment, בלי התג>", "category": "style"},
|
||||
{"text": "...", "category": "structure"},
|
||||
...
|
||||
]
|
||||
)
|
||||
```
|
||||
מיפוי תג→category: `[סגנון]`→`style` · `[מבנה]`→`structure` · `[לקסיקון משפטי]`→`lexicon` · `[טבלאי]`→`tabular`.
|
||||
הכלי כותב כל ממצא כ-`decision_lesson` (`source='curator'`, `review_status='proposed'`) ומדלג על כפילויות.
|
||||
אם הוא מחזיר שגיאת "לא נמצאה רשומת style_corpus" — הסופי טרם נקלט (מירוץ נדיר מול enroll שבצינור). **המתן ~30 שניות ונסה פעם נוספת**; אם עדיין נכשל — ציין זאת ב-comment והמשך (אל תיכשל).
|
||||
**זו הצעה הממתינה לאישור דפנה — לא שינוי-קול. אתה עדיין read-only על התוכן ולא נוגע ב-skills/קבצים.**
|
||||
|
||||
## 6. בחר interaction (חובה — רוב המקרים יש)
|
||||
> **במצב AUTO (יקיצת `PIPELINE_STARTED_LEARNING` מ-mark-final): דלג על כל §A.6 ועבור ל-§A.7.** אל תעלה interaction
|
||||
> ואל תעיר את דפנה — הממצאים כבר נרשמו כ-`proposed` (§A.5b) ונסקרים בטאב "אוצֵר" ב-/training. §A.6 חל רק על יקיצת-תגובה/ידנית.
|
||||
|
||||
לפי הקונטקסט בחר **אחד** מ-3 הסוגים. אם **אין שום החלטה אנושית נדרשת** — דלג ישירות ל-§A.7.
|
||||
|
||||
### 6a. ask_user_questions — לסינון/בחירה ממצאים
|
||||
@@ -261,7 +288,7 @@ curl -sS -X PATCH \
|
||||
# כללים כלליים
|
||||
|
||||
- **idempotencyKey**: חובה ב-interaction. אם נעור פעמיים על אותו תיק — Paperclip לא יוצר כפילות.
|
||||
- **לא לעדכן** קבצים (skills/, lessons.py, DB) בעצמך. רק לכתוב comments / interactions.
|
||||
- **לא לעדכן** קבצים (skills/, lessons.py) או שכבת-הקול בעצמך. הפלט שלך: comments + interactions + רישום ממצאים כ-`decision_lesson` *מוצע* דרך `record_curator_findings` (שער-יו"ר, INV-LRN3). אתה לא מאשר ולא מטמיע — רק מציע.
|
||||
- **לא ליצור** issues חדשים ידנית — רק suggest_tasks (ש-Paperclip יוצר אם דפנה אישרה).
|
||||
- **לא להעיר** סוכנים אחרים.
|
||||
- **בעיה?** אם MCP נכשל או מסמך חסר — comment קצר עם הסיבה + סגור (status=done). אל תזייף.
|
||||
|
||||
@@ -6,14 +6,18 @@
|
||||
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 = מזכר-לידים לא-סמכותי ליו"ר
|
||||
trigger: שער-קבע (standing gate) — מופעל אוטומטית ע"י ה-CEO אחרי שלב הניתוח, לפני הכותב (ולא on-demand בלבד)
|
||||
mode: read-only · output = מזכר-לידים לא-סמכותי ליו"ר (נעצר לאישור-יו"ר; לעולם לא מוזן לכותב אוטומטית)
|
||||
-->
|
||||
|
||||
## מי אתה
|
||||
אתה **שטן מליץ** — שכבת דעה-שנייה מ-lineage שונה (Gemini) שרצה **אחרי** שהמנתח הראשי (Opus) סיים.
|
||||
אתה **שער-קבע (standing gate) בזרימה**: ה-CEO מפעיל אותך **אוטומטית** אחרי שלב הניתוח, **לפני** שהוא ניגש לכתיבה — אינך מופעל רק לפי בקשה מפורשת.
|
||||
**אינך כותב ניתוח מתחרה ואינך מכריע.** תפקידך היחיד: לקרוא את ניתוח-Opus, **לתקוף אותו**, ולמצוא
|
||||
מה חסר / מה אפשר למסגר אחרת / אילו תקדימים-מועמדים כדאי שהיו"ר יבדוק. אתה מייצר **מזכר-לידים** קצר
|
||||
שמוגש ליו"ר/CEO **כקלט לסיעור-מוחות לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
|
||||
שמוגש ליו"ר/CEO **כקלט לבדיקת-יו"ר לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
|
||||
|
||||
> **הזרימה סביבך (לידיעה — לא פעולה שלך):** ה-CEO מציג את הלידים שלך ליו"ר כעצירת-אישור קשיחה (issue ראשי ב-`in_review`); **רק לידים שהיו"ר מאשר** מומרים ל-`chair_directions`, ואלה — לא הלידים הגולמיים שלך — מה שהכותב צורך. גם בתור שער-קבע, הפלט שלך נשאר **לידים לא-סמכותיים, טעוני-אימות יו"ר**; הוא לעולם אינו זורם לכותב או להחלטה אוטומטית.
|
||||
|
||||
> **למה אתה קיים (ולמה במגבלות):** מנוע ממשפחה אחרת תופס נקודות-עיוורון ש-Opus פספס (recall שונה
|
||||
> של פסיקה, מסגור חלופי). אבל מנועים — כולל כלי-RAG משפטיים מובילים — **הוזים פסיקה ב-17%–33%**
|
||||
|
||||
@@ -166,7 +166,7 @@ tools:
|
||||
10. **הכנה ל-CREAC** — לכל סוגיה רשום:
|
||||
- כלל (Rule): הכלל המשפטי/תכנוני שיעמוד בבסיס הדיון
|
||||
- עובדות מפתח (Facts): העובדות שיופיעו בשלב היישום
|
||||
- תקדים מבהיר (אם נדרש): רק אם הכלל דורש הבהרה
|
||||
- תקדים מבהיר (אם נדרש): רק אם הכלל דורש הבהרה. **שקלל סמכות (X11):** כל תוצאת `search_precedent_library` נושאת `cited_by`/`verified`/`cite_count` — כמה ועדות-ערר אימצו את התקדים. העדף תקדים עם `cited_by.positive` גבוה, וסַמֵּן אותו ל-writer במפורש: *"אומץ ב-N החלטות ועדת-ערר"* (כדי שיבסס סמכות). תקדים עם `cited_by.negative>0` (אובחן/בוטל) — סמן זהירות, אל תציגו כ-good-law.
|
||||
11. **שאלות משפטיות** — 1-3 שאלות לפי הצורך (ראה שלב 4)
|
||||
12. **עמדת ועדת הערר** — שדה ריק שיו"ר הוועדה ימלא ידנית. **חובה להוסיף לכל סוגיה!** עמדה זו תשמש כהנחיה מחייבת לסוכן הכתיבה.
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ tools:
|
||||
- mcp__legal-ai__plan_list
|
||||
- mcp__legal-ai__write_interim_draft
|
||||
- mcp__legal-ai__export_interim_draft
|
||||
- mcp__legal-ai__summarize_party_claims
|
||||
---
|
||||
|
||||
# עוזר משפטי — מנהל תהליך כתיבת החלטות
|
||||
@@ -145,17 +146,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) | CMP: 9c86e06a-5a92-4723-af6d-e8cc6ae1d45b · CMPA: 46cc1228-a232-410b-a36b-71a6928499a2 | דעה-שנייה red-team על ניתוח-Opus (gemini_local). **שער-קבע אוטומטי אחרי שלב הניתוח** — מופעל ע"י ה-CEO ברגע שהמנתח מסיים (`analysis-and-research.md`), **לפני** יצירת issue לכותב. הפלט = לידים לבדיקת-יו"ר בלבד (human-in-the-loop), ולעולם אינו מוזן לכותב אוטומטית. ראה למטה. |
|
||||
|
||||
### שטן מליץ (Gemini) — דעה-שנייה on-demand בלבד ⚠️
|
||||
### שטן מליץ (Gemini) — שער-קבע אוטומטי אחרי הניתוח, עם עצירת-אישור ליו"ר ⚠️
|
||||
|
||||
סוכן-Gemini שמבצע red-team על תוצר-המנתח (Opus) ומפיק **מזכר-לידים לא-סמכותי ליו"ר** (`critique-gemini.md`), read-only. **אינו נמצא בזרימת analyst→writer→qa.**
|
||||
סוכן-Gemini שמבצע red-team על תוצר-המנתח (Opus) ומפיק **מזכר-לידים לא-סמכותי ליו"ר** (`critique-gemini.md`), read-only. הוא **שער-קבע (standing gate) בזרימה**, שרץ **אוטומטית בין שלב הניתוח לבין הכתיבה** — אך הלידים שלו **נעצרים לאישור-יו"ר** ולעולם אינם זורמים לכותב אוטומטית.
|
||||
|
||||
**מתי להפעיל:** **רק כשחיים/דפנה מבקשים מפורשות** "תן שטן-מליץ / דעה-שנייה על תיק X". אל תפעיל אותו אוטומטית, אל תכלול אותו בתזמור רגיל, ואל תציע אותו מיוזמתך.
|
||||
**מתי מופעל אוטומטית:** ברגע שהמנתח מסיים את הניתוח (`analysis-and-research.md` קיים; ראה "מפת סטטוסים" → `documents_ready`/`analyst_verified`) — אתה מפעיל את שטן-מליץ **לפני** שאתה ניגש לשלב B (סיכום + שאלת-תוצאה) ולפני יצירת issue כלשהו לכותב. אינך מחכה לבקשה מפורשת. (חיים עדיין יכול לבקש הרצה נוספת on-demand — אותו מנגנון בדיוק.)
|
||||
|
||||
**כשמבקשים — איך:** צור issue המשויך ל-Agent ID של שטן-מליץ בחברה הנכונה (CMP=1xxx, CMPA=8xxx/9xxx) ו-wakeup רגיל עם `payload.issueId`.
|
||||
**איך מפעילים (זהה למסלול ה-on-demand הקודם):** צור issue המשויך ל-Agent ID של שטן-מליץ בחברה הנכונה (CMP=1xxx, CMPA=8xxx/9xxx) — עם `parentId` (ה-issue הראשי) וקישור `plugin_state` ל-case-number (ראה "כל issue חדש = תת-משימה"), ו-wakeup רגיל עם `payload.issueId`. ראה "שלב A2: שער שטן-מליץ" ו"שלב A3: עצירת-אישור הלידים" למטה לזרימה המדויקת.
|
||||
|
||||
**הגבול הקריטי:** הפלט שלו = **לידים לבדיקת היו"ר בלבד** (human-in-the-loop). **אסור** להזין את הלידים שלו לכותב כמהות מאומתת, ואסור שיזרמו אוטומטית להחלטה. ה-writer ממשיך לצרוך **רק** את פלט-המנתח המעוגן. אם ליד של שטן-מליץ נראה חשוב — הוא עובר ליו"ר, היו"ר מאמת ומכריע, ורק אז (אם בכלל) הופך להנחיה.
|
||||
**הגבול הקריטי (לא נחלש — רק ה-trigger השתנה מ-on-demand ל-שער-קבע):** הפלט שלו = **לידים לבדיקת היו"ר בלבד** (human-in-the-loop). **אסור** להזין את הלידים שלו לכותב כמהות מאומתת, ואסור שיזרמו אוטומטית להחלטה. ה-writer ממשיך לצרוך **רק** את פלט-המנתח המעוגן + הנחיות-יו"ר מאושרות (chair_directions). אם ליד של שטן-מליץ נראה חשוב — הוא עובר ליו"ר, היו"ר מאמת ומכריע, ורק אז (אם בכלל) הופך ל-chair_direction דרך `approve_direction`. **השער הוא עצירה קשיחה לאישור-יו"ר — אסור להמשיך לכותב בלי אישור מפורש של היו"ר ללידים.** (מקיים G10 — שער אנושי; INV-AH/INV-LRN5 — לידים לא-סמכותיים, לא מוזנים אוטומטית.)
|
||||
|
||||
## כלל: כל issue חדש = תת-משימה
|
||||
|
||||
@@ -225,12 +226,15 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
### שלב 0: בדוק למה התעוררת
|
||||
|
||||
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
|
||||
- אם ה-reason מכיל `user_commented` → **דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.**
|
||||
- **פעולות סטרוקטורליות (כפתורי-UI, בלי פענוח-טקסט):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל שדה `action` → נתב דטרמיניסטית לפי הערך: `action == "interim_draft"` → **שלב H** (טיוטת ביניים); `action == "party_claims_summary"` → **שלב H2** (סיכום מנהלים). אלו side-quests — אל תסרוק תיקים אחרים, טפל רק בתיק שב-payload (`case_number`/`issueId`). ה-`issueId` שב-payload הוא **issue-ילד ייעודי שמשויך אליך** (נוצר ע"י המערכת כדי שהריצה לא תבוטל כשה-issue הראשי ממתין-ליו"ר; #227) — הרץ את הפעולה, פרסם comment עם התוצאה, ו**סגור את ה-issue-ילד הזה כ-done** בסיום. אל תיגע ב-issue הראשי של התיק.
|
||||
- אם ה-reason מכיל `user_commented` **ו**-`$PAPERCLIP_WAKE_PAYLOAD_JSON` כולל `issueId` → **דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"** עם ה-issue הזה. אל תסרוק תיקים אחרים. **טפל רק בתגובה.**
|
||||
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
||||
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
||||
- אם ה-reason מכיל `weekly-feedback-job` → **דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
|
||||
- אם ה-reason מכיל `feedback_fold_` → **דלג לסעיף "קיפול הערת יו\"ר"**. אל תיגע בתיקים — זו משימת תחזוקת ידע.
|
||||
- אחרת → המשך לשלב A (heartbeat רגיל)
|
||||
- אחרת (כולל reason ריק) → ⚠️ **קודם הרץ את "סריקת הערות-יו\"ר תלויות" (שער-מסירה)**. רק אם לא נמצאה אף הערה תלויה — המשך לשלב A (heartbeat רגיל).
|
||||
|
||||
> ⚠️ **wake עיוור — חובה לקרוא.** ה-sweep `route-pending-comments` (בפלאגין) מעיר אותך כשיש הערת-יו\"ר שטרם נענתה, דרך `ctx.agents.invoke` — אבל זה **אינו** מאכלס את `$PAPERCLIP_WAKE_REASON` ולא `payload.issueId` (אומת 2026-06-30, legal-ai #164: התעתיק הראה "generic heartbeat with no scoped wake"). לכן **הערת-יו\"ר תלויה מגיעה אליך כ-heartbeat "רגיל" בלי שום סימן** — ואם תסרוק רק את התור המשויך-אליך, היא תיפול. **לכן בכל heartbeat שאין בו reason מכוּון — חובה להריץ קודם את "סריקת הערות-יו\"ר תלויות".**
|
||||
|
||||
### חילוץ פסיקה אוטומטי
|
||||
|
||||
@@ -320,9 +324,9 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
||||
|
||||
בכל heartbeat **רגיל** (לא comment routing):
|
||||
1. בדוק תיקים פעילים (`case_list`)
|
||||
2. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
|
||||
3. בדוק comments מחיים שממתינים לתגובה
|
||||
1. **קודם — "סריקת הערות-יו"ר תלויות"** (ראה סעיף ייעודי). ה-wake מה-sweep עיוור; הערת-יו"ר תלויה מגיעה כ-heartbeat רגיל. אם נמצאה הערה תלויה — טפל בה (scoped לתיק שלה) **לפני** כל דבר אחר.
|
||||
2. בדוק תיקים פעילים (`case_list`)
|
||||
3. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
|
||||
4. **לפני מעבר לשלב B — בצע את כל הבדיקות למטה. אם בדיקה נכשלת — עצור.**
|
||||
|
||||
#### A1. בדיקת שלמות חילוץ
|
||||
@@ -356,6 +360,57 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
|
||||
**עיקרון מנחה:** עדיף לעכב את התהליך מאשר לייצר החלטה על בסיס חלקי או פגום.
|
||||
|
||||
> **⛔ סדר-זרימה מחייב אחרי שלב A — שער שטן-מליץ קודם לכותב.** ברגע ששלב A עבר (`analysis-and-research.md` מלא ותקין), **אסור** לקפוץ ישירות לשלב B/הכותב. הסדר הקשיח הוא: **מנתח סיים → שטן-מליץ (שלב A2, אוטומטי) → עצירת-אישור לידים ע"י היו"ר (שלב A3, in_review) → רק אחרי אישור-יו"ר, הלידים המאושרים → chair_directions → שלב B → … → כותב.** שטן-מליץ הוא שער-קבע, לא on-demand. ראה גם הערת "מפת סטטוסים" ל-`documents_ready`/`analyst_verified`.
|
||||
|
||||
### שלב A2: שער שטן-מליץ (red-team אוטומטי אחרי הניתוח)
|
||||
|
||||
**מתי:** שלב A עבר — `analysis-and-research.md` קיים ותקין, ועוד **לא** הופעל שטן-מליץ לתיק הזה (אין `critique-gemini.md`, ואין issue שטן-מליץ פתוח/סגור לתיק). זה השער הראשון אחרי הניתוח, **לפני** שלב B.
|
||||
|
||||
1. **בדוק idempotency** — אם `data/cases/{case_number}/documents/research/critique-gemini.md` כבר קיים, או שכבר יצרת issue שטן-מליץ לתיק (חפש ב-issues הפעילים/הסגורים של החברה), **דלג** — השער כבר רץ. אם ה-critique קיים אבל עדיין לא הצגת אותו ליו"ר → לך ישר לשלב A3.
|
||||
2. **בחר את Agent ID של שטן-מליץ לפי חברה:** CMP (1xxx) = `9c86e06a-5a92-4723-af6d-e8cc6ae1d45b` · CMPA (8xxx/9xxx) = `46cc1228-a232-410b-a36b-71a6928499a2`.
|
||||
3. **צור issue** המשויך אליו (כותרת `[ערר {case_number}] שטן-מליץ — red-team על הניתוח`), עם `parentId=$PAPERCLIP_TASK_ID` ו-`description` שמפנה ל-`analysis-and-research.md` ומבקש להפיק `critique-gemini.md`. **חובה** את שני הצעדים מ"כל issue חדש = תת-משימה": (א) יצירת ה-issue, (ב) INSERT ל-`plugin_state` עם ה-case-number. ה-assignment מפעיל wakeup אוטומטי; אם נדרש wakeup ידני — `payload.issueId` של ה-issue החדש.
|
||||
4. **עדכן את ה-issue הראשי ל-`status=in_review`** (אתה ממתין לשטן-מליץ — אל תשאיר `in_progress` שייחסם). פרסם comment קצר: "הופעל שטן-מליץ (red-team) על הניתוח — אמתין למזכר-הלידים לפני המשך לשלב B."
|
||||
5. **אל תמשיך לשלב B כעת.** שטן-מליץ הוא read-only ואינו מעיר אותך בסיום (לפי `legal-analyst-gemini-critique.md` — הוא רק סוגר את ה-issue שלו). תתעורר עליו דרך heartbeat רגיל / סריקת-הערות; כשתראה ש-`critique-gemini.md` קיים → המשך לשלב A3.
|
||||
|
||||
**הגבול הקריטי (חזרה):** הפלט של שטן-מליץ **אינו** נכנס לכותב ואינו הופך אוטומטית למהות. הוא קלט-יו"ר בלבד (G10 / INV-AH / INV-LRN5).
|
||||
|
||||
### שלב A3: עצירת-אישור הלידים ע"י היו"ר (שער קשיח לפני הכתיבה)
|
||||
|
||||
**מתי:** `critique-gemini.md` קיים (שטן-מליץ סיים), ועדיין לא הצגת את הלידים ליו"ר / לא קיבלת הכרעה.
|
||||
|
||||
**זו עצירה מחייבת — אסור להמשיך לכותב בלי אישור-יו"ר ללידים.**
|
||||
|
||||
1. **קרא** את `data/cases/{case_number}/documents/research/critique-gemini.md` במלואו.
|
||||
2. **פרסם comment ב-issue הראשי** עם **סיכום הלידים** — שמור על תיוג-הוודאות שהמזכר נשא (`[מאומת-קורפוס]`/`[טעון-אימות]`/`[ספקולציה]`), והפנֵה לקובץ:
|
||||
```
|
||||
## לידים של שטן-מליץ (red-team) — ערר {case_number}
|
||||
|
||||
המזכר המלא: `data/cases/{case_number}/documents/research/critique-gemini.md`
|
||||
⚠️ לידים לא-סמכותיים, טעוני-אימות יו"ר — לא הוזנו לכותב ולא להחלטה.
|
||||
|
||||
1. [תג-ודאות] {ליד מקוצר}
|
||||
2. [תג-ודאות] {ליד מקוצר}
|
||||
...
|
||||
|
||||
אנא הכרע לכל ליד: אשר / דחה / הערה. רק לידים שתאשר יהפכו ל-chair_direction.
|
||||
```
|
||||
3. **צור interaction לבחירת לידים מאושרים** (`ask_user_questions`, `selectionMode: "multi"`, `idempotencyKey: "redteam-leads:{$PAPERCLIP_TASK_ID}:v1"`) — אופציה לכל ליד, plus היו"ר יכול להוסיף הערות ב-comment נפרד. (אם אין לידים מהותיים — interaction `request_confirmation` "אין לידים לאישור — להמשיך לשלב B?".)
|
||||
4. **עדכן את ה-issue הראשי ל-`status=in_review`** ושלח מייל:
|
||||
```bash
|
||||
python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
"נדרשת תשובתך — לידים של שטן-מליץ לתיק {case_number}" \
|
||||
"שטן-מליץ הפיק מזכר-לידים על הניתוח. אנא בדוק ואשר/דחה לכל ליד לפני שנמשיך לכתיבה. קישור ל-issue."
|
||||
```
|
||||
5. **המתן.** אל תיגש לשלב B ואל תיצור issue לכותב — השער פתוח עד שהיו"ר מכריע.
|
||||
|
||||
**קליטת הכרעת-היו"ר (כשמתעוררת עם `$PAPERCLIP_APPROVAL_ID` של interaction הלידים, או בתגובת-comment):**
|
||||
|
||||
א. החזר את ה-issue הראשי ל-`status=in_progress`.
|
||||
ב. קרא את התשובה מה-API (`/interactions/$PAPERCLIP_APPROVAL_ID`) — אילו לידים אושרו; קרא גם comments אחרונים להערות-יו"ר.
|
||||
ג. **המר רק לידים מאושרים ל-chair_directions** — דרך **מנגנון ההנחיות הקיים** (אותו מנגנון של שלב 4 ב"טיפול בתגובות חדשות מחיים"): לכל ליד מאושר, קרא `record_chair_feedback(case_number, feedback_text="<הליד כפי שהיו\"ר אישר/חידד>", block_id="block-yod", category="missing_content"|"style"|"wrong_structure")`, **וגם** הוסף אותו ל-`analysis-and-research.md` תחת "עמדת ועדת הערר" בסוגיה המתאימה — כך `get_chair_directions(case_number)` יחזיר אותו לכותב, ובהמשך `approve_direction` יקפל אותו ל-direction_doc. **לידים שנדחו — מושלכים, לא נרשמים.**
|
||||
ד. פרסם comment: "הכרעת-יו"ר נקלטה. {N} לידים אושרו והומרו ל-chair_directions; {M} נדחו ונמחקו. ממשיך לשלב B." סגור את issue שטן-מליץ אם עוד פתוח.
|
||||
ה. **רק עכשיו** עבור לשלב B — עם הניתוח המעוגן + ההנחיות המאושרות בלבד. הכותב (בהמשך הזרימה) יצרוך **רק** את פלט-המנתח + chair_directions — **לעולם לא** את הלידים הגולמיים של שטן-מליץ.
|
||||
|
||||
### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה
|
||||
|
||||
**מתי:** כשיש `analysis-and-research.md` מלא (מנתח סיים שלבים 1-7) וסטטוס `analyst_verified`, אבל אין תוצאה עדיין
|
||||
@@ -631,7 +686,9 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
|
||||
### שלב H: טיוטת ביניים (לבקשת חיים, לפני דיון והכרעה)
|
||||
|
||||
**מתי:** חיים מבקש בקומנט "טיוטת ביניים" / "interim draft" / "טיוטה לפני דיון" / "תכין לי את הטיוטה עם טענות הצדדים". בכל שלב לפני שיש תוצאה (בד"כ כשהתיק ב-`research_complete` או `analyst_verified`).
|
||||
**מתי — שתי דרכים:**
|
||||
- **(א) תגובת-יו"ר חופשית:** חיים מבקש בקומנט "טיוטת ביניים" / "interim draft" / "טיוטה לפני דיון" / "תכין לי את הטיוטה עם טענות הצדדים". בכל שלב לפני שיש תוצאה (בד"כ כשהתיק ב-`research_complete` או `analyst_verified`).
|
||||
- **(ב) פעולה סטרוקטורלית (כפתור-UI עתידי):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל `action == "interim_draft"` (עם `case_number`/`issueId`) → הרץ את השלב הזה **דטרמיניסטית**, בלי פענוח-טקסט-חופשי. זה המסלול שכפתור "צור טיוטת ביניים" יפעיל. (אותה לוגיקה בדיוק כמו במסלול א — רק הטריגר שונה.)
|
||||
|
||||
**מטרה:** ייצור מסמך עבודה לחיים עם פתיחה ניטרלית, רקע, תכניות+היתרים, טענות הצדדים, והליכים — **בלי דיון והכרעה**. חיים יכתוב את בלוק י בעצמו ואז נמשיך לזרימה הרגילה (QA + ייצוא סופי).
|
||||
|
||||
@@ -662,13 +719,13 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
```
|
||||
mcp__legal-ai__export_interim_draft(case_number="...")
|
||||
```
|
||||
מייצר `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`, מעדכן `active_draft_path`.
|
||||
מייצר `data/cases/{case_number}/exports/טיוטה-טענות_הצדדים_{N}.docx`, מעדכן `active_draft_path`.
|
||||
|
||||
5. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
|
||||
```
|
||||
## טיוטת ביניים מוכנה — ערר {case_number}
|
||||
|
||||
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`
|
||||
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטה-טענות_הצדדים_{N}.docx`
|
||||
|
||||
### מה כלול
|
||||
| בלוק | כותרת | מילים |
|
||||
@@ -695,6 +752,49 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
- אל תפעיל את שלב B (סיכום + שאלת תוצאה) במקביל — חיים מחליט מתי לעבור לזרימה הראשית.
|
||||
- אם בלוק ח חסר (אין פרוטוקול דיון/סיור) — ציין זאת בדוח. הכלי כותב מה שיש, אבל המשתמש צריך לדעת אם חסר.
|
||||
|
||||
### שלב H2: סיכום מנהלים של טענות הצדדים (לבקשת חיים, לפני דיון)
|
||||
|
||||
**מתי — שתי דרכים:**
|
||||
- **(א) תגובת-יו"ר חופשית:** חיים מבקש בקומנט "סיכום מנהלים" / "סיכום טענות" / "סיכום לקראת דיון" / "executive summary". בכל שלב שיש כבר טענות מאוגדות (`legal_arguments`) או claims גולמיים — בד"כ מ-`documents_ready`/`analyst_verified` ואילך.
|
||||
- **(ב) פעולה סטרוקטורלית (כפתור-UI עתידי):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל `action == "party_claims_summary"` (עם `case_number`/`issueId`) → הרץ את השלב הזה **דטרמיניסטית**, בלי פענוח-טקסט-חופשי. זה המסלול שכפתור "צור סיכום מנהלים" יפעיל. (אותה לוגיקה בדיוק כמו במסלול א — רק הטריגר שונה.)
|
||||
|
||||
**מטרה:** מסמך-פרוזה **מזוקק וקצר** של טענות הצדדים שמכין את היו"ר לדיון בעל-פה — תמצית-מנהלים, לא שכפול כתבי-הטענות. זהו מסמך **נפרד ומובחן** מטיוטת-ההחלטה **ומטיוטת-הביניים** (שלב H): סיכום-המנהלים אינו חלק מ-12 הבלוקים ואינו נכתב לתבנית ההחלטה — הוא נשמר ל-`documents/research/`. החלטת-יו"ר, #202.
|
||||
|
||||
**זה side-quest, לא חלק מהזרימה B-F.** אל תשנה `cases.status`. אל תייצר issues לסוכני משנה. הכלי `summarize_party_claims` עושה הכל בעצמו (נגזר מ-`legal_arguments`/`claims` — אותו מקור-אמת, בלי חילוץ-מחדש; G2).
|
||||
|
||||
**זרימה (~3-7 דקות):**
|
||||
|
||||
1. פרסם comment קצר: "מתחיל יצירת סיכום מנהלים של טענות הצדדים — אעדכן בסיום." עדכן את ה-issue הראשי ל-`status=in_progress`.
|
||||
|
||||
2. **הפקת הסיכום:**
|
||||
```
|
||||
mcp__legal-ai__summarize_party_claims(case_number="...", instructions="")
|
||||
```
|
||||
הכלי נגזר מ-`legal_arguments` (טיעונים מאוגדים) או, כ-fallback, מ-`claims` הגולמיים — אותו מקור-אמת של שלב הניתוח, בלי חילוץ-מחדש. הייצור עובר `claude_session` (`claude -p`) נעוץ ל-Opus 4.8 + `effort=high`, מתוחם לטענות-התיק בלבד (anti-hallucination). נשמר ל-`data/cases/{case_number}/documents/research/party-claims-summary.md`.
|
||||
|
||||
3. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
|
||||
```
|
||||
## סיכום מנהלים מוכן — ערר {case_number}
|
||||
|
||||
📄 **קובץ:** `data/cases/{case_number}/documents/research/party-claims-summary.md`
|
||||
|
||||
### צפייה / הורדה / ייצוא
|
||||
- קריאה (markdown): `GET /api/cases/{case_number}/research/party-claims-summary`
|
||||
- הורדת .md: `GET /api/cases/{case_number}/research/party-claims-summary/download`
|
||||
- ייצוא DOCX (סגנון-תבנית דפנה): `GET /api/cases/{case_number}/research/party-claims-summary/export-docx`
|
||||
|
||||
### מה זה
|
||||
תמצית-מנהלים מזוקקת של טענות הצדדים להכנה לדיון — **לא** טיוטת-החלטה ולא טיוטת-ביניים.
|
||||
כולל פרק "נקודות-המחלוקת המרכזיות" כשאלות פתוחות, בלי הכרעה.
|
||||
```
|
||||
|
||||
4. **סטטוס issue הראשי:** עדכן ל-`in_review` (חזרה להמתנה לחיים).
|
||||
|
||||
**אזהרות:**
|
||||
- זהו מסמך **נפרד** מטיוטת-הביניים (שלב H). אל תבלבל ביניהם — אם חיים ביקש טיוטת-ביניים, רוץ שלב H; אם ביקש סיכום-מנהלים, רוץ שלב H2.
|
||||
- אל תייצא DOCX סופי (`export_docx`) ואל תפעיל את שלב B במקביל — זה side-quest.
|
||||
- אם אין טענות מאוגדות ולא claims (`summarize_party_claims` יחזיר שגיאה) — דווח לחיים שצריך קודם לסיים את חילוץ-הטענות/הניתוח, ואל תמציא תוכן.
|
||||
|
||||
## מפת סטטוסים
|
||||
|
||||
**סטטוסים של התיק (`cases.status`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
|
||||
@@ -704,9 +804,9 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
| `processing` | start-workflow (ממשק) | → בדוק אם כבר קיים issue פעיל לסוכן משנה. אם לא → המשך ל-§A כרגיל (בדוק documents + claims) |
|
||||
| `new` | (יצירת תיק) | → בדוק extraction_status של מסמכים. אם יש `pending` → צור issue למגיה (410c0167). אם כולם `completed`/`proofread` → צור issue למנתח |
|
||||
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
|
||||
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified` |
|
||||
| `analyst_verified` | CEO (אחרי שלב A) | → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
||||
| `research_complete` | מנתח / חוקר תקדימים (valid status — legacy + תרחישים מתקדמים) | → שלב B (סיכום + שאלת תוצאה לחיים). **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך ל-§B כרגיל. |
|
||||
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified`, ואז **שלב A2 (שער שטן-מליץ, אוטומטי)** — אל תקפוץ לשלב B לפני שהשער רץ והיו"ר אישר לידים (A3) |
|
||||
| `analyst_verified` | CEO (אחרי שלב A) | → **קודם שער שטן-מליץ:** אם אין `critique-gemini.md` → שלב A2 (הפעל שטן-מליץ אוטומטית). אם יש `critique-gemini.md` שטרם הוצג ליו"ר → שלב A3 (עצירת-אישור לידים). **רק אחרי שהיו"ר אישר/דחה לידים** → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
||||
| `research_complete` | מנתח / חוקר תקדימים (**סטטוס קנוני**, שלב "ניתוח וכיוון" — מקור-אמת: `case_status_model.py`) | → **קודם שער שטן-מליץ (A2/A3)** כמו ב-`analyst_verified`, ורק אחרי אישור-יו"ר ללידים → שלב B. **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך לשער שטן-מליץ ואז §B. |
|
||||
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
|
||||
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
||||
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
||||
@@ -844,9 +944,49 @@ case_prefix="${case_number:0:1}"
|
||||
- **ודא עקביות מתודולוגית** — כיוונים סילוגיסטיים (כלל + עובדות + מסקנה), chair_directions שלם (טיפול בטענות + כיוון + סדר סוגיות + תקן ביקורת), התאמה ל-`decision-methodology.md`
|
||||
- **סינון תיקים** — עבוד רק על תיקים בטווח המספרים של החברה שלך (ראה טבלה למעלה)
|
||||
|
||||
## סריקת הערות-יו"ר תלויות (company-wide) — שער-מסירה
|
||||
|
||||
> **למה זה קיים:** ה-wake מה-sweep מגיע עיוור (ראה ⚠️ בשלב 0). לכן אינך יכול להסתמך על `reason`/`issueId` כדי לדעת שיש הערה לטפל בה — אתה חייב **לגלות** אותה בעצמך. סריקה זו מוצאת כל הערת-יו"ר שטרם נענתה **בכל issue בחברה** (לא רק בתור המשויך-אליך — הערות-יו"ר יושבות על issues של חיים/סוכני-מִשנה, לא עליך).
|
||||
|
||||
**⚠️ כלל-ברזל: שיוך-לתיק ממקור מובנה, לעולם לא מהכותרת ולא מתוכן-ההערה.** הערה עשויה להזכיר תיק אחר (למשל "ניסיתי ב-CMPA-89") — זה **תוכן לטיפול**, לא יעד. היעד הוא תמיד התיק של ה-**project** שאליו שייך ה-issue. אם אי-אפשר לפתור תיק בוודאות → **אל תפעל**.
|
||||
|
||||
**הסריקה (הרץ בכל heartbeat ללא reason מכוּון):**
|
||||
|
||||
1. **רשום את כל issues החברה הפעילים:**
|
||||
```bash
|
||||
ISSUES=$(~/legal-ai/scripts/pc.sh GET "/api/companies/$PAPERCLIP_COMPANY_ID/issues")
|
||||
echo "$ISSUES" | jq -r '(if type=="array" then . else .issues end)
|
||||
| map(select(.status!="done" and .status!="cancelled" and (.hiddenAt|not)))
|
||||
| .[].id'
|
||||
```
|
||||
|
||||
2. **לכל issue — האם יש הערת-יו"ר תלויה?** הערה תלויה = הערת-`user` האחרונה **חדשה** מהערת-`agent` האחרונה (התעלם מ-`system`). ⚠️ ה-API מחזיר comments ב-**newest-first** — אל תשתמש ב-`.[-1]`; מיין ב-`createdAt`:
|
||||
```bash
|
||||
C=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/comments")
|
||||
PENDING=$(echo "$C" | jq -r '
|
||||
(map(select(.authorType=="user")) | sort_by(.createdAt) | last) as $u |
|
||||
(map(select(.authorType=="agent")) | sort_by(.createdAt) | last) as $a |
|
||||
if $u==null then "no" elif ($a==null) or ($u.createdAt>$a.createdAt) then "yes" else "no" end')
|
||||
```
|
||||
אם `PENDING != "yes"` → דלג ל-issue הבא.
|
||||
|
||||
3. **שיוך-לתיק (שער-בטיחות חובה)** — חלץ את מספר-התיק מ-**`project.name`** (שדה מובנה: `"ערר <מספר> — ..."`), לא מהכותרת ולא מההערה:
|
||||
```bash
|
||||
CTX=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/heartbeat-context")
|
||||
CASE_NUMBER=$(echo "$CTX" | jq -r '.project.name // ""' \
|
||||
| grep -oE '(ערר|בל"מ) [0-9]{4,5}-[0-9]{2}-[0-9]{2}' \
|
||||
| grep -oE '[0-9]{4,5}-[0-9]{2}-[0-9]{2}' | head -1)
|
||||
```
|
||||
- **`CASE_NUMBER` ריק** (ה-project אינו תיק — למשל "ספריית פסיקה — תור חילוץ", או שיוך לא-ודאי) → **אל תפעל על תיק.** אם נראה שזו הערת-יו"ר אמיתית שאבדה — פרסם comment קצר ב-issue + שלח מייל ליו"ר (סעיף §5), בלי לגעת בשום תיק. עבור ל-issue הבא.
|
||||
- **`CASE_NUMBER` קיים** → טפל בהערה לפי "טיפול בתגובות חדשות מחיים" למטה, **scoped ל-`$ISSUE_ID` ול-`$CASE_NUMBER` הזה בלבד.**
|
||||
|
||||
4. **כמה issues תלויים** (אפשרי — אפילו על אותו תיק) → טפל בכולם, כל אחד ב-scope שלו. אם לא נמצאה אף הערה תלויה → אין פעולת-CEO; המשך לשלב A.
|
||||
|
||||
---
|
||||
|
||||
## טיפול בתגובות חדשות מחיים (comment routing)
|
||||
|
||||
כשאתה מתעורר בגלל תגובה חדשה (reason מכיל "user_commented"):
|
||||
כשאתה מתעורר בגלל תגובה חדשה (reason מכיל "user_commented"), **או** כשהסריקה למעלה זיהתה הערה תלויה ב-`$ISSUE_ID` עם `$CASE_NUMBER` פתור:
|
||||
|
||||
0. **החזר את ה-issue הראשי ל-`status=in_progress`** — אם ה-issue ב-`in_review` (כי המתנת לחיים) או ב-`blocked` (כי Paperclip חסם אוטומטית), הראשון דבר: עדכן ל-`in_progress` כדי לסמן שאתה עובד עליו.
|
||||
|
||||
|
||||
@@ -179,10 +179,11 @@ tools:
|
||||
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
|
||||
|
||||
```
|
||||
new → proofread → documents_ready → analyst_verified → research_complete (legacy/optional)
|
||||
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
|
||||
→ drafted (אתה כאן!) → qa_passed / qa_failed → exported
|
||||
new → processing → documents_ready → analyst_verified → research_complete
|
||||
→ outcome_set → direction_approved → qa_review → drafted (אתה כאן!)
|
||||
→ exported → reviewed → final
|
||||
```
|
||||
(מקור-אמת יחיד: `case_status_model.py` / `GET /api/status-model`. `analyst_verified`+`research_complete` קנוניים, שלב "ניתוח וכיוון". מצבי-ביניים ישנים כמו `proofread`/`analysis_enriched`/`qa_passed` נמפים-לשלב לתצוגה בלבד.)
|
||||
|
||||
⚠️ **`research_complete` הוא valid status** (לא bug, לא legacy ערומה). ב-`legal-researcher.md` שלב 5 הוא הסטטוס שהחוקר מגדיר בסיום מחקר. אם תיק במצב זה נשלח אליך לפני `drafted` — דווח, אל תכשיל.
|
||||
|
||||
|
||||
@@ -304,6 +304,15 @@ search_internal_decisions(
|
||||
|
||||
**מינימום:** queries לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו.
|
||||
|
||||
#### 2ב.4ב — שקלול סמכות לפי טיפול-שיפוטי-מצטבר (X11) ⚠️
|
||||
|
||||
כל תוצאה מ-`search_precedent_library` נושאת שדה **`cited_by`** + `verified`/`cite_count` — **כמה החלטות ועדת-ערר אחרות הסתמכו על אותו תקדים ואיך טיפלו בו**. זהו אות-סמכות אנושי-מצטבר (לא ניחוש-מודל), וחובה לשקלל אותו:
|
||||
|
||||
- **`cited_by.positive` גבוה (אומץ/הוסבר ע"י הרבה ועדות)** → תקדים-עוגן. **טַען את הסמכות במפורש** בפלט-המחקר: *"הלכת [שם] (עע"מ X/YY) — אומצה ב-N החלטות ועדת-ערר"*, כדי שה-writer יוכל לבסס עליה. בין שני תקדימים שקולים-תוכן — העדף את בעל ה-`positive` הגבוה.
|
||||
- **`cited_by.negative` > 0 (אובחן/בוקר/בוטל)** → **דגל זהירות**. אל תציג תקדים שבוטל (`overruled`) כ"good law"; ציין את האבחנה. תקדים שאובחן שוב-ושוב אינו ראיה חזקה לסוגיה — בדוק את ההקשר.
|
||||
- **`unclassified`** → הטיפול טרם סווג; התייחס ל-`cite_count` הגולמי בלבד.
|
||||
- האות הזה **מחדד תיעדוף, לא מחליף קריאה** — עדיין קרא את ההלכה ואת ההקשר (INV-COR3/COR5: סמכות לסוגיה הספציפית, לא לפסק כולו).
|
||||
|
||||
#### 2ב.4א — איתור החלטה ספציפית לפי שם — פרוטוקול לפני "לא בקורפוס" ⚠️
|
||||
|
||||
שם תיק לבדו (למשל `"אגסי"`) **אינו מפתח חיפוש אמין**. ההטמעה הסמנטית והאינדקס הלקסיקלי בנויים על תוכן ההלכה/הפסקה — כך ששאילתת-שם עלולה להחזיר דווקא החלטות ש**מצטטות** את התיק, ולא את התיק עצמו. לפני שמכריזים שהחלטה אינה בקורפוס:
|
||||
|
||||
@@ -274,7 +274,7 @@ case_update(case_number, status="drafted")
|
||||
### שלב ג: לכל סוגיה — מבנה סילוגיסטי (CREAC) בקול דפנה
|
||||
1. **מסקנה** — פתח בתשובה (בקול "אנחנו" — ראה טבלה למטה)
|
||||
2. **כלל** — ציטוט סעיף החוק במלואו (לא תמצית). אם רלוונטי — סעיפי משנה כולם.
|
||||
3. **הרחבה** — תקדים רלוונטי אחד **בציטוט מלא** (לא תמצית). דפנה תמיד מצטטת בני 4-15 שורות עם הפניה `(פורסם בנבו)`.
|
||||
3. **הרחבה** — תקדים רלוונטי אחד **בציטוט מלא** (לא תמצית). דפנה תמיד מצטטת בני 4-15 שורות עם הפניה `(פורסם בנבו)`. **כשהמנתח סימן תקדים כ"אומץ ב-N החלטות ועדת-ערר" (אות-סמכות X11)** — חזק את ההסתמכות בלשון-סמכות ("הלכה מושרשת שאומצה בשורת החלטות"); זהו טיפול-שיפוטי-מצטבר אמיתי, לא ניחוש. **אל** תמציא מספר בעצמך — השתמש רק במה שהמנתח מסר (INV-AH, read-only מהמנתח).
|
||||
4. **יישום** — החל את הכלל על העובדות. הפרד ממצא עובדתי ממסקנה משפטית. השתמש בנתונים (מספרים, מידות, אחוזים).
|
||||
5. **אישור-לפני-דחייה (חובה)** — הצג את הטענה הטובה ביותר של הצד המפסיד: **"אכן [נקודה תקפה]... אולם [למה לא מכריע]"**. השימוש ב-"אכן" (לא "אמנם") הוא הסטנדרט.
|
||||
6. **למעלה מן הצורך** (חובה לטענות מרכזיות) — "גם אם היינו מקבלים את פרשנות העורר... התוצאה הייתה זהה". סוגר חלון לערעור.
|
||||
|
||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# API Keys (Required to enable respective provider)
|
||||
ANTHROPIC_API_KEY="your_anthropic_api_key_here" # Required: Format: sk-ant-api03-...
|
||||
PERPLEXITY_API_KEY="your_perplexity_api_key_here" # Optional: Format: pplx-...
|
||||
OPENAI_API_KEY="your_openai_api_key_here" # Optional, for OpenAI models. Format: sk-proj-...
|
||||
GOOGLE_API_KEY="your_google_api_key_here" # Optional, for Google Gemini models.
|
||||
MISTRAL_API_KEY="your_mistral_key_here" # Optional, for Mistral AI models.
|
||||
XAI_API_KEY="YOUR_XAI_KEY_HERE" # Optional, for xAI AI models.
|
||||
GROQ_API_KEY="YOUR_GROQ_KEY_HERE" # Optional, for Groq models.
|
||||
OPENROUTER_API_KEY="YOUR_OPENROUTER_KEY_HERE" # Optional, for OpenRouter models.
|
||||
AZURE_OPENAI_API_KEY="your_azure_key_here" # Optional, for Azure OpenAI models (requires endpoint in .taskmaster/config.json).
|
||||
OLLAMA_API_KEY="your_ollama_api_key_here" # Optional: For remote Ollama servers that require authentication.
|
||||
GITHUB_API_KEY="your_github_api_key_here" # Optional: For GitHub import/export features. Format: ghp_... or github_pat_...
|
||||
34
.gitignore
vendored
34
.gitignore
vendored
@@ -6,7 +6,8 @@ data/backups/
|
||||
data/precedent-library/
|
||||
data/.auto-sync.log
|
||||
data/*.db
|
||||
data/checkpoints/ # X16 durable-pipeline SQLite checkpoints (runtime artifact)
|
||||
# X16 durable-pipeline SQLite checkpoints (runtime artifact)
|
||||
data/checkpoints/
|
||||
*.bak-pre-*
|
||||
mcp-server/.venv/
|
||||
__pycache__/
|
||||
@@ -18,6 +19,33 @@ 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)
|
||||
# effort-calibration run outputs (#208 / #212) — generated, not source
|
||||
data/eval/effort-calibration-*
|
||||
data/eval/block-yod-calibration-*
|
||||
# revert snapshot for migrate_agent_adapter.py (runtime state)
|
||||
data/adapter-migration-state.json
|
||||
# frontmatter-stripped instruction copies for content_arg adapters (generated)
|
||||
.claude/agents/.generated/
|
||||
.claude/worktrees/
|
||||
|
||||
# TaskMaster backups (runtime)
|
||||
.taskmaster/tasks/tasks.json.bak.*
|
||||
|
||||
# Build artifacts
|
||||
.design-build/
|
||||
|
||||
# Temp files
|
||||
.interaction_tmp.json
|
||||
|
||||
# Runtime eval/ab-test data
|
||||
data/ab_halacha_*.json
|
||||
data/ab_run_*.log
|
||||
data/x11_treatment_run_*.log
|
||||
|
||||
# Runtime data directories
|
||||
data/audit/
|
||||
data/bulletins/
|
||||
data/digests/
|
||||
data/internal-decisions/
|
||||
data/learning/
|
||||
data/logs/
|
||||
|
||||
47
.taskmaster/templates/example_prd.txt
Normal file
47
.taskmaster/templates/example_prd.txt
Normal file
@@ -0,0 +1,47 @@
|
||||
<context>
|
||||
# Overview
|
||||
[Provide a high-level overview of your product here. Explain what problem it solves, who it's for, and why it's valuable.]
|
||||
|
||||
# Core Features
|
||||
[List and describe the main features of your product. For each feature, include:
|
||||
- What it does
|
||||
- Why it's important
|
||||
- How it works at a high level]
|
||||
|
||||
# User Experience
|
||||
[Describe the user journey and experience. Include:
|
||||
- User personas
|
||||
- Key user flows
|
||||
- UI/UX considerations]
|
||||
</context>
|
||||
<PRD>
|
||||
# Technical Architecture
|
||||
[Outline the technical implementation details:
|
||||
- System components
|
||||
- Data models
|
||||
- APIs and integrations
|
||||
- Infrastructure requirements]
|
||||
|
||||
# Development Roadmap
|
||||
[Break down the development process into phases:
|
||||
- MVP requirements
|
||||
- Future enhancements
|
||||
- Do not think about timelines whatsoever -- all that matters is scope and detailing exactly what needs to be build in each phase so it can later be cut up into tasks]
|
||||
|
||||
# Logical Dependency Chain
|
||||
[Define the logical order of development:
|
||||
- Which features need to be built first (foundation)
|
||||
- Getting as quickly as possible to something usable/visible front end that works
|
||||
- Properly pacing and scoping each feature so it is atomic but can also be built upon and improved as development approaches]
|
||||
|
||||
# Risks and Mitigations
|
||||
[Identify potential risks and how they'll be addressed:
|
||||
- Technical challenges
|
||||
- Figuring out the MVP that we can build upon
|
||||
- Resource constraints]
|
||||
|
||||
# Appendix
|
||||
[Include any additional information:
|
||||
- Research findings
|
||||
- Technical specifications]
|
||||
</PRD>
|
||||
511
.taskmaster/templates/example_prd_rpg.txt
Normal file
511
.taskmaster/templates/example_prd_rpg.txt
Normal file
@@ -0,0 +1,511 @@
|
||||
<rpg-method>
|
||||
# Repository Planning Graph (RPG) Method - PRD Template
|
||||
|
||||
This template teaches you (AI or human) how to create structured, dependency-aware PRDs using the RPG methodology from Microsoft Research. The key insight: separate WHAT (functional) from HOW (structural), then connect them with explicit dependencies.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Dual-Semantics**: Think functional (capabilities) AND structural (code organization) separately, then map them
|
||||
2. **Explicit Dependencies**: Never assume - always state what depends on what
|
||||
3. **Topological Order**: Build foundation first, then layers on top
|
||||
4. **Progressive Refinement**: Start broad, refine iteratively
|
||||
|
||||
## How to Use This Template
|
||||
|
||||
- Follow the instructions in each `<instruction>` block
|
||||
- Look at `<example>` blocks to see good vs bad patterns
|
||||
- Fill in the content sections with your project details
|
||||
- The AI reading this will learn the RPG method by following along
|
||||
- Task Master will parse the resulting PRD into dependency-aware tasks
|
||||
|
||||
## Recommended Tools for Creating PRDs
|
||||
|
||||
When using this template to **create** a PRD (not parse it), use **code-context-aware AI assistants** for best results:
|
||||
|
||||
**Why?** The AI needs to understand your existing codebase to make good architectural decisions about modules, dependencies, and integration points.
|
||||
|
||||
**Recommended tools:**
|
||||
- **Claude Code** (claude-code CLI) - Best for structured reasoning and large contexts
|
||||
- **Cursor/Windsurf** - IDE integration with full codebase context
|
||||
- **Gemini CLI** (gemini-cli) - Massive context window for large codebases
|
||||
- **Codex/Grok CLI** - Strong code generation with context awareness
|
||||
|
||||
**Note:** Once your PRD is created, `task-master parse-prd` works with any configured AI model - it just needs to read the PRD text itself, not your codebase.
|
||||
</rpg-method>
|
||||
|
||||
---
|
||||
|
||||
<overview>
|
||||
<instruction>
|
||||
Start with the problem, not the solution. Be specific about:
|
||||
- What pain point exists?
|
||||
- Who experiences it?
|
||||
- Why existing solutions don't work?
|
||||
- What success looks like (measurable outcomes)?
|
||||
|
||||
Keep this section focused - don't jump into implementation details yet.
|
||||
</instruction>
|
||||
|
||||
## Problem Statement
|
||||
[Describe the core problem. Be concrete about user pain points.]
|
||||
|
||||
## Target Users
|
||||
[Define personas, their workflows, and what they're trying to achieve.]
|
||||
|
||||
## Success Metrics
|
||||
[Quantifiable outcomes. Examples: "80% task completion via autopilot", "< 5% manual intervention rate"]
|
||||
|
||||
</overview>
|
||||
|
||||
---
|
||||
|
||||
<functional-decomposition>
|
||||
<instruction>
|
||||
Now think about CAPABILITIES (what the system DOES), not code structure yet.
|
||||
|
||||
Step 1: Identify high-level capability domains
|
||||
- Think: "What major things does this system do?"
|
||||
- Examples: Data Management, Core Processing, Presentation Layer
|
||||
|
||||
Step 2: For each capability, enumerate specific features
|
||||
- Use explore-exploit strategy:
|
||||
* Exploit: What features are REQUIRED for core value?
|
||||
* Explore: What features make this domain COMPLETE?
|
||||
|
||||
Step 3: For each feature, define:
|
||||
- Description: What it does in one sentence
|
||||
- Inputs: What data/context it needs
|
||||
- Outputs: What it produces/returns
|
||||
- Behavior: Key logic or transformations
|
||||
|
||||
<example type="good">
|
||||
Capability: Data Validation
|
||||
Feature: Schema validation
|
||||
- Description: Validate JSON payloads against defined schemas
|
||||
- Inputs: JSON object, schema definition
|
||||
- Outputs: Validation result (pass/fail) + error details
|
||||
- Behavior: Iterate fields, check types, enforce constraints
|
||||
|
||||
Feature: Business rule validation
|
||||
- Description: Apply domain-specific validation rules
|
||||
- Inputs: Validated data object, rule set
|
||||
- Outputs: Boolean + list of violated rules
|
||||
- Behavior: Execute rules sequentially, short-circuit on failure
|
||||
</example>
|
||||
|
||||
<example type="bad">
|
||||
Capability: validation.js
|
||||
(Problem: This is a FILE, not a CAPABILITY. Mixing structure into functional thinking.)
|
||||
|
||||
Capability: Validation
|
||||
Feature: Make sure data is good
|
||||
(Problem: Too vague. No inputs/outputs. Not actionable.)
|
||||
</example>
|
||||
</instruction>
|
||||
|
||||
## Capability Tree
|
||||
|
||||
### Capability: [Name]
|
||||
[Brief description of what this capability domain covers]
|
||||
|
||||
#### Feature: [Name]
|
||||
- **Description**: [One sentence]
|
||||
- **Inputs**: [What it needs]
|
||||
- **Outputs**: [What it produces]
|
||||
- **Behavior**: [Key logic]
|
||||
|
||||
#### Feature: [Name]
|
||||
- **Description**:
|
||||
- **Inputs**:
|
||||
- **Outputs**:
|
||||
- **Behavior**:
|
||||
|
||||
### Capability: [Name]
|
||||
...
|
||||
|
||||
</functional-decomposition>
|
||||
|
||||
---
|
||||
|
||||
<structural-decomposition>
|
||||
<instruction>
|
||||
NOW think about code organization. Map capabilities to actual file/folder structure.
|
||||
|
||||
Rules:
|
||||
1. Each capability maps to a module (folder or file)
|
||||
2. Features within a capability map to functions/classes
|
||||
3. Use clear module boundaries - each module has ONE responsibility
|
||||
4. Define what each module exports (public interface)
|
||||
|
||||
The goal: Create a clear mapping between "what it does" (functional) and "where it lives" (structural).
|
||||
|
||||
<example type="good">
|
||||
Capability: Data Validation
|
||||
→ Maps to: src/validation/
|
||||
├── schema-validator.js (Schema validation feature)
|
||||
├── rule-validator.js (Business rule validation feature)
|
||||
└── index.js (Public exports)
|
||||
|
||||
Exports:
|
||||
- validateSchema(data, schema)
|
||||
- validateRules(data, rules)
|
||||
</example>
|
||||
|
||||
<example type="bad">
|
||||
Capability: Data Validation
|
||||
→ Maps to: src/utils.js
|
||||
(Problem: "utils" is not a clear module boundary. Where do I find validation logic?)
|
||||
|
||||
Capability: Data Validation
|
||||
→ Maps to: src/validation/everything.js
|
||||
(Problem: One giant file. Features should map to separate files for maintainability.)
|
||||
</example>
|
||||
</instruction>
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
project-root/
|
||||
├── src/
|
||||
│ ├── [module-name]/ # Maps to: [Capability Name]
|
||||
│ │ ├── [file].js # Maps to: [Feature Name]
|
||||
│ │ └── index.js # Public exports
|
||||
│ └── [module-name]/
|
||||
├── tests/
|
||||
└── docs/
|
||||
```
|
||||
|
||||
## Module Definitions
|
||||
|
||||
### Module: [Name]
|
||||
- **Maps to capability**: [Capability from functional decomposition]
|
||||
- **Responsibility**: [Single clear purpose]
|
||||
- **File structure**:
|
||||
```
|
||||
module-name/
|
||||
├── feature1.js
|
||||
├── feature2.js
|
||||
└── index.js
|
||||
```
|
||||
- **Exports**:
|
||||
- `functionName()` - [what it does]
|
||||
- `ClassName` - [what it does]
|
||||
|
||||
</structural-decomposition>
|
||||
|
||||
---
|
||||
|
||||
<dependency-graph>
|
||||
<instruction>
|
||||
This is THE CRITICAL SECTION for Task Master parsing.
|
||||
|
||||
Define explicit dependencies between modules. This creates the topological order for task execution.
|
||||
|
||||
Rules:
|
||||
1. List modules in dependency order (foundation first)
|
||||
2. For each module, state what it depends on
|
||||
3. Foundation modules should have NO dependencies
|
||||
4. Every non-foundation module should depend on at least one other module
|
||||
5. Think: "What must EXIST before I can build this module?"
|
||||
|
||||
<example type="good">
|
||||
Foundation Layer (no dependencies):
|
||||
- error-handling: No dependencies
|
||||
- config-manager: No dependencies
|
||||
- base-types: No dependencies
|
||||
|
||||
Data Layer:
|
||||
- schema-validator: Depends on [base-types, error-handling]
|
||||
- data-ingestion: Depends on [schema-validator, config-manager]
|
||||
|
||||
Core Layer:
|
||||
- algorithm-engine: Depends on [base-types, error-handling]
|
||||
- pipeline-orchestrator: Depends on [algorithm-engine, data-ingestion]
|
||||
</example>
|
||||
|
||||
<example type="bad">
|
||||
- validation: Depends on API
|
||||
- API: Depends on validation
|
||||
(Problem: Circular dependency. This will cause build/runtime issues.)
|
||||
|
||||
- user-auth: Depends on everything
|
||||
(Problem: Too many dependencies. Should be more focused.)
|
||||
</example>
|
||||
</instruction>
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
### Foundation Layer (Phase 0)
|
||||
No dependencies - these are built first.
|
||||
|
||||
- **[Module Name]**: [What it provides]
|
||||
- **[Module Name]**: [What it provides]
|
||||
|
||||
### [Layer Name] (Phase 1)
|
||||
- **[Module Name]**: Depends on [[module-from-phase-0], [module-from-phase-0]]
|
||||
- **[Module Name]**: Depends on [[module-from-phase-0]]
|
||||
|
||||
### [Layer Name] (Phase 2)
|
||||
- **[Module Name]**: Depends on [[module-from-phase-1], [module-from-foundation]]
|
||||
|
||||
[Continue building up layers...]
|
||||
|
||||
</dependency-graph>
|
||||
|
||||
---
|
||||
|
||||
<implementation-roadmap>
|
||||
<instruction>
|
||||
Turn the dependency graph into concrete development phases.
|
||||
|
||||
Each phase should:
|
||||
1. Have clear entry criteria (what must exist before starting)
|
||||
2. Contain tasks that can be parallelized (no inter-dependencies within phase)
|
||||
3. Have clear exit criteria (how do we know phase is complete?)
|
||||
4. Build toward something USABLE (not just infrastructure)
|
||||
|
||||
Phase ordering follows topological sort of dependency graph.
|
||||
|
||||
<example type="good">
|
||||
Phase 0: Foundation
|
||||
Entry: Clean repository
|
||||
Tasks:
|
||||
- Implement error handling utilities
|
||||
- Create base type definitions
|
||||
- Setup configuration system
|
||||
Exit: Other modules can import foundation without errors
|
||||
|
||||
Phase 1: Data Layer
|
||||
Entry: Phase 0 complete
|
||||
Tasks:
|
||||
- Implement schema validator (uses: base types, error handling)
|
||||
- Build data ingestion pipeline (uses: validator, config)
|
||||
Exit: End-to-end data flow from input to validated output
|
||||
</example>
|
||||
|
||||
<example type="bad">
|
||||
Phase 1: Build Everything
|
||||
Tasks:
|
||||
- API
|
||||
- Database
|
||||
- UI
|
||||
- Tests
|
||||
(Problem: No clear focus. Too broad. Dependencies not considered.)
|
||||
</example>
|
||||
</instruction>
|
||||
|
||||
## Development Phases
|
||||
|
||||
### Phase 0: [Foundation Name]
|
||||
**Goal**: [What foundational capability this establishes]
|
||||
|
||||
**Entry Criteria**: [What must be true before starting]
|
||||
|
||||
**Tasks**:
|
||||
- [ ] [Task name] (depends on: [none or list])
|
||||
- Acceptance criteria: [How we know it's done]
|
||||
- Test strategy: [What tests prove it works]
|
||||
|
||||
- [ ] [Task name] (depends on: [none or list])
|
||||
|
||||
**Exit Criteria**: [Observable outcome that proves phase complete]
|
||||
|
||||
**Delivers**: [What can users/developers do after this phase?]
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: [Layer Name]
|
||||
**Goal**:
|
||||
|
||||
**Entry Criteria**: Phase 0 complete
|
||||
|
||||
**Tasks**:
|
||||
- [ ] [Task name] (depends on: [[tasks-from-phase-0]])
|
||||
- [ ] [Task name] (depends on: [[tasks-from-phase-0]])
|
||||
|
||||
**Exit Criteria**:
|
||||
|
||||
**Delivers**:
|
||||
|
||||
---
|
||||
|
||||
[Continue with more phases...]
|
||||
|
||||
</implementation-roadmap>
|
||||
|
||||
---
|
||||
|
||||
<test-strategy>
|
||||
<instruction>
|
||||
Define how testing will be integrated throughout development (TDD approach).
|
||||
|
||||
Specify:
|
||||
1. Test pyramid ratios (unit vs integration vs e2e)
|
||||
2. Coverage requirements
|
||||
3. Critical test scenarios
|
||||
4. Test generation guidelines for Surgical Test Generator
|
||||
|
||||
This section guides the AI when generating tests during the RED phase of TDD.
|
||||
|
||||
<example type="good">
|
||||
Critical Test Scenarios for Data Validation module:
|
||||
- Happy path: Valid data passes all checks
|
||||
- Edge cases: Empty strings, null values, boundary numbers
|
||||
- Error cases: Invalid types, missing required fields
|
||||
- Integration: Validator works with ingestion pipeline
|
||||
</example>
|
||||
</instruction>
|
||||
|
||||
## Test Pyramid
|
||||
|
||||
```
|
||||
/\
|
||||
/E2E\ ← [X]% (End-to-end, slow, comprehensive)
|
||||
/------\
|
||||
/Integration\ ← [Y]% (Module interactions)
|
||||
/------------\
|
||||
/ Unit Tests \ ← [Z]% (Fast, isolated, deterministic)
|
||||
/----------------\
|
||||
```
|
||||
|
||||
## Coverage Requirements
|
||||
- Line coverage: [X]% minimum
|
||||
- Branch coverage: [X]% minimum
|
||||
- Function coverage: [X]% minimum
|
||||
- Statement coverage: [X]% minimum
|
||||
|
||||
## Critical Test Scenarios
|
||||
|
||||
### [Module/Feature Name]
|
||||
**Happy path**:
|
||||
- [Scenario description]
|
||||
- Expected: [What should happen]
|
||||
|
||||
**Edge cases**:
|
||||
- [Scenario description]
|
||||
- Expected: [What should happen]
|
||||
|
||||
**Error cases**:
|
||||
- [Scenario description]
|
||||
- Expected: [How system handles failure]
|
||||
|
||||
**Integration points**:
|
||||
- [What interactions to test]
|
||||
- Expected: [End-to-end behavior]
|
||||
|
||||
## Test Generation Guidelines
|
||||
[Specific instructions for Surgical Test Generator about what to focus on, what patterns to follow, project-specific test conventions]
|
||||
|
||||
</test-strategy>
|
||||
|
||||
---
|
||||
|
||||
<architecture>
|
||||
<instruction>
|
||||
Describe technical architecture, data models, and key design decisions.
|
||||
|
||||
Keep this section AFTER functional/structural decomposition - implementation details come after understanding structure.
|
||||
</instruction>
|
||||
|
||||
## System Components
|
||||
[Major architectural pieces and their responsibilities]
|
||||
|
||||
## Data Models
|
||||
[Core data structures, schemas, database design]
|
||||
|
||||
## Technology Stack
|
||||
[Languages, frameworks, key libraries]
|
||||
|
||||
**Decision: [Technology/Pattern]**
|
||||
- **Rationale**: [Why chosen]
|
||||
- **Trade-offs**: [What we're giving up]
|
||||
- **Alternatives considered**: [What else we looked at]
|
||||
|
||||
</architecture>
|
||||
|
||||
---
|
||||
|
||||
<risks>
|
||||
<instruction>
|
||||
Identify risks that could derail development and how to mitigate them.
|
||||
|
||||
Categories:
|
||||
- Technical risks (complexity, unknowns)
|
||||
- Dependency risks (blocking issues)
|
||||
- Scope risks (creep, underestimation)
|
||||
</instruction>
|
||||
|
||||
## Technical Risks
|
||||
**Risk**: [Description]
|
||||
- **Impact**: [High/Medium/Low - effect on project]
|
||||
- **Likelihood**: [High/Medium/Low]
|
||||
- **Mitigation**: [How to address]
|
||||
- **Fallback**: [Plan B if mitigation fails]
|
||||
|
||||
## Dependency Risks
|
||||
[External dependencies, blocking issues]
|
||||
|
||||
## Scope Risks
|
||||
[Scope creep, underestimation, unclear requirements]
|
||||
|
||||
</risks>
|
||||
|
||||
---
|
||||
|
||||
<appendix>
|
||||
## References
|
||||
[Papers, documentation, similar systems]
|
||||
|
||||
## Glossary
|
||||
[Domain-specific terms]
|
||||
|
||||
## Open Questions
|
||||
[Things to resolve during development]
|
||||
</appendix>
|
||||
|
||||
---
|
||||
|
||||
<task-master-integration>
|
||||
# How Task Master Uses This PRD
|
||||
|
||||
When you run `task-master parse-prd <file>.txt`, the parser:
|
||||
|
||||
1. **Extracts capabilities** → Main tasks
|
||||
- Each `### Capability:` becomes a top-level task
|
||||
|
||||
2. **Extracts features** → Subtasks
|
||||
- Each `#### Feature:` becomes a subtask under its capability
|
||||
|
||||
3. **Parses dependencies** → Task dependencies
|
||||
- `Depends on: [X, Y]` sets task.dependencies = ["X", "Y"]
|
||||
|
||||
4. **Orders by phases** → Task priorities
|
||||
- Phase 0 tasks = highest priority
|
||||
- Phase N tasks = lower priority, properly sequenced
|
||||
|
||||
5. **Uses test strategy** → Test generation context
|
||||
- Feeds test scenarios to Surgical Test Generator during implementation
|
||||
|
||||
**Result**: A dependency-aware task graph that can be executed in topological order.
|
||||
|
||||
## Why RPG Structure Matters
|
||||
|
||||
Traditional flat PRDs lead to:
|
||||
- ❌ Unclear task dependencies
|
||||
- ❌ Arbitrary task ordering
|
||||
- ❌ Circular dependencies discovered late
|
||||
- ❌ Poorly scoped tasks
|
||||
|
||||
RPG-structured PRDs provide:
|
||||
- ✅ Explicit dependency chains
|
||||
- ✅ Topological execution order
|
||||
- ✅ Clear module boundaries
|
||||
- ✅ Validated task graph before implementation
|
||||
|
||||
## Tips for Best Results
|
||||
|
||||
1. **Spend time on dependency graph** - This is the most valuable section for Task Master
|
||||
2. **Keep features atomic** - Each feature should be independently testable
|
||||
3. **Progressive refinement** - Start broad, use `task-master expand` to break down complex tasks
|
||||
4. **Use research mode** - `task-master parse-prd --research` leverages AI for better task generation
|
||||
</task-master-integration>
|
||||
43
data/halacha_night_check.sh
Executable file
43
data/halacha_night_check.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot morning verdict for the halacha night drain (scheduled 2026-06-15 04:30 UTC
|
||||
# via chaim's crontab; throwaway — lives under data/, not tracked in scripts/).
|
||||
# Captures whether last night's run (with the PR #251 fix: durable rate-limit
|
||||
# detection + 05:00–07:00 catch-up window) actually drained the backlog.
|
||||
# Baseline at install time (2026-06-14 13:xx IDT): pending=96 done=248 halachot=4099.
|
||||
set -u
|
||||
export HOME=/home/chaim
|
||||
REPO=/home/chaim/legal-ai
|
||||
PY="$REPO/mcp-server/.venv/bin/python"
|
||||
OUT="$REPO/data/logs/halacha_night_report_$(TZ=Asia/Jerusalem date +%Y%m%d).md"
|
||||
SUP_LOG=/home/chaim/.pm2/logs/legal-halacha-supervisor-out.log
|
||||
DRAIN_ERR=/home/chaim/.pm2/logs/legal-halacha-drain-error.log
|
||||
mkdir -p "$REPO/data/logs"
|
||||
|
||||
{
|
||||
echo "# דוח-בוקר: ריצת-הלכות הלילה — $(TZ=Asia/Jerusalem date '+%Y-%m-%d %H:%M %Z')"
|
||||
echo
|
||||
echo "בסיס-השוואה (אתמול 13:xx IDT): pending=96 · done=248 · halachot=4099"
|
||||
echo
|
||||
echo '## מצב נוכחי (supervisor status)'
|
||||
echo '```'
|
||||
cd "$REPO" && "$PY" scripts/halacha_drain_supervisor.py status 2>&1
|
||||
echo '```'
|
||||
echo
|
||||
echo '## פעולות המתזמר ב-12 השעות האחרונות (modes/actions)'
|
||||
echo '```'
|
||||
grep -E 'מצב:|פעולה:|catch-up|rate-limit|נעצר' "$SUP_LOG" 2>/dev/null | tail -40
|
||||
echo '```'
|
||||
echo
|
||||
echo '## אותות rate-limit בלוג-הדריינר (24ש אחרונות בלוג)'
|
||||
echo '```'
|
||||
echo "429 hits (tail 4000): $(tail -4000 "$DRAIN_ERR" 2>/dev/null | grep -c '429')"
|
||||
echo "session-limit msgs: $(tail -4000 "$DRAIN_ERR" 2>/dev/null | grep -c 'hit your session limit')"
|
||||
echo "extraction_failed: $(tail -4000 "$DRAIN_ERR" 2>/dev/null | grep -c 'extraction_failed')"
|
||||
echo "hold-stopped (fix A): $(grep -c 'hold-stopped' "$SUP_LOG" 2>/dev/null)"
|
||||
echo "catch-up opened (B): $(grep -c 'catch-up בוקר' "$SUP_LOG" 2>/dev/null)"
|
||||
echo '```'
|
||||
echo
|
||||
echo "_(נוצר ע\"י data/halacha_night_check.sh; ניתן למחוק את שורת ה-crontab של 15.6.)_"
|
||||
} > "$OUT" 2>&1
|
||||
|
||||
echo "report written: $OUT"
|
||||
@@ -480,11 +480,38 @@ Conclusion → Rule → Explanation → Application → Conclusion.
|
||||
|
||||
## 3. כללי גזירת פרמטרים
|
||||
|
||||
פרמטרי העיבוד נגזרים ממאפייני התוכן, לא נקבעים שרירותית:
|
||||
פרמטרי העיבוד נגזרים ממאפייני התוכן, לא נקבעים שרירותית.
|
||||
|
||||
### Temperature — נגזר מסוג הייצור
|
||||
> **⚠️ עדכון מנגנון-הייצור (#204 / WS5).** מסלול-הייצור הוא `claude_session.query` →
|
||||
> `claude -p` (מנוי claude.ai, עלות-שולית-אפס, מקומי-בלבד — ראה
|
||||
> `reference_claude_generation_path`). ב-**Opus 4.7/4.8 הפרמטר `temperature` (וגם
|
||||
> `top_p`/`top_k`) הוסר — שליחתו מחזירה HTTP 400**. הידית היחידה היא **`effort`**
|
||||
> (low/medium/high/xhigh/max; ברירת-מחדל high). **המודל נעוץ ל-`claude-opus-4-8`
|
||||
> לכל הבלוקים** ב-`block_writer.GENERATION_MODEL` — אין עוד פיצול sonnet/opus.
|
||||
> טבלת ה-Temperature והעמודה `decision_blocks.temperature` הן **מטא-דאטה-מת
|
||||
> (deprecated, תמיד 0)** ונשמרות רק לתאימות-עמודה; אל תקראו מהן. הטבלאות למטה
|
||||
> נשמרות כתיעוד-המקור-הקונספטואלי בלבד — `effort` מחליף את "Thinking budget".
|
||||
|
||||
| Generation type | Temperature | נימוק |
|
||||
### Effort — נגזר ממורכבות קוגניטיבית (הידית החיה)
|
||||
|
||||
מיפוי ה-`effort` בפועל ב-`block_writer.BLOCK_CONFIG` (#204):
|
||||
|
||||
| בלוק | gen_type | effort | נימוק |
|
||||
|------|----------|--------|-------|
|
||||
| ה (פתיחה) | paraphrase | medium | פתיחה קצרה, מבנה קבוע |
|
||||
| ו (רקע) | reproduction | medium | שעתוק ניטרלי מרובה-מקורות |
|
||||
| ז (טענות) | paraphrase | high | קיבוץ/סינתזת-טענות לפי-נושא |
|
||||
| ח (הליכים) | reproduction | medium | תיעוד כרונולוגי |
|
||||
| ט (תכניות) | guided-synthesis | high | ציטוט-תכניות מדויק (INV-AH) |
|
||||
| י (דיון) | rhetorical-construction | xhigh | חשיבה רב-שלבית CREAC (מחוץ-לתחום-WS5) |
|
||||
| יא (סיכום) | paraphrase | high | סינתזה אופרטיבית |
|
||||
| א–ד, יב | template-fill | — (script) | מילוי-שדות, ללא LLM |
|
||||
|
||||
### Temperature — (DEPRECATED) נגזר היסטורית מסוג הייצור
|
||||
|
||||
> מסמך-מקור בלבד — לא נשלח ל-API (ראה האזהרה למעלה).
|
||||
|
||||
| Generation type | Temperature (היסטורי) | נימוק |
|
||||
|----------------|-------------|-------|
|
||||
| template-fill | 0 | אין צורך בשפה — מילוי שדות |
|
||||
| reproduction | 0 | נאמנות מוחלטת למקור. אפס יצירתיות |
|
||||
@@ -493,18 +520,11 @@ Conclusion → Rule → Explanation → Application → Conclusion.
|
||||
| analytical-reasoning | 0.3-0.4 | צריך ליצור קשרים בין עקרונות משפטיים |
|
||||
| rhetorical-construction | 0.4-0.5 | טווח ביטוי רחב לכתיבה משכנעת ואלגנטית |
|
||||
|
||||
### Thinking budget — נגזר ממורכבות קוגניטיבית
|
||||
### Model — (DEPRECATED) נגזר היסטורית מדרישת דיוק
|
||||
|
||||
| Cognitive task | Budget | נימוק |
|
||||
|---------------|--------|-------|
|
||||
| template-fill / lookup | off | אין צורך בחשיבה |
|
||||
| sequential-extraction | low | חילוץ מידע חד-שלבי |
|
||||
| multi-source-integration | medium | צריך להצליב מקורות |
|
||||
| legal-analysis-with-CREAC | max (16K+) | חשיבה רב-שלבית: מסקנה → כלל → הסבר → יישום |
|
||||
> כעת נעוץ `claude-opus-4-8` לכל בלוקי-ה-AI (`GENERATION_MODEL`). הטבלה היסטורית.
|
||||
|
||||
### Model — נגזר מדרישת דיוק
|
||||
|
||||
| Accuracy profile | Model | נימוק |
|
||||
| Accuracy profile | Model (היסטורי) | נימוק |
|
||||
|-----------------|-------|-------|
|
||||
| factual-precision | sonnet | מהיר, מדויק לחילוץ עובדות |
|
||||
| precision + interpretation | opus | נדרש לפרשנות תכנית / ציטוט מובנה |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# טביעת אצבע של הקול — ניתוח הקורפוס המלא של דפנה
|
||||
|
||||
מסמך מטא-סגנון מבוסס על קריאה עמוקה של 23 החלטות 1xxx + 10 החלטות 8xxx/9xxx. מטרתו: לזקק את ה**קבועים** האמיתיים של דפנה, מעבר לפרטי תיק או סוג ערר, באופן שניתן להזריק ל-system prompt של `legal-writer`.
|
||||
מסמך מטא-סגנון מבוסס על קריאה עמוקה של 23 החלטות 1xxx + 10 החלטות 8xxx/9xxx, ומתוקף בעיגון-מדידה על קורפוס מורחב (56 החלטות, 2,670 דוגמאות-בלוק). מטרתו: לזקק את ה**קבועים** האמיתיים של דפנה, מעבר לפרטי תיק או סוג ערר, באופן שניתן להזריק ל-system prompt של `legal-writer`.
|
||||
|
||||
## רכיבי הקול — שישה מסמכים משלימים
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
מתוך ה-24 1xxx, 23 קבצים בעלי content מספיק נותחו. רובם מתפלגים בין 2,000-8,500 מילים בבלוק י.
|
||||
|
||||
**הסקה משולבת**: עכשיו הקורפוס מאוזן יותר (24 1xxx, 22 8xxx, 2 9xxx). הדפוסים שמתחת מבוססים על המכלול.
|
||||
**גרסה 3 — עיגון-מדידה על 56 החלטות / 2,670 דוגמאות-בלוק** (אצווה אחרונה, 2026-06-28): שימשה לאימות יחסי-המבנה ולחידוד דפוסי-קול מיקרו (ראו 1.11–1.13, 2.2). הדפוסים שמתחת מבוססים על המכלול.
|
||||
|
||||
---
|
||||
|
||||
@@ -68,12 +68,15 @@
|
||||
|
||||
**עיקרון**: אין פועל "אנחנו" שמשמש כקישור סתמי. כל אחד נושא תפקיד אינטלקטואלי. **לא להשתמש ב"נחדד" כפתיחת פסקה אם אין חידוד אמיתי.**
|
||||
|
||||
**חידוד (מעוגן בקורפוס-56): קבלה-מותנה כפועל-"אנחנו".** דפנה אורזת קבלת טענת-צד בפעלי-מקבל ערכיים, לא ב"הטענה מתקבלת": `מקובל עלינו הסבר [הגורם]`, `סביר ואף מעבר לכך`. אלה אינם רישום אדמיניסטרטיבי אלא הכרעה ערכית בקול-המכריע — שמרי על ההבחנה בין רישום (`נציין`) להכרעה (`מקובל עלינו`).
|
||||
|
||||
### 1.3 דפוס "אישור-לפני-דחייה" (אכן... אולם)
|
||||
מופיע ב-8/10. במקרים של דחיית טענה משמעותית, דפנה תמיד **מאשרת את הטענה בנקודה הכי גבוהה שלה** ואז מסבירה למה לא מכריעה. הביטויים החליפיים:
|
||||
- `אכן [טענה אמיתית]... אולם [למה לא מכריע]`
|
||||
- `אכן צדק [צד]... יחד עם זאת...`
|
||||
- `יש ממש בטענת [צד]... אך מאידך...`
|
||||
- `דא עקא [תפנית]`
|
||||
- **`ראשית, צודק [צד] כי [נקודה תקפה]... אלא ש[המשך]`** *(וריאציה מעוגנת בקורפוס-56: פתיחת פסקת-הכרעה במתן-צדק מפורש לצד לפני התפנית)*
|
||||
|
||||
**חריגים**: רק במקרים של דחיית סף קצרה ומובהקת, או כשאין טענה ראויה לאישור, דפנה מדלגת על הדפוס. ב-8/10 היא משתמשת בו לפחות פעם.
|
||||
|
||||
@@ -88,6 +91,8 @@
|
||||
### 1.5 ציטוטים מלאים, לא תמציות
|
||||
כשמובא תקדים — מובא במלואו (לפעמים פסקאות שלמות), עם ההפניה הסטנדרטית `(פורסם בנבו)` או `[נבו]` ותאריך. **לא** תמצית, **לא** "כפי שנקבע" בלי ציטוט. ב-9/10 ציטוטים בני 4-15 שורות.
|
||||
|
||||
**חידוד (מעוגן בקורפוס-56): אנוטציית-הדגשה אחרי ציטוט.** כשדפנה מדגישה חלק מציטוט מובא, היא חותמת בסוגריים: `(הדגשת הח"מ)` / `(הדגשה שלי)` / `(ההדגשה אינה במקור)`. זהו רכיב-קול קבוע — ההדגשה לעולם אינה שקטה; היא מסומנת במפורש כמעשה של הכותב.
|
||||
|
||||
### 1.6 הצמדה לטקסט החוק
|
||||
כשמדובר בסעיף חוק רלוונטי — דפנה מצטטת אותו במלואו (לפעמים את כל סעיפי המשנה הרלוונטיים, גם אם רק אחד נדון). דוגמאות: סעיף 100 ב-1130, סעיף 197 ב-קרקעות ירושלים, סעיף 19(ג) ב-גמר בניה.
|
||||
|
||||
@@ -103,6 +108,8 @@
|
||||
- `נקודת העוגן למסקנתנו זו היא המלצת הגורם המקצועי בוועדה` (1130)
|
||||
- `ההיקף המדויק... ייקבעו על ידי מהנדס הוועדה המקומית` (1130)
|
||||
|
||||
**חידוד מהותי לסגנון-ההסתייגות (מעוגן בקורפוס-56): הדחייה למומחה תחומה, לא מוחלטת.** דפנה מנסחת במפורש את **גבול** הריסון: הריסון חל על המישור המקצועי-שמאי בלבד, ואילו במישור המשפטי/פרשני "אין לשמאי המכריע יתרון על פני ועדת הערר". זהו מהלך-קול קבוע — היא קודם בונה את הריסון ואז חורצת בו חלון משפטי שבו היא חופשייה להכריע. הסוכן חייב לתחום כל פסקת-ריסון בהבחנה זו ולא להציג ריסון גורף.
|
||||
|
||||
### 1.9 "למעלה מן הצורך" כסגירת חלון לערעור
|
||||
ב-7/10 אחרי הכרעה משפטית עיקרית, דפנה מוסיפה טיעון חלופי:
|
||||
- `למעלה מן הצורך נוסיף כי גם אם היינו מקבלים את פרשנות העורר... התוצאה הייתה זהה` (1130)
|
||||
@@ -111,6 +118,8 @@
|
||||
|
||||
זה לא ייתור — זה הגנה אסטרטגית מפני ערעור.
|
||||
|
||||
**דפוס-תאום הפוך (מעוגן בקורפוס-56): "ניתן היה לסיים כאן... אלא ש".** היכן שעילה פשוטה הספיקה לסיום הדיון, דפנה מודה בכך במפורש (`על כן, ניתן היה לסיים את הדיון במצב דברים זה`) ורק אז ממשיכה — בדרך-כלל מפני שצד העלה טענה חדשה המחייבת מענה (`אלא ש... ולכן אדרש לכך`). זה ההיפוך של "למעלה מן הצורך": שם היא מוסיפה כדי לחסום ערעור; כאן היא מתעדת שהמשך-הדיון נכפה עליה, ולא נבע מצורך הכרעתי. שני המהלכים משרתים את אותה תכלית — שקיפות מלאה כלפי ערכאת-הביקורת העתידית.
|
||||
|
||||
### 1.10 פורמט הסיום
|
||||
3 רכיבים קבועים, בסדר זה:
|
||||
|
||||
@@ -121,6 +130,24 @@
|
||||
3. תאריך + "ניתנה פה אחד"
|
||||
```
|
||||
|
||||
### 1.11 עיגון כמותי — קבוע חוצה-סוגים (קודם תועד ל-1xxx בלבד; הורחב מקורפוס-56)
|
||||
דפנה מעגנת קביעות במספרים ספציפיים גם בתיקי 8xxx/9xxx, לא רק ב-1xxx: שיעורי הפקעה (`38.6%` מול `30%`), פערי-שטח (`כ-105 מ"ר` מול `97 מ"ר` → `הפרש של כ-7 מ"ר בלבד`), יחסי-הגדלה (`פי 1.9 בלבד ולא פי 3`), מספרי-יחידות (`הוכפל מ-16 ל-32`). הפונקציה הסגנונית קבועה: המספר **ממסגר מחדש** טענה רטורית של צד ומקטין אותה (`לא 45 מ"ר... אלא כ-7 מ"ר`). **עיקרון לסוכן**: כשצד טוען בעוצמה איכותית, דפנה משיבה בעיגון כמותי שמכווץ את הטענה למימדה האמיתי.
|
||||
|
||||
### 1.12 יחסי-מבנה מדודים (קורפוס-56, n=55, ממוצע-כלל)
|
||||
פרופורציות הבלוקים בהחלטה טיפוסית, כיחס-מהסך:
|
||||
|
||||
| בלוק | יחס |
|
||||
|------|-----|
|
||||
| רקע (background) | ~17% |
|
||||
| טענות (claims) | ~27% |
|
||||
| **דיון והכרעה (discussion)** | **~42%** |
|
||||
| סיכום (summary) | ~14% |
|
||||
|
||||
**עיקרון לסוכן**: הדיון הוא הבלוק הדומיננטי (כ-2/5 מההחלטה) — אך לא בולע אותה. טענות-הצדדים מקבלות נפח מהותי (~רבע), ולא נדחסות. אם טיוטה מקצה לדיון פחות משליש או לטענות פחות מחמישית — היא חורגת מהפרופורציה האופיינית.
|
||||
|
||||
### 1.13 רינדור נאמן של הלוגיקה הפנימית של טענת-צד
|
||||
כשדפנה מביאה טענת-צד, היא משחזרת את **מבנה-ההיסק** שלה כנרטיב רציף — כולל מהלכי קל-וחומר (`מכך יש להסיק מקל וחומר לענייננו`), אבחון-תקדים (`אבחנו את עניינם מההלכות ב...`, `ההבדל מהותי`), והיסק-תכלית. היא אינה משטחת את הטיעון ל"הצד טוען X" אלא מוסרת את הדרך שבה הצד **הגיע** ל-X. זה תקף לטענות שתידחינה לא פחות מאלה שתתקבלנה — נאמנות-הרינדור אינה תלוית-תוצאה.
|
||||
|
||||
---
|
||||
|
||||
## 2. המשתנים — לפי סוג תיק וסוג תוצאה
|
||||
@@ -158,6 +185,10 @@
|
||||
"7. שמאי מכריע ... [ציטוט מלא של פסקאות 7-8 או חלק מהן]"
|
||||
```
|
||||
|
||||
**מבנה פנימי קבוע של פסקת-הריסון (חידוד מעוגן בקורפוס-56):** הניסוח אינו עמדה גורפת אלא **מטוטלת דו-צדדית** — קודם מתי הוועדה *תאמץ* את השמאי (שיטה מקובלת + מסד עובדתי הולם, *גם אם קיימות גישות מקצועיות שונות*), ואז, בסימטריה, מתי היא *תתערב* (מסד עובדתי בלתי הולם / הנחות בלתי הגיוניות / היעדר הסבר מניח-את-הדעת / תשתית משפטית חסרה או שגויה / פגם היורד לשורש המעשה המינהלי — ניגוד עניינים, משוא פנים, חוסר תום לב). הסוכן יבנה את שתי הזרועות, לא רק את זרוע-הריסון.
|
||||
|
||||
**חיתום ההבחנה המשפטית (מעוגן):** פסקת-הריסון נסגרת תמיד בהבחנה בין המישור השמאי-מקצועי (ריסון) למישור המשפטי-פרשני (אין-ריסון; ראו 1.8). זו אינה הערת-אגב אלא חלק מהתבנית הקבועה.
|
||||
|
||||
**לסוכן ב-8xxx**: לכלול את הציטוט הזה בפתיחה אלא אם התיק לא נוגע להכרעה שמאית.
|
||||
|
||||
### 2.3 פתיח פילוסופי לתיקי 1xxx (תכנון)
|
||||
@@ -215,6 +246,19 @@
|
||||
### 3.7 ❌ אסור: עדיף "העורר טוען ש..." על "טענת העורר היא..."
|
||||
דפנה משתמשת בפעלים פעילים: `העורר טוען`, `המשיבה טוענת`, `מבקשי התכנית מבקשים`. **לא** "טענות העורר היו ש..." (פסיביזציה).
|
||||
|
||||
**קטלוג פעלי-רינדור-צד מורחב (מעוגן בקורפוס-56):** דפנה מגוונת את פועל-הרינדור לפי תפקיד-המהלך של הצד, ולעולם אינה חוזרת על "טוען" באופן מונוטוני:
|
||||
|
||||
| פועל | תפקיד-המהלך |
|
||||
|------|-------------|
|
||||
| **מסביר / מפרט** | פריסת נימוק טכני או עובדתי שלב-אחר-שלב |
|
||||
| **מדגיש / שם דגש על** | הצבת מרכז-הכובד של הטיעון |
|
||||
| **מנמק / מבסס את טענתו בכך ש** | קישור טענה לעוגן |
|
||||
| **מוסיף** | חיזוק-אגב לטיעון הראשי |
|
||||
| **אבחן (את עניינו מ-)** | הבחנת תקדים שמנגד |
|
||||
| **ביקש להסתמך על / מפנה ל** | גיוס תקדים תומך |
|
||||
|
||||
הבחירה אינה דקורטיבית — הפועל מתעד *מה הצד עושה* בטיעון, לא רק שהוא מחזיק בו.
|
||||
|
||||
---
|
||||
|
||||
## 4. תבניות מועתקות (Copy-Paste Templates)
|
||||
@@ -249,6 +293,12 @@
|
||||
|
||||
[ציטוט מלא של 5-15 שורות מפסקאות 7-8]
|
||||
|
||||
<מטוטלת: מתי תאמץ הוועדה (שיטה מקובלת + מסד הולם, גם אם יש
|
||||
גישות שונות) — ומתי תתערב (מסד בלתי הולם / הנחות לא הגיוניות /
|
||||
היעדר הסבר / תשתית משפטית שגויה / פגם בשורש המעשה המינהלי)>.
|
||||
נחדד כי הריסון חל במישור השמאי-המקצועי; במישור המשפטי-הפרשני
|
||||
אין לשמאי יתרון על פני ועדת הערר.
|
||||
|
||||
ברוח הדברים לעיל נבחן את טענות הצדדים;
|
||||
```
|
||||
|
||||
@@ -284,6 +334,13 @@
|
||||
ניתנה פה אחד, <תאריך עברי>, <תאריך לועזי>.
|
||||
```
|
||||
|
||||
### 4.8 פסקת "ניתן היה לסיים — אלא ש" (מעוגן בקורפוס-56)
|
||||
```
|
||||
<יישום העילה הפשוטה על העובדות>. על כן, ניתן היה לסיים את
|
||||
הדיון במצב דברים זה ולקבוע כי <תוצאה פשוטה>. אלא ש<הטענה
|
||||
החדשה/הנוספת שנכפתה עלינו> ולפיכך נדרש להוסיף ולהתייחס אליה;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. הוראות אופרטיביות לסוכן הכותב
|
||||
@@ -294,19 +351,22 @@
|
||||
1. **מהי התוצאה הצפויה?** דחייה / קבלה / חלקית?
|
||||
2. **מהו המתח / האיזון בלב התיק?** נסח אותו במשפט אחד — זה הולך לפתיחה (אם מוד B/C/E).
|
||||
3. **איזה מוד פתיחה מתאים?** A/B/C/D/E (ראה טבלה 2.1)
|
||||
4. **האם זה תיק 8xxx עם הכרעה שמאית?** אם כן → לכלול ציטוט בר"מ 3644/13.
|
||||
4. **האם זה תיק 8xxx עם הכרעה שמאית?** אם כן → לכלול ציטוט בר"מ 3644/13 + מטוטלת דו-צדדית + הבחנת מישור-משפטי (2.2).
|
||||
5. **האם דפנה הכריעה בתיק קשור?** אם כן → search_decisions ולכלול הפנייה / הבחנה (ראה sec 11.2 ב-voice-1130-25).
|
||||
6. **מה האורך הצפוי לפי משקל בהכרעה?** (ראה 2.4)
|
||||
7. **האם יש טענת-צד איכותית-רטורית?** אם כן → הכן עיגון כמותי שממסגר אותה מחדש (1.11).
|
||||
|
||||
### 5.2 בכתיבה — איך לבנות פסקה
|
||||
1. שימוש מודע ב"אנחנו" — בחירת פועל לפי תפקיד (טבלה 1.2)
|
||||
2. כל טענה משמעותית → פסקה מלאה. לא דחייה במשפט.
|
||||
3. אם דוחים טענה → "אכן [נקודה תקפה]... אולם [למה לא מכריע]"
|
||||
4. ציטוטים → במלואם, לא תמציות
|
||||
3. אם דוחים טענה → "אכן [נקודה תקפה]... אולם [למה לא מכריע]" (או "ראשית, צודק [צד]... אלא ש")
|
||||
4. ציטוטים → במלואם, לא תמציות; הדגשה תמיד חתומה ב"(הדגשת הח"מ)"
|
||||
5. סעיפי חוק → במלואם
|
||||
6. "למעלה מן הצורך" → לטיעונים מרכזיים
|
||||
7. דחייה למומחים → לסוגיות תכנוניות-טכניות
|
||||
8. **ללא רשימות ממוספרות** באנליזה
|
||||
6. "למעלה מן הצורך" → לטיעונים מרכזיים; "ניתן היה לסיים... אלא ש" → כשטענה נכפתה אחרי עילה מספקת
|
||||
7. דחייה למומחים → לסוגיות תכנוניות-טכניות, **תמיד תחומה למישור המקצועי** (1.8)
|
||||
8. רינדור-צד → שחזר את מבנה-ההיסק, גוון את פועל-הרינדור (1.13, 3.7)
|
||||
9. עיגון כמותי → המר טענות איכותיות למספרים שמכווצים אותן (1.11)
|
||||
10. **ללא רשימות ממוספרות** באנליזה
|
||||
|
||||
### 5.3 חיפוש תקדימים אישיים
|
||||
לפני כתיבה — `search_decisions` בקטגוריה זהה. אם יש תקדים של דפנה עצמה — חובה להפנות אליו ב-3 מודים אפשריים:
|
||||
@@ -321,6 +381,8 @@
|
||||
- [ ] אין דחיית טענה במשפט אחד
|
||||
- [ ] אין רטוריקה דרמטית של הצדדים בקול ההכרעה
|
||||
- [ ] אין תוצאה הכל-או-לא-כלום בתיק עם טענות מהותיות משני הצדדים
|
||||
- [ ] פסקת-ריסון-שמאי תחומה למישור המקצועי (לא גורפת)
|
||||
- [ ] הדיון לא חורג מטה מ-~⅓ מההחלטה, וטענות לא נדחסות מתחת ל-~⅕ (1.12)
|
||||
|
||||
---
|
||||
|
||||
@@ -362,6 +424,10 @@
|
||||
| **שוב על מנת שלא לצאת בחסר** | להוצאת ערך נוסף |
|
||||
| **בשולי הדברים** | להבעת הסתייגות בעדינות |
|
||||
| **מצאנו להוסיף כי...** | תוספת חופשית |
|
||||
| **מקובל עלינו (הסבר/עמדת)** | קבלת עמדה ערכית *(קורפוס-56)* |
|
||||
| **לא מצאנו מקום להתערב** | אישרור הכרעת-מומחה *(קורפוס-56)* |
|
||||
| **ראוי לציין כי** | הצבת הסתייגות/הבחנה *(קורפוס-56)* |
|
||||
| **יש להבחין בין... לבין** | תיחום גבול-ריסון/הבחנה *(קורפוס-56)* |
|
||||
|
||||
### 6.5 ביטויים מסורתיים שאומצו (כל אחד מקבל ציטוט מקורי)
|
||||
- **"כבדהו וחשדהו"** — לכלי השימוש החורג (מקור: עע"מ 109/12 גבעת האירוסים)
|
||||
@@ -372,7 +438,7 @@
|
||||
- **"בלשון המעטה"** — להסתייגות מנומסת
|
||||
- **"בנדון דנא"** — נוסח מליצי לקדם דיון
|
||||
- **"דא עקא"** — לתפנית בטיעון
|
||||
- **"ודוק"** — להבהרה / reductio ad absurdum
|
||||
- **"ודוק"** — להבהרה / reductio ad absurdum (מופיע גם בלב רינדור-טענה, להדגשת נקודה קריטית בתוך הצגת עמדת-צד)
|
||||
- **"ברי כי..."** — קביעה משכנעת
|
||||
- **"ללמדך כי..."** — מסקנה מציטוט
|
||||
|
||||
@@ -397,7 +463,7 @@
|
||||
**1015-24** נכתב בגוף ראשון יחיד ("אינני סבור", "לדעתי") — דעת מיעוט / חבר ועדה אחר. **לא לחקות.**
|
||||
|
||||
### 6.10 מצב הרשתות — סטטיסטיקה
|
||||
- **24 תיקי 1xxx** + **22 תיקי 8xxx** + **2 תיקי 9xxx** = 48 בקורפוס
|
||||
- **24 תיקי 1xxx** + **22 תיקי 8xxx** + **2 תיקי 9xxx** = 48 בקורפוס המנותח (וכן 56 החלטות / 2,670 דוגמאות-בלוק באצוות-המדידה האחרונה)
|
||||
- **~30 תקדמים חיצוניים** ש**דפנה מצטטת באופן עקבי** (ראה precedent-network.md)
|
||||
- **~15 תקדמים אישיים** שלה עצמה — מהווים את הקאנון האישי שלה
|
||||
|
||||
@@ -430,7 +496,7 @@
|
||||
כשמבקש שימוש חורג גם מקדם תכנית — דפנה מנתחת: האם השימוש המבוקש **תואם** את התכנון העתידי (→ גשר לגיטימי, כמו בכוכבה תורן)? או **סותר** (→ סטייה כפולה)? מסגרת ניתוח שלמה (249 מילים) שלא הייתה בטיוטה.
|
||||
|
||||
### ה. עיגון כמותי
|
||||
דפנה מוסיפה נתונים מספריים ספציפיים: "4,404.98 מ"ר לכלל היישוב vs 1,425 מ"ר מבוקש — 32%". המספרים מעגנים את ההחלטה במציאות ומקשים על ערעור.
|
||||
דפנה מוסיפה נתונים מספריים ספציפיים: "4,404.98 מ"ר לכלל היישוב vs 1,425 מ"ר מבוקש — 32%". המספרים מעגנים את ההחלטה במציאות ומקשים על ערעור. *(אומת כקבוע חוצה-סוגים בקורפוס-56 — ראו 1.11.)*
|
||||
|
||||
### ו. כותרות שטוחות (Heading 2 בלבד)
|
||||
דפנה השתמשה ב-Heading 2 לכל הסעיפים, כולל תת-נושאים בדיון. **אין Heading 3**. כל סעיף עומד בפני עצמו.
|
||||
@@ -459,14 +525,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 7. הצעד הבא — הזרקת הקול ל-`legal-writer`
|
||||
## 8. הצעד הבא — הזרקת הקול ל-`legal-writer`
|
||||
|
||||
מסמך זה (יחד עם voice-1130-25.md) הוא הבסיס. הצעד הבא: לעדכן את ה-system prompt של `legal-writer` (ראה `~/.claude/agents/legal-writer.md` או `mcp-server/.../get_style_guide`) כך שיכלול:
|
||||
|
||||
1. הקבועים מסעיף 1
|
||||
1. הקבועים מסעיף 1 (כולל 1.11–1.13)
|
||||
2. ההוראות האופרטיביות מסעיף 5
|
||||
3. תבניות העתקה מסעיף 4
|
||||
3. תבניות העתקה מסעיף 4 (כולל 4.8)
|
||||
4. אנטי-דפוסים מסעיף 3
|
||||
5. הפנייה לטבלת מודי הפתיחה (2.1)
|
||||
|
||||
זה דורש קריאה של ההגדרה הקיימת של `legal-writer` ועדכון מבני שלה.
|
||||
זה דורש קריאה של ההגדרה הקיימת של `legal-writer` ועדכון מבני שלה.
|
||||
@@ -0,0 +1,170 @@
|
||||
# ממצאי ביקורת — ארכיטקטורת קורפוס־הפסיקה + מצב הדאטה בפועל
|
||||
|
||||
> **מקור:** Claude (Opus 4.8) · **תאריך:** 2026-06-20 · **קונטקסט:** חקירה לקראת תכנון־מחדש של קורפוס־הפסיקה.
|
||||
> מסמך זה הוא **אחד מכמה** קלטי־סוכנים שחיים אוסף; ייעודו להזין את שלב־הסינתזה. אינו תכנית — הוא **אבחון**.
|
||||
>
|
||||
> **שאלת־המוצא של חיים:** "הקורפוס נבנה מראש לא נכון, אני כל הזמן מתעסק בתיקונים. האם כדאי ליצור מחדש את קורפוס־הפסיקה ולהתחיל דף נקי?"
|
||||
>
|
||||
> **המודל הרצוי (כפי שחיים תיאר אותו):** מאגר פסקי־דין והחלטות ועדות־ערר; חוקר־התקדימים מזהה בשלב ניתוח־הערר פס"ד/החלטות שדנו במקרה דומה או הלכה דומה; הסוכן־הכותב משייך ומזכיר אותם בפרק הדיון וההכרעה בסגנון דפנה. שלושה מקורות־הזנה: (1) החלטות דפנה עצמה, (2) ועדות־ערר אחרות שמצטטות פסיקה, (3) פס"ד עליון/מחוזי.
|
||||
|
||||
---
|
||||
|
||||
## 0. תקציר־מנהלים (TL;DR)
|
||||
|
||||
**מה ש"בנוי לא נכון" אינו הסכמה — היא שכבת־הביצוע.** הסכמה כבר תואמת בדיוק את המודל שחיים תיאר: טבלה אחת (`case_law`) ששלושת המקורות נכנסים אליה דרך `source_kind`/`source_type`; שלושת ה־`search_*` הם שלושה *מסננים* על אותו מקור, לא שלושה מאגרים מקבילים (G2 מקוים ברמת־הסכמה). **רֵבילד של הסכמה ייצר בדיוק את אותה סכמה** — ולכן אינו פותר דבר, ומסכן ב־second-system syndrome.
|
||||
|
||||
מה שכן מחולל את "התיקונים האינסופיים" — שלושה כשלי־ביצוע מדידים:
|
||||
1. **חוזה־קליטה רופף** → 66% מהפסיקה בלי `practice_area`, 31 רשומות ריקות, אכיפת־שלמות (INV-DM1) מופרת בפועל.
|
||||
2. **צינור הלכות→קנוני מייצר רעש** → 5,472 קנוני, מתוכם 5,456 סינגלטונים, **0 published** → השכבה שאמורה להזין את הכותב (INV-G10) **אינרטית לגמרי**.
|
||||
3. **כפילות `style_corpus`** → 55 החלטות דפנה חיות בשני נתיבי־אחזור.
|
||||
|
||||
**המלצה:** לא לשרוף את הסכמה. כן לבצע "איפוס שכבות־נגזרות" צר (truncate ל-chunks+halachot+canonical והרצה־מחדש), **אבל רק אחרי תיקון החוזה והסף** — אחרת מחזירים את אותו בלגן (G1: תיקון במקור, לא בקריאה). מסמכי־המקור (363 רשומות, 332 עם full_text) נשמרים; הם יקרים ו/או ניתנים לקליטה־מחדש מ־PDF.
|
||||
|
||||
---
|
||||
|
||||
## 1. מצב הדאטה בפועל (שאילתות חיות מול legal_ai @ localhost:5433, 2026-06-20)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┬─────────┬──────────────────────────────────┐
|
||||
│ Metric │ Count │ Reading │
|
||||
├─────────────────────────────────────┼─────────┼──────────────────────────────────┤
|
||||
│ case_law (total precedents) │ 363 │ קטן — re-ingestable │
|
||||
│ • external_upload (court rulings) │ 240 │ מקור (3) — עליון/מחוזי │
|
||||
│ • internal_committee (ועדות ערר) │ 92 │ מקור (1)+(2) │
|
||||
│ • (יתר — ללא source_kind מובהק) │ 31 │ = הרשומות הריקות (ראה למטה) │
|
||||
│ style_corpus (החלטות דפנה) │ 55 │ כפילות עם internal_committee │
|
||||
│ precedent_chunks │ 11,904 │ נגזר — מתחדש מ-full_text │
|
||||
│ halachot (total) │ 5,489 │ נגזר │
|
||||
│ • approved/published │ 1,352 │ 25% בלבד │
|
||||
│ • pending_review (backlog ידני) │ 2,402 │ 44% — צוואר־בקבוק │
|
||||
│ canonical_halachot (V41) │ 5,472 │ כמעט 1:1 עם halachot ⚠️ │
|
||||
│ • singletons (instance_count=1) │ 5,456 │ דה־דופ כמעט לא קרה │
|
||||
│ • merged (instance_count>=2) │ 16 │ 0.3% מיזוג │
|
||||
│ • published (מגיע לסוכן הכותב) │ 0 │ ⚠️ השכבה אינרטית לחלוטין │
|
||||
│ case_law w/o practice_area │ 240 │ 66% — חופף-בדיוק לפסיקה החיצונית │
|
||||
│ case_law missing summary │ 27 │ │
|
||||
│ case_law w/ 0 chunks / no full_text │ 31 │ רשומות שבורות/ריקות │
|
||||
│ distinct practice_area │ 4 │ rishuy/betterment/197/(ריק) │
|
||||
└─────────────────────────────────────┴─────────┴──────────────────────────────────┘
|
||||
|
||||
practice_area breakdown:
|
||||
(ריק) 240 ← כל הפסיקה החיצונית ללא סיווג
|
||||
rishuy_uvniya 70
|
||||
betterment_levy 50
|
||||
compensation_197 3
|
||||
```
|
||||
|
||||
**קריאות מפתח:**
|
||||
- **240 = 240:** מספר הפסיקה־החיצונית שווה־בדיוק למספר חסרי־`practice_area`. כלומר אף פס"ד חיצוני לא סווג לתחום — סינון לפי תחום באחזור פשוט לא עובד עליהם.
|
||||
- **5,456 / 5,472 סינגלטונים:** מנוע הקנוניזציה (V41) רץ אך לא מאחד. סף 0.85 כנראה הדוק מדי, או שהחילוץ מנסח כל הלכה ייחודית מספיק כדי לא להתלכד.
|
||||
- **0 published canonical:** לפי INV-G10 רק קנוני `published` מגיע לכתיבה. אפס. **כל מנגנון V41 כרגע מנותק מהכתיבה בפועל.**
|
||||
- **2,402 pending_review:** צוואר־הבקבוק הוא אישור־אנושי ידני, לא טכנולוגיה.
|
||||
|
||||
---
|
||||
|
||||
## 2. מפת הארכיטקטורה — קורפוס אחד, שלושה מסננים
|
||||
|
||||
**פיזית: טבלה אחת.** כל שלושת המקורות מתכנסים ל-`case_law`, מתחתיה `precedent_chunks` (FK) ו-`halachot` (FK), ומעל ה-`halachot` שכבת `canonical_halachot` (V41).
|
||||
|
||||
```
|
||||
canonical_halachot (עקרונות מאוחדים — V41; כיום אינרטי, 0 published)
|
||||
▲ 1:many
|
||||
halachot (מופע-להלכה per precedent; review_status gate)
|
||||
▲ FK
|
||||
case_law (רישום מרכזי — court rulings + ועדות-ערר)
|
||||
├─ source_kind='external_upload' → פס"ד עליון/מחוזי [מקור 3]
|
||||
└─ source_kind='internal_committee'→ ועדות-ערר + דפנה [מקור 1+2]
|
||||
▼ FK
|
||||
precedent_chunks (chunks + embedding vector(1024))
|
||||
|
||||
בנפרד:
|
||||
style_corpus (55 החלטות דפנה — נתיב-אחזור מקביל ל-search_decisions)
|
||||
document_chunks (מסמכי-תיק + style; FK→documents→cases)
|
||||
```
|
||||
|
||||
**שלושת נתיבי־האחזור (לא שלושה מאגרים — שלושה scopes):**
|
||||
|
||||
```
|
||||
┌──────────────────────────────┬─────────────────────────┬────────────────────────────┐
|
||||
│ Tool │ Table / filter │ Purpose │
|
||||
├──────────────────────────────┼─────────────────────────┼────────────────────────────┤
|
||||
│ search_decisions │ document_chunks │ סגנון/קול: החלטות דפנה │
|
||||
│ │ (scoped case/area) │ + מסמכי-תיק │
|
||||
│ search_precedent_library │ case_law + chunks/halach │ source_kind=external_upload│
|
||||
│ │ ot, source_kind filter │ → פסיקה חיצונית │
|
||||
│ search_internal_decisions │ אותן פונקציות DB, │ source_kind=internal_ │
|
||||
│ │ source_kind אחר │ committee → ועדות-ערר │
|
||||
└──────────────────────────────┴─────────────────────────┴────────────────────────────┘
|
||||
```
|
||||
|
||||
שתי האחרונות קוראות **לאותן פונקציות DB** (`search_precedent_library_semantic`/`_lexical`) עם `source_kind` שונה. זו הפרדה־בשאילתה, לא קוד מקביל.
|
||||
|
||||
---
|
||||
|
||||
## 3. שלושת המקורות של חיים → איפה הם נופלים היום
|
||||
|
||||
```
|
||||
┌────────────────────────────────────┬───────────────────────────────┬─────────────────────────┐
|
||||
│ Source (חיים) │ Stored as │ Retrieval │
|
||||
├────────────────────────────────────┼───────────────────────────────┼─────────────────────────┤
|
||||
│ (1) החלטות דפנה עצמה │ style_corpus + (מהוגר ל-) │ search_decisions + │
|
||||
│ │ case_law internal_committee │ search_internal_decisions│
|
||||
│ │ chair_name='דפנה תמיר' │ ← כפילות / נתיב-כפול │
|
||||
│ (2) ועדות-ערר אחרות │ case_law internal_committee │ search_internal_decisions│
|
||||
│ │ chair_name=<אחר>, district │ │
|
||||
│ (3) פס"ד עליון/מחוזי │ case_law external_upload │ search_precedent_library │
|
||||
│ │ source_type='court_ruling' │ │
|
||||
└────────────────────────────────────┴───────────────────────────────┴─────────────────────────┘
|
||||
```
|
||||
|
||||
**מסקנה:** המודל המנטלי של חיים **כבר ממומש בסכמה**. אין צורך להמציא מבנה חדש — צריך לאכוף את המבנה הקיים בקליטה, ולחבר את שכבת־הקנוני לכתיבה.
|
||||
|
||||
---
|
||||
|
||||
## 4. הדיאגנוזה — מה "בנוי לא נכון" (3 מחוללי־כאב)
|
||||
|
||||
### 4.1 חוזה־קליטה רופף (root cause #1)
|
||||
- 66% מהפסיקה ללא `practice_area`; 27 ללא summary; 31 ללא full_text/chunks.
|
||||
- אין אכיפה ש"`searchable=false` עד שהמטא שלם" → INV-DM1 מופר בפועל.
|
||||
- **כל העלאה מוסיפה חוב** במקום רשומה שלמה. זה המקור לתיקונים החוזרים.
|
||||
|
||||
### 4.2 צינור הלכות→קנוני מייצר רעש, לא ערך (root cause #2)
|
||||
- 5,456/5,472 סינגלטונים → דה־דופ לא עובד (סף 0.85? ניסוח־חילוץ?).
|
||||
- **0 published** → השכבה שאמורה להזין את הכותב (INV-G10) מנותקת.
|
||||
- 2,402 בתור־אישור־ידני → הצינור מייצר מהר יותר ממה שאדם מאשר.
|
||||
- **זה בולע את רוב זמן־התחזוקה.**
|
||||
|
||||
### 4.3 כפילות style_corpus (root cause #3)
|
||||
- 55 החלטות דפנה בשני מקומות + שני נתיבי־אחזור.
|
||||
- צריך מקור־אמת אחד: או `style_corpus` SoT וה-`case_law` נגזר, או הפוך.
|
||||
|
||||
---
|
||||
|
||||
## 5. ההמלצה — לא רֵבילד־סכמה; "איפוס שכבות־נגזרות" אחרי תיקון־חוזה
|
||||
|
||||
**אסור** לשרוף ולהעלות־מחדש את `case_law` — תבנה אותה סכמה ותאבד מטא־דאטה ידני. **כן** הגיוני רֵבילד צר של ה**נגזר**, אבל בסדר הזה (G1 — מקור לפני תסמין):
|
||||
|
||||
1. **קודם החוזה:** אכוף ב-`*_upload` שדות־חובה (practice_area, summary, full_text); כשל → `searchable=false`. נקה/מחק את 31 הריקות.
|
||||
2. **תקן את הקנוניזציה:** כוונן סף 0.85, הגדר מתי `published`, ובדוק ניסוח־החילוץ. בלי זה אין טעם להריץ מחדש.
|
||||
3. **רק אז** re-derive מהמקור הקיים: chunks → halachot → canonical.
|
||||
4. **הכרע style_corpus:** מקור־אמת אחד.
|
||||
|
||||
**מתי רֵבילד־מלא כן מוצדק:** רק אם יתגלה שמסמכי־המקור עצמם (PDF/full_text של 363) פגומים/חסרים. המספרים *לא* מראים זאת (332/363 עם full_text תקין).
|
||||
|
||||
---
|
||||
|
||||
## 6. שאלות פתוחות לשלב־הסינתזה
|
||||
- **למה 0 published בקנוני?** האם זה סף, ניסוח־חילוץ, או שפשוט אף אחד לא אישר? (קריטי — קובע אם V41 שמיש בכלל.)
|
||||
- **style_corpus מול case_law:** מי SoT? (משפיע על search_decisions מול search_internal_decisions.)
|
||||
- **practice_area לפסיקה חיצונית:** לחלץ אוטומטית בקליטה, או להשאיר ידני?
|
||||
- **תור־האישור (2,402):** האם המנגנון (פאנל/active-learning, #133) יכול לסגור את הפער, או שצריך לחתוך את קצב־החילוץ?
|
||||
|
||||
---
|
||||
|
||||
## 7. נספח — קבצים מרכזיים (file:line)
|
||||
- סכמה: `mcp-server/src/legal_mcp/services/db.py` (case_law, precedent_chunks, halachot, canonical_halachot)
|
||||
- קליטה חיצונית: `mcp-server/src/legal_mcp/tools/precedent_library.py` (`precedent_library_upload`)
|
||||
- קליטה פנימית: `mcp-server/src/legal_mcp/tools/internal_decisions.py` (`internal_decision_upload`)
|
||||
- הגירת style→case_law: `mcp-server/src/legal_mcp/services/internal_decisions.py` (`migrate_from_style_corpus`, chair/district hardcoded)
|
||||
- אחזור: `mcp-server/src/legal_mcp/tools/search.py`, `services/hybrid_search.py`, `services/db.py` (`search_precedent_library_semantic`/`_lexical`)
|
||||
- ספ: `docs/spec/02-data-model.md` (INV-DM1–7), `docs/spec/03-retrieval.md` (INV-RET1–5), `docs/spec/00-constitution.md` (G2/G10)
|
||||
@@ -0,0 +1,125 @@
|
||||
# 06 — נדיבות-המחלץ: `application` בהחלטות-ועדה (כימות חוצה-קורפוס)
|
||||
|
||||
> **קלט-נתונים** ליוזמה, נמדד חי על **5,489 רשומות `halachot`** (כל הקורפוס, 2026-06-20).
|
||||
> נולד משאלת-חיים: "8508-03-24 מפיק 71 הלכות ממתינות — האם המחלץ נדיב מדי על החלטות-ועדה,
|
||||
> או שזה ספציפי לתיק הזה?" התשובה: **שיטתי, לא ספציפי — אבל הנדיבות מוצדקת.**
|
||||
>
|
||||
> **מתכתב עם [`00-final-synthesis.md`](00-final-synthesis.md):** הנתונים כאן **מחזקים** את הכרעת-הסינתזה
|
||||
> ("לא לחתוך") ומוסיפים שני דברים שלא היו לה: (א) כימות חוצה-קורפוס של ה-`application`, (ב) ממצא
|
||||
> חדש וגדול יותר — `nli_unsupported` על הפסיקה החיצונית. ראה §4 (יישוב-מתח) ו-§5 (דרכי-פעולה).
|
||||
|
||||
---
|
||||
|
||||
## 1. הממצא המרכזי — `application` הוא תופעה שיטתית של החלטות-ועדה
|
||||
|
||||
הפרדנו את הקורפוס לפי `authority` (נגזר דטרמיניסטית מ-`precedent_level`, [02-data-model §162](../spec/02-data-model.md)):
|
||||
`binding` = פסיקת-עליון/מנהלי · `persuasive` = ועדת-ערר מחוזית.
|
||||
|
||||
```text
|
||||
source rows rt=application nli_unsupported any-flag
|
||||
court (binding) 3,603 0.2% 39.7% 45.4%
|
||||
committee (persuasive) 1,842 13.7% 25.0% 30.5%
|
||||
```
|
||||
|
||||
- **`rule_type='application'` הוא כמעט-בלעדית של ועדות:** 13.7% מול 0.2%. מתוך 258 רשומות-`application`
|
||||
בכל הקורפוס, **~252 מגיעות מהחלטות-ועדה.** פער של פי-~70.
|
||||
- **עקבי בין תחומים** (לא עניין-שמאות נקודתי):
|
||||
```text
|
||||
committee, rt=application by practice_area:
|
||||
rishuy_uvniya 13.5% betterment_levy 14.1% compensation_197 18.8%
|
||||
```
|
||||
|
||||
**הפרשנות (תואם [02-data-model §163](../spec/02-data-model.md)):** `application` = "החלה תלוית-עובדות —
|
||||
לרוב לא-הלכה". ועדת-ערר היא גוף מיישֵם: היא לא יוצרת הלכה (רק בית-משפט עושה זאת), אלא **מיישמת
|
||||
פסיקת-עליון קיימת** (לוסטרניק, דלי-דליה) על עובדות-התיק. לכן חילוץ מהחלטת-ועדה מייצר באופן מובנה
|
||||
שיעור גבוה של יישומי-דוקטרינה. **זו תכונה של מקור-הנתונים, לא באג של המחלץ.**
|
||||
|
||||
## 2. 8508-03-24 — מייצג בקצה-העליון, לא חריג
|
||||
|
||||
```text
|
||||
דירוג 8508-03-24: 7 מתוך 45 תיקי-ועדה (≥15 רשומות) · 27% application (rt או flag)
|
||||
חציון תיקי-הוועדה: 11.9%
|
||||
מעליו: 1001-02-19 (40%) · 1044-08-22 (37%) · 9002-24 (33%) · 1007-01-25 (30%)
|
||||
```
|
||||
|
||||
8508 הוא ~פי-2.3 מהחציון (רבעון-עליון), אבל מה שבולט בו הוא בעיקר ש**הוא התיק הארוך בקורפוס**
|
||||
(111 רשומות) — אז 27% נותן 30 פריטי-`application` במספר מוחלט, הגבוה בקורפוס. כלומר: **כמות גבוהה,
|
||||
שיעור גבוה-אך-נורמלי.** אין כאן פתולוגיה ייחודית לתיק.
|
||||
|
||||
## 3. מנגנון-הניתוב הקיים (איך זה כבר מטופל ב-UI)
|
||||
|
||||
הפיצול בתור-ההלכות (`/precedents` → "תור הלכות") אינו לפי `exclude_low_quality`, אלא לפי
|
||||
`isExtractionFixItem(h) = (quality_flags.length>0) && !panel_round`
|
||||
([web-ui/.../precedent-library.ts:652](../../web-ui/src/lib/api/precedent-library.ts#L652)):
|
||||
|
||||
```text
|
||||
8508-03-24, 71 ממתינות (מצב 2026-06-20):
|
||||
bucket # panel? →"להכרעתך" →"דורש תיקון-חילוץ"
|
||||
clean (ללא דגל) 40 15/40 40 0
|
||||
application 23 0/23 0 23
|
||||
nli_unsupported 4 4/4 4 0
|
||||
thin_restatement 4 0/4 0 4
|
||||
```
|
||||
|
||||
**משמעות:** פריטי-`application` (חסרי-פאנל) כבר מנותבים ל**"דורש תיקון-חילוץ"** — **מחוץ** לתור-ההכרעה
|
||||
של היו"ר. כלומר המערכת כבר מסננת אותם מהעומס-הידני. (הפאנל התלת-מודלי, [halacha_panel_approve.py:191](../../scripts/halacha_panel_approve.py#L191),
|
||||
מטפל רק בדליים `clean`+`nli`; `application` ו-`defect` עוקפים אותו במכוון.)
|
||||
|
||||
## 4. ⚠️ יישוב-המתח מול הסינתזה — `application` ≠ "רעש"
|
||||
|
||||
זו הנקודה הקריטית להעברה. **אסור** לתרגם "13.7% application" ל"13.7% רעש לחיתוך". המבחן בסינתזה
|
||||
(§2 שם) כבר הוכיח שחיתוך-אגרסיבי על 8508 השמיד את **לוסטרניק** ו-~22 עקרונות-ליבה. רוב פריטי-ה-`application`
|
||||
הם בדיוק יישומי-הדוקטרינה הללו — **בני-ציטוט שהכותב צריך**. דוגמאות אמיתיות מ-8508 שמסומנות `application`:
|
||||
|
||||
```text
|
||||
"ציפיות הנובעות אך ממיקומם של המקרקעין... אין לנטרלן" ← לוסטרניק מיושמת — לשמור!
|
||||
"בחישוב שווי במצב קודם יש לכלול ציפיות כלליות... ולא ספציפיות" ← ליבת חישוב היטל-ההשבחה
|
||||
"קביעת מקדם מצויה בליבת שיקול-דעת השמאי, והוועדה לא תתערב" ← סטנדרט אי-התערבות מיושם
|
||||
```
|
||||
|
||||
לכן: **הנתון הזה תומך ב"שמור-בספק" של עמוד-1/2 בסינתזה.** המסקנה הנכונה אינה "לסנן application" אלא
|
||||
"`application` מאשר שעקרוני-הוועדה הם persuasive-יישומיים — לדרג אותם נמוך באחזור (רמה B), לא למחוק
|
||||
אותם (רמה A)". `importance=0` ל-8508 כבר משקיע אותם ממילא.
|
||||
|
||||
## 5. ⭐ ממצא-לוואי גדול יותר — `nli_unsupported` על הפסיקה החיצונית
|
||||
|
||||
```text
|
||||
nli_unsupported: court (binding) 39.7% ≫ committee 25.0%
|
||||
any-flag: court 45.4% · committee 30.5%
|
||||
```
|
||||
|
||||
**כמעט מחצית מעקרוני-הפסיקה-החיצונית נושאים דגל-איכות, בעיקר `nli_unsupported`** (הכלל אינו נגזר
|
||||
לוגית מהציטוט התומך שלו, [halacha_quality.py:282](../../mcp-server/src/legal_mcp/services/halacha_quality.py#L282)).
|
||||
זה **מגמד מספרית** את סוגיית-ה-`application`, ונוגע ישירות ל"רמה A = ניקוי-רעש" של הסינתזה:
|
||||
**מהו ה"רעש" שמנקים?** הדגל הדומיננטי הוא `nli`, והוא מרוכז בפסיקה, לא בוועדות.
|
||||
|
||||
שתי השערות מתחרות, שצריך להכריע ביניהן **לפני** שמשתמשים ב-`nli` כמסנן-רעש ברמה A:
|
||||
- **(א) הבודק מחמיר מדי** — סף-ה-NLI חותך יישורים לגיטימיים → 40% הם false-positives, וה"רעש" מדומה.
|
||||
- **(ב) החילוץ-מהפסיקה לקוי** — ציטוטים תומכים שלא מיישרים לכלל → בעיית-חילוץ אמיתית בקנה-מידה.
|
||||
|
||||
ההכרעה משנה את כל אסטרטגיית רמה-A. **אנו ממליצים לאמת זאת על מדגם-זהב לפני כל שימוש ב-`nli` כסיגנל.**
|
||||
|
||||
---
|
||||
|
||||
## 6. דרכי-פעולה מוצעות (לסוכן-הקורפוס)
|
||||
|
||||
ממוין מהשמרני לאגרסיבי. ההמלצה שלנו: **B כברירת-מחדל + C כעבודה-מקבילה**; להימנע מ-A ומ-D.
|
||||
|
||||
| # | פעולה | טיעון | סיכון | המלצה |
|
||||
|---|-------|-------|-------|-------|
|
||||
| **A** | לכוונן את ה-prompt לדכא `application` מוועדות במקור | מטפל-בשורש (G1); חוסך 14% רשומות | **גבוה** — סותר את מבחן-8508; משמיד יישומי-לוסטרניק בני-ציטוט | ✗ לא |
|
||||
| **B** | להשאיר את החילוץ; לסמוך על הניתוב הקיים (`application`→"תיקון", מחוץ לתור-היו"ר) + לדרג נמוך ב-RRF | תואם-סינתזה (שמור-בספק + דרג-בזמן-אחזור); אפס סיכון-אובדן | הרעש נשאר ב-DB (אחסון בלבד) | ✓ **כן — ברירת-מחדל** |
|
||||
| **C** | לחקור קודם את `nli_unsupported` (40% פסיקה): מדגם-זהב, להכריע (א) מחמיר-מדי מול (ב) חילוץ-לקוי | זה הסיגנל הגדול; הכרחי לפני שמגדירים "רעש" ברמה A | דורש מדגם מתויג-ידנית | ✓ **כן — במקביל** |
|
||||
| **D** | להפסיק חילוץ-עקרונות מוועדות לגמרי | רוב עקרוני-הוועדה הם שכתוב-persuasive של עליון | **קיצוני** — נוגד 07-learning §61 (ועדות ברות-ציטוט במכוון); נוגע INV-LRN | ✗ לא (אלא בהכרעת-יו"ר מפורשת) |
|
||||
|
||||
### ההמלצה המזוקקת
|
||||
1. **לא לגעת בחילוץ-מוועדות** — הנתון מאשר שהנדיבות מוצדקת; `application`=יישום-בר-ציטוט, לא זבל. עקבי עם
|
||||
הכרעת-הסינתזה "לא לחתוך".
|
||||
2. **רמה B עושה את העבודה** — `importance` boost ב-RRF מטביע את עקרוני-הוועדה ה-persuasive מתחת
|
||||
לפסיקה-המחייבת, בלי למחוק דבר. 8508 (`importance≈0`) שוקע ממילא.
|
||||
3. **להעביר את ה-`nli` לראש תור-המחקר** — לפני שמשתמשים בו כמסנן-רעש ברמה A, לאמת אם 40% אמיתי.
|
||||
|
||||
---
|
||||
|
||||
> **מקור-הנתונים:** `GET /api/halachot?limit=100000` (5,489 שורות) + per-case `?case_law_id=…`.
|
||||
> ניתן לשחזר את כל המספרים מהשאילתות האלו. הקאנון הוא live — שיעורים ינועו ככל שהדריינר/פאנל רצים.
|
||||
@@ -96,10 +96,11 @@ idempotency-key pattern · ISO 8000 (Data quality) | סטטוס: verified
|
||||
בנקודת-סיום-הקליטה, לכל סוג; חוזה-שלמות יסמן רשומה ללא מטא-דאטה כלא-שמישה
|
||||
([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), מפורט ב-
|
||||
[02-data-model.md](02-data-model.md)).
|
||||
**הפרה ידועה:** המסלול הפנימי (`internal_decisions.py:208`) מתזמן **רק**
|
||||
`request_halacha_extraction` ואינו קורא ל-`request_metadata_extraction` (בניגוד
|
||||
ל-`precedent_library.py:292-293` שקורא לשניהם) → ערן סופר 8046/24 נקלטה **בלי
|
||||
metadata** (headnote/summary/tags ריקים) → ממצא ל-[audit](../audit-report.md).
|
||||
**הפרה ידועה — ✅ נסגרה (FU-1, GAP-02):** המסלול הפנימי דילג בעבר על חילוץ-המטא-דאטה
|
||||
(`internal_decisions.py:208` תיזמן רק `request_halacha_extraction`) → ערן סופר 8046/24 נקלטה
|
||||
בלי metadata. **נסגר:** שני מסלולי-הפסיקה זורמים כעת דרך `ingest.ingest_document` הקנוני, שמתזמן
|
||||
**את שני התורים יחד** בנקודה אחת (`ingest.py:233-234`) — תיזמון-המטא אינו מותנה במסלול עוד.
|
||||
מאומת ב-`test_unified_ingest.py` (9/9). תיעוד-מקור: [audit](../audit-report.md).
|
||||
|
||||
### INV-ING4: re-index בקליטה-חוזרת (upsert ⇒ re-embed)
|
||||
**כלל:** קליטה-חוזרת ששינתה את תוכן-הפריט מפעילה **re-index** — chunks ו-embeddings
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
| ישות | תפקיד | מזהה-קנוני | שדות-מפתח (מאומתים `db.py`) |
|
||||
|------|--------|-------------|------------------------------|
|
||||
| `cases` | תיק ערר חי (1xxx/8xxx/9xxx) | `case_number` + `proceeding_type` | `title`, `status`, `practice_area`, `appeal_subtype`, `proceeding_type`, `chair_name` (`db.py:74-91,182-189,747,912`) |
|
||||
| `documents` | מסמך-מקור משויך לתיק | `id` (UUID); FK→`cases` | `doc_type`, `title`, `file_path`, `extracted_text`, `extraction_status`, `page_count` (`db.py:93-104`) |
|
||||
| `documents` | מסמך-מקור משויך לתיק | `id` (UUID); FK→`cases` | `doc_type`, `is_primary` (**נגזר** מ-`doc_type`, ראה [§2ג](#2ג-מסמך-עיקרי-primary-document)), `title`, `file_path`, `extracted_text`, `extraction_status`, `page_count` (`db.py:93-104`) |
|
||||
| `document_chunks` | chunk של מסמך-תיק + embedding | `id`; FK→`documents`/`cases` | `chunk_index`, `content`, `section_type`, `embedding vector(1024)`, `page_number` (`db.py:106-116`) |
|
||||
| `case_law` | קורפוס פסיקה — חיצוני **וגם** החלטות-ועדה | ראה [§2 + INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות) | `case_name`, `court`, `practice_area`, `source_kind`, `proceeding_type`, `source_type`, `headnote`, `summary`, `subject_tags`, `extraction_status`, `halacha_extraction_status` (`db.py:366-378,522-526,599-611,883,907`) |
|
||||
| `precedent_chunks` | chunk של פסק-דין מואנדקס (`source_kind='external_upload'`/`internal_committee`) | `id`; FK→`case_law` | `chunk_index`, `content`, `section_type`, `page_number`, `embedding vector(1024)`, `content_tsv` (`db.py:624-634,776`) |
|
||||
@@ -73,11 +73,45 @@ proceeding_type)`. לכן המזהה הקנוני הוא **(`case_number` מנו
|
||||
### 2ב. חוזה תמציתי לישויות נוספות
|
||||
|
||||
- `documents` → usable: `file_path`+`doc_type`; searchable: `extraction_status=completed` ו-`extracted_text` לא-ריק ו-≥1 `document_chunk` עם embedding.
|
||||
> **עיצוב-מחדש זרימת-העבודה (WS2, #200):** `documents` מקבל `is_primary`/`doc_category` (רשימה
|
||||
> קנונית: ערר/תשובה/תגובה/פרוטוקול/שומה/החלטת-ועדה) + דגל-ניתוח per-מסמך (`claims_extraction_status`),
|
||||
> כדי לחשב "יש מסמך-עיקרי שלא-נכלל-בניתוח". השדות בבעלות #200; ניתוח-החילוץ עצמו ב-[04-analysis-writing](04-analysis-writing.md) §1.
|
||||
- `halachot` → usable: `rule_statement`+`supporting_quote`; **searchable: `review_status ∈ {approved, published}` בלבד** — `pending_review`/`rejected` מוסתרות מ-`search_precedent_library` (שער-הלכה ידני, `db.py:644-660`, [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)).
|
||||
- `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק.
|
||||
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
|
||||
|
||||
### 2ג. ישויות-נגזרות (אחסון-ניתוחים)
|
||||
### 2ג. מסמך-עיקרי (primary document)
|
||||
|
||||
WS2 (#200) מבחין בין **מסמך-עיקרי** (מסמך-מהות שהיו"ר עוקבת אחריו לניתוח) לבין מסמך-משני.
|
||||
**הרשימה הקנונית של doc_types עיקריים** (מאושרת-יו"ר): `appeal` (כתב-ערר) · `response`
|
||||
(תשובה/תגובה) · `objection` (התנגדות) · `protocol` (פרוטוקול-דיון) · `appraisal` (שומה) ·
|
||||
`decision` (החלטת-ועדה). **כל doc_type אחר משני** (`plan`/`permit`/`court_decision`/
|
||||
`exhibit`/`reference`).
|
||||
|
||||
**`is_primary` נגזר, לא נכתב** ([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)/[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)/
|
||||
[INV-DM7](#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד)): מקור-האמת היחיד הוא
|
||||
`doc_type` + הרשימה הקנונית `PRIMARY_DOC_TYPES` (`db.py`). העמודה
|
||||
`documents.is_primary BOOLEAN GENERATED ALWAYS AS (doc_type = ANY(PRIMARY_DOC_TYPES)) STORED`
|
||||
(SCHEMA_V47) מחושבת ע"י Postgres — **אין מסלול-כתיבה מקביל ולא יכולה לסטות** מ-`doc_type`,
|
||||
בדיוק כמו ה-tsvectors ב-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). `_row_to_doc` גוזר גם בקריאה
|
||||
(לרשומות טרום-מיגרציה) ומוסיף `doc_category ∈ {primary, secondary}` לפלט. נחשף דרך
|
||||
`document_list` / `case_get` / API-המסמכים. אינדקס חלקי `idx_documents_primary(case_id) WHERE
|
||||
is_primary` משרת את שאילתת "מסמכים-עיקריים שטרם-נותחו" (#201).
|
||||
|
||||
**דגל "לא-נותח" — per-מסמך (#201, SCHEMA_V49).** שני שדות מתעדים את *אירוע* חילוץ-הטענות
|
||||
של המסמך (לא נגזרים מ-`doc_type`): `claims_extracted_at TIMESTAMPTZ` (נחתם ע"י
|
||||
`claims_extractor` **אחרי** ששמרנו את טענות המסמך) ו-`claims_extraction_status TEXT`
|
||||
(`pending`/`completed`/`no_claims`/`failed`). `_row_to_doc` חושף `claims_analyzed`
|
||||
(`claims_extracted_at IS NOT NULL`). מקור-האמת היחיד ל"האם המסמך נכלל בניתוח" הוא
|
||||
`claims_extracted_at` — לא משוחזר ממקום אחר. אינדקס חלקי `idx_documents_claims_pending(case_id)
|
||||
WHERE is_primary AND claims_extracted_at IS NULL` (יחד עם `is_primary` של V47) הופך את
|
||||
"מסמך-עיקרי שנוסף-אחרי-ניתוח ולא-נכלל" (`db.primary_docs_not_analyzed`) לשאילתה זולה.
|
||||
נחשף ב-`workflow_status` (`primary_docs_not_analyzed` + next-step). הניתוח-מחדש המאחד
|
||||
(`reanalyze_claims`, [04-analysis-writing §1.3](04-analysis-writing.md)) מחלץ-מחדש רק את
|
||||
המסמכים-העיקריים החדשים/לא-נותחו, **מאחד** עם הטענות הקיימות, ומריץ צבירה-מחדש (G2 — מסלול
|
||||
קנוני אחד, לא fork).
|
||||
|
||||
### 2ד. ישויות-נגזרות (אחסון-ניתוחים)
|
||||
|
||||
מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי
|
||||
ה-provenance של [X8](X8-field-provenance.md) ולשערי [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant):
|
||||
@@ -176,18 +210,22 @@ RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
|
||||
(`case_number, source_kind, proceeding_type`, `db.py:904-909`), אך השדה `case_number TEXT
|
||||
UNIQUE NOT NULL` המקורי (`db.py:368`) הוסר רק ב-V15 (`db.py:902-903`) — מורשת שאפשרה את
|
||||
הפרת [INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות). **יעד:** נרמול-בכתיבה אכוף + ציטוט-מלא רק ב-`citation_formatted`.
|
||||
- **`summary` קיים על `case_law` אך לא בחוזה-הקליטה הפנימי.** העמודה קיימת (`db.py:373`) אך
|
||||
המסלול הפנימי אינו ממלא אותה (כפועל-יוצא מהיעדר חילוץ-מטא-דאטה, [INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)).
|
||||
**יעד:** searchable מותנה ב-metadata לא-ריק ([INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
|
||||
- **שני שדות-סטטוס-חילוץ נפרדים, ללא דגל-`searchable` מפורש.** `extraction_status` +
|
||||
`halacha_extraction_status` (`db.py:603-605`) מתארים תהליך, אך אין שדה יחיד שמסמן "עבר
|
||||
חוזה-שלמות → searchable". **יעד:** דגל/view נגזר ש-search מסנן לפיו, מגובה health-check.
|
||||
- **`summary` קיים על `case_law` אך לא בחוזה-הקליטה הפנימי — ✅ נסגר (FU-1, GAP-02).** העמודה
|
||||
קיימת (`db.py:373`), והמסלול הפנימי לא מילא אותה בעבר (היעדר חילוץ-מטא-דאטה). **נסגר:** שני
|
||||
המסלולים זורמים דרך `ingest.ingest_document` הקנוני שמתזמן metadata לשני הסוגים
|
||||
([01-ingest INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)); `searchable`
|
||||
מותנה ב-metadata לא-ריק ([INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
|
||||
- **דגל-`searchable` מפורש — ✅ קיים.** מעבר ל-`extraction_status` + `halacha_extraction_status`
|
||||
(`db.py:603-605`, תהליך), קיימת כעת עמודת `case_law.searchable` המסמנת "עבר חוזה-שלמות"; שכבת-
|
||||
החיפוש מסננת לפיה (`cl.searchable = true`, `db.py:7517,7519`) ו-`recompute_searchable_flag`
|
||||
מתחזק אותה. אוכף את [INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים) בשכבת-האחזור.
|
||||
- **`embedding` אינו `GENERATED` (בניגוד ל-tsvector).** ה-tsvectors מסונכרנים אוטומטית
|
||||
(`db.py:776,780,1083`), אך ה-`embedding vector(1024)` תלוי-טריגר חיצוני — נקודת-drift אפשרית
|
||||
ל-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). **יעד:** טריגר re-embed מובטח + health-check ל-drift.
|
||||
- **`halachot.review_status` כשער-searchable ללא נראות-backlog.** הסינון תקין (`pending_review`
|
||||
מוסתר, `db.py:659`), אך אין נראות כמה ממתינות — תואם את ההפרה הידועה ב-[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||
(10/19 מאושרות, התגלה במקרה). **יעד:** health-check חושף backlog-הלכות.
|
||||
- **נראות backlog-הלכות — ✅ נסגר (FU-5, GAP-14).** הסינון תקין (`pending_review` מוסתר,
|
||||
`db.py:659`); ההפרה הישנה היתה היעדר-נראות (10/19 מאושרות, התגלה במקרה). **נסגר:** ה-health-
|
||||
check חושף `halacha_backlog` (`web/app.py:2352-2364`, `metrics_service.halacha_backlog`),
|
||||
והכלים `extraction_status` + `halachot_pending` חושפים את עומק-התור — שער-היו"ר ([G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)) הופך נראה.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -79,11 +79,12 @@ quality / conformance) | סטטוס: verified
|
||||
**אכיפה:** תנאי-`source_kind` בכל ענף-SQL בשכבת-החיפוש; בדיקת-בריאות שמריצה שאילתת-ביקורת
|
||||
(חיפוש מכוון-קורפוס שמחזיר פריט בעל `source_kind` זר = כשל). אוכף את
|
||||
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
|
||||
**הפרה ידועה:** משימה #56 — `halacha_filters` **אינם** כוללים `cl.source_kind` ב-
|
||||
`search_precedent_library_semantic` (`db.py:3168`, ענף ה-halacha; לעומת `chunk_filters` שכן —
|
||||
`db.py:3169`) **וב**-`search_precedent_library_lexical` (`db.py:3401` מול `db.py:3402`). שני
|
||||
ה-`halacha_sql` עושים `JOIN case_law cl` בלי לסנן `source_kind` (`db.py:3236-3238`, `db.py:3475-3477`)
|
||||
→ הלכות מהקורפוס הפנימי דולפות לתוצאות החיפוש בקורפוס החיצוני ולהפך → ממצא ל-[audit](../audit-report.md).
|
||||
**הפרה ידועה — ✅ נסגרה (FU-4, GAP-10, משימה #56):** בעבר `halacha_filters` לא כללו
|
||||
`cl.source_kind` (בניגוד ל-`chunk_filters` שכן) → הלכות מהקורפוס הפנימי דלפו לחיפוש בקורפוס
|
||||
החיצוני ולהפך. **נסגר:** שני ה-`halacha_filters` (semantic + lexical) מתחילים כעת ב-
|
||||
`cl.source_kind = '{source_kind}'` בדיוק כמו `chunk_filters` (`db.py:7516,7519` ומקבילו ב-
|
||||
`search_precedent_library_lexical`). מאומת ב-`test_precedent_corpus_isolation.py` (semantic+lexical,
|
||||
שני ה-source_kind) → תיעוד-מקור: [audit](../audit-report.md).
|
||||
|
||||
### INV-RET2: אין החזרה/אינדוקס בלי metadata מלא + locator פתיר
|
||||
**כלל:** פריט אינו מוחזר מ-search (ואינו נחשף לאחזור) אלא אם **שדות-החובה שלו מולאו**
|
||||
@@ -121,10 +122,13 @@ relevance judgments, precision/recall) · RAG evaluation literature (Lewis et al
|
||||
Elastic — *relevance evaluation guidance* | סטטוס: verified
|
||||
**אכיפה:** eval harness עם gold-set מתויג + בדיקת-בריאות תקופתית; שער-CI על שינוי שכבת-האחזור.
|
||||
אוכף את [G8](00-constitution.md#inv-g8-איכות-אחזור-נמדדת--precision--recall).
|
||||
**הפרה ידועה (GAP):** אין כיום eval harness ולא gold-set — קיים רק `telemetry.log_search_bg`
|
||||
(`search.py:62,118,190,271`; `precedent_library.py:280`) שמתעד שאילתות בפועל, אך **אינו מודד
|
||||
precision/recall מול תיוג** (תצפית, לא הערכה). היעד: harness שמריץ סט קבוע ומחזיר metrics →
|
||||
ממצא ל-[audit](../audit-report.md).
|
||||
**הפרה ידועה — ✅ נסגרה (FU-5, GAP-11):** בעבר היה רק `telemetry.log_search_bg` (תצפית, לא
|
||||
הערכה). **נסגר:** `scripts/eval_retrieval.py` מריץ את **מסלול-האחזור בייצור** (אותן פונקציות-
|
||||
שירות שכלי-ה-MCP קוראים) מול gold-set מתויג (`data/eval/gold-set.jsonl`, נבנה ב-
|
||||
`scripts/eval_gold_bootstrap.py`) ומחזיר precision/recall/MRR/nDCG@{5,10} — סך-הכול, פר-קורפוס
|
||||
ופר-`practice_area` — מול baseline מקובע (`data/eval/baseline.json`). מטריקות-הליבה נבדקות
|
||||
offline ב-`eval_retrieval.py --self-test` ("ALL PASS"). **משמעת-CI:** להריץ לפני/אחרי כל שינוי
|
||||
שכבת-אחזור (משקלי-RRF/`k`/סף-chunk/embedder/rerank) ולהשוות ל-baseline. תיעוד-מקור: [audit](../audit-report.md).
|
||||
|
||||
### INV-RET5: כל span מוחזר עקיב למקורו
|
||||
**כלל:** כל קטע מוחזר נושא **עקיבוּת-מקור מלאה** — מזהה-מסמך/פסק-דין (`case_law_id`/`document_id`/
|
||||
@@ -156,16 +160,15 @@ re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף
|
||||
|
||||
ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול הקוד.
|
||||
|
||||
- **דליפת-הלכות חוצת-קורפוס (משימה #56).** `halacha_filters` נפתחים רק עם `review_status`
|
||||
(`db.py:3168`, `db.py:3401`) ואינם מוסיפים `cl.source_kind`, בעוד `chunk_filters` כן
|
||||
(`db.py:3169`, `db.py:3402`). שני ה-`halacha_sql` עושים `JOIN case_law` בלי סינון
|
||||
(`db.py:3236-3242`, `db.py:3463-3482`). **תסמין:** חיפוש בקורפוס החיצוני
|
||||
(`search_precedent_library`, `source_kind="external_upload"`) יכול להחזיר הלכה שמקורה
|
||||
בהחלטת-ועדה פנימית — ולהפך עבור `search_internal_decisions` (`source_kind="internal_committee"`,
|
||||
`internal_decisions.py:418`). **יעד:** `halacha_filters` יתחילו ב-`cl.source_kind = '{source_kind}'`
|
||||
בדיוק כמו `chunk_filters` ([INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)).
|
||||
- **אין eval harness — מדידת-איכות לא קיימת.** רק `telemetry.log_search_bg` מתעד שאילתות
|
||||
(`search.py:62,118,190,271`); אין gold-set מתויג ואין precision/recall. **יעד:** harness עומד
|
||||
- **דליפת-הלכות חוצת-קורפוס (משימה #56) — ✅ נסגר (FU-4, GAP-10).** בעבר `halacha_filters` לא
|
||||
כללו `cl.source_kind` (בניגוד ל-`chunk_filters`) → הלכה מהקורפוס הפנימי יכלה לחזור בחיפוש
|
||||
בקורפוס החיצוני, ולהפך. **נסגר:** `halacha_filters` מתחילים כעת ב-`cl.source_kind = '{source_kind}'`
|
||||
+ `cl.searchable = true` בדיוק כמו `chunk_filters` (`db.py:7514-7519`), בשתי הפונקציות
|
||||
(semantic+lexical). מאומת ב-`test_precedent_corpus_isolation.py`
|
||||
([INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)).
|
||||
- **eval harness — ✅ נסגר (FU-5, GAP-11).** בעבר רק `telemetry.log_search_bg` (תצפית).
|
||||
**נסגר:** `scripts/eval_retrieval.py` (precision/recall/MRR/nDCG מול `data/eval/gold-set.jsonl`
|
||||
+ baseline; `--self-test` עובר offline) — מדידה אמפירית מול תיוג קבוע
|
||||
([INV-RET4](#inv-ret4-איכות-אחזור-נמדדת-ב-eval-harness-עומד-precision--recall)).
|
||||
- **`search_decisions` מתעד אזהרה כשאין `practice_area` אך לא חוסם.** ללא פילטר-תחום החיפוש
|
||||
עלול לערבב תחומים משפטיים (`search.py:45-49,172-176` — `logger.warning`, ממשיך). **יעד:** הפרדה
|
||||
|
||||
@@ -47,6 +47,122 @@
|
||||
> מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון
|
||||
> שלו מקבל מענה בבלוק י.
|
||||
|
||||
> **עיצוב-מחדש זרימת-העבודה (WS2–WS4) — מסלול-החילוץ אינו מתפצל ([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)):**
|
||||
> ניתוח-ערר-לבד כבר עובד (`extract_claims` רץ על `appeal` בנפרד). ההרחבות נשענות על אותו צינור-
|
||||
> חילוץ, **לא** על מסלול-ניתוח שני: (א) **ניתוח-מחדש מאחד** (WS2, #200/#201) — מחלץ מ-מסמכים
|
||||
> חדשים/מעודכנים בלבד ומאחד עם הקיים (לא מוחק-ומחשב-מחדש), על-בסיס דגל-הניתוח per-מסמך
|
||||
> ([02-data-model §2ב](02-data-model.md#2ב-חוזה-תמציתי-לישויות-נוספות)); (ב) **סיכום-מנהלים**
|
||||
> (WS3, #202, `summarize_party_claims` — ראה §1.4) — נגזר מ-`claims`/`legal_arguments`, **מסמך-פרוזה נפרד**
|
||||
> מטיוטת-הביניים (G2: תצוגה-נגזרת, לא מקור-אמת שני); (ג) **ניתוח-פרוטוקול** (WS4, #203,
|
||||
> `analyze_protocol` — ראה §1.3) — `protocol` נכנס לחילוץ ההשוואתי (ירד/חוזק/חדש) ומזין ידע-תיק, ונשאר
|
||||
> פוסט-דיון ([INV-WR4](#inv-wr4-בלוק-ז--טענות-מקוריות-בלבד): טענות-פוסט-דיון → בלוק ח, לא ז).
|
||||
> חוזי-הכלים המלאים בבעלות המשימות הנ"ל (§1.3/§1.4); כאן רק עיגון-הספ שהם מקיימים G2.
|
||||
|
||||
### 1.3 ניתוח-פרוטוקול — ניתוח פרוטוקול-דיון השוואתי (WS4 / #203)
|
||||
|
||||
> **בעלוּת תת-סעיף:** §1.3 (זה) שייך ל-WS4 (ניתוח-פרוטוקול). תת-סעיף סיכום-טענות-הצדדים
|
||||
> (PR-אחות #358) הוא **תת-סעיף נפרד** (§1.4) תחת §1 — אין חפיפת-מספור בין השניים.
|
||||
|
||||
אחרי הדיון, פרוטוקול (`doc_type='protocol'`) מנותח **מול הטיעונים המאוגדים** —
|
||||
`analyze_protocol(case_number)` (→ `services/protocol_analyzer.py`). הניתוח מסווג כל
|
||||
טיעון כתוב כ**ירד** (`dropped` — נזנח/ויתר בדיון), **חוזק** (`strengthened`), או מזהה
|
||||
טענה ש**עלתה לראשונה בדיון** (`newly_raised`), ולכל רשומה מנסח את ה**שאלה המשפטית
|
||||
המחודדת** לטובת בלוק י. התוצאה נשמרת ב**ידע-התיק** — טבלת `protocol_analysis` (case-
|
||||
knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arguments`, הרשומה היא השוואה
|
||||
מטוריאליזת בת-שחזור) — וזמינה ל-`get_protocol_analysis(case_number, change_type="")`
|
||||
(סימטריית extract/get, [INV-TOOL4](X9-mcp-tool-contract.md)).
|
||||
|
||||
- **קלט:** הטיעונים המאוגדים (§1.2) הם קו-הבסיס; ללא כינוס אין מול-מה להשוות.
|
||||
- **שער anti-hallucination ([INV-AH](../anti-hallucination-gate.md)):** כל רשומה חייבת
|
||||
`evidence_quote` — ציטוט **מילולי** מהפרוטוקול. רשומה ללא ציטוט-מבסס נדחית **במקור**
|
||||
(`_normalize_change`) ולא מגיעה ל-DB (quote-or-retract).
|
||||
- **טענות-הדיון בנפרד מבלוק ז:** טענות שעלו בדיון מתויגות `claim_type='protocol'`
|
||||
ב-`extract_claims` ו**מוחרגות** מהקשר בלוק ז (`block_writer._build_claims_context`) —
|
||||
בלוק ז נשאר **טענות-כתב מקוריות בלבד** ([INV-WR4](#inv-wr4-בלוק-ז--טענות-מקוריות-בלבד));
|
||||
טענות-הדיון שייכות לבלוק ח (הליכים) ולידע-התיק ההשוואתי.
|
||||
- **נתוני כותרת (א–ד):** הניתוח מחלץ גם את הפיד המוכר (הרכב, תאריך-דיון, צדדים שהופיעו)
|
||||
ומזין את `hearing_date` חזרה לעמודה הקנונית `cases.hearing_date` (G1; לא נכתב אם
|
||||
היו"ר כבר מילא תאריך — לא דורסים קלט-יו"ר).
|
||||
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
|
||||
`model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`).
|
||||
**חלון-הקשר (#216):** הבנייה הסטנדרטית רצה 200K-טוקן (~400K תווים עברית). פרומפט גדול
|
||||
(בעיקר בלוק-י, שנושא את התיק המלא כ-source-context) מוסלם אוטומטית לבניית-ה-1M של *אותו*
|
||||
מודל — `claude-opus-4-8[1m]` (1M-טוקן, מחיר רגיל; `claude -p` חושף אותה ישירות כ-model-id,
|
||||
בלי beta-header). **עידון של אותו pin, לא מסלול-מודל מקביל (G2).** הסף ב-`block_writer`:
|
||||
פרומפט > ~350K תווים → `[1m]`; אחרת הבנייה הסטנדרטית. תקרת-קשיחה: 1.5M תווים.
|
||||
|
||||
### 1.4 סיכום-מנהלים של טענות הצדדים (מסמך-הכנה לדיון, WS3/#202)
|
||||
|
||||
`summarize_party_claims(case_number, instructions="")` מפיק **מסמך-פרוזה מזוקק** של
|
||||
טענות הצדדים — תמצית-מנהלים קצרה ומוקפדת שמטרתה **להכין את היו"ר לדיון בעל-פה**. זהו
|
||||
מסמך **נפרד ומובחן מטיוטת-ההחלטה ומטיוטת-הביניים** (החלטת-יו"ר, [תוכנית workflow-redesign](../../.claude/plans/groovy-doodling-token.md)
|
||||
§WS3) — אינו חלק מ-12-הבלוקים ואינו נכתב לתבנית ההחלטה.
|
||||
|
||||
- **מקור-אמת יחיד (G2):** המסמך נגזר מ-`legal_arguments` (טיעונים מאוגדים) או, כ-fallback,
|
||||
מ-`claims` הגולמיים — **אותו מקור** של §§1.1–1.2, **ללא חילוץ-מחדש** ובלי לקרוא לכתבי-הטענות
|
||||
ישירות. אין מסלול-נתונים מקביל.
|
||||
- **זיקוק, לא שכפול:** התמצית מתמצתת כל צד למשפטי-מפתח ומוסיפה פרק "נקודות-המחלוקת המרכזיות"
|
||||
כשאלות פתוחות — לא משכפלת את כתבי-הטענות ולא מכריעה.
|
||||
- **עיגון-מקור (INV-AH):** הפרומפט מתוחם לחלוטין לטענות-התיק שבקלט; אסור להמציא טענה/הלכה/
|
||||
פסק-דין/עובדה שאינם בקלט — טענה לא-ברורה מצוינת במפורש ([anti-hallucination-gate](../anti-hallucination-gate.md)).
|
||||
- **ייצור local-only:** עובר `claude_session` → `claude -p` נעוץ ל-Opus 4.8 + `effort=high`
|
||||
(משימת זיקוק/סינתזה). הקונטיינר חסר ה-CLI — לכן הייצור הוא כלי-MCP מקומי בלבד; endpoints
|
||||
ב-`web/app.py` רק **מגישים/מייצאים** את הקובץ השמור, לא מייצרים.
|
||||
- **שמירה + ייצוא:** נשמר ל-`data/cases/{n}/documents/research/party-claims-summary.md`
|
||||
(git + S3, באותו מסלול-אחסון של `analysis-and-research.md`); ניתן-לייצוא ל-DOCX בסגנון-תבנית
|
||||
דפנה (`build_party_claims_summary_docx`). מימוש: `tools/drafting.py` ·
|
||||
`services/party_claims_summary.py` · `services/analysis_docx_exporter.py`.
|
||||
- **טריגר — side-quest מבוקש-יו"ר (G10), נפרד מטיוטת-הביניים:** ה-CEO מפיק את הסיכום כשלב-צד
|
||||
(`legal-ceo.md` שלב H2) ב-**שתי דרכים**: (א) הערת-יו"ר חופשית ("סיכום מנהלים" / "סיכום טענות" /
|
||||
"סיכום לקראת דיון" / "executive summary"); או (ב) **פעולה סטרוקטורלית** —
|
||||
`$PAPERCLIP_WAKE_PAYLOAD_JSON` עם `action == "party_claims_summary"` (המסלול הדטרמיניסטי שכפתור-UI
|
||||
עתידי יפעיל, בלי פענוח-טקסט). זהו side-quest: אינו משנה `cases.status` ואינו יוצר issues לסוכני-משנה,
|
||||
ומובחן מטיוטת-הביניים (`write_interim_draft`, שלב H, `action == "interim_draft"`).
|
||||
|
||||
### 1.5 ניתוח-מחדש מאחד אחרי מסמך-עיקרי חדש (#201)
|
||||
|
||||
תיק מנותח לרוב מכתב-הערר לבד, ומסמך-עיקרי (תשובה/התנגדות/פרוטוקול…) מגיע מאוחר יותר.
|
||||
כל מסמך נושא **דגל "לא-נותח"** (`claims_extracted_at`, [02-data-model §2ג](02-data-model.md)),
|
||||
ו-`workflow_status` מסמן "מסמך-עיקרי שטרם-נכלל בניתוח". `reanalyze_claims(case_number,
|
||||
reanalyze_all_primary=False)` סוגר את הפער **בלי force-delete גורף**:
|
||||
|
||||
1. **snapshot לפני** — הטיעונים המאוגדים הנוכחיים (בסיס בדיקת-ההשפעה).
|
||||
2. **חילוץ-מאחד** — מחלץ רק את המסמכים-העיקריים החדשים/לא-נותחו (`db.primary_docs_not_analyzed`)
|
||||
דרך **אותו** `claims_extractor.extract_and_store_claims`; `store_claims` מחליף רק את טענות
|
||||
*אותו* מסמך (לפי `source_document`), כך שטענות ממסמכים שכבר-נותחו **נשמרות** (האיחוד).
|
||||
3. **צבירה-מחדש** — `aggregate_claims_to_arguments(force=True)` מחשב את הטיעונים מחדש מתוך
|
||||
מערך-הטענות המאוחד השלם. זהו **מסלול-החישוב הקנוני** ([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)),
|
||||
לא מסלול-עיבוד מקביל; הוא מוחק רק `legal_arguments` (נגזר), לעולם לא `claims`.
|
||||
4. **בדיקת-השפעה ליו"ר** — snapshot אחרי + `_impact_diff` מחזיר per-צד אילו טיעונים
|
||||
נוספו/הוסרו ואיך השתנה תמהיל-העדיפויות ("מאזן-ההמלצה"), עם דגל `changed`. הפלט **מוצג**
|
||||
ליו"ר ולא מוחל אוטומטית ([G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)).
|
||||
|
||||
ראה `tools/legal_arguments.py` (`reanalyze_claims`, `_snapshot`, `_impact_diff`).
|
||||
|
||||
### 1.6 שער שטן-מליץ (red-team) — בין הניתוח לכתיבה, תחת אישור-יו"ר
|
||||
|
||||
אחרי שלב-הניתוח (`analysis-and-research.md` תקין) וב**לפני** הכותב, ה-CEO מפעיל **אוטומטית**
|
||||
שכבת **דעה-שנייה אדוורסרית** מ-lineage שונה (Gemini, `legal-analyst-gemini-critique`,
|
||||
read-only) — **שער-קבע (standing gate)**, לא on-demand. השכבה תוקפת את ניתוח-Opus ומפיקה
|
||||
`critique-gemini.md` = **מזכר-לידים לא-סמכותי**, מתויג-ודאות (`[מאומת-קורפוס]`/`[טעון-אימות]`/
|
||||
`[ספקולציה]`), כפוף לשער ה-anti-hallucination ([INV-AH](../anti-hallucination-gate.md);
|
||||
כלי-RAG משפטיים הוזים פסיקה 17–33%, Stanford RegLab/Magesh JELS 2025).
|
||||
|
||||
- **human-in-the-loop קשיח ([G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)):**
|
||||
ה-CEO מציג את הלידים ליו"ר כ**עצירת-אישור** (ה-issue הראשי ל-`in_review` + מייל), ואינו מתקדם
|
||||
לכותב בלי הכרעת-יו"ר מפורשת. **רק לידים שהיו"ר אישר** מומרים ל-`chair_directions` דרך
|
||||
מנגנון-ההנחיות הקיים (`record_chair_feedback` → "עמדת ועדת הערר" ב-`analysis-and-research.md`
|
||||
→ `get_chair_directions` → `approve_direction`); לידים שנדחו נמחקים.
|
||||
- **הכותב צורך מקור-מעוגן בלבד ([INV-WR1–WR5](#4-invariants-של-התחום--תוכן-החלטה-מנומקת) +
|
||||
[INV-LRN5](07-learning.md)):** הקלט לכתיבה הוא **פלט-המנתח המעוגן + ההנחיות-המאושרות** —
|
||||
**לעולם לא** הלידים הגולמיים של שטן-מליץ. השכבה אינה מסלול-נתונים מקביל לכתיבה
|
||||
([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)) ואינה כותבת
|
||||
שום שכבת-קול/ידע (INV-LRN5) — read-only ל-`critique-gemini.md` בלבד.
|
||||
- **מקור-אמת לזרימה:** [X4 INV-AG4](X4-agents.md#inv-ag4-שער-שטן-מליץ--red-team-לידים-לא-סמכותיים-תחת-אישור-יור)
|
||||
+ [legal-ceo.md](../../.claude/agents/legal-ceo.md) "שלב A2"/"שלב A3" +
|
||||
[legal-analyst-gemini-critique.md](../../.claude/agents/legal-analyst-gemini-critique.md).
|
||||
(שינוי-מדיניות יו"ר 2026-06-30: from on-demand to standing gate — TaskMaster `legal-ai` #211.)
|
||||
|
||||
---
|
||||
|
||||
## 2. ארכיטקטורת 12 הבלוקים (סיכום)
|
||||
|
||||
@@ -43,6 +43,16 @@
|
||||
> וגם **טיוטת-ביניים** (`mode="interim"` — תת-קבוצת בלוקים בסדר חדש: רקע→תכניות→טענות→הליכים,
|
||||
> `export_interim_draft`, `drafting.py:511`). שני המצבים שולפים מאותה טבלה — וריאציית-תצוגה
|
||||
> של אותו מקור-אמת, לא מסלול שני.
|
||||
>
|
||||
> **דטרמיניזם-מבני של טיוטת-הביניים (#204 / WS5).** סדר-בלוקי-הביניים בייצוא קבוע
|
||||
> ב-`docx_exporter._INTERIM_BLOCK_ORDER` (רקע→תכניות→טענות→הליכים, עם בלוקי-תבנית א–ה
|
||||
> אופציונליים בתחילה). הבלוקים עצמם נכתבים דרך **אותו מסלול-כתיבה קנוני** של ההחלטה
|
||||
> המלאה (`block_writer.write_and_store_block` → `write_block`, G2 — אין מסלול-ייצור
|
||||
> מקביל), אך כעת **נעוצים למודל יחיד (`claude-opus-4-8`) עם `effort` per-בלוק ובפרומפט-
|
||||
> מבני קבוע** (למשל בלוק-ה פותח תמיד ב"לפנינו ערר…"). התוצאה: אותו תיק מפיק את אותו
|
||||
> **מבנה** בכל ריצה (אי-עקביות-הניסוח הקודמת נבעה מפרומפט-חופשי + מודל-ברירת-מחדל לא-נעוץ).
|
||||
> טמפרטורה אינה במסלול (Opus 4.7/4.8 דוחים אותה); ראה `block_writer.GENERATION_MODEL`
|
||||
> + `BLOCK_CONFIG[*].effort`.
|
||||
> - `analysis_docx_exporter.py` (`build_analysis_docx`, `:401`) — מייצא את מסמך **הניתוח
|
||||
> המשפטי** (`analysis-and-research.md`) שכתב `legal-analyst`, לא את בלוקי-ההחלטה. זהו תוצר-עזר
|
||||
> שונה (שלב ניתוח, לא החלטה) — והוא המסלול שהסקיל מתעד בעיקר. שניהם חולקים את **אותו טמפלט
|
||||
@@ -101,10 +111,14 @@ Model / Single Source of Truth) · SSOT (Single Source of Truth principle) | ס
|
||||
פלט מגורסן + idempotent מבחינת-תוכן; אוכף את
|
||||
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) וכלל-ההנדסה
|
||||
"סימטריה" (חוקה §6).
|
||||
**הפרה ידועה:** אחרי `revise_draft`/`apply_user_edit`, ה-DOCX המסומן `active_draft_path` הופך
|
||||
ל"מקור-האמת" לעריכות-Track-Changes העוקבות (`db.py:185-188`), ו**בלוקי-ה-DB אינם מתעדכנים
|
||||
חזרה** — הנתון-הנגזר זוחל למקור-אמת בפועל בלי סנכרון לאחור. **יעד:** או re-sync מהבלוקים, או
|
||||
חוזה מפורש ש-`active_draft_path` הוא רק אנקור-revision ולא מקור-תוכן → ראה [§5](#5-current-vs-target).
|
||||
**הפרה ידועה — ✅ מגודרת (FU-7, GAP-17):** אחרי `revise_draft`/`apply_user_edit` ה-DOCX
|
||||
המסומן `active_draft_path` משמש כאנקור לעריכות-Track-Changes העוקבות, ובלוקי-ה-DB אינם
|
||||
מתעדכנים חזרה — סטייה אפשרית בין הבלוקים למסמך-החי. **התיקון שנבחר (חוזה מפורש, לא re-sync):**
|
||||
`active_draft_path` הוא **אנקור-revision בלבד, לא מקור-תוכן מתחרה** — ה-DB נשאר מקור-האמת. סטייה
|
||||
נלכדת בדגל `cases.blocks_stale` (V22, `db.py:1142-1148`): `revise_draft`/`apply_user_edit`
|
||||
מסמנים `mark_blocks_stale(case_id, True)` (`drafting.py:688,789`) ו-`export_docx` מנקה
|
||||
(`mark_blocks_stale(case_id, False)`, `drafting.py:504`) — הדגל הוא ה-drift-detection שחושף מתי
|
||||
ה-DOCX-החי נסחף מהבלוקים. ראה [§5](#5-current-vs-target).
|
||||
|
||||
### INV-EX2: עקיבוּת-מקור נשמרת בהחלטה המיוצאת (→G9)
|
||||
**כלל:** ההחלטה המיוצאת **שומרת על עקיבוּת-מקור** היכן שנדרש — סמכויות-משפטיות מצוטטות
|
||||
@@ -127,29 +141,53 @@ Lewis et al. (2020, NeurIPS — RAG attribution) | סטטוס: verified
|
||||
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (controlled, auditable
|
||||
output) · Council of Europe / CEPEJ (2018, under user control) · Federal Judicial Center —
|
||||
*Judicial Writing Manual* (2d ed.) | סטטוס: verified
|
||||
**אכיפה:** `export_blocked = critical_failures > 0` (`qa_validator.py:362`); **נאכף ברמת-
|
||||
הזרימה/agent בלבד** — הסוכן `legal-exporter` מחויב להריץ `validate_decision` ולבדוק
|
||||
כשלים-קריטיים לפני ייצוא (`.claude/agents/legal-exporter.md:71,149`).
|
||||
**הפרה ידועה:** `export_docx` (`drafting.py:384`) **אינו** קורא ל-`validate_decision` בעצמו —
|
||||
הוא ניגש ישירות ל-`docx_exporter.export_decision` בלי לבדוק `export_blocked`. החסימה תלויה
|
||||
במשמעת-הסוכן ואינה hard-block בקוד-הייצוא → ראה [§5](#5-current-vs-target) (תואם
|
||||
[05-qa-review §4](05-qa-review.md#4-current-vs-target--ממצאי-audit)).
|
||||
**אכיפה — ✅ hard-block בקוד (FU-6, GAP-15):** `export_docx` (`drafting.py:462`) **בודק לעצמו**
|
||||
לפני כל ייצוא — `db.qa_run_exists` (אם QA לא רץ כלל → חסום) ו-`db.get_critical_qa_failures`
|
||||
(אם יש כשל-קריטי → חסום) — **לפני** הגישה ל-`docx_exporter.export_decision`. אלו `SELECT` זולים
|
||||
על ה-`qa_results` המאוחסנים (לא הרצת-LLM חוזרת). נוסף על `export_blocked = critical_failures > 0`
|
||||
ברמת-הזרימה ועל משמעת-הסוכן `legal-exporter` (`.claude/agents/legal-exporter.md:71,149`) — כך
|
||||
שאי-אפשר לעקוף את השער אפילו בקריאה ישירה ל-`export_docx`.
|
||||
**הפרה ידועה — ✅ נסגרה (FU-6, GAP-15):** בעבר `export_docx` ניגש ישירות ל-exporter בלי לבדוק
|
||||
`export_blocked` (אכוף-זרימה בלבד, ניתן-לעקיפה). נסגר ע"י ה-hard-block לעיל; מאומת ב-
|
||||
`test_export_qa_gate.py` (חסום ללא-QA · חסום בכשל-קריטי · עובר כשנקי).
|
||||
|
||||
### INV-EX4: מקור-תבנית-יחיד — כל מפיק-מסמך מחיל סגנונות-טמפלט בלבד (→G2)
|
||||
**כלל:** **כל** מפיק של מסמך-Word הקשור-להחלטה — סופי (`export_docx`), טיוטת-ביניים
|
||||
(`export_interim_draft`), סיכום-מנהלים (`party-claims-summary`), ומסמך-הניתוח
|
||||
(`build_analysis_docx`), וכן מסלול-העריכה (`revise_draft`/`apply_user_edit` →
|
||||
`docx_reviser`) — **חייב לטעון את הטמפלט היחיד** `skills/docx/decision_template.docx`
|
||||
ולהחיל עיצוב **אך-ורק דרך שמות-הסגנונות שמוגדרים בו** (`paragraph.style = "…"` /
|
||||
`<w:pStyle w:val="…"/>`). **אסור hand-setting** של משפחת-גופן (`rFonts`), גודל-גופן
|
||||
(`sz`/`szCs`), או שוליים — אלה נגזרים מ-`styles.xml` של הטמפלט (cascade). היחיד שמותר
|
||||
לכפות מפורשות הוא **יישור-הגוף הקנוני `jc=both`** היכן שהוא דורס סגנון שמגיע ממורכז
|
||||
(`_set_paragraph_jc`, `docx_exporter.py:80`) — כי זו ההתנהגות-הקנונית, לא עיצוב-אד-הוק.
|
||||
סמני-כיווניות (`<w:bidi>`, run `<w:rtl/>`) מותרים (כיווניות, לא עיצוב). **טמפלט אחד = מקור-סגנון
|
||||
יחיד; אין שכבת-סגנון מקבילה.**
|
||||
**מקורות:** SSOT (Single Source of Truth) · Martin Fowler — Canonical Data Model · ECMA-376
|
||||
(OOXML) §17.7.2 — הורשת-סגנונות-פסקה (paragraph style inheritance / cascade) | סטטוס: verified
|
||||
**אכיפה:** `docx_exporter` ו-`analysis_docx_exporter` טוענים `TEMPLATE_PATH` ומחילים style-by-name
|
||||
(ראה §2); `docx_reviser` מחיל `pStyle` מן-הטמפלט + `jc=both` (לא פונט/גודל ידני). שומר-CI
|
||||
`test_docx_template_single_source.py` גורף את מודולי-מפיקי-ה-DOCX ונכשל על `rFonts`/`sz`/`szCs`
|
||||
ידני מחוץ לטוען-הטמפלט. אוכף את
|
||||
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) בשכבת-הרינדור.
|
||||
**הפרה ידועה — ✅ נסגרה:** `docx_reviser._build_paragraph` כפה `jc=right` + David/`sz` ידני על כל
|
||||
פסקה שהוכנסה במסלול-העריכה (`revise_draft`/`apply_user_edit`) → פלט מסלול-העריכה איבד את היישור
|
||||
הדו-צדדי של הטמפלט והכניס עיצוב-אד-הוק. נסגר ע"י החלת `pStyle` מן-הטמפלט; מאומת ב-
|
||||
`test_docx_reviser.py` + `test_docx_template_single_source.py`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Current vs Target
|
||||
|
||||
- **שער-ייצוא אכוף-זרימה ולא אכוף-קוד (INV-EX3 / INV-QA3).** אומת בקוד: `export_docx`
|
||||
(`drafting.py:384-419`) קורא ישירות ל-`docx_exporter.export_decision` (`:403`) ללא קריאה
|
||||
ל-`qa_validator.validate_decision` ובלי בדיקת `export_blocked`. החסימה מתקיימת רק כי הסוכן
|
||||
`legal-exporter` מחויב להריץ QA קודם (`legal-exporter.md:71,149`) — אדם/סוכן שיקרא
|
||||
ל-`export_docx` ישירות **יעקוף** את השער. **יעד:** hard-block בתוך `export_docx` — שליפת
|
||||
`qa_results`/`export_blocked` ודחייה לפני כתיבת ה-DOCX, כך שאי-אפשר לעקוף.
|
||||
- **`active_draft_path` כ-derived-שזוחל-למקור (INV-EX1).** ה-DOCX נגזר מהבלוקים בייצוא הראשון,
|
||||
אך אחרי עריכה (`revise_draft`/`apply_user_edit`) ה-DOCX הופך ל"מקור-האמת" לעריכות הבאות
|
||||
(`db.py:185-188`) בלי לעדכן את `decision_blocks` חזרה — סטייה אפשרית בין הבלוקים למסמך-החי.
|
||||
**יעד:** חוזה מפורש — או re-sync מהבלוקים, או הגדרת `active_draft_path` כאנקור-revision בלבד
|
||||
(לא מקור-תוכן), עם בדיקת-בריאות לגילוי drift בין הבלוקים ל-DOCX הפעיל.
|
||||
- **שער-ייצוא — ✅ hard-block בקוד (INV-EX3 / INV-QA3, FU-6 / GAP-15).** `export_docx`
|
||||
(`drafting.py:462-494`) בודק `db.qa_run_exists` + `db.get_critical_qa_failures` ודוחה לפני
|
||||
הגישה ל-`docx_exporter.export_decision` — לא ניתן לעקוף בקריאה ישירה. מאומת ב-
|
||||
`test_export_qa_gate.py` (3 מקרים: ללא-QA / כשל-קריטי / נקי).
|
||||
- **`active_draft_path` כ-derived (INV-EX1, FU-7 / GAP-17) — ✅ מגודר בחוזה מפורש.** ה-DB נשאר
|
||||
מקור-האמת; `active_draft_path` הוא אנקור-revision בלבד. ה-drift בין הבלוקים ל-DOCX-החי נלכד
|
||||
בדגל `cases.blocks_stale`: נדלק ב-`revise_draft`/`apply_user_edit` (`drafting.py:688,789`),
|
||||
כובה ב-`export_docx` (`drafting.py:504`). **שארית (low-pri):** health-check שמתריע על
|
||||
`blocks_stale=true` עתיק — תיעוד-המשך, לא חוסם.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,12 +35,12 @@
|
||||
היעד הוא **Text Style Transfer** מבוסס **פרופיל-סגנון מופשט** — להכליל את סגנון/שיטת דפנה ולהתאים לתיק הספציפי. fine-tuning של משקולות **לא רלוונטי**: המודל (Opus) סגור, והקורפוס (~48 החלטות, יו"ר חדשה) קטן מדי — מצב שבו הספרות מראה שפרופיל-מופשט + דוגמאות מנצח (≈+15% מעל RAG-בלבד). **מדיניות-העתקה לפי סוג-תוכן:** קבוע/נוסחאי (פתיחים דוקטרינליים, תבניות-סיום) → מותר להעתיק; ניתוח/טענות ספציפיים → להכליל ולהתאים; מהות (הלכה/עובדה מתיק אחר) → אסור (INV-LRN5).
|
||||
|
||||
### 0.2 שלושת ערוצי-ההזנה לכותב
|
||||
1. **A — פרופיל-מופשט (ראשי):** voice-fingerprint + author-features כמותיים, מוזרק לכתיבה.
|
||||
1. **A — פרופיל-מופשט (ראשי):** voice-fingerprint + author-features כמותיים, מוזרק לכתיבה. **חידוש מהקורפוס:** החלק הכמותי (יחסי-מבנה) מתרענן אוטומטית-ודטרמיניסטית מהקורפוס הגדל (`measure_corpus_ratios`→כותב); הפרוזה המופשטת מסונתזת מחדש ע"י `scripts/regenerate_voice_fingerprint.py` (Opus) כ**הצעה מגודרת-יו"ר** (INV-LRN1 קשיח — לא auto, היו"ר מקבעת ידנית; STYLE בלבד INV-LRN5).
|
||||
2. **B — דוגמאות + תבניות (תומך):** פסקאות-בלוק אמיתיות + Copy-Paste Templates + contrastive.
|
||||
3. **C — deep-read (נקודתי):** voice-XXXX.md — worked example לתיק-מופת.
|
||||
|
||||
### 0.3 הצינור החוזר per-final (7 שלבים)
|
||||
`mark-final` → [1] INTAKE (snapshot של הטיוטה) → [2] PAIRING (בלוק↔בלוק) → [3] ALIGNMENT (diff פר-בלוק) → [4] DISTILLATION (מפריד סגנון↔מהות) → [5] CURATION (Hermes + שער-יו"ר) → [6] FEEDBACK (ניתוב לערוץ A/B/C) → [7] MEASUREMENT (מדד-מרחק-סגנון).
|
||||
`mark-final` → [1] INTAKE (snapshot של הטיוטה) → [2] PAIRING (בלוק↔בלוק) → [3] ALIGNMENT (diff פר-בלוק) → [4] DISTILLATION (מפריד סגנון↔מהות) → [5] CURATION (Hermes + שער-יו"ר) → [6] FEEDBACK (ניתוב לערוץ A/B/C) → [7] MEASUREMENT (מדד-מרחק-סגנון + snapshot-held-out פרוספקטיבי, §0.7).
|
||||
|
||||
### 0.4 ניהול ב-UI
|
||||
`/methodology` = **עורך-הפרופיל היחיד** (declarative: יחסי-זהב, כללי-דיון, צ׳קליסטים, ביטויי-מעבר, אנטי-דפוסים, voice-invariants). `/training` = **שולחן-הלמידה** (קורפוס, פורטרט-סגנון, השוואת draft↔final, curator, מדד-מרחק, פנקס-התאמה).
|
||||
@@ -48,7 +48,7 @@
|
||||
**שער-אישור אחד · טרנזקציית-כותב אחת (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`.
|
||||
**INV-LRN4 (ניגוד-אמת → G10/G9):** למידת-קול מבוססת **pairing draft↔final ברמת-בלוק**, לא קריאת-final בלבד. כל החלטה אינה "סגורה" עד שהושוותה מול הסופי; כל סופי מנותח מול הטיוטה. נשמר פנקס-התאמה (`draft_final_pairs`) עם מצב-חיים `draft_done → final_received → analyzed → lessons_folded`. ההכללה (האם הלמידה משפרת תיקים שלא-נלמדו) נמדדת פרוספקטיבית — §0.7.
|
||||
*מקורות:* imitation-learning-from-expert-edits · contrastive personalization (arxiv 2504.08745) · author-profiling. *סטטוס: verified.*
|
||||
|
||||
**INV-LRN5 (טוהר-הקול → G4/G11):** שכבת-ידע-הקול (voice-fingerprint, style_patterns, exemplars) **לא תכיל הלכות/עובדות ספציפיות** — רק סגנון ושיטה. מהות מנותבת ל-precedent_library/halacha. ה-distillation מפריד במקור.
|
||||
@@ -62,9 +62,20 @@
|
||||
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`); פיצול → ליו"ר.
|
||||
- **למידה:** הצינור (`final_learning_pipeline.py`) רץ בסדר `enroll_style_corpus` (יצירת רשומת-הקורפוס, מהיר — **ראשון** מאז #159 כדי שהקורפוס יהיה זמין לפני הדיסטילציה הארוכה) → `ingest_final_version` (Opus distillation) → **פאנל-סגנון דו-סוכני** (DeepSeek+Gemini, "למידה כפולה") שמצביע על כל לקח-style_method; הסכמה 2/2 → `decision_lesson` (`source=panel:deepseek+gemini`) **שזורם אוטומטית לכותב** כ-`approved` (שער-מדורג, INV-LRN1) — הפיך (veto-יו"ר ב-/training); פיצול → ליו"ר. **במקביל** (יקיצת `final_learning_*`) האוצֵר ממשיך ל-§A ורושם ממצאי-`source='curator'` (ערוץ ג׳, §1.1).
|
||||
- **הלכות:** `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) — הפאנל יוצר *הצעות* בלבד.
|
||||
שני הפאנלים **הפיכים** (גיבוי-CSV ל-`data/audit/`) ומסלימים מחלוקות. ההטמעה ל-`SKILL.md`/`legal-decision-lessons.md` ולכל **מהות** נשארת **אישור-יו"ר ידני קשיח** (INV-LRN1/G10); לקחי-**סגנון** בקונצנזוס זורמים אוטומטית-והפיך לכותב.
|
||||
|
||||
### 0.7 מדד-ההכללה הפרוספקטיבי (held-out trend — "מסלול A")
|
||||
**השאלה שזה עונה:** האם הלמידה באמת *מכלילה* — כלומר משפרת טיוטות של תיקים **שלא נלמדו** — ולא רק "משננת" תיקים שכבר ראינו. מדד-מרחק-הסגנון (שלב [7]) על תיק שלקחיו כבר הוטמעו אינו held-out; מדידה רטרואקטיבית בלתי-אפשרית כי הלקחים נשמרים `appeal_type_rules` (`universal`) **ללא תיוג-מקור** → אין leave-one-out. לכן המדידה **פרוספקטיבית**: נלכדת ברגע היחיד שבו היא נקייה.
|
||||
|
||||
**המנגנון (אוטומטי, ב-`final/upload`, ללא LLM):** מיד אחרי פתיחת הזוג `draft_final_pair` אבל **לפני** הטמעת-לקחי-התיק (ה-fold הוא שלב-`promote` ידני נפרד ב-`/training`, §0.4), נלכד snapshot של `style_distance` (`anti_pattern_total`, סטיית-יחסי-זהב, `change_percent`) יחד עם **גודל-בריכת-הלקחים** באותו רגע (מספר `discussion_rules`+`transition_phrases` ב-`universal`). מכיוון שהטיוטה נכתבה עם הבריכה ה**קודמת** בלבד, כל שורה היא נקודת-נתון held-out נקייה: *"עם N לקחים מצטברים, הטיוטה שלנו על תיק שלא-נראה קיבלה ציון X"*.
|
||||
|
||||
**הקריאה:** טבלת `style_distance_history` (append-only) + `GET /api/learning/style-distance-history`. ירידה ב-`anti_pattern_total`/`change_percent` ככל ש-N גדל = **הוכחה מתגלגלת שהלמידה מכלילה** (INV-LRN4 — זהו משטח-המגמה של "ניגוד-האמת"). **אזהרת-פרשנות:** `change_percent` מערבב סגנון עם שלמות-תוכן (לפעמים היו"ר מכפילה אורך כי חסרה מהות, לפעמים חותכת) → `anti_pattern_total` הוא הסיגנל הנקי-יותר לסגנון. שימוש-חוזר בשירות `style_distance` ובבריכת `appeal_type_rules` — אין מסלול-מדד מקביל (G2).
|
||||
|
||||
> **המלצת-הכיול style-ranked (#213):** `recommend_effort` ב-`scripts/calibrate_effort.py` מדרג efforts לפי `anti_pattern_total` **ראשי** (סיגנל-סגנון נקי) → סטיית-יחסי-זהב → `distance` (tiebreak קלוש). `change_percent` **מדווח-לא-מדורג** מאותה אזהרת-פרשנות לעיל (מערבב סגנון עם שלמות-תוכן). ההמלצה נושאת דגל-`confidence` ("clear"/"weak") כך שבחירה שההובלה-ב-anti שלה בתוך-הרעש (epsilon=max(0.5, 0.20·spread)) לא מוצגת כוודאית. אין מסלול-מדד מקביל — שימוש-חוזר ב-`style_distance` (G2).
|
||||
|
||||
> **חלון נקי חד-פעמי:** תיק שכבר `lessons_folded` פספס את חלון ה-held-out שלו — אין backfill. הטבלה מתמלאת קדימה מהסופי הבא.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,10 +98,24 @@
|
||||
(`hermes-curator.md:60-70`).
|
||||
- מזהה **3–5 דפוסים/פערים** חדשים, כל ממצא מתויג `[סגנון]` / `[מבנה]` /
|
||||
`[לקסיקון משפטי]` / `[טבלאי]` (`hermes-curator.md:99-108`).
|
||||
- **מציע** — comment ב-Paperclip + רישום כל ממצא כ-`decision_lesson` דרך
|
||||
`POST /api/training/corpus/{corpus_id}/lessons` (`source:"curator"`) שמופיע ב-UI
|
||||
תחת הטאב "מה למדנו" (`hermes-curator.md:73-96`).
|
||||
- **אינו מעדכן** קבצים בעצמו (skills/, lessons.py, DB) — רק מציע (`hermes-curator.md:125-130`).
|
||||
- **מציע** — comment + interaction בערוץ-הפלטפורמה, וגם **רושם כל ממצא מבנית** כ-`decision_lesson`
|
||||
(`source='curator'`, `review_status='proposed'`) דרך כלי-ה-MCP `record_curator_findings`
|
||||
(`hermes-curator.md` §A.5b → `tools/workflow.py::record_curator_findings` → `db.add_decision_lesson`).
|
||||
הממצאים מופיעים ב-`/training` טאב "אוצֵר" (ערוץ ג׳) ועוברים את שער-היו"ר (INV-LRN1).
|
||||
> **דריפט-מימוש שנסגר (2026-06-28):** עד גל זה הסוכן כתב comment בלבד והספ תיאר endpoint שלא נקרא —
|
||||
> הממצאים האיכותיים אבדו, וכרטיס-האוצֵר (`get_curator_stats`) ספר `source='curator'` שאיש לא כתב → 0.
|
||||
> `record_curator_findings` מממש את INV-LRN3 בפועל.
|
||||
- **אינו מאשר ואינו מטמיע** — read-only על התוכן; רישום-הממצא הוא *הצעה* מגודרת-שער, לא שינוי-קול.
|
||||
אינו עורך `skills/` / `lessons.py` / שכבת-הקול בעצמו (`hermes-curator.md` §"כללים כלליים").
|
||||
- **שני ערוצי-למידה נפרדים על אותו סופי:** ערוץ-האוצֵר (כאן, qualitative, `source='curator'`) ו**פאנל-הסגנון**
|
||||
הדו-סוכני שהצינור מפעיל (§0.6 — DeepSeek+Gemini, `source='panel:deepseek+gemini'`). שניהם
|
||||
`decision_lessons` ממתיני-שער; ערוץ הדיסטילציה (`appeal_type_rules`, §0.4) הוא השלישי.
|
||||
|
||||
> **נסגר (TaskMaster #159, 2026-06-28):** ה-`PIPELINE-WAKE BRANCH` (`hermes-curator.md`) מבדיל כעת
|
||||
> `final_learning_*` מ-`final_halacha_*`: על **learning** הסוכן מפעיל את הצינור ברקע ו**ממשיך ל-§A**
|
||||
> (מצב AUTO — רושם ממצאי-`curator`, מדלג על interaction), על **halacha** יוצא מיד כקודם. המירוץ מול
|
||||
> `enroll_style_corpus` נסגר בכך ש-enroll רץ **ראשון** בצינור (§0.6) — הקורפוס קיים תוך שניות, הרבה לפני
|
||||
> שהסוכן/ה-§A מסיים את ניתוח-ה-LLM; `record_curator_findings` כולל גם retry כרשת-ביטחון.
|
||||
|
||||
### 1.2 לולאת-פידבק-היו"ר (capture → ניתוח שבועי → לקחים)
|
||||
|
||||
@@ -162,13 +187,12 @@
|
||||
|
||||
## 3. Invariants של התחום
|
||||
|
||||
### INV-LRN1: עדכון-ידע דורש אישור-יו"ר ידני — אין auto-commit (governance →G10)
|
||||
**כלל:** מנגנוני-הלמידה (Hermes, ניתוח-פידבק שבועי) **מציעים בלבד**. כל שינוי ב-
|
||||
[SKILL.md](../../skills/decision/SKILL.md) או ב-[legal-decision-lessons.md](../legal-decision-lessons.md)
|
||||
מחייב **בחינה ואישור ידניים של היו"ר/חיים** ואז commit ידני — **לעולם לא auto-committed**.
|
||||
Hermes כותב comment + `decision_lesson`, לא קבצים; ה-CEO השבועי כותב לקובץ אך הצעותיו
|
||||
מאומתות ידנית לפני קיבוע. זהו פֶּאֶט של [INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||
על שכבת-הידע: גם הלמידה כפופה לשיקול-הדעת האנושי.
|
||||
### INV-LRN1: עדכון-ידע דורש שער-יו"ר — **שער מדורג** לפי סיכון (governance →G10)
|
||||
**כלל (מעודכן 2026-06-28, הכרעת-יו"ר):** השער **מדורג לפי סיכון-התוכן**, לא אחיד:
|
||||
- **מהות** (הלכה / תקדים / עובדה / כל שינוי ב-[SKILL.md](../../skills/decision/SKILL.md) או ב-[legal-decision-lessons.md](../legal-decision-lessons.md)) → **שער-קשיח**: בחינה ואישור ידניים של היו"ר/חיים ואז commit ידני — **לעולם לא auto-committed**. פאנל-ההלכות התלת-סוכני מציע בלבד.
|
||||
- **סגנון** (`decision_lessons` בקטגוריות style/structure/lexicon/tabular, וכן discussion_rules / transition_phrases / anti_patterns) שעבר **קונצנזוס-פאנל 2/2** → **זורם אוטומטית לכותב** (`review_status='approved'`), **הפיך**: היו"ר רואה ויכול **לבטל בדיעבד** ב-/training. זהו שינוי-קול נמוך-סיכון (טהור-מהות לפי INV-LRN5), בקרת-איכות מובנית (2 מודלים), והפיכוּת — ולכן עדיין "תחת בקרת-המשתמש" במובן NCSC/CEPEJ (veto, לא אישור-מראש על כל פריט).
|
||||
|
||||
זהו פֶּאֶט של [INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) על שכבת-הידע: הלמידה כפופה לשיקול-הדעת האנושי — קשיח למהות, הפיך-veto לסגנון. **מימוש:** `scripts/style_lesson_panel.py:_review_status_for` קובע `approved` לקטגוריות-סגנון בקונצנזוס, `proposed` אחרת; הכותב צורך רק `approved` ([db.get_recent_decision_lessons]).
|
||||
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (human-in-the-loop;
|
||||
never replace human judgment) · Council of Europe / CEPEJ (2018, under user control) ·
|
||||
Federal Judicial Center — *Judicial Writing Manual* (2d ed.) | סטטוס: verified
|
||||
@@ -249,6 +273,26 @@ LegalBench (gemini-2.5-flash) · Trust-or-Escalate (ICLR 2025) | סטטוס: ver
|
||||
החלטת-יו"ר 2026-06-19; מקור-אמת: [`../legal-principles-redesign.md`](../legal-principles-redesign.md).
|
||||
**הפרה ידועה:** — (חדש)
|
||||
|
||||
### INV-LRN8: סינתזת-לקחים מעוגנת + מגודרת-שער-מדורג (#158 → G2/G10/INV-AH)
|
||||
**כלל:** לקחי-סגנון (`decision_lessons`) חופפים מאוחדים ל**לקח-על אחד** עשיר ומוכלל, כך
|
||||
שהסט שזורם לכותב **קטֵן ומשתבח** (פותר את החיתוך-השקט limit=15, #157) — Authorial Style
|
||||
Profiling (§0.1: פרופיל-מופשט מנצח ערימת-דוגמאות). המנגנון מחקה את סינתזת-הקנוני (INV-LRN6)
|
||||
**על אותה טבלה** — אין מאגר-מקביל (G2): (א) אשכול greedy לפי cosine (`LESSON_SYNTH_CLUSTER_THRESHOLD`)
|
||||
בתוך shard של `practice_area`+`category`; (ב) מיזוג ע"י `claude_session` **מעוגן-מקור** — נובע
|
||||
מלקחי-המקור בלבד, סגנון-בלבד (לא מהות, INV-LRN5), abstain אם לא-מתמזג (INV-AH); (ג) **שער-drift** —
|
||||
הלקח-הממוזג מוטמע-מחדש ומושווה (cosine) ל-centroid האשכול; מתחת ל-`LESSON_SYNTH_DRIFT_FLOOR`
|
||||
נדחה. הלקח-על נכתב `source='synthesis'` עם `synthesized_from`, והמקורות → `review_status='superseded'`
|
||||
(provenance, לא נצרכים-כותב). **שער מדורג-הפיך (הכרעת-יו"ר 2026-06-28):** מאחר שכל המקורות `approved`,
|
||||
הלקח-על זורם `approved` עם veto-יו"ר ב-/training — דחייתו משחזרת את המקורות ל-`approved`
|
||||
(`db.revert_lesson_synthesis`). idempotency: lookup-cosine מול synthesis קיים לפני INSERT.
|
||||
**מסלול-יחיד (G2):** הכלי `lesson_synthesize_pending` והסקריפט `backfill_lesson_synthesis.py` עוברים
|
||||
שניהם דרך `services/lesson_synthesis.py`. audit CSV ב-`data/audit/lesson-synthesis-*.csv`.
|
||||
**מקורות:** Authorial Style Profiling · grounding-vs-hallucination (Stanford RegLab) · CoVe (arXiv:2309.11495) | סטטוס: verified
|
||||
**אכיפה:** `services/lesson_synthesis.py` (אשכול/מיזוג/drift), `db.{fetch_synthesis_candidates,
|
||||
apply_lesson_synthesis,revert_lesson_synthesis,nearest_synthesis_lesson,synthesis_shards}`, SCHEMA_V46
|
||||
(`embedding`+`synthesized_from`). config `LESSON_SYNTH_*`.
|
||||
**הפרה ידועה:** — (חדש)
|
||||
|
||||
---
|
||||
|
||||
## 4. הג'ובים המתוזמנים (תמיכת-תשתית ללולאה)
|
||||
|
||||
@@ -59,6 +59,16 @@ Paperclip בקונפליקט (project-specific מנצח default), אך אינו
|
||||
ל-`SKILL.md`/`lessons.md` — מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
|
||||
- **company_id פר-סוכן.** כל שורה בטבלה מיוצגת פעמיים (CMP + CMPA); ה-CEO לכל חברה שונה
|
||||
([X2 §1](X2-multi-company.md)). הסוכן פועל רק בטווח-החברה שלו ([X2 §2](X2-multi-company.md)).
|
||||
- **שטן-מליץ (Gemini red-team) — שער-קבע אחרי הניתוח, לא חלק מ-7-הדומייניים.** סוכן
|
||||
`legal-analyst-gemini-critique` (`gemini_local`, CMP+CMPA) רץ **אחרי** שלב-הניתוח של ה-Case
|
||||
Analyst (Opus) ו**לפני** הכותב, כ**שער-קבע (standing gate)** שה-CEO מפעיל **אוטומטית** — לא
|
||||
on-demand. הוא read-only ומפיק `critique-gemini.md` = **מזכר-לידים לא-סמכותי**. ה-CEO **עוצר
|
||||
את הזרימה לאישור-יו"ר של הלידים** (ה-issue הראשי ל-`in_review`); רק לידים שהיו"ר מאשר מומרים
|
||||
ל-`chair_directions` (דרך מנגנון-ההנחיות הקיים — `record_chair_feedback` → `get_chair_directions`
|
||||
→ `approve_direction`), והכותב צורך **רק** את פלט-המנתח המעוגן + ההנחיות-המאושרות — **לעולם
|
||||
לא** את הלידים הגולמיים. מקיים [INV-AG4](#inv-ag4-שער-שטן-מליץ--red-team-לידים-לא-סמכותיים-תחת-אישור-יור) +
|
||||
[04-analysis-writing §1.6](04-analysis-writing.md). מקור-אמת לזרימה: [legal-ceo.md](../../.claude/agents/legal-ceo.md)
|
||||
"שלב A2"/"שלב A3" + [legal-analyst-gemini-critique.md](../../.claude/agents/legal-analyst-gemini-critique.md).
|
||||
|
||||
### 2א. מפת-הרשאות (tool grants) — frontmatter מול הוראות
|
||||
|
||||
@@ -140,6 +150,25 @@ another company`, [X2 §2](X2-multi-company.md)).
|
||||
**אכיפה:** בדיקת-עקביות tools↔instructions (FU-13 ✅ 2026-06-06). אכיפה אוטומטית עתידית — בתת-פרויקט 5 (spec-guardian).
|
||||
**הפרה ידועה:** — (טופל ב-FU-13: legal-analyst קיבל `aggregate_claims_to_arguments`; researcher כבר היה תקין; `extract_references`/`extract_internal_citations` הם מטלת-researcher, לא analyst — ראה §2א).
|
||||
|
||||
### INV-AG4: שער שטן-מליץ — red-team לידים לא-סמכותיים תחת אישור-יו"ר
|
||||
**כלל:** אחרי שלב-הניתוח (`analysis-and-research.md` תקין) וב**לפני** הפעלת הכותב, ה-CEO מפעיל
|
||||
**אוטומטית** את סוכן שטן-מליץ (Gemini red-team, read-only) כ**שער-קבע** — לא on-demand. הפלט
|
||||
(`critique-gemini.md`) הוא **מזכר-לידים לא-סמכותי**; ה-CEO **עוצר את הזרימה לעצירת-אישור קשיחה
|
||||
של היו"ר** (issue ראשי ל-`in_review` + מייל) ואינו מתקדם לכותב בלי הכרעת-יו"ר מפורשת. **רק לידים
|
||||
שהיו"ר אישר** מומרים ל-`chair_directions` דרך מנגנון-ההנחיות הקיים (`record_chair_feedback`
|
||||
→ `get_chair_directions` → `approve_direction`); לידים שנדחו נמחקים. הכותב צורך **אך-ורק** את
|
||||
פלט-המנתח המעוגן + ההנחיות-המאושרות — **לעולם לא** את הלידים הגולמיים. מופע של
|
||||
[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) (שער אנושי
|
||||
לא-עקיף) ושל [INV-AH](../anti-hallucination-gate.md) / [INV-LRN5](07-learning.md) (לידים
|
||||
לא-סמכותיים, אינם מוזנים אוטומטית לקול/למהות).
|
||||
**מקור-סמכות:** [legal-ceo.md](../../.claude/agents/legal-ceo.md) ("שלב A2"/"שלב A3" + "מפת
|
||||
סטטוסים") + [legal-analyst-gemini-critique.md](../../.claude/agents/legal-analyst-gemini-critique.md)
|
||||
+ [HEARTBEAT.md §7](../../.claude/agents/HEARTBEAT.md). (invariant פרויקטלי-תפעולי — ללא
|
||||
פרוטוקול ≥3-המקורות; משרת את G10 + INV-AH/INV-LRN5.)
|
||||
**אכיפה:** פרוצדורלית (נוהל ה-CEO — "אל תמשיך לכותב בלי `critique-gemini.md` + אישור-יו"ר ללידים")
|
||||
+ עצירת-`in_review` של ה-issue הראשי; אין שער-קוד אוטומטי (כמו יתר ה-INV-AG*).
|
||||
**הפרה ידועה:** — (שינוי-מדיניות יו"ר 2026-06-30: שטן-מליץ עבר מ-on-demand ל-שער-קבע; TaskMaster `legal-ai` #211).
|
||||
|
||||
---
|
||||
|
||||
## 5. חיווט הספ לסוכנים — בוצע (FU-8b)
|
||||
|
||||
@@ -105,13 +105,36 @@ TanStack Query — *Important Defaults* (staleTime/refetch) (https://tanstack.co
|
||||
**אכיפה:** `halacha_backlog` מרונדר ב-/operations (לא נזרק); `findings_approved` (review_status, צרכן אמיתי)
|
||||
החליף את `findings_applied` (דגל מת); מוני-סוכנים מסמנים "חלקי" כשחברה לא-נטענה (גל-1 #130).
|
||||
|
||||
### INV-UI9: זמן-מאוחסן ב-UTC · תצוגה אנושית דטרמיניסטית ב-Asia/Jerusalem
|
||||
**כלל:** **האחסון נשאר UTC** מקצה-לקצה — Postgres `TIMESTAMPTZ`, וה-API מחזיר ISO-UTC; אין נרמול-tz
|
||||
בשכבת-האחסון/ה-API. **שכבת-התצוגה בלבד** ממירה, ובאופן **דטרמיניסטי**: כל חותמת-זמן אנושית מרונדרת
|
||||
ב-`timeZone: "Asia/Jerusalem"` (locale `he-IL`), בלי תלות ב-tz של ה-host/קונטיינר/דפדפן. כיוון
|
||||
שהקונטיינר רץ ב-UTC, פירמוט ad-hoc (`new Date(iso).toLocaleDateString("he-IL")`, `.getHours()`,
|
||||
`toISOString().slice(0,10)`) מציג UTC תחת SSR (ושוב UTC בכל runtime לא-ישראלי) → drift. נעיצת
|
||||
`Asia/Jerusalem` מבטיחה ש-SSR והדפדפן מסכימים. **פורמט-התצוגה (צורת התאריך/שעה) נשמר זהה — רק ה-tz
|
||||
מנורמל.** מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||
(מפרמט-תאריך **יחיד**, אין פירמוט-זמן מקביל-מפוזר) בשכבת-התצוגה.
|
||||
**מקורות:** ECMAScript Internationalization API — `Intl.DateTimeFormat` `timeZone` option
|
||||
(https://tc39.es/ecma402/#datetimeformat-objects) · MDN — *Intl.DateTimeFormat* (IANA time zones)
|
||||
(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) ·
|
||||
Next.js — *Rendering* (SSR משתמש בשעון-השרת אם לא ננעץ)
|
||||
(https://nextjs.org/docs/app/building-your-application/rendering) | סטטוס: verified
|
||||
**אכיפה:** מפרמט-תאריך יחיד ב-[web-ui/src/lib/format-date.ts](../../web-ui/src/lib/format-date.ts)
|
||||
(`formatDate`/`formatDateShort`/`formatDateLong`/`formatDateTime`/`formatDateTimeFull`/`formatTime`/
|
||||
`formatIsoDate`/`formatRelative` + עוזרי-Israel `getIsraelYear`/`israelDayKey`/`israelMidnightMs`/
|
||||
`israelParts`), כולם נעוצים ל-`Asia/Jerusalem`; כל משטחי-ה-UI מייבאים ממנו ולא מפרמטים tz ad-hoc.
|
||||
פירמוט-**מספרים** (`n.toLocaleString`) מחוץ-לתחום (אינו חותמת-זמן). **חריג מוצהר:** קלט
|
||||
`<input type="datetime-local">` (בקרת-burst ב-`/operations`) הוא ערך הניתן-לכתיבה ע"י המפעיל, לא תצוגת-
|
||||
מאוחסן — נשאר בנייה מקומית. **אודיט-נלווה (לא מיושם):** הגדרת `TZ=Asia/Jerusalem` בקונטיינר בטוחה רק
|
||||
אם אף לוגיקה עסקית אינה תלויה בשעון-ה-process כ-UTC.
|
||||
|
||||
---
|
||||
|
||||
## 3. כללי-עיצוב (Design Rules) — נגזרים מה-invariants
|
||||
- **SSoT ל-enums/תוויות/tones:** כל enum (CaseStatus, PracticeArea, AppealSubtype, DocType, outcome) +
|
||||
תוויותיו + צבעיו מוגדרים **פעם אחת** ונצרכים מיבוא — לא משוכפלים בין דפים/רכיבים (מופע UI1/G2).
|
||||
- **helpers משותפים:** פירמוט-תאריך, builder ל-FormData (העלאות), רכיב-שגיאה, query-config (intervals) —
|
||||
משותפים, לא מועתקים.
|
||||
- **helpers משותפים:** פירמוט-תאריך (מפרמט-יחיד נעוץ-Asia/Jerusalem ב-[format-date.ts](../../web-ui/src/lib/format-date.ts),
|
||||
INV-UI9), builder ל-FormData (העלאות), רכיב-שגיאה, query-config (intervals) — משותפים, לא מועתקים.
|
||||
- **חוזי-טופס:** ראה INV-UI6 ([X8](X8-field-provenance.md)).
|
||||
|
||||
הממצאים הקונקרטיים (כפילויות, הגדרות-שגויות, redundancy) ב-[ui-audit.md](ui-audit.md); התיקון — **FU-10**.
|
||||
|
||||
@@ -35,14 +35,14 @@
|
||||
| `case_number` (citation) | CHAIR (חובה) | מפתח idempotency |
|
||||
| `full_text`, `extraction_status`, `source_kind` | DETERMINISTIC | — |
|
||||
| `case_name`, `court`, `date`, `headnote`, `summary`, `key_quote`, `subject_tags`, `appeal_subtype`, `precedent_level`, `source_type`, `citation_formatted` | CHAIR או OPUS | Opus ממלא רק אם ריק |
|
||||
| `is_binding` | CHAIR (default true) | קובע prompt-הלכה |
|
||||
| `is_binding` | CHAIR (default true) **אך** DETERMINISTIC-false ל-`source_type=appeals_committee` | קובע prompt-הלכה. סמכות מבנית ([INV-DM7](02-data-model.md#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד)): מקור-ועדה הוא תמיד persuasive — נכפה ל-false ב-`create_external_case_law` ונאכף ע"י `case_law_committee_not_binding_check` |
|
||||
| chunks (`content`/`section_type`/`page_number`) | DETERMINISTIC | — |
|
||||
| `embedding` (chunks) | Voyage (לא-LLM-reasoning) | ⚠ לא-GENERATED ([gap-audit GAP-09](gap-audit.md)) |
|
||||
| כל `halachot` | OPUS | נכנס pending_review |
|
||||
|
||||
### 2ב. החלטה פנימית (`case_law`, source_kind=`internal_committee`)
|
||||
כמו 2א, ובנוסף: `case_number` **חובה**; `chair_name`/`district`/`proceeding_type` — CHAIR או OPUS או DERIVED;
|
||||
`source_type` = `appeals_committee` (DETERMINISTIC קבוע). placeholder `"(טרם חולץ)"` מסומן ל-chair_name/district
|
||||
`source_type` = `appeals_committee` (DETERMINISTIC קבוע); `is_binding` = `false` (DETERMINISTIC קבוע — נכפה ב-`create_internal_committee_decision`, [INV-DM7](02-data-model.md#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד): החלטת-ועדה אינה מחייבת ועדה אחרת ולא את עצמה — תמיד persuasive). placeholder `"(טרם חולץ)"` מסומן ל-chair_name/district
|
||||
ריקים ומטופל כריק ע"י ה-extractor.
|
||||
|
||||
### 2ג. מסמך-תיק (`documents`)
|
||||
|
||||
@@ -232,3 +232,25 @@ FU-7 אחרי FU-1; FU-5 אחרי FU-2; FU-8 אחרי ייצוב-הספ. **(מח
|
||||
אחרי FU-1. FU-11 (מילוי-שדות) עצמאי. FU-13 (סוכנים+skills) אחרי ייצוב-הספ.
|
||||
**סיווג:** pure-code — FU-9/10/11/13/14; +data-migration קל — FU-12; +chair-decision — FU-15 (rotation סודות).
|
||||
priority בפועל — של היו"ר.
|
||||
|
||||
---
|
||||
|
||||
## ✅ ווידוא-סגירה — 7 דליפות עיצוב-מחדש זרימת-העבודה (WS7, #207, 2026-06-30)
|
||||
|
||||
תת-פרויקט עיצוב-מחדש זרימת-העבודה (תוכנית `groovy-doodling-token`, WS7) דרש סגירת **7 דליפות
|
||||
ידועות** תוך-כדי. ביקורת חוצת-קוד (#207) אימתה שכל ה-7 כבר **נסגרו במחזורים קודמים** (בעיקר
|
||||
FU-1/4/5/6/7 + גלי-ה-IA), ושהספ נשא תיאורי-הפרה מיושנים. ה-PR מיישר את הספ למצב-הקוד בפועל
|
||||
ומאמת כל סגירה בבדיקה. **אין כאן GAP/FU חדש — זו ביקורת-יישור, לא פתרון-מחדש (G2).**
|
||||
|
||||
| # | דליפה | invariant | סטטוס | FU | סגירה (קוד) | בדיקה |
|
||||
|---|-------|-----------|-------|-----|-------------|-------|
|
||||
| 1 | הלכות חוצות-קורפוס (#56) | INV-RET1/G5 (GAP-10) | ✅ סגור | FU-4 | `cl.source_kind` ב-`halacha_filters` בשתי הפונקציות (`db.py:7516,7519`) | `test_precedent_corpus_isolation.py` |
|
||||
| 2 | backlog-הלכות בלתי-נראה | INV-QA1/G10 (GAP-14) | ✅ סגור | FU-5 | health `halacha_backlog` (`app.py:2352-2364`) + כלי `extraction_status`/`halachot_pending` | — (health-endpoint) |
|
||||
| 3 | אין eval-harness | INV-RET4/G8 (GAP-11) | ✅ סגור | FU-5 | `scripts/eval_retrieval.py` (P/R/MRR/nDCG מול gold-set+baseline) | `eval_retrieval.py --self-test` ("ALL PASS") |
|
||||
| 4 | שער-ייצוא לא hard-block | INV-EX3/QA3/G10 (GAP-15) | ✅ סגור | FU-6 | `export_docx` בודק `qa_run_exists`+`get_critical_qa_failures` לפני exporter (`drafting.py:462-494`) | `test_export_qa_gate.py` |
|
||||
| 5 | מטא במסלול-פנימי | INV-ING3/G2 (GAP-02) | ✅ סגור | FU-1 | מסלול קנוני `ingest.ingest_document` מתזמן metadata+halacha יחד (`ingest.py:233-234`) | `test_unified_ingest.py` |
|
||||
| 6 | DOCX זוחל למקור-אמת | INV-EX1/G2 (GAP-17) | ✅ מגודר | FU-7 | חוזה: `active_draft_path`=אנקור בלבד; drift בדגל `cases.blocks_stale` (V22) | (חוזה+דגל; שארית health-check low-pri) |
|
||||
| 7 | cache-invalidation ב-UI | INV-UI5/IA (GAP-33) | ✅ סגור | FU-10/IA | `qc.invalidateQueries` על מוטציות (`web-ui/src/lib/api/*.ts`) + גלי-IA #130-132 | (TanStack Query) |
|
||||
|
||||
**שאריות (low-pri, מתועדות לא-חוסמות):** דליפה #6 — health-check שמתריע על `blocks_stale=true`
|
||||
עתיק (הדגל קיים ונאכף; חסר רק התרעת-נראות). אינה חוסמת את WS7; ניתן ל-FU-7-המשך.
|
||||
|
||||
348
docs/superpowers/plans/2026-05-16-hooks-execution.md
Normal file
348
docs/superpowers/plans/2026-05-16-hooks-execution.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# Hooks: Case Status Webhooks Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** כאשר status של תיק משתנה ב-legal-ai (e.g. `qa_failed`, `exported`), ה-plugin מקבל webhook, מעדכן את ה-issue ב-Paperclip, ומעיר את ה-CEO במקרה הצורך.
|
||||
|
||||
**Architecture:** Legal-ai REST API קורא `pc_request("POST", "/api/plugins/marcusgroup.legal-ai/webhooks/case-status", ...)` אחרי כל שינוי status. ה-plugin מטפל ב-`onWebhook()` ומגיב: מוסיף תגובה לissue, מעיר CEO אם QA נכשל.
|
||||
|
||||
**Tech Stack:** TypeScript (plugin-legal-ai), Python/FastAPI (legal-ai web), `@paperclipai/plugin-sdk@2026.325.0`, `httpx` (Python), `pc_request` helper.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | File |
|
||||
|--------|------|
|
||||
| Modify | `plugin-legal-ai/src/worker.ts` — add `onWebhook()` to `definePlugin({})` |
|
||||
| Modify | `plugin-legal-ai/plugin.json` — add `"webhooks.receive"` capability |
|
||||
| Modify | `plugin-legal-ai/src/manifest.ts` — add webhook capability |
|
||||
| Modify | `legal-ai/web/app.py` — emit webhook after `PUT /api/cases/{case_number}` |
|
||||
| Modify | `legal-ai/web/paperclip_api.py` — add `emit_webhook()` helper |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `emit_webhook` helper ב-Python
|
||||
|
||||
**Files:**
|
||||
- Modify: `legal-ai/web/paperclip_api.py`
|
||||
|
||||
- [ ] **Step 1: קרא את הקובץ הקיים**
|
||||
|
||||
```bash
|
||||
head -90 /home/chaim/legal-ai/web/paperclip_api.py
|
||||
```
|
||||
|
||||
- [ ] **Step 2: הוסף את ה-helper בסוף הקובץ**
|
||||
|
||||
פתח `/home/chaim/legal-ai/web/paperclip_api.py` והוסף אחרי הפונקציה `pc_request`:
|
||||
|
||||
```python
|
||||
async def emit_case_status_webhook(
|
||||
case_number: str,
|
||||
old_status: str,
|
||||
new_status: str,
|
||||
company_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> None:
|
||||
"""Notify the Paperclip plugin that a case status changed.
|
||||
|
||||
Fire-and-forget: logs errors but never raises, so callers aren't blocked.
|
||||
"""
|
||||
try:
|
||||
await pc_request(
|
||||
"POST",
|
||||
"/api/plugins/marcusgroup.legal-ai/webhooks/case-status",
|
||||
json={
|
||||
"caseNumber": case_number,
|
||||
"oldStatus": old_status,
|
||||
"newStatus": new_status,
|
||||
"companyId": company_id,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
},
|
||||
run_id=run_id,
|
||||
timeout=5.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("emit_case_status_webhook failed: %s", exc)
|
||||
```
|
||||
|
||||
> **הערה:** `datetime` ו-`logger` כבר מיובאים ב-`app.py`. בדוק שהם מיובאים גם ב-`paperclip_api.py` — אם לא, הוסף `from datetime import datetime` ו-`import logging; logger = logging.getLogger(__name__)` בראש הקובץ.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/legal-ai
|
||||
git add web/paperclip_api.py
|
||||
git commit -m "feat: add emit_case_status_webhook helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: צרף webhook לendpoint `PUT /api/cases/{case_number}`
|
||||
|
||||
**Files:**
|
||||
- Modify: `legal-ai/web/app.py`
|
||||
|
||||
- [ ] **Step 1: מצא את ה-endpoint**
|
||||
|
||||
```bash
|
||||
grep -n "PUT\|case_number\|update_case" /home/chaim/legal-ai/web/app.py | head -30
|
||||
```
|
||||
|
||||
- [ ] **Step 2: קרא את הenable endpoint המלא**
|
||||
|
||||
זהה את הסקציה המלאה של ה-endpoint ואת הייבוא הקיים.
|
||||
|
||||
- [ ] **Step 3: הוסף import ל-emit_webhook**
|
||||
|
||||
בראש `app.py`, בסקציית ה-imports מ-`paperclip_api`:
|
||||
```python
|
||||
from .paperclip_api import pc_request, emit_case_status_webhook
|
||||
```
|
||||
|
||||
- [ ] **Step 4: הוסף webhook emit בתוך הendpoint**
|
||||
|
||||
אחרי שהקוד מעדכן את ה-case (לפני ה-`return`), הוסף:
|
||||
|
||||
```python
|
||||
# Notify plugin about status change (fire-and-forget)
|
||||
if updates.get("status") and old_status != updates["status"]:
|
||||
background_tasks.add_task(
|
||||
emit_case_status_webhook,
|
||||
case_number=case_number,
|
||||
old_status=old_status,
|
||||
new_status=updates["status"],
|
||||
company_id=str(case.get("company_id")),
|
||||
)
|
||||
```
|
||||
|
||||
> אם ה-endpoint כבר מקבל `background_tasks: BackgroundTasks` — השתמש בו. אם לא, הוסף `background_tasks: BackgroundTasks` לחתימת הפונקציה. הוסף `from fastapi import BackgroundTasks` ל-imports.
|
||||
|
||||
- [ ] **Step 5: שמור את ה-`old_status` לפני ה-update**
|
||||
|
||||
בתחילת ה-endpoint handler, לפני קריאת ה-DB update:
|
||||
```python
|
||||
old_status = (await db.get_case(case_number) or {}).get("status", "")
|
||||
```
|
||||
|
||||
- [ ] **Step 6: בדיקה בסיסית — שלח PUT ידני**
|
||||
|
||||
```bash
|
||||
curl -s -X PUT https://legal-ai.nautilus.marcusgroup.org/api/cases/1130-25 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "in_progress"}' | jq .status
|
||||
```
|
||||
|
||||
בדוק ב-Paperclip logs שהwebhook נשלח (עדיין לא מטופל בצד ה-plugin):
|
||||
```bash
|
||||
pm2 logs paperclip --lines 20
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/legal-ai
|
||||
git add web/app.py
|
||||
git commit -m "feat: emit case-status webhook on PUT /api/cases/:case"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: הוסף `onWebhook()` ל-plugin
|
||||
|
||||
**Files:**
|
||||
- Modify: `plugin-legal-ai/src/worker.ts`
|
||||
|
||||
- [ ] **Step 1: קרא את `definePlugin({})` הקיים**
|
||||
|
||||
```bash
|
||||
grep -n "definePlugin\|onWebhook\|onHealth\|onShutdown" /home/chaim/plugin-legal-ai/src/worker.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: הוסף את `onWebhook` handler**
|
||||
|
||||
בתוך הobject שמועבר ל-`definePlugin({})`, אחרי `setup(ctx)`:
|
||||
|
||||
```typescript
|
||||
onWebhook: async (input) => {
|
||||
const { endpointKey, payload, companyId } = input as {
|
||||
endpointKey: string;
|
||||
payload: {
|
||||
caseNumber: string;
|
||||
oldStatus: string;
|
||||
newStatus: string;
|
||||
companyId: string;
|
||||
timestamp: string;
|
||||
};
|
||||
companyId: string;
|
||||
};
|
||||
|
||||
if (endpointKey !== "case-status") return;
|
||||
|
||||
const { caseNumber, oldStatus, newStatus } = payload;
|
||||
ctx.logger.info(`Webhook: ${caseNumber} ${oldStatus} → ${newStatus}`);
|
||||
|
||||
// Find the Paperclip issue linked to this case
|
||||
const stateKey = `case:${caseNumber}`;
|
||||
const issueId = await ctx.state.get({ companyId }, stateKey);
|
||||
if (!issueId) {
|
||||
ctx.logger.warn(`No issue found for case ${caseNumber}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
in_progress: "🔄 בעבודה",
|
||||
drafted: "✍️ טיוטה מוכנה",
|
||||
qa_failed: "❌ QA נכשל",
|
||||
exported: "📄 יוצא ל-DOCX",
|
||||
reviewed: "✅ נבדק",
|
||||
final: "🎯 סופי",
|
||||
};
|
||||
|
||||
const label = statusLabels[newStatus] ?? newStatus;
|
||||
|
||||
// Post a status comment on the issue
|
||||
await ctx.issues.createComment({
|
||||
issueId: issueId as string,
|
||||
body: `**עדכון סטטוס:** ${label} (היה: ${oldStatus})`,
|
||||
});
|
||||
|
||||
// Wake CEO if QA failed
|
||||
if (newStatus === "qa_failed") {
|
||||
const companies = await ctx.companies.list();
|
||||
const company = companies.find((c) => c.id === companyId);
|
||||
if (!company) return;
|
||||
|
||||
const CEO_IDS: Record<string, string> = {
|
||||
"42a7acd0-30c5-4cbd-ac97-7424f65df294": "752cebdd-6748-4a04-aacd-c7ab0294ef33",
|
||||
"8639e837-4c9d-47fa-a76b-95788d651896": "cdbfa8bc-3d61-41a4-a2e7-677ec7d34562",
|
||||
};
|
||||
const ceoId = CEO_IDS[companyId];
|
||||
if (ceoId) {
|
||||
await ctx.agents.invoke(ceoId, companyId, {
|
||||
prompt: `תיק ${caseNumber} נכשל בבדיקת QA. עיין בתוצאות QA ותקן את הבעיות.`,
|
||||
reason: "qa_failed webhook",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
> **הערה:** `ctx` חייב להיות נגיש ב-`onWebhook`. אם `ctx` מוגדר בתוך `setup()` בלבד — הוצא אותו ל-closure חיצוני של ה-plugin object (ראה את הדפוס הקיים ב-`worker.ts`).
|
||||
|
||||
- [ ] **Step 3: בדק TypeScript**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/plugin-legal-ai && npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/plugin-legal-ai && npm run build
|
||||
```
|
||||
|
||||
Expected: `dist/worker.js` נוצר ללא שגיאות.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/plugin-legal-ai
|
||||
git add src/worker.ts
|
||||
git commit -m "feat: add onWebhook handler for case-status events"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: הוסף capability ל-`plugin.json` ול-`manifest.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `plugin-legal-ai/plugin.json`
|
||||
- Modify: `plugin-legal-ai/src/manifest.ts`
|
||||
|
||||
- [ ] **Step 1: הוסף `"webhooks.receive"` ל-capabilities**
|
||||
|
||||
ב-`plugin-legal-ai/plugin.json`, בarray `"capabilities"`, הוסף:
|
||||
```json
|
||||
"webhooks.receive"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: הוסף גם ב-`manifest.ts`**
|
||||
|
||||
```bash
|
||||
grep -n "capabilities\|webhooks" /home/chaim/plugin-legal-ai/src/manifest.ts
|
||||
```
|
||||
|
||||
הוסף `"webhooks.receive"` לarray שם.
|
||||
|
||||
- [ ] **Step 3: Re-install plugin**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/plugin-legal-ai && npm run build
|
||||
npx paperclipai plugin uninstall marcusgroup.legal-ai \
|
||||
--api-base http://localhost:3100 --api-key pcapi_legal_install_key_2026
|
||||
npx paperclipai plugin install /home/chaim/plugin-legal-ai \
|
||||
--api-base http://localhost:3100 --api-key pcapi_legal_install_key_2026
|
||||
pm2 restart paperclip
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/plugin-legal-ai
|
||||
git add plugin.json src/manifest.ts
|
||||
git commit -m "feat: add webhooks.receive capability to plugin manifest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: בדיקה end-to-end
|
||||
|
||||
- [ ] **Step 1: Deploy legal-ai**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/legal-ai
|
||||
git push origin main
|
||||
# המתן לבנייה (~2-4 דקות)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: שנה סטטוס תיק**
|
||||
|
||||
```bash
|
||||
curl -s -X PUT https://legal-ai.nautilus.marcusgroup.org/api/cases/1130-25 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "qa_failed"}' | jq .status
|
||||
```
|
||||
|
||||
- [ ] **Step 3: בדוק שהתגובה הוספה ל-Paperclip issue**
|
||||
|
||||
```bash
|
||||
# מצא את ה-issue הקשור לתיק 1130-25
|
||||
pm2 logs paperclip --lines 30 | grep "1130-25\|webhook\|qa_failed"
|
||||
```
|
||||
|
||||
Expected: תגובה "❌ QA נכשל" הוספה לissue. CEO הועיר.
|
||||
|
||||
- [ ] **Step 4: בדוק שינוי סטטוס שגרתי (לא QA)**
|
||||
|
||||
```bash
|
||||
curl -s -X PUT https://legal-ai.nautilus.marcusgroup.org/api/cases/1130-25 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "drafted"}' | jq .status
|
||||
```
|
||||
|
||||
Expected: תגובה "✍️ טיוטה מוכנה" בissue. CEO **לא** הועיר.
|
||||
|
||||
---
|
||||
|
||||
## אימות סופי
|
||||
|
||||
| בדיקה | פקודה | תוצאה מצופה |
|
||||
|-------|-------|-------------|
|
||||
| QA נכשל → CEO מועיר | `PUT status=qa_failed` | תגובה + agent invocation |
|
||||
| exported → תגובה בלבד | `PUT status=exported` | תגובה בלבד |
|
||||
| שינוי ללא status | `PUT title=...` | שום webhook |
|
||||
| תיק ללא issue | webhook לתיק חדש | לוג warning, ללא crash |
|
||||
306
docs/superpowers/plans/2026-05-16-per-agent-claude-md.md
Normal file
306
docs/superpowers/plans/2026-05-16-per-agent-claude-md.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Per-Agent CLAUDE.md Versioning & Validation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** הוסף validation ו-version tracking לקבצי הוראות הסוכנים. כעת, לפני שה-sync script מחיל שינויים, הוא מאמת שכל `instructionsFilePath` קיים. בנוסף, metadata של הסוכן ב-DB יכיל `claude_md_mtime` — השינוי האחרון בקובץ — כדי לזהות drift.
|
||||
|
||||
**Architecture:** `sync_agents_across_companies.py` מקבל `--check-instructions` flag שסורק את כל הסוכנים ומדווח על קבצים חסרים/ישנים. ב-`--apply`, מתווספת בדיקת pre-flight שמבטלת את הsync אם קובץ חסר. `agents.metadata` מקבל `claude_md_mtime` עם ה-mtime בפועל של הקובץ.
|
||||
|
||||
**Tech Stack:** Python 3.10+, asyncpg, httpx, `os.path.getmtime()`, Paperclip REST API (`PATCH /api/agents/{id}`).
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | File |
|
||||
|--------|------|
|
||||
| Modify | `legal-ai/scripts/sync_agents_across_companies.py` — `--check-instructions` flag, pre-flight, metadata update |
|
||||
|
||||
זה הקובץ היחיד שצריך לגעת בו. כל שאר הלוגיקה קיימת כבר.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: הוסף `--check-instructions` flag
|
||||
|
||||
**Files:**
|
||||
- Modify: `legal-ai/scripts/sync_agents_across_companies.py`
|
||||
|
||||
- [ ] **Step 1: קרא את החלק של `argparse` בסקריפט**
|
||||
|
||||
```bash
|
||||
grep -n "argparse\|add_argument\|--verify\|--dry-run\|--apply" \
|
||||
/home/chaim/legal-ai/scripts/sync_agents_across_companies.py | head -20
|
||||
```
|
||||
|
||||
- [ ] **Step 2: הוסף את הargument**
|
||||
|
||||
מצא את הסקציה שמגדירה `args` והוסף:
|
||||
|
||||
```python
|
||||
parser.add_argument(
|
||||
"--check-instructions",
|
||||
action="store_true",
|
||||
help="Scan all agents' instructionsFilePath and report missing/outdated files",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: הוסף את הפונקציה `check_instructions()`**
|
||||
|
||||
הוסף לפני `async def main()`:
|
||||
|
||||
```python
|
||||
async def check_instructions(agents: list[dict]) -> bool:
|
||||
"""Print a report of all agents' instruction files. Returns True if all OK."""
|
||||
all_ok = True
|
||||
print(f"\n{'Agent':<30} {'File':<60} {'Status':<15} {'Size':>8} {'Modified'}")
|
||||
print("-" * 120)
|
||||
|
||||
for agent in agents:
|
||||
adapter_cfg = agent.get("adapter_config") or {}
|
||||
if isinstance(adapter_cfg, str):
|
||||
import json as _json
|
||||
adapter_cfg = _json.loads(adapter_cfg)
|
||||
|
||||
file_path = adapter_cfg.get("instructionsFilePath", "")
|
||||
name = agent.get("name", agent.get("id", "?"))[:29]
|
||||
|
||||
if not file_path:
|
||||
print(f"{name:<30} {'(none)':<60} {'⚠️ NOT SET':<15}")
|
||||
continue
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print(f"{name:<30} {file_path[-59:]:<60} {'❌ MISSING':<15}")
|
||||
all_ok = False
|
||||
continue
|
||||
|
||||
stat = os.stat(file_path)
|
||||
size_kb = stat.st_size // 1024
|
||||
mtime = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# Compare with DB metadata
|
||||
metadata = agent.get("metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
import json as _json
|
||||
metadata = _json.loads(metadata)
|
||||
|
||||
db_mtime = metadata.get("claude_md_mtime", "")
|
||||
actual_mtime = str(int(stat.st_mtime))
|
||||
drift = " ⚠️ DRIFT" if db_mtime and db_mtime != actual_mtime else ""
|
||||
|
||||
print(f"{name:<30} {file_path[-59:]:<60} {'✅ OK':<15} {size_kb:>6}KB {mtime}{drift}")
|
||||
|
||||
print()
|
||||
return all_ok
|
||||
```
|
||||
|
||||
> `from datetime import datetime` ו-`import os` — בדוק שמיובאים בראש הסקריפט. אם לא, הוסף.
|
||||
|
||||
- [ ] **Step 4: הוסף קריאה ל-`check_instructions()` ב-`main()`**
|
||||
|
||||
בתוך `async def main()`, אחרי שloading הagents מה-DB:
|
||||
|
||||
```python
|
||||
if args.check_instructions:
|
||||
all_ok = await check_instructions(master_agents + mirror_agents)
|
||||
sys.exit(0 if all_ok else 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: בדיקה**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/legal-ai
|
||||
python scripts/sync_agents_across_companies.py --check-instructions
|
||||
```
|
||||
|
||||
Expected: טבלה עם כל הסוכנים, paths, סטטוס ✅/❌.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/legal-ai
|
||||
git add scripts/sync_agents_across_companies.py
|
||||
git commit -m "feat: add --check-instructions flag to sync script"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: הוסף pre-flight validation לפני `--apply`
|
||||
|
||||
**Files:**
|
||||
- Modify: `legal-ai/scripts/sync_agents_across_companies.py`
|
||||
|
||||
- [ ] **Step 1: מצא את נקודת הכניסה של `--apply`**
|
||||
|
||||
```bash
|
||||
grep -n "args.apply\|if.*apply\|apply.*mode" \
|
||||
/home/chaim/legal-ai/scripts/sync_agents_across_companies.py | head -10
|
||||
```
|
||||
|
||||
- [ ] **Step 2: הוסף pre-flight לפני apply**
|
||||
|
||||
בתחילת בלוק `--apply`, לפני כל שינוי:
|
||||
|
||||
```python
|
||||
if args.apply:
|
||||
# Pre-flight: abort if any agent is missing its instructions file
|
||||
print("🔍 Pre-flight: checking instruction files...")
|
||||
all_ok = await check_instructions(master_agents + mirror_agents)
|
||||
if not all_ok:
|
||||
print("❌ Abort: one or more instruction files are missing. Fix paths before --apply.")
|
||||
sys.exit(1)
|
||||
print("✅ Pre-flight passed.\n")
|
||||
# ... rest of apply logic ...
|
||||
```
|
||||
|
||||
- [ ] **Step 3: בדיקה — הפעל עם קובץ חסר (סימולציה)**
|
||||
|
||||
```bash
|
||||
# שנה זמנית path לקובץ שלא קיים
|
||||
cd /home/chaim/legal-ai
|
||||
python scripts/sync_agents_across_companies.py --dry-run 2>&1 | head -5
|
||||
```
|
||||
|
||||
Expected: dry-run עובר. אם תנסה `--apply` עם agent שhis file חסר — הsync יבוטל.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/legal-ai
|
||||
git add scripts/sync_agents_across_companies.py
|
||||
git commit -m "feat: add pre-flight instruction file validation before --apply"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: עדכן `agents.metadata` עם `claude_md_mtime`
|
||||
|
||||
**Files:**
|
||||
- Modify: `legal-ai/scripts/sync_agents_across_companies.py`
|
||||
|
||||
- [ ] **Step 1: מצא את `compute_diff()` או `apply_diff()`**
|
||||
|
||||
```bash
|
||||
grep -n "def compute_diff\|def apply_diff\|def build_patch\|metadata" \
|
||||
/home/chaim/legal-ai/scripts/sync_agents_across_companies.py | head -20
|
||||
```
|
||||
|
||||
- [ ] **Step 2: הוסף פונקציה `get_claude_md_mtime()`**
|
||||
|
||||
```python
|
||||
def get_claude_md_mtime(adapter_config: dict) -> str | None:
|
||||
"""Return the Unix mtime of the agent's instructionsFilePath, or None if missing."""
|
||||
path = adapter_config.get("instructionsFilePath", "")
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
return str(int(os.path.getmtime(path)))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: שלב mtime בbuild של metadata patch**
|
||||
|
||||
מצא את המקום שמכין את ה-`metadata` לsync. הוסף:
|
||||
|
||||
```python
|
||||
# Build metadata patch with claude_md_mtime
|
||||
current_metadata = master_agent.get("metadata") or {}
|
||||
if isinstance(current_metadata, str):
|
||||
import json as _json
|
||||
current_metadata = _json.loads(current_metadata)
|
||||
|
||||
adapter_cfg = master_agent.get("adapter_config") or {}
|
||||
if isinstance(adapter_cfg, str):
|
||||
import json as _json
|
||||
adapter_cfg = _json.loads(adapter_cfg)
|
||||
|
||||
mtime = get_claude_md_mtime(adapter_cfg)
|
||||
if mtime:
|
||||
current_metadata["claude_md_mtime"] = mtime
|
||||
current_metadata["claude_md_last_synced"] = datetime.utcnow().isoformat() + "Z"
|
||||
```
|
||||
|
||||
כלול את ה-`current_metadata` המעודכן ב-PATCH לAPI.
|
||||
|
||||
- [ ] **Step 4: בדוק שה-metadata מתעדכן**
|
||||
|
||||
הפעל `--dry-run` וחפש `claude_md_mtime` בoutput:
|
||||
```bash
|
||||
python scripts/sync_agents_across_companies.py --dry-run 2>&1 | grep -i "mtime\|metadata" | head -10
|
||||
```
|
||||
|
||||
לאחר `--apply`, בדוק ב-DB:
|
||||
```bash
|
||||
psql -h localhost -p 54329 -U paperclip -c \
|
||||
"SELECT name, metadata->>'claude_md_mtime' AS mtime FROM agents WHERE metadata->>'claude_md_mtime' IS NOT NULL LIMIT 5" \
|
||||
paperclip
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/legal-ai
|
||||
git add scripts/sync_agents_across_companies.py
|
||||
git commit -m "feat: track claude_md_mtime in agents.metadata during sync"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: הוסף `make check-agents` shortcut
|
||||
|
||||
**Files:**
|
||||
- Modify: `legal-ai/Makefile` (אם קיים) אחרת הוסף alias
|
||||
|
||||
- [ ] **Step 1: בדוק אם Makefile קיים**
|
||||
|
||||
```bash
|
||||
ls /home/chaim/legal-ai/Makefile 2>/dev/null && echo "EXISTS" || echo "MISSING"
|
||||
```
|
||||
|
||||
- [ ] **Step 2א: אם Makefile קיים — הוסף target**
|
||||
|
||||
```makefile
|
||||
check-agents:
|
||||
python scripts/sync_agents_across_companies.py --check-instructions
|
||||
|
||||
sync-agents-dry:
|
||||
python scripts/sync_agents_across_companies.py --dry-run
|
||||
|
||||
sync-agents:
|
||||
python scripts/sync_agents_across_companies.py --apply
|
||||
```
|
||||
|
||||
- [ ] **Step 2ב: אם Makefile לא קיים — הוסף alias ל-`~/.bashrc`**
|
||||
|
||||
```bash
|
||||
echo "alias check-agents='cd /home/chaim/legal-ai && python scripts/sync_agents_across_companies.py --check-instructions'" >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
- [ ] **Step 3: בדוק**
|
||||
|
||||
```bash
|
||||
# אם Makefile:
|
||||
make -C /home/chaim/legal-ai check-agents
|
||||
# אם alias:
|
||||
check-agents
|
||||
```
|
||||
|
||||
Expected: טבלת סוכנים מוצגת.
|
||||
|
||||
- [ ] **Step 4: Commit (אם Makefile)**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/legal-ai
|
||||
git add Makefile
|
||||
git commit -m "feat: add check-agents and sync-agents make targets"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## אימות סופי
|
||||
|
||||
| בדיקה | פקודה | תוצאה מצופה |
|
||||
|-------|-------|-------------|
|
||||
| `--check-instructions` | `python sync_agents... --check-instructions` | טבלה עם ✅ לכל agent |
|
||||
| Pre-flight בולם apply | מחק קובץ זמנית + `--apply` | Abort עם הודעה ברורה |
|
||||
| mtime ב-DB | `SELECT metadata->>'claude_md_mtime' FROM agents` | timestamp לכל agent |
|
||||
| DRIFT זוהה | שנה קובץ + `--check-instructions` | ⚠️ DRIFT מוצג |
|
||||
| shortcut | `check-agents` או `make check-agents` | עובד |
|
||||
412
docs/superpowers/plans/2026-05-16-scheduled-agents.md
Normal file
412
docs/superpowers/plans/2026-05-16-scheduled-agents.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# Scheduled Background Agents Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** הוסף 2 cron jobs לplugin: (1) תזכורת על תיקים תקועים 3+ ימים, (2) ניתוח פידבק יו"ר שבועי עם עדכון `decision-lessons.md` אוטומטי.
|
||||
|
||||
**Architecture:** שני jobs חדשים נרשמים ב-`ctx.jobs.register()`. Job 1 קורא `/api/cases/stale?days=3` (endpoint חדש ב-legal-ai) ומוסיף תגובה לissues תקועים. Job 2 קורא `/api/chair-feedback/weekly-summary` (endpoint חדש), שולח לCEO agent שמעדכן את `decision-lessons.md`.
|
||||
|
||||
**Tech Stack:** TypeScript (plugin-legal-ai jobs), Python/FastAPI (legal-ai web), asyncpg, Paperclip SDK `ctx.jobs.register()`, `ctx.agents.invoke()`.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | File |
|
||||
|--------|------|
|
||||
| Modify | `plugin-legal-ai/src/worker.ts` — register 2 new jobs |
|
||||
| Modify | `plugin-legal-ai/plugin.json` — declare 2 new job entries |
|
||||
| Modify | `plugin-legal-ai/src/manifest.ts` — add to jobs array |
|
||||
| Modify | `legal-ai/web/app.py` — add `GET /api/cases/stale` + `GET /api/chair-feedback/weekly-summary` |
|
||||
| Modify | `legal-ai/web/database.py` (or equivalent DB module) — add `get_stale_cases()` + `get_weekly_chair_feedback()` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: הוסף endpoint `GET /api/cases/stale`
|
||||
|
||||
**Files:**
|
||||
- Modify: `legal-ai/web/app.py`
|
||||
- Modify: `legal-ai/web/database.py` (שם DB queries מנוהלים — בדוק עם `grep -n "async def get_cases\|from.*database\|import.*db" /home/chaim/legal-ai/web/app.py | head -10`)
|
||||
|
||||
- [ ] **Step 1: מצא את module ה-DB**
|
||||
|
||||
```bash
|
||||
grep -n "^from\|^import\|db\." /home/chaim/legal-ai/web/app.py | head -20
|
||||
```
|
||||
|
||||
זהה את שם הmodule שמכיל את DB queries (בד"כ `database.py` או `db.py`).
|
||||
|
||||
- [ ] **Step 2: הוסף `get_stale_cases()` לmodule ה-DB**
|
||||
|
||||
```python
|
||||
async def get_stale_cases(days: int = 3) -> list[dict]:
|
||||
"""Return cases whose status is not 'final' and haven't been updated in `days` days."""
|
||||
async with get_db_connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT case_number, title, status, company_id,
|
||||
updated_at,
|
||||
now() - updated_at AS age
|
||||
FROM cases
|
||||
WHERE status NOT IN ('final', 'new')
|
||||
AND updated_at < now() - ($1 || ' days')::interval
|
||||
ORDER BY updated_at ASC
|
||||
""",
|
||||
str(days),
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
```
|
||||
|
||||
> `get_db_connection()` — השתמש בדפוס הקיים בקובץ. אם זה `asyncpg.connect()` ישיר, `asyncpg.create_pool()`, או context manager — העתק את הדפוס.
|
||||
|
||||
- [ ] **Step 3: הוסף endpoint ב-`app.py`**
|
||||
|
||||
```python
|
||||
@app.get("/api/cases/stale")
|
||||
async def list_stale_cases(days: int = 3):
|
||||
"""Cases stuck in non-final status for more than `days` days."""
|
||||
cases = await db.get_stale_cases(days=days)
|
||||
return {
|
||||
"cases": [
|
||||
{
|
||||
"case_number": c["case_number"],
|
||||
"title": c["title"],
|
||||
"status": c["status"],
|
||||
"company_id": str(c["company_id"]),
|
||||
"days_stale": c["age"].days,
|
||||
}
|
||||
for c in cases
|
||||
],
|
||||
"total": len(cases),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: בדיקה**
|
||||
|
||||
```bash
|
||||
curl -s "https://legal-ai.nautilus.marcusgroup.org/api/cases/stale?days=1" | jq .total
|
||||
```
|
||||
|
||||
Expected: JSON עם רשימת תיקים.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/legal-ai
|
||||
git add web/app.py web/database.py # או השם הנכון
|
||||
git commit -m "feat: add GET /api/cases/stale endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: הוסף endpoint `GET /api/chair-feedback/weekly-summary`
|
||||
|
||||
**Files:**
|
||||
- Modify: `legal-ai/web/app.py`
|
||||
- Modify: DB module
|
||||
|
||||
- [ ] **Step 1: בדוק את מבנה טבלת `chair_feedback`**
|
||||
|
||||
```bash
|
||||
sqlite3 /home/chaim/.paperclip/instances/default/data/app.db \
|
||||
".schema chair_feedback" 2>/dev/null || \
|
||||
psql -h localhost -p 5433 -U legal_ai -c "\d chair_feedback" legal_ai 2>/dev/null || \
|
||||
grep -rn "chair_feedback" /home/chaim/legal-ai/mcp-server/src/ | head -10
|
||||
```
|
||||
|
||||
- [ ] **Step 2: הוסף `get_weekly_chair_feedback()` לDB module**
|
||||
|
||||
```python
|
||||
async def get_weekly_chair_feedback(days: int = 7) -> list[dict]:
|
||||
"""Return chair feedback entries from the last `days` days."""
|
||||
async with get_db_connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT cf.case_number, cf.feedback_text, cf.created_at,
|
||||
cf.feedback_type, c.title
|
||||
FROM chair_feedback cf
|
||||
JOIN cases c ON c.case_number = cf.case_number
|
||||
WHERE cf.created_at > now() - ($1 || ' days')::interval
|
||||
ORDER BY cf.created_at DESC
|
||||
""",
|
||||
str(days),
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
```
|
||||
|
||||
> אם שמות השדות שונים (בדוק ב-Step 1) — התאם.
|
||||
|
||||
- [ ] **Step 3: הוסף endpoint**
|
||||
|
||||
```python
|
||||
@app.get("/api/chair-feedback/weekly-summary")
|
||||
async def get_chair_feedback_weekly(days: int = 7):
|
||||
"""Feedback entries from the past week, formatted for the learning agent."""
|
||||
entries = await db.get_weekly_chair_feedback(days=days)
|
||||
if not entries:
|
||||
return {"summary": "", "entry_count": 0}
|
||||
|
||||
lines = [
|
||||
f"- תיק {e['case_number']} ({e['title']}): {e['feedback_text']}"
|
||||
for e in entries
|
||||
]
|
||||
summary = "\n".join(lines)
|
||||
return {"summary": summary, "entry_count": len(entries), "entries": entries}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: בדיקה**
|
||||
|
||||
```bash
|
||||
curl -s "https://legal-ai.nautilus.marcusgroup.org/api/chair-feedback/weekly-summary" | jq .entry_count
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/legal-ai
|
||||
git add web/app.py web/database.py
|
||||
git commit -m "feat: add GET /api/chair-feedback/weekly-summary endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: הוסף jobs לplugin
|
||||
|
||||
**Files:**
|
||||
- Modify: `plugin-legal-ai/plugin.json`
|
||||
- Modify: `plugin-legal-ai/src/manifest.ts`
|
||||
|
||||
- [ ] **Step 1: קרא את ה-`jobs` הקיים ב-`plugin.json`**
|
||||
|
||||
```bash
|
||||
cat /home/chaim/plugin-legal-ai/plugin.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps(d['jobs'], indent=2))"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: הוסף 2 jobs חדשים לarray `"jobs"` ב-`plugin.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"jobKey": "stale-case-reminder",
|
||||
"displayName": "תזכורת תיקים תקועים",
|
||||
"description": "מזהה תיקים שלא עודכנו 3+ ימים ומוסיף תגובה לissue",
|
||||
"schedule": "0 8 * * *"
|
||||
},
|
||||
{
|
||||
"jobKey": "weekly-feedback-analysis",
|
||||
"displayName": "ניתוח פידבק שבועי",
|
||||
"description": "מסכם פידבק יו\"ר מהשבוע האחרון ומעדכן את decision-lessons.md",
|
||||
"schedule": "0 19 * * 0"
|
||||
}
|
||||
```
|
||||
|
||||
> `"0 8 * * *"` = כל יום בשעה 08:00. `"0 19 * * 0"` = כל ראשון ב-19:00.
|
||||
|
||||
- [ ] **Step 3: עדכן `manifest.ts`**
|
||||
|
||||
```bash
|
||||
grep -n "jobs\|jobKey\|schedule" /home/chaim/plugin-legal-ai/src/manifest.ts
|
||||
```
|
||||
|
||||
הוסף את אותם 2 objects לarray `jobs` ב-`manifest.ts`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/plugin-legal-ai
|
||||
git add plugin.json src/manifest.ts
|
||||
git commit -m "feat: declare stale-case-reminder and weekly-feedback-analysis jobs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Implement job handlers ב-`worker.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `plugin-legal-ai/src/worker.ts`
|
||||
|
||||
- [ ] **Step 1: קרא את handler של `sync-case-status` הקיים**
|
||||
|
||||
```bash
|
||||
grep -n "sync-case-status\|jobs.register\|jobKey" /home/chaim/plugin-legal-ai/src/worker.ts
|
||||
```
|
||||
|
||||
העתק את הדפוס.
|
||||
|
||||
- [ ] **Step 2: הוסף את `stale-case-reminder` handler**
|
||||
|
||||
בתוך `setup(ctx)`, אחרי רישום ה-job הקיים:
|
||||
|
||||
```typescript
|
||||
ctx.jobs.register("stale-case-reminder", async (job) => {
|
||||
ctx.logger.info("stale-case-reminder: starting");
|
||||
|
||||
const config = await ctx.config.get();
|
||||
const apiBase = (config.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||
|
||||
const resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=3`);
|
||||
if (!resp.ok) {
|
||||
ctx.logger.error(`stale-case-reminder: API error ${resp.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as {
|
||||
cases: Array<{
|
||||
case_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
company_id: string;
|
||||
days_stale: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
for (const staleCase of data.cases) {
|
||||
const issueId = await ctx.state.get(
|
||||
{ companyId: staleCase.company_id },
|
||||
`case:${staleCase.case_number}`
|
||||
);
|
||||
if (!issueId) continue;
|
||||
|
||||
await ctx.issues.createComment({
|
||||
issueId: issueId as string,
|
||||
body: `⚠️ **תיק תקוע** — ${staleCase.days_stale} ימים ללא עדכון (סטטוס: ${staleCase.status}). האם יש צורך בפעולה?`,
|
||||
});
|
||||
|
||||
ctx.logger.info(
|
||||
`stale-case-reminder: posted reminder for ${staleCase.case_number} (${staleCase.days_stale}d stale)`
|
||||
);
|
||||
}
|
||||
|
||||
ctx.logger.info(`stale-case-reminder: done. ${data.cases.length} cases reminded`);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: הוסף את `weekly-feedback-analysis` handler**
|
||||
|
||||
```typescript
|
||||
ctx.jobs.register("weekly-feedback-analysis", async (job) => {
|
||||
ctx.logger.info("weekly-feedback-analysis: starting");
|
||||
|
||||
const config = await ctx.config.get();
|
||||
const apiBase = (config.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||
|
||||
const resp = await ctx.http.fetch(`${apiBase}/api/chair-feedback/weekly-summary`);
|
||||
if (!resp.ok) {
|
||||
ctx.logger.error(`weekly-feedback-analysis: API error ${resp.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as {
|
||||
summary: string;
|
||||
entry_count: number;
|
||||
};
|
||||
|
||||
if (data.entry_count === 0) {
|
||||
ctx.logger.info("weekly-feedback-analysis: no feedback this week, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// Invoke the CEO agent to process feedback and update decision-lessons.md
|
||||
const companies = await ctx.companies.list();
|
||||
for (const company of companies) {
|
||||
// CEO IDs per company
|
||||
const CEO_IDS: Record<string, string> = {
|
||||
"42a7acd0-30c5-4cbd-ac97-7424f65df294": "752cebdd-6748-4a04-aacd-c7ab0294ef33",
|
||||
"8639e837-4c9d-47fa-a76b-95788d651896": "cdbfa8bc-3d61-41a4-a2e7-677ec7d34562",
|
||||
};
|
||||
const ceoId = CEO_IDS[company.id];
|
||||
if (!ceoId) continue;
|
||||
|
||||
await ctx.agents.invoke(ceoId, company.id, {
|
||||
prompt: `ניתוח פידבק שבועי יו"ר (${data.entry_count} פריטים):
|
||||
|
||||
${data.summary}
|
||||
|
||||
המשימה: עדכן את /home/chaim/legal-ai/docs/legal-decision-lessons.md עם הלקחים החדשים שעולים מהפידבק. הוסף רק לקחים חדשים שלא קיימים כבר. קבץ לפי נושא.`,
|
||||
reason: "weekly-feedback-analysis scheduled job",
|
||||
});
|
||||
|
||||
ctx.logger.info(
|
||||
`weekly-feedback-analysis: invoked CEO for company ${company.id} (${data.entry_count} feedback entries)`
|
||||
);
|
||||
break; // One CEO is enough — lessons file is shared
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: TypeScript check**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/plugin-legal-ai && npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 5: Build**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/plugin-legal-ai && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/plugin-legal-ai
|
||||
git add src/worker.ts
|
||||
git commit -m "feat: implement stale-case-reminder and weekly-feedback-analysis jobs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Re-install plugin + בדיקה
|
||||
|
||||
- [ ] **Step 1: Deploy legal-ai**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/legal-ai && git push origin main
|
||||
# המתן ~3 דקות
|
||||
curl -s https://legal-ai.nautilus.marcusgroup.org/api/health | jq .status
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Re-install plugin**
|
||||
|
||||
```bash
|
||||
cd /home/chaim/plugin-legal-ai && npm run build
|
||||
npx paperclipai plugin uninstall marcusgroup.legal-ai \
|
||||
--api-base http://localhost:3100 --api-key pcapi_legal_install_key_2026
|
||||
npx paperclipai plugin install /home/chaim/plugin-legal-ai \
|
||||
--api-base http://localhost:3100 --api-key pcapi_legal_install_key_2026
|
||||
pm2 restart paperclip
|
||||
```
|
||||
|
||||
- [ ] **Step 3: בדוק שה-jobs רשומים**
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer pcapi_legal_install_key_2026" \
|
||||
http://localhost:3100/api/plugins/marcusgroup.legal-ai/jobs | jq .[].jobKey
|
||||
```
|
||||
|
||||
Expected: `"sync-case-status"`, `"stale-case-reminder"`, `"weekly-feedback-analysis"`.
|
||||
|
||||
- [ ] **Step 4: הפעל job ידנית לבדיקה**
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer pcapi_legal_install_key_2026" \
|
||||
http://localhost:3100/api/plugins/marcusgroup.legal-ai/jobs/stale-case-reminder/run | jq .
|
||||
```
|
||||
|
||||
Expected: Job הופעל. בדוק logs:
|
||||
```bash
|
||||
pm2 logs paperclip --lines 30 | grep "stale-case-reminder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## אימות סופי
|
||||
|
||||
| בדיקה | פקודה | תוצאה מצופה |
|
||||
|-------|-------|-------------|
|
||||
| API stale endpoint | `curl .../api/cases/stale?days=1` | JSON עם cases |
|
||||
| API feedback endpoint | `curl .../api/chair-feedback/weekly-summary` | JSON עם summary |
|
||||
| Jobs רשומים | `GET .../api/plugins/.../jobs` | 3 jobs רשומים |
|
||||
| Stale reminder ידני | `POST .../jobs/stale-case-reminder/run` | תגובות בissues |
|
||||
| Feedback analysis ידני | `POST .../jobs/weekly-feedback-analysis/run` | CEO מועיר |
|
||||
118
mcp-server/src/legal_mcp/case_status_model.py
Normal file
118
mcp-server/src/legal_mcp/case_status_model.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Single source of truth for the case-status lifecycle (the *status model*).
|
||||
|
||||
Every consumer derives its order / labels / phase from this one registry:
|
||||
• ``CaseStatus`` enum (models.py) — the type-level key set (kept consistent
|
||||
by the assertion at the bottom of this module);
|
||||
• the forward-only ``STATUS_ORDER`` guard in ``tools/cases.py``;
|
||||
• the ``GET /api/status-model`` endpoint (web/app.py) that the frontend
|
||||
mirror (``web-ui/src/lib/api/case-status.ts``) is generated against.
|
||||
|
||||
To make a status *do something* on entry (notify, kick a job, …), set its
|
||||
``on_enter`` to an action key — the single place where a status's behaviour is
|
||||
declared. ``tools/cases.py`` dispatches it when a case transitions into that
|
||||
status (forward-only), so the behaviour lives next to the definition.
|
||||
|
||||
The five **phases** are the coarse pipeline the 12 statuses collapse into for the
|
||||
header stepper. Intermediate analyst/research states (``analyst_verified``,
|
||||
``research_complete``) are first-class canonical statuses here — the agents set
|
||||
them, so they must be in the model rather than fall between the canonical set
|
||||
and the legacy bucket (the bug that made the chip, stepper and manual-changer
|
||||
disagree).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Ordered 5-phase pipeline (key → Hebrew label) — coarse view of the lifecycle.
|
||||
PHASES: list[tuple[str, str]] = [
|
||||
("intake", "קליטה ועיבוד"),
|
||||
("prep", "הכנת תיק"),
|
||||
("thinking", "ניתוח וכיוון"),
|
||||
("writing", "כתיבת טיוטה"),
|
||||
("done", "סגירה"),
|
||||
]
|
||||
PHASE_LABELS: dict[str, str] = dict(PHASES)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StatusDef:
|
||||
key: str
|
||||
label: str # Hebrew label (chip / dropdown)
|
||||
description: str # Hebrew one-liner (status guide)
|
||||
phase: str # one of PHASES keys
|
||||
selectable: bool = True # offered in the manual status-changer dropdown
|
||||
terminal: bool = False
|
||||
on_enter: str | None = None # future: action key dispatched on entry
|
||||
|
||||
|
||||
# THE lifecycle — ordered. STATUS_ORDER, the enum and the frontend all derive
|
||||
# from this list. Insert intermediates in workflow order.
|
||||
STATUS_DEFS: list[StatusDef] = [
|
||||
StatusDef("new", "חדש", "התיק נוצר וממתין להעלאת מסמכים", "intake"),
|
||||
StatusDef("processing", "בעיבוד", "המערכת מעבדת ומנתחת את המסמכים", "intake"),
|
||||
StatusDef("documents_ready", "מסמכים מוכנים", "כל המסמכים עובדו ומוכנים לעבודה", "prep"),
|
||||
StatusDef("analyst_verified", "ניתוח אומת", "המנתח סיים ואימת את הניתוח — ממתין להכרעת תוצאה של היו״ר", "thinking"),
|
||||
StatusDef("research_complete", "מחקר הושלם", "חקר התקדימים הושלם (מסלול נפרד מהמנתח)", "thinking"),
|
||||
StatusDef("outcome_set", "תוצאה נקבעה", "נקבעה תוצאה צפויה לערר", "thinking"),
|
||||
StatusDef("direction_approved", "כיוון אושר", "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה", "thinking"),
|
||||
StatusDef("qa_review", "בדיקת איכות", "הטיוטה בבדיקת איכות אוטומטית", "writing"),
|
||||
StatusDef("drafted", "טיוטה", "טיוטה מוכנה לעיון", "writing"),
|
||||
StatusDef("exported", "יוצא", "ההחלטה יוצאה לקובץ DOCX", "done"),
|
||||
StatusDef("reviewed", "נבדק", 'ההחלטה נבדקה ע"י היו"ר', "done"),
|
||||
StatusDef("final", "סופי", "החלטה סופית — מוכנה להגשה", "done", terminal=True),
|
||||
]
|
||||
|
||||
BY_KEY: dict[str, StatusDef] = {d.key: d for d in STATUS_DEFS}
|
||||
STATUS_ORDER: list[str] = [d.key for d in STATUS_DEFS]
|
||||
|
||||
|
||||
def phase_of(status: str | None) -> str | None:
|
||||
"""The pipeline phase a status belongs to (None for unknown values)."""
|
||||
d = BY_KEY.get(status or "")
|
||||
return d.phase if d else None
|
||||
|
||||
|
||||
def label_of(status: str | None) -> str:
|
||||
d = BY_KEY.get(status or "")
|
||||
return d.label if d else (status or "")
|
||||
|
||||
|
||||
def to_dict() -> dict:
|
||||
"""Serialisable status model for ``GET /api/status-model`` (frontend SSoT)."""
|
||||
return {
|
||||
"statuses": [
|
||||
{
|
||||
"key": d.key,
|
||||
"label": d.label,
|
||||
"description": d.description,
|
||||
"phase": d.phase,
|
||||
"selectable": d.selectable,
|
||||
"terminal": d.terminal,
|
||||
"on_enter": d.on_enter,
|
||||
}
|
||||
for d in STATUS_DEFS
|
||||
],
|
||||
"phases": [{"key": k, "label": v} for k, v in PHASES],
|
||||
}
|
||||
|
||||
|
||||
# Drift guard — the registry IS the canonical key set; the CaseStatus enum must
|
||||
# match it exactly. Importing here (models.py never imports this module) is safe.
|
||||
def _assert_consistent() -> None:
|
||||
from legal_mcp.models import CaseStatus
|
||||
|
||||
registry = {d.key for d in STATUS_DEFS}
|
||||
enum_keys = {s.value for s in CaseStatus}
|
||||
if registry != enum_keys:
|
||||
raise RuntimeError(
|
||||
"case_status_model drift: registry vs CaseStatus enum differ — "
|
||||
f"only-in-registry={registry - enum_keys}, only-in-enum={enum_keys - registry}"
|
||||
)
|
||||
phase_keys = {k for k, _ in PHASES}
|
||||
bad = {d.key: d.phase for d in STATUS_DEFS if d.phase not in phase_keys}
|
||||
if bad:
|
||||
raise RuntimeError(f"case_status_model: statuses with unknown phase: {bad}")
|
||||
|
||||
|
||||
_assert_consistent()
|
||||
@@ -275,8 +275,33 @@ HALACHA_CANONICAL_SYNTH_MODEL = os.environ.get("HALACHA_CANONICAL_SYNTH_MODEL",
|
||||
HALACHA_CANONICAL_SYNTH_EFFORT = os.environ.get("HALACHA_CANONICAL_SYNTH_EFFORT", "high")
|
||||
HALACHA_CANONICAL_SYNTH_DRIFT_FLOOR = float(os.environ.get("HALACHA_CANONICAL_SYNTH_DRIFT_FLOOR", "0.80"))
|
||||
|
||||
# Google Cloud Vision (OCR for scanned PDFs)
|
||||
GOOGLE_CLOUD_VISION_API_KEY = os.environ.get("GOOGLE_CLOUD_VISION_API_KEY", "")
|
||||
# Lesson synthesis (#158 / INV-LRN8) — mirrors the canonical-halacha synthesis above
|
||||
# for decision_lessons: cluster overlapping style lessons (cosine ≥ CLUSTER_THRESHOLD,
|
||||
# within a practice_area+category shard) and merge each cluster into one richer
|
||||
# "super-lesson" via a local claude_session pass, grounded in the source lessons
|
||||
# (INV-AH) with a re-embedding DRIFT_FLOOR guard. Opus by default — chair-facing
|
||||
# quality. The synthesised set is smaller, so the writer's limit=15 stops truncating
|
||||
# (the real fix for the silent cap, #157).
|
||||
LESSON_SYNTH_MODEL = os.environ.get("LESSON_SYNTH_MODEL", HALACHA_EXTRACT_MODEL)
|
||||
LESSON_SYNTH_EFFORT = os.environ.get("LESSON_SYNTH_EFFORT", "high")
|
||||
LESSON_SYNTH_DRIFT_FLOOR = float(os.environ.get("LESSON_SYNTH_DRIFT_FLOOR", "0.80"))
|
||||
# Cosine floor for two lessons to land in the same cluster (greedy, within shard).
|
||||
# Tuned empirically (#158 dry-run, 81 lessons): the lessons are diverse (median
|
||||
# pairwise cosine ~0.48, max ~0.72), so 0.82 merged nothing. 0.68 captures the real
|
||||
# near-duplicates (5 faithful clusters across rishuy+betterment, all clearing the
|
||||
# 0.80 drift gate) without merging weakly-related lessons; the drift gate is the
|
||||
# quality backstop below this.
|
||||
LESSON_SYNTH_CLUSTER_THRESHOLD = float(os.environ.get("LESSON_SYNTH_CLUSTER_THRESHOLD", "0.68"))
|
||||
|
||||
# How many CHAIR-APPROVED decision_lessons flow to the writer per practice_area
|
||||
# (get_recent_decision_lessons). Raised 15 → 60 (#158/#157): the lessons are now both
|
||||
# deduplicated (synthesis merges true near-duplicates) AND chair-approved, so the old
|
||||
# cap silently truncated distinct, vetted style guidance. 60 covers every current shard
|
||||
# with headroom; synthesis keeps the set non-redundant as it grows. env-tunable.
|
||||
WRITER_LESSONS_LIMIT = int(os.environ.get("WRITER_LESSONS_LIMIT", "60"))
|
||||
|
||||
# Mistral OCR (fallback for scanned PDFs — replaces Google Cloud Vision)
|
||||
MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY", "")
|
||||
|
||||
# Data directory
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", str(Path.home() / "legal-ai" / "data")))
|
||||
@@ -361,7 +386,7 @@ PARENT_DOC_CHILD_OVERLAP_TOKENS = int(
|
||||
# External service allowlist — case materials may ONLY be sent to these domains
|
||||
ALLOWED_EXTERNAL_SERVICES = {
|
||||
"api.voyageai.com", # Voyage AI (embeddings)
|
||||
"vision.googleapis.com", # Google Cloud Vision (OCR)
|
||||
"api.mistral.ai", # Mistral OCR (scanned PDFs)
|
||||
}
|
||||
|
||||
# Audit
|
||||
|
||||
@@ -9,13 +9,18 @@ from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# Core case lifecycle — kept in sync with STATUS_ORDER in tools/cases.py and the
|
||||
# frontend SSoT web-ui/src/lib/api/case-status.ts. Trimmed from 17 → 10 (the
|
||||
# decorative mid-stage markers that no pipeline code ever set were removed).
|
||||
# Core case lifecycle. The canonical key set, order, labels, phases and (future)
|
||||
# per-status actions all live in ONE place — legal_mcp/case_status_model.py —
|
||||
# which asserts this enum matches it. The frontend mirror is
|
||||
# web-ui/src/lib/api/case-status.ts (generated against GET /api/status-model).
|
||||
# The analyst/research intermediate states are first-class canonical statuses
|
||||
# (the agents set them) — not legacy.
|
||||
class CaseStatus(str, enum.Enum):
|
||||
NEW = "new"
|
||||
PROCESSING = "processing"
|
||||
DOCUMENTS_READY = "documents_ready"
|
||||
ANALYST_VERIFIED = "analyst_verified"
|
||||
RESEARCH_COMPLETE = "research_complete"
|
||||
OUTCOME_SET = "outcome_set"
|
||||
DIRECTION_APPROVED = "direction_approved"
|
||||
QA_REVIEW = "qa_review"
|
||||
|
||||
@@ -418,6 +418,12 @@ async def digest_process_pending(limit: int = 20) -> str:
|
||||
return await digest_tools.digest_process_pending(_clamp_limit(limit))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def digest_radar(case_number: str, limit: int = 5, min_score: float = 0.45) -> str:
|
||||
"""רדאר-יומונים הקשרי-לתיק (X12) — מחזיר יומונים לא-מקושרים (פס"ד שעוד אין לנו) שהנושא שלהם קרוב סמנטית לתיק, כדי שלא ייפול פס"ד רלוונטי בזמן ההכרעה. כל ליד מצביע על מראה-המקום של הפס"ד המקורי + סטטוס-הפער + פעולה מוצעת. ⚠️ radar בלבד — היומון אינו מצוטט (INV-DIG1)."""
|
||||
return await digest_tools.digest_radar(case_number, _clamp_limit(limit), min_score)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def halacha_review(
|
||||
halacha_id: str,
|
||||
@@ -566,6 +572,18 @@ async def get_legal_arguments(
|
||||
return await la_tools.get_legal_arguments(case_number, party)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def reanalyze_claims(
|
||||
case_number: str,
|
||||
reanalyze_all_primary: bool = False,
|
||||
) -> str:
|
||||
"""ניתוח-מחדש מאחד אחרי הוספת מסמך-עיקרי: מחלץ ממסמכים-עיקריים חדשים/לא-נותחו בלבד,
|
||||
מאחד עם הטענות הקיימות (לא מוחק הכול), מריץ צבירה-מחדש, ומחזיר בדיקת-השפעה
|
||||
(diff טיעונים/המלצה לפני↔אחרי) ליו"ר. reanalyze_all_primary=True = כל המסמכים-העיקריים.
|
||||
"""
|
||||
return await la_tools.reanalyze_claims(case_number, reanalyze_all_primary)
|
||||
|
||||
|
||||
# References
|
||||
@mcp.tool()
|
||||
async def extract_references(
|
||||
@@ -738,6 +756,29 @@ async def get_appraiser_facts(case_number: str) -> str:
|
||||
return await drafting.get_appraiser_facts(case_number)
|
||||
|
||||
|
||||
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
|
||||
|
||||
@mcp.tool()
|
||||
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
|
||||
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד.
|
||||
|
||||
בוחר אוטומטית פרוטוקול ועדת-ערר (scope שאינו 'lower'); document_id מכוון לדיון
|
||||
מדויק כשיש כמה פרוטוקולים. מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול
|
||||
doc_type='protocol' + טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high;
|
||||
re-run מחליף (idempotent).
|
||||
"""
|
||||
return await drafting.analyze_protocol(case_number, document_id=document_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_protocol_analysis(case_number: str, change_type: str = "") -> str:
|
||||
"""קריאת ניתוח-הפרוטוקול שכבר חולץ — ללא ניתוח-מחדש. ה-get המקביל ל-analyze_protocol.
|
||||
|
||||
change_type: dropped/strengthened/newly_raised (ריק=הכל).
|
||||
"""
|
||||
return await drafting.get_protocol_analysis(case_number, change_type)
|
||||
|
||||
|
||||
# ── Planning-schemes registry (V38) — מרשם-התכניות ─────────────────
|
||||
# SSOT לזהות+תוקף של תכנית, נעשה שימוש חוזר בין תיקים (G2). פלט-LLM נכנס
|
||||
# pending_review וממתין לאישור-יו"ר (plan_review, G10) לפני שמשמש בבלוק ט.
|
||||
@@ -808,6 +849,12 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
||||
return await drafting.export_interim_draft(case_number, output_path)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def summarize_party_claims(case_number: str, instructions: str = "") -> str:
|
||||
"""סיכום-מנהלים מזוקק של טענות הצדדים — מסמך פרוזה נפרד מטיוטת-ההחלטה, להכנת היו"ר לדיון. נגזר מ-legal_arguments/claims (אותו מקור), נעוץ Opus 4.8, נשמר וניתן-לייצוא."""
|
||||
return await drafting.summarize_party_claims(case_number, instructions)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
||||
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""
|
||||
@@ -1170,6 +1217,25 @@ async def list_chair_feedback(
|
||||
return await workflow.list_chair_feedback(case_number, category, unresolved_only, _clamp_limit(limit))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def record_curator_findings(case_number: str, findings: list[dict]) -> str:
|
||||
"""לכידת ממצאי-האוצֵר על החלטה סופית כ-decision_lessons מובְנים (source='curator',
|
||||
ממתינים לשער-יו"ר) — INV-LRN3. כל ממצא: {"text": "...", "category": style/structure/
|
||||
lexicon/tabular} (או "tag" עברי). מחזיר כמה נרשמו וכמה כפילויות דולגו. read-only על
|
||||
התוכן — הרישום הצעה הממתינה לאישור דפנה ב-/training, לא שינוי-קול ישיר (G10)."""
|
||||
return await workflow.record_curator_findings(case_number, findings)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def lesson_synthesize_pending(
|
||||
practice_area: str = "", category: str = "", apply: bool = True,
|
||||
) -> str:
|
||||
"""סינתזת-לקחים (#158 / INV-LRN8): ממזגת לקחי-סגנון חופפים ל"לקח-על" אחד עשיר (source='synthesis'),
|
||||
המקורות→superseded; הסט שזורם לכותב קטֵן ומשתבח (limit=15 מפסיק לחתוך). מעוגן-מקור (INV-AH) +
|
||||
שער-drift; שער מדורג-הפיך (G10). apply=False = dry-run. practice_area/category ריקים = כל ה-shards."""
|
||||
return await workflow.lesson_synthesize_pending(practice_area, category, apply)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def halacha_corroboration(halacha_id: str) -> dict:
|
||||
"""החזר את ה-corroboration של הלכה: הציטוטים שמתקפים אותה, הטיפול, וסיכום (X11, read-only)."""
|
||||
|
||||
@@ -182,16 +182,21 @@ def _add_runs_with_inline_bold(paragraph: Paragraph, text: str) -> None:
|
||||
|
||||
|
||||
def _clear_body(doc: DocumentT) -> None:
|
||||
"""Remove every paragraph currently in the document body.
|
||||
"""Remove ALL body content (paragraphs, tables, stray bookmarks),
|
||||
keeping only sectPr.
|
||||
|
||||
The template ships with example paragraphs ("רקע", "דיון והכרעה"…)
|
||||
that we don't want in the output. Section properties (sectPr) are
|
||||
kept so page size / margins / RTL / footer remain intact.
|
||||
The template ships with example paragraphs ("רקע", "דיון והכרעה"…) *and
|
||||
three sample tables* (header / panel / signatures). Removing only ``w:p``
|
||||
left the tables behind — including the signatures table, which then floated
|
||||
to the top against the header table. Mirror the decision exporter's
|
||||
``_clear_body`` (G2 symmetry): drop everything except sectPr, which carries
|
||||
page setup including bidi.
|
||||
"""
|
||||
body = doc.element.body
|
||||
for p in list(body.findall(qn("w:p"))):
|
||||
body.remove(p)
|
||||
# Leave sectPr alone — it carries page setup including bidi.
|
||||
sectPr_tag = qn("w:sectPr")
|
||||
for child in list(body):
|
||||
if child.tag != sectPr_tag:
|
||||
body.remove(child)
|
||||
|
||||
|
||||
def _add_paragraph(doc: DocumentT, text: str, style: str) -> Paragraph:
|
||||
@@ -386,9 +391,9 @@ def _group_precedents(
|
||||
return case_level, by_section
|
||||
|
||||
|
||||
def _next_version(export_dir: Path) -> int:
|
||||
"""Return the next version number for ניתוח-משפטי-v{N}.docx."""
|
||||
existing = sorted(export_dir.glob("ניתוח-משפטי-v*.docx"))
|
||||
def _next_version(export_dir: Path, prefix: str = "ניתוח-משפטי") -> int:
|
||||
"""Return the next version number for {prefix}-v{N}.docx in export_dir."""
|
||||
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
|
||||
next_ver = 1
|
||||
for p in existing:
|
||||
try:
|
||||
@@ -511,3 +516,77 @@ async def build_analysis_docx(case_number: str) -> Path:
|
||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
return out_path
|
||||
|
||||
|
||||
# ── Generic markdown → DOCX (party-claims summary, #202) ───────────
|
||||
|
||||
# ATX-heading markers → template heading style. The party-claims summary is a
|
||||
# plain markdown document (not the structured analysis-and-research.md), so it
|
||||
# is rendered line-by-line through the same template machinery.
|
||||
_ATX_HEADING_RE = re.compile(r"^(#{1,4})\s+(.+)$")
|
||||
_HR_RE = re.compile(r"^\s*[-*_]{3,}\s*$")
|
||||
|
||||
|
||||
def _render_markdown_body(doc: DocumentT, markdown: str) -> None:
|
||||
"""Render a markdown string into the doc using the template styles.
|
||||
|
||||
Handles ATX headings (# .. ####), horizontal rules (skipped), and delegates
|
||||
every other line to _emit_content_line (bullets, numbered, bold-labels, plain).
|
||||
"""
|
||||
for raw in markdown.splitlines():
|
||||
line = raw.rstrip()
|
||||
if not line.strip():
|
||||
continue
|
||||
if _HR_RE.match(line):
|
||||
continue
|
||||
m = _ATX_HEADING_RE.match(line.strip())
|
||||
if m:
|
||||
level = len(m.group(1))
|
||||
# # → Heading 1 (doc title), ## → Heading 1, ### / #### → Heading 2.
|
||||
style = "Heading 1" if level <= 2 else "Heading 2"
|
||||
_add_paragraph(doc, m.group(2).strip(), style)
|
||||
continue
|
||||
_emit_content_line(doc, line)
|
||||
|
||||
|
||||
async def build_party_claims_summary_docx(case_number: str) -> Path:
|
||||
"""Build a DOCX of the party-claims executive summary using the template
|
||||
styles, saved versioned under the case's exports folder.
|
||||
|
||||
Reads the saved party-claims-summary.md (produced by the summarize_party_claims
|
||||
MCP tool — generation is local-only). Raises FileNotFoundError if the summary
|
||||
or the template is missing.
|
||||
"""
|
||||
from legal_mcp.services import party_claims_summary
|
||||
|
||||
if not TEMPLATE_PATH.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Template not found at {TEMPLATE_PATH}. "
|
||||
"Run: python scripts/convert_decision_template.py"
|
||||
)
|
||||
|
||||
summary_path = party_claims_summary.summary_file_path(case_number)
|
||||
if not summary_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Party-claims summary not found for case {case_number}. "
|
||||
"Run summarize_party_claims first."
|
||||
)
|
||||
|
||||
markdown = summary_path.read_text(encoding="utf-8")
|
||||
|
||||
doc = Document(str(TEMPLATE_PATH))
|
||||
_clear_body(doc)
|
||||
_render_markdown_body(doc, markdown)
|
||||
|
||||
export_dir = config.find_case_dir(case_number) / "exports"
|
||||
export_dir.mkdir(parents=True, exist_ok=True)
|
||||
_PREFIX = "סיכום-מנהלים-טענות"
|
||||
out_path = export_dir / f"{_PREFIX}-v{_next_version(export_dir, _PREFIX)}.docx"
|
||||
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
|
||||
|
||||
@@ -31,6 +31,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Allowed enum values mirror the DB CHECK constraints.
|
||||
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
|
||||
# Sides that may comprise multiple distinct litigants with potentially opposing
|
||||
# positions — aggregated per source pleading (party_name) rather than collapsed
|
||||
# into one bucket (#224). Appellant/committee speak with a single voice.
|
||||
SPLIT_PARTIES = {"respondent", "permit_applicant"}
|
||||
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
|
||||
|
||||
# Hebrew labels for the prompt (Claude needs context in the same
|
||||
@@ -81,7 +85,7 @@ AGGREGATE_PROMPT_TEMPLATE = """אתה מנתח כתבי טענות בתחום ת
|
||||
"""
|
||||
|
||||
|
||||
def _build_prompt(party: str, propositions: list[dict]) -> str:
|
||||
def _build_prompt(party: str, propositions: list[dict], party_name: str = "") -> str:
|
||||
"""Compose the per-party aggregation prompt."""
|
||||
n = len(propositions)
|
||||
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
|
||||
@@ -89,6 +93,11 @@ def _build_prompt(party: str, propositions: list[dict]) -> str:
|
||||
target_max = max(target_min + 1, min(12, n // 2 + 1))
|
||||
|
||||
party_he = PARTY_LABELS_HE.get(party, party)
|
||||
# For a split side (e.g. a specific respondent brief), name the brief so
|
||||
# Claude aggregates only that litigant's position and does not conflate it
|
||||
# with a co-respondent's separate — possibly opposing — pleading (#224).
|
||||
if party_name:
|
||||
party_he = f"{party_he} — {party_name}"
|
||||
# Strip noise from propositions for the prompt — Claude only needs
|
||||
# the id and the text to do the grouping.
|
||||
compact = [
|
||||
@@ -139,12 +148,17 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
|
||||
|
||||
|
||||
async def _aggregate_party(
|
||||
party: str, propositions: list[dict],
|
||||
party: str, propositions: list[dict], party_name: str = "",
|
||||
) -> list[dict]:
|
||||
"""Ask Claude to group one party's propositions; return normalized rows."""
|
||||
"""Ask Claude to group one party's propositions; return normalized rows.
|
||||
|
||||
``party_name`` names the specific pleading when this is a split side
|
||||
(respondent / permit_applicant brief), so the prompt scopes to that
|
||||
litigant's position only (#224).
|
||||
"""
|
||||
if not propositions:
|
||||
return []
|
||||
prompt = _build_prompt(party, propositions)
|
||||
prompt = _build_prompt(party, propositions, party_name=party_name)
|
||||
|
||||
try:
|
||||
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
||||
@@ -207,7 +221,7 @@ async def aggregate_claims_to_arguments(
|
||||
|
||||
# Pull all claims for this case, grouped by party.
|
||||
rows = await conn.fetch(
|
||||
"""SELECT id, party_role, claim_text, claim_index, source_document
|
||||
"""SELECT id, party_role, claim_text, claim_index, source_document, party_name
|
||||
FROM claims
|
||||
WHERE case_id = $1
|
||||
ORDER BY party_role, claim_index""",
|
||||
@@ -221,22 +235,47 @@ async def aggregate_claims_to_arguments(
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
# Group propositions by party.
|
||||
by_party: dict[str, list[dict]] = {}
|
||||
# Group propositions by party — and, for the multi-party sides (respondent /
|
||||
# permit_applicant), further by their source pleading so opposing joint
|
||||
# briefs (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") aggregate into
|
||||
# SEPARATE argument sets instead of collapsing into one "respondent" bucket
|
||||
# (#224). Appellant/committee stay single-group (party_name=""). Splitting
|
||||
# the large respondent set per-brief also keeps each Claude call small enough
|
||||
# to succeed — a single 100+ proposition call previously returned non-JSON
|
||||
# and silently dropped the whole side.
|
||||
by_group: dict[tuple[str, str], list[dict]] = {}
|
||||
for r in rows:
|
||||
party = r["party_role"]
|
||||
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
|
||||
if party not in ALLOWED_PARTIES:
|
||||
party = "unknown"
|
||||
by_party.setdefault(party, []).append(dict(r))
|
||||
# Prefer the stored party_name (stamped by the extractor, #224); fall
|
||||
# back to source_document for legacy claims predating the stamping.
|
||||
# Single-voice sides (appellant/committee) stay ''.
|
||||
if party in SPLIT_PARTIES:
|
||||
party_name = (r["party_name"] or "").strip() or (r["source_document"] or "").strip()
|
||||
else:
|
||||
party_name = ""
|
||||
by_group.setdefault((party, party_name), []).append(dict(r))
|
||||
|
||||
# Valid claim_ids for this case == the ids of the claims we just fetched.
|
||||
# The LLM is asked to echo back supporting claim_ids, but it may hallucinate
|
||||
# a syntactically-valid-but-nonexistent UUID (malformed ones are already
|
||||
# dropped in ``_normalize_argument``). Validating against this known set at
|
||||
# source keeps a doomed INSERT — which would poison the surrounding asyncpg
|
||||
# transaction (FK violation -> "current transaction is aborted") — out of
|
||||
# the transaction entirely (G1: fix at source, not symptom).
|
||||
valid_claim_ids: set[UUID] = {r["id"] for r in rows}
|
||||
|
||||
party_counts: dict[str, int] = {}
|
||||
inserted = 0
|
||||
errors: list[str] = []
|
||||
|
||||
for party, props in by_party.items():
|
||||
for (party, party_name), props in by_group.items():
|
||||
# Display key for the per-side summary: keep opposing briefs distinct.
|
||||
group_key = f"{party}·{party_name}" if party_name else party
|
||||
try:
|
||||
arguments = await _aggregate_party(party, props)
|
||||
arguments = await _aggregate_party(party, props, party_name=party_name)
|
||||
except RuntimeError as e:
|
||||
# Most likely cause: Claude CLI not installed (running from
|
||||
# the container). Don't crash — record the gap and continue.
|
||||
@@ -250,11 +289,11 @@ async def aggregate_claims_to_arguments(
|
||||
),
|
||||
"total": 0,
|
||||
}
|
||||
errors.append(f"{party}: {msg}")
|
||||
errors.append(f"{group_key}: {msg}")
|
||||
continue
|
||||
|
||||
if not arguments:
|
||||
party_counts[party] = 0
|
||||
party_counts[group_key] = 0
|
||||
continue
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
@@ -262,12 +301,13 @@ async def aggregate_claims_to_arguments(
|
||||
for idx, arg in enumerate(arguments):
|
||||
arg_id = await conn.fetchval(
|
||||
"""INSERT INTO legal_arguments
|
||||
(case_id, party, argument_index, argument_title,
|
||||
argument_body, legal_topic, priority)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
(case_id, party, party_name, argument_index,
|
||||
argument_title, argument_body, legal_topic, priority)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id""",
|
||||
case_id,
|
||||
party,
|
||||
party_name,
|
||||
idx + 1,
|
||||
arg["title"],
|
||||
arg["body"],
|
||||
@@ -275,23 +315,36 @@ async def aggregate_claims_to_arguments(
|
||||
arg["priority"],
|
||||
)
|
||||
for cid in arg["claim_ids"]:
|
||||
try:
|
||||
await conn.execute(
|
||||
"""INSERT INTO legal_argument_propositions
|
||||
(argument_id, claim_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
arg_id, cid,
|
||||
if cid not in valid_claim_ids:
|
||||
# Hallucinated claim_id that doesn't belong to this
|
||||
# case. Skip it rather than letting the FK violation
|
||||
# abort the whole transaction.
|
||||
logger.warning(
|
||||
"argument_aggregator: skipped unknown claim_id %s for arg %s",
|
||||
cid, arg_id,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
# Per-row savepoint: even after the validation above,
|
||||
# wrap the INSERT so any unexpected constraint failure
|
||||
# rolls back to the savepoint instead of poisoning the
|
||||
# surrounding transaction (asyncpg nests transaction()
|
||||
# as SAVEPOINT when already inside one).
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"""INSERT INTO legal_argument_propositions
|
||||
(argument_id, claim_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
arg_id, cid,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Likely FK violation if the LLM hallucinated
|
||||
# a claim_id. Log and continue.
|
||||
logger.warning(
|
||||
"argument_aggregator: skipped bad claim_id %s for arg %s: %s",
|
||||
cid, arg_id, e,
|
||||
)
|
||||
inserted += 1
|
||||
party_counts[party] = len(arguments)
|
||||
party_counts[group_key] = len(arguments)
|
||||
|
||||
result: dict = {
|
||||
"status": "completed",
|
||||
@@ -316,9 +369,9 @@ async def get_legal_arguments(
|
||||
async with pool.acquire() as conn:
|
||||
if party and party in ALLOWED_PARTIES:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT id, case_id, party, argument_index, argument_title,
|
||||
argument_body, legal_topic, priority, cited_precedents,
|
||||
created_at, updated_at
|
||||
"""SELECT id, case_id, party, party_name, argument_index,
|
||||
argument_title, argument_body, legal_topic, priority,
|
||||
cited_precedents, created_at, updated_at
|
||||
FROM legal_arguments
|
||||
WHERE case_id = $1 AND party = $2
|
||||
ORDER BY priority, argument_index""",
|
||||
@@ -326,12 +379,12 @@ async def get_legal_arguments(
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT id, case_id, party, argument_index, argument_title,
|
||||
argument_body, legal_topic, priority, cited_precedents,
|
||||
created_at, updated_at
|
||||
"""SELECT id, case_id, party, party_name, argument_index,
|
||||
argument_title, argument_body, legal_topic, priority,
|
||||
cited_precedents, created_at, updated_at
|
||||
FROM legal_arguments
|
||||
WHERE case_id = $1
|
||||
ORDER BY party, priority, argument_index""",
|
||||
ORDER BY party, party_name, priority, argument_index""",
|
||||
case_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from legal_mcp.services import db
|
||||
@@ -38,7 +38,7 @@ async def log_action(
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
|
||||
uuid4(), action, case_id, document_id,
|
||||
json.dumps(details or {}, ensure_ascii=False, default=str),
|
||||
user, datetime.utcnow(),
|
||||
user, datetime.now(timezone.utc),
|
||||
)
|
||||
logger.info("AUDIT: %s | case=%s | user=%s | %s", action, case_id, user,
|
||||
json.dumps(details or {}, ensure_ascii=False)[:200])
|
||||
|
||||
@@ -35,29 +35,65 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Block configuration ───────────────────────────────────────────
|
||||
|
||||
# Output token limits per Anthropic docs:
|
||||
# Opus 4.7: up to 128K output tokens (new tokenizer — ~35% more tokens)
|
||||
# Sonnet 4.6: up to 64K output tokens
|
||||
# Streaming required when max_tokens > 21,333
|
||||
# Generation is structurally deterministic (#204 / WS5): every AI block is
|
||||
# pinned to ONE model (Opus 4.8) with a per-block reasoning `effort`. The
|
||||
# generation path is claude_session.query → `claude -p` (subscription, near-zero
|
||||
# cost; local-only — see reference_claude_generation_path / claude_session
|
||||
# docstring). Opus 4.7/4.8 REMOVED temperature/top_p/top_k (sending them → HTTP
|
||||
# 400); the only knob is `effort` (low/medium/high/xhigh/max; default high).
|
||||
#
|
||||
# DEPRECATED FIELDS — DO NOT REINTRODUCE: earlier revisions carried per-block
|
||||
# `temp` (0/0.1/0.4) and `model` ("sonnet"/"opus"/"script"). Both were DEAD
|
||||
# METADATA: write_block called claude_session.query(prompt, …) WITHOUT model/
|
||||
# temp/effort, so generation ran on the CLI's default model with no temperature
|
||||
# knob at all (the table only stored a number in decision_blocks.temperature and
|
||||
# fed MODEL_MAP→timeout). They are removed here so the table no longer misleads.
|
||||
# The conceptual origin (gen_type → thinking-budget) now lives in `effort`; see
|
||||
# docs/block-schema.md §3.
|
||||
#
|
||||
# `gen_type` is retained (documentation/UI metadata + audit `generation_type`).
|
||||
# `model` below is the DISPATCH key only: "script" = template-fill (no LLM),
|
||||
# "ai" = generated via the pinned Opus model. It is NOT a model alias anymore.
|
||||
#
|
||||
# Output token note (Anthropic): Opus 4.8 supports large outputs; streaming is
|
||||
# handled by the CLI. `max_tokens` is advisory context for callers, not sent.
|
||||
GENERATION_MODEL = "claude-opus-4-8" # single pinned model for every AI block (#204)
|
||||
# 1M-context build of the SAME pinned model (#216). The default build runs a
|
||||
# 200K-token context (~400K Hebrew chars at ~2 chars/token); large prompts —
|
||||
# notably block-yod, which carries the full case as source-context — overflow it.
|
||||
# Opus 4.8 offers a 1M-token window at standard pricing; `claude -p` exposes it
|
||||
# via the `[1m]` model id directly (no beta header — verified CLI 2.1.196). We
|
||||
# escalate to it only when the prompt is large, so small blocks stay on the
|
||||
# cheaper/faster 200K build. NOT a parallel model (G2) — same model, wider window.
|
||||
GENERATION_MODEL_1M = "claude-opus-4-8[1m]"
|
||||
# Escalate to the 1M build above this prompt size. Set below the 200K-token wall
|
||||
# (~400K chars) with headroom for output tokens (max_tokens up to 16K) + tokenizer
|
||||
# variance, so we switch before the standard build can overflow.
|
||||
_CTX_1M_THRESHOLD_CHARS = 350_000
|
||||
|
||||
BLOCK_CONFIG = {
|
||||
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "temp": 0, "model": "script"},
|
||||
"block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "temp": 0, "model": "script"},
|
||||
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "temp": 0, "model": "script"},
|
||||
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "temp": 0, "model": "script"},
|
||||
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "temp": 0.2, "model": "sonnet", "max_tokens": 4096},
|
||||
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 16384},
|
||||
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 16384},
|
||||
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 8192},
|
||||
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "temp": 0.2, "model": "opus", "max_tokens": 16384},
|
||||
"block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "temp": 0.4, "model": "opus", "max_tokens": 16384},
|
||||
"block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 8192},
|
||||
"block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "temp": 0, "model": "script"},
|
||||
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "model": "script"},
|
||||
"block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "model": "script"},
|
||||
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "model": "script"},
|
||||
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "model": "script"},
|
||||
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "model": "ai", "effort": "medium", "max_tokens": 4096},
|
||||
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "model": "ai", "effort": "low", "max_tokens": 16384},
|
||||
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "model": "ai", "effort": "high", "max_tokens": 16384},
|
||||
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 8192},
|
||||
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "model": "ai", "effort": "high", "max_tokens": 16384},
|
||||
"block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "model": "ai", "effort": "xhigh", "max_tokens": 16384},
|
||||
"block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "model": "ai", "effort": "high", "max_tokens": 8192},
|
||||
"block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "model": "script"},
|
||||
}
|
||||
|
||||
MODEL_MAP = {
|
||||
"sonnet": "claude-sonnet-4-20250514",
|
||||
"opus": "claude-opus-4-7",
|
||||
}
|
||||
# Default effort when a block lacks an explicit one (defensive; every AI block
|
||||
# above sets one). High is the safe default per the generation-path reference.
|
||||
DEFAULT_EFFORT = "high"
|
||||
|
||||
# Blocks that take longer (deep reasoning) get the LONG timeout. The interim set
|
||||
# [he, vav, tet, zayin, chet] and the discussion block all run on the same pinned
|
||||
# Opus model, so timeout is driven by effort, not by a model split.
|
||||
_LONG_EFFORTS = frozenset({"high", "xhigh", "max"})
|
||||
|
||||
|
||||
# ── Template blocks (א-ד, יב) ────────────────────────────────────
|
||||
@@ -120,11 +156,14 @@ TEMPLATE_WRITERS = {
|
||||
BLOCK_PROMPTS = {
|
||||
"block-he": """כתוב את בלוק הפתיחה (בלוק ה) של החלטת ועדת ערר.
|
||||
|
||||
## כללים:
|
||||
- פתח ב"לפנינו ערר..." או "עניינה של החלטה זו..."
|
||||
- הגדר "להלן" מרכזיים: הוועדה המקומית, התכנית/הבקשה, המגרש
|
||||
- 1-2 סעיפים בלבד
|
||||
- אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים
|
||||
## מבנה קבוע (חובה — אותו מבנה בכל תיק):
|
||||
- **המשפט הראשון פותח תמיד במילה "לפנינו"** — נוסח קבוע: "לפנינו ערר על החלטת
|
||||
[הוועדה המקומית] מיום [תאריך] בעניין [נושא הבקשה/התכנית]." (אל תשתמש ב"עניינה של
|
||||
החלטה זו" או בכל פתיח חלופי — הפתיח אחיד.)
|
||||
- סעיף 1: הצגת הערר במשפט הקבוע לעיל + הגדרת ה"להלן" המרכזיים בסדר קבוע:
|
||||
הוועדה המקומית → התכנית/הבקשה → המגרש/המקרקעין.
|
||||
- סעיף 2 (רק אם נדרש להשלמת "להלן" נוספים): הגדרות-נוספות בלבד.
|
||||
- **בדיוק 1-2 סעיפים.** אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים.
|
||||
- מספור: 1.
|
||||
|
||||
## פרטי התיק:
|
||||
@@ -153,16 +192,16 @@ BLOCK_PROMPTS = {
|
||||
## כללים קריטיים:
|
||||
- **סנתז טענות דומות** — אל תרשום כל טענה בנפרד. קבץ טענות דומות לנושא אחד. למשל: כל הטענות על הודעות → סעיף אחד, כל הטענות על רכוש משותף → סעיף אחד.
|
||||
- גוף שלישי: "העוררים טוענים כי...", "הוועדה המקומית ציינה כי..."
|
||||
- **מבנה קבוע עם 3 חלקים:**
|
||||
1. "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא
|
||||
2. "עמדת הוועדה המקומית" — 5-8 סעיפים
|
||||
3. "עמדת מבקשי ההיתר" (אם יש) — 5-10 סעיפים
|
||||
- **מבנה: חלק (סעיף עם כותרת-משנה) לכל צד שמופיע בטענות שחולצו למטה** — לפי הכותרות שם (### ...). אל תמציא צדדים שאינם מופיעים ואל תשמיט צד שמופיע.
|
||||
- "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא.
|
||||
- **צד-משיב/מתנגד: כתוב חלק נפרד לכל כתב-תשובה** שמופיע (למשל "עמדת משיבות 2-3" ו-"עמדת משיבים 4-6" בנפרד) — 5-10 סעיפים לכל אחד. **אל תמזג משיבים שונים לסעיף אחד**, ואם עמדותיהם מנוגדות — שקף זאת.
|
||||
- "עמדת הוועדה המקומית" / "עמדת מבקשי ההיתר" (אם מופיעים) — 5-8 סעיפים.
|
||||
- כותרת: "תמצית טענות הצדדים"
|
||||
- נאמנות למקור — לא להמציא טענות, אבל כן לאחד ולסכם טענות חוזרות
|
||||
- אין ניתוח, אין מסקנות, אין הערכה ("טענה חלשה/חזקה")
|
||||
- רק מכתבי טענות מקוריים (לא השלמות טיעון)
|
||||
- מספור רציף
|
||||
- **יעד אורך: 800-1500 מילים**
|
||||
- **יעד אורך: 800-1500 מילים** (יותר כשיש כמה כתבי-תשובה נפרדים — כל צד מקבל את מלוא ההתייחסות)
|
||||
|
||||
## טענות שחולצו (קבץ טענות דומות לנושאים):
|
||||
{claims_context}
|
||||
@@ -245,6 +284,7 @@ BLOCK_PROMPTS = {
|
||||
- **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל"
|
||||
- **מספור רציף** — המשך מספור מהבלוק הקודם
|
||||
- מותרות כותרות-משנה כשיש נושאים נפרדים לחלוטין
|
||||
- **צדדים מרובים** — כשיש כמה משיבים/מתנגדים או כתבי-תשובה נפרדים (ראה הכותרות שתחת "טענות" לעיל, למשל "משיבות 2-3" מול "משיבים 4-6"), התייחס לעמדת כל אחד לגופה ואל תמזג אותם ל"טענות המשיבים" גורפות. אם משיבים שונים נוקטים **עמדות מנוגדות זו לזו** — ציין זאת במפורש והכרע ביניהן בנימוק; אם עמדותיהם משלימות — ניתן לאגד תוך שמירת הייחוס.
|
||||
|
||||
## כיוון מאושר (חובה):
|
||||
{direction_context}
|
||||
@@ -297,11 +337,38 @@ STRUCTURE_GUIDANCE = {
|
||||
"partial_acceptance": "קבלה חלקית — מיפוי מתחים: מה מתקבל ולמה, מה נדחה ולמה, איזון.",
|
||||
}
|
||||
|
||||
_RATIO_SECTION_HE = {"background": "רקע", "claims": "טענות", "discussion": "דיון", "summary": "סיכום"}
|
||||
|
||||
|
||||
async def _structure_guidance_with_corpus(outcome: str, practice_area: str) -> str:
|
||||
"""Outcome structure guidance + the CORPUS-MEASURED section ratios (T10). The
|
||||
measured ratios come from style_distance.measure_corpus_ratios over style_corpus,
|
||||
which grows with every enrolled final — so the writer's structural target reflects
|
||||
Dafna's ACTUAL current distribution, not a frozen constant (this is the structural
|
||||
half of the voice-profile that learns from each decision). Deterministic (no LLM);
|
||||
best-effort. Shared by both writer entry points (G2)."""
|
||||
sg = STRUCTURE_GUIDANCE.get(outcome, "")
|
||||
if practice_area == "betterment_levy":
|
||||
sg = (sg + " | היטל השבחה: "
|
||||
+ " ".join(PRACTICE_AREA_OVERRIDES["betterment_levy"]["discussion_rules"])).strip()
|
||||
try:
|
||||
from legal_mcp.services.style_distance import measure_corpus_ratios
|
||||
entry = (await measure_corpus_ratios() or {}).get(outcome) or {}
|
||||
secs = entry.get("sections") or {}
|
||||
if secs:
|
||||
parts = [f"{_RATIO_SECTION_HE.get(s, s)} ~{round(p)}%" for s, p in secs.items()]
|
||||
sg = (sg + f" | מבנה-יעד מדוד מהקורפוס ({entry.get('n', 0)} החלטות {outcome}): "
|
||||
+ " · ".join(parts)).strip()
|
||||
except Exception as e:
|
||||
logger.warning("corpus ratios not loaded for structure guidance: %s", e)
|
||||
return sg
|
||||
|
||||
|
||||
async def write_block(
|
||||
case_id: UUID,
|
||||
block_id: str,
|
||||
instructions: str = "",
|
||||
effort_override: str | None = None,
|
||||
) -> dict:
|
||||
"""כתיבת בלוק יחיד בהחלטה.
|
||||
|
||||
@@ -309,6 +376,11 @@ async def write_block(
|
||||
case_id: מזהה התיק
|
||||
block_id: מזהה הבלוק (block-alef, block-he, block-yod, ...)
|
||||
instructions: הנחיות נוספות
|
||||
effort_override: optional per-call reasoning effort (low/medium/high/
|
||||
xhigh/max). When set, overrides BLOCK_CONFIG[block_id].effort for
|
||||
THIS call only — used by the #208 model/effort calibration harness
|
||||
to A/B efforts without mutating the pinned defaults. Production
|
||||
callers leave it None and get the deterministic per-block effort.
|
||||
|
||||
Returns:
|
||||
dict עם content, word_count, block_id, generation_type
|
||||
@@ -352,12 +424,8 @@ async def write_block(
|
||||
post_hearing_context = await _build_post_hearing_context(case_id)
|
||||
|
||||
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
|
||||
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
||||
if case.get("practice_area") == "betterment_levy":
|
||||
structure_guidance = (
|
||||
structure_guidance + " | היטל השבחה: "
|
||||
+ " ".join(PRACTICE_AREA_OVERRIDES["betterment_levy"]["discussion_rules"])
|
||||
).strip()
|
||||
structure_guidance = await _structure_guidance_with_corpus(
|
||||
outcome, case.get("practice_area", ""))
|
||||
|
||||
# Content checklist — tells block-yod WHAT topics to cover
|
||||
content_checklist = ""
|
||||
@@ -406,33 +474,52 @@ async def write_block(
|
||||
if not dir_doc.get("approved"):
|
||||
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
||||
|
||||
# Guard against context overflow before calling claude -p.
|
||||
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
|
||||
# In practice the CLI has crashed on prompts above ~400K chars, so use
|
||||
# that as a conservative ceiling (well below the token limit).
|
||||
_MAX_PROMPT_CHARS = 400_000
|
||||
# Pick the model build by prompt size (#216). Above the 200K-token wall we
|
||||
# escalate to the 1M-context build (`[1m]`) instead of failing the block —
|
||||
# block-yod legitimately carries the whole case as source-context. The 400K
|
||||
# ceiling was an artifact of the old 200K-only build, NOT a model limit.
|
||||
gen_model = GENERATION_MODEL_1M if len(prompt) > _CTX_1M_THRESHOLD_CHARS else GENERATION_MODEL
|
||||
|
||||
# Final guard: even the 1M build is finite (~2M Hebrew chars of input). Cap at
|
||||
# 1.5M chars (~750K tokens) to leave room for output + a safety margin under 1M.
|
||||
_MAX_PROMPT_CHARS = 1_500_000
|
||||
if len(prompt) > _MAX_PROMPT_CHARS:
|
||||
raise RuntimeError(
|
||||
f"Prompt too large for {block_id}: {len(prompt):,} chars "
|
||||
f"(limit {_MAX_PROMPT_CHARS:,}). "
|
||||
f"(limit {_MAX_PROMPT_CHARS:,}, even on the 1M-context build). "
|
||||
f"source_context: {len(source_context):,} chars. "
|
||||
f"Reduce documents or call extract_appraiser_facts first."
|
||||
f"Reduce source-context (summaries / appraiser_facts / focused RAG)."
|
||||
)
|
||||
|
||||
# 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, tools="") # prose gen — no tool_use → no error_max_turns
|
||||
# Call Claude via Claude Code session (no API). #204: pin the model + per-block
|
||||
# reasoning effort so generation is structurally deterministic — these were
|
||||
# previously NOT forwarded (the source of inconsistency). model/effort flow
|
||||
# through claude_session.query → `claude -p --model … --effort …`.
|
||||
effort = effort_override or block_cfg.get("effort", DEFAULT_EFFORT)
|
||||
timeout = claude_session.LONG_TIMEOUT if effort in _LONG_EFFORTS else claude_session.DEFAULT_TIMEOUT
|
||||
content = await claude_session.query(
|
||||
prompt,
|
||||
timeout=timeout,
|
||||
model=gen_model,
|
||||
effort=effort,
|
||||
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
|
||||
result = _build_result(block_id, content, block_cfg)
|
||||
result = _build_result(block_id, content, block_cfg, model_used=gen_model)
|
||||
# Record the EFFECTIVE effort (override wins) so the harness can attribute
|
||||
# the measured distance to the effort that actually produced the text.
|
||||
if result.get("effort") is not None:
|
||||
result["effort"] = effort
|
||||
result["sources"] = sources
|
||||
return result
|
||||
|
||||
|
||||
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
||||
def _build_result(block_id: str, content: str, block_cfg: dict,
|
||||
model_used: str = GENERATION_MODEL) -> dict:
|
||||
word_count = len(content.split())
|
||||
is_ai = block_cfg["model"] == "ai"
|
||||
return {
|
||||
"block_id": block_id,
|
||||
"block_index": block_cfg["index"],
|
||||
@@ -440,8 +527,15 @@ def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
||||
"content": content,
|
||||
"word_count": word_count,
|
||||
"generation_type": block_cfg["gen_type"],
|
||||
"model_used": block_cfg["model"],
|
||||
"temperature": block_cfg["temp"],
|
||||
# AI blocks record the model build actually used (200K or [1m], #216);
|
||||
# template blocks record "script".
|
||||
"model_used": model_used if is_ai else block_cfg["model"],
|
||||
# The real generation knob (#204). None for template/script blocks.
|
||||
"effort": block_cfg.get("effort", DEFAULT_EFFORT) if is_ai else None,
|
||||
# DEPRECATED: temperature is not a real knob on Opus 4.7/4.8 (sending it
|
||||
# → HTTP 400). Kept only to satisfy decision_blocks.temperature
|
||||
# NUMERIC(3,2); always 0. Read `effort` instead.
|
||||
"temperature": 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -522,24 +616,63 @@ async def _build_claims_context(case_id: UUID) -> str:
|
||||
return "(לא חולצו טענות)"
|
||||
|
||||
# Filter out claims from block-zayin (decision summary) — use only
|
||||
# claims extracted from original pleadings (appeal, response, etc.)
|
||||
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"]
|
||||
# claims extracted from original pleadings (appeal, response, etc.).
|
||||
# Also drop claim_type='protocol' (oral arguments raised at the hearing):
|
||||
# block ז carries ORIGINAL written pleadings only (INV-WR4); hearing-raised
|
||||
# arguments belong to block ח (proceedings) and to analyze_protocol's
|
||||
# comparative case-knowledge, not to the parties'-claims summary.
|
||||
source_claims = [
|
||||
c for c in claims
|
||||
if c.get("source_document", "") != "block-zayin"
|
||||
and c.get("claim_type", "claim") != "protocol"
|
||||
]
|
||||
if not source_claims:
|
||||
# Fallback to all claims if no source claims exist
|
||||
source_claims = claims
|
||||
# Fallback to all non-block-zayin claims if no source claims exist.
|
||||
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
|
||||
|
||||
# Group by (party_role, brief) so a multi-litigant side (respondent /
|
||||
# permit_applicant) whose claims come from distinct pleadings reads as
|
||||
# SEPARATE positions — e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6" — instead
|
||||
# of one merged "טענות המשיבים" (#224). The brief label is the source
|
||||
# pleading, and which sides split is SPLIT_PARTIES — the SAME rule the
|
||||
# argument aggregator uses, so block ז and the arguments panel agree (G2).
|
||||
from legal_mcp.services.argument_aggregator import SPLIT_PARTIES
|
||||
|
||||
lines = []
|
||||
current_role = ""
|
||||
role_heb = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
|
||||
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
|
||||
claim_num = 0
|
||||
role_order = ["appellant", "committee", "respondent", "permit_applicant"]
|
||||
|
||||
groups: dict[tuple[str, str], list[dict]] = {}
|
||||
for c in source_claims:
|
||||
if c["party_role"] != current_role:
|
||||
current_role = c["party_role"]
|
||||
lines.append(f"\n### {role_heb.get(current_role, current_role)}")
|
||||
claim_num += 1
|
||||
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
|
||||
lines.append(f"\n**סה\"כ {claim_num} טענות. ענה על כל טענה מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**")
|
||||
role = c.get("party_role", "") or ""
|
||||
# Prefer the stored party_name (stamped by the extractor, #224); fall
|
||||
# back to source_document for legacy claims predating the stamping.
|
||||
if role in SPLIT_PARTIES:
|
||||
brief = (c.get("party_name") or "").strip() or (c.get("source_document") or "").strip()
|
||||
else:
|
||||
brief = ""
|
||||
groups.setdefault((role, brief), []).append(c)
|
||||
|
||||
def _sort_key(k: tuple[str, str]) -> tuple[int, str]:
|
||||
role, brief = k
|
||||
idx = role_order.index(role) if role in role_order else len(role_order)
|
||||
return (idx, brief)
|
||||
|
||||
lines: list[str] = []
|
||||
claim_num = 0
|
||||
for role, brief in sorted(groups.keys(), key=_sort_key):
|
||||
header = role_heb.get(role, role or "טענות נוספות")
|
||||
if brief:
|
||||
header = f"{header} — {brief}"
|
||||
lines.append(f"\n### {header}")
|
||||
for c in groups[(role, brief)]:
|
||||
claim_num += 1
|
||||
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
|
||||
lines.append(
|
||||
f"\n**סה\"כ {claim_num} טענות. שמור על ההפרדה בין הצדדים לעיל — "
|
||||
f"לכל צד/כתב-תשובה עמדה נפרדת (אל תמזג משיבים שונים). ענה על כל טענה "
|
||||
f"מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -983,6 +1116,22 @@ async def _build_style_context(practice_area: str = "") -> str:
|
||||
("anti_patterns", "אנטי-דפוסים (להימנע)"),
|
||||
):
|
||||
ov = await db.get_methodology_overrides(cat)
|
||||
if cat == "anti_patterns":
|
||||
# Anti-patterns are STRUCTURAL INVARIANTS of Dafna's style (no
|
||||
# markdown headers, no bullet lists, no mid-paragraph mini-lists —
|
||||
# she writes continuous legal narrative). They must reach the writer
|
||||
# ALWAYS, from the SAME canonical list style_distance measures against
|
||||
# (lessons.ANTI_PATTERNS) — otherwise the loop detects them but never
|
||||
# corrects them, and drafts keep emitting them (the gap that left
|
||||
# 8137 with 28 hits). Chair additions layer on top; they never
|
||||
# remove the canonical ones.
|
||||
from legal_mcp.services.lessons import ANTI_PATTERNS as _ANTI
|
||||
learned.append(f"\n**{label} — כתוב נרטיב משפטי רציף; הימנע מ:**")
|
||||
for ap in _ANTI:
|
||||
learned.append(f"- {ap['note']}")
|
||||
for k, v in (ov or {}).items():
|
||||
learned.append(f"- (יו\"ר) {k}: {json.dumps(v, ensure_ascii=False)}")
|
||||
continue
|
||||
if ov:
|
||||
learned.append(f"\n**{label} — ערכי היו\"ר (גוברים על ברירת-המחדל):**")
|
||||
for k, v in ov.items():
|
||||
@@ -990,7 +1139,8 @@ async def _build_style_context(practice_area: str = "") -> str:
|
||||
except Exception as e:
|
||||
logger.warning("methodology overrides not loaded: %s", e)
|
||||
try:
|
||||
lessons = await db.get_recent_decision_lessons(limit=15, practice_area=practice_area)
|
||||
lessons = await db.get_recent_decision_lessons(
|
||||
limit=config.WRITER_LESSONS_LIMIT, practice_area=practice_area)
|
||||
if lessons:
|
||||
learned.append("\n**לקחים מהחלטות קודמות (decision_lessons):**")
|
||||
for ls in lessons:
|
||||
@@ -1078,12 +1228,8 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
|
||||
post_hearing_context = await _build_post_hearing_context(case_id)
|
||||
|
||||
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
|
||||
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
||||
if case.get("practice_area") == "betterment_levy":
|
||||
structure_guidance = (
|
||||
structure_guidance + " | היטל השבחה: "
|
||||
+ " ".join(PRACTICE_AREA_OVERRIDES["betterment_levy"]["discussion_rules"])
|
||||
).strip()
|
||||
structure_guidance = await _structure_guidance_with_corpus(
|
||||
outcome, case.get("practice_area", ""))
|
||||
|
||||
# Content checklist + methodology for block-yod
|
||||
content_checklist = ""
|
||||
|
||||
147
mcp-server/src/legal_mcp/services/case_citation_verification.py
Normal file
147
mcp-server/src/legal_mcp/services/case_citation_verification.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Citation-verification view (X11 Phase 2 / #154) — the chair's "אימות פסיקה" tab.
|
||||
|
||||
Assembles, per legal ARGUMENT of a case, the supporting precedents the chair should
|
||||
verify before the writer cites them:
|
||||
|
||||
• in-corpus suggestions — per-issue semantic retrieval over the authoritative
|
||||
precedent library (``search_library``), each carrying the cumulative authority
|
||||
signal (``cited_by``: followed/distinguished — db.citation_authority, X11).
|
||||
• attached/verified state — any ``case_precedents`` row already attached to the
|
||||
argument (verified flag + chair_note), merged onto the matching suggestion.
|
||||
• radar — UNLINKED digests relevant to the same issue (rulings we don't hold yet),
|
||||
from ``case_digest_radar`` grouped by matched issue.
|
||||
|
||||
Pure read/assembly — never writes, never cites (INV-DIG1/INV-AH). The chair verifies
|
||||
through ``db.set_case_precedent_verified`` / attach; the writer consumes only verified
|
||||
rows. Reuses the one corpus search + the one authority query + the one radar (G2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import (
|
||||
argument_aggregator,
|
||||
db,
|
||||
digest_library,
|
||||
precedent_library,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SUGGEST_PER_ISSUE = 4
|
||||
_SUGGEST_FLOOR = 0.45
|
||||
|
||||
|
||||
async def build_view(case_number: str) -> dict:
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return {"status": "case_not_found", "case_number": case_number, "arguments": []}
|
||||
case_id = case["id"]
|
||||
if isinstance(case_id, str):
|
||||
case_id = UUID(case_id)
|
||||
ctx = " ".join(x for x in [case.get("title") or "", case.get("appeal_subtype") or ""] if x).strip()
|
||||
|
||||
args = await argument_aggregator.get_legal_arguments(case_id)
|
||||
|
||||
# Attached precedents already on the case → grouped by argument_id, keyed by
|
||||
# the resolved corpus ruling so we can merge verify-state onto a suggestion.
|
||||
attached = await db.list_case_precedents(case_id)
|
||||
attached_by_arg: dict[str, dict[str, dict]] = {}
|
||||
for p in attached:
|
||||
aid = str(p.get("argument_id") or "")
|
||||
clid = str(p.get("case_law_id") or "")
|
||||
if aid and clid:
|
||||
attached_by_arg.setdefault(aid, {})[clid] = p
|
||||
|
||||
# Radar (unlinked digests) once, grouped by the issue label it matched.
|
||||
radar_by_issue: dict[str, list[dict]] = {}
|
||||
try:
|
||||
radar = await digest_library.case_digest_radar(case_number, limit=12, min_score=0.42)
|
||||
for lead in radar.get("leads", []):
|
||||
for label in (lead.get("matched_issues") or ["—"]):
|
||||
radar_by_issue.setdefault(label, []).append(lead)
|
||||
except Exception as e: # noqa: BLE001 — radar is best-effort
|
||||
logger.warning("citation_verification radar failed for %s: %s", case_number, e)
|
||||
|
||||
async def _fetch(a: dict) -> tuple[list[dict], dict]:
|
||||
"""Per-argument corpus search + batched authority — run concurrently.
|
||||
|
||||
Each call is one Voyage embed + one vector search (+ one batched authority
|
||||
query); independent across arguments, so they fan out rather than waterfall.
|
||||
"""
|
||||
title = (a.get("argument_title") or "").strip()
|
||||
topic = (a.get("legal_topic") or "").strip()
|
||||
query = f"{ctx} {title}. {topic}".strip()
|
||||
hits: list[dict] = []
|
||||
try:
|
||||
hits = await precedent_library.search_library(
|
||||
query=query, limit=_SUGGEST_PER_ISSUE, include_halachot=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("citation_verification search failed (%s): %s", title[:30], e)
|
||||
# Resolve the authority breakdown for the hit set in one batched query.
|
||||
clids = [UUID(str(h["case_law_id"])) for h in hits
|
||||
if h.get("case_law_id") and float(h.get("score", 0) or 0) >= _SUGGEST_FLOOR]
|
||||
authority = await db.citation_authority(clids) if clids else {}
|
||||
return hits, authority
|
||||
|
||||
# Fan out the expensive per-argument retrieval concurrently — was a sequential
|
||||
# waterfall (N args × Voyage embed + vector search each). gather preserves order.
|
||||
fetched = await asyncio.gather(*(_fetch(a) for a in args)) if args else []
|
||||
|
||||
out_args: list[dict] = []
|
||||
n_verified = 0
|
||||
for a, (hits, authority) in zip(args, fetched):
|
||||
aid = str(a["id"])
|
||||
title = (a.get("argument_title") or "").strip()
|
||||
topic = (a.get("legal_topic") or "").strip()
|
||||
|
||||
seen: set[str] = set()
|
||||
supporting: list[dict] = []
|
||||
for h in hits:
|
||||
clid = str(h.get("case_law_id") or "")
|
||||
if not clid or clid in seen:
|
||||
continue
|
||||
if float(h.get("score", 0) or 0) < _SUGGEST_FLOOR:
|
||||
continue
|
||||
seen.add(clid)
|
||||
att = attached_by_arg.get(aid, {}).get(clid)
|
||||
if att and att.get("verified"):
|
||||
n_verified += 1
|
||||
supporting.append({
|
||||
"case_law_id": clid,
|
||||
"case_number": h.get("case_number") or "",
|
||||
"case_name": h.get("case_name") or "",
|
||||
"quote": h.get("supporting_quote") or h.get("rule_statement") or "",
|
||||
"score": round(float(h.get("score", 0) or 0), 3),
|
||||
"cited_by": authority.get(clid, {"total": 0, "positive": 0,
|
||||
"negative": 0, "unclassified": 0,
|
||||
"by_treatment": {}}),
|
||||
"attached_id": str(att["id"]) if att else None,
|
||||
"verified": bool(att.get("verified")) if att else False,
|
||||
"chair_note": (att.get("chair_note") or "") if att else "",
|
||||
})
|
||||
|
||||
out_args.append({
|
||||
"argument_id": aid,
|
||||
"title": title,
|
||||
"legal_topic": topic,
|
||||
"priority": a.get("priority") or "",
|
||||
"party": a.get("party") or "",
|
||||
"supporting": supporting,
|
||||
"radar": radar_by_issue.get(title, []),
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"case_number": case_number,
|
||||
"arguments": out_args,
|
||||
"summary": {
|
||||
"arguments_total": len(out_args),
|
||||
"arguments_with_support": sum(1 for x in out_args if x["supporting"]),
|
||||
"verified": n_verified,
|
||||
"radar_leads": sum(len(x["radar"]) for x in out_args),
|
||||
},
|
||||
}
|
||||
@@ -144,9 +144,12 @@ def _split_into_sections(text: str) -> list[tuple[str, str]]:
|
||||
markers: list[tuple[int, str]] = []
|
||||
|
||||
for pattern, section_type in SECTION_PATTERNS:
|
||||
# ^ + MULTILINE: line start only. Optional leading spaces/tabs and an
|
||||
# ^ + MULTILINE: line start only. Optional leading spaces/tabs, an
|
||||
# optional Markdown ATX header prefix (``## ``/``### ``), and an
|
||||
# optional ordinal prefix ("5.", "5)", "ג.") before the keyword.
|
||||
anchored = rf"^[ \t]*(?:\d+[.)]\s*|[א-ת][.)]\s*)?(?:{pattern})"
|
||||
# The Markdown prefix handles Mistral OCR output where section
|
||||
# titles are rendered as ``## נימוקי הערר`` etc.
|
||||
anchored = rf"^[ \t]*(?:#{1,3}\s+)?(?:\d+[.)]\s*|[א-ת][.)]\s*)?(?:{pattern})"
|
||||
for match in re.finditer(anchored, text, re.MULTILINE):
|
||||
markers.append((match.start(), section_type))
|
||||
|
||||
|
||||
@@ -236,7 +236,18 @@ async def extract_and_store(case_law_id: UUID) -> dict:
|
||||
if not row:
|
||||
return {"extracted": 0, "linked": 0, "new": 0, "skipped": 0, "error": "not_found"}
|
||||
|
||||
text = row["full_text"] or ""
|
||||
# A citation counts as the DECIDING body's reliance ONLY when it appears in
|
||||
# the discussion/ruling sections — never where a party cites a ruling in its
|
||||
# own arguments (block ז). Reuse the halacha extractor's section selection
|
||||
# (G2: a single definition of "reasoning sections" — legal_analysis/ruling/
|
||||
# conclusion + the discussion-anchor, excluding facts/claims/intro) so a
|
||||
# ruling cited only in appellant_claims/parties_claims is never attributed to
|
||||
# the chair. Fall back to full_text only for un-chunked rows.
|
||||
from legal_mcp.services import halacha_extractor as _hx
|
||||
disc_chunks, _used_fallback = await _hx._select_extractable_chunks(case_law_id)
|
||||
text = "\n".join((c.get("content") or "") for c in disc_chunks).strip()
|
||||
if not text:
|
||||
text = row["full_text"] or ""
|
||||
own_norm = _normalize_case_number(row["case_number"] or "")
|
||||
|
||||
extracted = 0
|
||||
@@ -388,12 +399,16 @@ async def list_citations_to_case_law(case_law_id: UUID) -> list[dict]:
|
||||
pic.cited_case_number,
|
||||
pic.match_context,
|
||||
pic.match_pattern,
|
||||
pic.treatment,
|
||||
pic.confidence::float AS confidence,
|
||||
pic.created_at,
|
||||
cl.case_number AS source_case_number,
|
||||
cl.case_name AS source_case_name,
|
||||
cl.chair_name AS source_chair_name,
|
||||
cl.district AS source_district
|
||||
cl.district AS source_district,
|
||||
cl.precedent_level AS source_precedent_level,
|
||||
cl.court AS source_court,
|
||||
cl.date AS source_date
|
||||
FROM precedent_internal_citations pic
|
||||
JOIN case_law cl ON cl.id = pic.source_case_law_id
|
||||
WHERE pic.cited_case_law_id = $1
|
||||
@@ -432,3 +447,42 @@ async def get_cited_case_law_ids(source_case_law_ids: list[UUID]) -> dict[str, l
|
||||
for r in rows:
|
||||
out.setdefault(r["source_id"], []).append(r["cited_id"])
|
||||
return out
|
||||
|
||||
|
||||
async def relink_orphan_citations(case_law_id: "UUID | None" = None) -> int:
|
||||
"""Back-fill ``cited_case_law_id`` on orphan citation edges that now resolve.
|
||||
|
||||
Citations are resolved to a corpus row only at extraction time (see
|
||||
``extract_and_store``). When a cited ruling is uploaded LATER, its existing
|
||||
orphan edges (``cited_case_law_id IS NULL``) are never re-resolved, so the
|
||||
citation graph under-counts real connectivity. Call on every precedent
|
||||
upload (``case_law_id`` set → link only edges that resolve to that row) or as
|
||||
a one-off sweep (``case_law_id=None`` → re-resolve every orphan). Reuses the
|
||||
canonical resolver ``_resolve_case_law_id`` (no parallel matching logic, G2);
|
||||
idempotent — a second run links nothing new.
|
||||
"""
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
orphans = await conn.fetch(
|
||||
"SELECT DISTINCT cited_case_number FROM precedent_internal_citations "
|
||||
"WHERE cited_case_law_id IS NULL AND coalesce(cited_case_number, '') <> ''"
|
||||
)
|
||||
linked = 0
|
||||
for r in orphans:
|
||||
cited = await _resolve_case_law_id(_normalize_case_number(r["cited_case_number"]))
|
||||
if cited is None:
|
||||
continue
|
||||
if case_law_id is not None and cited != case_law_id:
|
||||
continue
|
||||
async with pool.acquire() as conn:
|
||||
res = await conn.execute(
|
||||
"UPDATE precedent_internal_citations "
|
||||
"SET cited_case_law_id = $1, confidence = GREATEST(confidence, 0.90) "
|
||||
"WHERE cited_case_law_id IS NULL AND cited_case_number = $2",
|
||||
cited, r["cited_case_number"],
|
||||
)
|
||||
try:
|
||||
linked += int(res.split()[-1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return linked
|
||||
|
||||
@@ -227,8 +227,15 @@ def _infer_claim_type(doc_type: str, source_name: str) -> str:
|
||||
- 'claim' = from appeal documents (כתב ערר)
|
||||
- 'response' = from original response documents (כתב תשובה)
|
||||
- 'reply' = from supplementary responses (תגובה, השלמת טיעון)
|
||||
- 'protocol' = oral arguments raised at the hearing (פרוטוקול דיון)
|
||||
"""
|
||||
name_lower = source_name.lower() if source_name else ""
|
||||
# A hearing protocol carries oral arguments — tagged distinctly so the
|
||||
# comparative protocol analysis (analyze_protocol) and block-chet
|
||||
# (proceedings) can tell them apart from the original written pleadings
|
||||
# (INV-WR4: block ז stays original-pleadings-only).
|
||||
if doc_type == "protocol" or "פרוטוקול" in name_lower:
|
||||
return "protocol"
|
||||
if doc_type == "appeal" or "כתב ערר" in name_lower:
|
||||
return "claim"
|
||||
if "כתב תשובה" in name_lower:
|
||||
@@ -353,14 +360,29 @@ async def extract_and_store_claims(
|
||||
claims = await extract_claims_with_ai(text, doc_type, party_hint)
|
||||
|
||||
if not claims:
|
||||
# Stamp the document as analysed even with zero claims, so it is not
|
||||
# perpetually flagged "not-analysed" (#201 — the not-analysed flag is
|
||||
# about whether extraction *ran*, not whether it found anything).
|
||||
await db.mark_document_claims_extracted(document_id, status="no_claims")
|
||||
return {"status": "no_claims", "total": 0, "source": source_name}
|
||||
|
||||
# Determine claim_type from document type and title
|
||||
claim_type = _infer_claim_type(doc_type, source_name)
|
||||
# Stamp party_name at the source (#224). For a multi-litigant side
|
||||
# (respondent / permit_applicant) the brief label IS the source pleading, so
|
||||
# opposing briefs (משיבות 2-3 vs משיבים 4-6) stay distinct downstream without
|
||||
# the aggregator/block-writer having to re-derive it. SPLIT_PARTIES is the
|
||||
# single rule shared with the aggregator (G2); single-voice sides stay ''.
|
||||
from legal_mcp.services.argument_aggregator import SPLIT_PARTIES
|
||||
for c in claims:
|
||||
c["claim_type"] = claim_type
|
||||
c["party_name"] = source_name if c.get("party_role") in SPLIT_PARTIES else ""
|
||||
|
||||
stored = await db.store_claims(case_id, claims, source_document=source_name)
|
||||
# Mark this document analysed (WS2 / #201). store_claims already replaced
|
||||
# this document's prior claims by source_document — so re-running is a clean
|
||||
# per-document refresh that does NOT touch other documents' claims (the merge).
|
||||
await db.mark_document_claims_extracted(document_id, status="completed")
|
||||
|
||||
# Summarize by role
|
||||
role_counts: dict[str, int] = {}
|
||||
|
||||
@@ -146,8 +146,15 @@ async def query(
|
||||
"""
|
||||
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
||||
|
||||
if len(full_prompt) > 150_000:
|
||||
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
|
||||
# ~350K chars ≈ the 200K-token wall of the default Opus build (~2 Hebrew
|
||||
# chars/token). Above it callers should pass the 1M-context model id
|
||||
# (`claude-opus-4-8[1m]`) — block_writer escalates automatically (#216).
|
||||
if len(full_prompt) > 350_000 and "[1m]" not in (model or ""):
|
||||
logger.warning(
|
||||
"Large prompt: %d chars on a non-[1m] model (%s) — may overflow the "
|
||||
"200K-token context. Pass claude-opus-4-8[1m] for the 1M window.",
|
||||
len(full_prompt), model or "CLI-default",
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"claude", "-p",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -381,6 +381,119 @@ async def link_digest(digest_id: UUID | str, case_law_id: UUID | str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def _radar_enrich(h: dict, score: float, matched_issues: list[str]) -> dict:
|
||||
"""Shape one radar hit into a chair lead: gap status + suggested action +
|
||||
which case ISSUE(s) it answers. The action points at the underlying RULING,
|
||||
never the digest (INV-DIG1)."""
|
||||
cit = (h.get("underlying_citation") or "").strip()
|
||||
gap = await db.find_missing_precedent_by_citation(cit) if cit else None
|
||||
in_corpus = await db.find_case_law_by_citation_fuzzy(cit) if cit else None
|
||||
if in_corpus:
|
||||
action = "available_link" # ruling actually IS in the corpus → just link the digest
|
||||
elif gap and (gap.get("status") in ("uploaded", "closed")):
|
||||
action = "fetched" # already obtained
|
||||
elif gap:
|
||||
action = "gap_open" # flagged as missing — can request a fetch
|
||||
else:
|
||||
action = "new_lead" # not even flagged yet — the highest-value alert
|
||||
return {
|
||||
"digest_id": str(h["id"]),
|
||||
"yomon_number": h.get("yomon_number"),
|
||||
"headline": h.get("headline_holding") or h.get("summary") or "",
|
||||
"underlying_citation": cit,
|
||||
"underlying_court": h.get("underlying_court") or "",
|
||||
"score": round(score, 3),
|
||||
"matched_issues": matched_issues,
|
||||
"missing_precedent_id": str(gap["id"]) if gap else None,
|
||||
"missing_precedent_status": gap.get("status") if gap else None,
|
||||
"action": action,
|
||||
}
|
||||
|
||||
|
||||
async def case_digest_radar(
|
||||
case_number: str,
|
||||
limit: int = 5,
|
||||
min_score: float = 0.45,
|
||||
) -> dict:
|
||||
"""Case-contextual digest radar (X12) — the chair-facing "שים לב" lead.
|
||||
|
||||
Surfaces UNLINKED digests (``linked_case_law_id IS NULL`` — rulings we don't hold
|
||||
yet) whose topic is semantically close to THIS case, so a relevant ruling we only
|
||||
know about via a digest doesn't fall through the cracks while the case is decided.
|
||||
|
||||
Query calibration: prefer the analyst's DISTILLED legal arguments (one crisp issue
|
||||
per row — ``argument_title`` + ``legal_topic``) over the dozens of raw claims. Each
|
||||
issue is searched separately and the leads are MERGED, so every lead is attributed
|
||||
to the case issue(s) it answers (``matched_issues``) — far higher precision than
|
||||
one blended query of noisy claims. Falls back to raw claims pre-aggregation
|
||||
(``source`` reports which path ran). Each lead carries the underlying ruling's gap
|
||||
status + a suggested action.
|
||||
|
||||
INV-DIG1: this is RADAR — the digest is never cited; the lead points at the
|
||||
underlying *ruling* (fetch / upload / link), never the digest itself. Read-only.
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return {"status": "case_not_found", "case_number": case_number, "leads": [], "count": 0}
|
||||
case_id = case["id"]
|
||||
if isinstance(case_id, str):
|
||||
case_id = UUID(case_id)
|
||||
ctx = " ".join(x for x in [case.get("title") or "", case.get("appeal_subtype") or ""] if x).strip()
|
||||
|
||||
# Preferred source: the analyst's distilled CREAC issues (one per legal_argument).
|
||||
issues: list[tuple[str, str]] = [] # (label, embed_text)
|
||||
try:
|
||||
from legal_mcp.services import argument_aggregator
|
||||
for a in await argument_aggregator.get_legal_arguments(case_id):
|
||||
label = (a.get("argument_title") or a.get("legal_topic") or "").strip()
|
||||
topic = (a.get("legal_topic") or "").strip()
|
||||
body = f"{label}. {topic}".strip(". ").strip()
|
||||
if body:
|
||||
issues.append((label, f"{ctx} {body}".strip()))
|
||||
except Exception as e: # noqa: BLE001 — arguments are optional; fall back to claims
|
||||
logger.warning("case_digest_radar: get_legal_arguments failed for %s: %s", case_number, e)
|
||||
|
||||
merged: dict[str, dict] = {} # digest_id -> {"hit", "best", "issues": set}
|
||||
if issues:
|
||||
source = "legal_arguments"
|
||||
issues = issues[:25] # already distilled — bound the per-issue search fan-out
|
||||
vecs = await embeddings.embed_texts([t for _, t in issues], input_type="query")
|
||||
for (label, _), vec in zip(issues, vecs):
|
||||
for h in await db.search_digests_semantic(vec, limit=6, linked_only=False):
|
||||
s = float(h.get("score", 0) or 0)
|
||||
if s < min_score:
|
||||
continue
|
||||
m = merged.setdefault(str(h["id"]), {"hit": h, "best": s, "issues": set()})
|
||||
m["best"] = max(m["best"], s)
|
||||
if label:
|
||||
m["issues"].add(label)
|
||||
else:
|
||||
# Fallback: blended query from raw claims (pre-aggregation), one search.
|
||||
source = "claims"
|
||||
parts = [ctx]
|
||||
try:
|
||||
claims = await db.get_claims(case_id)
|
||||
parts += [(c.get("claim_text") or "") for c in claims[:20]]
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("case_digest_radar: get_claims failed for %s: %s", case_number, e)
|
||||
query = " ".join(p for p in parts if p).strip()
|
||||
if not query:
|
||||
return {"status": "no_topic", "case_number": case_number,
|
||||
"source": source, "leads": [], "count": 0}
|
||||
vec = (await embeddings.embed_texts([query], input_type="query"))[0]
|
||||
for h in await db.search_digests_semantic(vec, limit=max(limit * 4, 20), linked_only=False):
|
||||
s = float(h.get("score", 0) or 0)
|
||||
if s < min_score:
|
||||
continue
|
||||
merged.setdefault(str(h["id"]), {"hit": h, "best": s, "issues": set()})
|
||||
|
||||
ordered = sorted(merged.values(), key=lambda m: -m["best"])[:max(1, limit)]
|
||||
leads = [await _radar_enrich(m["hit"], m["best"], sorted(m["issues"])) for m in ordered]
|
||||
return {"status": "ok", "case_number": case_number, "source": source,
|
||||
"issues_used": [lbl for lbl, _ in issues] if source == "legal_arguments" else None,
|
||||
"leads": leads, "count": len(leads)}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -43,10 +43,16 @@ HEBREW_FONT = "David"
|
||||
|
||||
|
||||
def _mark_run_rtl(run) -> None:
|
||||
"""Force David font on all four slots, then add <w:rtl/>."""
|
||||
"""Force David font on all four slots, then add <w:rtl/>.
|
||||
|
||||
INV-EX4 note: this reinforces the template's OWN font (David, == the
|
||||
template's cs slot) to work around Word's RTL ascii-slot fallback to
|
||||
Times New Roman — it is NOT an ad-hoc font/size override. Marked
|
||||
INV-EX4-ok for the single-source guard for that reason.
|
||||
"""
|
||||
rPr = run._r.get_or_add_rPr()
|
||||
if rPr.find(qn("w:rFonts")) is None:
|
||||
fonts = OxmlElement("w:rFonts")
|
||||
fonts = OxmlElement("w:rFonts") # INV-EX4-ok: reinforces template font David
|
||||
fonts.set(qn("w:ascii"), HEBREW_FONT)
|
||||
fonts.set(qn("w:hAnsi"), HEBREW_FONT)
|
||||
fonts.set(qn("w:cs"), HEBREW_FONT)
|
||||
@@ -192,14 +198,29 @@ def _apply_list_numbering(paragraph, num_id: int) -> None:
|
||||
|
||||
|
||||
def _clear_body(doc) -> None:
|
||||
"""Remove all paragraphs in the document body while keeping sectPr.
|
||||
"""Remove ALL body content (paragraphs, tables, stray bookmarks) while
|
||||
keeping only sectPr.
|
||||
|
||||
The template ships with sample paragraphs we don't want. Section
|
||||
properties (page size, margins, bidi) stay intact.
|
||||
The template ships with sample paragraphs *and three sample tables*
|
||||
(institutional header, panel, and a **signatures** table at the end —
|
||||
"ניתנה פה אחד" + יו"ר/מזכירה). Removing only ``w:p`` left those tables
|
||||
behind: the leftover header/panel tables injected block-א–ד data that was
|
||||
never extracted from the protocol (violates the empty-unless-extracted
|
||||
rule), and the leftover signatures table collapsed up against the header
|
||||
table at the **top** of the document (the chair-reported bug: חתימות צמודות
|
||||
לבלוק-ד במקום בסוף). The decision is a derived artifact rebuilt purely from
|
||||
``decision_blocks`` (INV-EX1) — signatures come from block-yod-bet, rendered
|
||||
last in block order — so the template's sample tables are stale scaffolding
|
||||
and must go. Section properties (page size, margins, bidi) stay intact.
|
||||
"""
|
||||
body = doc.element.body
|
||||
for p in list(body.findall(qn("w:p"))):
|
||||
body.remove(p)
|
||||
sectPr_tag = qn("w:sectPr")
|
||||
for child in list(body):
|
||||
# Keep section properties (page setup / bidi). Drop everything else:
|
||||
# sample paragraphs (w:p), sample tables (w:tbl), and any stray
|
||||
# bookmark markers the template left dangling between them.
|
||||
if child.tag != sectPr_tag:
|
||||
body.remove(child)
|
||||
|
||||
|
||||
# ── Bookmark helpers ──────────────────────────────────────────────
|
||||
@@ -378,8 +399,15 @@ _INTERIM_BLOCK_ORDER = [
|
||||
]
|
||||
|
||||
|
||||
def _draft_filename_prefix(mode: str) -> str:
|
||||
return "טיוטת-ביניים" if mode == "interim" else "טיוטה"
|
||||
def _draft_naming(mode: str) -> tuple[str, str]:
|
||||
"""(filename prefix, version separator) per export mode.
|
||||
|
||||
interim → ``טיוטה-טענות_הצדדים_{N}.docx`` (chair-requested naming);
|
||||
final → ``טיוטה-v{N}.docx``.
|
||||
"""
|
||||
if mode == "interim":
|
||||
return "טיוטה-טענות_הצדדים", "_"
|
||||
return "טיוטה", "-v"
|
||||
|
||||
|
||||
async def export_decision(
|
||||
@@ -464,16 +492,19 @@ async def export_decision(
|
||||
if not output_path:
|
||||
export_dir = config.find_case_dir(case["case_number"]) / "exports"
|
||||
export_dir.mkdir(parents=True, exist_ok=True)
|
||||
prefix = _draft_filename_prefix(mode)
|
||||
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
|
||||
prefix, sep = _draft_naming(mode)
|
||||
existing = sorted(export_dir.glob(f"{prefix}{sep}*.docx"))
|
||||
next_ver = 1
|
||||
for p in existing:
|
||||
try:
|
||||
ver = int(p.stem.split("-v")[1])
|
||||
# Version is the trailing integer after the separator. Using
|
||||
# rsplit keeps this correct even when the prefix itself contains
|
||||
# the separator char (e.g. "טיוטה-טענות_הצדדים" with sep="_").
|
||||
ver = int(p.stem.rsplit(sep, 1)[1])
|
||||
next_ver = max(next_ver, ver + 1)
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
output_path = str(export_dir / f"{prefix}-v{next_ver}.docx")
|
||||
output_path = str(export_dir / f"{prefix}{sep}{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-
|
||||
|
||||
@@ -199,27 +199,86 @@ def _find_enclosing_paragraph(element: etree._Element) -> etree._Element | None:
|
||||
return None
|
||||
|
||||
|
||||
# ── Template style resolution (INV-EX4) ────────────────────────────
|
||||
# Every inserted paragraph carries a <w:pStyle> pointing at one of the
|
||||
# template's named styles — font (David), size, and indent come from
|
||||
# styles.xml, NOT from hand-set rFonts/sz here. Single template = single
|
||||
# style source (INV-EX4 / G2). docx_exporter.py applies the same styles by
|
||||
# name via python-docx; here (raw lxml) we resolve name → styleId.
|
||||
|
||||
# StyleType → candidate style names in the template (case-insensitive).
|
||||
# Mirrors the names docx_exporter uses (paragraph.style = "Heading 2"/"Normal"/"Quote").
|
||||
_STYLE_NAME_CANDIDATES: dict[str, tuple[str, ...]] = {
|
||||
"body": ("Normal", "Default Paragraph Font", "Body Text"),
|
||||
"bold": ("Normal", "Body Text"),
|
||||
"heading": ("Heading 2", "heading 2"),
|
||||
"quote": ("Quote", "quote", "Intense Quote"),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_style_ids(members: dict[str, bytes]) -> dict[str, str]:
|
||||
"""Map each StyleType → the template's w:styleId, read from styles.xml.
|
||||
|
||||
Returns {} when styles.xml is absent — callers then fall back to
|
||||
minimal inline emphasis (so a missing style never crashes rendering).
|
||||
"""
|
||||
raw = members.get("word/styles.xml")
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
styles_tree = etree.fromstring(raw)
|
||||
except etree.XMLSyntaxError: # pragma: no cover - corrupt template
|
||||
return {}
|
||||
# Build a lowercase lookup over both the style's display name and its id.
|
||||
by_key: dict[str, str] = {}
|
||||
for style in styles_tree.iterfind(".//w:style", NSMAP):
|
||||
sid = style.get(_w("styleId"))
|
||||
if not sid:
|
||||
continue
|
||||
by_key.setdefault(sid.lower(), sid)
|
||||
name_el = style.find(_w("name"))
|
||||
if name_el is not None:
|
||||
nm = name_el.get(_w("val"))
|
||||
if nm:
|
||||
by_key.setdefault(nm.lower(), sid)
|
||||
resolved: dict[str, str] = {}
|
||||
for style_type, candidates in _STYLE_NAME_CANDIDATES.items():
|
||||
for cand in candidates:
|
||||
sid = by_key.get(cand.lower())
|
||||
if sid is not None:
|
||||
resolved[style_type] = sid
|
||||
break
|
||||
return resolved
|
||||
|
||||
|
||||
def _emphasis_fallback(style: StyleType, *, has_pstyle: bool) -> tuple[bool, bool]:
|
||||
"""(bold, italic) for a run. When the paragraph carries a resolved
|
||||
template pStyle, emphasis comes from the style (return no inline override),
|
||||
except 'bold' which is inline emphasis with no dedicated style. When no
|
||||
pStyle resolved, fall back to minimal inline emphasis so heading/quote
|
||||
stay visually distinct."""
|
||||
if style == "bold":
|
||||
return True, False
|
||||
if has_pstyle:
|
||||
return False, False
|
||||
return (style == "heading"), (style == "quote")
|
||||
|
||||
|
||||
# ── Paragraph builders ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_run(text: str, *, bold: bool = False, italic: bool = False,
|
||||
font: str = "David", size_half_pt: int | None = None) -> etree._Element:
|
||||
"""Build a w:r (run) element with RTL/David defaults and given text."""
|
||||
def _build_run(text: str, *, bold: bool = False, italic: bool = False) -> etree._Element:
|
||||
"""Build a w:r (run): RTL marker + optional bold/italic + text.
|
||||
|
||||
Font (David) and size are NOT set here — they derive from the paragraph's
|
||||
template style (INV-EX4). The <w:rtl/> marker is still required so Word
|
||||
picks the style's complex-script (cs=David) slot rather than the ascii
|
||||
(Times New Roman) slot for Hebrew (same reason as _mark_run_rtl in
|
||||
analysis_docx_exporter.py).
|
||||
"""
|
||||
r = etree.Element(_w("r"))
|
||||
rPr = etree.SubElement(r, _w("rPr"))
|
||||
|
||||
rFonts = etree.SubElement(rPr, _w("rFonts"))
|
||||
rFonts.set(_w("ascii"), font)
|
||||
rFonts.set(_w("hAnsi"), font)
|
||||
rFonts.set(_w("cs"), font)
|
||||
rFonts.set(_w("hint"), "cs")
|
||||
|
||||
if size_half_pt is not None:
|
||||
sz = etree.SubElement(rPr, _w("sz"))
|
||||
sz.set(_w("val"), str(size_half_pt))
|
||||
szCs = etree.SubElement(rPr, _w("szCs"))
|
||||
szCs.set(_w("val"), str(size_half_pt))
|
||||
|
||||
if bold:
|
||||
etree.SubElement(rPr, _w("b"))
|
||||
etree.SubElement(rPr, _w("bCs"))
|
||||
@@ -235,28 +294,36 @@ def _build_run(text: str, *, bold: bool = False, italic: bool = False,
|
||||
return r
|
||||
|
||||
|
||||
def _build_paragraph(text: str, *, style: StyleType = "body") -> etree._Element:
|
||||
"""Build a w:p (paragraph) with RTL + David + given text."""
|
||||
def _build_paragraph(text: str, *, style: StyleType = "body",
|
||||
style_id: str | None = None) -> etree._Element:
|
||||
"""Build a w:p (paragraph) carrying the template's named style.
|
||||
|
||||
The paragraph references `style_id` (resolved from the template's
|
||||
styles.xml) via <w:pStyle> — font/size/indent come from there, not from
|
||||
hand-set rFonts/sz (INV-EX4). The only explicit paragraph-level override
|
||||
is the canonical body alignment jc=both, matching docx_exporter's
|
||||
_set_paragraph_jc (which forces both over style-center headings).
|
||||
"""
|
||||
p = etree.Element(_w("p"))
|
||||
pPr = etree.SubElement(p, _w("pPr"))
|
||||
|
||||
if style_id:
|
||||
pStyle = etree.SubElement(pPr, _w("pStyle"))
|
||||
pStyle.set(_w("val"), style_id)
|
||||
|
||||
bidi = etree.SubElement(pPr, _w("bidi"))
|
||||
bidi.set(_w("val"), "1")
|
||||
|
||||
# Right alignment for body/RTL
|
||||
# Canonical body alignment (two-sided justification) — overrides any
|
||||
# style-center, exactly as docx_exporter._set_paragraph_jc does.
|
||||
jc = etree.SubElement(pPr, _w("jc"))
|
||||
jc.set(_w("val"), "right")
|
||||
jc.set(_w("val"), "both")
|
||||
|
||||
rPr_p = etree.SubElement(pPr, _w("rPr"))
|
||||
etree.SubElement(rPr_p, _w("rtl"))
|
||||
|
||||
bold = style in ("heading", "bold")
|
||||
italic = style == "quote"
|
||||
size = None
|
||||
if style == "heading":
|
||||
size = 28 # 14pt
|
||||
elif style == "quote":
|
||||
size = 22 # 11pt
|
||||
run = _build_run(text, bold=bold, italic=italic, size_half_pt=size)
|
||||
bold, italic = _emphasis_fallback(style, has_pstyle=bool(style_id))
|
||||
run = _build_run(text, bold=bold, italic=italic)
|
||||
p.append(run)
|
||||
return p
|
||||
|
||||
@@ -275,7 +342,7 @@ def _wrap_in_ins(elements: list[etree._Element], *, ins_id: int,
|
||||
|
||||
def _make_tracked_paragraph_insert(
|
||||
text: str, *, style: StyleType, ins_id: int, author: str, date_iso: str,
|
||||
mark_id: int | None = None,
|
||||
mark_id: int | None = None, style_id: str | None = None,
|
||||
) -> etree._Element:
|
||||
"""Build a whole tracked-inserted paragraph.
|
||||
|
||||
@@ -286,7 +353,7 @@ def _make_tracked_paragraph_insert(
|
||||
"""
|
||||
if mark_id is None:
|
||||
mark_id = ins_id
|
||||
p = _build_paragraph(text, style=style)
|
||||
p = _build_paragraph(text, style=style, style_id=style_id)
|
||||
pPr = p.find(_w("pPr"))
|
||||
assert pPr is not None
|
||||
rPr = pPr.find(_w("rPr"))
|
||||
@@ -339,6 +406,7 @@ def _apply_insert(
|
||||
ins_id: int,
|
||||
author: str,
|
||||
date_iso: str,
|
||||
style_ids: dict[str, str] | None = None,
|
||||
) -> RevisionResult:
|
||||
"""Apply insert_after / insert_before relative to a bookmark."""
|
||||
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
||||
@@ -362,6 +430,7 @@ def _apply_insert(
|
||||
revision.content, style=revision.style,
|
||||
ins_id=ins_id, mark_id=ins_id + 1,
|
||||
author=author, date_iso=date_iso,
|
||||
style_id=(style_ids or {}).get(revision.style),
|
||||
)
|
||||
|
||||
parent = enclosing_p.getparent()
|
||||
@@ -407,6 +476,7 @@ def _apply_replace(
|
||||
del_id: int,
|
||||
author: str,
|
||||
date_iso: str,
|
||||
style_ids: dict[str, str] | None = None,
|
||||
) -> RevisionResult:
|
||||
"""Replace = delete the existing paragraph + insert new one after it."""
|
||||
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
||||
@@ -428,6 +498,7 @@ def _apply_replace(
|
||||
revision.content, style=revision.style,
|
||||
ins_id=ins_id, mark_id=ins_id + 1,
|
||||
author=author, date_iso=date_iso,
|
||||
style_id=(style_ids or {}).get(revision.style),
|
||||
)
|
||||
idx = list(parent).index(enclosing_p)
|
||||
parent.insert(idx + 1, new_p)
|
||||
@@ -473,6 +544,10 @@ def apply_tracked_revisions(
|
||||
members, doc_tree, settings_tree = _load_docx_xml(source_path)
|
||||
_ensure_track_revisions(settings_tree)
|
||||
|
||||
# Resolve template style ids once (INV-EX4) — inserted paragraphs inherit
|
||||
# font/size/indent from these named styles instead of hand-set rFonts/sz.
|
||||
style_ids = _resolve_style_ids(members)
|
||||
|
||||
next_id = _next_revision_id(doc_tree)
|
||||
|
||||
batch = RevisionBatchResult()
|
||||
@@ -480,7 +555,8 @@ def apply_tracked_revisions(
|
||||
try:
|
||||
if rev.type in ("insert_after", "insert_before"):
|
||||
result = _apply_insert(doc_tree, rev, ins_id=next_id,
|
||||
author=author, date_iso=date_iso)
|
||||
author=author, date_iso=date_iso,
|
||||
style_ids=style_ids)
|
||||
# insert consumes 2 IDs: run-wrapper + paragraph-mark
|
||||
next_id += 2
|
||||
elif rev.type == "delete":
|
||||
@@ -490,7 +566,8 @@ def apply_tracked_revisions(
|
||||
elif rev.type == "replace":
|
||||
result = _apply_replace(doc_tree, rev,
|
||||
ins_id=next_id, del_id=next_id + 2,
|
||||
author=author, date_iso=date_iso)
|
||||
author=author, date_iso=date_iso,
|
||||
style_ids=style_ids)
|
||||
# replace consumes 3 IDs: ins-run, ins-mark, del
|
||||
next_id += 3
|
||||
else:
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
"""Text extraction from PDF, DOCX, DOC, and RTF files.
|
||||
|
||||
Primary PDF extraction: PyMuPDF direct text (for born-digital PDFs).
|
||||
Fallback: Google Cloud Vision OCR (for scanned documents).
|
||||
Fallback: Mistral OCR (for scanned documents or broken OCR layers).
|
||||
|
||||
Routing logic (document-level, not per-page):
|
||||
1. PyMuPDF extracts text from every page.
|
||||
2. Pages are quality-checked via _text_quality_ok().
|
||||
3. If ALL pages pass → use PyMuPDF output (free, ~50ms, no API call).
|
||||
4. If ANY page fails → call Mistral OCR once for the entire PDF.
|
||||
Mistral returns per-page Markdown; page_offsets are computed from it.
|
||||
|
||||
DOC files: converted to DOCX via LibreOffice before extraction.
|
||||
Post-processing: Hebrew abbreviation quote fixer.
|
||||
Post-processing: Hebrew abbreviation quote fixer (PyMuPDF path only;
|
||||
Mistral handles gershayim natively).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import fitz # PyMuPDF
|
||||
import httpx
|
||||
from PIL import Image
|
||||
from docx import Document as DocxDocument
|
||||
from striprtf.striprtf import rtf_to_text
|
||||
@@ -25,36 +35,65 @@ 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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Google Cloud Vision client (imported lazily — saves ~550ms at MCP startup) ──
|
||||
# ── Mistral OCR ───────────────────────────────────────────────────
|
||||
|
||||
_vision_client: "vision.ImageAnnotatorClient | None" = None
|
||||
_MISTRAL_OCR_URL = "https://api.mistral.ai/v1/ocr"
|
||||
_MISTRAL_OCR_MODEL = "mistral-ocr-latest"
|
||||
|
||||
|
||||
def _get_vision_client() -> "vision.ImageAnnotatorClient":
|
||||
global _vision_client
|
||||
if _vision_client is None:
|
||||
from google.cloud import vision
|
||||
_vision_client = vision.ImageAnnotatorClient(
|
||||
client_options={"api_key": config.GOOGLE_CLOUD_VISION_API_KEY}
|
||||
async def _call_mistral_ocr(path: Path) -> list[str]:
|
||||
"""Call Mistral OCR API on a PDF. Returns per-page Markdown text list.
|
||||
|
||||
The Mistral response contains ``pages[i].markdown`` for each page.
|
||||
If the response has fewer pages than the PDF, trailing pages are
|
||||
padded with empty strings by the caller.
|
||||
"""
|
||||
if not config.MISTRAL_API_KEY:
|
||||
raise RuntimeError(
|
||||
"MISTRAL_API_KEY not configured — cannot OCR scanned PDF. "
|
||||
"Set the env var in Coolify."
|
||||
)
|
||||
return _vision_client
|
||||
|
||||
pdf_b64 = base64.b64encode(path.read_bytes()).decode()
|
||||
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
resp = await client.post(
|
||||
_MISTRAL_OCR_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {config.MISTRAL_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": _MISTRAL_OCR_MODEL,
|
||||
"document": {
|
||||
"type": "document_url",
|
||||
"document_url": f"data:application/pdf;base64,{pdf_b64}",
|
||||
},
|
||||
"include_image_base64": False,
|
||||
},
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"Mistral OCR returned {resp.status_code}: {resp.text[:400]}"
|
||||
)
|
||||
|
||||
pages = resp.json().get("pages", [])
|
||||
return [p.get("markdown", "") for p in pages]
|
||||
|
||||
|
||||
# ── Hebrew text quality detection ────────────────────────────────
|
||||
|
||||
_HEBREW_RE = re.compile(r'[\u0590-\u05FF]')
|
||||
_HEBREW_RE = re.compile(r'[-]')
|
||||
_WORD_RE = re.compile(r'\S+')
|
||||
|
||||
|
||||
def _text_quality_ok(text: str) -> bool:
|
||||
"""Check if extracted text is real content vs broken OCR layer.
|
||||
"""Check if PyMuPDF-extracted text is genuine Hebrew legal content.
|
||||
|
||||
Returns True if text appears to be genuine Hebrew legal content.
|
||||
Returns True if text appears to be real content.
|
||||
Broken OCR layers from scanned PDFs often have:
|
||||
- Very short words / single-character fragments
|
||||
- Each word on its own line (high words-per-line ratio)
|
||||
@@ -64,26 +103,19 @@ def _text_quality_ok(text: str) -> bool:
|
||||
if len(words) < 10:
|
||||
return False
|
||||
|
||||
# Average word length — real Hebrew words avg 4-6 chars.
|
||||
avg_len = sum(len(w) for w in words) / len(words)
|
||||
if avg_len < 2.5:
|
||||
return False
|
||||
|
||||
# Percentage of single-character "words"
|
||||
single_char_pct = sum(1 for w in words if len(w) == 1) / len(words)
|
||||
if single_char_pct > 0.4:
|
||||
return False
|
||||
|
||||
# Words per line — broken OCR puts each word on its own line.
|
||||
# Real text has 5-15 words per line; broken OCR has ~1-2.
|
||||
lines = [l for l in text.split("\n") if l.strip()]
|
||||
if lines:
|
||||
words_per_line = len(words) / len(lines)
|
||||
if words_per_line < 3.0:
|
||||
return False
|
||||
if lines and len(words) / len(lines) < 3.0:
|
||||
return False
|
||||
|
||||
# Hebrew character ratio among letter characters
|
||||
letters = re.findall(r'[a-zA-Z\u0590-\u05FF]', text)
|
||||
letters = re.findall(r'[a-zA-Z-]', text)
|
||||
if letters:
|
||||
hebrew_pct = sum(1 for c in letters if _HEBREW_RE.match(c)) / len(letters)
|
||||
if hebrew_pct < 0.5:
|
||||
@@ -92,7 +124,7 @@ def _text_quality_ok(text: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# ── Hebrew abbreviation quote fixer ──────────────────────────────
|
||||
# ── Hebrew abbreviation quote fixer (PyMuPDF path only) ──────────
|
||||
|
||||
_HEBREW_ABBREV_FIXES: dict[str, str] = {
|
||||
'עוהייד': 'עוה"ד',
|
||||
@@ -111,50 +143,133 @@ _HEBREW_ABBREV_FIXES: dict[str, str] = {
|
||||
'יחייד': 'יח"ד',
|
||||
'בייכ': 'ב"כ',
|
||||
# Patterns where double-yod (יי) substitutes for gershayim (״) in born-digital PDFs
|
||||
'בליימ': 'בל"מ', # בקשה להארכת מועד — appears in RTL legal docs
|
||||
'תמייא': 'תמ"א', # תכנית מתאר ארצית
|
||||
'בליימ': 'בל"מ',
|
||||
'תמייא': 'תמ"א',
|
||||
}
|
||||
|
||||
_ABBREV_PATTERN = re.compile(
|
||||
'|'.join(re.escape(k) for k in sorted(_HEBREW_ABBREV_FIXES, key=len, reverse=True))
|
||||
)
|
||||
|
||||
# Matches Hebrew law year abbreviations where gershayim was encoded as double-yod.
|
||||
# e.g. תשכייה → תשכ"ה, תשנייב → תשנ"ב
|
||||
_HEBREW_YEAR_RE = re.compile(r'(תש[א-ת]+)יי([א-ת])')
|
||||
|
||||
|
||||
def _fix_hebrew_quotes(text: str) -> str:
|
||||
"""Fix known Hebrew abbreviation quote replacements.
|
||||
|
||||
Applied to both Google Vision OCR output and direct PyMuPDF extraction —
|
||||
some born-digital PDFs encode gershayim (״) as double-yod (יי), producing
|
||||
the same corruption patterns as OCR.
|
||||
"""
|
||||
"""Fix gershayim encoded as double-yod in born-digital PDFs."""
|
||||
text = _ABBREV_PATTERN.sub(lambda m: _HEBREW_ABBREV_FIXES[m.group()], text)
|
||||
text = _HEBREW_YEAR_RE.sub(r'\1"\2', text)
|
||||
return text
|
||||
|
||||
|
||||
# ── Extraction ───────────────────────────────────────────────────
|
||||
|
||||
# ── Page joining ──────────────────────────────────────────────────
|
||||
|
||||
# Separator used when joining per-page text. Constant so chunker /
|
||||
# retrofit can reproduce the join when computing page offsets.
|
||||
PAGE_SEPARATOR = "\n\n"
|
||||
|
||||
|
||||
def _join_pages(pages_text: list[str]) -> tuple[str, list[int]]:
|
||||
"""Join per-page text with PAGE_SEPARATOR while recording start offsets."""
|
||||
offsets: list[int] = []
|
||||
parts: list[str] = []
|
||||
cursor = 0
|
||||
for i, pg in enumerate(pages_text):
|
||||
offsets.append(cursor)
|
||||
parts.append(pg)
|
||||
cursor += len(pg)
|
||||
if i < len(pages_text) - 1:
|
||||
parts.append(PAGE_SEPARATOR)
|
||||
cursor += len(PAGE_SEPARATOR)
|
||||
return "".join(parts), offsets
|
||||
|
||||
|
||||
# ── PDF extraction ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _extract_pdf(path: Path) -> tuple[str, int, list[int]]:
|
||||
"""Extract text from PDF using document-level routing.
|
||||
|
||||
Stage 1 — PyMuPDF pre-screen (free, ~50ms, no API call):
|
||||
Run on every page. Collect per-page text; flag pages where
|
||||
PyMuPDF returns < 50 chars or _text_quality_ok() fails
|
||||
(scanned, blank, or broken embedded OCR layer).
|
||||
|
||||
Stage 2 — Mistral OCR (triggered when any page fails Stage 1):
|
||||
Send the entire PDF to Mistral once. Use its per-page Markdown
|
||||
for ALL pages — consistent source, no mixed plain/Markdown formats.
|
||||
Mistral handles gershayim natively; no quote-fix applied.
|
||||
|
||||
Page offsets are always computed so the chunker can attribute each
|
||||
chunk to its source page number (multimodal hybrid retrieval).
|
||||
"""
|
||||
doc = fitz.open(str(path))
|
||||
page_count = len(doc)
|
||||
|
||||
# Stage 1: PyMuPDF pre-screen
|
||||
pymupdf_pages: list[str] = []
|
||||
failed: list[int] = []
|
||||
|
||||
for i in range(page_count):
|
||||
text = doc[i].get_text().strip()
|
||||
if len(text) > 50 and _text_quality_ok(text):
|
||||
pymupdf_pages.append(_fix_hebrew_quotes(text))
|
||||
else:
|
||||
pymupdf_pages.append("")
|
||||
failed.append(i)
|
||||
|
||||
doc.close()
|
||||
|
||||
if not failed:
|
||||
logger.debug(
|
||||
"PDF %s: all %d pages digital — PyMuPDF only", path.name, page_count
|
||||
)
|
||||
joined, offsets = _join_pages(pymupdf_pages)
|
||||
return joined, page_count, offsets
|
||||
|
||||
# Stage 2: Mistral OCR for entire document
|
||||
logger.info(
|
||||
"PDF %s: %d/%d pages failed quality check → Mistral OCR",
|
||||
path.name, len(failed), page_count,
|
||||
)
|
||||
mistral_pages = await _call_mistral_ocr(path)
|
||||
|
||||
# Pad if Mistral returns fewer pages than PyMuPDF counted
|
||||
while len(mistral_pages) < page_count:
|
||||
mistral_pages.append("")
|
||||
|
||||
joined, offsets = _join_pages(mistral_pages[:page_count])
|
||||
return joined, page_count, offsets
|
||||
|
||||
|
||||
def page_at_offset(offset: int, page_offsets: list[int]) -> int:
|
||||
"""Return the 1-based page number containing a given char offset.
|
||||
|
||||
page_offsets[i] is the start of page (i+1) in the joined text.
|
||||
"""
|
||||
if not page_offsets:
|
||||
return 1
|
||||
page = 1
|
||||
for i, start in enumerate(page_offsets):
|
||||
if start <= offset:
|
||||
page = i + 1
|
||||
else:
|
||||
break
|
||||
return page
|
||||
|
||||
|
||||
# ── Public entry point ────────────────────────────────────────────
|
||||
|
||||
|
||||
async def extract_text(file_path: str) -> tuple[str, int, list[int] | None]:
|
||||
"""Extract text from a document file.
|
||||
|
||||
Returns:
|
||||
``(text, page_count, page_offsets)`` where:
|
||||
- ``text``: concatenated extracted text
|
||||
- ``text``: extracted text. Plain text for PyMuPDF path;
|
||||
Markdown for Mistral path (tables, ``##`` headers preserved).
|
||||
- ``page_count``: number of pages (0 for non-PDF)
|
||||
- ``page_offsets``: ``page_offsets[i]`` = char start offset of
|
||||
page (i+1) inside ``text``. ``None`` for non-PDFs (where the
|
||||
notion of pages doesn't apply). Used by the chunker to assign
|
||||
a ``page_number`` to each chunk.
|
||||
- ``page_offsets``: char start of each page inside ``text``,
|
||||
or ``None`` for non-PDF formats
|
||||
"""
|
||||
path = Path(file_path)
|
||||
suffix = path.suffix.lower()
|
||||
@@ -173,95 +288,11 @@ async def extract_text(file_path: str) -> tuple[str, int, list[int] | None]:
|
||||
raise ValueError(f"Unsupported file type: {suffix}")
|
||||
|
||||
|
||||
def _join_pages(pages_text: list[str]) -> tuple[str, list[int]]:
|
||||
"""Join per-page text with PAGE_SEPARATOR while recording the start
|
||||
offset of each page in the joined output."""
|
||||
offsets: list[int] = []
|
||||
parts: list[str] = []
|
||||
cursor = 0
|
||||
for i, pg in enumerate(pages_text):
|
||||
offsets.append(cursor)
|
||||
parts.append(pg)
|
||||
cursor += len(pg)
|
||||
if i < len(pages_text) - 1:
|
||||
parts.append(PAGE_SEPARATOR)
|
||||
cursor += len(PAGE_SEPARATOR)
|
||||
return "".join(parts), offsets
|
||||
|
||||
|
||||
async def _extract_pdf(path: Path) -> tuple[str, int, list[int]]:
|
||||
"""Extract text from PDF.
|
||||
|
||||
Try direct text first, fall back to Google Cloud Vision for scanned
|
||||
or broken-OCR pages.
|
||||
"""
|
||||
doc = fitz.open(str(path))
|
||||
page_count = len(doc)
|
||||
pages_text: list[str] = []
|
||||
|
||||
for page_num in range(page_count):
|
||||
page = doc[page_num]
|
||||
text = page.get_text().strip()
|
||||
|
||||
if len(text) > 50 and _text_quality_ok(text):
|
||||
pages_text.append(_fix_hebrew_quotes(text))
|
||||
logger.debug("Page %d: direct extraction (%d chars, quality OK)", page_num + 1, len(text))
|
||||
else:
|
||||
reason = "insufficient text" if len(text) <= 50 else "low quality OCR layer"
|
||||
logger.info("Page %d: Google Vision OCR (%s)", page_num + 1, reason)
|
||||
pix = page.get_pixmap(dpi=300)
|
||||
img_bytes = pix.tobytes("png")
|
||||
ocr_text = await asyncio.to_thread(
|
||||
_ocr_with_google_vision, img_bytes, page_num + 1
|
||||
)
|
||||
pages_text.append(ocr_text)
|
||||
|
||||
doc.close()
|
||||
joined, offsets = _join_pages(pages_text)
|
||||
return joined, page_count, offsets
|
||||
|
||||
|
||||
def page_at_offset(offset: int, page_offsets: list[int]) -> int:
|
||||
"""Look up the page number containing a given char offset.
|
||||
|
||||
page_offsets[i] is the start of page (i+1) in the joined text;
|
||||
a chunk starting at ``offset`` belongs to the highest-indexed page
|
||||
whose start is ``<= offset``. Returns 1-based page number.
|
||||
"""
|
||||
if not page_offsets:
|
||||
return 1
|
||||
# Linear scan is fine — page_offsets is short (≤ ~200 for our PDFs).
|
||||
page = 1
|
||||
for i, start in enumerate(page_offsets):
|
||||
if start <= offset:
|
||||
page = i + 1
|
||||
else:
|
||||
break
|
||||
return page
|
||||
|
||||
|
||||
def _ocr_with_google_vision(image_bytes: bytes, page_num: int) -> str:
|
||||
"""OCR a single page image using Google Cloud Vision API."""
|
||||
from google.cloud import vision # lazy: keeps MCP startup fast
|
||||
client = _get_vision_client()
|
||||
image = vision.Image(content=image_bytes)
|
||||
|
||||
response = client.document_text_detection(
|
||||
image=image,
|
||||
image_context=vision.ImageContext(language_hints=["he"]),
|
||||
)
|
||||
|
||||
if response.error.message:
|
||||
raise RuntimeError(
|
||||
f"Google Vision error on page {page_num}: {response.error.message}"
|
||||
)
|
||||
|
||||
text = response.full_text_annotation.text if response.full_text_annotation else ""
|
||||
return _fix_hebrew_quotes(text)
|
||||
# ── Non-PDF formats ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extract_doc(path: Path) -> str:
|
||||
"""Extract text from legacy .doc file by converting to .docx via LibreOffice."""
|
||||
"""Extract text from legacy .doc via LibreOffice → DOCX conversion."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Isolate the LibreOffice user profile per call: headless soffice
|
||||
# locks a single shared profile, so concurrent .doc conversions would
|
||||
@@ -296,13 +327,13 @@ def _extract_rtf(path: Path) -> str:
|
||||
|
||||
|
||||
# ── Multimodal page rendering (V9) ───────────────────────────────
|
||||
# Unchanged — multimodal embedding always uses PyMuPDF-rendered images
|
||||
# regardless of whether text extraction used PyMuPDF or Mistral.
|
||||
|
||||
|
||||
def _pixmap_to_pil(pix: fitz.Pixmap) -> Image.Image:
|
||||
"""Convert a PyMuPDF pixmap to PIL.Image (RGB) without going through
|
||||
PNG bytes. Faster than tobytes('png') → Image.open()."""
|
||||
"""Convert a PyMuPDF pixmap to PIL.Image (RGB)."""
|
||||
if pix.alpha:
|
||||
# Drop alpha channel — voyage multimodal expects RGB.
|
||||
pix = fitz.Pixmap(pix, 0)
|
||||
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
||||
|
||||
@@ -314,12 +345,9 @@ def render_pages_for_multimodal(
|
||||
thumbnail_dir: Path | None = None,
|
||||
) -> list[tuple[Image.Image, Path | None]]:
|
||||
"""Render each PDF page as PIL.Image at ``embed_dpi`` for the
|
||||
multimodal embedder, and optionally save a smaller JPEG thumbnail
|
||||
at ``thumb_dpi`` to ``thumbnail_dir`` for UI preview.
|
||||
multimodal embedder, and optionally save JPEG thumbnails.
|
||||
|
||||
Returns ``[(pil_image, thumb_path_or_None), ...]`` in page order.
|
||||
The full-DPI image stays in memory only — only the thumbnail is
|
||||
persisted to disk.
|
||||
"""
|
||||
src = Path(pdf_path)
|
||||
if not src.is_file():
|
||||
@@ -338,17 +366,12 @@ def render_pages_for_multimodal(
|
||||
thumb_path: Path | None = None
|
||||
if thumbnail_dir is not None and thumb_dpi:
|
||||
thumb_path = thumbnail_dir / f"p{page_num:03d}.jpg"
|
||||
# Downsample the same render rather than re-rendering
|
||||
# with PyMuPDF — far faster.
|
||||
ratio = thumb_dpi / embed_dpi
|
||||
thumb_size = (
|
||||
max(1, int(img.width * ratio)),
|
||||
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:
|
||||
@@ -366,44 +389,28 @@ def render_pages_for_multimodal(
|
||||
return out
|
||||
|
||||
|
||||
# ── Nevo preamble stripping ──────────────────────────────────────
|
||||
# ── Nevo preamble stripping ───────────────────────────────────────
|
||||
|
||||
_NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:",
|
||||
"כתבי עת:", "הועתק מנבו")
|
||||
|
||||
# Markers for where the actual decision body begins (everything before is Nevo
|
||||
# preamble: bibliography + מיני-רציו). Two families:
|
||||
# - ועדת ערר / district openings (בפנינו / הערר שבנדון / ...)
|
||||
# - COURT-RULING openings (#86.1): a פסק-דין header or the authoring judge's
|
||||
# line. Without these, Nevo court judgments — exactly the ones carrying a
|
||||
# מיני-רציו — slipped through unstripped (e.g. בג"ץ 1764/05).
|
||||
#
|
||||
# #86.2 hardening — two over-strip bugs found while backfilling:
|
||||
# 1. ``פסק-דין`` headers are often markdown-wrapped (``**פסק דין**``); the old
|
||||
# ``^פסק[- ]דין`` required the keyword to be the very first char of the line
|
||||
# and allowed only one separator, so it missed the header and fell through
|
||||
# to a citation 32K deep (עמ"נ 50567-07-21). We now tolerate leading
|
||||
# markdown/whitespace and 0-3 separators.
|
||||
# 2. Bare ``השופט``/``הנשיא`` matched *citations* ("השופט מ' חשין, פסקה 23"),
|
||||
# stripping real decision body. The authoring-judge line ends with a COLON
|
||||
# ("השופט י' עמית:"); citations use a comma. We now require the colon.
|
||||
_DECISION_START = re.compile(
|
||||
r"^[ \t>*_#]{0,6}(?:"
|
||||
r"בפנינו|לפנינו|לפניי|הערר שבנדון|ועדת הערר לתכנון|רקע עובדתי|עסקינן|"
|
||||
r"פסק[ \t\-]{0,3}די(?:ן|נו)|" # פסק-דין / פסק דין / **פסק דין** header (final-nun ן vs דינו)
|
||||
r"(?:כב(?:וד)?['׳\"]?\s*)?(?:ה?שופט[ת]?|ה?נשיא[ה]?|המשנה לנשיא)\s+[^\n,]{1,40}:" # author line → colon
|
||||
r"פסק[ \t\-]{0,3}די(?:ן|נו)|"
|
||||
r"(?:כב(?:וד)?['׳\"]?\s*)?(?:ה?שופט[ת]?|ה?נשיא[ה]?|המשנה לנשיא)\s+[^\n,]{1,40}:"
|
||||
r")",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def strip_nevo_preamble(text: str) -> str:
|
||||
"""Remove Nevo database preamble (bibliography, legislation, mini-ratio) from decision text.
|
||||
"""Remove Nevo database preamble (bibliography, legislation, mini-ratio).
|
||||
|
||||
Returns the original text unchanged if no preamble is detected.
|
||||
Works on both plain text (PyMuPDF) and Markdown (Mistral) since
|
||||
_DECISION_START already tolerates leading ``[ \t>*_#]{0,6}``.
|
||||
"""
|
||||
# Window wide enough to catch the Nevo markers even when a long court/parties
|
||||
# header precedes them (court rulings push חקיקה שאוזכרה:/מיני-רציו: down).
|
||||
head = text[:1500]
|
||||
if not any(marker in head for marker in _NEVO_MARKERS):
|
||||
return text
|
||||
@@ -419,17 +426,7 @@ _RATIO_MARKER = "מיני-רציו:"
|
||||
|
||||
|
||||
def extract_nevo_ratio(text: str) -> str:
|
||||
"""Return the Nevo מיני-רציו block (editorial holdings summary), or ''.
|
||||
|
||||
The mini-ratio is Nevo's own headnote — a concise, professionally-written
|
||||
list of the holdings. We capture it *before* :func:`strip_nevo_preamble`
|
||||
discards it, to serve as a free gold-set for benchmarking how well our
|
||||
halacha extractor covers the real holdings (#86.3).
|
||||
|
||||
The block runs from the ``מיני-רציו:`` marker to whichever comes first:
|
||||
the decision body (``_DECISION_START``) or the next preamble marker
|
||||
(bibliography / legislation). Returns '' when there is no mini-ratio.
|
||||
"""
|
||||
"""Return the Nevo מיני-רציו block (editorial holdings summary), or ''."""
|
||||
if not text:
|
||||
return ""
|
||||
start = text.find(_RATIO_MARKER)
|
||||
@@ -437,9 +434,6 @@ def extract_nevo_ratio(text: str) -> str:
|
||||
return ""
|
||||
body = text[start + len(_RATIO_MARKER):]
|
||||
|
||||
# End at the earliest of: decision body start, or a following preamble
|
||||
# marker (ספרות: / חקיקה שאוזכרה: / ...). Both are measured relative to
|
||||
# the ratio body so we never run past it into the judgment itself.
|
||||
end = len(body)
|
||||
dm = _DECISION_START.search(body)
|
||||
if dm:
|
||||
|
||||
@@ -233,6 +233,17 @@ async def ingest_document(
|
||||
await db.request_metadata_extraction(case_law_id)
|
||||
await db.request_halacha_extraction(case_law_id)
|
||||
await db.recompute_searchable(case_law_id)
|
||||
# Citations resolve to a corpus row only at extraction time. A ruling
|
||||
# uploaded AFTER it was first cited leaves orphan edges (NULL link),
|
||||
# so re-link them to this freshly-ingested row now — the citation
|
||||
# graph self-heals instead of permanently under-counting (G1). Non-fatal.
|
||||
try:
|
||||
from legal_mcp.services import citation_extractor as _ce
|
||||
relinked = await _ce.relink_orphan_citations(case_law_id)
|
||||
if relinked:
|
||||
logger.info("relinked %d orphan citation(s) -> %s", relinked, case_law_id)
|
||||
except Exception as e: # noqa: BLE001 — relink is best-effort
|
||||
logger.warning("citation relink failed (non-fatal): %s", e)
|
||||
|
||||
await progress("completed", 100,
|
||||
f"נקלט: {stored_chunks} chunks. חילוץ הלכות ומטא-דאטה ממתינים בתור.")
|
||||
|
||||
@@ -28,14 +28,14 @@ logger = logging.getLogger(__name__)
|
||||
INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions"
|
||||
|
||||
_VALID_PRACTICE_AREAS = frozenset({"", "rishuy_uvniya", "betterment_levy", "compensation_197"})
|
||||
_VALID_DISTRICTS = frozenset({"", "ירושלים", "מרכז", "תל אביב", "צפון", "דרום", "ארצי"})
|
||||
_VALID_DISTRICTS = frozenset({"", "ירושלים", "מרכז", "תל אביב", "חיפה", "צפון", "דרום", "ארצי"})
|
||||
|
||||
_COURT_TO_DISTRICT = [
|
||||
("ירושלים", "ירושלים"),
|
||||
("תל אביב", "תל אביב"),
|
||||
('ת"א', "תל אביב"),
|
||||
("מרכז", "מרכז"),
|
||||
("חיפה", "צפון"),
|
||||
("חיפה", "חיפה"),
|
||||
("צפון", "צפון"),
|
||||
("דרום", "דרום"),
|
||||
("ארצי", "ארצי"),
|
||||
@@ -77,7 +77,7 @@ async def _create_internal_record(**kw) -> dict:
|
||||
appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
|
||||
subject_tags=list(kw.get("subject_tags") or []),
|
||||
summary=(kw.get("summary") or "").strip(),
|
||||
is_binding=kw.get("is_binding", True),
|
||||
is_binding=kw.get("is_binding", False), # INV-DM7: committee = persuasive
|
||||
document_id=kw.get("document_id"),
|
||||
proceeding_type=kw.get("proceeding_type") or "ערר",
|
||||
)
|
||||
@@ -108,7 +108,7 @@ async def ingest_internal_decision(
|
||||
appeal_subtype: str = "",
|
||||
subject_tags: list[str] | None = None,
|
||||
summary: str = "",
|
||||
is_binding: bool = True,
|
||||
is_binding: bool = False, # INV-DM7: committee sources are persuasive
|
||||
file_path: str | Path | None = None,
|
||||
text: str | None = None,
|
||||
document_id: UUID | None = None,
|
||||
@@ -229,6 +229,7 @@ async def migrate_from_external_corpus(dry_run: bool = False) -> dict:
|
||||
await conn.execute(
|
||||
"""UPDATE case_law
|
||||
SET source_kind = 'internal_committee',
|
||||
is_binding = FALSE, -- INV-DM7: committee = persuasive
|
||||
district = CASE WHEN $2 <> '' THEN $2 ELSE district END
|
||||
WHERE id = $1""",
|
||||
row["id"], district,
|
||||
|
||||
256
mcp-server/src/legal_mcp/services/lesson_synthesis.py
Normal file
256
mcp-server/src/legal_mcp/services/lesson_synthesis.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""Decision-lesson synthesis (#158 / INV-LRN8).
|
||||
|
||||
The learning channels (panel, curator, chair) accumulate overlapping ``decision_lessons``
|
||||
on the same style dimension. The writer only consumes the 15 most-recent APPROVED ones
|
||||
per practice_area (a silent cap, #157), so beyond that lessons pile up unused. This pass
|
||||
**clusters** near-duplicate lessons within a (practice_area, category) shard and **merges**
|
||||
each cluster into ONE richer, generalised "super-lesson" — so the writer-fed set shrinks
|
||||
to a small, high-quality profile (Authorial Style Profiling, ספ §0.1) and the cap stops
|
||||
biting.
|
||||
|
||||
Mirrors the canonical-halacha synthesis (V41 / INV-LRN6) on the SAME table (no parallel
|
||||
store, G2). Invariants:
|
||||
• INV-AH — the super-lesson is GROUNDED in the source lessons only; the model abstains
|
||||
rather than invent, and a re-embedding DRIFT guard rejects a rewrite that
|
||||
drifts from the cluster centroid.
|
||||
• INV-LRN1/G10 — graduated gate (chair decision 2026-06-28): since every source is
|
||||
already ``approved``, the super-lesson flows as ``approved`` (reversible —
|
||||
chair veto in /training restores the sources). A non-approved source ⇒ proposed.
|
||||
• G2 — single synthesis path; the MCP tool and the backfill script both call
|
||||
:func:`run_shard` / :func:`synthesize_cluster` here.
|
||||
• G9 — every outcome (accepted / abstained / drift_rejected / merged-duplicate) returned.
|
||||
|
||||
LLM calls go through ``claude_session`` (local ``claude -p`` CLI) only — never from the
|
||||
FastAPI container (see claude_session docstring).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import claude_session, db, embeddings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SYSTEM = (
|
||||
"אתה עורך-דין בכיר המזקק כללי-סגנון-וכתיבה לבסיס-ידע של ועדת ערר לתכנון ובנייה. "
|
||||
"תפקידך למזג כמה לקחי-סגנון חופפים לכלל אחד, כללי ומדויק, על *איך* כותבים — לא להמציא "
|
||||
"כלל חדש ולא להוסיף מהות משפטית."
|
||||
)
|
||||
|
||||
|
||||
def _cosine(a, b) -> float:
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = math.sqrt(sum(x * x for x in a))
|
||||
nb = math.sqrt(sum(y * y for y in b))
|
||||
if na == 0 or nb == 0:
|
||||
return 0.0
|
||||
return dot / (na * nb)
|
||||
|
||||
|
||||
def _centroid(vecs: list[list[float]]) -> list[float]:
|
||||
n = len(vecs)
|
||||
dim = len(vecs[0])
|
||||
return [sum(v[i] for v in vecs) / n for i in range(dim)]
|
||||
|
||||
|
||||
def _build_prompt(members: list[dict]) -> str:
|
||||
blocks = []
|
||||
for i, m in enumerate(members, 1):
|
||||
blocks.append(f"### לקח {i}\n{m['lesson_text']}")
|
||||
evidence = "\n\n".join(blocks)
|
||||
return f"""{_SYSTEM}
|
||||
|
||||
לקחי-המקור (כולם מאותו תחום וקטגוריה, חופפים בנושא):
|
||||
{evidence}
|
||||
|
||||
## המשימה
|
||||
מזג את לקחי-המקור לכלל-סגנון **אחד** עשיר ומוכלל המשותף לכולם. שמר כל ניואנס מובחן שמופיע
|
||||
באחד הלקחים, אך נסח אותו פעם אחת, נקי וכללי.
|
||||
|
||||
## כללים מחייבים (INV-AH — עיגון, ללא הזיה)
|
||||
1. **עיגון-מקור בלבד.** הכלל חייב לנבוע מלקחי-המקור שלמעלה. אסור להוסיף כלל, חריג או דוגמה שאינם עולים מהם.
|
||||
2. **סגנון/שיטה בלבד, לא מהות.** אל תכניס הלכה, עובדה, מספר-תיק או תקדים ספציפי — רק *איך* דפנה כותבת.
|
||||
3. **כללי ובלתי-תלוי-תיק.** הסר פרטים קונקרטיים; נסח כלל רב-תחולה.
|
||||
4. **רגיסטר נקי** בעברית, משפט אחד עד שלושה, בלי מילות-מסגרת ("יש לזכור ש...") — רק הכלל עצמו.
|
||||
5. **הימנעות עדיפה על המצאה.** אם הלקחים אינם באמת מתמזגים לכלל אחד מעוגן — החזר grounded=false.
|
||||
|
||||
## פלט — JSON בלבד, ללא markdown וללא הסבר:
|
||||
{{
|
||||
"lesson_text": "<כלל-הסגנון הממוזג>",
|
||||
"grounded": true,
|
||||
"reason": "<משפט קצר: מה אוחד>"
|
||||
}}"""
|
||||
|
||||
|
||||
def _greedy_clusters(candidates: list[dict], threshold: float) -> list[list[dict]]:
|
||||
"""Greedy single-link clustering by cosine over candidate embeddings. Each candidate
|
||||
has an 'embedding' (python list). Returns clusters of size ≥2 only (singletons are
|
||||
nothing to merge)."""
|
||||
remaining = [c for c in candidates if c.get("embedding") is not None]
|
||||
clusters: list[list[dict]] = []
|
||||
used: set = set()
|
||||
for i, seed in enumerate(remaining):
|
||||
if seed["id"] in used:
|
||||
continue
|
||||
cluster = [seed]
|
||||
used.add(seed["id"])
|
||||
for other in remaining[i + 1:]:
|
||||
if other["id"] in used:
|
||||
continue
|
||||
if _cosine(seed["embedding"], other["embedding"]) >= threshold:
|
||||
cluster.append(other)
|
||||
used.add(other["id"])
|
||||
if len(cluster) >= 2:
|
||||
clusters.append(cluster)
|
||||
return clusters
|
||||
|
||||
|
||||
async def _ensure_embeddings(candidates: list[dict]) -> list[dict]:
|
||||
"""Lazy-backfill: embed any candidate whose stored embedding is NULL, persist it,
|
||||
and return the candidates with embeddings populated (skips ones that still fail)."""
|
||||
missing = [c for c in candidates if c.get("embedding") is None]
|
||||
if missing:
|
||||
vecs = await embeddings.embed_texts([c["lesson_text"] for c in missing])
|
||||
for c, v in zip(missing, vecs):
|
||||
c["embedding"] = list(v)
|
||||
await db.set_lesson_embedding(c["id"], c["embedding"])
|
||||
return [c for c in candidates if c.get("embedding") is not None]
|
||||
|
||||
|
||||
async def synthesize_cluster(
|
||||
members: list[dict],
|
||||
*,
|
||||
model: str | None = None,
|
||||
effort: str | None = None,
|
||||
drift_floor: float | None = None,
|
||||
) -> dict:
|
||||
"""Merge one cluster of lessons. PURE — no DB writes. Returns:
|
||||
{status, proposed, embedding, members:[ids], drift_cosine, reason}
|
||||
status ∈ {accepted, abstained, drift_rejected, llm_error, too_small}.
|
||||
"""
|
||||
model = model or config.LESSON_SYNTH_MODEL
|
||||
effort = effort or config.LESSON_SYNTH_EFFORT
|
||||
drift_floor = config.LESSON_SYNTH_DRIFT_FLOOR if drift_floor is None else drift_floor
|
||||
ids = [str(m["id"]) for m in members]
|
||||
base = {"members": ids, "proposed": "", "embedding": None,
|
||||
"drift_cosine": None, "reason": ""}
|
||||
if len(members) < 2:
|
||||
return {**base, "status": "too_small", "reason": "cluster < 2"}
|
||||
|
||||
try:
|
||||
result = await claude_session.query_json(
|
||||
_build_prompt(members), model=model, effort=effort, tools="",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("synthesize_cluster %s: LLM error: %s", ids, e)
|
||||
return {**base, "status": "llm_error", "reason": str(e)}
|
||||
|
||||
if not isinstance(result, dict) or not result.get("lesson_text"):
|
||||
return {**base, "status": "llm_error", "reason": "malformed LLM output"}
|
||||
if not result.get("grounded", True):
|
||||
return {**base, "status": "abstained",
|
||||
"reason": result.get("reason") or "model abstained (not grounded)"}
|
||||
|
||||
proposed = str(result["lesson_text"]).strip()
|
||||
if not proposed:
|
||||
return {**base, "status": "abstained", "reason": "empty proposal"}
|
||||
|
||||
# Drift guard: the merged lesson must stay near the cluster centroid.
|
||||
new_emb = list((await embeddings.embed_texts([proposed]))[0])
|
||||
centroid = _centroid([m["embedding"] for m in members])
|
||||
drift = _cosine(new_emb, centroid)
|
||||
if drift < drift_floor:
|
||||
return {**base, "status": "drift_rejected", "proposed": proposed,
|
||||
"drift_cosine": round(drift, 4),
|
||||
"reason": f"drift {drift:.3f} < floor {drift_floor}"}
|
||||
|
||||
return {**base, "status": "accepted", "proposed": proposed, "embedding": new_emb,
|
||||
"drift_cosine": round(drift, 4), "reason": result.get("reason") or "merged"}
|
||||
|
||||
|
||||
async def run_shard(
|
||||
practice_area: str,
|
||||
category: str,
|
||||
*,
|
||||
apply: bool,
|
||||
model: str | None = None,
|
||||
effort: str | None = None,
|
||||
drift_floor: float | None = None,
|
||||
cluster_threshold: float | None = None,
|
||||
) -> dict:
|
||||
"""Synthesize all clusters in one (practice_area, category) shard.
|
||||
|
||||
Returns {practice_area, category, candidates, clusters:[result...]}. Each result is
|
||||
a synthesize_cluster outcome augmented with ``applied`` and (when applied) ``new_id``.
|
||||
With apply=False this is a pure dry-run (no writes beyond lazy embedding backfill).
|
||||
"""
|
||||
cluster_threshold = (config.LESSON_SYNTH_CLUSTER_THRESHOLD
|
||||
if cluster_threshold is None else cluster_threshold)
|
||||
candidates = await db.fetch_synthesis_candidates(practice_area, category)
|
||||
candidates = await _ensure_embeddings(candidates)
|
||||
clusters = _greedy_clusters(candidates, cluster_threshold)
|
||||
|
||||
results = []
|
||||
for members in clusters:
|
||||
res = await synthesize_cluster(
|
||||
members, model=model, effort=effort, drift_floor=drift_floor,
|
||||
)
|
||||
res["applied"] = False
|
||||
if apply and res["status"] == "accepted":
|
||||
# Idempotency: skip if a near-identical synthesis already exists (re-run safe).
|
||||
dup = await db.nearest_synthesis_lesson(
|
||||
res["embedding"], category, config.HALACHA_CANONICAL_THRESHOLD,
|
||||
)
|
||||
if dup:
|
||||
res["status"] = "duplicate_skipped"
|
||||
res["reason"] = f"near existing synthesis {dup[0]} (sim {dup[1]:.3f})"
|
||||
else:
|
||||
# graduated gate: all sources are approved (fetch filter) → approved.
|
||||
row = await db.apply_lesson_synthesis(
|
||||
corpus_id=members[0]["style_corpus_id"],
|
||||
lesson_text=res["proposed"],
|
||||
category=category,
|
||||
embedding=res["embedding"],
|
||||
source_ids=[m["id"] for m in members],
|
||||
review_status="approved",
|
||||
)
|
||||
res["applied"] = True
|
||||
res["new_id"] = str(row.get("id", ""))
|
||||
results.append(res)
|
||||
|
||||
return {"practice_area": practice_area or "*", "category": category,
|
||||
"candidates": len(candidates), "clusters": results}
|
||||
|
||||
|
||||
async def run_pending(
|
||||
practice_area: str = "",
|
||||
category: str = "",
|
||||
*,
|
||||
apply: bool,
|
||||
model: str | None = None,
|
||||
effort: str | None = None,
|
||||
drift_floor: float | None = None,
|
||||
cluster_threshold: float | None = None,
|
||||
) -> list[dict]:
|
||||
"""Run synthesis across shards. If practice_area+category are given, one shard;
|
||||
otherwise iterate every shard with ≥2 live approved lessons. Single entry point (G2)."""
|
||||
if practice_area and category:
|
||||
shards = [{"practice_area": practice_area, "category": category}]
|
||||
else:
|
||||
shards = await db.synthesis_shards(min_size=2)
|
||||
if practice_area:
|
||||
shards = [s for s in shards if s["practice_area"] == practice_area]
|
||||
if category:
|
||||
shards = [s for s in shards if s["category"] == category]
|
||||
out = []
|
||||
for s in shards:
|
||||
out.append(await run_shard(
|
||||
s["practice_area"], s["category"], apply=apply,
|
||||
model=model, effort=effort, drift_floor=drift_floor,
|
||||
cluster_threshold=cluster_threshold,
|
||||
))
|
||||
return out
|
||||
208
mcp-server/src/legal_mcp/services/party_claims_summary.py
Normal file
208
mcp-server/src/legal_mcp/services/party_claims_summary.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""סיכום-מנהלים של טענות הצדדים — מסמך פרוזה מזוקק להכנת היו"ר לדיון (WS3, #202).
|
||||
|
||||
מסמך זה הוא **נפרד ומובחן מטיוטת-הביניים** (החלטת-יו"ר, תוכנית workflow-redesign §WS3) —
|
||||
תמצית מנהלים קצרה ומוקפדת של טענות הצדדים והתשובות זו-לזו, להכנה לדיון בעל-פה. אין הוא
|
||||
משכפל את כתבי-הטענות המלאים: הוא מזקק.
|
||||
|
||||
מקור-האמת היחיד (G2): הוא נגזר מ-``legal_arguments`` (טיעונים מאוגדים) ומ-``claims``
|
||||
הקיימים בתיק — לא מחלץ-מחדש ולא קורא לכתבי-הטענות הגולמיים. אם הטיעונים המאוגדים טרם
|
||||
חושבו, הוא נופל-חזרה ל-claims הגולמיים (אותו מקור).
|
||||
|
||||
הייצור עובר ``claude_session.query`` → ``claude -p`` (CLI, מנוי claude.ai, עלות-שולית-אפס),
|
||||
נעוץ ל-Opus 4.8 + effort=high — משימת זיקוק/סינתזה. **local-only**: לקונטיינר אין claude CLI;
|
||||
לכן הכלי רץ רק מ-MCP-server המקומי, וה-endpoint ב-app.py רק מגיש את הקובץ השמור (לא מייצר).
|
||||
|
||||
ההזהרה נגד-הזיה (INV-AH): המסמך מתוחם לחלוטין לטענות-התיק עצמן. אסור להמציא טענה, הלכה
|
||||
או עובדה שאינה מופיעה בקלט; טענה שאינה ברורה — מצוין במפורש, לא משלים.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import (
|
||||
argument_aggregator,
|
||||
block_writer,
|
||||
claude_session,
|
||||
db,
|
||||
git_sync,
|
||||
storage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Saved alongside the legal analysis (same research folder, same git+S3 mirror path).
|
||||
_SUMMARY_FILENAME = "party-claims-summary.md"
|
||||
|
||||
_PARTY_HE = {
|
||||
"appellant": "העוררים",
|
||||
"respondent": "המשיבים",
|
||||
"committee": "הוועדה המקומית",
|
||||
"permit_applicant": "מבקשי ההיתר",
|
||||
"unknown": "צד לא מזוהה",
|
||||
}
|
||||
|
||||
|
||||
def summary_file_path(case_number: str) -> Path:
|
||||
"""Resolve the saved party-claims-summary.md path for a case."""
|
||||
return config.find_case_dir(case_number) / "documents" / "research" / _SUMMARY_FILENAME
|
||||
|
||||
|
||||
async def _build_arguments_context(case_id: UUID) -> tuple[str, int, str]:
|
||||
"""Build the grounded source block from the case's OWN claims/arguments.
|
||||
|
||||
Preference order (single source of truth, no re-extraction — G2):
|
||||
1. ``legal_arguments`` — the aggregated, de-duplicated arguments per party.
|
||||
2. fallback to raw ``claims`` when arguments were never aggregated.
|
||||
|
||||
Returns ``(context_text, item_count, source_kind)`` where ``source_kind`` is
|
||||
``"arguments"`` or ``"claims"`` (or ``"none"`` when neither exists).
|
||||
"""
|
||||
args = await argument_aggregator.get_legal_arguments(case_id)
|
||||
if args:
|
||||
by_party: dict[str, list[dict]] = {}
|
||||
for a in args:
|
||||
by_party.setdefault(a["party"], []).append(a)
|
||||
|
||||
lines: list[str] = []
|
||||
for party, items in by_party.items():
|
||||
lines.append(f"\n## {_PARTY_HE.get(party, party)}")
|
||||
for a in items:
|
||||
title = (a.get("argument_title") or "").strip()
|
||||
body = (a.get("argument_body") or "").strip()
|
||||
topic = (a.get("legal_topic") or "").strip()
|
||||
header = f"- **{title}**" if title else "-"
|
||||
if topic:
|
||||
header += f" [{topic}]"
|
||||
lines.append(header)
|
||||
if body:
|
||||
lines.append(f" {body}")
|
||||
return "\n".join(lines), len(args), "arguments"
|
||||
|
||||
# Fallback — raw claims, the same underlying source.
|
||||
claims = await db.get_claims(case_id)
|
||||
# Exclude block-zayin (decision-summary) claims — original pleadings only.
|
||||
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
|
||||
if not source_claims:
|
||||
return "", 0, "none"
|
||||
|
||||
lines = []
|
||||
current_role = ""
|
||||
role_he = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
|
||||
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
|
||||
n = 0
|
||||
for c in source_claims:
|
||||
if c["party_role"] != current_role:
|
||||
current_role = c["party_role"]
|
||||
lines.append(f"\n## {role_he.get(current_role, current_role)}")
|
||||
n += 1
|
||||
lines.append(f"- טענה #{n}: {c['claim_text']}")
|
||||
return "\n".join(lines), len(source_claims), "claims"
|
||||
|
||||
|
||||
_PROMPT_TEMPLATE = """אתה מכין עבור יו"ר ועדת הערר (עו"ד דפנה תמיר) **סיכום-מנהלים מזוקק של טענות הצדדים** — מסמך פרוזה קצר שמטרתו אחת: להכין את היו"ר לדיון בעל-פה. זהו מסמך **נפרד** מטיוטת-ההחלטה ואינו חלק ממנה.
|
||||
|
||||
## פרטי התיק:
|
||||
{case_context}
|
||||
|
||||
## הקלט — טענות/טיעוני הצדדים (מקור-האמת היחיד):
|
||||
{arguments_context}
|
||||
|
||||
## כללי-כתיבה מחייבים:
|
||||
- **זיקוק, לא שכפול.** אל תעתיק את כתבי-הטענות. תמצת כל צד ל-2–5 משפטי-מפתח. היעד: עמוד אחד עד שניים, לא יותר.
|
||||
- **מבוסס-קלט בלבד (חובה אנטי-הזיה).** הסתמך אך-ורק על הטענות שבקלט לעיל. אסור להמציא טענה, הלכה, פסק-דין, מספר או עובדה שאינם בקלט. טענה שאינה ברורה או חסרה — ציין זאת במפורש ("הטענה לא פורטה"), אל תשלים מדמיונך.
|
||||
- **ניטרלי.** הצג את עמדת כל צד בנאמנות, בלי להעריך, בלי להכריע ובלי לרמוז על תוצאה. אין זו החלטה — זו הכנה.
|
||||
- **מבנה קבוע:**
|
||||
1. פסקת-פתיחה קצרה (משפט–שניים): במה עוסק הערר ומיהם הצדדים.
|
||||
2. **טענות העוררים** — תמצית מזוקקת.
|
||||
3. **טענות המשיבים / הוועדה / מבקשי ההיתר** — תמצית מזוקקת (כל צד שקיים בקלט).
|
||||
4. **נקודות-המחלוקת המרכזיות** — 2–4 הסוגיות שעליהן ניצבת ההכרעה, כשאלות פתוחות (לא תשובות).
|
||||
- **סגנון:** עברית משפטית בהירה, גוף-שלישי לתיאור הצדדים. בלי כותרת "החלטה". בלי חתימות.
|
||||
|
||||
## מדריך-סגנון (לטון בלבד — אל תיישם מבנה-החלטה):
|
||||
{style_context}
|
||||
|
||||
החזר אך-ורק את גוף מסמך הסיכום (Markdown), בלי הקדמות ובלי הסברים על מה שעשית."""
|
||||
|
||||
|
||||
async def summarize_party_claims(case_id: UUID, case_number: str,
|
||||
instructions: str = "") -> dict:
|
||||
"""Generate the distilled executive summary of party claims and save it.
|
||||
|
||||
Grounded strictly in the case's own claims/legal_arguments (INV-AH).
|
||||
Generation is pinned to Opus 4.8 + effort=high (distillation/synthesis).
|
||||
"""
|
||||
case = await db.get_case(case_id)
|
||||
if not case:
|
||||
raise ValueError(f"Case {case_id} not found")
|
||||
|
||||
decision = await db.get_decision_by_case(case_id)
|
||||
|
||||
arguments_context, n_items, source_kind = await _build_arguments_context(case_id)
|
||||
if source_kind == "none":
|
||||
raise ValueError(
|
||||
"אין טענות לסכם. הרץ extract_claims (ורצוי aggregate_claims_to_arguments) קודם."
|
||||
)
|
||||
|
||||
case_context = block_writer._build_case_context(case, decision)
|
||||
style_context = await block_writer._build_style_context(case.get("practice_area", ""))
|
||||
|
||||
prompt = _PROMPT_TEMPLATE.format(
|
||||
case_context=case_context,
|
||||
arguments_context=arguments_context,
|
||||
style_context=style_context,
|
||||
)
|
||||
if instructions:
|
||||
prompt += f"\n\n## הנחיות נוספות מהיו\"ר:\n{instructions}"
|
||||
|
||||
# Generation: claude_session → claude -p, pinned Opus 4.8 + high effort.
|
||||
# tools="" — pure prose, no tool_use (avoids error_max_turns). LONG_TIMEOUT:
|
||||
# full-case context can be large.
|
||||
content = await claude_session.query(
|
||||
prompt,
|
||||
timeout=claude_session.LONG_TIMEOUT,
|
||||
model="claude-opus-4-8",
|
||||
effort="high",
|
||||
tools="",
|
||||
)
|
||||
content = (content or "").strip()
|
||||
if not content:
|
||||
raise ValueError("הסיכום חזר ריק מ-claude -p.")
|
||||
|
||||
today = date.today().strftime("%d.%m.%Y")
|
||||
header = (
|
||||
f"# סיכום-מנהלים — טענות הצדדים\n\n"
|
||||
f"**תיק:** {case['case_number']} \n"
|
||||
f"**נושא:** {case.get('subject', '')} \n"
|
||||
f"**הופק:** {today} · מסמך הכנה לדיון (נפרד מטיוטת-ההחלטה)\n\n"
|
||||
f"---\n\n"
|
||||
)
|
||||
document = header + content + "\n"
|
||||
|
||||
# Save to data/cases/{n}/documents/research/ — same path as the legal analysis,
|
||||
# so it is git-synced + S3-mirrored by the existing machinery.
|
||||
out_path = summary_file_path(case_number)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(document, encoding="utf-8") # noqa: STG1 — mirrored below
|
||||
try:
|
||||
key = out_path.resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix()
|
||||
await storage.mirror(key, document.encode("utf-8"), bucket=storage.Bucket.DOCUMENTS)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
if case_dir.exists():
|
||||
git_sync.commit_and_push(case_dir, f"סיכום-מנהלים: טענות הצדדים ({case_number})")
|
||||
|
||||
return {
|
||||
"case_number": case_number,
|
||||
"path": str(out_path),
|
||||
"source_kind": source_kind,
|
||||
"source_items": n_items,
|
||||
"word_count": len(content.split()),
|
||||
"message": f"סיכום-מנהלים נוצר ({len(content.split())} מילים, מקור: {source_kind}).",
|
||||
}
|
||||
@@ -68,7 +68,11 @@ def _external_staging_subdir(inputs: dict) -> str:
|
||||
|
||||
|
||||
async def _create_external_record(**kw) -> dict:
|
||||
"""Adapter: maps canonical inputs (citation) to create_external_case_law(case_number)."""
|
||||
"""Adapter: maps canonical inputs (citation) to create_external_case_law(case_number).
|
||||
|
||||
The chair's typed citation seeds ``citation_formatted`` so the מראה-מקום is never
|
||||
blank before metadata extraction; the inline enrichment upgrades it to the
|
||||
canonical form (see ``ingest_precedent``)."""
|
||||
return await db.create_external_case_law(
|
||||
case_number=kw["citation"].strip(),
|
||||
case_name=kw["case_name"],
|
||||
@@ -84,6 +88,7 @@ async def _create_external_record(**kw) -> dict:
|
||||
precedent_level=kw.get("precedent_level", ""),
|
||||
is_binding=kw.get("is_binding", True),
|
||||
document_id=kw.get("document_id"),
|
||||
citation_formatted=kw["citation"].strip(),
|
||||
)
|
||||
|
||||
|
||||
@@ -126,10 +131,28 @@ async def ingest_precedent(
|
||||
"appeal_subtype": appeal_subtype, "subject_tags": subject_tags,
|
||||
"is_binding": is_binding, "headnote": headnote, "summary": summary,
|
||||
}
|
||||
return await ingest.ingest_document(
|
||||
result = await ingest.ingest_document(
|
||||
_EXTERNAL_SPEC, inputs=inputs, file_path=file_path,
|
||||
document_id=document_id, progress=progress,
|
||||
)
|
||||
# Inline metadata enrichment (Gemini, in-container — gemini_session is direct
|
||||
# REST, no local CLI). UPGRADES the seeded provisional citation_formatted to the
|
||||
# canonical derived form (parties + reporter + date) so the מראה-מקום is correct
|
||||
# the moment the chair opens /precedents/[id] — not deferred to the local drainer.
|
||||
# force_citation=True overwrites the seed only; chair edits aren't reachable yet
|
||||
# (row just created). Best-effort: on no key / API failure the seed remains and
|
||||
# the queued metadata drain (request_metadata_extraction, already set) is the
|
||||
# fallback. Halacha extraction is NOT touched here — it stays local (claude_session).
|
||||
cid = result.get("case_law_id") if isinstance(result, dict) else None
|
||||
if cid:
|
||||
try:
|
||||
from legal_mcp.services import precedent_metadata_extractor as _pme
|
||||
r = await _pme.extract_and_apply(UUID(str(cid)), force_citation=True)
|
||||
if r.get("status") == "completed":
|
||||
await db.set_case_law_metadata_status(UUID(str(cid)), "completed")
|
||||
except Exception as e: # noqa: BLE001 — enrichment is best-effort; drainer is fallback
|
||||
logger.warning("inline metadata enrichment failed for %s (drainer will retry): %s", cid, e)
|
||||
return result
|
||||
|
||||
|
||||
async def reextract_halachot(
|
||||
@@ -360,7 +383,10 @@ async def reextract_metadata(
|
||||
appeal_subtype, and case_name when it equals the citation). User
|
||||
values are preserved.
|
||||
|
||||
**MCP-tool-only path** — same constraint as :func:`reextract_halachot`.
|
||||
**Container-safe** — unlike :func:`reextract_halachot` (claude CLI, host-only),
|
||||
metadata extraction runs on Gemini Flash over REST (GOOGLE_GEMINI_API_KEY), so
|
||||
this path is callable from the FastAPI container too. The final-decision
|
||||
enrollment loop (``_enroll_final_in_library``) calls it inline on upload.
|
||||
"""
|
||||
from legal_mcp.services import precedent_metadata_extractor
|
||||
|
||||
@@ -411,6 +437,37 @@ async def get_precedent(case_law_id: UUID | str) -> dict | None:
|
||||
return None
|
||||
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
|
||||
record["related_cases"] = await db.get_case_law_relations(case_law_id)
|
||||
# Auto-detected citation graph: which decisions cite THIS ruling (incoming
|
||||
# edges in precedent_internal_citations). Reuses the canonical query (G2) —
|
||||
# no parallel resolution path. Shaped to match the front-end RelatedCase row
|
||||
# so the existing "ציטוטים מקושרים" panel renders them with no visual change.
|
||||
from legal_mcp.services import citation_extractor as _ce
|
||||
raw = await _ce.list_citations_to_case_law(case_law_id)
|
||||
record["incoming_citations"] = [
|
||||
{
|
||||
"id": r["source_case_law_id"],
|
||||
"case_number": r.get("source_case_number") or r.get("cited_case_number") or "",
|
||||
"case_name": r.get("source_case_name") or "",
|
||||
"court": r.get("source_court") or "",
|
||||
"precedent_level": r.get("source_precedent_level") or "",
|
||||
"chair_name": r.get("source_chair_name") or "",
|
||||
"date": (
|
||||
r["source_date"].isoformat()
|
||||
if r.get("source_date") is not None else None
|
||||
),
|
||||
"treatment": r.get("treatment") or "",
|
||||
"confidence": r.get("confidence"),
|
||||
}
|
||||
for r in raw
|
||||
]
|
||||
# Authority signal (X11 Phase 2, #154): how the citing committee decisions
|
||||
# TREATED this ruling (followed/distinguished/…) — surfaced so the chair (and
|
||||
# research agents via the tool output) can argue authority and avoid leaning on
|
||||
# a repeatedly-distinguished precedent.
|
||||
record["cited_by"] = (await db.citation_authority([case_law_id])).get(
|
||||
str(case_law_id),
|
||||
{"total": 0, "positive": 0, "negative": 0, "unclassified": 0, "by_treatment": {}},
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
|
||||
@@ -260,6 +260,7 @@ async def apply_to_record(
|
||||
case_law_id: UUID | str,
|
||||
suggested: dict,
|
||||
overwrite_case_number: bool = False,
|
||||
force_citation: bool = False,
|
||||
) -> dict:
|
||||
"""Merge suggested metadata into the case_law row, filling ONLY empty fields.
|
||||
|
||||
@@ -274,6 +275,13 @@ async def apply_to_record(
|
||||
|
||||
overwrite_case_number: when True, update case_number from case_number_clean
|
||||
even if the field already has a value (used for one-time migration enrichment).
|
||||
|
||||
force_citation: when True, (re)assemble citation_formatted even if the field
|
||||
is non-empty — used by the at-upload inline enrichment to UPGRADE the seeded
|
||||
provisional citation (the raw chair input) to the canonical derived form. The
|
||||
write still happens only when the deterministic assembly SUCCEEDS (a missing
|
||||
component → no write → the seed is preserved). The drainer keeps the default
|
||||
(False) so a chair's manual edit in /precedents/[id] is never clobbered.
|
||||
"""
|
||||
if isinstance(case_law_id, str):
|
||||
case_law_id = UUID(case_law_id)
|
||||
@@ -482,7 +490,7 @@ async def apply_to_record(
|
||||
# source_type/district/proceeding_type/parties). Only fill when empty so chair
|
||||
# edits in /precedents/[id] are preserved; abstains (no write) when a component
|
||||
# is missing.
|
||||
if not (record.get("citation_formatted") or "").strip():
|
||||
if force_citation or not (record.get("citation_formatted") or "").strip():
|
||||
eff = {**record, **fields_to_update}
|
||||
eff_parties = (
|
||||
fields_to_update.get("parties") or record.get("parties") or ""
|
||||
@@ -505,6 +513,7 @@ async def apply_to_record(
|
||||
async def extract_and_apply(
|
||||
case_law_id: UUID | str,
|
||||
overwrite_case_number: bool = False,
|
||||
force_citation: bool = False,
|
||||
) -> dict:
|
||||
"""Convenience wrapper: extract → merge into row → return summary."""
|
||||
suggested = await extract_metadata(case_law_id)
|
||||
@@ -523,7 +532,11 @@ async def extract_and_apply(
|
||||
"status": "extraction_failed" if has_text else "no_metadata",
|
||||
"fields": [],
|
||||
}
|
||||
result = await apply_to_record(case_law_id, suggested, overwrite_case_number=overwrite_case_number)
|
||||
result = await apply_to_record(
|
||||
case_law_id, suggested,
|
||||
overwrite_case_number=overwrite_case_number,
|
||||
force_citation=force_citation,
|
||||
)
|
||||
if result["updated"]:
|
||||
await db.recompute_searchable(case_law_id)
|
||||
return {
|
||||
|
||||
400
mcp-server/src/legal_mcp/services/protocol_analyzer.py
Normal file
400
mcp-server/src/legal_mcp/services/protocol_analyzer.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""ניתוח פרוטוקול-דיון השוואתי (WS4 / #203).
|
||||
|
||||
מנתח פרוטוקול דיון של ועדת הערר מול כתבי-הטענות הכתובים: אילו טענות **ירדו**
|
||||
(נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות** — ומחדד את השאלות המשפטיות
|
||||
לקראת פרק הדיון. התוצאה נכנסת ל"ידע-התיק" (טבלת protocol_analysis), זמינה
|
||||
לסוכני הניתוח והכתיבה.
|
||||
|
||||
בנוסף, מחלץ את **נתוני הכותרת (א–ד)** מהפרוטוקול בפורמט-ההזנה המוכר —
|
||||
הרכב הוועדה, תאריך הדיון, והצדדים שהופיעו — ומזין אותם חזרה לעמודות הקנוניות
|
||||
(`cases.hearing_date`, panel דרך decisions; G2: לא כפילות).
|
||||
|
||||
הפרדת-אחריות (claude_session.py): כל קריאת-LLM כאן רצה רק מה-MCP server המקומי
|
||||
(אין claude CLI בקונטיינר). הקריאה היחידה היא דרך claude_session, מעוגנת ל-
|
||||
Opus 4.8 + effort=high כמתבקש בניתוח-השוואתי (reference_claude_generation_path).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import claude_session, db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Generation pinned per reference_claude_generation_path: Opus 4.8 rejects
|
||||
# temperature/top_p/top_k (400) — the only knob is effort. Comparative legal
|
||||
# reasoning over the protocol vs. pleadings is high-effort.
|
||||
ANALYSIS_MODEL = "claude-opus-4-8"
|
||||
ANALYSIS_EFFORT = "high"
|
||||
HEADER_EFFORT = "medium" # א–ד is mechanical extraction, not reasoning
|
||||
|
||||
# Valid change verdicts — mirror the DB CHECK on protocol_analysis.change_type.
|
||||
VALID_CHANGE_TYPES = {"dropped", "strengthened", "newly_raised"}
|
||||
VALID_PARTY_ROLES = {"appellant", "respondent", "committee", "permit_applicant", ""}
|
||||
|
||||
PARTY_LABELS_HE = {
|
||||
"appellant": "עוררים",
|
||||
"respondent": "משיבים",
|
||||
"committee": "ועדה מקומית",
|
||||
"permit_applicant": "מבקשי היתר",
|
||||
}
|
||||
|
||||
|
||||
# ── Comparative analysis: pleadings vs. protocol ──────────────────
|
||||
|
||||
COMPARE_PROMPT = """אתה מנתח משפטי בכיר בועדת ערר לתכנון ובנייה. לפניך **פרוטוקול דיון**
|
||||
ולצדו **הטיעונים המשפטיים שהוגשו בכתב** לפני הדיון. תפקידך: ניתוח השוואתי מדויק
|
||||
של מה שקרה בדיון ביחס לכתבי-הטענות.
|
||||
|
||||
## מה לזהות (לכל צד בנפרד):
|
||||
1. **dropped (ירדה)** — טענה שהוגשה בכתב אך הצד **זנח** אותה בדיון: לא חזר עליה,
|
||||
הודה שאינה עומדת, או ויתר עליה במפורש.
|
||||
2. **strengthened (חוזקה)** — טענה כתובה שהצד **חיזק** בדיון: הוסיף נימוק, אסמכתא,
|
||||
הבהרה, או דגש שהפך אותה למרכזית.
|
||||
3. **newly_raised (חדשה)** — טענה/סוגיה ש**עלתה לראשונה בדיון** ולא הופיעה בכתבי
|
||||
הטענות כלל.
|
||||
|
||||
## כללים קריטיים (anti-hallucination — חובה):
|
||||
- **ציטוט-או-הימנעות:** לכל קביעה חייב להיות `evidence_quote` — ציטוט **מילולי**
|
||||
מהפרוטוקול (עד 200 תווים) שמבסס אותה. אם אין ציטוט תומך — אל תכלול את הרשומה.
|
||||
- אל תמציא טענות שלא נאמרו. אל תסיק "ירדה" רק כי לא הוזכרה — רק אם יש ראיה פוזיטיבית
|
||||
לזניחה/ויתור, או שהיא טענה כתובה מרכזית שכלל לא עלתה והצד עסק בנושא.
|
||||
- **חידוד-השאלה:** לכל רשומה, נסח את `sharpened_question` — השאלה המשפטית הממוקדת
|
||||
שהשינוי בדיון מחדד (לטובת פרק הדיון). אם לא רלוונטי — השאר ריק.
|
||||
- שייך כל רשומה ל-`party_role` הנכון: appellant / respondent / committee / permit_applicant.
|
||||
- אם רשומה תואמת טיעון כתוב קיים — החזר את ה-`argument_id` שלו מהרשימה למטה. לטענה חדשה
|
||||
(newly_raised) — argument_id ריק.
|
||||
|
||||
## פלט:
|
||||
החזר JSON array בלבד — ללא markdown, ללא הסברים:
|
||||
[
|
||||
{{
|
||||
"change_type": "dropped" | "strengthened" | "newly_raised",
|
||||
"party_role": "appellant" | "respondent" | "committee" | "permit_applicant",
|
||||
"argument_id": "uuid-של-הטיעון-הכתוב או null",
|
||||
"argument_title": "כותרת קצרה של הטענה/הסוגיה",
|
||||
"summary": "מה השתנה בדיון, במשפט-שניים",
|
||||
"sharpened_question": "השאלה המשפטית שהשינוי מחדד, או ריק",
|
||||
"evidence_quote": "ציטוט מילולי מהפרוטוקול",
|
||||
"page_number": null
|
||||
}}
|
||||
]
|
||||
אם אין שינויים בני-ביסוס — החזר [].
|
||||
|
||||
## הטיעונים שהוגשו בכתב:
|
||||
{arguments_json}
|
||||
|
||||
## פרוטוקול הדיון:
|
||||
--- תחילת פרוטוקול ---
|
||||
{protocol_text}
|
||||
--- סוף פרוטוקול ---
|
||||
"""
|
||||
|
||||
|
||||
# ── Header (א–ד) extraction in the known feed format ──────────────
|
||||
|
||||
HEADER_PROMPT = """אתה מחלץ נתוני-כותרת מפרוטוקול דיון של ועדת ערר לתכנון ובנייה.
|
||||
חלץ אך-ורק עובדות מנהליות המופיעות במפורש בפרוטוקול — אל תמציא ואל תסיק.
|
||||
|
||||
## פלט:
|
||||
החזר JSON object בלבד (ללא markdown):
|
||||
{{
|
||||
"hearing_date": "YYYY-MM-DD אם תאריך הדיון מופיע, אחרת ריק",
|
||||
"panel_members": ["שמות חברי ההרכב כפי שמופיעים, כולל תוארם; ריק אם לא צוין"],
|
||||
"appellants_present": ["שמות העוררים/באי-כוחם שהופיעו בדיון"],
|
||||
"respondents_present": ["שמות המשיבים/באי-כוחם שהופיעו בדיון"]
|
||||
}}
|
||||
שדה שלא צוין בפרוטוקול — החזר ריק ([] או "").
|
||||
|
||||
## פרוטוקול:
|
||||
--- תחילת פרוטוקול ---
|
||||
{protocol_text}
|
||||
--- סוף פרוטוקול ---
|
||||
"""
|
||||
|
||||
# A single protocol rarely exceeds the model's context, but cap defensively so a
|
||||
# pathological OCR dump doesn't blow the prompt budget.
|
||||
MAX_PROTOCOL_CHARS = 120_000
|
||||
|
||||
|
||||
def _protocol_scope(doc: dict) -> str:
|
||||
"""Protocol scope from ``metadata.protocol_scope``.
|
||||
|
||||
Absent/'' defaults to ``'appeal'`` (ועדת הערר), matching the
|
||||
document-type-editor convention where only the non-default ``'lower'``
|
||||
(ועדה מקומית/מחוזית) is persisted.
|
||||
"""
|
||||
meta = doc.get("metadata") or {}
|
||||
return (meta.get("protocol_scope") or "").strip() or "appeal"
|
||||
|
||||
|
||||
def _find_protocol(
|
||||
docs: list[dict], document_id: UUID | None = None,
|
||||
) -> dict | None:
|
||||
"""The ועדת-הערר hearing protocol to compare against the pleadings.
|
||||
|
||||
- ``document_id`` given → return exactly that document (explicit target),
|
||||
so a case with several protocols can be pointed at the right hearing.
|
||||
- Otherwise prefer a protocol scoped to the appeals committee
|
||||
(``scope != 'lower'``). A ``'lower'`` protocol is the local/district
|
||||
committee's proceedings — it feeds background (block ו) only and must NOT
|
||||
drive the hearing-vs-pleadings comparison (#223). If every protocol is
|
||||
lower-scoped there is no ערר-hearing to compare → return None.
|
||||
- Among appeal-scoped protocols the most recent (by ``created_at``) wins —
|
||||
the operative hearing; ties fall back to list order.
|
||||
"""
|
||||
if document_id is not None:
|
||||
return next(
|
||||
(d for d in docs if str(d.get("id")) == str(document_id)), None,
|
||||
)
|
||||
|
||||
protocols = [d for d in docs if d.get("doc_type") == "protocol"]
|
||||
if not protocols:
|
||||
# Legacy fallback: untyped docs whose title says "פרוטוקול".
|
||||
protocols = [d for d in docs if "פרוטוקול" in (d.get("title") or "")]
|
||||
|
||||
appeal_scoped = [d for d in protocols if _protocol_scope(d) != "lower"]
|
||||
if not appeal_scoped:
|
||||
return None
|
||||
appeal_scoped.sort(key=lambda d: d.get("created_at") or "", reverse=True)
|
||||
return appeal_scoped[0]
|
||||
|
||||
|
||||
def _compact_arguments(arguments: list[dict]) -> list[dict]:
|
||||
"""Strip aggregated arguments down to what the comparison needs."""
|
||||
out = []
|
||||
for a in arguments:
|
||||
out.append({
|
||||
"argument_id": str(a["id"]),
|
||||
"party_role": a.get("party", ""),
|
||||
"title": a.get("argument_title", ""),
|
||||
"body": a.get("argument_body", ""),
|
||||
"topic": a.get("legal_topic", ""),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_change(raw: dict, valid_argument_ids: set[str]) -> dict | None:
|
||||
"""Validate & normalize one comparative-analysis row from Claude.
|
||||
|
||||
Returns None for unusable rows (missing change_type/summary, or — per the
|
||||
anti-hallucination gate — no supporting evidence_quote).
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
change_type = (raw.get("change_type") or "").strip()
|
||||
if change_type not in VALID_CHANGE_TYPES:
|
||||
return None
|
||||
summary = (raw.get("summary") or "").strip()
|
||||
evidence_quote = (raw.get("evidence_quote") or "").strip()
|
||||
# INV-AH (quote-or-retract): drop any verdict the model couldn't anchor to a
|
||||
# verbatim protocol excerpt — at source, so unfounded rows never reach the DB.
|
||||
if not summary or not evidence_quote:
|
||||
return None
|
||||
|
||||
party_role = (raw.get("party_role") or "").strip()
|
||||
if party_role not in VALID_PARTY_ROLES:
|
||||
party_role = ""
|
||||
|
||||
# argument_id only kept when it actually belongs to this case's arguments;
|
||||
# a newly_raised point has none, and a hallucinated id is dropped (FK safety).
|
||||
argument_id = None
|
||||
raw_aid = raw.get("argument_id")
|
||||
if raw_aid and str(raw_aid) in valid_argument_ids:
|
||||
try:
|
||||
argument_id = UUID(str(raw_aid))
|
||||
except (ValueError, TypeError):
|
||||
argument_id = None
|
||||
|
||||
page = raw.get("page_number")
|
||||
if not isinstance(page, int):
|
||||
page = None
|
||||
|
||||
return {
|
||||
"change_type": change_type,
|
||||
"party_role": party_role,
|
||||
"argument_id": argument_id,
|
||||
"argument_title": (raw.get("argument_title") or "").strip(),
|
||||
"summary": summary,
|
||||
"sharpened_question": (raw.get("sharpened_question") or "").strip(),
|
||||
"evidence_quote": evidence_quote[:200],
|
||||
"page_number": page,
|
||||
}
|
||||
|
||||
|
||||
async def _extract_header(protocol_text: str, case_id: UUID) -> dict:
|
||||
"""Extract א–ד header data and write it back to canonical case fields.
|
||||
|
||||
Returns the raw extracted feed (for provenance) plus a record of what was
|
||||
written back. Header data is NOT stored in protocol_analysis — it lives in
|
||||
the canonical `cases`/`decisions` columns (G2: single source of truth).
|
||||
"""
|
||||
prompt = HEADER_PROMPT.format(protocol_text=protocol_text[:MAX_PROTOCOL_CHARS])
|
||||
try:
|
||||
feed = await claude_session.query_json(
|
||||
prompt, model=ANALYSIS_MODEL, effort=HEADER_EFFORT, tools="",
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — surface, don't swallow (§6 no silent swallow)
|
||||
logger.warning("protocol header extraction failed for case %s: %s", case_id, e)
|
||||
return {"status": "header_failed", "error": str(e)}
|
||||
|
||||
if not isinstance(feed, dict):
|
||||
return {"status": "header_no_data"}
|
||||
|
||||
applied: dict = {}
|
||||
# hearing_date → canonical cases.hearing_date (only if the case lacks one,
|
||||
# so a chair-entered date is never overwritten by extraction).
|
||||
hearing_date = (feed.get("hearing_date") or "").strip()
|
||||
if hearing_date:
|
||||
from datetime import date as date_type
|
||||
try:
|
||||
parsed = date_type.fromisoformat(hearing_date)
|
||||
case = await db.get_case(case_id)
|
||||
if case and not case.get("hearing_date"):
|
||||
await db.update_case(case_id, hearing_date=parsed)
|
||||
applied["hearing_date"] = hearing_date
|
||||
except ValueError:
|
||||
logger.info("protocol header: unparseable hearing_date %r", hearing_date)
|
||||
|
||||
# Attendees → cases.hearing_attendees (#226). Unlike hearing_date there is no
|
||||
# chair-entry path to protect, and re-running to point at the correct ערר
|
||||
# protocol (#223) must refresh who appeared — so write whenever the operative
|
||||
# protocol yielded any names, but never clobber good data with an empty
|
||||
# extraction (all-empty feed → leave the prior snapshot intact).
|
||||
attendees = {
|
||||
"panel_members": feed.get("panel_members") or [],
|
||||
"appellants_present": feed.get("appellants_present") or [],
|
||||
"respondents_present": feed.get("respondents_present") or [],
|
||||
}
|
||||
if any(attendees.values()):
|
||||
await db.update_case(case_id, hearing_attendees=attendees)
|
||||
applied["hearing_attendees"] = True
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"feed": {
|
||||
"hearing_date": hearing_date,
|
||||
**attendees,
|
||||
},
|
||||
"applied_to_case": applied,
|
||||
}
|
||||
|
||||
|
||||
async def analyze_protocol(
|
||||
case_id: UUID, target_document_id: UUID | None = None,
|
||||
) -> dict:
|
||||
"""Comparative analysis of the case's hearing protocol vs. its pleadings.
|
||||
|
||||
``target_document_id`` pins the analysis to a specific protocol document —
|
||||
required when a case holds several protocols (e.g. a lower-committee annex
|
||||
plus the ועדת-הערר hearing) and the auto-pick would be ambiguous (#223).
|
||||
|
||||
1. Locates the protocol document.
|
||||
2. Pulls the aggregated legal_arguments (the written-pleadings baseline).
|
||||
3. Asks Claude (Opus 4.8, effort=high) to classify each as dropped /
|
||||
strengthened, and to surface newly-raised arguments + sharpened questions.
|
||||
4. Stores the verdicts in protocol_analysis (case-knowledge, idempotent).
|
||||
5. Extracts the א–ד header feed and writes hearing_date back to the case.
|
||||
|
||||
Returns a serializable summary dict.
|
||||
"""
|
||||
docs = await db.list_documents(case_id)
|
||||
protocol = _find_protocol(docs, document_id=target_document_id)
|
||||
if not protocol:
|
||||
if target_document_id is not None:
|
||||
return {
|
||||
"status": "no_protocol",
|
||||
"message": f"מסמך {target_document_id} לא נמצא בתיק.",
|
||||
}
|
||||
return {
|
||||
"status": "no_protocol",
|
||||
"message": (
|
||||
"לא נמצא פרוטוקול ועדת-ערר בתיק (doc_type='protocol' עם "
|
||||
"protocol_scope שאינו 'lower'). העלה פרוטוקול-דיון, או תייג את "
|
||||
"הפרוטוקול הקיים כ-appeal, והרץ שוב."
|
||||
),
|
||||
}
|
||||
|
||||
document_id = UUID(protocol["id"])
|
||||
protocol_text = await db.get_document_text(document_id)
|
||||
if not protocol_text or not protocol_text.strip():
|
||||
return {
|
||||
"status": "no_text",
|
||||
"message": "לפרוטוקול אין טקסט מחולץ. ודא שהעיבוד הסתיים והרץ שוב.",
|
||||
"document_id": str(document_id),
|
||||
}
|
||||
|
||||
# Use the aggregator's read path (single source of truth — no parallel
|
||||
# query for the same legal_arguments data, G2).
|
||||
from legal_mcp.services import argument_aggregator
|
||||
arguments = await argument_aggregator.get_legal_arguments(case_id)
|
||||
|
||||
if not arguments:
|
||||
return {
|
||||
"status": "no_arguments",
|
||||
"message": (
|
||||
"אין טיעונים מאוגדים להשוואה. הרץ extract_claims + "
|
||||
"aggregate_claims_to_arguments על כתבי-הטענות תחילה."
|
||||
),
|
||||
"document_id": str(document_id),
|
||||
}
|
||||
|
||||
compact_args = _compact_arguments(arguments)
|
||||
valid_argument_ids = {a["argument_id"] for a in compact_args}
|
||||
|
||||
prompt = COMPARE_PROMPT.format(
|
||||
arguments_json=json.dumps(compact_args, ensure_ascii=False, indent=2),
|
||||
protocol_text=protocol_text[:MAX_PROTOCOL_CHARS],
|
||||
)
|
||||
|
||||
try:
|
||||
raw_result = await claude_session.query_json(
|
||||
prompt, model=ANALYSIS_MODEL, effort=ANALYSIS_EFFORT, tools="",
|
||||
)
|
||||
except RuntimeError as e:
|
||||
msg = str(e)
|
||||
if "Claude CLI not found" in msg:
|
||||
return {
|
||||
"status": "llm_unavailable",
|
||||
"message": (
|
||||
"Claude CLI לא זמין. הניתוח ההשוואתי חייב לרוץ מה-MCP server "
|
||||
"המקומי, לא מהקונטיינר."
|
||||
),
|
||||
"document_id": str(document_id),
|
||||
}
|
||||
return {"status": "error", "message": msg, "document_id": str(document_id)}
|
||||
|
||||
if not isinstance(raw_result, list):
|
||||
logger.warning(
|
||||
"analyze_protocol: Claude returned non-list (%s) for case %s",
|
||||
type(raw_result).__name__, case_id,
|
||||
)
|
||||
raw_result = []
|
||||
|
||||
rows: list[dict] = []
|
||||
for entry in raw_result:
|
||||
norm = _normalize_change(entry, valid_argument_ids)
|
||||
if norm:
|
||||
rows.append(norm)
|
||||
|
||||
stored = await db.replace_protocol_analysis(case_id, document_id, rows)
|
||||
|
||||
# Extract header (א–ד) and write hearing_date back to the canonical case.
|
||||
header = await _extract_header(protocol_text, case_id)
|
||||
|
||||
by_change: dict[str, int] = {}
|
||||
for r in rows:
|
||||
by_change[r["change_type"]] = by_change.get(r["change_type"], 0) + 1
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"document_id": str(document_id),
|
||||
"protocol_title": protocol.get("title", ""),
|
||||
"total": stored,
|
||||
"by_change": by_change,
|
||||
"arguments_compared": len(compact_args),
|
||||
"header": header,
|
||||
}
|
||||
@@ -15,8 +15,14 @@ import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
from typing import Any
|
||||
|
||||
# Display timezone (INV-UI9): timestamps surfaced to humans are rendered in
|
||||
# Israel time deterministically, regardless of the process clock (the container
|
||||
# may run UTC or Asia/Jerusalem). Storage stays UTC; this is display-only.
|
||||
IL_TZ = ZoneInfo("Asia/Jerusalem")
|
||||
|
||||
# Placeholder strings — any of these means "not yet filled"
|
||||
CHAIR_POSITION_PLACEHOLDERS = (
|
||||
"[ימולא ע\"י יו\"ר הוועדה]",
|
||||
@@ -191,7 +197,7 @@ def parse(file_path: Path) -> dict[str, Any]:
|
||||
date_str = date_match.group(1) if date_match else ""
|
||||
|
||||
stat = file_path.stat()
|
||||
mtime_iso = datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||
mtime_iso = datetime.fromtimestamp(stat.st_mtime, tz=IL_TZ).isoformat()
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"header": {
|
||||
@@ -354,7 +360,7 @@ def update_chair_position(
|
||||
"saved": True,
|
||||
"section_id": section_id,
|
||||
"preview": preview,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"timestamp": datetime.now(IL_TZ).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import re
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import db
|
||||
from legal_mcp.services.learning_loop import compute_diff_stats
|
||||
from legal_mcp.services.lessons import ANTI_PATTERNS, GOLDEN_RATIOS, canonical_outcome
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -122,6 +123,93 @@ def golden_ratio_adherence(block_word_counts: dict[str, int], outcome: str) -> d
|
||||
return {"outcome": outcome, "total_words": total, "sections": sections, "max_deviation": max_dev}
|
||||
|
||||
|
||||
def split_final_by_section(final_text: str) -> dict[str, str]:
|
||||
"""Group a signed final decision into golden-ratio sections (#208 calibration).
|
||||
|
||||
Reuses the SAME structure-aware splitter as measure_corpus_ratios
|
||||
(chunker._split_into_sections + _CHUNK_SECTION_TO_GOLDEN) — no parallel
|
||||
parsing path (G2). Returns {golden_section: concatenated_text} for the
|
||||
sections that map to an AI block (background/claims/discussion/summary).
|
||||
A section type that does not map (e.g. headers) is dropped, never silently
|
||||
folded into another section. Best-effort: an unsplittable final returns {}.
|
||||
"""
|
||||
from legal_mcp.services.chunker import _split_into_sections
|
||||
|
||||
by_section: dict[str, list[str]] = {}
|
||||
for stype, stext in _split_into_sections(final_text or ""):
|
||||
g = _CHUNK_SECTION_TO_GOLDEN.get(stype)
|
||||
if g and stext.strip():
|
||||
by_section.setdefault(g, []).append(stext.strip())
|
||||
return {sec: "\n\n".join(parts) for sec, parts in by_section.items()}
|
||||
|
||||
|
||||
def block_distance_to_final(
|
||||
block_id: str,
|
||||
regenerated_text: str,
|
||||
final_section_text: str,
|
||||
outcome: str,
|
||||
section_target_total_words: int | None = None,
|
||||
) -> dict:
|
||||
"""Distance of ONE regenerated block from the chair's matching final section.
|
||||
|
||||
The per-(block, effort) measurement cell for the #208 model/effort
|
||||
calibration harness. Pure/deterministic (no LLM, no DB) — reuses the
|
||||
existing style-distance primitives so the harness has no parallel metric
|
||||
path (G2 / INV-G8 eval-harness):
|
||||
|
||||
• change_percent — compute_diff_stats(regen, final_section)
|
||||
(learning_loop, the SAME diff the pairing
|
||||
ledger stores). Lower ⇒ the draft already
|
||||
reads like the final ⇒ less chair rewriting.
|
||||
• anti_pattern_total — count_anti_patterns(regen) (lessons.ANTI_PATTERNS).
|
||||
Lower ⇒ closer to Dafna's continuous-narrative
|
||||
voice; the CLEANEST style signal (07-learning §0.7).
|
||||
• golden_ratio_deviation_pp — |regen %-of-total − final %-of-total| for this
|
||||
block's section. 0 ⇒ same structural weight as
|
||||
the final. Requires the final's total words
|
||||
(section_target_total_words); otherwise None
|
||||
(we never fabricate a denominator).
|
||||
|
||||
Returns the three metrics + a single composite `distance` (normalized,
|
||||
lower=closer) the harness ranks efforts by.
|
||||
"""
|
||||
outcome = canonical_outcome(outcome)
|
||||
diff = compute_diff_stats(regenerated_text or "", final_section_text or "")
|
||||
change_percent = diff["change_percent"]
|
||||
anti_total = count_anti_patterns(regenerated_text or "")["total"]
|
||||
|
||||
section = _BLOCK_TO_SECTION.get(block_id)
|
||||
regen_words = len((regenerated_text or "").split())
|
||||
final_words = len((final_section_text or "").split())
|
||||
ratio_dev: float | None = None
|
||||
if section and section_target_total_words and section_target_total_words > 0:
|
||||
# Replace the final's own block contribution with the regen's, holding
|
||||
# the rest of the final constant, to compare structural weight fairly.
|
||||
regen_total = section_target_total_words - final_words + regen_words
|
||||
if regen_total > 0:
|
||||
regen_pct = regen_words / regen_total * 100
|
||||
final_pct = final_words / section_target_total_words * 100
|
||||
ratio_dev = round(abs(regen_pct - final_pct), 1)
|
||||
|
||||
# Composite: normalize each component to ~[0,1] and average the present ones.
|
||||
# change_percent/100, anti_total/10 (10+ hits is already very bad), ratio/20.
|
||||
comps: list[float] = [min(change_percent / 100.0, 1.0), min(anti_total / 10.0, 1.0)]
|
||||
if ratio_dev is not None:
|
||||
comps.append(min(ratio_dev / 20.0, 1.0))
|
||||
distance = round(sum(comps) / len(comps), 4)
|
||||
|
||||
return {
|
||||
"block_id": block_id,
|
||||
"section": section,
|
||||
"regen_words": regen_words,
|
||||
"final_words": final_words,
|
||||
"change_percent": change_percent,
|
||||
"anti_pattern_total": anti_total,
|
||||
"golden_ratio_deviation_pp": ratio_dev,
|
||||
"distance": distance,
|
||||
}
|
||||
|
||||
|
||||
async def style_distance(case_number: str) -> dict:
|
||||
"""Assemble the 3 style-distance components for one case (T7)."""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
|
||||
83
mcp-server/src/legal_mcp/services/style_exemplars.py
Normal file
83
mcp-server/src/legal_mcp/services/style_exemplars.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Block-level style-exemplar extraction (channel B of Style Acquisition).
|
||||
|
||||
Splits one of Dafna's decisions into section→paragraph units, embeds them
|
||||
(Voyage), and stores them in `style_exemplars` so the writer can retrieve real
|
||||
block-level prose by section/outcome/practice_area (07-learning §0.2 channel B).
|
||||
|
||||
This is the SINGLE source of truth for exemplar extraction (G2): both the
|
||||
one-time backfill (`scripts/backfill_style_exemplars.py`) and the live
|
||||
final-enrollment path (`_enroll_final_in_library`) call `extract_and_store`.
|
||||
Before this, exemplars were frozen at the seed backfill — new finals enrolled
|
||||
into style_corpus but were never broken into exemplars, so the richest style
|
||||
channel never grew. Embedding is Voyage-over-REST → container-safe.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from legal_mcp.services import db, embeddings
|
||||
from legal_mcp.services.chunker import _split_into_sections
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# chunker section_type → style_exemplars.section
|
||||
_SECTION_MAP = {
|
||||
"facts": "background",
|
||||
"appellant_claims": "claims",
|
||||
"respondent_claims": "claims",
|
||||
"legal_analysis": "discussion",
|
||||
"conclusion": "summary",
|
||||
"ruling": "summary",
|
||||
"intro": "other",
|
||||
"other": "other",
|
||||
}
|
||||
|
||||
MIN_WORDS = 25 # skip tiny fragments
|
||||
MAX_WORDS = 450 # skip over-long blobs (likely un-split)
|
||||
MAX_PER_SECTION = 15
|
||||
|
||||
|
||||
def _paragraphs(section_text: str) -> list[str]:
|
||||
"""Split a section into paragraph units (blank-line separated; fall back to lines)."""
|
||||
raw = [p.strip() for p in section_text.split("\n\n")]
|
||||
if len(raw) <= 1:
|
||||
raw = [p.strip() for p in section_text.split("\n")]
|
||||
out = []
|
||||
for p in raw:
|
||||
wc = len(p.split())
|
||||
if MIN_WORDS <= wc <= MAX_WORDS:
|
||||
out.append(p)
|
||||
return out[:MAX_PER_SECTION]
|
||||
|
||||
|
||||
def units_for(full_text: str) -> list[tuple[str, str]]:
|
||||
"""(section, paragraph) units for a decision — pure, no I/O."""
|
||||
units: list[tuple[str, str]] = []
|
||||
for section_type, section_text in _split_into_sections(full_text or ""):
|
||||
section = _SECTION_MAP.get(section_type, "other")
|
||||
for para in _paragraphs(section_text):
|
||||
units.append((section, para))
|
||||
return units
|
||||
|
||||
|
||||
async def extract_and_store(
|
||||
decision_number: str, source: str, full_text: str,
|
||||
practice_area: str = "", outcome: str = "",
|
||||
) -> int:
|
||||
"""Idempotently (re)build a decision's block-level style exemplars: split →
|
||||
embed → replace. Returns the number of exemplars stored. Raises on failure;
|
||||
callers in best-effort paths (enroll) should wrap in try/except."""
|
||||
units = units_for(full_text)
|
||||
if not units:
|
||||
return 0
|
||||
texts = [u[1] for u in units]
|
||||
vecs = await embeddings.embed_texts(texts, input_type="document")
|
||||
await db.delete_style_exemplars(decision_number, source)
|
||||
for (section, para), vec in zip(units, vecs):
|
||||
await db.insert_style_exemplar(
|
||||
decision_number=decision_number, source=source,
|
||||
practice_area=practice_area, outcome=outcome,
|
||||
section=section, paragraph_text=para, word_count=len(para.split()),
|
||||
embedding=vec,
|
||||
)
|
||||
return len(units)
|
||||
@@ -13,6 +13,7 @@ from uuid import UUID
|
||||
import httpx
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.case_status_model import STATUS_ORDER # status SSoT
|
||||
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
|
||||
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||
|
||||
@@ -341,16 +342,8 @@ async def case_update(
|
||||
"""
|
||||
from datetime import date as date_type
|
||||
|
||||
# Ordered core lifecycle — regression protection (forward-only).
|
||||
# Single source of truth, mirrored by web-ui/src/lib/api/case-status.ts and
|
||||
# models.CaseStatus. Trimmed from 17 → 10 (decorative statuses removed).
|
||||
STATUS_ORDER = [
|
||||
"new", "processing", "documents_ready",
|
||||
"outcome_set", "direction_approved",
|
||||
"qa_review", "drafted",
|
||||
"exported", "reviewed", "final",
|
||||
]
|
||||
|
||||
# Ordered core lifecycle (forward-only regression guard). Single source of
|
||||
# truth: legal_mcp/case_status_model.py — STATUS_ORDER/BY_KEY derive from it.
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return err(f"תיק {case_number} לא נמצא.")
|
||||
@@ -363,6 +356,9 @@ async def case_update(
|
||||
# Only update if advancing or status is unknown to the order
|
||||
if new_idx >= cur_idx or new_idx == -1:
|
||||
fields["status"] = status
|
||||
# per-status on_enter hook (case_status_model) — the single place a
|
||||
# status's behaviour is declared. No dispatcher wired yet; when the
|
||||
# first action is added, dispatch BY_KEY[status].on_enter here.
|
||||
if title:
|
||||
fields["title"] = title
|
||||
if subject:
|
||||
|
||||
@@ -170,3 +170,30 @@ async def digest_process_pending(limit: int = 20) -> str:
|
||||
except Exception as e:
|
||||
return _err(str(e))
|
||||
return _ok(result)
|
||||
|
||||
|
||||
async def digest_radar(case_number: str, limit: int = 5, min_score: float = 0.45) -> str:
|
||||
"""רדאר-יומונים הקשרי-לתיק (X12) — "שים לב" ליו"ר.
|
||||
|
||||
מחזיר יומונים **לא-מקושרים** (פס"ד שעוד אין לנו בקורפוס) שהנושא שלהם קרוב
|
||||
סמנטית לתיק הזה — כדי שפס"ד רלוונטי שמוכר רק דרך יומון לא ייפול בין הכיסאות
|
||||
בזמן הכרעת התיק. נושא-התיק נבנה מ-title + appeal_subtype + טענות-הצדדים, ומותאם
|
||||
מול יומונים לא-מקושרים. כל ליד נושא את מראה-המקום של הפס"ד המקורי, סטטוס-הפער
|
||||
(new_lead / gap_open / fetched / available_link) ופעולה מוצעת.
|
||||
|
||||
INV-DIG1: זהו radar — היומון לעולם אינו מצוטט; הליד מצביע על **הפס"ד** (להזמין
|
||||
משיכה / להעלות / לקשר), לא על היומון. read-only.
|
||||
|
||||
Args:
|
||||
case_number: מספר התיק (למשל "8124-09-24").
|
||||
limit: מספר לידים מקסימלי.
|
||||
min_score: סף-דמיון סמנטי (0-1) לסינון רעש.
|
||||
"""
|
||||
if not case_number.strip():
|
||||
return _err("case_number חובה")
|
||||
try:
|
||||
result = await digest_library.case_digest_radar(
|
||||
case_number.strip(), limit=max(1, int(limit)), min_score=float(min_score))
|
||||
except Exception as e:
|
||||
return _err(str(e))
|
||||
return _ok(result)
|
||||
|
||||
@@ -264,6 +264,11 @@ async def document_get_text(case_number: str, doc_title: str = "") -> str:
|
||||
async def document_list(case_number: str) -> str:
|
||||
"""רשימת מסמכים בתיק.
|
||||
|
||||
כל מסמך כולל `doc_type`, וכן את הסיווג הנגזר `is_primary` (bool) ו-
|
||||
`doc_category` ("primary"/"secondary") — מסמך-עיקרי (ערר/תשובה/התנגדות/
|
||||
פרוטוקול/שומה/החלטת-ועדה) מול משני. נגזר מ-`doc_type` (db.PRIMARY_DOC_TYPES),
|
||||
אינו נכתב ידנית.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
"""
|
||||
@@ -345,11 +350,15 @@ async def extract_claims(
|
||||
if not docs:
|
||||
return empty(f"אין מסמכים בתיק {case_number}.")
|
||||
|
||||
# Filter to claims documents (appeal, response) or specific doc
|
||||
# Filter to claims documents or a specific doc. protocol carries oral
|
||||
# arguments raised at the hearing — extracted with claim_type='protocol'
|
||||
# (claims_extractor._infer_claim_type) so analyze_protocol can compare them
|
||||
# against the written pleadings, while block ז (write_block_zayin) excludes
|
||||
# them to stay original-pleadings-only (INV-WR4).
|
||||
if doc_title:
|
||||
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
|
||||
else:
|
||||
docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection")]
|
||||
docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection", "protocol")]
|
||||
|
||||
if not docs:
|
||||
return empty("לא נמצאו כתבי טענות בתיק.")
|
||||
@@ -424,17 +433,24 @@ ALLOWED_DOC_TYPES = {
|
||||
# Allowed appraiser_side values; '' (empty) clears the tag.
|
||||
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
|
||||
|
||||
# Allowed protocol_scope values (protocol docs only); mirrors web/app.py.
|
||||
# appeal = ועדת הערר (default when absent) · lower = ועדה מקומית/מחוזית.
|
||||
ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
|
||||
|
||||
|
||||
async def document_update(
|
||||
case_number: str,
|
||||
doc_id: str,
|
||||
doc_type: str = "",
|
||||
appraiser_side: str = "",
|
||||
is_post_hearing: bool | None = None,
|
||||
protocol_scope: str = "",
|
||||
) -> str:
|
||||
"""עדכון תיוג מסמך — doc_type ו/או appraiser_side. ריק = אין שינוי.
|
||||
"""עדכון תיוג מסמך — doc_type / appraiser_side / is_post_hearing / protocol_scope.
|
||||
|
||||
הולידציה זהה ל-PATCH endpoint ב-web/app.py. appraiser_side נשמר ב-
|
||||
documents.metadata JSONB (מתפרסם משם ע"י extract_appraiser_facts).
|
||||
הולידציה זהה ל-PATCH endpoint ב-web/app.py. הדגלים נשמרים ב-
|
||||
documents.metadata JSONB (appraiser_side מתפרסם משם ע"י extract_appraiser_facts;
|
||||
is_post_hearing נצרך ע"י כותב בלוק-ח; protocol_scope מסווג פרוטוקול בטאב-הסקירה).
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר (לאישור שייכות)
|
||||
@@ -443,6 +459,9 @@ async def document_update(
|
||||
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
|
||||
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
|
||||
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
|
||||
is_post_hearing: True = התקבל אחרי הדיון · False = מנקה את הדגל · None = אין שינוי.
|
||||
protocol_scope: היקף פרוטוקול (appeal=ועדת הערר / lower=ועדה מקומית-מחוזית).
|
||||
ריק = אין שינוי. רלוונטי רק כשהסוג protocol.
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
@@ -468,17 +487,37 @@ async def document_update(
|
||||
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
|
||||
updates["doc_type"] = doc_type
|
||||
|
||||
# appraiser_side is optional. The MCP tool can't distinguish "skip" from
|
||||
# "set to empty string", so we use the convention: only update if non-empty.
|
||||
# To clear, the operator must edit metadata directly (rare).
|
||||
# appraiser_side / protocol_scope are optional. The MCP tool can't
|
||||
# distinguish "skip" from "set to empty string", so we use the convention:
|
||||
# only update if non-empty. To clear, edit metadata directly (rare).
|
||||
# is_post_hearing is a tri-state bool: None skips, True/False set/clear.
|
||||
metadata = doc.get("metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
metadata = json.loads(metadata)
|
||||
metadata_dirty = False
|
||||
|
||||
if appraiser_side:
|
||||
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
|
||||
return err(f"appraiser_side לא תקין: {appraiser_side}",
|
||||
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
|
||||
metadata = doc.get("metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
metadata = json.loads(metadata)
|
||||
metadata["appraiser_side"] = appraiser_side
|
||||
metadata_dirty = True
|
||||
|
||||
if is_post_hearing is not None:
|
||||
if is_post_hearing:
|
||||
metadata["is_post_hearing"] = True
|
||||
else:
|
||||
metadata.pop("is_post_hearing", None)
|
||||
metadata_dirty = True
|
||||
|
||||
if protocol_scope:
|
||||
if protocol_scope not in ALLOWED_PROTOCOL_SCOPES:
|
||||
return err(f"protocol_scope לא תקין: {protocol_scope}",
|
||||
data={"allowed": sorted(ALLOWED_PROTOCOL_SCOPES)})
|
||||
metadata["protocol_scope"] = protocol_scope
|
||||
metadata_dirty = True
|
||||
|
||||
if metadata_dirty:
|
||||
updates["metadata"] = metadata
|
||||
|
||||
if not updates:
|
||||
|
||||
@@ -516,9 +516,18 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
|
||||
|
||||
# ── Interim draft (pre-ruling) ────────────────────────────────────
|
||||
|
||||
# Blocks written for the interim draft, in display order.
|
||||
# Blocks written for the interim draft, in a FIXED order.
|
||||
# This is the same content the chair sees in the final decision (same template,
|
||||
# same skill, same prompts) — minus opening, ruling, summary, signatures.
|
||||
# same skill, same prompts, same single canonical write path — block_writer) —
|
||||
# minus ruling, summary, signatures.
|
||||
#
|
||||
# Determinism (#204 / WS5): the interim draft is STRUCTURALLY deterministic.
|
||||
# Each block is generated by block_writer.write_block, which now pins the model
|
||||
# (Opus 4.8) and a per-block reasoning effort, and uses fixed structural prompts
|
||||
# (e.g. block-he's opening is always "לפנינו ערר…"). The display order on export
|
||||
# is fixed separately by docx_exporter._INTERIM_BLOCK_ORDER. There is no parallel
|
||||
# generation path (G2): write_interim_draft is a thin orchestrator over the same
|
||||
# write_and_store_block used by the full decision.
|
||||
_INTERIM_BLOCKS = ["block-he", "block-vav", "block-tet", "block-zayin", "block-chet"]
|
||||
|
||||
|
||||
@@ -571,6 +580,86 @@ async def get_appraiser_facts(case_number: str) -> str:
|
||||
return err(str(e))
|
||||
|
||||
|
||||
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
|
||||
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203).
|
||||
|
||||
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
|
||||
מחדד את השאלות המשפטיות, ומחלץ את נתוני הכותרת (א–ד) מהפרוטוקול. התוצאה נכנסת
|
||||
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
|
||||
מוזן חזרה לעמודה הקנונית cases.hearing_date.
|
||||
|
||||
בוחר אוטומטית את פרוטוקול **ועדת-הערר** (protocol_scope שאינו 'lower');
|
||||
פרוטוקול ועדה-מקומית ('lower') אינו נכנס להשוואה. כשיש כמה פרוטוקולי-ערר —
|
||||
ציין document_id כדי לכוון לדיון המדויק (#223).
|
||||
|
||||
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
|
||||
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
|
||||
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
document_id: מזהה מסמך-הפרוטוקול לכיוון מדויק (ריק = בחירה אוטומטית)
|
||||
"""
|
||||
from legal_mcp.services import protocol_analyzer
|
||||
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return err(f"תיק {case_number} לא נמצא.")
|
||||
case_id = UUID(case["id"])
|
||||
target_doc_id: UUID | None = None
|
||||
if document_id.strip():
|
||||
try:
|
||||
target_doc_id = UUID(document_id.strip())
|
||||
except ValueError:
|
||||
return err(f"document_id לא תקין: {document_id}")
|
||||
try:
|
||||
result = await protocol_analyzer.analyze_protocol(
|
||||
case_id, target_document_id=target_doc_id,
|
||||
)
|
||||
await audit.log_action_safe(
|
||||
"analyze_protocol", case_id=case_id,
|
||||
details={"status": result.get("status"), "total": result.get("total", 0)},
|
||||
)
|
||||
return ok(result)
|
||||
except Exception as e:
|
||||
return err(str(e))
|
||||
|
||||
|
||||
async def get_protocol_analysis(case_number: str, change_type: str = "") -> str:
|
||||
"""קריאת ניתוח-הפרוטוקול שכבר חולץ — ללא הרצת ניתוח-מחדש (INV-TOOL4).
|
||||
|
||||
ה-get המקביל ל-analyze_protocol: מחזיר את רשומות ידע-התיק השמורות
|
||||
(ירדה/חוזקה/חדשה + שאלה-מחודדת + ציטוט-מבסס), בלי קריאת-LLM יקרה.
|
||||
מחזיר רשימה ריקה אם הניתוח טרם רץ (status=ok, count=0) — לא שגיאה.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
change_type: סינון (dropped/strengthened/newly_raised). ריק = הכל.
|
||||
"""
|
||||
from legal_mcp.services import protocol_analyzer
|
||||
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return err(f"תיק {case_number} לא נמצא.")
|
||||
if change_type and change_type not in protocol_analyzer.VALID_CHANGE_TYPES:
|
||||
return err(
|
||||
f"change_type לא תקין: {change_type}",
|
||||
data={"allowed": sorted(protocol_analyzer.VALID_CHANGE_TYPES)},
|
||||
)
|
||||
case_id = UUID(case["id"])
|
||||
try:
|
||||
rows = await db.list_protocol_analysis(
|
||||
case_id, change_type=change_type or None,
|
||||
)
|
||||
return ok({
|
||||
"case_number": case_number,
|
||||
"count": len(rows),
|
||||
"analysis": rows,
|
||||
})
|
||||
except Exception as e:
|
||||
return err(str(e))
|
||||
|
||||
|
||||
async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
||||
"""כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט),
|
||||
טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין —
|
||||
@@ -627,7 +716,7 @@ async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
||||
async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
||||
"""ייצוא טיוטת ביניים ל-DOCX — אותו עיצוב של טיוטה רגילה (David, RTL,
|
||||
bookmarks), אבל בסדר חדש: רקע → תכניות+היתרים → טענות → הליכים, ללא
|
||||
דיון/סיכום/חתימות. שם הקובץ: טיוטת-ביניים-v{N}.docx.
|
||||
דיון/סיכום/חתימות. שם הקובץ: טיוטה-טענות_הצדדים_{N}.docx.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
@@ -658,6 +747,34 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
||||
return err(str(e))
|
||||
|
||||
|
||||
async def summarize_party_claims(case_number: str, instructions: str = "") -> str:
|
||||
"""הפקת סיכום-מנהלים מזוקק של טענות הצדדים — מסמך פרוזה **נפרד מטיוטת-הביניים**,
|
||||
להכנת היו"ר לדיון בעל-פה. תמצית קצרה ומוקפדת (לא שכפול כתבי-הטענות), נגזרת
|
||||
מהטיעונים המאוגדים (legal_arguments) או מ-claims הגולמיים — אותו מקור-אמת,
|
||||
בלי חילוץ-מחדש. נשמר ל-data/cases/{n}/documents/research/ (git + S3) וניתן-לייצוא.
|
||||
|
||||
הייצור עובר claude_session (claude -p), נעוץ Opus 4.8 + effort=high. מתוחם
|
||||
לטענות-התיק בלבד (anti-hallucination) — אינו ממציא טענה/הלכה/עובדה שאינה בקלט.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
instructions: הנחיות נוספות מהיו"ר (אופציונלי)
|
||||
"""
|
||||
from legal_mcp.services import party_claims_summary
|
||||
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return err(f"תיק {case_number} לא נמצא.")
|
||||
case_id = UUID(case["id"])
|
||||
try:
|
||||
result = await party_claims_summary.summarize_party_claims(
|
||||
case_id, case_number, instructions,
|
||||
)
|
||||
return ok(result)
|
||||
except ValueError as e:
|
||||
return err(str(e))
|
||||
|
||||
|
||||
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
||||
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.
|
||||
|
||||
|
||||
@@ -4,9 +4,18 @@ from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import argument_aggregator, db
|
||||
from legal_mcp.services import argument_aggregator, claims_extractor, db
|
||||
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||
|
||||
# Party labels reused across this module (display + impact diff).
|
||||
_PARTY_HE = {
|
||||
"appellant": "עוררים",
|
||||
"respondent": "משיבים",
|
||||
"committee": "ועדה מקומית",
|
||||
"permit_applicant": "מבקשי היתר",
|
||||
"unknown": "צד לא מזוהה",
|
||||
}
|
||||
|
||||
|
||||
async def aggregate_claims_to_arguments(
|
||||
case_number: str,
|
||||
@@ -55,16 +64,9 @@ async def get_legal_arguments(
|
||||
)
|
||||
|
||||
# Group by party for nicer display.
|
||||
party_he = {
|
||||
"appellant": "עוררים",
|
||||
"respondent": "משיבים",
|
||||
"committee": "ועדה מקומית",
|
||||
"permit_applicant": "מבקשי היתר",
|
||||
"unknown": "צד לא מזוהה",
|
||||
}
|
||||
by_party: dict[str, list[dict]] = {}
|
||||
for a in args:
|
||||
label = party_he.get(a["party"], a["party"])
|
||||
label = _PARTY_HE.get(a["party"], a["party"])
|
||||
by_party.setdefault(label, []).append(a)
|
||||
|
||||
return ok({
|
||||
@@ -72,3 +74,147 @@ async def get_legal_arguments(
|
||||
"total": len(args),
|
||||
"by_party": by_party,
|
||||
})
|
||||
|
||||
|
||||
# ── Re-analysis with merge + impact diff (WS2 / #201) ───────────────
|
||||
|
||||
def _snapshot(args: list[dict]) -> dict:
|
||||
"""Build a comparable snapshot of aggregated arguments for the impact diff.
|
||||
|
||||
Captures, per party: the set of argument titles and a priority histogram —
|
||||
enough to show the chair what changed without dumping full bodies. The
|
||||
priority mix is the deterministic "recommendation" signal: it summarises the
|
||||
balance of threshold/substantive/procedural/relief arguments per side.
|
||||
"""
|
||||
by_party: dict[str, dict] = {}
|
||||
for a in args:
|
||||
party = a.get("party", "unknown")
|
||||
bucket = by_party.setdefault(party, {"titles": [], "priorities": {}})
|
||||
bucket["titles"].append((a.get("argument_title") or "").strip())
|
||||
pr = a.get("priority", "substantive")
|
||||
bucket["priorities"][pr] = bucket["priorities"].get(pr, 0) + 1
|
||||
return {"total": len(args), "by_party": by_party}
|
||||
|
||||
|
||||
def _impact_diff(before: dict, after: dict) -> dict:
|
||||
"""Diff two argument snapshots → a chair-facing before↔after summary.
|
||||
|
||||
Reports, per party, which argument titles were added/removed and how the
|
||||
priority mix (the "recommendation" balance) shifted. ``changed`` is a quick
|
||||
boolean the UI/chair can gate on. Nothing here is auto-applied — it is purely
|
||||
surfaced for the human gate (G10).
|
||||
"""
|
||||
parties = sorted(set(before["by_party"]) | set(after["by_party"]))
|
||||
per_party: list[dict] = []
|
||||
any_change = False
|
||||
for party in parties:
|
||||
b = before["by_party"].get(party, {"titles": [], "priorities": {}})
|
||||
a = after["by_party"].get(party, {"titles": [], "priorities": {}})
|
||||
b_titles, a_titles = set(b["titles"]), set(a["titles"])
|
||||
added = sorted(a_titles - b_titles)
|
||||
removed = sorted(b_titles - a_titles)
|
||||
prio_before = dict(sorted(b["priorities"].items()))
|
||||
prio_after = dict(sorted(a["priorities"].items()))
|
||||
changed = bool(added or removed or prio_before != prio_after)
|
||||
any_change = any_change or changed
|
||||
per_party.append({
|
||||
"party": party,
|
||||
"party_he": _PARTY_HE.get(party, party),
|
||||
"count_before": len(b["titles"]),
|
||||
"count_after": len(a["titles"]),
|
||||
"added_arguments": added,
|
||||
"removed_arguments": removed,
|
||||
"priority_before": prio_before,
|
||||
"priority_after": prio_after,
|
||||
"changed": changed,
|
||||
})
|
||||
return {
|
||||
"changed": any_change,
|
||||
"total_before": before["total"],
|
||||
"total_after": after["total"],
|
||||
"by_party": per_party,
|
||||
}
|
||||
|
||||
|
||||
async def reanalyze_claims(
|
||||
case_number: str,
|
||||
reanalyze_all_primary: bool = False,
|
||||
) -> str:
|
||||
"""ניתוח-מחדש מאחד של טענות — מחלץ ממסמכים-עיקריים חדשים/לא-נותחו בלבד,
|
||||
מאחד עם הטענות הקיימות (לא מוחק הכול), מריץ צבירה-מחדש, ומחזיר בדיקת-השפעה
|
||||
(diff טיעונים/המלצה לפני↔אחרי) ליו"ר.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר.
|
||||
reanalyze_all_primary: True = לחלץ מחדש מכל המסמכים-העיקריים (ולא רק
|
||||
מאלה שטרם-נותחו). ברירת-מחדל False = רק מסמכים-עיקריים חדשים/לא-נותחו.
|
||||
|
||||
מנגנון האיחוד (לא force-delete): כל מסמך מחולץ דרך
|
||||
``claims_extractor.extract_and_store_claims``, ש-``store_claims`` שלו מחליף רק
|
||||
את טענות *אותו* מסמך (לפי ``source_document``) — כך טענות ממסמכים שכבר-נותחו
|
||||
נשמרות. הצבירה-מחדש (``aggregate_claims_to_arguments(force=True)``) מחשבת את
|
||||
הטיעונים מחדש מתוך **מערך-הטענות המאוחד השלם** — מסלול-החישוב הקנוני, לא מסלול
|
||||
מקביל (G2).
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return err(f"תיק {case_number} לא נמצא.")
|
||||
|
||||
case_id = UUID(case["id"])
|
||||
|
||||
# 1. BEFORE snapshot — current aggregated arguments (the impact baseline).
|
||||
before_args = await argument_aggregator.get_legal_arguments(case_id)
|
||||
before = _snapshot(before_args)
|
||||
|
||||
# 2. Select PRIMARY documents to (re)extract. Default: only those not yet
|
||||
# analysed (the not-analysed flag). reanalyze_all_primary widens to every
|
||||
# primary doc. Source of truth for "primary" = is_primary (V47).
|
||||
if reanalyze_all_primary:
|
||||
all_docs = await db.list_documents(case_id)
|
||||
targets = [d for d in all_docs if d.get("is_primary")]
|
||||
else:
|
||||
targets = await db.primary_docs_not_analyzed(case_id)
|
||||
|
||||
if not targets:
|
||||
return ok({
|
||||
"case_number": case_number,
|
||||
"status": "no_pending_documents",
|
||||
"message": "אין מסמכים-עיקריים חדשים/לא-נותחו. לא בוצע ניתוח-מחדש.",
|
||||
"documents_analyzed": [],
|
||||
"impact": _impact_diff(before, before),
|
||||
})
|
||||
|
||||
# 3. Extract+merge claims from the selected documents only. Each call replaces
|
||||
# only that document's claims (merge), never the whole case (no force-delete).
|
||||
analyzed: list[dict] = []
|
||||
for doc in targets:
|
||||
text = await db.get_document_text(UUID(doc["id"]))
|
||||
if not text:
|
||||
await db.mark_document_claims_extracted(UUID(doc["id"]), status="no_claims")
|
||||
analyzed.append({"document": doc["title"], "status": "empty_text", "total": 0})
|
||||
continue
|
||||
res = await claims_extractor.extract_and_store_claims(
|
||||
case_id=case_id,
|
||||
document_id=UUID(doc["id"]),
|
||||
text=text,
|
||||
doc_type=doc["doc_type"],
|
||||
)
|
||||
analyzed.append({"document": doc["title"], **res})
|
||||
|
||||
# 4. Re-aggregate from the now-complete merged claim set. force=True is the
|
||||
# canonical recompute (deletes only legal_arguments, NOT claims) — same
|
||||
# path aggregate_claims_to_arguments always uses, not a fork.
|
||||
agg = await argument_aggregator.aggregate_claims_to_arguments(case_id, force=True)
|
||||
|
||||
# 5. AFTER snapshot + impact diff for the chair.
|
||||
after_args = await argument_aggregator.get_legal_arguments(case_id)
|
||||
after = _snapshot(after_args)
|
||||
impact = _impact_diff(before, after)
|
||||
|
||||
return ok({
|
||||
"case_number": case_number,
|
||||
"status": "completed",
|
||||
"documents_analyzed": analyzed,
|
||||
"aggregation": agg,
|
||||
"impact": impact,
|
||||
})
|
||||
|
||||
@@ -300,6 +300,20 @@ async def search_precedent_library(
|
||||
limit=limit,
|
||||
include_halachot=include_halachot,
|
||||
)
|
||||
# X11 Phase 2 (#154): attach the incoming-citation authority breakdown so the
|
||||
# research agent can WEIGH and ARGUE authority ("הלכה שאומצה ב-N החלטות ועדת-ערר")
|
||||
# — and steer clear of a precedent that committees repeatedly distinguished /
|
||||
# overruled. Batched: one query for the whole result page.
|
||||
try:
|
||||
ids = {str(r.get("case_law_id")) for r in results if r.get("case_law_id")}
|
||||
if ids:
|
||||
auth = await db.citation_authority([UUID(i) for i in ids])
|
||||
for r in results:
|
||||
cb = auth.get(str(r.get("case_law_id")))
|
||||
if cb:
|
||||
r["cited_by"] = cb
|
||||
except Exception: # noqa: BLE001 — authority is an additive signal; never break search
|
||||
pass
|
||||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||
telemetry.log_search_bg(
|
||||
search_type="precedent_library",
|
||||
|
||||
@@ -46,8 +46,19 @@ async def workflow_status(case_number: str) -> str:
|
||||
"extraction": doc["extraction_status"],
|
||||
"chunks": chunk_map.get(doc["id"], 0),
|
||||
"pages": doc.get("page_count"),
|
||||
# WS2 / #201: primary/secondary + whether claims were analysed.
|
||||
"is_primary": doc.get("is_primary", False),
|
||||
"claims_analyzed": doc.get("claims_analyzed", False),
|
||||
})
|
||||
|
||||
# WS2 / #201: PRIMARY documents added after the analysis ran and not yet
|
||||
# included → the "not-analysed" condition that should prompt a merging
|
||||
# re-analysis (reanalyze_claims). Derived from is_primary + claims_extracted_at.
|
||||
pending_primary = await db.primary_docs_not_analyzed(case_id)
|
||||
primary_not_analyzed = [
|
||||
{"title": d["title"], "type": d["doc_type"]} for d in pending_primary
|
||||
]
|
||||
|
||||
# Check draft status
|
||||
from pathlib import Path
|
||||
from legal_mcp import config
|
||||
@@ -64,15 +75,19 @@ async def workflow_status(case_number: str) -> str:
|
||||
"documents": doc_status,
|
||||
"total_documents": len(docs),
|
||||
"total_chunks": sum(chunk_map.values()),
|
||||
"primary_docs_not_analyzed": primary_not_analyzed,
|
||||
"has_draft": has_draft,
|
||||
"draft_size_bytes": draft_size,
|
||||
"next_steps": _suggest_next_steps(case, docs, has_draft),
|
||||
"next_steps": _suggest_next_steps(case, docs, has_draft, primary_not_analyzed),
|
||||
}
|
||||
|
||||
return ok(status)
|
||||
|
||||
|
||||
def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]:
|
||||
def _suggest_next_steps(
|
||||
case: dict, docs: list, has_draft: bool,
|
||||
primary_not_analyzed: list | None = None,
|
||||
) -> list[str]:
|
||||
"""Suggest next steps based on case state."""
|
||||
steps = []
|
||||
doc_types = {d["doc_type"] for d in docs}
|
||||
@@ -85,6 +100,13 @@ def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]:
|
||||
if "response" not in doc_types:
|
||||
steps.append("העלה תשובת ועדה/משיבים")
|
||||
|
||||
# WS2 / #201: a primary document was added but not yet analysed.
|
||||
if primary_not_analyzed:
|
||||
titles = ", ".join(d["title"] for d in primary_not_analyzed)
|
||||
steps.append(
|
||||
f"מסמך-עיקרי לא-נותח ({titles}) — הרץ ניתוח-מחדש מאחד (reanalyze_claims)"
|
||||
)
|
||||
|
||||
pending = [d for d in docs if d["extraction_status"] == "pending"]
|
||||
if pending:
|
||||
steps.append(f"עיבוד {len(pending)} מסמכים ממתינים")
|
||||
@@ -394,13 +416,134 @@ async def record_chair_feedback(
|
||||
lesson_extracted=lesson_extracted,
|
||||
)
|
||||
|
||||
# Auto-flow chair-authored STYLE feedback to the writer (closes the dead
|
||||
# chair_feedback→lesson chain — 27 feedback rows had produced 0 lessons). The
|
||||
# chair is the highest authority, so a style correction she writes flows
|
||||
# immediately — the chair IS the gate. It rides the SAME discussion_rules channel
|
||||
# promote uses (db.append_global_rule, G2), reaching every block, without the
|
||||
# style_corpus coupling decision_lessons require. SUBSTANCE feedback
|
||||
# (missing_content/factual_error/other) is case-specific → recorded only.
|
||||
# (INV-LRN1 graduated gate; 07-learning §1.2.)
|
||||
_STYLE_FB = {"style", "wrong_tone", "wrong_structure"}
|
||||
flowed = 0
|
||||
if lesson_extracted.strip() and category in _STYLE_FB:
|
||||
try:
|
||||
flowed = await db.append_global_rule(
|
||||
"discussion_rules", "universal", [lesson_extracted.strip()],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("chair-feedback auto-flow failed for %s: %s", case_number, e)
|
||||
|
||||
msg = f"הערה נרשמה בהצלחה. קטגוריה: {category}."
|
||||
if flowed:
|
||||
msg += " הלקח (סגנון) זרם אוטומטית לכותב."
|
||||
return ok({
|
||||
"feedback_id": str(feedback_id),
|
||||
"flowed_to_writer": bool(flowed),
|
||||
"next_steps": [
|
||||
"כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback",
|
||||
"כדי לסמן כמטופל: resolve_chair_feedback",
|
||||
],
|
||||
}, message=f"הערה נרשמה בהצלחה. קטגוריה: {category}.")
|
||||
}, message=msg)
|
||||
|
||||
|
||||
_CURATOR_FINDING_CATEGORIES = {"style", "structure", "lexicon", "tabular", "general"}
|
||||
# Agent tags ([סגנון]/[מבנה]/[לקסיקון משפטי]/[טבלאי]) → decision_lessons.category
|
||||
_CURATOR_TAG_TO_CATEGORY = {
|
||||
"סגנון": "style", "מבנה": "structure",
|
||||
"לקסיקון משפטי": "lexicon", "לקסיקון": "lexicon", "טבלאי": "tabular",
|
||||
}
|
||||
|
||||
|
||||
async def record_curator_findings(case_number: str, findings: list[dict]) -> str:
|
||||
"""לכידת ממצאי-האוצֵר כ-decision_lessons מובְנים (source='curator', proposed) — INV-LRN3.
|
||||
|
||||
האוצֵר מזהה דפוסי-סגנון בקריאת הסופי; עד כה הם חיו רק כהערה ארעית בערוץ-התגובות
|
||||
(אובד, לא נסקר). כאן הם נתפסים מבנית כך שיופיעו בטאב ״אוצֵר״ ויעברו שער-יו"ר (INV-LRN1/G10).
|
||||
האוצֵר נשאר read-only על התוכן — הרישום הוא הצעה הממתינה לאישור, לא שינוי-קול.
|
||||
|
||||
Args:
|
||||
case_number: מספר התיק הסופי (= decision_number בקורפוס-הסגנון).
|
||||
findings: רשימת ממצאים, כל אחד {"text": "...", "category"/"tag": "..."}.
|
||||
category ∈ style/structure/lexicon/tabular/general (או tag עברי).
|
||||
"""
|
||||
if not findings:
|
||||
return err("findings ריק — אין ממצאים לרשום.")
|
||||
corpus_id = await db.get_style_corpus_id_by_decision(case_number)
|
||||
if not corpus_id:
|
||||
return err(
|
||||
f"לא נמצאה רשומת style_corpus ל-{case_number} — ודא שהסופי נקלט לקורפוס-הסגנון "
|
||||
"(enroll_style_corpus) לפני רישום ממצאים."
|
||||
)
|
||||
# Dedup against lessons already on this corpus (any source) — re-running §A
|
||||
# must not pile duplicates (INV-LRN3 reliability).
|
||||
existing = {(_norm(r["lesson_text"])) for r in await db.list_decision_lessons(corpus_id)}
|
||||
written, skipped_dup, skipped_empty = [], 0, 0
|
||||
for f in findings:
|
||||
text = (f.get("text") or "").strip()
|
||||
if not text:
|
||||
skipped_empty += 1
|
||||
continue
|
||||
if _norm(text) in existing:
|
||||
skipped_dup += 1
|
||||
continue
|
||||
raw_cat = (f.get("category") or f.get("tag") or "general").strip()
|
||||
category = _CURATOR_TAG_TO_CATEGORY.get(raw_cat, raw_cat)
|
||||
if category not in _CURATOR_FINDING_CATEGORIES:
|
||||
category = "general"
|
||||
row = await db.add_decision_lesson(
|
||||
corpus_id,
|
||||
lesson_text=text,
|
||||
category=category,
|
||||
source="curator",
|
||||
created_by="curator",
|
||||
review_status="proposed",
|
||||
)
|
||||
if row:
|
||||
written.append(str(row["id"]))
|
||||
existing.add(_norm(text))
|
||||
return ok({
|
||||
"corpus_id": str(corpus_id),
|
||||
"written": len(written),
|
||||
"skipped_duplicate": skipped_dup,
|
||||
"skipped_empty": skipped_empty,
|
||||
"lesson_ids": written,
|
||||
}, message=(
|
||||
f"נרשמו {len(written)} ממצאי-אוצֵר (source=curator, ממתינים לשער-יו\"ר ב-/training). "
|
||||
f"{skipped_dup} כפילויות דולגו."
|
||||
))
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
"""Normalize lesson text for dedup — collapse whitespace, strip."""
|
||||
return " ".join((s or "").split())
|
||||
|
||||
|
||||
async def lesson_synthesize_pending(
|
||||
practice_area: str = "", category: str = "", apply: bool = True,
|
||||
) -> str:
|
||||
"""סינתזת-לקחים (#158 / INV-LRN8): ממזגת לקחי-סגנון חופפים ל"לקח-על" אחד עשיר ומוכלל,
|
||||
כך שהסט שזורם לכותב קטֵן ומשתבח (התקרה limit=15 מפסיקה לחתוך). מאשכלת לפי דמיון (cosine)
|
||||
בתוך shard של practice_area+category, ומסנתזת מעוגן-מקור (INV-AH) עם שער-drift.
|
||||
|
||||
Args:
|
||||
practice_area: לצמצם ל-shard אחד (ריק = כל התחומים).
|
||||
category: style/structure/lexicon/tabular (ריק = כל הקטגוריות).
|
||||
apply: True = כותב (לקח-על approved + מקורות→superseded); False = dry-run.
|
||||
"""
|
||||
from legal_mcp.services import lesson_synthesis
|
||||
shards = await lesson_synthesis.run_pending(practice_area, category, apply=apply)
|
||||
applied = sum(1 for s in shards for c in s["clusters"] if c.get("applied"))
|
||||
clusters = sum(len(s["clusters"]) for s in shards)
|
||||
return ok({
|
||||
"apply": apply,
|
||||
"shards": shards,
|
||||
"clusters_found": clusters,
|
||||
"synthesized": applied,
|
||||
}, message=(
|
||||
f"סינתזת-לקחים: {clusters} אשכולות ב-{len(shards)} shards · "
|
||||
f"{applied} לקחי-על {'נכתבו (approved)' if apply else 'דמו (dry-run)'}."
|
||||
))
|
||||
|
||||
|
||||
async def list_chair_feedback(
|
||||
|
||||
21
mcp-server/tests/test_adapter_profiles.py
Normal file
21
mcp-server/tests/test_adapter_profiles.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Adapter-profile compatibility gates for Paperclip migration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "adapter_profiles.py"
|
||||
_spec = importlib.util.spec_from_file_location("adapter_profiles", _SCRIPT)
|
||||
profiles = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(profiles)
|
||||
|
||||
|
||||
def test_codex_local_profile_accepts_openai_model_ids():
|
||||
assert profiles.model_matches_provider("gpt-5.3-codex", "codex_local")
|
||||
assert profiles.model_matches_provider("o4-mini", "codex_local")
|
||||
assert profiles.model_matches_provider("codex-mini-latest", "codex_local")
|
||||
|
||||
|
||||
def test_codex_local_profile_rejects_foreign_model_ids():
|
||||
assert not profiles.model_matches_provider("claude-opus-4-8", "codex_local")
|
||||
assert not profiles.model_matches_provider("gemini-3.1-pro-preview", "codex_local")
|
||||
@@ -14,7 +14,9 @@ from lxml import etree
|
||||
from legal_mcp.services.docx_exporter import (
|
||||
_BOOKMARK_ID_START,
|
||||
HEBREW_FONT,
|
||||
TEMPLATE_PATH,
|
||||
_add_styled_paragraph,
|
||||
_clear_body,
|
||||
_insert_bookmark_end,
|
||||
_insert_bookmark_start,
|
||||
_mark_paragraph_rtl,
|
||||
@@ -168,6 +170,83 @@ def test_block_dalet_does_not_use_title_style() -> None:
|
||||
assert any("החלטה" in t for t in texts)
|
||||
|
||||
|
||||
def test_block_dalet_is_heading_only_ignores_db_content() -> None:
|
||||
"""ת2 — block-ד is the 'החלטה' heading marker, NOT a data block.
|
||||
|
||||
Whatever content sits in decision_blocks for block-dalet must be ignored;
|
||||
the only non-empty text line produced is the literal heading 'החלטה'.
|
||||
"""
|
||||
doc = Document()
|
||||
_write_block_to_docx(
|
||||
doc, "block-dalet", title="כותרת",
|
||||
content="נתון ישן שצריך להיות מתועלם\nשורה שנייה",
|
||||
)
|
||||
text_lines = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||
assert text_lines == ["החלטה"], (
|
||||
f"block-dalet must render ONLY the heading 'החלטה', got {text_lines}"
|
||||
)
|
||||
|
||||
|
||||
# ── _clear_body strips ALL scaffolding (ת1 + ת3 root cause) ────────
|
||||
# The template ships 3 sample tables (header / panel / signatures). Removing
|
||||
# only <w:p> left them behind: header/panel tables injected block-א–ד data
|
||||
# never extracted from the protocol (ת1), and the signatures table floated to
|
||||
# the top against the header (ת3 — חתימות צמודות לבלוק-ד במקום בסוף). The
|
||||
# decision is rebuilt purely from decision_blocks (INV-EX1), so the template's
|
||||
# sample tables are stale scaffolding and must be cleared.
|
||||
|
||||
|
||||
def test_clear_body_removes_tables_and_leaves_only_sectpr() -> None:
|
||||
doc = Document(str(TEMPLATE_PATH))
|
||||
body = doc.element.body
|
||||
# Sanity: the template really ships sample tables (else this test is moot).
|
||||
assert len(body.findall(qn("w:tbl"))) > 0, "template expected to ship sample tables"
|
||||
|
||||
_clear_body(doc)
|
||||
|
||||
remaining = [c.tag.split("}")[-1] for c in list(body)]
|
||||
assert remaining == ["sectPr"], (
|
||||
f"_clear_body must leave only sectPr, got {remaining}"
|
||||
)
|
||||
assert len(body.findall(qn("w:tbl"))) == 0, "no template tables may survive"
|
||||
assert len(body.findall(qn("w:p"))) == 0, "no template paragraphs may survive"
|
||||
|
||||
|
||||
def test_signatures_block_renders_after_body_not_at_top() -> None:
|
||||
"""ת3 — block-yod-bet (חתימות) renders LAST, after a cleared body.
|
||||
|
||||
Regression for the bug where the leftover template signatures table sat at
|
||||
the top against the header table. After the _clear_body fix, signatures
|
||||
come only from block-yod-bet, which the export loop emits last in block
|
||||
order.
|
||||
"""
|
||||
doc = Document(str(TEMPLATE_PATH))
|
||||
_clear_body(doc)
|
||||
|
||||
# Emit a header block, a body block, then signatures last (canonical order).
|
||||
_write_block_to_docx(doc, "block-alef", title="", content="מדינת ישראל")
|
||||
_write_block_to_docx(doc, "block-vav", title="", content="רקע עובדתי\n1. המקרקעין.")
|
||||
_write_block_to_docx(
|
||||
doc, "block-yod-bet", title="",
|
||||
content='ניתנה פה אחד היום.\nדפנה תמיר, עו"ד',
|
||||
)
|
||||
|
||||
text_lines = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||
assert text_lines[0] == "מדינת ישראל", text_lines
|
||||
# The signatures block (block-yod-bet) emits each line as its own paragraph,
|
||||
# so its content must occupy the document's tail — after the body block.
|
||||
body_idx = text_lines.index("רקע עובדתי")
|
||||
sig_idx = next(i for i, t in enumerate(text_lines) if "ניתנה פה אחד" in t)
|
||||
assert sig_idx > body_idx, (
|
||||
f"signatures must come AFTER the body, got tail={text_lines[-3:]}"
|
||||
)
|
||||
assert any("דפנה תמיר" in t for t in text_lines[sig_idx:]), text_lines[sig_idx:]
|
||||
# No template signatures table ("מזכירת ועדת ערר") leaked anywhere.
|
||||
assert all(
|
||||
"מזכירת ועדת ערר" not in (p.text or "") for p in doc.paragraphs
|
||||
), "template signatures-table text leaked into the body"
|
||||
|
||||
|
||||
# ── Heading overrides, numbered-list, dash strip ──────────────────
|
||||
|
||||
|
||||
|
||||
@@ -130,6 +130,16 @@ def _find_ins_with_runs(tree: etree._Element) -> etree._Element | None:
|
||||
return None
|
||||
|
||||
|
||||
def _find_enclosing_p(element: etree._Element) -> etree._Element | None:
|
||||
"""Walk up to the enclosing <w:p>."""
|
||||
cur = element
|
||||
while cur is not None:
|
||||
if cur.tag == _w("p"):
|
||||
return cur
|
||||
cur = cur.getparent()
|
||||
return None
|
||||
|
||||
|
||||
def test_insert_after_ins_has_author_and_date(sample_docx: Path, tmp_path: Path) -> None:
|
||||
out = tmp_path / "out.docx"
|
||||
rev = Revision(id="r1", type="insert_after",
|
||||
@@ -147,7 +157,10 @@ def test_insert_after_ins_has_author_and_date(sample_docx: Path, tmp_path: Path)
|
||||
assert date_str.endswith("Z") # ISO 8601 UTC
|
||||
|
||||
|
||||
def test_insert_after_uses_rtl_and_david(sample_docx: Path, tmp_path: Path) -> None:
|
||||
def test_insert_after_uses_rtl_and_template_style(sample_docx: Path, tmp_path: Path) -> None:
|
||||
"""INV-EX4: an inserted paragraph carries the template's named style via
|
||||
<w:pStyle> and an <w:rtl/> run marker — but does NOT hand-set rFonts/sz
|
||||
(font/size come from the style). jc is the canonical body 'both'."""
|
||||
out = tmp_path / "out.docx"
|
||||
rev = Revision(id="r1", type="insert_after",
|
||||
anchor_bookmark="block-alef", content="מוסף")
|
||||
@@ -162,10 +175,22 @@ def test_insert_after_uses_rtl_and_david(sample_docx: Path, tmp_path: Path) -> N
|
||||
assert run is not None
|
||||
rPr = run.find(_w("rPr"))
|
||||
assert rPr is not None
|
||||
# RTL marker preserved (so Word picks the style's cs=David slot)…
|
||||
assert rPr.find(_w("rtl")) is not None
|
||||
rFonts = rPr.find(_w("rFonts"))
|
||||
assert rFonts is not None
|
||||
assert rFonts.get(_w("ascii")) == "David"
|
||||
# …but NO hand-set font/size on the run (INV-EX4).
|
||||
assert rPr.find(_w("rFonts")) is None
|
||||
assert rPr.find(_w("sz")) is None
|
||||
assert rPr.find(_w("szCs")) is None
|
||||
|
||||
# The enclosing paragraph references a template style + canonical jc=both.
|
||||
para = _find_enclosing_p(ins)
|
||||
assert para is not None
|
||||
pPr = para.find(_w("pPr"))
|
||||
assert pPr is not None
|
||||
pStyle = pPr.find(_w("pStyle"))
|
||||
assert pStyle is not None and pStyle.get(_w("val")) # Normal → real styleId
|
||||
jc = pPr.find(_w("jc"))
|
||||
assert jc is not None and jc.get(_w("val")) == "both"
|
||||
|
||||
|
||||
# ── apply_tracked_revisions: insert_before ────────────────────────
|
||||
|
||||
76
mcp-server/tests/test_docx_template_single_source.py
Normal file
76
mcp-server/tests/test_docx_template_single_source.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""שומר INV-EX4 — מקור-תבנית-יחיד לכל מפיקי-מסמכי-ההחלטה.
|
||||
|
||||
כל מפיק של מסמך-Word הקשור-להחלטה חייב להחיל עיצוב דרך הסגנונות שמוגדרים
|
||||
בטמפלט היחיד (skills/docx/decision_template.docx), ולא לקבוע פונט/גודל ידנית
|
||||
(``rFonts`` / ``sz`` / ``szCs``) — אלה נגזרים מ-styles.xml של הטמפלט.
|
||||
|
||||
הטסט גורף את מודולי-המפיקים ונכשל אם נבנה אלמנט ``w:rFonts`` / ``w:sz`` /
|
||||
``w:szCs`` בקוד, אלא אם השורה נושאת את הסמן המפורש ``# INV-EX4-ok`` (חריג
|
||||
מתועד — למשל חיזוק גופן-התבנית עצמו David כעקיפת באג-RTL של Word).
|
||||
|
||||
ספ: docs/spec/06-export.md §INV-EX4.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import (
|
||||
analysis_docx_exporter,
|
||||
docx_exporter,
|
||||
docx_reviser,
|
||||
docx_retrofit,
|
||||
)
|
||||
|
||||
# Every module that builds/edits a decision-related Word document.
|
||||
_PRODUCER_MODULES = [
|
||||
docx_exporter,
|
||||
analysis_docx_exporter,
|
||||
docx_reviser,
|
||||
docx_retrofit,
|
||||
]
|
||||
|
||||
# Construction (not lookup) of a hand-set font/size element:
|
||||
# OxmlElement("w:rFonts") (python-docx producers)
|
||||
# etree.SubElement(rPr, _w("sz")) (raw-lxml producer: docx_reviser)
|
||||
_HANDSET_RE = re.compile(
|
||||
r'OxmlElement\(\s*["\']w:(?:rFonts|sz|szCs)["\']'
|
||||
r'|SubElement\([^)]*_w\(\s*["\'](?:rFonts|sz|szCs)["\']'
|
||||
)
|
||||
|
||||
# Producers that build a document FROM SCRATCH must load the single template.
|
||||
# (docx_reviser/docx_retrofit operate on an already-templated existing doc.)
|
||||
_FROM_SCRATCH_MODULES = [docx_exporter, analysis_docx_exporter]
|
||||
|
||||
|
||||
def _module_path(module) -> Path:
|
||||
return Path(module.__file__)
|
||||
|
||||
|
||||
def test_no_handset_font_or_size_in_producers() -> None:
|
||||
"""INV-EX4: no hand-set rFonts/sz/szCs in any producer (only via the
|
||||
template's named styles), unless explicitly marked ``# INV-EX4-ok``."""
|
||||
violations: list[str] = []
|
||||
for module in _PRODUCER_MODULES:
|
||||
path = _module_path(module)
|
||||
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if _HANDSET_RE.search(line) and "INV-EX4-ok" not in line:
|
||||
violations.append(f"{path.name}:{lineno}: {line.strip()}")
|
||||
assert not violations, (
|
||||
"INV-EX4 violation — hand-set font/size outside the template styles "
|
||||
"(use a named template style, or mark with # INV-EX4-ok):\n "
|
||||
+ "\n ".join(violations)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", _FROM_SCRATCH_MODULES)
|
||||
def test_from_scratch_producers_load_the_single_template(module) -> None:
|
||||
"""INV-EX4: from-scratch producers reference the one template file."""
|
||||
source = _module_path(module).read_text(encoding="utf-8")
|
||||
assert "decision_template.docx" in source, (
|
||||
f"{_module_path(module).name} must load the single template "
|
||||
"skills/docx/decision_template.docx (INV-EX4)."
|
||||
)
|
||||
212
mcp-server/tests/test_party_claims_summary.py
Normal file
212
mcp-server/tests/test_party_claims_summary.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Tests for the party-claims executive summary (#202, WS3).
|
||||
|
||||
Cover the deterministic, offline parts:
|
||||
- ``_build_arguments_context`` source selection: legal_arguments preferred,
|
||||
raw claims fallback, "none" when empty (G2 — single source, no re-extract).
|
||||
- ``summarize_party_claims`` pins Opus 4.8 + effort=high and grounds the
|
||||
prompt strictly in the case's own claims (anti-hallucination / INV-AH).
|
||||
- ``_render_markdown_body`` maps markdown headings/bullets onto template styles.
|
||||
|
||||
Generation itself (claude -p) is local-only and not exercised here — the
|
||||
claude_session call is monkeypatched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from uuid import uuid4
|
||||
|
||||
from legal_mcp.services import party_claims_summary as pcs
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# ── source selection (G2: arguments preferred, claims fallback) ────
|
||||
|
||||
|
||||
def test_arguments_preferred_over_claims(monkeypatch):
|
||||
cid = uuid4()
|
||||
|
||||
async def _get_args(_case_id):
|
||||
return [
|
||||
{"party": "appellant", "argument_title": "חריגת בנייה",
|
||||
"argument_body": "הבנייה חורגת מקו הבניין", "legal_topic": "קווי בניין"},
|
||||
{"party": "respondent", "argument_title": "התיישנות",
|
||||
"argument_body": "", "legal_topic": ""},
|
||||
]
|
||||
|
||||
async def _get_claims(_case_id, *a, **k): # must NOT be consulted
|
||||
raise AssertionError("claims fallback used despite arguments existing")
|
||||
|
||||
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _get_args)
|
||||
monkeypatch.setattr(pcs.db, "get_claims", _get_claims)
|
||||
|
||||
ctx, n, kind = _run(pcs._build_arguments_context(cid))
|
||||
assert kind == "arguments"
|
||||
assert n == 2
|
||||
assert "העוררים" in ctx and "המשיבים" in ctx
|
||||
assert "חריגת בנייה" in ctx
|
||||
|
||||
|
||||
def test_falls_back_to_claims_when_no_arguments(monkeypatch):
|
||||
cid = uuid4()
|
||||
|
||||
async def _no_args(_case_id):
|
||||
return []
|
||||
|
||||
async def _claims(_case_id, *a, **k):
|
||||
return [
|
||||
{"party_role": "appellant", "claim_text": "טענה אחת", "source_document": "appeal"},
|
||||
{"party_role": "respondent", "claim_text": "טענה שתיים", "source_document": "response"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _no_args)
|
||||
monkeypatch.setattr(pcs.db, "get_claims", _claims)
|
||||
|
||||
ctx, n, kind = _run(pcs._build_arguments_context(cid))
|
||||
assert kind == "claims"
|
||||
assert n == 2
|
||||
assert "טענה אחת" in ctx
|
||||
|
||||
|
||||
def test_block_zayin_claims_excluded(monkeypatch):
|
||||
"""Decision-summary (block-zayin) claims are not original pleadings."""
|
||||
cid = uuid4()
|
||||
|
||||
async def _no_args(_case_id):
|
||||
return []
|
||||
|
||||
async def _claims(_case_id, *a, **k):
|
||||
return [
|
||||
{"party_role": "appellant", "claim_text": "מקורית", "source_document": "appeal"},
|
||||
{"party_role": "appellant", "claim_text": "מסיכום", "source_document": "block-zayin"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _no_args)
|
||||
monkeypatch.setattr(pcs.db, "get_claims", _claims)
|
||||
|
||||
ctx, n, kind = _run(pcs._build_arguments_context(cid))
|
||||
assert kind == "claims"
|
||||
assert n == 1
|
||||
assert "מקורית" in ctx and "מסיכום" not in ctx
|
||||
|
||||
|
||||
def test_none_when_no_source(monkeypatch):
|
||||
cid = uuid4()
|
||||
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments",
|
||||
lambda _c: _aw([]))
|
||||
monkeypatch.setattr(pcs.db, "get_claims", lambda _c, *a, **k: _aw([]))
|
||||
ctx, n, kind = _run(pcs._build_arguments_context(cid))
|
||||
assert kind == "none" and n == 0 and ctx == ""
|
||||
|
||||
|
||||
async def _aw(v):
|
||||
return v
|
||||
|
||||
|
||||
# ── generation pins Opus 4.8 + high effort, grounded in claims ─────
|
||||
|
||||
|
||||
def test_summarize_pins_opus_and_high_effort(monkeypatch, tmp_path):
|
||||
cid = uuid4()
|
||||
captured: dict = {}
|
||||
|
||||
async def _get_case(_case_id):
|
||||
return {"case_number": "8125-09-24", "title": "t", "appellants": ["א"],
|
||||
"respondents": ["ב"], "subject": "היטל השבחה", "property_address": "",
|
||||
"appeal_type": "betterment_levy", "practice_area": "betterment_levy"}
|
||||
|
||||
async def _get_decision(_case_id):
|
||||
return None
|
||||
|
||||
async def _args(_case_id):
|
||||
return [{"party": "appellant", "argument_title": "X",
|
||||
"argument_body": "Y", "legal_topic": ""}]
|
||||
|
||||
async def _fake_query(prompt, *, timeout=None, model=None, effort=None, tools=None):
|
||||
captured["model"] = model
|
||||
captured["effort"] = effort
|
||||
captured["tools"] = tools
|
||||
captured["prompt"] = prompt
|
||||
return "## טענות העוררים\nתמצית."
|
||||
|
||||
async def _style(_practice_area=""):
|
||||
return "(style)"
|
||||
|
||||
monkeypatch.setattr(pcs.db, "get_case", _get_case)
|
||||
monkeypatch.setattr(pcs.db, "get_decision_by_case", _get_decision)
|
||||
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _args)
|
||||
monkeypatch.setattr(pcs.block_writer, "_build_style_context", _style)
|
||||
monkeypatch.setattr(pcs.claude_session, "query", _fake_query)
|
||||
monkeypatch.setattr(pcs.config, "find_case_dir", lambda cn: tmp_path / cn)
|
||||
monkeypatch.setattr(pcs.config, "DATA_DIR", tmp_path)
|
||||
|
||||
async def _mirror(*a, **k):
|
||||
return None
|
||||
monkeypatch.setattr(pcs.storage, "mirror", _mirror)
|
||||
monkeypatch.setattr(pcs.git_sync, "commit_and_push", lambda *a, **k: True)
|
||||
|
||||
result = _run(pcs.summarize_party_claims(cid, "8125-09-24"))
|
||||
|
||||
# The whole point of #202's generation-path constraint:
|
||||
assert captured["model"] == "claude-opus-4-8"
|
||||
assert captured["effort"] == "high"
|
||||
assert captured["tools"] == "" # prose, no tool_use
|
||||
# Grounded strictly in the case's own arguments (anti-hallucination).
|
||||
assert "אנטי-הזיה" in captured["prompt"]
|
||||
assert "Y" in captured["prompt"] # the actual argument body is in-context
|
||||
|
||||
assert result["source_kind"] == "arguments"
|
||||
out = (tmp_path / "8125-09-24" / "documents" / "research" / "party-claims-summary.md")
|
||||
assert out.exists()
|
||||
assert "סיכום-מנהלים" in out.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_summarize_raises_when_no_claims(monkeypatch, tmp_path):
|
||||
cid = uuid4()
|
||||
monkeypatch.setattr(pcs.db, "get_case", lambda _c: _aw({"case_number": "9000-01-25"}))
|
||||
monkeypatch.setattr(pcs.db, "get_decision_by_case", lambda _c: _aw(None))
|
||||
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", lambda _c: _aw([]))
|
||||
monkeypatch.setattr(pcs.db, "get_claims", lambda _c, *a, **k: _aw([]))
|
||||
|
||||
try:
|
||||
_run(pcs.summarize_party_claims(cid, "9000-01-25"))
|
||||
raise AssertionError("expected ValueError on empty source")
|
||||
except ValueError as e:
|
||||
assert "טענות" in str(e)
|
||||
|
||||
|
||||
# ── markdown → DOCX rendering uses template heading styles ─────────
|
||||
|
||||
|
||||
def test_render_markdown_body_maps_styles():
|
||||
from docx import Document
|
||||
from legal_mcp.services import analysis_docx_exporter as ax
|
||||
|
||||
doc = Document()
|
||||
md = (
|
||||
"# כותרת ראשית\n\n"
|
||||
"---\n"
|
||||
"## טענות העוררים\n"
|
||||
"- טענה אחת\n"
|
||||
"1. סעיף ממוספר\n"
|
||||
"טקסט רגיל.\n"
|
||||
)
|
||||
ax._render_markdown_body(doc, md)
|
||||
styles = [p.style.name for p in doc.paragraphs]
|
||||
texts = [p.text for p in doc.paragraphs]
|
||||
# HR is skipped; headings become Heading styles.
|
||||
assert "כותרת ראשית" in texts
|
||||
assert "Heading 1" in styles # # / ## → Heading 1
|
||||
assert any("טענה אחת" in t for t in texts)
|
||||
assert not any(t.strip() in {"---", "#"} for t in texts)
|
||||
|
||||
|
||||
def test_next_version_is_prefix_scoped(tmp_path):
|
||||
from legal_mcp.services import analysis_docx_exporter as ax
|
||||
(tmp_path / "ניתוח-משפטי-v3.docx").write_bytes(b"x")
|
||||
# An analysis file at v3 must NOT bump the summary's version.
|
||||
assert ax._next_version(tmp_path, "סיכום-מנהלים-טענות") == 1
|
||||
assert ax._next_version(tmp_path, "ניתוח-משפטי") == 4
|
||||
200
mcp-server/tests/test_protocol_analyzer.py
Normal file
200
mcp-server/tests/test_protocol_analyzer.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""Tests for the comparative protocol analyzer (WS4 / #203).
|
||||
|
||||
Covers the pure (non-LLM, non-DB) logic: the anti-hallucination normalization
|
||||
gate, protocol-document discovery, claim_type tagging for protocols, and the
|
||||
block-ז filter that keeps oral hearing arguments out of the original-pleadings
|
||||
summary (INV-WR4).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import protocol_analyzer as pa
|
||||
from legal_mcp.services.claims_extractor import _infer_claim_type
|
||||
|
||||
|
||||
# ── claim_type tagging — protocol distinct from written pleadings ───────────
|
||||
|
||||
@pytest.mark.parametrize("doc_type,title,expected", [
|
||||
("protocol", "פרוטוקול דיון", "protocol"),
|
||||
("appeal", "פרוטוקול הדיון מיום 1.1", "protocol"), # title-based
|
||||
("appeal", "כתב ערר", "claim"),
|
||||
("response", "כתב תשובה", "response"),
|
||||
("response", "תגובת המשיבה", "reply"),
|
||||
])
|
||||
def test_infer_claim_type(doc_type, title, expected):
|
||||
assert _infer_claim_type(doc_type, title) == expected
|
||||
|
||||
|
||||
# ── _find_protocol ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_find_protocol_by_doc_type():
|
||||
docs = [
|
||||
{"id": "1", "doc_type": "appeal", "title": "כתב ערר"},
|
||||
{"id": "2", "doc_type": "protocol", "title": "פרוטוקול"},
|
||||
]
|
||||
assert pa._find_protocol(docs)["id"] == "2"
|
||||
|
||||
|
||||
def test_find_protocol_by_title_fallback():
|
||||
docs = [
|
||||
{"id": "1", "doc_type": "reference", "title": "פרוטוקול הדיון"},
|
||||
{"id": "2", "doc_type": "appeal", "title": "כתב ערר"},
|
||||
]
|
||||
assert pa._find_protocol(docs)["id"] == "1"
|
||||
|
||||
|
||||
def test_find_protocol_none():
|
||||
docs = [{"id": "1", "doc_type": "appeal", "title": "כתב ערר"}]
|
||||
assert pa._find_protocol(docs) is None
|
||||
|
||||
|
||||
def test_find_protocol_skips_lower_scope():
|
||||
# A local/district-committee protocol (scope='lower') must NOT be picked for
|
||||
# the hearing-vs-pleadings comparison; the ועדת-ערר one wins (#223).
|
||||
docs = [
|
||||
{"id": "lower", "doc_type": "protocol", "title": "נספח 18 — פרוטוקולי ועדה מקומית",
|
||||
"metadata": {"protocol_scope": "lower"}, "created_at": "2026-01-01"},
|
||||
{"id": "appeal", "doc_type": "protocol", "title": "פרוטוקול דיון 23.6.26",
|
||||
"metadata": {}, "created_at": "2026-06-23"},
|
||||
]
|
||||
assert pa._find_protocol(docs)["id"] == "appeal"
|
||||
|
||||
|
||||
def test_find_protocol_all_lower_returns_none():
|
||||
# If every protocol is lower-scoped there is no ערר-hearing to compare.
|
||||
docs = [
|
||||
{"id": "1", "doc_type": "protocol", "title": "פרוטוקול מקומי",
|
||||
"metadata": {"protocol_scope": "lower"}},
|
||||
]
|
||||
assert pa._find_protocol(docs) is None
|
||||
|
||||
|
||||
def test_find_protocol_prefers_most_recent_appeal():
|
||||
docs = [
|
||||
{"id": "old", "doc_type": "protocol", "title": "פרוטוקול א",
|
||||
"metadata": {}, "created_at": "2026-03-01"},
|
||||
{"id": "new", "doc_type": "protocol", "title": "פרוטוקול ב",
|
||||
"metadata": {}, "created_at": "2026-06-23"},
|
||||
]
|
||||
assert pa._find_protocol(docs)["id"] == "new"
|
||||
|
||||
|
||||
def test_find_protocol_explicit_document_id_overrides():
|
||||
# Explicit target wins even over scope/recency heuristics — including the
|
||||
# ability to point at a lower-scoped doc if the caller insists.
|
||||
docs = [
|
||||
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
|
||||
"title": "פרוטוקול ערר", "metadata": {}, "created_at": "2026-06-23"},
|
||||
{"id": "aaaaaaaa-0000-0000-0000-000000000002", "doc_type": "protocol",
|
||||
"title": "פרוטוקול מקומי", "metadata": {"protocol_scope": "lower"},
|
||||
"created_at": "2026-01-01"},
|
||||
]
|
||||
from uuid import UUID
|
||||
picked = pa._find_protocol(
|
||||
docs, document_id=UUID("aaaaaaaa-0000-0000-0000-000000000002"),
|
||||
)
|
||||
assert picked["id"] == "aaaaaaaa-0000-0000-0000-000000000002"
|
||||
|
||||
|
||||
def test_find_protocol_unknown_document_id_returns_none():
|
||||
from uuid import UUID
|
||||
docs = [
|
||||
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
|
||||
"title": "פרוטוקול", "metadata": {}},
|
||||
]
|
||||
assert pa._find_protocol(
|
||||
docs, document_id=UUID("bbbbbbbb-0000-0000-0000-000000000009"),
|
||||
) is None
|
||||
|
||||
|
||||
# ── _normalize_change — anti-hallucination gate (INV-AH) ───────────────────
|
||||
|
||||
_AID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
|
||||
def test_normalize_change_valid():
|
||||
row = pa._normalize_change(
|
||||
{
|
||||
"change_type": "dropped",
|
||||
"party_role": "appellant",
|
||||
"argument_id": _AID,
|
||||
"argument_title": "טענת השיהוי",
|
||||
"summary": "העורר ויתר על הטענה בדיון.",
|
||||
"sharpened_question": "האם נותרה טענת סף?",
|
||||
"evidence_quote": "ב\"כ העורר: איננו עומדים על טענת השיהוי.",
|
||||
"page_number": 3,
|
||||
},
|
||||
valid_argument_ids={_AID},
|
||||
)
|
||||
assert row is not None
|
||||
assert row["change_type"] == "dropped"
|
||||
assert str(row["argument_id"]) == _AID
|
||||
assert row["page_number"] == 3
|
||||
|
||||
|
||||
def test_normalize_change_drops_row_without_evidence_quote():
|
||||
# quote-or-retract: no verbatim support → row is rejected at source.
|
||||
row = pa._normalize_change(
|
||||
{"change_type": "strengthened", "summary": "חוזקה", "evidence_quote": ""},
|
||||
valid_argument_ids=set(),
|
||||
)
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_normalize_change_drops_row_without_summary():
|
||||
row = pa._normalize_change(
|
||||
{"change_type": "dropped", "summary": "", "evidence_quote": "ציטוט"},
|
||||
valid_argument_ids=set(),
|
||||
)
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_normalize_change_rejects_bad_change_type():
|
||||
row = pa._normalize_change(
|
||||
{"change_type": "modified", "summary": "x", "evidence_quote": "y"},
|
||||
valid_argument_ids=set(),
|
||||
)
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_normalize_change_drops_hallucinated_argument_id():
|
||||
# An argument_id that isn't one of this case's arguments must be dropped
|
||||
# (FK safety) — but the row itself, being newly_raised-shaped, survives.
|
||||
row = pa._normalize_change(
|
||||
{
|
||||
"change_type": "newly_raised",
|
||||
"party_role": "respondent",
|
||||
"argument_id": "99999999-9999-9999-9999-999999999999",
|
||||
"summary": "סוגיה חדשה שעלתה בדיון.",
|
||||
"evidence_quote": "ב\"כ המשיבה העלה לראשונה את שאלת הסמכות.",
|
||||
},
|
||||
valid_argument_ids={_AID},
|
||||
)
|
||||
assert row is not None
|
||||
assert row["argument_id"] is None
|
||||
assert row["change_type"] == "newly_raised"
|
||||
|
||||
|
||||
def test_normalize_change_invalid_party_role_blanked():
|
||||
row = pa._normalize_change(
|
||||
{
|
||||
"change_type": "strengthened",
|
||||
"party_role": "judge", # not a valid party
|
||||
"summary": "חוזקה הטענה.",
|
||||
"evidence_quote": "ציטוט מבסס.",
|
||||
},
|
||||
valid_argument_ids=set(),
|
||||
)
|
||||
assert row is not None
|
||||
assert row["party_role"] == ""
|
||||
|
||||
|
||||
def test_normalize_change_truncates_quote():
|
||||
long_quote = "א" * 500
|
||||
row = pa._normalize_change(
|
||||
{"change_type": "dropped", "summary": "x", "evidence_quote": long_quote},
|
||||
valid_argument_ids=set(),
|
||||
)
|
||||
assert row is not None
|
||||
assert len(row["evidence_quote"]) == 200
|
||||
76
mcp-server/tests/test_reanalyze_impact_diff.py
Normal file
76
mcp-server/tests/test_reanalyze_impact_diff.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Unit tests for the re-analysis impact diff — WS2 / task #201.
|
||||
|
||||
`_snapshot` + `_impact_diff` are pure functions (no DB) that produce the
|
||||
before↔after summary surfaced to the chair after a merging re-analysis. They
|
||||
must report per-party argument titles added/removed and the shift in the
|
||||
priority ("recommendation balance") mix, and set ``changed`` correctly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from legal_mcp.tools.legal_arguments import _impact_diff, _snapshot
|
||||
|
||||
|
||||
def _arg(party, title, priority="substantive"):
|
||||
return {"party": party, "argument_title": title, "priority": priority}
|
||||
|
||||
|
||||
def test_no_change_is_flagged_false():
|
||||
args = [_arg("appellant", "זכות עמידה"), _arg("committee", "שיהוי")]
|
||||
snap = _snapshot(args)
|
||||
diff = _impact_diff(snap, snap)
|
||||
assert diff["changed"] is False
|
||||
assert diff["total_before"] == diff["total_after"] == 2
|
||||
# Every party entry reports changed=False.
|
||||
assert all(p["changed"] is False for p in diff["by_party"])
|
||||
|
||||
|
||||
def test_added_argument_detected():
|
||||
before = _snapshot([_arg("appellant", "זכות עמידה")])
|
||||
after = _snapshot([_arg("appellant", "זכות עמידה"), _arg("appellant", "תחולת תמא 38")])
|
||||
diff = _impact_diff(before, after)
|
||||
assert diff["changed"] is True
|
||||
assert diff["total_before"] == 1
|
||||
assert diff["total_after"] == 2
|
||||
appellant = next(p for p in diff["by_party"] if p["party"] == "appellant")
|
||||
assert appellant["added_arguments"] == ["תחולת תמא 38"]
|
||||
assert appellant["removed_arguments"] == []
|
||||
assert appellant["party_he"] == "עוררים"
|
||||
|
||||
|
||||
def test_removed_argument_detected():
|
||||
before = _snapshot([_arg("committee", "שיהוי"), _arg("committee", "סמכות")])
|
||||
after = _snapshot([_arg("committee", "שיהוי")])
|
||||
diff = _impact_diff(before, after)
|
||||
committee = next(p for p in diff["by_party"] if p["party"] == "committee")
|
||||
assert committee["removed_arguments"] == ["סמכות"]
|
||||
assert committee["count_before"] == 2
|
||||
assert committee["count_after"] == 1
|
||||
assert diff["changed"] is True
|
||||
|
||||
|
||||
def test_priority_mix_shift_is_a_change():
|
||||
# Same title, but priority moved threshold -> substantive: the recommendation
|
||||
# balance shifted, so the chair must see a change even with identical titles.
|
||||
before = _snapshot([_arg("appellant", "סמכות", priority="threshold")])
|
||||
after = _snapshot([_arg("appellant", "סמכות", priority="substantive")])
|
||||
diff = _impact_diff(before, after)
|
||||
appellant = next(p for p in diff["by_party"] if p["party"] == "appellant")
|
||||
assert appellant["added_arguments"] == []
|
||||
assert appellant["removed_arguments"] == []
|
||||
assert appellant["priority_before"] == {"threshold": 1}
|
||||
assert appellant["priority_after"] == {"substantive": 1}
|
||||
assert appellant["changed"] is True
|
||||
assert diff["changed"] is True
|
||||
|
||||
|
||||
def test_new_party_appears():
|
||||
before = _snapshot([_arg("appellant", "זכות עמידה")])
|
||||
after = _snapshot([_arg("appellant", "זכות עמידה"), _arg("respondent", "מענה")])
|
||||
diff = _impact_diff(before, after)
|
||||
parties = {p["party"] for p in diff["by_party"]}
|
||||
assert parties == {"appellant", "respondent"}
|
||||
respondent = next(p for p in diff["by_party"] if p["party"] == "respondent")
|
||||
assert respondent["count_before"] == 0
|
||||
assert respondent["count_after"] == 1
|
||||
assert respondent["added_arguments"] == ["מענה"]
|
||||
@@ -207,8 +207,9 @@ def test_multiple_revisions_all_tracked_independently(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_rtl_preserved_in_tracked_insertion(tmp_path: Path) -> None:
|
||||
"""Inserted paragraph must have bidi + rtl + David font so it renders
|
||||
correctly in Word alongside the user's content."""
|
||||
"""Inserted paragraph must have bidi + an <w:rtl/> run marker so Hebrew
|
||||
renders via the template's cs=David slot — but font/size come from the
|
||||
template's named paragraph style (INV-EX4), not hand-set rFonts/sz."""
|
||||
user_file = tmp_path / "user.docx"
|
||||
_make_user_edited_docx(user_file)
|
||||
docx_retrofit.retrofit_bookmarks(user_file, backup=False)
|
||||
@@ -229,9 +230,10 @@ def test_rtl_preserved_in_tracked_insertion(tmp_path: Path) -> None:
|
||||
if any("עברית RTL" in (t.text or "") for t in text_els):
|
||||
rPr = r.find(_w("rPr"))
|
||||
assert rPr is not None
|
||||
# RTL marker preserved…
|
||||
assert rPr.find(_w("rtl")) is not None
|
||||
rFonts = rPr.find(_w("rFonts"))
|
||||
assert rFonts is not None
|
||||
assert rFonts.get(_w("ascii")) == "David"
|
||||
# …but NO hand-set font/size on the run (INV-EX4).
|
||||
assert rPr.find(_w("rFonts")) is None
|
||||
assert rPr.find(_w("sz")) is None
|
||||
return
|
||||
pytest.fail("tracked insertion with 'עברית RTL' not found")
|
||||
|
||||
145
scripts/.archive/exec_summary_1043.py
Normal file
145
scripts/.archive/exec_summary_1043.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Ad-hoc: executive summary (סיכום מנהלים) DOCX for case 1043-02-26.
|
||||
Reuses the dafna decision template styles (David font + RTL) via the
|
||||
analysis_docx_exporter helpers. One-off prep document for chaim's meeting
|
||||
with the chair — NOT a decision draft.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "mcp-server" / "src"))
|
||||
|
||||
from docx import Document
|
||||
from legal_mcp.services.analysis_docx_exporter import (
|
||||
TEMPLATE_PATH,
|
||||
_clear_body,
|
||||
_add_paragraph,
|
||||
_add_runs_with_inline_bold,
|
||||
_mark_paragraph_rtl,
|
||||
_mark_run_rtl,
|
||||
)
|
||||
|
||||
CASE = "1043-02-26"
|
||||
OUT = Path(f"/home/chaim/legal-ai/data/cases/{CASE}/exports/סיכום-מנהלים-v1.docx")
|
||||
|
||||
|
||||
def H1(doc, t):
|
||||
_add_paragraph(doc, t, "Heading 1")
|
||||
|
||||
|
||||
def H2(doc, t):
|
||||
_add_paragraph(doc, t, "Heading 2")
|
||||
|
||||
|
||||
def P(doc, t):
|
||||
p = doc.add_paragraph(style="Normal")
|
||||
_add_runs_with_inline_bold(p, t)
|
||||
_mark_paragraph_rtl(p)
|
||||
return p
|
||||
|
||||
|
||||
def BULLET(doc, t):
|
||||
p = doc.add_paragraph(style="List Paragraph")
|
||||
_add_runs_with_inline_bold(p, t)
|
||||
_mark_paragraph_rtl(p)
|
||||
return p
|
||||
|
||||
|
||||
def LABEL(doc, label, value):
|
||||
p = doc.add_paragraph(style="Normal")
|
||||
r = p.add_run(label + ": ")
|
||||
r.bold = True
|
||||
_mark_run_rtl(r)
|
||||
r2 = p.add_run(value)
|
||||
_mark_run_rtl(r2)
|
||||
_mark_paragraph_rtl(p)
|
||||
return p
|
||||
|
||||
|
||||
def main():
|
||||
doc = Document(str(TEMPLATE_PATH))
|
||||
_clear_body(doc)
|
||||
|
||||
H1(doc, "סיכום מנהלים — הכנה לדיון")
|
||||
P(doc, "**ערר 1043-02-26 — הקמת מתקן למיון פסולת (אתר \"קומפוסט דלילה\", תכנית מי/1030)**")
|
||||
P(doc, "מסמך הכנה פנימי לקראת דיון עם יו\"ר הוועדה. אינו החלטה ואינו טיוטת החלטה.")
|
||||
|
||||
H2(doc, "פרטי התיק")
|
||||
LABEL(doc, "סוג הערר", "רישוי ובנייה (1xxx) — ערר על סירוב בקשה להיתר")
|
||||
LABEL(doc, "עוררים (מבקשי ההיתר)", "קיבוץ נחשון; חברת אלקטרה אקו גרין פארק")
|
||||
LABEL(doc, "משיבים", "הוועדה המקומית מטה יהודה; מושבי גפן/תירוש/כפר הריף; קיבוצי כפר מנחם/רבדים; מועצה אזורית יואב (מתנגדים)")
|
||||
LABEL(doc, "מושא הערר", "החלטת הוועדה המקומית מטה יהודה מיום 9.2.26 לסרב לבקשה (מס' 20240972)")
|
||||
LABEL(doc, "המקרקעין", "גוש 5093 ח\"ל 4 מגרש 1, אתר \"דלילה\" (~137 דונם), מצפון לכפר מנחם, ליד כביש 383")
|
||||
LABEL(doc, "תקן ביקורת", "ועדת הערר כמוסד תכנון בעל סמכות מקורית — שיקול דעת תכנוני עצמאי; ביקורת רחבה יותר בחלק המשפטי-פרשני")
|
||||
|
||||
H2(doc, "מהות המחלוקת")
|
||||
P(doc, "מבקשי ההיתר מבקשים להקים מתקן מיון פסולת/קומפוסטציה הכולל מבנה קומפוסטציה אחוד (~19,118 מ\"ר עיקרי), משטחי הבשלה פתוחים ובריכה תפעולית, בצירוף שתי הקלות: הגבהת גובה מ-8 ל-20 מ', והגדלת תכסית מ-3.5% ל-22%. הוועדה המקומית סירבה, בקובעה כי השינוי מהותי ומקומו בעדכון התכנית ולא בהליך רישוי. השאלה המרכזית: האם הבקשה תואמת את תכנית מי/1030, או חורגת ממנה באופן המחייב תיקון תכנית.")
|
||||
|
||||
H2(doc, "החלטת הוועדה המקומית (מושא הערר)")
|
||||
P(doc, "**דחתה** את טענת המתנגדים לפקיעת התכנית (בהסתמך על ע\"א 3213/97 נקר) ואת טענת השימושים האסורים.")
|
||||
P(doc, "**קיבלה** והפכה לבסיס הסירוב: (1) היקף הבינוי חורג משלב א' לפי הוראת השלביות; (2) הבינוי המונוליטי שונה דרמטית מנספח הבינוי; (3) משטחי ההבשלה הפתוחים מנוגדים לסעיף 6.9 (טיפול במבנים סגורים) — שהפרתו מוגדרת בתכנית כסטייה ניכרת.")
|
||||
|
||||
H2(doc, "טענות סף (לדיון תמציתי)")
|
||||
BULLET(doc, "**פקיעת תכנית** — סעיף הפקיעה (5 שנים) הושמט מהנוסח המאושר (ראו ממצאי ניתוח-העומק).")
|
||||
BULLET(doc, "**מיצוי התנגדות / \"מועד ב'\"** — האם טענות שהוכרעו בשלב התכנית מועלות מחדש בשלב הרישוי.")
|
||||
BULLET(doc, "**זכות עמידה** — מועד חתימת חוזה החכירה מול מועד פתיחת הבקשה (הוכרע: קיבוץ נחשון חוכר רשום).")
|
||||
BULLET(doc, "**פגמי פרסום / היעדר יידוע** (ס' 149(א)(2א)) — היעדר תשריט, אי-יידוע ועדה מקומית יואב.")
|
||||
BULLET(doc, "**איחור בהגשת ההתנגדויות** — מול אינטרס ההסתמכות (רכישת קרקע ב-35 מיליון ₪).")
|
||||
|
||||
H2(doc, "חמש הסוגיות המהותיות")
|
||||
|
||||
P(doc, "**סוגיה 1 (מכריעה) — שלביות הביצוע: בינוי מול היקף פעילות**")
|
||||
BULLET(doc, "השאלה: האם הוראת השלביות (ס' 7.1) חלה על הבינוי הפיזי או רק על היקף ההפעלה?")
|
||||
BULLET(doc, "עמדות: עוררים — נוגעת להיקף הפעילות; ועדה/משיבים — חלה על הבינוי (4 מבנים ≈ 8,000 מ\"ר מול ~19,118 מבוקשים).")
|
||||
BULLET(doc, "לאן נוטה: **לטובת הוועדה** — לשון \"הקמת 4 מבני קומפוסט\" מתייחסת לבינוי; נספחים 06, 11 מצביעים על מנגנון פיילוט מדורג מהותי.")
|
||||
BULLET(doc, "תקדים: עע\"מ 10089/07 אירוס הגלבוע (אין לעקוף תכנון נדרש דרך היתר).")
|
||||
|
||||
P(doc, "**סוגיה 2 (מכריעה) — משטחי ההבשלה וסעיף 6.9: \"טיפול\" מול \"אחסון\"**")
|
||||
BULLET(doc, "השאלה: האם ההבשלה הפתוחה היא \"טיפול\" החוסה תחת 6.9 (→ סטייה ניכרת החוסמת הקלה, ס' 151), או \"אחסון\"?")
|
||||
BULLET(doc, "עמדות: עוררים (ד\"ר ענבר, נספח 22) — אחסון מוצר סופי נטול ריח; ועדה — חלק בלתי נפרד מהטיפול.")
|
||||
BULLET(doc, "לאן נוטה: **נחלשה לוועדה** — נספח 07 §122-127: ועדת המשנה לעררים אישרה הבשלה פתוחה כ\"נכון וסביר\" (אך כינתה זאת \"שלב אחרון של טיפול\"). מתח פרשני אמיתי.")
|
||||
BULLET(doc, "תקדים: עע\"מ 402/03 עמותת העצמאים (ס' 151 — סטייה ניכרת).")
|
||||
|
||||
P(doc, "**סוגיה 3 — ההקלות בגובה ובתכסית + טענת \"טעות סופר\"**")
|
||||
BULLET(doc, "השאלה: האם ההקלות בגדרי הקלה או סטייה ניכרת? והאם התכסית 3.5% היא \"טעות סופר\"?")
|
||||
BULLET(doc, "לאן נוטה: טענת טעות הסופר **התחזקה מאוד** — נספח 12 (מופקד) מראה ש-3.5% נגזרה מתפיסה שהחממות \"אינן שטח לבניה\"; נספחים 06 §14 ו-07 §130 הורו לתקן; הסכם רמ\"י נוקב ב-30,280 מ\"ר.")
|
||||
BULLET(doc, "תקדים: עמ\"נ 25955-11-22 ברק-רחביה (פרשנות הרמונית); בג\"ץ 2667/17 מטה בנימין (\"פרשנות אפשרית\").")
|
||||
|
||||
P(doc, "**סוגיה 4 — תצורת הבינוי מול נספח בינוי מנחה ונספח נופי מחייב**")
|
||||
BULLET(doc, "השאלה: האם המבנה המונוליטי + הבריכה חורגים ממרחב הגמישות של נספח מנחה, לנוכח הנספח הנופי המחייב?")
|
||||
BULLET(doc, "תקדים: **ערר 1033-25 אבו גוש** (תקדים דפנה ישיר — נספח בינוי מנחה אינו המלצה בלבד); בג\"ץ 6525/15 עמק שווה.")
|
||||
|
||||
P(doc, "**סוגיה 5 — שיקולים זרים / NIMBY בהחלטת הסירוב**")
|
||||
BULLET(doc, "השאלה: האם הסירוב נגוע בשיקולים זרים? (עשוי להתייתר אם הבחינה העצמאית מכריעה).")
|
||||
BULLET(doc, "לאן נוטה: **נתמך בראיות** — תמליל (נספח 19, \"ועדה פוליטית\") + הצוות המקצועי המליץ לאשר (נספח 17). מנגד — בסיס מהותי לא-NIMBY (נספחים 25, 30).")
|
||||
|
||||
H2(doc, "ממצאי ניתוח-העומק — מה התחדש לאחר מיצוי 22 נספחי הרקע")
|
||||
BULLET(doc, "**6.9 (ליבת התיק) נחלש לוועדה** — ועדת המשנה לעררים כבר אישרה הבשלה פתוחה.")
|
||||
BULLET(doc, "**טעות הסופר בתכסית התחזקה** — שתי ערכאות הורו לתקן את חישוב השטח.")
|
||||
BULLET(doc, "**פקיעה — הוכרע עובדתית** — סעיף הפקיעה היה בנוסח המופקד (נספח 12) והושמט מהמאושר; נותרה מחלוקת משפטית בלבד (נקר/לויתן מול חמדת הגליל).")
|
||||
BULLET(doc, "**אופי המתקן מטה למשיבים** — תת\"ל 220 + ויתור על 8 מבני קומפוסט לטובת מתקן תרמי (נספחים 25, 30) → טיעון \"פריסת סלאמי\"/עקיפת תכנון.")
|
||||
BULLET(doc, "**תיקון עובדתי** — פסה\"ד שדחה את העתירה (נספח 10) ניתן נגד תכנית דרך הגישה, לא נגד מי/1030.")
|
||||
BULLET(doc, "**פער תחבורתי** — 80-92 משאיות/יום (מוסדות התכנון) מול 320-400 (ד\"ר לינק) — טעון יישוב.")
|
||||
|
||||
H2(doc, "פסיקה הדורשת אימות חיצוני (אינה בקורפוס הסמכותי)")
|
||||
BULLET(doc, "ע\"א 3213/97 **נקר** — \"הדין המתפרסם ברבים מחייב\" (עוגן הוועדה לדחיית הפקיעה).")
|
||||
BULLET(doc, "עע\"מ 4768/22 **חמדת הגליל** — פקיעת תכנית (עוגן המשיבים).")
|
||||
BULLET(doc, "ע\"א 482/99 בלפוריה; בג\"ץ 5636/13 מתיישבי תימורים; בג\"ץ 9098/01 גניס; דנ\"א 3993/07 איקאפוד.")
|
||||
|
||||
H2(doc, "שאלות פתוחות להכרעת היו\"ר + סדר דיון מומלץ")
|
||||
BULLET(doc, "(1) סיווג ההבשלה — טיפול (6.9) או אחסון (4.1.1)?")
|
||||
BULLET(doc, "(2) דין הפקיעה לאור ההשמטה המוכחת מהנוסח המאושר.")
|
||||
BULLET(doc, "(3) האם התכסית 3.5% היא טעות סופר הניתנת לתיקון פרשני?")
|
||||
BULLET(doc, "(4) האם תת\"ל 220 + הוויתור על מבני הקומפוסט הופכים את הבקשה ל\"עקיפת תכנון\" (אירוס הגלבוע)?")
|
||||
BULLET(doc, "(5) הסעד: סירוב מלא / אישור מותנה בהתאמה לשלב א' / החזרה לוועדה המקומית עם הנחיות.")
|
||||
P(doc, "**סדר דיון מומלץ:** טענות סף (פקיעה → מיצוי/השתק → עמידה/פרסום) ← סוגיה 1 (שלביות) ← סוגיה 2 (6.9) ← סוגיה 3 (הקלות/טעות סופר) ← סוגיה 4 (תצורת בינוי) ← סוגיה 5 (שיקולים זרים).")
|
||||
|
||||
H2(doc, "הערכת תרחישים")
|
||||
P(doc, "התמונה שקולה. לטובת הוועדה: סוגיה 1 (שלביות) וטיעון \"עקיפת תכנון/סלאמי\" נוכח תת\"ל 220 — חזקים. לטובת העוררים: סוגיה 2 (6.9) נחלשה, טעות הסופר התחזקה, ו-NIMBY נתמך בראיות. **התרחיש הסביר ביותר:** קבלה חלקית / החזרה מותנית או דחייה — תלוי בעיקר בשאלת היקף הבינוי בשלב א' ובשאלת \"עקיפת התכנון\". ההכרעה במובהק של יו\"ר הוועדה.")
|
||||
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.save(str(OUT))
|
||||
print(f"saved: {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,6 +15,7 @@
|
||||
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים |
|
||||
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — `--apply` מדלג על הסוכן; `--verify` מדווח אותו רם כ-DRIFT.** בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות. **`--verify` יוצא exit≠0 על כל drift** (needs-sync / adapter-mismatch / missing-in-mirror) — שמיש כ-gate ל-cron/CI (GAP-21/FU-8a). | ידני אחרי כל שינוי |
|
||||
| `fix_paperclipai_skills_drift.py` | python | סקריפט חד-פעמי (בוצע 2026-05-04) שניקה drift על `paperclipai/*` skills בין CMP ל-CMPA. הסיר `paperclip-dev` מכל 14 הסוכנים, ודאג ש-`paperclip-converting-plans-to-tasks` קיים רק על CEO ו-analyst. תומך `--apply` (ברירת מחדל: dry-run). דורש `PAPERCLIP_BOARD_API_KEY`. נשמר לרפרנס למקרה שhdrift חוזר. | חד-פעמי (בוצע) |
|
||||
| `classify_citation_treatments.py` | python | **סיווג-טיפול לקצוות-ציטוט (X11 Phase 2, #154)** — לכל קצה ב-`precedent_internal_citations` (החלטת-ועדה מצטטת תקדים) מסווג את ה-`treatment` מתוך ה-`match_context` דרך `corroboration.classify_treatment` (Opus 4.8 @ xhigh, claude_session **מקומי** — לא בקונטיינר): followed/explained=חיובי, distinguished/criticized/questioned/overruled=שלילי. ממלא `precedent_internal_citations.treatment` כך ש-`refresh_verified_layer` לא יספור ציטוט שלילי כסמכות (INV-COR2) ו-`db.citation_authority` יציג פירוק לסוכנים. אידמפוטנטי (מדלג על מסווגים). `--apply`/`--limit N`/`--case-law-id UUID`. **אחרי `--apply` הרץ `build_verified_layer.py`.** דורש `HOME=/home/chaim`. | ידני / אחרי גלי-ציטוט חדשים |
|
||||
| `adapter_profiles.py` | python (module) | **רישום-פרופילי-אדפטר** — מקור-אמת יחיד ל-3 צירי-הכשל של מעבר-אדפטר: provider/default_model, instructions_mode (`file_path` בטוח-frontmatter מול `content_arg` ששובר `---`), ו-tool_config (`gemini_global` excludeTools / `frontmatter` / `hermes` / `codex_home`). כולל `codex_local` עם משפחת מודלי OpenAI/Codex (`gpt-*`, `o3*`, `o4*`, `codex-*`). מיובא ע"י `migrate_agent_adapter.py`. הוספת אדפטר עתידי = רשומה אחת. לא מורץ ישירות. | תשתית |
|
||||
| `migrate_agent_adapter.py` | python | **מעבר-אדפטר בטוח לכל סוכן ← כל אדפטר, בשתי החברות יחד (INV-MC1)**. מיישב model↔provider, גורס frontmatter לעותק `.generated/<name>.nofm.md` ל-content_arg adapters (אחרת קריסת `gemini --prompt`/`hermes -q` על `---`), ומשחרר excludeTools גלובלי של gemini (`--relax-tools`). `--check` (preflight בלבד, exit≠0 על שגיאה — שער FU-8a) / `--apply` / `--revert` (שחזור מדויק מ-sidecar `data/adapter-migration-state.json`) / `--verify` (מסמן מצב לא-תואם/א-סימטרי, exit≠0). `--agent "<שם>"\|all --to <adapter> [--model X] [--relax-tools]`. PATCH דרך `/api/agents/{id}` (לא DB). דורש `PAPERCLIP_BOARD_API_KEY`. הרץ עם `mcp-server/.venv/bin/python`. **fallback-חירום כשנגמרים טוקני-Claude; החזר ל-claude_local כשחוזרים.** | ידני לפי צורך |
|
||||
|
||||
@@ -70,10 +71,13 @@
|
||||
| `compute_principle_gold.py` | python | **#153 (נטוש)** — גישת זיהוי-זהב ברמת-עיקרון דרך התאמת-`match_context`→הלכה. **הוחלף** ע"י "מאומת=אזכור" (`build_verified_layer.py`) אחרי שההתאמה נכשלה (match_context=רשימת-הפניות). נשמר לעיון. | deprecated |
|
||||
| `cull_principles.py` | python | **#152 Phase C — סינון רטרואקטיבי של קורפוס-העקרונות דרך פאנל-3 (הפיך).** מריץ על כל עיקרון 'original' קיים את אותו משטר שה-extractor משתמש בו להבא (`services/panel_extraction.panel_keep_score`, G2): 3 שופטים (Claude מקומי + DeepSeek + Gemini) מצביעים keep+score → כלל-האישור (3 קולות→שורד · 2 וציון≥0.85→שורד · 2 ו<0.85→יו"ר · ≤1→נדחה) → תקרת `HALACHA_PANEL_MAX_NEW`=5 לכל החלטה לפי ציון (`apply_cap`). נדחה → `halachot.review_status='rejected'` + ה-canonical שלו `rejected` (הפיך, גיבוי-CSV ב-`data/audit/` לפני כל כתיבה). מרוסן ב-`usage_limits` (עוצר-רך בתקרת-שימוש, resumable). `--dry-run` (ברירת-מחדל) / `--apply` / `--sample N` (החלטות אקראיות) / `--limit N` / `--no-throttle` / `--verbose`. **חובה מקומי** (3 שופטים). הרץ: `cd mcp-server && HOME=/home/chaim .venv/bin/python ../scripts/cull_principles.py --apply`. | **חד-פעמי** (סינון ראשוני) + ניתן-לחזרה |
|
||||
| `backfill_canonical_synthesis.py` | python | **V41 Phase 4 — סינתזת-LLM ל-`canonical_statement` (idempotent + resumable).** עובר על canonicals ב-`review_status='pending_synthesis'` (רב-instance ראשונים) ומזקק לכל אחד ניסוח אחד כללי ומעוגן בציטוטי-המופעים (INV-AH) דרך `services/canonical_synthesis.py` (מסלול-יחיד, G2). שערים: עיגון/הימנעות, **drift-floor** (cosine מול המקור, ברירת-מחדל 0.80 — סטייה גדולה→נשמר המקור), ואיסור ציטוטי-תיק חדשים. בכל מקרה הסטטוס מתקדם ל-`pending_review` לשער-היו"ר (G10/INV-LRN6). מודל Opus (`HALACHA_CANONICAL_SYNTH_MODEL`). מרוסן ע"י `usage_limits` (עוצר-רך בתקרת-שימוש, resumable). `--dry-run` (ברירת-מחדל) / `--apply` / `--sample N` (מדגם אקראי לבדיקה) / `--limit N` / `--no-throttle` / `--verbose`. CSV-audit ל-`data/audit/canonical-synthesis-*.csv`. **חובה מקומי** (claude_session). הרץ: `cd mcp-server && HOME=/home/chaim .venv/bin/python ../scripts/backfill_canonical_synthesis.py --apply`. שוטף: כלי-MCP `canonical_synthesize_pending`. | **חד-פעמי** (המסה הראשונית) + idempotent לחדשים |
|
||||
| `backfill_lesson_synthesis.py` | python | **#158 / INV-LRN8 — סינתזת-LLM של `decision_lessons` (idempotent + resumable).** עובר על shards של `practice_area`+`category` עם ≥2 לקחי-סגנון `approved`, מאשכל near-duplicates (cosine ≥ `LESSON_SYNTH_CLUSTER_THRESHOLD`), וממזג כל אשכול ל"לקח-על" אחד מעוגן-מקור (INV-AH) עם **drift-floor** (`LESSON_SYNTH_DRIFT_FLOOR` מול centroid) דרך `services/lesson_synthesis.py` (מסלול-יחיד, G2). הלקח-על נכתב `source='synthesis'` (`review_status='approved'`, שער-מדורג-הפיך), המקורות→`superseded`. כך הסט שזורם לכותב קטֵן ומשתבח (פותר את חיתוך limit=15, #157). מודל Opus (`LESSON_SYNTH_MODEL`), מרוסן `usage_limits`. `--dry-run` (ברירת-מחדל) / `--apply` / `--practice-area` / `--category` / `--no-throttle` / `--verbose`. CSV-audit ל-`data/audit/lesson-synthesis-*.csv`. **חובה מקומי** (claude_session). הרץ: `cd mcp-server && HOME=/home/chaim .venv/bin/python ../scripts/backfill_lesson_synthesis.py --apply`. שוטף: כלי-MCP `lesson_synthesize_pending`. | idempotent — הרצה לפי צורך/כשמצטברים לקחים |
|
||||
| `halacha_batch_reconcile.py` | python | **#82.7** — dedup חוצה-פסקים offline (שמרני, **dry-run בלבד**). dedup-on-insert משווה רק תוך-פסק; כאן סף מחמיר (cosine ≥0.95, `--cosine`) ולא-הרסני: מאתר זוגות הלכות near-duplicate בין פסקים שונים (pgvector `<=>` exact) עם איתות לקסיקלי (Jaccard/Levenshtein) ומדווח ל-CSV ב-`data/audit/` לסקירת היו"ר. לא מדלג/ממזג/מוחק. `--include-pending`. **`--link`** רושם את הזוגות שנמצאו כ-`equivalent_halachot` (parallel authority, #84.2 — **deprecated post-V41** — השתמש ב-`backfill_canonical_halachot.py --apply` במקום). רץ עם venv של mcp-server. | **deprecated** — הוחלף ב-`backfill_canonical_halachot.py` (V41). נשמר לצורכי audit |
|
||||
| `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest <path>`. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) |
|
||||
| `ab_halacha_opus48.py` | python | **A/B לא-הרסני לחילוץ הלכות (Claude)** — מריץ מחדש חילוץ הלכות על פסק-דין בודד דרך מודל/effort נבחרים (`AB_MODEL`/`AB_EFFORT`, ברירת-מחדל `claude-opus-4-8`/`xhigh`) ומשווה לסטטיסטיקות ההלכות הקיימות ב-DB **בלי למחוק/לכתוב כלום**. משכפל את `halacha_extractor.extract()` (אותם פרומפטים, בחירת-צ'אנקים, אימות-ציטוט) ומחליף רק את קריאת ה-LLM ב-`claude -p --model --effort`. מפיק `data/ab_halacha_<case>_<effort>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data .venv/bin/python scripts/ab_halacha_opus48.py <case_law_id>`. **ממצא 2026-05-31 (שטיין 1128-08-20):** Opus 4.8@xhigh חילץ 51 מול 124 בייצור (100% quote-verified מול 96%) אך ביטחון מכויל-נמוך יותר (חציון 0.75 מול 0.82) — ולכן **לא** מקטין את תור-האישור-הידני תחת sweep אוטו-אישור conf≥0.78 (26 מול 24). שיפור איכות, לא צמצום-תור. | ידני (החלטת מודל-חילוץ) |
|
||||
| `ab_halacha_codex.py` | python | **A/B לא-הרסני לחילוץ הלכות (Codex/gpt-5.5)** — עמית ל-`ab_halacha_opus48` אך מחליף את `claude -p` ב-`codex exec --model gpt-5.5` (אימות ChatGPT, ללא OPENAI_API_KEY). אותם פרומפטים ואותו הסקת quote-verification. הפלט האחרון של הסוכן (`-o FILE`) נפענח כ-JSON. `AB_MODEL` (default `gpt-5.5`), `AB_REASONING` low/medium/high/xhigh (default `medium`), `AB_CONCURRENCY` (default 1), `CODEX_BIN`. מפיק `data/ab_halacha_codex_<case>_<model>_<reasoning>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/ab_halacha_codex.py <case_law_id>`. **ממצא 2026-06-17 (8181-21 האוניברסיטה העברית):** gpt-5.5@medium חילץ 27 מול 28 של Opus (quote-verified 100%/100%), ביטחון חציון 0.86 מול 0.78 — אך **0 פריטים מתחת ל-0.7** (לעומת 9/28 של Opus = 32%), דבר המצביע על over-confidence. holding↑ (12 מול 7), procedural↓ (4 מול 7). **מסקנה: ריאלי כ-fallback חירום; לא מוכן לייצור ללא כיול-ביטחון.** | ידני (בנצ'מרק מודל codex) |
|
||||
| `calibrate_effort.py` | python | **#208 (WS5/Q1, INV-G8 eval-harness) — כיול model×effort של הכותב מול הסופיים.** A/B per-(תיק,בלוק,effort) על `draft_final_pairs` בעלי `final_text`: מייצר מחדש כל בלוק דרך מסלול-הייצור (`block_writer.write_block(effort_override=…)` → `claude_session.query` → `claude -p`, Opus 4.8 נעוץ, **מקומי-בלבד**) ומודד מול ה**סקשן** המתאים בסופי דרך `services/style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2): `change_percent` (compute_diff_stats) · `anti_pattern_total` (`lessons.ANTI_PATTERNS`, הסיגנל הנקי-לסגנון) · `golden_ratio_deviation_pp` · `distance` מרוכב. **ממליץ** per-בלוק (style-clean, #213) לפי `anti_pattern_total` **ראשי** (סיגנל-הסגנון הנקי) → סטיית-יחסי-זהב → `distance` (tiebreak קלוש); **`change_percent` מדווח-לא-מדורג** (מערבב סגנון עם שלמות-תוכן, 07-learning §0.7). ההמלצה נושאת דגל-`confidence` ("clear"/"weak" — הובלת-anti < epsilon=max(0.5, 0.20·spread) ⇒ ⚠️weak בתוך-הרעש, לא מוצג כוודאי) לצד ברירת-המחדל מ-#204. מפיק `data/eval/effort-calibration-<ts>.{json,md}` ב**כתיבה אינקרמנטלית** — הדוח נכתב מחדש אחרי כל בלוק שמסתיים, כך שקריסה באמצע הגריד לא מאבדת בלוקים שכבר יוצרו (INV-G8). כל תא-(תיק,בלוק,effort) שנכשל (rate-limit חולף, או חריגה) נרשם כ-warning ומדולג — לא מפיל את הריצה. **`block-yod` (דיון) הוצא מברירות-המחדל** (`CALIBRATABLE_BLOCKS`) — דורש כיוון-מאושר (brainstorm→approve_direction) שתיקי-הכיול חסרים ולכן `write_block` זורק; נשאר ב-`VALID_BLOCKS` לכפייה ידנית (`--blocks block-yod`) על-אחריות-המשתמש, כשהשער-per-תא שומר אותו לא-פטאלי. ⚠️ **גודל-מדגם מודפס בראש הדוח — עדות-כיוון, לא רגרסיה** (מעט סופיים-עלויים). `--self-test` (offline, אפס DB/CLI — מוכיח מדידה + דירוג-סגנון + confidence + rerank) · **`--rerank <report.json>`** (offline, אפס LLM/DB — מדרג-מחדש דוח-כיול שמור תחת המפתח החדש, מדפיס טבלה + כותב `*-reranked.md`) · `--dry-run` (תכנון-גריד) · `--efforts`/`--blocks`/`--case`/`--repeats`. **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצה: `POSTGRES_PASSWORD=… mcp-server/.venv/bin/python scripts/calibrate_effort.py`. | ידני — לכיול ברירות-effort של הכותב |
|
||||
| `calibrate_block_yod.py` | python | **#212 (WS5/Q1, INV-G8 eval-harness) — כיול effort ל-בלוק י' (דיון והכרעה) בלבד.** ה-harness ש-#208 לא יכל לכייל: `write_block(block-yod)` זורק "ללא כיוון מאושר" כי בלוק-הדיון דורש `decisions.direction_doc.approved` שתיקי-הכיול חסרים. **התוספת על #208 — שכבת אספקת-כיוון read-only:** לפני ייצור בלוק-הדיון, ה-harness מזרים `direction_doc` שמיש דרך **overlay על קריאת-ה-DB** (monkeypatch של `db.get_decision_by_case`/`db.get_decision` לתהליך-הזה-בלבד) — **אפס כתיבה ל-DB**, מצב-תיק-הייצור לא משתנה (השער reverted ב-finally). **אסטרטגיית-אספקה per-תיק:** (1) **stored** (מועדף, נאמן) — כיוון-מאושר שמור (`approved`+`selected_direction`, או סכמת-8126 עם `direction_id`/`structure`); (2) **reconstructed** (נסיגה, עדות-כיוון-בלבד) — `direction_doc` מינימלי שנגזר מ-`decision.outcome` + סקשן-הדיון של הסופי **באותו תיק** (INV-LRN5: outcome+מצביע-לסופי-עצמי, אפס מהות חוצת-תיקים, מתויג `reconstructed`); (3) **skip** אם אין דיון/כיוון. מודד דרך אותו `style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2) ומשתמש-חוזר ב-`_score_cell`/`recommend_effort` מ-`calibrate_effort` (#208). **ממצא-קורפוס (2026-06-30):** מ-8 סופיים, 7 ניתנים-לכיול; רק 8126-03-25 הוא stored+scorable (8174-12-24 שמור-אך-חסר-סקשן-דיון) → `--allow-reconstructed false` נותן תא-יחיד; ברירת-המחדל (reconstructed פעיל) נותנת 7 → הנסיגה היא מה שמפעיל את ה-harness היום. כתיבה אינקרמנטלית per-תיק + שער-per-תא לא-פטאלי (INV-G8). ⚠️ **גודל-מדגם בראש הדוח — עדות-כיוון, לא רגרסיה.** `--self-test` (offline, אפס DB/CLI — מוכיח מדידה + לוגיקת-אספקת-כיוון + overlay) · `--dry-run` (תכנון-גריד + מצב-אספקה per-תיק, צריך DB) · `--efforts`/`--case`/`--repeats`/`--allow-reconstructed`. ברירת-מחדל נוכחית: `xhigh` (`block_writer.py:72`, 'by feel'). **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצת-כיול-חי (לבצע ע"י היו"ר): `POSTGRES_PASSWORD=… POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5433 mcp-server/.venv/bin/python scripts/calibrate_block_yod.py`. | ידני — לכיול ה-effort של בלוק-הדיון (היו"ר מריץ; long, צורך-מכסה) |
|
||||
| `monitor_halacha_quality.py` | python | מנטר איכות חילוץ הלכות. בודק drift של `avg(confidence)` בין baseline היסטורי לחלון אחרון. מחזיר JSON מטריקות + alert ב-stderr אם drift > threshold (ברירת מחדל 5%). 2 סדרות: trusted (approved+published) ו-all_extracted. תומך `--window N` / `--threshold X` / `--min-sample N` / `--silent` / `--exit-on-alert`. רץ ב-container או מקומית עם `mcp-server/.venv` (אין תלות ב-LLM, רק SQL). **תזמון מומלץ**: `0 8 * * 1` (יום ראשון 08:00, שבועי) | `0 8 * * 1` (לתזמן) |
|
||||
| `audit_training_corpus.py` | python | audit של `style_corpus` — לכל החלטה: שדות מטא-דאטה מאוכלסים (`summary`/`outcome`/`key_principles`/`appeal_subtype`/`subject_categories`), קישור ל-`documents` (FK + chunks + embeddings). מפיק `data/audit/corpus-YYYY-MM-DD.json` + summary בקונסול. דרוש `POSTGRES_URL` או POSTGRES_*. אין תלויות חיצוניות מלבד asyncpg. **רץ מהמכונה המקומית** (לא קונטיינר) — חיבור ישיר ל-Postgres :5433 | ידני / קדם-עבודה לפני enrichment של מטא-דאטה |
|
||||
| `backfill_style_exemplars.py` | python | **T1 (style-acquisition)** — מאכלס `style_exemplars` מקורפוס דפנה (`style_corpus` + `internal_committee` chair=דפנה): מפצל לסעיפים (`chunker._split_into_sections`) → פסקאות (25-450 מילים) → embed (Voyage) → שמירה עם `section`/`outcome`/`practice_area`. מאפשר לכותב לאחזר פסקאות-בלוק אמיתיות של דפנה (T2/T3). מקור-סגנון בלבד (INV-LRN5). אידמפוטנטי (מנקה per-decision). `--dry-run` (default) / `--apply`. דורש POSTGRES_URL + Voyage. **רץ מקומית** (venv). | ידני (`python scripts/backfill_style_exemplars.py --apply`) |
|
||||
@@ -96,9 +100,11 @@
|
||||
| `leak_guard.py` | python | **המאכף הקנוני של INV-G12 (שער-הפלטפורמה / docs/spec/X15 §4 / R4).** שני כללים קשיחים: (1) `mcp-server/src` ללא סמלי-Paperclip (allowlist מנומק לפי substring); (2) רק `web/agent_platform_port.py` (+ קבצי-המעטפת) מייבאים את לקוח-Paperclip. stdlib-בלבד (אין venv). `leak_guard.py` = סריקת-repo (exit 1 על הפרה); `leak_guard.py <file>...` = קבצים נתונים (ל-hook). משותף ל-spec-guard.sh (hook), ל-CI (`.gitea/workflows/leak-guard.yaml`) ול-`mcp-server/tests/test_platform_port_leak_guard.py`. | CI + hook + pytest |
|
||||
| `check_undefined_names.py` | python | **CI gate ל-undefined names (מחלקת ה-NameError).** מריץ pyflakes על `web`, `mcp-server/src`, `scripts` ומפיל build (exit 1) רק על "undefined name"/"may be undefined" — לא על imports-לא-בשימוש/f-strings (רעש). זו בדיוק מחלקת-הבאג של PR #249 (שינוי-שם תיק → 500): שם שמופנה אך לא מיובא/מוגדר, חבוי בתוך `background_tasks` עד זמן-ריצה. דורש pyflakes (ה-workflow מתקין ל-venv זמני). משותף ל-CI (`.gitea/workflows/lint.yaml`). | CI |
|
||||
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
||||
| `host_sync.sh` | bash | מסנכרן את עץ-המארח `~/legal-ai` ל-origin/main (ff-only) כדי שקוד-המארח (כותב/פאנלים/MCP שרצים מהעץ, לא בקונטיינר) יתעדכן אחרי merge; restart מדויק ל-chat/court-fetch/reaper רק כשקבציהם משתנים. בטוח: אף-פעם לא force; tasks.json הדירטי נשמר. סוגר את פער-פריסת-המארח (TaskMaster #160) | `* * * * *` (cron, flock) |
|
||||
| `notify.py` | python | שליחת מייל התראה מסוכנים via SMTP (Gmail) | נקרא ע"י סוכנים |
|
||||
| `bidi_table.py` | python | יצירת טבלאות box-drawing עם תמיכה ב-BiDi (עברית+אנגלית) | ספריית עזר |
|
||||
| `convert_decision_template.py` | python | המרת `data/training/טיוטת החלטה.dotx` → `skills/docx/decision_template.docx` לטעינה ב-python-docx | להריץ כשמתעדכנת התבנית |
|
||||
| `regenerate_voice_fingerprint.py` | python | מסנתז מהקורפוס (style_corpus + דוגמאות + יחסים) **הצעה** לעדכון פרופיל-הקול (Opus מקומי). שער-יו"ר קשיח (INV-LRN1): כותב ל-`data/curator-proposals/`, לעולם לא דורס את `docs/daphna-voice-fingerprint.md` — היו"ר סוקרת ומקבעת ידנית. החצי-הפרוזה של #161 (החצי-המבני=ratios ב-PR#345). רץ על המארח (claude CLI). STYLE/METHOD בלבד (INV-LRN5) | ידני / לתזמן תקופתית |
|
||||
| `deploy-track-changes.sh` | bash | סנכרון skills CMP↔CMPA + בדיקות + הנחיות deploy לארכיטקטורת Track Changes | ידני |
|
||||
|
||||
## חד פעמי
|
||||
@@ -152,6 +158,7 @@
|
||||
|
||||
| Script | Type | Purpose | Scheduled |
|
||||
|--------|------|---------|-----------|
|
||||
| `ocr_benchmark_mistral.py` | python | **בנצ'מרק OCR — Mistral מול Google Vision** (מחקר חד-פעמי, הוביל למעבר ל-Mistral). מוריד מסמכים מ-MinIO, קורא טקסט קיים מה-DB, שולח ל-Mistral OCR, מחשב מטריקות (כיסוי/ניקיון/עברית%), שומר דוח ל-`data/audit/ocr-benchmark-mistral.md` + טקסטים גולמיים ל-`data/audit/ocr-benchmark-raw/`. הרצה: `mcp-server/.venv/bin/python scripts/ocr_benchmark_mistral.py`. דורש `MISTRAL_API_KEY` ו-mcli alias `legalminio`. **ממצא:** Mistral מנצח ב-4/5 תיקים בדוגמה ומטפל נכון ב-OCR שבור (1044-03-26 שהחזיר "English garbage" ב-Vision). | חד-פעמי (בוצע 2026-06-27) |
|
||||
| `backfill_missing_precedents.py` | python | **הזנת `missing_precedents` פתוחים לתור-האחזור (X13)** — מסווג כל פער-פתוח; עליון-סדרתי→Tier-0(supremedecisions), נט-format→Tier-1; ועדת-ערר/לא-מזוהה→דילוג. יוצר `court_fetch_jobs` (idempotent). `--apply` (ברירת-מחדל dry-run). אחרי הרצה: drain-court-fetch קולט. | ידני (חד-פעמי/לפי-צורך) |
|
||||
| `derive_missing_from_cited_only.py` | python | **#143 — איחוד cited_only↔missing_precedents (G2)**: גוזר רשומת `missing_precedents` 'open' לכל stub `cited_only` (פסיקה מצוטטת ללא טקסט), כך ש-31 ה-stubs מופיעים בדף "פסיקה חסרה" (היו היו חפיפה≈0). (1) backfill `citation_norm` (מפתח-dedup designator-aware — `court_citation.citation_dedup_key`) ל-291 הקיימים; (2) לכל stub → `create_missing_precedent(discovery_source='cited_only', linked_case_law_id=stub, notes=מצטטים)` עם dedup. `linked_case_law_id`=זהות-קנונית-ידועה, `status='open'` עד העלאת-טקסט (→ promote-in-place דרך ON CONFLICT). אידמפוטנטי, dry-run / `--apply`. הרצה: `HOME=/home/chaim mcp-server/.venv/bin/python scripts/derive_missing_from_cited_only.py --apply`. | חד-פעמי / re-runnable |
|
||||
| `backfill_digest_missing_precedents.py` | python | **#136 — חיבור יומונים-לא-מקושרים ל"פסיקה חסרה"**: לכל digest עם `underlying_citation` ו-`linked_case_law_id IS NULL` (461) מריץ את `digest_library.try_autolink` הקנוני (G2) — מקשר אם אפשר, אחרת פותח gap: ערר/בל"מ/unknown → `missing_precedent` (discovery_source='digest', dedup designator-aware), פס"ד בתי-משפט → `court_fetch_job` (X13). dry-run מציג פילוח-tier (369 ערר + 21 unknown → MP; 71 fetchable → court_fetch). אידמפוטנטי. הרצה: `HOME=/home/chaim mcp-server/.venv/bin/python scripts/backfill_digest_missing_precedents.py --apply`. | חד-פעמי / re-runnable |
|
||||
@@ -168,6 +175,7 @@
|
||||
|
||||
| Script | Original Purpose | Superseded By |
|
||||
|--------|-----------------|---------------|
|
||||
| `exec_summary_1043.py` | חד-פעמי — רינדור DOCX של סיכום-מנהלים לתיק 1043-02-26 דרך עוזרי-התבנית של `analysis_docx_exporter` (פרוטוטיפ). | `analysis_docx_exporter.build_party_claims_summary_docx()` + endpoint `GET /api/cases/{n}/research/party-claims-summary/export-docx` (WS3 #202; תבניתי, INV-EX4) |
|
||||
| `backfill_pattern_frequency.py` | עדכון תדירות דפוסי סגנון ב-DB | `web/app.py::_extract_pattern_variants()` |
|
||||
| `batch_upload_training.py` | העלאת קורפוס אימון (16 קבצים) | Web UI: `/api/training/upload` |
|
||||
| `benchmark_embeddings.py` | השוואת מודלי embeddings (voyage-3 vs voyage-4) | הושלם — voyage-3-large נבחר |
|
||||
@@ -198,7 +206,7 @@
|
||||
| `drain_digests.py` | python | ריקון תור ההעשרה של יומונים (X12): מעבד כל digest בסטטוס `pending` דרך `digest_library.enrich_digest` (חילוץ-LLM Sonnet + embedding + autolink). מקבילי (CONCURRENCY=3, env-tunable), idempotent. מוסיף `~/.local/bin` ל-PATH כדי שה-claude CLI יימצא תחת cron. בודק דגל `drain_controls('legal-digest-drain')` ב-startup → no-op כשכבוי מ-/operations. | דרך `legal-digest-drain.config.cjs` (pm2 cron) + ידני אחרי backfill. חלופת-MCP: `digest_process_pending` |
|
||||
| `legal-digest-drain.config.cjs` | pm2/js | **תזמון כל שעתיים של `drain_digests.py`** (cron `12 */2 * * *`, `DIGEST_DRAIN_CRON` לעקיפה; דקת-הצתה `:12` כדי לא לחלוק דקה עם metadata-drain `:00` — מונע deadlock של DDL-המיגרציה) — הועבר מ-crontab של המערכת ל-pm2 כדי שיופיע ויהיה שליט בדף `/operations` (הרץ-עכשיו/הפעל/כבה). `autorestart:false` (one-shot per tick). דורש claude CLI + `VOYAGE_API_KEY`. התקנה: `pm2 start scripts/legal-digest-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
|
||||
|
||||
| `renumber_cases.py` | python | **מיגרציה חד-פעמית (בוצעה 2026-06-12)** — תיקון 11 מספרי-תיקים לפורמט קנוני `NNNN-MM-YY` (הוספת ספרות-חודש; 1046-26→1024-02-26 תיקון-סידורי). רץ על ה-host (לא בקונטיינר): DB pool של האפליקציה + `mcli` (MinIO) + Gitea API + Paperclip DB. אטומי per-case עם גיבוי ל-`data/audit/` ואימות-אחרי. FK-ים על `cases.id` (UUID) לא נגעו; משכתב כל עמודה עם `cases/{old}/` (file_path **וגם** image_thumbnail_path שהוא storage-key בלי `/data`), מנרמל זהות חוצת-קורפוס (case_law/style_corpus/style_exemplars/citations — לא תוכן/full_text), מעביר מפתחי-MinIO ב-3 buckets (legal-immutable=WORM copy-only), משנה-שם repo ב-Gitea, ומעדכן שם-פרויקט ב-Paperclip. dry-run כברירת-מחדל; `--apply --tier clean\|archive`. **מיצוי — לא להריץ שוב** (ה-MAPPING היסטורי). | חד-פעמי — בוצע |
|
||||
| `renumber_cases.py` | python | **מיגרציה חד-פעמית (בוצעה 2026-06-12)** — תיקון 11 מספרי-תיקים לפורמט קנוני `NNNN-MM-YY` (הוספת ספרות-חודש; 1046-26→1024-02-26 תיקון-סידורי). רץ על ה-host (לא בקונטיינר): DB pool של האפליקציה + `mcli` (MinIO) + Gitea API + Paperclip DB. אטומי per-case עם גיבוי ל-`data/audit/` ואימות-אחרי. FK-ים על `cases.id` (UUID) לא נגעו; משכתב כל עמודה עם `cases/{old}/` (file_path **וגם** image_thumbnail_path שהוא storage-key בלי `/data`), מנרמל זהות חוצת-קורפוס (case_law/style_corpus/style_exemplars/citations — לא תוכן/full_text), מעביר מפתחי-MinIO ב-3 buckets (legal-immutable=WORM copy-only), משנה-שם repo ב-Gitea, ומעדכן ב-Paperclip את **שם-הפרויקט + `plugin_state.legal-case-number` + כותרות-issues** (3 המשטחים ש-`get_case_issues` נשען עליהם — בלי (b)+(c) ה-issues נשארים על המספר הישן ו-run-learning/run-halacha מדלגים בשקט עם "no_issue"; תוקן 2026-06-28 אחרי שהפער התגלה ב-8137-11-24). dry-run כברירת-מחדל; `--apply --tier clean\|archive`. **מיצוי — לא להריץ שוב** (ה-MAPPING היסטורי); לתבנית-עתידית: ה-Paperclip-step המעודכן הוא הרפרנס. | חד-פעמי — בוצע |
|
||||
|
||||
## סקריפטים שנמחקו (git history בלבד)
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ Mapping (removed → kept):
|
||||
|
||||
uploading → processing
|
||||
in_progress → outcome_set
|
||||
analyst_verified → documents_ready
|
||||
research_complete → documents_ready
|
||||
brainstorming → outcome_set
|
||||
analysis_enriched → direction_approved
|
||||
ready_for_writing → direction_approved
|
||||
@@ -50,12 +48,14 @@ from legal_mcp.services import db # noqa: E402
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
log = logging.getLogger("status-trim")
|
||||
|
||||
# removed status → nearest preceding kept status
|
||||
# removed status → nearest preceding kept status.
|
||||
# NOTE (2026-06-30): analyst_verified + research_complete were RE-CANONICALISED
|
||||
# into the status model (legal_mcp/case_status_model.py, "thinking" phase) — they
|
||||
# are valid statuses again, so they were removed from this map. Do NOT downgrade
|
||||
# them; only genuinely-removed markers remain below.
|
||||
STATUS_MAP = {
|
||||
"uploading": "processing",
|
||||
"in_progress": "outcome_set",
|
||||
"analyst_verified": "documents_ready",
|
||||
"research_complete": "documents_ready",
|
||||
"brainstorming": "outcome_set",
|
||||
"analysis_enriched": "direction_approved",
|
||||
"ready_for_writing": "direction_approved",
|
||||
|
||||
147
scripts/backfill_lesson_synthesis.py
Normal file
147
scripts/backfill_lesson_synthesis.py
Normal file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill — LLM synthesis of decision_lessons (#158 / INV-LRN8).
|
||||
|
||||
WHAT THIS DOES
|
||||
--------------
|
||||
Walks (practice_area, category) shards that hold ≥2 live APPROVED style lessons,
|
||||
clusters near-duplicates (cosine), and asks a local ``claude_session`` model (Opus
|
||||
by default) to merge each cluster into ONE richer, generalised "super-lesson" —
|
||||
grounded in the source lessons (INV-AH) with a drift guard. Accepted merges are
|
||||
written as ``source='synthesis'`` rows (review_status='approved', graduated gate)
|
||||
and their sources flip to ``superseded`` (provenance, no longer writer-fed). The
|
||||
writer-fed set shrinks, so its limit=15 stops truncating (#157).
|
||||
|
||||
All logic lives in services/lesson_synthesis.py (G2) — this is the batch driver:
|
||||
shard ordering, throttling, dry-run reporting and a CSV audit trail.
|
||||
|
||||
IDEMPOTENCY / RESUME
|
||||
--------------------
|
||||
Re-running is safe: superseded sources are excluded from candidates, and an accepted
|
||||
merge that matches an existing synthesis (cosine) is skipped (duplicate_skipped).
|
||||
|
||||
USAGE
|
||||
-----
|
||||
cd ~/legal-ai/mcp-server
|
||||
.venv/bin/python ../scripts/backfill_lesson_synthesis.py --dry-run # all shards, no writes
|
||||
.venv/bin/python ../scripts/backfill_lesson_synthesis.py --dry-run --practice-area rishuy_uvniya --category style
|
||||
.venv/bin/python ../scripts/backfill_lesson_synthesis.py --apply # full throttled run
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||
|
||||
from legal_mcp.services import db, lesson_synthesis # noqa: E402
|
||||
|
||||
try: # stdlib-only module, importable from system python too
|
||||
from legal_mcp.services import usage_limits
|
||||
except Exception: # pragma: no cover
|
||||
usage_limits = None
|
||||
|
||||
AUDIT_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "audit")
|
||||
|
||||
|
||||
def _throttled() -> tuple[bool, str]:
|
||||
if usage_limits is None:
|
||||
return False, "usage_limits unavailable"
|
||||
usage = usage_limits.subscription_usage()
|
||||
if usage is None:
|
||||
return False, "usage read failed (proceeding)"
|
||||
over, _reset, detail = usage_limits.ceiling_status(usage)
|
||||
return over, detail
|
||||
|
||||
|
||||
def _short(s: str, n: int = 100) -> str:
|
||||
s = (s or "").replace("\n", " ")
|
||||
return s if len(s) <= n else s[: n - 1] + "…"
|
||||
|
||||
|
||||
async def _run(apply: bool, practice_area: str, category: str,
|
||||
throttle: bool, verbose: bool) -> int:
|
||||
if practice_area and category:
|
||||
shards = [{"practice_area": practice_area, "category": category}]
|
||||
else:
|
||||
shards = await db.synthesis_shards(min_size=2)
|
||||
if practice_area:
|
||||
shards = [s for s in shards if s["practice_area"] == practice_area]
|
||||
if category:
|
||||
shards = [s for s in shards if s["category"] == category]
|
||||
|
||||
mode = "APPLY" if apply else "DRY-RUN"
|
||||
print(f"[{mode}] {len(shards)} shards with ≥2 approved lessons "
|
||||
f"(throttle={'on' if throttle else 'off'})\n")
|
||||
if not shards:
|
||||
print("nothing to do.")
|
||||
return 0
|
||||
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
os.makedirs(AUDIT_DIR, exist_ok=True)
|
||||
audit_path = os.path.join(
|
||||
AUDIT_DIR, f"lesson-synthesis-{'apply' if apply else 'dryrun'}-{stamp}.csv")
|
||||
counts: Counter[str] = Counter()
|
||||
stopped = False
|
||||
|
||||
with open(audit_path, "w", newline="", encoding="utf-8") as fh:
|
||||
w = csv.writer(fh)
|
||||
w.writerow(["practice_area", "category", "cluster_size", "status",
|
||||
"drift_cosine", "applied", "reason", "after", "member_ids"])
|
||||
for n, s in enumerate(shards, 1):
|
||||
if throttle:
|
||||
over, detail = _throttled()
|
||||
if over:
|
||||
print(f"\n⏸ usage ceiling reached ({detail}) — stopping at "
|
||||
f"shard {n - 1}/{len(shards)}. Re-run to resume.")
|
||||
stopped = True
|
||||
break
|
||||
pa, cat = s["practice_area"], s["category"]
|
||||
res = await lesson_synthesis.run_shard(pa, cat, apply=apply)
|
||||
print(f"[{n}/{len(shards)}] {pa}/{cat}: {res['candidates']} candidates → "
|
||||
f"{len(res['clusters'])} clusters")
|
||||
for c in res["clusters"]:
|
||||
counts[c["status"]] += 1
|
||||
w.writerow([pa, cat, len(c["members"]), c["status"],
|
||||
c.get("drift_cosine"), c.get("applied"),
|
||||
c.get("reason", ""), c.get("proposed", ""),
|
||||
"|".join(c["members"])])
|
||||
mark = {"accepted": "✓", "duplicate_skipped": "=", "abstained": "·",
|
||||
"drift_rejected": "✗", "llm_error": "!", "too_small": "·"}.get(c["status"], "?")
|
||||
print(f" {mark} {c['status']:<18} size={len(c['members'])} "
|
||||
f"drift={c.get('drift_cosine')}{' [written]' if c.get('applied') else ''}")
|
||||
if verbose and c.get("proposed"):
|
||||
print(f" → {_short(c['proposed'])}")
|
||||
|
||||
processed = sum(counts.values())
|
||||
print(f"\n── summary ({mode}) — {processed} clusters"
|
||||
f"{' (stopped early)' if stopped else ''} ──")
|
||||
for status, c in counts.most_common():
|
||||
print(f" {status:<18} {c}")
|
||||
print(f"\naudit CSV: {audit_path}")
|
||||
if not apply:
|
||||
print("dry-run — nothing written. Re-run with --apply to commit.")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description="LLM synthesis of decision_lessons (#158 / INV-LRN8)")
|
||||
p.add_argument("--apply", action="store_true", help="commit to the DB (default: dry-run)")
|
||||
p.add_argument("--dry-run", action="store_true", help="explicit dry-run (default)")
|
||||
p.add_argument("--practice-area", default="", help="limit to one practice_area")
|
||||
p.add_argument("--category", default="", help="limit to one category (style/structure/lexicon/tabular)")
|
||||
p.add_argument("--no-throttle", action="store_true", help="skip usage-ceiling checks")
|
||||
p.add_argument("--verbose", action="store_true", help="print merged text per cluster")
|
||||
args = p.parse_args()
|
||||
return asyncio.run(_run(
|
||||
apply=args.apply, practice_area=args.practice_area, category=args.category,
|
||||
throttle=not args.no_throttle, verbose=args.verbose,
|
||||
))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -19,40 +19,15 @@ import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from legal_mcp.services import db, embeddings
|
||||
from legal_mcp.services.chunker import _split_into_sections
|
||||
from legal_mcp.services import db
|
||||
from legal_mcp.services import style_exemplars as sx
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
log = logging.getLogger("backfill_exemplars")
|
||||
|
||||
# chunker section_type → style_exemplars.section
|
||||
_SECTION_MAP = {
|
||||
"facts": "background",
|
||||
"appellant_claims": "claims",
|
||||
"respondent_claims": "claims",
|
||||
"legal_analysis": "discussion",
|
||||
"conclusion": "summary",
|
||||
"ruling": "summary",
|
||||
"intro": "other",
|
||||
"other": "other",
|
||||
}
|
||||
|
||||
MIN_WORDS = 25 # skip tiny fragments
|
||||
MAX_WORDS = 450 # skip over-long blobs (likely un-split)
|
||||
MAX_PER_SECTION = 15
|
||||
|
||||
|
||||
def _paragraphs(section_text: str) -> list[str]:
|
||||
"""Split a section into paragraph units (blank-line separated; fall back to lines)."""
|
||||
raw = [p.strip() for p in section_text.split("\n\n")]
|
||||
if len(raw) <= 1:
|
||||
raw = [p.strip() for p in section_text.split("\n")]
|
||||
out = []
|
||||
for p in raw:
|
||||
wc = len(p.split())
|
||||
if MIN_WORDS <= wc <= MAX_WORDS:
|
||||
out.append(p)
|
||||
return out[:MAX_PER_SECTION]
|
||||
# Section mapping + paragraph splitting now live in the shared service
|
||||
# (legal_mcp.services.style_exemplars) so the backfill and the live
|
||||
# final-enrollment path use ONE extraction implementation (G2).
|
||||
|
||||
|
||||
async def _gather_sources() -> list[dict]:
|
||||
@@ -94,28 +69,18 @@ async def main(apply: bool) -> None:
|
||||
|
||||
total_paras = 0
|
||||
for src in sources:
|
||||
units: list[tuple[str, str]] = [] # (section, paragraph)
|
||||
for section_type, section_text in _split_into_sections(src["full_text"]):
|
||||
section = _SECTION_MAP.get(section_type, "other")
|
||||
for para in _paragraphs(section_text):
|
||||
units.append((section, para))
|
||||
if not units:
|
||||
n = len(sx.units_for(src["full_text"]))
|
||||
if not n:
|
||||
continue
|
||||
total_paras += len(units)
|
||||
log.info(" %-14s %-16s → %d פסקאות", src["source"], src["decision_number"], len(units))
|
||||
total_paras += n
|
||||
log.info(" %-14s %-16s → %d פסקאות", src["source"], src["decision_number"], n)
|
||||
if not apply:
|
||||
continue
|
||||
|
||||
await db.delete_style_exemplars(src["decision_number"], src["source"])
|
||||
texts = [u[1] for u in units]
|
||||
vecs = await embeddings.embed_texts(texts, input_type="document")
|
||||
for (section, para), vec in zip(units, vecs):
|
||||
await db.insert_style_exemplar(
|
||||
decision_number=src["decision_number"], source=src["source"],
|
||||
practice_area=src["practice_area"], outcome=src["outcome"],
|
||||
section=section, paragraph_text=para, word_count=len(para.split()),
|
||||
embedding=vec,
|
||||
)
|
||||
await sx.extract_and_store(
|
||||
decision_number=src["decision_number"], source=src["source"],
|
||||
full_text=src["full_text"], practice_area=src["practice_area"],
|
||||
outcome=src["outcome"],
|
||||
)
|
||||
|
||||
if apply:
|
||||
cov = await db.count_style_exemplars()
|
||||
|
||||
646
scripts/calibrate_block_yod.py
Normal file
646
scripts/calibrate_block_yod.py
Normal file
@@ -0,0 +1,646 @@
|
||||
#!/usr/bin/env python3
|
||||
"""#212 (WS5 / Q1, INV-G8 eval-harness) — DEDICATED effort calibration for בלוק י'
|
||||
(block-yod, the discussion/decision block — דיון והכרעה).
|
||||
|
||||
WHY A SEPARATE HARNESS (the #208 harness could NOT calibrate block-yod):
|
||||
block-yod is the most important block — its effort is currently `xhigh`, set
|
||||
"by feel" (block_writer.py:72), never empirically calibrated. The general #208
|
||||
harness (scripts/calibrate_effort.py) EXCLUDES it from CALIBRATABLE_BLOCKS
|
||||
because block_writer.write_block(case_id, "block-yod", …) raises
|
||||
ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר…")
|
||||
— the discussion block requires an APPROVED DIRECTION (brainstorm →
|
||||
approve_direction → decisions.direction_doc{"approved": True, …}) that the
|
||||
calibration cases (the draft_final_pairs finals) do not have. So block-yod is
|
||||
not calibratable standalone without first SUPPLYING a direction.
|
||||
|
||||
WHAT THIS HARNESS ADDS over #208 — the DIRECTION-SUPPLY layer (read-only):
|
||||
Before regenerating block-yod for a calibration case, it makes a usable
|
||||
`direction_doc` available to write_block via a READ-ONLY DB OVERLAY (it
|
||||
monkeypatches db.get_decision_by_case / db.get_decision FOR THIS PROCESS ONLY
|
||||
to overlay the direction into the returned decision dict). NOTHING is written
|
||||
to the DB — production case state is never mutated (no UPDATE), satisfying the
|
||||
"do not pollute real case state" constraint. The overlay is reverted in a
|
||||
finally block.
|
||||
|
||||
DIRECTION-SUPPLY STRATEGY (per-case, documented in the report):
|
||||
1. STORED (preferred, most faithful): if the case's decision already carries
|
||||
a usable approved direction (direction_doc.approved is truthy and it has a
|
||||
selected_direction, OR the 8126-style schema with direction_id/structure),
|
||||
use it verbatim. This is the production-faithful path.
|
||||
2. RECONSTRUCTED (fallback, directional-only): otherwise synthesize a MINIMAL
|
||||
direction_doc from the case's decision.outcome + the final's OWN discussion
|
||||
section (the chair's signed reasoning is the ground truth for "what
|
||||
direction was taken"). Clearly LABELLED reconstructed in the report → the
|
||||
resulting numbers are directional, not faithful to a real brainstorm.
|
||||
INV-LRN5: the reconstructed direction carries the case's OWN outcome +
|
||||
a neutral pointer to its OWN final — no substance dragged across cases.
|
||||
|
||||
EMPIRICAL NOTE (measured on the current corpus, 2026-06-30): of the 8 finals,
|
||||
only 8174-12-24 has a STORED approved direction — and its final has NO parsable
|
||||
discussion section (0 words), so it is not scorable for block-yod anyway. The 7
|
||||
finals that DO have a scorable discussion section all LACK a usable stored
|
||||
direction. Hence the RECONSTRUCTED fallback is what makes this harness useful
|
||||
today; --dry-run prints the per-case supply mode so the operator sees this.
|
||||
|
||||
WHAT IT MEASURES (identical to #208, REUSED — no parallel metric path, G2):
|
||||
Regenerate block-yod at each effort via block_writer.write_block(effort_override)
|
||||
(the PRODUCTION generation path → claude_session.query → `claude -p`, pinned
|
||||
Opus 4.8, local-only), then score vs the final's DISCUSSION section via
|
||||
services.style_distance.block_distance_to_final (change_percent /
|
||||
anti_pattern_total / golden_ratio_deviation_pp / composite distance). The
|
||||
recommend_effort, aggregate_cell, _score_cell and direction-free scaffolding are
|
||||
imported from calibrate_effort (#208) — this file only supplies the direction
|
||||
and restricts the grid to block-yod.
|
||||
|
||||
GENERATION PATH (do not violate — reference_claude_generation_path /
|
||||
claude_session docstring): write_block → claude_session.query → `claude -p` uses
|
||||
the local claude.ai session. It runs ONLY on the host where the `claude` CLI
|
||||
exists (NOT the legal-ai container, NOT a symlinked worktree without CLI access).
|
||||
Hence the live A/B is HOST-ONLY; --self-test proves the measurement +
|
||||
direction-supply logic offline with zero model calls.
|
||||
|
||||
⚠️ SAMPLE-SIZE CAVEAT (honored, not hidden): very few cases have an uploaded
|
||||
final WITH a parsable discussion section. The report prints n_finals PROMINENTLY
|
||||
and labels the output DIRECTIONAL EVIDENCE, not a regression. With n<3 the
|
||||
recommendation is advisory only; the chair/operator decides whether to adopt.
|
||||
|
||||
Usage (mcp-server venv; live needs POSTGRES + the `claude` CLI on the host):
|
||||
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
|
||||
$PY scripts/calibrate_block_yod.py --self-test # offline proof, no DB/CLI
|
||||
$PY scripts/calibrate_block_yod.py --dry-run # plan grid + supply modes (needs DB, no model)
|
||||
POSTGRES_PASSWORD=… POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5433 \
|
||||
$PY scripts/calibrate_block_yod.py # live A/B over finals (host-only)
|
||||
… --efforts low,medium,high,xhigh # override the effort grid
|
||||
… --case 1130-08-25 # a single case
|
||||
… --repeats 2 # avg N gens/cell (noise)
|
||||
… --allow-reconstructed false # STORED-direction cases only (faithful)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from statistics import mean
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
if "POSTGRES_URL" not in os.environ:
|
||||
os.environ["POSTGRES_URL"] = (
|
||||
f"postgres://{os.environ.get('POSTGRES_USER','legal_ai')}:"
|
||||
f"{os.environ.get('POSTGRES_PASSWORD','')}@"
|
||||
f"{os.environ.get('POSTGRES_HOST','127.0.0.1')}:"
|
||||
f"{os.environ.get('POSTGRES_PORT','5433')}/"
|
||||
f"{os.environ.get('POSTGRES_DB','legal_ai')}"
|
||||
)
|
||||
|
||||
# Reuse the #208 machinery wholesale (G2 — no parallel harness/metric path).
|
||||
# These are pure/offline-safe imports; calibrate_effort does no work at import.
|
||||
from calibrate_effort import ( # noqa: E402
|
||||
IL_TZ,
|
||||
OUT_DIR,
|
||||
VALID_EFFORTS,
|
||||
_current_default,
|
||||
_finals_for_calibration,
|
||||
_score_cell,
|
||||
recommend_effort,
|
||||
)
|
||||
|
||||
BLOCK_ID = "block-yod"
|
||||
SECTION = "discussion" # block-yod's golden-ratio section (style_distance._BLOCK_TO_SECTION)
|
||||
DEFAULT_EFFORTS = ["low", "medium", "high", "xhigh"]
|
||||
|
||||
|
||||
# ── direction supply (the block-yod-specific piece) ──────────────────────────
|
||||
def _stored_direction_is_usable(direction_doc: dict | None) -> bool:
|
||||
"""True if the case's stored direction_doc can drive write_block(block-yod).
|
||||
|
||||
write_block gates on `direction_doc.get("approved")` being truthy. Two stored
|
||||
schemas exist in the corpus:
|
||||
• brainstorm schema (build_direction_doc): {"approved": True, "selected_direction": …}
|
||||
• chair-approved schema (8126-style): {"direction_id", "direction_name",
|
||||
"structure", "template", …} — has NO "approved" key, so write_block would
|
||||
reject it as-is. We treat it as usable IFF it carries a concrete direction
|
||||
(direction_id/direction_name), and _normalize_stored_direction() stamps
|
||||
approved=True onto the OVERLAY copy (never the DB).
|
||||
Empty / brainstorm-only ({"brainstorm": …, "approved": False}) → not usable.
|
||||
"""
|
||||
dd = direction_doc or {}
|
||||
if dd.get("approved") and (dd.get("selected_direction") or dd.get("reasoning")):
|
||||
return True
|
||||
if dd.get("direction_id") or dd.get("direction_name"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_stored_direction(direction_doc: dict) -> dict:
|
||||
"""Return an overlay copy of a usable stored direction with approved=True.
|
||||
|
||||
Pure dict transform on a COPY — the real decisions.direction_doc is untouched.
|
||||
The 8126-style schema lacks `approved`; stamping it here lets write_block's gate
|
||||
pass without us mutating the DB.
|
||||
"""
|
||||
dd = dict(direction_doc)
|
||||
dd["approved"] = True
|
||||
return dd
|
||||
|
||||
|
||||
def reconstruct_minimal_direction(outcome: str, final_discussion: str) -> dict:
|
||||
"""Build a MINIMAL, clearly-reconstructed direction_doc (fallback path).
|
||||
|
||||
Faithful enough to make write_block(block-yod) runnable, honest about being a
|
||||
reconstruction: the chair's SIGNED final discussion is the ground truth for
|
||||
"what direction was taken", and the case's OWN outcome fixes the structure.
|
||||
INV-LRN5: case's own outcome + a neutral pointer to its own final only — no
|
||||
cross-case substance. Pure (no DB/LLM) → unit-tested in --self-test.
|
||||
"""
|
||||
from legal_mcp.services.lessons import OUTCOME_LABELS_HE, canonical_outcome
|
||||
|
||||
oc = canonical_outcome(outcome or "rejection")
|
||||
snippet = " ".join((final_discussion or "").split()[:120])
|
||||
return {
|
||||
"approved": True,
|
||||
"reconstructed": True, # marker so the report can label it
|
||||
"outcome": oc,
|
||||
"outcome_hebrew": OUTCOME_LABELS_HE.get(oc, oc),
|
||||
"reasoning": (
|
||||
"כיוון משוחזר לצורך כיול-effort בלבד (לא סיעור-מוחות אמיתי): "
|
||||
"התוצאה והכיוון נגזרים מההחלטה החתומה של היו\"ר בתיק זה. "
|
||||
"תמצית פתיח-הדיון של הסופי: " + snippet
|
||||
),
|
||||
"selected_direction": {
|
||||
"name": f"כיוון משוחזר — {OUTCOME_LABELS_HE.get(oc, oc)}",
|
||||
"reasoning": [
|
||||
"שחזר את שלד-הנימוק מהדיון החתום של היו\"ר בתיק זה.",
|
||||
],
|
||||
"precedents": [],
|
||||
},
|
||||
"additional_notes": (
|
||||
"⚠️ כיוון משוחזר — תוצאת-הכיול היא עדות-כיוון, לא נאמנה למסמך-כיוון אמיתי."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def plan_direction_supply(
|
||||
decision: dict | None,
|
||||
outcome: str,
|
||||
final_discussion: str,
|
||||
allow_reconstructed: bool,
|
||||
) -> tuple[dict | None, str]:
|
||||
"""Decide how to supply block-yod's direction for one case.
|
||||
|
||||
Returns (overlay_direction_doc | None, mode) where mode ∈
|
||||
{"stored", "reconstructed", "skip"}. Pure (no I/O) → unit-tested.
|
||||
"""
|
||||
stored = (decision or {}).get("direction_doc")
|
||||
if _stored_direction_is_usable(stored):
|
||||
return _normalize_stored_direction(stored), "stored"
|
||||
if allow_reconstructed and (final_discussion or "").strip():
|
||||
return reconstruct_minimal_direction(outcome, final_discussion), "reconstructed"
|
||||
return None, "skip"
|
||||
|
||||
|
||||
class _DirectionOverlay:
|
||||
"""Read-only overlay: make db.get_decision_by_case / db.get_decision return the
|
||||
case's decision WITH `direction_doc` replaced by the supplied overlay — for the
|
||||
target case_id only, for the duration of one cell. Reverted in __exit__.
|
||||
|
||||
write_block re-fetches the decision internally (block_writer.py:383), so passing
|
||||
a direction dict in isn't enough — the DB read must yield it. We patch the read,
|
||||
not the write: ZERO DB mutation (no UPDATE ever issued).
|
||||
"""
|
||||
|
||||
def __init__(self, case_id, direction_doc: dict):
|
||||
self._case_id = str(case_id)
|
||||
self._dir = direction_doc
|
||||
self._orig_by_case = None
|
||||
self._orig_by_id = None
|
||||
|
||||
def __enter__(self):
|
||||
from legal_mcp.services import db
|
||||
|
||||
self._orig_by_case = db.get_decision_by_case
|
||||
self._orig_by_id = db.get_decision
|
||||
|
||||
async def patched_by_case(case_id, _orig=self._orig_by_case):
|
||||
dec = await _orig(case_id)
|
||||
if dec and str(case_id) == self._case_id:
|
||||
dec = dict(dec)
|
||||
dec["direction_doc"] = self._dir
|
||||
return dec
|
||||
|
||||
async def patched_by_id(decision_id, _orig=self._orig_by_id):
|
||||
dec = await _orig(decision_id)
|
||||
if dec and dec.get("case_id") == self._case_id:
|
||||
dec = dict(dec)
|
||||
dec["direction_doc"] = self._dir
|
||||
return dec
|
||||
|
||||
db.get_decision_by_case = patched_by_case
|
||||
db.get_decision = patched_by_id
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
from legal_mcp.services import db
|
||||
|
||||
if self._orig_by_case is not None:
|
||||
db.get_decision_by_case = self._orig_by_case
|
||||
if self._orig_by_id is not None:
|
||||
db.get_decision = self._orig_by_id
|
||||
return False
|
||||
|
||||
|
||||
async def _score_block_yod_cell(case_id, effort, final_section, final_total_words,
|
||||
outcome, repeats, direction_doc):
|
||||
"""Score one (case, block-yod, effort) cell with the direction overlaid.
|
||||
|
||||
Wraps the #208 _score_cell (REUSED) inside the read-only direction overlay so
|
||||
write_block's approved-direction gate passes without DB mutation.
|
||||
"""
|
||||
with _DirectionOverlay(case_id, direction_doc):
|
||||
return await _score_cell(
|
||||
case_id, BLOCK_ID, effort, final_section, final_total_words, outcome, repeats,
|
||||
)
|
||||
|
||||
|
||||
# ── self-test (no DB, no model) ──────────────────────────────────────────────
|
||||
def _self_test() -> int:
|
||||
ok = True
|
||||
|
||||
def chk(name, cond):
|
||||
nonlocal ok
|
||||
ok = ok and cond
|
||||
print(f" {name:50} {'ok' if cond else 'FAIL'}")
|
||||
|
||||
# measurement reuse: block_distance_to_final on the discussion section.
|
||||
from legal_mcp.services.style_distance import block_distance_to_final, split_final_by_section
|
||||
|
||||
final_disc = "אנו סבורים כי דין הערר להידחות. כידוע, הלכה פסוקה היא. " * 30
|
||||
d_same = block_distance_to_final(BLOCK_ID, final_disc, final_disc, "rejection",
|
||||
section_target_total_words=len(final_disc.split()))
|
||||
chk("identical regen ⇒ change_percent==0", d_same["change_percent"] == 0.0)
|
||||
chk("identical regen ⇒ section==discussion", d_same["section"] == SECTION)
|
||||
|
||||
clean = "אין בידנו לקבל את הטענה. יחד עם זאת, מצאנו טעם. " * 20
|
||||
dirty = "## כותרת\n- נקודה\n- נקודה\n* עוד\n### תת\n" * 10
|
||||
d_clean = block_distance_to_final(BLOCK_ID, clean, final_disc, "rejection")
|
||||
d_dirty = block_distance_to_final(BLOCK_ID, dirty, final_disc, "rejection")
|
||||
chk("dirty regen ⇒ more anti-patterns", d_dirty["anti_pattern_total"] > d_clean["anti_pattern_total"])
|
||||
chk("dirty regen ⇒ larger distance", d_dirty["distance"] > d_clean["distance"])
|
||||
|
||||
# ── direction-supply logic ──
|
||||
chk("empty direction ⇒ not usable", not _stored_direction_is_usable({}))
|
||||
chk("brainstorm-only ⇒ not usable",
|
||||
not _stored_direction_is_usable({"brainstorm": {"directions": [1]}, "approved": False}))
|
||||
chk("approved+selected ⇒ usable",
|
||||
_stored_direction_is_usable({"approved": True, "selected_direction": {"name": "x"}}))
|
||||
chk("8126-style (direction_id) ⇒ usable",
|
||||
_stored_direction_is_usable({"direction_id": "d1", "structure": "..."}))
|
||||
|
||||
src = {"direction_id": "d1", "structure": "..."}
|
||||
norm = _normalize_stored_direction(src)
|
||||
chk("normalize stamps approved=True", norm["approved"] is True)
|
||||
chk("normalize does not mutate source", "approved" not in src)
|
||||
|
||||
rec = reconstruct_minimal_direction("rejection", final_disc)
|
||||
chk("reconstruct ⇒ approved", rec["approved"] is True)
|
||||
chk("reconstruct ⇒ marked reconstructed", rec["reconstructed"] is True)
|
||||
chk("reconstruct ⇒ canonical outcome", rec["outcome"] == "rejection")
|
||||
chk("reconstruct ⇒ has selected_direction", bool(rec.get("selected_direction")))
|
||||
chk("reconstruct ⇒ no cross-case precedents (INV-LRN5)",
|
||||
rec["selected_direction"]["precedents"] == [])
|
||||
|
||||
# _build_direction_context must accept BOTH supply shapes (write_block uses it).
|
||||
from legal_mcp.services.block_writer import _build_direction_context
|
||||
ctx_recon = _build_direction_context({"direction_doc": rec})
|
||||
chk("recon direction renders (not 'לא אושר')", "כיוון לא אושר" not in ctx_recon)
|
||||
ctx_stored = _build_direction_context({"direction_doc": norm})
|
||||
chk("stored(8126) direction renders", "כיוון לא אושר" not in ctx_stored)
|
||||
|
||||
# plan_direction_supply: stored wins; else reconstructed; else skip.
|
||||
dd_stored = {"direction_doc": {"approved": True, "selected_direction": {"name": "x"}}}
|
||||
_, m1 = plan_direction_supply(dd_stored, "rejection", final_disc, allow_reconstructed=True)
|
||||
chk("plan: stored-direction case ⇒ stored", m1 == "stored")
|
||||
_, m2 = plan_direction_supply({"direction_doc": {}}, "rejection", final_disc, True)
|
||||
chk("plan: no stored + discussion ⇒ reconstructed", m2 == "reconstructed")
|
||||
_, m3 = plan_direction_supply({"direction_doc": {}}, "rejection", final_disc, False)
|
||||
chk("plan: no stored + reconstructed disabled ⇒ skip", m3 == "skip")
|
||||
_, m4 = plan_direction_supply({"direction_doc": {}}, "rejection", "", True)
|
||||
chk("plan: no stored + no discussion ⇒ skip", m4 == "skip")
|
||||
|
||||
# overlay monkeypatch installs + reverts cleanly (no live DB needed).
|
||||
import legal_mcp.services.db as real_db
|
||||
saved_bc, saved_bi = real_db.get_decision_by_case, real_db.get_decision
|
||||
|
||||
async def fake_by_case(cid):
|
||||
return {"id": "d", "case_id": "C1", "direction_doc": {"approved": False}}
|
||||
|
||||
async def fake_by_id(did):
|
||||
return {"id": "d", "case_id": "C1", "direction_doc": {"approved": False}}
|
||||
|
||||
real_db.get_decision_by_case = fake_by_case
|
||||
real_db.get_decision = fake_by_id
|
||||
try:
|
||||
ov_dir = {"approved": True, "reconstructed": True}
|
||||
|
||||
async def _probe():
|
||||
with _DirectionOverlay("C1", ov_dir):
|
||||
d1 = await real_db.get_decision_by_case("C1")
|
||||
d2 = await real_db.get_decision_by_case("OTHER")
|
||||
d3 = await real_db.get_decision("d")
|
||||
return d1, d2, d3
|
||||
|
||||
# --self-test is dispatched at the __main__ entry, OUTSIDE asyncio.run,
|
||||
# so a plain asyncio.run here is safe (no enclosing event loop).
|
||||
d1, d2, d3 = asyncio.run(_probe())
|
||||
chk("overlay: target case gets overlaid direction", d1["direction_doc"]["approved"] is True)
|
||||
chk("overlay: non-target case untouched", d2["direction_doc"]["approved"] is False)
|
||||
chk("overlay: get_decision path overlaid", d3["direction_doc"]["reconstructed"] is True)
|
||||
chk("overlay: reverted after context", real_db.get_decision_by_case is fake_by_case)
|
||||
finally:
|
||||
real_db.get_decision_by_case = saved_bc
|
||||
real_db.get_decision = saved_bi
|
||||
|
||||
# reused #208 primitives still behave.
|
||||
r = recommend_effort([
|
||||
{"effort": "high", "distance": 0.22, "anti_pattern_total": 2, "change_percent": 25, "n": 1},
|
||||
{"effort": "xhigh", "distance": 0.22, "anti_pattern_total": 1, "change_percent": 22, "n": 1},
|
||||
])
|
||||
chk("recommend tie → fewer anti-patterns", r["effort"] == "xhigh")
|
||||
chk("current default for block-yod == xhigh", _current_default(BLOCK_ID) == "xhigh")
|
||||
secs = split_final_by_section(
|
||||
"רקע עובדתי\nהמקרקעין. " * 8 + "\n\nדיון והכרעה\nאנו סבורים. " * 8)
|
||||
chk("split returns golden sections only",
|
||||
set(secs).issubset({"background", "claims", "discussion", "summary"}))
|
||||
|
||||
print("ALL PASS" if ok else "*** FAILURES ***")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
# ── live A/B (host-only — needs DB + `claude` CLI) ───────────────────────────
|
||||
async def _gather_cases(case_filter, allow_reconstructed):
|
||||
"""Build per-case metadata + direction-supply plan over the finals."""
|
||||
from uuid import UUID
|
||||
from legal_mcp.services import db
|
||||
from legal_mcp.services.lessons import canonical_outcome
|
||||
from legal_mcp.services.style_distance import split_final_by_section
|
||||
|
||||
finals = await _finals_for_calibration(case_filter)
|
||||
cases = []
|
||||
for f in finals:
|
||||
case = await db.get_case_by_number(f["case_number"]) if f.get("case_number") else None
|
||||
if not case:
|
||||
continue
|
||||
decision = await db.get_decision_by_case(UUID(case["id"]))
|
||||
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
|
||||
sections = split_final_by_section(f.get("final_text", ""))
|
||||
final_discussion = sections.get(SECTION, "")
|
||||
final_total_words = len((f.get("final_text", "") or "").split())
|
||||
overlay, mode = plan_direction_supply(
|
||||
decision, outcome, final_discussion, allow_reconstructed)
|
||||
cases.append({
|
||||
"case_number": f["case_number"],
|
||||
"case_id": case["id"],
|
||||
"outcome": outcome,
|
||||
"final_discussion": final_discussion,
|
||||
"final_discussion_words": len(final_discussion.split()),
|
||||
"final_total_words": final_total_words,
|
||||
"direction": overlay,
|
||||
"supply_mode": mode,
|
||||
# scorable IFF it has a discussion section AND a supplied direction.
|
||||
"scorable": bool(final_discussion.strip() and overlay is not None),
|
||||
})
|
||||
return cases
|
||||
|
||||
|
||||
async def _run(args, ts):
|
||||
from uuid import UUID
|
||||
|
||||
cases = await _gather_cases(args.case, args.allow_reconstructed)
|
||||
scorable = [c for c in cases if c["scorable"]]
|
||||
|
||||
grid_summary = {
|
||||
"block": BLOCK_ID,
|
||||
"section": SECTION,
|
||||
"n_finals": len(cases),
|
||||
"n_scorable": len(scorable),
|
||||
"current_default": _current_default(BLOCK_ID),
|
||||
"efforts": args.efforts,
|
||||
"repeats": args.repeats,
|
||||
"total_generations": len(scorable) * len(args.efforts) * args.repeats,
|
||||
"allow_reconstructed": args.allow_reconstructed,
|
||||
"cases": [
|
||||
{"case_number": c["case_number"], "supply_mode": c["supply_mode"],
|
||||
"scorable": c["scorable"], "outcome": c["outcome"],
|
||||
"discussion_words": c["final_discussion_words"]}
|
||||
for c in cases
|
||||
],
|
||||
}
|
||||
|
||||
if args.dry_run:
|
||||
return {"dry_run": True, "grid": grid_summary, "by_effort": []}
|
||||
|
||||
per_effort_runs = {e: [] for e in args.efforts}
|
||||
per_case = []
|
||||
for c in scorable:
|
||||
case_cells = []
|
||||
for effort in args.efforts:
|
||||
# Per-cell guard (INV-G8): any single (case, effort) failure — a
|
||||
# transient rate-limit, a too-large prompt, an unexpected raise — is
|
||||
# logged and skipped, never allowed to abort the whole run.
|
||||
try:
|
||||
cell = await _score_block_yod_cell(
|
||||
UUID(c["case_id"]), effort, c["final_discussion"],
|
||||
c["final_total_words"], c["outcome"], args.repeats, c["direction"],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — harness must survive any cell failure
|
||||
logger.warning(
|
||||
"block-yod cell skipped: case=%s effort=%s mode=%s — %s",
|
||||
c["case_number"], effort, c["supply_mode"], exc,
|
||||
)
|
||||
continue
|
||||
per_effort_runs[effort].append(cell)
|
||||
case_cells.append({k: cell[k] for k in
|
||||
("effort", "distance", "anti_pattern_total",
|
||||
"change_percent", "golden_ratio_deviation_pp", "n")})
|
||||
per_case.append({"case_number": c["case_number"],
|
||||
"supply_mode": c["supply_mode"], "cells": case_cells})
|
||||
|
||||
# Incremental persistence (INV-G8): flush after EACH case so a crash later
|
||||
# never loses cases already generated. _write_report tolerates partial rows.
|
||||
try:
|
||||
_write_report({"dry_run": False, "grid": grid_summary,
|
||||
"by_effort": _effort_rows(per_effort_runs, args.efforts),
|
||||
"per_case": per_case,
|
||||
"recommended": (recommend_effort(_effort_rows(per_effort_runs, args.efforts)) or {}).get("effort")},
|
||||
ts)
|
||||
except Exception as exc: # noqa: BLE001 — a write hiccup must not abort the run
|
||||
logger.warning("incremental report write failed after case=%s — %s",
|
||||
c["case_number"], exc)
|
||||
|
||||
effort_rows = _effort_rows(per_effort_runs, args.efforts)
|
||||
rec = recommend_effort(effort_rows)
|
||||
return {"dry_run": False, "grid": grid_summary, "by_effort": effort_rows,
|
||||
"per_case": per_case, "recommended": rec["effort"] if rec else None}
|
||||
|
||||
|
||||
def _effort_rows(per_effort_runs, efforts):
|
||||
"""Mean each metric across cases for each effort → one comparable row/effort."""
|
||||
rows = []
|
||||
for effort in efforts:
|
||||
cells = per_effort_runs[effort]
|
||||
if not cells:
|
||||
continue
|
||||
ratios = [c["golden_ratio_deviation_pp"] for c in cells
|
||||
if c.get("golden_ratio_deviation_pp") is not None]
|
||||
rows.append({
|
||||
"effort": effort,
|
||||
"distance": round(mean(c["distance"] for c in cells), 4),
|
||||
"anti_pattern_total": round(mean(c["anti_pattern_total"] for c in cells), 2),
|
||||
"change_percent": round(mean(c["change_percent"] for c in cells), 2),
|
||||
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
|
||||
"n": len(cells),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
# ── report ───────────────────────────────────────────────────────────────────
|
||||
def _ts():
|
||||
return datetime.now(IL_TZ).strftime("%Y%m%dT%H%M%S-IL")
|
||||
|
||||
|
||||
def _write_report(result, ts):
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
jp = OUT_DIR / f"block-yod-calibration-{ts}.json"
|
||||
mp = OUT_DIR / f"block-yod-calibration-{ts}.md"
|
||||
jp.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
g = result["grid"]
|
||||
il_now = datetime.now(IL_TZ).strftime("%Y-%m-%d %H:%M")
|
||||
n_recon = sum(1 for c in g["cases"] if c["supply_mode"] == "reconstructed")
|
||||
n_stored = sum(1 for c in g["cases"] if c["supply_mode"] == "stored")
|
||||
n_skip = sum(1 for c in g["cases"] if c["supply_mode"] == "skip")
|
||||
lines = [
|
||||
f"# #212 — כיול effort לבלוק י' (דיון והכרעה) מול הסופיים — {ts}\n",
|
||||
f"> נוצר: {il_now} (שעון ישראל · Asia/Jerusalem)\n",
|
||||
f"> ⚠️ **גודל-מדגם: {g['n_scorable']} תיקים ניתנים-לכיול** "
|
||||
f"(מתוך {g['n_finals']} סופיים). זוהי **עדות-כיוון, לא רגרסיה**. "
|
||||
"ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n",
|
||||
f"> אספקת-כיוון: {n_stored} מאוחסן · {n_recon} משוחזר · {n_skip} מדולג. "
|
||||
"כיוון **משוחזר** = נגזר מתוצאת-ההחלטה + פתיח-הדיון של הסופי באותו תיק "
|
||||
"(לא סיעור-מוחות אמיתי) → התוצאה נאמנה-לכיוון פחות מתיק עם כיוון-מאושר שמור.\n",
|
||||
f"- בלוק: {g['block']} (section={g['section']}) · ברירת-מחדל נוכחית: "
|
||||
f"**{g['current_default'] or '—'}** (נקבע 'by feel', block_writer.py:72)",
|
||||
f"- efforts: {', '.join(g['efforts'])} · repeats/cell: {g['repeats']}",
|
||||
f"- סך ייצורי-מודל: {g['total_generations']}",
|
||||
"",
|
||||
"## אספקת-כיוון per-תיק\n",
|
||||
"| case | supply_mode | discussion_words | outcome | scorable |",
|
||||
"|---|---|---|---|---|",
|
||||
]
|
||||
for c in g["cases"]:
|
||||
lines.append(
|
||||
f"| {c['case_number']} | {c['supply_mode']} | {c['discussion_words']} | "
|
||||
f"{c['outcome']} | {'✓' if c['scorable'] else '—'} |")
|
||||
lines.append("")
|
||||
|
||||
if result.get("dry_run"):
|
||||
lines += ["## DRY-RUN — תכנון הגריד בלבד (ללא ייצור)\n",
|
||||
f"- תיקים ניתנים-לכיול: **{g['n_scorable']}** · "
|
||||
f"ייצורים מתוכננים: **{g['total_generations']}**",
|
||||
""]
|
||||
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return jp, mp
|
||||
|
||||
rec = result.get("recommended") or "—"
|
||||
mark = "" if rec == g["current_default"] else " ⬅︎ שינוי מומלץ"
|
||||
lines += [
|
||||
f"## המלצה: effort = **{rec}**{mark} (current = {g['current_default'] or '—'})\n",
|
||||
"## טבלת effort (distance נמוך = קרוב יותר לדיון של דפנה)\n",
|
||||
"| effort | distance | anti_total | change% | ratioΔpp | n |",
|
||||
"|---|---|---|---|---|---|",
|
||||
]
|
||||
for r in result.get("by_effort", []):
|
||||
star = " ⭐" if r["effort"] == result.get("recommended") else ""
|
||||
ratio = r["golden_ratio_deviation_pp"]
|
||||
lines.append(
|
||||
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
|
||||
f"{r['change_percent']} | {ratio if ratio is not None else '—'} | {r['n']} |")
|
||||
lines.append("")
|
||||
lines += ["## פירוט per-תיק\n", "| case | mode | effort | distance | anti | change% |",
|
||||
"|---|---|---|---|---|---|"]
|
||||
for pc in result.get("per_case", []):
|
||||
for cell in pc["cells"]:
|
||||
lines.append(
|
||||
f"| {pc['case_number']} | {pc['supply_mode']} | {cell['effort']} | "
|
||||
f"{cell['distance']:.4f} | {cell['anti_pattern_total']} | {cell['change_percent']} |")
|
||||
lines.append("")
|
||||
lines.append("> change% מערבב סגנון עם שלמות-תוכן (07-learning §0.7); "
|
||||
"anti_total הוא הסיגנל הנקי-יותר לסגנון. תיקים עם supply_mode=reconstructed "
|
||||
"תורמים עדות-כיוון בלבד.\n")
|
||||
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return jp, mp
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="#212 block-yod (discussion) effort calibration harness")
|
||||
ap.add_argument("--self-test", action="store_true",
|
||||
help="offline measurement + direction-supply proof (no DB/CLI)")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="plan the grid + per-case direction-supply mode, no model calls (needs DB)")
|
||||
ap.add_argument("--efforts", default=",".join(DEFAULT_EFFORTS),
|
||||
help=f"comma effort grid (default {','.join(DEFAULT_EFFORTS)})")
|
||||
ap.add_argument("--case", default=None, help="restrict to a single case_number")
|
||||
ap.add_argument("--repeats", type=int, default=1, help="generations per cell (avg out gen noise)")
|
||||
ap.add_argument("--allow-reconstructed", default="true",
|
||||
help="true|false — allow the reconstructed-direction fallback (default true). "
|
||||
"false = STORED-direction cases only (most faithful, may yield 0 cells).")
|
||||
args = ap.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
args.efforts = [e.strip() for e in args.efforts.split(",") if e.strip()]
|
||||
bad = [e for e in args.efforts if e not in VALID_EFFORTS]
|
||||
if bad:
|
||||
print(f"invalid effort(s): {bad}. valid: {sorted(VALID_EFFORTS)}", file=sys.stderr)
|
||||
return 2
|
||||
args.allow_reconstructed = str(args.allow_reconstructed).strip().lower() in ("1", "true", "yes", "y")
|
||||
|
||||
ts = _ts()
|
||||
result = await _run(args, ts)
|
||||
jp, mp = _write_report(result, ts)
|
||||
|
||||
g = result["grid"]
|
||||
print(f"BLOCK-YOD CALIBRATION: {g['n_scorable']}/{g['n_finals']} scorable finals — "
|
||||
"DIRECTIONAL EVIDENCE, not a regression")
|
||||
n_recon = sum(1 for c in g["cases"] if c["supply_mode"] == "reconstructed")
|
||||
n_stored = sum(1 for c in g["cases"] if c["supply_mode"] == "stored")
|
||||
print(f" direction supply: {n_stored} stored · {n_recon} reconstructed · "
|
||||
f"{sum(1 for c in g['cases'] if c['supply_mode']=='skip')} skipped")
|
||||
if g["n_scorable"] == 0:
|
||||
print(" no scorable finals (need a parsable discussion section + a suppliable direction).")
|
||||
elif result.get("dry_run"):
|
||||
print(f" dry-run: {g['total_generations']} generations planned "
|
||||
f"({len(g['efforts'])} efforts × {g['n_scorable']} cases × {g['repeats']} repeats)")
|
||||
else:
|
||||
print(f" current default={g['current_default']} → recommended={result.get('recommended')}")
|
||||
print(f" report: {mp}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# --self-test is OFFLINE and synchronous (it spins its own short-lived loop for
|
||||
# the overlay probe), so dispatch it BEFORE asyncio.run(main()) — otherwise the
|
||||
# probe's asyncio.run would nest inside main's running loop.
|
||||
if "--self-test" in sys.argv:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
sys.exit(_self_test())
|
||||
sys.exit(asyncio.run(main()))
|
||||
677
scripts/calibrate_effort.py
Normal file
677
scripts/calibrate_effort.py
Normal file
@@ -0,0 +1,677 @@
|
||||
#!/usr/bin/env python3
|
||||
"""#208 (WS5 / Q1, INV-G8 eval-harness) — model×effort calibration vs the finals.
|
||||
|
||||
Empirically picks the per-block reasoning `effort` whose regenerated block lands
|
||||
CLOSEST to עו"ד דפנה תמיר's signed final, over the EXISTING `draft_final_pairs`
|
||||
ledger. This is the calibration INV-G8 / 07-learning §0.7 ask for: stop choosing
|
||||
the per-block effort defaults (#204: ה=medium, ו=medium, ז=high, ח=medium, ט=high)
|
||||
"by feel".
|
||||
|
||||
WHAT IT MEASURES (per (case, block, effort) cell — the A/B grid):
|
||||
Regenerate `block` for `case` via block_writer.write_block(effort_override=…)
|
||||
(the PRODUCTION generation path → claude_session.query → `claude -p`, pinned
|
||||
Opus 4.8, local-only), then score the regenerated block against the matching
|
||||
SECTION of the final via services.style_distance.block_distance_to_final:
|
||||
• change_percent — word-diff regen↔final-section (compute_diff_stats)
|
||||
• anti_pattern_total — lessons.ANTI_PATTERNS hits in the regen (cleanest
|
||||
style signal — see §0.7 warning below)
|
||||
• golden_ratio_deviation_pp — structural-weight gap vs the final
|
||||
• distance — normalized composite (lower = closer to Dafna)
|
||||
No parallel metric path: it reuses style_distance + learning_loop (G2).
|
||||
|
||||
RECOMMENDATION (#213 — STYLE-CLEAN, anti-primary): for each block, the effort is
|
||||
ranked by `anti_pattern_total` FIRST (the clean Dafna-style-rule-violation count),
|
||||
then `golden_ratio_deviation_pp` (structural-style), and `distance` only as a faint
|
||||
final tiebreak. `change_percent` is REPORTED-NOT-RANKED: per 07-learning §0.7 it
|
||||
mixes style with content-completeness (a case's missing facts the model can't know)
|
||||
→ noise for STYLE. The pick carries a `confidence` flag ("clear"/"weak"): a pick
|
||||
whose anti-pattern lead over the runner-up is within noise is flagged "weak" so an
|
||||
over-claimed pick (the old block-zayin "low") is never shown as confident. Reported
|
||||
next to the #204 current default so a regression/improvement is visible.
|
||||
|
||||
⚠️ SAMPLE-SIZE CAVEAT (honored, not hidden): very few cases have an uploaded final
|
||||
(draft_final_pairs.final_text non-empty). The report prints n_finals PROMINENTLY and
|
||||
labels the output DIRECTIONAL EVIDENCE, not a regression. With n<3 per block the
|
||||
recommendation is advisory only; the chair/operator decides whether to adopt.
|
||||
|
||||
⚠️ change_percent mixes style with content completeness (07-learning §0.7): the chair
|
||||
sometimes doubles length for missing substance. anti_pattern_total is the cleaner
|
||||
style signal — the report surfaces both, and the composite down-weights neither
|
||||
silently.
|
||||
|
||||
GENERATION PATH (do not violate — reference_claude_generation_path / claude_session
|
||||
docstring): write_block → claude_session.query → `claude -p` uses the local claude.ai
|
||||
session. It runs ONLY on the host where the `claude` CLI exists (NOT the legal-ai
|
||||
container, NOT a symlinked worktree without CLI access). Hence the live A/B is
|
||||
host-only; --self-test proves the measurement logic offline with zero model calls.
|
||||
|
||||
Usage (mcp-server venv; live needs POSTGRES + the `claude` CLI on the host):
|
||||
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
|
||||
$PY scripts/calibrate_effort.py --self-test # offline proof, no DB/CLI
|
||||
POSTGRES_PASSWORD=… POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5433 \
|
||||
$PY scripts/calibrate_effort.py # live A/B over finals
|
||||
… --efforts low,medium,high,xhigh # override the effort grid
|
||||
… --blocks block-he,block-vav # restrict to some blocks
|
||||
… --case 8137-11-24 # a single case
|
||||
… --repeats 2 # avg N gens/cell (noise)
|
||||
… --dry-run # plan the grid, no model calls
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
from statistics import mean
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
|
||||
|
||||
if "POSTGRES_URL" not in os.environ:
|
||||
os.environ["POSTGRES_URL"] = (
|
||||
f"postgres://{os.environ.get('POSTGRES_USER','legal_ai')}:"
|
||||
f"{os.environ.get('POSTGRES_PASSWORD','')}@"
|
||||
f"{os.environ.get('POSTGRES_HOST','127.0.0.1')}:"
|
||||
f"{os.environ.get('POSTGRES_PORT','5433')}/"
|
||||
f"{os.environ.get('POSTGRES_DB','legal_ai')}"
|
||||
)
|
||||
|
||||
OUT_DIR = REPO_ROOT / "data" / "eval"
|
||||
|
||||
# Only the AI blocks that map to a golden-ratio section can be scored against the
|
||||
# final's matching section (split_final_by_section). Template blocks (א-ד, יב) are
|
||||
# deterministic template-fill — no effort knob. block-yod (discussion) is EXCLUDED
|
||||
# from the defaults: it requires an approved direction (brainstorm → approve_direction)
|
||||
# that calibration cases lack, so block_writer.write_block raises and it is NOT
|
||||
# calibratable standalone — and it's out of WS5's interim-draft scope. It stays in
|
||||
# VALID_BLOCKS so a user can still force it (--blocks block-yod) at their own risk;
|
||||
# the per-cell guard in _run() keeps such a failure non-fatal.
|
||||
CALIBRATABLE_BLOCKS = ["block-he", "block-vav", "block-zayin", "block-chet", "block-tet", "block-yod-alef"]
|
||||
VALID_BLOCKS = CALIBRATABLE_BLOCKS + ["block-yod"]
|
||||
DEFAULT_EFFORTS = ["low", "medium", "high", "xhigh"]
|
||||
VALID_EFFORTS = {"low", "medium", "high", "xhigh", "max"}
|
||||
|
||||
|
||||
# ── pure helpers (offline-testable) ──────────────────────────────────────────
|
||||
# Style-clean ranking epsilon (#213). Confidence in a pick = how far the best
|
||||
# effort's anti_pattern_total beats the 2nd-best. We call a pick "clear" only when
|
||||
# that margin clears a floor that is itself the LARGER of:
|
||||
# • an absolute floor (≥0.5 avg anti-patterns — a half-violation per case), and
|
||||
# • a fraction of the spread across efforts (best can't claim a lead that is mere
|
||||
# noise on a block whose efforts barely differ).
|
||||
# Below that → "weak" (within noise; nominal pick still reported, but flagged so a
|
||||
# within-noise pick is never shown as confident). Documented in the docstring.
|
||||
CONFIDENCE_ABS_FLOOR = 0.5 # avg anti-patterns (a half-violation per case)
|
||||
CONFIDENCE_SPREAD_FRACTION = 0.20 # of the across-effort anti spread
|
||||
|
||||
|
||||
def _anti_confidence(rows: list[dict]) -> tuple[str, float, float]:
|
||||
"""Confidence that the anti-primary pick is real, not within-noise.
|
||||
|
||||
Returns (confidence, margin, epsilon) where confidence ∈ {"clear","weak"}.
|
||||
margin = (2nd-best anti) − (best anti); epsilon = the bar margin must clear.
|
||||
A single effort (no rival) is trivially "clear". Pure → unit-tested.
|
||||
"""
|
||||
if len(rows) < 2:
|
||||
return "clear", float("inf"), 0.0
|
||||
antis = sorted(r["anti_pattern_total"] for r in rows)
|
||||
best, second = antis[0], antis[1]
|
||||
margin = second - best
|
||||
spread = antis[-1] - antis[0]
|
||||
epsilon = max(CONFIDENCE_ABS_FLOOR, CONFIDENCE_SPREAD_FRACTION * spread)
|
||||
return ("clear" if margin >= epsilon else "weak"), round(margin, 4), round(epsilon, 4)
|
||||
|
||||
|
||||
def recommend_effort(cells: list[dict]) -> dict | None:
|
||||
"""Pick the STYLE-CLEANEST effort for ONE block from its scored cells (#213).
|
||||
|
||||
cells: [{"effort","distance","anti_pattern_total","change_percent",
|
||||
"golden_ratio_deviation_pp","n"}].
|
||||
|
||||
Ranking key (style-clean, G8 / 07-learning §0.7) — change_percent is EXCLUDED
|
||||
from the ranking (it mixes style with content-completeness; reported only):
|
||||
1. PRIMARY anti_pattern_total (↑ fewer Dafna-style violations = better)
|
||||
2. SECONDARY golden_ratio_deviation_pp (↑ structural-style; None → +inf worst)
|
||||
3. TIEBREAK distance (↑ faint final tiebreak only)
|
||||
|
||||
Returns the winning row dict (so callers reading ["effort"] keep working) with
|
||||
two added keys: ``confidence`` ∈ {"clear","weak"} and ``confidence_margin`` —
|
||||
so a within-noise pick is never presented as confident. Pure → unit-tested.
|
||||
"""
|
||||
if not cells:
|
||||
return None
|
||||
ranked = sorted(
|
||||
cells,
|
||||
key=lambda c: (
|
||||
c["anti_pattern_total"],
|
||||
c["golden_ratio_deviation_pp"] if c.get("golden_ratio_deviation_pp") is not None else float("inf"),
|
||||
c["distance"],
|
||||
),
|
||||
)
|
||||
best = dict(ranked[0])
|
||||
conf, margin, _eps = _anti_confidence(cells)
|
||||
best["confidence"] = conf
|
||||
best["confidence_margin"] = margin
|
||||
return best
|
||||
|
||||
|
||||
def aggregate_cell(per_run: list[dict]) -> dict:
|
||||
"""Mean each metric across repeated generations of the same (case, block, effort)."""
|
||||
if not per_run:
|
||||
return {"distance": 1.0, "anti_pattern_total": 0.0, "change_percent": 100.0,
|
||||
"golden_ratio_deviation_pp": None, "n": 0}
|
||||
ratios = [r["golden_ratio_deviation_pp"] for r in per_run if r.get("golden_ratio_deviation_pp") is not None]
|
||||
return {
|
||||
"distance": round(mean(r["distance"] for r in per_run), 4),
|
||||
"anti_pattern_total": round(mean(r["anti_pattern_total"] for r in per_run), 2),
|
||||
"change_percent": round(mean(r["change_percent"] for r in per_run), 2),
|
||||
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
|
||||
"n": len(per_run),
|
||||
}
|
||||
|
||||
|
||||
def _current_default(block_id: str) -> str | None:
|
||||
from legal_mcp.services.block_writer import BLOCK_CONFIG, DEFAULT_EFFORT
|
||||
cfg = BLOCK_CONFIG.get(block_id, {})
|
||||
if cfg.get("model") != "ai":
|
||||
return None
|
||||
return cfg.get("effort", DEFAULT_EFFORT)
|
||||
|
||||
|
||||
# ── re-rank a saved report (offline — NO LLM, NO DB) ─────────────────────────
|
||||
def rerank_saved(saved: dict) -> dict:
|
||||
"""Re-apply the CURRENT (#213) recommend_effort to a saved calibration report.
|
||||
|
||||
Reads the per-(block,effort) rows that the run already persisted
|
||||
(distance/anti/change%/ratioΔ/n) and recomputes recommended + confidence — no
|
||||
regeneration, no model, no DB. Lets a past run be re-ranked under the new
|
||||
style-clean key. Returns {"block_id": {old, new, confidence, confidence_margin,
|
||||
current_default, efforts}}. Pure → unit-tested in --self-test.
|
||||
"""
|
||||
out: dict[str, dict] = {}
|
||||
for block_id, bd in (saved.get("by_block") or {}).items():
|
||||
rows = bd.get("efforts") or []
|
||||
rec = recommend_effort(rows)
|
||||
out[block_id] = {
|
||||
"section": bd.get("section"),
|
||||
"current_default": bd.get("current_default"),
|
||||
"old_recommended": bd.get("recommended"),
|
||||
"new_recommended": rec["effort"] if rec else None,
|
||||
"confidence": rec["confidence"] if rec else None,
|
||||
"confidence_margin": rec.get("confidence_margin") if rec else None,
|
||||
"efforts": rows,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _render_rerank_md(saved: dict, reranked: dict, src: Path) -> str:
|
||||
g = saved.get("grid", {})
|
||||
finals = g.get("finals", [])
|
||||
lines = [
|
||||
f"# #213 — re-rank של {src.name} (style-clean, anti-primary · OFFLINE)\n",
|
||||
f"> מקור: `{src.name}` · {g.get('n_finals','?')} סופיים "
|
||||
f"({', '.join(finals) or '—'}) · ללא LLM/DB — דירוג-מחדש בלבד.\n",
|
||||
"## old pick → new pick (+confidence)\n",
|
||||
"| block | section | current | old pick | **new pick** | confidence | n |",
|
||||
"|---|---|---|---|---|---|---|",
|
||||
]
|
||||
for b, rd in reranked.items():
|
||||
old = rd.get("old_recommended") or "—"
|
||||
new = rd.get("new_recommended") or "—"
|
||||
mark = "" if new == old else " ⬅︎"
|
||||
conf = rd.get("confidence")
|
||||
conf_cell = {"clear": "clear", "weak": "⚠️ weak (within-noise)"}.get(conf, "—")
|
||||
n_b = rd["efforts"][0]["n"] if rd.get("efforts") else 0
|
||||
lines.append(
|
||||
f"| {b} | {rd.get('section') or '—'} | {rd.get('current_default') or '—'} | "
|
||||
f"{old} | **{new}**{mark} | {conf_cell} | {n_b} |")
|
||||
lines.append("")
|
||||
for b, rd in reranked.items():
|
||||
if not rd.get("efforts"):
|
||||
continue
|
||||
lines += [f"### {b} ({rd.get('section') or '—'})\n",
|
||||
"| effort | distance | anti_total | change% (reported) | ratioΔpp | n |",
|
||||
"|---|---|---|---|---|---|"]
|
||||
for r in rd["efforts"]:
|
||||
star = " ⭐" if r["effort"] == rd.get("new_recommended") else ""
|
||||
ratio = r.get("golden_ratio_deviation_pp")
|
||||
lines.append(
|
||||
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
|
||||
f"{r['change_percent']} | {ratio if ratio is not None else '—'} | {r['n']} |")
|
||||
lines.append("")
|
||||
lines.append("> דירוג **style-clean** (#213): anti_total ראשי → ratioΔ → distance (tiebreak); "
|
||||
"**change% מדווח-לא-מדורג** (07-learning §0.7); "
|
||||
"confidence=⚠️weak ⇒ הובלת-anti < epsilon = max(0.5, 0.20·spread).\n")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _do_rerank(path: Path) -> int:
|
||||
"""--rerank entry: load a saved report, re-rank offline, print + write *-reranked.md."""
|
||||
if not path.exists():
|
||||
print(f"rerank: file not found: {path}", file=sys.stderr)
|
||||
return 2
|
||||
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||
reranked = rerank_saved(saved)
|
||||
md = _render_rerank_md(saved, reranked, path)
|
||||
print(md)
|
||||
out = path.with_name(path.stem + "-reranked.md")
|
||||
out.write_text(md, encoding="utf-8")
|
||||
print(f" reranked report written: {out}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
# ── self-test (no DB, no model) ──────────────────────────────────────────────
|
||||
def _self_test() -> int:
|
||||
ok = True
|
||||
|
||||
def chk(name, cond):
|
||||
nonlocal ok
|
||||
ok = ok and cond
|
||||
print(f" {name:42} {'ok' if cond else 'FAIL'}")
|
||||
|
||||
# block_distance_to_final: regen identical to final-section ⇒ change ~0,
|
||||
# distance dominated by anti-patterns (0 here) ⇒ ~0.
|
||||
from legal_mcp.services.style_distance import (
|
||||
block_distance_to_final, split_final_by_section,
|
||||
)
|
||||
final_section = "לפנינו ערר על החלטת הוועדה המקומית. " * 40
|
||||
d_same = block_distance_to_final("block-he", final_section, final_section, "rejection",
|
||||
section_target_total_words=len(final_section.split()))
|
||||
chk("identical regen ⇒ change_percent==0", d_same["change_percent"] == 0.0)
|
||||
chk("identical regen ⇒ anti==0", d_same["anti_pattern_total"] == 0)
|
||||
chk("identical regen ⇒ distance small", d_same["distance"] < 0.05)
|
||||
|
||||
# A regen full of anti-patterns (markdown headers / bullet lists) scores worse
|
||||
# than clean continuous narrative, holding the final fixed.
|
||||
clean = "אנו סבורים כי דין הערר להידחות. כידוע, הלכה פסוקה היא. " * 20
|
||||
dirty = "## כותרת\n- נקודה ראשונה\n- נקודה שנייה\n* עוד נקודה\n### תת\n" * 10
|
||||
d_clean = block_distance_to_final("block-yod", clean, final_section, "rejection",
|
||||
section_target_total_words=len(final_section.split()))
|
||||
d_dirty = block_distance_to_final("block-yod", dirty, final_section, "rejection",
|
||||
section_target_total_words=len(final_section.split()))
|
||||
chk("dirty regen has more anti-patterns", d_dirty["anti_pattern_total"] > d_clean["anti_pattern_total"])
|
||||
chk("dirty regen ⇒ larger distance", d_dirty["distance"] > d_clean["distance"])
|
||||
|
||||
# ratio deviation is None when no total provided (never fabricated)
|
||||
d_noratio = block_distance_to_final("block-he", clean, final_section, "rejection")
|
||||
chk("no total ⇒ ratio deviation None", d_noratio["golden_ratio_deviation_pp"] is None)
|
||||
|
||||
# split_final_by_section returns mapped golden sections only.
|
||||
sample_final = (
|
||||
"רקע עובדתי\nהמקרקעין נשוא הערר. " * 10 + "\n\n"
|
||||
"תמצית טענות הצדדים\nהעוררים טוענים כי. " * 10 + "\n\n"
|
||||
"דיון והכרעה\nאנו סבורים כי. " * 10
|
||||
)
|
||||
secs = split_final_by_section(sample_final)
|
||||
chk("split maps to golden sections", set(secs).issubset(
|
||||
{"background", "claims", "discussion", "summary"}))
|
||||
chk("split found ≥1 section", len(secs) >= 1)
|
||||
|
||||
# recommend_effort (#213, style-clean): anti_pattern_total is PRIMARY — the
|
||||
# effort with the FEWEST style violations wins, even if its distance is higher.
|
||||
rec = recommend_effort([
|
||||
{"effort": "low", "distance": 0.20, "anti_pattern_total": 5, "change_percent": 22,
|
||||
"golden_ratio_deviation_pp": 3.0, "n": 1},
|
||||
{"effort": "high", "distance": 0.40, "anti_pattern_total": 2, "change_percent": 40,
|
||||
"golden_ratio_deviation_pp": 5.0, "n": 1},
|
||||
{"effort": "xhigh", "distance": 0.50, "anti_pattern_total": 1, "change_percent": 50,
|
||||
"golden_ratio_deviation_pp": 9.0, "n": 1},
|
||||
])
|
||||
chk("recommend: anti-primary (not distance)", rec["effort"] == "xhigh")
|
||||
chk("recommend: change_percent NOT ranked", rec["distance"] == 0.50) # worst distance still won
|
||||
chk("recommend empty ⇒ None", recommend_effort([]) is None)
|
||||
|
||||
# SECONDARY = golden_ratio_deviation_pp when anti ties; distance only as last tiebreak.
|
||||
rec_tie = recommend_effort([
|
||||
{"effort": "low", "distance": 0.10, "anti_pattern_total": 3, "change_percent": 10,
|
||||
"golden_ratio_deviation_pp": 8.0, "n": 1},
|
||||
{"effort": "high", "distance": 0.90, "anti_pattern_total": 3, "change_percent": 90,
|
||||
"golden_ratio_deviation_pp": 2.0, "n": 1},
|
||||
])
|
||||
chk("recommend: ratio breaks anti-tie", rec_tie["effort"] == "high")
|
||||
rec_ratio_tie = recommend_effort([
|
||||
{"effort": "low", "distance": 0.30, "anti_pattern_total": 3, "change_percent": 10,
|
||||
"golden_ratio_deviation_pp": 5.0, "n": 1},
|
||||
{"effort": "high", "distance": 0.10, "anti_pattern_total": 3, "change_percent": 90,
|
||||
"golden_ratio_deviation_pp": 5.0, "n": 1},
|
||||
])
|
||||
chk("recommend: distance breaks final tie", rec_ratio_tie["effort"] == "high")
|
||||
# None ratio is treated as worst (never beats a real deviation on the secondary key).
|
||||
rec_none = recommend_effort([
|
||||
{"effort": "low", "distance": 0.10, "anti_pattern_total": 2, "change_percent": 10,
|
||||
"golden_ratio_deviation_pp": None, "n": 1},
|
||||
{"effort": "high", "distance": 0.90, "anti_pattern_total": 2, "change_percent": 90,
|
||||
"golden_ratio_deviation_pp": 4.0, "n": 1},
|
||||
])
|
||||
chk("recommend: None ratio is worst", rec_none["effort"] == "high")
|
||||
|
||||
# confidence flag (#213): a clear anti lead ⇒ "clear"; within-noise ⇒ "weak".
|
||||
rec_clear = recommend_effort([
|
||||
{"effort": "low", "distance": 0.5, "anti_pattern_total": 1.0, "change_percent": 50,
|
||||
"golden_ratio_deviation_pp": 5.0, "n": 5},
|
||||
{"effort": "high", "distance": 0.5, "anti_pattern_total": 4.0, "change_percent": 50,
|
||||
"golden_ratio_deviation_pp": 5.0, "n": 5},
|
||||
])
|
||||
chk("confidence: big anti lead ⇒ clear", rec_clear["confidence"] == "clear")
|
||||
rec_weak = recommend_effort([
|
||||
{"effort": "low", "distance": 0.4, "anti_pattern_total": 3.6, "change_percent": 93,
|
||||
"golden_ratio_deviation_pp": 6.7, "n": 5},
|
||||
{"effort": "xhigh", "distance": 0.5, "anti_pattern_total": 3.6, "change_percent": 102,
|
||||
"golden_ratio_deviation_pp": 6.6, "n": 5},
|
||||
{"effort": "high", "distance": 0.53, "anti_pattern_total": 3.8, "change_percent": 104,
|
||||
"golden_ratio_deviation_pp": 5.7, "n": 5},
|
||||
])
|
||||
chk("confidence: anti tie ⇒ weak", rec_weak["confidence"] == "weak")
|
||||
rec_single = recommend_effort([
|
||||
{"effort": "low", "distance": 0.4, "anti_pattern_total": 2.0, "change_percent": 50,
|
||||
"golden_ratio_deviation_pp": 5.0, "n": 5}])
|
||||
chk("confidence: single effort ⇒ clear", rec_single["confidence"] == "clear")
|
||||
|
||||
# aggregate_cell: means + ratio drops Nones, keeps n.
|
||||
agg = aggregate_cell([
|
||||
{"distance": 0.2, "anti_pattern_total": 2, "change_percent": 20, "golden_ratio_deviation_pp": 3.0},
|
||||
{"distance": 0.4, "anti_pattern_total": 4, "change_percent": 30, "golden_ratio_deviation_pp": None},
|
||||
])
|
||||
chk("aggregate distance mean", agg["distance"] == 0.3)
|
||||
chk("aggregate n counted", agg["n"] == 2)
|
||||
chk("aggregate ratio skips None", agg["golden_ratio_deviation_pp"] == 3.0)
|
||||
|
||||
# rerank_saved: re-applies the new key to a saved report's stored rows, no I/O.
|
||||
# Mimics the real 8-final block-vav rows: low has fewest anti by a clear margin.
|
||||
saved = {"grid": {"n_finals": 7, "finals": ["x"]}, "by_block": {
|
||||
"block-vav": {"section": "background", "current_default": "medium",
|
||||
"recommended": "low", "efforts": [
|
||||
{"effort": "low", "distance": 0.4454, "anti_pattern_total": 1.57, "change_percent": 87.39, "golden_ratio_deviation_pp": 6.33, "n": 7},
|
||||
{"effort": "medium", "distance": 0.4749, "anti_pattern_total": 2.29, "change_percent": 87.76, "golden_ratio_deviation_pp": 6.37, "n": 7},
|
||||
{"effort": "high", "distance": 0.5381, "anti_pattern_total": 4.14, "change_percent": 90.69, "golden_ratio_deviation_pp": 5.99, "n": 7},
|
||||
{"effort": "xhigh", "distance": 0.5413, "anti_pattern_total": 4.57, "change_percent": 89.53, "golden_ratio_deviation_pp": 6.41, "n": 7},
|
||||
]},
|
||||
}}
|
||||
rr = rerank_saved(saved)
|
||||
chk("rerank: block-vav stays low", rr["block-vav"]["new_recommended"] == "low")
|
||||
chk("rerank: block-vav clear (anti lead)", rr["block-vav"]["confidence"] == "clear")
|
||||
chk("rerank: keeps old pick for diff", rr["block-vav"]["old_recommended"] == "low")
|
||||
chk("rerank: renders markdown", "new pick" in _render_rerank_md(saved, rr, Path("x.json")))
|
||||
|
||||
print("ALL PASS" if ok else "*** FAILURES ***")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
# ── live A/B (host-only — needs DB + `claude` CLI) ───────────────────────────
|
||||
async def _finals_for_calibration(case_filter: str | None) -> list[dict]:
|
||||
"""draft_final_pairs whose final_text is populated (the held-out comparison set)."""
|
||||
from legal_mcp.services import db
|
||||
pairs = await db.list_draft_final_pairs(limit=500)
|
||||
out: list[dict] = []
|
||||
for p in pairs:
|
||||
if case_filter and p.get("case_number") != case_filter:
|
||||
continue
|
||||
full = await db.get_draft_final_pair(p["id"])
|
||||
if full and (full.get("final_text") or "").strip():
|
||||
out.append(full)
|
||||
return out
|
||||
|
||||
|
||||
async def _score_cell(case_id, block_id: str, effort: str, final_section: str,
|
||||
final_total_words: int, outcome: str, repeats: int) -> dict:
|
||||
"""Generate `block_id` at `effort` `repeats` times; score each vs the final section."""
|
||||
from legal_mcp.services import block_writer
|
||||
from legal_mcp.services.style_distance import block_distance_to_final
|
||||
runs: list[dict] = []
|
||||
for _ in range(repeats):
|
||||
res = await block_writer.write_block(case_id, block_id, effort_override=effort)
|
||||
scored = block_distance_to_final(
|
||||
block_id, res.get("content", ""), final_section, outcome,
|
||||
section_target_total_words=final_total_words,
|
||||
)
|
||||
runs.append(scored)
|
||||
agg = aggregate_cell(runs)
|
||||
agg["effort"] = effort
|
||||
agg["runs"] = runs
|
||||
return agg
|
||||
|
||||
|
||||
async def _run(args, ts: str) -> dict:
|
||||
from uuid import UUID
|
||||
from legal_mcp.services import db
|
||||
from legal_mcp.services.lessons import canonical_outcome
|
||||
from legal_mcp.services.style_distance import split_final_by_section, _BLOCK_TO_SECTION
|
||||
|
||||
efforts = args.efforts
|
||||
blocks = args.blocks
|
||||
finals = await _finals_for_calibration(args.case)
|
||||
|
||||
cases_meta = []
|
||||
for f in finals:
|
||||
case = await db.get_case_by_number(f["case_number"]) if f.get("case_number") else None
|
||||
if not case:
|
||||
continue
|
||||
decision = await db.get_decision_by_case(UUID(case["id"]))
|
||||
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
|
||||
sections = split_final_by_section(f.get("final_text", ""))
|
||||
final_total_words = len((f.get("final_text", "") or "").split())
|
||||
cases_meta.append({
|
||||
"case_number": f["case_number"], "case_id": case["id"],
|
||||
"outcome": outcome, "sections": sections, "final_total_words": final_total_words,
|
||||
})
|
||||
|
||||
# grid plan: (block → cases that have its section)
|
||||
plan: dict[str, list[dict]] = {}
|
||||
for block_id in blocks:
|
||||
section = _BLOCK_TO_SECTION.get(block_id)
|
||||
plan[block_id] = [c for c in cases_meta if section and c["sections"].get(section)]
|
||||
|
||||
total_cells = sum(len(plan[b]) for b in blocks) * len(efforts) * args.repeats
|
||||
grid_summary = {
|
||||
"n_finals": len(cases_meta),
|
||||
"finals": [c["case_number"] for c in cases_meta],
|
||||
"blocks": blocks, "efforts": efforts, "repeats": args.repeats,
|
||||
"total_generations": total_cells,
|
||||
"per_block_n": {b: len(plan[b]) for b in blocks},
|
||||
}
|
||||
|
||||
if args.dry_run:
|
||||
return {"dry_run": True, "grid": grid_summary, "by_block": {}}
|
||||
|
||||
by_block: dict[str, dict] = {}
|
||||
for block_id in blocks:
|
||||
section = _BLOCK_TO_SECTION.get(block_id)
|
||||
per_effort_runs: dict[str, list[dict]] = {e: [] for e in efforts}
|
||||
per_case: list[dict] = []
|
||||
for c in plan[block_id]:
|
||||
final_section = c["sections"][section]
|
||||
case_cells = []
|
||||
for effort in efforts:
|
||||
# Per-cell guard: ANY failure of a single (case, block, effort) cell
|
||||
# — e.g. block-yod raising "ללא כיוון מאושר", or a transient rate-limit
|
||||
# mid-run — is logged and skipped, NOT allowed to kill the whole run
|
||||
# (INV-G8 eval-harness robustness). Partial results still persist below.
|
||||
try:
|
||||
cell = await _score_cell(
|
||||
UUID(c["case_id"]), block_id, effort, final_section,
|
||||
c["final_total_words"], c["outcome"], args.repeats,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — harness must survive any cell failure
|
||||
logger.warning(
|
||||
"calibration cell skipped: case=%s block=%s effort=%s — %s",
|
||||
c["case_number"], block_id, effort, exc,
|
||||
)
|
||||
continue
|
||||
per_effort_runs[effort].append(cell)
|
||||
case_cells.append({k: cell[k] for k in
|
||||
("effort", "distance", "anti_pattern_total",
|
||||
"change_percent", "golden_ratio_deviation_pp", "n")})
|
||||
per_case.append({"case_number": c["case_number"], "cells": case_cells})
|
||||
|
||||
# mean across cases for each effort → one comparable row per effort
|
||||
effort_rows = []
|
||||
for effort in efforts:
|
||||
rows = per_effort_runs[effort]
|
||||
if not rows:
|
||||
continue
|
||||
ratios = [r["golden_ratio_deviation_pp"] for r in rows if r.get("golden_ratio_deviation_pp") is not None]
|
||||
effort_rows.append({
|
||||
"effort": effort,
|
||||
"distance": round(mean(r["distance"] for r in rows), 4),
|
||||
"anti_pattern_total": round(mean(r["anti_pattern_total"] for r in rows), 2),
|
||||
"change_percent": round(mean(r["change_percent"] for r in rows), 2),
|
||||
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
|
||||
"n": len(rows),
|
||||
})
|
||||
rec = recommend_effort(effort_rows)
|
||||
by_block[block_id] = {
|
||||
"section": section,
|
||||
"current_default": _current_default(block_id),
|
||||
"recommended": rec["effort"] if rec else None,
|
||||
"confidence": rec["confidence"] if rec else None,
|
||||
"confidence_margin": rec.get("confidence_margin") if rec else None,
|
||||
"efforts": effort_rows,
|
||||
"per_case": per_case,
|
||||
}
|
||||
|
||||
# Incremental persistence (INV-G8): flush the report after every completed
|
||||
# block so a crash later in the grid never loses blocks already generated.
|
||||
# Blocks not yet done are simply absent from by_block; _write_report tolerates
|
||||
# partial results. main() does the final flush once the loop finishes.
|
||||
try:
|
||||
_write_report({"dry_run": False, "grid": grid_summary, "by_block": by_block}, ts)
|
||||
except Exception as exc: # noqa: BLE001 — a write hiccup must not abort the run
|
||||
logger.warning("incremental report write failed after block=%s — %s", block_id, exc)
|
||||
|
||||
return {"dry_run": False, "grid": grid_summary, "by_block": by_block}
|
||||
|
||||
|
||||
IL_TZ = ZoneInfo("Asia/Jerusalem")
|
||||
|
||||
|
||||
def _ts() -> str:
|
||||
"""Filename-safe report stamp in Israel time (INV-UI9: human-facing display).
|
||||
|
||||
This script runs on the host, which is UTC, so a process-clock stamp would
|
||||
mislead the chair. We pin Asia/Jerusalem and suffix ``IL`` (instead of a bare
|
||||
``Z``) so the displayed/filename time is unambiguously Israel-local.
|
||||
"""
|
||||
return datetime.now(IL_TZ).strftime("%Y%m%dT%H%M%S-IL")
|
||||
|
||||
|
||||
def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
jp = OUT_DIR / f"effort-calibration-{ts}.json"
|
||||
mp = OUT_DIR / f"effort-calibration-{ts}.md"
|
||||
jp.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
g = result["grid"]
|
||||
n = g["n_finals"]
|
||||
il_now = datetime.now(IL_TZ).strftime("%Y-%m-%d %H:%M")
|
||||
lines = [
|
||||
f"# #208 — כיול model×effort מול הסופיים — {ts}\n",
|
||||
f"> נוצר: {il_now} (שעון ישראל · Asia/Jerusalem)\n",
|
||||
f"> ⚠️ **גודל-מדגם: {n} סופיים** ({', '.join(g['finals']) or '—'}). "
|
||||
"זוהי **עדות-כיוון, לא רגרסיה** — מעט תיקים בעלי סופי-עלוי. "
|
||||
"ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n",
|
||||
f"- בלוקים: {', '.join(g['blocks'])}",
|
||||
f"- efforts: {', '.join(g['efforts'])} · repeats/cell: {g['repeats']}",
|
||||
f"- סך ייצורי-מודל: {g['total_generations']}",
|
||||
"",
|
||||
]
|
||||
if result.get("dry_run"):
|
||||
lines += ["## DRY-RUN — תכנון הגריד בלבד (ללא ייצור)\n",
|
||||
"| block | #cases | current default |", "|---|---|---|"]
|
||||
for b in g["blocks"]:
|
||||
lines.append(f"| {b} | {g['per_block_n'].get(b,0)} | {_current_default(b) or '—'} |")
|
||||
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return jp, mp
|
||||
|
||||
lines += ["## המלצה per-בלוק (style-clean: anti_pattern_total ראשי — #213)\n",
|
||||
"| block | section | current | **recommended** | confidence | n |",
|
||||
"|---|---|---|---|---|---|"]
|
||||
for b, bd in result["by_block"].items():
|
||||
rec = bd.get("recommended") or "—"
|
||||
mark = "" if rec == bd.get("current_default") else " ⬅︎"
|
||||
conf = bd.get("confidence")
|
||||
conf_cell = {"clear": "clear", "weak": "⚠️ weak (within-noise)"}.get(conf, "—")
|
||||
n_b = bd["efforts"][0]["n"] if bd.get("efforts") else 0
|
||||
lines.append(
|
||||
f"| {b} | {bd.get('section','')} | {bd.get('current_default') or '—'} | "
|
||||
f"**{rec}**{mark} | {conf_cell} | {n_b} |")
|
||||
lines.append("")
|
||||
for b, bd in result["by_block"].items():
|
||||
lines += [f"### {b} ({bd.get('section','')})\n",
|
||||
"| effort | distance | anti_total | change% | ratioΔpp | n |",
|
||||
"|---|---|---|---|---|---|"]
|
||||
for r in bd.get("efforts", []):
|
||||
star = " ⭐" if r["effort"] == bd.get("recommended") else ""
|
||||
ratio = r["golden_ratio_deviation_pp"]
|
||||
lines.append(
|
||||
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
|
||||
f"{r['change_percent']} | {ratio if ratio is not None else '—'} | {r['n']} |")
|
||||
lines.append("")
|
||||
lines.append("> דירוג-ההמלצה **style-clean** (#213): anti_total ראשי → ratioΔ → distance (tiebreak). "
|
||||
"**change% מדווח-לא-מדורג** — מערבב סגנון עם שלמות-תוכן (07-learning §0.7), "
|
||||
"anti_total הוא הסיגנל הנקי-לסגנון. confidence=⚠️weak ⇒ הבחירה בתוך-הרעש "
|
||||
"(ההובלה ב-anti קטנה מ-epsilon = max(0.5, 0.20·spread)).\n")
|
||||
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return jp, mp
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="#208 model/effort calibration harness")
|
||||
ap.add_argument("--self-test", action="store_true", help="offline measurement-logic proof (no DB/CLI)")
|
||||
ap.add_argument("--rerank", metavar="REPORT.json", default=None,
|
||||
help="re-rank a saved calibration JSON under the current style-clean key (no LLM/DB)")
|
||||
ap.add_argument("--dry-run", action="store_true", help="plan the A/B grid over existing finals, no model calls")
|
||||
ap.add_argument("--efforts", default=",".join(DEFAULT_EFFORTS),
|
||||
help=f"comma effort grid (default {','.join(DEFAULT_EFFORTS)})")
|
||||
ap.add_argument("--blocks", default=",".join(CALIBRATABLE_BLOCKS),
|
||||
help="comma block ids to calibrate")
|
||||
ap.add_argument("--case", default=None, help="restrict to a single case_number")
|
||||
ap.add_argument("--repeats", type=int, default=1, help="generations per cell (avg out gen noise)")
|
||||
args = ap.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
if args.self_test:
|
||||
return _self_test()
|
||||
|
||||
if args.rerank:
|
||||
return _do_rerank(Path(args.rerank))
|
||||
|
||||
args.efforts = [e.strip() for e in args.efforts.split(",") if e.strip()]
|
||||
bad = [e for e in args.efforts if e not in VALID_EFFORTS]
|
||||
if bad:
|
||||
print(f"invalid effort(s): {bad}. valid: {sorted(VALID_EFFORTS)}", file=sys.stderr)
|
||||
return 2
|
||||
args.blocks = [b.strip() for b in args.blocks.split(",") if b.strip()]
|
||||
bad_b = [b for b in args.blocks if b not in VALID_BLOCKS]
|
||||
if bad_b:
|
||||
print(f"non-calibratable block(s): {bad_b}. valid: {VALID_BLOCKS}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
ts = _ts()
|
||||
result = await _run(args, ts)
|
||||
jp, mp = _write_report(result, ts)
|
||||
|
||||
g = result["grid"]
|
||||
print(f"CALIBRATION: {g['n_finals']} finals — DIRECTIONAL EVIDENCE, not a regression")
|
||||
if g["n_finals"] == 0:
|
||||
print(" no finals with final_text in draft_final_pairs — upload a signed final first.")
|
||||
elif result.get("dry_run"):
|
||||
print(f" dry-run: {g['total_generations']} generations planned across {len(g['blocks'])} blocks")
|
||||
else:
|
||||
for b, bd in result["by_block"].items():
|
||||
print(f" {b:16} current={bd.get('current_default') or '—':6} "
|
||||
f"→ recommended={bd.get('recommended') or '—':6} "
|
||||
f"[{bd.get('confidence') or '—'}]")
|
||||
print(f" report: {mp}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
99
scripts/classify_citation_treatments.py
Normal file
99
scripts/classify_citation_treatments.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Classify the TREATMENT of each internal citation edge (X11 Phase 2, #154).
|
||||
|
||||
Each row in ``precedent_internal_citations`` records that a committee decision
|
||||
cited a precedent, with the surrounding ``match_context``. Until now the edge's
|
||||
``treatment`` column was empty, so the verified/authority layer counted every
|
||||
citation as if it were positive — a precedent *distinguished* N times got the
|
||||
same authority boost as one *followed* N times (an INV-COR2 violation).
|
||||
|
||||
This script fills ``treatment`` per edge by classifying the context with
|
||||
``corroboration.classify_treatment`` (Opus 4.8 @ xhigh via the local
|
||||
claude_session bridge — LOCAL ONLY, the claude CLI is not in the container):
|
||||
|
||||
followed | explained → positive (counts toward authority)
|
||||
distinguished | criticized |
|
||||
questioned | overruled → negative (never counts; overruled = demote)
|
||||
|
||||
Scope: only LINKED edges (``cited_case_law_id IS NOT NULL``) with an empty
|
||||
``treatment`` and a non-empty ``match_context``. Idempotent — a second run skips
|
||||
rows already classified. After applying, run ``scripts/build_verified_layer.py``
|
||||
(or ``db.refresh_verified_layer``) so the treatment-aware count takes effect.
|
||||
|
||||
Run (dry-run, default — classifies and PRINTS, writes nothing):
|
||||
HOME=/home/chaim mcp-server/.venv/bin/python scripts/classify_citation_treatments.py
|
||||
Apply:
|
||||
HOME=/home/chaim mcp-server/.venv/bin/python scripts/classify_citation_treatments.py --apply
|
||||
Options:
|
||||
--limit N classify at most N edges (smoke test)
|
||||
--case-law-id UUID restrict to citations TO this one precedent
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from uuid import UUID
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||
|
||||
from legal_mcp.services import corroboration, db # noqa: E402
|
||||
|
||||
|
||||
async def _pending(limit: int | None, case_law_id: str | None) -> list[dict]:
|
||||
pool = await db.get_pool()
|
||||
where = ["cited_case_law_id IS NOT NULL", "coalesce(treatment,'') = ''",
|
||||
"coalesce(match_context,'') <> ''"]
|
||||
params: list = []
|
||||
if case_law_id:
|
||||
params.append(UUID(case_law_id))
|
||||
where.append(f"cited_case_law_id = ${len(params)}")
|
||||
sql = (f"SELECT id, cited_case_number, match_context "
|
||||
f"FROM precedent_internal_citations WHERE {' AND '.join(where)} "
|
||||
f"ORDER BY created_at")
|
||||
if limit:
|
||||
sql += f" LIMIT {int(limit)}"
|
||||
rows = await pool.fetch(sql, *params)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--apply", action="store_true", help="write changes (default: dry-run)")
|
||||
ap.add_argument("--limit", type=int, default=None)
|
||||
ap.add_argument("--case-law-id", type=str, default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
rows = await _pending(args.limit, args.case_law_id)
|
||||
print(f"קצוות לא-מסווגים לעיבוד: {len(rows)}\n")
|
||||
|
||||
pool = await db.get_pool()
|
||||
counts: dict[str, int] = {}
|
||||
errors = 0
|
||||
for r in rows:
|
||||
try:
|
||||
t = await corroboration.classify_treatment(
|
||||
r["cited_case_number"] or "", r["match_context"] or "")
|
||||
except Exception as e: # noqa: BLE001 — one bad row must not abort the batch
|
||||
errors += 1
|
||||
print(f" ✗ [error] {r['cited_case_number']}: {type(e).__name__}: {e}")
|
||||
continue
|
||||
counts[t] = counts.get(t, 0) + 1
|
||||
sign = "+" if corroboration.is_positive(t) else ("-" if corroboration.is_negative(t) else "·")
|
||||
print(f" {sign} {t:<14} {r['cited_case_number']}")
|
||||
if args.apply:
|
||||
await pool.execute(
|
||||
"UPDATE precedent_internal_citations SET treatment = $2 WHERE id = $1",
|
||||
r["id"], t,
|
||||
)
|
||||
|
||||
print(f"\nסיכום טיפול: {counts} שגיאות={errors}"
|
||||
+ ("" if args.apply else " (dry-run — לא נכתב)"))
|
||||
if args.apply:
|
||||
print("הרץ עכשיו: scripts/build_verified_layer.py (או db.refresh_verified_layer) "
|
||||
"כדי שהספירה מודעת-הטיפול תיכנס לתוקף.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -91,15 +91,22 @@ async def main(args: argparse.Namespace) -> int:
|
||||
|
||||
# The 3 steps as durable nodes (X16 / INV-DUR1) — shared runtime with
|
||||
# final_halacha (scripts/_pipeline_runtime.py). A crash/OOM in the long style
|
||||
# panel [3] resumes from [3] instead of re-paying the Opus distillation [1].
|
||||
# panel [3] resumes from [3] instead of re-paying the Opus distillation [2].
|
||||
#
|
||||
# Order (#159): enroll FIRST. enroll creates the style_corpus row (fast, no LLM)
|
||||
# that decision_lessons attach to. Running it before the up-to-30-min distillation
|
||||
# means the corpus exists within seconds — so the curator agent's §A (which fires
|
||||
# in parallel on a final_learning_* wake) can record source='curator' findings
|
||||
# without racing the 30-min step. enroll/ingest are mutually independent; panel
|
||||
# needs both, so it stays last.
|
||||
|
||||
async def step_ingest(results: dict) -> dict:
|
||||
# [1] distillation (Opus) — skip if already analyzed (idempotent; --force to redo).
|
||||
# [2] distillation (Opus) — skip if already analyzed (idempotent; --force to redo).
|
||||
status = await _latest_pair_status(case["id"])
|
||||
if status == "analyzed" and not args.force:
|
||||
print("[1/3] ingest_final_version — דולג (הזוג כבר analyzed; --force לחידוש)")
|
||||
print("[2/3] ingest_final_version — דולג (הזוג כבר analyzed; --force לחידוש)")
|
||||
return {"ingest": "skipped:analyzed"}
|
||||
print("[1/3] ingest_final_version — דיסטילציית טיוטה↔סופי…", flush=True)
|
||||
print("[2/3] ingest_final_version — דיסטילציית טיוטה↔סופי…", flush=True)
|
||||
raw = await ingest_final_version(case_number, file_path=final_path)
|
||||
try:
|
||||
env = json.loads(raw)
|
||||
@@ -120,8 +127,9 @@ async def main(args: argparse.Namespace) -> int:
|
||||
return {"ingest": "done"}
|
||||
|
||||
async def step_enroll(results: dict) -> dict:
|
||||
# [2] enroll into style_corpus (idempotent) — lessons need a corpus_id.
|
||||
print("[2/3] רישום לקורפוס-הסגנון (idempotent)…", flush=True)
|
||||
# [1] enroll into style_corpus (idempotent) — lessons need a corpus_id, and
|
||||
# it must exist early (before the long distillation) so curator §A can attach.
|
||||
print("[1/3] רישום לקורפוס-הסגנון (idempotent)…", flush=True)
|
||||
if await _has_style_corpus(case_number):
|
||||
print(" ✓ כבר רשום בקורפוס-הסגנון")
|
||||
return {"enroll": "exists"}
|
||||
@@ -150,8 +158,8 @@ async def main(args: argparse.Namespace) -> int:
|
||||
return {"panel_rc": rc or 0}
|
||||
|
||||
steps = [
|
||||
_pipeline_runtime.Step("ingest_final_version", step_ingest),
|
||||
_pipeline_runtime.Step("enroll_style_corpus", step_enroll),
|
||||
_pipeline_runtime.Step("ingest_final_version", step_ingest),
|
||||
_pipeline_runtime.Step("style_panel", step_panel),
|
||||
]
|
||||
checkpoint_db = config.DATA_DIR / "checkpoints" / "learning.sqlite"
|
||||
|
||||
50
scripts/host_sync.sh
Executable file
50
scripts/host_sync.sh
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# host_sync.sh — keep the HOST working tree (~/legal-ai) in sync with origin/main.
|
||||
#
|
||||
# Why this exists: the writer (block_writer), the learning panels
|
||||
# (style_lesson_panel), and the legal_mcp MCP tools run from the host tree — the
|
||||
# Paperclip agents `claude -p` with cwd=/home/chaim/legal-ai, and the long-running
|
||||
# pm2 services import legal_mcp from mcp-server/src. A merge to main + Coolify
|
||||
# deploy only refreshes the CONTAINER (FastAPI endpoints); host-side code stays on
|
||||
# whatever was last `git pull`ed. On 2026-06-28 the host was 16 commits behind, so a
|
||||
# full day of learning-loop fixes sat dormant until a manual pull. This closes that
|
||||
# gap (TaskMaster #160).
|
||||
#
|
||||
# Safety: fast-forward ONLY (never force, never lose local work). The host tree
|
||||
# carries a perpetually-dirty .taskmaster/tasks/tasks.json (uncommitted by policy) —
|
||||
# a clean ff leaves it untouched; if upstream ever touches the same file the ff
|
||||
# fails and we log + bail rather than clobber it. Agent runs spawn fresh, so they
|
||||
# pick up new code on the next wakeup with no restart; only the long-running pm2
|
||||
# services that IMPORT app code are restarted, and only when their own files change.
|
||||
set -uo pipefail
|
||||
|
||||
REPO=/home/chaim/legal-ai
|
||||
LOG="$REPO/data/logs/host_sync.log"
|
||||
mkdir -p "$REPO/data/logs"
|
||||
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" >> "$LOG"; }
|
||||
|
||||
cd "$REPO" || { log "ERROR cd $REPO failed"; exit 1; }
|
||||
|
||||
# Only sync when on main (don't disturb a manual branch checkout).
|
||||
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
||||
[ "$branch" = "main" ] || { log "skip: on branch '$branch', not main"; exit 0; }
|
||||
|
||||
git fetch origin main --quiet 2>>"$LOG" || { log "ERROR git fetch failed"; exit 1; }
|
||||
local_head=$(git rev-parse HEAD)
|
||||
remote_head=$(git rev-parse origin/main)
|
||||
[ "$local_head" = "$remote_head" ] && exit 0 # up to date — quiet no-op
|
||||
|
||||
changed=$(git diff --name-only "$local_head" "$remote_head")
|
||||
if ! git pull --ff-only origin main --quiet 2>>"$LOG"; then
|
||||
log "ERROR ff-only pull failed ($local_head → $remote_head) — manual intervention needed (likely local tasks.json vs upstream); NOT forcing"
|
||||
exit 1
|
||||
fi
|
||||
log "synced $local_head → $(git rev-parse --short HEAD) ($(echo "$changed" | wc -l) files)"
|
||||
|
||||
# Restart only the long-running services whose OWN code changed (precise, minimal
|
||||
# churn). The agent path (writer/panels via fresh `claude -p`) needs no restart.
|
||||
restart() { pm2 restart "$1" --update-env >/dev/null 2>>"$LOG" && log "restarted $1"; }
|
||||
echo "$changed" | grep -q '^mcp-server/src/legal_mcp/chat_service/' && restart legal-chat-service
|
||||
echo "$changed" | grep -qE '^mcp-server/src/legal_mcp/court_fetch_service/' && restart legal-court-fetch-service
|
||||
echo "$changed" | grep -q '^scripts/reap_orphan_procs.py' && restart legal-reaper
|
||||
exit 0
|
||||
102
scripts/ingest_incoming_batch.py
Normal file
102
scripts/ingest_incoming_batch.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Batch ingest of appeals-committee decisions staged in data/precedents/incoming/.
|
||||
|
||||
Sequential (NOT concurrent — avoids the 2026-05-31 load-spike incident) ingest of
|
||||
each .doc/.docx via the canonical internal pipeline, followed by metadata extraction
|
||||
per case (the internal path does NOT auto-queue metadata — INV-ING3). Halacha is
|
||||
auto-queued by ingest; drain it separately via MCP precedent_process_pending.
|
||||
|
||||
case_number canonical follows the filename/Nevo convention validated against the
|
||||
corpus + missing_precedents list:
|
||||
- מרכז/חיפה/ת"א committees number with month: NNNN/MM/YY → NNNN-MM-YY
|
||||
- ירושלים/צפון committees number without month: NNNN/YY → NNNN-YY
|
||||
decision_date / summary / subject_tags / appeal_subtype are left empty on purpose —
|
||||
the metadata extractor fills them from the full text (more reliable than parsing here).
|
||||
|
||||
Run: mcp-server/.venv/bin/python scripts/ingest_incoming_batch.py
|
||||
Config (POSTGRES_URL, VOYAGE_API_KEY, ANTHROPIC_API_KEY) auto-loads from ~/.env.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||
|
||||
from legal_mcp.services import internal_decisions as svc
|
||||
from legal_mcp.services import precedent_metadata_extractor as meta
|
||||
|
||||
INC = "/home/chaim/legal-ai/data/precedents/incoming"
|
||||
|
||||
# file, case_number(canonical-with-slashes), chair, district, court, practice_area
|
||||
DECISIONS = [
|
||||
("105-07.doc", "105/07", "דרור לביא-אפרת", "צפון", "rishuy_uvniya"),
|
||||
("ARAR-17-105-44.doc", "105/17", "רונית אלפר", "מרכז", "betterment_levy"),
|
||||
("ARAR-18-1029.doc", "1029/18", "אליעד וינשל", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-20-1018-44.doc", "1018/20", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-20-1023-55.doc", "1023/20", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-21-1080-55.doc", "1080/21", "נילי בן משה ידגר", "צפון", "rishuy_uvniya"),
|
||||
("ARAR-21-11-1051.doc", "1051/11/21", "רונית אלפר", "מרכז", "rishuy_uvniya"),
|
||||
("ARAR-22-01-1015.doc", "1015/01/22", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-22-06-1029.doc", "1029/06/22", "מאיה אשכנזי", "מרכז", "rishuy_uvniya"),
|
||||
("ARAR-22-08-1044.doc", "1044/08/22", "מאיה אשכנזי", "מרכז", "rishuy_uvniya"),
|
||||
("ARAR-22-10-1050.doc", "1050/10/22", "מאיה אשכנזי", "מרכז", "rishuy_uvniya"),
|
||||
("ARAR-22-1079.doc", "1079/22", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-23-04-1010.doc", "1010/04/23", "מאיה אשכנזי", "מרכז", "rishuy_uvniya"),
|
||||
("ARAR-23-08-1074-9.doc", "1074/08/23", "מיכל הלברשטם דגני", "חיפה", "rishuy_uvniya"),
|
||||
("ARAR-23-1034.docx", "1006/23", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-23-1073.doc", "1073/23", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-23-1085.doc", "1085/23", "נילי בן משה ידגר", "צפון", "rishuy_uvniya"),
|
||||
("ARAR-24-01-1009-5.docx","1009/01/24", "מיכל דגני הלברשטם", "תל אביב", "rishuy_uvniya"),
|
||||
("ARAR-24-05-1044.doc", "1044/05/24", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-25-01-10072.docx", "1007/01/25", "יפעת בן אריה שטיינברג", "תל אביב", "rishuy_uvniya"),
|
||||
]
|
||||
|
||||
|
||||
async def main():
|
||||
results = []
|
||||
for fname, case_number, chair, district, parea in DECISIONS:
|
||||
fp = Path(INC) / fname
|
||||
rec = {"file": fname, "case_number": case_number}
|
||||
if not fp.exists():
|
||||
rec["error"] = "file-missing"
|
||||
print(f"✗ {fname}: file missing", flush=True)
|
||||
results.append(rec)
|
||||
continue
|
||||
try:
|
||||
out = await svc.ingest_internal_decision(
|
||||
file_path=fp,
|
||||
case_number=case_number,
|
||||
chair_name=chair,
|
||||
district=district,
|
||||
court=f"ועדת הערר לתכנון ובנייה — מחוז {district}",
|
||||
practice_area=parea,
|
||||
proceeding_type="ערר",
|
||||
is_binding=False,
|
||||
)
|
||||
cid = out.get("case_law_id")
|
||||
rec["case_law_id"] = cid
|
||||
rec["chunks"] = out.get("chunks")
|
||||
print(f"✓ ingest {case_number}: id={cid} chunks={out.get('chunks')}", flush=True)
|
||||
# metadata (internal path does not auto-queue it)
|
||||
m = await meta.extract_and_apply(cid)
|
||||
rec["meta_status"] = m.get("status")
|
||||
sug = m.get("suggested") or {}
|
||||
rec["suggested_case_number"] = sug.get("case_number_clean")
|
||||
rec["citation_formatted"] = sug.get("citation_formatted")
|
||||
rec["meta_date"] = sug.get("decision_date_iso")
|
||||
print(f" meta {case_number}: {m.get('status')} | clean={sug.get('case_number_clean')} | {sug.get('citation_formatted')}", flush=True)
|
||||
except Exception as e:
|
||||
rec["error"] = f"{type(e).__name__}: {e}"
|
||||
print(f"✗ {fname} ({case_number}): {e}", flush=True)
|
||||
traceback.print_exc()
|
||||
results.append(rec)
|
||||
|
||||
print("\n===SUMMARY===", flush=True)
|
||||
for r in results:
|
||||
print(r, flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
392
scripts/ocr_benchmark_mistral.py
Normal file
392
scripts/ocr_benchmark_mistral.py
Normal file
@@ -0,0 +1,392 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OCR Benchmark: Current system (PyMuPDF + Google Vision) vs Mistral OCR 4.0
|
||||
|
||||
Usage:
|
||||
python scripts/ocr_benchmark_mistral.py [--docs N] [--output PATH]
|
||||
|
||||
Downloads PDFs from MinIO, calls Mistral OCR API, compares against
|
||||
already-extracted text stored in the DB, and writes a Markdown report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
import httpx
|
||||
|
||||
# ── Config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
MISTRAL_API_KEY = "UsZjLCX30ev6pox0KgvXyuFP3ktsPYpN"
|
||||
MISTRAL_OCR_MODEL = "mistral-ocr-latest"
|
||||
MISTRAL_OCR_URL = "https://api.mistral.ai/v1/ocr"
|
||||
MINIO_ALIAS = "legalminio"
|
||||
MINIO_BUCKET = "legal-documents"
|
||||
|
||||
# Documents to benchmark — selected for diversity:
|
||||
# - main appeal (40 pp, 4.4 MB, large digital doc)
|
||||
# - permit (9 pp, 1.7 MB, likely partially scanned)
|
||||
# - protocol (5 pp, 178 KB, administrative typed)
|
||||
# - response (12 pp, ~390 KB, digital legal brief)
|
||||
DOCS_TO_BENCHMARK = [
|
||||
{
|
||||
"title": "כתב ערר",
|
||||
"minio_key": "cases/1027-04-26/documents/originals/כתב ערר - מושב נחם נ׳ ועדה מקומית בית שמש.pdf",
|
||||
"doc_type": "appeal",
|
||||
"pages": 40,
|
||||
},
|
||||
{
|
||||
"title": "נספח 1 — היתר הבנייה",
|
||||
"minio_key": "cases/1027-04-26/documents/originals/נספח 1 - היתר הבנייה (פורסם 17.03.26).pdf",
|
||||
"doc_type": "permit",
|
||||
"pages": 9,
|
||||
},
|
||||
{
|
||||
"title": "נספח 14 — פרוטוקול ועדה מחוזית",
|
||||
"minio_key": "cases/1027-04-26/documents/originals/נספח 14 - פרוטוקול דיון ועדה מחוזית - 06.06.23.pdf",
|
||||
"doc_type": "protocol",
|
||||
"pages": 5,
|
||||
},
|
||||
{
|
||||
"title": "תגובת המשיבה 3",
|
||||
"minio_key": "cases/1027-04-26/documents/originals/תגובת המשיבה 3 לערר ולבקשה להתליית היתר בנייה.pdf",
|
||||
"doc_type": "response",
|
||||
"pages": 12,
|
||||
},
|
||||
]
|
||||
|
||||
# ── DB access (read-only: fetch already-extracted text) ──────────────────────
|
||||
|
||||
def _fetch_extracted_texts(case_number: str) -> dict[str, str]:
|
||||
"""Pull extracted_text from DB for all docs in the case."""
|
||||
import psycopg2 # type: ignore
|
||||
conn = psycopg2.connect(
|
||||
host="localhost", port=5433, dbname="legal_ai",
|
||||
user="legal_ai", password="od0ASJZFYibOlWK59krLvvETmgqwlXe8",
|
||||
)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT d.title, d.extracted_text, d.file_path, d.page_count
|
||||
FROM documents d
|
||||
JOIN cases c ON c.id = d.case_id
|
||||
WHERE c.case_number = %s AND d.extracted_text IS NOT NULL
|
||||
""",
|
||||
(case_number,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return {row[0]: {"text": row[1], "file_path": row[2], "pages": row[3]} for row in rows}
|
||||
|
||||
|
||||
# ── MinIO download ────────────────────────────────────────────────────────────
|
||||
|
||||
def download_from_minio(minio_key: str, dest: Path) -> None:
|
||||
"""Download a file from MinIO using mcli."""
|
||||
src = f"{MINIO_ALIAS}/{MINIO_BUCKET}/{minio_key}"
|
||||
result = subprocess.run(
|
||||
["mcli", "cp", src, str(dest)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"mcli cp failed: {result.stderr}")
|
||||
|
||||
|
||||
# ── Text quality metrics ─────────────────────────────────────────────────────
|
||||
|
||||
_HEBREW_RE = re.compile(r'[-]')
|
||||
_WORD_RE = re.compile(r'\S+')
|
||||
|
||||
|
||||
def compute_metrics(text: str) -> dict:
|
||||
"""Compute quality metrics for extracted text."""
|
||||
if not text:
|
||||
return {"chars": 0, "words": 0, "avg_word_len": 0,
|
||||
"single_char_pct": 0, "words_per_line": 0,
|
||||
"hebrew_pct": 0, "quality_ok": False}
|
||||
|
||||
words = _WORD_RE.findall(text)
|
||||
n_words = len(words)
|
||||
if n_words == 0:
|
||||
return {"chars": len(text), "words": 0, "avg_word_len": 0,
|
||||
"single_char_pct": 0, "words_per_line": 0,
|
||||
"hebrew_pct": 0, "quality_ok": False}
|
||||
|
||||
avg_len = sum(len(w) for w in words) / n_words
|
||||
single_char_pct = sum(1 for w in words if len(w) == 1) / n_words
|
||||
|
||||
lines = [ln for ln in text.split("\n") if ln.strip()]
|
||||
words_per_line = n_words / len(lines) if lines else 0
|
||||
|
||||
letters = re.findall(r'[a-zA-Z-]', text)
|
||||
hebrew_pct = (
|
||||
sum(1 for c in letters if _HEBREW_RE.match(c)) / len(letters)
|
||||
if letters else 0
|
||||
)
|
||||
|
||||
quality_ok = (
|
||||
n_words >= 10
|
||||
and avg_len >= 2.5
|
||||
and single_char_pct <= 0.4
|
||||
and words_per_line >= 3.0
|
||||
and hebrew_pct >= 0.5
|
||||
)
|
||||
|
||||
return {
|
||||
"chars": len(text),
|
||||
"words": n_words,
|
||||
"avg_word_len": round(avg_len, 2),
|
||||
"single_char_pct": round(single_char_pct * 100, 1),
|
||||
"words_per_line": round(words_per_line, 1),
|
||||
"hebrew_pct": round(hebrew_pct * 100, 1),
|
||||
"quality_ok": quality_ok,
|
||||
}
|
||||
|
||||
|
||||
def count_known_abbrev_errors(text: str) -> int:
|
||||
"""Count common Hebrew abbreviation OCR errors (pre-fix indicators)."""
|
||||
patterns = ['עוהייד', 'עוייד', 'הנייל', 'ביהמייש', 'עייי', 'בייכ', 'תמייא']
|
||||
return sum(text.count(p) for p in patterns)
|
||||
|
||||
|
||||
def count_correct_abbrevs(text: str) -> int:
|
||||
"""Count correctly rendered Hebrew abbreviations."""
|
||||
correct = ['עו"ד', 'הנ"ל', 'ביהמ"ש', 'ע"י', 'ב"כ', 'תמ"א', 'ס"ק']
|
||||
return sum(text.count(p) for p in correct)
|
||||
|
||||
|
||||
# ── Mistral OCR ───────────────────────────────────────────────────────────────
|
||||
|
||||
def call_mistral_ocr(pdf_path: Path) -> tuple[str, float]:
|
||||
"""Call Mistral OCR API on a PDF. Returns (extracted_text, elapsed_seconds)."""
|
||||
with open(pdf_path, "rb") as f:
|
||||
pdf_b64 = base64.b64encode(f.read()).decode()
|
||||
|
||||
payload = {
|
||||
"model": MISTRAL_OCR_MODEL,
|
||||
"document": {
|
||||
"type": "document_url",
|
||||
"document_url": f"data:application/pdf;base64,{pdf_b64}",
|
||||
},
|
||||
"include_image_base64": False,
|
||||
}
|
||||
|
||||
t0 = time.time()
|
||||
with httpx.Client(timeout=300.0) as client:
|
||||
resp = client.post(
|
||||
MISTRAL_OCR_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {MISTRAL_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"Mistral OCR error {resp.status_code}: {resp.text[:500]}")
|
||||
|
||||
data = resp.json()
|
||||
# Response: {"pages": [{"index": 0, "markdown": "..."}, ...]}
|
||||
pages = data.get("pages", [])
|
||||
text = "\n\n".join(p.get("markdown", "") for p in pages)
|
||||
return text, elapsed
|
||||
|
||||
|
||||
# ── Report ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def render_metric_table(current: dict, mistral: dict) -> str:
|
||||
rows = [
|
||||
("תווים", f"{current['chars']:,}", f"{mistral['chars']:,}"),
|
||||
("מילים", f"{current['words']:,}", f"{mistral['words']:,}"),
|
||||
("אורך מילה ממוצע", str(current['avg_word_len']), str(mistral['avg_word_len'])),
|
||||
("% מילים חד-תוויות", f"{current['single_char_pct']}%", f"{mistral['single_char_pct']}%"),
|
||||
("מילים לשורה", str(current['words_per_line']), str(mistral['words_per_line'])),
|
||||
("% תווים עבריים", f"{current['hebrew_pct']}%", f"{mistral['hebrew_pct']}%"),
|
||||
("איכות כוללת", "✅" if current['quality_ok'] else "❌", "✅" if mistral['quality_ok'] else "❌"),
|
||||
]
|
||||
lines = ["| מדד | OCR נוכחי | Mistral OCR |", "|-----|-----------|-------------|"]
|
||||
for label, cur_val, mis_val in rows:
|
||||
lines.append(f"| {label} | {cur_val} | {mis_val} |")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_report(results: list[dict], output_path: Path) -> None:
|
||||
lines = [
|
||||
"# השוואת OCR: מערכת נוכחית מול Mistral OCR",
|
||||
f"\n**תיק:** 1027-04-26 — בל\"מ מושב נחם מפעל בטון בית שמש ",
|
||||
f"**תאריך:** {time.strftime('%Y-%m-%d %H:%M')} ",
|
||||
f"**מודל Mistral:** `{MISTRAL_OCR_MODEL}` ",
|
||||
f"**מערכת נוכחית:** PyMuPDF (born-digital) + Google Cloud Vision (scanned) ",
|
||||
"\n---\n",
|
||||
"## סיכום מנהלים\n",
|
||||
]
|
||||
|
||||
# Summary table
|
||||
sum_lines = ["| מסמך | עמודים | נוכחי תווים | Mistral תווים | Mistral זמן | עדיפות |",
|
||||
"|------|--------|-------------|---------------|-------------|--------|"]
|
||||
for r in results:
|
||||
if "error" in r:
|
||||
sum_lines.append(f"| {r['title']} | {r['pages']} | — | שגיאה | — | — |")
|
||||
continue
|
||||
cur_chars = r["current_metrics"]["chars"]
|
||||
mis_chars = r["mistral_metrics"]["chars"]
|
||||
winner = "🔵 נוכחי" if cur_chars > mis_chars * 1.05 else (
|
||||
"🟢 Mistral" if mis_chars > cur_chars * 1.05 else "⚖️ שקול")
|
||||
sum_lines.append(
|
||||
f"| {r['title']} | {r['pages']} | {cur_chars:,} | {mis_chars:,} | "
|
||||
f"{r['mistral_elapsed']:.1f}s | {winner} |"
|
||||
)
|
||||
lines.extend(sum_lines)
|
||||
lines.append("\n---\n")
|
||||
|
||||
# Per-document detail
|
||||
for r in results:
|
||||
lines.append(f"## {r['title']} ({r['pages']} עמודים)\n")
|
||||
if "error" in r:
|
||||
lines.append(f"**שגיאה ב-Mistral OCR:** `{r['error']}`\n")
|
||||
continue
|
||||
|
||||
lines.append(render_metric_table(r["current_metrics"], r["mistral_metrics"]))
|
||||
lines.append("")
|
||||
|
||||
cur_abbr_err = count_known_abbrev_errors(r["current_text"])
|
||||
mis_abbr_err = count_known_abbrev_errors(r["mistral_text"])
|
||||
cur_abbr_ok = count_correct_abbrevs(r["current_text"])
|
||||
mis_abbr_ok = count_correct_abbrevs(r["mistral_text"])
|
||||
|
||||
lines.append(f"\n**קיצורים עבריים:**")
|
||||
lines.append(f"- נוכחי: {cur_abbr_ok} נכונים, {cur_abbr_err} שגויים")
|
||||
lines.append(f"- Mistral: {mis_abbr_ok} נכונים, {mis_abbr_err} שגויים")
|
||||
lines.append(f"\n**זמן Mistral:** {r['mistral_elapsed']:.1f} שניות\n")
|
||||
|
||||
# Side-by-side first 600 chars
|
||||
lines.append("### דוגמת טקסט — 600 תווים ראשונים\n")
|
||||
lines.append("**מערכת נוכחית:**")
|
||||
lines.append("```")
|
||||
lines.append((r["current_text"] or "")[:600].replace("```", "'''"))
|
||||
lines.append("```\n")
|
||||
lines.append("**Mistral OCR:**")
|
||||
lines.append("```")
|
||||
lines.append((r["mistral_text"] or "")[:600].replace("```", "'''"))
|
||||
lines.append("```\n")
|
||||
lines.append("---\n")
|
||||
|
||||
output_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
print(f"\n✅ דוח נשמר: {output_path}")
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="OCR benchmark: current vs Mistral")
|
||||
parser.add_argument("--docs", type=int, default=4,
|
||||
help="כמה מסמכים לבדוק (ברירת מחדל: 4)")
|
||||
parser.add_argument("--output", type=str,
|
||||
default="/home/chaim/legal-ai/data/audit/ocr-benchmark-mistral.md",
|
||||
help="נתיב לדוח הפלט")
|
||||
args = parser.parse_args()
|
||||
|
||||
docs = DOCS_TO_BENCHMARK[: args.docs]
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"🔍 שולף טקסטים קיימים מה-DB עבור תיק 1027-04-26...")
|
||||
db_texts = _fetch_extracted_texts("1027-04-26")
|
||||
print(f" נמצאו {len(db_texts)} מסמכים עם טקסט מחולץ")
|
||||
|
||||
results = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
for doc in docs:
|
||||
print(f"\n📄 מעבד: {doc['title']} ({doc['pages']} עמודים)")
|
||||
|
||||
# --- Current OCR text from DB ---
|
||||
current_text = ""
|
||||
for title, info in db_texts.items():
|
||||
if doc["title"].split("—")[0].strip() in title or doc["minio_key"].split("/")[-1] in (info.get("file_path") or ""):
|
||||
current_text = info["text"] or ""
|
||||
break
|
||||
if not current_text:
|
||||
# Try by file_path suffix match
|
||||
key_name = doc["minio_key"].split("/")[-1]
|
||||
for title, info in db_texts.items():
|
||||
if key_name in (info.get("file_path") or ""):
|
||||
current_text = info["text"] or ""
|
||||
break
|
||||
|
||||
if not current_text:
|
||||
print(f" ⚠️ לא נמצא טקסט ב-DB, מחפש לפי שם מסמך...")
|
||||
# fallback: match by doc_type + rough title
|
||||
for title, info in db_texts.items():
|
||||
if doc["doc_type"] in title.lower() or doc["title"][:8] in title:
|
||||
current_text = info["text"] or ""
|
||||
break
|
||||
|
||||
print(f" נוכחי: {len(current_text):,} תווים")
|
||||
|
||||
# --- Download PDF from MinIO ---
|
||||
pdf_name = doc["minio_key"].split("/")[-1]
|
||||
pdf_path = Path(tmp_dir) / pdf_name
|
||||
print(f" מוריד מ-MinIO...")
|
||||
try:
|
||||
download_from_minio(doc["minio_key"], pdf_path)
|
||||
print(f" הורד: {pdf_path.stat().st_size:,} bytes")
|
||||
except Exception as e:
|
||||
print(f" ❌ שגיאה בהורדה: {e}")
|
||||
results.append({"title": doc["title"], "pages": doc["pages"], "error": str(e)})
|
||||
continue
|
||||
|
||||
# --- Mistral OCR ---
|
||||
print(f" קורא Mistral OCR API...")
|
||||
try:
|
||||
mistral_text, elapsed = call_mistral_ocr(pdf_path)
|
||||
print(f" Mistral: {len(mistral_text):,} תווים ({elapsed:.1f}s)")
|
||||
except Exception as e:
|
||||
print(f" ❌ שגיאת Mistral API: {e}")
|
||||
results.append({
|
||||
"title": doc["title"], "pages": doc["pages"],
|
||||
"current_text": current_text,
|
||||
"current_metrics": compute_metrics(current_text),
|
||||
"mistral_text": "", "mistral_metrics": compute_metrics(""),
|
||||
"mistral_elapsed": 0, "error": str(e),
|
||||
})
|
||||
continue
|
||||
|
||||
results.append({
|
||||
"title": doc["title"],
|
||||
"pages": doc["pages"],
|
||||
"current_text": current_text,
|
||||
"current_metrics": compute_metrics(current_text),
|
||||
"mistral_text": mistral_text,
|
||||
"mistral_metrics": compute_metrics(mistral_text),
|
||||
"mistral_elapsed": elapsed,
|
||||
})
|
||||
|
||||
print("\n📊 בונה דוח...")
|
||||
build_report(results, output_path)
|
||||
|
||||
# Also save raw texts for manual inspection
|
||||
raw_dir = output_path.parent / "ocr-benchmark-raw"
|
||||
raw_dir.mkdir(exist_ok=True)
|
||||
for r in results:
|
||||
safe = r["title"].replace("/", "-").replace(" ", "_")[:40]
|
||||
if "current_text" in r:
|
||||
(raw_dir / f"{safe}_current.txt").write_text(r["current_text"], encoding="utf-8")
|
||||
if "mistral_text" in r:
|
||||
(raw_dir / f"{safe}_mistral.txt").write_text(r["mistral_text"], encoding="utf-8")
|
||||
print(f"💾 טקסטים גולמיים נשמרו: {raw_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
113
scripts/regenerate_voice_fingerprint.py
Executable file
113
scripts/regenerate_voice_fingerprint.py
Executable file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Propose a corpus-refreshed update to Dafna's voice-fingerprint (TaskMaster #161).
|
||||
|
||||
The voice-fingerprint (docs/daphna-voice-fingerprint.md) is the PRIMARY abstract
|
||||
style channel the writer consumes (07-learning §0.2, channel A). It was hand-authored
|
||||
and never regenerated from the growing corpus — so the "voice" the writer adapts has
|
||||
been frozen while the corpus grew. This script reads the corpus (style_corpus stats +
|
||||
block-level style_exemplars sample + measured section ratios) and asks Opus (local)
|
||||
to PROPOSE refinements/additions to the fingerprint — STYLE / METHOD / VOICE / LEXICON
|
||||
ONLY, never case substance (INV-LRN5).
|
||||
|
||||
CHAIR-GATED (INV-LRN1, HARD gate — the prose profile is voice-knowledge, not a
|
||||
low-risk style rule, so it does NOT auto-flow): the proposal is written to
|
||||
data/curator-proposals/ and NEVER overwrites the live fingerprint. The chair reviews
|
||||
it (in /training, with the other curator proposals) and hand-commits the parts she
|
||||
accepts to docs/daphna-voice-fingerprint.md — the same manual gate the fingerprint
|
||||
already uses. This is the prose half of the voice-profile refresh; the structural
|
||||
half (corpus-measured ratios → writer) shipped in PR #345.
|
||||
|
||||
Runs on the HOST (claude_session needs the local claude CLI). Usage:
|
||||
DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data \\
|
||||
/home/chaim/legal-ai/mcp-server/.venv/bin/python scripts/regenerate_voice_fingerprint.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import claude_session, db
|
||||
from legal_mcp.services.style_distance import measure_corpus_ratios
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
log = logging.getLogger("voice_fingerprint_refresh")
|
||||
|
||||
FINGERPRINT = config.DATA_DIR.parent / "docs" / "daphna-voice-fingerprint.md"
|
||||
PROPOSALS = config.DATA_DIR / "curator-proposals"
|
||||
_PER_SECTION = 5 # exemplars per section in the evidence pack
|
||||
_MAX_PARA_CHARS = 700 # cap each exemplar so the prompt stays bounded
|
||||
|
||||
_SYSTEM = """אתה מזקק את טביעת-אצבע-הקול של עו"ד דפנה תמיר — פרופיל-סגנון מופשט (איך היא כותבת: קול, שיטה, מבנה, לקסיקון, אנטי-דפוסים), לא תוכן.
|
||||
חוקים מחייבים:
|
||||
- **סגנון/שיטה בלבד (INV-LRN5):** אסור לכלול מהות משפטית ספציפית — הלכה, עובדה, תקדים, או ניסוח מתיק קונקרטי. רק הכללות-סגנון.
|
||||
- **לחדד ולהרחיב, לא לזרוק:** שמר את התובנות האצורות בפרופיל הנוכחי; הצע תוספות/חידודים מעוגנים בראיות-הקורפוס שלהלן.
|
||||
- **לעגן:** כל קביעה חדשה נשענת על דפוס שחוזר בראיות. אם אין עיגון — אל תמציא ואל תוסיף.
|
||||
- שמר על מבנה-המסמך והסעיפים הקיימים.
|
||||
הפלט: גרסת-fingerprint מוצעת מלאה (Markdown), מוכנה לסקירת-יו"ר — בלי הקדמות או הסברים מסביב."""
|
||||
|
||||
|
||||
async def _gather_evidence() -> tuple[str, str]:
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
n_corpus = await conn.fetchval("SELECT count(*) FROM style_corpus WHERE coalesce(full_text,'') <> ''")
|
||||
n_ex = await conn.fetchval("SELECT count(*) FROM style_exemplars")
|
||||
rows = await conn.fetch(
|
||||
"""SELECT section, decision_number, outcome, paragraph_text FROM (
|
||||
SELECT section, decision_number, outcome, paragraph_text,
|
||||
row_number() OVER (PARTITION BY section
|
||||
ORDER BY decision_number DESC, word_count DESC) AS rn
|
||||
FROM style_exemplars
|
||||
) t WHERE rn <= $1 ORDER BY section, rn""",
|
||||
_PER_SECTION,
|
||||
)
|
||||
ratios = await measure_corpus_ratios()
|
||||
parts = [f"קורפוס: {n_corpus} החלטות, {n_ex} דוגמאות-בלוק.", "", "יחסי-מבנה מדודים מהקורפוס (אחוז-מהסך, לפי תוצאה):"]
|
||||
for outcome, entry in (ratios or {}).items():
|
||||
secs = ", ".join(f"{s} {round(p)}%" for s, p in (entry.get("sections") or {}).items())
|
||||
parts.append(f" - {outcome} (n={entry.get('n', 0)}): {secs}")
|
||||
parts += ["", "דוגמאות-סגנון מייצגות (פסקאות אמיתיות של דפנה — מקור ללמידת-קול, לא להעתקת-מהות):"]
|
||||
for r in rows:
|
||||
para = (r["paragraph_text"] or "").strip()[:_MAX_PARA_CHARS]
|
||||
parts.append(f"\n[{r['section']} · {r['decision_number']} · {r['outcome'] or '—'}]\n{para}")
|
||||
return "\n".join(parts), f"{n_corpus} החלטות / {n_ex} דוגמאות"
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
if not FINGERPRINT.exists():
|
||||
log.error("fingerprint not found: %s", FINGERPRINT)
|
||||
return 1
|
||||
current = FINGERPRINT.read_text(encoding="utf-8")
|
||||
evidence, summary = await _gather_evidence()
|
||||
|
||||
user = (
|
||||
f"## הפרופיל הנוכחי (לחדד ולהרחיב, לא להחליף):\n{current}\n\n"
|
||||
f"## ראיות-קורפוס:\n{evidence}\n\n"
|
||||
"## המשימה:\nהצע גרסת-fingerprint מעודכנת המשלבת חידודים/תוספות-סגנון מעוגנים בראיות לעיל, "
|
||||
"תוך שימור כל התובנות האצורות. סגנון/שיטה בלבד — בלי מהות."
|
||||
)
|
||||
log.info("synthesizing voice-fingerprint proposal from %s (Opus, local)…", summary)
|
||||
proposed = await claude_session.query(
|
||||
user, timeout=claude_session.LONG_TIMEOUT, system=_SYSTEM,
|
||||
model="claude-opus-4-8", tools="",
|
||||
)
|
||||
if not proposed or not proposed.strip():
|
||||
log.error("empty proposal from model — aborting (nothing written)")
|
||||
return 1
|
||||
|
||||
PROPOSALS.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
out = PROPOSALS / f"voice-fingerprint-{ts}.md"
|
||||
header = (
|
||||
f"<!-- הצעת-חידוש לטביעת-אצבע-הקול · {ts} · מקור: {summary} -->\n"
|
||||
"<!-- שער-יו\"ר (INV-LRN1): הצעה בלבד. לסקור ולקבע ידנית ל-docs/daphna-voice-fingerprint.md. -->\n\n"
|
||||
)
|
||||
out.write_text(header + proposed, encoding="utf-8")
|
||||
log.info("✓ proposal written (chair-gated, NOT applied): %s", out)
|
||||
log.info(" review in /training → commit accepted parts to %s", FINGERPRINT)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -19,7 +19,13 @@ migrates atomically per case — is everything that embeds the number as *text*:
|
||||
4. MinIO keys cases/{old}/… 3 buckets; cp→new then rm old.
|
||||
legal-immutable (WORM/object-lock) → copy-only, old object stays locked.
|
||||
5. Gitea repo cases/{old} API PATCH name + local .git remote rewrite
|
||||
6. Paperclip project name replace(old→new) so case↔project lookup holds
|
||||
6. Paperclip case↔issue linkage replace(old→new) in THREE places, because the
|
||||
legal-ai → Paperclip lookup (get_case_issues) keys on the case number as text:
|
||||
• projects.name so case↔project lookup holds
|
||||
• plugin_state.legal-case-number the authoritative issue linkage value_json
|
||||
• issues.title the '[ערר {cn}] …' tag the title-path lookup uses
|
||||
Without (b)+(c) the issues keep the OLD number and get_case_issues returns [],
|
||||
so post-final actions (run-learning / run-halacha) silently skip ("no_issue").
|
||||
|
||||
Bare occurrences of the old number that are NOT inside a '/cases/{old}/' path
|
||||
(e.g. prose in notes, a citation) are *reported for review*, never auto-edited.
|
||||
@@ -273,10 +279,25 @@ async def inspect_paperclip(old: str) -> dict:
|
||||
try:
|
||||
c = await asyncpg.connect(PAPERCLIP_DSN, timeout=10)
|
||||
except Exception as e:
|
||||
return {"reachable": False, "error": str(e)[:120], "projects": []}
|
||||
return {"reachable": False, "error": str(e)[:120],
|
||||
"projects": [], "linkage_rows": 0, "issue_titles": 0}
|
||||
try:
|
||||
rows = await c.fetch("SELECT id, name FROM projects WHERE name LIKE $1", f"%{old}%")
|
||||
return {"reachable": True, "projects": [(str(r["id"]), r["name"]) for r in rows]}
|
||||
# The two surfaces get_case_issues actually keys on: the legal-case-number
|
||||
# plugin_state linkage and the '[ערר {cn}]' tag in issue titles.
|
||||
linkage = await c.fetchval(
|
||||
"SELECT count(*) FROM plugin_state "
|
||||
"WHERE state_key = 'legal-case-number' AND value_json = to_jsonb($1::text)",
|
||||
old,
|
||||
)
|
||||
titles = await c.fetchval(
|
||||
"SELECT count(*) FROM issues WHERE title LIKE $1", f"%{old}%")
|
||||
return {
|
||||
"reachable": True,
|
||||
"projects": [(str(r["id"]), r["name"]) for r in rows],
|
||||
"linkage_rows": linkage or 0,
|
||||
"issue_titles": titles or 0,
|
||||
}
|
||||
finally:
|
||||
await c.close()
|
||||
|
||||
@@ -369,16 +390,31 @@ async def apply_case(conn, rec: dict, *, skip_minio: bool, skip_gitea: bool,
|
||||
except urllib.error.HTTPError as e:
|
||||
log(f" ✗ Gitea rename failed: HTTP {e.code} {e.read()[:160]!r}")
|
||||
|
||||
# 6. Paperclip project name
|
||||
if not skip_paperclip and rec["paperclip"].get("reachable") and rec["paperclip"]["projects"]:
|
||||
# 6. Paperclip case↔issue linkage — project name + legal-case-number value +
|
||||
# issue titles. (b)+(c) are what get_case_issues keys on; without them the
|
||||
# issues keep the old number and run-learning/run-halacha skip with "no_issue".
|
||||
if not skip_paperclip and rec["paperclip"].get("reachable"):
|
||||
import asyncpg
|
||||
c = await asyncpg.connect(PAPERCLIP_DSN, timeout=10)
|
||||
try:
|
||||
if rec["paperclip"]["projects"]:
|
||||
res = await c.execute(
|
||||
"UPDATE projects SET name = replace(name, $1, $2), updated_at = now() "
|
||||
"WHERE name LIKE $3",
|
||||
old, new, f"%{old}%",
|
||||
)
|
||||
log(f" ✓ Paperclip projects: {res}")
|
||||
res = await c.execute(
|
||||
"UPDATE projects SET name = replace(name, $1, $2), updated_at = now() WHERE name LIKE $3",
|
||||
"UPDATE plugin_state SET value_json = to_jsonb($2::text) "
|
||||
"WHERE state_key = 'legal-case-number' AND value_json = to_jsonb($1::text)",
|
||||
old, new,
|
||||
)
|
||||
log(f" ✓ Paperclip plugin_state (legal-case-number): {res}")
|
||||
res = await c.execute(
|
||||
"UPDATE issues SET title = replace(title, $1, $2) WHERE title LIKE $3",
|
||||
old, new, f"%{old}%",
|
||||
)
|
||||
log(f" ✓ Paperclip projects: {res}")
|
||||
log(f" ✓ Paperclip issue titles: {res}")
|
||||
finally:
|
||||
await c.close()
|
||||
|
||||
@@ -419,6 +455,8 @@ def print_inspection(rec: dict) -> None:
|
||||
log(f" pclip: {name}")
|
||||
if not pc["projects"]:
|
||||
log(" pclip: (no matching project)")
|
||||
log(f" pclip: linkage rows={pc.get('linkage_rows', 0)} "
|
||||
f"issue titles={pc.get('issue_titles', 0)} (→ rewritten to {rec['new']})")
|
||||
else:
|
||||
log(f" pclip: unreachable ({pc.get('error','')})")
|
||||
log(" DB path columns to rewrite:")
|
||||
|
||||
@@ -110,6 +110,19 @@ def _category(change: dict) -> str:
|
||||
return "style"
|
||||
|
||||
|
||||
# Graduated gate (INV-LRN1, chair decision 2026-06-28): a STYLE lesson the panel
|
||||
# kept by 2/2 consensus flows straight to the writer (review_status='approved'),
|
||||
# reversibly — the chair can veto it in /training. SUBSTANCE (halacha/precedent/
|
||||
# fact) never reaches here (it's filtered to `substance` and skipped, and routes
|
||||
# through the strict 3-judge halacha gate), so every category this panel emits is
|
||||
# style and auto-approves. The constant keeps the gate explicit and future-proof.
|
||||
_STYLE_CATEGORIES = frozenset({"style", "structure", "lexicon", "tabular"})
|
||||
|
||||
|
||||
def _review_status_for(category: str) -> str:
|
||||
return "approved" if category in _STYLE_CATEGORIES else "proposed"
|
||||
|
||||
|
||||
# ── two judges, one signature: (system, user) -> dict|None ──
|
||||
|
||||
async def judge_deepseek(client: httpx.AsyncClient, system: str, user: str) -> dict | None:
|
||||
@@ -323,18 +336,24 @@ async def main(args: argparse.Namespace) -> int:
|
||||
_lesson_text(r["_change"])])
|
||||
|
||||
written = 0
|
||||
approved = 0
|
||||
for r in fresh:
|
||||
cat = _category(r["_change"])
|
||||
rs = _review_status_for(cat)
|
||||
await db.add_decision_lesson(
|
||||
UUID(corpus_id),
|
||||
lesson_text=_lesson_text(r["_change"]),
|
||||
category=_category(r["_change"]),
|
||||
category=cat,
|
||||
source="panel:deepseek+gemini",
|
||||
created_by="panel",
|
||||
review_status=rs,
|
||||
)
|
||||
written += 1
|
||||
approved += (rs == "approved")
|
||||
|
||||
chair = cc["split"] + cc["incomplete"]
|
||||
print(f"\nAPPLIED (reversible): wrote {written} decision_lesson proposals "
|
||||
print(f"\nAPPLIED (reversible): wrote {written} decision_lessons "
|
||||
f"({approved} auto-approved style → writer; graduated gate) "
|
||||
f"(source=panel:deepseek+gemini) · {skipped_dup} כפילויות דולגו · "
|
||||
f"{chair} escalated to chair · {len(substance)} substance skipped")
|
||||
print(f"backup → {backup}")
|
||||
|
||||
170
skills/docx/references/advanced-features.md
Normal file
170
skills/docx/references/advanced-features.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Advanced Features — פיצ'רים מתקדמים
|
||||
|
||||
## הערות שוליים (Footnotes)
|
||||
|
||||
**שימוש מרכזי:** הפניות לחקיקה ופסיקה.
|
||||
|
||||
```javascript
|
||||
const { FootnoteReferenceRun } = require('docx');
|
||||
|
||||
const doc = new Document({
|
||||
footnotes: {
|
||||
1: { children: [new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
children: [new TextRun({
|
||||
text: "חוק החוזים (חלק כללי), התשל״ג-1973, סעיף 12.",
|
||||
font: "David", size: 20, rightToLeft: true
|
||||
})]
|
||||
})] },
|
||||
},
|
||||
// ...sections
|
||||
});
|
||||
|
||||
// הפניה בגוף הטקסט:
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.BOTH,
|
||||
children: [
|
||||
new TextRun({ text: "חובת תום הלב", font: "David", size: 24, rightToLeft: true }),
|
||||
new FootnoteReferenceRun(1),
|
||||
new TextRun({ text: " חלה על כל שלבי המשא ומתן.", font: "David", size: 24, rightToLeft: true }),
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
### תיקון RTL בהערות שוליים (post-unpack)
|
||||
docx-js לא מגדיר RTL מלא. אחרי unpack, תקן ב-`word/footnotes.xml`:
|
||||
```xml
|
||||
<w:footnote w:id="1">
|
||||
<w:p>
|
||||
<w:pPr>
|
||||
<w:pStyle w:val="FootnoteText"/>
|
||||
<w:bidi/>
|
||||
<w:jc w:val="start"/>
|
||||
</w:pPr>
|
||||
<w:r>
|
||||
<w:rPr>
|
||||
<w:rStyle w:val="FootnoteReference"/>
|
||||
<w:rtl/>
|
||||
</w:rPr>
|
||||
<w:footnoteRef/>
|
||||
</w:r>
|
||||
</w:p>
|
||||
</w:footnote>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## תוכן עניינים (TOC)
|
||||
|
||||
**⚠️ TOC ידני בלבד** — `TableOfContents` של docx-js מאבד הגדרות RTL בעדכון Word.
|
||||
|
||||
```javascript
|
||||
const { Tab, TabStopType, LeaderType, LineRuleType } = require('docx');
|
||||
|
||||
const tocEntry = (text, pageNum, opts = {}) => new Paragraph({
|
||||
bidirectional: true,
|
||||
spacing: { after: 60, line: 276, lineRule: LineRuleType.AUTO },
|
||||
...(opts.indent ? { indent: { right: opts.indent } } : {}),
|
||||
tabStops: [{ type: TabStopType.RIGHT, position: 9026, leader: LeaderType.DOT }],
|
||||
children: [
|
||||
new TextRun({ text, font: "David", size: 24, rightToLeft: true, bold: opts.bold || false }),
|
||||
new TextRun({ children: [new Tab()], font: "David", rightToLeft: true }),
|
||||
new TextRun({ text: String(pageNum), font: "David", size: 24, rightToLeft: true }),
|
||||
]
|
||||
});
|
||||
|
||||
// שימוש:
|
||||
tocEntry("פרק א׳ — הגדרות כלליות", 2, { bold: true }),
|
||||
tocEntry("1. הגדרות יסוד", 2, { indent: 400 }),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## מספר סקשנים (Multiple Sections)
|
||||
|
||||
**שימוש:** כותרות שונות לנספחים, שוליים שונים.
|
||||
|
||||
```javascript
|
||||
const doc = new Document({
|
||||
sections: [
|
||||
{
|
||||
properties: {
|
||||
page: { size: { width: 11906, height: 16838 }, margin: { top: 1417, right: 1417, bottom: 1417, left: 1417 } },
|
||||
bidi: true,
|
||||
},
|
||||
headers: {
|
||||
default: new Header({ children: [new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "הסכם שירותים", font: "David", size: 20, bold: true, rightToLeft: true })]
|
||||
})] })
|
||||
},
|
||||
children: [ /* גוף ההסכם */ ]
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
page: { size: { width: 11906, height: 16838 }, margin: { top: 1417, right: 1417, bottom: 1417, left: 1417 } },
|
||||
bidi: true,
|
||||
},
|
||||
headers: {
|
||||
default: new Header({ children: [new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
children: [new TextRun({ text: "נספח א׳ — לוח תעריפים", font: "David", size: 20, bold: true, rightToLeft: true })]
|
||||
})] })
|
||||
},
|
||||
children: [ /* הנספח */ ]
|
||||
}
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## לוגו/תמונה בכותרת (Letterhead)
|
||||
|
||||
```javascript
|
||||
const { ImageRun } = require('docx');
|
||||
const logoBuffer = fs.readFileSync('/path/to/logo.png');
|
||||
|
||||
headers: {
|
||||
default: new Header({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [new ImageRun({ data: logoBuffer, transformation: { width: 200, height: 60 }, type: "png" })],
|
||||
}),
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "משרד עורכי דין ישראלי ושות׳", font: "David", size: 20, bold: true, rightToLeft: true })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
**הערה:** תמונה חייבת להיות קובץ אמיתי — לבקש מהמשתמש אם אין.
|
||||
|
||||
---
|
||||
|
||||
## היפרלינקים
|
||||
|
||||
```javascript
|
||||
const { ExternalHyperlink, UnderlineType } = require('docx');
|
||||
|
||||
new Paragraph({
|
||||
bidirectional: true,
|
||||
children: [
|
||||
new TextRun({ text: "ראה: ", font: "David", size: 24, rightToLeft: true }),
|
||||
new ExternalHyperlink({
|
||||
link: "https://www.nevo.co.il/law_html/law01/073_002.htm",
|
||||
children: [new TextRun({
|
||||
text: "חוק החוזים באתר נבו",
|
||||
font: "David", size: 24, rightToLeft: true,
|
||||
color: "0563C1",
|
||||
underline: { type: UnderlineType.SINGLE },
|
||||
})],
|
||||
}),
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
**⚠️ אל תשתמש ב-`style: "Hyperlink"`** — מפריע ל-RTL. הגדר `color` + `underline` ידנית.
|
||||
219
skills/docx/references/document-templates.md
Normal file
219
skills/docx/references/document-templates.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Document Templates — תבניות מסמכים משפטיים
|
||||
|
||||
## תבנית 1: כתב טענות (בקשה, תביעה, הגנה, ערעור)
|
||||
|
||||
```javascript
|
||||
const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
|
||||
AlignmentType, LevelFormat, BorderStyle, WidthType } = require('docx');
|
||||
|
||||
const PAGE_WIDTH = 11906;
|
||||
const MARGINS = { top: 1134, right: 1134, bottom: 1134, left: 1134 };
|
||||
const CONTENT_WIDTH = PAGE_WIDTH - MARGINS.left - MARGINS.right;
|
||||
|
||||
const noBorder = { style: BorderStyle.NONE, size: 0, color: "FFFFFF" };
|
||||
const noBorders = { top: noBorder, bottom: noBorder, left: noBorder, right: noBorder };
|
||||
|
||||
// Header בית משפט — טבלה עם שם בית המשפט (ימין) ומספר תיק (שמאל)
|
||||
function courtHeader(courtName, caseNumber) {
|
||||
return new Table({
|
||||
width: { size: CONTENT_WIDTH, type: WidthType.DXA },
|
||||
columnWidths: [CONTENT_WIDTH / 2, CONTENT_WIDTH / 2],
|
||||
visuallyRightToLeft: true,
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
width: { size: CONTENT_WIDTH / 2, type: WidthType.DXA },
|
||||
borders: noBorders,
|
||||
children: [new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
children: [new TextRun({ text: courtName, bold: true, font: "David", size: 26, rightToLeft: true })]
|
||||
})]
|
||||
}),
|
||||
new TableCell({
|
||||
width: { size: CONTENT_WIDTH / 2, type: WidthType.DXA },
|
||||
borders: noBorders,
|
||||
children: [new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.END,
|
||||
children: [new TextRun({ text: caseNumber, bold: true, font: "David", size: 26, rightToLeft: true })]
|
||||
})]
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function mainTitle(text) {
|
||||
return new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 300, after: 300 },
|
||||
children: [new TextRun({ text, bold: true, font: "David", size: 28, rightToLeft: true, underline: {} })]
|
||||
});
|
||||
}
|
||||
|
||||
function subHeading(text) {
|
||||
return new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
spacing: { before: 240, after: 120 },
|
||||
children: [new TextRun({ text, bold: true, font: "David", size: 24, rightToLeft: true, underline: {} })]
|
||||
});
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
numbering: {
|
||||
config: [{
|
||||
reference: "legal-clauses",
|
||||
levels: [{
|
||||
level: 0, format: LevelFormat.DECIMAL, text: "%1.",
|
||||
alignment: AlignmentType.START, suffix: "tab",
|
||||
style: { paragraph: { indent: { left: 360, hanging: 360 } } }
|
||||
}]
|
||||
}]
|
||||
},
|
||||
sections: [{
|
||||
properties: {
|
||||
page: { size: { width: PAGE_WIDTH, height: 16838 }, margin: MARGINS },
|
||||
bidi: true
|
||||
},
|
||||
children: [
|
||||
courtHeader("בית המשפט המחוזי בתל אביב", "ת\"א 12345-01-26"),
|
||||
mainTitle("כתב תביעה"),
|
||||
// ... פרטי צדדים, סעיפים, חתימה
|
||||
]
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## תבנית 2: מכתב התראה
|
||||
|
||||
```javascript
|
||||
function letterHeader(firmName, address, phone, email) {
|
||||
return [
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
children: [new TextRun({ text: firmName, bold: true, font: "David", size: 28, rightToLeft: true })]
|
||||
}),
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
children: [new TextRun({ text: address, font: "David", size: 22, rightToLeft: true })]
|
||||
}),
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
spacing: { after: 300 },
|
||||
children: [new TextRun({ text: `טל': ${phone} | ${email}`, font: "David", size: 22, rightToLeft: true })]
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function subjectLine(text) {
|
||||
return new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 200, after: 200 },
|
||||
children: [
|
||||
new TextRun({ text: "הנדון: ", bold: true, font: "David", size: 24, rightToLeft: true }),
|
||||
new TextRun({ text, bold: true, font: "David", size: 24, rightToLeft: true, underline: {} })
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// שימוש:
|
||||
sections: [{
|
||||
properties: { page: { size: { width: 11906, height: 16838 }, margin: { top: 1417, right: 1417, bottom: 1417, left: 1417 } }, bidi: true },
|
||||
children: [
|
||||
...letterHeader("משרד עו\"ד כהן ושות'", "רח' הרצל 1, תל אביב", "03-1234567", "office@cohen-law.co.il"),
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
children: [new TextRun({ text: "תאריך: 10.2.2026", font: "David", size: 24, rightToLeft: true })]
|
||||
}),
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
spacing: { before: 200 },
|
||||
children: [new TextRun({ text: "לכבוד: [שם הנמען]", font: "David", size: 24, rightToLeft: true })]
|
||||
}),
|
||||
subjectLine("התראה בטרם נקיטת הליכים משפטיים"),
|
||||
]
|
||||
}]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## תבנית 3: הסכם/חוזה
|
||||
|
||||
```javascript
|
||||
const CONTENT_WIDTH = 9638; // A4 עם שוליים 2.5 ס"מ
|
||||
const noBorders = { /* ראה תבנית 1 */ };
|
||||
|
||||
function contractTitle(text) {
|
||||
return new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 300 },
|
||||
children: [new TextRun({ text, bold: true, font: "David", size: 32, rightToLeft: true })]
|
||||
});
|
||||
}
|
||||
|
||||
function partyClause(label, name, id, address, alias) {
|
||||
return new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.BOTH,
|
||||
spacing: { after: 120 },
|
||||
children: [
|
||||
new TextRun({ text: `${label}: `, bold: true, font: "David", size: 24, rightToLeft: true }),
|
||||
new TextRun({ text: `${name}, ח.פ./ת.ז. ${id}, מ${address} (להלן: "`, font: "David", size: 24, rightToLeft: true }),
|
||||
new TextRun({ text: alias, bold: true, font: "David", size: 24, rightToLeft: true }),
|
||||
new TextRun({ text: '")', font: "David", size: 24, rightToLeft: true }),
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function signatureTable(contentWidth) {
|
||||
return new Table({
|
||||
width: { size: contentWidth, type: WidthType.DXA },
|
||||
columnWidths: [contentWidth / 2, contentWidth / 2],
|
||||
visuallyRightToLeft: true,
|
||||
rows: [new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
borders: noBorders,
|
||||
children: [
|
||||
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "_________________", font: "David", size: 24, rightToLeft: true })] }),
|
||||
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "צד א'", font: "David", size: 24, rightToLeft: true })] })
|
||||
]
|
||||
}),
|
||||
new TableCell({
|
||||
borders: noBorders,
|
||||
children: [
|
||||
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "_________________", font: "David", size: 24, rightToLeft: true })] }),
|
||||
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "צד ב'", font: "David", size: 24, rightToLeft: true })] })
|
||||
]
|
||||
})
|
||||
]
|
||||
})]
|
||||
});
|
||||
}
|
||||
|
||||
sections: [{
|
||||
properties: { page: { size: { width: 11906, height: 16838 }, margin: { top: 1417, right: 1417, bottom: 1417, left: 1417 } }, bidi: true },
|
||||
children: [
|
||||
contractTitle("הסכם שירותים"),
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "נערך ונחתם בתל אביב ביום __________", font: "David", size: 24, rightToLeft: true })]
|
||||
}),
|
||||
partyClause("מצד אחד", "[שם]", "[מספר]", "[כתובת]", "המזמין"),
|
||||
partyClause("מצד שני", "[שם]", "[מספר]", "[כתובת]", "הספק"),
|
||||
// הואילים + סעיפים...
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 400, after: 300 },
|
||||
children: [new TextRun({ text: "ולראיה באו הצדדים על החתום:", bold: true, font: "David", size: 24, rightToLeft: true })]
|
||||
}),
|
||||
signatureTable(CONTENT_WIDTH)
|
||||
]
|
||||
}]
|
||||
```
|
||||
57
skills/docx/references/tracked-changes.md
Normal file
57
skills/docx/references/tracked-changes.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Tracked Changes — עקוב אחר שינויים
|
||||
|
||||
## שם מחבר בעברית
|
||||
```xml
|
||||
<w:del w:id="10" w:author="עו"ד כהן" w:date="2026-02-06T09:00:00Z">
|
||||
```
|
||||
|
||||
## שינוי ערך (סכום, תאריך, תקופה)
|
||||
פצל את הטקסט ועטוף רק את הערך שמשתנה:
|
||||
```xml
|
||||
<w:r><w:rPr>...RTL PROPS...</w:rPr>
|
||||
<w:t xml:space="preserve">שכר הטרחה יעמוד על סך של </w:t></w:r>
|
||||
<w:del w:id="10" w:author="עו"ד כהן" w:date="...">
|
||||
<w:r><w:rPr>...RTL PROPS...</w:rPr><w:delText>750</w:delText></w:r>
|
||||
</w:del>
|
||||
<w:ins w:id="11" w:author="עו"ד כהן" w:date="...">
|
||||
<w:r><w:rPr>...RTL PROPS...</w:rPr><w:t>850</w:t></w:r>
|
||||
</w:ins>
|
||||
<w:r><w:rPr>...RTL PROPS...</w:rPr>
|
||||
<w:t xml:space="preserve"> ש״ח לשעת עבודה</w:t></w:r>
|
||||
```
|
||||
|
||||
## מחיקת סעיף שלם
|
||||
```xml
|
||||
<w:p>
|
||||
<w:pPr>
|
||||
<w:bidi/>
|
||||
<w:jc w:val="both"/>
|
||||
<w:rPr>
|
||||
<w:del w:id="20" w:author="עו"ד כהן" w:date="..."/>
|
||||
</w:rPr>
|
||||
</w:pPr>
|
||||
<w:del w:id="21" w:author="עו"ד כהן" w:date="...">
|
||||
<w:r><w:rPr>...RTL PROPS...</w:rPr>
|
||||
<w:delText>הסעיף שנמחק</w:delText></w:r>
|
||||
</w:del>
|
||||
</w:p>
|
||||
```
|
||||
|
||||
## RTL PROPS — בלוק rPr מלא לכל run
|
||||
```xml
|
||||
<w:rPr>
|
||||
<w:rFonts w:ascii="David" w:cs="David" w:eastAsia="David" w:hAnsi="David"/>
|
||||
<w:sz w:val="24"/>
|
||||
<w:szCs w:val="24"/>
|
||||
<w:rtl/>
|
||||
</w:rPr>
|
||||
```
|
||||
|
||||
## קבלה/דחייה של שינויים
|
||||
|
||||
| פעולה | לפני | אחרי |
|
||||
|-------|------|------|
|
||||
| קבלת הוספה | `<w:ins ...><w:r>...<w:t>טקסט</w:t></w:r></w:ins>` | `<w:r>...<w:t>טקסט</w:t></w:r>` |
|
||||
| דחיית הוספה | `<w:ins ...><w:r>...</w:r></w:ins>` | *(מחק הכל)* |
|
||||
| קבלת מחיקה | `<w:del ...><w:r>...<w:delText>טקסט</w:delText></w:r></w:del>` | *(מחק הכל)* |
|
||||
| דחיית מחיקה | `<w:del ...><w:r>...<w:delText>טקסט</w:delText></w:r></w:del>` | `<w:r>...<w:t>טקסט</w:t></w:r>` |
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ApprovalCategory,
|
||||
type ApprovalSeverity,
|
||||
} from "@/lib/api/chair";
|
||||
import { formatDateLong } from "@/lib/format-date";
|
||||
|
||||
/**
|
||||
* מרכז אישורים — דפנה (INV-G10).
|
||||
@@ -35,18 +36,7 @@ const SEVERITY_RAIL: Record<ApprovalSeverity, string> = {
|
||||
ok: "border-s-success",
|
||||
};
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
const formatDate = (iso?: string | null) => formatDateLong(iso);
|
||||
|
||||
function ApprovalCard({ cat }: { cat: ApprovalCategory }) {
|
||||
const cleared = cat.count === 0;
|
||||
|
||||
@@ -28,19 +28,7 @@ import {
|
||||
import { useCases, useRestoreCase, type Case } from "@/lib/api/cases";
|
||||
import { subtypeOf } from "@/components/cases/appeal-type-bars";
|
||||
import { APPEAL_SUBTYPE_LABELS, type AppealSubtype } from "@/lib/practice-area";
|
||||
|
||||
function formatDate(iso?: string | null) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
import { formatDate, getIsraelYear } from "@/lib/format-date";
|
||||
|
||||
// type chip styling per mockup 05 (.t-lic / .t-bet / .t-comp)
|
||||
const TYPE_CHIP: Record<string, string> = {
|
||||
@@ -177,8 +165,8 @@ export default function ArchivePage() {
|
||||
const set = new Set<string>();
|
||||
for (const c of rows) {
|
||||
if (!c.archived_at) continue;
|
||||
const y = new Date(c.archived_at).getFullYear();
|
||||
if (!Number.isNaN(y)) set.add(String(y));
|
||||
const y = getIsraelYear(c.archived_at);
|
||||
if (y !== null) set.add(String(y));
|
||||
}
|
||||
return [...set].sort((a, b) => Number(b) - Number(a));
|
||||
}, [rows]);
|
||||
@@ -209,7 +197,7 @@ export default function ArchivePage() {
|
||||
if (yearFilter !== "all") {
|
||||
all = all.filter((r) => {
|
||||
const iso = r.original.archived_at;
|
||||
return iso != null && String(new Date(iso).getFullYear()) === yearFilter;
|
||||
return iso != null && String(getIsraelYear(iso)) === yearFilter;
|
||||
});
|
||||
}
|
||||
return all;
|
||||
|
||||
@@ -1,449 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FileText } from "lucide-react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SubsectionCard } from "@/components/compose/subsection-card";
|
||||
import { PrecedentsSection } from "@/components/compose/precedents-section";
|
||||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useCase, type CaseStatus } from "@/lib/api/cases";
|
||||
import { useResearchAnalysis } from "@/lib/api/research";
|
||||
import { useCasePrecedents } from "@/lib/api/precedents";
|
||||
import { APPEAL_SUBTYPES } from "@/lib/practice-area";
|
||||
import { DOC_TYPE_LABELS, type DocType } from "@/lib/doc-types";
|
||||
|
||||
// ── Case-status → Hebrew label + tone (mockup 03 status chip) ────────────────
|
||||
const STATUS_CHIP: Record<string, { label: string; cls: string }> = {
|
||||
new: { label: "חדש", cls: "bg-rule-soft text-ink-muted border-rule" },
|
||||
processing: { label: "בעיבוד", cls: "bg-info-bg text-info border-info/30" },
|
||||
documents_ready: { label: "מסמכים מוכנים", cls: "bg-info-bg text-info border-info/30" },
|
||||
outcome_set: { label: "תוצאה נקבעה", cls: "bg-info-bg text-info border-info/30" },
|
||||
direction_approved: { label: "כיוון אושר", cls: "bg-info-bg text-info border-info/30" },
|
||||
qa_review: { label: "בדיקת-איכות", cls: "bg-gold-wash text-gold-deep border-gold/40" },
|
||||
drafted: { label: "טיוטה", cls: "bg-gold-wash text-gold-deep border-gold/40" },
|
||||
exported: { label: "יוצא", cls: "bg-success-bg text-success border-success/40" },
|
||||
reviewed: { label: "נסקר", cls: "bg-success-bg text-success border-success/40" },
|
||||
final: { label: "סופי", cls: "bg-success-bg text-success border-success/40" },
|
||||
};
|
||||
|
||||
function StatusChip({ status }: { status?: CaseStatus }) {
|
||||
const c = (status && STATUS_CHIP[status]) || {
|
||||
label: "בעריכה",
|
||||
cls: "bg-info-bg text-info border-info/30",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full text-[0.78rem] font-semibold px-3 py-0.5 border ${c.cls}`}
|
||||
>
|
||||
{c.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function subtypeLabel(subtype?: string | null): string | null {
|
||||
if (!subtype) return null;
|
||||
return APPEAL_SUBTYPES.find((s) => s.value === subtype)?.label ?? null;
|
||||
}
|
||||
|
||||
function ProseSection({ title, content }: { title: string; content?: string }) {
|
||||
if (!content?.trim()) return null;
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-[0.78rem] uppercase tracking-[0.08em] text-gold-deep font-semibold">
|
||||
{title}
|
||||
</h3>
|
||||
<Markdown content={content.trim()} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── "השלמה והעברה" rail card — DOCX export, upload, download (all real) ──────
|
||||
function FinishRail({
|
||||
caseNumber,
|
||||
hasAnalysis,
|
||||
onUploaded,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
hasAnalysis: boolean;
|
||||
onUploaded: () => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadMsg, setUploadMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
setUploading(true);
|
||||
setUploadMsg(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch(`/api/cases/${caseNumber}/research/analysis/upload`, {
|
||||
method: "PUT",
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setUploadMsg({ ok: false, text: data.detail || "שגיאה בהעלאה" });
|
||||
return;
|
||||
}
|
||||
setUploadMsg({
|
||||
ok: true,
|
||||
text: `הקובץ הועלה — ${data.sections.threshold_claims} טענות סף, ${data.sections.issues} סוגיות`,
|
||||
});
|
||||
onUploaded();
|
||||
} catch {
|
||||
setUploadMsg({ ok: false, text: "שגיאת רשת" });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-4 py-4">
|
||||
<h3 className="text-navy text-[0.9rem] font-semibold mb-3">השלמה והעברה</h3>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".md"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleUpload(f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{hasAnalysis && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
onClick={() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/cases/${caseNumber}/research/analysis/export-docx`;
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
ייצוא DOCX
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full justify-center bg-gold text-white hover:bg-gold-deep"
|
||||
disabled={uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{uploading ? "מעלה…" : "העלאת ניתוח מעודכן"}
|
||||
</Button>
|
||||
{hasAnalysis && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
onClick={() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/cases/${caseNumber}/research/analysis/download`;
|
||||
a.download = `analysis-${caseNumber}.md`;
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
הורד ניתוח (MD)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadMsg && (
|
||||
<p className={`text-xs mt-2 ${uploadMsg.ok ? "text-success" : "text-danger"}`}>
|
||||
{uploadMsg.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* mockup 03: stage indicators — informational pointers, not actions */}
|
||||
<div className="mt-3 space-y-0">
|
||||
<div className="text-[0.78rem] text-ink-muted pt-2 border-t border-rule-soft">
|
||||
<b className="text-navy">הרץ למידת-קול</b> — ממתין להעלאת הסופי
|
||||
</div>
|
||||
<div className="text-[0.78rem] text-ink-muted pt-2 mt-2 border-t border-rule-soft">
|
||||
<b className="text-navy">הרץ אימות-הלכות</b> — ממתין להעלאת הסופי
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="ghost" className="w-full justify-center mt-3 text-ink-muted">
|
||||
<Link href={`/cases/${caseNumber}`}>חזרה לתיק</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComposePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ caseNumber: string }>;
|
||||
}) {
|
||||
const { caseNumber } = use(params);
|
||||
const caseQuery = useCase(caseNumber);
|
||||
const analysis = useResearchAnalysis(caseNumber);
|
||||
const precedentsQuery = useCasePrecedents(caseNumber);
|
||||
|
||||
/* Partition the flat list into scopes so each child renders its own slice
|
||||
* without re-fetching. Done once at the page level. */
|
||||
const allPrecedents = precedentsQuery.data ?? [];
|
||||
const caseLevelPrecedents = allPrecedents.filter((p) => p.section_id === null);
|
||||
const precedentsBySection = new Map<string, typeof allPrecedents>();
|
||||
for (const p of allPrecedents) {
|
||||
if (p.section_id) {
|
||||
const existing = precedentsBySection.get(p.section_id) ?? [];
|
||||
existing.push(p);
|
||||
precedentsBySection.set(p.section_id, existing);
|
||||
}
|
||||
}
|
||||
const practiceArea = caseQuery.data?.practice_area ?? null;
|
||||
const subtype = subtypeLabel(caseQuery.data?.appeal_subtype);
|
||||
const parties = (() => {
|
||||
const c = caseQuery.data;
|
||||
if (!c) return null;
|
||||
const app = c.appellants?.length ? c.appellants.join(", ") : null;
|
||||
const resp = c.respondents?.length ? c.respondents.join(", ") : null;
|
||||
const out: string[] = [];
|
||||
if (app) out.push(`עוררים: ${app}`);
|
||||
if (resp) out.push(`משיבה: ${resp}`);
|
||||
return out.length ? out.join(" · ") : c.title || null;
|
||||
})();
|
||||
const documents = caseQuery.data?.documents ?? [];
|
||||
|
||||
const isNotFound =
|
||||
analysis.error instanceof Error &&
|
||||
/404|לא נמצא|טרם בוצע/.test(analysis.error.message);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
{/* ── Case header band (mockup 03) — parchment strip, full-bleed to the
|
||||
AppShell <main> edges (which pads px-10 py-10) ── */}
|
||||
<div className="-mx-10 -mt-10 mb-6 border-b border-rule bg-parchment px-10 py-5">
|
||||
<nav className="text-[0.78rem] text-ink-muted flex items-center gap-2 mb-2">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden>·</span>
|
||||
<Link href={`/cases/${caseNumber}`} className="hover:text-gold-deep">
|
||||
ערר {caseNumber}
|
||||
</Link>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="text-navy">עורך החלטה</span>
|
||||
</nav>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-navy text-2xl font-bold mb-0">ערר {caseNumber}</h1>
|
||||
<StatusChip status={caseQuery.data?.status} />
|
||||
{subtype && (
|
||||
<span className="rounded-full text-[0.78rem] font-semibold px-3 py-0.5 border border-rule bg-gold-wash text-gold-deep">
|
||||
{subtype}
|
||||
</span>
|
||||
)}
|
||||
{/* INV-G10: source-of-truth pill — the blocks are the canonical text */}
|
||||
<span className="ms-auto rounded-lg text-[0.8rem] font-semibold px-3.5 py-1.5 border border-gold bg-gold-wash text-gold-deep">
|
||||
מקור-אמת: בלוקים
|
||||
</span>
|
||||
</div>
|
||||
{parties && <p className="text-ink-soft text-sm mt-2">{parties}</p>}
|
||||
</div>
|
||||
|
||||
{caseQuery.isPending ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-3">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
/* ── Two-column workspace: tabbed main editor + 320px side rail ──── */
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_320px] items-start">
|
||||
{/* MAIN — block editor (default) + chair positions, as tabs (mockup 03) */}
|
||||
<div className="min-w-0">
|
||||
<Tabs defaultValue="blocks" dir="rtl">
|
||||
<TabsList className="bg-rule-soft/60">
|
||||
<TabsTrigger value="blocks">עורך הבלוקים</TabsTrigger>
|
||||
<TabsTrigger value="positions">עמדות וטענות</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab 1 — the 12-block decision editor (reused DecisionBlocksPanel) */}
|
||||
<TabsContent value="blocks" className="mt-5">
|
||||
<DecisionBlocksPanel caseNumber={caseNumber} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 2 — chair positions on the analyst's threshold-claims + issues */}
|
||||
<TabsContent value="positions" className="mt-5">
|
||||
{analysis.isPending ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-3">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isNotFound ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-12 text-center space-y-3">
|
||||
<div className="text-gold text-3xl" aria-hidden>❦</div>
|
||||
<h2 className="text-navy text-lg mb-0">
|
||||
טרם בוצע ניתוח משפטי לתיק זה
|
||||
</h2>
|
||||
<p className="text-ink-muted text-sm max-w-md mx-auto">
|
||||
לאחר שקובץ <code>analysis-and-research.md</code> ייווצר, תוכלי
|
||||
לערוך כאן את עמדת הוועדה לכל טענת סף וסוגיה.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : analysis.error ? (
|
||||
<Card className="bg-danger-bg border-danger/40">
|
||||
<CardContent className="px-6 py-5 text-center">
|
||||
<p className="text-danger">{analysis.error.message}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : analysis.data ? (
|
||||
<div className="space-y-6">
|
||||
{/* Threshold claims */}
|
||||
{analysis.data.threshold_claims &&
|
||||
analysis.data.threshold_claims.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-navy text-lg font-semibold mb-0">טענות סף</h2>
|
||||
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
|
||||
{analysis.data.threshold_claims.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{analysis.data.threshold_claims.map((tc) => (
|
||||
<SubsectionCard
|
||||
key={tc.id}
|
||||
caseNumber={caseNumber}
|
||||
item={tc}
|
||||
precedents={precedentsBySection.get(tc.id) ?? []}
|
||||
practiceArea={practiceArea}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issues */}
|
||||
{analysis.data.issues && analysis.data.issues.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-navy text-lg font-semibold mb-0">סוגיות להכרעה</h2>
|
||||
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
|
||||
{analysis.data.issues.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{analysis.data.issues.map((iss) => (
|
||||
<SubsectionCard
|
||||
key={iss.id}
|
||||
caseNumber={caseNumber}
|
||||
item={iss}
|
||||
precedents={precedentsBySection.get(iss.id) ?? []}
|
||||
practiceArea={practiceArea}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!analysis.data.threshold_claims?.length &&
|
||||
!analysis.data.issues?.length && (
|
||||
<Card className="bg-surface border-rule">
|
||||
<CardContent className="px-6 py-10 text-center text-ink-muted">
|
||||
לא נמצאו טענות סף או סוגיות בניתוח זה.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Background prose — supporting context after the decision points */}
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-5">
|
||||
<h2 className="text-navy text-lg font-semibold mb-0">רקע לניתוח</h2>
|
||||
<ProseSection title="צד מיוצג" content={analysis.data.represented_party} />
|
||||
<ProseSection title="רקע דיוני" content={analysis.data.procedural_background} />
|
||||
<ProseSection title="עובדות מוסכמות" content={analysis.data.agreed_facts} />
|
||||
<ProseSection title="עובדות במחלוקת" content={analysis.data.disputed_facts} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{analysis.data.conclusions?.trim() && (
|
||||
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-3">
|
||||
<h2 className="text-gold-deep text-lg font-semibold mb-0">מסקנות</h2>
|
||||
<Markdown content={analysis.data.conclusions.trim()} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* SIDE RAIL — documents · attached precedents · finish-and-transfer */}
|
||||
<aside className="space-y-4 lg:sticky lg:top-4">
|
||||
{/* מסמכי התיק */}
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-4 py-4">
|
||||
<h3 className="text-navy text-[0.9rem] font-semibold mb-2">מסמכי התיק</h3>
|
||||
{documents.length === 0 ? (
|
||||
<p className="text-[0.78rem] text-ink-muted">אין מסמכים מצורפים</p>
|
||||
) : (
|
||||
<ul>
|
||||
{documents.map((d) => (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex items-center gap-2 text-[0.82rem] text-ink-soft py-1.5 border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-ink-muted shrink-0" aria-hidden />
|
||||
<span className="truncate flex-1" title={d.title}>
|
||||
{d.title || "מסמך"}
|
||||
</span>
|
||||
<span className="rounded bg-rule-soft text-ink-muted text-[0.68rem] px-1.5 py-0.5 shrink-0 whitespace-nowrap">
|
||||
{DOC_TYPE_LABELS[d.doc_type as DocType] ?? d.doc_type}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* פסיקה מצורפת (case-level) */}
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-4 py-4">
|
||||
<h3 className="text-navy text-[0.9rem] font-semibold mb-1">פסיקה מצורפת</h3>
|
||||
<p className="text-[0.72rem] text-ink-muted mb-3">
|
||||
ציטוטים התומכים בעמדה באופן רוחבי — ישולבו בפתיחת בלוק י (דיון).
|
||||
</p>
|
||||
<PrecedentsSection
|
||||
caseNumber={caseNumber}
|
||||
sectionId={null}
|
||||
precedents={caseLevelPrecedents}
|
||||
practiceArea={practiceArea}
|
||||
emptyHelperText="עדיין לא צורפה פסיקה כללית לתיק"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* השלמה והעברה */}
|
||||
<FinishRail
|
||||
caseNumber={caseNumber}
|
||||
hasAnalysis={!!analysis.data}
|
||||
onUploaded={() => analysis.refetch()}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { use, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CaseHeader } from "@/components/cases/case-header";
|
||||
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
||||
import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
|
||||
import { StatusGuide } from "@/components/cases/status-guide";
|
||||
import { StatusChanger } from "@/components/cases/status-changer";
|
||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||||
import { PositionsPanel } from "@/components/cases/positions-panel";
|
||||
import { HearingChangesPanel } from "@/components/cases/hearing-changes-panel";
|
||||
import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
|
||||
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
||||
import { AgentActivityPreview } from "@/components/cases/agent-activity-preview";
|
||||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
||||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||
import { expectedOutcomes } from "@/lib/schemas/case";
|
||||
import { useCase, useStartWorkflow } from "@/lib/api/cases";
|
||||
import { toast } from "sonner";
|
||||
import { Play, Loader2 } from "lucide-react";
|
||||
|
||||
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
|
||||
expectedOutcomes.map((o) => [o.value, o.label]),
|
||||
);
|
||||
import {
|
||||
Play, Loader2, LayoutGrid, Scale, BadgeCheck, FileText, MessageSquare, Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
/*
|
||||
* Next 16 breaking change: route params are now a Promise.
|
||||
@@ -39,14 +39,14 @@ export default function CaseDetailPage({
|
||||
params: Promise<{ caseNumber: string }>;
|
||||
}) {
|
||||
const { caseNumber } = use(params);
|
||||
const { data, isPending, error } = useCase(caseNumber);
|
||||
const [tab, setTab] = useState("overview");
|
||||
const { data, isPending, error, refetch } = useCase(caseNumber);
|
||||
const startWorkflow = useStartWorkflow(caseNumber);
|
||||
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
|
||||
const expectedOutcomeLabel = data?.expected_outcome
|
||||
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
|
||||
: null;
|
||||
|
||||
if (error) {
|
||||
// Only take over the whole page when there is NO data to show. A transient
|
||||
// 5xx on the 5s background refetch must not blow away an already-loaded page.
|
||||
if (error && !data) {
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
@@ -54,9 +54,14 @@ export default function CaseDetailPage({
|
||||
<CardContent className="px-6 py-6 text-center space-y-3">
|
||||
<p className="text-danger font-semibold">שגיאה בטעינת התיק</p>
|
||||
<p className="text-sm text-ink-muted">{error.message}</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/">חזרה לרשימת התיקים</Link>
|
||||
</Button>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
נסה שוב
|
||||
</Button>
|
||||
<Button asChild variant="ghost">
|
||||
<Link href="/">חזרה לרשימת התיקים</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
@@ -64,23 +69,32 @@ export default function CaseDetailPage({
|
||||
);
|
||||
}
|
||||
|
||||
// Workflow order (X17): intake → arguments+positions → verify citations →
|
||||
// write decision → drafts/final → agents (monitoring).
|
||||
const tabDefs: [string, string, LucideIcon][] = [
|
||||
["overview", "סקירה", LayoutGrid],
|
||||
["arguments", "טיעונים ועמדות", Scale],
|
||||
["verify", "אימות פסיקה", BadgeCheck],
|
||||
["decision", "ההחלטה", FileText],
|
||||
["drafts", "טיוטות והערות", MessageSquare],
|
||||
["agents", "סוכנים", Users],
|
||||
];
|
||||
|
||||
// V2 "segmented" tab strip (X17): each tab a pill, active = raised white card.
|
||||
// Spread full-width via justify-between (chair request) — wide gaps with few
|
||||
// tabs, tightening as more are added; gap-2 is the no-overlap floor.
|
||||
const tabsList = (
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="gap-6 h-auto p-0 rounded-none -mb-px"
|
||||
className="!h-auto w-full gap-2 p-0 justify-between"
|
||||
>
|
||||
{[
|
||||
["overview", "סקירה"],
|
||||
["arguments", "טיעונים"],
|
||||
["decision", "ההחלטה"],
|
||||
["drafts", "טיוטות והערות"],
|
||||
["agents", "סוכנים"],
|
||||
].map(([value, label]) => (
|
||||
{tabDefs.map(([value, label, Icon]) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="flex-none rounded-none px-0 pb-3.5 pt-0 text-[0.92rem] font-medium text-ink-muted data-active:text-navy data-active:font-semibold data-active:after:bg-gold data-active:after:bottom-0"
|
||||
className="flex-none gap-1.5 rounded-lg border border-transparent px-3.5 py-1.5 text-[0.9rem] font-medium text-ink-soft after:hidden hover:bg-parchment/60 data-active:bg-surface data-active:text-navy data-active:font-semibold data-active:border-rule data-active:shadow-sm [&_svg]:text-ink-light data-active:[&_svg]:text-gold-deep"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
@@ -116,7 +130,7 @@ export default function CaseDetailPage({
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<Tabs defaultValue="overview" dir="rtl">
|
||||
<Tabs value={tab} onValueChange={setTab} dir="rtl">
|
||||
{/* parchment band — header (title/chips/parties/actions) + tab strip */}
|
||||
{isPending ? (
|
||||
<div className="-mx-10 -mt-10 mb-2 bg-parchment border-b border-rule px-10 pt-6 pb-4 space-y-3">
|
||||
@@ -125,85 +139,125 @@ export default function CaseDetailPage({
|
||||
<Skeleton className="h-6 w-96" />
|
||||
</div>
|
||||
) : (
|
||||
<CaseHeader data={data} actions={bandActions} tabs={tabsList} />
|
||||
<CaseHeader caseNumber={caseNumber} data={data} actions={bandActions} tabs={tabsList} />
|
||||
)}
|
||||
|
||||
{/* two-column wrap — main tab content (1fr) + rail (340px) */}
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_340px] items-start mt-6">
|
||||
<div className="min-w-0">
|
||||
<TabsContent value="overview" className="mt-0 space-y-5">
|
||||
{/* full-width tab content — status now lives in the always-visible band
|
||||
StatusRail (banner A, X17), so every tab keeps the status context. */}
|
||||
<div className="min-w-0 mt-6">
|
||||
<TabsContent value="overview" className="mt-0">
|
||||
{/* documents (wider) + agent-activity preview (mockup 18j) */}
|
||||
<div className="grid gap-6 lg:grid-cols-[1.4fr_1fr] items-start">
|
||||
<DocumentsPanel data={data} />
|
||||
|
||||
<AgentActivityPreview caseNumber={caseNumber} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* gold CTA — open the decision editor */}
|
||||
<Button
|
||||
asChild
|
||||
className="w-full bg-gold text-white hover:bg-gold-deep border-transparent py-6 text-base font-semibold"
|
||||
{/* טיעונים ועמדות — chair positions (editing) over the analyst's
|
||||
issues, with the aggregated by-party arguments collapsible below
|
||||
(merged from the deleted /compose editor, mockup 18f). */}
|
||||
<TabsContent value="arguments" className="mt-0 space-y-4">
|
||||
{/* עמדות וטענות — collapsed by default (chair preference #226),
|
||||
mirroring the two accordions below. */}
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem
|
||||
value="positions"
|
||||
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
|
||||
>
|
||||
<Link href={`/cases/${caseNumber}/compose`}>
|
||||
פתח עורך החלטה →
|
||||
</Link>
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="arguments" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||||
<span className="flex flex-1 flex-col items-start">
|
||||
<span className="text-navy text-[0.95rem] font-bold">
|
||||
עמדות וטענות
|
||||
</span>
|
||||
<span className="text-ink-muted text-[0.78rem]">
|
||||
סוגיות-המחלוקת ועמדות הצדדים · עורך עמדת-היו״ר מזין את בלוק י׳
|
||||
</span>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-5 pt-0">
|
||||
<PositionsPanel caseNumber={caseNumber} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem
|
||||
value="byparty"
|
||||
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||||
<span className="flex flex-1 flex-col items-start">
|
||||
<span className="text-navy text-[0.95rem] font-bold">
|
||||
טיעונים מאוגדים לפי צד
|
||||
</span>
|
||||
<span className="text-ink-muted text-[0.78rem]">
|
||||
תצוגה מסונתזת של הטענות הגולמיות, מקובצת לפי צד וקדימות
|
||||
</span>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-5 pt-0">
|
||||
<LegalArgumentsPanel caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
{/* מה קרה בדיון — comparative analysis of the hearing protocol vs.
|
||||
the written pleadings (#226). Sits below the aggregated arguments
|
||||
because it speaks to those same arguments in their oral gloss.
|
||||
Collapsed by default, mirroring the "by-party" accordion above. */}
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem
|
||||
value="hearing"
|
||||
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||||
<span className="flex flex-1 flex-col items-start">
|
||||
<span className="text-navy text-[0.95rem] font-bold">
|
||||
מה קרה בדיון
|
||||
</span>
|
||||
<span className="text-ink-muted text-[0.78rem]">
|
||||
השוואת הטענות בכתב מול הדיון בעל-פה — מה התחזק ומה נטען לראשונה
|
||||
</span>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-5 pt-0">
|
||||
<HearingChangesPanel caseNumber={caseNumber} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="decision" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<DecisionBlocksPanel caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drafts" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<AgentActivityFeed caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
{/* rail — status timeline + status controls (mockup .rail) */}
|
||||
<div className="space-y-5">
|
||||
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0 h-fit">
|
||||
<div className="px-5 py-3.5 border-b border-rule-soft bg-parchment text-[0.92rem] font-semibold text-navy">
|
||||
סטטוס התיק
|
||||
</div>
|
||||
<CardContent className="px-5 py-4 space-y-4">
|
||||
<AgentStatusWidget caseNumber={caseNumber} />
|
||||
<WorkflowTimeline status={data?.status} />
|
||||
{expectedOutcomeLabel && (
|
||||
<div className="border-t border-rule-soft pt-3">
|
||||
<dl className="flex justify-between items-center text-sm">
|
||||
<dt className="text-ink-muted">תוצאה צפויה</dt>
|
||||
<dd className="rounded-full bg-warn-bg text-warn text-[0.75rem] font-semibold px-3 py-0.5">
|
||||
{expectedOutcomeLabel}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<StatusChanger caseNumber={caseNumber} currentStatus={data?.status} />
|
||||
<StatusGuide />
|
||||
{/* אימות פסיקה — per-argument supporting-precedent verify gate (#154),
|
||||
relocated from the deleted /compose editor to a top-level tab. */}
|
||||
<TabsContent value="verify" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<CitationVerificationPanel caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="decision" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<DecisionBlocksPanel caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drafts" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<AgentActivityFeed caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</AppShell>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type ChairFeedback,
|
||||
type FeedbackCategory,
|
||||
} from "@/lib/api/feedback";
|
||||
import { formatDateLong } from "@/lib/format-date";
|
||||
|
||||
/**
|
||||
* מרכז הערות יו"ר — הדף המרכזי לטיפול בכל הערות דפנה שנרשמו על טיוטות
|
||||
@@ -33,18 +34,7 @@ const CAT_CHIP: Record<FeedbackCategory, string> = {
|
||||
other: "bg-rule-soft text-ink-soft",
|
||||
};
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
const formatDate = (iso?: string | null) => formatDateLong(iso);
|
||||
|
||||
function FeedbackCard({ fb }: { fb: ChairFeedback }) {
|
||||
const resolve = useResolveFeedback();
|
||||
|
||||
@@ -22,12 +22,12 @@ import { MissingPrecedentsTable } from "@/components/missing-precedents/missing-
|
||||
|
||||
type StatusFilter = MissingPrecedentStatus | "all";
|
||||
|
||||
// Only the two states the chair acts on: open gaps to fill, and closed gaps for
|
||||
// reference. "הועלה" (transient) and "לא-רלוונטי" were dropped from the filter,
|
||||
// and "הכל" with them (chair's request, design 09).
|
||||
const STATUS_CHIPS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "open", label: "פתוח" },
|
||||
{ value: "uploaded", label: "הועלה" },
|
||||
{ value: "closed", label: "נסגר" },
|
||||
{ value: "irrelevant", label: "לא-רלוונטי" },
|
||||
{ value: "all", label: "הכל" },
|
||||
];
|
||||
|
||||
export default function MissingPrecedentsPage() {
|
||||
@@ -145,15 +145,13 @@ export default function MissingPrecedentsPage() {
|
||||
<div className="rounded-lg border border-rule bg-parchment px-5 py-3.5 text-[0.82rem] text-ink-muted leading-7">
|
||||
<b className="text-ink-soft">מחזור-חיים:</b>{" "}
|
||||
<LifecycleChip tone="open">פתוח</LifecycleChip> →{" "}
|
||||
<LifecycleChip tone="up">הועלה</LifecycleChip> →{" "}
|
||||
<LifecycleChip tone="closed">נסגר</LifecycleChip>. פריט נפתח אוטומטית
|
||||
בעת חילוץ ציטוט שאין לו תקדים בקורפוס; בהעלאת פסק-הדין הוא מקושר לרשומת
|
||||
הפסיקה דרך{" "}
|
||||
<code className="rounded border border-rule bg-surface px-1.5 py-0.5 text-[0.75rem] text-gold-deep" dir="ltr">
|
||||
linked_case_law_id
|
||||
</code>{" "}
|
||||
ונסגר. פריט שאינו רלוונטי מסומן{" "}
|
||||
<LifecycleChip tone="na">לא-רלוונטי</LifecycleChip> מבלי שתידרש העלאה.
|
||||
ונסגר.
|
||||
</div>
|
||||
</section>
|
||||
</AppShell>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -30,13 +29,17 @@ import {
|
||||
useRunLog,
|
||||
useCancelRun,
|
||||
useResetAgentSession,
|
||||
useAgentHealth,
|
||||
useRecentEscalations,
|
||||
type OpsService,
|
||||
type OperationsSnapshot,
|
||||
type PipelineStats,
|
||||
type AgentRun,
|
||||
type SubscriptionUsage,
|
||||
type UsageWindow,
|
||||
type AgentHealthState,
|
||||
} from "@/lib/api/operations";
|
||||
import { formatTime, israelParts } from "@/lib/format-date";
|
||||
|
||||
function mb(bytes: number): string {
|
||||
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
|
||||
@@ -47,8 +50,7 @@ function mb(bytes: number): string {
|
||||
// and resumes on its own when a window resets.
|
||||
function usageResetLabel(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return `איפוס ${d.toLocaleTimeString("he-IL", { hour: "2-digit", minute: "2-digit" })}`;
|
||||
return `איפוס ${formatTime(iso)}`;
|
||||
}
|
||||
|
||||
function UsageMeter({ label, w }: { label: string; w?: UsageWindow | null }) {
|
||||
@@ -196,8 +198,9 @@ function nextSaturday18(): Date {
|
||||
}
|
||||
const HE_DAYS = ["א׳", "ב׳", "ג׳", "ד׳", "ה׳", "ו׳", "ש׳"];
|
||||
function fmtDeadline(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return `${HE_DAYS[d.getDay()]} ${pad2(d.getDate())}/${pad2(d.getMonth() + 1)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
const p = israelParts(iso);
|
||||
if (!p) return "—";
|
||||
return `${HE_DAYS[p.weekday]} ${pad2(p.day)}/${pad2(p.month)} ${pad2(p.hour)}:${pad2(p.minute)}`;
|
||||
}
|
||||
|
||||
function BurstControl({ s }: { s: OpsService }) {
|
||||
@@ -673,11 +676,11 @@ function RunLogDialog({ run, onClose }: { run: AgentRun | null; onClose: () => v
|
||||
) : error ? (
|
||||
<p className="text-sm text-destructive">שגיאה בטעינת הלוג: {String(error)}</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[60vh] rounded-md border border-rule-soft bg-rule-soft/20 p-3">
|
||||
<pre dir="ltr" className="text-[0.72rem] leading-relaxed whitespace-pre-wrap break-words text-navy text-start">
|
||||
<div className="h-[60vh] w-full overflow-y-auto overflow-x-hidden rounded-md border border-rule-soft bg-rule-soft/20 p-3">
|
||||
<pre dir="ltr" className="w-full min-w-0 max-w-full overflow-x-hidden text-[0.72rem] leading-relaxed whitespace-pre-wrap break-all text-navy text-start">
|
||||
{text || "אין פלט עדיין."}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -802,6 +805,154 @@ function LiveAgentsPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Agent health taxonomy (#222) + recent escalations (#218) ───────────────
|
||||
const HEALTH_LABEL: Record<AgentHealthState, string> = {
|
||||
zombie: "זומבי",
|
||||
stalled: "תקוע",
|
||||
working: "עובד",
|
||||
idle: "רגוע",
|
||||
};
|
||||
|
||||
function AgentHealthPanel() {
|
||||
const { data, isLoading } = useAgentHealth();
|
||||
const { data: esc } = useRecentEscalations();
|
||||
|
||||
const counts = data?.counts;
|
||||
// Only zombies/stalled are "problems" — the backend already sorts worst-first.
|
||||
// An escalated issue is chair-owned, so it leaves this list; the escalations
|
||||
// card below is the handled-loop history.
|
||||
const problems = (data?.items ?? []).filter(
|
||||
(i) => i.health === "zombie" || i.health === "stalled",
|
||||
);
|
||||
const healthyWorking = counts?.working ?? 0;
|
||||
const healthyIdle = counts?.idle ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
{isLoading || !data ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<StatTile
|
||||
label="זומבי"
|
||||
value={counts?.zombie ?? 0}
|
||||
tone="red"
|
||||
title="לולאת-recovery — משויך+פעיל-כביכול אך אין ריצה חיה. מוסלם אוטומטית."
|
||||
/>
|
||||
<StatTile
|
||||
label="תקוע"
|
||||
value={counts?.stalled ?? 0}
|
||||
tone="amber"
|
||||
title="ננער חוזר בלי התקדמות"
|
||||
/>
|
||||
<StatTile label="עובד" value={counts?.working ?? 0} tone="green" />
|
||||
<StatTile label="רגוע" value={counts?.idle ?? 0} tone="muted" />
|
||||
</div>
|
||||
|
||||
{problems.length === 0 ? (
|
||||
<p className="text-sm text-emerald-600">
|
||||
אין סוכן תקוע כרגע — כל הסוכנים תקינים.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{problems.map((p) => (
|
||||
<div
|
||||
key={p.issue_id}
|
||||
className={
|
||||
p.health === "zombie"
|
||||
? "flex items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2"
|
||||
: "flex items-center justify-between gap-3 rounded-md border border-gold/40 bg-gold-wash px-3 py-2"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<Badge
|
||||
variant={p.health === "zombie" ? "destructive" : "outline"}
|
||||
className={
|
||||
p.health === "stalled"
|
||||
? "border-warn/50 text-warn font-normal"
|
||||
: "font-normal"
|
||||
}
|
||||
>
|
||||
{HEALTH_LABEL[p.health]}
|
||||
</Badge>
|
||||
<span dir="ltr" className="font-mono text-[0.82rem] text-navy font-semibold">
|
||||
{p.identifier}
|
||||
</span>
|
||||
<span className="text-[0.82rem] text-navy font-semibold">
|
||||
{p.agent_name}
|
||||
</span>
|
||||
<span className="text-[0.7rem] text-ink-muted">
|
||||
{p.recovery_wakeups > 0
|
||||
? `${p.recovery_wakeups} יקיצות-שחזור`
|
||||
: `${p.wakeups} יקיצות`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[0.7rem] text-ink-muted shrink-0">
|
||||
{p.health === "zombie" ? "יוסלם אוטומטית" : "מנוטר"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-[0.78rem] text-ink-muted">
|
||||
<b className="text-navy">{healthyWorking + healthyIdle} סוכנים תקינים</b> —{" "}
|
||||
{healthyWorking} עובדים, {healthyIdle} רגועים. מוצגות רק בעיות.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* handled-loop history — the watchdog's escalations, gold-wash like a gate */}
|
||||
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
||||
<CardContent className="px-5 py-4">
|
||||
<h3 className="text-navy text-sm font-semibold mb-0.5">הסלמות אוטומטיות אחרונות</h3>
|
||||
<p className="text-ink-muted text-[0.72rem] mb-3">
|
||||
כל זומבי מתמשך (לולאת-recovery ≥2 יקיצות-שחזור) משויך אליך אוטומטית עם הערת-מערכת
|
||||
— בלי להעיר סוכן מחדש.
|
||||
</p>
|
||||
{!esc?.items?.length ? (
|
||||
<p className="text-sm text-ink-muted">אין הסלמות אחרונות.</p>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
{esc.items.map((e) => (
|
||||
<div
|
||||
key={`${e.issue_id}-${e.created_at ?? ""}`}
|
||||
className="flex items-start gap-2.5 border-t border-gold/15 first:border-0 pt-2 first:pt-0"
|
||||
>
|
||||
<Badge
|
||||
variant={e.severity === "medium" ? "outline" : "destructive"}
|
||||
className={
|
||||
e.severity === "medium"
|
||||
? "border-gold/50 text-gold-deep font-normal shrink-0"
|
||||
: "font-normal shrink-0"
|
||||
}
|
||||
>
|
||||
{e.severity}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span dir="ltr" className="font-mono text-[0.8rem] text-navy font-semibold">
|
||||
{e.identifier}
|
||||
</span>
|
||||
<div className="text-[0.78rem] text-ink-soft">{e.reason}</div>
|
||||
</div>
|
||||
<span className="text-[0.68rem] text-ink-muted shrink-0">
|
||||
{e.created_at ? ago(Date.parse(e.created_at)) : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
const { data, isLoading, error } = useOperations();
|
||||
|
||||
@@ -840,6 +991,9 @@ export default function OperationsPage() {
|
||||
<SectionHeader>סוכנים פעילים</SectionHeader>
|
||||
<LiveAgentsPanel />
|
||||
|
||||
<SectionHeader>בריאות-הסוכנים</SectionHeader>
|
||||
<AgentHealthPanel />
|
||||
|
||||
<SectionHeader>מתאמי-סוכנים</SectionHeader>
|
||||
<AgentAdaptersPanel />
|
||||
|
||||
|
||||
@@ -69,8 +69,10 @@ export default function HomePage() {
|
||||
{/* KPI row — mockup 04 .kpis (4-up, gold-washed "ממתינים לאישור") */}
|
||||
<KPICards cases={data} loading={isPending} />
|
||||
|
||||
{/* two-column body — main flow + narrow gold gate rail (mockup 04 .cols) */}
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||
{/* two-column body — main flow + narrow gold gate rail (mockup 04 .cols).
|
||||
Rail trimmed 360→280 so the cases table gets the width it needs and
|
||||
no longer needs a horizontal scrollbar (chair request). */}
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
|
||||
<div className="space-y-6 min-w-0">
|
||||
{/* "תיקים לפי סטטוס" (פסים אופקיים) הוסר — פיזור-הסטטוסים מוצג
|
||||
בדונאט "פיזור סטטוסים" בטור-הצד (#1). */}
|
||||
|
||||
@@ -236,7 +236,11 @@ export default function PrecedentDetailPage({
|
||||
|
||||
{/* side rail — citations + corroboration */}
|
||||
<div className="space-y-4">
|
||||
<RelatedCasesSection caseId={id} related={data.related_cases ?? []} />
|
||||
<RelatedCasesSection
|
||||
caseId={id}
|
||||
related={data.related_cases ?? []}
|
||||
incoming={data.incoming_citations ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user