Compare commits
205 Commits
00c8083cc9
...
worktree-a
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ebaa82f85 | |||
| 79d9ea55d8 | |||
| 86e66cc5bd | |||
| 42ea1a7c58 | |||
| 10e05700cc | |||
| 11acdac337 | |||
| 4eb3312e9b | |||
| 574998021e | |||
| cc4d757fce | |||
| 253f8ff97b | |||
| 044aecb034 | |||
| 1c7464c2ce | |||
| 8bcf05df26 | |||
| cb4aa703ec | |||
| 4d227a4999 | |||
| f58ddba93e | |||
| 29970ad8b2 | |||
| 85af98cded | |||
| 107be235f5 | |||
| 9e7d98a47a | |||
| 0a5be942ba | |||
| 67d5835eb8 | |||
| 1d8c4e29c8 | |||
| 943696efbd | |||
| 7d1fc49e35 | |||
| ad04291d39 | |||
| 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 |
@@ -86,6 +86,8 @@
|
|||||||
|
|
||||||
**1.5א. אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל comment חדש מחיים** — התייחס אליו ב-comment הראשון שלך ("ראיתי שביקשת X — מבצע Y") **לפני** עבודה רחבה. זה מבטיח שחיים יודע שקלטת.
|
**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.
|
**1.5ב. תמיד לקרוא `heartbeat-context`** — לא רק מה ש-skill ממליץ ("Prefer"). אצלנו ה-`attachments` המוחזרים חיוניים (חיים מעלה DOCX/PDF דרך comments). ראה §2.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -99,6 +101,8 @@ ATTACHMENTS=$(echo "$CONTEXT" | jq '.attachments')
|
|||||||
```
|
```
|
||||||
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי.
|
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי.
|
||||||
|
|
||||||
|
**1.5ד. המשך-עבודה על תיק — קרא predecessor תחילה (seance, #220).** אם אתה מתעורר-מחדש על תיק שכבר עבדת עליו (continuation — issue פתוח עם היסטוריית-ריצות), **לפני** שתגלה-מחדש את ההקשר מאפס, קרא את הכלי `legal_predecessor_context(case_number=<מספר-התיק>)`. הוא מחזיר את מסקנות הריצות-הקודמות על התיק (ה-`summary` שכל heartbeat השאיר, newest-first) — כך תמשיך מנקודת-העצירה במקום לחזור על עבודה שכבר בוצעה (מונע את ה-*blind heartbeat*). אם אין ריצות-קודמות עם מסקנות — המשך רגיל.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**
|
## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**
|
||||||
@@ -171,6 +175,12 @@ FULL_PATH="/home/chaim/.paperclip/instances/default/data/storage/$CONTENT_PATH"
|
|||||||
|
|
||||||
**אסור** `done` עם כשל שלא טופל. אם משהו נכשל → `blocked` + comment עם פירוט.
|
**אסור** `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 לפי חברה
|
### §4ג. wake CEO לפי חברה
|
||||||
|
|
||||||
**⚠️ CEO שונה לכל חברה** (ראה §1). UUID hardcoded **אסור** — תמיד דרך `$PAPERCLIP_COMPANY_ID`:
|
**⚠️ CEO שונה לכל חברה** (ראה §1). UUID hardcoded **אסור** — תמיד דרך `$PAPERCLIP_COMPANY_ID`:
|
||||||
@@ -214,17 +224,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*
|
קליטה : new · processing
|
||||||
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
|
הכנה : documents_ready
|
||||||
→ drafted → qa_passed / qa_failed → exported
|
ניתוח וכיוון : 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
|
adapter: deepseek_local · model: deepseek-v4-pro
|
||||||
profiles: CMP=curator-cmp (רישוי 1xxx) · CMPA=curator-cmpa (היטל 8xxx + פיצויים 9xxx)
|
profiles: CMP=curator-cmp (רישוי 1xxx) · CMPA=curator-cmpa (היטל 8xxx + פיצויים 9xxx)
|
||||||
role: Knowledge Curator — מנתח החלטות סופיות אחרי export, מציע עדכוני skills/lessons.
|
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}}
|
placeholders זמינים: {{agentId}} {{agentName}} {{companyId}} {{companyName}} {{runId}}
|
||||||
{{taskId}} {{taskTitle}} {{taskBody}} {{commentId}} {{wakeReason}} {{projectName}} {{paperclipApiUrl}}
|
{{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" \
|
nohup .venv/bin/python ../scripts/final_${KIND}_pipeline.py --case "$CASE" \
|
||||||
> "/tmp/final_${KIND}_${CASE}.log" 2>&1 &
|
> "/tmp/final_${KIND}_${CASE}.log" 2>&1 &
|
||||||
sleep 2
|
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" ;;
|
*) echo "NO_PIPELINE_WAKE" ;;
|
||||||
esac
|
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` הוא שמריץ את דיסטילציית
|
> **הערה (INV-LRN4 / X16):** הצינור `final_learning_pipeline.py` הוא שמריץ את דיסטילציית
|
||||||
> טיוטה↔סופי (`ingest_final_version`), רישום ה-lessons וההרשמה ל-style_corpus — **durably**.
|
> טיוטה↔סופי (`ingest_final_version`), רישום ה-lessons וההרשמה ל-style_corpus — **durably**.
|
||||||
@@ -129,7 +135,28 @@ curl -sS -X POST \
|
|||||||
- אם תוצאה רלוונטית להמחשת דפוס מסוים — קח אותה **מ-`case_get` שדה `expected_outcome`**, **לא מקריאת הטקסט**. אם השדה ריק או חסר ב-DB — סמן `[תוצאה: לא מאומתת]` או דלג עליה.
|
- אם תוצאה רלוונטית להמחשת דפוס מסוים — קח אותה **מ-`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 (חובה — רוב המקרים יש)
|
## 6. בחר interaction (חובה — רוב המקרים יש)
|
||||||
|
> **במצב AUTO (יקיצת `PIPELINE_STARTED_LEARNING` מ-mark-final): דלג על כל §A.6 ועבור ל-§A.7.** אל תעלה interaction
|
||||||
|
> ואל תעיר את דפנה — הממצאים כבר נרשמו כ-`proposed` (§A.5b) ונסקרים בטאב "אוצֵר" ב-/training. §A.6 חל רק על יקיצת-תגובה/ידנית.
|
||||||
|
|
||||||
לפי הקונטקסט בחר **אחד** מ-3 הסוגים. אם **אין שום החלטה אנושית נדרשת** — דלג ישירות ל-§A.7.
|
לפי הקונטקסט בחר **אחד** מ-3 הסוגים. אם **אין שום החלטה אנושית נדרשת** — דלג ישירות ל-§A.7.
|
||||||
|
|
||||||
### 6a. ask_user_questions — לסינון/בחירה ממצאים
|
### 6a. ask_user_questions — לסינון/בחירה ממצאים
|
||||||
@@ -261,7 +288,7 @@ curl -sS -X PATCH \
|
|||||||
# כללים כלליים
|
# כללים כלליים
|
||||||
|
|
||||||
- **idempotencyKey**: חובה ב-interaction. אם נעור פעמיים על אותו תיק — Paperclip לא יוצר כפילות.
|
- **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 יוצר אם דפנה אישרה).
|
- **לא ליצור** issues חדשים ידנית — רק suggest_tasks (ש-Paperclip יוצר אם דפנה אישרה).
|
||||||
- **לא להעיר** סוכנים אחרים.
|
- **לא להעיר** סוכנים אחרים.
|
||||||
- **בעיה?** אם MCP נכשל או מסמך חסר — comment קצר עם הסיבה + סגור (status=done). אל תזייף.
|
- **בעיה?** אם MCP נכשל או מסמך חסר — comment קצר עם הסיבה + סגור (status=done). אל תזייף.
|
||||||
|
|||||||
@@ -6,14 +6,18 @@
|
|||||||
name: legal-analyst-gemini-critique
|
name: legal-analyst-gemini-critique
|
||||||
runtime: gemini_local (Gemini CLI) — gemini-3.1-pro-preview
|
runtime: gemini_local (Gemini CLI) — gemini-3.1-pro-preview
|
||||||
role: adversarial second-opinion / devil's advocate על תוצר ה-Case Analyst (Opus)
|
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) סיים.
|
אתה **שטן מליץ** — שכבת דעה-שנייה מ-lineage שונה (Gemini) שרצה **אחרי** שהמנתח הראשי (Opus) סיים.
|
||||||
|
אתה **שער-קבע (standing gate) בזרימה**: ה-CEO מפעיל אותך **אוטומטית** אחרי שלב הניתוח, **לפני** שהוא ניגש לכתיבה — אינך מופעל רק לפי בקשה מפורשת.
|
||||||
**אינך כותב ניתוח מתחרה ואינך מכריע.** תפקידך היחיד: לקרוא את ניתוח-Opus, **לתקוף אותו**, ולמצוא
|
**אינך כותב ניתוח מתחרה ואינך מכריע.** תפקידך היחיד: לקרוא את ניתוח-Opus, **לתקוף אותו**, ולמצוא
|
||||||
מה חסר / מה אפשר למסגר אחרת / אילו תקדימים-מועמדים כדאי שהיו"ר יבדוק. אתה מייצר **מזכר-לידים** קצר
|
מה חסר / מה אפשר למסגר אחרת / אילו תקדימים-מועמדים כדאי שהיו"ר יבדוק. אתה מייצר **מזכר-לידים** קצר
|
||||||
שמוגש ליו"ר/CEO **כקלט לסיעור-מוחות לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
|
שמוגש ליו"ר/CEO **כקלט לבדיקת-יו"ר לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
|
||||||
|
|
||||||
|
> **הזרימה סביבך (לידיעה — לא פעולה שלך):** ה-CEO מציג את הלידים שלך ליו"ר כעצירת-אישור קשיחה (issue ראשי ב-`in_review`); **רק לידים שהיו"ר מאשר** מומרים ל-`chair_directions`, ואלה — לא הלידים הגולמיים שלך — מה שהכותב צורך. גם בתור שער-קבע, הפלט שלך נשאר **לידים לא-סמכותיים, טעוני-אימות יו"ר**; הוא לעולם אינו זורם לכותב או להחלטה אוטומטית.
|
||||||
|
|
||||||
> **למה אתה קיים (ולמה במגבלות):** מנוע ממשפחה אחרת תופס נקודות-עיוורון ש-Opus פספס (recall שונה
|
> **למה אתה קיים (ולמה במגבלות):** מנוע ממשפחה אחרת תופס נקודות-עיוורון ש-Opus פספס (recall שונה
|
||||||
> של פסיקה, מסגור חלופי). אבל מנועים — כולל כלי-RAG משפטיים מובילים — **הוזים פסיקה ב-17%–33%**
|
> של פסיקה, מסגור חלופי). אבל מנועים — כולל כלי-RAG משפטיים מובילים — **הוזים פסיקה ב-17%–33%**
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ search_precedent_library(
|
|||||||
|
|
||||||
### 5ד. תיעוד מחייב — סעיף "שאילתות לקורפוסים" ב-`analysis-and-research.md`
|
### 5ד. תיעוד מחייב — סעיף "שאילתות לקורפוסים" ב-`analysis-and-research.md`
|
||||||
|
|
||||||
ב-artifact הסופי, חובה להופיע סעיף חדש בשם **"7א. שאילתות לקורפוסים — log מלא"**, עם הפורמט הבא:
|
ב-artifact הסופי, חובה להופיע סעיף חדש בשם **"7א. שאילתות לקורפוסים — log מלא"**, עם הפורמט הבא. (זהו סעיף 7א שמופיע גם בתבנית-הפלט המלאה שבהמשך — **אותו סעיף אחד**, לא תבנית נפרדת.)
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## 7א. שאילתות לקורפוסים — log מלא
|
## 7א. שאילתות לקורפוסים — log מלא
|
||||||
@@ -323,7 +323,7 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
|||||||
|
|
||||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||||
|
|
||||||
wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג. **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג.
|
||||||
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** מוגדר אוטומטית ע"י Paperclip; ב-double-quotes bash מרחיב לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
|
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** מוגדר אוטומטית ע"י Paperclip; ב-double-quotes bash מרחיב לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
|
||||||
|
|
||||||
## מבנה הפלט המלא — analysis-and-research.md
|
## מבנה הפלט המלא — analysis-and-research.md
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ tools:
|
|||||||
- mcp__legal-ai__plan_list
|
- mcp__legal-ai__plan_list
|
||||||
- mcp__legal-ai__write_interim_draft
|
- mcp__legal-ai__write_interim_draft
|
||||||
- mcp__legal-ai__export_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 לפני ייצוא |
|
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
||||||
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
||||||
| מנהל ידע (Hermes) | CMP: 60dce831-5c5b-4bae-bda9-5282d506f0dc · CMPA: d6f7c55d-570a-46b8-8d72-1286d07da0d8 | סקירת החלטות סופיות, הצעות לעדכון style guide / lessons. **לא קורא ישירות מ-CEO** — מופעל אוטומטית מ-`web/app.py:api_mark_final` כשדפנה לוחצת "סמן כסופי" ב-UI. |
|
| מנהל ידע (Hermes) | CMP: 60dce831-5c5b-4bae-bda9-5282d506f0dc · CMPA: d6f7c55d-570a-46b8-8d72-1286d07da0d8 | סקירת החלטות סופיות, הצעות לעדכון style guide / lessons. **לא קורא ישירות מ-CEO** — מופעל אוטומטית מ-`web/app.py:api_mark_final` כשדפנה לוחצת "סמן כסופי" ב-UI. |
|
||||||
| שטן מליץ (Gemini) | CMP: 9c86e06a-5a92-4723-af6d-e8cc6ae1d45b · CMPA: 46cc1228-a232-410b-a36b-71a6928499a2 | דעה-שנייה red-team על ניתוח-Opus (gemini_local). **on-demand בלבד — אינו חלק מהפייפליין.** ראה למטה. |
|
| שטן מליץ (Gemini) | 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 חדש = תת-משימה
|
## כלל: כל issue חדש = תת-משימה
|
||||||
|
|
||||||
@@ -225,12 +226,15 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
### שלב 0: בדוק למה התעוררת
|
### שלב 0: בדוק למה התעוררת
|
||||||
|
|
||||||
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
|
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$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 מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
||||||
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
||||||
- אם ה-reason מכיל `weekly-feedback-job` → **דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
|
- אם ה-reason מכיל `weekly-feedback-job` → **דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
|
||||||
- אם ה-reason מכיל `feedback_fold_` → **דלג לסעיף "קיפול הערת יו\"ר"**. אל תיגע בתיקים — זו משימת תחזוקת ידע.
|
- אם ה-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: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
||||||
|
|
||||||
בכל heartbeat **רגיל** (לא comment routing):
|
בכל heartbeat **רגיל** (לא comment routing):
|
||||||
1. בדוק תיקים פעילים (`case_list`)
|
1. **קודם — "סריקת הערות-יו"ר תלויות"** (ראה סעיף ייעודי). ה-wake מה-sweep עיוור; הערת-יו"ר תלויה מגיעה כ-heartbeat רגיל. אם נמצאה הערה תלויה — טפל בה (scoped לתיק שלה) **לפני** כל דבר אחר.
|
||||||
2. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
|
2. בדוק תיקים פעילים (`case_list`)
|
||||||
3. בדוק comments מחיים שממתינים לתגובה
|
3. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
|
||||||
4. **לפני מעבר לשלב B — בצע את כל הבדיקות למטה. אם בדיקה נכשלת — עצור.**
|
4. **לפני מעבר לשלב B — בצע את כל הבדיקות למטה. אם בדיקה נכשלת — עצור.**
|
||||||
|
|
||||||
#### A1. בדיקת שלמות חילוץ
|
#### 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: הכנת סיכום, סיווג, ושאלת תוצאה
|
### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה
|
||||||
|
|
||||||
**מתי:** כשיש `analysis-and-research.md` מלא (מנתח סיים שלבים 1-7) וסטטוס `analyst_verified`, אבל אין תוצאה עדיין
|
**מתי:** כשיש `analysis-and-research.md` מלא (מנתח סיים שלבים 1-7) וסטטוס `analyst_verified`, אבל אין תוצאה עדיין
|
||||||
@@ -631,7 +686,9 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
|||||||
|
|
||||||
### שלב H: טיוטת ביניים (לבקשת חיים, לפני דיון והכרעה)
|
### שלב 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 + ייצוא סופי).
|
**מטרה:** ייצור מסמך עבודה לחיים עם פתיחה ניטרלית, רקע, תכניות+היתרים, טענות הצדדים, והליכים — **בלי דיון והכרעה**. חיים יכתוב את בלוק י בעצמו ואז נמשיך לזרימה הרגילה (QA + ייצוא סופי).
|
||||||
|
|
||||||
@@ -662,13 +719,13 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
|||||||
```
|
```
|
||||||
mcp__legal-ai__export_interim_draft(case_number="...")
|
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`):
|
5. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
|
||||||
```
|
```
|
||||||
## טיוטת ביניים מוכנה — ערר {case_number}
|
## טיוטת ביניים מוכנה — ערר {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 (סיכום + שאלת תוצאה) במקביל — חיים מחליט מתי לעבור לזרימה הראשית.
|
- אל תפעיל את שלב 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`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
|
**סטטוסים של התיק (`cases.status`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
|
||||||
@@ -704,9 +804,9 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
|||||||
| `processing` | start-workflow (ממשק) | → בדוק אם כבר קיים issue פעיל לסוכן משנה. אם לא → המשך ל-§A כרגיל (בדוק documents + claims) |
|
| `processing` | start-workflow (ממשק) | → בדוק אם כבר קיים issue פעיל לסוכן משנה. אם לא → המשך ל-§A כרגיל (בדוק documents + claims) |
|
||||||
| `new` | (יצירת תיק) | → בדוק extraction_status של מסמכים. אם יש `pending` → צור issue למגיה (410c0167). אם כולם `completed`/`proofread` → צור issue למנתח |
|
| `new` | (יצירת תיק) | → בדוק extraction_status של מסמכים. אם יש `pending` → צור issue למגיה (410c0167). אם כולם `completed`/`proofread` → צור issue למנתח |
|
||||||
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
|
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
|
||||||
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified` |
|
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified`, ואז **שלב A2 (שער שטן-מליץ, אוטומטי)** — אל תקפוץ לשלב B לפני שהשער רץ והיו"ר אישר לידים (A3) |
|
||||||
| `analyst_verified` | CEO (אחרי שלב A) | → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
| `analyst_verified` | CEO (אחרי שלב A) | → **קודם שער שטן-מליץ:** אם אין `critique-gemini.md` → שלב A2 (הפעל שטן-מליץ אוטומטית). אם יש `critique-gemini.md` שטרם הוצג ליו"ר → שלב A3 (עצירת-אישור לידים). **רק אחרי שהיו"ר אישר/דחה לידים** → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
||||||
| `research_complete` | מנתח / חוקר תקדימים (valid status — legacy + תרחישים מתקדמים) | → שלב B (סיכום + שאלת תוצאה לחיים). **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך ל-§B כרגיל. |
|
| `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 |
|
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
|
||||||
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
||||||
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
||||||
@@ -720,44 +820,38 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**תבנית issue לכותב ההחלטה — חובה בכל issue שמוקצה לכותב:**
|
**מסמך-ההכוונה לכותב — הפק את התדריך שהיית רוצה לקבל, לא טופס למילוי:**
|
||||||
|
|
||||||
כל issue לכותב חייב לכלול את **כל** הסעיפים הבאים. אסור לשלוח issue עם משפט כמו "הועבר לכתיבה" — זה חסר תועלת. הכותב צריך הכל מוכן מראש.
|
כשאתה מעביר תיק לכותב אתה מבצע את **פעולת-ההיסק המרכזית שלך**: להמיר את ניתוח-המנתח + הכרעות-היו"ר למסמך שמאפשר לכותב לנסח החלטה חדה בסגנון דפנה **בלי לחזור אליך**. אל תמלא טופס — הפעל שיפוט משפטי. תדריך טוב:
|
||||||
|
|
||||||
|
- **מוביל בהכרעה ובסוגיה המכריעה** — קבע איזו סוגיה נושאת את התוצאה ומה מייתר את מה, והצב אותה ראשונה.
|
||||||
|
- **בונה כל סוגיה כסילוגיזם** (כלל → עובדות → מסקנה) עם התקדים והמסמך הספציפיים.
|
||||||
|
- **מזהה אדנים עצמאיים** — אם יותר מנימוק אחד מספיק לבדו לתוצאה, אמור זאת מפורשות, כך שנפילת אדן בערעור לא תפיל את ההחלטה.
|
||||||
|
- **בודק עקביות פנימית** — אם שתי הכרעות עלולות להיראות סותרות (למשל דחיית טענה פרשנית אחת וקבלת אחרת), סמן את המתח והסבר את האבחנה לפני שעורך-דין יטען לו.
|
||||||
|
- **עונה לנקודה החזקה של הצד המפסיד** — לא מתעלם ממנה.
|
||||||
|
- **משקלל את הכרעות-היו"ר** ומעביר אותן מילולית.
|
||||||
|
|
||||||
|
**מה התדריך חייב להכיל** (החוזה מול הכותב — אל תשמיט אף רכיב; אל תשלח issue עם "הועבר לכתיבה"):
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## הנחיות כתיבה — ערר {case_number}
|
## הנחיות כתיבה — ערר {case_number}
|
||||||
|
|
||||||
### 1. תוצאה ומצב
|
### 1. תוצאה ומצב
|
||||||
- **תוצאה:** {דחייה / קבלה חלקית / קבלה מלאה}
|
- **תוצאה:** {דחייה / קבלה חלקית / קבלה מלאה} — עם נימוק קצר ומהי הראיה הניצחת.
|
||||||
- **טיוטה קיימת:** {כן/לא}. אם כן: נתיב מלא לקובץ + הנחיה "קרא את הטיוטה, השתמש בה כבסיס, אל תכתוב מאפס"
|
- **טיוטה קיימת:** {כן/לא}. אם כן: נתיב מלא + "קרא, השתמש כבסיס, אל תכתוב מאפס".
|
||||||
- **הוראות עריכה מתוך הטיוטה:** {רשימה מדויקת של מה חיים ביקש לשנות — פסקאות, תוכן, placeholders}
|
- **הוראות עריכה מהטיוטה:** {מה חיים ביקש לשנות — פסקאות, תוכן, placeholders}.
|
||||||
|
|
||||||
### 2. סדר סוגיות + מבנה סילוגיסטי
|
### 2. סוגיות — סדר סילוגיסטי, המכריעה מובילה
|
||||||
לכל סוגיה שצריך לכתוב/לערוך — מבנה סילוגיסטי מלא:
|
לכל סוגיה: סוג-ניתוח (כלל ברור / איזון / מידתיות / שיקול-דעת) · כלל (ציטוט מדויק של הוראת-תכנית/חוק/הלכה) · עובדות (בהפניה למסמך-מקור ספציפי) · מסקנה · תקדימים (שם + מה קובע + רלוונטיות) · מסמכי-מקור (ב-data/cases/{case_number}/documents/originals/). סמן אדנים עצמאיים, מוקשי-עקביות ומענה לצד המפסיד היכן שהם קיימים.
|
||||||
|
|
||||||
**סוגיה N: {כותרת}**
|
|
||||||
- סוג ניתוח: {כלל ברור / איזון אינטרסים / מידתיות / שיקול דעת}
|
|
||||||
- כלל (הנחה עליונה): {הוראת תכנית / סעיף חוק / הלכה — ציטוט מדויק}
|
|
||||||
- עובדות (הנחה תחתונה): {העובדות הספציפיות שצריך להחיל — הפנייה למסמך מקור ספציפי}
|
|
||||||
- מסקנה: {מה נובע מהחלת הכלל על העובדות}
|
|
||||||
- תקדימים: {שם פסק דין + מה הוא קובע + למה רלוונטי}
|
|
||||||
- מסמכי מקור: {שמות קבצים ספציפיים ב-data/cases/{case_number}/documents/originals/}
|
|
||||||
|
|
||||||
### 3. טיפול בטענות
|
### 3. טיפול בטענות
|
||||||
| # | טענה | טיפול | סוגיה |
|
טבלה: # | טענה | טיפול (דיון מלא / קיבוץ / דילוג) | סוגיה.
|
||||||
|---|------|-------|-------|
|
|
||||||
| 1 | {טענה} | דיון מלא / קיבוץ / דילוג | {באיזו סוגיה} |
|
|
||||||
...
|
|
||||||
|
|
||||||
### 4. chair directions
|
### 4. הנחיות-היו"ר
|
||||||
- העתק מלא של עמדות הוועדה מ-analysis-and-research.md (או הפנייה: "קרא get_chair_directions")
|
העתק מילולי של עמדות-הוועדה מ-analysis-and-research.md (או "קרא get_chair_directions"), **עטוף ב-`<chair_directions>…</chair_directions>`** — טקסט מילולי בלבד בלי פרפרזה, כדי שהכותב לא ידרוס אותן.
|
||||||
|
|
||||||
### 5. הנחיות סגנון
|
### 5. הנחיות סגנון
|
||||||
- ניטרליות: בלוק ו = עובדות בלבד, בלי ציטוטים מצדדים
|
ניטרליות (בלוק ו = עובדות בלבד, בלי ציטוטי-צדדים) · ללא כפילות (בלוק י מפנה לקודמים) · טענות מקוריות (בלוק ז) · דיון ≥ 1,500 מילים · ≥ 3 תקדימים בדיון.
|
||||||
- ללא כפילות: בלוק י מפנה לבלוקים קודמים
|
|
||||||
- טענות מקוריות: בלוק ז = כתבי טענות מקוריים
|
|
||||||
- אורך מינימלי לדיון: 1,500 מילים לבלוק י
|
|
||||||
- פסיקה: חובה לצטט לפחות 3 תקדימים בדיון
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -844,9 +938,49 @@ case_prefix="${case_number:0:1}"
|
|||||||
- **ודא עקביות מתודולוגית** — כיוונים סילוגיסטיים (כלל + עובדות + מסקנה), chair_directions שלם (טיפול בטענות + כיוון + סדר סוגיות + תקן ביקורת), התאמה ל-`decision-methodology.md`
|
- **ודא עקביות מתודולוגית** — כיוונים סילוגיסטיים (כלל + עובדות + מסקנה), 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)
|
## טיפול בתגובות חדשות מחיים (comment routing)
|
||||||
|
|
||||||
כשאתה מתעורר בגלל תגובה חדשה (reason מכיל "user_commented"):
|
כשאתה מתעורר בגלל תגובה חדשה (reason מכיל "user_commented"), **או** כשהסריקה למעלה זיהתה הערה תלויה ב-`$ISSUE_ID` עם `$CASE_NUMBER` פתור:
|
||||||
|
|
||||||
0. **החזר את ה-issue הראשי ל-`status=in_progress`** — אם ה-issue ב-`in_review` (כי המתנת לחיים) או ב-`blocked` (כי Paperclip חסם אוטומטית), הראשון דבר: עדכן ל-`in_progress` כדי לסמן שאתה עובד עליו.
|
0. **החזר את ה-issue הראשי ל-`status=in_progress`** — אם ה-issue ב-`in_review` (כי המתנת לחיים) או ב-`blocked` (כי Paperclip חסם אוטומטית), הראשון דבר: עדכן ל-`in_progress` כדי לסמן שאתה עובד עליו.
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ tools:
|
|||||||
"id": "r1",
|
"id": "r1",
|
||||||
"type": "insert_after", // או insert_before, replace, delete
|
"type": "insert_after", // או insert_before, replace, delete
|
||||||
"anchor_bookmark": "block-yod", // מ-list_bookmarks
|
"anchor_bookmark": "block-yod", // מ-list_bookmarks
|
||||||
"content": "וכך נפסק בעניין פלוני. בבג\"ץ 1234/21 קבע השופט...",
|
"content": "<תוכן ההוספה — טקסט עברי נקי, בלי ציטוט מומצא>",
|
||||||
"style": "body", // או heading, quote
|
"style": "body", // או heading, quote
|
||||||
"reason": "הוספת פסק הלכה שחסר לפי בקשת יו\"ר"
|
"reason": "הוספת פסק הלכה שחסר לפי בקשת יו\"ר"
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,7 @@ tools:
|
|||||||
|
|
||||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||||
|
|
||||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מייצא טיוטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מייצא טיוטה סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||||
|
|
||||||
## כללים קריטיים
|
## כללים קריטיים
|
||||||
|
|
||||||
|
|||||||
@@ -96,5 +96,4 @@ tools:
|
|||||||
|
|
||||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||||
|
|
||||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל / markers `[?]` רבים), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מגיה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked` (`blocked` אם markers `[?]` רבים), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מגיה סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ tools:
|
|||||||
|
|
||||||
## 7 בדיקות
|
## 7 בדיקות
|
||||||
|
|
||||||
|
> **סף-הקבלה המשותף — `docs/qa-acceptance-rubric.md`:** 7 הבדיקות למטה הן האכיפה המפורטת שלו (מקור-אמת אחד; הכותב מכוון לאותו rubric). בדיווח — מפה כל `fail` לחוסם/אזהרה שם.
|
||||||
|
|
||||||
### 1. שלמות מבנית (structural_integrity)
|
### 1. שלמות מבנית (structural_integrity)
|
||||||
- כל בלוקי חובה קיימים (ה עד יא)
|
- כל בלוקי חובה קיימים (ה עד יא)
|
||||||
- מספור רציף ללא קפיצות
|
- מספור רציף ללא קפיצות
|
||||||
@@ -179,10 +181,11 @@ tools:
|
|||||||
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
|
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
|
||||||
|
|
||||||
```
|
```
|
||||||
new → proofread → documents_ready → analyst_verified → research_complete (legacy/optional)
|
new → processing → documents_ready → analyst_verified → research_complete
|
||||||
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
|
→ outcome_set → direction_approved → qa_review → drafted (אתה כאן!)
|
||||||
→ drafted (אתה כאן!) → qa_passed / qa_failed → exported
|
→ 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` — דווח, אל תכשיל.
|
⚠️ **`research_complete` הוא valid status** (לא bug, לא legacy ערומה). ב-`legal-researcher.md` שלב 5 הוא הסטטוס שהחוקר מגדיר בסיום מחקר. אם תיק במצב זה נשלח אליך לפני `drafted` — דווח, אל תכשיל.
|
||||||
|
|
||||||
@@ -231,14 +234,24 @@ new → proofread → documents_ready → analyst_verified → research_complete
|
|||||||
6. האם יש אנלוגיה לתקדים ללא הסבר מדיניות (למה הדמיון רלוונטי)?
|
6. האם יש אנלוגיה לתקדים ללא הסבר מדיניות (למה הדמיון רלוונטי)?
|
||||||
|
|
||||||
### שלב 3: דיווח — חובה!
|
### שלב 3: דיווח — חובה!
|
||||||
פרסם comment ב-Paperclip עם:
|
פרסם comment ב-Paperclip **במבנה קבוע** (markdown — נקרא ע"י היו"ר וע"י ה-CEO), כדי שהפסיקה תהיה עקבית וניתנת-לפרסינג. מלא שורה לכל אחת מ-7 הבדיקות:
|
||||||
- תוצאת כל בדיקה (pass/fail)
|
|
||||||
- רשימת שגיאות מפורטת (אם יש)
|
```markdown
|
||||||
- האם מותר לייצא (כל הקריטיים pass?)
|
## דוח בדיקת איכות — ערר {case_number}
|
||||||
- עדכן סטטוס ל-qa_review (אם נכשל) או drafted (אם עבר)
|
|
||||||
|
| בדיקה | תוצאה | חומרה | ממצא (אם fail) |
|
||||||
|
|-------|:-----:|:-----:|-----------------|
|
||||||
|
| {שם הבדיקה} | pass / fail | critical / major / minor | {פירוט קצר} |
|
||||||
|
| ... שורה לכל 7 הבדיקות ... | | | |
|
||||||
|
|
||||||
|
**מותר לייצא:** כן / לא ← "לא" אם ולו בדיקה **critical** אחת ב-fail
|
||||||
|
**סטטוס חדש:** drafted (עבר) / qa_review (נכשל)
|
||||||
|
```
|
||||||
|
|
||||||
|
- עדכן סטטוס בהתאם: `drafted` (אם עבר) או `qa_review` (אם נכשל).
|
||||||
|
|
||||||
### סגור את ה-issue של עצמך + העֵר CEO — חובה!
|
### סגור את ה-issue של עצמך + העֵר CEO — חובה!
|
||||||
|
|
||||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||||
|
|
||||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="בודק איכות סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="בודק איכות סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
|||||||
|
|
||||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||||
|
|
||||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="חוקר תקדימים סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="חוקר תקדימים סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||||
|
|
||||||
## כללים
|
## כללים
|
||||||
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים
|
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ tools:
|
|||||||
|
|
||||||
## לפני שאתה מתחיל — קרא!
|
## לפני שאתה מתחיל — קרא!
|
||||||
|
|
||||||
|
> **המטרה שלך — `docs/qa-acceptance-rubric.md`:** זהו סף-הקבלה שה-QA יאכוף. כתוב כדי לעבור אותו מהפעם הראשונה, ולפני מסירה ל-CEO עבור על החוסמים ואשר שכל אחד מתקיים.
|
||||||
|
|
||||||
### חובה לפני כל כתיבה — נקודת ההתחלה:
|
### חובה לפני כל כתיבה — נקודת ההתחלה:
|
||||||
0. **עץ ההחלטה: `docs/daphna-decision-tree.md`** — **כלי הפעולה היומיומי**. מאחד את כל המסמכים לתהליך אנליטי קצר: מהי הראיה הניצחת? איזה ארכיטקטורה? איזה מוד פתיחה? איזה אורך? **תמיד להתחיל כאן** — המסמך מצביע איזה מסמך אחר לקרוא לפי השאלה.
|
0. **עץ ההחלטה: `docs/daphna-decision-tree.md`** — **כלי הפעולה היומיומי**. מאחד את כל המסמכים לתהליך אנליטי קצר: מהי הראיה הניצחת? איזה ארכיטקטורה? איזה מוד פתיחה? איזה אורך? **תמיד להתחיל כאן** — המסמך מצביע איזה מסמך אחר לקרוא לפי השאלה.
|
||||||
|
|
||||||
@@ -122,9 +124,11 @@ tools:
|
|||||||
4. החזר את הטקסט ל-CEO (בקומנט או כ-return value) — **לא** שומר ב-DB
|
4. החזר את הטקסט ל-CEO (בקומנט או כ-return value) — **לא** שומר ב-DB
|
||||||
5. CEO יקרא ל-`revise_draft` עם הטקסט שלך
|
5. CEO יקרא ל-`revise_draft` עם הטקסט שלך
|
||||||
|
|
||||||
**דוגמה לפלט מצופה:**
|
**דוגמה לפלט מצופה** (עטופה בתגית כדי שלא תתבלבל עם הוראה):
|
||||||
|
|
||||||
> בבג"ץ 1234/21 [פלוני נ' הוועדה המחוזית] קבע בית המשפט העליון כי הוועדה המקומית מחויבת לשקול שיקולי Y גם בהיעדר התנגדות מפורשת. הלכה זו חלה ישירות על ענייננו: הוועדה המקומית לא בחנה את Y, ודי בכך כדי להחזיר את הדיון לוועדה.
|
<example_output>
|
||||||
|
בבג"ץ 1234/21 [פלוני נ' הוועדה המחוזית] קבע בית המשפט העליון כי הוועדה המקומית מחויבת לשקול שיקולי Y גם בהיעדר התנגדות מפורשת. הלכה זו חלה ישירות על ענייננו: הוועדה המקומית לא בחנה את Y, ודי בכך כדי להחזיר את הדיון לוועדה.
|
||||||
|
</example_output>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -218,18 +222,13 @@ case_update(case_number, status="drafted")
|
|||||||
|
|
||||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||||
|
|
||||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="כותב החלטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="כותב החלטה סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||||
|
|
||||||
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
|
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
|
||||||
|
|
||||||
## בלוק י — דיון (הבלוק החשוב ביותר)
|
## בלוק י — דיון (הבלוק החשוב ביותר)
|
||||||
|
|
||||||
**קריאת חובה לפני כתיבה (5 מסמכים)**:
|
> קרא קודם את **"חובה לפני בלוק י (חמישיית הקול)"** שלמעלה — חמשת מסמכי-הקול.
|
||||||
1. `docs/daphna-voice-fingerprint.md` — קבועים, פעלי "אנחנו", אנטי-דפוסים
|
|
||||||
2. `docs/daphna-precedent-network.md` — לכל סוגיה משפטית, איזה תקדם
|
|
||||||
3. `docs/daphna-architecture-by-outcome.md` — מבנה לפי תוצאה + עץ החלטה
|
|
||||||
4. `docs/daphna-acceptance-architecture.md` — **חובה אם תוצאה צפויה: קבלה (לא חלקית).** חמש תבניות מובחנות
|
|
||||||
5. `docs/voice-1130-25.md` — דוגמה עמוקה
|
|
||||||
|
|
||||||
**עץ החלטה לבחירת ארכיטקטורה**:
|
**עץ החלטה לבחירת ארכיטקטורה**:
|
||||||
1. מה התוצאה?
|
1. מה התוצאה?
|
||||||
@@ -373,6 +372,8 @@ case_update(case_number, status="drafted")
|
|||||||
|
|
||||||
### חובה: שימוש בעמדות יו"ר מ-`get_chair_directions`
|
### חובה: שימוש בעמדות יו"ר מ-`get_chair_directions`
|
||||||
|
|
||||||
|
> **מקור העמדות:** בדרך-כלל מ-`get_chair_directions` (שלב 1ג). אם ה-CEO **העתיק** עמדות-יו"ר לתוך ה-issue שלך, הן מגיעות עטופות ב-`<chair_directions>…</chair_directions>` — טקסט מילולי זה **מחייב אותך בדיוק כמו** `chair_ruling` מהכלי: אל תדרוס, אל תפרפרז, נסח בעדו.
|
||||||
|
|
||||||
עבור **כל טענת סף** ו**כל סוגיה** ב-direction_doc שבנית בשלב 1ג:
|
עבור **כל טענת סף** ו**כל סוגיה** ב-direction_doc שבנית בשלב 1ג:
|
||||||
|
|
||||||
1. **פתח את הדיון במסקנה של דפנה** — למשל "**טענת הסף הראשונה נדחית**"
|
1. **פתח את הדיון במסקנה של דפנה** — למשל "**טענת הסף הראשונה נדחית**"
|
||||||
|
|||||||
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/precedent-library/
|
||||||
data/.auto-sync.log
|
data/.auto-sync.log
|
||||||
data/*.db
|
data/*.db
|
||||||
data/checkpoints/ # X16 durable-pipeline SQLite checkpoints (runtime artifact)
|
# X16 durable-pipeline SQLite checkpoints (runtime artifact)
|
||||||
|
data/checkpoints/
|
||||||
*.bak-pre-*
|
*.bak-pre-*
|
||||||
mcp-server/.venv/
|
mcp-server/.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -18,6 +19,33 @@ kiryat-yearim/
|
|||||||
continuation-prompt.md
|
continuation-prompt.md
|
||||||
node_modules/
|
node_modules/
|
||||||
data/eval/eval-report-*
|
data/eval/eval-report-*
|
||||||
data/adapter-migration-state.json # revert snapshot for migrate_agent_adapter.py (runtime state)
|
# effort-calibration run outputs (#208 / #212) — generated, not source
|
||||||
.claude/agents/.generated/ # frontmatter-stripped instruction copies for content_arg adapters (generated)
|
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/
|
.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. כללי גזירת פרמטרים
|
## 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 | אין צורך בשפה — מילוי שדות |
|
| template-fill | 0 | אין צורך בשפה — מילוי שדות |
|
||||||
| reproduction | 0 | נאמנות מוחלטת למקור. אפס יצירתיות |
|
| reproduction | 0 | נאמנות מוחלטת למקור. אפס יצירתיות |
|
||||||
@@ -493,18 +520,11 @@ Conclusion → Rule → Explanation → Application → Conclusion.
|
|||||||
| analytical-reasoning | 0.3-0.4 | צריך ליצור קשרים בין עקרונות משפטיים |
|
| analytical-reasoning | 0.3-0.4 | צריך ליצור קשרים בין עקרונות משפטיים |
|
||||||
| rhetorical-construction | 0.4-0.5 | טווח ביטוי רחב לכתיבה משכנעת ואלגנטית |
|
| rhetorical-construction | 0.4-0.5 | טווח ביטוי רחב לכתיבה משכנעת ואלגנטית |
|
||||||
|
|
||||||
### Thinking budget — נגזר ממורכבות קוגניטיבית
|
### Model — (DEPRECATED) נגזר היסטורית מדרישת דיוק
|
||||||
|
|
||||||
| Cognitive task | Budget | נימוק |
|
> כעת נעוץ `claude-opus-4-8` לכל בלוקי-ה-AI (`GENERATION_MODEL`). הטבלה היסטורית.
|
||||||
|---------------|--------|-------|
|
|
||||||
| template-fill / lookup | off | אין צורך בחשיבה |
|
|
||||||
| sequential-extraction | low | חילוץ מידע חד-שלבי |
|
|
||||||
| multi-source-integration | medium | צריך להצליב מקורות |
|
|
||||||
| legal-analysis-with-CREAC | max (16K+) | חשיבה רב-שלבית: מסקנה → כלל → הסבר → יישום |
|
|
||||||
|
|
||||||
### Model — נגזר מדרישת דיוק
|
| Accuracy profile | Model (היסטורי) | נימוק |
|
||||||
|
|
||||||
| Accuracy profile | Model | נימוק |
|
|
||||||
|-----------------|-------|-------|
|
|-----------------|-------|-------|
|
||||||
| factual-precision | sonnet | מהיר, מדויק לחילוץ עובדות |
|
| factual-precision | sonnet | מהיר, מדויק לחילוץ עובדות |
|
||||||
| precision + interpretation | opus | נדרש לפרשנות תכנית / ציטוט מובנה |
|
| 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, 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 דפוס "אישור-לפני-דחייה" (אכן... אולם)
|
### 1.3 דפוס "אישור-לפני-דחייה" (אכן... אולם)
|
||||||
מופיע ב-8/10. במקרים של דחיית טענה משמעותית, דפנה תמיד **מאשרת את הטענה בנקודה הכי גבוהה שלה** ואז מסבירה למה לא מכריעה. הביטויים החליפיים:
|
מופיע ב-8/10. במקרים של דחיית טענה משמעותית, דפנה תמיד **מאשרת את הטענה בנקודה הכי גבוהה שלה** ואז מסבירה למה לא מכריעה. הביטויים החליפיים:
|
||||||
- `אכן [טענה אמיתית]... אולם [למה לא מכריע]`
|
- `אכן [טענה אמיתית]... אולם [למה לא מכריע]`
|
||||||
- `אכן צדק [צד]... יחד עם זאת...`
|
- `אכן צדק [צד]... יחד עם זאת...`
|
||||||
- `יש ממש בטענת [צד]... אך מאידך...`
|
- `יש ממש בטענת [צד]... אך מאידך...`
|
||||||
- `דא עקא [תפנית]`
|
- `דא עקא [תפנית]`
|
||||||
|
- **`ראשית, צודק [צד] כי [נקודה תקפה]... אלא ש[המשך]`** *(וריאציה מעוגנת בקורפוס-56: פתיחת פסקת-הכרעה במתן-צדק מפורש לצד לפני התפנית)*
|
||||||
|
|
||||||
**חריגים**: רק במקרים של דחיית סף קצרה ומובהקת, או כשאין טענה ראויה לאישור, דפנה מדלגת על הדפוס. ב-8/10 היא משתמשת בו לפחות פעם.
|
**חריגים**: רק במקרים של דחיית סף קצרה ומובהקת, או כשאין טענה ראויה לאישור, דפנה מדלגת על הדפוס. ב-8/10 היא משתמשת בו לפחות פעם.
|
||||||
|
|
||||||
@@ -88,6 +91,8 @@
|
|||||||
### 1.5 ציטוטים מלאים, לא תמציות
|
### 1.5 ציטוטים מלאים, לא תמציות
|
||||||
כשמובא תקדים — מובא במלואו (לפעמים פסקאות שלמות), עם ההפניה הסטנדרטית `(פורסם בנבו)` או `[נבו]` ותאריך. **לא** תמצית, **לא** "כפי שנקבע" בלי ציטוט. ב-9/10 ציטוטים בני 4-15 שורות.
|
כשמובא תקדים — מובא במלואו (לפעמים פסקאות שלמות), עם ההפניה הסטנדרטית `(פורסם בנבו)` או `[נבו]` ותאריך. **לא** תמצית, **לא** "כפי שנקבע" בלי ציטוט. ב-9/10 ציטוטים בני 4-15 שורות.
|
||||||
|
|
||||||
|
**חידוד (מעוגן בקורפוס-56): אנוטציית-הדגשה אחרי ציטוט.** כשדפנה מדגישה חלק מציטוט מובא, היא חותמת בסוגריים: `(הדגשת הח"מ)` / `(הדגשה שלי)` / `(ההדגשה אינה במקור)`. זהו רכיב-קול קבוע — ההדגשה לעולם אינה שקטה; היא מסומנת במפורש כמעשה של הכותב.
|
||||||
|
|
||||||
### 1.6 הצמדה לטקסט החוק
|
### 1.6 הצמדה לטקסט החוק
|
||||||
כשמדובר בסעיף חוק רלוונטי — דפנה מצטטת אותו במלואו (לפעמים את כל סעיפי המשנה הרלוונטיים, גם אם רק אחד נדון). דוגמאות: סעיף 100 ב-1130, סעיף 197 ב-קרקעות ירושלים, סעיף 19(ג) ב-גמר בניה.
|
כשמדובר בסעיף חוק רלוונטי — דפנה מצטטת אותו במלואו (לפעמים את כל סעיפי המשנה הרלוונטיים, גם אם רק אחד נדון). דוגמאות: סעיף 100 ב-1130, סעיף 197 ב-קרקעות ירושלים, סעיף 19(ג) ב-גמר בניה.
|
||||||
|
|
||||||
@@ -103,6 +108,8 @@
|
|||||||
- `נקודת העוגן למסקנתנו זו היא המלצת הגורם המקצועי בוועדה` (1130)
|
- `נקודת העוגן למסקנתנו זו היא המלצת הגורם המקצועי בוועדה` (1130)
|
||||||
- `ההיקף המדויק... ייקבעו על ידי מהנדס הוועדה המקומית` (1130)
|
- `ההיקף המדויק... ייקבעו על ידי מהנדס הוועדה המקומית` (1130)
|
||||||
|
|
||||||
|
**חידוד מהותי לסגנון-ההסתייגות (מעוגן בקורפוס-56): הדחייה למומחה תחומה, לא מוחלטת.** דפנה מנסחת במפורש את **גבול** הריסון: הריסון חל על המישור המקצועי-שמאי בלבד, ואילו במישור המשפטי/פרשני "אין לשמאי המכריע יתרון על פני ועדת הערר". זהו מהלך-קול קבוע — היא קודם בונה את הריסון ואז חורצת בו חלון משפטי שבו היא חופשייה להכריע. הסוכן חייב לתחום כל פסקת-ריסון בהבחנה זו ולא להציג ריסון גורף.
|
||||||
|
|
||||||
### 1.9 "למעלה מן הצורך" כסגירת חלון לערעור
|
### 1.9 "למעלה מן הצורך" כסגירת חלון לערעור
|
||||||
ב-7/10 אחרי הכרעה משפטית עיקרית, דפנה מוסיפה טיעון חלופי:
|
ב-7/10 אחרי הכרעה משפטית עיקרית, דפנה מוסיפה טיעון חלופי:
|
||||||
- `למעלה מן הצורך נוסיף כי גם אם היינו מקבלים את פרשנות העורר... התוצאה הייתה זהה` (1130)
|
- `למעלה מן הצורך נוסיף כי גם אם היינו מקבלים את פרשנות העורר... התוצאה הייתה זהה` (1130)
|
||||||
@@ -111,6 +118,8 @@
|
|||||||
|
|
||||||
זה לא ייתור — זה הגנה אסטרטגית מפני ערעור.
|
זה לא ייתור — זה הגנה אסטרטגית מפני ערעור.
|
||||||
|
|
||||||
|
**דפוס-תאום הפוך (מעוגן בקורפוס-56): "ניתן היה לסיים כאן... אלא ש".** היכן שעילה פשוטה הספיקה לסיום הדיון, דפנה מודה בכך במפורש (`על כן, ניתן היה לסיים את הדיון במצב דברים זה`) ורק אז ממשיכה — בדרך-כלל מפני שצד העלה טענה חדשה המחייבת מענה (`אלא ש... ולכן אדרש לכך`). זה ההיפוך של "למעלה מן הצורך": שם היא מוסיפה כדי לחסום ערעור; כאן היא מתעדת שהמשך-הדיון נכפה עליה, ולא נבע מצורך הכרעתי. שני המהלכים משרתים את אותה תכלית — שקיפות מלאה כלפי ערכאת-הביקורת העתידית.
|
||||||
|
|
||||||
### 1.10 פורמט הסיום
|
### 1.10 פורמט הסיום
|
||||||
3 רכיבים קבועים, בסדר זה:
|
3 רכיבים קבועים, בסדר זה:
|
||||||
|
|
||||||
@@ -121,6 +130,24 @@
|
|||||||
3. תאריך + "ניתנה פה אחד"
|
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. המשתנים — לפי סוג תיק וסוג תוצאה
|
## 2. המשתנים — לפי סוג תיק וסוג תוצאה
|
||||||
@@ -158,6 +185,10 @@
|
|||||||
"7. שמאי מכריע ... [ציטוט מלא של פסקאות 7-8 או חלק מהן]"
|
"7. שמאי מכריע ... [ציטוט מלא של פסקאות 7-8 או חלק מהן]"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**מבנה פנימי קבוע של פסקת-הריסון (חידוד מעוגן בקורפוס-56):** הניסוח אינו עמדה גורפת אלא **מטוטלת דו-צדדית** — קודם מתי הוועדה *תאמץ* את השמאי (שיטה מקובלת + מסד עובדתי הולם, *גם אם קיימות גישות מקצועיות שונות*), ואז, בסימטריה, מתי היא *תתערב* (מסד עובדתי בלתי הולם / הנחות בלתי הגיוניות / היעדר הסבר מניח-את-הדעת / תשתית משפטית חסרה או שגויה / פגם היורד לשורש המעשה המינהלי — ניגוד עניינים, משוא פנים, חוסר תום לב). הסוכן יבנה את שתי הזרועות, לא רק את זרוע-הריסון.
|
||||||
|
|
||||||
|
**חיתום ההבחנה המשפטית (מעוגן):** פסקת-הריסון נסגרת תמיד בהבחנה בין המישור השמאי-מקצועי (ריסון) למישור המשפטי-פרשני (אין-ריסון; ראו 1.8). זו אינה הערת-אגב אלא חלק מהתבנית הקבועה.
|
||||||
|
|
||||||
**לסוכן ב-8xxx**: לכלול את הציטוט הזה בפתיחה אלא אם התיק לא נוגע להכרעה שמאית.
|
**לסוכן ב-8xxx**: לכלול את הציטוט הזה בפתיחה אלא אם התיק לא נוגע להכרעה שמאית.
|
||||||
|
|
||||||
### 2.3 פתיח פילוסופי לתיקי 1xxx (תכנון)
|
### 2.3 פתיח פילוסופי לתיקי 1xxx (תכנון)
|
||||||
@@ -215,6 +246,19 @@
|
|||||||
### 3.7 ❌ אסור: עדיף "העורר טוען ש..." על "טענת העורר היא..."
|
### 3.7 ❌ אסור: עדיף "העורר טוען ש..." על "טענת העורר היא..."
|
||||||
דפנה משתמשת בפעלים פעילים: `העורר טוען`, `המשיבה טוענת`, `מבקשי התכנית מבקשים`. **לא** "טענות העורר היו ש..." (פסיביזציה).
|
דפנה משתמשת בפעלים פעילים: `העורר טוען`, `המשיבה טוענת`, `מבקשי התכנית מבקשים`. **לא** "טענות העורר היו ש..." (פסיביזציה).
|
||||||
|
|
||||||
|
**קטלוג פעלי-רינדור-צד מורחב (מעוגן בקורפוס-56):** דפנה מגוונת את פועל-הרינדור לפי תפקיד-המהלך של הצד, ולעולם אינה חוזרת על "טוען" באופן מונוטוני:
|
||||||
|
|
||||||
|
| פועל | תפקיד-המהלך |
|
||||||
|
|------|-------------|
|
||||||
|
| **מסביר / מפרט** | פריסת נימוק טכני או עובדתי שלב-אחר-שלב |
|
||||||
|
| **מדגיש / שם דגש על** | הצבת מרכז-הכובד של הטיעון |
|
||||||
|
| **מנמק / מבסס את טענתו בכך ש** | קישור טענה לעוגן |
|
||||||
|
| **מוסיף** | חיזוק-אגב לטיעון הראשי |
|
||||||
|
| **אבחן (את עניינו מ-)** | הבחנת תקדים שמנגד |
|
||||||
|
| **ביקש להסתמך על / מפנה ל** | גיוס תקדים תומך |
|
||||||
|
|
||||||
|
הבחירה אינה דקורטיבית — הפועל מתעד *מה הצד עושה* בטיעון, לא רק שהוא מחזיק בו.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. תבניות מועתקות (Copy-Paste Templates)
|
## 4. תבניות מועתקות (Copy-Paste Templates)
|
||||||
@@ -249,6 +293,12 @@
|
|||||||
|
|
||||||
[ציטוט מלא של 5-15 שורות מפסקאות 7-8]
|
[ציטוט מלא של 5-15 שורות מפסקאות 7-8]
|
||||||
|
|
||||||
|
<מטוטלת: מתי תאמץ הוועדה (שיטה מקובלת + מסד הולם, גם אם יש
|
||||||
|
גישות שונות) — ומתי תתערב (מסד בלתי הולם / הנחות לא הגיוניות /
|
||||||
|
היעדר הסבר / תשתית משפטית שגויה / פגם בשורש המעשה המינהלי)>.
|
||||||
|
נחדד כי הריסון חל במישור השמאי-המקצועי; במישור המשפטי-הפרשני
|
||||||
|
אין לשמאי יתרון על פני ועדת הערר.
|
||||||
|
|
||||||
ברוח הדברים לעיל נבחן את טענות הצדדים;
|
ברוח הדברים לעיל נבחן את טענות הצדדים;
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -284,6 +334,13 @@
|
|||||||
ניתנה פה אחד, <תאריך עברי>, <תאריך לועזי>.
|
ניתנה פה אחד, <תאריך עברי>, <תאריך לועזי>.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 4.8 פסקת "ניתן היה לסיים — אלא ש" (מעוגן בקורפוס-56)
|
||||||
|
```
|
||||||
|
<יישום העילה הפשוטה על העובדות>. על כן, ניתן היה לסיים את
|
||||||
|
הדיון במצב דברים זה ולקבוע כי <תוצאה פשוטה>. אלא ש<הטענה
|
||||||
|
החדשה/הנוספת שנכפתה עלינו> ולפיכך נדרש להוסיף ולהתייחס אליה;
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. הוראות אופרטיביות לסוכן הכותב
|
## 5. הוראות אופרטיביות לסוכן הכותב
|
||||||
@@ -294,19 +351,22 @@
|
|||||||
1. **מהי התוצאה הצפויה?** דחייה / קבלה / חלקית?
|
1. **מהי התוצאה הצפויה?** דחייה / קבלה / חלקית?
|
||||||
2. **מהו המתח / האיזון בלב התיק?** נסח אותו במשפט אחד — זה הולך לפתיחה (אם מוד B/C/E).
|
2. **מהו המתח / האיזון בלב התיק?** נסח אותו במשפט אחד — זה הולך לפתיחה (אם מוד B/C/E).
|
||||||
3. **איזה מוד פתיחה מתאים?** A/B/C/D/E (ראה טבלה 2.1)
|
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).
|
5. **האם דפנה הכריעה בתיק קשור?** אם כן → search_decisions ולכלול הפנייה / הבחנה (ראה sec 11.2 ב-voice-1130-25).
|
||||||
6. **מה האורך הצפוי לפי משקל בהכרעה?** (ראה 2.4)
|
6. **מה האורך הצפוי לפי משקל בהכרעה?** (ראה 2.4)
|
||||||
|
7. **האם יש טענת-צד איכותית-רטורית?** אם כן → הכן עיגון כמותי שממסגר אותה מחדש (1.11).
|
||||||
|
|
||||||
### 5.2 בכתיבה — איך לבנות פסקה
|
### 5.2 בכתיבה — איך לבנות פסקה
|
||||||
1. שימוש מודע ב"אנחנו" — בחירת פועל לפי תפקיד (טבלה 1.2)
|
1. שימוש מודע ב"אנחנו" — בחירת פועל לפי תפקיד (טבלה 1.2)
|
||||||
2. כל טענה משמעותית → פסקה מלאה. לא דחייה במשפט.
|
2. כל טענה משמעותית → פסקה מלאה. לא דחייה במשפט.
|
||||||
3. אם דוחים טענה → "אכן [נקודה תקפה]... אולם [למה לא מכריע]"
|
3. אם דוחים טענה → "אכן [נקודה תקפה]... אולם [למה לא מכריע]" (או "ראשית, צודק [צד]... אלא ש")
|
||||||
4. ציטוטים → במלואם, לא תמציות
|
4. ציטוטים → במלואם, לא תמציות; הדגשה תמיד חתומה ב"(הדגשת הח"מ)"
|
||||||
5. סעיפי חוק → במלואם
|
5. סעיפי חוק → במלואם
|
||||||
6. "למעלה מן הצורך" → לטיעונים מרכזיים
|
6. "למעלה מן הצורך" → לטיעונים מרכזיים; "ניתן היה לסיים... אלא ש" → כשטענה נכפתה אחרי עילה מספקת
|
||||||
7. דחייה למומחים → לסוגיות תכנוניות-טכניות
|
7. דחייה למומחים → לסוגיות תכנוניות-טכניות, **תמיד תחומה למישור המקצועי** (1.8)
|
||||||
8. **ללא רשימות ממוספרות** באנליזה
|
8. רינדור-צד → שחזר את מבנה-ההיסק, גוון את פועל-הרינדור (1.13, 3.7)
|
||||||
|
9. עיגון כמותי → המר טענות איכותיות למספרים שמכווצים אותן (1.11)
|
||||||
|
10. **ללא רשימות ממוספרות** באנליזה
|
||||||
|
|
||||||
### 5.3 חיפוש תקדימים אישיים
|
### 5.3 חיפוש תקדימים אישיים
|
||||||
לפני כתיבה — `search_decisions` בקטגוריה זהה. אם יש תקדים של דפנה עצמה — חובה להפנות אליו ב-3 מודים אפשריים:
|
לפני כתיבה — `search_decisions` בקטגוריה זהה. אם יש תקדים של דפנה עצמה — חובה להפנות אליו ב-3 מודים אפשריים:
|
||||||
@@ -321,6 +381,8 @@
|
|||||||
- [ ] אין דחיית טענה במשפט אחד
|
- [ ] אין דחיית טענה במשפט אחד
|
||||||
- [ ] אין רטוריקה דרמטית של הצדדים בקול ההכרעה
|
- [ ] אין רטוריקה דרמטית של הצדדים בקול ההכרעה
|
||||||
- [ ] אין תוצאה הכל-או-לא-כלום בתיק עם טענות מהותיות משני הצדדים
|
- [ ] אין תוצאה הכל-או-לא-כלום בתיק עם טענות מהותיות משני הצדדים
|
||||||
|
- [ ] פסקת-ריסון-שמאי תחומה למישור המקצועי (לא גורפת)
|
||||||
|
- [ ] הדיון לא חורג מטה מ-~⅓ מההחלטה, וטענות לא נדחסות מתחת ל-~⅕ (1.12)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -362,6 +424,10 @@
|
|||||||
| **שוב על מנת שלא לצאת בחסר** | להוצאת ערך נוסף |
|
| **שוב על מנת שלא לצאת בחסר** | להוצאת ערך נוסף |
|
||||||
| **בשולי הדברים** | להבעת הסתייגות בעדינות |
|
| **בשולי הדברים** | להבעת הסתייגות בעדינות |
|
||||||
| **מצאנו להוסיף כי...** | תוספת חופשית |
|
| **מצאנו להוסיף כי...** | תוספת חופשית |
|
||||||
|
| **מקובל עלינו (הסבר/עמדת)** | קבלת עמדה ערכית *(קורפוס-56)* |
|
||||||
|
| **לא מצאנו מקום להתערב** | אישרור הכרעת-מומחה *(קורפוס-56)* |
|
||||||
|
| **ראוי לציין כי** | הצבת הסתייגות/הבחנה *(קורפוס-56)* |
|
||||||
|
| **יש להבחין בין... לבין** | תיחום גבול-ריסון/הבחנה *(קורפוס-56)* |
|
||||||
|
|
||||||
### 6.5 ביטויים מסורתיים שאומצו (כל אחד מקבל ציטוט מקורי)
|
### 6.5 ביטויים מסורתיים שאומצו (כל אחד מקבל ציטוט מקורי)
|
||||||
- **"כבדהו וחשדהו"** — לכלי השימוש החורג (מקור: עע"מ 109/12 גבעת האירוסים)
|
- **"כבדהו וחשדהו"** — לכלי השימוש החורג (מקור: עע"מ 109/12 גבעת האירוסים)
|
||||||
@@ -372,7 +438,7 @@
|
|||||||
- **"בלשון המעטה"** — להסתייגות מנומסת
|
- **"בלשון המעטה"** — להסתייגות מנומסת
|
||||||
- **"בנדון דנא"** — נוסח מליצי לקדם דיון
|
- **"בנדון דנא"** — נוסח מליצי לקדם דיון
|
||||||
- **"דא עקא"** — לתפנית בטיעון
|
- **"דא עקא"** — לתפנית בטיעון
|
||||||
- **"ודוק"** — להבהרה / reductio ad absurdum
|
- **"ודוק"** — להבהרה / reductio ad absurdum (מופיע גם בלב רינדור-טענה, להדגשת נקודה קריטית בתוך הצגת עמדת-צד)
|
||||||
- **"ברי כי..."** — קביעה משכנעת
|
- **"ברי כי..."** — קביעה משכנעת
|
||||||
- **"ללמדך כי..."** — מסקנה מציטוט
|
- **"ללמדך כי..."** — מסקנה מציטוט
|
||||||
|
|
||||||
@@ -397,7 +463,7 @@
|
|||||||
**1015-24** נכתב בגוף ראשון יחיד ("אינני סבור", "לדעתי") — דעת מיעוט / חבר ועדה אחר. **לא לחקות.**
|
**1015-24** נכתב בגוף ראשון יחיד ("אינני סבור", "לדעתי") — דעת מיעוט / חבר ועדה אחר. **לא לחקות.**
|
||||||
|
|
||||||
### 6.10 מצב הרשתות — סטטיסטיקה
|
### 6.10 מצב הרשתות — סטטיסטיקה
|
||||||
- **24 תיקי 1xxx** + **22 תיקי 8xxx** + **2 תיקי 9xxx** = 48 בקורפוס
|
- **24 תיקי 1xxx** + **22 תיקי 8xxx** + **2 תיקי 9xxx** = 48 בקורפוס המנותח (וכן 56 החלטות / 2,670 דוגמאות-בלוק באצוות-המדידה האחרונה)
|
||||||
- **~30 תקדמים חיצוניים** ש**דפנה מצטטת באופן עקבי** (ראה precedent-network.md)
|
- **~30 תקדמים חיצוניים** ש**דפנה מצטטת באופן עקבי** (ראה precedent-network.md)
|
||||||
- **~15 תקדמים אישיים** שלה עצמה — מהווים את הקאנון האישי שלה
|
- **~15 תקדמים אישיים** שלה עצמה — מהווים את הקאנון האישי שלה
|
||||||
|
|
||||||
@@ -430,7 +496,7 @@
|
|||||||
כשמבקש שימוש חורג גם מקדם תכנית — דפנה מנתחת: האם השימוש המבוקש **תואם** את התכנון העתידי (→ גשר לגיטימי, כמו בכוכבה תורן)? או **סותר** (→ סטייה כפולה)? מסגרת ניתוח שלמה (249 מילים) שלא הייתה בטיוטה.
|
כשמבקש שימוש חורג גם מקדם תכנית — דפנה מנתחת: האם השימוש המבוקש **תואם** את התכנון העתידי (→ גשר לגיטימי, כמו בכוכבה תורן)? או **סותר** (→ סטייה כפולה)? מסגרת ניתוח שלמה (249 מילים) שלא הייתה בטיוטה.
|
||||||
|
|
||||||
### ה. עיגון כמותי
|
### ה. עיגון כמותי
|
||||||
דפנה מוסיפה נתונים מספריים ספציפיים: "4,404.98 מ"ר לכלל היישוב vs 1,425 מ"ר מבוקש — 32%". המספרים מעגנים את ההחלטה במציאות ומקשים על ערעור.
|
דפנה מוסיפה נתונים מספריים ספציפיים: "4,404.98 מ"ר לכלל היישוב vs 1,425 מ"ר מבוקש — 32%". המספרים מעגנים את ההחלטה במציאות ומקשים על ערעור. *(אומת כקבוע חוצה-סוגים בקורפוס-56 — ראו 1.11.)*
|
||||||
|
|
||||||
### ו. כותרות שטוחות (Heading 2 בלבד)
|
### ו. כותרות שטוחות (Heading 2 בלבד)
|
||||||
דפנה השתמשה ב-Heading 2 לכל הסעיפים, כולל תת-נושאים בדיון. **אין Heading 3**. כל סעיף עומד בפני עצמו.
|
דפנה השתמשה ב-Heading 2 לכל הסעיפים, כולל תת-נושאים בדיון. **אין Heading 3**. כל סעיף עומד בפני עצמו.
|
||||||
@@ -459,13 +525,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. הצעד הבא — הזרקת הקול ל-`legal-writer`
|
## 8. הצעד הבא — הזרקת הקול ל-`legal-writer`
|
||||||
|
|
||||||
מסמך זה (יחד עם voice-1130-25.md) הוא הבסיס. הצעד הבא: לעדכן את ה-system prompt של `legal-writer` (ראה `~/.claude/agents/legal-writer.md` או `mcp-server/.../get_style_guide`) כך שיכלול:
|
מסמך זה (יחד עם 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
|
2. ההוראות האופרטיביות מסעיף 5
|
||||||
3. תבניות העתקה מסעיף 4
|
3. תבניות העתקה מסעיף 4 (כולל 4.8)
|
||||||
4. אנטי-דפוסים מסעיף 3
|
4. אנטי-דפוסים מסעיף 3
|
||||||
5. הפנייה לטבלת מודי הפתיחה (2.1)
|
5. הפנייה לטבלת מודי הפתיחה (2.1)
|
||||||
|
|
||||||
|
|||||||
@@ -275,3 +275,40 @@ WARN [plugin] issue.comment.created event missing issueId in payload, skipping
|
|||||||
- **תוקן בצד שלנו** (PR #2, נפרס דרך `npm run build` + `pm2 restart paperclip` — הפלאגין נטען מ-`/home/chaim/plugin-legal-ai` לפי `package_path`).
|
- **תוקן בצד שלנו** (PR #2, נפרס דרך `npm run build` + `pm2 restart paperclip` — הפלאגין נטען מ-`/home/chaim/plugin-legal-ai` לפי `package_path`).
|
||||||
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
|
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
|
||||||
- TaskMaster: `legal-ai` #149.
|
- TaskMaster: `legal-ai` #149.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `agents.invoke({prompt})` — הפרומפט נבלע; רק `payload.issueId` נמסר
|
||||||
|
|
||||||
|
**התסמין (מנקודת-מבט היו"ר):** כותבים הוראה לסוכן — והוא "מתעלם". ההרצה נראית תקינה
|
||||||
|
לחלוטין: `status=succeeded`, `exit_code=0`, בלי שגיאה, ואפילו עולה כסף. הסוכן מדווח
|
||||||
|
"כל התורים ריקים — אין עבודה".
|
||||||
|
|
||||||
|
**הסיבה:** `ctx.agents.invoke(agentId, companyId, {prompt, reason})` הוא ה-API **המתועד**
|
||||||
|
של ה-Plugin SDK ("*Invoke an agent with a prompt payload*"), ו-`prompt` הוא שדה חובה.
|
||||||
|
Paperclip מקבל אותו, שומר אותו ב-`agent_wakeup_requests.payload.prompt` — **וה-runner קורא
|
||||||
|
רק `payload.issueId`**. הפרומפט לא מגיע למודל. ה-`reason` כן שורד (ל-`context_snapshot.wakeReason`),
|
||||||
|
ולכן זה נראה כאילו משהו נמסר.
|
||||||
|
|
||||||
|
מדידה ב-DB (2026-07-16): **18 מתוך 18** ה-wakeups שאי-פעם נשאו `payload.prompt` לא הגיעו
|
||||||
|
לאף הרצה — 0% מסירה. מול 107,172 wakeups עם `issueId` שעובדים. אימות ישיר: בלוג-ההרצה
|
||||||
|
שנבלעה אין ולו אזכור אחד של מספר-התיק, והמודל פותח ב-*"This heartbeat has no scoped wake payload"*.
|
||||||
|
|
||||||
|
**מה עושים במקום:** נושאים את ההוראה על **issue**, לא ב-payload — issue-ילד שכבר משויך
|
||||||
|
ל-CEO, עם ההוראה ב-`description`, ואז wakeup עם `payload.issueId`. זה בדיוק
|
||||||
|
`open_ceo_run` ב-[`web/paperclip_client.py`](../web/paperclip_client.py), והמסלול היחיד
|
||||||
|
שאומת כמגיע לסוכן.
|
||||||
|
|
||||||
|
**מלכודות שנלוות לזה — אל תיפול בהן שוב:**
|
||||||
|
- **`issues.create` של ה-SDK עם `assigneeAgentId` *לא* מעיר את הסוכן.** ה-REST המקביל
|
||||||
|
(`POST /api/issues/:id/children`) *כן* — הוא פולט wakeup `issue_assigned`. אל תכליל
|
||||||
|
מהתנהגות REST על ה-SDK; זה נבדק ונמצא שונה.
|
||||||
|
- **`ctx.issues.requestWakeup` (ה-primitive הנכון ב-SDK) נכשל מתוך scheduled job** עם
|
||||||
|
`missing, expired, or unknown invocation scope` — למרות ש-capability `issues.wakeup`
|
||||||
|
מוצהר ומותקן. אותה שגיאה מפילה גם `companies.list` / `issues.list` / `issues.listComments`
|
||||||
|
בתוך ה-sweep, ולכן **רשת-הביטחון `route-pending-comments` אינה אמינה**. לא נחקר לעומק —
|
||||||
|
לכן ניתוב-ההערות הועבר ל-backend של legal-ai (REST), שאינו חשוף לבעיה.
|
||||||
|
|
||||||
|
**סטטוס:** נעקף — `POST /api/cases/{case}/agents/comment` ו-`/agents/interaction-response`
|
||||||
|
פותחים הרצת-CEO ישירות (REST) ומסמנים את ההערה כמנותבת, כדי שה-sweep השבור לא יירה
|
||||||
|
הרצת-סרק. TaskMaster `legal-ai` #228.
|
||||||
|
|||||||
@@ -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 — שיעורים ינועו ככל שהדריינר/פאנל רצים.
|
||||||
57
docs/qa-acceptance-rubric.md
Normal file
57
docs/qa-acceptance-rubric.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Rubric קבלה של החלטה — מקור-אמת אחד
|
||||||
|
|
||||||
|
מסמך זה הוא **הגדרת ה"מוכן"** של טיוטת החלטה: סף-הקבלה שהכותב **מכוון אליו מראש**
|
||||||
|
וה-QA **אוכף** כשער. שני הסוכנים מפנים לכאן — אין לשכפל את הקריטריונים בקבצי-הסוכן.
|
||||||
|
|
||||||
|
**זהו סף-הקבלה, לא הוראת-הכתיבה.** ה"איך" המלא חי במסמכי-הקול המופנים בכל שורה
|
||||||
|
(`daphna-voice-fingerprint.md`, `daphna-block-zayin-claims.md`,
|
||||||
|
`daphna-architecture-by-outcome.md`, `daphna-acceptance-architecture.md`,
|
||||||
|
`decision-methodology.md`, `anti-hallucination-gate.md`). כאן — רק המבחן.
|
||||||
|
|
||||||
|
> **לכותב:** לפני מסירה ל-CEO, עבור על החוסמים למטה ואשר שכל אחד מתקיים. טיוטה
|
||||||
|
> שלא עוברת חוסם אחד — תוחזר ב-`needs_revision`. כתוב כדי לעבור מהפעם הראשונה.
|
||||||
|
>
|
||||||
|
> **ל-QA:** אלה הקריטריונים ש-7 הבדיקות שלך אוכפות. `fail` על חוסם = `qa_review`;
|
||||||
|
> `fail` על אזהרה = דיווח לחיים, לא חוסם.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## חוסמי ייצוא (critical — כל אחד מכשיל את הטיוטה)
|
||||||
|
|
||||||
|
1. **שלמות מבנית** — בלוקים ה–יא קיימים; מספור רציף ללא קפיצות/כפילות; "להלן" בשימוש ראשון.
|
||||||
|
2. **רקע ניטרלי (בלוק ו)** — עובדות בלבד; **אפס** ציטוטים מצדדים; **אפס** מילות-שיפוט
|
||||||
|
("חריג", "בעייתי", "מגוחך", "פגום", "שערורייתי").
|
||||||
|
3. **כיסוי טענות** — כל טענה מהותית מבלוק ז קיבלה מענה בבלוק י (ישיר / קיבוץ / ציון שנבחנה).
|
||||||
|
טענות `[skip]` לא נספרות; `[bundle]` נבדקות כקבוצה. טענה מהותית לא-מסומנת שלא נענתה = כשל.
|
||||||
|
4. **מתודולוגיה** — לכל סוגיה מבנה סילוגיסטי (כלל+עובדות+מסקנה); ממצא עובדתי מופרד ממסקנה
|
||||||
|
משפטית; טענת הצד המפסיד הוצגה בחוזקתה (Steel-Man); איזון מפורש כשנדרש; בלי "נוסחאות ריקות";
|
||||||
|
ציטוטים בסנדוויץ' (הקדמה→ציטוט→ניתוח).
|
||||||
|
5. **קול דפנה** —
|
||||||
|
- בלוק ז: כותרת **"תמצית טענות הצדדים"**; כותרת-משנה לכל צד; בלי רשימה ממוספרת בפסקה;
|
||||||
|
בלי מילות-הערכה; בלי גילוי-מסקנה; קול פעיל ("העורר טוען").
|
||||||
|
- בלוק י: כותרת **"דיון והכרעה"**; קול "אנחנו" תפקידי ("מצאנו", לא "הוועדה מוצאת");
|
||||||
|
דפוס **"אכן... אולם"** לטענות שנדחות; בלי מספור-פסקאות סדרתי; ציטוטי פסיקה במלואם (4–15 שורות);
|
||||||
|
"למעלה מן הצורך" לטיעונים מרכזיים; בלי תוצאת הכל-או-לא-כלום כשיש טענות מהותיות משני הצדדים.
|
||||||
|
- 8xxx עם הכרעה שמאית → ציטוט **בר"מ 3644/13** קיים.
|
||||||
|
6. **עיגון-מקור (INV-AH)** — כל אזכור פסיקה/חוק/הלכה/מספר מעוגן-מקור עם ציטוט; ציטוט חיצוני
|
||||||
|
תואם להלכה מאושרת בקורפוס (`search_precedent_library`). אזכור לא-מעוגן = הסר או הורד ל-`[ספקולציה]`.
|
||||||
|
7. **צירוף פסיקה ל-DB** — כל ציטוט בבלוק י (חיצוני/internal) רשום ב-`case_precedents` דרך
|
||||||
|
`precedent_attach`. ציטוט שקיים רק בטקסט ולא ב-DB = חוסם (ה-exporter ו-Hermes קוראים מה-DB).
|
||||||
|
8. **log שאילתות-קורפוס** — `analysis-and-research.md` §7א ו-`precedent-research.md` §ז קיימים,
|
||||||
|
עם מינימום queries לקורפוס הסמכותי (`analyst ≥ טענות-סף + סוגיות`; `researcher ≥ סוגיות`)
|
||||||
|
ותיעוד negative-evidence (גם 0-results).
|
||||||
|
|
||||||
|
## אזהרות (warning — מדווח לחיים, לא חוסם)
|
||||||
|
- **משקלות בטווח:** ו 15–40% · ז 20–40% · י 32–50% · יא 2–9%.
|
||||||
|
- **בלי כפילות:** בלוק י מפנה לבלוקים קודמים ("כאמור"), לא חוזר על עובדות/טענות.
|
||||||
|
- **מראה-מקום מלא בציטוטים:** מספר-תיק מלא + ערכאה + תאריך/`פורסם בנבו` (+ `page_reference` לציטוט ארוך).
|
||||||
|
|
||||||
|
## אם תוצאה = קבלה (מ-`daphna-acceptance-architecture.md`)
|
||||||
|
- התבנית הנבחרת (A/B/C/D/E) מתאימה לסיבת-הקבלה, ופורמט-הסיום תואם לתבנית
|
||||||
|
(A: "מתבטלת" + הודאת-צד-נגדי; B: "תיקבע לדיון"; C: "בכפוף לתיקונים" + הכרה בוועדה;
|
||||||
|
D: "דרישת התשלום בטלה"; E: "השומה תושב לתיקון").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **מקור-אמת:** חומרת-הבדיקות המחייבת חיה ב-`.claude/agents/legal-qa.md` ("7 בדיקות" + טבלת-חומרה)
|
||||||
|
> וב-`docs/spec/05-qa-review.md`. Rubric זה הוא התמצית המשותפת — כשמשתנה שער QA, עדכן שם ואת התמצית כאן.
|
||||||
@@ -96,10 +96,11 @@ idempotency-key pattern · ISO 8000 (Data quality) | סטטוס: verified
|
|||||||
בנקודת-סיום-הקליטה, לכל סוג; חוזה-שלמות יסמן רשומה ללא מטא-דאטה כלא-שמישה
|
בנקודת-סיום-הקליטה, לכל סוג; חוזה-שלמות יסמן רשומה ללא מטא-דאטה כלא-שמישה
|
||||||
([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), מפורט ב-
|
([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), מפורט ב-
|
||||||
[02-data-model.md](02-data-model.md)).
|
[02-data-model.md](02-data-model.md)).
|
||||||
**הפרה ידועה:** המסלול הפנימי (`internal_decisions.py:208`) מתזמן **רק**
|
**הפרה ידועה — ✅ נסגרה (FU-1, GAP-02):** המסלול הפנימי דילג בעבר על חילוץ-המטא-דאטה
|
||||||
`request_halacha_extraction` ואינו קורא ל-`request_metadata_extraction` (בניגוד
|
(`internal_decisions.py:208` תיזמן רק `request_halacha_extraction`) → ערן סופר 8046/24 נקלטה
|
||||||
ל-`precedent_library.py:292-293` שקורא לשניהם) → ערן סופר 8046/24 נקלטה **בלי
|
בלי metadata. **נסגר:** שני מסלולי-הפסיקה זורמים כעת דרך `ingest.ingest_document` הקנוני, שמתזמן
|
||||||
metadata** (headnote/summary/tags ריקים) → ממצא ל-[audit](../audit-report.md).
|
**את שני התורים יחד** בנקודה אחת (`ingest.py:233-234`) — תיזמון-המטא אינו מותנה במסלול עוד.
|
||||||
|
מאומת ב-`test_unified_ingest.py` (9/9). תיעוד-מקור: [audit](../audit-report.md).
|
||||||
|
|
||||||
### INV-ING4: re-index בקליטה-חוזרת (upsert ⇒ re-embed)
|
### INV-ING4: re-index בקליטה-חוזרת (upsert ⇒ re-embed)
|
||||||
**כלל:** קליטה-חוזרת ששינתה את תוכן-הפריט מפעילה **re-index** — chunks ו-embeddings
|
**כלל:** קליטה-חוזרת ששינתה את תוכן-הפריט מפעילה **re-index** — chunks ו-embeddings
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
| ישות | תפקיד | מזהה-קנוני | שדות-מפתח (מאומתים `db.py`) |
|
| ישות | תפקיד | מזהה-קנוני | שדות-מפתח (מאומתים `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`) |
|
| `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`) |
|
| `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`) |
|
| `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`) |
|
| `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ב. חוזה תמציתי לישויות נוספות
|
### 2ב. חוזה תמציתי לישויות נוספות
|
||||||
|
|
||||||
- `documents` → usable: `file_path`+`doc_type`; searchable: `extraction_status=completed` ו-`extracted_text` לא-ריק ו-≥1 `document_chunk` עם embedding.
|
- `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)).
|
- `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` לא-ריק.
|
- `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק.
|
||||||
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
|
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
|
||||||
|
|
||||||
### 2ג. ישויות-נגזרות (אחסון-ניתוחים)
|
### 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/קוד. אלו כפופים לכללי
|
מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי
|
||||||
ה-provenance של [X8](X8-field-provenance.md) ולשערי [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant):
|
ה-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
|
(`case_number, source_kind, proceeding_type`, `db.py:904-909`), אך השדה `case_number TEXT
|
||||||
UNIQUE NOT NULL` המקורי (`db.py:368`) הוסר רק ב-V15 (`db.py:902-903`) — מורשת שאפשרה את
|
UNIQUE NOT NULL` המקורי (`db.py:368`) הוסר רק ב-V15 (`db.py:902-903`) — מורשת שאפשרה את
|
||||||
הפרת [INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות). **יעד:** נרמול-בכתיבה אכוף + ציטוט-מלא רק ב-`citation_formatted`.
|
הפרת [INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות). **יעד:** נרמול-בכתיבה אכוף + ציטוט-מלא רק ב-`citation_formatted`.
|
||||||
- **`summary` קיים על `case_law` אך לא בחוזה-הקליטה הפנימי.** העמודה קיימת (`db.py:373`) אך
|
- **`summary` קיים על `case_law` אך לא בחוזה-הקליטה הפנימי — ✅ נסגר (FU-1, GAP-02).** העמודה
|
||||||
המסלול הפנימי אינו ממלא אותה (כפועל-יוצא מהיעדר חילוץ-מטא-דאטה, [INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)).
|
קיימת (`db.py:373`), והמסלול הפנימי לא מילא אותה בעבר (היעדר חילוץ-מטא-דאטה). **נסגר:** שני
|
||||||
**יעד:** searchable מותנה ב-metadata לא-ריק ([INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
|
המסלולים זורמים דרך `ingest.ingest_document` הקנוני שמתזמן metadata לשני הסוגים
|
||||||
- **שני שדות-סטטוס-חילוץ נפרדים, ללא דגל-`searchable` מפורש.** `extraction_status` +
|
([01-ingest INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)); `searchable`
|
||||||
`halacha_extraction_status` (`db.py:603-605`) מתארים תהליך, אך אין שדה יחיד שמסמן "עבר
|
מותנה ב-metadata לא-ריק ([INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
|
||||||
חוזה-שלמות → searchable". **יעד:** דגל/view נגזר ש-search מסנן לפיו, מגובה health-check.
|
- **דגל-`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 מסונכרנים אוטומטית
|
- **`embedding` אינו `GENERATED` (בניגוד ל-tsvector).** ה-tsvectors מסונכרנים אוטומטית
|
||||||
(`db.py:776,780,1083`), אך ה-`embedding vector(1024)` תלוי-טריגר חיצוני — נקודת-drift אפשרית
|
(`db.py:776,780,1083`), אך ה-`embedding vector(1024)` תלוי-טריגר חיצוני — נקודת-drift אפשרית
|
||||||
ל-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). **יעד:** טריגר re-embed מובטח + health-check ל-drift.
|
ל-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). **יעד:** טריגר re-embed מובטח + health-check ל-drift.
|
||||||
- **`halachot.review_status` כשער-searchable ללא נראות-backlog.** הסינון תקין (`pending_review`
|
- **נראות backlog-הלכות — ✅ נסגר (FU-5, GAP-14).** הסינון תקין (`pending_review` מוסתר,
|
||||||
מוסתר, `db.py:659`), אך אין נראות כמה ממתינות — תואם את ההפרה הידועה ב-[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
`db.py:659`); ההפרה הישנה היתה היעדר-נראות (10/19 מאושרות, התגלה במקרה). **נסגר:** ה-health-
|
||||||
(10/19 מאושרות, התגלה במקרה). **יעד:** health-check חושף backlog-הלכות.
|
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` בכל ענף-SQL בשכבת-החיפוש; בדיקת-בריאות שמריצה שאילתת-ביקורת
|
||||||
(חיפוש מכוון-קורפוס שמחזיר פריט בעל `source_kind` זר = כשל). אוכף את
|
(חיפוש מכוון-קורפוס שמחזיר פריט בעל `source_kind` זר = כשל). אוכף את
|
||||||
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
|
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
|
||||||
**הפרה ידועה:** משימה #56 — `halacha_filters` **אינם** כוללים `cl.source_kind` ב-
|
**הפרה ידועה — ✅ נסגרה (FU-4, GAP-10, משימה #56):** בעבר `halacha_filters` לא כללו
|
||||||
`search_precedent_library_semantic` (`db.py:3168`, ענף ה-halacha; לעומת `chunk_filters` שכן —
|
`cl.source_kind` (בניגוד ל-`chunk_filters` שכן) → הלכות מהקורפוס הפנימי דלפו לחיפוש בקורפוס
|
||||||
`db.py:3169`) **וב**-`search_precedent_library_lexical` (`db.py:3401` מול `db.py:3402`). שני
|
החיצוני ולהפך. **נסגר:** שני ה-`halacha_filters` (semantic + lexical) מתחילים כעת ב-
|
||||||
ה-`halacha_sql` עושים `JOIN case_law cl` בלי לסנן `source_kind` (`db.py:3236-3238`, `db.py:3475-3477`)
|
`cl.source_kind = '{source_kind}'` בדיוק כמו `chunk_filters` (`db.py:7516,7519` ומקבילו ב-
|
||||||
→ הלכות מהקורפוס הפנימי דולפות לתוצאות החיפוש בקורפוס החיצוני ולהפך → ממצא ל-[audit](../audit-report.md).
|
`search_precedent_library_lexical`). מאומת ב-`test_precedent_corpus_isolation.py` (semantic+lexical,
|
||||||
|
שני ה-source_kind) → תיעוד-מקור: [audit](../audit-report.md).
|
||||||
|
|
||||||
### INV-RET2: אין החזרה/אינדוקס בלי metadata מלא + locator פתיר
|
### INV-RET2: אין החזרה/אינדוקס בלי metadata מלא + locator פתיר
|
||||||
**כלל:** פריט אינו מוחזר מ-search (ואינו נחשף לאחזור) אלא אם **שדות-החובה שלו מולאו**
|
**כלל:** פריט אינו מוחזר מ-search (ואינו נחשף לאחזור) אלא אם **שדות-החובה שלו מולאו**
|
||||||
@@ -121,10 +122,13 @@ relevance judgments, precision/recall) · RAG evaluation literature (Lewis et al
|
|||||||
Elastic — *relevance evaluation guidance* | סטטוס: verified
|
Elastic — *relevance evaluation guidance* | סטטוס: verified
|
||||||
**אכיפה:** eval harness עם gold-set מתויג + בדיקת-בריאות תקופתית; שער-CI על שינוי שכבת-האחזור.
|
**אכיפה:** eval harness עם gold-set מתויג + בדיקת-בריאות תקופתית; שער-CI על שינוי שכבת-האחזור.
|
||||||
אוכף את [G8](00-constitution.md#inv-g8-איכות-אחזור-נמדדת--precision--recall).
|
אוכף את [G8](00-constitution.md#inv-g8-איכות-אחזור-נמדדת--precision--recall).
|
||||||
**הפרה ידועה (GAP):** אין כיום eval harness ולא gold-set — קיים רק `telemetry.log_search_bg`
|
**הפרה ידועה — ✅ נסגרה (FU-5, GAP-11):** בעבר היה רק `telemetry.log_search_bg` (תצפית, לא
|
||||||
(`search.py:62,118,190,271`; `precedent_library.py:280`) שמתעד שאילתות בפועל, אך **אינו מודד
|
הערכה). **נסגר:** `scripts/eval_retrieval.py` מריץ את **מסלול-האחזור בייצור** (אותן פונקציות-
|
||||||
precision/recall מול תיוג** (תצפית, לא הערכה). היעד: harness שמריץ סט קבוע ומחזיר metrics →
|
שירות שכלי-ה-MCP קוראים) מול gold-set מתויג (`data/eval/gold-set.jsonl`, נבנה ב-
|
||||||
ממצא ל-[audit](../audit-report.md).
|
`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 מוחזר עקיב למקורו
|
### INV-RET5: כל span מוחזר עקיב למקורו
|
||||||
**כלל:** כל קטע מוחזר נושא **עקיבוּת-מקור מלאה** — מזהה-מסמך/פסק-דין (`case_law_id`/`document_id`/
|
**כלל:** כל קטע מוחזר נושא **עקיבוּת-מקור מלאה** — מזהה-מסמך/פסק-דין (`case_law_id`/`document_id`/
|
||||||
@@ -156,16 +160,15 @@ re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף
|
|||||||
|
|
||||||
ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול הקוד.
|
ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול הקוד.
|
||||||
|
|
||||||
- **דליפת-הלכות חוצת-קורפוס (משימה #56).** `halacha_filters` נפתחים רק עם `review_status`
|
- **דליפת-הלכות חוצת-קורפוס (משימה #56) — ✅ נסגר (FU-4, GAP-10).** בעבר `halacha_filters` לא
|
||||||
(`db.py:3168`, `db.py:3401`) ואינם מוסיפים `cl.source_kind`, בעוד `chunk_filters` כן
|
כללו `cl.source_kind` (בניגוד ל-`chunk_filters`) → הלכה מהקורפוס הפנימי יכלה לחזור בחיפוש
|
||||||
(`db.py:3169`, `db.py:3402`). שני ה-`halacha_sql` עושים `JOIN case_law` בלי סינון
|
בקורפוס החיצוני, ולהפך. **נסגר:** `halacha_filters` מתחילים כעת ב-`cl.source_kind = '{source_kind}'`
|
||||||
(`db.py:3236-3242`, `db.py:3463-3482`). **תסמין:** חיפוש בקורפוס החיצוני
|
+ `cl.searchable = true` בדיוק כמו `chunk_filters` (`db.py:7514-7519`), בשתי הפונקציות
|
||||||
(`search_precedent_library`, `source_kind="external_upload"`) יכול להחזיר הלכה שמקורה
|
(semantic+lexical). מאומת ב-`test_precedent_corpus_isolation.py`
|
||||||
בהחלטת-ועדה פנימית — ולהפך עבור `search_internal_decisions` (`source_kind="internal_committee"`,
|
([INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)).
|
||||||
`internal_decisions.py:418`). **יעד:** `halacha_filters` יתחילו ב-`cl.source_kind = '{source_kind}'`
|
- **eval harness — ✅ נסגר (FU-5, GAP-11).** בעבר רק `telemetry.log_search_bg` (תצפית).
|
||||||
בדיוק כמו `chunk_filters` ([INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)).
|
**נסגר:** `scripts/eval_retrieval.py` (precision/recall/MRR/nDCG מול `data/eval/gold-set.jsonl`
|
||||||
- **אין eval harness — מדידת-איכות לא קיימת.** רק `telemetry.log_search_bg` מתעד שאילתות
|
+ baseline; `--self-test` עובר offline) — מדידה אמפירית מול תיוג קבוע
|
||||||
(`search.py:62,118,190,271`); אין gold-set מתויג ואין precision/recall. **יעד:** harness עומד
|
|
||||||
([INV-RET4](#inv-ret4-איכות-אחזור-נמדדת-ב-eval-harness-עומד-precision--recall)).
|
([INV-RET4](#inv-ret4-איכות-אחזור-נמדדת-ב-eval-harness-עומד-precision--recall)).
|
||||||
- **`search_decisions` מתעד אזהרה כשאין `practice_area` אך לא חוסם.** ללא פילטר-תחום החיפוש
|
- **`search_decisions` מתעד אזהרה כשאין `practice_area` אך לא חוסם.** ללא פילטר-תחום החיפוש
|
||||||
עלול לערבב תחומים משפטיים (`search.py:45-49,172-176` — `logger.warning`, ממשיך). **יעד:** הפרדה
|
עלול לערבב תחומים משפטיים (`search.py:45-49,172-176` — `logger.warning`, ממשיך). **יעד:** הפרדה
|
||||||
|
|||||||
@@ -47,6 +47,122 @@
|
|||||||
> מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון
|
> מקוריות מהשלמות). הסינון לפי `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 הבלוקים (סיכום)
|
## 2. ארכיטקטורת 12 הבלוקים (סיכום)
|
||||||
|
|||||||
@@ -43,6 +43,16 @@
|
|||||||
> וגם **טיוטת-ביניים** (`mode="interim"` — תת-קבוצת בלוקים בסדר חדש: רקע→תכניות→טענות→הליכים,
|
> וגם **טיוטת-ביניים** (`mode="interim"` — תת-קבוצת בלוקים בסדר חדש: רקע→תכניות→טענות→הליכים,
|
||||||
> `export_interim_draft`, `drafting.py:511`). שני המצבים שולפים מאותה טבלה — וריאציית-תצוגה
|
> `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_docx_exporter.py` (`build_analysis_docx`, `:401`) — מייצא את מסמך **הניתוח
|
||||||
> המשפטי** (`analysis-and-research.md`) שכתב `legal-analyst`, לא את בלוקי-ההחלטה. זהו תוצר-עזר
|
> המשפטי** (`analysis-and-research.md`) שכתב `legal-analyst`, לא את בלוקי-ההחלטה. זהו תוצר-עזר
|
||||||
> שונה (שלב ניתוח, לא החלטה) — והוא המסלול שהסקיל מתעד בעיקר. שניהם חולקים את **אותו טמפלט
|
> שונה (שלב ניתוח, לא החלטה) — והוא המסלול שהסקיל מתעד בעיקר. שניהם חולקים את **אותו טמפלט
|
||||||
@@ -101,10 +111,14 @@ Model / Single Source of Truth) · SSOT (Single Source of Truth principle) | ס
|
|||||||
פלט מגורסן + idempotent מבחינת-תוכן; אוכף את
|
פלט מגורסן + idempotent מבחינת-תוכן; אוכף את
|
||||||
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) וכלל-ההנדסה
|
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) וכלל-ההנדסה
|
||||||
"סימטריה" (חוקה §6).
|
"סימטריה" (חוקה §6).
|
||||||
**הפרה ידועה:** אחרי `revise_draft`/`apply_user_edit`, ה-DOCX המסומן `active_draft_path` הופך
|
**הפרה ידועה — ✅ מגודרת (FU-7, GAP-17):** אחרי `revise_draft`/`apply_user_edit` ה-DOCX
|
||||||
ל"מקור-האמת" לעריכות-Track-Changes העוקבות (`db.py:185-188`), ו**בלוקי-ה-DB אינם מתעדכנים
|
המסומן `active_draft_path` משמש כאנקור לעריכות-Track-Changes העוקבות, ובלוקי-ה-DB אינם
|
||||||
חזרה** — הנתון-הנגזר זוחל למקור-אמת בפועל בלי סנכרון לאחור. **יעד:** או re-sync מהבלוקים, או
|
מתעדכנים חזרה — סטייה אפשרית בין הבלוקים למסמך-החי. **התיקון שנבחר (חוזה מפורש, לא re-sync):**
|
||||||
חוזה מפורש ש-`active_draft_path` הוא רק אנקור-revision ולא מקור-תוכן → ראה [§5](#5-current-vs-target).
|
`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)
|
### 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
|
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (controlled, auditable
|
||||||
output) · Council of Europe / CEPEJ (2018, under user control) · Federal Judicial Center —
|
output) · Council of Europe / CEPEJ (2018, under user control) · Federal Judicial Center —
|
||||||
*Judicial Writing Manual* (2d ed.) | סטטוס: verified
|
*Judicial Writing Manual* (2d ed.) | סטטוס: verified
|
||||||
**אכיפה:** `export_blocked = critical_failures > 0` (`qa_validator.py:362`); **נאכף ברמת-
|
**אכיפה — ✅ hard-block בקוד (FU-6, GAP-15):** `export_docx` (`drafting.py:462`) **בודק לעצמו**
|
||||||
הזרימה/agent בלבד** — הסוכן `legal-exporter` מחויב להריץ `validate_decision` ולבדוק
|
לפני כל ייצוא — `db.qa_run_exists` (אם QA לא רץ כלל → חסום) ו-`db.get_critical_qa_failures`
|
||||||
כשלים-קריטיים לפני ייצוא (`.claude/agents/legal-exporter.md:71,149`).
|
(אם יש כשל-קריטי → חסום) — **לפני** הגישה ל-`docx_exporter.export_decision`. אלו `SELECT` זולים
|
||||||
**הפרה ידועה:** `export_docx` (`drafting.py:384`) **אינו** קורא ל-`validate_decision` בעצמו —
|
על ה-`qa_results` המאוחסנים (לא הרצת-LLM חוזרת). נוסף על `export_blocked = critical_failures > 0`
|
||||||
הוא ניגש ישירות ל-`docx_exporter.export_decision` בלי לבדוק `export_blocked`. החסימה תלויה
|
ברמת-הזרימה ועל משמעת-הסוכן `legal-exporter` (`.claude/agents/legal-exporter.md:71,149`) — כך
|
||||||
במשמעת-הסוכן ואינה hard-block בקוד-הייצוא → ראה [§5](#5-current-vs-target) (תואם
|
שאי-אפשר לעקוף את השער אפילו בקריאה ישירה ל-`export_docx`.
|
||||||
[05-qa-review §4](05-qa-review.md#4-current-vs-target--ממצאי-audit)).
|
**הפרה ידועה — ✅ נסגרה (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
|
## 5. Current vs Target
|
||||||
|
|
||||||
- **שער-ייצוא אכוף-זרימה ולא אכוף-קוד (INV-EX3 / INV-QA3).** אומת בקוד: `export_docx`
|
- **שער-ייצוא — ✅ hard-block בקוד (INV-EX3 / INV-QA3, FU-6 / GAP-15).** `export_docx`
|
||||||
(`drafting.py:384-419`) קורא ישירות ל-`docx_exporter.export_decision` (`:403`) ללא קריאה
|
(`drafting.py:462-494`) בודק `db.qa_run_exists` + `db.get_critical_qa_failures` ודוחה לפני
|
||||||
ל-`qa_validator.validate_decision` ובלי בדיקת `export_blocked`. החסימה מתקיימת רק כי הסוכן
|
הגישה ל-`docx_exporter.export_decision` — לא ניתן לעקוף בקריאה ישירה. מאומת ב-
|
||||||
`legal-exporter` מחויב להריץ QA קודם (`legal-exporter.md:71,149`) — אדם/סוכן שיקרא
|
`test_export_qa_gate.py` (3 מקרים: ללא-QA / כשל-קריטי / נקי).
|
||||||
ל-`export_docx` ישירות **יעקוף** את השער. **יעד:** hard-block בתוך `export_docx` — שליפת
|
- **`active_draft_path` כ-derived (INV-EX1, FU-7 / GAP-17) — ✅ מגודר בחוזה מפורש.** ה-DB נשאר
|
||||||
`qa_results`/`export_blocked` ודחייה לפני כתיבת ה-DOCX, כך שאי-אפשר לעקוף.
|
מקור-האמת; `active_draft_path` הוא אנקור-revision בלבד. ה-drift בין הבלוקים ל-DOCX-החי נלכד
|
||||||
- **`active_draft_path` כ-derived-שזוחל-למקור (INV-EX1).** ה-DOCX נגזר מהבלוקים בייצוא הראשון,
|
בדגל `cases.blocks_stale`: נדלק ב-`revise_draft`/`apply_user_edit` (`drafting.py:688,789`),
|
||||||
אך אחרי עריכה (`revise_draft`/`apply_user_edit`) ה-DOCX הופך ל"מקור-האמת" לעריכות הבאות
|
כובה ב-`export_docx` (`drafting.py:504`). **שארית (low-pri):** health-check שמתריע על
|
||||||
(`db.py:185-188`) בלי לעדכן את `decision_blocks` חזרה — סטייה אפשרית בין הבלוקים למסמך-החי.
|
`blocks_stale=true` עתיק — תיעוד-המשך, לא חוסם.
|
||||||
**יעד:** חוזה מפורש — או re-sync מהבלוקים, או הגדרת `active_draft_path` כאנקור-revision בלבד
|
|
||||||
(לא מקור-תוכן), עם בדיקת-בריאות לגילוי drift בין הבלוקים ל-DOCX הפעיל.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -35,12 +35,12 @@
|
|||||||
היעד הוא **Text Style Transfer** מבוסס **פרופיל-סגנון מופשט** — להכליל את סגנון/שיטת דפנה ולהתאים לתיק הספציפי. fine-tuning של משקולות **לא רלוונטי**: המודל (Opus) סגור, והקורפוס (~48 החלטות, יו"ר חדשה) קטן מדי — מצב שבו הספרות מראה שפרופיל-מופשט + דוגמאות מנצח (≈+15% מעל RAG-בלבד). **מדיניות-העתקה לפי סוג-תוכן:** קבוע/נוסחאי (פתיחים דוקטרינליים, תבניות-סיום) → מותר להעתיק; ניתוח/טענות ספציפיים → להכליל ולהתאים; מהות (הלכה/עובדה מתיק אחר) → אסור (INV-LRN5).
|
היעד הוא **Text Style Transfer** מבוסס **פרופיל-סגנון מופשט** — להכליל את סגנון/שיטת דפנה ולהתאים לתיק הספציפי. fine-tuning של משקולות **לא רלוונטי**: המודל (Opus) סגור, והקורפוס (~48 החלטות, יו"ר חדשה) קטן מדי — מצב שבו הספרות מראה שפרופיל-מופשט + דוגמאות מנצח (≈+15% מעל RAG-בלבד). **מדיניות-העתקה לפי סוג-תוכן:** קבוע/נוסחאי (פתיחים דוקטרינליים, תבניות-סיום) → מותר להעתיק; ניתוח/טענות ספציפיים → להכליל ולהתאים; מהות (הלכה/עובדה מתיק אחר) → אסור (INV-LRN5).
|
||||||
|
|
||||||
### 0.2 שלושת ערוצי-ההזנה לכותב
|
### 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.
|
2. **B — דוגמאות + תבניות (תומך):** פסקאות-בלוק אמיתיות + Copy-Paste Templates + contrastive.
|
||||||
3. **C — deep-read (נקודתי):** voice-XXXX.md — worked example לתיק-מופת.
|
3. **C — deep-read (נקודתי):** voice-XXXX.md — worked example לתיק-מופת.
|
||||||
|
|
||||||
### 0.3 הצינור החוזר per-final (7 שלבים)
|
### 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
|
### 0.4 ניהול ב-UI
|
||||||
`/methodology` = **עורך-הפרופיל היחיד** (declarative: יחסי-זהב, כללי-דיון, צ׳קליסטים, ביטויי-מעבר, אנטי-דפוסים, voice-invariants). `/training` = **שולחן-הלמידה** (קורפוס, פורטרט-סגנון, השוואת draft↔final, curator, מדד-מרחק, פנקס-התאמה).
|
`/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 הפרות-ידועות).
|
**שער-אישור אחד · טרנזקציית-כותב אחת (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 חדשים
|
### 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.*
|
*מקורות:* 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 מפריד במקור.
|
**INV-LRN5 (טוהר-הקול → G4/G11):** שכבת-ידע-הקול (voice-fingerprint, style_patterns, exemplars) **לא תכיל הלכות/עובדות ספציפיות** — רק סגנון ושיטה. מהות מנותבת ל-precedent_library/halacha. ה-distillation מפריד במקור.
|
||||||
@@ -62,9 +62,20 @@
|
|||||||
3. **בדיקת-ציטוטים** — `extract_internal_citations` מקשר את הפסיקה שההחלטה מצטטת לספרייה; כל ציטוט שאינו בספרייה **מסומן אוטומטית** כ-`missing_precedent` (open) להעלאה ע"י היו"ר.
|
3. **בדיקת-ציטוטים** — `extract_internal_citations` מקשר את הפסיקה שההחלטה מצטטת לספרייה; כל ציטוט שאינו בספרייה **מסומן אוטומטית** כ-`missing_precedent` (open) להעלאה ע"י היו"ר.
|
||||||
4. הציטוטים-המקושרים מזינים את **לולאת-ה-corroboration** (X11): ציטוט-נכנס מההחלטה שלנו מחזק את ההלכות של התקדים המצוטט (`corroboration_rebuild`).
|
4. הציטוטים-המקושרים מזינים את **לולאת-ה-corroboration** (X11): ציטוט-נכנס מההחלטה שלנו מחזק את ההלכות של התקדים המצוטט (`corroboration_rebuild`).
|
||||||
ואז שני שלבים אוטומטיים נפרדים (`run-learning` / `run-halacha`) המעירים worker מקומי (claude/DeepSeek/Gemini מקומיים בלבד):
|
ואז שני שלבים אוטומטיים נפרדים (`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`).
|
- **הלכות:** `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`).
|
(`hermes-curator.md:60-70`).
|
||||||
- מזהה **3–5 דפוסים/פערים** חדשים, כל ממצא מתויג `[סגנון]` / `[מבנה]` /
|
- מזהה **3–5 דפוסים/פערים** חדשים, כל ממצא מתויג `[סגנון]` / `[מבנה]` /
|
||||||
`[לקסיקון משפטי]` / `[טבלאי]` (`hermes-curator.md:99-108`).
|
`[לקסיקון משפטי]` / `[טבלאי]` (`hermes-curator.md:99-108`).
|
||||||
- **מציע** — comment ב-Paperclip + רישום כל ממצא כ-`decision_lesson` דרך
|
- **מציע** — comment + interaction בערוץ-הפלטפורמה, וגם **רושם כל ממצא מבנית** כ-`decision_lesson`
|
||||||
`POST /api/training/corpus/{corpus_id}/lessons` (`source:"curator"`) שמופיע ב-UI
|
(`source='curator'`, `review_status='proposed'`) דרך כלי-ה-MCP `record_curator_findings`
|
||||||
תחת הטאב "מה למדנו" (`hermes-curator.md:73-96`).
|
(`hermes-curator.md` §A.5b → `tools/workflow.py::record_curator_findings` → `db.add_decision_lesson`).
|
||||||
- **אינו מעדכן** קבצים בעצמו (skills/, lessons.py, DB) — רק מציע (`hermes-curator.md:125-130`).
|
הממצאים מופיעים ב-`/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 → ניתוח שבועי → לקחים)
|
### 1.2 לולאת-פידבק-היו"ר (capture → ניתוח שבועי → לקחים)
|
||||||
|
|
||||||
@@ -162,13 +187,12 @@
|
|||||||
|
|
||||||
## 3. Invariants של התחום
|
## 3. Invariants של התחום
|
||||||
|
|
||||||
### INV-LRN1: עדכון-ידע דורש אישור-יו"ר ידני — אין auto-commit (governance →G10)
|
### INV-LRN1: עדכון-ידע דורש שער-יו"ר — **שער מדורג** לפי סיכון (governance →G10)
|
||||||
**כלל:** מנגנוני-הלמידה (Hermes, ניתוח-פידבק שבועי) **מציעים בלבד**. כל שינוי ב-
|
**כלל (מעודכן 2026-06-28, הכרעת-יו"ר):** השער **מדורג לפי סיכון-התוכן**, לא אחיד:
|
||||||
[SKILL.md](../../skills/decision/SKILL.md) או ב-[legal-decision-lessons.md](../legal-decision-lessons.md)
|
- **מהות** (הלכה / תקדים / עובדה / כל שינוי ב-[SKILL.md](../../skills/decision/SKILL.md) או ב-[legal-decision-lessons.md](../legal-decision-lessons.md)) → **שער-קשיח**: בחינה ואישור ידניים של היו"ר/חיים ואז commit ידני — **לעולם לא auto-committed**. פאנל-ההלכות התלת-סוכני מציע בלבד.
|
||||||
מחייב **בחינה ואישור ידניים של היו"ר/חיים** ואז 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, לא אישור-מראש על כל פריט).
|
||||||
Hermes כותב comment + `decision_lesson`, לא קבצים; ה-CEO השבועי כותב לקובץ אך הצעותיו
|
|
||||||
מאומתות ידנית לפני קיבוע. זהו פֶּאֶט של [INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
זהו פֶּאֶט של [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;
|
**מקורות:** 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) ·
|
never replace human judgment) · Council of Europe / CEPEJ (2018, under user control) ·
|
||||||
Federal Judicial Center — *Judicial Writing Manual* (2d ed.) | סטטוס: verified
|
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).
|
החלטת-יו"ר 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. הג'ובים המתוזמנים (תמיכת-תשתית ללולאה)
|
## 4. הג'ובים המתוזמנים (תמיכת-תשתית ללולאה)
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ Paperclip בקונפליקט (project-specific מנצח default), אך אינו
|
|||||||
ל-`SKILL.md`/`lessons.md` — מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
|
ל-`SKILL.md`/`lessons.md` — מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
|
||||||
- **company_id פר-סוכן.** כל שורה בטבלה מיוצגת פעמיים (CMP + CMPA); ה-CEO לכל חברה שונה
|
- **company_id פר-סוכן.** כל שורה בטבלה מיוצגת פעמיים (CMP + CMPA); ה-CEO לכל חברה שונה
|
||||||
([X2 §1](X2-multi-company.md)). הסוכן פועל רק בטווח-החברה שלו ([X2 §2](X2-multi-company.md)).
|
([X2 §1](X2-multi-company.md)). הסוכן פועל רק בטווח-החברה שלו ([X2 §2](X2-multi-company.md)).
|
||||||
|
- **שטן-מליץ (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 מול הוראות
|
### 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).
|
**אכיפה:** בדיקת-עקביות 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א).
|
**הפרה ידועה:** — (טופל ב-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)
|
## 5. חיווט הספ לסוכנים — בוצע (FU-8b)
|
||||||
|
|||||||
@@ -105,13 +105,36 @@ TanStack Query — *Important Defaults* (staleTime/refetch) (https://tanstack.co
|
|||||||
**אכיפה:** `halacha_backlog` מרונדר ב-/operations (לא נזרק); `findings_approved` (review_status, צרכן אמיתי)
|
**אכיפה:** `halacha_backlog` מרונדר ב-/operations (לא נזרק); `findings_approved` (review_status, צרכן אמיתי)
|
||||||
החליף את `findings_applied` (דגל מת); מוני-סוכנים מסמנים "חלקי" כשחברה לא-נטענה (גל-1 #130).
|
החליף את `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
|
## 3. כללי-עיצוב (Design Rules) — נגזרים מה-invariants
|
||||||
- **SSoT ל-enums/תוויות/tones:** כל enum (CaseStatus, PracticeArea, AppealSubtype, DocType, outcome) +
|
- **SSoT ל-enums/תוויות/tones:** כל enum (CaseStatus, PracticeArea, AppealSubtype, DocType, outcome) +
|
||||||
תוויותיו + צבעיו מוגדרים **פעם אחת** ונצרכים מיבוא — לא משוכפלים בין דפים/רכיבים (מופע UI1/G2).
|
תוויותיו + צבעיו מוגדרים **פעם אחת** ונצרכים מיבוא — לא משוכפלים בין דפים/רכיבים (מופע 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)).
|
- **חוזי-טופס:** ראה INV-UI6 ([X8](X8-field-provenance.md)).
|
||||||
|
|
||||||
הממצאים הקונקרטיים (כפילויות, הגדרות-שגויות, redundancy) ב-[ui-audit.md](ui-audit.md); התיקון — **FU-10**.
|
הממצאים הקונקרטיים (כפילויות, הגדרות-שגויות, redundancy) ב-[ui-audit.md](ui-audit.md); התיקון — **FU-10**.
|
||||||
|
|||||||
@@ -232,3 +232,25 @@ FU-7 אחרי FU-1; FU-5 אחרי FU-2; FU-8 אחרי ייצוב-הספ. **(מח
|
|||||||
אחרי FU-1. FU-11 (מילוי-שדות) עצמאי. FU-13 (סוכנים+skills) אחרי ייצוב-הספ.
|
אחרי FU-1. FU-11 (מילוי-שדות) עצמאי. FU-13 (סוכנים+skills) אחרי ייצוב-הספ.
|
||||||
**סיווג:** pure-code — FU-9/10/11/13/14; +data-migration קל — FU-12; +chair-decision — FU-15 (rotation סודות).
|
**סיווג:** pure-code — FU-9/10/11/13/14; +data-migration קל — FU-12; +chair-decision — FU-15 (rotation סודות).
|
||||||
priority בפועל — של היו"ר.
|
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_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"))
|
HALACHA_CANONICAL_SYNTH_DRIFT_FLOOR = float(os.environ.get("HALACHA_CANONICAL_SYNTH_DRIFT_FLOOR", "0.80"))
|
||||||
|
|
||||||
# Google Cloud Vision (OCR for scanned PDFs)
|
# Lesson synthesis (#158 / INV-LRN8) — mirrors the canonical-halacha synthesis above
|
||||||
GOOGLE_CLOUD_VISION_API_KEY = os.environ.get("GOOGLE_CLOUD_VISION_API_KEY", "")
|
# 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 directory
|
||||||
DATA_DIR = Path(os.environ.get("DATA_DIR", str(Path.home() / "legal-ai" / "data")))
|
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
|
# External service allowlist — case materials may ONLY be sent to these domains
|
||||||
ALLOWED_EXTERNAL_SERVICES = {
|
ALLOWED_EXTERNAL_SERVICES = {
|
||||||
"api.voyageai.com", # Voyage AI (embeddings)
|
"api.voyageai.com", # Voyage AI (embeddings)
|
||||||
"vision.googleapis.com", # Google Cloud Vision (OCR)
|
"api.mistral.ai", # Mistral OCR (scanned PDFs)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Audit
|
# Audit
|
||||||
|
|||||||
@@ -9,13 +9,18 @@ from uuid import UUID
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
# Core case lifecycle — kept in sync with STATUS_ORDER in tools/cases.py and the
|
# Core case lifecycle. The canonical key set, order, labels, phases and (future)
|
||||||
# frontend SSoT web-ui/src/lib/api/case-status.ts. Trimmed from 17 → 10 (the
|
# per-status actions all live in ONE place — legal_mcp/case_status_model.py —
|
||||||
# decorative mid-stage markers that no pipeline code ever set were removed).
|
# 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):
|
class CaseStatus(str, enum.Enum):
|
||||||
NEW = "new"
|
NEW = "new"
|
||||||
PROCESSING = "processing"
|
PROCESSING = "processing"
|
||||||
DOCUMENTS_READY = "documents_ready"
|
DOCUMENTS_READY = "documents_ready"
|
||||||
|
ANALYST_VERIFIED = "analyst_verified"
|
||||||
|
RESEARCH_COMPLETE = "research_complete"
|
||||||
OUTCOME_SET = "outcome_set"
|
OUTCOME_SET = "outcome_set"
|
||||||
DIRECTION_APPROVED = "direction_approved"
|
DIRECTION_APPROVED = "direction_approved"
|
||||||
QA_REVIEW = "qa_review"
|
QA_REVIEW = "qa_review"
|
||||||
|
|||||||
@@ -572,6 +572,18 @@ async def get_legal_arguments(
|
|||||||
return await la_tools.get_legal_arguments(case_number, party)
|
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
|
# References
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def extract_references(
|
async def extract_references(
|
||||||
@@ -744,6 +756,29 @@ async def get_appraiser_facts(case_number: str) -> str:
|
|||||||
return await drafting.get_appraiser_facts(case_number)
|
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) — מרשם-התכניות ─────────────────
|
# ── Planning-schemes registry (V38) — מרשם-התכניות ─────────────────
|
||||||
# SSOT לזהות+תוקף של תכנית, נעשה שימוש חוזר בין תיקים (G2). פלט-LLM נכנס
|
# SSOT לזהות+תוקף של תכנית, נעשה שימוש חוזר בין תיקים (G2). פלט-LLM נכנס
|
||||||
# pending_review וממתין לאישור-יו"ר (plan_review, G10) לפני שמשמש בבלוק ט.
|
# pending_review וממתין לאישור-יו"ר (plan_review, G10) לפני שמשמש בבלוק ט.
|
||||||
@@ -814,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)
|
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()
|
@mcp.tool()
|
||||||
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
||||||
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""
|
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""
|
||||||
@@ -1176,6 +1217,25 @@ async def list_chair_feedback(
|
|||||||
return await workflow.list_chair_feedback(case_number, category, unresolved_only, _clamp_limit(limit))
|
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()
|
@mcp.tool()
|
||||||
async def halacha_corroboration(halacha_id: str) -> dict:
|
async def halacha_corroboration(halacha_id: str) -> dict:
|
||||||
"""החזר את ה-corroboration של הלכה: הציטוטים שמתקפים אותה, הטיפול, וסיכום (X11, read-only)."""
|
"""החזר את ה-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:
|
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 ("רקע", "דיון והכרעה"…)
|
The template ships with example paragraphs ("רקע", "דיון והכרעה"…) *and
|
||||||
that we don't want in the output. Section properties (sectPr) are
|
three sample tables* (header / panel / signatures). Removing only ``w:p``
|
||||||
kept so page size / margins / RTL / footer remain intact.
|
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
|
body = doc.element.body
|
||||||
for p in list(body.findall(qn("w:p"))):
|
sectPr_tag = qn("w:sectPr")
|
||||||
body.remove(p)
|
for child in list(body):
|
||||||
# Leave sectPr alone — it carries page setup including bidi.
|
if child.tag != sectPr_tag:
|
||||||
|
body.remove(child)
|
||||||
|
|
||||||
|
|
||||||
def _add_paragraph(doc: DocumentT, text: str, style: str) -> Paragraph:
|
def _add_paragraph(doc: DocumentT, text: str, style: str) -> Paragraph:
|
||||||
@@ -386,9 +391,9 @@ def _group_precedents(
|
|||||||
return case_level, by_section
|
return case_level, by_section
|
||||||
|
|
||||||
|
|
||||||
def _next_version(export_dir: Path) -> int:
|
def _next_version(export_dir: Path, prefix: str = "ניתוח-משפטי") -> int:
|
||||||
"""Return the next version number for ניתוח-משפטי-v{N}.docx."""
|
"""Return the next version number for {prefix}-v{N}.docx in export_dir."""
|
||||||
existing = sorted(export_dir.glob("ניתוח-משפטי-v*.docx"))
|
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
|
||||||
next_ver = 1
|
next_ver = 1
|
||||||
for p in existing:
|
for p in existing:
|
||||||
try:
|
try:
|
||||||
@@ -511,3 +516,77 @@ async def build_analysis_docx(case_number: str) -> Path:
|
|||||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
)
|
)
|
||||||
return out_path
|
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 enum values mirror the DB CHECK constraints.
|
||||||
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
|
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"}
|
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
|
||||||
|
|
||||||
# Hebrew labels for the prompt (Claude needs context in the same
|
# 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."""
|
"""Compose the per-party aggregation prompt."""
|
||||||
n = len(propositions)
|
n = len(propositions)
|
||||||
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
|
# 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))
|
target_max = max(target_min + 1, min(12, n // 2 + 1))
|
||||||
|
|
||||||
party_he = PARTY_LABELS_HE.get(party, party)
|
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
|
# Strip noise from propositions for the prompt — Claude only needs
|
||||||
# the id and the text to do the grouping.
|
# the id and the text to do the grouping.
|
||||||
compact = [
|
compact = [
|
||||||
@@ -139,12 +148,17 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
async def _aggregate_party(
|
async def _aggregate_party(
|
||||||
party: str, propositions: list[dict],
|
party: str, propositions: list[dict], party_name: str = "",
|
||||||
) -> list[dict]:
|
) -> 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:
|
if not propositions:
|
||||||
return []
|
return []
|
||||||
prompt = _build_prompt(party, propositions)
|
prompt = _build_prompt(party, propositions, party_name=party_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
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.
|
# Pull all claims for this case, grouped by party.
|
||||||
rows = await conn.fetch(
|
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
|
FROM claims
|
||||||
WHERE case_id = $1
|
WHERE case_id = $1
|
||||||
ORDER BY party_role, claim_index""",
|
ORDER BY party_role, claim_index""",
|
||||||
@@ -221,22 +235,47 @@ async def aggregate_claims_to_arguments(
|
|||||||
"total": 0,
|
"total": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Group propositions by party.
|
# Group propositions by party — and, for the multi-party sides (respondent /
|
||||||
by_party: dict[str, list[dict]] = {}
|
# 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:
|
for r in rows:
|
||||||
party = r["party_role"]
|
party = r["party_role"]
|
||||||
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
|
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
|
||||||
if party not in ALLOWED_PARTIES:
|
if party not in ALLOWED_PARTIES:
|
||||||
party = "unknown"
|
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] = {}
|
party_counts: dict[str, int] = {}
|
||||||
inserted = 0
|
inserted = 0
|
||||||
errors: list[str] = []
|
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:
|
try:
|
||||||
arguments = await _aggregate_party(party, props)
|
arguments = await _aggregate_party(party, props, party_name=party_name)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
# Most likely cause: Claude CLI not installed (running from
|
# Most likely cause: Claude CLI not installed (running from
|
||||||
# the container). Don't crash — record the gap and continue.
|
# the container). Don't crash — record the gap and continue.
|
||||||
@@ -250,11 +289,11 @@ async def aggregate_claims_to_arguments(
|
|||||||
),
|
),
|
||||||
"total": 0,
|
"total": 0,
|
||||||
}
|
}
|
||||||
errors.append(f"{party}: {msg}")
|
errors.append(f"{group_key}: {msg}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not arguments:
|
if not arguments:
|
||||||
party_counts[party] = 0
|
party_counts[group_key] = 0
|
||||||
continue
|
continue
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -262,12 +301,13 @@ async def aggregate_claims_to_arguments(
|
|||||||
for idx, arg in enumerate(arguments):
|
for idx, arg in enumerate(arguments):
|
||||||
arg_id = await conn.fetchval(
|
arg_id = await conn.fetchval(
|
||||||
"""INSERT INTO legal_arguments
|
"""INSERT INTO legal_arguments
|
||||||
(case_id, party, argument_index, argument_title,
|
(case_id, party, party_name, argument_index,
|
||||||
argument_body, legal_topic, priority)
|
argument_title, argument_body, legal_topic, priority)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id""",
|
RETURNING id""",
|
||||||
case_id,
|
case_id,
|
||||||
party,
|
party,
|
||||||
|
party_name,
|
||||||
idx + 1,
|
idx + 1,
|
||||||
arg["title"],
|
arg["title"],
|
||||||
arg["body"],
|
arg["body"],
|
||||||
@@ -275,7 +315,22 @@ async def aggregate_claims_to_arguments(
|
|||||||
arg["priority"],
|
arg["priority"],
|
||||||
)
|
)
|
||||||
for cid in arg["claim_ids"]:
|
for cid in arg["claim_ids"]:
|
||||||
|
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:
|
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(
|
await conn.execute(
|
||||||
"""INSERT INTO legal_argument_propositions
|
"""INSERT INTO legal_argument_propositions
|
||||||
(argument_id, claim_id)
|
(argument_id, claim_id)
|
||||||
@@ -284,14 +339,12 @@ async def aggregate_claims_to_arguments(
|
|||||||
arg_id, cid,
|
arg_id, cid,
|
||||||
)
|
)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
# Likely FK violation if the LLM hallucinated
|
|
||||||
# a claim_id. Log and continue.
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"argument_aggregator: skipped bad claim_id %s for arg %s: %s",
|
"argument_aggregator: skipped bad claim_id %s for arg %s: %s",
|
||||||
cid, arg_id, e,
|
cid, arg_id, e,
|
||||||
)
|
)
|
||||||
inserted += 1
|
inserted += 1
|
||||||
party_counts[party] = len(arguments)
|
party_counts[group_key] = len(arguments)
|
||||||
|
|
||||||
result: dict = {
|
result: dict = {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
@@ -316,9 +369,9 @@ async def get_legal_arguments(
|
|||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
if party and party in ALLOWED_PARTIES:
|
if party and party in ALLOWED_PARTIES:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""SELECT id, case_id, party, argument_index, argument_title,
|
"""SELECT id, case_id, party, party_name, argument_index,
|
||||||
argument_body, legal_topic, priority, cited_precedents,
|
argument_title, argument_body, legal_topic, priority,
|
||||||
created_at, updated_at
|
cited_precedents, created_at, updated_at
|
||||||
FROM legal_arguments
|
FROM legal_arguments
|
||||||
WHERE case_id = $1 AND party = $2
|
WHERE case_id = $1 AND party = $2
|
||||||
ORDER BY priority, argument_index""",
|
ORDER BY priority, argument_index""",
|
||||||
@@ -326,12 +379,12 @@ async def get_legal_arguments(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""SELECT id, case_id, party, argument_index, argument_title,
|
"""SELECT id, case_id, party, party_name, argument_index,
|
||||||
argument_body, legal_topic, priority, cited_precedents,
|
argument_title, argument_body, legal_topic, priority,
|
||||||
created_at, updated_at
|
cited_precedents, created_at, updated_at
|
||||||
FROM legal_arguments
|
FROM legal_arguments
|
||||||
WHERE case_id = $1
|
WHERE case_id = $1
|
||||||
ORDER BY party, priority, argument_index""",
|
ORDER BY party, party_name, priority, argument_index""",
|
||||||
case_id,
|
case_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from legal_mcp.services import db
|
from legal_mcp.services import db
|
||||||
@@ -38,7 +38,7 @@ async def log_action(
|
|||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
|
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
|
||||||
uuid4(), action, case_id, document_id,
|
uuid4(), action, case_id, document_id,
|
||||||
json.dumps(details or {}, ensure_ascii=False, default=str),
|
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,
|
logger.info("AUDIT: %s | case=%s | user=%s | %s", action, case_id, user,
|
||||||
json.dumps(details or {}, ensure_ascii=False)[:200])
|
json.dumps(details or {}, ensure_ascii=False)[:200])
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ from pathlib import Path
|
|||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import db, embeddings, claude_session, audit, storage
|
from legal_mcp.services import db, embeddings, claude_session, audit, storage
|
||||||
from legal_mcp.services.lessons import (
|
from legal_mcp.services.lessons import (
|
||||||
|
ANTI_PATTERNS as _ANTI_PATTERNS,
|
||||||
OUTCOME_LABELS_HE,
|
OUTCOME_LABELS_HE,
|
||||||
PRACTICE_AREA_OVERRIDES,
|
PRACTICE_AREA_OVERRIDES,
|
||||||
|
anti_pattern_directive,
|
||||||
canonical_outcome,
|
canonical_outcome,
|
||||||
get_content_checklist,
|
get_content_checklist,
|
||||||
get_methodology_summary,
|
get_methodology_summary,
|
||||||
@@ -35,29 +37,65 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# ── Block configuration ───────────────────────────────────────────
|
# ── Block configuration ───────────────────────────────────────────
|
||||||
|
|
||||||
# Output token limits per Anthropic docs:
|
# Generation is structurally deterministic (#204 / WS5): every AI block is
|
||||||
# Opus 4.7: up to 128K output tokens (new tokenizer — ~35% more tokens)
|
# pinned to ONE model (Opus 4.8) with a per-block reasoning `effort`. The
|
||||||
# Sonnet 4.6: up to 64K output tokens
|
# generation path is claude_session.query → `claude -p` (subscription, near-zero
|
||||||
# Streaming required when max_tokens > 21,333
|
# 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_CONFIG = {
|
||||||
"block-alef": {"index": 1, "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", "temp": 0, "model": "script"},
|
"block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "model": "script"},
|
||||||
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "temp": 0, "model": "script"},
|
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "model": "script"},
|
||||||
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "temp": 0, "model": "script"},
|
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "model": "script"},
|
||||||
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "temp": 0.2, "model": "sonnet", "max_tokens": 4096},
|
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "model": "ai", "effort": "medium", "max_tokens": 4096},
|
||||||
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 16384},
|
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "model": "ai", "effort": "low", "max_tokens": 16384},
|
||||||
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "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", "temp": 0, "model": "sonnet", "max_tokens": 8192},
|
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 8192},
|
||||||
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "temp": 0.2, "model": "opus", "max_tokens": 16384},
|
"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", "temp": 0.4, "model": "opus", "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", "temp": 0.1, "model": "sonnet", "max_tokens": 8192},
|
"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", "temp": 0, "model": "script"},
|
"block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "model": "script"},
|
||||||
}
|
}
|
||||||
|
|
||||||
MODEL_MAP = {
|
# Default effort when a block lacks an explicit one (defensive; every AI block
|
||||||
"sonnet": "claude-sonnet-4-20250514",
|
# above sets one). High is the safe default per the generation-path reference.
|
||||||
"opus": "claude-opus-4-7",
|
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 (א-ד, יב) ────────────────────────────────────
|
# ── Template blocks (א-ד, יב) ────────────────────────────────────
|
||||||
@@ -120,11 +158,14 @@ TEMPLATE_WRITERS = {
|
|||||||
BLOCK_PROMPTS = {
|
BLOCK_PROMPTS = {
|
||||||
"block-he": """כתוב את בלוק הפתיחה (בלוק ה) של החלטת ועדת ערר.
|
"block-he": """כתוב את בלוק הפתיחה (בלוק ה) של החלטת ועדת ערר.
|
||||||
|
|
||||||
## כללים:
|
## מבנה קבוע (חובה — אותו מבנה בכל תיק):
|
||||||
- פתח ב"לפנינו ערר..." או "עניינה של החלטה זו..."
|
- **המשפט הראשון פותח תמיד במילה "לפנינו"** — נוסח קבוע: "לפנינו ערר על החלטת
|
||||||
- הגדר "להלן" מרכזיים: הוועדה המקומית, התכנית/הבקשה, המגרש
|
[הוועדה המקומית] מיום [תאריך] בעניין [נושא הבקשה/התכנית]." (אל תשתמש ב"עניינה של
|
||||||
- 1-2 סעיפים בלבד
|
החלטה זו" או בכל פתיח חלופי — הפתיח אחיד.)
|
||||||
- אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים
|
- סעיף 1: הצגת הערר במשפט הקבוע לעיל + הגדרת ה"להלן" המרכזיים בסדר קבוע:
|
||||||
|
הוועדה המקומית → התכנית/הבקשה → המגרש/המקרקעין.
|
||||||
|
- סעיף 2 (רק אם נדרש להשלמת "להלן" נוספים): הגדרות-נוספות בלבד.
|
||||||
|
- **בדיוק 1-2 סעיפים.** אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים.
|
||||||
- מספור: 1.
|
- מספור: 1.
|
||||||
|
|
||||||
## פרטי התיק:
|
## פרטי התיק:
|
||||||
@@ -153,16 +194,16 @@ BLOCK_PROMPTS = {
|
|||||||
## כללים קריטיים:
|
## כללים קריטיים:
|
||||||
- **סנתז טענות דומות** — אל תרשום כל טענה בנפרד. קבץ טענות דומות לנושא אחד. למשל: כל הטענות על הודעות → סעיף אחד, כל הטענות על רכוש משותף → סעיף אחד.
|
- **סנתז טענות דומות** — אל תרשום כל טענה בנפרד. קבץ טענות דומות לנושא אחד. למשל: כל הטענות על הודעות → סעיף אחד, כל הטענות על רכוש משותף → סעיף אחד.
|
||||||
- גוף שלישי: "העוררים טוענים כי...", "הוועדה המקומית ציינה כי..."
|
- גוף שלישי: "העוררים טוענים כי...", "הוועדה המקומית ציינה כי..."
|
||||||
- **מבנה קבוע עם 3 חלקים:**
|
- **מבנה: חלק (סעיף עם כותרת-משנה) לכל צד שמופיע בטענות שחולצו למטה** — לפי הכותרות שם (### ...). אל תמציא צדדים שאינם מופיעים ואל תשמיט צד שמופיע.
|
||||||
1. "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא
|
- "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא.
|
||||||
2. "עמדת הוועדה המקומית" — 5-8 סעיפים
|
- **צד-משיב/מתנגד: כתוב חלק נפרד לכל כתב-תשובה** שמופיע (למשל "עמדת משיבות 2-3" ו-"עמדת משיבים 4-6" בנפרד) — 5-10 סעיפים לכל אחד. **אל תמזג משיבים שונים לסעיף אחד**, ואם עמדותיהם מנוגדות — שקף זאת.
|
||||||
3. "עמדת מבקשי ההיתר" (אם יש) — 5-10 סעיפים
|
- "עמדת הוועדה המקומית" / "עמדת מבקשי ההיתר" (אם מופיעים) — 5-8 סעיפים.
|
||||||
- כותרת: "תמצית טענות הצדדים"
|
- כותרת: "תמצית טענות הצדדים"
|
||||||
- נאמנות למקור — לא להמציא טענות, אבל כן לאחד ולסכם טענות חוזרות
|
- נאמנות למקור — לא להמציא טענות, אבל כן לאחד ולסכם טענות חוזרות
|
||||||
- אין ניתוח, אין מסקנות, אין הערכה ("טענה חלשה/חזקה")
|
- אין ניתוח, אין מסקנות, אין הערכה ("טענה חלשה/חזקה")
|
||||||
- רק מכתבי טענות מקוריים (לא השלמות טיעון)
|
- רק מכתבי טענות מקוריים (לא השלמות טיעון)
|
||||||
- מספור רציף
|
- מספור רציף
|
||||||
- **יעד אורך: 800-1500 מילים**
|
- **יעד אורך: 800-1500 מילים** (יותר כשיש כמה כתבי-תשובה נפרדים — כל צד מקבל את מלוא ההתייחסות)
|
||||||
|
|
||||||
## טענות שחולצו (קבץ טענות דומות לנושאים):
|
## טענות שחולצו (קבץ טענות דומות לנושאים):
|
||||||
{claims_context}
|
{claims_context}
|
||||||
@@ -245,6 +286,7 @@ BLOCK_PROMPTS = {
|
|||||||
- **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל"
|
- **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל"
|
||||||
- **מספור רציף** — המשך מספור מהבלוק הקודם
|
- **מספור רציף** — המשך מספור מהבלוק הקודם
|
||||||
- מותרות כותרות-משנה כשיש נושאים נפרדים לחלוטין
|
- מותרות כותרות-משנה כשיש נושאים נפרדים לחלוטין
|
||||||
|
- **צדדים מרובים** — כשיש כמה משיבים/מתנגדים או כתבי-תשובה נפרדים (ראה הכותרות שתחת "טענות" לעיל, למשל "משיבות 2-3" מול "משיבים 4-6"), התייחס לעמדת כל אחד לגופה ואל תמזג אותם ל"טענות המשיבים" גורפות. אם משיבים שונים נוקטים **עמדות מנוגדות זו לזו** — ציין זאת במפורש והכרע ביניהן בנימוק; אם עמדותיהם משלימות — ניתן לאגד תוך שמירת הייחוס.
|
||||||
|
|
||||||
## כיוון מאושר (חובה):
|
## כיוון מאושר (חובה):
|
||||||
{direction_context}
|
{direction_context}
|
||||||
@@ -297,11 +339,39 @@ STRUCTURE_GUIDANCE = {
|
|||||||
"partial_acceptance": "קבלה חלקית — מיפוי מתחים: מה מתקבל ולמה, מה נדחה ולמה, איזון.",
|
"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(
|
async def write_block(
|
||||||
case_id: UUID,
|
case_id: UUID,
|
||||||
block_id: str,
|
block_id: str,
|
||||||
instructions: str = "",
|
instructions: str = "",
|
||||||
|
effort_override: str | None = None,
|
||||||
|
model_override: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""כתיבת בלוק יחיד בהחלטה.
|
"""כתיבת בלוק יחיד בהחלטה.
|
||||||
|
|
||||||
@@ -309,6 +379,17 @@ async def write_block(
|
|||||||
case_id: מזהה התיק
|
case_id: מזהה התיק
|
||||||
block_id: מזהה הבלוק (block-alef, block-he, block-yod, ...)
|
block_id: מזהה הבלוק (block-alef, block-he, block-yod, ...)
|
||||||
instructions: הנחיות נוספות
|
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.
|
||||||
|
model_override: optional per-call generation model id (e.g.
|
||||||
|
"claude-opus-5"). Same contract as effort_override — the #208
|
||||||
|
harness A/Bs MODELS without mutating the pinned GENERATION_MODEL.
|
||||||
|
Pass the BASE id only: the 1M-context escalation (#216) is applied
|
||||||
|
on top automatically for large prompts, so an override never
|
||||||
|
silently loses the 1M window. Production callers leave it None.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict עם content, word_count, block_id, generation_type
|
dict עם content, word_count, block_id, generation_type
|
||||||
@@ -352,12 +433,8 @@ async def write_block(
|
|||||||
post_hearing_context = await _build_post_hearing_context(case_id)
|
post_hearing_context = await _build_post_hearing_context(case_id)
|
||||||
|
|
||||||
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
|
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
|
||||||
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
structure_guidance = await _structure_guidance_with_corpus(
|
||||||
if case.get("practice_area") == "betterment_levy":
|
outcome, case.get("practice_area", ""))
|
||||||
structure_guidance = (
|
|
||||||
structure_guidance + " | היטל השבחה: "
|
|
||||||
+ " ".join(PRACTICE_AREA_OVERRIDES["betterment_levy"]["discussion_rules"])
|
|
||||||
).strip()
|
|
||||||
|
|
||||||
# Content checklist — tells block-yod WHAT topics to cover
|
# Content checklist — tells block-yod WHAT topics to cover
|
||||||
content_checklist = ""
|
content_checklist = ""
|
||||||
@@ -400,39 +477,69 @@ async def write_block(
|
|||||||
if instructions:
|
if instructions:
|
||||||
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||||
|
|
||||||
|
# LAST in the prompt, deliberately (see lessons.anti_pattern_directive): the
|
||||||
|
# same canonical rule already appears inside style_context, but ~47K chars
|
||||||
|
# deep, where it measurably fails to bind. Restating it here is the only
|
||||||
|
# change the A/B isolated as effective — so nothing may be appended after it.
|
||||||
|
prompt += "\n\n" + anti_pattern_directive()
|
||||||
|
|
||||||
# Block י requires approved direction
|
# Block י requires approved direction
|
||||||
if block_id == "block-yod":
|
if block_id == "block-yod":
|
||||||
dir_doc = (decision or {}).get("direction_doc") or {}
|
dir_doc = (decision or {}).get("direction_doc") or {}
|
||||||
if not dir_doc.get("approved"):
|
if not dir_doc.get("approved"):
|
||||||
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
||||||
|
|
||||||
# Guard against context overflow before calling claude -p.
|
# Pick the model build by prompt size (#216). Above the 200K-token wall we
|
||||||
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
|
# escalate to the 1M-context build (`[1m]`) instead of failing the block —
|
||||||
# In practice the CLI has crashed on prompts above ~400K chars, so use
|
# block-yod legitimately carries the whole case as source-context. The 400K
|
||||||
# that as a conservative ceiling (well below the token limit).
|
# ceiling was an artifact of the old 200K-only build, NOT a model limit.
|
||||||
_MAX_PROMPT_CHARS = 400_000
|
# model_override (#208 harness) swaps the BASE id only — the 1M decision below
|
||||||
|
# still applies, so an A/B'd model keeps the same context-window behaviour as
|
||||||
|
# the pinned default instead of silently falling back to the 200K build.
|
||||||
|
_base_model = model_override or GENERATION_MODEL
|
||||||
|
_model_1m = GENERATION_MODEL_1M if _base_model == GENERATION_MODEL else f"{_base_model}[1m]"
|
||||||
|
gen_model = _model_1m if len(prompt) > _CTX_1M_THRESHOLD_CHARS else _base_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:
|
if len(prompt) > _MAX_PROMPT_CHARS:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Prompt too large for {block_id}: {len(prompt):,} chars "
|
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"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)
|
# Call Claude via Claude Code session (no API). #204: pin the model + per-block
|
||||||
model_key = block_cfg["model"]
|
# reasoning effort so generation is structurally deterministic — these were
|
||||||
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
# previously NOT forwarded (the source of inconsistency). model/effort flow
|
||||||
content = await claude_session.query(prompt, timeout=timeout, tools="") # prose gen — no tool_use → no error_max_turns
|
# 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 = await _collect_block_sources(case_id, block_id)
|
||||||
sources["case_law_ids"] = _precedent_case_law_ids
|
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
|
result["sources"] = sources
|
||||||
return result
|
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())
|
word_count = len(content.split())
|
||||||
|
is_ai = block_cfg["model"] == "ai"
|
||||||
return {
|
return {
|
||||||
"block_id": block_id,
|
"block_id": block_id,
|
||||||
"block_index": block_cfg["index"],
|
"block_index": block_cfg["index"],
|
||||||
@@ -440,8 +547,15 @@ def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
|||||||
"content": content,
|
"content": content,
|
||||||
"word_count": word_count,
|
"word_count": word_count,
|
||||||
"generation_type": block_cfg["gen_type"],
|
"generation_type": block_cfg["gen_type"],
|
||||||
"model_used": block_cfg["model"],
|
# AI blocks record the model build actually used (200K or [1m], #216);
|
||||||
"temperature": block_cfg["temp"],
|
# 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 +636,63 @@ async def _build_claims_context(case_id: UUID) -> str:
|
|||||||
return "(לא חולצו טענות)"
|
return "(לא חולצו טענות)"
|
||||||
|
|
||||||
# Filter out claims from block-zayin (decision summary) — use only
|
# Filter out claims from block-zayin (decision summary) — use only
|
||||||
# claims extracted from original pleadings (appeal, response, etc.)
|
# claims extracted from original pleadings (appeal, response, etc.).
|
||||||
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"]
|
# 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:
|
if not source_claims:
|
||||||
# Fallback to all claims if no source claims exist
|
# Fallback to all non-block-zayin claims if no source claims exist.
|
||||||
source_claims = claims
|
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": "טענות המשיבים",
|
role_heb = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
|
||||||
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
|
"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:
|
for c in source_claims:
|
||||||
if c["party_role"] != current_role:
|
role = c.get("party_role", "") or ""
|
||||||
current_role = c["party_role"]
|
# Prefer the stored party_name (stamped by the extractor, #224); fall
|
||||||
lines.append(f"\n### {role_heb.get(current_role, current_role)}")
|
# 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
|
claim_num += 1
|
||||||
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
|
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
|
||||||
lines.append(f"\n**סה\"כ {claim_num} טענות. ענה על כל טענה מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**")
|
lines.append(
|
||||||
|
f"\n**סה\"כ {claim_num} טענות. שמור על ההפרדה בין הצדדים לעיל — "
|
||||||
|
f"לכל צד/כתב-תשובה עמדה נפרדת (אל תמזג משיבים שונים). ענה על כל טענה "
|
||||||
|
f"מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**"
|
||||||
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -974,6 +1127,16 @@ async def _build_style_context(practice_area: str = "") -> str:
|
|||||||
# ── למידה מצטברת (T15) — עריכות היו"ר ב-/methodology + לקחי /training ──
|
# ── למידה מצטברת (T15) — עריכות היו"ר ב-/methodology + לקחי /training ──
|
||||||
# גובר על ברירות-המחדל לעיל. כך כל מה שלמדנו עד היום מגיע לכותב.
|
# גובר על ברירות-המחדל לעיל. כך כל מה שלמדנו עד היום מגיע לכותב.
|
||||||
learned: list[str] = []
|
learned: list[str] = []
|
||||||
|
# The canonical anti-patterns are rendered UNCONDITIONALLY, before any DB
|
||||||
|
# call. They used to be produced inside the overrides loop below — so a
|
||||||
|
# failure on an EARLIER category (e.g. golden_ratios) aborted the loop and
|
||||||
|
# dropped the style invariants from the prompt silently, with only a generic
|
||||||
|
# "overrides not loaded" warning to show for it (§6). A chair-override
|
||||||
|
# outage must not be able to un-teach Dafna's structural style.
|
||||||
|
learned.append("\n**אנטי-דפוסים (להימנע) — כתוב נרטיב משפטי רציף; הימנע מ:**")
|
||||||
|
for ap in _ANTI_PATTERNS:
|
||||||
|
learned.append(f"- {ap['note']}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for cat, label in (
|
for cat, label in (
|
||||||
("golden_ratios", "יחסי-זהב (אחוזי-סעיפים)"),
|
("golden_ratios", "יחסי-זהב (אחוזי-סעיפים)"),
|
||||||
@@ -983,6 +1146,20 @@ async def _build_style_context(practice_area: str = "") -> str:
|
|||||||
("anti_patterns", "אנטי-דפוסים (להימנע)"),
|
("anti_patterns", "אנטי-דפוסים (להימנע)"),
|
||||||
):
|
):
|
||||||
ov = await db.get_methodology_overrides(cat)
|
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.
|
||||||
|
# The canonical list is already rendered above, outside this try —
|
||||||
|
# here we only layer the chair's ADDITIONS on top of it.
|
||||||
|
for k, v in (ov or {}).items():
|
||||||
|
learned.append(f"- (יו\"ר) {k}: {json.dumps(v, ensure_ascii=False)}")
|
||||||
|
continue
|
||||||
if ov:
|
if ov:
|
||||||
learned.append(f"\n**{label} — ערכי היו\"ר (גוברים על ברירת-המחדל):**")
|
learned.append(f"\n**{label} — ערכי היו\"ר (גוברים על ברירת-המחדל):**")
|
||||||
for k, v in ov.items():
|
for k, v in ov.items():
|
||||||
@@ -990,7 +1167,8 @@ async def _build_style_context(practice_area: str = "") -> str:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("methodology overrides not loaded: %s", e)
|
logger.warning("methodology overrides not loaded: %s", e)
|
||||||
try:
|
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:
|
if lessons:
|
||||||
learned.append("\n**לקחים מהחלטות קודמות (decision_lessons):**")
|
learned.append("\n**לקחים מהחלטות קודמות (decision_lessons):**")
|
||||||
for ls in lessons:
|
for ls in lessons:
|
||||||
@@ -1078,12 +1256,8 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
|
|||||||
post_hearing_context = await _build_post_hearing_context(case_id)
|
post_hearing_context = await _build_post_hearing_context(case_id)
|
||||||
|
|
||||||
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
|
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
|
||||||
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
structure_guidance = await _structure_guidance_with_corpus(
|
||||||
if case.get("practice_area") == "betterment_levy":
|
outcome, case.get("practice_area", ""))
|
||||||
structure_guidance = (
|
|
||||||
structure_guidance + " | היטל השבחה: "
|
|
||||||
+ " ".join(PRACTICE_AREA_OVERRIDES["betterment_levy"]["discussion_rules"])
|
|
||||||
).strip()
|
|
||||||
|
|
||||||
# Content checklist + methodology for block-yod
|
# Content checklist + methodology for block-yod
|
||||||
content_checklist = ""
|
content_checklist = ""
|
||||||
@@ -1118,6 +1292,12 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
|
|||||||
if instructions:
|
if instructions:
|
||||||
formatted_prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
formatted_prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||||
|
|
||||||
|
# Same closing directive, same position, same canonical source as write_block.
|
||||||
|
# This is the EXTERNAL-writer path (legal-writer agent) — if the rule were
|
||||||
|
# applied only in write_block, agent-written blocks would keep emitting the
|
||||||
|
# anti-patterns and the two writers would drift apart (G2).
|
||||||
|
formatted_prompt += "\n\n" + anti_pattern_directive()
|
||||||
|
|
||||||
# Block י requires approved direction
|
# Block י requires approved direction
|
||||||
if block_id == "block-yod":
|
if block_id == "block-yod":
|
||||||
dir_doc = (decision or {}).get("direction_doc") or {}
|
dir_doc = (decision or {}).get("direction_doc") or {}
|
||||||
|
|||||||
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]] = []
|
markers: list[tuple[int, str]] = []
|
||||||
|
|
||||||
for pattern, section_type in SECTION_PATTERNS:
|
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.
|
# 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):
|
for match in re.finditer(anchored, text, re.MULTILINE):
|
||||||
markers.append((match.start(), section_type))
|
markers.append((match.start(), section_type))
|
||||||
|
|
||||||
|
|||||||
@@ -227,8 +227,15 @@ def _infer_claim_type(doc_type: str, source_name: str) -> str:
|
|||||||
- 'claim' = from appeal documents (כתב ערר)
|
- 'claim' = from appeal documents (כתב ערר)
|
||||||
- 'response' = from original response documents (כתב תשובה)
|
- 'response' = from original response documents (כתב תשובה)
|
||||||
- 'reply' = from supplementary responses (תגובה, השלמת טיעון)
|
- 'reply' = from supplementary responses (תגובה, השלמת טיעון)
|
||||||
|
- 'protocol' = oral arguments raised at the hearing (פרוטוקול דיון)
|
||||||
"""
|
"""
|
||||||
name_lower = source_name.lower() if source_name else ""
|
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:
|
if doc_type == "appeal" or "כתב ערר" in name_lower:
|
||||||
return "claim"
|
return "claim"
|
||||||
if "כתב תשובה" in name_lower:
|
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)
|
claims = await extract_claims_with_ai(text, doc_type, party_hint)
|
||||||
|
|
||||||
if not claims:
|
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}
|
return {"status": "no_claims", "total": 0, "source": source_name}
|
||||||
|
|
||||||
# Determine claim_type from document type and title
|
# Determine claim_type from document type and title
|
||||||
claim_type = _infer_claim_type(doc_type, source_name)
|
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:
|
for c in claims:
|
||||||
c["claim_type"] = claim_type
|
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)
|
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
|
# Summarize by role
|
||||||
role_counts: dict[str, int] = {}
|
role_counts: dict[str, int] = {}
|
||||||
|
|||||||
@@ -146,8 +146,15 @@ async def query(
|
|||||||
"""
|
"""
|
||||||
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
||||||
|
|
||||||
if len(full_prompt) > 150_000:
|
# ~350K chars ≈ the 200K-token wall of the default Opus build (~2 Hebrew
|
||||||
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
|
# 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 = [
|
cmd = [
|
||||||
"claude", "-p",
|
"claude", "-p",
|
||||||
|
|||||||
@@ -18,6 +18,43 @@ from legal_mcp.services import court_citation, halacha_quality, principles
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Primary-document classification (WS2 / task #200) ────────────────
|
||||||
|
# Canonical, single-source list of "primary" (מסמך עיקרי) doc_types — the
|
||||||
|
# substantive case documents the chair tracks for analysis (appeal, the
|
||||||
|
# replies/objections, the hearing protocol, the appraisal, and the
|
||||||
|
# committee decision). Every OTHER doc_type (plan, permit, court_decision,
|
||||||
|
# exhibit, reference…) is secondary. Chair-approved set (plan WS2 §1).
|
||||||
|
#
|
||||||
|
# G1/G2/INV-DM7: `is_primary` is DERIVED from `doc_type`, never an
|
||||||
|
# independently-written column. There is ONE source of truth: this tuple.
|
||||||
|
# The DB column `documents.is_primary` is a GENERATED ALWAYS … STORED column
|
||||||
|
# (V47) computed by Postgres from `doc_type`, so it can never drift; the
|
||||||
|
# Python helper below mirrors the same list for read-time derivation and for
|
||||||
|
# building the generated-column expression. No parallel write path.
|
||||||
|
PRIMARY_DOC_TYPES: tuple[str, ...] = (
|
||||||
|
"appeal", # כתב-ערר
|
||||||
|
"response", # תשובה / תגובה
|
||||||
|
"objection", # התנגדות
|
||||||
|
"protocol", # פרוטוקול-דיון
|
||||||
|
"appraisal", # שומה
|
||||||
|
"decision", # החלטת-ועדה
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_primary_doc_type(doc_type: str | None) -> bool:
|
||||||
|
"""Whether a doc_type is a 'primary' (מסמך עיקרי) document. Single source
|
||||||
|
of truth = PRIMARY_DOC_TYPES; mirrors the V47 generated column."""
|
||||||
|
return doc_type in PRIMARY_DOC_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_doc_types_sql_array() -> str:
|
||||||
|
"""SQL ARRAY[...] literal of PRIMARY_DOC_TYPES for the generated column.
|
||||||
|
Values are fixed identifiers from this module (no user input) — safe to
|
||||||
|
inline; kept derived from the tuple so the list has ONE definition."""
|
||||||
|
quoted = ", ".join("'" + t.replace("'", "''") + "'" for t in PRIMARY_DOC_TYPES)
|
||||||
|
return f"ARRAY[{quoted}]::text[]"
|
||||||
|
|
||||||
|
|
||||||
_pool: asyncpg.Pool | None = None
|
_pool: asyncpg.Pool | None = None
|
||||||
_schema_ready: bool = False
|
_schema_ready: bool = False
|
||||||
_init_lock: asyncio.Lock = asyncio.Lock()
|
_init_lock: asyncio.Lock = asyncio.Lock()
|
||||||
@@ -1731,6 +1768,170 @@ UPDATE case_law SET district = 'חיפה'
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_V44_SQL = """
|
||||||
|
-- Citation-verification panel (#154): the chair verifies each corpus precedent
|
||||||
|
-- against the specific legal ARGUMENT before the writer may cite it (INV-AH gate).
|
||||||
|
-- case_precedents gains: the argument it supports, the resolved case_law row
|
||||||
|
-- (for the cited_by authority signal + dedup), a verified flag (the gate), and a
|
||||||
|
-- verification timestamp. chair_note already exists. All nullable so legacy
|
||||||
|
-- section-scoped rows stay valid.
|
||||||
|
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS argument_id UUID
|
||||||
|
REFERENCES legal_arguments(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS case_law_id UUID
|
||||||
|
REFERENCES case_law(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS verified BOOLEAN DEFAULT false;
|
||||||
|
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS verified_at TIMESTAMPTZ;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_case_precedents_argument ON case_precedents(argument_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# V45 (Path A — prospective held-out): a style-distance snapshot captured at
|
||||||
|
# final-upload time, BEFORE this case's lessons are folded. Because the draft was
|
||||||
|
# written with only the PRIOR lesson pool, each row is a clean generalization
|
||||||
|
# datapoint: "with N accumulated lessons, our draft on this unseen case scored X".
|
||||||
|
# pool_* record how much voice-learning had accumulated when the draft was made,
|
||||||
|
# so the trend (style-distance vs pool size / time) shows whether learning
|
||||||
|
# generalizes. Append-only; one row per final upload. (07-learning §0, INV-LRN4.)
|
||||||
|
SCHEMA_V45_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS style_distance_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
case_number TEXT NOT NULL,
|
||||||
|
pair_id UUID REFERENCES draft_final_pairs(id) ON DELETE SET NULL,
|
||||||
|
measured_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
pool_discussion_rules INT DEFAULT 0,
|
||||||
|
pool_transition_phrases INT DEFAULT 0,
|
||||||
|
anti_pattern_total INT,
|
||||||
|
ratio_max_deviation REAL,
|
||||||
|
change_percent REAL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_style_distance_history_measured ON style_distance_history(measured_at);
|
||||||
|
"""
|
||||||
|
|
||||||
|
SCHEMA_V46_SQL = """
|
||||||
|
-- decision_lessons synthesis (#158 / INV-LRN8): consolidate overlapping lessons into
|
||||||
|
-- one richer "super-lesson" (source='synthesis', synthesized_from=[merged ids]); the
|
||||||
|
-- merged sources flip review_status='superseded' (kept as provenance, no longer fed to
|
||||||
|
-- the writer). embedding powers cosine clustering + the drift guard. Mirrors the
|
||||||
|
-- canonical-halacha synthesis (V41) ON THE SAME TABLE — no parallel store (G2).
|
||||||
|
ALTER TABLE decision_lessons ADD COLUMN IF NOT EXISTS embedding vector(1024);
|
||||||
|
ALTER TABLE decision_lessons ADD COLUMN IF NOT EXISTS synthesized_from UUID[] NOT NULL DEFAULT '{}';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_lessons_vec
|
||||||
|
ON decision_lessons USING ivfflat (embedding vector_cosine_ops) WITH (lists = 30);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# V47 (WS2 / task #200): "primary document" (מסמך עיקרי) concept.
|
||||||
|
# `documents.is_primary` is a GENERATED ALWAYS … STORED column derived purely
|
||||||
|
# from `doc_type` against the canonical PRIMARY_DOC_TYPES list (built from the
|
||||||
|
# single-source tuple via _primary_doc_types_sql_array). Because Postgres
|
||||||
|
# computes it, there is NO parallel write path and it can never drift from
|
||||||
|
# doc_type (G1/G2/INV-DM7 — same drift-free pattern as the GENERATED tsvectors,
|
||||||
|
# INV-DM3). Idempotent: ADD COLUMN IF NOT EXISTS; the generated expression is
|
||||||
|
# fixed, so re-running is a no-op. The partial index serves the
|
||||||
|
# "primary docs not yet analysed" queries that #201 builds on.
|
||||||
|
SCHEMA_V47_SQL = f"""
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS is_primary BOOLEAN
|
||||||
|
GENERATED ALWAYS AS (doc_type = ANY({_primary_doc_types_sql_array()})) STORED;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_primary
|
||||||
|
ON documents(case_id) WHERE is_primary;
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── V48: Protocol comparative analysis (WS4 / #203) ───────────────
|
||||||
|
#
|
||||||
|
# protocol_analysis: case-knowledge derived from a hearing-protocol document by
|
||||||
|
# comparing the oral arguments raised at the hearing against the written
|
||||||
|
# pleadings (legal_arguments). One row per (case_id, document_id) — idempotent
|
||||||
|
# replace on re-run (INV-TOOL3). Each row holds the comparative verdict on a
|
||||||
|
# single argument: was it DROPPED at the hearing, STRENGTHENED, or NEWLY_RAISED,
|
||||||
|
# plus the sharpened legal question it bears on. This is *derived* knowledge —
|
||||||
|
# the source of truth is the protocol document + legal_arguments; the row is a
|
||||||
|
# materialized comparison, re-buildable from those (G2: no parallel store, this
|
||||||
|
# is derived-from-source not a competing claims table).
|
||||||
|
#
|
||||||
|
# header_data (the א–ד feed: panel, hearing date, parties present) is written
|
||||||
|
# back to the canonical `cases` columns (panel via decisions, hearing_date on
|
||||||
|
# cases) — NOT duplicated here — and a copy of the raw extracted feed is kept on
|
||||||
|
# the analysis row for provenance only (G9).
|
||||||
|
SCHEMA_V48_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS protocol_analysis (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
|
||||||
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
party_role TEXT NOT NULL DEFAULT '', -- appellant/respondent/committee/permit_applicant/''
|
||||||
|
change_type TEXT NOT NULL CHECK (change_type IN ('dropped', 'strengthened', 'newly_raised')),
|
||||||
|
argument_id UUID REFERENCES legal_arguments(id) ON DELETE SET NULL, -- the pleaded argument, when matched
|
||||||
|
argument_title TEXT NOT NULL DEFAULT '', -- snapshot of the argument / new point
|
||||||
|
summary TEXT NOT NULL, -- what changed at the hearing, in prose
|
||||||
|
sharpened_question TEXT NOT NULL DEFAULT '', -- the legal question this sharpens (for the discussion)
|
||||||
|
evidence_quote TEXT NOT NULL DEFAULT '', -- verbatim protocol excerpt (INV-AH: quote-or-retract)
|
||||||
|
page_number INTEGER,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_protocol_analysis_case ON protocol_analysis(case_id, change_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_protocol_analysis_doc ON protocol_analysis(document_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# V49 (WS2 / task #201): per-document "claims analysed" tracking — the "not-analysed"
|
||||||
|
# flag. `claims_extracted_at` is stamped by claims_extractor AFTER a document's claims
|
||||||
|
# are stored; `claims_extraction_status` records the outcome
|
||||||
|
# ('pending' | 'completed' | 'no_claims' | 'failed'). These describe the per-document
|
||||||
|
# extraction *event* (the single source of truth for "has this doc been analysed") —
|
||||||
|
# NOT derived from doc_type. They let us detect a PRIMARY document (is_primary, V47)
|
||||||
|
# added after the analysis ran and not yet included → "primary doc not yet analysed".
|
||||||
|
# The partial index pairs with idx_documents_primary so that query is cheap. Idempotent:
|
||||||
|
# ADD COLUMN IF NOT EXISTS; re-running is a no-op. (V48 = protocol_analysis, #357.)
|
||||||
|
SCHEMA_V49_SQL = """
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS claims_extracted_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS claims_extraction_status TEXT
|
||||||
|
NOT NULL DEFAULT 'pending';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_claims_pending
|
||||||
|
ON documents(case_id) WHERE is_primary AND claims_extracted_at IS NULL;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# V50 (#224): per-respondent-brief separation. The aggregator groups respondent /
|
||||||
|
# permit_applicant claims by their source pleading — each joint response brief
|
||||||
|
# (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") is one coherent litigation
|
||||||
|
# position, so opposing briefs no longer collapse into a single "respondent"
|
||||||
|
# bucket. ``party_name`` carries the brief label; appellant/committee stay ''.
|
||||||
|
SCHEMA_V50_SQL = """
|
||||||
|
ALTER TABLE legal_arguments ADD COLUMN IF NOT EXISTS party_name TEXT NOT NULL DEFAULT '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_legal_arguments_party_name
|
||||||
|
ON legal_arguments(case_id, party, party_name);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# V51 (#226): hearing-attendance provenance for the "מה קרה בדיון" panel. The
|
||||||
|
# ערר-hearing protocol names who actually appeared (עוררים/משיבים + their
|
||||||
|
# counsel) and, when stated, the presiding panel. ``_extract_header`` already
|
||||||
|
# extracts this feed but only ``hearing_date`` had a canonical home (on cases);
|
||||||
|
# the attendee lists were returned as provenance and dropped. This column gives
|
||||||
|
# them a home so the panel can display them.
|
||||||
|
#
|
||||||
|
# This is DISTINCT from ``decisions.panel_members`` (G2, not a parallel path):
|
||||||
|
# that column is the authoring tribunal recorded on a specific written-decision
|
||||||
|
# version (the DOCX signature block). ``hearing_attendees`` is a snapshot of who
|
||||||
|
# was present at the *hearing*, extracted from the protocol — hearing-event
|
||||||
|
# provenance, not decision-authorship. Shape:
|
||||||
|
# {"panel_members": [...], "appellants_present": [...], "respondents_present": [...]}
|
||||||
|
SCHEMA_V51_SQL = """
|
||||||
|
ALTER TABLE cases ADD COLUMN IF NOT EXISTS hearing_attendees JSONB NOT NULL DEFAULT '{}';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# V52 (#224): backfill claims.party_name for existing rows. The extractor now
|
||||||
|
# stamps party_name = source_document for the multi-litigant sides at write time
|
||||||
|
# (claims_extractor), but claims stored before that shipped have party_name=''.
|
||||||
|
# For those, the brief label is the source pleading — same rule the aggregator
|
||||||
|
# uses (respondent / permit_applicant split). Idempotent: only fills empties, so
|
||||||
|
# re-running is a no-op. Single-voice sides (appellant/committee) stay ''.
|
||||||
|
SCHEMA_V52_SQL = """
|
||||||
|
UPDATE claims SET party_name = source_document
|
||||||
|
WHERE party_role IN ('respondent', 'permit_applicant')
|
||||||
|
AND COALESCE(party_name, '') = ''
|
||||||
|
AND COALESCE(source_document, '') <> '';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Stable, arbitrary key for the session-level advisory lock that serialises
|
# Stable, arbitrary key for the session-level advisory lock that serialises
|
||||||
# schema DDL across processes. Every short-lived process (cron drains, services)
|
# schema DDL across processes. Every short-lived process (cron drains, services)
|
||||||
# re-runs the idempotent migrations on startup; without this lock two processes
|
# re-runs the idempotent migrations on startup; without this lock two processes
|
||||||
@@ -1796,6 +1997,15 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
|||||||
await conn.execute(SCHEMA_V41_SQL)
|
await conn.execute(SCHEMA_V41_SQL)
|
||||||
await conn.execute(SCHEMA_V42_SQL)
|
await conn.execute(SCHEMA_V42_SQL)
|
||||||
await conn.execute(SCHEMA_V43_SQL)
|
await conn.execute(SCHEMA_V43_SQL)
|
||||||
|
await conn.execute(SCHEMA_V44_SQL)
|
||||||
|
await conn.execute(SCHEMA_V45_SQL)
|
||||||
|
await conn.execute(SCHEMA_V46_SQL)
|
||||||
|
await conn.execute(SCHEMA_V47_SQL)
|
||||||
|
await conn.execute(SCHEMA_V48_SQL)
|
||||||
|
await conn.execute(SCHEMA_V49_SQL)
|
||||||
|
await conn.execute(SCHEMA_V50_SQL)
|
||||||
|
await conn.execute(SCHEMA_V51_SQL)
|
||||||
|
await conn.execute(SCHEMA_V52_SQL)
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -2018,7 +2228,7 @@ async def update_case(case_id: UUID, **fields) -> dict | None:
|
|||||||
set_clauses = []
|
set_clauses = []
|
||||||
values = []
|
values = []
|
||||||
for i, (key, val) in enumerate(fields.items(), start=2):
|
for i, (key, val) in enumerate(fields.items(), start=2):
|
||||||
if key in ("appellants", "respondents", "tags"):
|
if key in ("appellants", "respondents", "tags", "hearing_attendees"):
|
||||||
val = json.dumps(val)
|
val = json.dumps(val)
|
||||||
set_clauses.append(f"{key} = ${i}")
|
set_clauses.append(f"{key} = ${i}")
|
||||||
values.append(val)
|
values.append(val)
|
||||||
@@ -2031,7 +2241,7 @@ async def update_case(case_id: UUID, **fields) -> dict | None:
|
|||||||
|
|
||||||
def _row_to_case(row: asyncpg.Record) -> dict:
|
def _row_to_case(row: asyncpg.Record) -> dict:
|
||||||
d = dict(row)
|
d = dict(row)
|
||||||
for field in ("appellants", "respondents", "tags"):
|
for field in ("appellants", "respondents", "tags", "hearing_attendees"):
|
||||||
if isinstance(d.get(field), str):
|
if isinstance(d.get(field), str):
|
||||||
d[field] = json.loads(d[field])
|
d[field] = json.loads(d[field])
|
||||||
d["id"] = str(d["id"])
|
d["id"] = str(d["id"])
|
||||||
@@ -2168,6 +2378,22 @@ def _row_to_doc(row: asyncpg.Record) -> dict:
|
|||||||
d["case_id"] = str(d["case_id"])
|
d["case_id"] = str(d["case_id"])
|
||||||
if isinstance(d.get("metadata"), str):
|
if isinstance(d.get("metadata"), str):
|
||||||
d["metadata"] = json.loads(d["metadata"])
|
d["metadata"] = json.loads(d["metadata"])
|
||||||
|
# Primary/secondary classification (WS2 / #200). `is_primary` is the
|
||||||
|
# generated DB column (V47); derive it at read-time too so the field is
|
||||||
|
# always present even on rows fetched before the migration ran, and expose
|
||||||
|
# a human-facing `doc_category`. Single source of truth: PRIMARY_DOC_TYPES.
|
||||||
|
is_primary = bool(d["is_primary"]) if d.get("is_primary") is not None \
|
||||||
|
else is_primary_doc_type(d.get("doc_type"))
|
||||||
|
d["is_primary"] = is_primary
|
||||||
|
d["doc_category"] = "primary" if is_primary else "secondary"
|
||||||
|
# Per-document claims-analysis state (WS2 / #201). `claims_analyzed` is the
|
||||||
|
# human-facing "has this doc been included in the claims analysis" flag —
|
||||||
|
# True iff claims extraction has been stamped for it. Single source of truth:
|
||||||
|
# claims_extracted_at. Defaults keep pre-migration rows well-formed.
|
||||||
|
extracted_at = d.get("claims_extracted_at")
|
||||||
|
if "claims_extraction_status" not in d or d.get("claims_extraction_status") is None:
|
||||||
|
d["claims_extraction_status"] = "pending"
|
||||||
|
d["claims_analyzed"] = extracted_at is not None
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -2217,6 +2443,47 @@ async def get_claims(case_id: UUID, party_role: str | None = None) -> list[dict]
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Per-document claims-analysis tracking (WS2 / #201) ──────────────
|
||||||
|
|
||||||
|
async def mark_document_claims_extracted(
|
||||||
|
doc_id: UUID, status: str = "completed",
|
||||||
|
) -> None:
|
||||||
|
"""Stamp a document as having had its claims extracted (WS2 / #201).
|
||||||
|
|
||||||
|
Sets ``claims_extracted_at = now()`` and records the outcome in
|
||||||
|
``claims_extraction_status`` ('completed' | 'no_claims' | 'failed').
|
||||||
|
This is the single source of truth for the per-document "not-analysed"
|
||||||
|
flag — it is written at the point claims are stored, not derived later.
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE documents SET claims_extracted_at = now(), "
|
||||||
|
"claims_extraction_status = $2 WHERE id = $1",
|
||||||
|
doc_id, status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def primary_docs_not_analyzed(case_id: UUID) -> list[dict]:
|
||||||
|
"""Return PRIMARY documents in a case that have NOT yet been analysed.
|
||||||
|
|
||||||
|
"Primary" = ``is_primary`` (the V47 generated column, single source of
|
||||||
|
truth = PRIMARY_DOC_TYPES). "Not analysed" = ``claims_extracted_at IS NULL``.
|
||||||
|
Backed by the partial index ``idx_documents_claims_pending``. Surfaces the
|
||||||
|
"there is a primary document not yet included in the analysis" condition
|
||||||
|
(#201). Returns the same shape as ``list_documents`` rows.
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT * FROM documents "
|
||||||
|
"WHERE case_id = $1 AND is_primary AND claims_extracted_at IS NULL "
|
||||||
|
"ORDER BY created_at",
|
||||||
|
case_id,
|
||||||
|
)
|
||||||
|
return [_row_to_doc(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# ── Decisions ──────────────────────────────────────────────────────
|
# ── Decisions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def create_decision(
|
async def create_decision(
|
||||||
@@ -2600,7 +2867,7 @@ async def list_decision_lessons(corpus_id: UUID) -> list[dict]:
|
|||||||
# review_status is the single writer gate).
|
# review_status is the single writer gate).
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"SELECT id, style_corpus_id, lesson_text, category, source, "
|
"SELECT id, style_corpus_id, lesson_text, category, source, "
|
||||||
" review_status, created_by, created_at, updated_at "
|
" review_status, created_by, created_at, updated_at, synthesized_from "
|
||||||
"FROM decision_lessons WHERE style_corpus_id = $1 "
|
"FROM decision_lessons WHERE style_corpus_id = $1 "
|
||||||
"ORDER BY created_at DESC",
|
"ORDER BY created_at DESC",
|
||||||
corpus_id,
|
corpus_id,
|
||||||
@@ -2633,6 +2900,155 @@ async def add_decision_lesson(
|
|||||||
return dict(row) if row else {}
|
return dict(row) if row else {}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_style_corpus_id_by_decision(decision_number: str) -> UUID | None:
|
||||||
|
"""Resolve the style_corpus row id for a final decision by its number.
|
||||||
|
|
||||||
|
The curator knows the case_number; lessons attach to the corpus row the
|
||||||
|
learning pipeline enrolled (enroll_style_corpus). Returns None if the final
|
||||||
|
has not been enrolled yet (caller surfaces it — no silent attach).
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT id FROM style_corpus WHERE decision_number = $1 LIMIT 1",
|
||||||
|
decision_number,
|
||||||
|
)
|
||||||
|
return row["id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── decision_lessons synthesis (#158 / INV-LRN8) ───────────────────
|
||||||
|
# Consolidate overlapping lessons into one richer 'synthesis' row; sources are
|
||||||
|
# marked 'superseded' (provenance, not writer-fed). Mirrors V41 on the same table.
|
||||||
|
|
||||||
|
async def fetch_synthesis_candidates(practice_area: str, category: str) -> list[dict]:
|
||||||
|
"""Lessons eligible for synthesis in one (practice_area, category) shard.
|
||||||
|
|
||||||
|
Only ``review_status='approved'`` lessons that are themselves NOT a synthesis
|
||||||
|
output and NOT already superseded — i.e. live, writer-fed lessons. Returns id,
|
||||||
|
lesson_text, review_status, source, style_corpus_id and the stored embedding
|
||||||
|
(python list or None; the caller lazily backfills NULLs via set_lesson_embedding).
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT dl.id, dl.lesson_text, dl.review_status, dl.source, "
|
||||||
|
" dl.style_corpus_id, dl.embedding "
|
||||||
|
"FROM decision_lessons dl JOIN style_corpus sc ON sc.id = dl.style_corpus_id "
|
||||||
|
"WHERE dl.review_status = 'approved' "
|
||||||
|
" AND dl.source <> 'synthesis' "
|
||||||
|
" AND dl.category = $1 "
|
||||||
|
" AND ($2 = '' OR sc.practice_area = $2) "
|
||||||
|
"ORDER BY dl.created_at",
|
||||||
|
category, practice_area,
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def synthesis_shards(min_size: int = 2) -> list[dict]:
|
||||||
|
"""(practice_area, category) shards that have ≥min_size live approved lessons —
|
||||||
|
the candidate shards a synthesis pass should consider. Largest first."""
|
||||||
|
pool = await get_pool()
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT sc.practice_area AS practice_area, dl.category AS category, count(*) AS n "
|
||||||
|
"FROM decision_lessons dl JOIN style_corpus sc ON sc.id = dl.style_corpus_id "
|
||||||
|
"WHERE dl.review_status = 'approved' AND dl.source <> 'synthesis' "
|
||||||
|
"GROUP BY 1, 2 HAVING count(*) >= $1 ORDER BY count(*) DESC",
|
||||||
|
min_size,
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def set_lesson_embedding(lesson_id: UUID, embedding: list[float]) -> None:
|
||||||
|
"""Lazy backfill: store a lesson's embedding so clustering/drift don't re-embed."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE decision_lessons SET embedding = $2 WHERE id = $1",
|
||||||
|
lesson_id, embedding,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def nearest_synthesis_lesson(
|
||||||
|
vec: list[float], category: str, threshold: float,
|
||||||
|
) -> "tuple[str, float] | None":
|
||||||
|
"""Nearest existing synthesis lesson (same category) by cosine, for idempotency —
|
||||||
|
so a re-run doesn't create a near-duplicate super-lesson. None if below threshold."""
|
||||||
|
pool = await get_pool()
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT id::text AS id, 1 - (embedding <=> $1) AS sim "
|
||||||
|
"FROM decision_lessons "
|
||||||
|
"WHERE source = 'synthesis' AND category = $2 AND embedding IS NOT NULL "
|
||||||
|
" AND review_status <> 'rejected' "
|
||||||
|
"ORDER BY embedding <=> $1 LIMIT 1",
|
||||||
|
vec, category,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
sim = float(row["sim"])
|
||||||
|
return (row["id"], sim) if sim >= threshold else None
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_lesson_synthesis(
|
||||||
|
*,
|
||||||
|
corpus_id: UUID,
|
||||||
|
lesson_text: str,
|
||||||
|
category: str,
|
||||||
|
embedding: list[float],
|
||||||
|
source_ids: list[UUID],
|
||||||
|
review_status: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Atomically commit a synthesis: insert the super-lesson (source='synthesis')
|
||||||
|
and flip its source lessons to 'superseded' (provenance, no longer writer-fed).
|
||||||
|
Returns the new row. INV-LRN8 / G2 — single write path."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"INSERT INTO decision_lessons "
|
||||||
|
"(style_corpus_id, lesson_text, category, source, created_by, "
|
||||||
|
" review_status, embedding, synthesized_from) "
|
||||||
|
"VALUES ($1, $2, $3, 'synthesis', 'synthesis', $4, $5, $6) "
|
||||||
|
"RETURNING id, style_corpus_id, lesson_text, category, source, "
|
||||||
|
" review_status, created_by, created_at, updated_at",
|
||||||
|
corpus_id, lesson_text, category, review_status, embedding, source_ids,
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE decision_lessons SET review_status = 'superseded', updated_at = now() "
|
||||||
|
"WHERE id = ANY($1::uuid[])",
|
||||||
|
source_ids,
|
||||||
|
)
|
||||||
|
return dict(row) if row else {}
|
||||||
|
|
||||||
|
|
||||||
|
async def revert_lesson_synthesis(synthesis_id: UUID) -> dict:
|
||||||
|
"""Chair veto of a super-lesson: mark it 'rejected' and restore its source lessons
|
||||||
|
to 'approved' (so they flow to the writer again). Idempotent; returns counts."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT synthesized_from, source FROM decision_lessons WHERE id = $1",
|
||||||
|
synthesis_id,
|
||||||
|
)
|
||||||
|
if not row or row["source"] != "synthesis":
|
||||||
|
return {"reverted": False, "reason": "not a synthesis lesson"}
|
||||||
|
source_ids = list(row["synthesized_from"] or [])
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE decision_lessons SET review_status = 'rejected', updated_at = now() "
|
||||||
|
"WHERE id = $1",
|
||||||
|
synthesis_id,
|
||||||
|
)
|
||||||
|
restored = 0
|
||||||
|
if source_ids:
|
||||||
|
res = await conn.execute(
|
||||||
|
"UPDATE decision_lessons SET review_status = 'approved', updated_at = now() "
|
||||||
|
"WHERE id = ANY($1::uuid[]) AND review_status = 'superseded'",
|
||||||
|
source_ids,
|
||||||
|
)
|
||||||
|
restored = int(res.split()[-1]) if res.split()[-1].isdigit() else 0
|
||||||
|
return {"reverted": True, "restored_sources": restored}
|
||||||
|
|
||||||
|
|
||||||
async def update_decision_lesson(
|
async def update_decision_lesson(
|
||||||
lesson_id: UUID,
|
lesson_id: UUID,
|
||||||
*,
|
*,
|
||||||
@@ -2817,6 +3233,48 @@ async def get_style_patterns(pattern_type: str | None = None) -> list[dict]:
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def append_global_rule(
|
||||||
|
category: str, key: str, items: list[str], seed_if_missing: list | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Append items to a _global appeal_type_rules list value (the writer-consumed
|
||||||
|
methodology channel), idempotently and under a row lock. Returns how many NEW
|
||||||
|
items were added (existing duplicates are skipped). Single locked read-modify-
|
||||||
|
write (MET-2/3) so concurrent appends can't drop items. Shared by the /training
|
||||||
|
promote gate (web `_append_methodology_override`) and chair-feedback auto-flow
|
||||||
|
(G2 — one append implementation)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT rule_value FROM appeal_type_rules "
|
||||||
|
"WHERE appeal_type = '_global' AND rule_category = $1 AND rule_key = $2 "
|
||||||
|
"FOR UPDATE",
|
||||||
|
category, key,
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
current = row["rule_value"]
|
||||||
|
if isinstance(current, str):
|
||||||
|
try:
|
||||||
|
current = json.loads(current)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
current = []
|
||||||
|
else:
|
||||||
|
current = list(seed_if_missing or [])
|
||||||
|
if not isinstance(current, list):
|
||||||
|
current = []
|
||||||
|
added = [s for s in items if s and s not in current]
|
||||||
|
if not added and row:
|
||||||
|
return 0
|
||||||
|
merged = current + added
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
|
||||||
|
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::text::jsonb) "
|
||||||
|
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::text::jsonb",
|
||||||
|
category, key, json.dumps(merged, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
return len(added)
|
||||||
|
|
||||||
|
|
||||||
async def get_methodology_overrides(category: str) -> dict:
|
async def get_methodology_overrides(category: str) -> dict:
|
||||||
"""Chair's /methodology edits for one category (golden_ratios / discussion_rules /
|
"""Chair's /methodology edits for one category (golden_ratios / discussion_rules /
|
||||||
content_checklists). Returns {rule_key: parsed_value}. These OVERRIDE the hardcoded
|
content_checklists). Returns {rule_key: parsed_value}. These OVERRIDE the hardcoded
|
||||||
@@ -2841,13 +3299,78 @@ async def get_methodology_overrides(category: str) -> dict:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "") -> list[dict]:
|
async def voice_lesson_pool_sizes() -> dict:
|
||||||
|
"""How many folded voice-learning items are in the writer-consumed pool right now
|
||||||
|
(universal discussion_rules + transition_phrases). Used by the prospective held-out
|
||||||
|
snapshot to record how much learning had accumulated when a draft was produced."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT rule_category, COALESCE(jsonb_array_length(rule_value), 0) AS n "
|
||||||
|
"FROM appeal_type_rules "
|
||||||
|
"WHERE appeal_type = '_global' AND rule_key = 'universal' "
|
||||||
|
"AND rule_category IN ('discussion_rules', 'transition_phrases')",
|
||||||
|
)
|
||||||
|
out = {"discussion_rules": 0, "transition_phrases": 0}
|
||||||
|
for r in rows:
|
||||||
|
out[r["rule_category"]] = r["n"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def record_style_distance_snapshot(
|
||||||
|
case_number: str, pair_id: str | None, pool_rules: int, pool_phrases: int,
|
||||||
|
anti_pattern_total: int | None, ratio_max_deviation: float | None,
|
||||||
|
change_percent: float | None,
|
||||||
|
) -> None:
|
||||||
|
"""Append one prospective held-out datapoint (Path A). Append-only; never updates."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO style_distance_history (case_number, pair_id, "
|
||||||
|
"pool_discussion_rules, pool_transition_phrases, anti_pattern_total, "
|
||||||
|
"ratio_max_deviation, change_percent) VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||||
|
case_number, UUID(pair_id) if pair_id else None,
|
||||||
|
pool_rules, pool_phrases, anti_pattern_total,
|
||||||
|
ratio_max_deviation, change_percent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_style_distance_history() -> list[dict]:
|
||||||
|
"""The prospective held-out trend, oldest-first (Path A). Each row = one final
|
||||||
|
upload's style-distance measured before that case's lessons were folded."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT case_number, measured_at, pool_discussion_rules, "
|
||||||
|
"pool_transition_phrases, anti_pattern_total, ratio_max_deviation, "
|
||||||
|
"change_percent FROM style_distance_history ORDER BY measured_at",
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"case_number": r["case_number"],
|
||||||
|
"measured_at": r["measured_at"].isoformat() if r["measured_at"] else None,
|
||||||
|
"pool_discussion_rules": r["pool_discussion_rules"],
|
||||||
|
"pool_transition_phrases": r["pool_transition_phrases"],
|
||||||
|
"anti_pattern_total": r["anti_pattern_total"],
|
||||||
|
"ratio_max_deviation": r["ratio_max_deviation"],
|
||||||
|
"change_percent": r["change_percent"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_recent_decision_lessons(limit: int = 60, practice_area: str = "") -> list[dict]:
|
||||||
"""Per-decision learnings the chair/curator attached in /training (decision_lessons),
|
"""Per-decision learnings the chair/curator attached in /training (decision_lessons),
|
||||||
so the writer consumes them too (T15). Prefers style/structure/lexicon, recent first.
|
so the writer consumes them too (T15). Prefers style/structure/lexicon, recent first.
|
||||||
|
|
||||||
Gate (INV-LRN1/G10): returns only CHAIR-APPROVED lessons (review_status='approved').
|
Gate (INV-LRN1/G10): returns only CHAIR-APPROVED lessons (review_status='approved');
|
||||||
Panel-written proposals stay out of the writer's context until the chair approves
|
superseded sources (merged by synthesis, #158) are excluded automatically.
|
||||||
them in /training — the chair's review is the gate, not the panel's 2/2 vote.
|
|
||||||
|
Cap (#157/#158): callers pass config.WRITER_LESSONS_LIMIT (60). Raised from 15 once
|
||||||
|
lessons became deduplicated (synthesis) AND chair-approved — the old cap silently
|
||||||
|
truncated distinct, vetted style guidance. If even 60 is exceeded the overflow is
|
||||||
|
dropped (ORDER BY created_at DESC) and logged (no silent cap, חוקה §6); that WARN is
|
||||||
|
the signal to run synthesis (lesson_synthesize_pending) or raise WRITER_LESSONS_LIMIT.
|
||||||
"""
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -2862,6 +3385,21 @@ async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "")
|
|||||||
LIMIT $1""",
|
LIMIT $1""",
|
||||||
limit, practice_area,
|
limit, practice_area,
|
||||||
)
|
)
|
||||||
|
if len(rows) >= limit:
|
||||||
|
total = await conn.fetchval(
|
||||||
|
"""SELECT count(*) FROM decision_lessons dl
|
||||||
|
JOIN style_corpus sc ON sc.id = dl.style_corpus_id
|
||||||
|
WHERE dl.review_status = 'approved'
|
||||||
|
AND ($1 = '' OR sc.practice_area = $1)""",
|
||||||
|
practice_area,
|
||||||
|
) or 0
|
||||||
|
if total > limit:
|
||||||
|
logger.warning(
|
||||||
|
"get_recent_decision_lessons: capped %d→%d approved lessons "
|
||||||
|
"(practice_area=%r) — %d not reaching the writer. Run synthesis "
|
||||||
|
"(lesson_synthesize_pending) or raise WRITER_LESSONS_LIMIT.",
|
||||||
|
total, limit, practice_area or "*", total - limit,
|
||||||
|
)
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@@ -3331,28 +3869,58 @@ async def create_case_precedent(
|
|||||||
chair_note: str = "",
|
chair_note: str = "",
|
||||||
pdf_document_id: UUID | None = None,
|
pdf_document_id: UUID | None = None,
|
||||||
practice_area: str | None = None,
|
practice_area: str | None = None,
|
||||||
|
argument_id: UUID | None = None,
|
||||||
|
case_law_id: UUID | None = None,
|
||||||
|
verified: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Insert a new precedent attached to a case."""
|
"""Insert a new precedent attached to a case.
|
||||||
|
|
||||||
|
``argument_id``/``case_law_id``/``verified`` (X11 #154) link the attachment to
|
||||||
|
the specific legal argument it supports and the corpus ruling, and mark whether
|
||||||
|
the chair verified it (the INV-AH gate the writer respects)."""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
row = await pool.fetchrow(
|
row = await pool.fetchrow(
|
||||||
"""
|
"""
|
||||||
INSERT INTO case_precedents
|
INSERT INTO case_precedents
|
||||||
(case_id, section_id, quote, citation, chair_note, pdf_document_id, practice_area)
|
(case_id, section_id, quote, citation, chair_note, pdf_document_id,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
practice_area, argument_id, case_law_id, verified, verified_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||||
|
CASE WHEN $10 THEN now() ELSE NULL END)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
case_id, section_id, quote, citation, chair_note, pdf_document_id, practice_area,
|
case_id, section_id, quote, citation, chair_note, pdf_document_id,
|
||||||
|
practice_area, argument_id, case_law_id, verified,
|
||||||
)
|
)
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_case_precedent_verified(
|
||||||
|
precedent_id: UUID, verified: bool, chair_note: str | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Toggle the chair-verification gate on an attached precedent (#154), optionally
|
||||||
|
updating the chair note. Sets verified_at when verifying, clears it when un-verifying."""
|
||||||
|
pool = await get_pool()
|
||||||
|
sets = ["verified = $2", "verified_at = CASE WHEN $2 THEN now() ELSE NULL END",
|
||||||
|
"updated_at = now()"]
|
||||||
|
params: list = [precedent_id, verified]
|
||||||
|
if chair_note is not None:
|
||||||
|
sets.append(f"chair_note = ${len(params) + 1}")
|
||||||
|
params.append(chair_note)
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
f"UPDATE case_precedents SET {', '.join(sets)} WHERE id = $1 RETURNING *",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def list_case_precedents(case_id: UUID) -> list[dict]:
|
async def list_case_precedents(case_id: UUID) -> list[dict]:
|
||||||
"""List all precedents attached to a case, ordered by section then creation time."""
|
"""List all precedents attached to a case, ordered by section then creation time."""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
rows = await pool.fetch(
|
rows = await pool.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT id, case_id, section_id, quote, citation, chair_note,
|
SELECT id, case_id, section_id, quote, citation, chair_note,
|
||||||
pdf_document_id, practice_area, created_at, updated_at
|
pdf_document_id, practice_area, argument_id, case_law_id,
|
||||||
|
verified, verified_at, created_at, updated_at
|
||||||
FROM case_precedents
|
FROM case_precedents
|
||||||
WHERE case_id = $1
|
WHERE case_id = $1
|
||||||
ORDER BY section_id NULLS LAST, created_at
|
ORDER BY section_id NULLS LAST, created_at
|
||||||
@@ -3611,6 +4179,83 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]:
|
|||||||
return conflicts
|
return conflicts
|
||||||
|
|
||||||
|
|
||||||
|
# ── Protocol comparative analysis (V48 / WS4 #203) ────────────────
|
||||||
|
|
||||||
|
async def replace_protocol_analysis(
|
||||||
|
case_id: UUID,
|
||||||
|
document_id: UUID,
|
||||||
|
rows: list[dict],
|
||||||
|
) -> int:
|
||||||
|
"""Replace all protocol_analysis rows for a given protocol document (idempotent).
|
||||||
|
|
||||||
|
Each row dict: change_type ('dropped'|'strengthened'|'newly_raised'),
|
||||||
|
party_role, argument_id (UUID|None), argument_title, summary,
|
||||||
|
sharpened_question, evidence_quote, page_number (optional).
|
||||||
|
|
||||||
|
Idempotent on document_id (INV-TOOL3): re-running analyze_protocol replaces
|
||||||
|
the prior verdict for that protocol rather than appending duplicates.
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM protocol_analysis WHERE document_id = $1", document_id,
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO protocol_analysis
|
||||||
|
(case_id, document_id, party_role, change_type, argument_id,
|
||||||
|
argument_title, summary, sharpened_question, evidence_quote, page_number)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)""",
|
||||||
|
case_id, document_id,
|
||||||
|
r.get("party_role", "") or "",
|
||||||
|
r["change_type"],
|
||||||
|
r.get("argument_id"),
|
||||||
|
r.get("argument_title", "") or "",
|
||||||
|
r["summary"],
|
||||||
|
r.get("sharpened_question", "") or "",
|
||||||
|
r.get("evidence_quote", "") or "",
|
||||||
|
r.get("page_number"),
|
||||||
|
)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_protocol_analysis(
|
||||||
|
case_id: UUID,
|
||||||
|
change_type: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List protocol_analysis rows for a case, optionally filtered by change_type.
|
||||||
|
|
||||||
|
The read side of the extract/get symmetry (INV-TOOL4) for analyze_protocol.
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if change_type:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT * FROM protocol_analysis
|
||||||
|
WHERE case_id = $1 AND change_type = $2
|
||||||
|
ORDER BY change_type, party_role, created_at""",
|
||||||
|
case_id, change_type,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT * FROM protocol_analysis
|
||||||
|
WHERE case_id = $1
|
||||||
|
ORDER BY change_type, party_role, created_at""",
|
||||||
|
case_id,
|
||||||
|
)
|
||||||
|
results = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
d["id"] = str(d["id"])
|
||||||
|
d["case_id"] = str(d["case_id"])
|
||||||
|
d["document_id"] = str(d["document_id"])
|
||||||
|
if d.get("argument_id") is not None:
|
||||||
|
d["argument_id"] = str(d["argument_id"])
|
||||||
|
results.append(d)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
# ── Plans registry (V38) ──────────────────────────────────────────
|
# ── Plans registry (V38) ──────────────────────────────────────────
|
||||||
# Canonical registry of planning schemes (תכניות). SSOT for a plan's identity +
|
# Canonical registry of planning schemes (תכניות). SSOT for a plan's identity +
|
||||||
# validity, reused across cases (G2). See SCHEMA_V38_SQL for the data contract.
|
# validity, reused across cases (G2). See SCHEMA_V38_SQL for the data contract.
|
||||||
|
|||||||
@@ -43,10 +43,16 @@ HEBREW_FONT = "David"
|
|||||||
|
|
||||||
|
|
||||||
def _mark_run_rtl(run) -> None:
|
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()
|
rPr = run._r.get_or_add_rPr()
|
||||||
if rPr.find(qn("w:rFonts")) is None:
|
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:ascii"), HEBREW_FONT)
|
||||||
fonts.set(qn("w:hAnsi"), HEBREW_FONT)
|
fonts.set(qn("w:hAnsi"), HEBREW_FONT)
|
||||||
fonts.set(qn("w:cs"), 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:
|
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
|
The template ships with sample paragraphs *and three sample tables*
|
||||||
properties (page size, margins, bidi) stay intact.
|
(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
|
body = doc.element.body
|
||||||
for p in list(body.findall(qn("w:p"))):
|
sectPr_tag = qn("w:sectPr")
|
||||||
body.remove(p)
|
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 ──────────────────────────────────────────────
|
# ── Bookmark helpers ──────────────────────────────────────────────
|
||||||
@@ -378,8 +399,15 @@ _INTERIM_BLOCK_ORDER = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _draft_filename_prefix(mode: str) -> str:
|
def _draft_naming(mode: str) -> tuple[str, str]:
|
||||||
return "טיוטת-ביניים" if mode == "interim" else "טיוטה"
|
"""(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(
|
async def export_decision(
|
||||||
@@ -464,16 +492,19 @@ async def export_decision(
|
|||||||
if not output_path:
|
if not output_path:
|
||||||
export_dir = config.find_case_dir(case["case_number"]) / "exports"
|
export_dir = config.find_case_dir(case["case_number"]) / "exports"
|
||||||
export_dir.mkdir(parents=True, exist_ok=True)
|
export_dir.mkdir(parents=True, exist_ok=True)
|
||||||
prefix = _draft_filename_prefix(mode)
|
prefix, sep = _draft_naming(mode)
|
||||||
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
|
existing = sorted(export_dir.glob(f"{prefix}{sep}*.docx"))
|
||||||
next_ver = 1
|
next_ver = 1
|
||||||
for p in existing:
|
for p in existing:
|
||||||
try:
|
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)
|
next_ver = max(next_ver, ver + 1)
|
||||||
except (IndexError, ValueError):
|
except (IndexError, ValueError):
|
||||||
pass
|
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
|
# Persist through the storage layer (INV-STG1). Under the filesystem
|
||||||
# backend the bytes land at output_path exactly as before; a caller-
|
# 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
|
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 ─────────────────────────────────────────────
|
# ── Paragraph builders ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _build_run(text: str, *, bold: bool = False, italic: bool = False,
|
def _build_run(text: str, *, bold: bool = False, italic: bool = False) -> etree._Element:
|
||||||
font: str = "David", size_half_pt: int | None = None) -> etree._Element:
|
"""Build a w:r (run): RTL marker + optional bold/italic + text.
|
||||||
"""Build a w:r (run) element with RTL/David defaults and given 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"))
|
r = etree.Element(_w("r"))
|
||||||
rPr = etree.SubElement(r, _w("rPr"))
|
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:
|
if bold:
|
||||||
etree.SubElement(rPr, _w("b"))
|
etree.SubElement(rPr, _w("b"))
|
||||||
etree.SubElement(rPr, _w("bCs"))
|
etree.SubElement(rPr, _w("bCs"))
|
||||||
@@ -235,28 +294,36 @@ def _build_run(text: str, *, bold: bool = False, italic: bool = False,
|
|||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
def _build_paragraph(text: str, *, style: StyleType = "body") -> etree._Element:
|
def _build_paragraph(text: str, *, style: StyleType = "body",
|
||||||
"""Build a w:p (paragraph) with RTL + David + given text."""
|
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"))
|
p = etree.Element(_w("p"))
|
||||||
pPr = etree.SubElement(p, _w("pPr"))
|
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 = etree.SubElement(pPr, _w("bidi"))
|
||||||
bidi.set(_w("val"), "1")
|
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 = etree.SubElement(pPr, _w("jc"))
|
||||||
jc.set(_w("val"), "right")
|
jc.set(_w("val"), "both")
|
||||||
|
|
||||||
rPr_p = etree.SubElement(pPr, _w("rPr"))
|
rPr_p = etree.SubElement(pPr, _w("rPr"))
|
||||||
etree.SubElement(rPr_p, _w("rtl"))
|
etree.SubElement(rPr_p, _w("rtl"))
|
||||||
|
|
||||||
bold = style in ("heading", "bold")
|
bold, italic = _emphasis_fallback(style, has_pstyle=bool(style_id))
|
||||||
italic = style == "quote"
|
run = _build_run(text, bold=bold, italic=italic)
|
||||||
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)
|
|
||||||
p.append(run)
|
p.append(run)
|
||||||
return p
|
return p
|
||||||
|
|
||||||
@@ -275,7 +342,7 @@ def _wrap_in_ins(elements: list[etree._Element], *, ins_id: int,
|
|||||||
|
|
||||||
def _make_tracked_paragraph_insert(
|
def _make_tracked_paragraph_insert(
|
||||||
text: str, *, style: StyleType, ins_id: int, author: str, date_iso: str,
|
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:
|
) -> etree._Element:
|
||||||
"""Build a whole tracked-inserted paragraph.
|
"""Build a whole tracked-inserted paragraph.
|
||||||
|
|
||||||
@@ -286,7 +353,7 @@ def _make_tracked_paragraph_insert(
|
|||||||
"""
|
"""
|
||||||
if mark_id is None:
|
if mark_id is None:
|
||||||
mark_id = ins_id
|
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"))
|
pPr = p.find(_w("pPr"))
|
||||||
assert pPr is not None
|
assert pPr is not None
|
||||||
rPr = pPr.find(_w("rPr"))
|
rPr = pPr.find(_w("rPr"))
|
||||||
@@ -339,6 +406,7 @@ def _apply_insert(
|
|||||||
ins_id: int,
|
ins_id: int,
|
||||||
author: str,
|
author: str,
|
||||||
date_iso: str,
|
date_iso: str,
|
||||||
|
style_ids: dict[str, str] | None = None,
|
||||||
) -> RevisionResult:
|
) -> RevisionResult:
|
||||||
"""Apply insert_after / insert_before relative to a bookmark."""
|
"""Apply insert_after / insert_before relative to a bookmark."""
|
||||||
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
||||||
@@ -362,6 +430,7 @@ def _apply_insert(
|
|||||||
revision.content, style=revision.style,
|
revision.content, style=revision.style,
|
||||||
ins_id=ins_id, mark_id=ins_id + 1,
|
ins_id=ins_id, mark_id=ins_id + 1,
|
||||||
author=author, date_iso=date_iso,
|
author=author, date_iso=date_iso,
|
||||||
|
style_id=(style_ids or {}).get(revision.style),
|
||||||
)
|
)
|
||||||
|
|
||||||
parent = enclosing_p.getparent()
|
parent = enclosing_p.getparent()
|
||||||
@@ -407,6 +476,7 @@ def _apply_replace(
|
|||||||
del_id: int,
|
del_id: int,
|
||||||
author: str,
|
author: str,
|
||||||
date_iso: str,
|
date_iso: str,
|
||||||
|
style_ids: dict[str, str] | None = None,
|
||||||
) -> RevisionResult:
|
) -> RevisionResult:
|
||||||
"""Replace = delete the existing paragraph + insert new one after it."""
|
"""Replace = delete the existing paragraph + insert new one after it."""
|
||||||
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
||||||
@@ -428,6 +498,7 @@ def _apply_replace(
|
|||||||
revision.content, style=revision.style,
|
revision.content, style=revision.style,
|
||||||
ins_id=ins_id, mark_id=ins_id + 1,
|
ins_id=ins_id, mark_id=ins_id + 1,
|
||||||
author=author, date_iso=date_iso,
|
author=author, date_iso=date_iso,
|
||||||
|
style_id=(style_ids or {}).get(revision.style),
|
||||||
)
|
)
|
||||||
idx = list(parent).index(enclosing_p)
|
idx = list(parent).index(enclosing_p)
|
||||||
parent.insert(idx + 1, new_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)
|
members, doc_tree, settings_tree = _load_docx_xml(source_path)
|
||||||
_ensure_track_revisions(settings_tree)
|
_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)
|
next_id = _next_revision_id(doc_tree)
|
||||||
|
|
||||||
batch = RevisionBatchResult()
|
batch = RevisionBatchResult()
|
||||||
@@ -480,7 +555,8 @@ def apply_tracked_revisions(
|
|||||||
try:
|
try:
|
||||||
if rev.type in ("insert_after", "insert_before"):
|
if rev.type in ("insert_after", "insert_before"):
|
||||||
result = _apply_insert(doc_tree, rev, ins_id=next_id,
|
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
|
# insert consumes 2 IDs: run-wrapper + paragraph-mark
|
||||||
next_id += 2
|
next_id += 2
|
||||||
elif rev.type == "delete":
|
elif rev.type == "delete":
|
||||||
@@ -490,7 +566,8 @@ def apply_tracked_revisions(
|
|||||||
elif rev.type == "replace":
|
elif rev.type == "replace":
|
||||||
result = _apply_replace(doc_tree, rev,
|
result = _apply_replace(doc_tree, rev,
|
||||||
ins_id=next_id, del_id=next_id + 2,
|
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
|
# replace consumes 3 IDs: ins-run, ins-mark, del
|
||||||
next_id += 3
|
next_id += 3
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
"""Text extraction from PDF, DOCX, DOC, and RTF files.
|
"""Text extraction from PDF, DOCX, DOC, and RTF files.
|
||||||
|
|
||||||
Primary PDF extraction: PyMuPDF direct text (for born-digital PDFs).
|
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.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import fitz # PyMuPDF
|
import fitz # PyMuPDF
|
||||||
|
import httpx
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from docx import Document as DocxDocument
|
from docx import Document as DocxDocument
|
||||||
from striprtf.striprtf import rtf_to_text
|
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 import config
|
||||||
from legal_mcp.services import storage
|
from legal_mcp.services import storage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from google.cloud import vision
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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":
|
async def _call_mistral_ocr(path: Path) -> list[str]:
|
||||||
global _vision_client
|
"""Call Mistral OCR API on a PDF. Returns per-page Markdown text list.
|
||||||
if _vision_client is None:
|
|
||||||
from google.cloud import vision
|
The Mistral response contains ``pages[i].markdown`` for each page.
|
||||||
_vision_client = vision.ImageAnnotatorClient(
|
If the response has fewer pages than the PDF, trailing pages are
|
||||||
client_options={"api_key": config.GOOGLE_CLOUD_VISION_API_KEY}
|
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 text quality detection ────────────────────────────────
|
||||||
|
|
||||||
_HEBREW_RE = re.compile(r'[\u0590-\u05FF]')
|
_HEBREW_RE = re.compile(r'[-]')
|
||||||
_WORD_RE = re.compile(r'\S+')
|
_WORD_RE = re.compile(r'\S+')
|
||||||
|
|
||||||
|
|
||||||
def _text_quality_ok(text: str) -> bool:
|
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:
|
Broken OCR layers from scanned PDFs often have:
|
||||||
- Very short words / single-character fragments
|
- Very short words / single-character fragments
|
||||||
- Each word on its own line (high words-per-line ratio)
|
- 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:
|
if len(words) < 10:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Average word length — real Hebrew words avg 4-6 chars.
|
|
||||||
avg_len = sum(len(w) for w in words) / len(words)
|
avg_len = sum(len(w) for w in words) / len(words)
|
||||||
if avg_len < 2.5:
|
if avg_len < 2.5:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Percentage of single-character "words"
|
|
||||||
single_char_pct = sum(1 for w in words if len(w) == 1) / len(words)
|
single_char_pct = sum(1 for w in words if len(w) == 1) / len(words)
|
||||||
if single_char_pct > 0.4:
|
if single_char_pct > 0.4:
|
||||||
return False
|
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()]
|
lines = [l for l in text.split("\n") if l.strip()]
|
||||||
if lines:
|
if lines and len(words) / len(lines) < 3.0:
|
||||||
words_per_line = len(words) / len(lines)
|
|
||||||
if words_per_line < 3.0:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Hebrew character ratio among letter characters
|
letters = re.findall(r'[a-zA-Z-]', text)
|
||||||
letters = re.findall(r'[a-zA-Z\u0590-\u05FF]', text)
|
|
||||||
if letters:
|
if letters:
|
||||||
hebrew_pct = sum(1 for c in letters if _HEBREW_RE.match(c)) / len(letters)
|
hebrew_pct = sum(1 for c in letters if _HEBREW_RE.match(c)) / len(letters)
|
||||||
if hebrew_pct < 0.5:
|
if hebrew_pct < 0.5:
|
||||||
@@ -92,7 +124,7 @@ def _text_quality_ok(text: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# ── Hebrew abbreviation quote fixer ──────────────────────────────
|
# ── Hebrew abbreviation quote fixer (PyMuPDF path only) ──────────
|
||||||
|
|
||||||
_HEBREW_ABBREV_FIXES: dict[str, str] = {
|
_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
|
# Patterns where double-yod (יי) substitutes for gershayim (״) in born-digital PDFs
|
||||||
'בליימ': 'בל"מ', # בקשה להארכת מועד — appears in RTL legal docs
|
'בליימ': 'בל"מ',
|
||||||
'תמייא': 'תמ"א', # תכנית מתאר ארצית
|
'תמייא': 'תמ"א',
|
||||||
}
|
}
|
||||||
|
|
||||||
_ABBREV_PATTERN = re.compile(
|
_ABBREV_PATTERN = re.compile(
|
||||||
'|'.join(re.escape(k) for k in sorted(_HEBREW_ABBREV_FIXES, key=len, reverse=True))
|
'|'.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'(תש[א-ת]+)יי([א-ת])')
|
_HEBREW_YEAR_RE = re.compile(r'(תש[א-ת]+)יי([א-ת])')
|
||||||
|
|
||||||
|
|
||||||
def _fix_hebrew_quotes(text: str) -> str:
|
def _fix_hebrew_quotes(text: str) -> str:
|
||||||
"""Fix known Hebrew abbreviation quote replacements.
|
"""Fix gershayim encoded as double-yod in born-digital PDFs."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
text = _ABBREV_PATTERN.sub(lambda m: _HEBREW_ABBREV_FIXES[m.group()], text)
|
text = _ABBREV_PATTERN.sub(lambda m: _HEBREW_ABBREV_FIXES[m.group()], text)
|
||||||
text = _HEBREW_YEAR_RE.sub(r'\1"\2', text)
|
text = _HEBREW_YEAR_RE.sub(r'\1"\2', text)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
# ── Extraction ───────────────────────────────────────────────────
|
# ── Page joining ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
# Separator used when joining per-page text. Constant so chunker /
|
# Separator used when joining per-page text. Constant so chunker /
|
||||||
# retrofit can reproduce the join when computing page offsets.
|
# retrofit can reproduce the join when computing page offsets.
|
||||||
PAGE_SEPARATOR = "\n\n"
|
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]:
|
async def extract_text(file_path: str) -> tuple[str, int, list[int] | None]:
|
||||||
"""Extract text from a document file.
|
"""Extract text from a document file.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
``(text, page_count, page_offsets)`` where:
|
``(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_count``: number of pages (0 for non-PDF)
|
||||||
- ``page_offsets``: ``page_offsets[i]`` = char start offset of
|
- ``page_offsets``: char start of each page inside ``text``,
|
||||||
page (i+1) inside ``text``. ``None`` for non-PDFs (where the
|
or ``None`` for non-PDF formats
|
||||||
notion of pages doesn't apply). Used by the chunker to assign
|
|
||||||
a ``page_number`` to each chunk.
|
|
||||||
"""
|
"""
|
||||||
path = Path(file_path)
|
path = Path(file_path)
|
||||||
suffix = path.suffix.lower()
|
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}")
|
raise ValueError(f"Unsupported file type: {suffix}")
|
||||||
|
|
||||||
|
|
||||||
def _join_pages(pages_text: list[str]) -> tuple[str, list[int]]:
|
# ── Non-PDF formats ───────────────────────────────────────────────
|
||||||
"""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)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_doc(path: Path) -> str:
|
def _extract_doc(path: Path) -> str:
|
||||||
"""Extract text from legacy .doc file by converting to .docx via LibreOffice."""
|
"""Extract text from legacy .doc via LibreOffice → DOCX conversion."""
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
# Isolate the LibreOffice user profile per call: headless soffice
|
# Isolate the LibreOffice user profile per call: headless soffice
|
||||||
# locks a single shared profile, so concurrent .doc conversions would
|
# locks a single shared profile, so concurrent .doc conversions would
|
||||||
@@ -296,13 +327,13 @@ def _extract_rtf(path: Path) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# ── Multimodal page rendering (V9) ───────────────────────────────
|
# ── 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:
|
def _pixmap_to_pil(pix: fitz.Pixmap) -> Image.Image:
|
||||||
"""Convert a PyMuPDF pixmap to PIL.Image (RGB) without going through
|
"""Convert a PyMuPDF pixmap to PIL.Image (RGB)."""
|
||||||
PNG bytes. Faster than tobytes('png') → Image.open()."""
|
|
||||||
if pix.alpha:
|
if pix.alpha:
|
||||||
# Drop alpha channel — voyage multimodal expects RGB.
|
|
||||||
pix = fitz.Pixmap(pix, 0)
|
pix = fitz.Pixmap(pix, 0)
|
||||||
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
||||||
|
|
||||||
@@ -314,12 +345,9 @@ def render_pages_for_multimodal(
|
|||||||
thumbnail_dir: Path | None = None,
|
thumbnail_dir: Path | None = None,
|
||||||
) -> list[tuple[Image.Image, Path | None]]:
|
) -> list[tuple[Image.Image, Path | None]]:
|
||||||
"""Render each PDF page as PIL.Image at ``embed_dpi`` for the
|
"""Render each PDF page as PIL.Image at ``embed_dpi`` for the
|
||||||
multimodal embedder, and optionally save a smaller JPEG thumbnail
|
multimodal embedder, and optionally save JPEG thumbnails.
|
||||||
at ``thumb_dpi`` to ``thumbnail_dir`` for UI preview.
|
|
||||||
|
|
||||||
Returns ``[(pil_image, thumb_path_or_None), ...]`` in page order.
|
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)
|
src = Path(pdf_path)
|
||||||
if not src.is_file():
|
if not src.is_file():
|
||||||
@@ -338,17 +366,12 @@ def render_pages_for_multimodal(
|
|||||||
thumb_path: Path | None = None
|
thumb_path: Path | None = None
|
||||||
if thumbnail_dir is not None and thumb_dpi:
|
if thumbnail_dir is not None and thumb_dpi:
|
||||||
thumb_path = thumbnail_dir / f"p{page_num:03d}.jpg"
|
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
|
ratio = thumb_dpi / embed_dpi
|
||||||
thumb_size = (
|
thumb_size = (
|
||||||
max(1, int(img.width * ratio)),
|
max(1, int(img.width * ratio)),
|
||||||
max(1, int(img.height * ratio)),
|
max(1, int(img.height * ratio)),
|
||||||
)
|
)
|
||||||
thumb = img.resize(thumb_size, Image.Resampling.LANCZOS)
|
thumb = img.resize(thumb_size, Image.Resampling.LANCZOS)
|
||||||
# Persist the thumbnail (a DERIVED, regenerable artifact)
|
|
||||||
# through the storage layer (INV-STG1). Under the filesystem
|
|
||||||
# backend it lands at thumb_path exactly as before.
|
|
||||||
_tbuf = io.BytesIO()
|
_tbuf = io.BytesIO()
|
||||||
thumb.save(_tbuf, "JPEG", quality=75, optimize=True)
|
thumb.save(_tbuf, "JPEG", quality=75, optimize=True)
|
||||||
try:
|
try:
|
||||||
@@ -366,44 +389,28 @@ def render_pages_for_multimodal(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ── Nevo preamble stripping ──────────────────────────────────────
|
# ── Nevo preamble stripping ───────────────────────────────────────
|
||||||
|
|
||||||
_NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:",
|
_NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:",
|
||||||
"כתבי עת:", "הועתק מנבו")
|
"כתבי עת:", "הועתק מנבו")
|
||||||
|
|
||||||
# Markers for where the actual decision body begins (everything before is Nevo
|
|
||||||
# preamble: bibliography + מיני-רציו). Two families:
|
|
||||||
# - ועדת ערר / district openings (בפנינו / הערר שבנדון / ...)
|
|
||||||
# - COURT-RULING openings (#86.1): a פסק-דין header or the authoring judge's
|
|
||||||
# line. Without these, Nevo court judgments — exactly the ones carrying a
|
|
||||||
# מיני-רציו — slipped through unstripped (e.g. בג"ץ 1764/05).
|
|
||||||
#
|
|
||||||
# #86.2 hardening — two over-strip bugs found while backfilling:
|
|
||||||
# 1. ``פסק-דין`` headers are often markdown-wrapped (``**פסק דין**``); the old
|
|
||||||
# ``^פסק[- ]דין`` required the keyword to be the very first char of the line
|
|
||||||
# and allowed only one separator, so it missed the header and fell through
|
|
||||||
# to a citation 32K deep (עמ"נ 50567-07-21). We now tolerate leading
|
|
||||||
# markdown/whitespace and 0-3 separators.
|
|
||||||
# 2. Bare ``השופט``/``הנשיא`` matched *citations* ("השופט מ' חשין, פסקה 23"),
|
|
||||||
# stripping real decision body. The authoring-judge line ends with a COLON
|
|
||||||
# ("השופט י' עמית:"); citations use a comma. We now require the colon.
|
|
||||||
_DECISION_START = re.compile(
|
_DECISION_START = re.compile(
|
||||||
r"^[ \t>*_#]{0,6}(?:"
|
r"^[ \t>*_#]{0,6}(?:"
|
||||||
r"בפנינו|לפנינו|לפניי|הערר שבנדון|ועדת הערר לתכנון|רקע עובדתי|עסקינן|"
|
r"בפנינו|לפנינו|לפניי|הערר שבנדון|ועדת הערר לתכנון|רקע עובדתי|עסקינן|"
|
||||||
r"פסק[ \t\-]{0,3}די(?:ן|נו)|" # פסק-דין / פסק דין / **פסק דין** header (final-nun ן vs דינו)
|
r"פסק[ \t\-]{0,3}די(?:ן|נו)|"
|
||||||
r"(?:כב(?:וד)?['׳\"]?\s*)?(?:ה?שופט[ת]?|ה?נשיא[ה]?|המשנה לנשיא)\s+[^\n,]{1,40}:" # author line → colon
|
r"(?:כב(?:וד)?['׳\"]?\s*)?(?:ה?שופט[ת]?|ה?נשיא[ה]?|המשנה לנשיא)\s+[^\n,]{1,40}:"
|
||||||
r")",
|
r")",
|
||||||
re.MULTILINE,
|
re.MULTILINE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def strip_nevo_preamble(text: str) -> str:
|
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.
|
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]
|
head = text[:1500]
|
||||||
if not any(marker in head for marker in _NEVO_MARKERS):
|
if not any(marker in head for marker in _NEVO_MARKERS):
|
||||||
return text
|
return text
|
||||||
@@ -419,17 +426,7 @@ _RATIO_MARKER = "מיני-רציו:"
|
|||||||
|
|
||||||
|
|
||||||
def extract_nevo_ratio(text: str) -> str:
|
def extract_nevo_ratio(text: str) -> str:
|
||||||
"""Return the Nevo מיני-רציו block (editorial holdings summary), or ''.
|
"""Return the Nevo מיני-רציו block (editorial holdings summary), or ''."""
|
||||||
|
|
||||||
The mini-ratio is Nevo's own headnote — a concise, professionally-written
|
|
||||||
list of the holdings. We capture it *before* :func:`strip_nevo_preamble`
|
|
||||||
discards it, to serve as a free gold-set for benchmarking how well our
|
|
||||||
halacha extractor covers the real holdings (#86.3).
|
|
||||||
|
|
||||||
The block runs from the ``מיני-רציו:`` marker to whichever comes first:
|
|
||||||
the decision body (``_DECISION_START``) or the next preamble marker
|
|
||||||
(bibliography / legislation). Returns '' when there is no mini-ratio.
|
|
||||||
"""
|
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
start = text.find(_RATIO_MARKER)
|
start = text.find(_RATIO_MARKER)
|
||||||
@@ -437,9 +434,6 @@ def extract_nevo_ratio(text: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
body = text[start + len(_RATIO_MARKER):]
|
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)
|
end = len(body)
|
||||||
dm = _DECISION_START.search(body)
|
dm = _DECISION_START.search(body)
|
||||||
if dm:
|
if dm:
|
||||||
|
|||||||
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
|
||||||
@@ -59,6 +59,28 @@ ANTI_PATTERNS: list[dict] = [
|
|||||||
"note": "רשימות תבליטים באנליזה — דפנה כותבת נרטיב רציף"},
|
"note": "רשימות תבליטים באנליזה — דפנה כותבת נרטיב רציף"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def anti_pattern_directive() -> str:
|
||||||
|
"""The closing style directive, rendered from ANTI_PATTERNS (the same list
|
||||||
|
style_distance scores against — one source, two renderings, not two rules).
|
||||||
|
|
||||||
|
WHY THIS EXISTS SEPARATELY FROM the style-context rendering: the rule was
|
||||||
|
already reaching the writer, buried ~47K chars deep inside style_context,
|
||||||
|
and drafts kept emitting the very patterns it forbids. A measured A/B over
|
||||||
|
the signed finals (9 cases, 60 generations, 2026-07-28) showed that the SAME
|
||||||
|
rule restated at the END of the assembled prompt cuts anti-pattern hits by
|
||||||
|
72–93% on both blocks and both models:
|
||||||
|
|
||||||
|
block-vav opus-4-8 1.75 → 0.12 | opus-5 2.25 → 0.62
|
||||||
|
block-zayin opus-4-8 4.57 → 0.43 | opus-5 4.43 → 0.43
|
||||||
|
|
||||||
|
So this is a POSITION fix, not a new instruction. Keep it last in the prompt.
|
||||||
|
"""
|
||||||
|
lines = ["## כלל-סגנון מחייב (גובר על כל דוגמה בהקשר שלמעלה)",
|
||||||
|
"כתוב נרטיב משפטי רציף בלבד — פסקאות שלמות. אסור:"]
|
||||||
|
lines += [f"- {ap['note']}" for ap in ANTI_PATTERNS]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
# ── Paragraph length guidance (word counts) ────────────────────────
|
# ── Paragraph length guidance (word counts) ────────────────────────
|
||||||
|
|
||||||
PARAGRAPH_LENGTHS = {
|
PARAGRAPH_LENGTHS = {
|
||||||
|
|||||||
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}).",
|
||||||
|
}
|
||||||
@@ -383,7 +383,10 @@ async def reextract_metadata(
|
|||||||
appeal_subtype, and case_name when it equals the citation). User
|
appeal_subtype, and case_name when it equals the citation). User
|
||||||
values are preserved.
|
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
|
from legal_mcp.services import precedent_metadata_extractor
|
||||||
|
|
||||||
|
|||||||
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
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
from typing import Any
|
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"
|
# Placeholder strings — any of these means "not yet filled"
|
||||||
CHAIR_POSITION_PLACEHOLDERS = (
|
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 ""
|
date_str = date_match.group(1) if date_match else ""
|
||||||
|
|
||||||
stat = file_path.stat()
|
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] = {
|
result: dict[str, Any] = {
|
||||||
"header": {
|
"header": {
|
||||||
@@ -354,7 +360,7 @@ def update_chair_position(
|
|||||||
"saved": True,
|
"saved": True,
|
||||||
"section_id": section_id,
|
"section_id": section_id,
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now(IL_TZ).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import re
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import db
|
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
|
from legal_mcp.services.lessons import ANTI_PATTERNS, GOLDEN_RATIOS, canonical_outcome
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -122,6 +123,100 @@ 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}
|
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 = count_anti_patterns(regenerated_text or "")
|
||||||
|
anti_total = anti["total"]
|
||||||
|
# Per-pattern breakdown, not just the total: a calibration run that only
|
||||||
|
# reports "anti=4" cannot tell you WHICH rule was broken, so it cannot say
|
||||||
|
# what to fix. (Diagnosing the 2026-07-28 model A/B needed exactly this and
|
||||||
|
# had to fall back on inference.)
|
||||||
|
anti_by_pattern = {name: h["count"] for name, h in anti["by_pattern"].items()}
|
||||||
|
|
||||||
|
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,
|
||||||
|
"anti_by_pattern": anti_by_pattern,
|
||||||
|
"golden_ratio_deviation_pp": ratio_dev,
|
||||||
|
"distance": distance,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def style_distance(case_number: str) -> dict:
|
async def style_distance(case_number: str) -> dict:
|
||||||
"""Assemble the 3 style-distance components for one case (T7)."""
|
"""Assemble the 3 style-distance components for one case (T7)."""
|
||||||
case = await db.get_case_by_number(case_number)
|
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
|
import httpx
|
||||||
|
|
||||||
from legal_mcp import config
|
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.services import audit, db, extractor, git_sync, practice_area as pa
|
||||||
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
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
|
from datetime import date as date_type
|
||||||
|
|
||||||
# Ordered core lifecycle — regression protection (forward-only).
|
# Ordered core lifecycle (forward-only regression guard). Single source of
|
||||||
# Single source of truth, mirrored by web-ui/src/lib/api/case-status.ts and
|
# truth: legal_mcp/case_status_model.py — STATUS_ORDER/BY_KEY derive from it.
|
||||||
# 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",
|
|
||||||
]
|
|
||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return err(f"תיק {case_number} לא נמצא.")
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
@@ -363,6 +356,9 @@ async def case_update(
|
|||||||
# Only update if advancing or status is unknown to the order
|
# Only update if advancing or status is unknown to the order
|
||||||
if new_idx >= cur_idx or new_idx == -1:
|
if new_idx >= cur_idx or new_idx == -1:
|
||||||
fields["status"] = status
|
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:
|
if title:
|
||||||
fields["title"] = title
|
fields["title"] = title
|
||||||
if subject:
|
if subject:
|
||||||
|
|||||||
@@ -264,6 +264,11 @@ async def document_get_text(case_number: str, doc_title: str = "") -> str:
|
|||||||
async def document_list(case_number: 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:
|
Args:
|
||||||
case_number: מספר תיק הערר
|
case_number: מספר תיק הערר
|
||||||
"""
|
"""
|
||||||
@@ -345,11 +350,15 @@ async def extract_claims(
|
|||||||
if not docs:
|
if not docs:
|
||||||
return empty(f"אין מסמכים בתיק {case_number}.")
|
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:
|
if doc_title:
|
||||||
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
|
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
|
||||||
else:
|
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:
|
if not docs:
|
||||||
return empty("לא נמצאו כתבי טענות בתיק.")
|
return empty("לא נמצאו כתבי טענות בתיק.")
|
||||||
@@ -424,17 +433,24 @@ ALLOWED_DOC_TYPES = {
|
|||||||
# Allowed appraiser_side values; '' (empty) clears the tag.
|
# Allowed appraiser_side values; '' (empty) clears the tag.
|
||||||
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
|
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(
|
async def document_update(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
doc_id: str,
|
doc_id: str,
|
||||||
doc_type: str = "",
|
doc_type: str = "",
|
||||||
appraiser_side: str = "",
|
appraiser_side: str = "",
|
||||||
|
is_post_hearing: bool | None = None,
|
||||||
|
protocol_scope: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""עדכון תיוג מסמך — doc_type ו/או appraiser_side. ריק = אין שינוי.
|
"""עדכון תיוג מסמך — doc_type / appraiser_side / is_post_hearing / protocol_scope.
|
||||||
|
|
||||||
הולידציה זהה ל-PATCH endpoint ב-web/app.py. appraiser_side נשמר ב-
|
הולידציה זהה ל-PATCH endpoint ב-web/app.py. הדגלים נשמרים ב-
|
||||||
documents.metadata JSONB (מתפרסם משם ע"י extract_appraiser_facts).
|
documents.metadata JSONB (appraiser_side מתפרסם משם ע"י extract_appraiser_facts;
|
||||||
|
is_post_hearing נצרך ע"י כותב בלוק-ח; protocol_scope מסווג פרוטוקול בטאב-הסקירה).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
case_number: מספר תיק הערר (לאישור שייכות)
|
case_number: מספר תיק הערר (לאישור שייכות)
|
||||||
@@ -443,6 +459,9 @@ async def document_update(
|
|||||||
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
|
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
|
||||||
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
|
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
|
||||||
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
|
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
|
||||||
|
is_post_hearing: True = התקבל אחרי הדיון · False = מנקה את הדגל · None = אין שינוי.
|
||||||
|
protocol_scope: היקף פרוטוקול (appeal=ועדת הערר / lower=ועדה מקומית-מחוזית).
|
||||||
|
ריק = אין שינוי. רלוונטי רק כשהסוג protocol.
|
||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
@@ -468,17 +487,37 @@ async def document_update(
|
|||||||
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
|
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
|
||||||
updates["doc_type"] = doc_type
|
updates["doc_type"] = doc_type
|
||||||
|
|
||||||
# appraiser_side is optional. The MCP tool can't distinguish "skip" from
|
# appraiser_side / protocol_scope are optional. The MCP tool can't
|
||||||
# "set to empty string", so we use the convention: only update if non-empty.
|
# distinguish "skip" from "set to empty string", so we use the convention:
|
||||||
# To clear, the operator must edit metadata directly (rare).
|
# 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:
|
||||||
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
|
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
|
||||||
return err(f"appraiser_side לא תקין: {appraiser_side}",
|
return err(f"appraiser_side לא תקין: {appraiser_side}",
|
||||||
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
|
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["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
|
updates["metadata"] = metadata
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
|
|||||||
@@ -516,9 +516,18 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
|
|||||||
|
|
||||||
# ── Interim draft (pre-ruling) ────────────────────────────────────
|
# ── 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,
|
# 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"]
|
_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))
|
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:
|
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:
|
async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
||||||
"""ייצוא טיוטת ביניים ל-DOCX — אותו עיצוב של טיוטה רגילה (David, RTL,
|
"""ייצוא טיוטת ביניים ל-DOCX — אותו עיצוב של טיוטה רגילה (David, RTL,
|
||||||
bookmarks), אבל בסדר חדש: רקע → תכניות+היתרים → טענות → הליכים, ללא
|
bookmarks), אבל בסדר חדש: רקע → תכניות+היתרים → טענות → הליכים, ללא
|
||||||
דיון/סיכום/חתימות. שם הקובץ: טיוטת-ביניים-v{N}.docx.
|
דיון/סיכום/חתימות. שם הקובץ: טיוטה-טענות_הצדדים_{N}.docx.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
case_number: מספר תיק הערר
|
case_number: מספר תיק הערר
|
||||||
@@ -658,6 +747,34 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
|||||||
return err(str(e))
|
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:
|
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 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
|
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(
|
async def aggregate_claims_to_arguments(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
@@ -55,16 +64,9 @@ async def get_legal_arguments(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Group by party for nicer display.
|
# Group by party for nicer display.
|
||||||
party_he = {
|
|
||||||
"appellant": "עוררים",
|
|
||||||
"respondent": "משיבים",
|
|
||||||
"committee": "ועדה מקומית",
|
|
||||||
"permit_applicant": "מבקשי היתר",
|
|
||||||
"unknown": "צד לא מזוהה",
|
|
||||||
}
|
|
||||||
by_party: dict[str, list[dict]] = {}
|
by_party: dict[str, list[dict]] = {}
|
||||||
for a in args:
|
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)
|
by_party.setdefault(label, []).append(a)
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
@@ -72,3 +74,147 @@ async def get_legal_arguments(
|
|||||||
"total": len(args),
|
"total": len(args),
|
||||||
"by_party": by_party,
|
"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,
|
||||||
|
})
|
||||||
|
|||||||
@@ -46,8 +46,19 @@ async def workflow_status(case_number: str) -> str:
|
|||||||
"extraction": doc["extraction_status"],
|
"extraction": doc["extraction_status"],
|
||||||
"chunks": chunk_map.get(doc["id"], 0),
|
"chunks": chunk_map.get(doc["id"], 0),
|
||||||
"pages": doc.get("page_count"),
|
"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
|
# Check draft status
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
@@ -64,15 +75,19 @@ async def workflow_status(case_number: str) -> str:
|
|||||||
"documents": doc_status,
|
"documents": doc_status,
|
||||||
"total_documents": len(docs),
|
"total_documents": len(docs),
|
||||||
"total_chunks": sum(chunk_map.values()),
|
"total_chunks": sum(chunk_map.values()),
|
||||||
|
"primary_docs_not_analyzed": primary_not_analyzed,
|
||||||
"has_draft": has_draft,
|
"has_draft": has_draft,
|
||||||
"draft_size_bytes": draft_size,
|
"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)
|
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."""
|
"""Suggest next steps based on case state."""
|
||||||
steps = []
|
steps = []
|
||||||
doc_types = {d["doc_type"] for d in docs}
|
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:
|
if "response" not in doc_types:
|
||||||
steps.append("העלה תשובת ועדה/משיבים")
|
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"]
|
pending = [d for d in docs if d["extraction_status"] == "pending"]
|
||||||
if pending:
|
if pending:
|
||||||
steps.append(f"עיבוד {len(pending)} מסמכים ממתינים")
|
steps.append(f"עיבוד {len(pending)} מסמכים ממתינים")
|
||||||
@@ -394,13 +416,134 @@ async def record_chair_feedback(
|
|||||||
lesson_extracted=lesson_extracted,
|
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({
|
return ok({
|
||||||
"feedback_id": str(feedback_id),
|
"feedback_id": str(feedback_id),
|
||||||
|
"flowed_to_writer": bool(flowed),
|
||||||
"next_steps": [
|
"next_steps": [
|
||||||
"כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback",
|
"כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback",
|
||||||
"כדי לסמן כמטופל: resolve_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(
|
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")
|
||||||
60
mcp-server/tests/test_anti_pattern_directive.py
Normal file
60
mcp-server/tests/test_anti_pattern_directive.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""The style invariants must actually REACH the writer.
|
||||||
|
|
||||||
|
Both tests here cover defects found by the 2026-07-28 model×prompt A/B over the
|
||||||
|
signed finals: the canonical anti-patterns were present in the prompt but buried
|
||||||
|
~47K chars into style_context (where they measurably failed to bind), and they
|
||||||
|
were rendered inside a try/except that an unrelated DB failure could abort.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from legal_mcp.services import block_writer
|
||||||
|
from legal_mcp.services.lessons import ANTI_PATTERNS, anti_pattern_directive
|
||||||
|
|
||||||
|
|
||||||
|
def test_directive_renders_every_canonical_anti_pattern():
|
||||||
|
"""One source, two renderings — the directive may not drift from the list
|
||||||
|
style_distance scores against."""
|
||||||
|
text = anti_pattern_directive()
|
||||||
|
for ap in ANTI_PATTERNS:
|
||||||
|
assert ap["note"] in text, f"missing anti-pattern in directive: {ap['name']}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_writer_paths_append_the_directive_last():
|
||||||
|
"""write_block (in-process) and get_block_context (legal-writer agent) must
|
||||||
|
both close with the directive — otherwise the two writers drift (G2)."""
|
||||||
|
import inspect
|
||||||
|
src = inspect.getsource(block_writer)
|
||||||
|
for fn in ("async def write_block(", "async def get_block_context("):
|
||||||
|
start = src.index(fn)
|
||||||
|
# bound the search to this function: up to the next top-level def
|
||||||
|
rest = src[start + len(fn):]
|
||||||
|
nxt = rest.find("\nasync def ")
|
||||||
|
body = rest[: nxt if nxt != -1 else len(rest)]
|
||||||
|
assert "anti_pattern_directive()" in body, f"{fn} does not append the style directive"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_style_context_keeps_anti_patterns_when_overrides_fail(monkeypatch):
|
||||||
|
"""A chair-override outage must not silently un-teach the structural style.
|
||||||
|
|
||||||
|
Regression: the canonical list used to be emitted inside the overrides loop,
|
||||||
|
so a throw on an EARLIER category (golden_ratios) dropped it entirely.
|
||||||
|
"""
|
||||||
|
async def _boom(*a, **k):
|
||||||
|
raise RuntimeError("methodology table unavailable")
|
||||||
|
|
||||||
|
async def _empty(*a, **k):
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Every DB accessor this function touches is stubbed — the test must not open
|
||||||
|
# a real connection (a live pool here leaks across the shared event loop and
|
||||||
|
# breaks unrelated tests later in the run).
|
||||||
|
monkeypatch.setattr(block_writer.db, "get_style_patterns", _empty)
|
||||||
|
monkeypatch.setattr(block_writer.db, "get_methodology_overrides", _boom)
|
||||||
|
monkeypatch.setattr(block_writer.db, "get_recent_decision_lessons", _empty)
|
||||||
|
|
||||||
|
ctx = await block_writer._build_style_context("היטל השבחה")
|
||||||
|
|
||||||
|
assert "נרטיב משפטי רציף" in ctx
|
||||||
|
for ap in ANTI_PATTERNS:
|
||||||
|
assert ap["note"] in ctx, f"anti-pattern dropped on override failure: {ap['name']}"
|
||||||
@@ -14,7 +14,9 @@ from lxml import etree
|
|||||||
from legal_mcp.services.docx_exporter import (
|
from legal_mcp.services.docx_exporter import (
|
||||||
_BOOKMARK_ID_START,
|
_BOOKMARK_ID_START,
|
||||||
HEBREW_FONT,
|
HEBREW_FONT,
|
||||||
|
TEMPLATE_PATH,
|
||||||
_add_styled_paragraph,
|
_add_styled_paragraph,
|
||||||
|
_clear_body,
|
||||||
_insert_bookmark_end,
|
_insert_bookmark_end,
|
||||||
_insert_bookmark_start,
|
_insert_bookmark_start,
|
||||||
_mark_paragraph_rtl,
|
_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)
|
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 ──────────────────
|
# ── Heading overrides, numbered-list, dash strip ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,16 @@ def _find_ins_with_runs(tree: etree._Element) -> etree._Element | None:
|
|||||||
return 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:
|
def test_insert_after_ins_has_author_and_date(sample_docx: Path, tmp_path: Path) -> None:
|
||||||
out = tmp_path / "out.docx"
|
out = tmp_path / "out.docx"
|
||||||
rev = Revision(id="r1", type="insert_after",
|
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
|
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"
|
out = tmp_path / "out.docx"
|
||||||
rev = Revision(id="r1", type="insert_after",
|
rev = Revision(id="r1", type="insert_after",
|
||||||
anchor_bookmark="block-alef", content="מוסף")
|
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
|
assert run is not None
|
||||||
rPr = run.find(_w("rPr"))
|
rPr = run.find(_w("rPr"))
|
||||||
assert rPr is not None
|
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
|
assert rPr.find(_w("rtl")) is not None
|
||||||
rFonts = rPr.find(_w("rFonts"))
|
# …but NO hand-set font/size on the run (INV-EX4).
|
||||||
assert rFonts is not None
|
assert rPr.find(_w("rFonts")) is None
|
||||||
assert rFonts.get(_w("ascii")) == "David"
|
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 ────────────────────────
|
# ── 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:
|
def test_rtl_preserved_in_tracked_insertion(tmp_path: Path) -> None:
|
||||||
"""Inserted paragraph must have bidi + rtl + David font so it renders
|
"""Inserted paragraph must have bidi + an <w:rtl/> run marker so Hebrew
|
||||||
correctly in Word alongside the user's content."""
|
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"
|
user_file = tmp_path / "user.docx"
|
||||||
_make_user_edited_docx(user_file)
|
_make_user_edited_docx(user_file)
|
||||||
docx_retrofit.retrofit_bookmarks(user_file, backup=False)
|
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):
|
if any("עברית RTL" in (t.text or "") for t in text_els):
|
||||||
rPr = r.find(_w("rPr"))
|
rPr = r.find(_w("rPr"))
|
||||||
assert rPr is not None
|
assert rPr is not None
|
||||||
|
# RTL marker preserved…
|
||||||
assert rPr.find(_w("rtl")) is not None
|
assert rPr.find(_w("rtl")) is not None
|
||||||
rFonts = rPr.find(_w("rFonts"))
|
# …but NO hand-set font/size on the run (INV-EX4).
|
||||||
assert rFonts is not None
|
assert rPr.find(_w("rFonts")) is None
|
||||||
assert rFonts.get(_w("ascii")) == "David"
|
assert rPr.find(_w("sz")) is None
|
||||||
return
|
return
|
||||||
pytest.fail("tracked insertion with 'עברית RTL' not found")
|
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()
|
||||||
@@ -71,10 +71,13 @@
|
|||||||
| `compute_principle_gold.py` | python | **#153 (נטוש)** — גישת זיהוי-זהב ברמת-עיקרון דרך התאמת-`match_context`→הלכה. **הוחלף** ע"י "מאומת=אזכור" (`build_verified_layer.py`) אחרי שההתאמה נכשלה (match_context=רשימת-הפניות). נשמר לעיון. | deprecated |
|
| `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`. | **חד-פעמי** (סינון ראשוני) + ניתן-לחזרה |
|
| `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_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 |
|
| `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) |
|
| `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_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) |
|
| `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` (לתזמן) |
|
| `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 של מטא-דאטה |
|
| `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`) |
|
| `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`) |
|
||||||
@@ -97,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 |
|
| `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 |
|
| `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) |
|
| `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) | נקרא ע"י סוכנים |
|
| `notify.py` | python | שליחת מייל התראה מסוכנים via SMTP (Gmail) | נקרא ע"י סוכנים |
|
||||||
| `bidi_table.py` | python | יצירת טבלאות box-drawing עם תמיכה ב-BiDi (עברית+אנגלית) | ספריית עזר |
|
| `bidi_table.py` | python | יצירת טבלאות box-drawing עם תמיכה ב-BiDi (עברית+אנגלית) | ספריית עזר |
|
||||||
| `convert_decision_template.py` | python | המרת `data/training/טיוטת החלטה.dotx` → `skills/docx/decision_template.docx` לטעינה ב-python-docx | להריץ כשמתעדכנת התבנית |
|
| `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 | ידני |
|
| `deploy-track-changes.sh` | bash | סנכרון skills CMP↔CMPA + בדיקות + הנחיות deploy לארכיטקטורת Track Changes | ידני |
|
||||||
|
|
||||||
## חד פעמי
|
## חד פעמי
|
||||||
@@ -153,6 +158,7 @@
|
|||||||
|
|
||||||
| Script | Type | Purpose | Scheduled |
|
| 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 קולט. | ידני (חד-פעמי/לפי-צורך) |
|
| `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 |
|
| `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 |
|
| `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 |
|
||||||
@@ -169,6 +175,7 @@
|
|||||||
|
|
||||||
| Script | Original Purpose | Superseded By |
|
| 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()` |
|
| `backfill_pattern_frequency.py` | עדכון תדירות דפוסי סגנון ב-DB | `web/app.py::_extract_pattern_variants()` |
|
||||||
| `batch_upload_training.py` | העלאת קורפוס אימון (16 קבצים) | Web UI: `/api/training/upload` |
|
| `batch_upload_training.py` | העלאת קורפוס אימון (16 קבצים) | Web UI: `/api/training/upload` |
|
||||||
| `benchmark_embeddings.py` | השוואת מודלי embeddings (voyage-3 vs voyage-4) | הושלם — voyage-3-large נבחר |
|
| `benchmark_embeddings.py` | השוואת מודלי embeddings (voyage-3 vs voyage-4) | הושלם — voyage-3-large נבחר |
|
||||||
@@ -199,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` |
|
| `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) |
|
| `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 בלבד)
|
## סקריפטים שנמחקו (git history בלבד)
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ Mapping (removed → kept):
|
|||||||
|
|
||||||
uploading → processing
|
uploading → processing
|
||||||
in_progress → outcome_set
|
in_progress → outcome_set
|
||||||
analyst_verified → documents_ready
|
|
||||||
research_complete → documents_ready
|
|
||||||
brainstorming → outcome_set
|
brainstorming → outcome_set
|
||||||
analysis_enriched → direction_approved
|
analysis_enriched → direction_approved
|
||||||
ready_for_writing → 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")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
log = logging.getLogger("status-trim")
|
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 = {
|
STATUS_MAP = {
|
||||||
"uploading": "processing",
|
"uploading": "processing",
|
||||||
"in_progress": "outcome_set",
|
"in_progress": "outcome_set",
|
||||||
"analyst_verified": "documents_ready",
|
|
||||||
"research_complete": "documents_ready",
|
|
||||||
"brainstorming": "outcome_set",
|
"brainstorming": "outcome_set",
|
||||||
"analysis_enriched": "direction_approved",
|
"analysis_enriched": "direction_approved",
|
||||||
"ready_for_writing": "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 asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from legal_mcp.services import db, embeddings
|
from legal_mcp.services import db
|
||||||
from legal_mcp.services.chunker import _split_into_sections
|
from legal_mcp.services import style_exemplars as sx
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
log = logging.getLogger("backfill_exemplars")
|
log = logging.getLogger("backfill_exemplars")
|
||||||
|
|
||||||
# chunker section_type → style_exemplars.section
|
# Section mapping + paragraph splitting now live in the shared service
|
||||||
_SECTION_MAP = {
|
# (legal_mcp.services.style_exemplars) so the backfill and the live
|
||||||
"facts": "background",
|
# final-enrollment path use ONE extraction implementation (G2).
|
||||||
"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]
|
|
||||||
|
|
||||||
|
|
||||||
async def _gather_sources() -> list[dict]:
|
async def _gather_sources() -> list[dict]:
|
||||||
@@ -94,27 +69,17 @@ async def main(apply: bool) -> None:
|
|||||||
|
|
||||||
total_paras = 0
|
total_paras = 0
|
||||||
for src in sources:
|
for src in sources:
|
||||||
units: list[tuple[str, str]] = [] # (section, paragraph)
|
n = len(sx.units_for(src["full_text"]))
|
||||||
for section_type, section_text in _split_into_sections(src["full_text"]):
|
if not n:
|
||||||
section = _SECTION_MAP.get(section_type, "other")
|
|
||||||
for para in _paragraphs(section_text):
|
|
||||||
units.append((section, para))
|
|
||||||
if not units:
|
|
||||||
continue
|
continue
|
||||||
total_paras += len(units)
|
total_paras += n
|
||||||
log.info(" %-14s %-16s → %d פסקאות", src["source"], src["decision_number"], len(units))
|
log.info(" %-14s %-16s → %d פסקאות", src["source"], src["decision_number"], n)
|
||||||
if not apply:
|
if not apply:
|
||||||
continue
|
continue
|
||||||
|
await sx.extract_and_store(
|
||||||
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"],
|
decision_number=src["decision_number"], source=src["source"],
|
||||||
practice_area=src["practice_area"], outcome=src["outcome"],
|
full_text=src["full_text"], practice_area=src["practice_area"],
|
||||||
section=section, paragraph_text=para, word_count=len(para.split()),
|
outcome=src["outcome"],
|
||||||
embedding=vec,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if apply:
|
if apply:
|
||||||
|
|||||||
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()))
|
||||||
809
scripts/calibrate_effort.py
Normal file
809
scripts/calibrate_effort.py
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
#!/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, "anti_by_pattern": {}, "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,
|
||||||
|
"anti_by_pattern": _mean_by_pattern(per_run),
|
||||||
|
"n": len(per_run),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mean_by_pattern(per_run: list[dict]) -> dict:
|
||||||
|
"""Mean hits PER anti-pattern name across runs — the 'which rule broke' view.
|
||||||
|
|
||||||
|
A pattern absent from a run counts as 0 (count_anti_patterns omits zero-hit
|
||||||
|
keys), so the mean is over ALL runs, not only the ones that tripped it.
|
||||||
|
"""
|
||||||
|
names: set[str] = set()
|
||||||
|
for r in per_run:
|
||||||
|
names |= set((r.get("anti_by_pattern") or {}).keys())
|
||||||
|
return {
|
||||||
|
name: round(mean((r.get("anti_by_pattern") or {}).get(name, 0) for r in per_run), 2)
|
||||||
|
for name in sorted(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
model: str | None = None, instructions: str = "") -> dict:
|
||||||
|
"""Generate `block_id` at `effort` `repeats` times; score each vs the final section.
|
||||||
|
|
||||||
|
`model` (optional) A/Bs the generation model via write_block(model_override=…).
|
||||||
|
None ⇒ the pinned GENERATION_MODEL, i.e. the production path unchanged.
|
||||||
|
|
||||||
|
`instructions` (optional) is appended to the block prompt for EVERY cell in
|
||||||
|
the run — a prompt-variant A/B (e.g. an explicit formatting rule). It is
|
||||||
|
applied to all models so the comparison stays a model comparison rather
|
||||||
|
than silently becoming a prompt comparison.
|
||||||
|
"""
|
||||||
|
from legal_mcp.services import block_writer
|
||||||
|
from legal_mcp.services.style_distance import block_distance_to_final
|
||||||
|
runs: list[dict] = []
|
||||||
|
models_used: list[str] = []
|
||||||
|
for _ in range(repeats):
|
||||||
|
res = await block_writer.write_block(
|
||||||
|
case_id, block_id, instructions=instructions,
|
||||||
|
effort_override=effort, model_override=model,
|
||||||
|
)
|
||||||
|
# Record what the CLI was actually asked to run, so a silent fallback to
|
||||||
|
# a different build is visible in the report rather than mis-attributed.
|
||||||
|
models_used.append(res.get("model_used") or "?")
|
||||||
|
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["model"] = model
|
||||||
|
agg["models_used"] = sorted(set(models_used))
|
||||||
|
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
|
||||||
|
models = args.models
|
||||||
|
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 * len(models)
|
||||||
|
grid_summary = {
|
||||||
|
"n_finals": len(cases_meta),
|
||||||
|
"finals": [c["case_number"] for c in cases_meta],
|
||||||
|
"blocks": blocks, "efforts": efforts, "repeats": args.repeats,
|
||||||
|
"models": models,
|
||||||
|
# Provenance: a prompt-variant run is NOT comparable to a baseline run,
|
||||||
|
# so the instruction text is recorded in the report, not just the shell.
|
||||||
|
"instructions": getattr(args, "instructions", "") or "",
|
||||||
|
"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_model: dict[str, dict] = {}
|
||||||
|
for model in models:
|
||||||
|
by_block = await _run_blocks_for_model(
|
||||||
|
model, blocks, efforts, plan, args, ts, grid_summary, by_model, _BLOCK_TO_SECTION,
|
||||||
|
)
|
||||||
|
by_model[model] = by_block
|
||||||
|
|
||||||
|
# `by_block` stays the single-model shape (first model) so --rerank and the
|
||||||
|
# existing per-block report path keep working unchanged (G2 — no second
|
||||||
|
# result schema); multi-model runs additionally carry by_model.
|
||||||
|
out = {"dry_run": False, "grid": grid_summary, "by_block": by_model[models[0]]}
|
||||||
|
if len(models) > 1:
|
||||||
|
out["by_model"] = by_model
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_blocks_for_model(model, blocks, efforts, plan, args, ts, grid_summary,
|
||||||
|
by_model_so_far, _BLOCK_TO_SECTION) -> dict:
|
||||||
|
"""The per-block × per-effort grid for ONE generation model."""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
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,
|
||||||
|
model=model, instructions=getattr(args, "instructions", "") or "",
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — harness must survive any cell failure
|
||||||
|
logger.warning(
|
||||||
|
"calibration cell skipped: case=%s block=%s effort=%s model=%s — %s",
|
||||||
|
c["case_number"], block_id, effort, model, 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,
|
||||||
|
"anti_by_pattern": _mean_by_pattern(rows),
|
||||||
|
"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,
|
||||||
|
"model": model,
|
||||||
|
# Model builds the CLI actually reported across this block's cells —
|
||||||
|
# a mismatch vs `model` means a silent fallback, not a real A/B.
|
||||||
|
"models_used": sorted({m for e in per_effort_runs.values()
|
||||||
|
for cell in e for m in cell.get("models_used", [])}),
|
||||||
|
"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:
|
||||||
|
snap = {"dry_run": False, "grid": grid_summary, "by_block": by_block}
|
||||||
|
if by_model_so_far or len(grid_summary.get("models", [])) > 1:
|
||||||
|
snap["by_model"] = {**by_model_so_far, model: by_block}
|
||||||
|
_write_report(snap, ts)
|
||||||
|
except Exception as exc: # noqa: BLE001 — a write hiccup must not abort the run
|
||||||
|
logger.warning("incremental report write failed after block=%s model=%s — %s",
|
||||||
|
block_id, model, exc)
|
||||||
|
|
||||||
|
return 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"- models: {', '.join(m or 'pinned-default' for m in g.get('models', [None]))}",
|
||||||
|
f"- סך ייצורי-מודל: {g['total_generations']}",
|
||||||
|
(f"- ⚠️ **וריאנט-פרומפט** (לא בר-השוואה לריצת-בסיס): `{g['instructions']}`"
|
||||||
|
if g.get("instructions") else "- וריאנט-פרומפט: — (פרומפט ייצור כפי-שהוא)"),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
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("")
|
||||||
|
by_model = result.get("by_model") or {}
|
||||||
|
if len(by_model) > 1:
|
||||||
|
lines += ["## השוואת-מודלים (אותו block, אותו effort, אותם סופיים)\n",
|
||||||
|
"| block | effort | model | anti_total | change% | ratioΔpp | distance | n |",
|
||||||
|
"|---|---|---|---|---|---|---|---|"]
|
||||||
|
for b in g["blocks"]:
|
||||||
|
for eff in g["efforts"]:
|
||||||
|
rows = []
|
||||||
|
for m, bb in by_model.items():
|
||||||
|
for r in (bb.get(b) or {}).get("efforts", []):
|
||||||
|
if r["effort"] == eff:
|
||||||
|
rows.append((m, r))
|
||||||
|
if len(rows) < 2:
|
||||||
|
continue # nothing to compare for this cell — don't fake a row
|
||||||
|
best = min(rows, key=lambda mr: (mr[1]["anti_pattern_total"],
|
||||||
|
mr[1]["golden_ratio_deviation_pp"] or 0,
|
||||||
|
mr[1]["distance"]))[0]
|
||||||
|
for m, r in rows:
|
||||||
|
ratio = r["golden_ratio_deviation_pp"]
|
||||||
|
star = " ⭐" if m == best else ""
|
||||||
|
lines.append(
|
||||||
|
f"| {b} | {eff} | {m}{star} | {r['anti_pattern_total']} | "
|
||||||
|
f"{r['change_percent']} | {ratio if ratio is not None else '—'} | "
|
||||||
|
f"{r['distance']:.4f} | {r['n']} |")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# WHICH rule broke — a total alone can't tell you what to fix.
|
||||||
|
bd_rows = [(b, eff, m, r) for b in g["blocks"] for eff in g["efforts"]
|
||||||
|
for m, bb in by_model.items()
|
||||||
|
for r in (bb.get(b) or {}).get("efforts", []) if r["effort"] == eff]
|
||||||
|
if any(r.get("anti_by_pattern") for *_, r in bd_rows):
|
||||||
|
names = sorted({n for *_, r in bd_rows for n in (r.get("anti_by_pattern") or {})})
|
||||||
|
lines += ["### פילוח אנטי-דפוסים (איזה כלל הופר)\n",
|
||||||
|
"| block | effort | model | " + " | ".join(names) + " |",
|
||||||
|
"|---|---|---|" + "---|" * len(names)]
|
||||||
|
for b, eff, m, r in bd_rows:
|
||||||
|
cells = " | ".join(str((r.get("anti_by_pattern") or {}).get(n, 0)) for n in names)
|
||||||
|
lines.append(f"| {b} | {eff} | {m} | {cells} |")
|
||||||
|
lines.append("")
|
||||||
|
# A silent CLI fallback would make the whole comparison meaningless — surface it.
|
||||||
|
for m, bb in by_model.items():
|
||||||
|
for b, bd in bb.items():
|
||||||
|
used = bd.get("models_used") or []
|
||||||
|
if used and any(not u.startswith(str(m)) for u in used):
|
||||||
|
lines.append(f"> ⚠️ **{b} / {m}**: ה-CLI דיווח `{', '.join(used)}` — "
|
||||||
|
"ייתכן fallback שקט; ההשוואה לתא זה אינה תקפה.\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)")
|
||||||
|
ap.add_argument("--models", default="",
|
||||||
|
help="comma generation-model ids to A/B (e.g. claude-opus-4-8,claude-opus-5). "
|
||||||
|
"Empty (default) = the pinned GENERATION_MODEL, i.e. production unchanged.")
|
||||||
|
ap.add_argument("--instructions", default="",
|
||||||
|
help="extra prompt instruction appended to EVERY cell (prompt-variant A/B). "
|
||||||
|
"Applied to all models — the run stays a model comparison. Recorded in the report.")
|
||||||
|
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
|
||||||
|
# [None] = "use the pinned GENERATION_MODEL" — keeps the default run byte-identical
|
||||||
|
# to the pre-#models behaviour instead of hard-coding the id in a second place (G2).
|
||||||
|
args.models = [m.strip() for m in args.models.split(",") if m.strip()] or [None]
|
||||||
|
|
||||||
|
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()))
|
||||||
@@ -91,15 +91,22 @@ async def main(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
# The 3 steps as durable nodes (X16 / INV-DUR1) — shared runtime with
|
# 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
|
# 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:
|
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"])
|
status = await _latest_pair_status(case["id"])
|
||||||
if status == "analyzed" and not args.force:
|
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"}
|
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)
|
raw = await ingest_final_version(case_number, file_path=final_path)
|
||||||
try:
|
try:
|
||||||
env = json.loads(raw)
|
env = json.loads(raw)
|
||||||
@@ -120,8 +127,9 @@ async def main(args: argparse.Namespace) -> int:
|
|||||||
return {"ingest": "done"}
|
return {"ingest": "done"}
|
||||||
|
|
||||||
async def step_enroll(results: dict) -> dict:
|
async def step_enroll(results: dict) -> dict:
|
||||||
# [2] enroll into style_corpus (idempotent) — lessons need a corpus_id.
|
# [1] enroll into style_corpus (idempotent) — lessons need a corpus_id, and
|
||||||
print("[2/3] רישום לקורפוס-הסגנון (idempotent)…", flush=True)
|
# 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):
|
if await _has_style_corpus(case_number):
|
||||||
print(" ✓ כבר רשום בקורפוס-הסגנון")
|
print(" ✓ כבר רשום בקורפוס-הסגנון")
|
||||||
return {"enroll": "exists"}
|
return {"enroll": "exists"}
|
||||||
@@ -150,8 +158,8 @@ async def main(args: argparse.Namespace) -> int:
|
|||||||
return {"panel_rc": rc or 0}
|
return {"panel_rc": rc or 0}
|
||||||
|
|
||||||
steps = [
|
steps = [
|
||||||
_pipeline_runtime.Step("ingest_final_version", step_ingest),
|
|
||||||
_pipeline_runtime.Step("enroll_style_corpus", step_enroll),
|
_pipeline_runtime.Step("enroll_style_corpus", step_enroll),
|
||||||
|
_pipeline_runtime.Step("ingest_final_version", step_ingest),
|
||||||
_pipeline_runtime.Step("style_panel", step_panel),
|
_pipeline_runtime.Step("style_panel", step_panel),
|
||||||
]
|
]
|
||||||
checkpoint_db = config.DATA_DIR / "checkpoints" / "learning.sqlite"
|
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.
|
4. MinIO keys cases/{old}/… 3 buckets; cp→new then rm old.
|
||||||
legal-immutable (WORM/object-lock) → copy-only, old object stays locked.
|
legal-immutable (WORM/object-lock) → copy-only, old object stays locked.
|
||||||
5. Gitea repo cases/{old} API PATCH name + local .git remote rewrite
|
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
|
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.
|
(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:
|
try:
|
||||||
c = await asyncpg.connect(PAPERCLIP_DSN, timeout=10)
|
c = await asyncpg.connect(PAPERCLIP_DSN, timeout=10)
|
||||||
except Exception as e:
|
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:
|
try:
|
||||||
rows = await c.fetch("SELECT id, name FROM projects WHERE name LIKE $1", f"%{old}%")
|
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:
|
finally:
|
||||||
await c.close()
|
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:
|
except urllib.error.HTTPError as e:
|
||||||
log(f" ✗ Gitea rename failed: HTTP {e.code} {e.read()[:160]!r}")
|
log(f" ✗ Gitea rename failed: HTTP {e.code} {e.read()[:160]!r}")
|
||||||
|
|
||||||
# 6. Paperclip project name
|
# 6. Paperclip case↔issue linkage — project name + legal-case-number value +
|
||||||
if not skip_paperclip and rec["paperclip"].get("reachable") and rec["paperclip"]["projects"]:
|
# 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
|
import asyncpg
|
||||||
c = await asyncpg.connect(PAPERCLIP_DSN, timeout=10)
|
c = await asyncpg.connect(PAPERCLIP_DSN, timeout=10)
|
||||||
try:
|
try:
|
||||||
|
if rec["paperclip"]["projects"]:
|
||||||
res = await c.execute(
|
res = await c.execute(
|
||||||
"UPDATE projects SET name = replace(name, $1, $2), updated_at = now() WHERE name LIKE $3",
|
"UPDATE projects SET name = replace(name, $1, $2), updated_at = now() "
|
||||||
|
"WHERE name LIKE $3",
|
||||||
old, new, f"%{old}%",
|
old, new, f"%{old}%",
|
||||||
)
|
)
|
||||||
log(f" ✓ Paperclip projects: {res}")
|
log(f" ✓ Paperclip projects: {res}")
|
||||||
|
res = await c.execute(
|
||||||
|
"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 issue titles: {res}")
|
||||||
finally:
|
finally:
|
||||||
await c.close()
|
await c.close()
|
||||||
|
|
||||||
@@ -419,6 +455,8 @@ def print_inspection(rec: dict) -> None:
|
|||||||
log(f" pclip: {name}")
|
log(f" pclip: {name}")
|
||||||
if not pc["projects"]:
|
if not pc["projects"]:
|
||||||
log(" pclip: (no matching project)")
|
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:
|
else:
|
||||||
log(f" pclip: unreachable ({pc.get('error','')})")
|
log(f" pclip: unreachable ({pc.get('error','')})")
|
||||||
log(" DB path columns to rewrite:")
|
log(" DB path columns to rewrite:")
|
||||||
|
|||||||
@@ -110,6 +110,19 @@ def _category(change: dict) -> str:
|
|||||||
return "style"
|
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 ──
|
# ── two judges, one signature: (system, user) -> dict|None ──
|
||||||
|
|
||||||
async def judge_deepseek(client: httpx.AsyncClient, system: str, user: str) -> 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"])])
|
_lesson_text(r["_change"])])
|
||||||
|
|
||||||
written = 0
|
written = 0
|
||||||
|
approved = 0
|
||||||
for r in fresh:
|
for r in fresh:
|
||||||
|
cat = _category(r["_change"])
|
||||||
|
rs = _review_status_for(cat)
|
||||||
await db.add_decision_lesson(
|
await db.add_decision_lesson(
|
||||||
UUID(corpus_id),
|
UUID(corpus_id),
|
||||||
lesson_text=_lesson_text(r["_change"]),
|
lesson_text=_lesson_text(r["_change"]),
|
||||||
category=_category(r["_change"]),
|
category=cat,
|
||||||
source="panel:deepseek+gemini",
|
source="panel:deepseek+gemini",
|
||||||
created_by="panel",
|
created_by="panel",
|
||||||
|
review_status=rs,
|
||||||
)
|
)
|
||||||
written += 1
|
written += 1
|
||||||
|
approved += (rs == "approved")
|
||||||
|
|
||||||
chair = cc["split"] + cc["incomplete"]
|
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"(source=panel:deepseek+gemini) · {skipped_dup} כפילויות דולגו · "
|
||||||
f"{chair} escalated to chair · {len(substance)} substance skipped")
|
f"{chair} escalated to chair · {len(substance)} substance skipped")
|
||||||
print(f"backup → {backup}")
|
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 ApprovalCategory,
|
||||||
type ApprovalSeverity,
|
type ApprovalSeverity,
|
||||||
} from "@/lib/api/chair";
|
} from "@/lib/api/chair";
|
||||||
|
import { formatDateLong } from "@/lib/format-date";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* מרכז אישורים — דפנה (INV-G10).
|
* מרכז אישורים — דפנה (INV-G10).
|
||||||
@@ -35,18 +36,7 @@ const SEVERITY_RAIL: Record<ApprovalSeverity, string> = {
|
|||||||
ok: "border-s-success",
|
ok: "border-s-success",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(iso?: string | null): string {
|
const formatDate = (iso?: string | null) => formatDateLong(iso);
|
||||||
if (!iso) return "";
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleDateString("he-IL", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ApprovalCard({ cat }: { cat: ApprovalCategory }) {
|
function ApprovalCard({ cat }: { cat: ApprovalCategory }) {
|
||||||
const cleared = cat.count === 0;
|
const cleared = cat.count === 0;
|
||||||
|
|||||||
@@ -28,19 +28,7 @@ import {
|
|||||||
import { useCases, useRestoreCase, type Case } from "@/lib/api/cases";
|
import { useCases, useRestoreCase, type Case } from "@/lib/api/cases";
|
||||||
import { subtypeOf } from "@/components/cases/appeal-type-bars";
|
import { subtypeOf } from "@/components/cases/appeal-type-bars";
|
||||||
import { APPEAL_SUBTYPE_LABELS, type AppealSubtype } from "@/lib/practice-area";
|
import { APPEAL_SUBTYPE_LABELS, type AppealSubtype } from "@/lib/practice-area";
|
||||||
|
import { formatDate, getIsraelYear } from "@/lib/format-date";
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// type chip styling per mockup 05 (.t-lic / .t-bet / .t-comp)
|
// type chip styling per mockup 05 (.t-lic / .t-bet / .t-comp)
|
||||||
const TYPE_CHIP: Record<string, string> = {
|
const TYPE_CHIP: Record<string, string> = {
|
||||||
@@ -177,8 +165,8 @@ export default function ArchivePage() {
|
|||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
for (const c of rows) {
|
for (const c of rows) {
|
||||||
if (!c.archived_at) continue;
|
if (!c.archived_at) continue;
|
||||||
const y = new Date(c.archived_at).getFullYear();
|
const y = getIsraelYear(c.archived_at);
|
||||||
if (!Number.isNaN(y)) set.add(String(y));
|
if (y !== null) set.add(String(y));
|
||||||
}
|
}
|
||||||
return [...set].sort((a, b) => Number(b) - Number(a));
|
return [...set].sort((a, b) => Number(b) - Number(a));
|
||||||
}, [rows]);
|
}, [rows]);
|
||||||
@@ -209,7 +197,7 @@ export default function ArchivePage() {
|
|||||||
if (yearFilter !== "all") {
|
if (yearFilter !== "all") {
|
||||||
all = all.filter((r) => {
|
all = all.filter((r) => {
|
||||||
const iso = r.original.archived_at;
|
const iso = r.original.archived_at;
|
||||||
return iso != null && String(new Date(iso).getFullYear()) === yearFilter;
|
return iso != null && String(getIsraelYear(iso)) === yearFilter;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return all;
|
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,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { use } from "react";
|
import { use, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { CaseHeader } from "@/components/cases/case-header";
|
import { CaseHeader } from "@/components/cases/case-header";
|
||||||
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
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 { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||||
|
import { CaseFilesBrowser } from "@/components/cases/case-files-browser";
|
||||||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-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 { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
||||||
import { AgentActivityPreview } from "@/components/cases/agent-activity-preview";
|
import { AgentActivityPreview } from "@/components/cases/agent-activity-preview";
|
||||||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
|
||||||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||||
import { expectedOutcomes } from "@/lib/schemas/case";
|
|
||||||
import { useCase, useStartWorkflow } from "@/lib/api/cases";
|
import { useCase, useStartWorkflow } from "@/lib/api/cases";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Play, Loader2 } from "lucide-react";
|
import {
|
||||||
|
Play, Loader2, LayoutGrid, Scale, BadgeCheck, FileText, MessageSquare, Users,
|
||||||
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
|
type LucideIcon,
|
||||||
expectedOutcomes.map((o) => [o.value, o.label]),
|
} from "lucide-react";
|
||||||
);
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Next 16 breaking change: route params are now a Promise.
|
* Next 16 breaking change: route params are now a Promise.
|
||||||
@@ -39,14 +40,14 @@ export default function CaseDetailPage({
|
|||||||
params: Promise<{ caseNumber: string }>;
|
params: Promise<{ caseNumber: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { caseNumber } = use(params);
|
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 startWorkflow = useStartWorkflow(caseNumber);
|
||||||
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
|
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 (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
@@ -54,9 +55,14 @@ export default function CaseDetailPage({
|
|||||||
<CardContent className="px-6 py-6 text-center space-y-3">
|
<CardContent className="px-6 py-6 text-center space-y-3">
|
||||||
<p className="text-danger font-semibold">שגיאה בטעינת התיק</p>
|
<p className="text-danger font-semibold">שגיאה בטעינת התיק</p>
|
||||||
<p className="text-sm text-ink-muted">{error.message}</p>
|
<p className="text-sm text-ink-muted">{error.message}</p>
|
||||||
<Button asChild variant="outline">
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => refetch()}>
|
||||||
|
נסה שוב
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="ghost">
|
||||||
<Link href="/">חזרה לרשימת התיקים</Link>
|
<Link href="/">חזרה לרשימת התיקים</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
@@ -64,23 +70,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 = (
|
const tabsList = (
|
||||||
<TabsList
|
<TabsList
|
||||||
variant="line"
|
variant="line"
|
||||||
className="gap-6 h-auto p-0 rounded-none -mb-px"
|
className="!h-auto w-full gap-2 p-0 justify-between"
|
||||||
>
|
>
|
||||||
{[
|
{tabDefs.map(([value, label, Icon]) => (
|
||||||
["overview", "סקירה"],
|
|
||||||
["arguments", "טיעונים"],
|
|
||||||
["decision", "ההחלטה"],
|
|
||||||
["drafts", "טיוטות והערות"],
|
|
||||||
["agents", "סוכנים"],
|
|
||||||
].map(([value, label]) => (
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={value}
|
key={value}
|
||||||
value={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}
|
{label}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
@@ -116,7 +131,7 @@ export default function CaseDetailPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Tabs defaultValue="overview" dir="rtl">
|
<Tabs value={tab} onValueChange={setTab} dir="rtl">
|
||||||
{/* parchment band — header (title/chips/parties/actions) + tab strip */}
|
{/* parchment band — header (title/chips/parties/actions) + tab strip */}
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<div className="-mx-10 -mt-10 mb-2 bg-parchment border-b border-rule px-10 pt-6 pb-4 space-y-3">
|
<div className="-mx-10 -mt-10 mb-2 bg-parchment border-b border-rule px-10 pt-6 pb-4 space-y-3">
|
||||||
@@ -125,32 +140,98 @@ export default function CaseDetailPage({
|
|||||||
<Skeleton className="h-6 w-96" />
|
<Skeleton className="h-6 w-96" />
|
||||||
</div>
|
</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) */}
|
{/* full-width tab content — status now lives in the always-visible band
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_340px] items-start mt-6">
|
StatusRail (banner A, X17), so every tab keeps the status context. */}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 mt-6">
|
||||||
<TabsContent value="overview" className="mt-0 space-y-5">
|
<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} />
|
<DocumentsPanel data={data} />
|
||||||
|
|
||||||
<AgentActivityPreview caseNumber={caseNumber} />
|
<AgentActivityPreview caseNumber={caseNumber} />
|
||||||
|
</div>
|
||||||
{/* 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"
|
|
||||||
>
|
|
||||||
<Link href={`/cases/${caseNumber}/compose`}>
|
|
||||||
פתח עורך החלטה →
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="arguments" className="mt-0">
|
{/* טיעונים ועמדות — 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"
|
||||||
|
>
|
||||||
|
<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} />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* אימות פסיקה — 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">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-6 py-5">
|
<CardContent className="px-6 py-5">
|
||||||
<LegalArgumentsPanel caseNumber={caseNumber} />
|
<CitationVerificationPanel caseNumber={caseNumber} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -163,12 +244,17 @@ export default function CaseDetailPage({
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="drafts" className="mt-0">
|
<TabsContent value="drafts" className="mt-0 space-y-4">
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-6 py-5">
|
<CardContent className="px-6 py-5">
|
||||||
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<CaseFilesBrowser caseNumber={caseNumber} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="agents" className="mt-0">
|
<TabsContent value="agents" className="mt-0">
|
||||||
@@ -179,32 +265,6 @@ export default function CaseDetailPage({
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</div>
|
</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 />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type ChairFeedback,
|
type ChairFeedback,
|
||||||
type FeedbackCategory,
|
type FeedbackCategory,
|
||||||
} from "@/lib/api/feedback";
|
} 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",
|
other: "bg-rule-soft text-ink-soft",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(iso?: string | null): string {
|
const formatDate = (iso?: string | null) => formatDateLong(iso);
|
||||||
if (!iso) return "";
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleDateString("he-IL", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function FeedbackCard({ fb }: { fb: ChairFeedback }) {
|
function FeedbackCard({ fb }: { fb: ChairFeedback }) {
|
||||||
const resolve = useResolveFeedback();
|
const resolve = useResolveFeedback();
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -30,13 +29,17 @@ import {
|
|||||||
useRunLog,
|
useRunLog,
|
||||||
useCancelRun,
|
useCancelRun,
|
||||||
useResetAgentSession,
|
useResetAgentSession,
|
||||||
|
useAgentHealth,
|
||||||
|
useRecentEscalations,
|
||||||
type OpsService,
|
type OpsService,
|
||||||
type OperationsSnapshot,
|
type OperationsSnapshot,
|
||||||
type PipelineStats,
|
type PipelineStats,
|
||||||
type AgentRun,
|
type AgentRun,
|
||||||
type SubscriptionUsage,
|
type SubscriptionUsage,
|
||||||
type UsageWindow,
|
type UsageWindow,
|
||||||
|
type AgentHealthState,
|
||||||
} from "@/lib/api/operations";
|
} from "@/lib/api/operations";
|
||||||
|
import { formatTime, israelParts } from "@/lib/format-date";
|
||||||
|
|
||||||
function mb(bytes: number): string {
|
function mb(bytes: number): string {
|
||||||
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
|
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.
|
// and resumes on its own when a window resets.
|
||||||
function usageResetLabel(iso: string | null): string {
|
function usageResetLabel(iso: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
return `איפוס ${formatTime(iso)}`;
|
||||||
return `איפוס ${d.toLocaleTimeString("he-IL", { hour: "2-digit", minute: "2-digit" })}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function UsageMeter({ label, w }: { label: string; w?: UsageWindow | null }) {
|
function UsageMeter({ label, w }: { label: string; w?: UsageWindow | null }) {
|
||||||
@@ -196,8 +198,9 @@ function nextSaturday18(): Date {
|
|||||||
}
|
}
|
||||||
const HE_DAYS = ["א׳", "ב׳", "ג׳", "ד׳", "ה׳", "ו׳", "ש׳"];
|
const HE_DAYS = ["א׳", "ב׳", "ג׳", "ד׳", "ה׳", "ו׳", "ש׳"];
|
||||||
function fmtDeadline(iso: string): string {
|
function fmtDeadline(iso: string): string {
|
||||||
const d = new Date(iso);
|
const p = israelParts(iso);
|
||||||
return `${HE_DAYS[d.getDay()]} ${pad2(d.getDate())}/${pad2(d.getMonth() + 1)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
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 }) {
|
function BurstControl({ s }: { s: OpsService }) {
|
||||||
@@ -673,11 +676,11 @@ function RunLogDialog({ run, onClose }: { run: AgentRun | null; onClose: () => v
|
|||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="text-sm text-destructive">שגיאה בטעינת הלוג: {String(error)}</p>
|
<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">
|
<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="text-[0.72rem] leading-relaxed whitespace-pre-wrap break-words text-navy text-start">
|
<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 || "אין פלט עדיין."}
|
{text || "אין פלט עדיין."}
|
||||||
</pre>
|
</pre>
|
||||||
</ScrollArea>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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() {
|
export default function OperationsPage() {
|
||||||
const { data, isLoading, error } = useOperations();
|
const { data, isLoading, error } = useOperations();
|
||||||
|
|
||||||
@@ -840,6 +991,9 @@ export default function OperationsPage() {
|
|||||||
<SectionHeader>סוכנים פעילים</SectionHeader>
|
<SectionHeader>סוכנים פעילים</SectionHeader>
|
||||||
<LiveAgentsPanel />
|
<LiveAgentsPanel />
|
||||||
|
|
||||||
|
<SectionHeader>בריאות-הסוכנים</SectionHeader>
|
||||||
|
<AgentHealthPanel />
|
||||||
|
|
||||||
<SectionHeader>מתאמי-סוכנים</SectionHeader>
|
<SectionHeader>מתאמי-סוכנים</SectionHeader>
|
||||||
<AgentAdaptersPanel />
|
<AgentAdaptersPanel />
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,10 @@ export default function HomePage() {
|
|||||||
{/* KPI row — mockup 04 .kpis (4-up, gold-washed "ממתינים לאישור") */}
|
{/* KPI row — mockup 04 .kpis (4-up, gold-washed "ממתינים לאישור") */}
|
||||||
<KPICards cases={data} loading={isPending} />
|
<KPICards cases={data} loading={isPending} />
|
||||||
|
|
||||||
{/* two-column body — main flow + narrow gold gate rail (mockup 04 .cols) */}
|
{/* two-column body — main flow + narrow gold gate rail (mockup 04 .cols).
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
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">
|
<div className="space-y-6 min-w-0">
|
||||||
{/* "תיקים לפי סטטוס" (פסים אופקיים) הוסר — פיזור-הסטטוסים מוצג
|
{/* "תיקים לפי סטטוס" (פסים אופקיים) הוסר — פיזור-הסטטוסים מוצג
|
||||||
בדונאט "פיזור סטטוסים" בטור-הצד (#1). */}
|
בדונאט "פיזור סטטוסים" בטור-הצד (#1). */}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
useRunScript,
|
useRunScript,
|
||||||
type ScriptRunResult,
|
type ScriptRunResult,
|
||||||
} from "@/lib/api/scripts";
|
} from "@/lib/api/scripts";
|
||||||
|
import { formatDateLong } from "@/lib/format-date";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* /scripts — catalog of everything under scripts/, rendered as the
|
* /scripts — catalog of everything under scripts/, rendered as the
|
||||||
@@ -354,11 +355,7 @@ export default function ScriptsPage() {
|
|||||||
|
|
||||||
const lastModified =
|
const lastModified =
|
||||||
data?.last_modified != null
|
data?.last_modified != null
|
||||||
? new Date(data.last_modified * 1000).toLocaleDateString("he-IL", {
|
? formatDateLong(data.last_modified * 1000)
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const giteaBase = data?.gitea_url ?? null;
|
const giteaBase = data?.gitea_url ?? null;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type DriftEntry,
|
type DriftEntry,
|
||||||
type PaperclipAgent,
|
type PaperclipAgent,
|
||||||
} from "@/lib/api/paperclip-agents";
|
} from "@/lib/api/paperclip-agents";
|
||||||
|
import { formatDateShort } from "@/lib/format-date";
|
||||||
|
|
||||||
const ROLE_LABEL: Record<string, string> = {
|
const ROLE_LABEL: Record<string, string> = {
|
||||||
ceo: "CEO",
|
ceo: "CEO",
|
||||||
@@ -211,7 +212,7 @@ function PairCard({ pair }: { pair: AgentPair }) {
|
|||||||
</Button>
|
</Button>
|
||||||
{pair.master?.updated_at && (
|
{pair.master?.updated_at && (
|
||||||
<span className="text-[0.7rem] text-ink-light">
|
<span className="text-[0.7rem] text-ink-light">
|
||||||
עודכן: {new Date(pair.master.updated_at).toLocaleDateString("he-IL")}
|
עודכן: {formatDateShort(pair.master.updated_at)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ function BlockRow({ block }: { block: McpBlock }) {
|
|||||||
<Badge variant="outline" className="text-[0.7rem] font-mono" dir="ltr">
|
<Badge variant="outline" className="text-[0.7rem] font-mono" dir="ltr">
|
||||||
{block.model}
|
{block.model}
|
||||||
</Badge>
|
</Badge>
|
||||||
{isLLM && block.temperature !== null && (
|
{isLLM && block.effort !== null && (
|
||||||
<Badge variant="outline" className="text-[0.7rem]">
|
<Badge variant="outline" className="text-[0.7rem]" dir="ltr">
|
||||||
temp <span className="tabular-nums">{block.temperature}</span>
|
effort <span className="tabular-nums">{block.effort}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{block.max_tokens !== null && (
|
{block.max_tokens !== null && (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useSkills, type Skill } from "@/lib/api/skills";
|
import { useSkills, type Skill } from "@/lib/api/skills";
|
||||||
|
import { formatIsoDate } from "@/lib/format-date";
|
||||||
|
|
||||||
function formatChars(skill: Skill): string {
|
function formatChars(skill: Skill): string {
|
||||||
// Mockup column = "גודל (תווים)" — the DB markdown char count, grouped.
|
// Mockup column = "גודל (תווים)" — the DB markdown char count, grouped.
|
||||||
@@ -21,13 +22,8 @@ function formatChars(skill: Skill): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatUpdated(iso: string | null): string {
|
function formatUpdated(iso: string | null): string {
|
||||||
if (!iso) return "—";
|
// ISO-style date to match the mockup (2026-06-09), tabular — Israel-time day.
|
||||||
try {
|
return formatIsoDate(iso);
|
||||||
// ISO-style date to match the mockup (2026-06-09), tabular.
|
|
||||||
return new Date(iso).toISOString().slice(0, 10);
|
|
||||||
} catch {
|
|
||||||
return "—";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user