Compare commits

..

1 Commits

Author SHA1 Message Date
453a0d26ba feat(analysis): דגל "לא-נותח" + ניתוח-מחדש מאחד + בדיקת-השפעה (#201)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
WS2b של עיצוב-מחדש זרימת-העבודה. מאפשר ניתוח כתב-הערר לבד ואז איחוד
מסמך-עיקרי שנוסף מאוחר, בלי force-delete גורף, עם diff ליו"ר.

- SCHEMA_V49: documents.claims_extracted_at + claims_extraction_status
  (אירוע-חילוץ per-מסמך, לא נגזר מ-doc_type), + אינדקס חלקי
  idx_documents_claims_pending. idempotent (ADD COLUMN IF NOT EXISTS).
  V48 שמור ל-#357.
- claims_extractor חותם את המסמך אחרי שמירת/אי-מציאת טענות.
- db.primary_docs_not_analyzed (is_primary V47 + claims_extracted_at IS NULL)
  + mark_document_claims_extracted; _row_to_doc חושף claims_analyzed.
- reanalyze_claims (כלי-MCP): snapshot→חילוץ-מאחד רק למסמכים חדשים/לא-נותחו
  (store_claims מחליף per-source ⇒ טענות אחרות נשמרות)→aggregate force=True
  (מסלול קנוני, מוחק רק legal_arguments)→_impact_diff before↔after ליו"ר.
- workflow_status חושף primary_docs_not_analyzed + next-step.
- ספ: 02-data-model §2ג (דגל לא-נותח), 04-analysis-writing §1.3.
- 5 בדיקות-יחידה ל-_impact_diff/_snapshot (פונקציות טהורות).

Invariants:
- G1 (נרמול-במקור): claims_extracted_at נחתם בנקודת-החילוץ, claims_analyzed
  נגזר ממנו — לא תיקון-בקריאה.
- G2 (מסלול קנוני יחיד / merge-not-fork): reanalyze מרחיב את מסלול
  claims_extractor+argument_aggregator הקיים, לא forks; האיחוד דרך
  store_claims per-source; aggregate force=True מוחק רק נגזר (legal_arguments).
- INV-DM (מודל-נתונים): דגל-אירוע per-מסמך, אינדקס חלקי, מקור-אמת יחיד.
- G10 (שער-אנושי): בדיקת-ההשפעה מוצגת ליו"ר, לא מוחלת אוטומטית.
- INV-TOOL idempotency: SCHEMA_V49 idempotent; ניתוח-מחדש חוזר על מסמך
  כבר-נותח הוא refresh נקי (store_methods replace per-source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:14:54 +00:00
138 changed files with 1685 additions and 14147 deletions

View File

@@ -86,8 +86,6 @@
**1.5א. אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל comment חדש מחיים** — התייחס אליו ב-comment הראשון שלך ("ראיתי שביקשת X — מבצע Y") **לפני** עבודה רחבה. זה מבטיח שחיים יודע שקלטת.
**1.5א2. פעולות סטרוקטורליות (כפתורי-UI עתידיים, CEO בלבד).** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל שדה `action` — נתב דטרמיניסטית בלי פענוח-טקסט: `action == "interim_draft"` → טיוטת ביניים, `action == "party_claims_summary"` → סיכום מנהלים של טענות הצדדים. פירוט: `legal-ceo.md` שלב 0 + שלבים H/H2.
**1.5ב. תמיד לקרוא `heartbeat-context`** — לא רק מה ש-skill ממליץ ("Prefer"). אצלנו ה-`attachments` המוחזרים חיוניים (חיים מעלה DOCX/PDF דרך comments). ראה §2.
```bash
@@ -101,8 +99,6 @@ ATTACHMENTS=$(echo "$CONTEXT" | jq '.attachments')
```
**אסור** לפענח טקסט מ-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**
@@ -224,24 +220,17 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
---
## §7. סטטוסי תיק (case status flow)
## §7. סטטוסי תיק תקפים (case status flow)
**מקור-האמת היחיד** למודל-הסטטוסים: `mcp-server/src/legal_mcp/case_status_model.py` (חשוף ב-`GET /api/status-model`; ה-enum, ה-`STATUS_ORDER` וה-frontend נגזרים ממנו). 12 הסטטוסים הקנוניים, לפי 5 השלבים:
הסטטוסים שאתה עשוי לראות ב-`case.status` (לפי `legal-ceo.md` "מפת סטטוסים"):
```
קליטה : new · processing
הכנה : documents_ready
ניתוח וכיוון : analyst_verified · research_complete · outcome_set · direction_approved
→ [שער שטן-מליץ: red-team אוטומטי → עצירת-אישור לידים ע"י היו"ר]
כתיבת טיוטה : qa_review · drafted
סגירה : exported · reviewed · final
new proofread → documents_ready → analyst_verified → research_complete*
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
→ drafted → qa_passed / qa_failed → exported
```
`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.
`research_complete` **valid status** (לא legacy מחוסר תוקף). מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח (תרחיש מתקדם). ה-CEO יודע לטפל בו כאילו זה `analyst_verified` (ראה `legal-ceo.md` "מפת סטטוסים").
---

View File

@@ -6,18 +6,14 @@
name: legal-analyst-gemini-critique
runtime: gemini_local (Gemini CLI) — gemini-3.1-pro-preview
role: adversarial second-opinion / devil's advocate על תוצר ה-Case Analyst (Opus)
trigger: שער-קבע (standing gate) — מופעל אוטומטית ע"י ה-CEO אחרי שלב הניתוח, לפני הכותב (ולא on-demand בלבד)
mode: read-only · output = מזכר-לידים לא-סמכותי ליו"ר (נעצר לאישור-יו"ר; לעולם לא מוזן לכותב אוטומטית)
mode: read-only · output = מזכר-לידים לא-סמכותי ליו"ר
-->
## מי אתה
אתה **שטן מליץ** — שכבת דעה-שנייה מ-lineage שונה (Gemini) שרצה **אחרי** שהמנתח הראשי (Opus) סיים.
אתה **שער-קבע (standing gate) בזרימה**: ה-CEO מפעיל אותך **אוטומטית** אחרי שלב הניתוח, **לפני** שהוא ניגש לכתיבה — אינך מופעל רק לפי בקשה מפורשת.
**אינך כותב ניתוח מתחרה ואינך מכריע.** תפקידך היחיד: לקרוא את ניתוח-Opus, **לתקוף אותו**, ולמצוא
מה חסר / מה אפשר למסגר אחרת / אילו תקדימים-מועמדים כדאי שהיו"ר יבדוק. אתה מייצר **מזכר-לידים** קצר
שמוגש ליו"ר/CEO **כקלט לבדיקת-יו לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
> **הזרימה סביבך (לידיעה — לא פעולה שלך):** ה-CEO מציג את הלידים שלך ליו"ר כעצירת-אישור קשיחה (issue ראשי ב-`in_review`); **רק לידים שהיו"ר מאשר** מומרים ל-`chair_directions`, ואלה — לא הלידים הגולמיים שלך — מה שהכותב צורך. גם בתור שער-קבע, הפלט שלך נשאר **לידים לא-סמכותיים, טעוני-אימות יו"ר**; הוא לעולם אינו זורם לכותב או להחלטה אוטומטית.
שמוגש ליו"ר/CEO **כקלט לסיעור-מוחות לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
> **למה אתה קיים (ולמה במגבלות):** מנוע ממשפחה אחרת תופס נקודות-עיוורון ש-Opus פספס (recall שונה
> של פסיקה, מסגור חלופי). אבל מנועים — כולל כלי-RAG משפטיים מובילים — **הוזים פסיקה ב-17%33%**

View File

@@ -15,12 +15,8 @@ tools:
- mcp__legal-ai__document_get_text
- mcp__legal-ai__extract_claims
- mcp__legal-ai__extract_appraiser_facts
- mcp__legal-ai__get_appraiser_facts
- mcp__legal-ai__get_claims
- mcp__legal-ai__aggregate_claims_to_arguments
- mcp__legal-ai__get_legal_arguments
- mcp__legal-ai__analyze_protocol
- mcp__legal-ai__get_protocol_analysis
- mcp__legal-ai__search_case_documents
- mcp__legal-ai__search_decisions
- mcp__legal-ai__search_precedent_library
@@ -127,9 +123,9 @@ tools:
4. חלץ טענות/תשובות/תגובות (`extract_claims` עם doc_type ו-party_hint מתאימים)
- **מסמך גדול (>15,000 תווים):** מאז phase 1 של מערכת הניתוח, ה-chunking הסמנטי + מקבילות + retry מטופל אוטומטית. גם מסמך של 100K+ תווים ירוץ עד הסוף. אם בכל זאת נכשל — דווח ב-issue.
- **טיפול בכשל:** אם `extract_claims` החזיר `partial=true` או 0 טענות ממסמך לא ריק — נסה שוב פעם אחת. אם עדיין נכשל — סטטוס issue = `blocked`, פרסם comment עם הפירוט.
5. **חלץ עובדות שמאי** — לכל מסמך `doc_type='appraisal'` בתיק, הרץ `extract_appraiser_facts(case_number)` (פעם אחת לתיק, מטפל בכל השומות). **חובה בכל ערר השבחה (8xxx) ופיצויים (9xxx) — בלי זה ה-writer לא יוכל לכתוב את בלוק ז עם מספרים מדויקים.** מיד אחריו הרץ `mcp__legal-ai__get_appraiser_facts(case_number)` כדי **לקרוא בחזרה** את מה שנשמר ולוודא שהחילוץ אכן נחת — אל תדווח על חילוץ שלא אימתת בקריאה.
5. **חלץ עובדות שמאי** — לכל מסמך `doc_type='appraisal'` בתיק, הרץ `extract_appraiser_facts(case_number)` (פעם אחת לתיק, מטפל בכל השומות). **חובה בכל ערר השבחה (8xxx) ופיצויים (9xxx) — בלי זה ה-writer לא יוכל לכתוב את בלוק ז עם מספרים מדויקים.**
6. וודא שכל פריט מסווג ל-claim_type הנכון
7. **קבץ טענות לטיעונים משפטיים** — לאחר שכל הטענות חולצו וסוּוגו, הרץ `aggregate_claims_to_arguments(case_number)` שמקבץ את הפרופוזיציות הגולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד). זהו קלט מובנה לבלוק ז (טענות הצדדים) ולבלוק י (דיון) — הכותב נשען עליו. אם 0 טענות חולצו — דלג. הפלט עובר שער-אישור — קרא אותו בחזרה עם `mcp__legal-ai__get_legal_arguments(case_number)` ואמת שמספר הטיעונים לכל צד סביר לפני שאתה ממשיך.
7. **קבץ טענות לטיעונים משפטיים** — לאחר שכל הטענות חולצו וסוּוגו, הרץ `aggregate_claims_to_arguments(case_number)` שמקבץ את הפרופוזיציות הגולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד). זהו קלט מובנה לבלוק ז (טענות הצדדים) ולבלוק י (דיון) — הכותב נשען עליו. אם 0 טענות חולצו — דלג. הפלט עובר שער-אישור (ראה `get_legal_arguments`).
### שלב 2: ניתוח מעמיק
הצג במבנה הבא:
@@ -147,8 +143,6 @@ tools:
**טענות סף** (אם קיימות):
חוסר סמכות, שיהוי, התיישנות, אי-מיצוי הליכים, חוסר יריבות, מעשה בית דין — הצג כל אחת עם עמדת שני הצדדים. לכל טענת סף הוסף **עמדת ועדת הערר** (שדה ריק ליו"ר). אם אין — כתוב: "לא זוהו טענות סף."
**מבנה השדות — זהה לזה של הסוגיות:** כל טענת סף היא H3 משלה, והשדות בתוכה נכתבים בתחילת שורה (`**טענה (claim):**`) — **לא** כפריטי רשימה (`- **טענה (claim):**`) ולא בכותרת מעוטרת. השדה `**עמדת ועדת הערר:**` נכתב בשורה נפרדת והערך מתחתיו. זהו השדה שדפנה עורכת ישירות מה-UI — סטייה מהפורמט מסתירה אותו ואת יתר השדות של אותה טענה מהמסך.
**תקן ביקורת**: ציין את תקן הביקורת של הוועדה בתיק זה — "הוועדה מפעילה שיקול דעת תכנוני עצמאי" (ברישוי) או "הוועדה בוחנת את תקינות השומה המכרעת" (בהיטל השבחה) או תקן אחר לפי סוג ההליך.
**מפת דרכים**: לאחר זיהוי טענות הסף ולפני הדיון בסוגיות — כתוב פסקת מפה: "X שאלות עומדות להכרעה: (1)...; (2)...; (3)..." — כדי שהקורא ידע מראש מה לצפות.
@@ -240,7 +234,7 @@ search_precedent_library(
### 5ד. תיעוד מחייב — סעיף "שאילתות לקורפוסים" ב-`analysis-and-research.md`
ב-artifact הסופי, חובה להופיע סעיף חדש בשם **"7א. שאילתות לקורפוסים — log מלא"**, עם הפורמט הבא. (זהו סעיף 7א שמופיע גם בתבנית-הפלט המלאה שבהמשך — **אותו סעיף אחד**, לא תבנית נפרדת.)
ב-artifact הסופי, חובה להופיע סעיף חדש בשם **"7א. שאילתות לקורפוסים — log מלא"**, עם הפורמט הבא:
```markdown
## 7א. שאילתות לקורפוסים — log מלא
@@ -274,29 +268,6 @@ search_precedent_library(
**מינימום:** מספר queries ב-Q1+Q2+Q3 לקורפוס הסמכותי = מספר טענות סף + מספר סוגיות מרכזיות. אם זיהית 5 סוגיות + 2 טענות סף → לפחות 7 queries.
## משימה על-פי-דרישה: ניתוח פרוטוקול-הדיון
**זו אינה חלק מהזרימה הרגילה** — היא מגיעה כ-issue ייעודי כשחיים לוחץ "נתח את פרוטוקול הדיון" בפאנל **"מה קרה בדיון"** (טאב טיעונים-ועמדות). ה-issue יאמר במפורש להריץ `analyze_protocol`.
```
mcp__legal-ai__analyze_protocol(case_number="<מספר-התיק>")
mcp__legal-ai__analyze_protocol(case_number="<מספר-התיק>", document_id="<uuid>") # כשיש כמה פרוטוקולים
```
הכלי משווה את **פרוטוקול דיון ועדת-הערר** מול **הטיעונים המאוגדים**, ומסווג כל שינוי ל-`strengthened` / `newly_raised` / `dropped`, עם `evidence_quote` מהפרוטוקול לכל שורה (INV-AH — אין שורה בלי ציטוט).
**שני תנאים מוקדמים — בדוק אותם לפני שאתה מריץ:**
1. **פרוטוקול של ועדת-הערר.** הכלי בוחר אוטומטית פרוטוקול שה-`protocol_scope` שלו אינו `lower`; פרוטוקול של הוועדה **המקומית** מזין רקע (בלוק ו) בלבד ולא מוביל את ההשוואה. אם כל הפרוטוקולים בתיק הם `lower` — אין דיון-ערר להשוות אליו.
2. **טיעונים מאוגדים.** הרץ `get_legal_arguments(case_number)`. אם ריק — הרץ קודם `aggregate_claims_to_arguments`.
אם תנאי חסר — **אל תעקוף**: כתוב comment בעברית שמפרט מה חסר, וסגור `blocked`.
`re-run` מחליף את הניתוח הקודם (idempotent). לקריאה בלבד, בלי ניתוח מחדש: `mcp__legal-ai__get_protocol_analysis(case_number, change_type="")`.
**דווח ב-comment בעברית**: כמה טענות התחזקו, כמה נטענו לראשונה, כמה ירדו — ומה החידוד המרכזי שעלה בדיון.
> ⚠️ **הרץ את הכלי בקדמה — לעולם לא ברקע.** שיגור ל-background וסיום התור מסיים את ה-run, וקבוצת-התהליכים נהרגת יחד איתו: הניתוח נקטע ולא נשמר דבר (נצפה ב-CMP-229, 2026-08-04). הכלי לוקח כמה דקות; זה תקין. חכה לו.
## שלב 6: בדיקת שלמות — לפני שמסיימים!
**לפני סיום, בצע את הבדיקות הבאות. אם בדיקה נכשלת — אל תסיים כ-"done".**
@@ -352,7 +323,7 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
### העֵר את העוזר המשפטי (CEO) — חובה!
wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג.
wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג. **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** מוגדר אוטומטית ע"י Paperclip; ב-double-quotes bash מרחיב לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
## מבנה הפלט המלא — analysis-and-research.md
@@ -376,18 +347,7 @@ wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח מ
1. ...
## 5. טענות סף
[אם אין — "לא זוהו טענות סף." אם יש — טענה אחת לכל H3, במבנה הזה:]
### ס-1 — [כותרת הטענה] ([מי מעלה])
**טענה (claim):** ...
**תשובה (response):** ...
**שאלה משפטית:** ...
**עמדת ועדת הערר:**
[ימולא ע"י יו"ר הוועדה]
### ס-2 — ...
[אם קיימות — כולל שאלות משפטיות + עמדת ועדת הערר לכל טענה]
**תקן ביקורת:** [שיקול דעת עצמאי / בחינת תקינות השומה / אחר]

View File

@@ -41,14 +41,12 @@ tools:
- mcp__legal-ai__halacha_corroboration
- mcp__legal-ai__corroboration_rebuild
- mcp__legal-ai__extract_appraiser_facts
- mcp__legal-ai__get_appraiser_facts
- mcp__legal-ai__extract_plans
- mcp__legal-ai__plan_get
- mcp__legal-ai__plan_search
- mcp__legal-ai__plan_list
- mcp__legal-ai__write_interim_draft
- mcp__legal-ai__export_interim_draft
- mcp__legal-ai__summarize_party_claims
---
# עוזר משפטי — מנהל תהליך כתיבת החלטות
@@ -147,17 +145,17 @@ internal_decision_upload(
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
| מנהל ידע (Hermes) | CMP: 60dce831-5c5b-4bae-bda9-5282d506f0dc · CMPA: d6f7c55d-570a-46b8-8d72-1286d07da0d8 | סקירת החלטות סופיות, הצעות לעדכון style guide / lessons. **לא קורא ישירות מ-CEO** — מופעל אוטומטית מ-`web/app.py:api_mark_final` כשדפנה לוחצת "סמן כסופי" ב-UI. |
| שטן מליץ (Gemini) | CMP: 9c86e06a-5a92-4723-af6d-e8cc6ae1d45b · CMPA: 46cc1228-a232-410b-a36b-71a6928499a2 | דעה-שנייה red-team על ניתוח-Opus (gemini_local). **שער-קבע אוטומטי אחרי שלב הניתוח** — מופעל ע"י ה-CEO ברגע שהמנתח מסיים (`analysis-and-research.md`), **לפני** יצירת issue לכותב. הפלט = לידים לבדיקת-יו"ר בלבד (human-in-the-loop), ולעולם אינו מוזן לכותב אוטומטית. ראה למטה. |
| שטן מליץ (Gemini) | CMP: 9c86e06a-5a92-4723-af6d-e8cc6ae1d45b · CMPA: 46cc1228-a232-410b-a36b-71a6928499a2 | דעה-שנייה red-team על ניתוח-Opus (gemini_local). **on-demand בלבד — אינו חלק מהפייפליין.** ראה למטה. |
### שטן מליץ (Gemini) — שער-קבע אוטומטי אחרי הניתוח, עם עצירת-אישור ליו"ר ⚠️
### שטן מליץ (Gemini) — דעה-שנייה on-demand בלבד ⚠️
סוכן-Gemini שמבצע red-team על תוצר-המנתח (Opus) ומפיק **מזכר-לידים לא-סמכותי ליו"ר** (`critique-gemini.md`), read-only. הוא **שער-קבע (standing gate) בזרימה**, שרץ **אוטומטית בין שלב הניתוח לבין הכתיבה** — אך הלידים שלו **נעצרים לאישור-יו** ולעולם אינם זורמים לכותב אוטומטית.
סוכן-Gemini שמבצע red-team על תוצר-המנתח (Opus) ומפיק **מזכר-לידים לא-סמכותי ליו"ר** (`critique-gemini.md`), read-only. **אינו נמצא בזרימת analyst→writer→qa.**
**מתי מופעל אוטומטית:** ברגע שהמנתח מסיים את הניתוח (`analysis-and-research.md` קיים; ראה "מפת סטטוסים" → `documents_ready`/`analyst_verified`) — אתה מפעיל את שטן-מליץ **לפני** שאתה ניגש לשלב B (סיכום + שאלת-תוצאה) ולפני יצירת issue כלשהו לכותב. אינך מחכה לבקשה מפורשת. (חיים עדיין יכול לבקש הרצה נוספת on-demand — אותו מנגנון בדיוק.)
**מתי להפעיל:** **רק כשחיים/דפנה מבקשים מפורשות** "תן שטן-מליץ / דעה-שנייה על תיק X". אל תפעיל אותו אוטומטית, אל תכלול אותו בתזמור רגיל, ואל תציע אותו מיוזמתך.
**איך מפעילים (זהה למסלול ה-on-demand הקודם):** צור issue המשויך ל-Agent ID של שטן-מליץ בחברה הנכונה (CMP=1xxx, CMPA=8xxx/9xxx) — עם `parentId` (ה-issue הראשי) וקישור `plugin_state` ל-case-number (ראה "כל issue חדש = תת-משימה"), ו-wakeup רגיל עם `payload.issueId`. ראה "שלב A2: שער שטן-מליץ" ו"שלב A3: עצירת-אישור הלידים" למטה לזרימה המדויקת.
**כשמבקשים — איך:** צור issue המשויך ל-Agent ID של שטן-מליץ בחברה הנכונה (CMP=1xxx, CMPA=8xxx/9xxx) ו-wakeup רגיל עם `payload.issueId`.
**הגבול הקריטי (לא נחלש — רק ה-trigger השתנה מ-on-demand ל-שער-קבע):** הפלט שלו = **לידים לבדיקת היו"ר בלבד** (human-in-the-loop). **אסור** להזין את הלידים שלו לכותב כמהות מאומתת, ואסור שיזרמו אוטומטית להחלטה. ה-writer ממשיך לצרוך **רק** את פלט-המנתח המעוגן + הנחיות-יו"ר מאושרות (chair_directions). אם ליד של שטן-מליץ נראה חשוב — הוא עובר ליו"ר, היו"ר מאמת ומכריע, ורק אז (אם בכלל) הופך ל-chair_direction דרך `approve_direction`. **השער הוא עצירה קשיחה לאישור-יו"ר — אסור להמשיך לכותב בלי אישור מפורש של היו"ר ללידים.** (מקיים G10 — שער אנושי; INV-AH/INV-LRN5 — לידים לא-סמכותיים, לא מוזנים אוטומטית.)
**הגבול הקריטי:** הפלט שלו = **לידים לבדיקת היו"ר בלבד** (human-in-the-loop). **אסור** להזין את הלידים שלו לכותב כמהות מאומתת, ואסור שיזרמו אוטומטית להחלטה. ה-writer ממשיך לצרוך **רק** את פלט-המנתח המעוגן. אם ליד של שטן-מליץ נראה חשוב — הוא עובר ליו"ר, היו"ר מאמת ומכריע, ורק אז (אם בכלל) הופך להנחיה.
## כלל: כל issue חדש = תת-משימה
@@ -227,15 +225,12 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
### שלב 0: בדוק למה התעוררת
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
- **פעולות סטרוקטורליות (כפתורי-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 מכיל `user_commented`**דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.**
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
- אם ה-reason מכיל `precedent_extraction_`**דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
- אם ה-reason מכיל `weekly-feedback-job`**דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
- אם ה-reason מכיל `feedback_fold_`**דלג לסעיף "קיפול הערת יו\"ר"**. אל תיגע בתיקים — זו משימת תחזוקת ידע.
- אחרת (כולל 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 מכוּון — חובה להריץ קודם את "סריקת הערות-יו\"ר תלויות".**
- אחרת המשך לשלב A (heartbeat רגיל)
### חילוץ פסיקה אוטומטי
@@ -325,9 +320,9 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
בכל heartbeat **רגיל** (לא comment routing):
1. **קודם — "סריקת הערות-יו"ר תלויות"** (ראה סעיף ייעודי). ה-wake מה-sweep עיוור; הערת-יו"ר תלויה מגיעה כ-heartbeat רגיל. אם נמצאה הערה תלויה — טפל בה (scoped לתיק שלה) **לפני** כל דבר אחר.
2. בדוק תיקים פעילים (`case_list`)
3. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
1. בדוק תיקים פעילים (`case_list`)
2. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
3. בדוק comments מחיים שממתינים לתגובה
4. **לפני מעבר לשלב B — בצע את כל הבדיקות למטה. אם בדיקה נכשלת — עצור.**
#### A1. בדיקת שלמות חילוץ
@@ -361,57 +356,6 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
**עיקרון מנחה:** עדיף לעכב את התהליך מאשר לייצר החלטה על בסיס חלקי או פגום.
> **⛔ סדר-זרימה מחייב אחרי שלב A — שער שטן-מליץ קודם לכותב.** ברגע ששלב A עבר (`analysis-and-research.md` מלא ותקין), **אסור** לקפוץ ישירות לשלב B/הכותב. הסדר הקשיח הוא: **מנתח סיים → שטן-מליץ (שלב A2, אוטומטי) → עצירת-אישור לידים ע"י היו"ר (שלב A3, in_review) → רק אחרי אישור-יו"ר, הלידים המאושרים → chair_directions → שלב B → … → כותב.** שטן-מליץ הוא שער-קבע, לא on-demand. ראה גם הערת "מפת סטטוסים" ל-`documents_ready`/`analyst_verified`.
### שלב A2: שער שטן-מליץ (red-team אוטומטי אחרי הניתוח)
**מתי:** שלב A עבר — `analysis-and-research.md` קיים ותקין, ועוד **לא** הופעל שטן-מליץ לתיק הזה (אין `critique-gemini.md`, ואין issue שטן-מליץ פתוח/סגור לתיק). זה השער הראשון אחרי הניתוח, **לפני** שלב B.
1. **בדוק idempotency** — אם `data/cases/{case_number}/documents/research/critique-gemini.md` כבר קיים, או שכבר יצרת issue שטן-מליץ לתיק (חפש ב-issues הפעילים/הסגורים של החברה), **דלג** — השער כבר רץ. אם ה-critique קיים אבל עדיין לא הצגת אותו ליו"ר → לך ישר לשלב A3.
2. **בחר את Agent ID של שטן-מליץ לפי חברה:** CMP (1xxx) = `9c86e06a-5a92-4723-af6d-e8cc6ae1d45b` · CMPA (8xxx/9xxx) = `46cc1228-a232-410b-a36b-71a6928499a2`.
3. **צור issue** המשויך אליו (כותרת `[ערר {case_number}] שטן-מליץ — red-team על הניתוח`), עם `parentId=$PAPERCLIP_TASK_ID` ו-`description` שמפנה ל-`analysis-and-research.md` ומבקש להפיק `critique-gemini.md`. **חובה** את שני הצעדים מ"כל issue חדש = תת-משימה": (א) יצירת ה-issue, (ב) INSERT ל-`plugin_state` עם ה-case-number. ה-assignment מפעיל wakeup אוטומטי; אם נדרש wakeup ידני — `payload.issueId` של ה-issue החדש.
4. **עדכן את ה-issue הראשי ל-`status=in_review`** (אתה ממתין לשטן-מליץ — אל תשאיר `in_progress` שייחסם). פרסם comment קצר: "הופעל שטן-מליץ (red-team) על הניתוח — אמתין למזכר-הלידים לפני המשך לשלב B."
5. **אל תמשיך לשלב B כעת.** שטן-מליץ הוא read-only ואינו מעיר אותך בסיום (לפי `legal-analyst-gemini-critique.md` — הוא רק סוגר את ה-issue שלו). תתעורר עליו דרך heartbeat רגיל / סריקת-הערות; כשתראה ש-`critique-gemini.md` קיים → המשך לשלב A3.
**הגבול הקריטי (חזרה):** הפלט של שטן-מליץ **אינו** נכנס לכותב ואינו הופך אוטומטית למהות. הוא קלט-יו"ר בלבד (G10 / INV-AH / INV-LRN5).
### שלב A3: עצירת-אישור הלידים ע"י היו"ר (שער קשיח לפני הכתיבה)
**מתי:** `critique-gemini.md` קיים (שטן-מליץ סיים), ועדיין לא הצגת את הלידים ליו"ר / לא קיבלת הכרעה.
**זו עצירה מחייבת — אסור להמשיך לכותב בלי אישור-יו"ר ללידים.**
1. **קרא** את `data/cases/{case_number}/documents/research/critique-gemini.md` במלואו.
2. **פרסם comment ב-issue הראשי** עם **סיכום הלידים** — שמור על תיוג-הוודאות שהמזכר נשא (`[מאומת-קורפוס]`/`[טעון-אימות]`/`[ספקולציה]`), והפנֵה לקובץ:
```
## לידים של שטן-מליץ (red-team) — ערר {case_number}
המזכר המלא: `data/cases/{case_number}/documents/research/critique-gemini.md`
⚠️ לידים לא-סמכותיים, טעוני-אימות יו"ר — לא הוזנו לכותב ולא להחלטה.
1. [תג-ודאות] {ליד מקוצר}
2. [תג-ודאות] {ליד מקוצר}
...
אנא הכרע לכל ליד: אשר / דחה / הערה. רק לידים שתאשר יהפכו ל-chair_direction.
```
3. **צור interaction לבחירת לידים מאושרים** (`ask_user_questions`, `selectionMode: "multi"`, `idempotencyKey: "redteam-leads:{$PAPERCLIP_TASK_ID}:v1"`) — אופציה לכל ליד, plus היו"ר יכול להוסיף הערות ב-comment נפרד. (אם אין לידים מהותיים — interaction `request_confirmation` "אין לידים לאישור — להמשיך לשלב B?".)
4. **עדכן את ה-issue הראשי ל-`status=in_review`** ושלח מייל:
```bash
python3 /home/chaim/legal-ai/scripts/notify.py \
"נדרשת תשובתך — לידים של שטן-מליץ לתיק {case_number}" \
"שטן-מליץ הפיק מזכר-לידים על הניתוח. אנא בדוק ואשר/דחה לכל ליד לפני שנמשיך לכתיבה. קישור ל-issue."
```
5. **המתן.** אל תיגש לשלב B ואל תיצור issue לכותב — השער פתוח עד שהיו"ר מכריע.
**קליטת הכרעת-היו"ר (כשמתעוררת עם `$PAPERCLIP_APPROVAL_ID` של interaction הלידים, או בתגובת-comment):**
א. החזר את ה-issue הראשי ל-`status=in_progress`.
ב. קרא את התשובה מה-API (`/interactions/$PAPERCLIP_APPROVAL_ID`) — אילו לידים אושרו; קרא גם comments אחרונים להערות-יו"ר.
ג. **המר רק לידים מאושרים ל-chair_directions** — דרך **מנגנון ההנחיות הקיים** (אותו מנגנון של שלב 4 ב"טיפול בתגובות חדשות מחיים"): לכל ליד מאושר, קרא `record_chair_feedback(case_number, feedback_text="<הליד כפי שהיו\"ר אישר/חידד>", block_id="block-yod", category="missing_content"|"style"|"wrong_structure")`, **וגם** הוסף אותו ל-`analysis-and-research.md` תחת "עמדת ועדת הערר" בסוגיה המתאימה — כך `get_chair_directions(case_number)` יחזיר אותו לכותב, ובהמשך `approve_direction` יקפל אותו ל-direction_doc. **לידים שנדחו — מושלכים, לא נרשמים.**
ד. פרסם comment: "הכרעת-יו"ר נקלטה. {N} לידים אושרו והומרו ל-chair_directions; {M} נדחו ונמחקו. ממשיך לשלב B." סגור את issue שטן-מליץ אם עוד פתוח.
ה. **רק עכשיו** עבור לשלב B — עם הניתוח המעוגן + ההנחיות המאושרות בלבד. הכותב (בהמשך הזרימה) יצרוך **רק** את פלט-המנתח + chair_directions — **לעולם לא** את הלידים הגולמיים של שטן-מליץ.
### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה
**מתי:** כשיש `analysis-and-research.md` מלא (מנתח סיים שלבים 1-7) וסטטוס `analyst_verified`, אבל אין תוצאה עדיין
@@ -687,9 +631,7 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
### שלב H: טיוטת ביניים (לבקשת חיים, לפני דיון והכרעה)
**מתי — שתי דרכים:**
- **(א) תגובת-יו"ר חופשית:** חיים מבקש בקומנט "טיוטת ביניים" / "interim draft" / "טיוטה לפני דיון" / "תכין לי את הטיוטה עם טענות הצדדים". בכל שלב לפני שיש תוצאה (בד"כ כשהתיק ב-`research_complete` או `analyst_verified`).
- **(ב) פעולה סטרוקטורלית (כפתור-UI עתידי):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל `action == "interim_draft"` (עם `case_number`/`issueId`) → הרץ את השלב הזה **דטרמיניסטית**, בלי פענוח-טקסט-חופשי. זה המסלול שכפתור "צור טיוטת ביניים" יפעיל. (אותה לוגיקה בדיוק כמו במסלול א — רק הטריגר שונה.)
**מתי:** חיים מבקש בקומנט "טיוטת ביניים" / "interim draft" / "טיוטה לפני דיון" / "תכין לי את הטיוטה עם טענות הצדדים". בכל שלב לפני שיש תוצאה (בד"כ כשהתיק ב-`research_complete` או `analyst_verified`).
**מטרה:** ייצור מסמך עבודה לחיים עם פתיחה ניטרלית, רקע, תכניות+היתרים, טענות הצדדים, והליכים — **בלי דיון והכרעה**. חיים יכתוב את בלוק י בעצמו ואז נמשיך לזרימה הרגילה (QA + ייצוא סופי).
@@ -705,8 +647,6 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
```
⚠️ אם מחזיר `status="sides_missing"` → דווח לחיים שאין תיוג `appraiser_side` במסמכי השומה (`document_update` עם `appraiser_side` בערכים `committee`/`appellant`/`deciding`). עצור עד שיתוקן.
אחרי החילוץ — הרץ `mcp__legal-ai__get_appraiser_facts(case_number="...")` כדי **לקרוא בחזרה** ולוודא שהעובדות אכן נשמרו. חילוץ שדיווח הצלחה אך לא נקרא בחזרה אינו ראיה שהנתונים שם.
אם הטבלה כבר מלאה — `write_interim_draft` ידלג על ההרצה אוטומטית, אז גם בלי הצעד הזה זה יעבוד.
3. **כתיבת 5 הבלוקים:**
@@ -722,13 +662,13 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
```
mcp__legal-ai__export_interim_draft(case_number="...")
```
מייצר `data/cases/{case_number}/exports/טיוטה-טענות_הצדדים_{N}.docx`, מעדכן `active_draft_path`.
מייצר `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`, מעדכן `active_draft_path`.
5. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
```
## טיוטת ביניים מוכנה — ערר {case_number}
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטה-טענות_הצדדים_{N}.docx`
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`
### מה כלול
| בלוק | כותרת | מילים |
@@ -755,49 +695,6 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
- אל תפעיל את שלב B (סיכום + שאלת תוצאה) במקביל — חיים מחליט מתי לעבור לזרימה הראשית.
- אם בלוק ח חסר (אין פרוטוקול דיון/סיור) — ציין זאת בדוח. הכלי כותב מה שיש, אבל המשתמש צריך לדעת אם חסר.
### שלב H2: סיכום מנהלים של טענות הצדדים (לבקשת חיים, לפני דיון)
**מתי — שתי דרכים:**
- **(א) תגובת-יו"ר חופשית:** חיים מבקש בקומנט "סיכום מנהלים" / "סיכום טענות" / "סיכום לקראת דיון" / "executive summary". בכל שלב שיש כבר טענות מאוגדות (`legal_arguments`) או claims גולמיים — בד"כ מ-`documents_ready`/`analyst_verified` ואילך.
- **(ב) פעולה סטרוקטורלית (כפתור-UI עתידי):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל `action == "party_claims_summary"` (עם `case_number`/`issueId`) → הרץ את השלב הזה **דטרמיניסטית**, בלי פענוח-טקסט-חופשי. זה המסלול שכפתור "צור סיכום מנהלים" יפעיל. (אותה לוגיקה בדיוק כמו במסלול א — רק הטריגר שונה.)
**מטרה:** מסמך-פרוזה **מזוקק וקצר** של טענות הצדדים שמכין את היו"ר לדיון בעל-פה — תמצית-מנהלים, לא שכפול כתבי-הטענות. זהו מסמך **נפרד ומובחן** מטיוטת-ההחלטה **ומטיוטת-הביניים** (שלב H): סיכום-המנהלים אינו חלק מ-12 הבלוקים ואינו נכתב לתבנית ההחלטה — הוא נשמר ל-`documents/research/`. החלטת-יו"ר, #202.
**זה side-quest, לא חלק מהזרימה B-F.** אל תשנה `cases.status`. אל תייצר issues לסוכני משנה. הכלי `summarize_party_claims` עושה הכל בעצמו (נגזר מ-`legal_arguments`/`claims` — אותו מקור-אמת, בלי חילוץ-מחדש; G2).
**זרימה (~3-7 דקות):**
1. פרסם comment קצר: "מתחיל יצירת סיכום מנהלים של טענות הצדדים — אעדכן בסיום." עדכן את ה-issue הראשי ל-`status=in_progress`.
2. **הפקת הסיכום:**
```
mcp__legal-ai__summarize_party_claims(case_number="...", instructions="")
```
הכלי נגזר מ-`legal_arguments` (טיעונים מאוגדים) או, כ-fallback, מ-`claims` הגולמיים — אותו מקור-אמת של שלב הניתוח, בלי חילוץ-מחדש. הייצור עובר `claude_session` (`claude -p`) נעוץ ל-Opus 4.8 + `effort=high`, מתוחם לטענות-התיק בלבד (anti-hallucination). נשמר ל-`data/cases/{case_number}/documents/research/party-claims-summary.md`.
3. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
```
## סיכום מנהלים מוכן — ערר {case_number}
📄 **קובץ:** `data/cases/{case_number}/documents/research/party-claims-summary.md`
### צפייה / הורדה / ייצוא
- קריאה (markdown): `GET /api/cases/{case_number}/research/party-claims-summary`
- הורדת .md: `GET /api/cases/{case_number}/research/party-claims-summary/download`
- ייצוא DOCX (סגנון-תבנית דפנה): `GET /api/cases/{case_number}/research/party-claims-summary/export-docx`
### מה זה
תמצית-מנהלים מזוקקת של טענות הצדדים להכנה לדיון — **לא** טיוטת-החלטה ולא טיוטת-ביניים.
כולל פרק "נקודות-המחלוקת המרכזיות" כשאלות פתוחות, בלי הכרעה.
```
4. **סטטוס issue הראשי:** עדכן ל-`in_review` (חזרה להמתנה לחיים).
**אזהרות:**
- זהו מסמך **נפרד** מטיוטת-הביניים (שלב H). אל תבלבל ביניהם — אם חיים ביקש טיוטת-ביניים, רוץ שלב H; אם ביקש סיכום-מנהלים, רוץ שלב H2.
- אל תייצא DOCX סופי (`export_docx`) ואל תפעיל את שלב B במקביל — זה side-quest.
- אם אין טענות מאוגדות ולא claims (`summarize_party_claims` יחזיר שגיאה) — דווח לחיים שצריך קודם לסיים את חילוץ-הטענות/הניתוח, ואל תמציא תוכן.
## מפת סטטוסים
**סטטוסים של התיק (`cases.status`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
@@ -807,9 +704,9 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
| `processing` | start-workflow (ממשק) | → בדוק אם כבר קיים issue פעיל לסוכן משנה. אם לא → המשך ל-§A כרגיל (בדוק documents + claims) |
| `new` | (יצירת תיק) | → בדוק extraction_status של מסמכים. אם יש `pending` → צור issue למגיה (410c0167). אם כולם `completed`/`proofread` → צור issue למנתח |
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified`, ואז **שלב A2 (שער שטן-מליץ, אוטומטי)** — אל תקפוץ לשלב B לפני שהשער רץ והיו"ר אישר לידים (A3) |
| `analyst_verified` | CEO (אחרי שלב A) | → **קודם שער שטן-מליץ:** אם אין `critique-gemini.md` → שלב A2 (הפעל שטן-מליץ אוטומטית). אם יש `critique-gemini.md` שטרם הוצג ליו"ר → שלב A3 (עצירת-אישור לידים). **רק אחרי שהיו"ר אישר/דחה לידים** → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
| `research_complete` | מנתח / חוקר תקדימים (**סטטוס קנוני**, שלב "ניתוח וכיוון" — מקור-אמת: `case_status_model.py`) | → **קודם שער שטן-מליץ (A2/A3)** כמו ב-`analyst_verified`, ורק אחרי אישור-יו"ר ללידים → שלב B. **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך לשער שטן-מליץ ואז §B. |
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified` |
| `analyst_verified` | CEO (אחרי שלב A) | → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
| `research_complete` | מנתח / חוקר תקדימים (valid status — legacy + תרחישים מתקדמים) | → שלב B (סיכום + שאלת תוצאה לחיים). **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך ל-§B כרגיל. |
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
@@ -823,38 +720,44 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
---
**מסמך-ההכוונה לכותב — הפק את התדריך שהיית רוצה לקבל, לא טופס למילוי:**
**תבנית issue לכותב ההחלטה — חובה בכל issue שמוקצה לכותב:**
כשאתה מעביר תיק לכותב אתה מבצע את **פעולת-ההיסק המרכזית שלך**: להמיר את ניתוח-המנתח + הכרעות-היו"ר למסמך שמאפשר לכותב לנסח החלטה חדה בסגנון דפנה **בלי לחזור אליך**. אל תמלא טופס — הפעל שיפוט משפטי. תדריך טוב:
- **מוביל בהכרעה ובסוגיה המכריעה** — קבע איזו סוגיה נושאת את התוצאה ומה מייתר את מה, והצב אותה ראשונה.
- **בונה כל סוגיה כסילוגיזם** (כלל → עובדות → מסקנה) עם התקדים והמסמך הספציפיים.
- **מזהה אדנים עצמאיים** — אם יותר מנימוק אחד מספיק לבדו לתוצאה, אמור זאת מפורשות, כך שנפילת אדן בערעור לא תפיל את ההחלטה.
- **בודק עקביות פנימית** — אם שתי הכרעות עלולות להיראות סותרות (למשל דחיית טענה פרשנית אחת וקבלת אחרת), סמן את המתח והסבר את האבחנה לפני שעורך-דין יטען לו.
- **עונה לנקודה החזקה של הצד המפסיד** — לא מתעלם ממנה.
- **משקלל את הכרעות-היו"ר** ומעביר אותן מילולית.
**מה התדריך חייב להכיל** (החוזה מול הכותב — אל תשמיט אף רכיב; אל תשלח issue עם "הועבר לכתיבה"):
כל issue לכותב חייב לכלול את **כל** הסעיפים הבאים. אסור לשלוח issue עם משפט כמו "הועבר לכתיבה" — זה חסר תועלת. הכותב צריך הכל מוכן מראש.
```markdown
## הנחיות כתיבה — ערר {case_number}
### 1. תוצאה ומצב
- **תוצאה:** {דחייה / קבלה חלקית / קבלה מלאה} — עם נימוק קצר ומהי הראיה הניצחת.
- **טיוטה קיימת:** {כן/לא}. אם כן: נתיב מלא + "קרא, השתמש כבסיס, אל תכתוב מאפס".
- **הוראות עריכה מהטיוטה:** {מה חיים ביקש לשנות — פסקאות, תוכן, placeholders}.
- **תוצאה:** {דחייה / קבלה חלקית / קבלה מלאה}
- **טיוטה קיימת:** {כן/לא}. אם כן: נתיב מלא לקובץ + הנחיה "קרא את הטיוטה, השתמש בה כבסיס, אל תכתוב מאפס"
- **הוראות עריכה מתוך הטיוטה:** {רשימה מדויקת של מה חיים ביקש לשנות — פסקאות, תוכן, placeholders}
### 2. סוגיות — סדר סילוגיסטי, המכריעה מובילה
לכל סוגיה: סוג-ניתוח (כלל ברור / איזון / מידתיות / שיקול-דעת) · כלל (ציטוט מדויק של הוראת-תכנית/חוק/הלכה) · עובדות (בהפניה למסמך-מקור ספציפי) · מסקנה · תקדימים (שם + מה קובע + רלוונטיות) · מסמכי-מקור (ב-data/cases/{case_number}/documents/originals/). סמן אדנים עצמאיים, מוקשי-עקביות ומענה לצד המפסיד היכן שהם קיימים.
### 2. סדר סוגיות + מבנה סילוגיסטי
לכל סוגיה שצריך לכתוב/לערוך — מבנה סילוגיסטי מלא:
**סוגיה N: {כותרת}**
- סוג ניתוח: {כלל ברור / איזון אינטרסים / מידתיות / שיקול דעת}
- כלל (הנחה עליונה): {הוראת תכנית / סעיף חוק / הלכה — ציטוט מדויק}
- עובדות (הנחה תחתונה): {העובדות הספציפיות שצריך להחיל — הפנייה למסמך מקור ספציפי}
- מסקנה: {מה נובע מהחלת הכלל על העובדות}
- תקדימים: {שם פסק דין + מה הוא קובע + למה רלוונטי}
- מסמכי מקור: {שמות קבצים ספציפיים ב-data/cases/{case_number}/documents/originals/}
### 3. טיפול בטענות
טבלה: # | טענה | טיפול (דיון מלא / קיבוץ / דילוג) | סוגיה.
| # | טענה | טיפול | סוגיה |
|---|------|-------|-------|
| 1 | {טענה} | דיון מלא / קיבוץ / דילוג | {באיזו סוגיה} |
...
### 4. הנחיות-היו"ר
העתק מילולי של עמדות-הוועדה מ-analysis-and-research.md (או "קרא get_chair_directions"), **עטוף ב-`<chair_directions>…</chair_directions>`** — טקסט מילולי בלבד בלי פרפרזה, כדי שהכותב לא ידרוס אותן.
### 4. chair directions
- העתק מלא של עמדות הוועדה מ-analysis-and-research.md (או הפנייה: "קרא get_chair_directions")
### 5. הנחיות סגנון
ניטרליות (בלוק ו = עובדות בלבד, בלי ציטוטי-צדדים) · ללא כפילות (בלוק י מפנה לקודמים) · טענות מקוריות (בלוק ז) · דיון ≥ 1,500 מילים · ≥ 3 תקדימים בדיון.
- ניטרליות: בלוק ו = עובדות בלבד, בלי ציטוטים מצדדים
- ללא כפילות: בלוק י מפנה לבלוקים קודמים
- טענות מקוריות: בלוק ז = כתבי טענות מקוריים
- אורך מינימלי לדיון: 1,500 מילים לבלוק י
- פסיקה: חובה לצטט לפחות 3 תקדימים בדיון
```
---
@@ -941,49 +844,9 @@ case_prefix="${case_number:0:1}"
- **ודא עקביות מתודולוגית** — כיוונים סילוגיסטיים (כלל + עובדות + מסקנה), chair_directions שלם (טיפול בטענות + כיוון + סדר סוגיות + תקן ביקורת), התאמה ל-`decision-methodology.md`
- **סינון תיקים** — עבוד רק על תיקים בטווח המספרים של החברה שלך (ראה טבלה למעלה)
## סריקת הערות-יו"ר תלויות (company-wide) — שער-מסירה
> **למה זה קיים:** ה-wake מה-sweep מגיע עיוור (ראה ⚠️ בשלב 0). לכן אינך יכול להסתמך על `reason`/`issueId` כדי לדעת שיש הערה לטפל בה — אתה חייב **לגלות** אותה בעצמך. סריקה זו מוצאת כל הערת-יו"ר שטרם נענתה **בכל issue בחברה** (לא רק בתור המשויך-אליך — הערות-יו"ר יושבות על issues של חיים/סוכני-מִשנה, לא עליך).
**⚠️ כלל-ברזל: שיוך-לתיק ממקור מובנה, לעולם לא מהכותרת ולא מתוכן-ההערה.** הערה עשויה להזכיר תיק אחר (למשל "ניסיתי ב-CMPA-89") — זה **תוכן לטיפול**, לא יעד. היעד הוא תמיד התיק של ה-**project** שאליו שייך ה-issue. אם אי-אפשר לפתור תיק בוודאות → **אל תפעל**.
**הסריקה (הרץ בכל heartbeat ללא reason מכוּון):**
1. **רשום את כל issues החברה הפעילים:**
```bash
ISSUES=$(~/legal-ai/scripts/pc.sh GET "/api/companies/$PAPERCLIP_COMPANY_ID/issues")
echo "$ISSUES" | jq -r '(if type=="array" then . else .issues end)
| map(select(.status!="done" and .status!="cancelled" and (.hiddenAt|not)))
| .[].id'
```
2. **לכל issue — האם יש הערת-יו"ר תלויה?** הערה תלויה = הערת-`user` האחרונה **חדשה** מהערת-`agent` האחרונה (התעלם מ-`system`). ⚠️ ה-API מחזיר comments ב-**newest-first** — אל תשתמש ב-`.[-1]`; מיין ב-`createdAt`:
```bash
C=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/comments")
PENDING=$(echo "$C" | jq -r '
(map(select(.authorType=="user")) | sort_by(.createdAt) | last) as $u |
(map(select(.authorType=="agent")) | sort_by(.createdAt) | last) as $a |
if $u==null then "no" elif ($a==null) or ($u.createdAt>$a.createdAt) then "yes" else "no" end')
```
אם `PENDING != "yes"` → דלג ל-issue הבא.
3. **שיוך-לתיק (שער-בטיחות חובה)** — חלץ את מספר-התיק מ-**`project.name`** (שדה מובנה: `"ערר <מספר> — ..."`), לא מהכותרת ולא מההערה:
```bash
CTX=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/heartbeat-context")
CASE_NUMBER=$(echo "$CTX" | jq -r '.project.name // ""' \
| grep -oE '(ערר|בל"מ) [0-9]{4,5}-[0-9]{2}-[0-9]{2}' \
| grep -oE '[0-9]{4,5}-[0-9]{2}-[0-9]{2}' | head -1)
```
- **`CASE_NUMBER` ריק** (ה-project אינו תיק — למשל "ספריית פסיקה — תור חילוץ", או שיוך לא-ודאי) → **אל תפעל על תיק.** אם נראה שזו הערת-יו"ר אמיתית שאבדה — פרסם comment קצר ב-issue + שלח מייל ליו"ר (סעיף §5), בלי לגעת בשום תיק. עבור ל-issue הבא.
- **`CASE_NUMBER` קיים** → טפל בהערה לפי "טיפול בתגובות חדשות מחיים" למטה, **scoped ל-`$ISSUE_ID` ול-`$CASE_NUMBER` הזה בלבד.**
4. **כמה issues תלויים** (אפשרי — אפילו על אותו תיק) → טפל בכולם, כל אחד ב-scope שלו. אם לא נמצאה אף הערה תלויה → אין פעולת-CEO; המשך לשלב A.
---
## טיפול בתגובות חדשות מחיים (comment routing)
כשאתה מתעורר בגלל תגובה חדשה (reason מכיל "user_commented"), **או** כשהסריקה למעלה זיהתה הערה תלויה ב-`$ISSUE_ID` עם `$CASE_NUMBER` פתור:
כשאתה מתעורר בגלל תגובה חדשה (reason מכיל "user_commented"):
0. **החזר את ה-issue הראשי ל-`status=in_progress`** — אם ה-issue ב-`in_review` (כי המתנת לחיים) או ב-`blocked` (כי Paperclip חסם אוטומטית), הראשון דבר: עדכן ל-`in_progress` כדי לסמן שאתה עובד עליו.

View File

@@ -96,7 +96,7 @@ tools:
"id": "r1",
"type": "insert_after", // או insert_before, replace, delete
"anchor_bookmark": "block-yod", // מ-list_bookmarks
"content": "<תוכן ההוספה — טקסט עברי נקי, בלי ציטוט מומצא>",
"content": "וכך נפסק בעניין פלוני. בבג\"ץ 1234/21 קבע השופט...",
"style": "body", // או heading, quote
"reason": "הוספת פסק הלכה שחסר לפי בקשת יו\"ר"
}
@@ -128,7 +128,7 @@ tools:
בלי סגירת-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]"`.
**הפרוטוקול המלא — מקור יחיד: [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`).
## כללים קריטיים

View File

@@ -96,4 +96,5 @@ tools:
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked` (`blocked` אם markers `[?]` רבים), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מגיה סיים [issue-id] בסטטוס [done/blocked]"`.
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל / markers `[?]` רבים), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מגיה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.

View File

@@ -17,7 +17,6 @@ tools:
- mcp__legal-ai__search_precedent_library
- mcp__legal-ai__search_internal_decisions
- mcp__legal-ai__precedent_library_get
- mcp__legal-ai__precedent_library_list
- mcp__legal-ai__precedent_list
- mcp__legal-ai__halacha_review
---
@@ -57,8 +56,6 @@ tools:
## 7 בדיקות
> **סף-הקבלה המשותף — `docs/qa-acceptance-rubric.md`:** 7 הבדיקות למטה הן האכיפה המפורטת שלו (מקור-אמת אחד; הכותב מכוון לאותו rubric). בדיווח — מפה כל `fail` לחוסם/אזהרה שם.
### 1. שלמות מבנית (structural_integrity)
- כל בלוקי חובה קיימים (ה עד יא)
- מספור רציף ללא קפיצות
@@ -182,11 +179,10 @@ tools:
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
```
new → processing → documents_ready → analyst_verified → research_complete
→ outcome_set → direction_approved → qa_review → drafted (אתה כאן!)
exported → reviewed → final
new → proofread → documents_ready → analyst_verified → research_complete (legacy/optional)
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
drafted (אתה כאן!) → qa_passed / qa_failed → exported
```
(מקור-אמת יחיד: `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` — דווח, אל תכשיל.
@@ -235,24 +231,14 @@ new → processing → documents_ready → analyst_verified → research_complet
6. האם יש אנלוגיה לתקדים ללא הסבר מדיניות (למה הדמיון רלוונטי)?
### שלב 3: דיווח — חובה!
פרסם comment ב-Paperclip **במבנה קבוע** (markdown — נקרא ע"י היו"ר וע"י ה-CEO), כדי שהפסיקה תהיה עקבית וניתנת-לפרסינג. מלא שורה לכל אחת מ-7 הבדיקות:
```markdown
## דוח בדיקת איכות — ערר {case_number}
| בדיקה | תוצאה | חומרה | ממצא (אם fail) |
|-------|:-----:|:-----:|-----------------|
| {שם הבדיקה} | pass / fail | critical / major / minor | {פירוט קצר} |
| ... שורה לכל 7 הבדיקות ... | | | |
**מותר לייצא:** כן / לא ← "לא" אם ולו בדיקה **critical** אחת ב-fail
**סטטוס חדש:** drafted (עבר) / qa_review (נכשל)
```
- עדכן סטטוס בהתאם: `drafted` (אם עבר) או `qa_review` (אם נכשל).
פרסם comment ב-Paperclip עם:
- תוצאת כל בדיקה (pass/fail)
- רשימת שגיאות מפורטת (אם יש)
- האם מותר לייצא (כל הקריטיים pass?)
- עדכן סטטוס ל-qa_review (אם נכשל) או drafted (אם עבר)
### סגור את ה-issue של עצמך + העֵר CEO — חובה!
בלי סגירת-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]"`.
**הפרוטוקול המלא — מקור יחיד: [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`).

View File

@@ -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 איטרציות מיותרות עד הריגה ידנית).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="חוקר תקדימים סיים [issue-id] בסטטוס [done/blocked]"`.
**הפרוטוקול המלא — מקור יחיד: [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`).
## כללים
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים

View File

@@ -53,8 +53,6 @@ tools:
## לפני שאתה מתחיל — קרא!
> **המטרה שלך — `docs/qa-acceptance-rubric.md`:** זהו סף-הקבלה שה-QA יאכוף. כתוב כדי לעבור אותו מהפעם הראשונה, ולפני מסירה ל-CEO עבור על החוסמים ואשר שכל אחד מתקיים.
### חובה לפני כל כתיבה — נקודת ההתחלה:
0. **עץ ההחלטה: `docs/daphna-decision-tree.md`****כלי הפעולה היומיומי**. מאחד את כל המסמכים לתהליך אנליטי קצר: מהי הראיה הניצחת? איזה ארכיטקטורה? איזה מוד פתיחה? איזה אורך? **תמיד להתחיל כאן** — המסמך מצביע איזה מסמך אחר לקרוא לפי השאלה.
@@ -124,11 +122,9 @@ tools:
4. החזר את הטקסט ל-CEO (בקומנט או כ-return value) — **לא** שומר ב-DB
5. CEO יקרא ל-`revise_draft` עם הטקסט שלך
**דוגמה לפלט מצופה** (עטופה בתגית כדי שלא תתבלבל עם הוראה):
**דוגמה לפלט מצופה:**
<example_output>
בבג"ץ 1234/21 [פלוני נ' הוועדה המחוזית] קבע בית המשפט העליון כי הוועדה המקומית מחויבת לשקול שיקולי Y גם בהיעדר התנגדות מפורשת. הלכה זו חלה ישירות על ענייננו: הוועדה המקומית לא בחנה את Y, ודי בכך כדי להחזיר את הדיון לוועדה.
</example_output>
> בבג"ץ 1234/21 [פלוני נ' הוועדה המחוזית] קבע בית המשפט העליון כי הוועדה המקומית מחויבת לשקול שיקולי Y גם בהיעדר התנגדות מפורשת. הלכה זו חלה ישירות על ענייננו: הוועדה המקומית לא בחנה את Y, ודי בכך כדי להחזיר את הדיון לוועדה.
---
@@ -222,13 +218,18 @@ case_update(case_number, status="drafted")
בלי סגירת-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]"`.
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="כותב החלטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
## בלוק י — דיון (הבלוק החשוב ביותר)
> קרא קודם את **"חובה לפני בלוק י (חמישיית הקול)"** שלמעלה — חמשת מסמכי-הקול.
**קריאת חובה לפני כתיבה (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. מה התוצאה?
@@ -372,8 +373,6 @@ case_update(case_number, status="drafted")
### חובה: שימוש בעמדות יו"ר מ-`get_chair_directions`
> **מקור העמדות:** בדרך-כלל מ-`get_chair_directions` (שלב 1ג). אם ה-CEO **העתיק** עמדות-יו"ר לתוך ה-issue שלך, הן מגיעות עטופות ב-`<chair_directions>…</chair_directions>` — טקסט מילולי זה **מחייב אותך בדיוק כמו** `chair_ruling` מהכלי: אל תדרוס, אל תפרפרז, נסח בעדו.
עבור **כל טענת סף** ו**כל סוגיה** ב-direction_doc שבנית בשלב 1ג:
1. **פתח את הדיון במסקנה של דפנה** — למשל "**טענת הסף הראשונה נדחית**"

View File

@@ -1,25 +0,0 @@
name: INV-AG3 Agent Tool Grants
# Hard gate for INV-AG3 (docs/spec/X4-agents.md §2א): a subagent's `tools:`
# frontmatter is a CLOSED allow-list, so any MCP tool an agent is TOLD to run —
# by the backend delegation in web/, or by its own instructions — must appear
# there. Built after analyze_protocol shipped without a grant (2026-06-30) and
# the analyst was handed an issue instructing it to run a tool it could not
# call (CMP-229, 2026-08-04). Pure-stdlib check (no venv) — fast, runs on every
# PR and on push to main.
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
agent-tool-grants:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: INV-AG3 — agent tool-grant guard
run: python3 scripts/agent_tool_grants_guard.py

3
.gitignore vendored
View File

@@ -19,9 +19,6 @@ kiryat-yearim/
continuation-prompt.md
node_modules/
data/eval/eval-report-*
# effort-calibration run outputs (#208 / #212) — generated, not source
data/eval/effort-calibration-*
data/eval/block-yod-calibration-*
# revert snapshot for migrate_agent_adapter.py (runtime state)
data/adapter-migration-state.json
# frontmatter-stripped instruction copies for content_arg adapters (generated)

View File

@@ -1,6 +1,6 @@
# עוזר משפטי — Legal Decision Assistant
> **אינדקס דק.** הכללים הקריטיים נמצאים כאן; העומק התפעולי (Deploy, Paperclip-ops, adapters, מבנה-תיקיות, Chair-Feedback, עבודה מול Gitea Issues) הוצא ל-[`docs/operations-runbook.md`](docs/operations-runbook.md) כדי לרזות את ההקשר הנטען בכל סשן.
> **אינדקס דק.** הכללים הקריטיים נמצאים כאן; העומק התפעולי (Deploy, Paperclip-ops, adapters, מבנה-תיקיות, Chair-Feedback, TaskMaster מלא) הוצא ל-[`docs/operations-runbook.md`](docs/operations-runbook.md) כדי לרזות את ההקשר הנטען בכל סשן.
## רקע הפרויקט
@@ -64,8 +64,7 @@
| [`.claude/agents/HEARTBEAT.md`](.claude/agents/HEARTBEAT.md) | checklist הפעלת סוכן — routing, company filtering, quirks, wakeup עם UUID נכון | **לפני כל עבודה על סוכנים** |
| [`skills/dafna-decision-template/SKILL.md`](skills/dafna-decision-template/SKILL.md) | export DOCX לפי styles של תבנית Word של דפנה — line classification, dash policy, placeholder handling | לפני export DOCX |
| [`docs/corpus-graph.md`](docs/corpus-graph.md) | **מפת הקורפוס** (`/graph`) — גרף ציטוטים אינטראקטיבי נייטיב; 6 שכבות (פסיקה/נושא/תחום/הלכות/חוסרי‑מחקר/יומונים), אנליטיקה (PageRank/אשכולות), endpoints, ואיך מוסיפים שכבה | לפני עבודה על דף `/graph` או `web/graph_api.py` |
| [`docs/taskmaster-to-gitea-migration.md`](docs/taskmaster-to-gitea-migration.md) | **מיגרציית ניהול-המשימות** — TaskMaster→Gitea Issues (2026-08-05), טבלת מיפוי מלאה, ומלכודת המספור בהפניות `(#N)` ישנות | כשנתקלים בהפניה `(#N)` בקומיט ישן, או כשמחפשים משימה שהייתה ב-TaskMaster |
| [`docs/operations-runbook.md`](docs/operations-runbook.md) | **עומק תפעולי** — Deploy (Coolify/pm2), Paperclip-ops מלא (wakeup, sync, webhook, scheduled jobs, adapters), מבנה-תיקיות, Chair-Feedback, Gitea Issues API | לפני עבודה על Deploy / אינטגרציית-Paperclip / adapters |
| [`docs/operations-runbook.md`](docs/operations-runbook.md) | **עומק תפעולי** — Deploy (Coolify/pm2), Paperclip-ops מלא (wakeup, sync, webhook, scheduled jobs, adapters), מבנה-תיקיות, Chair-Feedback, TaskMaster | לפני עבודה על Deploy / אינטגרציית-Paperclip / adapters |
---
@@ -115,7 +114,7 @@ cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד
6. **אל תיגע** בשינויים לא-מתויקים שאינם שלך בעץ הראשי — הם של סשן אחר. אם העץ הראשי על ענף זר — אל תתייק עליו.
> **בידוד-DB:** ה-worktree מבודד-קבצים בלבד — לא בידוד-repo ולא בידוד-DB. **אל תריץ migrations מ-2 worktrees במקביל** על Postgres המשותף (`localhost:5433`) — סכמה שאף סשן לא מצפה לה ([Run agents in parallel](https://code.claude.com/docs/en/agents)).
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w`. כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. ניתוח מלא: ארכיון TaskMaster `legal-ai` #104 (נסגר cancelled — "לתעד, לא לבדד").
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w`. כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. ניתוח מלא: TaskMaster `legal-ai` #104 (נסגר cancelled — "לתעד, לא לבדד").
---
@@ -145,36 +144,9 @@ cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד
## כלל: עדכון `scripts/SCRIPTS.md`
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/`**חובה לעדכן את `scripts/SCRIPTS.md`** (תפקיד, סטטוס, החלפה).
## ניהול משימות — Gitea Issues ⚠️
> **מקור-האמת לעבודה הוא [Gitea Issues](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues) — לא TaskMaster, לא הקובץ הזה, לא זיכרון.**
> **TaskMaster הוצא משימוש ב-2026-08-05.** 26 המשימות הפתוחות הועברו ל-issues #433#458 (מיפוי מלא: [`docs/taskmaster-to-gitea-migration.md`](docs/taskmaster-to-gitea-migration.md)). `.taskmaster/tasks/tasks.json` נשמר לקריאה-בלבד כארכיון היסטורי — **אין לכתוב אליו ואין ליצור בו משימות חדשות.**
המוסכמות מגיעות מ-[`infra/project-template`](https://gitea.marcus-law.co.il/infra/project-template) — אותה מערכת תיוג ואותו workflow כמו שאר פרויקטי המשרד.
### Session-Start Protocol
כשנפתח סשן עם "איפה אנחנו עומדים / מה נשאר / בוא נמשיך" — **אל תשחזר מצב מזיכרון.** קרא את ה-issues החיים קודם:
1. `GET /api/v1/repos/ezer-mishpati/legal-ai/issues?state=open` (טוקן מ-`~/.git-credentials` או Infisical `/apps/legal-ai` `GITEA_TOKEN`).
2. הבא בתור = ה-issue בעל העדיפות הגבוהה ביותר (`p0-blocker``p1-high``p2-normal``p3-low`; בתוך עדיפות — `type:bug` קודם).
3. **דלג** על `status:in-progress` (מישהו עובד), `status:in-review` (יש PR), `status:needs-decision` (ממתין להכרעת חיים/דפנה), `status:blocked`, `status:deferred`.
4. רק **אחרי** קריאת ה-issues — הצע את הצעד הבא.
### Issue-First
- **שום קוד בלי issue.** כל תקלה/צורך/משימה → issue קודם.
- כל issue חייב: **What & Why** + **Acceptance Criteria** + הקשר. issue ללא AC אינו `status:ready` ואינו נלקח.
- **Branch naming:** `<type>/<issue>-<slug>` (`feat`/`fix`/`chore`/`refactor`/`docs`/`test`) — יצירת branch = בלעדיות על ה-issue.
- ה-PR מפנה ל-issue (`Closes #N`) ומצהיר invariants (ראה [`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md)).
### מערכת התיוג
| שכבה | ערכים |
|------|-------|
| `type:` | `bug` · `feature` · `chore` · `spike` |
| `priority:` | `p0-blocker` · `p1-high` · `p2-normal` · `p3-low` |
| `status:` | `ready` · `in-progress` · `in-review` · `needs-decision` · `blocked` · `deferred` · `good-first-issue` |
| `size:` | `xs` (<חצי יום) · `s` (~יום) · `m` (2-3 ימים) · `l` (שבוע+ — לשקול פיצול) |
| `area:` | `agents` · `retrieval` · `extraction` · `writing` · `backend` · `frontend` · `mcp` · `infra` · `docs` · `ci` · `security` |
> ⚠️ **הפניות `(#N)` בהודעות-commit מלפני 2026-08-05 מתייחסות למזהי TaskMaster, לא למספרי issue.** מספרי ה-issue של legal-ai מתחילים ב-#433; מתחת לזה המספרים שייכים ל-PRs ולשני issues ישנים.
## ניהול משימות — TaskMaster AI
**תמיד** TaskMaster (לא TASKS.md ידני). קובץ קנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (tags: `master`, `legal-ai`). פקודות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`.
> **⚠️ מלכוד cwd ב-CLI:** `--tag` בוחר קבוצה *בתוך* הקובץ — לא לאיזה קובץ לכתוב (ה-CLI מאתר לפי cwd). תמיד `cd ~/legal-ai` לפני כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את הקובץ ישירות. פירוט: [`docs/operations-runbook.md`](docs/operations-runbook.md).
---

View File

@@ -17,7 +17,7 @@
| ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` |
| Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` |
| legal-chat-service | גשר claude CLI לטאב הצ'אט ב-/training (pm2, loopback) | `127.0.0.1:8770` |
| Infisical | ניהול סודות — פרויקט **All Infrastructure** (`2c462576-b125-4279-b0ec-7220dbf51ccf`), env `main`. סודות המערכת מפוזרים על שלוש תיקיות-אחיות: `/apps/legal-ai` · `/apps/paperclip` · `/apps/paperclip-hermes` | `secret.marcus-law.co.il` |
| Infisical | ניהול סודות | `secret.dev.marcus-law.co.il` |
---
@@ -52,28 +52,6 @@
- **אפס עלות API** — claude CLI משתמש ב-claude.ai subscription של chaim. הנחת היסוד של `claude_session.py` (claude CLI מקומי בלבד) נשמרת.
- Coolify dependency: ה-Service Definition של legal-ai חייב להכיל `extra_hosts: host.docker.internal:host-gateway` (אחרת ה-proxy יקבל ConnectError).
### legal-mcp-http — שרת ה-MCP ב-HTTP (מאוגוסט 2026)
- פורט: `127.0.0.1:8790` (loopback בלבד) · קונפיג: [`scripts/legal-mcp-http.config.cjs`](../scripts/legal-mcp-http.config.cjs)
- **הדלת של סוכני הפלטפורמה** אל 108 כלי ה-MCP. סשן אינטראקטיבי מגיע לאותו שרת ב-stdio דרך `~/.claude.json`**אותו קוד, שתי תחבורות** (G2).
- שער Bearer (`MCP_HTTP_SHARED_SECRET`); השרת **מסרב לעלות** בלי טוקן. בדיקה: ללא טוקן → `401`, עם טוקן → `200`.
- **⚠️ שינוי קוד ב-`mcp-server/` לא נכנס לתוקף עד `pm2 restart legal-mcp-http`.** זה תהליך ארוך-חיים, בניגוד ל-stdio שנטען מחדש בכל סשן. אחרי מיזוג שנוגע ב-`mcp-server/` — להפעיל מחדש, אחרת הסוכנים ירוצו על קוד ישן בשקט.
> #### 🚨 דריפט תצורה פתוח — `STORAGE_BACKEND`
> הקונטיינר רץ `STORAGE_BACKEND=s3`; שרת ה-MCP נופל לברירת-המחדל `filesystem`, כי
> `~/.env` אינו מגדיר אותו. כלומר **סוכן שכותב קובץ דרך MCP כותב לדיסק, בעוד ה-web
> קורא מ-S3** — שני מסלולים שמתפצלים (G2 / INV-STG1).
>
> **למה זה עדיין לא תוקן:** ההעברה ל-s3 דורשת `MINIO_ENDPOINT` + `MINIO_ACCESS_KEY` +
> `MINIO_SECRET_KEY`, ואלה **אינם קיימים ב-Infisical** — רק ב-env של Coolify. הפיכת
> הדגל בלעדיהם תפתור את `MINIO_ENDPOINT` לברירת-המחדל `http://minio:9000` (שם-רשת
> פנימי של דוקר, לא נגיש מהמארח) ותשבור כל פעולת-בלוב.
>
> **מה שצריך כדי לסגור:** ליצור את שלושת המפתחות ב-Infisical תחת `/apps/legal-ai`
> (תגית `credentials`, באישור חיים — ראה כלל "Saving a NEW secret" ב-`~/CLAUDE.md`),
> לטעון אותם בקונפיג ה-pm2 כמו `MCP_HTTP_SHARED_SECRET`, ואז להוסיף
> `STORAGE_BACKEND: "s3"`. MinIO **כן** נגיש מהמארח: `https://s3.nautilus.marcusgroup.org`
> (אומת — `/minio/health/live` מחזיר 200).
---
## מבנה תיקיות
@@ -217,33 +195,9 @@ CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **
---
## ניהול משימות — Gitea Issues (פירוט)
## ניהול משימות — TaskMaster AI (פירוט)
> **TaskMaster AI הוצא משימוש ב-2026-08-05.** מקור-האמת לעבודה הוא
> [Gitea Issues](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues).
> ה-workflow, מערכת התיוג ו-Session-Start Protocol מתועדים ב-[`CLAUDE.md`](../CLAUDE.md);
> טבלת המיפוי TaskMaster→issue ב-[`taskmaster-to-gitea-migration.md`](taskmaster-to-gitea-migration.md).
### עבודה מול ה-API
הטוקן: `~/.git-credentials` (שורת `gitea.nautilus`) או Infisical `/apps/legal-ai` `GITEA_TOKEN`.
```bash
API=https://gitea.nautilus.marcusgroup.org/api/v1/repos/ezer-mishpati/legal-ai
T=$(grep -m1 'gitea.nautilus' ~/.git-credentials | sed 's|.*://[^:]*:\([^@]*\)@.*|\1|')
# מה הבא בתור (דילוג על in-progress/in-review/blocked/deferred/needs-decision)
curl -s -H "Authorization: token $T" "$API/issues?state=open&labels=priority:p1-high"
# issue חדש
curl -s -X POST -H "Authorization: token $T" -H "Content-Type: application/json" \
"$API/issues" -d '{"title":"...","body":"## What & Why\n...\n## Acceptance Criteria\n...","labels":[<ids>]}'
# תוויות (id ← שם)
curl -s -H "Authorization: token $T" "$API/labels?limit=100"
```
### ארכיון TaskMaster
`~/legal-ai/.taskmaster/tasks/tasks.json` **מוקפא לקריאה-בלבד** — 166 משימות שהושלמו,
4 שבוטלו, ו-26 הפתוחות שהועברו. שימושי לחפירה היסטורית ("למה עשינו X"), אבל
**אין לכתוב אליו**. אם צריך הקשר ממשימה ישנה — לחפש בקובץ הזה, ולפתוח issue חדש
אם נדרשת עבודה.
- קובץ המשימות הקנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (יחסי ל-project root, **לא** `~/.taskmaster/tasks/tasks.json`). מכיל את כל ה-tags של legal-ai (`master`, `legal-ai`).
- פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`
- לפני התחלת עבודה → `next_task`; אחרי סיום → `update_task` עם status=done; משימה מורכבת → `expand_task`
- **⚠️ מלכוד cwd ב-CLI:** הדגל `--tag` בוחר קבוצה לוגית *בתוך* הקובץ — הוא **לא** בוחר לאיזה `tasks.json` לכתוב. ה-CLI מאתר את הקובץ לפי ה-cwd. תמיד `cd ~/legal-ai` לפני `task-master add-task` או כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את `~/legal-ai/.taskmaster/tasks/tasks.json` ישירות.

View File

@@ -275,40 +275,3 @@ 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`).
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
- 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.

View File

@@ -1,57 +0,0 @@
# 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. **קול דפנה**
- בלוק ז: כותרת **"תמצית טענות הצדדים"**; כותרת-משנה לכל צד; בלי רשימה ממוספרת בפסקה;
בלי מילות-הערכה; בלי גילוי-מסקנה; קול פעיל ("העורר טוען").
- בלוק י: כותרת **"דיון והכרעה"**; קול "אנחנו" תפקידי ("מצאנו", לא "הוועדה מוצאת");
דפוס **"אכן... אולם"** לטענות שנדחות; בלי מספור-פסקאות סדרתי; ציטוטי פסיקה במלואם (415 שורות);
"למעלה מן הצורך" לטיעונים מרכזיים; בלי תוצאת הכל-או-לא-כלום כשיש טענות מהותיות משני הצדדים.
- 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 — מדווח לחיים, לא חוסם)
- **משקלות בטווח:** ו 1540% · ז 2040% · י 3250% · יא 29%.
- **בלי כפילות:** בלוק י מפנה לבלוקים קודמים ("כאמור"), לא חוזר על עובדות/טענות.
- **מראה-מקום מלא בציטוטים:** מספר-תיק מלא + ערכאה + תאריך/`פורסם בנבו` (+ `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, עדכן שם ואת התמצית כאן.

View File

@@ -96,11 +96,10 @@ idempotency-key pattern · ISO 8000 (Data quality) | סטטוס: verified
בנקודת-סיום-הקליטה, לכל סוג; חוזה-שלמות יסמן רשומה ללא מטא-דאטה כלא-שמישה
([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), מפורט ב-
[02-data-model.md](02-data-model.md)).
**הפרה ידועה — ✅ נסגרה (FU-1, GAP-02):** המסלול הפנימי דילג בעבר על חילוץ-המטא-דאטה
(`internal_decisions.py:208` תיזמן רק `request_halacha_extraction`) → ערן סופר 8046/24 נקלטה
בלי metadata. **נסגר:** שני מסלולי-הפסיקה זורמים כעת דרך `ingest.ingest_document` הקנוני, שמתזמן
**את שני התורים יחד** בנקודה אחת (`ingest.py:233-234`) — תיזמון-המטא אינו מותנה במסלול עוד.
מאומת ב-`test_unified_ingest.py` (9/9). תיעוד-מקור: [audit](../audit-report.md).
**הפרה ידועה:** המסלול הפנימי (`internal_decisions.py:208`) מתזמן **רק**
`request_halacha_extraction` ואינו קורא ל-`request_metadata_extraction` (בניגוד
ל-`precedent_library.py:292-293` שקורא לשניהם) → ערן סופר 8046/24 נקלטה **בלי
metadata** (headnote/summary/tags ריקים) → ממצא ל-[audit](../audit-report.md).
### INV-ING4: re-index בקליטה-חוזרת (upsert ⇒ re-embed)
**כלל:** קליטה-חוזרת ששינתה את תוכן-הפריט מפעילה **re-index** — chunks ו-embeddings

View File

@@ -73,9 +73,6 @@ proceeding_type)`. לכן המזהה הקנוני הוא **(`case_number` מנו
### 2ב. חוזה תמציתי לישויות נוספות
- `documents` → usable: `file_path`+`doc_type`; searchable: `extraction_status=completed` ו-`extracted_text` לא-ריק ו-≥1 `document_chunk` עם embedding.
> **עיצוב-מחדש זרימת-העבודה (WS2, #200):** `documents` מקבל `is_primary`/`doc_category` (רשימה
> קנונית: ערר/תשובה/תגובה/פרוטוקול/שומה/החלטת-ועדה) + דגל-ניתוח per-מסמך (`claims_extraction_status`),
> כדי לחשב "יש מסמך-עיקרי שלא-נכלל-בניתוח". השדות בבעלות #200; ניתוח-החילוץ עצמו ב-[04-analysis-writing](04-analysis-writing.md) §1.
- `halachot` → usable: `rule_statement`+`supporting_quote`; **searchable: `review_status ∈ {approved, published}` בלבד**`pending_review`/`rejected` מוסתרות מ-`search_precedent_library` (שער-הלכה ידני, `db.py:644-660`, [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)).
- `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק.
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
@@ -210,22 +207,18 @@ RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
(`case_number, source_kind, proceeding_type`, `db.py:904-909`), אך השדה `case_number TEXT
UNIQUE NOT NULL` המקורי (`db.py:368`) הוסר רק ב-V15 (`db.py:902-903`) — מורשת שאפשרה את
הפרת [INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות). **יעד:** נרמול-בכתיבה אכוף + ציטוט-מלא רק ב-`citation_formatted`.
- **`summary` קיים על `case_law` אך לא בחוזה-הקליטה הפנימי — ✅ נסגר (FU-1, GAP-02).** העמודה
קיימת (`db.py:373`), והמסלול הפנימי לא מילא אותה בעבר (היעדר חילוץ-מטא-דאטה). **נסגר:** שני
המסלולים זורמים דרך `ingest.ingest_document` הקנוני שמתזמן metadata לשני הסוגים
([01-ingest INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)); `searchable`
מותנה ב-metadata לא-ריק ([INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
- **דגל-`searchable` מפורש — ✅ קיים.** מעבר ל-`extraction_status` + `halacha_extraction_status`
(`db.py:603-605`, תהליך), קיימת כעת עמודת `case_law.searchable` המסמנת "עבר חוזה-שלמות"; שכבת-
החיפוש מסננת לפיה (`cl.searchable = true`, `db.py:7517,7519`) ו-`recompute_searchable_flag`
מתחזק אותה. אוכף את [INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים) בשכבת-האחזור.
- **`summary` קיים על `case_law` אך לא בחוזה-הקליטה הפנימי.** העמודה קיימת (`db.py:373`) אך
המסלול הפנימי אינו ממלא אותה (כפועל-יוצא מהיעדר חילוץ-מטא-דאטה, [INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)).
**יעד:** searchable מותנה ב-metadata לא-ריק ([INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
- **שני שדות-סטטוס-חילוץ נפרדים, ללא דגל-`searchable` מפורש.** `extraction_status` +
`halacha_extraction_status` (`db.py:603-605`) מתארים תהליך, אך אין שדה יחיד שמסמן "עבר
חוזה-שלמות → searchable". **יעד:** דגל/view נגזר ש-search מסנן לפיו, מגובה health-check.
- **`embedding` אינו `GENERATED` (בניגוד ל-tsvector).** ה-tsvectors מסונכרנים אוטומטית
(`db.py:776,780,1083`), אך ה-`embedding vector(1024)` תלוי-טריגר חיצוני — נקודת-drift אפשרית
ל-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). **יעד:** טריגר re-embed מובטח + health-check ל-drift.
- **נראות backlog-הלכות — ✅ נסגר (FU-5, GAP-14).** הסינון תקין (`pending_review` מוסתר,
`db.py:659`); ההפרה הישנה היתה היעדר-נראות (10/19 מאושרות, התגלה במקרה). **נסגר:** ה-health-
check חושף `halacha_backlog` (`web/app.py:2352-2364`, `metrics_service.halacha_backlog`),
והכלים `extraction_status` + `halachot_pending` חושפים את עומק-התור — שער-היו"ר ([G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)) הופך נראה.
- **`halachot.review_status` כשער-searchable ללא נראות-backlog.** הסינון תקין (`pending_review`
מוסתר, `db.py:659`), אך אין נראות כמה ממתינות — תואם את ההפרה הידועה ב-[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
(10/19 מאושרות, התגלה במקרה). **יעד:** health-check חושף backlog-הלכות.
---

View File

@@ -79,12 +79,11 @@ quality / conformance) | סטטוס: verified
**אכיפה:** תנאי-`source_kind` בכל ענף-SQL בשכבת-החיפוש; בדיקת-בריאות שמריצה שאילתת-ביקורת
(חיפוש מכוון-קורפוס שמחזיר פריט בעל `source_kind` זר = כשל). אוכף את
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
**הפרה ידועה — ✅ נסגרה (FU-4, GAP-10, משימה #56):** בעבר `halacha_filters` לא כללו
`cl.source_kind` (בניגוד ל-`chunk_filters` שכן) → הלכות מהקורפוס הפנימי דלפו לחיפוש בקורפוס
החיצוני ולהפך. **נסגר:** שני ה-`halacha_filters` (semantic + lexical) מתחילים כעת ב-
`cl.source_kind = '{source_kind}'` בדיוק כמו `chunk_filters` (`db.py:7516,7519` ומקבילו ב-
`search_precedent_library_lexical`). מאומת ב-`test_precedent_corpus_isolation.py` (semantic+lexical,
שני ה-source_kind) → תיעוד-מקור: [audit](../audit-report.md).
**הפרה ידועה:** משימה #56 `halacha_filters` **אינם** כוללים `cl.source_kind` ב-
`search_precedent_library_semantic` (`db.py:3168`, ענף ה-halacha; לעומת `chunk_filters` שכן —
`db.py:3169`) **וב**-`search_precedent_library_lexical` (`db.py:3401` מול `db.py:3402`). שני
ה-`halacha_sql` עושים `JOIN case_law cl` בלי לסנן `source_kind` (`db.py:3236-3238`, `db.py:3475-3477`)
→ הלכות מהקורפוס הפנימי דולפות לתוצאות החיפוש בקורפוס החיצוני ולהפך → ממצא ל-[audit](../audit-report.md).
### INV-RET2: אין החזרה/אינדוקס בלי metadata מלא + locator פתיר
**כלל:** פריט אינו מוחזר מ-search (ואינו נחשף לאחזור) אלא אם **שדות-החובה שלו מולאו**
@@ -122,13 +121,10 @@ relevance judgments, precision/recall) · RAG evaluation literature (Lewis et al
Elastic — *relevance evaluation guidance* | סטטוס: verified
**אכיפה:** eval harness עם gold-set מתויג + בדיקת-בריאות תקופתית; שער-CI על שינוי שכבת-האחזור.
אוכף את [G8](00-constitution.md#inv-g8-איכות-אחזור-נמדדת--precision--recall).
**הפרה ידועה — ✅ נסגרה (FU-5, GAP-11):** בעבר היה רק `telemetry.log_search_bg` (תצפית, לא
הערכה). **נסגר:** `scripts/eval_retrieval.py` מריץ את **מסלול-האחזור בייצור** (אותן פונקציות-
שירות שכלי-ה-MCP קוראים) מול gold-set מתויג (`data/eval/gold-set.jsonl`, נבנה ב-
`scripts/eval_gold_bootstrap.py`) ומחזיר precision/recall/MRR/nDCG@{5,10} — סך-הכול, פר-קורפוס
ופר-`practice_area` — מול baseline מקובע (`data/eval/baseline.json`). מטריקות-הליבה נבדקות
offline ב-`eval_retrieval.py --self-test` ("ALL PASS"). **משמעת-CI:** להריץ לפני/אחרי כל שינוי
שכבת-אחזור (משקלי-RRF/`k`/סף-chunk/embedder/rerank) ולהשוות ל-baseline. תיעוד-מקור: [audit](../audit-report.md).
**הפרה ידועה (GAP):** אין כיום eval harness ולא gold-set — קיים רק `telemetry.log_search_bg`
(`search.py:62,118,190,271`; `precedent_library.py:280`) שמתעד שאילתות בפועל, אך **אינו מודד
precision/recall מול תיוג** (תצפית, לא הערכה). היעד: harness שמריץ סט קבוע ומחזיר metrics →
ממצא ל-[audit](../audit-report.md).
### INV-RET5: כל span מוחזר עקיב למקורו
**כלל:** כל קטע מוחזר נושא **עקיבוּת-מקור מלאה** — מזהה-מסמך/פסק-דין (`case_law_id`/`document_id`/
@@ -160,15 +156,16 @@ re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף
ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול הקוד.
- **דליפת-הלכות חוצת-קורפוס (משימה #56) — ✅ נסגר (FU-4, GAP-10).** בעבר `halacha_filters` לא
כללו `cl.source_kind` (בניגוד ל-`chunk_filters`) → הלכה מהקורפוס הפנימי יכלה לחזור בחיפוש
בקורפוס החיצוני, ולהפך. **נסגר:** `halacha_filters` מתחילים כעת ב-`cl.source_kind = '{source_kind}'`
+ `cl.searchable = true` בדיוק כמו `chunk_filters` (`db.py:7514-7519`), בשתי הפונקציות
(semantic+lexical). מאומת ב-`test_precedent_corpus_isolation.py`
([INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)).
- **eval harness — ✅ נסגר (FU-5, GAP-11).** בעבר רק `telemetry.log_search_bg` (תצפית).
**נסגר:** `scripts/eval_retrieval.py` (precision/recall/MRR/nDCG מול `data/eval/gold-set.jsonl`
+ baseline; `--self-test` עובר offline) — מדידה אמפירית מול תיוג קבוע
- **דליפת-הלכות חוצת-קורפוס (משימה #56).** `halacha_filters` נפתחים רק עם `review_status`
(`db.py:3168`, `db.py:3401`) ואינם מוסיפים `cl.source_kind`, בעוד `chunk_filters` כן
(`db.py:3169`, `db.py:3402`). שני ה-`halacha_sql` עושים `JOIN case_law` בלי סינון
(`db.py:3236-3242`, `db.py:3463-3482`). **תסמין:** חיפוש בקורפוס החיצוני
(`search_precedent_library`, `source_kind="external_upload"`) יכול להחזיר הלכה שמקורה
בהחלטת-ועדה פנימית — ולהפך עבור `search_internal_decisions` (`source_kind="internal_committee"`,
`internal_decisions.py:418`). **יעד:** `halacha_filters` יתחילו ב-`cl.source_kind = '{source_kind}'`
בדיוק כמו `chunk_filters` ([INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)).
- **אין eval harness — מדידת-איכות לא קיימת.** רק `telemetry.log_search_bg` מתעד שאילתות
(`search.py:62,118,190,271`); אין gold-set מתויג ואין precision/recall. **יעד:** harness עומד
([INV-RET4](#inv-ret4-איכות-אחזור-נמדדת-ב-eval-harness-עומד-precision--recall)).
- **`search_decisions` מתעד אזהרה כשאין `practice_area` אך לא חוסם.** ללא פילטר-תחום החיפוש
עלול לערבב תחומים משפטיים (`search.py:45-49,172-176``logger.warning`, ממשיך). **יעד:** הפרדה

View File

@@ -47,79 +47,7 @@
> מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון
> שלו מקבל מענה בבלוק י.
> **עיצוב-מחדש זרימת-העבודה (WS2WS4) — מסלול-החילוץ אינו מתפצל ([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.11.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)
### 1.3 ניתוח-מחדש מאחד אחרי מסמך-עיקרי חדש (#201)
תיק מנותח לרוב מכתב-הערר לבד, ומסמך-עיקרי (תשובה/התנגדות/פרוטוקול…) מגיע מאוחר יותר.
כל מסמך נושא **דגל "לא-נותח"** (`claims_extracted_at`, [02-data-model §2ג](02-data-model.md)),
@@ -139,30 +67,6 @@ reanalyze_all_primary=False)` סוגר את הפער **בלי force-delete גו
ראה `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 משפטיים הוזים פסיקה 1733%, 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-WR1WR5](#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 הבלוקים (סיכום)

View File

@@ -111,14 +111,10 @@ Model / Single Source of Truth) · SSOT (Single Source of Truth principle) | ס
פלט מגורסן + idempotent מבחינת-תוכן; אוכף את
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) וכלל-ההנדסה
"סימטריה" (חוקה §6).
**הפרה ידועה — ✅ מגודרת (FU-7, GAP-17):** אחרי `revise_draft`/`apply_user_edit` ה-DOCX
המסומן `active_draft_path` משמש כאנקור לעריכות-Track-Changes העוקבות, ובלוקי-ה-DB אינם
מתעדכנים חזרה — סטייה אפשרית בין הבלוקים למסמך-החי. **התיקון שנבחר (חוזה מפורש, לא re-sync):**
`active_draft_path` הוא **אנקור-revision בלבד, לא מקור-תוכן מתחרה** — ה-DB נשאר מקור-האמת. סטייה
נלכדת בדגל `cases.blocks_stale` (V22, `db.py:1142-1148`): `revise_draft`/`apply_user_edit`
מסמנים `mark_blocks_stale(case_id, True)` (`drafting.py:688,789`) ו-`export_docx` מנקה
(`mark_blocks_stale(case_id, False)`, `drafting.py:504`) — הדגל הוא ה-drift-detection שחושף מתי
ה-DOCX-החי נסחף מהבלוקים. ראה [§5](#5-current-vs-target).
**הפרה ידועה:** אחרי `revise_draft`/`apply_user_edit`, ה-DOCX המסומן `active_draft_path` הופך
ל"מקור-האמת" לעריכות-Track-Changes העוקבות (`db.py:185-188`), ו**בלוקי-ה-DB אינם מתעדכנים
חזרה**הנתון-הנגזר זוחל למקור-אמת בפועל בלי סנכרון לאחור. **יעד:** או re-sync מהבלוקים, או
חוזה מפורש ש-`active_draft_path` הוא רק אנקור-revision ולא מקור-תוכן → ראה [§5](#5-current-vs-target).
### INV-EX2: עקיבוּת-מקור נשמרת בהחלטה המיוצאת (→G9)
**כלל:** ההחלטה המיוצאת **שומרת על עקיבוּת-מקור** היכן שנדרש — סמכויות-משפטיות מצוטטות
@@ -141,53 +137,29 @@ Lewis et al. (2020, NeurIPS — RAG attribution) | סטטוס: verified
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (controlled, auditable
output) · Council of Europe / CEPEJ (2018, under user control) · Federal Judicial Center —
*Judicial Writing Manual* (2d ed.) | סטטוס: verified
**אכיפה — ✅ hard-block בקוד (FU-6, GAP-15):** `export_docx` (`drafting.py:462`) **בודק לעצמו**
לפני כל ייצוא — `db.qa_run_exists` (אם QA לא רץ כלל → חסום) ו-`db.get_critical_qa_failures`
(אם יש כשל-קריטי → חסום) — **לפני** הגישה ל-`docx_exporter.export_decision`. אלו `SELECT` זולים
על ה-`qa_results` המאוחסנים (לא הרצת-LLM חוזרת). נוסף על `export_blocked = critical_failures > 0`
ברמת-הזרימה ועל משמעת-הסוכן `legal-exporter` (`.claude/agents/legal-exporter.md:71,149`) — כך
שאי-אפשר לעקוף את השער אפילו בקריאה ישירה ל-`export_docx`.
**הפרה ידועה — ✅ נסגרה (FU-6, GAP-15):** בעבר `export_docx` ניגש ישירות ל-exporter בלי לבדוק
`export_blocked` (אכוף-זרימה בלבד, ניתן-לעקיפה). נסגר ע"י ה-hard-block לעיל; מאומת ב-
`test_export_qa_gate.py` (חסום ללא-QA · חסום בכשל-קריטי · עובר כשנקי).
### INV-EX4: מקור-תבנית-יחיד — כל מפיק-מסמך מחיל סגנונות-טמפלט בלבד (→G2)
**כלל:** **כל** מפיק של מסמך-Word הקשור-להחלטה — סופי (`export_docx`), טיוטת-ביניים
(`export_interim_draft`), סיכום-מנהלים (`party-claims-summary`), ומסמך-הניתוח
(`build_analysis_docx`), וכן מסלול-העריכה (`revise_draft`/`apply_user_edit`
`docx_reviser`) — **חייב לטעון את הטמפלט היחיד** `skills/docx/decision_template.docx`
ולהחיל עיצוב **אך-ורק דרך שמות-הסגנונות שמוגדרים בו** (`paragraph.style = "…"` /
`<w:pStyle w:val="…"/>`). **אסור hand-setting** של משפחת-גופן (`rFonts`), גודל-גופן
(`sz`/`szCs`), או שוליים — אלה נגזרים מ-`styles.xml` של הטמפלט (cascade). היחיד שמותר
לכפות מפורשות הוא **יישור-הגוף הקנוני `jc=both`** היכן שהוא דורס סגנון שמגיע ממורכז
(`_set_paragraph_jc`, `docx_exporter.py:80`) — כי זו ההתנהגות-הקנונית, לא עיצוב-אד-הוק.
סמני-כיווניות (`<w:bidi>`, run `<w:rtl/>`) מותרים (כיווניות, לא עיצוב). **טמפלט אחד = מקור-סגנון
יחיד; אין שכבת-סגנון מקבילה.**
**מקורות:** SSOT (Single Source of Truth) · Martin Fowler — Canonical Data Model · ECMA-376
(OOXML) §17.7.2 — הורשת-סגנונות-פסקה (paragraph style inheritance / cascade) | סטטוס: verified
**אכיפה:** `docx_exporter` ו-`analysis_docx_exporter` טוענים `TEMPLATE_PATH` ומחילים style-by-name
(ראה §2); `docx_reviser` מחיל `pStyle` מן-הטמפלט + `jc=both` (לא פונט/גודל ידני). שומר-CI
`test_docx_template_single_source.py` גורף את מודולי-מפיקי-ה-DOCX ונכשל על `rFonts`/`sz`/`szCs`
ידני מחוץ לטוען-הטמפלט. אוכף את
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) בשכבת-הרינדור.
**הפרה ידועה — ✅ נסגרה:** `docx_reviser._build_paragraph` כפה `jc=right` + David/`sz` ידני על כל
פסקה שהוכנסה במסלול-העריכה (`revise_draft`/`apply_user_edit`) → פלט מסלול-העריכה איבד את היישור
הדו-צדדי של הטמפלט והכניס עיצוב-אד-הוק. נסגר ע"י החלת `pStyle` מן-הטמפלט; מאומת ב-
`test_docx_reviser.py` + `test_docx_template_single_source.py`.
**אכיפה:** `export_blocked = critical_failures > 0` (`qa_validator.py:362`); **נאכף ברמת-
הזרימה/agent בלבד** — הסוכן `legal-exporter` מחויב להריץ `validate_decision` ולבדוק
כשלים-קריטיים לפני ייצוא (`.claude/agents/legal-exporter.md:71,149`).
**הפרה ידועה:** `export_docx` (`drafting.py:384`) **אינו** קורא ל-`validate_decision` בעצמו —
הוא ניגש ישירות ל-`docx_exporter.export_decision` בלי לבדוק `export_blocked`. החסימה תלויה
במשמעת-הסוכן ואינה hard-block בקוד-הייצוא → ראה [§5](#5-current-vs-target) (תואם
[05-qa-review §4](05-qa-review.md#4-current-vs-target--ממצאי-audit)).
---
## 5. Current vs Target
- **שער-ייצוא — ✅ hard-block בקוד (INV-EX3 / INV-QA3, FU-6 / GAP-15).** `export_docx`
(`drafting.py:462-494`) בודק `db.qa_run_exists` + `db.get_critical_qa_failures` ודוחה לפני
הגישה ל-`docx_exporter.export_decision` — לא ניתן לעקוף בקריאה ישירה. מאומת ב-
`test_export_qa_gate.py` (3 מקרים: ללא-QA / כשל-קריטי / נקי).
- **`active_draft_path` כ-derived (INV-EX1, FU-7 / GAP-17) — ✅ מגודר בחוזה מפורש.** ה-DB נשאר
מקור-האמת; `active_draft_path` הוא אנקור-revision בלבד. ה-drift בין הבלוקים ל-DOCX-החי נלכד
בדגל `cases.blocks_stale`: נדלק ב-`revise_draft`/`apply_user_edit` (`drafting.py:688,789`),
כובה ב-`export_docx` (`drafting.py:504`). **שארית (low-pri):** health-check שמתריע על
`blocks_stale=true` עתיק — תיעוד-המשך, לא חוסם.
- **שער-ייצוא אכוף-זרימה ולא אכוף-קוד (INV-EX3 / INV-QA3).** אומת בקוד: `export_docx`
(`drafting.py:384-419`) קורא ישירות ל-`docx_exporter.export_decision` (`:403`) ללא קריאה
ל-`qa_validator.validate_decision` ובלי בדיקת `export_blocked`. החסימה מתקיימת רק כי הסוכן
`legal-exporter` מחויב להריץ QA קודם (`legal-exporter.md:71,149`) — אדם/סוכן שיקרא
ל-`export_docx` ישירות **יעקוף** את השער. **יעד:** hard-block בתוך `export_docx` — שליפת
`qa_results`/`export_blocked` ודחייה לפני כתיבת ה-DOCX, כך שאי-אפשר לעקוף.
- **`active_draft_path` כ-derived-שזוחל-למקור (INV-EX1).** ה-DOCX נגזר מהבלוקים בייצוא הראשון,
אך אחרי עריכה (`revise_draft`/`apply_user_edit`) ה-DOCX הופך ל"מקור-האמת" לעריכות הבאות
(`db.py:185-188`) בלי לעדכן את `decision_blocks` חזרה — סטייה אפשרית בין הבלוקים למסמך-החי.
**יעד:** חוזה מפורש — או re-sync מהבלוקים, או הגדרת `active_draft_path` כאנקור-revision בלבד
(לא מקור-תוכן), עם בדיקת-בריאות לגילוי drift בין הבלוקים ל-DOCX הפעיל.
---

View File

@@ -73,8 +73,6 @@
**הקריאה:** טבלת `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. הטבלה מתמלאת קדימה מהסופי הבא.
---

View File

@@ -59,16 +59,6 @@ Paperclip בקונפליקט (project-specific מנצח default), אך אינו
ל-`SKILL.md`/`lessons.md` — מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
- **company_id פר-סוכן.** כל שורה בטבלה מיוצגת פעמיים (CMP + CMPA); ה-CEO לכל חברה שונה
([X2 §1](X2-multi-company.md)). הסוכן פועל רק בטווח-החברה שלו ([X2 §2](X2-multi-company.md)).
- **שטן-מליץ (Gemini red-team) — שער-קבע אחרי הניתוח, לא חלק מ-7-הדומייניים.** סוכן
`legal-analyst-gemini-critique` (`gemini_local`, CMP+CMPA) רץ **אחרי** שלב-הניתוח של ה-Case
Analyst (Opus) ו**לפני** הכותב, כ**שער-קבע (standing gate)** שה-CEO מפעיל **אוטומטית** — לא
on-demand. הוא read-only ומפיק `critique-gemini.md` = **מזכר-לידים לא-סמכותי**. ה-CEO **עוצר
את הזרימה לאישור-יו"ר של הלידים** (ה-issue הראשי ל-`in_review`); רק לידים שהיו"ר מאשר מומרים
ל-`chair_directions` (דרך מנגנון-ההנחיות הקיים — `record_chair_feedback``get_chair_directions`
`approve_direction`), והכותב צורך **רק** את פלט-המנתח המעוגן + ההנחיות-המאושרות — **לעולם
לא** את הלידים הגולמיים. מקיים [INV-AG4](#inv-ag4-שער-שטן-מליץ--red-team-לידים-לא-סמכותיים-תחת-אישור-יור) +
[04-analysis-writing §1.6](04-analysis-writing.md). מקור-אמת לזרימה: [legal-ceo.md](../../.claude/agents/legal-ceo.md)
"שלב A2"/"שלב A3" + [legal-analyst-gemini-critique.md](../../.claude/agents/legal-analyst-gemini-critique.md).
### 2א. מפת-הרשאות (tool grants) — frontmatter מול הוראות
@@ -146,48 +136,9 @@ another company`, [X2 §2](X2-multi-company.md)).
**כלל:** ה-frontmatter `tools:` של כל סוכן מעניק **בדיוק** את הכלים שהוראותיו דורשות — כל כלי שההוראות
מצריכות מוענק, וכלי שמוענק-ולא-בשימוש נבחן. מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
(שערים מוגדרים) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים); מקביל ל-[X9 INV-TOOL6](X9-mcp-tool-contract.md).
> **ה-frontmatter הוא allow-list סגורה.** כלי הרשום בשרת-ה-MCP אך חסר מהרשימה **אינו ניתן לקריאה**
> ע"י הסוכן — גם כשהשרת מחובר לחלוטין. הסוכן חווה זאת כ"הכלים לא נחשפים לסשן", ובלי הבנת המנגנון
> הוא נוטה **לעקוף** (SQL ישיר, סקריפט מקומי) במקום לדווח — וזה מסלול מקביל, כלומר הפרת G2.
> **"מורים להריץ" כולל את ה-backend:** טקסט של issue שנוצר ב-`web/` ומכיל `mcp__legal-ai__X` הוא
> הוראה לכל דבר, ולכן מחייב הענקה.
**מקור-סמכות:** frontmatter `tools:` מול ה-instructions בקבצי-[.claude/agents/](../../.claude/agents/)
**ומול הוראות-ה-backend** ב-`web/`. (פרויקטלי-תפעולי.)
**אכיפה:****אוטומטית מ-2026-08-04** — [`scripts/agent_tool_grants_guard.py`](../../scripts/agent_tool_grants_guard.py),
שער-CI קשיח ([`.gitea/workflows/agent-tool-grants.yaml`](../../.gitea/workflows/agent-tool-grants.yaml)),
בדפוס [`leak_guard.py`](../../scripts/leak_guard.py) של G12. ארבעה כללים: (1) כל `mcp__legal-ai__X`
ב-`web/` מוענק לסוכן כלשהו · (2) כל `mcp__legal-ai__X` בגוף קובץ-סוכן מוענק **באותו** קובץ ·
(3) אין הענקה לכלי שאינו רשום בשרת · (4) שם-כלי בגרשיים-הפוכים ללא תחילית — מוענק, או מסווג
מפורשות ב-`CONTRASTIVE_OK` (אזכור ניגודי / מטלת-סוכן-אחר / שם-עמודה מתנגש). לא-סוכנים ולכן
מוחרגים: `hermes-curator.md` ו-`legal-analyst-gemini-critique.md` (בלי frontmatter בכוונה —
האדפטר שולח פרומפט גולמי) ו-`HEARTBEAT.md` (checklist משותף).
**הפרה ידועה:** — (היסטוריה: FU-13 ✅ 2026-06-06 — `aggregate_claims_to_arguments` ל-analyst;
`extract_references`/`extract_internal_citations` הם מטלת-researcher, ראה §2א. **הישנות 2026-08-04**
— האכיפה הידנית לא החזיקה: `analyze_protocol` (24e3e2f, 2026-06-30) נרשם בשרת בלי הענקה, ו-#226
הוסיף delegation שמורה למנתח להריץ אותו → CMP-229 נשרף בשתי הרצות ועקף ל-SQL ידני. נסגר יחד עם
`get_protocol_analysis`/`get_legal_arguments`/`get_appraiser_facts` ו-`precedent_library_list` ל-QA,
והאכיפה הועברה ל-CI כדי שלא תישען שוב על משמעת ידנית.)
### 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).
**מקור-סמכות:** frontmatter `tools:` מול ה-instructions בקבצי-[.claude/agents/](../../.claude/agents/). (פרויקטלי-תפעולי.)
**אכיפה:** בדיקת-עקביות tools↔instructions (FU-13 ✅ 2026-06-06). אכיפה אוטומטית עתידית — בתת-פרויקט 5 (spec-guardian).
**הפרה ידועה:** — (טופל ב-FU-13: legal-analyst קיבל `aggregate_claims_to_arguments`; researcher כבר היה תקין; `extract_references`/`extract_internal_citations` הם מטלת-researcher, לא analyst — ראה §2א).
---

View File

@@ -105,36 +105,13 @@ TanStack Query — *Important Defaults* (staleTime/refetch) (https://tanstack.co
**אכיפה:** `halacha_backlog` מרונדר ב-/operations (לא נזרק); `findings_approved` (review_status, צרכן אמיתי)
החליף את `findings_applied` (דגל מת); מוני-סוכנים מסמנים "חלקי" כשחברה לא-נטענה (גל-1 #130).
### INV-UI9: זמן-מאוחסן ב-UTC · תצוגה אנושית דטרמיניסטית ב-Asia/Jerusalem
**כלל:** **האחסון נשאר UTC** מקצה-לקצה — Postgres `TIMESTAMPTZ`, וה-API מחזיר ISO-UTC; אין נרמול-tz
בשכבת-האחסון/ה-API. **שכבת-התצוגה בלבד** ממירה, ובאופן **דטרמיניסטי**: כל חותמת-זמן אנושית מרונדרת
ב-`timeZone: "Asia/Jerusalem"` (locale `he-IL`), בלי תלות ב-tz של ה-host/קונטיינר/דפדפן. כיוון
שהקונטיינר רץ ב-UTC, פירמוט ad-hoc (`new Date(iso).toLocaleDateString("he-IL")`, `.getHours()`,
`toISOString().slice(0,10)`) מציג UTC תחת SSR (ושוב UTC בכל runtime לא-ישראלי) → drift. נעיצת
`Asia/Jerusalem` מבטיחה ש-SSR והדפדפן מסכימים. **פורמט-התצוגה (צורת התאריך/שעה) נשמר זהה — רק ה-tz
מנורמל.** מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
(מפרמט-תאריך **יחיד**, אין פירמוט-זמן מקביל-מפוזר) בשכבת-התצוגה.
**מקורות:** ECMAScript Internationalization API — `Intl.DateTimeFormat` `timeZone` option
(https://tc39.es/ecma402/#datetimeformat-objects) · MDN — *Intl.DateTimeFormat* (IANA time zones)
(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) ·
Next.js — *Rendering* (SSR משתמש בשעון-השרת אם לא ננעץ)
(https://nextjs.org/docs/app/building-your-application/rendering) | סטטוס: verified
**אכיפה:** מפרמט-תאריך יחיד ב-[web-ui/src/lib/format-date.ts](../../web-ui/src/lib/format-date.ts)
(`formatDate`/`formatDateShort`/`formatDateLong`/`formatDateTime`/`formatDateTimeFull`/`formatTime`/
`formatIsoDate`/`formatRelative` + עוזרי-Israel `getIsraelYear`/`israelDayKey`/`israelMidnightMs`/
`israelParts`), כולם נעוצים ל-`Asia/Jerusalem`; כל משטחי-ה-UI מייבאים ממנו ולא מפרמטים tz ad-hoc.
פירמוט-**מספרים** (`n.toLocaleString`) מחוץ-לתחום (אינו חותמת-זמן). **חריג מוצהר:** קלט
`<input type="datetime-local">` (בקרת-burst ב-`/operations`) הוא ערך הניתן-לכתיבה ע"י המפעיל, לא תצוגת-
מאוחסן — נשאר בנייה מקומית. **אודיט-נלווה (לא מיושם):** הגדרת `TZ=Asia/Jerusalem` בקונטיינר בטוחה רק
אם אף לוגיקה עסקית אינה תלויה בשעון-ה-process כ-UTC.
---
## 3. כללי-עיצוב (Design Rules) — נגזרים מה-invariants
- **SSoT ל-enums/תוויות/tones:** כל enum (CaseStatus, PracticeArea, AppealSubtype, DocType, outcome) +
תוויותיו + צבעיו מוגדרים **פעם אחת** ונצרכים מיבוא — לא משוכפלים בין דפים/רכיבים (מופע UI1/G2).
- **helpers משותפים:** פירמוט-תאריך (מפרמט-יחיד נעוץ-Asia/Jerusalem ב-[format-date.ts](../../web-ui/src/lib/format-date.ts),
INV-UI9), builder ל-FormData (העלאות), רכיב-שגיאה, query-config (intervals) — משותפים, לא מועתקים.
- **helpers משותפים:** פירמוט-תאריך, builder ל-FormData (העלאות), רכיב-שגיאה, query-config (intervals) —
משותפים, לא מועתקים.
- **חוזי-טופס:** ראה INV-UI6 ([X8](X8-field-provenance.md)).
הממצאים הקונקרטיים (כפילויות, הגדרות-שגויות, redundancy) ב-[ui-audit.md](ui-audit.md); התיקון — **FU-10**.

View File

@@ -232,25 +232,3 @@ FU-7 אחרי FU-1; FU-5 אחרי FU-2; FU-8 אחרי ייצוב-הספ. **(מח
אחרי FU-1. FU-11 (מילוי-שדות) עצמאי. FU-13 (סוכנים+skills) אחרי ייצוב-הספ.
**סיווג:** pure-code — FU-9/10/11/13/14; +data-migration קל — FU-12; +chair-decision — FU-15 (rotation סודות).
priority בפועל — של היו"ר.
---
## ✅ ווידוא-סגירה — 7 דליפות עיצוב-מחדש זרימת-העבודה (WS7, #207, 2026-06-30)
תת-פרויקט עיצוב-מחדש זרימת-העבודה (תוכנית `groovy-doodling-token`, WS7) דרש סגירת **7 דליפות
ידועות** תוך-כדי. ביקורת חוצת-קוד (#207) אימתה שכל ה-7 כבר **נסגרו במחזורים קודמים** (בעיקר
FU-1/4/5/6/7 + גלי-ה-IA), ושהספ נשא תיאורי-הפרה מיושנים. ה-PR מיישר את הספ למצב-הקוד בפועל
ומאמת כל סגירה בבדיקה. **אין כאן GAP/FU חדש — זו ביקורת-יישור, לא פתרון-מחדש (G2).**
| # | דליפה | invariant | סטטוס | FU | סגירה (קוד) | בדיקה |
|---|-------|-----------|-------|-----|-------------|-------|
| 1 | הלכות חוצות-קורפוס (#56) | INV-RET1/G5 (GAP-10) | ✅ סגור | FU-4 | `cl.source_kind` ב-`halacha_filters` בשתי הפונקציות (`db.py:7516,7519`) | `test_precedent_corpus_isolation.py` |
| 2 | backlog-הלכות בלתי-נראה | INV-QA1/G10 (GAP-14) | ✅ סגור | FU-5 | health `halacha_backlog` (`app.py:2352-2364`) + כלי `extraction_status`/`halachot_pending` | — (health-endpoint) |
| 3 | אין eval-harness | INV-RET4/G8 (GAP-11) | ✅ סגור | FU-5 | `scripts/eval_retrieval.py` (P/R/MRR/nDCG מול gold-set+baseline) | `eval_retrieval.py --self-test` ("ALL PASS") |
| 4 | שער-ייצוא לא hard-block | INV-EX3/QA3/G10 (GAP-15) | ✅ סגור | FU-6 | `export_docx` בודק `qa_run_exists`+`get_critical_qa_failures` לפני exporter (`drafting.py:462-494`) | `test_export_qa_gate.py` |
| 5 | מטא במסלול-פנימי | INV-ING3/G2 (GAP-02) | ✅ סגור | FU-1 | מסלול קנוני `ingest.ingest_document` מתזמן metadata+halacha יחד (`ingest.py:233-234`) | `test_unified_ingest.py` |
| 6 | DOCX זוחל למקור-אמת | INV-EX1/G2 (GAP-17) | ✅ מגודר | FU-7 | חוזה: `active_draft_path`=אנקור בלבד; drift בדגל `cases.blocks_stale` (V22) | (חוזה+דגל; שארית health-check low-pri) |
| 7 | cache-invalidation ב-UI | INV-UI5/IA (GAP-33) | ✅ סגור | FU-10/IA | `qc.invalidateQueries` על מוטציות (`web-ui/src/lib/api/*.ts`) + גלי-IA #130-132 | (TanStack Query) |
**שאריות (low-pri, מתועדות לא-חוסמות):** דליפה #6 — health-check שמתריע על `blocks_stale=true`
עתיק (הדגל קיים ונאכף; חסר רק התרעת-נראות). אינה חוסמת את WS7; ניתן ל-FU-7-המשך.

View File

@@ -1,61 +0,0 @@
# מיגרציה: TaskMaster → Gitea Issues
**תאריך: 2026-08-05.** ניהול המשימות של legal-ai עבר מ-TaskMaster AI ל-[Gitea Issues](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues), לפי המוסכמות של [`infra/project-template`](https://gitea.marcus-law.co.il/infra/project-template).
## מה השתנה
- **מקור-האמת לעבודה = Gitea Issues.** לא TaskMaster, לא `CLAUDE.md`, לא זיכרון.
- **`.taskmaster/tasks/tasks.json` הוקפא** — נשמר לקריאה-בלבד כארכיון היסטורי (166 משימות שהושלמו + 4 שבוטלו). אין לכתוב אליו ואין ליצור בו משימות חדשות.
- **26 המשימות הפתוחות** הועברו ל-issues **#433#458**, כל אחת עם `type` / `priority` / `area` / `status`, וגוף במבנה *What & Why → הקשר → Acceptance Criteria*.
- **30 תוויות** נוצרו בריפו: 5 שכבות (`type`/`priority`/`status`/`size`/`area`). שכבות `type`/`priority`/`status`/`size` זהות לתבנית; שכבת `area` הותאמה לתחומי legal-ai.
## ⚠️ מלכודת מספור
ב-Gitea, issues ו-PRs חולקים מרחב-מספרים אחד. מספרי ה-issue של המיגרציה מתחילים ב-**#433** כי #1#432 כבר תפוסים (2 issues ישנים + כל ה-PRs).
**לכן: כל הפניה `(#N)` בהודעת-commit מלפני 2026-08-05 מתייחסת למזהה TaskMaster — לא למספר issue.** למשל `fix(retrieval): ... (#232)` מפנה ל-TaskMaster #232, בעוד #232 ב-Gitea הוא PR ישן. הטבלה למטה היא הגשר.
## טבלת המיפוי
| TaskMaster | Gitea | תוויות | כותרת |
|---|---|---|---|
| #14 | [#433](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/433) | type:chore · priority:p3-low · status:deferred | Upgrade: speed up halacha+metadata extraction |
| #116 | [#434](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/434) | type:chore · priority:p3-low · status:deferred | P2 (אופציונלי) — resume תוך-פאנל: פירוק הפאנל ל-map per-item |
| #121 | [#435](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/435) | type:spike · priority:p1-high · status:needs-decision | מיפוי כל שערי-האישור-האנושי (חיים/דפנה) והכרעה פר-מקום: להחליף ב"קונצנזוס תלת-מודלי ללא-אדם"? |
| #124 | [#436](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/436) | type:feature · priority:p3-low · status:ready | קטלוג עלון חודשי "עו"ד על נדל"ן" — פיצול ל-N רשומות digest (design-open) |
| #125 | [#437](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/437) | type:chore · priority:p1-high · status:in-progress | אופרציונליזציה של לולאת רכישת-הסגנון — סגירת ממצאי אודיט #122 |
| #126 | [#438](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/438) | type:chore · priority:p2-normal · status:in-progress | ניקוי persona "Hermes" → Curator + כיבוי self-learning אינרטי בפרופילי ה-curator |
| #128 | [#439](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/439) | type:chore · priority:p2-normal · status:ready | X14 finalize: ניקוי-דיסק אחרי soak + replication off-site מתוזמן (SNSD) |
| #129 | [#440](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/440) | type:chore · priority:p2-normal · status:ready | INV-STG1 Phase 2: read-wire ה-pipeline ל-ensure_local → כתיבת-בלוב ל-S3 בלבד (ללא דיסק) |
| #147 | [#441](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/441) | type:bug · priority:p3-low | חילוץ-הלכות: אטומיות-מלאה ל-force re-extract (extract-then-swap / staging_run_id) |
| #150 | [#442](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/442) | type:feature · priority:p2-normal | משיכת פרטי-תב"ע אוטומטית למרשם-התכניות (V38) + עיון בתכניות-מאושרות |
| #154 | [#443](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/443) | type:feature · priority:p1-high | X11 Phase 2 — חיווט סמכות-תקדים מודעת-טיפול לסוכני-המחקר |
| #155 | [#444](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/444) | type:bug · priority:p1-high | באג עמידות: ריצת-heartbeat כפולה על אותו issue דורסת טיוטת-קובץ תוך כדי כתיבה |
| #163 | [#445](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/445) | type:bug · priority:p2-normal | חקירת אנומליית chunking — פסיקות עליון ארוכות מקבלות chunk יחיד → 0 הלכות |
| #209 | [#446](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/446) | type:bug · priority:p3-low | Plugin status-sync מכוון ל-issue-ילד ומחזיר done→in_progress |
| #217 | [#447](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/447) | type:chore · priority:p2-normal | בלוק-י: צמצום-הקשר ממוקד-רלוונטיות (איכות + מניעת-דחיסה) |
| #218 | [#448](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/448) | type:feature · priority:p1-high · status:in-progress | Escalation primitive מדורג-חומרה בשער-הפלטפורמה (השראת Gastown gt escalate) |
| #219 | [#449](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/449) | type:feature · priority:p1-high · status:in-progress | טלמטריה מובנית (OpenTelemetry) על שכבת-הסוכנים (השראת Gastown OTel) |
| #220 | [#450](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/450) | type:feature · priority:p2-normal · status:in-progress | Seance — תשאול סשן-קודם לצמצום re-work של blind-heartbeats (השראת Gastown gt seance) |
| #221 | [#451](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/451) | type:feature · priority:p2-normal · status:deferred | Molecules — פייפליין 12-הבלוקים כתבנית הצהרתית עם checkpoint-recovery (השראת Gastown formulas) |
| #222 | [#452](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/452) | type:feature · priority:p2-normal · status:in-progress | טקסונומיית-בריאות מפורשת לסוכן ב-dashboard (Zombie/Stalled/Working/Idle — השראת Gastown gt feed --problems) |
| #225 | [#453](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/453) | type:spike · priority:p2-normal · status:needs-decision | בית-אב לדוקטרינה-משפטית של דפנה (chair legal-doctrine home) — תכנון בלבד |
| #228 | [#454](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/454) | type:bug · priority:p1-high | תשובת-interaction/הערה על issue בבעלות-אדם לא מעירה את ה-CEO — לנתב דרך פרימיטיב CEO-child (#227) |
| #229 | [#455](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/455) | type:bug · priority:p1-high | block_writer שומר טקסט-שיחה של המודל לתוך תוכן הבלוק — נדרש שער-ניקוי בכתיבה |
| #230 | [#456](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/456) | type:bug · priority:p2-normal | extractor._text_quality_ok לא מזהה סדר-קריאה RTL משובש ב-PDF דיגיטלי |
| #231 | [#457](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/457) | type:bug · priority:p1-high · status:ready | סוכני Paperclip מנותקים משרת ה-MCP legal-ai — ACP הפך לברירת-מחדל ב-claude_local |
| #233 | [#458](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/458) | type:bug · priority:p1-high · status:ready | צבירת-הטיעונים מוחקת צד שלם בשקט — 310 טענות עוררים → 0 טיעונים, status ok |
## משימות שנסגרו בזמן המיגרציה (לא הועברו)
| TaskMaster | למה נסגרה |
|---|---|
| #223 | PR #393 מוזג — בחירת פרוטוקול לפי `protocol_scope`. אומת ב-1069-04-26. |
| #224 | PRs #392 + #403 מוזגו — `claims.party_name` במקור + פיצול משיבים. אומת ב-1069-04-26. |
| #232 | PR #431 מוזג — 104 החלטות ועדות-ערר היו מסוננות מהחיפוש (סינון בברירת-מחדל). מלכודת 3 נסגרה ב-PR #432. |
## מה עדיין דורש עבודה ידנית
- **`size:` לא הוקצה** לאף issue — TaskMaster לא החזיק הערכות-גודל, ולא רציתי להמציא אותן. לפי כלל התבנית, issue ללא AC+size אינו `status:ready`.
- **21 מתוך 26 issues ללא Acceptance Criteria** — הן הועברו מ-TaskMaster בלי `testStrategy`, וגוף ה-issue מסמן זאת במפורש. לפני לקיחת issue כזה — להגדיר AC, להקצות `size`, ולסמן `status:ready`.
- 5 שכן קיבלו `status:ready` (היה להן `testStrategy`): #436, #439, #440, #457, #458.

View File

@@ -1,118 +0,0 @@
"""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()

View File

@@ -9,18 +9,13 @@ from uuid import UUID
from pydantic import BaseModel, Field
# Core case lifecycle. The canonical key set, order, labels, phases and (future)
# per-status actions all live in ONE place — legal_mcp/case_status_model.py —
# which asserts this enum matches it. The frontend mirror is
# web-ui/src/lib/api/case-status.ts (generated against GET /api/status-model).
# The analyst/research intermediate states are first-class canonical statuses
# (the agents set them) — not legacy.
# Core case lifecycle — kept in sync with STATUS_ORDER in tools/cases.py and the
# frontend SSoT web-ui/src/lib/api/case-status.ts. Trimmed from 17 → 10 (the
# decorative mid-stage markers that no pipeline code ever set were removed).
class CaseStatus(str, enum.Enum):
NEW = "new"
PROCESSING = "processing"
DOCUMENTS_READY = "documents_ready"
ANALYST_VERIFIED = "analyst_verified"
RESEARCH_COMPLETE = "research_complete"
OUTCOME_SET = "outcome_set"
DIRECTION_APPROVED = "direction_approved"
QA_REVIEW = "qa_review"

View File

@@ -6,7 +6,6 @@ Run with: python -m legal_mcp.server
from __future__ import annotations
import logging
import os
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@@ -42,56 +41,11 @@ async def lifespan(server: FastMCP) -> AsyncIterator[None]:
logger.info("Ezer Mishpati MCP server stopped")
# HTTP listener address, used only when MCP_TRANSPORT selects an HTTP transport.
#
# These MUST be passed to the constructor rather than left to FastMCP's own
# FASTMCP_HOST / FASTMCP_PORT environment settings: FastMCP.__init__ declares
# `host: str = "127.0.0.1"` and `port: int = 8000` as explicit keyword defaults
# and forwards them into Settings(**settings). In pydantic-settings, explicit
# init kwargs outrank environment variables — so FASTMCP_PORT is silently
# ignored and the server binds 8000 regardless (verified 2026-08-05; on this
# host 8000 is already taken, so it failed loudly by luck rather than design).
#
# Default to loopback. The bearer gate below is the real protection; the narrow
# bind is defence in depth, not a substitute for it.
MCP_HTTP_HOST = os.environ.get("MCP_HTTP_HOST", "127.0.0.1")
MCP_HTTP_PORT = int(os.environ.get("MCP_HTTP_PORT", "8790"))
# Bearer gate — wired only when an HTTP transport is actually selected (#231.2).
#
# stdio must never require a token: the pipe is the boundary there, and every
# interactive session reaches us that way. Demanding a token on stdio would
# break all of them for no security gain.
#
# Building the verifier at import time (rather than inside main()) is deliberate:
# FastMCP takes `token_verifier` and `auth` as constructor arguments, so a
# missing token has to fail here — before the listener exists — not after it is
# already accepting connections.
_http_transport = os.environ.get("MCP_TRANSPORT", "stdio").strip() in ("sse", "streamable-http")
_auth_kwargs: dict = {}
if _http_transport:
from mcp.server.auth.settings import AuthSettings
from legal_mcp.services.http_auth import StaticTokenVerifier, load_token
_base_url = f"http://{MCP_HTTP_HOST}:{MCP_HTTP_PORT}"
_auth_kwargs = {
"token_verifier": StaticTokenVerifier(load_token()),
# AuthSettings is what switches on the SDK's BearerAuthBackend. We are a
# resource server with a pre-shared token, not an OAuth client, so both
# URLs simply point at ourselves — they exist to satisfy the protected-
# resource metadata contract, and nothing issues tokens from them.
"auth": AuthSettings(issuer_url=_base_url, resource_server_url=_base_url),
}
# Create MCP server
mcp = FastMCP(
"Ezer Mishpati - עוזר משפטי",
instructions="מערכת AI לסיוע בניסוח החלטות משפטיות בסגנון דפנה תמיר",
lifespan=lifespan,
host=MCP_HTTP_HOST,
port=MCP_HTTP_PORT,
**_auth_kwargs,
)
# ── Import and register tools ───────────────────────────────────────
@@ -802,29 +756,6 @@ async def get_appraiser_facts(case_number: str) -> str:
return await drafting.get_appraiser_facts(case_number)
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
@mcp.tool()
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד.
בוחר אוטומטית פרוטוקול ועדת-ערר (scope שאינו 'lower'); document_id מכוון לדיון
מדויק כשיש כמה פרוטוקולים. מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול
doc_type='protocol' + טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high;
re-run מחליף (idempotent).
"""
return await drafting.analyze_protocol(case_number, document_id=document_id)
@mcp.tool()
async def get_protocol_analysis(case_number: str, change_type: str = "") -> str:
"""קריאת ניתוח-הפרוטוקול שכבר חולץ — ללא ניתוח-מחדש. ה-get המקביל ל-analyze_protocol.
change_type: dropped/strengthened/newly_raised (ריק=הכל).
"""
return await drafting.get_protocol_analysis(case_number, change_type)
# ── Planning-schemes registry (V38) — מרשם-התכניות ─────────────────
# SSOT לזהות+תוקף של תכנית, נעשה שימוש חוזר בין תיקים (G2). פלט-LLM נכנס
# pending_review וממתין לאישור-יו"ר (plan_review, G10) לפני שמשמש בבלוק ט.
@@ -895,12 +826,6 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
return await drafting.export_interim_draft(case_number, output_path)
@mcp.tool()
async def summarize_party_claims(case_number: str, instructions: str = "") -> str:
"""סיכום-מנהלים מזוקק של טענות הצדדים — מסמך פרוזה נפרד מטיוטת-ההחלטה, להכנת היו"ר לדיון. נגזר מ-legal_arguments/claims (אותו מקור), נעוץ Opus 4.8, נשמר וניתן-לייצוא."""
return await drafting.summarize_party_claims(case_number, instructions)
@mcp.tool()
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""
@@ -1306,44 +1231,7 @@ async def corroboration_rebuild(case_law_id: str = "") -> dict:
def main():
"""Run the server on the transport named by ``MCP_TRANSPORT`` (default stdio).
ONE server, two transports — deliberately not a second implementation (G2).
The tool registry, services and DB pool above are shared verbatim; only the
wire protocol differs, so a tool can never exist on one transport and not
the other.
- ``stdio`` (default) — the historical path. Every interactive Claude Code
session reaches us this way via the ``legal-ai`` entry in ``~/.claude.json``.
Changing this default would break them all, so it stays the default.
- ``streamable-http`` — required by the agent-platform port. Agents driven
over the Agent Client Protocol receive their MCP servers from the *client*
at session start, and that channel accepts HTTP servers only: each is
injected as ``{type:"http", name, url, headers:[Bearer]}``. A stdio server
has no path into such a session at all, which is why platform-driven agents
lost all 108 tools when their execution engine changed — TaskMaster #231.
Platform-side wiring lives behind the port (``web/agent_platform_port.py``),
not here; this module only has to be reachable over HTTP.
Host/port come from FastMCP's own ``FASTMCP_HOST`` / ``FASTMCP_PORT`` settings.
Bind to loopback only: this transport carries no authentication yet, and the
registry includes destructive tools (``case_delete``, ``precedent_library_delete``).
The Bearer gate is #231.2 and MUST land before this is reachable off-host.
"""
transport = os.environ.get("MCP_TRANSPORT", "stdio").strip() or "stdio"
valid = ("stdio", "sse", "streamable-http")
if transport not in valid:
# Fail loudly — a typo must not silently fall back to stdio and leave
# the HTTP listener absent while everything "looks" fine (§6).
raise SystemExit(
f"MCP_TRANSPORT={transport!r} is not one of {valid}",
)
if transport != "stdio":
logger.info(
"Serving MCP over %s on %s:%s",
transport, mcp.settings.host, mcp.settings.port,
)
mcp.run(transport=transport)
mcp.run(transport="stdio")
if __name__ == "__main__":

View File

@@ -182,21 +182,16 @@ def _add_runs_with_inline_bold(paragraph: Paragraph, text: str) -> None:
def _clear_body(doc: DocumentT) -> None:
"""Remove ALL body content (paragraphs, tables, stray bookmarks),
keeping only sectPr.
"""Remove every paragraph currently in the document body.
The template ships with example paragraphs ("רקע", "דיון והכרעה"…) *and
three sample tables* (header / panel / signatures). Removing only ``w:p``
left the tables behind — including the signatures table, which then floated
to the top against the header table. Mirror the decision exporter's
``_clear_body`` (G2 symmetry): drop everything except sectPr, which carries
page setup including bidi.
The template ships with example paragraphs ("רקע", "דיון והכרעה"…)
that we don't want in the output. Section properties (sectPr) are
kept so page size / margins / RTL / footer remain intact.
"""
body = doc.element.body
sectPr_tag = qn("w:sectPr")
for child in list(body):
if child.tag != sectPr_tag:
body.remove(child)
for p in list(body.findall(qn("w:p"))):
body.remove(p)
# Leave sectPr alone — it carries page setup including bidi.
def _add_paragraph(doc: DocumentT, text: str, style: str) -> Paragraph:
@@ -391,9 +386,9 @@ def _group_precedents(
return case_level, by_section
def _next_version(export_dir: Path, prefix: str = "ניתוח-משפטי") -> int:
"""Return the next version number for {prefix}-v{N}.docx in export_dir."""
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
def _next_version(export_dir: Path) -> int:
"""Return the next version number for ניתוח-משפטי-v{N}.docx."""
existing = sorted(export_dir.glob("ניתוח-משפטי-v*.docx"))
next_ver = 1
for p in existing:
try:
@@ -516,77 +511,3 @@ async def build_analysis_docx(case_number: str) -> Path:
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
return out_path
# ── Generic markdown → DOCX (party-claims summary, #202) ───────────
# ATX-heading markers → template heading style. The party-claims summary is a
# plain markdown document (not the structured analysis-and-research.md), so it
# is rendered line-by-line through the same template machinery.
_ATX_HEADING_RE = re.compile(r"^(#{1,4})\s+(.+)$")
_HR_RE = re.compile(r"^\s*[-*_]{3,}\s*$")
def _render_markdown_body(doc: DocumentT, markdown: str) -> None:
"""Render a markdown string into the doc using the template styles.
Handles ATX headings (# .. ####), horizontal rules (skipped), and delegates
every other line to _emit_content_line (bullets, numbered, bold-labels, plain).
"""
for raw in markdown.splitlines():
line = raw.rstrip()
if not line.strip():
continue
if _HR_RE.match(line):
continue
m = _ATX_HEADING_RE.match(line.strip())
if m:
level = len(m.group(1))
# # → Heading 1 (doc title), ## → Heading 1, ### / #### → Heading 2.
style = "Heading 1" if level <= 2 else "Heading 2"
_add_paragraph(doc, m.group(2).strip(), style)
continue
_emit_content_line(doc, line)
async def build_party_claims_summary_docx(case_number: str) -> Path:
"""Build a DOCX of the party-claims executive summary using the template
styles, saved versioned under the case's exports folder.
Reads the saved party-claims-summary.md (produced by the summarize_party_claims
MCP tool — generation is local-only). Raises FileNotFoundError if the summary
or the template is missing.
"""
from legal_mcp.services import party_claims_summary
if not TEMPLATE_PATH.exists():
raise FileNotFoundError(
f"Template not found at {TEMPLATE_PATH}. "
"Run: python scripts/convert_decision_template.py"
)
summary_path = party_claims_summary.summary_file_path(case_number)
if not summary_path.exists():
raise FileNotFoundError(
f"Party-claims summary not found for case {case_number}. "
"Run summarize_party_claims first."
)
markdown = summary_path.read_text(encoding="utf-8")
doc = Document(str(TEMPLATE_PATH))
_clear_body(doc)
_render_markdown_body(doc, markdown)
export_dir = config.find_case_dir(case_number) / "exports"
export_dir.mkdir(parents=True, exist_ok=True)
_PREFIX = "סיכום-מנהלים-טענות"
out_path = export_dir / f"{_PREFIX}-v{_next_version(export_dir, _PREFIX)}.docx"
buf = io.BytesIO()
doc.save(buf)
await storage.put_bytes(
out_path.relative_to(config.DATA_DIR).as_posix(), buf.getvalue(),
bucket=storage.Bucket.DOCUMENTS,
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
return out_path

View File

@@ -31,10 +31,6 @@ logger = logging.getLogger(__name__)
# Allowed enum values mirror the DB CHECK constraints.
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
# Sides that may comprise multiple distinct litigants with potentially opposing
# positions — aggregated per source pleading (party_name) rather than collapsed
# into one bucket (#224). Appellant/committee speak with a single voice.
SPLIT_PARTIES = {"respondent", "permit_applicant"}
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
# Hebrew labels for the prompt (Claude needs context in the same
@@ -85,7 +81,7 @@ AGGREGATE_PROMPT_TEMPLATE = """אתה מנתח כתבי טענות בתחום ת
"""
def _build_prompt(party: str, propositions: list[dict], party_name: str = "") -> str:
def _build_prompt(party: str, propositions: list[dict]) -> str:
"""Compose the per-party aggregation prompt."""
n = len(propositions)
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
@@ -93,11 +89,6 @@ def _build_prompt(party: str, propositions: list[dict], party_name: str = "") ->
target_max = max(target_min + 1, min(12, n // 2 + 1))
party_he = PARTY_LABELS_HE.get(party, party)
# For a split side (e.g. a specific respondent brief), name the brief so
# Claude aggregates only that litigant's position and does not conflate it
# with a co-respondent's separate — possibly opposing — pleading (#224).
if party_name:
party_he = f"{party_he}{party_name}"
# Strip noise from propositions for the prompt — Claude only needs
# the id and the text to do the grouping.
compact = [
@@ -147,68 +138,13 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
}
class AggregationFailed(RuntimeError):
"""One side's aggregation call failed. Never swallowed — see #233."""
#: Largest number of propositions sent to Claude in a single aggregation call.
#:
#: Above roughly this size the model stops returning a JSON array and the whole
#: side is lost. The per-brief split added for the respondent side (#224) hid
#: this by accident — it kept those calls small — but appellant and committee
#: speak with a single voice and are never split by brief, so a big appeal goes
#: out in one oversized call. 1069-04-26 sent 310 propositions and got nothing
#: back (#233).
#:
#: Chunking is a real trade-off, not a free win: each chunk is grouped in
#: isolation, so a side split across chunks can end up with more (and slightly
#: overlapping) arguments than a single pass would have produced. Losing an
#: entire litigant is worse, and the alternative — a smaller prompt per
#: proposition — would degrade every case to fix the large ones.
MAX_PROPS_PER_CALL = 80
def _chunk(propositions: list[dict], size: int) -> list[list[dict]]:
"""Split propositions into calls of at most ``size``, keeping claim order.
Order matters: claims arrive sorted by ``claim_index``, so neighbouring
propositions usually belong to the same head of argument. Slicing in order
keeps related material together instead of scattering one argument across
chunks.
"""
return [propositions[i:i + size] for i in range(0, len(propositions), size)]
async def _aggregate_party(
party: str, propositions: list[dict], party_name: str = "",
party: str, propositions: list[dict],
) -> list[dict]:
"""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).
Sides larger than ``MAX_PROPS_PER_CALL`` are aggregated in several calls and
concatenated; a failure in any chunk raises rather than returning a partial
side quietly.
"""
"""Ask Claude to group one party's propositions; return normalized rows."""
if not propositions:
return []
if len(propositions) > MAX_PROPS_PER_CALL:
chunks = _chunk(propositions, MAX_PROPS_PER_CALL)
logger.info(
"argument_aggregator: party '%s'%s has %d propositions — "
"aggregating in %d calls of up to %d",
party, f" ({party_name})" if party_name else "",
len(propositions), len(chunks), MAX_PROPS_PER_CALL,
)
out: list[dict] = []
for chunk in chunks:
out.extend(await _aggregate_party(party, chunk, party_name=party_name))
return out
prompt = _build_prompt(party, propositions, party_name=party_name)
prompt = _build_prompt(party, propositions)
try:
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
@@ -221,18 +157,11 @@ async def _aggregate_party(
) from e
if not isinstance(raw_result, list):
# NOT a silent []: returning empty here is indistinguishable from "this
# side genuinely has no arguments", and the caller would then report
# status=completed while a whole litigant vanished. That is exactly what
# happened to the appellant side of 1069-04-26 — 310 claims in, 0
# arguments out, "completed" (#233). Raise so the caller records it in
# ``errors`` and the status degrades to completed_with_errors (§6).
raise AggregationFailed(
f"Claude returned {type(raw_result).__name__}, not a list, for party "
f"'{party}'{f' ({party_name})' if party_name else ''} with "
f"{len(propositions)} propositions — the side would otherwise be "
f"dropped without trace",
logger.warning(
"argument_aggregator: Claude returned non-list (%s) for party '%s'",
type(raw_result).__name__, party,
)
return []
out: list[dict] = []
for entry in raw_result:
@@ -278,7 +207,7 @@ async def aggregate_claims_to_arguments(
# Pull all claims for this case, grouped by party.
rows = await conn.fetch(
"""SELECT id, party_role, claim_text, claim_index, source_document, party_name
"""SELECT id, party_role, claim_text, claim_index, source_document
FROM claims
WHERE case_id = $1
ORDER BY party_role, claim_index""",
@@ -292,28 +221,14 @@ async def aggregate_claims_to_arguments(
"total": 0,
}
# Group propositions by party — and, for the multi-party sides (respondent /
# permit_applicant), further by their source pleading so opposing joint
# briefs (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") aggregate into
# SEPARATE argument sets instead of collapsing into one "respondent" bucket
# (#224). Appellant/committee stay single-group (party_name=""). Splitting
# the large respondent set per-brief also keeps each Claude call small enough
# to succeed — a single 100+ proposition call previously returned non-JSON
# and silently dropped the whole side.
by_group: dict[tuple[str, str], list[dict]] = {}
# Group propositions by party.
by_party: dict[str, list[dict]] = {}
for r in rows:
party = r["party_role"]
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
if party not in ALLOWED_PARTIES:
party = "unknown"
# 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))
by_party.setdefault(party, []).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
@@ -328,17 +243,9 @@ async def aggregate_claims_to_arguments(
inserted = 0
errors: list[str] = []
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
for party, props in by_party.items():
try:
arguments = await _aggregate_party(party, props, party_name=party_name)
except AggregationFailed as e:
# A side that failed is NOT a side with no arguments. Record it so
# the status degrades and the caller can see which litigant is
# missing (#233).
errors.append(f"{group_key}: {e}")
continue
arguments = await _aggregate_party(party, props)
except RuntimeError as e:
# Most likely cause: Claude CLI not installed (running from
# the container). Don't crash — record the gap and continue.
@@ -352,11 +259,11 @@ async def aggregate_claims_to_arguments(
),
"total": 0,
}
errors.append(f"{group_key}: {msg}")
errors.append(f"{party}: {msg}")
continue
if not arguments:
party_counts[group_key] = 0
party_counts[party] = 0
continue
async with pool.acquire() as conn:
@@ -364,13 +271,12 @@ async def aggregate_claims_to_arguments(
for idx, arg in enumerate(arguments):
arg_id = await conn.fetchval(
"""INSERT INTO legal_arguments
(case_id, party, party_name, argument_index,
argument_title, argument_body, legal_topic, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
(case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id""",
case_id,
party,
party_name,
idx + 1,
arg["title"],
arg["body"],
@@ -407,7 +313,7 @@ async def aggregate_claims_to_arguments(
cid, arg_id, e,
)
inserted += 1
party_counts[group_key] = len(arguments)
party_counts[party] = len(arguments)
result: dict = {
"status": "completed",
@@ -432,9 +338,9 @@ async def get_legal_arguments(
async with pool.acquire() as conn:
if party and party in ALLOWED_PARTIES:
rows = await conn.fetch(
"""SELECT id, case_id, party, party_name, argument_index,
argument_title, argument_body, legal_topic, priority,
cited_precedents, created_at, updated_at
"""SELECT id, case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority, cited_precedents,
created_at, updated_at
FROM legal_arguments
WHERE case_id = $1 AND party = $2
ORDER BY priority, argument_index""",
@@ -442,12 +348,12 @@ async def get_legal_arguments(
)
else:
rows = await conn.fetch(
"""SELECT id, case_id, party, party_name, argument_index,
argument_title, argument_body, legal_topic, priority,
cited_precedents, created_at, updated_at
"""SELECT id, case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority, cited_precedents,
created_at, updated_at
FROM legal_arguments
WHERE case_id = $1
ORDER BY party, party_name, priority, argument_index""",
ORDER BY party, priority, argument_index""",
case_id,
)

View File

@@ -7,7 +7,7 @@ from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from datetime import datetime
from uuid import UUID, uuid4
from legal_mcp.services import db
@@ -38,7 +38,7 @@ async def log_action(
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
uuid4(), action, case_id, document_id,
json.dumps(details or {}, ensure_ascii=False, default=str),
user, datetime.now(timezone.utc),
user, datetime.utcnow(),
)
logger.info("AUDIT: %s | case=%s | user=%s | %s", action, case_id, user,
json.dumps(details or {}, ensure_ascii=False)[:200])

View File

@@ -23,10 +23,8 @@ from pathlib import Path
from legal_mcp import config
from legal_mcp.services import db, embeddings, claude_session, audit, storage
from legal_mcp.services.lessons import (
ANTI_PATTERNS as _ANTI_PATTERNS,
OUTCOME_LABELS_HE,
PRACTICE_AREA_OVERRIDES,
anti_pattern_directive,
canonical_outcome,
get_content_checklist,
get_methodology_summary,
@@ -60,18 +58,6 @@ logger = logging.getLogger(__name__)
# Output token note (Anthropic): Opus 4.8 supports large outputs; streaming is
# handled by the CLI. `max_tokens` is advisory context for callers, not sent.
GENERATION_MODEL = "claude-opus-4-8" # single pinned model for every AI block (#204)
# 1M-context build of the SAME pinned model (#216). The default build runs a
# 200K-token context (~400K Hebrew chars at ~2 chars/token); large prompts —
# notably block-yod, which carries the full case as source-context — overflow it.
# Opus 4.8 offers a 1M-token window at standard pricing; `claude -p` exposes it
# via the `[1m]` model id directly (no beta header — verified CLI 2.1.196). We
# escalate to it only when the prompt is large, so small blocks stay on the
# cheaper/faster 200K build. NOT a parallel model (G2) — same model, wider window.
GENERATION_MODEL_1M = "claude-opus-4-8[1m]"
# Escalate to the 1M build above this prompt size. Set below the 200K-token wall
# (~400K chars) with headroom for output tokens (max_tokens up to 16K) + tokenizer
# variance, so we switch before the standard build can overflow.
_CTX_1M_THRESHOLD_CHARS = 350_000
BLOCK_CONFIG = {
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "model": "script"},
@@ -79,7 +65,7 @@ BLOCK_CONFIG = {
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "model": "script"},
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "model": "script"},
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "model": "ai", "effort": "medium", "max_tokens": 4096},
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "model": "ai", "effort": "low", "max_tokens": 16384},
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 16384},
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "model": "ai", "effort": "high", "max_tokens": 16384},
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 8192},
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "model": "ai", "effort": "high", "max_tokens": 16384},
@@ -194,16 +180,16 @@ BLOCK_PROMPTS = {
## כללים קריטיים:
- **סנתז טענות דומות** — אל תרשום כל טענה בנפרד. קבץ טענות דומות לנושא אחד. למשל: כל הטענות על הודעות → סעיף אחד, כל הטענות על רכוש משותף → סעיף אחד.
- גוף שלישי: "העוררים טוענים כי...", "הוועדה המקומית ציינה כי..."
- **מבנה: חלק (סעיף עם כותרת-משנה) לכל צד שמופיע בטענות שחולצו למטה** — לפי הכותרות שם (### ...). אל תמציא צדדים שאינם מופיעים ואל תשמיט צד שמופיע.
- "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא.
- **צד-משיב/מתנגד: כתוב חלק נפרד לכל כתב-תשובה** שמופיע (למשל "עמדת משיבות 2-3" ו-"עמדת משיבים 4-6" בנפרד) — 5-10 סעיפים לכל אחד. **אל תמזג משיבים שונים לסעיף אחד**, ואם עמדותיהם מנוגדות — שקף זאת.
- "עמדת הוועדה המקומית" / "עמדת מבקשי ההיתר" (אם מופיעים) — 5-8 סעיפים.
- **מבנה קבוע עם 3 חלקים:**
1. "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא
2. "עמדת הוועדה המקומית" — 5-8 סעיפים
3. "עמדת מבקשי ההיתר" (אם יש) — 5-10 סעיפים
- כותרת: "תמצית טענות הצדדים"
- נאמנות למקור — לא להמציא טענות, אבל כן לאחד ולסכם טענות חוזרות
- אין ניתוח, אין מסקנות, אין הערכה ("טענה חלשה/חזקה")
- רק מכתבי טענות מקוריים (לא השלמות טיעון)
- מספור רציף
- **יעד אורך: 800-1500 מילים** (יותר כשיש כמה כתבי-תשובה נפרדים — כל צד מקבל את מלוא ההתייחסות)
- **יעד אורך: 800-1500 מילים**
## טענות שחולצו (קבץ טענות דומות לנושאים):
{claims_context}
@@ -286,7 +272,6 @@ BLOCK_PROMPTS = {
- **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל"
- **מספור רציף** — המשך מספור מהבלוק הקודם
- מותרות כותרות-משנה כשיש נושאים נפרדים לחלוטין
- **צדדים מרובים** — כשיש כמה משיבים/מתנגדים או כתבי-תשובה נפרדים (ראה הכותרות שתחת "טענות" לעיל, למשל "משיבות 2-3" מול "משיבים 4-6"), התייחס לעמדת כל אחד לגופה ואל תמזג אותם ל"טענות המשיבים" גורפות. אם משיבים שונים נוקטים **עמדות מנוגדות זו לזו** — ציין זאת במפורש והכרע ביניהן בנימוק; אם עמדותיהם משלימות — ניתן לאגד תוך שמירת הייחוס.
## כיוון מאושר (חובה):
{direction_context}
@@ -370,8 +355,6 @@ async def write_block(
case_id: UUID,
block_id: str,
instructions: str = "",
effort_override: str | None = None,
model_override: str | None = None,
) -> dict:
"""כתיבת בלוק יחיד בהחלטה.
@@ -379,17 +362,6 @@ async def write_block(
case_id: מזהה התיק
block_id: מזהה הבלוק (block-alef, block-he, block-yod, ...)
instructions: הנחיות נוספות
effort_override: optional per-call reasoning effort (low/medium/high/
xhigh/max). When set, overrides BLOCK_CONFIG[block_id].effort for
THIS call only — used by the #208 model/effort calibration harness
to A/B efforts without mutating the pinned defaults. Production
callers leave it None and get the deterministic per-block effort.
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:
dict עם content, word_count, block_id, generation_type
@@ -477,67 +449,47 @@ async def write_block(
if 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
if block_id == "block-yod":
dir_doc = (decision or {}).get("direction_doc") or {}
if not dir_doc.get("approved"):
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
# Pick the model build by prompt size (#216). Above the 200K-token wall we
# escalate to the 1M-context build (`[1m]`) instead of failing the block —
# block-yod legitimately carries the whole case as source-context. The 400K
# ceiling was an artifact of the old 200K-only build, NOT a model limit.
# 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
# Guard against context overflow before calling claude -p.
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
# In practice the CLI has crashed on prompts above ~400K chars, so use
# that as a conservative ceiling (well below the token limit).
_MAX_PROMPT_CHARS = 400_000
if len(prompt) > _MAX_PROMPT_CHARS:
raise RuntimeError(
f"Prompt too large for {block_id}: {len(prompt):,} chars "
f"(limit {_MAX_PROMPT_CHARS:,}, even on the 1M-context build). "
f"(limit {_MAX_PROMPT_CHARS:,}). "
f"source_context: {len(source_context):,} chars. "
f"Reduce source-context (summaries / appraiser_facts / focused RAG)."
f"Reduce documents or call extract_appraiser_facts first."
)
# Call Claude via Claude Code session (no API). #204: pin the model + per-block
# reasoning effort so generation is structurally deterministic — these were
# previously NOT forwarded (the source of inconsistency). model/effort flow
# through claude_session.query → `claude -p --model … --effort …`.
effort = effort_override or block_cfg.get("effort", DEFAULT_EFFORT)
effort = 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,
model=GENERATION_MODEL,
effort=effort,
tools="", # prose gen — no tool_use → no error_max_turns
)
sources = await _collect_block_sources(case_id, block_id)
sources["case_law_ids"] = _precedent_case_law_ids
result = _build_result(block_id, content, block_cfg, 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 = _build_result(block_id, content, block_cfg)
result["sources"] = sources
return result
def _build_result(block_id: str, content: str, block_cfg: dict,
model_used: str = GENERATION_MODEL) -> dict:
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
word_count = len(content.split())
is_ai = block_cfg["model"] == "ai"
return {
@@ -547,9 +499,8 @@ def _build_result(block_id: str, content: str, block_cfg: dict,
"content": content,
"word_count": word_count,
"generation_type": block_cfg["gen_type"],
# AI blocks record the model build actually used (200K or [1m], #216);
# template blocks record "script".
"model_used": model_used if is_ai else block_cfg["model"],
# AI blocks record the pinned model; template blocks record "script".
"model_used": GENERATION_MODEL 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
@@ -636,63 +587,24 @@ async def _build_claims_context(case_id: UUID) -> str:
return "(לא חולצו טענות)"
# Filter out claims from block-zayin (decision summary) — use only
# claims extracted from original pleadings (appeal, response, etc.).
# 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"
]
# claims extracted from original pleadings (appeal, response, etc.)
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"]
if not source_claims:
# Fallback to all non-block-zayin claims if no source claims exist.
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
# Group by (party_role, brief) so a multi-litigant side (respondent /
# permit_applicant) whose claims come from distinct pleadings reads as
# SEPARATE positions — e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6" — instead
# of one merged "טענות המשיבים" (#224). The brief label is the source
# pleading, and which sides split is SPLIT_PARTIES — the SAME rule the
# argument aggregator uses, so block ז and the arguments panel agree (G2).
from legal_mcp.services.argument_aggregator import SPLIT_PARTIES
# Fallback to all claims if no source claims exist
source_claims = claims
lines = []
current_role = ""
role_heb = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
role_order = ["appellant", "committee", "respondent", "permit_applicant"]
groups: dict[tuple[str, str], list[dict]] = {}
for c in source_claims:
role = c.get("party_role", "") or ""
# Prefer the stored party_name (stamped by the extractor, #224); fall
# back to source_document for legacy claims predating the stamping.
if role in SPLIT_PARTIES:
brief = (c.get("party_name") or "").strip() or (c.get("source_document") or "").strip()
else:
brief = ""
groups.setdefault((role, brief), []).append(c)
def _sort_key(k: tuple[str, str]) -> tuple[int, str]:
role, brief = k
idx = role_order.index(role) if role in role_order else len(role_order)
return (idx, brief)
lines: list[str] = []
claim_num = 0
for role, brief in sorted(groups.keys(), key=_sort_key):
header = role_heb.get(role, role or "טענות נוספות")
if brief:
header = f"{header}{brief}"
lines.append(f"\n### {header}")
for c in groups[(role, brief)]:
claim_num += 1
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
lines.append(
f"\n**סה\"כ {claim_num} טענות. שמור על ההפרדה בין הצדדים לעיל — "
f"לכל צד/כתב-תשובה עמדה נפרדת (אל תמזג משיבים שונים). ענה על כל טענה "
f"מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**"
)
for c in source_claims:
if c["party_role"] != current_role:
current_role = c["party_role"]
lines.append(f"\n### {role_heb.get(current_role, current_role)}")
claim_num += 1
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
lines.append(f"\n**סה\"כ {claim_num} טענות. ענה על כל טענה מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**")
return "\n".join(lines)
@@ -1127,16 +1039,6 @@ async def _build_style_context(practice_area: str = "") -> str:
# ── למידה מצטברת (T15) — עריכות היו"ר ב-/methodology + לקחי /training ──
# גובר על ברירות-המחדל לעיל. כך כל מה שלמדנו עד היום מגיע לכותב.
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:
for cat, label in (
("golden_ratios", "יחסי-זהב (אחוזי-סעיפים)"),
@@ -1155,8 +1057,10 @@ async def _build_style_context(practice_area: str = "") -> str:
# 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.
from legal_mcp.services.lessons import ANTI_PATTERNS as _ANTI
learned.append(f"\n**{label} — כתוב נרטיב משפטי רציף; הימנע מ:**")
for ap in _ANTI:
learned.append(f"- {ap['note']}")
for k, v in (ov or {}).items():
learned.append(f"- (יו\"ר) {k}: {json.dumps(v, ensure_ascii=False)}")
continue
@@ -1292,12 +1196,6 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
if 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
if block_id == "block-yod":
dir_doc = (decision or {}).get("direction_doc") or {}

View File

@@ -18,9 +18,7 @@ rows. Reuses the one corpus search + the one authority query + the one radar (G2
from __future__ import annotations
import asyncio
import logging
import time
from uuid import UUID
from legal_mcp.services import (
@@ -35,132 +33,8 @@ logger = logging.getLogger(__name__)
_SUGGEST_PER_ISSUE = 4
_SUGGEST_FLOOR = 0.45
#: Concurrent per-argument retrievals. The fan-out used to be unbounded — one
#: task per legal argument — which is self-defeating, not merely risky: each
#: task opens a Voyage embed call, and past ~8 in flight Voyage throttles, so
#: every request stalls together. Measured on this corpus (32 arguments):
#:
#: one search alone ...... 1.2s
#: 32 unbounded .......... 30.4s ← 25× a single search
#: 32 at 8 concurrent .... 22.1s ← bounded is FASTER
#:
#: A 69-argument case (1069-04-26) therefore blew past the 30s proxy timeout and
#: returned 500, and overlapping page reloads piled contention into Postgres
#: deadlocks. Bounding the fan-out both fixes the failure and speeds it up.
_MAX_CONCURRENT_LOOKUPS = 8
def _passes_floor(hit: dict) -> bool:
"""Is this hit similar enough to the argument to suggest to the chair?
Reads ``relevance`` (always a cosine similarity), NOT ``score`` — ``score``
becomes a rank-fusion value (~0.008-0.02) as soon as the lexical leg returns
rows, and comparing that against a cosine-calibrated floor rejected every
hit. That is why this tab showed no supporting precedent for any argument.
``relevance is None`` means the row came from the lexical leg only, so no
cosine was ever computed. It is KEPT: it earned its place by BM25 rank
inside an already-tiny top-k, and dropping it would silently hide exact
phrase/docket matches — the very hits a chair searches for by name.
"""
rel = hit.get("relevance")
if rel is None:
return True
return float(rel) >= _SUGGEST_FLOOR
#: Wall-clock ceiling for the whole retrieval phase. The proxy gives up at 30s;
#: cutting ourselves off earlier lets us return the suggestions that DID land
#: instead of a 500 that shows the chair nothing. Partial results are labelled
#: (``retrieval_complete: false``) rather than passed off as the full picture.
_RETRIEVAL_BUDGET_S = 22.0
#: Cached views, keyed by case number → (fingerprint, expires_at|None, view).
#: Assembling a view costs 20+ seconds of Voyage embeds and vector searches, and
#: the result changes only when its inputs do. Keyed by a FINGERPRINT of those
#: inputs rather than a guessed TTL: a chair who verifies a precedent must see it
#: reflected on the next load, not after a timer expires.
_view_cache: dict[str, tuple[str, float | None, dict]] = {}
#: One lock per case so two concurrent loads of the same page don't both compute.
#: Reload-during-a-slow-load is how the original outage compounded into Postgres
#: deadlocks; the second reader now waits for the first and gets its result.
_view_locks: dict[str, asyncio.Lock] = {}
#: A partial view (retrieval budget exhausted) is still worth caching — otherwise
#: every reload re-pays 22 seconds — but only briefly, so the missing suggestions
#: get another chance while the corpus is quieter.
_PARTIAL_TTL_S = 90.0
async def _inputs_fingerprint(case_id: UUID) -> str:
"""Cheap signature of everything the view is derived from.
Changes when: arguments are re-aggregated, the chair attaches or verifies a
precedent, or the corpus grows (new rulings change the suggestions). One
round-trip — the point is to be far cheaper than the 20s it guards.
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
(SELECT count(*)::text || ':' || coalesce(max(updated_at)::text, '-')
FROM legal_arguments WHERE case_id = $1) AS args,
(SELECT count(*)::text || ':' || coalesce(max(updated_at)::text, '-')
FROM case_precedents WHERE case_id = $1) AS attached,
(SELECT count(*)::text || ':' || coalesce(max(created_at)::text, '-')
FROM case_law WHERE searchable = true) AS corpus
""",
case_id,
)
return f"{row['args']}|{row['attached']}|{row['corpus']}"
def invalidate(case_number: str = "") -> None:
"""Drop cached views — one case, or all when called with no argument.
Callers that mutate the inputs may use this for immediacy, but correctness
does not depend on it: the fingerprint check catches any change on the next
read regardless of whether anyone remembered to invalidate (G1 — the guard
lives at the source of truth, not in every writer's memory).
"""
if case_number:
_view_cache.pop(case_number, None)
else:
_view_cache.clear()
async def build_view(case_number: str, *, use_cache: bool = True) -> dict:
"""Assemble the citation-verification view, served from cache when unchanged."""
if not use_cache:
return await _build_view_uncached(case_number)
lock = _view_locks.setdefault(case_number, asyncio.Lock())
async with lock:
case = await db.get_case_by_number(case_number)
if not case:
return {"status": "case_not_found", "case_number": case_number,
"arguments": []}
cid = case["id"]
fp = await _inputs_fingerprint(UUID(cid) if isinstance(cid, str) else cid)
hit = _view_cache.get(case_number)
if hit is not None:
cached_fp, expires_at, view = hit
fresh = cached_fp == fp and (expires_at is None or time.monotonic() < expires_at)
if fresh:
return {**view, "cached": True}
view = await _build_view_uncached(case_number)
if view.get("status") == "ok":
expires_at = (None if view.get("retrieval_complete", True)
else time.monotonic() + _PARTIAL_TTL_S)
_view_cache[case_number] = (fp, expires_at, view)
return {**view, "cached": False}
async def _build_view_uncached(case_number: str) -> dict:
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": []}
@@ -191,70 +65,25 @@ async def _build_view_uncached(case_number: str) -> dict:
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.
"""
out_args: list[dict] = []
n_verified = 0
for a in args:
aid = str(a["id"])
title = (a.get("argument_title") or "").strip()
topic = (a.get("legal_topic") or "").strip()
query = f"{ctx} {title}. {topic}".strip()
hits: list[dict] = []
hits = []
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 _passes_floor(h)]
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, but BOUNDED — see
# _MAX_CONCURRENT_LOOKUPS. gather preserves order, so the zip below still
# pairs each argument with its own result.
_sem = asyncio.Semaphore(_MAX_CONCURRENT_LOOKUPS)
async def _fetch_bounded(a: dict) -> tuple[list[dict], dict]:
async with _sem:
return await _fetch(a)
retrieval_complete = True
fetched: list[tuple[list[dict], dict]] = []
if args:
# Harvest whatever finished inside the budget, per argument. Deliberately
# NOT wait_for(gather(...)): that cancels every task on timeout, so one
# slow lookup would throw away the 30 that already succeeded and the page
# would show nothing at all.
tasks = [asyncio.ensure_future(_fetch_bounded(a)) for a in args]
done, pending = await asyncio.wait(tasks, timeout=_RETRIEVAL_BUDGET_S)
for t in pending:
t.cancel()
if pending:
# Let the cancellations settle before the caller's DB pool unwinds —
# a task cancelled mid-query otherwise surfaces as a stray
# "connection_lost" future with no owner.
await asyncio.gather(*pending, return_exceptions=True)
retrieval_complete = False
logger.warning(
"citation_verification: retrieval budget of %.0fs exhausted for %s "
"%d of %d arguments returned suggestions, the rest are empty",
_RETRIEVAL_BUDGET_S, case_number, len(done), len(args),
)
for t in tasks:
if t in done and not t.cancelled() and t.exception() is None:
fetched.append(t.result())
else:
fetched.append(([], {}))
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] = []
@@ -262,7 +91,7 @@ async def _build_view_uncached(case_number: str) -> dict:
clid = str(h.get("case_law_id") or "")
if not clid or clid in seen:
continue
if not _passes_floor(h):
if float(h.get("score", 0) or 0) < _SUGGEST_FLOOR:
continue
seen.add(clid)
att = attached_by_arg.get(aid, {}).get(clid)
@@ -295,11 +124,6 @@ async def _build_view_uncached(case_number: str) -> dict:
return {
"status": "ok",
"case_number": case_number,
# False when the retrieval budget ran out: the attached/verified rows and
# the radar are complete, but the corpus SUGGESTIONS are missing. The UI
# must say so — an empty suggestion list otherwise reads as "no precedent
# in the corpus supports this argument", which is a different claim.
"retrieval_complete": retrieval_complete,
"arguments": out_args,
"summary": {
"arguments_total": len(out_args),

View File

@@ -227,15 +227,8 @@ def _infer_claim_type(doc_type: str, source_name: str) -> str:
- 'claim' = from appeal documents (כתב ערר)
- 'response' = from original response documents (כתב תשובה)
- 'reply' = from supplementary responses (תגובה, השלמת טיעון)
- 'protocol' = oral arguments raised at the hearing (פרוטוקול דיון)
"""
name_lower = source_name.lower() if source_name else ""
# A hearing protocol carries oral arguments — tagged distinctly so the
# comparative protocol analysis (analyze_protocol) and block-chet
# (proceedings) can tell them apart from the original written pleadings
# (INV-WR4: block ז stays original-pleadings-only).
if doc_type == "protocol" or "פרוטוקול" in name_lower:
return "protocol"
if doc_type == "appeal" or "כתב ערר" in name_lower:
return "claim"
if "כתב תשובה" in name_lower:
@@ -368,15 +361,8 @@ async def extract_and_store_claims(
# Determine claim_type from document type and title
claim_type = _infer_claim_type(doc_type, source_name)
# Stamp party_name at the source (#224). For a multi-litigant side
# (respondent / permit_applicant) the brief label IS the source pleading, so
# opposing briefs (משיבות 2-3 vs משיבים 4-6) stay distinct downstream without
# the aggregator/block-writer having to re-derive it. SPLIT_PARTIES is the
# single rule shared with the aggregator (G2); single-voice sides stay ''.
from legal_mcp.services.argument_aggregator import SPLIT_PARTIES
for c in claims:
c["claim_type"] = claim_type
c["party_name"] = source_name if c.get("party_role") in SPLIT_PARTIES else ""
stored = await db.store_claims(case_id, claims, source_document=source_name)
# Mark this document analysed (WS2 / #201). store_claims already replaced

View File

@@ -146,15 +146,8 @@ async def query(
"""
full_prompt = f"{system}\n\n{prompt}" if system else prompt
# ~350K chars ≈ the 200K-token wall of the default Opus build (~2 Hebrew
# chars/token). Above it callers should pass the 1M-context model id
# (`claude-opus-4-8[1m]`) — block_writer escalates automatically (#216).
if len(full_prompt) > 350_000 and "[1m]" not in (model or ""):
logger.warning(
"Large prompt: %d chars on a non-[1m] model (%s) — may overflow the "
"200K-token context. Pass claude-opus-4-8[1m] for the 1M window.",
len(full_prompt), model or "CLI-default",
)
if len(full_prompt) > 150_000:
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
cmd = [
"claude", "-p",

View File

@@ -1835,41 +1835,6 @@ 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
@@ -1878,7 +1843,8 @@ CREATE INDEX IF NOT EXISTS idx_protocol_analysis_doc ON protocol_analysis(docume
# 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.)
# ADD COLUMN IF NOT EXISTS; re-running is a no-op. (V48 is reserved for the
# protocol_analysis sibling PR #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
@@ -1888,50 +1854,6 @@ CREATE INDEX IF NOT EXISTS idx_documents_claims_pending
"""
# 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
# schema DDL across processes. Every short-lived process (cron drains, services)
# re-runs the idempotent migrations on startup; without this lock two processes
@@ -2001,11 +1923,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
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:
@@ -2228,7 +2146,7 @@ async def update_case(case_id: UUID, **fields) -> dict | None:
set_clauses = []
values = []
for i, (key, val) in enumerate(fields.items(), start=2):
if key in ("appellants", "respondents", "tags", "hearing_attendees"):
if key in ("appellants", "respondents", "tags"):
val = json.dumps(val)
set_clauses.append(f"{key} = ${i}")
values.append(val)
@@ -2241,7 +2159,7 @@ async def update_case(case_id: UUID, **fields) -> dict | None:
def _row_to_case(row: asyncpg.Record) -> dict:
d = dict(row)
for field in ("appellants", "respondents", "tags", "hearing_attendees"):
for field in ("appellants", "respondents", "tags"):
if isinstance(d.get(field), str):
d[field] = json.loads(d[field])
d["id"] = str(d["id"])
@@ -4179,83 +4097,6 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]:
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) ──────────────────────────────────────────
# Canonical registry of planning schemes (תכניות). SSOT for a plan's identity +
# validity, reused across cases (G2). See SCHEMA_V38_SQL for the data contract.
@@ -5324,19 +5165,21 @@ async def list_external_case_law(
search: str = "",
limit: int = 100,
offset: int = 0,
source_kind: str = "",
source_kind: str = "external_upload",
) -> list[dict]:
"""List chair-uploaded precedents, with simple filters.
source_kind="" (default) = the whole corpus — court rulings *and*
appeals-committee decisions. The old ``external_upload`` default hid
every committee decision from a plain listing (#232).
source_kind="all_committees" expands to: source_kind='internal_committee'
OR (source_kind='external_upload' AND source_type='appeals_committee').
"""
pool = await get_pool()
sk_clause = _source_kind_clause(source_kind)
conditions = [sk_clause] if sk_clause else []
if source_kind == "all_committees":
conditions = [
"(source_kind = 'internal_committee' OR "
"(source_kind = 'external_upload' AND source_type = 'appeals_committee'))"
]
else:
conditions = [f"source_kind = '{source_kind}'"]
params: list = []
idx = 1
if practice_area:
@@ -5356,19 +5199,13 @@ async def list_external_case_law(
params.append(source_type)
idx += 1
if search:
# Case-number separator normalisation (#232, trap 2): committee numbers
# are stored hyphenated ("83-16") while people write them with a slash
# ("83/16"). Fold both sides to '/' so either form finds the row —
# matching at the point of comparison rather than asking every caller
# to guess the stored form.
conditions.append(
f"(case_number ILIKE ${idx} OR case_name ILIKE ${idx} "
f"OR summary ILIKE ${idx} OR headnote ILIKE ${idx} "
f"OR replace(case_number, '-', '/') ILIKE replace(${idx}, '-', '/'))"
f"OR summary ILIKE ${idx} OR headnote ILIKE ${idx})"
)
params.append(f"%{search}%")
idx += 1
where_sql = " AND ".join(conditions) if conditions else "TRUE"
where_sql = " AND ".join(conditions)
params.extend([limit, offset])
sql = f"""
SELECT id, case_number, case_name, court, date, practice_area,
@@ -7779,45 +7616,6 @@ async def list_corroboration_for_halacha(halacha_id: UUID) -> list[dict]:
]
#: Accepted ``source_kind`` selectors. ``""``/``"all"`` mean *no filter* —
#: the whole corpus, court rulings and appeals-committee decisions alike.
_SOURCE_KIND_SELECTORS = frozenset(
{"", "all", "all_committees", "external_upload", "internal_committee", "cited_only"}
)
def _source_kind_clause(source_kind: str, column_prefix: str = "") -> str:
"""Return the SQL predicate for a ``source_kind`` selector, or "" for none.
Single definition for every caller (G2) — the selector vocabulary was
previously restated at each search/list site, which is how the
``external_upload`` default silently hid 104 appeals-committee
decisions from ``search_precedent_library`` (#232).
``column_prefix`` is the table alias plus dot (e.g. ``"cl."``) or "" when
the query selects from ``case_law`` directly.
Raises ValueError on an unknown selector rather than interpolating it —
the value reaches SQL by f-string, so the whitelist is also what keeps
that safe.
"""
sk = (source_kind or "").strip()
if sk not in _SOURCE_KIND_SELECTORS:
raise ValueError(
f"source_kind לא מוכר: {source_kind!r}. "
f"ערכים חוקיים: {sorted(_SOURCE_KIND_SELECTORS - {''})} או '' (הכל)"
)
if sk in ("", "all"):
return ""
p = column_prefix
if sk == "all_committees":
return (
f"({p}source_kind = 'internal_committee' OR "
f"({p}source_kind = 'external_upload' AND {p}source_type = 'appeals_committee'))"
)
return f"{p}source_kind = '{sk}'"
async def search_precedent_library_semantic(
query_embedding: list[float],
practice_area: str = "",
@@ -7828,15 +7626,14 @@ async def search_precedent_library_semantic(
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
source_kind: str = "",
source_kind: str = "external_upload",
district: str = "",
chair_name: str = "",
) -> list[dict]:
"""Semantic search over precedents, optionally filtered by source_kind.
"""Semantic search over precedents filtered by source_kind.
source_kind='' → the whole corpus (default, #232)
source_kind='external_upload' → court rulings only
source_kind='internal_committee' → appeals-committee decisions only
source_kind='external_upload' → court rulings (default)
source_kind='internal_committee' → appeals-committee decisions
Returns merged halachot + chunks. Halachot are pre-distilled rules, so
they get a small score boost. Only ``approved`` / ``published`` halachot
@@ -7844,15 +7641,12 @@ async def search_precedent_library_semantic(
of halacha review status.
"""
pool = await get_pool()
sk_clause = _source_kind_clause(source_kind, "cl.")
halacha_filters = [
"h.review_status <> 'rejected'", # #153: include background; rank verified higher
f"cl.source_kind = '{source_kind}'",
"cl.searchable = true",
]
chunk_filters = ["cl.searchable = true"]
if sk_clause:
halacha_filters.append(sk_clause)
chunk_filters.append(sk_clause)
chunk_filters = [f"cl.source_kind = '{source_kind}'", "cl.searchable = true"]
h_params: list = [query_embedding, limit]
c_params: list = [query_embedding, limit]
h_idx = 3
@@ -7979,14 +7773,6 @@ async def search_precedent_library_semantic(
# Calibrated so the average (≈0.85) stays at +0.05 (legacy value).
_conf = float(d.get("confidence") or 0.0)
d["score"] = float(d["score"]) + max(_conf * 0.06, 0.0)
# Stable cosine-scale relevance, carried alongside ``score``.
# ``score`` is the RANKING signal and downstream fusion overwrites it
# with an RRF value (~0.008-0.02) whenever the lexical leg returns
# rows — a different scale entirely. Anything that THRESHOLDS must
# read ``relevance`` instead, which always means "cosine similarity
# to the query" no matter which fusion stages ran. See
# hybrid_search._merge_sem_lex.
d["relevance"] = d["score"]
d["type"] = "halacha"
# authority is DERIVED from the source, never stored (INV-DM7)
d["authority"] = halacha_quality.derive_authority(d.get("precedent_level"))
@@ -7998,7 +7784,6 @@ async def search_precedent_library_semantic(
if d.get("decision_date") is not None:
d["decision_date"] = d["decision_date"].isoformat()
d["score"] = float(d["score"])
d["relevance"] = d["score"] # cosine anchor — see the halacha branch above
d["type"] = "passage"
_maybe_swap_parent(d)
results.append(d)
@@ -8073,7 +7858,7 @@ async def search_precedent_library_lexical(
appeal_subtype: str = "",
is_binding: bool | None = None,
subject_tag: str = "",
source_kind: str = "",
source_kind: str = "external_upload",
district: str = "",
chair_name: str = "",
limit: int = 30,
@@ -8099,15 +7884,12 @@ async def search_precedent_library_lexical(
return []
pool = await get_pool()
sk_clause = _source_kind_clause(source_kind, "cl.")
halacha_filters = [
"h.review_status <> 'rejected'", # #153: include background; rank verified higher
f"cl.source_kind = '{source_kind}'",
"cl.searchable = true",
]
chunk_filters = ["cl.searchable = true"]
if sk_clause:
halacha_filters.append(sk_clause)
chunk_filters.append(sk_clause)
chunk_filters = [f"cl.source_kind = '{source_kind}'", "cl.searchable = true"]
# $1 = query, $2 = limit. Filters append starting at $3.
h_params: list = [query, limit]
c_params: list = [query, limit]

View File

@@ -43,16 +43,10 @@ HEBREW_FONT = "David"
def _mark_run_rtl(run) -> None:
"""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.
"""
"""Force David font on all four slots, then add <w:rtl/>."""
rPr = run._r.get_or_add_rPr()
if rPr.find(qn("w:rFonts")) is None:
fonts = OxmlElement("w:rFonts") # INV-EX4-ok: reinforces template font David
fonts = OxmlElement("w:rFonts")
fonts.set(qn("w:ascii"), HEBREW_FONT)
fonts.set(qn("w:hAnsi"), HEBREW_FONT)
fonts.set(qn("w:cs"), HEBREW_FONT)
@@ -198,29 +192,14 @@ def _apply_list_numbering(paragraph, num_id: int) -> None:
def _clear_body(doc) -> None:
"""Remove ALL body content (paragraphs, tables, stray bookmarks) while
keeping only sectPr.
"""Remove all paragraphs in the document body while keeping sectPr.
The template ships with sample paragraphs *and three sample tables*
(institutional header, panel, and a **signatures** table at the end —
"ניתנה פה אחד" + יו"ר/מזכירה). Removing only ``w:p`` left those tables
behind: the leftover header/panel tables injected block-א–ד data that was
never extracted from the protocol (violates the empty-unless-extracted
rule), and the leftover signatures table collapsed up against the header
table at the **top** of the document (the chair-reported bug: חתימות צמודות
לבלוק-ד במקום בסוף). The decision is a derived artifact rebuilt purely from
``decision_blocks`` (INV-EX1) — signatures come from block-yod-bet, rendered
last in block order — so the template's sample tables are stale scaffolding
and must go. Section properties (page size, margins, bidi) stay intact.
The template ships with sample paragraphs we don't want. Section
properties (page size, margins, bidi) stay intact.
"""
body = doc.element.body
sectPr_tag = qn("w:sectPr")
for child in list(body):
# Keep section properties (page setup / bidi). Drop everything else:
# sample paragraphs (w:p), sample tables (w:tbl), and any stray
# bookmark markers the template left dangling between them.
if child.tag != sectPr_tag:
body.remove(child)
for p in list(body.findall(qn("w:p"))):
body.remove(p)
# ── Bookmark helpers ──────────────────────────────────────────────
@@ -399,15 +378,8 @@ _INTERIM_BLOCK_ORDER = [
]
def _draft_naming(mode: str) -> tuple[str, str]:
"""(filename prefix, version separator) per export mode.
interim → ``טיוטה-טענות_הצדדים_{N}.docx`` (chair-requested naming);
final → ``טיוטה-v{N}.docx``.
"""
if mode == "interim":
return "טיוטה-טענות_הצדדים", "_"
return "טיוטה", "-v"
def _draft_filename_prefix(mode: str) -> str:
return "טיוטת-ביניים" if mode == "interim" else "טיוטה"
async def export_decision(
@@ -492,19 +464,16 @@ async def export_decision(
if not output_path:
export_dir = config.find_case_dir(case["case_number"]) / "exports"
export_dir.mkdir(parents=True, exist_ok=True)
prefix, sep = _draft_naming(mode)
existing = sorted(export_dir.glob(f"{prefix}{sep}*.docx"))
prefix = _draft_filename_prefix(mode)
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
next_ver = 1
for p in existing:
try:
# 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])
ver = int(p.stem.split("-v")[1])
next_ver = max(next_ver, ver + 1)
except (IndexError, ValueError):
pass
output_path = str(export_dir / f"{prefix}{sep}{next_ver}.docx")
output_path = str(export_dir / f"{prefix}-v{next_ver}.docx")
# Persist through the storage layer (INV-STG1). Under the filesystem
# backend the bytes land at output_path exactly as before; a caller-

View File

@@ -199,86 +199,27 @@ def _find_enclosing_paragraph(element: etree._Element) -> etree._Element | None:
return None
# ── Template style resolution (INV-EX4) ────────────────────────────
# Every inserted paragraph carries a <w:pStyle> pointing at one of the
# template's named styles — font (David), size, and indent come from
# styles.xml, NOT from hand-set rFonts/sz here. Single template = single
# style source (INV-EX4 / G2). docx_exporter.py applies the same styles by
# name via python-docx; here (raw lxml) we resolve name → styleId.
# StyleType → candidate style names in the template (case-insensitive).
# Mirrors the names docx_exporter uses (paragraph.style = "Heading 2"/"Normal"/"Quote").
_STYLE_NAME_CANDIDATES: dict[str, tuple[str, ...]] = {
"body": ("Normal", "Default Paragraph Font", "Body Text"),
"bold": ("Normal", "Body Text"),
"heading": ("Heading 2", "heading 2"),
"quote": ("Quote", "quote", "Intense Quote"),
}
def _resolve_style_ids(members: dict[str, bytes]) -> dict[str, str]:
"""Map each StyleType → the template's w:styleId, read from styles.xml.
Returns {} when styles.xml is absent — callers then fall back to
minimal inline emphasis (so a missing style never crashes rendering).
"""
raw = members.get("word/styles.xml")
if not raw:
return {}
try:
styles_tree = etree.fromstring(raw)
except etree.XMLSyntaxError: # pragma: no cover - corrupt template
return {}
# Build a lowercase lookup over both the style's display name and its id.
by_key: dict[str, str] = {}
for style in styles_tree.iterfind(".//w:style", NSMAP):
sid = style.get(_w("styleId"))
if not sid:
continue
by_key.setdefault(sid.lower(), sid)
name_el = style.find(_w("name"))
if name_el is not None:
nm = name_el.get(_w("val"))
if nm:
by_key.setdefault(nm.lower(), sid)
resolved: dict[str, str] = {}
for style_type, candidates in _STYLE_NAME_CANDIDATES.items():
for cand in candidates:
sid = by_key.get(cand.lower())
if sid is not None:
resolved[style_type] = sid
break
return resolved
def _emphasis_fallback(style: StyleType, *, has_pstyle: bool) -> tuple[bool, bool]:
"""(bold, italic) for a run. When the paragraph carries a resolved
template pStyle, emphasis comes from the style (return no inline override),
except 'bold' which is inline emphasis with no dedicated style. When no
pStyle resolved, fall back to minimal inline emphasis so heading/quote
stay visually distinct."""
if style == "bold":
return True, False
if has_pstyle:
return False, False
return (style == "heading"), (style == "quote")
# ── Paragraph builders ─────────────────────────────────────────────
def _build_run(text: str, *, bold: bool = False, italic: bool = False) -> etree._Element:
"""Build a w:r (run): RTL marker + optional bold/italic + text.
Font (David) and size are NOT set here — they derive from the paragraph's
template style (INV-EX4). The <w:rtl/> marker is still required so Word
picks the style's complex-script (cs=David) slot rather than the ascii
(Times New Roman) slot for Hebrew (same reason as _mark_run_rtl in
analysis_docx_exporter.py).
"""
def _build_run(text: str, *, bold: bool = False, italic: bool = False,
font: str = "David", size_half_pt: int | None = None) -> etree._Element:
"""Build a w:r (run) element with RTL/David defaults and given text."""
r = etree.Element(_w("r"))
rPr = etree.SubElement(r, _w("rPr"))
rFonts = etree.SubElement(rPr, _w("rFonts"))
rFonts.set(_w("ascii"), font)
rFonts.set(_w("hAnsi"), font)
rFonts.set(_w("cs"), font)
rFonts.set(_w("hint"), "cs")
if size_half_pt is not None:
sz = etree.SubElement(rPr, _w("sz"))
sz.set(_w("val"), str(size_half_pt))
szCs = etree.SubElement(rPr, _w("szCs"))
szCs.set(_w("val"), str(size_half_pt))
if bold:
etree.SubElement(rPr, _w("b"))
etree.SubElement(rPr, _w("bCs"))
@@ -294,36 +235,28 @@ def _build_run(text: str, *, bold: bool = False, italic: bool = False) -> etree.
return r
def _build_paragraph(text: str, *, style: StyleType = "body",
style_id: str | None = None) -> etree._Element:
"""Build a w:p (paragraph) carrying the template's named style.
The paragraph references `style_id` (resolved from the template's
styles.xml) via <w:pStyle> — font/size/indent come from there, not from
hand-set rFonts/sz (INV-EX4). The only explicit paragraph-level override
is the canonical body alignment jc=both, matching docx_exporter's
_set_paragraph_jc (which forces both over style-center headings).
"""
def _build_paragraph(text: str, *, style: StyleType = "body") -> etree._Element:
"""Build a w:p (paragraph) with RTL + David + given text."""
p = etree.Element(_w("p"))
pPr = etree.SubElement(p, _w("pPr"))
if style_id:
pStyle = etree.SubElement(pPr, _w("pStyle"))
pStyle.set(_w("val"), style_id)
bidi = etree.SubElement(pPr, _w("bidi"))
bidi.set(_w("val"), "1")
# Canonical body alignment (two-sided justification) — overrides any
# style-center, exactly as docx_exporter._set_paragraph_jc does.
# Right alignment for body/RTL
jc = etree.SubElement(pPr, _w("jc"))
jc.set(_w("val"), "both")
jc.set(_w("val"), "right")
rPr_p = etree.SubElement(pPr, _w("rPr"))
etree.SubElement(rPr_p, _w("rtl"))
bold, italic = _emphasis_fallback(style, has_pstyle=bool(style_id))
run = _build_run(text, bold=bold, italic=italic)
bold = style in ("heading", "bold")
italic = style == "quote"
size = None
if style == "heading":
size = 28 # 14pt
elif style == "quote":
size = 22 # 11pt
run = _build_run(text, bold=bold, italic=italic, size_half_pt=size)
p.append(run)
return p
@@ -342,7 +275,7 @@ def _wrap_in_ins(elements: list[etree._Element], *, ins_id: int,
def _make_tracked_paragraph_insert(
text: str, *, style: StyleType, ins_id: int, author: str, date_iso: str,
mark_id: int | None = None, style_id: str | None = None,
mark_id: int | None = None,
) -> etree._Element:
"""Build a whole tracked-inserted paragraph.
@@ -353,7 +286,7 @@ def _make_tracked_paragraph_insert(
"""
if mark_id is None:
mark_id = ins_id
p = _build_paragraph(text, style=style, style_id=style_id)
p = _build_paragraph(text, style=style)
pPr = p.find(_w("pPr"))
assert pPr is not None
rPr = pPr.find(_w("rPr"))
@@ -406,7 +339,6 @@ def _apply_insert(
ins_id: int,
author: str,
date_iso: str,
style_ids: dict[str, str] | None = None,
) -> RevisionResult:
"""Apply insert_after / insert_before relative to a bookmark."""
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
@@ -430,7 +362,6 @@ def _apply_insert(
revision.content, style=revision.style,
ins_id=ins_id, mark_id=ins_id + 1,
author=author, date_iso=date_iso,
style_id=(style_ids or {}).get(revision.style),
)
parent = enclosing_p.getparent()
@@ -476,7 +407,6 @@ def _apply_replace(
del_id: int,
author: str,
date_iso: str,
style_ids: dict[str, str] | None = None,
) -> RevisionResult:
"""Replace = delete the existing paragraph + insert new one after it."""
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
@@ -498,7 +428,6 @@ def _apply_replace(
revision.content, style=revision.style,
ins_id=ins_id, mark_id=ins_id + 1,
author=author, date_iso=date_iso,
style_id=(style_ids or {}).get(revision.style),
)
idx = list(parent).index(enclosing_p)
parent.insert(idx + 1, new_p)
@@ -544,10 +473,6 @@ def apply_tracked_revisions(
members, doc_tree, settings_tree = _load_docx_xml(source_path)
_ensure_track_revisions(settings_tree)
# Resolve template style ids once (INV-EX4) — inserted paragraphs inherit
# font/size/indent from these named styles instead of hand-set rFonts/sz.
style_ids = _resolve_style_ids(members)
next_id = _next_revision_id(doc_tree)
batch = RevisionBatchResult()
@@ -555,8 +480,7 @@ def apply_tracked_revisions(
try:
if rev.type in ("insert_after", "insert_before"):
result = _apply_insert(doc_tree, rev, ins_id=next_id,
author=author, date_iso=date_iso,
style_ids=style_ids)
author=author, date_iso=date_iso)
# insert consumes 2 IDs: run-wrapper + paragraph-mark
next_id += 2
elif rev.type == "delete":
@@ -566,8 +490,7 @@ def apply_tracked_revisions(
elif rev.type == "replace":
result = _apply_replace(doc_tree, rev,
ins_id=next_id, del_id=next_id + 2,
author=author, date_iso=date_iso,
style_ids=style_ids)
author=author, date_iso=date_iso)
# replace consumes 3 IDs: ins-run, ins-mark, del
next_id += 3
else:

View File

@@ -1,106 +0,0 @@
"""Bearer-token gate for the HTTP transport of the MCP server (#231.2).
Why this exists
---------------
Over ``stdio`` the protection is the pipe itself: only a process that already
runs as this user can speak to the server. ``streamable-http`` removes that
property entirely — anything that can reach the socket can call any of the 108
registered tools, and the registry includes ``case_delete``,
``precedent_library_delete``, ``document_upload`` and every block-writing tool.
An unauthenticated listener is therefore a delete-any-case endpoint.
The agent platform already speaks this dialect: it injects each runtime MCP
server as ``{type:"http", name, url, headers:[{name:"Authorization",
value:"Bearer <token>"}]}``. So a static bearer token is exactly the shape the
caller will present — no negotiation, no OAuth dance.
Design notes
------------
- We implement the SDK's own ``TokenVerifier`` protocol and let
``BearerAuthBackend`` do the enforcement, rather than adding bespoke
middleware. One auth path, the framework's (G2).
- The token is read from the environment, which the service unit populates from
Infisical. It is never defaulted, never logged, and never embedded here.
- Comparison is constant-time: a naive ``==`` leaks the token byte-by-byte to a
caller who can time responses.
- ``verify_token`` returns ``None`` (not an exception) on mismatch — that is the
protocol's "reject" signal and yields a clean 401 instead of a 500 that would
read as a server fault.
"""
from __future__ import annotations
import hmac
import logging
import os
from mcp.server.auth.provider import AccessToken, TokenVerifier
logger = logging.getLogger("legal_mcp.http_auth")
#: Environment variable carrying the shared bearer token.
#:
#: Name matches the Infisical key exactly — All Infrastructure / env `main` /
#: `/apps/legal-ai` / ``MCP_HTTP_SHARED_SECRET``, tagged ``credentials``. Keeping
#: the two identical means nobody has to hold a mapping in their head, and it
#: follows the two bridge tokens already in that folder
#: (``COURT_FETCH_SHARED_SECRET``, ``LEGAL_CHAT_SHARED_SECRET``).
TOKEN_ENV = "MCP_HTTP_SHARED_SECRET"
#: Minimum acceptable token length. Short tokens are brute-forceable; refusing
#: them at startup is cheaper than discovering it from an access log.
MIN_TOKEN_LEN = 32
#: Reported as the authenticated principal. Single shared token today, so this
#: is a constant rather than a real identity — kept explicit so that audit rows
#: never imply per-agent attribution we cannot actually make.
CLIENT_ID = "mcp-http-shared"
class MissingTokenError(RuntimeError):
"""Raised when the HTTP transport is requested without a usable token.
Deliberately fatal. The tempting alternative — start anyway and log a
warning — produces a listener that looks healthy and answers every
destructive tool call. Refusing to boot is the safe failure (§6: never
swallow, never degrade silently).
"""
def load_token() -> str:
"""Return the configured bearer token, or raise if it is unusable."""
token = (os.environ.get(TOKEN_ENV) or "").strip()
if not token:
raise MissingTokenError(
f"{TOKEN_ENV} is not set. The HTTP transport exposes destructive "
f"tools and will not start without a bearer token. Set it from "
f"Infisical, or use MCP_TRANSPORT=stdio.",
)
if len(token) < MIN_TOKEN_LEN:
raise MissingTokenError(
f"{TOKEN_ENV} is shorter than {MIN_TOKEN_LEN} characters — refusing "
f"to start. Generate a long random token.",
)
return token
class StaticTokenVerifier(TokenVerifier):
"""Verifies the single shared bearer token presented by the platform.
Not an identity system: it answers "may this caller in at all", not "who is
it". Per-agent attribution would need per-agent tokens, which is a later
step once profiles exist (#231.4).
"""
def __init__(self, expected: str) -> None:
self._expected = expected
async def verify_token(self, token: str) -> AccessToken | None:
# compare_digest over bytes; it tolerates unequal lengths without
# short-circuiting, which is the whole point.
if not hmac.compare_digest(token.encode("utf-8"),
self._expected.encode("utf-8")):
# No token material in the log line — only the fact of a rejection.
logger.warning("Rejected MCP HTTP request: bearer token mismatch")
return None
return AccessToken(token=token, client_id=CLIENT_ID, scopes=[])

View File

@@ -98,16 +98,15 @@ async def search_precedent_library_hybrid(
is_binding: bool | None = None,
subject_tag: str = "",
include_halachot: bool = True,
source_kind: str = "",
source_kind: str = "external_upload",
district: str = "",
chair_name: str = "",
max_per_case_law: int = 2,
) -> list[dict]:
"""Hybrid wrapper for precedent-library search.
source_kind='' → the whole corpus (default, #232)
source_kind='external_upload' → court rulings only
source_kind='internal_committee' → appeals-committee decisions only
source_kind='external_upload' → court rulings (default)
source_kind='internal_committee' → appeals-committee decisions
max_per_case_law: MMR-style diversity cap — at most N hits per
case_law_id in the final ranked list (default 2). Prevents a
single precedent from monopolizing the result list when many of
@@ -303,21 +302,6 @@ def _merge_sem_lex(
if key in lex_row_by_key else 0.0
d["lex_rank"] = lex_rank or 0
d["score"] = sem_term + lex_term
# ``score`` is now an RRF value (~0.008-0.02), NOT a cosine. Carry the
# cosine forward under ``relevance`` so thresholding callers keep a
# stable scale — without it, a caller comparing score >= 0.45 silently
# drops every result the moment the lexical leg returns anything, which
# is exactly how the citation-verification tab ended up showing no
# supporting precedent for any argument.
#
# A lexical-only row has no cosine at all. It gets ``relevance = None``
# rather than 0.0: "we did not measure this" is not "measured as
# irrelevant", and the row earned its place by BM25 rank. Callers decide
# (see case_citation_verification) — but they must decide knowingly.
if key in sem_row_by_key:
d["relevance"] = float(sem_row_by_key[key].get("relevance", d["sem_score"]))
else:
d["relevance"] = None
merged.append(d)
merged.sort(key=lambda x: -float(x["score"]))

View File

@@ -59,28 +59,6 @@ ANTI_PATTERNS: list[dict] = [
"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
7293% 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_LENGTHS = {

View File

@@ -1,208 +0,0 @@
"""סיכום-מנהלים של טענות הצדדים — מסמך פרוזה מזוקק להכנת היו"ר לדיון (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}
## כללי-כתיבה מחייבים:
- **זיקוק, לא שכפול.** אל תעתיק את כתבי-הטענות. תמצת כל צד ל-25 משפטי-מפתח. היעד: עמוד אחד עד שניים, לא יותר.
- **מבוסס-קלט בלבד (חובה אנטי-הזיה).** הסתמך אך-ורק על הטענות שבקלט לעיל. אסור להמציא טענה, הלכה, פסק-דין, מספר או עובדה שאינם בקלט. טענה שאינה ברורה או חסרה — ציין זאת במפורש ("הטענה לא פורטה"), אל תשלים מדמיונך.
- **ניטרלי.** הצג את עמדת כל צד בנאמנות, בלי להעריך, בלי להכריע ובלי לרמוז על תוצאה. אין זו החלטה — זו הכנה.
- **מבנה קבוע:**
1. פסקת-פתיחה קצרה (משפט–שניים): במה עוסק הערר ומיהם הצדדים.
2. **טענות העוררים** — תמצית מזוקקת.
3. **טענות המשיבים / הוועדה / מבקשי ההיתר** — תמצית מזוקקת (כל צד שקיים בקלט).
4. **נקודות-המחלוקת המרכזיות** — 24 הסוגיות שעליהן ניצבת ההכרעה, כשאלות פתוחות (לא תשובות).
- **סגנון:** עברית משפטית בהירה, גוף-שלישי לתיאור הצדדים. בלי כותרת "החלטה". בלי חתימות.
## מדריך-סגנון (לטון בלבד — אל תיישם מבנה-החלטה):
{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}).",
}

View File

@@ -477,7 +477,7 @@ async def list_precedents(
precedent_level: str = "",
source_type: str = "",
search: str = "",
source_kind: str = "",
source_kind: str = "external_upload",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
@@ -503,18 +503,12 @@ async def search_library(
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
source_kind: str = "",
) -> list[dict]:
"""Semantic search merging halachot (rule-level) and chunks (passage-level).
Only ``approved`` / ``published`` halachot are returned, per chair-review
policy. Chunks are returned regardless of halacha review status.
``source_kind=""`` (default) covers the whole corpus — court rulings and
appeals-committee decisions together. It used to be hard-wired to
``external_upload`` here, which made 104 committee decisions
unreachable through this entry point (#232).
When ``VOYAGE_RERANK_ENABLED`` is set, results are passed through
voyage rerank-2 (cross-encoder). The +0.05 halacha boost from
``search_precedent_library_semantic`` is preserved before rerank
@@ -535,5 +529,4 @@ async def search_library(
is_binding=is_binding,
subject_tag=subject_tag,
include_halachot=include_halachot,
source_kind=source_kind,
)

View File

@@ -22,7 +22,6 @@ from __future__ import annotations
import logging
import re
import unicodedata
from datetime import date as date_type
from uuid import UUID
@@ -73,7 +72,7 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
"proceeding_type": "אחד מ-2 (רק להחלטות ועדת ערר): 'ערר' (הליך ערר עיקרי על החלטת ועדה מקומית) / 'בל\\\"מ' (בקשה להארכת מועד להגשת ערר). זהה דרך כותרת המסמך: 'ערר (ועדות ערר ...) NNNN/YY''ערר'; 'בל\\\"מ NNNN/YY' או נושא 'בקשה להארכת מועד להגשת ערר''בל\\\"מ'. בפסיקת בית משפט (לא ועדת ערר) — מחרוזת ריקה.",
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
"case_number_clean": "מספר הערר/תיק **בדיוק כפי שמופיע בכותרת** — רק ספרות ומפרידים. שתי צורות קיימות ושתיהן חוקיות: דו-חלקית ('1062/24', '8031/21') ותלת-חלקית של ועדות ערר ('1094-09-19', '85074-09-24' — סידורי-חודש-שנה). **העתק את הספרות מהמסמך; אל תשלים, אל תנחש ואל תתקן ספרה.** ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. **אם המספר אינו מופיע בטקסט — מחרוזת ריקה** (הקוד דוחה ממילא מספר שאינו מעוגן בטקסט).",
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.",
"chair_name": "שם יו\\\"ר ההרכב של **ההחלטה הזו** — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. כמעט תמיד מופיע — בשני מקומות: (א) בכותרת/רובריקה בראש המסמך, ליד 'בפני:' / 'בהרכב:' / רשימת חברי הוועדה; (ב) בבלוק-החתימה בסוף ההחלטה, אחרי 'ההחלטה ניתנה' — שם מופיעים זה-לצד-זה מזכיר/ת הוועדה והיו\\\"ר (למשל בשתי עמודות: בצד אחד 'פלוני, עו\\\"ד / מזכיר ועדת הערר' ובצד השני 'אלמוני, עו\\\"ד / יו\\\"ר ועדת הערר'). **קח את השם שמעליו/לצדו כתוב 'יו\\\"ר' — לא את המזכיר/ה.** השאר שם פרטי+משפחה בלבד, בלי תוארים ('עו\\\"ד', 'אדריכל', 'עו\\\"ד דפנה תמיר''דפנה תמיר'). **אזהרה קריטית:** אל תיקח שם יו\\\"ר של פסק/החלטה אחרים ש**מצוטטים** בגוף ההחלטה (למשל 'כפי שנקבע ברשותה של יו\\\"ר פלונית בערר אחר...') — אלה תקדימים מצוטטים, לא היו\\\"ר של ההחלטה הנוכחית. אם זה פסק דין של בית משפט — מחרוזת ריקה.",
"district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים''ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו''תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
"parties": "שמות הצדדים בשורה אחת בצורה 'עורר נ\\' משיב' — בדיוק כפי שמופיעים בכותרת/רובריקה. בלי הדגשה, בלי מספר-תיק, בלי תוארים מיותרים. למשל 'ישיבת חברת אהבת שלום נ\\' תאיה' או 'ראם חיים נ\\' הוועדה המקומית לתכנון ובניה ירושלים'. אם הצדדים אינם מופיעים בטקסט (למשל החלטה שמתחילה בגוף בלי רובריקה) — מחרוזת ריקה. **אל תמציא שמות.**",
@@ -243,37 +242,6 @@ def _is_clean_docket(s: str) -> bool:
return bool(_DOCKET_RE.fullmatch((s or "").strip()))
def _strip_invisibles(s: str) -> str:
"""Drop Unicode format chars (category Cf) — RLM/LRM/ZWJ and friends.
Hebrew legal PDFs carry bidi marks *inside* docket numbers, so a plain
substring test against the raw text misses a docket that is plainly there.
"""
return "".join(ch for ch in (s or "") if unicodedata.category(ch) != "Cf")
def _docket_grounded(docket: str, *sources: str) -> bool:
"""True when every digit group of ``docket`` appears, in order, in a source.
INV-AH (quote-or-retract) applied to the identity field. ``_is_clean_docket``
only checks the *shape*, so a model that misreads one digit produces a
perfectly well-formed but wrong docket — which is exactly how ערר 1094-09-19
(פדילה) was stored as ``1094-09-14`` while all five case documents said
...-19 (#232, trap 3). Shape validation cannot catch that; grounding can.
Tolerant of the separator (``-`` vs ``/``), of whitespace around it, and of
bidi marks, so a real docket in the source still matches. Sources are the
document text and the value being replaced — never the LLM's own output,
which would make the check circular.
"""
parts = re.split(r"[-/]", (docket or "").strip())
if not parts or not all(p.isdigit() for p in parts):
return False
pattern = r"\s*[-/]\s*".join(re.escape(p) for p in parts)
haystack = _strip_invisibles("\n".join(s or "" for s in sources))
return re.search(pattern, haystack) is not None
def _source_type_for_level(level: str) -> str:
"""Derive source_type from precedent_level — the library section is driven by
source_type, so the two MUST agree (an LLM slip pairing
@@ -432,23 +400,6 @@ async def apply_to_record(
"already owned by another non-internal row (likely duplicate)",
cur_cn, cn_clean,
)
elif not _docket_grounded(
cn_clean,
record.get("full_text") or "",
cur_cn,
record.get("citation_formatted") or "",
):
# The docket is well-formed but appears nowhere in the decision text
# or in the value it would replace — i.e. the model produced digits
# it cannot point at. case_number is the identity field; a wrong one
# silently detaches the row from every reference to the real case.
# Refuse the write and say so (§6) rather than trust the shape.
logger.warning(
"metadata_extractor: case_number normalization %r%r REFUSED — the "
"docket does not appear in the decision text or in the current "
"value (ungrounded extraction, INV-AH). Keeping %r.",
cur_cn, cn_clean, cur_cn,
)
else:
fields_to_update["case_number"] = cn_clean

View File

@@ -1,400 +0,0 @@
"""ניתוח פרוטוקול-דיון השוואתי (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,
}

View File

@@ -15,14 +15,8 @@ import os
import re
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from typing import Any
# Display timezone (INV-UI9): timestamps surfaced to humans are rendered in
# Israel time deterministically, regardless of the process clock (the container
# may run UTC or Asia/Jerusalem). Storage stays UTC; this is display-only.
IL_TZ = ZoneInfo("Asia/Jerusalem")
# Placeholder strings — any of these means "not yet filled"
CHAIR_POSITION_PLACEHOLDERS = (
"[ימולא ע\"י יו\"ר הוועדה]",
@@ -43,18 +37,8 @@ SUBSECTION_RE = re.compile(r"^###\s+(.+?)$", re.MULTILINE)
# Matches "**LABEL:**" field markers — handles both inline and block variants:
# "**עמדת המבקשת:** Some text on same line"
# "**שאלות משפטיות:**\n1. First question"
# and both the bare and the list-item form, because the analyst agent writes
# threshold claims as a bullet list ("- **עמדת ועדת הערר:**") while it writes
# issues bare. Group 1 is the list marker (or None), group 2 is the label.
# The label itself must not contain ** or newlines.
FIELD_LABEL_RE = re.compile(
r"^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)?\*\*([^\n*]+?):\*\*[ \t]*",
re.MULTILINE,
)
# Terminators that end a field's content even without a following field label:
# a heading, or a horizontal rule closing the subsection.
FIELD_TERMINATOR_RE = re.compile(r"^(?:#{2,}[ \t]|[ \t]*---[ \t]*$)", re.MULTILINE)
FIELD_LABEL_RE = re.compile(r"^\*\*([^\n*]+?):\*\*[ \t]*", re.MULTILINE)
# Matches the case number in the H1
CASE_NUMBER_RE = re.compile(r"#\s*ניתוח.*?ערר\s+([\d/\-]+)", re.MULTILINE)
@@ -63,17 +47,6 @@ CASE_NUMBER_RE = re.compile(r"#\s*ניתוח.*?ערר\s+([\d/\-]+)", re.MULTILIN
DATE_RE = re.compile(r"^תאריך:\s*(.+?)\s*$", re.MULTILINE)
def _is_chair_label(label: str) -> bool:
"""Is this field label the chair-position field?
Matches on prefix, not equality, because the analyst sometimes decorates the
label with a parenthetical — "עמדת ועדת הערר (הכוונת יו"ר 24.6)". Requiring
equality made the reader treat those as ordinary fields and the writer append
a second, duplicate block instead of updating the existing one.
"""
return label.strip().startswith(CHAIR_POSITION_LABEL)
def _is_placeholder(text: str) -> bool:
"""Check if a field value is one of the placeholder strings (empty)."""
stripped = text.strip()
@@ -156,7 +129,7 @@ def _extract_fields(text: str) -> list[dict]:
fields = []
for i, m in enumerate(matches):
label = m.group(2).strip()
label = m.group(1).strip()
content_start = m.end()
content_end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
content = text[content_start:content_end].strip()
@@ -185,17 +158,11 @@ def _build_subsection_dict(
parts = title.split(": ", 1)
display_title = parts[1] if len(parts) > 1 else title
# Only the *first* chair-position field is the editable one — the same one
# update_chair_position writes to. A subsection that carries more than one
# (e.g. an H3 covering "סוגיות 46") keeps the extras as ordinary fields
# rather than silently reading back a different field than the one saved.
chair_position = ""
chair_seen = False
regular_fields = []
for f in fields:
if not chair_seen and _is_chair_label(f["label"]):
if f["label"] == CHAIR_POSITION_LABEL:
chair_position = _normalize_chair_position(f["content"])
chair_seen = True
else:
regular_fields.append(f)
@@ -224,7 +191,7 @@ def parse(file_path: Path) -> dict[str, Any]:
date_str = date_match.group(1) if date_match else ""
stat = file_path.stat()
mtime_iso = datetime.fromtimestamp(stat.st_mtime, tz=IL_TZ).isoformat()
mtime_iso = datetime.fromtimestamp(stat.st_mtime).isoformat()
result: dict[str, Any] = {
"header": {
@@ -338,46 +305,6 @@ def _find_subsection_by_id(
return None
def _split_trailing_rule(body: str) -> tuple[str, str]:
"""Split a subsection body into (content, trailing "---" separator).
Returns ("<body>", "") when the subsection has no closing rule.
"""
m = re.search(r"\n[ \t]*---[ \t]*\s*\Z", body)
if not m:
return body, ""
return body[: m.start()], body[m.start() :]
def _chair_field_span(body: str) -> tuple[int, int] | None:
"""Locate the chair-position field's *content* range in a subsection body.
Returns (content_start, content_end) — the slice the chair's text occupies,
excluding the "**LABEL:**" marker itself so the marker line (and any list
bullet in front of it) survives an update untouched. Returns None when the
subsection has no chair-position field yet.
Field boundaries come from FIELD_LABEL_RE — the same definition the reader
uses — so what update writes is exactly what parse reads back (G2: one
definition of a field, not two that can drift).
"""
matches = list(FIELD_LABEL_RE.finditer(body))
for i, m in enumerate(matches):
if not _is_chair_label(m.group(2)):
continue
content_start = m.end()
if i + 1 < len(matches):
content_end = matches[i + 1].start()
else:
content_end = len(body)
# A heading or closing rule ends the field even without a next label.
term = FIELD_TERMINATOR_RE.search(body, content_start, content_end)
if term:
content_end = term.start()
return content_start, content_end
return None
def update_chair_position(
file_path: Path, section_id: str, new_text: str
) -> dict[str, Any]:
@@ -397,62 +324,40 @@ def update_chair_position(
_abs_start, _abs_end, subsection_body = found
span = _chair_field_span(subsection_body)
body_text = new_text.strip() or CHAIR_POSITION_PLACEHOLDERS[0]
if span is None:
# Label not present — append it at the end of the subsection,
# before a trailing horizontal rule if there is one.
head, tail = _split_trailing_rule(subsection_body)
new_block = f"\n\n**{CHAIR_POSITION_LABEL}:**\n{body_text}\n"
new_subsection = head.rstrip() + new_block + tail
# Find the "**עמדת ועדת הערר:**" label within this subsection
label_pattern = re.compile(
r"(\*\*" + re.escape(CHAIR_POSITION_LABEL) + r":\*\*)\s*\n?([^*]*?)(?=\n\*\*|\n##|\n---|\Z)",
re.DOTALL,
)
m = label_pattern.search(subsection_body)
if not m:
# Label not present — append it at the end of the subsection
# (just before the trailing --- if any)
new_block = f"\n\n**{CHAIR_POSITION_LABEL}:**\n{new_text.strip()}\n"
new_subsection = subsection_body.rstrip() + new_block
new_content = content[:_abs_start] + new_subsection + content[_abs_end:]
else:
# Replace the existing content of the chair_position field, keeping the
# label line exactly as written (including any list marker) so the file
# structure the analyst produced is preserved.
content_start, content_end = span
# Replace the existing content of the chair_position field
replacement = f"{m.group(1)}\n{new_text.strip() if new_text.strip() else CHAIR_POSITION_PLACEHOLDERS[0]}\n"
new_subsection = (
subsection_body[:content_start].rstrip("\r\n \t")
+ f"\n{body_text}\n"
+ subsection_body[content_end:]
subsection_body[: m.start()] + replacement + subsection_body[m.end():]
)
new_content = content[:_abs_start] + new_subsection + content[_abs_end:]
new_content = content[:_abs_start] + new_subsection + content[_abs_end:]
# Atomic write
tmp_path = file_path.with_suffix(file_path.suffix + ".tmp")
tmp_path.write_text(new_content, encoding="utf-8") # noqa: STG1 — atomic .tmp; in-place edit, S3 re-sync in Phase-2 read-wiring
os.replace(tmp_path, file_path)
# Read-after-write: a position the parser cannot read back is not saved,
# however cleanly the write itself succeeded. Reporting success here is what
# let a whole class of format drift hide behind a green "נשמר" in the UI.
stored = _stored_chair_position(file_path, section_id)
expected = _normalize_chair_position(new_text)
if stored != expected:
raise RuntimeError(
f"העמדה נכתבה ל-{file_path.name} אך לא נקראה בחזרה עבור {section_id} "
f"— ככל הנראה מבנה השדה בקובץ חורג מהתבנית הצפויה"
)
preview = new_text.strip()[:120]
return {
"saved": True,
"section_id": section_id,
"position": stored,
"preview": stored[:120],
"timestamp": datetime.now(IL_TZ).isoformat(),
"preview": preview,
"timestamp": datetime.now().isoformat(),
}
def _stored_chair_position(file_path: Path, section_id: str) -> str:
"""Re-parse the file and return the chair position now stored for a section."""
parsed = parse(file_path)
for item in parsed.get("threshold_claims", []) + parsed.get("issues", []):
if item["id"] == section_id:
return item.get("chair_position", "") or ""
return ""
# ── Chair directions extraction (for downstream agents) ─────────

View File

@@ -15,7 +15,6 @@ import re
from uuid import UUID
from legal_mcp.services import db
from legal_mcp.services.learning_loop import compute_diff_stats
from legal_mcp.services.lessons import ANTI_PATTERNS, GOLDEN_RATIOS, canonical_outcome
logger = logging.getLogger(__name__)
@@ -123,100 +122,6 @@ def golden_ratio_adherence(block_word_counts: dict[str, int], outcome: str) -> d
return {"outcome": outcome, "total_words": total, "sections": sections, "max_deviation": max_dev}
def split_final_by_section(final_text: str) -> dict[str, str]:
"""Group a signed final decision into golden-ratio sections (#208 calibration).
Reuses the SAME structure-aware splitter as measure_corpus_ratios
(chunker._split_into_sections + _CHUNK_SECTION_TO_GOLDEN) — no parallel
parsing path (G2). Returns {golden_section: concatenated_text} for the
sections that map to an AI block (background/claims/discussion/summary).
A section type that does not map (e.g. headers) is dropped, never silently
folded into another section. Best-effort: an unsplittable final returns {}.
"""
from legal_mcp.services.chunker import _split_into_sections
by_section: dict[str, list[str]] = {}
for stype, stext in _split_into_sections(final_text or ""):
g = _CHUNK_SECTION_TO_GOLDEN.get(stype)
if g and stext.strip():
by_section.setdefault(g, []).append(stext.strip())
return {sec: "\n\n".join(parts) for sec, parts in by_section.items()}
def block_distance_to_final(
block_id: str,
regenerated_text: str,
final_section_text: str,
outcome: str,
section_target_total_words: int | None = None,
) -> dict:
"""Distance of ONE regenerated block from the chair's matching final section.
The per-(block, effort) measurement cell for the #208 model/effort
calibration harness. Pure/deterministic (no LLM, no DB) — reuses the
existing style-distance primitives so the harness has no parallel metric
path (G2 / INV-G8 eval-harness):
• change_percent — compute_diff_stats(regen, final_section)
(learning_loop, the SAME diff the pairing
ledger stores). Lower ⇒ the draft already
reads like the final ⇒ less chair rewriting.
• anti_pattern_total — count_anti_patterns(regen) (lessons.ANTI_PATTERNS).
Lower ⇒ closer to Dafna's continuous-narrative
voice; the CLEANEST style signal (07-learning §0.7).
• golden_ratio_deviation_pp — |regen %-of-total final %-of-total| for this
block's section. 0 ⇒ same structural weight as
the final. Requires the final's total words
(section_target_total_words); otherwise None
(we never fabricate a denominator).
Returns the three metrics + a single composite `distance` (normalized,
lower=closer) the harness ranks efforts by.
"""
outcome = canonical_outcome(outcome)
diff = compute_diff_stats(regenerated_text or "", final_section_text or "")
change_percent = diff["change_percent"]
anti = 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:
"""Assemble the 3 style-distance components for one case (T7)."""
case = await db.get_case_by_number(case_number)

View File

@@ -13,7 +13,6 @@ from uuid import UUID
import httpx
from legal_mcp import config
from legal_mcp.case_status_model import STATUS_ORDER # status SSoT
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
@@ -342,8 +341,16 @@ async def case_update(
"""
from datetime import date as date_type
# Ordered core lifecycle (forward-only regression guard). Single source of
# truth: legal_mcp/case_status_model.py — STATUS_ORDER/BY_KEY derive from it.
# Ordered core lifecycle — regression protection (forward-only).
# Single source of truth, mirrored by web-ui/src/lib/api/case-status.ts and
# models.CaseStatus. Trimmed from 17 → 10 (decorative statuses removed).
STATUS_ORDER = [
"new", "processing", "documents_ready",
"outcome_set", "direction_approved",
"qa_review", "drafted",
"exported", "reviewed", "final",
]
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
@@ -356,9 +363,6 @@ async def case_update(
# Only update if advancing or status is unknown to the order
if new_idx >= cur_idx or new_idx == -1:
fields["status"] = status
# per-status on_enter hook (case_status_model) — the single place a
# status's behaviour is declared. No dispatcher wired yet; when the
# first action is added, dispatch BY_KEY[status].on_enter here.
if title:
fields["title"] = title
if subject:

View File

@@ -350,15 +350,11 @@ async def extract_claims(
if not docs:
return empty(f"אין מסמכים בתיק {case_number}.")
# 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).
# Filter to claims documents (appeal, response) or specific doc
if doc_title:
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
else:
docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection", "protocol")]
docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection")]
if not docs:
return empty("לא נמצאו כתבי טענות בתיק.")
@@ -433,24 +429,17 @@ ALLOWED_DOC_TYPES = {
# Allowed appraiser_side values; '' (empty) clears the tag.
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
# Allowed protocol_scope values (protocol docs only); mirrors web/app.py.
# appeal = ועדת הערר (default when absent) · lower = ועדה מקומית/מחוזית.
ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
async def document_update(
case_number: str,
doc_id: str,
doc_type: str = "",
appraiser_side: str = "",
is_post_hearing: bool | None = None,
protocol_scope: str = "",
) -> str:
"""עדכון תיוג מסמך — doc_type / appraiser_side / is_post_hearing / protocol_scope.
"""עדכון תיוג מסמך — doc_type ו/או appraiser_side. ריק = אין שינוי.
הולידציה זהה ל-PATCH endpoint ב-web/app.py. הדגלים נשמרים ב-
documents.metadata JSONB (appraiser_side מתפרסם משם ע"י extract_appraiser_facts;
is_post_hearing נצרך ע"י כותב בלוק-ח; protocol_scope מסווג פרוטוקול בטאב-הסקירה).
הולידציה זהה ל-PATCH endpoint ב-web/app.py. appraiser_side נשמר ב-
documents.metadata JSONB (מתפרסם משם ע"י extract_appraiser_facts).
Args:
case_number: מספר תיק הערר (לאישור שייכות)
@@ -459,9 +448,6 @@ async def document_update(
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
is_post_hearing: True = התקבל אחרי הדיון · False = מנקה את הדגל · None = אין שינוי.
protocol_scope: היקף פרוטוקול (appeal=ועדת הערר / lower=ועדה מקומית-מחוזית).
ריק = אין שינוי. רלוונטי רק כשהסוג protocol.
"""
case = await db.get_case_by_number(case_number)
if not case:
@@ -487,37 +473,17 @@ async def document_update(
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
updates["doc_type"] = doc_type
# appraiser_side / protocol_scope are optional. The MCP tool can't
# distinguish "skip" from "set to empty string", so we use the convention:
# only update if non-empty. To clear, edit metadata directly (rare).
# is_post_hearing is a tri-state bool: None skips, True/False set/clear.
metadata = doc.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
metadata_dirty = False
# appraiser_side is optional. The MCP tool can't distinguish "skip" from
# "set to empty string", so we use the convention: only update if non-empty.
# To clear, the operator must edit metadata directly (rare).
if appraiser_side:
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
return err(f"appraiser_side לא תקין: {appraiser_side}",
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
metadata = doc.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
metadata["appraiser_side"] = appraiser_side
metadata_dirty = True
if is_post_hearing is not None:
if is_post_hearing:
metadata["is_post_hearing"] = True
else:
metadata.pop("is_post_hearing", None)
metadata_dirty = True
if protocol_scope:
if protocol_scope not in ALLOWED_PROTOCOL_SCOPES:
return err(f"protocol_scope לא תקין: {protocol_scope}",
data={"allowed": sorted(ALLOWED_PROTOCOL_SCOPES)})
metadata["protocol_scope"] = protocol_scope
metadata_dirty = True
if metadata_dirty:
updates["metadata"] = metadata
if not updates:

View File

@@ -580,86 +580,6 @@ async def get_appraiser_facts(case_number: str) -> str:
return err(str(e))
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203).
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
מחדד את השאלות המשפטיות, ומחלץ את נתוני הכותרת (א–ד) מהפרוטוקול. התוצאה נכנסת
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
מוזן חזרה לעמודה הקנונית cases.hearing_date.
בוחר אוטומטית את פרוטוקול **ועדת-הערר** (protocol_scope שאינו 'lower');
פרוטוקול ועדה-מקומית ('lower') אינו נכנס להשוואה. כשיש כמה פרוטוקולי-ערר —
ציין document_id כדי לכוון לדיון המדויק (#223).
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
Args:
case_number: מספר תיק הערר
document_id: מזהה מסמך-הפרוטוקול לכיוון מדויק (ריק = בחירה אוטומטית)
"""
from legal_mcp.services import protocol_analyzer
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"])
target_doc_id: UUID | None = None
if document_id.strip():
try:
target_doc_id = UUID(document_id.strip())
except ValueError:
return err(f"document_id לא תקין: {document_id}")
try:
result = await protocol_analyzer.analyze_protocol(
case_id, target_document_id=target_doc_id,
)
await audit.log_action_safe(
"analyze_protocol", case_id=case_id,
details={"status": result.get("status"), "total": result.get("total", 0)},
)
return ok(result)
except Exception as e:
return err(str(e))
async def get_protocol_analysis(case_number: str, change_type: str = "") -> str:
"""קריאת ניתוח-הפרוטוקול שכבר חולץ — ללא הרצת ניתוח-מחדש (INV-TOOL4).
ה-get המקביל ל-analyze_protocol: מחזיר את רשומות ידע-התיק השמורות
(ירדה/חוזקה/חדשה + שאלה-מחודדת + ציטוט-מבסס), בלי קריאת-LLM יקרה.
מחזיר רשימה ריקה אם הניתוח טרם רץ (status=ok, count=0) — לא שגיאה.
Args:
case_number: מספר תיק הערר
change_type: סינון (dropped/strengthened/newly_raised). ריק = הכל.
"""
from legal_mcp.services import protocol_analyzer
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
if change_type and change_type not in protocol_analyzer.VALID_CHANGE_TYPES:
return err(
f"change_type לא תקין: {change_type}",
data={"allowed": sorted(protocol_analyzer.VALID_CHANGE_TYPES)},
)
case_id = UUID(case["id"])
try:
rows = await db.list_protocol_analysis(
case_id, change_type=change_type or None,
)
return ok({
"case_number": case_number,
"count": len(rows),
"analysis": rows,
})
except Exception as e:
return err(str(e))
async def write_interim_draft(case_number: str, instructions: str = "") -> str:
"""כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט),
טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין —
@@ -716,7 +636,7 @@ async def write_interim_draft(case_number: str, instructions: str = "") -> str:
async def export_interim_draft(case_number: str, output_path: str = "") -> str:
"""ייצוא טיוטת ביניים ל-DOCX — אותו עיצוב של טיוטה רגילה (David, RTL,
bookmarks), אבל בסדר חדש: רקע → תכניות+היתרים → טענות → הליכים, ללא
דיון/סיכום/חתימות. שם הקובץ: טיוטה-טענות_הצדדים_{N}.docx.
דיון/סיכום/חתימות. שם הקובץ: טיוטת-ביניים-v{N}.docx.
Args:
case_number: מספר תיק הערר
@@ -747,34 +667,6 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
return err(str(e))
async def summarize_party_claims(case_number: str, instructions: str = "") -> str:
"""הפקת סיכום-מנהלים מזוקק של טענות הצדדים — מסמך פרוזה **נפרד מטיוטת-הביניים**,
להכנת היו"ר לדיון בעל-פה. תמצית קצרה ומוקפדת (לא שכפול כתבי-הטענות), נגזרת
מהטיעונים המאוגדים (legal_arguments) או מ-claims הגולמיים — אותו מקור-אמת,
בלי חילוץ-מחדש. נשמר ל-data/cases/{n}/documents/research/ (git + S3) וניתן-לייצוא.
הייצור עובר claude_session (claude -p), נעוץ Opus 4.8 + effort=high. מתוחם
לטענות-התיק בלבד (anti-hallucination) — אינו ממציא טענה/הלכה/עובדה שאינה בקלט.
Args:
case_number: מספר תיק הערר
instructions: הנחיות נוספות מהיו"ר (אופציונלי)
"""
from legal_mcp.services import party_claims_summary
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"])
try:
result = await party_claims_summary.summarize_party_claims(
case_id, case_number, instructions,
)
return ok(result)
except ValueError as e:
return err(str(e))
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.

View File

@@ -96,13 +96,10 @@ async def precedent_library_list(
precedent_level: str = "",
source_type: str = "",
search: str = "",
source_kind: str = "",
source_kind: str = "external_upload",
limit: int = 100,
) -> str:
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים.
source_kind ריק (ברירת מחדל) = כל הקורפוס, כולל החלטות ועדות ערר.
"""
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים."""
rows = await precedent_library.list_precedents(
practice_area=practice_area,
court=court,
@@ -269,9 +266,8 @@ async def search_precedent_library(
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
source_kind: str = "",
) -> str:
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית — פסקי דין **והחלטות ועדות ערר**.
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית.
מחזיר תוצאות מעורבות: הלכות (rule-level, מאושרות בלבד) + קטעי טקסט
(passage-level). הלכות מקבלות boost קל בדירוג כי הן מזוקקות מראש.
@@ -286,9 +282,6 @@ async def search_precedent_library(
subject_tag: סינון לפי תגית נושא (לדוגמה "מועד_קביעת_שומה").
limit: מספר תוצאות מקסימלי.
include_halachot: האם לכלול הלכות (ברירת מחדל: כן).
source_kind: ריק (ברירת מחדל) = כל הקורפוס — פסקי דין והחלטות ועדות
ערר יחד. "external_upload" = פסקי בתי משפט בלבד;
"internal_committee" = החלטות ועדות ערר בלבד.
Returns: רשימה מדורגת. כל פריט הוא {"type": "halacha"|"passage", "score", ...}.
"""
@@ -306,7 +299,6 @@ async def search_precedent_library(
subject_tag=subject_tag,
limit=limit,
include_halachot=include_halachot,
source_kind=source_kind,
)
# X11 Phase 2 (#154): attach the incoming-citation authority breakdown so the
# research agent can WEIGH and ARGUE authority ("הלכה שאומצה ב-N החלטות ועדת-ערר")

View File

@@ -1,60 +0,0 @@
"""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']}"

View File

@@ -1,54 +0,0 @@
"""Regression tests for argument aggregation (#233).
Both tests cover the same 2026-08-05 incident from different angles: the
appellant side of 1069-04-26 sent 310 propositions in one Claude call, the call
came back as something other than a JSON array, and the code logged a warning
and returned ``[]``. The caller could not tell that apart from "this side has no
arguments", so ``aggregate_claims_to_arguments`` reported ``completed`` with the
central litigant of the appeal missing entirely.
"""
from __future__ import annotations
import pytest
from legal_mcp.services.argument_aggregator import (
MAX_PROPS_PER_CALL,
AggregationFailed,
_aggregate_party,
_chunk,
)
def test_chunk_preserves_every_proposition_and_their_order():
"""Chunking must not drop or reorder — losing claims here is invisible."""
props = [{"i": i} for i in range(310)]
chunks = _chunk(props, MAX_PROPS_PER_CALL)
assert sum(len(c) for c in chunks) == 310, "propositions were lost"
assert [p for c in chunks for p in c] == props, "order changed"
assert all(len(c) <= MAX_PROPS_PER_CALL for c in chunks)
@pytest.mark.asyncio
async def test_non_list_reply_raises_instead_of_dropping_the_side(monkeypatch):
"""A malformed reply must surface, never look like an empty side.
This is the exact 1069-04-26 failure. If this test ever goes back to
asserting ``== []``, the silent-drop bug has been reintroduced.
"""
async def _query_json(prompt, tools=""): # noqa: ARG001
return {"error": "not a list"}
monkeypatch.setattr(
"legal_mcp.services.argument_aggregator.claude_session.query_json",
_query_json,
)
with pytest.raises(AggregationFailed) as excinfo:
await _aggregate_party("appellant", [{"id": "x", "claim_text": "t"}])
# The message has to name the side, or an operator reading
# completed_with_errors cannot tell which litigant went missing.
assert "appellant" in str(excinfo.value)

View File

@@ -1,125 +0,0 @@
"""The citation view is cached by input fingerprint, not by a guessed TTL.
Assembling it costs 20+ seconds of Voyage embeds and vector searches. A TTL
would force a choice between staleness and cost: a chair who verifies a
precedent must see it on the very next load, and a timer cannot promise that.
Keying on a fingerprint of the inputs (arguments · attachments · corpus) makes
freshness a property of the data rather than of the clock.
"""
import asyncio
import pytest
from legal_mcp.services import case_citation_verification as ccv
@pytest.fixture(autouse=True)
def _clean_cache():
ccv.invalidate()
yield
ccv.invalidate()
def _stub(monkeypatch, fingerprint: str, calls: list, *, complete: bool = True):
"""Point build_view at a fake case, a controllable fingerprint, and a
counted assembler, so these tests never touch the corpus."""
async def fake_case(_cn):
return {"id": "00000000-0000-0000-0000-000000000001"}
async def fake_fp(_cid):
return fingerprint
async def fake_build(cn):
calls.append(cn)
return {"status": "ok", "case_number": cn, "arguments": [],
"retrieval_complete": complete, "summary": {}}
monkeypatch.setattr(ccv.db, "get_case_by_number", fake_case)
monkeypatch.setattr(ccv, "_inputs_fingerprint", fake_fp)
monkeypatch.setattr(ccv, "_build_view_uncached", fake_build)
@pytest.mark.asyncio
async def test_second_load_is_served_from_cache(monkeypatch):
calls: list = []
_stub(monkeypatch, "fp-1", calls)
first = await ccv.build_view("1069-04-26")
second = await ccv.build_view("1069-04-26")
assert first["cached"] is False
assert second["cached"] is True
assert len(calls) == 1, "the expensive assembly must run once"
@pytest.mark.asyncio
async def test_changed_inputs_invalidate_without_anyone_calling_invalidate(monkeypatch):
"""The chair verifies a precedent → fingerprint moves → next load rebuilds.
Correctness must not depend on a writer remembering to clear the cache."""
calls: list = []
_stub(monkeypatch, "fp-before", calls)
await ccv.build_view("1069-04-26")
_stub(monkeypatch, "fp-after", calls) # e.g. a new case_precedents row
again = await ccv.build_view("1069-04-26")
assert again["cached"] is False
assert len(calls) == 2
@pytest.mark.asyncio
async def test_concurrent_loads_do_not_stampede(monkeypatch):
"""Reload-during-a-slow-load is how the original outage compounded into
Postgres deadlocks. The later readers must wait, not pile on."""
calls: list = []
async def fake_case(_cn):
return {"id": "00000000-0000-0000-0000-000000000001"}
async def fake_fp(_cid):
return "fp-1"
async def slow_build(cn):
calls.append(cn)
await asyncio.sleep(0.05)
return {"status": "ok", "case_number": cn, "arguments": [],
"retrieval_complete": True, "summary": {}}
monkeypatch.setattr(ccv.db, "get_case_by_number", fake_case)
monkeypatch.setattr(ccv, "_inputs_fingerprint", fake_fp)
monkeypatch.setattr(ccv, "_build_view_uncached", slow_build)
await asyncio.gather(*(ccv.build_view("1069-04-26") for _ in range(6)))
assert len(calls) == 1, "six concurrent loads, one assembly"
@pytest.mark.asyncio
async def test_partial_view_expires_so_missing_suggestions_get_retried(monkeypatch):
"""A budget-exhausted view is cached to stop reload storms, but it must not
become permanent — the arguments with no suggestions deserve another try."""
calls: list = []
_stub(monkeypatch, "fp-1", calls, complete=False)
await ccv.build_view("1069-04-26")
_fp, expires_at, _view = ccv._view_cache["1069-04-26"]
assert expires_at is not None, "a partial view must carry an expiry"
monkeypatch.setattr(ccv.time, "monotonic", lambda: expires_at + 1)
after = await ccv.build_view("1069-04-26")
assert after["cached"] is False
assert len(calls) == 2
@pytest.mark.asyncio
async def test_complete_view_does_not_expire_on_a_timer(monkeypatch):
calls: list = []
_stub(monkeypatch, "fp-1", calls, complete=True)
await ccv.build_view("1069-04-26")
_fp, expires_at, _view = ccv._view_cache["1069-04-26"]
assert expires_at is None, "freshness comes from the fingerprint, not a clock"
@pytest.mark.asyncio
async def test_use_cache_false_always_rebuilds(monkeypatch):
calls: list = []
_stub(monkeypatch, "fp-1", calls)
await ccv.build_view("1069-04-26")
await ccv.build_view("1069-04-26", use_cache=False)
assert len(calls) == 2

View File

@@ -1,81 +0,0 @@
"""The citation-verification view must not fan out without a bound.
`build_view` used to spawn one retrieval task per legal argument with a bare
`asyncio.gather`. Each task opens a Voyage embed call, and past ~8 in flight
Voyage throttles — so the unbounded version was *slower* than a bounded one
(32 arguments: 30.4s unbounded vs 22.1s at 8), and a 69-argument case blew past
the 30s proxy timeout and returned 500 while overlapping reloads piled
contention into Postgres deadlocks.
These tests pin the two properties that fix gave us: the fan-out is bounded,
and a timeout yields the results that DID land instead of nothing.
"""
import asyncio
import pytest
from legal_mcp.services import case_citation_verification as ccv
def test_concurrency_bound_is_set_and_modest():
assert 1 <= ccv._MAX_CONCURRENT_LOOKUPS <= 16, (
"the bound exists to stay under Voyage's throttle point — a large value "
"reintroduces the stall this was added to fix"
)
def test_retrieval_budget_leaves_room_under_the_proxy_timeout():
"""The proxy gives up at 30s; we must cut ourselves off before that."""
assert 0 < ccv._RETRIEVAL_BUDGET_S < 30
@pytest.mark.asyncio
async def test_semaphore_actually_caps_in_flight_work():
"""A semaphore of N never lets N+1 coroutines run the body at once."""
limit = ccv._MAX_CONCURRENT_LOOKUPS
sem = asyncio.Semaphore(limit)
in_flight = 0
peak = 0
async def worker():
nonlocal in_flight, peak
async with sem:
in_flight += 1
peak = max(peak, in_flight)
await asyncio.sleep(0.01)
in_flight -= 1
await asyncio.gather(*(worker() for _ in range(limit * 4)))
assert peak <= limit
@pytest.mark.asyncio
async def test_timeout_harvests_finished_work_instead_of_discarding_it():
"""The regression: wait_for(gather(...)) cancels everything on timeout, so
one slow lookup threw away every result that had already succeeded. The
harvest pattern must keep them."""
async def quick(i):
await asyncio.sleep(0.01)
return i
async def never():
await asyncio.sleep(30)
return "unreachable"
tasks = [asyncio.ensure_future(quick(i)) for i in range(5)]
tasks.append(asyncio.ensure_future(never()))
done, pending = await asyncio.wait(tasks, timeout=0.3)
for t in pending:
t.cancel()
await asyncio.gather(*pending, return_exceptions=True)
harvested = [
t.result() if (t in done and not t.cancelled() and t.exception() is None) else None
for t in tasks
]
assert harvested[:5] == [0, 1, 2, 3, 4], "finished work must survive the timeout"
assert harvested[5] is None, "the unfinished one is empty, not fabricated"
assert len(pending) == 1

View File

@@ -1,65 +0,0 @@
"""#232 trap 3 — a well-formed docket is not necessarily the right docket.
ערר (מרכז) 1094-09-19 (פדילה) was stored as ``1094-09-14``: shape-valid, so
``_is_clean_docket`` waved it through, but wrong — and case_number is the
identity field, so the row detached from every reference to the real case.
Grounding the digits in the source text is what shape validation cannot do.
"""
from legal_mcp.services.precedent_metadata_extractor import (
_docket_grounded,
_is_clean_docket,
_strip_invisibles,
)
HEADER = "ערר (ועדות ערר - תכנון ובנייה מרכז) 1094-09-19 פדילה אברהים נ' הוועדה המקומית"
def test_the_regression_shape_valid_but_wrong_digit():
"""Both pass the shape check; only the real one is grounded."""
assert _is_clean_docket("1094-09-14")
assert _is_clean_docket("1094-09-19")
assert _docket_grounded("1094-09-19", HEADER)
assert not _docket_grounded("1094-09-14", HEADER)
def test_separator_and_spacing_are_tolerated():
"""A real docket must still match when the source writes it differently."""
assert _docket_grounded("1094-09-19", "בערר 1094/09/19 נקבע")
assert _docket_grounded("4768/22", "עת\"מ 4768-22 פלוני")
assert _docket_grounded("1132-09-24", "תיק 1132 - 09 - 24")
def test_bidi_marks_inside_the_number_do_not_defeat_grounding():
"""Hebrew legal PDFs embed RLM/LRM between digits and separators."""
noisy = "ערר (‏מרכז‏) 1094-09-19 פדילה"
assert _strip_invisibles(noisy).count("") == 0
assert _docket_grounded("1094-09-19", noisy)
def test_grounding_accepts_the_value_being_replaced():
"""Normalising an uploader's citation string into a clean docket is the
whole point of the rewrite — the digits come from there, not the text."""
citation = "ערר (ועדות ערר - תכנון ובנייה מרכז) 1094-09-19 פדילה נ' טירה (נבו 4.12.2019)"
assert _docket_grounded("1094-09-19", "", citation)
assert not _docket_grounded("1094-09-14", "", citation)
def test_two_and_three_part_dockets_both_ground():
assert _docket_grounded("8031/21", "בהיטל השבחה 8031/21 נדון")
assert _docket_grounded("85074-09-24", "בל\"מ 85074-09-24")
def test_non_numeric_or_empty_never_grounds():
assert not _docket_grounded("", HEADER)
assert not _docket_grounded("ערר 1094", HEADER)
assert not _docket_grounded("abc-de", HEADER)
def test_absent_from_every_source_is_refused():
"""The פדילה failure mode: text has no docket at all, so anything the
model offers is ungrounded and must not reach the identity field."""
body = "בפני: יו\"ר הוועדה: רונית אלפר, עו\"ד\nהעוררים: 1. פדילה אברהים"
assert not _docket_grounded("1094-09-14", body)
assert not _docket_grounded("1094-09-19", body)

View File

@@ -14,9 +14,7 @@ from lxml import etree
from legal_mcp.services.docx_exporter import (
_BOOKMARK_ID_START,
HEBREW_FONT,
TEMPLATE_PATH,
_add_styled_paragraph,
_clear_body,
_insert_bookmark_end,
_insert_bookmark_start,
_mark_paragraph_rtl,
@@ -170,83 +168,6 @@ def test_block_dalet_does_not_use_title_style() -> None:
assert any("החלטה" in t for t in texts)
def test_block_dalet_is_heading_only_ignores_db_content() -> None:
"""ת2 — block-ד is the 'החלטה' heading marker, NOT a data block.
Whatever content sits in decision_blocks for block-dalet must be ignored;
the only non-empty text line produced is the literal heading 'החלטה'.
"""
doc = Document()
_write_block_to_docx(
doc, "block-dalet", title="כותרת",
content="נתון ישן שצריך להיות מתועלם\nשורה שנייה",
)
text_lines = [p.text for p in doc.paragraphs if p.text.strip()]
assert text_lines == ["החלטה"], (
f"block-dalet must render ONLY the heading 'החלטה', got {text_lines}"
)
# ── _clear_body strips ALL scaffolding (ת1 + ת3 root cause) ────────
# The template ships 3 sample tables (header / panel / signatures). Removing
# only <w:p> left them behind: header/panel tables injected block-א–ד data
# never extracted from the protocol (ת1), and the signatures table floated to
# the top against the header (ת3 — חתימות צמודות לבלוק-ד במקום בסוף). The
# decision is rebuilt purely from decision_blocks (INV-EX1), so the template's
# sample tables are stale scaffolding and must be cleared.
def test_clear_body_removes_tables_and_leaves_only_sectpr() -> None:
doc = Document(str(TEMPLATE_PATH))
body = doc.element.body
# Sanity: the template really ships sample tables (else this test is moot).
assert len(body.findall(qn("w:tbl"))) > 0, "template expected to ship sample tables"
_clear_body(doc)
remaining = [c.tag.split("}")[-1] for c in list(body)]
assert remaining == ["sectPr"], (
f"_clear_body must leave only sectPr, got {remaining}"
)
assert len(body.findall(qn("w:tbl"))) == 0, "no template tables may survive"
assert len(body.findall(qn("w:p"))) == 0, "no template paragraphs may survive"
def test_signatures_block_renders_after_body_not_at_top() -> None:
"""ת3 — block-yod-bet (חתימות) renders LAST, after a cleared body.
Regression for the bug where the leftover template signatures table sat at
the top against the header table. After the _clear_body fix, signatures
come only from block-yod-bet, which the export loop emits last in block
order.
"""
doc = Document(str(TEMPLATE_PATH))
_clear_body(doc)
# Emit a header block, a body block, then signatures last (canonical order).
_write_block_to_docx(doc, "block-alef", title="", content="מדינת ישראל")
_write_block_to_docx(doc, "block-vav", title="", content="רקע עובדתי\n1. המקרקעין.")
_write_block_to_docx(
doc, "block-yod-bet", title="",
content='ניתנה פה אחד היום.\nדפנה תמיר, עו"ד',
)
text_lines = [p.text for p in doc.paragraphs if p.text.strip()]
assert text_lines[0] == "מדינת ישראל", text_lines
# The signatures block (block-yod-bet) emits each line as its own paragraph,
# so its content must occupy the document's tail — after the body block.
body_idx = text_lines.index("רקע עובדתי")
sig_idx = next(i for i, t in enumerate(text_lines) if "ניתנה פה אחד" in t)
assert sig_idx > body_idx, (
f"signatures must come AFTER the body, got tail={text_lines[-3:]}"
)
assert any("דפנה תמיר" in t for t in text_lines[sig_idx:]), text_lines[sig_idx:]
# No template signatures table ("מזכירת ועדת ערר") leaked anywhere.
assert all(
"מזכירת ועדת ערר" not in (p.text or "") for p in doc.paragraphs
), "template signatures-table text leaked into the body"
# ── Heading overrides, numbered-list, dash strip ──────────────────

View File

@@ -130,16 +130,6 @@ def _find_ins_with_runs(tree: etree._Element) -> etree._Element | None:
return None
def _find_enclosing_p(element: etree._Element) -> etree._Element | None:
"""Walk up to the enclosing <w:p>."""
cur = element
while cur is not None:
if cur.tag == _w("p"):
return cur
cur = cur.getparent()
return None
def test_insert_after_ins_has_author_and_date(sample_docx: Path, tmp_path: Path) -> None:
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="insert_after",
@@ -157,10 +147,7 @@ def test_insert_after_ins_has_author_and_date(sample_docx: Path, tmp_path: Path)
assert date_str.endswith("Z") # ISO 8601 UTC
def test_insert_after_uses_rtl_and_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'."""
def test_insert_after_uses_rtl_and_david(sample_docx: Path, tmp_path: Path) -> None:
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="insert_after",
anchor_bookmark="block-alef", content="מוסף")
@@ -175,22 +162,10 @@ def test_insert_after_uses_rtl_and_template_style(sample_docx: Path, tmp_path: P
assert run is not None
rPr = run.find(_w("rPr"))
assert rPr is not None
# RTL marker preserved (so Word picks the style's cs=David slot)…
assert rPr.find(_w("rtl")) is not None
# …but NO hand-set font/size on the run (INV-EX4).
assert rPr.find(_w("rFonts")) is None
assert rPr.find(_w("sz")) is None
assert rPr.find(_w("szCs")) is None
# The enclosing paragraph references a template style + canonical jc=both.
para = _find_enclosing_p(ins)
assert para is not None
pPr = para.find(_w("pPr"))
assert pPr is not None
pStyle = pPr.find(_w("pStyle"))
assert pStyle is not None and pStyle.get(_w("val")) # Normal → real styleId
jc = pPr.find(_w("jc"))
assert jc is not None and jc.get(_w("val")) == "both"
rFonts = rPr.find(_w("rFonts"))
assert rFonts is not None
assert rFonts.get(_w("ascii")) == "David"
# ── apply_tracked_revisions: insert_before ────────────────────────

View File

@@ -1,76 +0,0 @@
"""שומר 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)."
)

View File

@@ -1,212 +0,0 @@
"""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

View File

@@ -1,200 +0,0 @@
"""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

View File

@@ -1,68 +0,0 @@
"""`score` and `relevance` are different things — thresholds must use `relevance`.
Retrieval returns cosine similarities (~0.4-0.75) until the lexical leg returns
rows; then `_merge_sem_lex` replaces `score` with an RRF value (~0.008-0.02).
Both are legitimate *ranking* signals, but they are not on the same scale, so a
caller comparing `score >= 0.45` rejected every hit the moment BM25 matched
anything. That is how the citation-verification tab came to show no supporting
precedent for a single argument, on every case.
`relevance` is the fix: always a cosine, or None when the row came from the
lexical leg alone and no cosine was ever computed.
"""
import pytest
from legal_mcp.services.case_citation_verification import _SUGGEST_FLOOR, _passes_floor
from legal_mcp.services.hybrid_search import _merge_sem_lex
def _sem(key: str, score: float) -> dict:
return {"chunk_id": key, "case_law_id": "c1", "score": score, "relevance": score}
def _lex(key: str, score: float) -> dict:
# The lexical leg emits ts_rank_cd, never a cosine — so no `relevance`.
return {"chunk_id": key, "case_law_id": "c1", "score": score}
def test_fusion_replaces_score_but_preserves_the_cosine():
"""The regression in one assertion."""
merged = _merge_sem_lex([_sem("a", 0.73)], [_lex("a", 0.31)], limit=10)
row = merged[0]
assert row["score"] < 0.1, "fused score is an RRF value, not a cosine"
assert row["relevance"] == pytest.approx(0.73), "the cosine must survive fusion"
def test_lexical_only_row_has_no_fabricated_cosine():
"""None means 'not measured' — not 'measured as irrelevant'."""
merged = _merge_sem_lex([], [_lex("b", 0.31)], limit=10)
assert merged[0]["relevance"] is None
def test_semantic_only_row_keeps_its_cosine():
merged = _merge_sem_lex([_sem("c", 0.62)], [], limit=10)
assert merged[0]["relevance"] == pytest.approx(0.62)
def test_floor_would_have_rejected_everything_on_the_fused_score():
"""Guards the exact production symptom: fused scores are ~0.008, the floor
is 0.45, so score-based filtering wipes the result set."""
merged = _merge_sem_lex(
[_sem(k, 0.70) for k in "abcd"], [_lex(k, 0.30) for k in "abcd"], limit=10)
assert all(r["score"] < _SUGGEST_FLOOR for r in merged) # the bug
assert all(_passes_floor(r) for r in merged) # the fix
def test_floor_still_rejects_genuinely_weak_hits():
"""The fix must not become 'accept everything'."""
assert not _passes_floor({"relevance": 0.10})
assert not _passes_floor({"relevance": _SUGGEST_FLOOR - 0.01})
assert _passes_floor({"relevance": _SUGGEST_FLOOR})
def test_lexical_only_hits_are_kept_deliberately():
"""An exact docket/phrase match reaches the top by BM25 rank with no cosine.
Dropping it would hide precisely what a chair searches for by name."""
assert _passes_floor({"relevance": None})
assert _passes_floor({}) # missing key behaves the same as None

View File

@@ -1,237 +0,0 @@
"""Chair-position round-trip in analysis-and-research.md.
Regression cover for the reader/writer asymmetry that made "עמדת ועדת הערר"
appear to save on threshold claims and then vanish on refresh: the writer
matched the label anywhere on a line, the reader only at the start of one, and
the analyst agent writes threshold claims as a bullet list
("- **עמדת ועדת הערר:**") while it writes issues flush-left.
The invariant these tests pin down: whatever update_chair_position writes,
parse() must read back — for every label form that appears in the corpus.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from legal_mcp.services import research_md
BULLETED = """# ניתוח משפטי — ערר 1069-04-26
תאריך: 2026-08-02
## 5. טענות סף
### ס-1 — אי-מילוי תנאי ההפקדה
- **טענה (claim):** התכנית אושרה מבלי שמולאו תנאי ההפקדה.
- **שאלה משפטית:** האם הסטת התנאים פוגמת בחוקיות?
- **עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
### ס-2 — זכות עמידה
- **טענה (claim):** לעוררים אין אינטרס מוגן.
- **עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
## 6. סוגיות להכרעה
### סוגיה 1: סמכות לפי 62א(א)(11)
**ניתוח:**
- הכלל החל: ...
**עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
---
"""
@pytest.fixture()
def analysis_file(tmp_path: Path) -> Path:
path = tmp_path / "analysis-and-research.md"
path.write_text(BULLETED, encoding="utf-8")
return path
def _positions(path: Path) -> dict[str, str]:
parsed = research_md.parse(path)
items = parsed["threshold_claims"] + parsed["issues"]
return {item["id"]: item["chair_position"] for item in items}
# ── the reported bug ────────────────────────────────────────────
def test_bulleted_threshold_position_survives_a_reload(analysis_file: Path) -> None:
"""The bug as chaim hit it: saved on a threshold claim, gone on refresh."""
research_md.update_chair_position(analysis_file, "threshold_1", "עמדתנו היא שהטענה נדחית.")
assert _positions(analysis_file)["threshold_1"] == "עמדתנו היא שהטענה נדחית."
def test_flush_left_issue_position_still_round_trips(analysis_file: Path) -> None:
"""The surface that already worked must keep working."""
research_md.update_chair_position(analysis_file, "issue_1", "יש לקבל את הערר בנקודה זו.")
assert _positions(analysis_file)["issue_1"] == "יש לקבל את הערר בנקודה זו."
def test_bulleted_threshold_fields_are_parsed_at_all(analysis_file: Path) -> None:
"""Bulleted labels were invisible to the reader — the whole claim looked empty."""
threshold = research_md.parse(analysis_file)["threshold_claims"]
labels = [f["label"] for f in threshold[0]["fields"]]
assert "טענה (claim)" in labels
assert "שאלה משפטית" in labels
# the chair field is surfaced separately, never as a regular field
assert not any(research_md._is_chair_label(label) for label in labels)
# ── writer/reader agreement on content boundaries ───────────────
def test_position_containing_markdown_is_not_truncated(analysis_file: Path) -> None:
"""The old writer stopped the field at the first '*' and swallowed the rest."""
text = 'העמדה כוללת **הדגשה** וגם קו --- באמצע\nושורה שנייה.'
research_md.update_chair_position(analysis_file, "issue_1", text)
assert _positions(analysis_file)["issue_1"] == text
def test_update_does_not_touch_sibling_subsections(analysis_file: Path) -> None:
research_md.update_chair_position(analysis_file, "threshold_1", "ראשונה")
research_md.update_chair_position(analysis_file, "threshold_2", "שנייה")
positions = _positions(analysis_file)
assert positions["threshold_1"] == "ראשונה"
assert positions["threshold_2"] == "שנייה"
assert positions["issue_1"] == ""
def test_closing_rule_and_bullet_marker_are_preserved(analysis_file: Path) -> None:
"""An update must not restructure the file the analyst produced."""
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
research_md.update_chair_position(analysis_file, "issue_1", "עמדה")
content = analysis_file.read_text(encoding="utf-8")
assert "- **עמדת ועדת הערר:**" in content
assert content.rstrip().endswith("---")
def test_clearing_a_position_restores_the_placeholder(analysis_file: Path) -> None:
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
research_md.update_chair_position(analysis_file, "threshold_1", "")
assert _positions(analysis_file)["threshold_1"] == ""
assert research_md.CHAIR_POSITION_PLACEHOLDERS[0] in analysis_file.read_text(
encoding="utf-8"
)
def test_repeated_saves_do_not_accumulate_blocks(analysis_file: Path) -> None:
for text in ("ראשון", "שני", "שלישי"):
research_md.update_chair_position(analysis_file, "threshold_1", text)
content = analysis_file.read_text(encoding="utf-8")
assert content.count(f"**{research_md.CHAIR_POSITION_LABEL}:**") == 3
assert _positions(analysis_file)["threshold_1"] == "שלישי"
# ── decorated and duplicated labels ─────────────────────────────
def test_decorated_label_is_updated_not_duplicated(tmp_path: Path) -> None:
"""'עמדת ועדת הערר (הכוונת יו"ר 24.6)' appears in the corpus (8125-09-24)."""
path = tmp_path / "a.md"
path.write_text(
"## 6. סוגיות להכרעה\n\n"
"### סוגיה 1: כותרת\n\n"
'**עמדת ועדת הערר (הכוונת יו"ר 24.6):** [ימולא ע"י יו"ר הוועדה]\n',
encoding="utf-8",
)
research_md.update_chair_position(path, "issue_1", "עמדה מעודכנת")
content = path.read_text(encoding="utf-8")
assert content.count(research_md.CHAIR_POSITION_LABEL) == 1
assert _positions(path)["issue_1"] == "עמדה מעודכנת"
def test_duplicate_labels_read_back_the_one_that_was_written(tmp_path: Path) -> None:
"""An H3 covering several issues (1033-02-25) must not read a sibling's text."""
path = tmp_path / "a.md"
path.write_text(
"## 6. סוגיות להכרעה\n\n"
"### סוגיות 46: מקובצות\n\n"
"**עמדת ועדת הערר:** ראשונה\n\n"
"**עמדת ועדת הערר:** אחרונה\n",
encoding="utf-8",
)
research_md.update_chair_position(path, "issue_1", "העמדה הנכונה")
assert _positions(path)["issue_1"] == "העמדה הנכונה"
# ── read-after-write guard ──────────────────────────────────────
def test_update_returns_the_persisted_value(analysis_file: Path) -> None:
result = research_md.update_chair_position(analysis_file, "threshold_1", " עמדה ")
assert result["saved"] is True
assert result["position"] == "עמדה"
def test_a_write_the_parser_cannot_read_back_is_reported_as_failure(
analysis_file: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Regression guard: the bug hid behind a success response for months."""
monkeypatch.setattr(
research_md, "_stored_chair_position", lambda *_args: "משהו אחר"
)
with pytest.raises(RuntimeError, match="לא נקראה בחזרה"):
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
# ── downstream consumers see the recovered positions ────────────
def test_chair_directions_count_bulleted_positions(analysis_file: Path) -> None:
"""extract_chair_directions feeds legal-writer; it under-reported before."""
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
directions = research_md.extract_chair_directions(analysis_file)
assert directions["filled_count"] == 1
assert directions["status"] == "partial"
filled = [t for t in directions["threshold_claims"] if t["direction"]]
assert [t["id"] for t in filled] == ["threshold_1"]
# ── the real corpus ─────────────────────────────────────────────
CORPUS = sorted(
Path("/home/chaim/legal-ai/data/cases").glob(
"*/documents/research/analysis-and-research.md"
)
)
@pytest.mark.skipif(not CORPUS, reason="case corpus not present on this host")
@pytest.mark.parametrize("source", CORPUS, ids=lambda p: p.parts[-4])
def test_every_corpus_subsection_round_trips(source: Path, tmp_path: Path) -> None:
sentinel = "עמדת-בדיקה — **הדגשה** ו---קו\nושורה שנייה."
before = _positions(source)
for section_id in before:
working = tmp_path / f"{section_id}.md"
working.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
research_md.update_chair_position(working, section_id, sentinel)
after = _positions(working)
assert after[section_id] == sentinel, f"{source.parts[-4]} {section_id}"
untouched = {k: v for k, v in after.items() if k != section_id}
assert untouched == {k: v for k, v in before.items() if k != section_id}

View File

@@ -1,67 +0,0 @@
"""#232 — the source_kind selector must not silently hide a corpus.
`search_precedent_library` defaulted to source_kind='external_upload' all the
way down the stack, so 104 appeals-committee decisions (29% of the corpus)
were unreachable through the entry point the writing agents actually call.
These tests pin the selector semantics; the end-to-end retrieval check lives
in scripts/test_retrieval_by_name.py (needs a live DB).
"""
import pytest
from legal_mcp.services.db import _source_kind_clause
def test_empty_selector_means_no_filter():
"""'' and 'all' must produce no predicate — the whole corpus."""
assert _source_kind_clause("") == ""
assert _source_kind_clause("all") == ""
assert _source_kind_clause(" ") == ""
def test_named_kinds_produce_equality_predicate():
assert _source_kind_clause("internal_committee", "cl.") == (
"cl.source_kind = 'internal_committee'"
)
assert _source_kind_clause("external_upload") == "source_kind = 'external_upload'"
def test_all_committees_expands_to_both_shapes():
"""Committee decisions live under two historical shapes — cover both."""
clause = _source_kind_clause("all_committees", "cl.")
assert "cl.source_kind = 'internal_committee'" in clause
assert "cl.source_type = 'appeals_committee'" in clause
assert clause.startswith("(") and clause.endswith(")")
def test_unknown_selector_raises_rather_than_reaching_sql():
"""The value is f-string-interpolated, so the whitelist is the guard."""
with pytest.raises(ValueError, match="source_kind"):
_source_kind_clause("'; DROP TABLE case_law; --")
with pytest.raises(ValueError):
_source_kind_clause("internal")
def test_default_of_the_search_entry_points_is_whole_corpus():
"""A regression guard on the defaults themselves — this is the bug."""
import inspect
from legal_mcp.services import hybrid_search, precedent_library
from legal_mcp.services import db as db_mod
from legal_mcp.tools import precedent_library as plib_tool
for fn in (
db_mod.search_precedent_library_semantic,
db_mod.search_precedent_library_lexical,
db_mod.list_external_case_law,
hybrid_search.search_precedent_library_hybrid,
precedent_library.search_library,
precedent_library.list_precedents,
plib_tool.search_precedent_library,
plib_tool.precedent_library_list,
):
default = inspect.signature(fn).parameters["source_kind"].default
assert default == "", (
f"{fn.__module__}.{fn.__qualname__} defaults source_kind to "
f"{default!r} — that hides a corpus from every caller (#232)"
)

View File

@@ -207,9 +207,8 @@ def test_multiple_revisions_all_tracked_independently(tmp_path: Path) -> None:
def test_rtl_preserved_in_tracked_insertion(tmp_path: Path) -> None:
"""Inserted paragraph must have bidi + an <w:rtl/> run marker so Hebrew
renders via the template's cs=David slot — but font/size come from the
template's named paragraph style (INV-EX4), not hand-set rFonts/sz."""
"""Inserted paragraph must have bidi + rtl + David font so it renders
correctly in Word alongside the user's content."""
user_file = tmp_path / "user.docx"
_make_user_edited_docx(user_file)
docx_retrofit.retrofit_bookmarks(user_file, backup=False)
@@ -230,10 +229,9 @@ def test_rtl_preserved_in_tracked_insertion(tmp_path: Path) -> None:
if any("עברית RTL" in (t.text or "") for t in text_els):
rPr = r.find(_w("rPr"))
assert rPr is not None
# RTL marker preserved…
assert rPr.find(_w("rtl")) is not None
# …but NO hand-set font/size on the run (INV-EX4).
assert rPr.find(_w("rFonts")) is None
assert rPr.find(_w("sz")) is None
rFonts = rPr.find(_w("rFonts"))
assert rFonts is not None
assert rFonts.get(_w("ascii")) == "David"
return
pytest.fail("tracked insertion with 'עברית RTL' not found")

View File

@@ -26,7 +26,6 @@
| `test_retrieval_by_name.py` | python | בדיקת אחזור-לפי-שם (#52/RC-A) — מאמת ש`search_precedent_library`/`search_internal_decisions` מדרגים את ההחלטה עצמה (אגסי) מעל מי שמצטט אותה, + רגרסיות לשאילתות מהותיות. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/test_retrieval_by_name.py` (exit 0 = עבר). | ידני אחרי שינוי שכבת חיפוש |
| `eval_gold_bootstrap.py` | python | **FU-5 (GAP-11) — bootstrap ל-gold-set** של הערכת-אחזור ל-`data/eval/gold-set.jsonl`. שני מקורות: `--source citations` (cited==relevant מ-`search_relevance_feedback`; ריק עד שייצברו ציטוטים) ו-`--source known_item` (query=שם-תיק → relevant=עצמו; אות אמיתי היום). Idempotent — שומר שורות `source=chair`, מחדש `bootstrap_*`. דורש POSTGRES. | לפני eval; חוזר כשנצבר ground-truth |
| `eval_retrieval.py` | python | **FU-5 (GAP-11, INV-RET4/G8) — harness הערכת-אחזור** — מריץ את מסלול-האחזור בייצור (`search_library`/`search_internal`) על ה-gold-set, מחשב precision@k/recall@k/MRR/nDCG@k (k=5,10), מצרף overall+per-corpus+per-PA ל-`data/eval/eval-report-<ts>.{json,md}` + delta מול `data/eval/baseline.json` (מתעד retrieval_config). `--self-test` בודק את המטריקות offline; `--update-baseline` מאמץ snapshot. **שער-CI במשמעת:** הרץ לפני/אחרי כל שינוי בשכבת-האחזור באותו קונפיג. דורש POSTGRES+VOYAGE_API_KEY. | לפני/אחרי שינוי RRF/k/embedder/rerank |
| `legal-mcp-http.config.cjs` | pm2/js | **שרת ה-MCP חשוף ב-streamable-http** (#231) — `python -m legal_mcp.server` עם `MCP_TRANSPORT=streamable-http`, bound **`127.0.0.1:8790`**, Bearer `MCP_HTTP_SHARED_SECRET` מ-`~/.legal-mcp-http.env` (מקור-אמת: Infisical → All Infrastructure / `main` / `/apps/legal-ai`, תג `credentials`). **למה:** סוכנים המונעים דרך Agent Client Protocol מקבלים את שרתי-ה-MCP שלהם מהלקוח בפתיחת הסשן, והערוץ הזה נושא שרתי HTTP בלבד — ל-stdio אין מסלול לשם, ומכאן שהסוכנים נותרו בלי 108 הכלים. **אינו מחליף את stdio:** כל סשן אינטראקטיבי ממשיך דרך `~/.claude.json`; אותו קוד, אותו מרשם-כלים, שתי תחבורות (G2). **אבטחה:** loopback בלבד (צר יותר מ-`10.0.1.1` של legal-chat-service — שום קונטיינר לא צריך MCP), והשרת **מסרב לעלות בלי טוקן** (`services/http_auth.py`), כך שתקלת-הגדרה לא יכולה לייצר מאזין לא-מאומת. מראָה לדפוס `legal-chat-service.config.cjs`. התקנה: `pm2 start scripts/legal-mcp-http.config.cjs && pm2 save`. בדיקה: POST ל-`/mcp` → 401 בלי טוקן, 200 עם. | pm2 (host-side) |
| `legal-court-fetch-service.config.cjs` | pm2/js | **שירות-מארח Tier-1 לאחזור פסקי-דין מנט המשפט (X13)** — 2 apps: (א) `legal-court-fetch-xvfb` (Xvfb :99, צג-וירטואלי ל-Camoufox); (ב) `legal-court-fetch-service` (`python -m legal_mcp.court_fetch_service.server`, bound `10.0.1.1:8771`, Bearer `COURT_FETCH_SHARED_SECRET` מ-`~/.legal-court-fetch-service.env`, `DISPLAY=:99`). מריץ Camoufox דרך חבילת-הפייתון (in-process) כי הקונטיינר לא יכול דפדפן. תלות: `pip install -e "mcp-server[court-fetch]" && python -m camoufox fetch`. אחזור = ניווט→צופה→`GetImages`(X-Requested-With)→PDF, ללא CAPTCHA; כשל→`ok:false`→orchestrator מסלים ל-fallback אנושי. **אומת על עת"מ 46111-12-22 (34 עמ').** מראָה לדפוס `legal-chat-service.config.cjs`. ספ: `docs/spec/X13-court-fetch.md`. התקנה: `pm2 start scripts/legal-court-fetch-service.config.cjs && pm2 save`. בריאות: `curl http://10.0.1.1:8771/health`. | pm2 (host-side) |
| `drain_court_fetch.py` | python | **ריקון תור-אחזור הפסיקה (X13)** — קורא ל-`court_fetch_orchestrator.drain_pending(limit)` שמוריד+קולט כל job ממתין שהיומונים מילאו, וקושר חזרה ליומון. מקומי בלבד (ingest = claude CLI). no-op מהיר כשהתור ריק. הרצה ידנית: `mcp-server/.venv/bin/python scripts/drain_court_fetch.py [limit]`. | דרך `legal-court-fetch-drain.config.cjs` (pm2 cron) |
| `legal-court-fetch-drain.config.cjs` | pm2/js | **תזמון שעתי של `drain_court_fetch.py`** (cron `17 * * * *`, `COURT_FETCH_DRAIN_CRON` לעקיפה) — הופך את לולאת יומון→אחזור→קליטה ל-fully-autonomous. `autorestart:false` (one-shot per tick). דורש `legal-court-fetch-service` רץ. התקנה: `pm2 start scripts/legal-court-fetch-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
@@ -77,8 +76,6 @@
| `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest <path>`. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) |
| `ab_halacha_opus48.py` | python | **A/B לא-הרסני לחילוץ הלכות (Claude)** — מריץ מחדש חילוץ הלכות על פסק-דין בודד דרך מודל/effort נבחרים (`AB_MODEL`/`AB_EFFORT`, ברירת-מחדל `claude-opus-4-8`/`xhigh`) ומשווה לסטטיסטיקות ההלכות הקיימות ב-DB **בלי למחוק/לכתוב כלום**. משכפל את `halacha_extractor.extract()` (אותם פרומפטים, בחירת-צ'אנקים, אימות-ציטוט) ומחליף רק את קריאת ה-LLM ב-`claude -p --model --effort`. מפיק `data/ab_halacha_<case>_<effort>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data .venv/bin/python scripts/ab_halacha_opus48.py <case_law_id>`. **ממצא 2026-05-31 (שטיין 1128-08-20):** Opus 4.8@xhigh חילץ 51 מול 124 בייצור (100% quote-verified מול 96%) אך ביטחון מכויל-נמוך יותר (חציון 0.75 מול 0.82) — ולכן **לא** מקטין את תור-האישור-הידני תחת sweep אוטו-אישור conf≥0.78 (26 מול 24). שיפור איכות, לא צמצום-תור. | ידני (החלטת מודל-חילוץ) |
| `ab_halacha_codex.py` | python | **A/B לא-הרסני לחילוץ הלכות (Codex/gpt-5.5)** — עמית ל-`ab_halacha_opus48` אך מחליף את `claude -p` ב-`codex exec --model gpt-5.5` (אימות ChatGPT, ללא OPENAI_API_KEY). אותם פרומפטים ואותו הסקת quote-verification. הפלט האחרון של הסוכן (`-o FILE`) נפענח כ-JSON. `AB_MODEL` (default `gpt-5.5`), `AB_REASONING` low/medium/high/xhigh (default `medium`), `AB_CONCURRENCY` (default 1), `CODEX_BIN`. מפיק `data/ab_halacha_codex_<case>_<model>_<reasoning>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/ab_halacha_codex.py <case_law_id>`. **ממצא 2026-06-17 (8181-21 האוניברסיטה העברית):** gpt-5.5@medium חילץ 27 מול 28 של Opus (quote-verified 100%/100%), ביטחון חציון 0.86 מול 0.78 — אך **0 פריטים מתחת ל-0.7** (לעומת 9/28 של Opus = 32%), דבר המצביע על over-confidence. holding↑ (12 מול 7), procedural↓ (4 מול 7). **מסקנה: ריאלי כ-fallback חירום; לא מוכן לייצור ללא כיול-ביטחון.** | ידני (בנצ'מרק מודל codex) |
| `calibrate_effort.py` | python | **#208 (WS5/Q1, INV-G8 eval-harness) — כיול model×effort של הכותב מול הסופיים.** A/B per-(תיק,בלוק,effort) על `draft_final_pairs` בעלי `final_text`: מייצר מחדש כל בלוק דרך מסלול-הייצור (`block_writer.write_block(effort_override=…)``claude_session.query``claude -p`, Opus 4.8 נעוץ, **מקומי-בלבד**) ומודד מול ה**סקשן** המתאים בסופי דרך `services/style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2): `change_percent` (compute_diff_stats) · `anti_pattern_total` (`lessons.ANTI_PATTERNS`, הסיגנל הנקי-לסגנון) · `golden_ratio_deviation_pp` · `distance` מרוכב. **ממליץ** per-בלוק (style-clean, #213) לפי `anti_pattern_total` **ראשי** (סיגנל-הסגנון הנקי) → סטיית-יחסי-זהב → `distance` (tiebreak קלוש); **`change_percent` מדווח-לא-מדורג** (מערבב סגנון עם שלמות-תוכן, 07-learning §0.7). ההמלצה נושאת דגל-`confidence` ("clear"/"weak" — הובלת-anti < epsilon=max(0.5, 0.20·spread) ⇒ ⚠weak בתוך-הרעש, לא מוצג כוודאי) לצד ברירת-המחדל מ-#204. מפיק `data/eval/effort-calibration-<ts>.{json,md}` ב**כתיבה אינקרמנטלית** — הדוח נכתב מחדש אחרי כל בלוק שמסתיים, כך שקריסה באמצע הגריד לא מאבדת בלוקים שכבר יוצרו (INV-G8). כל תא-(תיק,בלוק,effort) שנכשל (rate-limit חולף, או חריגה) נרשם כ-warning ומדולג — לא מפיל את הריצה. **`block-yod` (דיון) הוצא מברירות-המחדל** (`CALIBRATABLE_BLOCKS`) — דורש כיוון-מאושר (brainstorm→approve_direction) שתיקי-הכיול חסרים ולכן `write_block` זורק; נשאר ב-`VALID_BLOCKS` לכפייה ידנית (`--blocks block-yod`) על-אחריות-המשתמש, כשהשער-per-תא שומר אותו לא-פטאלי. ⚠️ **גודל-מדגם מודפס בראש הדוח — עדות-כיוון, לא רגרסיה** (מעט סופיים-עלויים). `--self-test` (offline, אפס DB/CLI — מוכיח מדידה + דירוג-סגנון + confidence + rerank) · **`--rerank <report.json>`** (offline, אפס LLM/DB — מדרג-מחדש דוח-כיול שמור תחת המפתח החדש, מדפיס טבלה + כותב `*-reranked.md`) · `--dry-run` (תכנון-גריד) · `--efforts`/`--blocks`/`--case`/`--repeats`. **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצה: `POSTGRES_PASSWORD=… mcp-server/.venv/bin/python scripts/calibrate_effort.py`. | ידני — לכיול ברירות-effort של הכותב |
| `calibrate_block_yod.py` | python | **#212 (WS5/Q1, INV-G8 eval-harness) — כיול effort ל-בלוק י' (דיון והכרעה) בלבד.** ה-harness ש-#208 לא יכל לכייל: `write_block(block-yod)` זורק "ללא כיוון מאושר" כי בלוק-הדיון דורש `decisions.direction_doc.approved` שתיקי-הכיול חסרים. **התוספת על #208 — שכבת אספקת-כיוון read-only:** לפני ייצור בלוק-הדיון, ה-harness מזרים `direction_doc` שמיש דרך **overlay על קריאת-ה-DB** (monkeypatch של `db.get_decision_by_case`/`db.get_decision` לתהליך-הזה-בלבד) — **אפס כתיבה ל-DB**, מצב-תיק-הייצור לא משתנה (השער reverted ב-finally). **אסטרטגיית-אספקה per-תיק:** (1) **stored** (מועדף, נאמן) — כיוון-מאושר שמור (`approved`+`selected_direction`, או סכמת-8126 עם `direction_id`/`structure`); (2) **reconstructed** (נסיגה, עדות-כיוון-בלבד) — `direction_doc` מינימלי שנגזר מ-`decision.outcome` + סקשן-הדיון של הסופי **באותו תיק** (INV-LRN5: outcome+מצביע-לסופי-עצמי, אפס מהות חוצת-תיקים, מתויג `reconstructed`); (3) **skip** אם אין דיון/כיוון. מודד דרך אותו `style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2) ומשתמש-חוזר ב-`_score_cell`/`recommend_effort` מ-`calibrate_effort` (#208). **ממצא-קורפוס (2026-06-30):** מ-8 סופיים, 7 ניתנים-לכיול; רק 8126-03-25 הוא stored+scorable (8174-12-24 שמור-אך-חסר-סקשן-דיון) → `--allow-reconstructed false` נותן תא-יחיד; ברירת-המחדל (reconstructed פעיל) נותנת 7 → הנסיגה היא מה שמפעיל את ה-harness היום. כתיבה אינקרמנטלית per-תיק + שער-per-תא לא-פטאלי (INV-G8). ⚠️ **גודל-מדגם בראש הדוח — עדות-כיוון, לא רגרסיה.** `--self-test` (offline, אפס DB/CLI — מוכיח מדידה + לוגיקת-אספקת-כיוון + overlay) · `--dry-run` (תכנון-גריד + מצב-אספקה per-תיק, צריך DB) · `--efforts`/`--case`/`--repeats`/`--allow-reconstructed`. ברירת-מחדל נוכחית: `xhigh` (`block_writer.py:72`, 'by feel'). **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצת-כיול-חי (לבצע ע"י היו"ר): `POSTGRES_PASSWORD=… POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5433 mcp-server/.venv/bin/python scripts/calibrate_block_yod.py`. | ידני — לכיול ה-effort של בלוק-הדיון (היו"ר מריץ; long, צורך-מכסה) |
| `monitor_halacha_quality.py` | python | מנטר איכות חילוץ הלכות. בודק drift של `avg(confidence)` בין baseline היסטורי לחלון אחרון. מחזיר JSON מטריקות + alert ב-stderr אם drift > threshold (ברירת מחדל 5%). 2 סדרות: trusted (approved+published) ו-all_extracted. תומך `--window N` / `--threshold X` / `--min-sample N` / `--silent` / `--exit-on-alert`. רץ ב-container או מקומית עם `mcp-server/.venv` (אין תלות ב-LLM, רק SQL). **תזמון מומלץ**: `0 8 * * 1` (יום ראשון 08:00, שבועי) | `0 8 * * 1` (לתזמן) |
| `audit_training_corpus.py` | python | audit של `style_corpus` — לכל החלטה: שדות מטא-דאטה מאוכלסים (`summary`/`outcome`/`key_principles`/`appeal_subtype`/`subject_categories`), קישור ל-`documents` (FK + chunks + embeddings). מפיק `data/audit/corpus-YYYY-MM-DD.json` + summary בקונסול. דרוש `POSTGRES_URL` או POSTGRES_*. אין תלויות חיצוניות מלבד asyncpg. **רץ מהמכונה המקומית** (לא קונטיינר) — חיבור ישיר ל-Postgres :5433 | ידני / קדם-עבודה לפני enrichment של מטא-דאטה |
| `backfill_style_exemplars.py` | python | **T1 (style-acquisition)** — מאכלס `style_exemplars` מקורפוס דפנה (`style_corpus` + `internal_committee` chair=דפנה): מפצל לסעיפים (`chunker._split_into_sections`) → פסקאות (25-450 מילים) → embed (Voyage) → שמירה עם `section`/`outcome`/`practice_area`. מאפשר לכותב לאחזר פסקאות-בלוק אמיתיות של דפנה (T2/T3). מקור-סגנון בלבד (INV-LRN5). אידמפוטנטי (מנקה per-decision). `--dry-run` (default) / `--apply`. דורש POSTGRES_URL + Voyage. **רץ מקומית** (venv). | ידני (`python scripts/backfill_style_exemplars.py --apply`) |
@@ -99,7 +96,6 @@
|--------|------|---------|-----------|
| `spec-guard.sh` | bash | **PreToolUse hook לאכיפת "פרוטוקול כתיבת-קוד"** (CLAUDE.md §פרוטוקול כתיבת-קוד) — בכל Edit/Write/MultiEdit על נתיב-קוד (`web/`, `mcp-server/`, `web-ui/src/`, `scripts/`, `adapters/`) מזריק תזכורת ל-Claude לקרוא את `docs/spec/00-constitution.md`+ספ-התחום ולוודא קיום G1G12 — לפני שכותבים. **+ leak-guard בזמן-אמת (G12):** על כתיבה ל-`mcp-server/src/*` בודק את התוכן-הנכתב (`new_string`/`content`) ומזהיר אם מוזרק מונח-Paperclip לשכבת-האינטליגנציה (לא-deduped). המקבילה האינטראקטיבית ל-INV-AG1. קלט JSON ב-stdin, פלט `hookSpecificOutput.additionalContext` (non-blocking, exit 0). Dedup פעם-בסשן לתזכורת-הספ. רשום ב-`.claude/settings.json`. | נקרא אוטומטית ע"י Claude Code (hook) |
| `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 |
| `agent_tool_grants_guard.py` | python | **המאכף הקנוני של INV-AG3 (מפת-הרשאות הסוכנים / docs/spec/X4-agents.md §2א).** ה-frontmatter `tools:` של סוכן הוא **allow-list סגורה** — כלי הרשום בשרת-ה-MCP אך חסר ממנה אינו ניתן לקריאה, גם כשהשרת מחובר. ארבעה כללים קשיחים: (1) כל `mcp__legal-ai__X` המופיע ב-`web/` (delegation שיוצר issue לסוכן) מוענק לסוכן כלשהו; (2) כל `mcp__legal-ai__X` בגוף קובץ-סוכן מוענק ב-frontmatter של **אותו** קובץ; (3) אין הענקה לכלי שאינו רשום ב-`@mcp.tool`; (4) שם-כלי בגרשיים-הפוכים ללא תחילית — מוענק, או מסווג ב-`CONTRASTIVE_OK` עם נימוק (כולל בדיקת-התיישנות לסיווגים). מחריג קבצים שאינם סוכני-claude_local: `hermes-curator.md`, `legal-analyst-gemini-critique.md`, `HEARTBEAT.md`. **כלל 5 (host-only, `--check-bindings`):** allow-list נאכפת רק כשה-runtime בוחר את הסוכן (`--agent <name>`); בלעדיו אותו קובץ נמסר כ-`--append-system-prompt-file` — פרוזה, לא שער — וכל הכלים נשארים נגישים. הקשירה יושבת ב-DB של הפלטפורמה ולכן ה-CI לא רואה אותה; בלי הדגל השער **אומר זאת במפורש** במקום לרמוז על אכיפה שלא אימת. stdlib-בלבד. נבנה אחרי CMP-229 (2026-08-04) — `analyze_protocol` נרשם בשרת ב-2026-06-30 בלי הענקה, ו-#226 הורה למנתח להריץ אותו. CI: `.gitea/workflows/agent-tool-grants.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) |
| `host_sync.sh` | bash | מסנכרן את עץ-המארח `~/legal-ai` ל-origin/main (ff-only) כדי שקוד-המארח (כותב/פאנלים/MCP שרצים מהעץ, לא בקונטיינר) יתעדכן אחרי merge; restart מדויק ל-chat/court-fetch/reaper רק כשקבציהם משתנים. בטוח: אף-פעם לא force; tasks.json הדירטי נשמר. סוגר את פער-פריסת-המארח (TaskMaster #160) | `* * * * *` (cron, flock) |
@@ -177,7 +173,6 @@
| Script | Original Purpose | Superseded By |
|--------|-----------------|---------------|
| `exec_summary_1043.py` | חד-פעמי — רינדור DOCX של סיכום-מנהלים לתיק 1043-02-26 דרך עוזרי-התבנית של `analysis_docx_exporter` (פרוטוטיפ). | `analysis_docx_exporter.build_party_claims_summary_docx()` + endpoint `GET /api/cases/{n}/research/party-claims-summary/export-docx` (WS3 #202; תבניתי, INV-EX4) |
| `backfill_pattern_frequency.py` | עדכון תדירות דפוסי סגנון ב-DB | `web/app.py::_extract_pattern_variants()` |
| `batch_upload_training.py` | העלאת קורפוס אימון (16 קבצים) | Web UI: `/api/training/upload` |
| `benchmark_embeddings.py` | השוואת מודלי embeddings (voyage-3 vs voyage-4) | הושלם — voyage-3-large נבחר |

View File

@@ -1,319 +0,0 @@
#!/usr/bin/env python3
"""INV-AG3 guard — every MCP tool an agent is TOLD to run must be GRANTED to it.
The canonical checker for INV-AG3 (docs/spec/X4-agents.md §2א): a Claude-Code
subagent's ``tools:`` frontmatter is a CLOSED allow-list. A tool that is
registered on the MCP server but absent from that list is *not callable* by the
agent, however well the server is connected.
Why this exists — the failure it is built to catch (2026-08-04):
``analyze_protocol`` shipped on 2026-06-30 (24e3e2f) touching 9 files, none of
them ``.claude/agents/*``. Later ``wake_analyst_for_protocol_analysis``
(web/paperclip_client.py, #226) started writing "הרץ
``mcp__legal-ai__analyze_protocol(...)``" straight into the analyst's issue.
The analyst therefore received an explicit instruction to run a tool it was
never granted, reported "tools exist on the connected server but aren't exposed
as callable in this session", and burned two runs working around it via raw
psql + a hand-written script. INV-AG3 already covered this on paper; its
enforcement was deferred ("אכיפה אוטומטית עתידית"), so the drift went unnoticed
for five weeks. This script is that deferred enforcement.
Three HARD rules:
1. **Backend delegation.** Every ``mcp__legal-ai__X`` named inside ``web/``
(the backend telling an agent what to run) must be granted to at least one
agent. This is the rule that catches the 2026-08-04 failure.
2. **Per-agent instructions.** Every ``mcp__legal-ai__X`` in an agent file's
BODY must be granted in that same file's frontmatter. Prefixed mentions are
imperative by convention ("הרץ `mcp__legal-ai__…`").
3. **No phantom grants.** Every granted tool must actually be registered on
the MCP server — catches typos and tools deleted out from under an agent.
Plus one reviewed-exception rule:
4. **Bare tool names.** An agent body may name a tool in backticks without the
``mcp__legal-ai__`` prefix (```get_legal_arguments```). Those are
ambiguous: some are real requirements, others are deliberately contrastive
("**לא** דרך `precedent_library_upload`"), a pointer at *another* agent's
job, or a DB column that merely shares a tool's name. Each is classified
once in ``CONTRASTIVE_OK`` below; anything new fails until reviewed.
And one host-only rule, opt-in via ``--check-bindings``:
5. **Bindings.** Rules 14 compare files to files, which says nothing about
whether an allow-list is *enforced*. It is only enforced when the runtime
selects that agent (``--agent <name>``); without the flag the same file is
delivered as ``--append-system-prompt-file`` — prose, not a gate — and every
tool stays reachable. Found on 2026-08-05: one agent declared 41 grants with
no ``--agent`` flag, so the largest allow-list in the system was inert while
this guard reported OK. The binding lives in the platform DB, so CI cannot
see it; without the flag the guard now says so out loud instead of implying
enforcement it never verified.
NOT AGENTS (no frontmatter by design — the adapter sends the file as a raw
prompt, so YAML would leak into it): ``hermes-curator.md`` (deepseek_local),
``legal-analyst-gemini-critique.md`` (gemini_local). ``HEARTBEAT.md`` is a
shared checklist, not an agent. All three are skipped.
Usage:
agent_tool_grants_guard.py # exit 1 on any violation
"""
from __future__ import annotations
import json
import os
import subprocess
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
AGENTS_DIR = REPO / ".claude" / "agents"
MCP_SRC = REPO / "mcp-server" / "src"
BACKEND_DIR = REPO / "web"
# Files under .claude/agents/ that are not claude_local subagent definitions.
NOT_AGENTS = {
"HEARTBEAT.md",
"hermes-curator.md",
"legal-analyst-gemini-critique.md",
}
# Bare (unprefixed) tool names in an agent body that are NOT requirements.
# Each entry is (agent file, tool, why) — reviewed 2026-08-04. Adding to this
# map is a deliberate act: it asserts "the agent is not being told to call this".
CONTRASTIVE_OK = {
("legal-analyst.md", "case_create"): "prose about the cases.practice_area CHECK constraint, not a call",
("legal-analyst.md", "search_internal_decisions"): "names the filter surface when contrasting Axis A/B",
("legal-ceo.md", "search_decisions"): "contrast — 'search_decisions = only Dafna' vs the granted search_internal_decisions",
("legal-ceo.md", "precedent_library_upload"): "explicitly the forbidden path ('לא דרך …', citation guard rejects)",
("legal-ceo.md", "document_update"): "describes the tagging chaim must fix, not a CEO call",
("legal-proofreader.md", "extraction_status"): "the documents.extraction_status DB column — name collides with a tool",
("legal-qa.md", "precedent_attach"): "explicitly the researcher's job ('דרך precedent_attach של ה-researcher')",
("legal-writer.md", "revise_draft"): "the CEO calls it ('CEO יקרא ל-revise_draft'), not the writer",
("legal-writer.md", "search_case_precedents"): "a do-not-confuse disambiguation note ('שונה! … לא לבלבל')",
}
TOOL_RE = re.compile(r"mcp__legal-ai__(\w+)")
REGISTER_RE = re.compile(r"@mcp\.tool\([^)]*\)\s*(?:async\s+)?def\s+(\w+)")
# `tool_name(` or `tool_name` inside backticks.
BARE_RE = re.compile(r"`(\w+)[(`]")
def server_tools() -> set[str]:
"""Tool names registered on the MCP server."""
out: set[str] = set()
for path in MCP_SRC.rglob("*.py"):
out |= set(REGISTER_RE.findall(path.read_text(encoding="utf-8", errors="ignore")))
return out
def split_frontmatter(text: str) -> tuple[str, str]:
"""Return (frontmatter, body). Empty frontmatter when the file has none."""
if not text.startswith("---"):
return "", text
parts = text.split("---")
if len(parts) < 3:
return "", text
return parts[1], "---".join(parts[2:])
def agent_files() -> list[Path]:
return sorted(p for p in AGENTS_DIR.glob("*.md") if p.name not in NOT_AGENTS)
def check_bindings() -> list[tuple[str, str]]:
"""Return [(agent file, why)] for agents whose allow-list nothing enforces.
A ``tools:`` list is only an allow-list when the runtime is told which agent
to be. The local adapter enforces it under ``--agent <name>``; without that
flag the very same file is delivered as ``--append-system-prompt-file``, i.e.
prose the model may follow or ignore, and every tool stays reachable.
Found the hard way on 2026-08-05: one agent carried 41 grants and no
``--agent`` flag, so the largest allow-list in the system was inert — and
this guard had been reporting OK on it, because Rules 14 only ever compare
files to files.
Host-only. The binding lives in the platform's database, which CI cannot
reach, so this shells out to ``psql`` rather than adding a driver dependency
that would break the stdlib-only property the CI path relies on. Returns []
when the database is unreachable — an unreachable DB is "not checked", not
"no violations", and the caller prints that distinction.
"""
sql = (
"select adapter_config->>'instructionsEntryFile', "
"coalesce(adapter_config->>'extraArgs','') "
"from agents where adapter_type='claude_local' "
"and adapter_config->>'instructionsEntryFile' is not null;"
)
try:
out = subprocess.run(
["psql", "-h", "localhost", "-p", "54329", "-U", "paperclip",
"-d", "paperclip", "-X", "-A", "-t", "-F", "\t", "-c", sql],
capture_output=True, text=True, timeout=20,
env={**os.environ, "PGPASSWORD": os.environ.get("PGPASSWORD", "paperclip")},
)
except (OSError, subprocess.SubprocessError):
return []
if out.returncode != 0:
return []
bad: dict[str, str] = {}
for line in out.stdout.splitlines():
if "\t" not in line:
continue
entry_file, extra = line.split("\t", 1)
entry_file = entry_file.strip()
if not entry_file or entry_file in NOT_AGENTS:
continue
want = entry_file[:-3] if entry_file.endswith(".md") else entry_file
try:
args = json.loads(extra) if extra.strip() else []
except json.JSONDecodeError:
args = []
# Only a literal ["--agent", "<name>"] pair binds the allow-list.
ok = any(
a == "--agent" and i + 1 < len(args) and args[i + 1] == want
for i, a in enumerate(args)
)
if not ok:
bad[entry_file] = (
"extraArgs is empty" if not args
else f"extraArgs={extra.strip()} does not select '{want}'"
)
# Only report agents that actually declare grants — an agent with no tools:
# list has nothing to enforce and is not a finding.
result = []
for path in agent_files():
if path.name in bad:
fm, _ = split_frontmatter(path.read_text(encoding="utf-8", errors="ignore"))
if TOOL_RE.findall(fm):
result.append((path.name, bad[path.name]))
return sorted(result)
def main() -> int:
registered = server_tools()
if not registered:
print("agent-tool-grants: FAIL — no @mcp.tool registrations found; is the tree complete?")
return 1
grants: dict[str, set[str]] = {}
bodies: dict[str, str] = {}
for path in agent_files():
fm, body = split_frontmatter(path.read_text(encoding="utf-8", errors="ignore"))
grants[path.name] = set(TOOL_RE.findall(fm))
bodies[path.name] = body
all_granted: set[str] = set().union(*grants.values()) if grants else set()
violations: list[str] = []
# Rule 1 — backend delegation must land on a granted tool.
for path in sorted(BACKEND_DIR.rglob("*.py")):
text = path.read_text(encoding="utf-8", errors="ignore")
for tool in sorted(set(TOOL_RE.findall(text))):
if tool not in all_granted:
rel = path.relative_to(REPO)
violations.append(
f"[1 backend] {rel} instructs an agent to run "
f"mcp__legal-ai__{tool}, but NO agent grants it.\n"
f" fix: add `- mcp__legal-ai__{tool}` to the tools: "
f"frontmatter of the agent that receives that issue."
)
# Rule 2 — a prefixed mention in an agent body is an instruction to that agent.
for name, body in bodies.items():
for tool in sorted(set(TOOL_RE.findall(body))):
if tool not in grants[name]:
violations.append(
f"[2 instructions] .claude/agents/{name} tells the agent to run "
f"mcp__legal-ai__{tool}, which its own tools: list omits.\n"
f" fix: add `- mcp__legal-ai__{tool}` to that frontmatter."
)
# Rule 3 — no grant may point at a tool the server does not register.
for name, granted in grants.items():
for tool in sorted(granted - registered):
violations.append(
f"[3 phantom] .claude/agents/{name} grants mcp__legal-ai__{tool}, "
f"which is not registered on the MCP server.\n"
f" fix: correct the name, or drop the grant if the tool was removed."
)
# Rule 4 — every bare tool name is either granted or classified as contrastive.
for name, body in bodies.items():
bare = {m for m in BARE_RE.findall(body) if m in registered}
for tool in sorted(bare - grants[name]):
if (name, tool) in CONTRASTIVE_OK:
continue
violations.append(
f"[4 bare name] .claude/agents/{name} mentions `{tool}` — a real MCP "
f"tool it is not granted.\n"
f" fix: grant it if the agent must call it, otherwise add "
f"(\"{name}\", \"{tool}\") to CONTRASTIVE_OK with the reason."
)
# Stale exceptions: a classification that no longer matches the text is noise.
for (name, tool), _why in sorted(CONTRASTIVE_OK.items()):
if name not in bodies:
violations.append(
f"[4 stale] CONTRASTIVE_OK names {name}, which is not an agent file."
)
elif tool not in {m for m in BARE_RE.findall(bodies[name])}:
violations.append(
f"[4 stale] CONTRASTIVE_OK ({name}, {tool}) no longer appears in that "
f"file — drop the exception."
)
# Rule 5 — a grant list only binds if the runtime actually selects that agent.
unenforced = check_bindings() if "--check-bindings" in sys.argv else None
if unenforced:
for name, detail in unenforced:
violations.append(
f"[5 binding] .claude/agents/{name} declares a tools: allow-list, but "
f"the runtime does not select that agent — {detail}.\n"
f" The list is inert: it is delivered as prompt text only, so "
f"every tool remains callable.\n"
f" fix: set adapter_config.extraArgs to "
f'["--agent", "{name[:-3]}"], or drop tools: and document the agent as '
f"unrestricted. Not both."
)
if violations:
print(f"INV-AG3 agent-tool-grants guard: {len(violations)} violation(s)\n")
for v in violations:
print(f"{v}")
print(
"\ndocs/spec/X4-agents.md §2א INV-AG3 — the frontmatter tools: list is a "
"CLOSED allow-list.\nA tool missing from it is not callable, no matter that "
"the MCP server is connected."
)
return 1
print(
f"INV-AG3 agent-tool-grants guard: OK "
f"({len(agent_files())} agents, {len(all_granted)} distinct grants, "
f"{len(registered)} tools registered)"
)
if unenforced is None:
# Say plainly what was NOT checked. A guard that prints a bare "OK" invites
# the reader to conclude the allow-lists are enforced; this one has only
# compared files to files. Enforcement is a runtime property (see Rule 5),
# and on 2026-08-05 exactly one agent was found declaring 41 grants that
# nothing enforces — while this guard reported OK.
print(
" note: file-level only. Whether each allow-list is actually ENFORCED "
"depends on the\n runtime passing --agent <name>, which needs the platform "
"DB — re-run with --check-bindings\n on the host to verify."
)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -12,6 +12,8 @@ Mapping (removed → kept):
uploading → processing
in_progress → outcome_set
analyst_verified → documents_ready
research_complete → documents_ready
brainstorming → outcome_set
analysis_enriched → direction_approved
ready_for_writing → direction_approved
@@ -48,14 +50,12 @@ from legal_mcp.services import db # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("status-trim")
# removed status → nearest preceding kept status.
# 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.
# removed status → nearest preceding kept status
STATUS_MAP = {
"uploading": "processing",
"in_progress": "outcome_set",
"analyst_verified": "documents_ready",
"research_complete": "documents_ready",
"brainstorming": "outcome_set",
"analysis_enriched": "direction_approved",
"ready_for_writing": "direction_approved",

View File

@@ -1,646 +0,0 @@
#!/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()))

View File

@@ -1,809 +0,0 @@
#!/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()))

View File

@@ -1,110 +0,0 @@
/**
* pm2 ecosystem entry for legal-mcp-http — the legal-ai MCP server exposed over
* streamable-http (TaskMaster #231).
*
* Why it exists
* Agents driven over the Agent Client Protocol get their MCP servers from the
* *client* at session start, and that channel carries HTTP servers only. A
* stdio server has no path into such a session, which is how platform-driven
* agents ended up with none of the 108 tools. This service is the HTTP end
* they can actually be pointed at.
*
* It does NOT replace the stdio path. Every interactive Claude Code session
* still reaches the same server through the `legal-ai` entry in
* ~/.claude.json, spawned per session. Same code, same tool registry, two
* transports (G2) — this is a second *door*, not a second server.
*
* Security
* The registry includes case_delete, precedent_library_delete, document_upload
* and every block-writing tool, so an open port here is a delete-any-case
* endpoint. Two defences, both required:
* 1. Bind 127.0.0.1 — the platform runs on this host, so loopback suffices.
* Deliberately narrower than legal-chat-service's 10.0.1.1: nothing in a
* container needs to call MCP.
* 2. Bearer token — MCP_HTTP_SHARED_SECRET, loaded below. The server
* REFUSES TO START without it (services/http_auth.py), so a
* misconfiguration cannot silently produce an unauthenticated listener.
*
* Secret
* Source of truth: Infisical, project "All Infrastructure", env `main`,
* /apps/legal-ai/MCP_HTTP_SHARED_SECRET (tag: credentials). The file read
* below is a chmod-600 runtime copy, same arrangement as
* legal-chat-service.config.cjs. Rotate in Infisical first, then refresh the
* file and `pm2 restart legal-mcp-http`.
*
* Install (once):
* pm2 start /home/chaim/legal-ai/scripts/legal-mcp-http.config.cjs
* pm2 save
*
* Smoke test — expect 401 without the token, 200 with it:
* curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8790/mcp \
* -H 'Content-Type: application/json' \
* -H 'Accept: application/json, text/event-stream' \
* -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}'
*
* Update: pm2 restart legal-mcp-http --update-env
* Stop: pm2 stop legal-mcp-http
*/
const fs = require("fs");
const ENV_FILE = "/home/chaim/.legal-mcp-http.env";
const env = {
HOME: "/home/chaim",
PATH: "/home/chaim/.local/bin:/usr/local/bin:/usr/bin:/bin",
PYTHONUNBUFFERED: "1",
// Same DB/data wiring the stdio server gets from ~/.claude.json, so both
// transports read exactly the same corpus.
DOTENV_PATH: "/home/chaim/.env",
DATA_DIR: "/home/chaim/legal-ai/data",
// Retrieval flags must match the container's, or the two doors into the same
// corpus rank differently (G2 — a parallel path that drifts). The container
// runs MULTIMODAL_ENABLED=true; without this the MCP door skipped the
// page-image merge entirely, so an agent and the web UI could answer the same
// question from different result sets. ~/.env sets neither, so both fell to
// the code defaults (false) — the drift was silent.
//
// NOT set here: STORAGE_BACKEND. The container runs `s3`, this service falls
// back to `filesystem`, and closing that gap needs MinIO credentials that do
// not exist in Infisical yet (only in Coolify's env). Flipping the flag
// without them would resolve MINIO_ENDPOINT to the in-cluster default
// `http://minio:9000` — unreachable from the host — and break every blob
// read. Tracked separately; see the note in docs/operations-runbook.md.
MULTIMODAL_ENABLED: "true",
MCP_TRANSPORT: "streamable-http",
MCP_HTTP_HOST: "127.0.0.1",
MCP_HTTP_PORT: "8790",
};
try {
const text = fs.readFileSync(ENV_FILE, "utf8");
for (const line of text.split("\n")) {
if (!line || line.trim().startsWith("#")) continue;
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
if (m) env[m[1]] = m[2];
}
} catch (e) {
// Warn, but do not fabricate a token. The server's own gate turns a missing
// secret into a refusal to boot, which is the outcome we want — pm2 will
// surface it as a crash loop rather than serve unauthenticated traffic.
console.error(`legal-mcp-http: failed to load ${ENV_FILE}: ${e.message}`);
console.error("Service will refuse to start without MCP_HTTP_SHARED_SECRET.");
}
module.exports = {
apps: [
{
name: "legal-mcp-http",
cwd: "/home/chaim/legal-ai/mcp-server",
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
args: "-m legal_mcp.server",
env,
restart_delay: 5000,
// Low ceiling on purpose: if the token is missing the process exits
// immediately, and we want pm2 to stop retrying and leave an obvious
// errored entry rather than loop forever on a config mistake.
max_restarts: 10,
autorestart: true,
max_memory_restart: "800M",
},
],
};

View File

@@ -9,7 +9,6 @@ import {
type ApprovalCategory,
type ApprovalSeverity,
} from "@/lib/api/chair";
import { formatDateLong } from "@/lib/format-date";
/**
* מרכז אישורים — דפנה (INV-G10).
@@ -36,7 +35,18 @@ const SEVERITY_RAIL: Record<ApprovalSeverity, string> = {
ok: "border-s-success",
};
const formatDate = (iso?: string | null) => formatDateLong(iso);
function formatDate(iso?: string | null): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleDateString("he-IL", {
day: "numeric",
month: "long",
year: "numeric",
});
} catch {
return "";
}
}
function ApprovalCard({ cat }: { cat: ApprovalCategory }) {
const cleared = cat.count === 0;

View File

@@ -28,7 +28,19 @@ import {
import { useCases, useRestoreCase, type Case } from "@/lib/api/cases";
import { subtypeOf } from "@/components/cases/appeal-type-bars";
import { APPEAL_SUBTYPE_LABELS, type AppealSubtype } from "@/lib/practice-area";
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)
const TYPE_CHIP: Record<string, string> = {
@@ -165,8 +177,8 @@ export default function ArchivePage() {
const set = new Set<string>();
for (const c of rows) {
if (!c.archived_at) continue;
const y = getIsraelYear(c.archived_at);
if (y !== null) set.add(String(y));
const y = new Date(c.archived_at).getFullYear();
if (!Number.isNaN(y)) set.add(String(y));
}
return [...set].sort((a, b) => Number(b) - Number(a));
}, [rows]);
@@ -197,7 +209,7 @@ export default function ArchivePage() {
if (yearFilter !== "all") {
all = all.filter((r) => {
const iso = r.original.archived_at;
return iso != null && String(getIsraelYear(iso)) === yearFilter;
return iso != null && String(new Date(iso).getFullYear()) === yearFilter;
});
}
return all;

View File

@@ -0,0 +1,506 @@
"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 { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
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 {
useCaseLearningStatus,
type CaseLearningStatus,
} from "@/lib/api/learning";
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;
}
// ── Staged-pipeline indicator text (mockup 03) — derived from the live
// learning-status, same source as the drafts-panel LearningStatusBadges. ──────
function voiceLearningText(s?: CaseLearningStatus): string {
if (!s?.final_uploaded) return "ממתין להעלאת הסופי";
const v = s.voice_learning;
if (v.outcome === "succeeded") {
const bits = [`${v.lessons_count} לקחים הופקו`];
if (v.lessons_proposed > 0) bits.push(`${v.lessons_proposed} הוצעו לאישור`);
return `✓ הושלם · ${bits.join(" · ")}`;
}
if (v.outcome === "failed") return v.error ? `✗ נכשל — ${v.error}` : "✗ נכשל";
return "ממתין להרצה";
}
function halachaExtractionText(s?: CaseLearningStatus): string {
if (!s?.final_uploaded) return "ממתין להעלאת הסופי";
const h = s.halacha_extraction;
if (!h.enrolled_in_corpus)
return h.not_enrolled_reason ?? "לא נכנס לקורפוס-הפסיקה";
switch (h.status) {
case "completed":
return `✓ הושלם · חולצו ${h.halachot_count} · ${h.approved} אושרו · ${h.rejected} נדחו`;
case "processing":
return "רץ עכשיו…";
case "pending":
case "busy":
return "בתור";
case "partial":
return `חלקי · חולצו ${h.halachot_count}`;
case "failed":
case "extraction_failed":
return "✗ נכשל";
case "no_chunks":
return "אין טקסט לחילוץ";
default:
return "ממתין להרצה";
}
}
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);
const learning = useCaseLearningStatus(caseNumber);
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> {voiceLearningText(learning.data)}
</div>
<div className="text-[0.78rem] text-ink-muted pt-2 mt-2 border-t border-rule-soft">
<b className="text-navy">הרץ אימות-הלכות</b> {halachaExtractionText(learning.data)}
</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>
<Link
href={`/cases/${caseNumber}`}
className="inline-flex items-center gap-1.5 mb-3 rounded-md border border-rule bg-surface px-3 py-1.5 text-[0.82rem] font-semibold text-gold-deep hover:bg-gold-wash hover:text-navy transition-colors"
>
<span aria-hidden></span> חזרה לדף התיק
</Link>
<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>
<TabsTrigger value="verify">אימות פסיקה</TabsTrigger>
</TabsList>
{/* Tab 1 — the 12-block decision editor (reused DecisionBlocksPanel) */}
<TabsContent value="blocks" className="mt-5">
<DecisionBlocksPanel caseNumber={caseNumber} />
</TabsContent>
{/* Tab 3 — citation verification: per-argument support + verify gate (#154) */}
<TabsContent value="verify" className="mt-5">
<CitationVerificationPanel 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>
);
}

View File

@@ -1,34 +1,33 @@
"use client";
import { use, useState } from "react";
import { use } from "react";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { CaseHeader } from "@/components/cases/case-header";
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
import { StatusGuide } from "@/components/cases/status-guide";
import { StatusChanger } from "@/components/cases/status-changer";
import { DocumentsPanel } from "@/components/cases/documents-panel";
import { DraftsPanel } from "@/components/cases/drafts-panel";
import { CaseFilesBrowser } from "@/components/cases/case-files-browser";
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
import { PositionsPanel } from "@/components/cases/positions-panel";
import { HearingChangesPanel } from "@/components/cases/hearing-changes-panel";
import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
import { AgentActivityPreview } from "@/components/cases/agent-activity-preview";
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
import { UploadSheet } from "@/components/documents/upload-sheet";
import { expectedOutcomes } from "@/lib/schemas/case";
import { useCase, useStartWorkflow } from "@/lib/api/cases";
import { toast } from "sonner";
import {
Play, Loader2, LayoutGrid, Scale, BadgeCheck, FileText, MessageSquare, Users,
type LucideIcon,
} from "lucide-react";
import { Play, Loader2 } from "lucide-react";
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
expectedOutcomes.map((o) => [o.value, o.label]),
);
/*
* Next 16 breaking change: route params are now a Promise.
@@ -40,10 +39,12 @@ export default function CaseDetailPage({
params: Promise<{ caseNumber: string }>;
}) {
const { caseNumber } = use(params);
const [tab, setTab] = useState("overview");
const { data, isPending, error, refetch } = useCase(caseNumber);
const startWorkflow = useStartWorkflow(caseNumber);
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
const expectedOutcomeLabel = data?.expected_outcome
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
: null;
// 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.
@@ -70,32 +71,23 @@ export default function CaseDetailPage({
);
}
// Workflow order (X17): intake → arguments+positions → verify citations →
// write decision → drafts/final → agents (monitoring).
const tabDefs: [string, string, LucideIcon][] = [
["overview", "סקירה", LayoutGrid],
["arguments", "טיעונים ועמדות", Scale],
["verify", "אימות פסיקה", BadgeCheck],
["decision", "ההחלטה", FileText],
["drafts", "טיוטות והערות", MessageSquare],
["agents", "סוכנים", Users],
];
// V2 "segmented" tab strip (X17): each tab a pill, active = raised white card.
// Spread full-width via justify-between (chair request) — wide gaps with few
// tabs, tightening as more are added; gap-2 is the no-overlap floor.
const tabsList = (
<TabsList
variant="line"
className="!h-auto w-full gap-2 p-0 justify-between"
className="gap-6 h-auto p-0 rounded-none -mb-px"
>
{tabDefs.map(([value, label, Icon]) => (
{[
["overview", "סקירה"],
["arguments", "טיעונים"],
["decision", "ההחלטה"],
["drafts", "טיוטות והערות"],
["agents", "סוכנים"],
].map(([value, label]) => (
<TabsTrigger
key={value}
value={value}
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"
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"
>
<Icon className="w-4 h-4" />
{label}
</TabsTrigger>
))}
@@ -106,6 +98,9 @@ export default function CaseDetailPage({
<>
{data && <CaseEditDialog data={data} />}
<UploadSheet caseNumber={caseNumber} />
<Button asChild className="bg-gold text-white hover:bg-gold-deep border-transparent">
<Link href={`/cases/${caseNumber}/compose`}>פתח עורך החלטה</Link>
</Button>
{canStartWorkflow && (
<Button
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
@@ -131,7 +126,7 @@ export default function CaseDetailPage({
return (
<AppShell>
<Tabs value={tab} onValueChange={setTab} dir="rtl">
<Tabs defaultValue="overview" dir="rtl">
{/* parchment band — header (title/chips/parties/actions) + tab strip */}
{isPending ? (
<div className="-mx-10 -mt-10 mb-2 bg-parchment border-b border-rule px-10 pt-6 pb-4 space-y-3">
@@ -140,130 +135,76 @@ export default function CaseDetailPage({
<Skeleton className="h-6 w-96" />
</div>
) : (
<CaseHeader caseNumber={caseNumber} data={data} actions={bandActions} tabs={tabsList} />
<CaseHeader data={data} actions={bandActions} tabs={tabsList} />
)}
{/* full-width tab content — status now lives in the always-visible band
StatusRail (banner A, X17), so every tab keeps the status context. */}
<div className="min-w-0 mt-6">
<TabsContent value="overview" className="mt-0">
{/* documents (wider) + agent-activity preview (mockup 18j) */}
<div className="grid gap-6 lg:grid-cols-[1.4fr_1fr] items-start">
{/* two-column wrap — main tab content (1fr) + rail (340px) */}
<div className="grid gap-6 lg:grid-cols-[1fr_340px] items-start mt-6">
<div className="min-w-0">
<TabsContent value="overview" className="mt-0 space-y-5">
<DocumentsPanel data={data} />
<AgentActivityPreview caseNumber={caseNumber} />
</div>
</TabsContent>
{/* decision-editor CTA moved to the band actions (visible on all tabs) */}
</TabsContent>
{/* טיעונים ועמדות — 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">
<TabsContent value="arguments" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<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>
</CardContent>
</Card>
</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">
<CardContent className="px-6 py-5">
<CitationVerificationPanel caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="decision" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<DecisionBlocksPanel caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="decision" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<DecisionBlocksPanel caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts" className="mt-0 space-y-4">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<CaseFilesBrowser caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="agents" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<AgentActivityFeed caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
</div>
<TabsContent value="agents" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<AgentActivityFeed caseNumber={caseNumber} />
{/* 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>
</TabsContent>
</div>
</div>
</Tabs>
</AppShell>

View File

@@ -16,7 +16,6 @@ import {
type ChairFeedback,
type FeedbackCategory,
} from "@/lib/api/feedback";
import { formatDateLong } from "@/lib/format-date";
/**
* מרכז הערות יו"ר — הדף המרכזי לטיפול בכל הערות דפנה שנרשמו על טיוטות
@@ -34,7 +33,18 @@ const CAT_CHIP: Record<FeedbackCategory, string> = {
other: "bg-rule-soft text-ink-soft",
};
const formatDate = (iso?: string | null) => formatDateLong(iso);
function formatDate(iso?: string | null): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleDateString("he-IL", {
day: "numeric",
month: "long",
year: "numeric",
});
} catch {
return "";
}
}
function FeedbackCard({ fb }: { fb: ChairFeedback }) {
const resolve = useResolveFeedback();

View File

@@ -29,17 +29,13 @@ import {
useRunLog,
useCancelRun,
useResetAgentSession,
useAgentHealth,
useRecentEscalations,
type OpsService,
type OperationsSnapshot,
type PipelineStats,
type AgentRun,
type SubscriptionUsage,
type UsageWindow,
type AgentHealthState,
} from "@/lib/api/operations";
import { formatTime, israelParts } from "@/lib/format-date";
function mb(bytes: number): string {
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
@@ -50,7 +46,8 @@ function mb(bytes: number): string {
// and resumes on its own when a window resets.
function usageResetLabel(iso: string | null): string {
if (!iso) return "—";
return `איפוס ${formatTime(iso)}`;
const d = new Date(iso);
return `איפוס ${d.toLocaleTimeString("he-IL", { hour: "2-digit", minute: "2-digit" })}`;
}
function UsageMeter({ label, w }: { label: string; w?: UsageWindow | null }) {
@@ -198,9 +195,8 @@ function nextSaturday18(): Date {
}
const HE_DAYS = ["א׳", "ב׳", "ג׳", "ד׳", "ה׳", "ו׳", "ש׳"];
function fmtDeadline(iso: string): string {
const p = israelParts(iso);
if (!p) return "—";
return `${HE_DAYS[p.weekday]} ${pad2(p.day)}/${pad2(p.month)} ${pad2(p.hour)}:${pad2(p.minute)}`;
const d = new Date(iso);
return `${HE_DAYS[d.getDay()]} ${pad2(d.getDate())}/${pad2(d.getMonth() + 1)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
function BurstControl({ s }: { s: OpsService }) {
@@ -805,154 +801,6 @@ function LiveAgentsPanel() {
);
}
// ── Agent health taxonomy (#222) + recent escalations (#218) ───────────────
const HEALTH_LABEL: Record<AgentHealthState, string> = {
zombie: "זומבי",
stalled: "תקוע",
working: "עובד",
idle: "רגוע",
};
function AgentHealthPanel() {
const { data, isLoading } = useAgentHealth();
const { data: esc } = useRecentEscalations();
const counts = data?.counts;
// Only zombies/stalled are "problems" — the backend already sorts worst-first.
// An escalated issue is chair-owned, so it leaves this list; the escalations
// card below is the handled-loop history.
const problems = (data?.items ?? []).filter(
(i) => i.health === "zombie" || i.health === "stalled",
);
const healthyWorking = counts?.working ?? 0;
const healthyIdle = counts?.idle ?? 0;
return (
<div className="space-y-4">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
{isLoading || !data ? (
<Skeleton className="h-24 w-full" />
) : (
<>
<div className="flex flex-wrap gap-2 mb-4">
<StatTile
label="זומבי"
value={counts?.zombie ?? 0}
tone="red"
title="לולאת-recovery — משויך+פעיל-כביכול אך אין ריצה חיה. מוסלם אוטומטית."
/>
<StatTile
label="תקוע"
value={counts?.stalled ?? 0}
tone="amber"
title="ננער חוזר בלי התקדמות"
/>
<StatTile label="עובד" value={counts?.working ?? 0} tone="green" />
<StatTile label="רגוע" value={counts?.idle ?? 0} tone="muted" />
</div>
{problems.length === 0 ? (
<p className="text-sm text-emerald-600">
אין סוכן תקוע כרגע כל הסוכנים תקינים.
</p>
) : (
<div className="grid gap-2">
{problems.map((p) => (
<div
key={p.issue_id}
className={
p.health === "zombie"
? "flex items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2"
: "flex items-center justify-between gap-3 rounded-md border border-gold/40 bg-gold-wash px-3 py-2"
}
>
<div className="flex items-center gap-2 flex-wrap min-w-0">
<Badge
variant={p.health === "zombie" ? "destructive" : "outline"}
className={
p.health === "stalled"
? "border-warn/50 text-warn font-normal"
: "font-normal"
}
>
{HEALTH_LABEL[p.health]}
</Badge>
<span dir="ltr" className="font-mono text-[0.82rem] text-navy font-semibold">
{p.identifier}
</span>
<span className="text-[0.82rem] text-navy font-semibold">
{p.agent_name}
</span>
<span className="text-[0.7rem] text-ink-muted">
{p.recovery_wakeups > 0
? `${p.recovery_wakeups} יקיצות-שחזור`
: `${p.wakeups} יקיצות`}
</span>
</div>
<span className="text-[0.7rem] text-ink-muted shrink-0">
{p.health === "zombie" ? "יוסלם אוטומטית" : "מנוטר"}
</span>
</div>
))}
</div>
)}
<p className="mt-3 text-[0.78rem] text-ink-muted">
<b className="text-navy">{healthyWorking + healthyIdle} סוכנים תקינים</b> {" "}
{healthyWorking} עובדים, {healthyIdle} רגועים. מוצגות רק בעיות.
</p>
</>
)}
</CardContent>
</Card>
{/* handled-loop history — the watchdog's escalations, gold-wash like a gate */}
<Card className="bg-gold-wash border-gold/40 shadow-sm">
<CardContent className="px-5 py-4">
<h3 className="text-navy text-sm font-semibold mb-0.5">הסלמות אוטומטיות אחרונות</h3>
<p className="text-ink-muted text-[0.72rem] mb-3">
כל זומבי מתמשך (לולאת-recovery 2 יקיצות-שחזור) משויך אליך אוטומטית עם הערת-מערכת
בלי להעיר סוכן מחדש.
</p>
{!esc?.items?.length ? (
<p className="text-sm text-ink-muted">אין הסלמות אחרונות.</p>
) : (
<div className="grid gap-1">
{esc.items.map((e) => (
<div
key={`${e.issue_id}-${e.created_at ?? ""}`}
className="flex items-start gap-2.5 border-t border-gold/15 first:border-0 pt-2 first:pt-0"
>
<Badge
variant={e.severity === "medium" ? "outline" : "destructive"}
className={
e.severity === "medium"
? "border-gold/50 text-gold-deep font-normal shrink-0"
: "font-normal shrink-0"
}
>
{e.severity}
</Badge>
<div className="min-w-0 flex-1">
<span dir="ltr" className="font-mono text-[0.8rem] text-navy font-semibold">
{e.identifier}
</span>
<div className="text-[0.78rem] text-ink-soft">{e.reason}</div>
</div>
<span className="text-[0.68rem] text-ink-muted shrink-0">
{e.created_at ? ago(Date.parse(e.created_at)) : ""}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function OperationsPage() {
const { data, isLoading, error } = useOperations();
@@ -991,9 +839,6 @@ export default function OperationsPage() {
<SectionHeader>סוכנים פעילים</SectionHeader>
<LiveAgentsPanel />
<SectionHeader>בריאות-הסוכנים</SectionHeader>
<AgentHealthPanel />
<SectionHeader>מתאמי-סוכנים</SectionHeader>
<AgentAdaptersPanel />

View File

@@ -26,7 +26,6 @@ import {
useRunScript,
type ScriptRunResult,
} from "@/lib/api/scripts";
import { formatDateLong } from "@/lib/format-date";
/*
* /scripts — catalog of everything under scripts/, rendered as the
@@ -355,7 +354,11 @@ export default function ScriptsPage() {
const lastModified =
data?.last_modified != null
? formatDateLong(data.last_modified * 1000)
? new Date(data.last_modified * 1000).toLocaleDateString("he-IL", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
const giteaBase = data?.gitea_url ?? null;

View File

@@ -19,7 +19,6 @@ import {
type DriftEntry,
type PaperclipAgent,
} from "@/lib/api/paperclip-agents";
import { formatDateShort } from "@/lib/format-date";
const ROLE_LABEL: Record<string, string> = {
ceo: "CEO",
@@ -212,7 +211,7 @@ function PairCard({ pair }: { pair: AgentPair }) {
</Button>
{pair.master?.updated_at && (
<span className="text-[0.7rem] text-ink-light">
עודכן: {formatDateShort(pair.master.updated_at)}
עודכן: {new Date(pair.master.updated_at).toLocaleDateString("he-IL")}
</span>
)}
</div>

View File

@@ -13,7 +13,6 @@ import {
TableRow,
} from "@/components/ui/table";
import { useSkills, type Skill } from "@/lib/api/skills";
import { formatIsoDate } from "@/lib/format-date";
function formatChars(skill: Skill): string {
// Mockup column = "גודל (תווים)" — the DB markdown char count, grouped.
@@ -22,8 +21,13 @@ function formatChars(skill: Skill): string {
}
function formatUpdated(iso: string | null): string {
// ISO-style date to match the mockup (2026-06-09), tabular — Israel-time day.
return formatIsoDate(iso);
if (!iso) return "—";
try {
// ISO-style date to match the mockup (2026-06-09), tabular.
return new Date(iso).toISOString().slice(0, 10);
} catch {
return "—";
}
}
/**

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useRef, useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
@@ -9,8 +9,6 @@ import {
useAgentActivity,
useSendComment,
useSubmitInteraction,
useDismissInteraction,
useSetIssueStatus,
} from "@/lib/api/agents";
import type {
Interaction,
@@ -18,10 +16,7 @@ import type {
InteractionQuestion,
InteractionTask,
PaperclipComment,
PaperclipAgentStatus,
PaperclipIssue,
} from "@/lib/api/agents";
import { formatRelative } from "@/lib/format-date";
import { toast } from "sonner";
import {
Bot,
@@ -33,11 +28,6 @@ import {
CheckCircle2,
XCircle,
HelpCircle,
ChevronDown,
Ban,
Target,
Plus,
AlertTriangle,
} from "lucide-react";
/* ── Role → color mapping ────────────────────────────────────── */
@@ -90,83 +80,18 @@ function issueStatusLabel(status: string) {
return ISSUE_STATUS_LABELS[status] ?? status;
}
/* ── Agent roster (mockup 18i) — who's doing what right now ────── */
const AGENT_STATUS_LABEL: Record<string, string> = {
active: "פעיל",
running: "פעיל",
idle: "ממתין",
error: "שגיאה",
};
function agentIsActive(s: string) {
return s === "active" || s === "running";
}
function agentStatusDot(s: string) {
return agentIsActive(s)
? "bg-success shadow-[0_0_6px_rgba(74,124,89,0.6)]"
: s === "error"
? "bg-danger"
: "bg-rule";
}
function agentStatusTone(s: string) {
return agentIsActive(s)
? "bg-success-bg text-success"
: s === "error"
? "bg-danger-bg text-danger"
: "bg-rule-soft text-ink-muted";
}
function AgentRoster({ agents }: { agents: PaperclipAgentStatus[] }) {
if (!agents.length) return null;
const activeCount = agents.filter((a) => agentIsActive(a.status)).length;
return (
<div className="mb-3 rounded-lg border border-rule bg-parchment/40 p-3">
<div className="flex items-center gap-2 mb-2.5">
<Bot className="w-4 h-4 text-gold-deep shrink-0" />
<h3 className="text-[0.82rem] font-bold text-navy">סוכני התיק</h3>
<span className="text-[0.72rem] text-ink-muted">
<b className="text-success font-bold">{activeCount} פעילים</b> מתוך {agents.length}
</span>
</div>
{/* Compact roster — all agents on one row (mockup 18i v2): status · name ·
role only. The per-agent "current activity" line was removed: it
matched comments by role-fallback, so agents sharing a role showed the
same text. */}
<div className="grid gap-1.5 grid-cols-3 sm:grid-cols-5 lg:grid-cols-9">
{agents.map((a) => {
const active = agentIsActive(a.status);
return (
<div
key={a.id}
className={`rounded-lg border p-2 min-w-0 ${active ? "border-success/40 bg-success-bg/40" : "border-rule bg-surface"}`}
>
<div className="flex items-center gap-1.5 min-w-0">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
<span className="text-[0.72rem] font-bold text-navy truncate">{a.name}</span>
</div>
<div className="text-[0.6rem] text-ink-muted mt-0.5 truncate">{roleLabel(a.role)}</div>
<span
className={`inline-block mt-1 text-[0.55rem] font-bold rounded-full px-1.5 py-0.5 whitespace-nowrap ${agentStatusTone(a.status)}`}
>
{AGENT_STATUS_LABEL[a.status] ?? a.status}
</span>
</div>
);
})}
</div>
</div>
);
}
/* ── Time formatting ─────────────────────────────────────────── */
function timeAgo(iso: string | null): string {
return formatRelative(iso, { units: "short", fallback: "" });
if (!iso) return "";
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "עכשיו";
if (mins < 60) return `לפני ${mins} דק׳`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `לפני ${hours} שע׳`;
const days = Math.floor(hours / 24);
return `לפני ${days} ימים`;
}
/* ── Issue identifier → find matching identifier ─────────────── */
@@ -588,21 +513,15 @@ function InteractionCard({
interaction,
caseNumber,
issueMap,
defaultOpen = true,
}: {
interaction: Interaction;
caseNumber: string;
issueMap: Map<string, string>;
defaultOpen?: boolean;
}) {
const submit = useSubmitInteraction(caseNumber);
const dismiss = useDismissInteraction(caseNumber);
const identifier = issueMap.get(interaction.issue_id) ?? "";
const isPending = interaction.status === "pending";
const summary = summaryAnswer(interaction);
// Pending interactions collapse into an accordion (mockup 18i v2); resolved
// ones always render their one-line summary.
const [open, setOpen] = useState(defaultOpen);
const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record<string, unknown>) => {
submit.mutate(
@@ -619,16 +538,6 @@ function InteractionCard({
);
};
const handleDismiss = () => {
dismiss.mutate(
{ issue_id: interaction.issue_id, interaction_id: interaction.id },
{
onSuccess: () => toast.success("הבקשה בוטלה"),
onError: () => toast.error("שגיאה בביטול הבקשה"),
},
);
};
return (
<div
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
@@ -651,30 +560,15 @@ function InteractionCard({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-sm font-semibold text-navy">
{interaction.title || "שאלה לסוכן"}
</span>
{isPending ? (
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex items-center gap-2 flex-wrap text-start min-w-0"
>
<ChevronDown
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/>
<span className="text-sm font-semibold text-navy">
{interaction.title || "שאלה לסוכן"}
</span>
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
ממתין לתשובה
</span>
</button>
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
ממתין לתשובה
</span>
) : (
<>
<span className="text-sm font-semibold text-navy">
{interaction.title || "שאלה לסוכן"}
</span>
<ResolvedBadge status={interaction.status} />
</>
<ResolvedBadge status={interaction.status} />
)}
{identifier && (
<Badge variant="outline" className="text-[10px] font-mono">
@@ -685,58 +579,39 @@ function InteractionCard({
<Clock className="w-3 h-3" />
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
</span>
{isPending && (
<Button
size="sm"
variant="ghost"
onClick={handleDismiss}
disabled={dismiss.isPending}
className="h-7 px-2 text-ink-muted hover:text-danger"
title="בטל בקשה כפולה/מיושנת — ללא העברה לסוכן"
>
{dismiss.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Ban className="w-3.5 h-3.5 me-1" />
)}
התעלם
</Button>
)}
</div>
{interaction.summary && (!isPending || open) && (
{interaction.summary && (
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
)}
{isPending ? (
open ? (
interaction.kind === "ask_user_questions" ? (
<AskUserQuestionsForm
interaction={interaction}
onSubmit={(answers) => send("respond", { answers })}
pending={submit.isPending}
/>
) : interaction.kind === "request_confirmation" ? (
<RequestConfirmationForm
interaction={interaction}
onAccept={() => send("accept", {})}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
) : interaction.kind === "suggest_tasks" ? (
<SuggestTasksForm
interaction={interaction}
onAccept={(keys) =>
send("accept", keys.length ? { selectedClientKeys: keys } : {})
}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
) : null
interaction.kind === "ask_user_questions" ? (
<AskUserQuestionsForm
interaction={interaction}
onSubmit={(answers) => send("respond", { answers })}
pending={submit.isPending}
/>
) : interaction.kind === "request_confirmation" ? (
<RequestConfirmationForm
interaction={interaction}
onAccept={() => send("accept", {})}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
) : interaction.kind === "suggest_tasks" ? (
<SuggestTasksForm
interaction={interaction}
onAccept={(keys) =>
send("accept", keys.length ? { selectedClientKeys: keys } : {})
}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
) : null
) : summary ? (
<div className="text-sm">
@@ -748,480 +623,6 @@ function InteractionCard({
);
}
/* ── Issue group (timeline grouped by issue, accordion) ──────── */
type FeedItem =
| { kind: "comment"; at: number; comment: PaperclipComment }
| { kind: "interaction"; at: number; interaction: Interaction };
/** Drop the "[ערר 1043-02-26] " prefix Paperclip puts on every issue title. */
function shortIssueTitle(title: string): string {
return title.replace(/^\s*\[[^\]]*\]\s*/, "").trim() || title;
}
function IssueGroup({
issue,
items,
caseNumber,
issueMap,
defaultOpen,
}: {
issue: PaperclipIssue;
items: FeedItem[];
caseNumber: string;
issueMap: Map<string, string>;
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const setStatus = useSetIssueStatus(caseNumber);
const closed = issue.status === "done" || issue.status === "cancelled";
// Show the parent run this issue hangs under (e.g. a "הרצה חדשה" nested under
// the live CEO root) so the hierarchy is visible, not flattened.
const parentIdentifier = issue.parent_id
? issueMap.get(issue.parent_id)
: undefined;
const close = (status: "done" | "cancelled") =>
setStatus.mutate(
{ issue_id: issue.id, status },
{
onSuccess: () =>
toast.success(
status === "done" ? "המשימה סומנה כהושלמה" : "המשימה בוטלה",
),
onError: () => toast.error("שגיאה בעדכון סטטוס המשימה"),
},
);
return (
<div className="border-b-2 border-rule last:border-b-0">
<div className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex-1 flex items-center gap-2 text-start min-w-0"
>
<Badge
variant={closed ? "secondary" : "default"}
className="text-[10px] font-mono shrink-0"
>
{issue.identifier}
</Badge>
<span className="text-[0.78rem] font-semibold text-navy truncate">
{shortIssueTitle(issue.title)}
</span>
{parentIdentifier && (
<span
className="inline-flex items-center gap-0.5 text-[0.6rem] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parentIdentifier}`}
>
{parentIdentifier}
</span>
)}
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
{issueStatusLabel(issue.status)} · {items.length}
</span>
</button>
{!closed && (
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="outline"
onClick={() => close("done")}
disabled={setStatus.isPending}
className="h-7 px-2 text-[0.7rem] text-success border-success/40 hover:bg-success-bg"
title="סמן את המשימה כהושלמה"
>
{setStatus.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<CheckCircle2 className="w-3.5 h-3.5 me-1" />
)}
הושלם
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => close("cancelled")}
disabled={setStatus.isPending}
className="h-7 px-2 text-[0.7rem] text-ink-muted hover:text-danger"
title="בטל את המשימה"
>
בטל
</Button>
</div>
)}
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label={open ? "כווץ" : "הרחב"}
className="shrink-0"
>
<ChevronDown
className={`w-4 h-4 text-ink-faint transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
</div>
{open && (
<div className="pb-1">
{items.map((item) =>
item.kind === "comment" ? (
<CommentCard
key={`c-${item.comment.id}`}
comment={item.comment}
issueMap={issueMap}
/>
) : (
<InteractionCard
key={`i-${item.interaction.id}`}
interaction={item.interaction}
caseNumber={caseNumber}
issueMap={issueMap}
/>
),
)}
</div>
)}
</div>
);
}
/* ── Compose: directive box + target selector (top of tab) ────── */
const isOpenStatus = (s: string) => s !== "done" && s !== "cancelled";
/** Client mirror of the server's pick_default_comment_target
* (web/paperclip_client.py): prefer the newest OPEN top-level issue (the live
* CEO main issue), then any open issue, then any top-level, else newest.
* `issues` arrives oldest→newest, so the last match is the newest. */
function pickDefaultTarget(issues: PaperclipIssue[]): PaperclipIssue | null {
if (!issues.length) return null;
const isTop = (i: PaperclipIssue) => i.parent_id == null;
const preds: ((i: PaperclipIssue) => boolean)[] = [
(i) => isOpenStatus(i.status) && isTop(i),
(i) => isOpenStatus(i.status),
isTop,
];
for (const p of preds) {
const m = issues.filter(p);
if (m.length) return m[m.length - 1];
}
return issues[issues.length - 1];
}
const STATUS_TONE: Record<string, string> = {
in_progress: "bg-emerald-100 text-emerald-700",
in_review: "bg-amber-100 text-amber-700",
todo: "bg-emerald-100 text-emerald-700",
backlog: "bg-emerald-100 text-emerald-700",
blocked: "bg-red-100 text-red-700",
done: "bg-gray-100 text-gray-500",
cancelled: "bg-red-50 text-red-600",
};
function statusTone(s: string) {
return STATUS_TONE[s] ?? "bg-gray-100 text-gray-600";
}
/** Where a chair instruction is routed. `auto` lets the server pick the live
* CEO main issue; `issue` targets an explicit one; `new_run` opens a fresh
* CEO-owned run (bypasses the human-owned-issue cancellation). */
type Target =
| { kind: "auto" }
| { kind: "issue"; id: string }
| { kind: "new_run" };
function TargetSelector({
issues,
target,
onChange,
}: {
issues: PaperclipIssue[];
target: Target;
onChange: (t: Target) => void;
}) {
const [open, setOpen] = useState(false);
const defaultIssue = useMemo(() => pickDefaultTarget(issues), [issues]);
const activeIssues = issues.filter((i) => isOpenStatus(i.status));
const closedIssues = issues.filter((i) => !isOpenStatus(i.status));
// id → identifier, to label a run with the parent it hangs under (↳ CMP-189).
const idToIdentifier = useMemo(
() => new Map(issues.map((i) => [i.id, i.identifier])),
[issues],
);
const parentTag = (i: PaperclipIssue) =>
i.parent_id ? idToIdentifier.get(i.parent_id) : undefined;
// The issue the pill currently represents (explicit pick, or the auto default).
const effectiveIssue =
target.kind === "issue"
? issues.find((i) => i.id === target.id) ?? null
: target.kind === "auto"
? defaultIssue
: null;
return (
<div className="relative min-w-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex items-center gap-2 bg-sand-soft/60 border border-rule rounded-lg px-2.5 py-1.5 hover:border-gold/60 min-w-0 max-w-full"
>
<Target className="w-3.5 h-3.5 text-gold-deep shrink-0" />
{target.kind === "new_run" ? (
<span className="text-xs font-semibold text-navy">פתח הרצה חדשה</span>
) : effectiveIssue ? (
<>
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
{effectiveIssue.identifier}
</Badge>
<span className="text-xs font-medium text-navy truncate">
{shortIssueTitle(effectiveIssue.title)}
</span>
<span
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(effectiveIssue.status)}`}
>
{issueStatusLabel(effectiveIssue.status)}
</span>
</>
) : (
<span className="text-xs text-ink-muted">בחר יעד</span>
)}
<ChevronDown className="w-3.5 h-3.5 text-ink-faint shrink-0 ms-1" />
</button>
{open && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setOpen(false)}
aria-hidden
/>
<div className="absolute z-20 top-full mt-1 start-0 w-[360px] max-w-[88vw] max-h-[62vh] overflow-y-auto overscroll-contain bg-white border border-rule rounded-xl shadow-lg p-1.5">
{/* "פתח הרצה חדשה" is the FIRST, most-prominent option so it's always
visible without scrolling to the bottom of a long task list. */}
<button
type="button"
onClick={() => {
onChange({ kind: "new_run" });
setOpen(false);
}}
className="w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-start bg-gold/10 border border-rule hover:border-gold/60"
>
<span className="w-6 h-6 rounded-md bg-navy text-white flex items-center justify-center shrink-0">
<Plus className="w-4 h-4" />
</span>
<span className="min-w-0">
<span className="block text-xs font-semibold text-navy">
פתח הרצה חדשה
</span>
<span className="block text-[10px] text-ink-muted">
ה-CEO ייצור משימה חדשה ויטפל בהוראה מאפס
</span>
</span>
</button>
<div className="h-px bg-rule-soft my-1.5 mx-1" />
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
משימות פעילות
</div>
{activeIssues.map((i) => {
const selected =
(target.kind === "issue" && target.id === i.id) ||
(target.kind === "auto" && defaultIssue?.id === i.id);
const parent = parentTag(i);
return (
<button
key={i.id}
type="button"
onClick={() => {
onChange({ kind: "issue", id: i.id });
setOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg text-start hover:bg-sand-soft ${parent ? "ps-6" : ""} ${selected ? "bg-gold/10" : ""}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0" />
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
{i.identifier}
</Badge>
{parent && (
<span
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parent}`}
>
{parent}
</span>
)}
<span className="text-xs text-ink-soft truncate flex-1">
{shortIssueTitle(i.title)}
</span>
<span
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
>
{issueStatusLabel(i.status)}
</span>
{selected && (
<CheckCircle2 className="w-3.5 h-3.5 text-gold-deep shrink-0" />
)}
</button>
);
})}
{closedIssues.length > 0 && (
<>
<div className="h-px bg-rule-soft my-1.5 mx-1" />
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
משימות סגורות · שליחה אליהן לא תעיר סוכן
</div>
{closedIssues.map((i) => {
const parent = parentTag(i);
return (
<div
key={i.id}
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed ${parent ? "ps-6" : ""}`}
title="משימה סגורה — שליחה אליה לא תעיר סוכן"
>
<span className="w-1.5 h-1.5 rounded-full bg-ink-faint shrink-0" />
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
{i.identifier}
</Badge>
{parent && (
<span
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parent}`}
>
{parent}
</span>
)}
<span className="text-xs text-ink-faint truncate flex-1">
{shortIssueTitle(i.title)}
</span>
<span
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
>
{issueStatusLabel(i.status)}
</span>
</div>
);
})}
</>
)}
</div>
</>
)}
</div>
);
}
function Composer({
caseNumber,
issues,
}: {
caseNumber: string;
issues: PaperclipIssue[];
}) {
const sendComment = useSendComment(caseNumber);
const [body, setBody] = useState("");
const [target, setTarget] = useState<Target>({ kind: "auto" });
const selectedIssue =
target.kind === "issue" ? issues.find((i) => i.id === target.id) ?? null : null;
const closedTarget = !!selectedIssue && !isOpenStatus(selectedIssue.status);
const handleSend = () => {
if (!body.trim() || closedTarget) return;
const vars =
target.kind === "new_run"
? { body: body.trim(), new_run: true }
: target.kind === "issue"
? { body: body.trim(), issue_id: target.id }
: { body: body.trim() };
sendComment.mutate(vars, {
onSuccess: (res) => {
setBody("");
setTarget({ kind: "auto" });
toast.success(
res.new_run
? `נפתחה הרצה חדשה — ${res.issue_identifier}`
: `נשלח ל-${res.issue_identifier}`,
);
},
onError: () => toast.error("שגיאה בשליחת ההודעה"),
});
};
return (
<div className="bg-white border border-rule rounded-xl shadow-sm p-3.5">
<div className="flex items-center gap-2 mb-2.5">
<MessageSquare className="w-4 h-4 text-gold-deep" />
<h3 className="text-sm font-bold text-navy">הוראה לסוכנים</h3>
<span className="text-[11px] text-ink-faint ms-auto">
Ctrl+Enter לשליחה
</span>
</div>
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-[11px] font-semibold text-ink-muted shrink-0">
שליחה אל:
</span>
<TargetSelector issues={issues} target={target} onChange={setTarget} />
</div>
{closedTarget && (
<div className="flex items-start gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2 mb-2 text-[11.5px] text-red-700 leading-snug">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>
<b className="font-bold">יעד סגור.</b> שליחה למשימה סגורה לא תעיר סוכן
ההוראה לא תטופל. בחר משימה פעילה או{" "}
<button
type="button"
className="font-bold underline"
onClick={() => setTarget({ kind: "new_run" })}
>
פתח הרצה חדשה
</button>
.
</div>
</div>
)}
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="כתוב הוראה לסוכנים..."
aria-label="הוראה לסוכנים"
className="min-h-[60px] resize-none text-sm"
dir="rtl"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
}
}}
/>
<div className="flex items-center mt-2">
<span className="text-[11px] text-ink-faint">
ההודעה תנותב דרך סוכן ה-CEO · היעד ניתן לשינוי למעלה
</span>
<Button
size="sm"
className="ms-auto"
onClick={handleSend}
disabled={!body.trim() || closedTarget || sendComment.isPending}
>
{sendComment.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4 me-1" />
)}
שלח
</Button>
</div>
</div>
);
}
/* ── Main Feed ───────────────────────────────────────────────── */
export function AgentActivityFeed({
@@ -1230,6 +631,9 @@ export function AgentActivityFeed({
caseNumber: string;
}) {
const { data, isLoading, error } = useAgentActivity(caseNumber);
const sendComment = useSendComment(caseNumber);
const [body, setBody] = useState("");
const endRef = useRef<HTMLDivElement>(null);
// Build issue_id → identifier map (memoized — the feed refetches every 10s,
// and a fresh Map each render would defeat the child cards' memoization).
@@ -1239,6 +643,27 @@ export function AgentActivityFeed({
return m;
}, [data?.issues]);
// Auto-scroll on new comments or interactions
const commentCount = data?.comments?.length ?? 0;
const interactionCount = data?.interactions?.length ?? 0;
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [commentCount, interactionCount]);
const handleSend = () => {
if (!body.trim()) return;
sendComment.mutate(
{ body: body.trim() },
{
onSuccess: (res) => {
setBody("");
toast.success(`נשלח ל-${res.issue_identifier}`);
},
onError: () => toast.error("שגיאה בשליחת ההודעה"),
},
);
};
// ── Empty / loading states ──
if (isLoading) {
@@ -1271,46 +696,24 @@ export function AgentActivityFeed({
const comments = data.comments ?? [];
const interactions = data.interactions ?? [];
// Pending interactions are surfaced at the top (awaiting-you, mockup 18i) and
// NOT repeated in the timeline below.
const pending = interactions.filter((i) => i.status === "pending");
// Group the timeline by issue, each an accordion (mockup 18i v2). Comments +
// resolved interactions; pending ones are surfaced above and not repeated.
const itemsByIssue = new Map<string, FeedItem[]>();
const pushItem = (issueId: string, item: FeedItem) => {
const arr = itemsByIssue.get(issueId);
if (arr) arr.push(item);
else itemsByIssue.set(issueId, [item]);
};
for (const c of comments) {
pushItem(c.issue_id, {
// Unified, time-sorted feed: comments + interactions interleaved.
type FeedItem =
| { kind: "comment"; at: number; comment: PaperclipComment }
| { kind: "interaction"; at: number; interaction: Interaction };
const feed: FeedItem[] = [
...comments.map<FeedItem>((c) => ({
kind: "comment",
at: c.created_at ? new Date(c.created_at).getTime() : 0,
comment: c,
});
}
for (const i of interactions) {
if (i.status === "pending") continue;
pushItem(i.issue_id, {
})),
...interactions.map<FeedItem>((i) => ({
kind: "interaction",
at: i.created_at ? new Date(i.created_at).getTime() : 0,
interaction: i,
});
}
for (const arr of itemsByIssue.values()) arr.sort((a, b) => a.at - b.at);
// Order groups by most-recent activity so live work floats to the top.
const issueGroups = data.issues
.map((iss) => {
const items = itemsByIssue.get(iss.id) ?? [];
const maxAt = items.reduce((m, it) => Math.max(m, it.at), 0);
return { iss, items, maxAt };
})
.filter((g) => g.items.length > 0)
.sort((a, b) => b.maxAt - a.maxAt);
const totalFeedItems = issueGroups.reduce((n, g) => n + g.items.length, 0);
})),
].sort((a, b) => a.at - b.at);
// An issue is "active" if it's not done/cancelled. When everything is closed
// we should NOT show the "agents are working, waiting for report" spinner.
@@ -1319,51 +722,23 @@ export function AgentActivityFeed({
);
return (
<div className="flex flex-col h-full gap-3">
{/* directive box — moved to the TOP, above the activity (most available,
no scrolling). Carries the target selector. */}
<Composer caseNumber={caseNumber} issues={data.issues} />
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
<AgentRoster agents={data.agents ?? []} />
{/* pending interactions surfaced to the top — each an accordion, all
COLLAPSED by default (chair opens to look). Excluded from the timeline. */}
{pending.length > 0 && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2">
<div className="flex items-center gap-2 text-[0.82rem] font-bold text-amber-800">
<Clock className="w-4 h-4" />
ממתין לתשובתך
<span className="text-[0.7rem] font-semibold bg-amber-100 rounded-full px-2 py-0.5">
{pending.length}
</span>
</div>
{pending.map((i) => (
<InteractionCard
key={`pending-${i.id}`}
interaction={i}
caseNumber={caseNumber}
issueMap={issueMap}
defaultOpen={false}
/>
))}
</div>
)}
{/* Timeline header */}
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule">
<span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
<span className="text-[0.68rem] font-semibold text-gold-deep bg-gold/10 rounded-full px-2 py-0.5">
החדש למעלה
</span>
<span className="text-[0.72rem] text-ink-muted ms-auto">
{data.issues.length} משימות · {totalFeedItems} הודעות
</span>
<div className="flex flex-col h-full">
{/* Issue summary bar */}
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-2 flex-wrap">
{data.issues.map((iss) => (
<Badge
key={iss.id}
variant={iss.status === "done" ? "secondary" : "default"}
className="text-[11px] font-mono"
>
{iss.identifier} {issueStatusLabel(iss.status)}
</Badge>
))}
</div>
{/* Per-issue accordions — all COLLAPSED by default, newest group first */}
<div className="flex-1 overflow-y-auto max-h-[500px]">
{totalFeedItems === 0 ? (
{/* Comments + interactions stream */}
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
{feed.length === 0 ? (
hasActiveIssue ? (
<div className="text-center py-8 text-ink-faint text-sm">
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
@@ -1376,17 +751,59 @@ export function AgentActivityFeed({
</div>
)
) : (
issueGroups.map(({ iss, items }) => (
<IssueGroup
key={iss.id}
issue={iss}
items={items}
caseNumber={caseNumber}
issueMap={issueMap}
defaultOpen={false}
/>
))
feed.map((item) =>
item.kind === "comment" ? (
<CommentCard
key={`c-${item.comment.id}`}
comment={item.comment}
issueMap={issueMap}
/>
) : (
<InteractionCard
key={`i-${item.interaction.id}`}
interaction={item.interaction}
caseNumber={caseNumber}
issueMap={issueMap}
/>
),
)
)}
<div ref={endRef} />
</div>
{/* Comment input */}
<div className="border-t border-rule pt-3 mt-3 space-y-2">
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="כתוב הוראה לסוכנים..."
aria-label="הוראה לסוכנים"
className="min-h-[60px] resize-none text-sm"
dir="rtl"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
}
}}
/>
<div className="flex items-center justify-between">
<span className="text-[11px] text-ink-faint">
ההודעה תנותב דרך סוכן ה-CEO &middot; Ctrl+Enter לשליחה
</span>
<Button
size="sm"
onClick={handleSend}
disabled={!body.trim() || sendComment.isPending}
>
{sendComment.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4 me-1" />
)}
שלח
</Button>
</div>
</div>
</div>
);

View File

@@ -4,7 +4,6 @@ import Link from "next/link";
import { Loader2, CheckCircle2, Clock } from "lucide-react";
import { useAgentActivity } from "@/lib/api/agents";
import type { PaperclipIssue } from "@/lib/api/agents";
import { formatRelative } from "@/lib/format-date";
const STATUS_DOT: Record<string, string> = {
in_progress: "bg-gold",
@@ -23,7 +22,13 @@ const STATUS_LABEL: Record<string, string> = {
};
function timeAgo(iso: string | null): string {
return formatRelative(iso, { units: "tight", fallback: "—" });
if (!iso) return "—";
const diffMs = Date.now() - new Date(iso).getTime();
const m = Math.floor(diffMs / 60_000);
if (m < 60) return `לפני ${m}ד'`;
const h = Math.floor(m / 60);
if (h < 24) return `לפני ${h}ש'`;
return `לפני ${Math.floor(h / 24)} ימים`;
}
function IssueRow({ issue }: { issue: PaperclipIssue }) {

View File

@@ -1,304 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import {
useCaseFiles,
useCaseFileText,
caseFileUrl,
type CaseFileFolder,
} from "@/lib/api/case-files";
import { Markdown } from "@/components/ui/markdown";
import { Badge } from "@/components/ui/badge";
import { formatDateTime } from "@/lib/format-date";
import {
Folder,
FileText,
File as FileIcon,
Download,
ChevronDown,
Loader2,
FolderOpen,
} from "lucide-react";
/* ── Helpers ─────────────────────────────────────────────────── */
const FOLDER_LABELS: Record<string, string> = {
research: "מחקר וטיוטות",
originals: "מסמכי מקור",
extracted: "טקסט מחולץ (OCR)",
proofread: "לאחר הגהה",
drafts: "טיוטות DOCX",
backup: "גיבויים",
};
// Extensions the inline viewer can render (everything else → download only).
const TEXT_EXT = new Set(["md", "txt", "json", "csv", "log", "markdown"]);
function fileExt(filename: string): string {
return filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : "";
}
function folderLabel(f: CaseFileFolder): string {
return FOLDER_LABELS[f.name] ?? f.name;
}
function fmtSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
const EXT_TONE: Record<string, string> = {
md: "bg-teal-100 text-teal-700",
markdown: "bg-teal-100 text-teal-700",
txt: "bg-blue-100 text-blue-700",
json: "bg-blue-100 text-blue-700",
pdf: "bg-red-100 text-red-700",
docx: "bg-indigo-100 text-indigo-700",
doc: "bg-indigo-100 text-indigo-700",
};
function extTone(ext: string): string {
return EXT_TONE[ext] ?? "bg-gray-100 text-gray-600";
}
type Selected = { folderKey: string; filename: string };
/* ── Component ───────────────────────────────────────────────── */
export function CaseFilesBrowser({ caseNumber }: { caseNumber: string }) {
const { data, isLoading, error } = useCaseFiles(caseNumber);
const folders = useMemo(() => data?.folders ?? [], [data?.folders]);
const totalFiles = folders.reduce((n, f) => n + f.files.length, 0);
// Default selection derived (not via an effect): a text file in research,
// else any text file. The chair's explicit pick overrides it.
const defaultSelected = useMemo<Selected | null>(() => {
const pick = (
pred: (f: CaseFileFolder, name: string) => boolean,
): Selected | null => {
for (const f of folders)
for (const file of f.files)
if (pred(f, file.filename))
return { folderKey: f.key, filename: file.filename };
return null;
};
return (
pick((f, name) => f.name === "research" && TEXT_EXT.has(fileExt(name))) ??
pick((_f, name) => TEXT_EXT.has(fileExt(name)))
);
}, [folders]);
const [userSelected, setUserSelected] = useState<Selected | null>(null);
const selected = userSelected ?? defaultSelected;
// A folder is open if explicitly toggled; otherwise the folder of the current
// selection (or the first non-empty folder) is open by default.
const defaultOpenKey =
selected?.folderKey ?? folders.find((f) => f.files.length)?.key ?? null;
const [openOverrides, setOpenOverrides] = useState<Map<string, boolean>>(
new Map(),
);
const isOpen = (key: string) =>
openOverrides.has(key) ? openOverrides.get(key)! : key === defaultOpenKey;
const selExt = selected ? fileExt(selected.filename) : "";
const selIsText = TEXT_EXT.has(selExt);
const {
data: fileText,
isLoading: textLoading,
error: textError,
} = useCaseFileText(
caseNumber,
selIsText ? selected?.folderKey : undefined,
selIsText ? selected?.filename : undefined,
);
const toggleFolder = (key: string) => {
const next = !isOpen(key);
setOpenOverrides((prev) => new Map(prev).set(key, next));
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-10 text-ink-faint">
<Loader2 className="w-5 h-5 animate-spin me-2" />
<span>טוען קבצים...</span>
</div>
);
}
if (error) {
return (
<div className="text-center py-10 text-red-500 text-sm">
שגיאה בטעינת קבצי-התיק
</div>
);
}
return (
<div className="border border-rule rounded-xl overflow-hidden">
{/* header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-rule-soft bg-parchment">
<FolderOpen className="w-4 h-4 text-gold-deep" />
<h3 className="text-sm font-bold text-navy">קבצי-התיק</h3>
<span className="text-[11px] text-ink-muted ms-auto">
כל תתי-התיקיות · {totalFiles} קבצים
</span>
</div>
<div className="grid md:grid-cols-[320px_1fr]">
{/* file tree */}
<div className="border-b md:border-b-0 md:border-e border-rule-soft max-h-[560px] overflow-y-auto">
{folders.map((f) => {
const open = isOpen(f.key);
return (
<div key={f.key} className="border-b border-rule-soft last:border-b-0">
<button
type="button"
onClick={() => toggleFolder(f.key)}
aria-expanded={open}
className="w-full flex items-center gap-2 px-3.5 py-2.5 hover:bg-sand-soft/60 text-start"
>
<Folder className="w-4 h-4 text-gold-deep shrink-0" />
<span className="text-[0.78rem] font-bold text-navy">
{folderLabel(f)}
</span>
<span className="text-[9px] font-mono text-ink-faint hidden sm:inline">
{f.path}
</span>
<span
className={`text-[10px] font-semibold rounded-full px-2 ms-auto ${f.files.length ? "bg-gray-100 text-ink-muted" : "text-ink-light"}`}
>
{f.files.length || "ריק"}
</span>
<ChevronDown
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
{open && f.files.length > 0 && (
<div className="pb-1 bg-parchment/50">
{f.files.map((file) => {
const ext = fileExt(file.filename);
const isSel =
selected?.folderKey === f.key &&
selected?.filename === file.filename;
const viewable = TEXT_EXT.has(ext);
return (
<div
key={file.filename}
className={`flex items-center gap-2.5 py-1.5 pe-3.5 ps-5 cursor-pointer border-s-2 ${isSel ? "bg-gold-wash border-gold" : "border-transparent hover:bg-sand-soft/60"}`}
onClick={() =>
viewable
? setUserSelected({
folderKey: f.key,
filename: file.filename,
})
: undefined
}
>
<span
className={`w-6 h-6 rounded-md flex items-center justify-center shrink-0 ${extTone(ext)}`}
>
{viewable ? (
<FileText className="w-3.5 h-3.5" />
) : (
<FileIcon className="w-3.5 h-3.5" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="text-[11.5px] font-medium text-ink-soft truncate">
{file.filename}
</div>
<div className="text-[9.5px] text-ink-light">
{fmtSize(file.size)} ·{" "}
{formatDateTime(file.modified_at * 1000)}
</div>
</div>
<a
href={caseFileUrl(caseNumber, f.key, file.filename)}
download={file.filename}
onClick={(e) => e.stopPropagation()}
className="w-7 h-7 rounded-md border border-rule bg-surface flex items-center justify-center text-ink-muted hover:text-navy shrink-0"
title="הורדה"
>
<Download className="w-3.5 h-3.5" />
</a>
</div>
);
})}
</div>
)}
</div>
);
})}
{folders.length === 0 && (
<div className="text-center py-10 text-ink-faint text-sm">
אין קבצים בתיק.
</div>
)}
</div>
{/* viewer */}
<div className="min-w-0 flex flex-col">
{!selected ? (
<div className="flex-1 flex flex-col items-center justify-center py-16 text-ink-faint text-sm">
<FileText className="w-8 h-8 mb-2 opacity-40" />
בחר קובץ טקסט מהרשימה לצפייה
</div>
) : (
<>
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft">
<span
className={`w-7 h-7 rounded-md flex items-center justify-center shrink-0 ${extTone(selExt)}`}
>
<FileText className="w-4 h-4" />
</span>
<div className="min-w-0">
<div className="text-[12.5px] font-bold text-navy truncate">
{selected.filename}
</div>
</div>
<a
href={caseFileUrl(
caseNumber,
selected.folderKey,
selected.filename,
)}
download={selected.filename}
className="ms-auto inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-navy bg-parchment border border-rule rounded-md px-3 py-1.5 hover:bg-sand-soft"
>
<Download className="w-3.5 h-3.5" />
הורדה
</a>
</div>
<div className="px-5 py-4 overflow-y-auto max-h-[520px] bg-[#fdfcf9]">
{!selIsText ? (
<div className="text-center py-12 text-sm text-ink-muted">
קובץ מסוג <Badge variant="outline">{selExt || "?"}</Badge>
לא ניתן לצפייה מוטבעת. הורד כדי לפתוח.
</div>
) : textLoading ? (
<div className="flex items-center justify-center py-12 text-ink-faint">
<Loader2 className="w-5 h-5 animate-spin me-2" />
טוען תוכן...
</div>
) : textError ? (
<div className="text-center py-12 text-red-500 text-sm">
שגיאה בטעינת תוכן הקובץ
</div>
) : selExt === "md" || selExt === "markdown" ? (
<Markdown content={fileText ?? ""} />
) : (
<pre className="whitespace-pre-wrap text-xs text-ink-soft font-mono leading-relaxed">
{fileText}
</pre>
)}
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/cases/status-badge";
import { StatusRail } from "@/components/cases/status-rail";
import { SyncIndicator } from "@/components/cases/sync-indicator";
import { CaseArchiveAction } from "@/components/cases/case-archive-action";
import { CreateRepoButton } from "@/components/cases/create-repo-button";
import {
@@ -10,14 +10,30 @@ import {
APPEAL_SUBTYPE_LABELS,
isBlamSubtype,
} from "@/lib/practice-area";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Users } from "lucide-react";
import type { CaseDetail } from "@/lib/api/cases";
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 ?? "—";
}
}
function partiesLine(data?: CaseDetail): string | null {
const appellant = data?.appellants?.filter(Boolean) ?? [];
const respondent = data?.respondents?.filter(Boolean) ?? [];
const parts: string[] = [];
if (appellant.length) parts.push(`עוררת: ${appellant.join(", ")}`);
if (respondent.length) parts.push(`משיבה: ${respondent.join(", ")}`);
return parts.length ? parts.join(" · ") : null;
}
/**
* Case header — parchment band (IA-redesign mockup 17): full-bleed band with
* the case title + status/type chips inline, a parties line, the case actions
@@ -25,19 +41,15 @@ import type { CaseDetail } from "@/lib/api/cases";
* the tab strip inside the band, anchored to its bottom edge.
*/
export function CaseHeader({
caseNumber,
data,
actions,
tabs,
}: {
caseNumber: string;
data?: CaseDetail;
actions?: ReactNode;
tabs?: ReactNode;
}) {
const appellants = data?.appellants?.filter(Boolean) ?? [];
const respondents = data?.respondents?.filter(Boolean) ?? [];
const hasParties = appellants.length > 0 || respondents.length > 0;
const parties = partiesLine(data);
const isBlam =
data?.proceeding_type === 'בל"מ' || isBlamSubtype(data?.appeal_subtype);
@@ -60,12 +72,6 @@ export function CaseHeader({
<span className="tabular-nums">
{data?.proceeding_type ?? "ערר"} {data?.case_number ?? "—"}
</span>
{/* case subject on the same line, smaller font (chair request) */}
{data?.title && (
<span className="text-navy/75 text-lg font-semibold leading-snug">
{data.title}
</span>
)}
{data?.status && <StatusBadge status={data.status} />}
{data?.archived_at && (
<Badge
@@ -97,44 +103,27 @@ export function CaseHeader({
)}
</h1>
{/* case actions — parties now live in a popover (chair request) so
they don't push the tab content down the page. */}
{/* case title / subject under the heading */}
{data?.title && (
<p className="text-navy/90 text-base font-semibold mt-2 max-w-3xl leading-snug">
{data.title}
</p>
)}
{/* parties line (mockup .parties) — clamped to 2 lines so a long list
of appellants/respondents doesn't grow the band; full text on hover. */}
{parties ? (
<p className="text-ink-soft text-sm mt-1.5 max-w-3xl line-clamp-2" title={parties}>
{parties}
</p>
) : data?.subject ? (
<p className="text-ink-soft text-sm mt-1.5 max-w-3xl leading-relaxed line-clamp-2"
title={data.subject}>
{data.subject}
</p>
) : null}
{/* case actions — kept verbatim, moved into the band */}
<div className="flex items-center gap-2 flex-wrap mt-3">
{hasParties && (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md border border-rule bg-surface px-3 py-1.5 text-[0.82rem] font-semibold text-ink-soft hover:bg-parchment transition-colors"
>
<Users className="w-3.5 h-3.5" />
צדדים
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-3 space-y-2.5 text-sm">
{appellants.length > 0 && (
<div>
<div className="text-[0.7rem] font-bold text-gold-deep uppercase tracking-wider mb-1">
עוררים
</div>
<ul className="space-y-0.5 text-ink-soft">
{appellants.map((a, i) => <li key={`a${i}`}>{a}</li>)}
</ul>
</div>
)}
{respondents.length > 0 && (
<div>
<div className="text-[0.7rem] font-bold text-gold-deep uppercase tracking-wider mb-1">
משיבים
</div>
<ul className="space-y-0.5 text-ink-soft">
{respondents.map((r, i) => <li key={`r${i}`}>{r}</li>)}
</ul>
</div>
)}
</PopoverContent>
</Popover>
)}
{data?.case_number && (
<CaseArchiveAction
caseNumber={data.case_number}
@@ -146,13 +135,25 @@ export function CaseHeader({
</div>
</div>
{/* metadata strip — hearing date / updated / sync */}
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm shrink-0">
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
תאריך דיון
</dt>
<dd className="text-ink-soft tabular-nums">{formatDate(data?.hearing_date)}</dd>
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
עודכן
</dt>
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
סנכרון
</dt>
<dd><SyncIndicator caseNumber={data?.case_number} /></dd>
</dl>
</div>
{/* ★ integrated status rail — visible on every tab (banner A, X17) */}
<StatusRail caseNumber={caseNumber} data={data} />
{/* tab strip anchored to band bottom */}
{tabs ? <div className="mt-4">{tabs}</div> : null}
{/* tab strip anchored to band bottom (mockup .tabs) */}
{tabs ? <div className="mt-5">{tabs}</div> : null}
</div>
);
}

View File

@@ -24,16 +24,32 @@ import {
import { StatusBadge } from "@/components/cases/status-badge";
import { isBlamSubtype } from "@/lib/practice-area";
import type { Case } from "@/lib/api/cases";
import { formatDate, israelMidnightMs, todayIsraelMidnightMs } from "@/lib/format-date";
/** Midnight today in Israel time, in ms — pivot between an upcoming and a past hearing. */
function startOfToday() {
return todayIsraelMidnightMs();
function formatDate(iso?: string) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return iso;
}
}
/** Day-precision ms (Israel time) for a hearing date, or null when absent/unparseable. */
/** Midnight today, in ms — the pivot between an upcoming and a past hearing. */
function startOfToday() {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d.getTime();
}
/** Day-precision ms for a hearing date, or null when absent/unparseable. */
function hearingMs(iso?: string | null): number | null {
return israelMidnightMs(iso);
if (!iso) return null;
const t = new Date(iso).setHours(0, 0, 0, 0);
return Number.isNaN(t) ? null : t;
}
type HearingClass = "soon" | "upcoming" | "past" | "none";

View File

@@ -17,7 +17,6 @@ import {
type BlockStatus,
} from "@/lib/api/decision-blocks";
import { BLOCK_LABELS } from "@/lib/api/feedback";
import { formatTime } from "@/lib/format-date";
import { AlertTriangle, Pencil, FileText } from "lucide-react";
/* ── status badge styling ─────────────────────────────── */
@@ -234,7 +233,10 @@ function SaveIndicator({ state }: { state: SaveState }) {
return <span className="text-[0.72rem] text-ink-muted"> שומר</span>;
}
if (state.kind === "saved") {
const time = formatTime(state.at);
const time = state.at.toLocaleTimeString("he-IL", {
hour: "2-digit",
minute: "2-digit",
});
return <span className="text-[0.72rem] text-success"> נשמר {time}</span>;
}
return <span className="text-[0.72rem] text-danger"> {state.message}</span>;

View File

@@ -4,7 +4,6 @@ import { useState } from "react";
import { CheckCircle2, Loader2, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Popover,
PopoverContent,
@@ -21,7 +20,6 @@ import {
APPRAISER_SIDE_LABELS,
APPRAISER_SIDE_OPTIONS,
DOC_TYPE_OPTIONS,
PROTOCOL_SCOPE_OPTIONS,
appraiserSideLabel,
doctypeLabel,
doctypeTone,
@@ -31,17 +29,14 @@ import {
import {
useExtractAppraiserFacts,
usePatchDocument,
type DocumentPatch,
type ExtractAppraiserFactsResponse,
} from "@/lib/api/documents";
/*
* Inline editor for a document's tags. Renders a colored Badge that opens a
* Popover with:
* Popover with two Selects:
* 1. doc_type (always shown)
* 2. appraiser_side (only when doc_type === "appraisal")
* 3. protocol_scope (only when doc_type === "protocol") — ועדת הערר / מקומית
* 4. is_post_hearing toggle (always shown) — moves the doc to overview group 4
* 2. appraiser_side (only when doc_type === "appraisal")
*
* After a successful save we swap the Popover body to a confirmation view
* with a "חלץ עובדות שמאיות עכשיו" button — extraction is expensive so we
@@ -54,25 +49,15 @@ export function DocumentTypeEditor({
docId,
docType,
appraiserSide,
isPostHearing,
protocolScope,
}: {
caseNumber: string;
docId: string;
docType: string;
appraiserSide?: string;
isPostHearing?: boolean;
protocolScope?: string;
}) {
// Effective stored scope: absent === "appeal" (ועדת הערר) by convention.
const storedScope = protocolScope === "lower" ? "lower" : "appeal";
const storedPost = isPostHearing === true;
const [open, setOpen] = useState(false);
const [draftType, setDraftType] = useState<string>(docType || "");
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
const [draftScope, setDraftScope] = useState<string>(storedScope);
const [draftPost, setDraftPost] = useState<boolean>(storedPost);
const [saved, setSaved] = useState(false);
const [extractResult, setExtractResult] =
useState<ExtractAppraiserFactsResponse | null>(null);
@@ -82,8 +67,6 @@ export function DocumentTypeEditor({
function reset() {
setDraftType(docType || "");
setDraftSide(appraiserSide || "");
setDraftScope(storedScope);
setDraftPost(storedPost);
setSaved(false);
setExtractResult(null);
patch.reset();
@@ -91,17 +74,14 @@ export function DocumentTypeEditor({
}
const isAppraisal = draftType === "appraisal";
const isProtocol = draftType === "protocol";
const sideMissing = isAppraisal && !draftSide;
const dirty =
draftType !== docType ||
(isAppraisal && draftSide !== (appraiserSide || "")) ||
(isProtocol && draftScope !== storedScope) ||
draftPost !== storedPost;
(isAppraisal && draftSide !== (appraiserSide || ""));
async function handleSave() {
if (sideMissing || !dirty) return;
const body: DocumentPatch = {};
const body: { doc_type?: string; appraiser_side?: string } = {};
if (draftType !== docType) body.doc_type = draftType;
if (isAppraisal && draftSide !== (appraiserSide || "")) {
body.appraiser_side = draftSide;
@@ -110,16 +90,6 @@ export function DocumentTypeEditor({
// clear it so it doesn't dangle confusingly in metadata.
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
// protocol_scope: store only the non-default "lower"; anything else clears
// back to the implicit ועדת-הערר default. Also clear when leaving protocol.
if (isProtocol && draftScope !== storedScope) {
body.protocol_scope = draftScope === "lower" ? "lower" : "";
} else if (!isProtocol && protocolScope) {
body.protocol_scope = "";
}
if (draftPost !== storedPost) body.is_post_hearing = draftPost;
// Swallow the rejection — errors surface via `patch.isError`; an unhandled
// rejection from this async click handler would otherwise leak.
try {
@@ -220,46 +190,6 @@ export function DocumentTypeEditor({
</div>
)}
{isProtocol && (
<div className="space-y-1.5">
<label className="text-xs text-ink-muted">פרוטוקול של</label>
<Select value={draftScope} onValueChange={setDraftScope} dir="rtl">
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROTOCOL_SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[0.65rem] text-ink-muted leading-tight">
ועדת הערר קבוצת הפרוטוקול · מקומית/מחוזית מסמכים נלווים
</p>
</div>
)}
<div className="flex items-start justify-between gap-3 rounded-md border border-rule bg-parchment/40 px-3 py-2.5">
<div className="space-y-0.5">
<label
htmlFor={`post-${docId}`}
className="text-xs font-medium text-ink block"
>
התקבל אחרי הדיון
</label>
<p className="text-[0.65rem] text-ink-muted leading-tight">
מעביר את המסמך לקבוצת המסמכים שלאחר הדיון ומזין את בלוק-ח
</p>
</div>
<Switch
id={`post-${docId}`}
checked={draftPost}
onCheckedChange={setDraftPost}
/>
</div>
{patch.isError && (
<p className="text-[0.7rem] text-danger">
שמירה נכשלה. נסה שוב.

View File

@@ -24,14 +24,6 @@ import { apiRequest } from "@/lib/api/client";
import { casesKeys } from "@/lib/api/cases";
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
import { formatDateShort as formatDate } from "@/lib/format-date";
import {
documentGroup,
DOC_GROUP_ORDER,
DOC_GROUP_LABELS,
DOC_GROUP_HINTS,
type DocGroup,
} from "@/lib/doc-types";
/*
* Document list for the case detail "מסמכים" tab. Uses the real document
@@ -84,6 +76,15 @@ function StatusIcon({ status }: { status: string }) {
}
}
function formatDate(iso: string) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL");
} catch {
return iso;
}
}
function filenameFromPath(path: string): string {
const parts = path.split("/");
return parts[parts.length - 1] || path;
@@ -231,13 +232,6 @@ function DocumentRow({
const canPreview =
doc.extraction_status === "completed" || doc.extraction_status === "proofread";
const meta = doc.metadata as
| { appraiser_side?: string; is_post_hearing?: boolean; protocol_scope?: string }
| undefined;
const isPostHearing = meta?.is_post_hearing === true;
const protocolScope = meta?.protocol_scope;
const isProtocol = doc.doc_type === "protocol";
return (
<>
<li className="py-3 flex items-start gap-3 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded group">
@@ -260,24 +254,15 @@ function DocumentRow({
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
</div>
</button>
{isProtocol && (
<span className="shrink-0 self-center rounded-full border border-rule bg-parchment px-2 py-0.5 text-[0.62rem] font-medium text-navy whitespace-nowrap">
{protocolScope === "lower" ? "ועדה מקומית" : "ועדת הערר"}
</span>
)}
{isPostHearing && (
<span className="shrink-0 self-center rounded-full border border-info/40 bg-info-bg px-2 py-0.5 text-[0.62rem] font-medium text-info whitespace-nowrap">
לאחר הדיון
</span>
)}
{doc.doc_type && (
<DocumentTypeEditor
caseNumber={caseNumber}
docId={doc.id}
docType={doc.doc_type}
appraiserSide={meta?.appraiser_side}
isPostHearing={isPostHearing}
protocolScope={protocolScope}
appraiserSide={
(doc.metadata as { appraiser_side?: string } | undefined)
?.appraiser_side
}
/>
)}
<button
@@ -329,30 +314,6 @@ function DocumentsShell({ count, children }: { count: number; children: ReactNod
);
}
/* ── Group section header ──────────────────────────────────────────
* The list is split into four convenience buckets (see documentGroup).
* The number is the fixed 1-4 position in DOC_GROUP_ORDER so a group keeps
* its identity even when earlier groups are empty and hidden. */
function GroupHeader({ group, count }: { group: DocGroup; count: number }) {
const index = DOC_GROUP_ORDER.indexOf(group) + 1;
return (
<div className="flex items-center gap-2.5 px-2 -mx-2 py-2 border-b border-rule-soft bg-parchment/50">
<span className="w-[19px] h-[19px] shrink-0 rounded-full bg-navy text-cream text-[0.66rem] font-bold flex items-center justify-center tabular-nums">
{index}
</span>
<span className="text-[0.8rem] font-bold text-navy shrink-0">
{DOC_GROUP_LABELS[group]}
</span>
<span className="shrink-0 text-[0.62rem] font-bold text-gold-deep bg-gold-wash border border-rule rounded-full px-1.5 tabular-nums">
{count}
</span>
<span className="text-[0.66rem] text-ink-muted font-medium truncate">
{DOC_GROUP_HINTS[group]}
</span>
</div>
);
}
export function DocumentsPanel({
data,
}: {
@@ -376,16 +337,6 @@ export function DocumentsPanel({
(a, b) => statusOrder(a.extraction_status) - statusOrder(b.extraction_status),
);
// Bucket into the four overview groups. Iterating `sorted` preserves the
// within-group status ordering. Grouping is display-only — see documentGroup.
const grouped = new Map<DocGroup, CaseDocument[]>();
for (const doc of sorted) {
const g = documentGroup(doc.doc_type, doc.metadata);
const bucket = grouped.get(g);
if (bucket) bucket.push(doc);
else grouped.set(g, [doc]);
}
const done = docs.filter(
(d) => d.extraction_status === "completed" || d.extraction_status === "proofread",
).length;
@@ -435,21 +386,12 @@ export function DocumentsPanel({
</div>
)}
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden space-y-2" dir="rtl">
{DOC_GROUP_ORDER.map((group) => {
const groupDocs = grouped.get(group);
if (!groupDocs || groupDocs.length === 0) return null;
return (
<section key={group}>
<GroupHeader group={group} count={groupDocs.length} />
<ul className="divide-y divide-rule" dir="rtl">
{groupDocs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
))}
</ul>
</section>
);
})}
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden" dir="rtl">
<ul className="divide-y divide-rule" dir="rtl">
{sorted.map((doc) => (
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
))}
</ul>
</div>
</div>
</DocumentsShell>

View File

@@ -25,15 +25,7 @@ import {
useUploadFinalDecision,
useRunFinalLearning,
useRunFinalHalacha,
type ExportFile,
} from "@/lib/api/exports";
import {
usePartyClaimsSummary,
useGeneratePartyClaimsSummary,
useGenerateInterimDraft,
useGenerateStatus,
} from "@/lib/api/generate";
import { GenerateStatusChip } from "@/components/cases/generate-status-chip";
import {
useCaseFeedback,
useCreateFeedback,
@@ -46,7 +38,6 @@ import {
import { useCaseCitations } from "@/lib/api/citations";
import { learningKeys } from "@/lib/api/learning";
import { LearningStatusBadges } from "@/components/cases/learning-status-badges";
import { formatDateTime, formatDateShort } from "@/lib/format-date";
import type { CaseStatus } from "@/lib/api/cases";
import { toast } from "sonner";
import {
@@ -63,10 +54,6 @@ import {
Stamp,
Link2,
AlertTriangle,
Zap,
ClipboardList,
Files,
ExternalLink,
} from "lucide-react";
/* Statuses at which a draft is considered ready */
@@ -85,32 +72,13 @@ function formatSize(bytes: number): string {
}
function formatDate(epoch: number): string {
return formatDateTime(epoch * 1000);
}
/**
* Pick the newest export file whose name starts with `prefix`, preferring the
* highest v-number and falling back to the latest created_at. Used by the
* "הפקת מסמכים" card to surface the most recent טיוטה-טענות_הצדדים_* file (#214).
*/
function pickLatestVersioned(
files: ExportFile[] | undefined,
prefix: string,
): (ExportFile & { version: number | null }) | null {
const matches = (files ?? []).filter((f) => f.filename.startsWith(prefix));
if (!matches.length) return null;
const withVer = matches.map((f) => {
// Version is the trailing integer, after either "-v" (final) or "_"
// (interim: טיוטה-טענות_הצדדים_{N}). Anchor to the end so the "_" inside
// the interim prefix isn't mistaken for the version separator.
const m = f.filename.match(/[_v](\d+)(?:\.\w+)?$/);
return { ...f, version: m ? parseInt(m[1], 10) : null };
return new Date(epoch * 1000).toLocaleDateString("he-IL", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
withVer.sort((a, b) => {
if (a.version != null && b.version != null) return b.version - a.version;
return b.created_at - a.created_at;
});
return withVer[0];
}
/* ── Main component ─────────────────────────────────── */
@@ -134,13 +102,6 @@ export function DraftsPanel({
const uploadFinal = useUploadFinalDecision(caseNumber);
const runLearning = useRunFinalLearning(caseNumber);
const runHalacha = useRunFinalHalacha(caseNumber);
// ── Document generation (#214) — the "הפקת מסמכים" card ──
const { data: summary } = usePartyClaimsSummary(caseNumber);
const genSummary = useGeneratePartyClaimsSummary(caseNumber);
const genInterim = useGenerateInterimDraft(caseNumber);
// Server-derived background-run status (#227 ג) — survives navigation/reload.
const summaryStatus = useGenerateStatus(caseNumber, "party_claims_summary");
const interimStatus = useGenerateStatus(caseNumber, "interim_draft");
const qc = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
@@ -173,38 +134,6 @@ export function DraftsPanel({
return `טיוטה v${maxVer}${suffix} מוכנה לעיון`;
})();
// ── "הפקת מסמכים" derived state (#214) ──
// Latest interim ("party-claims") partial draft in the exports table — the poll
// target for button 2. The CEO writes it as טיוטה-טענות_הצדדים_{N}.docx.
const latestInterim = pickLatestVersioned(exports, "טיוטה-טענות_הצדדים_");
const summaryReady = Boolean(summary?.markdown);
function handleGenerateSummary() {
genSummary.mutate(undefined, {
onSuccess: (d) => {
if (d.status === "ok") {
toast.success("הופעלה הפקת סיכום-המנהלים — רצה ברקע (אופוס)");
} else {
toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`);
}
},
onError: () => toast.error("שגיאה בהפעלת הפקת סיכום-המנהלים"),
});
}
function handleGenerateInterim() {
genInterim.mutate(undefined, {
onSuccess: (d) => {
if (d.status === "ok") {
toast.success("הופעלה הפקת טיוטת טענות-הצדדים — רצה ברקע (אופוס)");
} else {
toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`);
}
},
onError: () => toast.error("שגיאה בהפעלת הפקת הטיוטה החלקית"),
});
}
function handleUpload(file: File) {
uploadDraft.mutate(file, {
onSuccess: (data) => {
@@ -309,194 +238,34 @@ export function DraftsPanel({
}
return (
// flex + per-card `order` so the visual order matches mockup 26:
// generate-card → banner → active-draft → drafts → final+learning → feedback.
<div className="flex flex-col gap-6">
{/* ── Card: document generation (mockup 26) — 3 deterministic "הפק" buttons ── */}
<section className="order-0 rounded-lg border border-rule bg-surface overflow-hidden">
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft bg-parchment">
<Zap className="w-[18px] h-[18px] text-gold-deep shrink-0" />
<h3 className="text-navy text-base font-semibold">הפקת מסמכים</h3>
</div>
<div className="p-4">
<p className="text-xs text-ink-muted leading-relaxed mb-1">
הפקה דטרמיניסטית בלחיצה בלי בקשת-טקסט. כל כפתור מריץ פעולה מדויקת,
והקובץ מופיע בטבלת-הטיוטות / לקריאה מקומית.
</p>
{/* 1 — executive summary of party claims */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft first-of-type:border-t-0">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<ClipboardList className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">הפק סיכום מנהלים</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
תמצית מזוקקת של טענות הצדדים לקראת הדיון מסמך נפרד מהטיוטה.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleGenerateSummary}
disabled={genSummary.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{genSummary.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
"הפק"
)}
</Button>
<GenerateStatusChip
status={summaryStatus.data}
onRetry={handleGenerateSummary}
retrying={genSummary.isPending}
/>
{summaryReady && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-gold-deep"
onClick={() =>
window.open(
`/api/cases/${caseNumber}/research/party-claims-summary/download`,
"_blank",
)
}
>
<ExternalLink className="w-3.5 h-3.5 me-1" />
פתח
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-gold-deep"
onClick={() =>
window.open(
`/api/cases/${caseNumber}/research/party-claims-summary/export-docx`,
"_blank",
)
}
>
<Download className="w-3.5 h-3.5 me-1" />
DOCX
</Button>
</>
)}
</div>
</div>
</div>
{/* 2 — partial draft (parties' claims, blocks ה–ט) */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<FileText className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">
הפק טיוטה של טענות הצדדים{" "}
<span className="font-medium text-ink-muted text-xs">
(טיוטה חלקית)
</span>
</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
בלוקים הט (רקע, טענות, הליכים, תכניות) עד הדיון, בלי דיון
והכרעה. נשמרת בטבלת-הטיוטות.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleGenerateInterim}
disabled={genInterim.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{genInterim.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
"הפק"
)}
</Button>
<GenerateStatusChip
status={interimStatus.data}
onRetry={handleGenerateInterim}
retrying={genInterim.isPending}
/>
{latestInterim && (
<span className="text-[0.7rem] text-ink-muted">
{latestInterim.version
? `v${latestInterim.version} · `
: ""}
{formatDate(latestInterim.created_at)}
</span>
)}
</div>
</div>
</div>
{/* 3 — full decision draft (REUSES useExportDocx; moved from the banner) */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<Files className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">
הפק טיוטת החלטה מלאה
</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
ההחלטה המלאה כל 12 הבלוקים, כולל דיון והכרעה. נקבעת
כמקור-האמת ונשמרת בטבלת-הטיוטות.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleExport}
disabled={exportDocx.isPending || !isDraftReady}
title={!isDraftReady ? "זמין כשיש בלוקים כתובים" : undefined}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{exportDocx.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
<>
<FileOutput className="w-3.5 h-3.5 me-1.5" />
הפק
</>
)}
</Button>
{!isDraftReady && (
<span className="rounded bg-warn-bg px-2 py-0.5 text-[0.65rem] text-warn">
זמין כשיש בלוקים כתובים
</span>
)}
</div>
</div>
</div>
</div>
</section>
{/* ── Banner — draft-ready indicator (export button moved to "הפקת מסמכים") ── */}
<div className="space-y-6">
{/* ── Banner ── */}
{isDraftReady && (
<div className="order-1 flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
<div className="flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
<FileText className="w-5 h-5 text-gold-deep shrink-0" />
<span className="text-sm font-medium text-gold-deep">
{draftLabel}
</span>
<div className="me-auto" />
<Button
size="sm"
onClick={handleExport}
disabled={exportDocx.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{exportDocx.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<FileOutput className="w-4 h-4 me-1.5" />
)}
הפק DOCX
</Button>
</div>
)}
{/* ── Active-draft badge — the DOCX that is the current source of truth ── */}
{activeDraft?.filename && (
<div className="order-2 flex items-center gap-2 text-xs text-ink-muted">
<div className="flex items-center gap-2 text-xs text-ink-muted">
<span>מקור האמת:</span>
<Badge variant="outline" className="bg-surface">
{activeDraft.filename}
@@ -505,7 +274,7 @@ export function DraftsPanel({
)}
{/* ── Chair's signed final decision — clean upload + staged learning pipeline ── */}
<section className="order-4 rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-3">
<section className="rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-3">
<input
ref={finalFileRef}
type="file"
@@ -583,13 +352,13 @@ export function DraftsPanel({
</div>
)}
{hasFinal && <LearningStatusBadges caseNumber={caseNumber} />}
{/* precedents cited inside the signed decision — sub-section of the
final card (mockup 18h), collapsible inside CitationsSection itself */}
<CitationsSection caseNumber={caseNumber} />
</section>
{/* ── Card: draft files (mockup 18h) ── */}
<section className="order-3 rounded-lg border border-rule bg-surface p-4">
{/* ── Precedents cited inside the signed decision ── */}
<CitationsSection caseNumber={caseNumber} />
{/* ── Exports list ── */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-navy text-base">קבצי טיוטה</h3>
<div className="flex items-center gap-2">
@@ -632,9 +401,9 @@ export function DraftsPanel({
<table className="w-full text-sm">
<thead>
<tr className="bg-rule-soft/40 text-ink-muted text-[0.75rem]">
<th className="text-start px-4 py-2 font-medium">קובץ</th>
<th className="text-start px-4 py-2 font-medium">גודל</th>
<th className="text-start px-4 py-2 font-medium">תאריך</th>
<th className="text-start px-4 py-2 font-medium">File</th>
<th className="text-start px-4 py-2 font-medium">Size</th>
<th className="text-start px-4 py-2 font-medium">Date</th>
<th className="px-4 py-2" />
</tr>
</thead>
@@ -757,8 +526,8 @@ export function DraftsPanel({
</Dialog>
</section>
{/* ── Card: chair feedback (mockup 18h) ── */}
<section className="order-5 rounded-lg border border-rule bg-surface p-4">
{/* ── Chair feedback ── */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-navy text-base">
הערות יו״ר
@@ -799,7 +568,9 @@ export function DraftsPanel({
</Badge>
)}
<span className="text-[0.65rem] text-ink-muted me-auto">
{fb.created_at ? formatDateShort(fb.created_at) : ""}
{fb.created_at
? new Date(fb.created_at).toLocaleDateString("he-IL")
: ""}
</span>
</div>
<p className="text-sm leading-relaxed">{fb.feedback_text}</p>

View File

@@ -1,127 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { CheckCircle2, Clock, Loader2, RotateCw, XCircle } from "lucide-react";
import type { GenerateStatus } from "@/lib/api/generate";
/**
* Background-run status chip for the "הפקת מסמכים" generation actions (#227 ג).
*
* The chip is driven entirely by the server-derived {@link GenerateStatus} — it
* holds no run state of its own, so it renders correctly after the chair
* navigates away and back. The running timer is anchored to the server
* `started_at`, not a click-time counter, so the elapsed value is right on
* return too.
*/
function useElapsedSeconds(startedAt: string | null, active: boolean): number | null {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) return;
const id = setInterval(() => setNow(Date.now()), 1_000);
return () => clearInterval(id);
}, [active]);
if (!startedAt) return null;
const start = Date.parse(startedAt);
if (Number.isNaN(start)) return null;
return Math.max(0, Math.floor((now - start) / 1000));
}
function fmtDuration(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
if (m >= 60) {
const h = Math.floor(m / 60);
return `${h}:${String(m % 60).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
return `${m}:${String(s).padStart(2, "0")}`;
}
function relativeTime(iso: string | null): string {
if (!iso) return "";
const t = Date.parse(iso);
if (Number.isNaN(t)) return "";
const sec = Math.max(0, Math.floor((Date.now() - t) / 1000));
if (sec < 60) return "הרגע";
const min = Math.floor(sec / 60);
if (min < 60) return `לפני ${min} דק׳`;
const hr = Math.floor(min / 60);
if (hr < 24) return `לפני ${hr} שע׳`;
return `לפני ${Math.floor(hr / 24)} ימים`;
}
const CHIP_BASE =
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold whitespace-nowrap";
export function GenerateStatusChip({
status,
onRetry,
retrying,
}: {
status: GenerateStatus | undefined;
onRetry?: () => void;
retrying?: boolean;
}) {
const state = status?.state ?? "idle";
const elapsed = useElapsedSeconds(status?.started_at ?? null, state === "running");
if (!status || state === "idle") return null;
if (state === "queued") {
return (
<span className={`${CHIP_BASE} bg-rule-soft text-ink-soft border-rule`}>
<Clock className="size-3.5" aria-hidden />
בתור
</span>
);
}
if (state === "running") {
return (
<span className={`${CHIP_BASE} bg-info-bg text-info border-info/35`}>
<Loader2 className="size-3.5 animate-spin" aria-hidden />
רץ ברקע
{elapsed !== null && (
<span className="tabular-nums">· {fmtDuration(elapsed)}</span>
)}
</span>
);
}
if (state === "done") {
const rel = relativeTime(status.finished_at);
return (
<span className={`${CHIP_BASE} bg-success-bg text-success border-success/35`}>
<CheckCircle2 className="size-3.5" aria-hidden />
הושלם{rel && ` · ${rel}`}
</span>
);
}
// failed
return (
<span className="inline-flex items-center gap-2">
<span className={`${CHIP_BASE} bg-danger-bg text-danger border-danger/35`}>
<XCircle className="size-3.5" aria-hidden />
{status.detail || "נכשל"}
</span>
{onRetry && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-gold-deep"
disabled={retrying}
onClick={onRetry}
>
{retrying ? (
<Loader2 className="size-3.5 animate-spin me-1" />
) : (
<RotateCw className="size-3.5 me-1" />
)}
נסה שוב
</Button>
)}
</span>
);
}

View File

@@ -1,360 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
ArrowDown,
ArrowUp,
CalendarDays,
HelpCircle,
Link2,
Loader2,
Plus,
RotateCw,
Sparkles,
} from "lucide-react";
import { toast } from "sonner";
import {
CHANGE_LABELS_HE,
PROTOCOL_PARTY_LABELS_HE,
PROTOCOL_PARTY_ORDER,
useAnalyzeProtocol,
useProtocolAnalysis,
type ProtocolAnalysisRow,
type ProtocolChangeType,
type ProtocolPartyRole,
} from "@/lib/api/protocol-analysis";
const PARTY_MARK_TONE: Record<ProtocolPartyRole, string> = {
appellant: "bg-info",
respondent: "bg-gold-deep",
committee: "bg-success",
permit_applicant: "bg-warn",
unknown: "bg-ink-muted",
"": "bg-ink-muted",
};
const CHANGE_BADGE_TONE: Record<ProtocolChangeType, string> = {
strengthened: "bg-gold-wash text-gold-deep border-gold/40",
newly_raised: "bg-emerald-50 text-emerald-900 border-emerald-200",
dropped: "bg-rule-soft text-ink-soft border-rule",
};
const CHANGE_ACCENT: Record<ProtocolChangeType, string> = {
strengthened: "border-s-gold",
newly_raised: "border-s-emerald-500",
dropped: "border-s-rule",
};
function ChangeIcon({ type }: { type: ProtocolChangeType }) {
if (type === "strengthened") return <ArrowUp className="size-3.5" aria-hidden />;
if (type === "newly_raised") return <Plus className="size-3.5" aria-hidden />;
return <ArrowDown className="size-3.5" aria-hidden />;
}
type FilterKey = "all" | ProtocolChangeType;
function AttendeeColumn({
label,
dotTone,
names,
}: {
label: string;
dotTone: string;
names: string[];
}) {
if (!names.length) return null;
return (
<div className="min-w-[210px] flex-1">
<div className="text-gold-deep mb-1.5 flex items-center gap-1.5 text-[0.7rem] font-bold">
<span className={`size-1.5 flex-none rounded-full ${dotTone}`} aria-hidden />
{label}
</div>
<div className="text-ink-soft text-xs leading-relaxed">
{names.join(" · ")}
</div>
</div>
);
}
function DevelopmentCard({ row }: { row: ProtocolAnalysisRow }) {
return (
<div
className={`bg-surface border-rule overflow-hidden rounded-lg border border-s-4 shadow-sm ${CHANGE_ACCENT[row.change_type]}`}
>
<div className="space-y-2.5 px-4 py-3.5">
<div className="flex flex-wrap items-start gap-2.5">
<Badge
variant="outline"
className={`${CHANGE_BADGE_TONE[row.change_type]} flex-none gap-1 text-[0.72rem] font-bold`}
>
<ChangeIcon type={row.change_type} />
{CHANGE_LABELS_HE[row.change_type]}
</Badge>
<span className="text-navy flex-1 text-sm font-bold leading-snug">
{row.argument_title}
</span>
</div>
{row.summary && (
<p className="text-ink-soft text-[0.82rem] leading-relaxed">
{row.summary}
</p>
)}
{row.sharpened_question && (
<div className="bg-parchment border-rule-soft rounded-md border px-3 py-2">
<div className="text-gold-deep mb-1 flex items-center gap-1.5 text-[0.66rem] font-bold">
<HelpCircle className="size-3" aria-hidden />
השאלה שהתחדדה
</div>
<div className="text-ink text-xs leading-relaxed">
{row.sharpened_question}
</div>
</div>
)}
{row.evidence_quote && (
<blockquote className="border-rule text-ink-muted border-s-[3px] px-3 text-xs italic leading-relaxed">
&ldquo;{row.evidence_quote}&rdquo;
<span className="text-ink-muted mt-1 block text-[0.68rem] not-italic font-semibold">
מתוך פרוטוקול הדיון
</span>
</blockquote>
)}
<div className="flex flex-wrap items-center gap-2 pt-0.5">
{row.change_type === "newly_raised" ? (
<span className="text-emerald-800 inline-flex items-center gap-1.5 text-[0.72rem] font-semibold">
<Sparkles className="size-3.5" aria-hidden />
אין מקבילה בכתב עלה לראשונה בדיון
</span>
) : row.argument_id ? (
<span className="text-ink-muted inline-flex items-center gap-1.5 text-[0.72rem] font-semibold">
<Link2 className="size-3.5" aria-hidden />
מבוסס על טיעון כתוב קיים
</span>
) : null}
</div>
</div>
</div>
);
}
type HearingChangesPanelProps = {
caseNumber: string;
};
export function HearingChangesPanel({ caseNumber }: HearingChangesPanelProps) {
const { data, isPending, isError, error } = useProtocolAnalysis(caseNumber);
const analyze = useAnalyzeProtocol(caseNumber);
const [filter, setFilter] = useState<FilterKey>("all");
const handleAnalyze = () => {
analyze.mutate(undefined, {
onSuccess: (res) => {
if (res.status === "queued") {
toast.success("נשלח למנתח המשפטי — הניתוח ירוץ ברקע; רענן בעוד כמה דקות.");
} else {
toast.warning(
`לא ניתן להריץ אוטומטית (${res.reason}). ניתן להריץ ידנית מ-Claude Code.`,
);
}
},
onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`),
});
};
const header = data?.header;
const counts = data?.by_change ?? {};
const strengthened = counts.strengthened ?? 0;
const newlyRaised = counts.newly_raised ?? 0;
const dropped = counts.dropped ?? 0;
const sections = useMemo(() => {
if (!data) return [];
return PROTOCOL_PARTY_ORDER.map((role) => {
const rows = (data.by_party[role] ?? []).filter(
(r) => filter === "all" || r.change_type === filter,
);
return { role, rows };
}).filter((s) => s.rows.length > 0);
}, [data, filter]);
const runButton = (
<Button
variant="outline"
size="sm"
disabled={analyze.isPending}
onClick={handleAnalyze}
>
{analyze.isPending ? (
<Loader2 className="me-1.5 size-3.5 animate-spin" />
) : (
<RotateCw className="me-1.5 size-3.5" />
)}
{data?.total ? "נתח מחדש" : "נתח את פרוטוקול הדיון"}
</Button>
);
return (
<div className="space-y-4">
{isPending ? (
<div className="space-y-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : isError ? (
<p className="text-danger text-sm">
שגיאה בטעינת ניתוח-הפרוטוקול: {(error as Error).message}
</p>
) : !data?.total ? (
<div className="bg-surface border-rule rounded-lg border border-dashed px-6 py-8 text-center">
<h3 className="text-navy text-sm font-bold">טרם נותח פרוטוקול</h3>
<p className="text-ink-muted mx-auto mt-1.5 max-w-md text-xs leading-relaxed">
כשיש בתיק פרוטוקול של ועדת-הערר וטיעונים מאוגדים, הניתוח מזהה מה
התחזק ומה נטען לראשונה בדיון. ההרצה נשלחת למנתח המשפטי ורצה ברקע.
</p>
<div className="mt-4 flex justify-center">{runButton}</div>
</div>
) : (
<>
<div className="flex justify-end">{runButton}</div>
{/* hearing header strip */}
{header && (
<div className="bg-parchment border-rule rounded-lg border px-5 py-4 shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<span className="bg-gold-wash border-rule flex size-9 flex-none items-center justify-center rounded-lg border">
<CalendarDays className="text-gold-deep size-5" aria-hidden />
</span>
<div className="flex-1">
<div className="text-navy text-[0.95rem] font-bold">
{header.protocol_title || "פרוטוקול דיון"}
</div>
<div className="text-ink-muted mt-0.5 text-xs">
{header.hearing_date
? `תאריך דיון: ${header.hearing_date}`
: "תאריך דיון לא זוהה"}
{header.panel_members.length > 0 &&
` · מותב: ${header.panel_members.join(", ")}`}
</div>
</div>
<Badge
variant="outline"
className="bg-info-bg text-info border-info/30 flex-none rounded-full text-[0.72rem] font-semibold"
>
פרוטוקול ועדת-הערר
</Badge>
</div>
{(header.appellants_present.length > 0 ||
header.respondents_present.length > 0) && (
<div className="border-rule-soft mt-3 flex flex-wrap gap-6 border-t pt-3">
<AttendeeColumn
label="נכחו מטעם העוררים"
dotTone="bg-info"
names={header.appellants_present}
/>
<AttendeeColumn
label="נכחו מטעם המשיבים"
dotTone="bg-gold-deep"
names={header.respondents_present}
/>
</div>
)}
</div>
)}
{/* filter pills */}
<div className="flex flex-wrap items-center gap-2">
<FilterPill
active={filter === "all"}
onClick={() => setFilter("all")}
label="הכל"
count={data.total}
/>
{strengthened > 0 && (
<FilterPill
active={filter === "strengthened"}
onClick={() => setFilter("strengthened")}
label="התחזקו"
count={strengthened}
icon={<ArrowUp className="size-3.5" aria-hidden />}
/>
)}
{newlyRaised > 0 && (
<FilterPill
active={filter === "newly_raised"}
onClick={() => setFilter("newly_raised")}
label="נטענו לראשונה"
count={newlyRaised}
icon={<Plus className="size-3.5" aria-hidden />}
/>
)}
{dropped > 0 && (
<FilterPill
active={filter === "dropped"}
onClick={() => setFilter("dropped")}
label="נזנחו"
count={dropped}
icon={<ArrowDown className="size-3.5" aria-hidden />}
/>
)}
</div>
{/* per-party sections */}
{sections.map(({ role, rows }) => (
<div key={role || "unassigned"} className="space-y-2.5">
<div className="flex items-center gap-2.5">
<span
className={`h-5 w-1.5 flex-none rounded ${PARTY_MARK_TONE[role]}`}
aria-hidden
/>
<span className="text-navy text-[0.95rem] font-bold">
{PROTOCOL_PARTY_LABELS_HE[role]}
</span>
<span className="text-ink-muted text-xs">{rows.length}</span>
</div>
<div className="space-y-2.5">
{rows.map((r) => (
<DevelopmentCard key={r.id} row={r} />
))}
</div>
</div>
))}
</>
)}
</div>
);
}
function FilterPill({
active,
onClick,
label,
count,
icon,
}: {
active: boolean;
onClick: () => void;
label: string;
count: number;
icon?: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors ${
active
? "bg-navy border-navy text-white"
: "bg-surface border-rule text-ink-soft hover:bg-parchment"
}`}
>
{icon}
{label}
<span className="font-bold">{count}</span>
</button>
);
}

View File

@@ -36,23 +36,6 @@ const PRIORITY_BADGE_TONE: Record<LegalArgumentPriority, string> = {
relief: "bg-emerald-50 text-emerald-900 border-emerald-200",
};
// Per-party colour mark + one-line role description (mockup 25b party trigger).
const PARTY_MARK_TONE: Record<LegalArgumentParty, string> = {
appellant: "bg-info",
respondent: "bg-gold-deep",
committee: "bg-success",
permit_applicant: "bg-warn",
unknown: "bg-ink-muted",
};
const PARTY_SUB_HE: Record<LegalArgumentParty, string> = {
appellant: "הצד היוזם של הערר",
respondent: "הצד המשיב לערר",
committee: "עמדת הגורם המאשר",
permit_applicant: "צד שלישי שזכותו עשויה להיפגע",
unknown: "צד שלא זוהה אוטומטית",
};
function groupByPriority(
args: LegalArgument[],
): Record<LegalArgumentPriority, LegalArgument[]> {
@@ -72,57 +55,22 @@ function groupByPriority(
}
type PartySectionProps = {
party: LegalArgumentParty;
args: LegalArgument[];
};
/**
* Inner body of a single party. For a multi-litigant side (respondent /
* permit_applicant) whose arguments carry distinct ``party_name`` brief labels,
* we sub-group by brief first — so opposing joint briefs (e.g. "משיבות 2-3" vs
* "משיבים 4-6") read as separate positions rather than one merged list (#224).
* Single-voice sides (one empty brief) render the priority groups directly.
*/
function PartySection({ args }: PartySectionProps) {
const briefs = useMemo(() => {
const byBrief = new Map<string, LegalArgument[]>();
for (const a of args) {
const key = a.party_name?.trim() ?? "";
const list = byBrief.get(key);
if (list) list.push(a);
else byBrief.set(key, [a]);
}
return [...byBrief.entries()];
}, [args]);
// No real per-brief split (single, unnamed group) → render groups directly.
if (briefs.length <= 1) {
return <PriorityGroups args={briefs[0]?.[1] ?? args} />;
}
return (
<div className="space-y-5">
{briefs.map(([brief, briefArgs]) => (
<div key={brief || "—"} className="space-y-2">
<div className="flex items-center gap-2 border-s-2 border-gold-deep ps-2">
<span className="text-navy text-sm font-semibold">
{brief || "ללא שיוך לכתב"}
</span>
<span className="text-ink-muted text-xs">
{briefArgs.length} טיעונים
</span>
</div>
<PriorityGroups args={briefArgs} />
</div>
))}
</div>
);
}
/** Priority-grouped argument accordion for one coherent set of arguments. */
function PriorityGroups({ args }: PartySectionProps) {
function PartySection({ party, args }: PartySectionProps) {
const grouped = useMemo(() => groupByPriority(args), [args]);
return (
<div className="space-y-4">
<div className="space-y-3">
<div className="flex items-baseline justify-between border-b border-rule pb-2">
<h3 className="text-navy text-base font-semibold">
{PARTY_LABELS_HE[party] ?? party}
</h3>
<span className="text-ink-muted text-xs">
{args.length} טיעונים
</span>
</div>
{PRIORITY_ORDER.map((priority) => {
const list = grouped[priority];
if (!list?.length) return null;
@@ -305,47 +253,15 @@ export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) {
החישוב למנתח המשפטי.
</p>
) : (
// Top-level party accordion — each party is its own collapsible section,
// closed by default (empty defaultValue). The count badge on every
// trigger shows the scope without expanding (mockup 25b).
<Accordion type="multiple" className="space-y-3.5">
{parties.map((party) => {
const args = data.by_party[party] ?? [];
return (
<AccordionItem
key={party}
value={party}
className="overflow-hidden rounded-lg border border-rule bg-surface shadow-sm"
>
<AccordionTrigger className="px-5 py-4 hover:no-underline data-[state=open]:border-b data-[state=open]:border-rule-soft data-[state=open]:bg-parchment">
<span className="flex flex-1 items-center gap-3.5">
<span
className={`h-8 w-1.5 flex-none rounded ${PARTY_MARK_TONE[party]}`}
aria-hidden
/>
<span className="flex flex-1 flex-col items-start gap-0.5">
<span className="text-navy text-base font-bold leading-tight">
{PARTY_LABELS_HE[party] ?? party}
</span>
<span className="text-ink-muted text-xs">
{PARTY_SUB_HE[party]}
</span>
</span>
<Badge
variant="outline"
className="bg-gold-wash text-gold-deep border-rule rounded-full px-3 py-0.5 text-xs font-bold whitespace-nowrap"
>
{args.length} טיעונים
</Badge>
</span>
</AccordionTrigger>
<AccordionContent className="px-5 pb-4 pt-4">
<PartySection args={args} />
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
<div className="space-y-6">
{parties.map((party) => (
<PartySection
key={party}
party={party}
args={data.by_party[party] ?? []}
/>
))}
</div>
)}
</div>
);

View File

@@ -1,306 +0,0 @@
"use client";
import { useRef, useState } from "react";
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 { Markdown } from "@/components/ui/markdown";
import { useCase } from "@/lib/api/cases";
import { useResearchAnalysis } from "@/lib/api/research";
import { useCasePrecedents } from "@/lib/api/precedents";
/*
* PositionsPanel — the chair's editing surface for the analyst's threshold
* claims + issues (chair positions feed block י). Extracted verbatim from the
* former /compose "עמדות וטענות" tab when the decision editor was merged into
* the main case page (X17 #3). Lives inside the "טיעונים ועמדות" tab above the
* collapsible by-party aggregated arguments. The 12-block editor + citation
* verification moved to their own top-level tabs; /compose was deleted.
*
* The threshold-claim / issue cards deliberately stack in a SINGLE column.
* They were a two-column CSS grid, but grid rows share a height: expanding one
* card grew its row and shoved every card below it down — in *both* columns.
* A single column keeps the jump local to what sits underneath, and gives the
* expanded body (fields + chair editor + supporting precedents) full width
* instead of half. Do not reintroduce `lg:grid-cols-2` here (chair, 2026-08-04).
*/
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 — round-trips the analysis-and-research.md
// research analysis: export DOCX, upload an updated version, download the raw MD.
// (Post-final learning status lives on the drafts tab, not here — #226.) ──────
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 h-full">
<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">
פעולות על קובץ ניתוח-המחקר (analysis-and-research.md).
</p>
<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>
)}
</CardContent>
</Card>
);
}
export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
const caseQuery = useCase(caseNumber);
const analysis = useResearchAnalysis(caseNumber);
const precedentsQuery = useCasePrecedents(caseNumber);
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 isNotFound =
analysis.error instanceof Error &&
/404|לא נמצא|טרם בוצע/.test(analysis.error.message);
return (
<div className="space-y-4">
{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">
{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-3">
{analysis.data.threshold_claims.map((tc) => (
<SubsectionCard
key={tc.id}
caseNumber={caseNumber}
item={tc}
precedents={precedentsBySection.get(tc.id) ?? []}
practiceArea={practiceArea}
/>
))}
</div>
</div>
)}
{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-3">
{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>
)}
<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}
{/* case-level פסיקה מצורפת + analysis export/update (INV-IA3: not removed).
items-stretch keeps both cards the same height (#226). */}
<div className="grid gap-4 lg:grid-cols-2 items-stretch pt-2">
<Card className="bg-surface border-rule shadow-sm h-full">
<CardContent className="px-5 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()}
/>
</div>
</div>
);
}

View File

@@ -17,8 +17,6 @@ const STATUS_ICONS: Record<CaseStatus, LucideIcon> = {
new: FilePlus2,
processing: Loader2,
documents_ready: FileCheck,
analyst_verified: SearchCheck,
research_complete: Compass,
outcome_set: Target,
direction_approved: Compass,
qa_review: SearchCheck,
@@ -38,8 +36,6 @@ const STATUS_TONE: Record<CaseStatus, string> = {
new: "bg-rule-soft text-ink-muted border-rule",
processing: "bg-info-bg text-info border-info/30",
documents_ready: "bg-info-bg text-info border-info/40",
analyst_verified: "bg-gold-wash text-gold-deep border-gold/40",
research_complete: "bg-gold-wash text-gold-deep border-gold/40",
outcome_set: "bg-gold-wash text-gold-deep border-gold/40",
direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
qa_review: "bg-warn-bg text-warn border-warn/40",

View File

@@ -21,13 +21,9 @@ const ALL_STATUSES: readonly CaseStatus[] = CASE_STATUSES;
export function StatusChanger({
caseNumber,
currentStatus,
inline = false,
}: {
caseNumber: string;
currentStatus?: CaseStatus;
/** Compact horizontal layout for the status hero (mockup 18b hero-controls);
* default is the stacked rail layout. */
inline?: boolean;
}) {
// `null` = untouched → the dropdown tracks the live `currentStatus` (which
// arrives async and changes on the 5s poll / external updates). Only an
@@ -49,10 +45,8 @@ export function StatusChanger({
};
return (
<div className={inline ? "space-y-0" : "mt-4 border-t border-rule pt-3 space-y-2"}>
{!inline && (
<label className="text-[0.72rem] text-ink-muted block">שינוי סטטוס ידני</label>
)}
<div className="mt-4 border-t border-rule pt-3 space-y-2">
<label className="text-[0.72rem] text-ink-muted block">שינוי סטטוס ידני</label>
<div className="flex items-center gap-2">
<Select
value={effective || "__current__"}
@@ -83,7 +77,7 @@ export function StatusChanger({
disabled={!canSave || mutate.isPending}
onClick={handleSave}
>
{mutate.isPending ? "שומר…" : inline ? "עדכן סטטוס" : "עדכן"}
{mutate.isPending ? "שומר…" : "עדכן"}
</Button>
</div>
</div>

View File

@@ -1,195 +0,0 @@
"use client";
import {
PHASES,
phaseIndexOf,
phaseOf,
type CaseStatus,
} from "@/lib/api/case-status";
import { StatusChanger } from "@/components/cases/status-changer";
import { StatusGuide } from "@/components/cases/status-guide";
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
import { SyncIndicator } from "@/components/cases/sync-indicator";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { expectedOutcomes } from "@/lib/schemas/case";
import { formatDate } from "@/lib/format-date";
import { Settings2, Users } from "lucide-react";
import type { CaseDetail } from "@/lib/api/cases";
/*
* StatusRail — the integrated case-status strip that lives inside the parchment
* band (CaseHeader), so it is visible on EVERY tab (banner "A", X17 case-detail
* redesign — supersedes the overview-only StatusHero). One thin row of chips
* (current phase · expected outcome · meta · agents popover · manual changer)
* over the 5-phase pipeline stepper. Reuses the shared StatusChanger / StatusGuide
* / AgentStatusWidget so no parallel status path is created (G2).
*/
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
expectedOutcomes.map((o) => [o.value, o.label]),
);
/** Semantic colour per expected-outcome value (rejection/partial/full/none). */
const OUTCOME_TONE: Record<string, string> = {
rejection: "bg-danger-bg text-danger",
partial_acceptance: "bg-warn-bg text-warn",
full_acceptance: "bg-success-bg text-success",
};
/** Horizontal RTL stepper over the 5 lifecycle phases (mockup A .stepper). */
function HorizontalStepper({ status }: { status?: CaseStatus }) {
const currentIdx = phaseIndexOf(status);
return (
<ol className="flex items-start gap-0">
{PHASES.map((phase, i) => {
const state =
currentIdx === -1 ? "pending"
: i < currentIdx ? "done"
: i === currentIdx ? "now"
: "pending";
const isLast = i === PHASES.length - 1;
const knobTone =
state === "done"
? "bg-success [box-shadow:0_0_0_1px_var(--color-success)]"
: state === "now"
? "bg-gold [box-shadow:0_0_0_2px_var(--color-gold-wash),0_0_0_3px_var(--color-gold)]"
: "bg-rule [box-shadow:0_0_0_1px_var(--color-rule)]";
const labelTone =
state === "done"
? "text-ink-soft"
: state === "now"
? "text-navy font-bold"
: "text-ink-muted";
const segTone = state === "done" ? "bg-success" : "bg-rule";
return (
<li key={phase.key} className="relative flex-1 pt-6 text-center">
{!isLast && (
<span
className={`absolute top-[7px] h-0.5 w-full ${segTone}`}
style={{ insetInlineStart: "50%" }}
aria-hidden
/>
)}
<span
className={`absolute top-[2px] h-3.5 w-3.5 rounded-full border-[2.5px] border-surface ${knobTone}`}
style={{ insetInlineStart: "50%", transform: "translateX(50%)" }}
aria-hidden
/>
<span className={`block text-[0.74rem] ${labelTone}`}>
{phase.label}
</span>
</li>
);
})}
</ol>
);
}
const PILL =
"inline-flex items-center gap-1.5 rounded-full border border-rule bg-surface px-3 py-1 text-[0.72rem] font-semibold text-ink-soft hover:bg-parchment transition-colors";
export function StatusRail({
caseNumber,
data,
}: {
caseNumber: string;
data?: CaseDetail;
}) {
const status = data?.status;
const phaseKey = phaseOf(status);
const phaseLabel = phaseKey
? PHASES.find((p) => p.key === phaseKey)?.label
: null;
const outcome = data?.expected_outcome ?? "";
const outcomeLabel = outcome
? EXPECTED_OUTCOME_LABELS[outcome] ?? outcome
: null;
const outcomeTone = outcome
? OUTCOME_TONE[outcome] ?? "bg-warn-bg text-warn"
: "bg-rule-soft text-ink-muted";
return (
<div className="mt-3 border-t border-rule-soft pt-3">
{/* chip row */}
<div className="flex items-center gap-x-3 gap-y-2 flex-wrap mb-3">
<span className="text-navy text-[0.8rem] font-bold me-1">סטטוס</span>
{phaseLabel && (
<span className="rounded-full bg-warn-bg text-warn text-[0.78rem] font-bold px-3 py-1">
כעת: {phaseLabel}
</span>
)}
<span className="inline-flex items-center gap-1.5">
<span className="text-[0.72rem] text-ink-muted">תוצאה צפויה:</span>
<span
className={`inline-block rounded-full text-[0.74rem] font-semibold px-2.5 py-0.5 ${outcomeTone}`}
>
{outcomeLabel ?? "טרם נקבעה"}
</span>
</span>
<span className="flex-1" />
<span className="flex items-center gap-3 text-[0.72rem] text-ink-muted">
<span>
דיון{" "}
<b className="text-ink-soft font-semibold tabular-nums">
{formatDate(data?.hearing_date)}
</b>
</span>
<span>
עודכן{" "}
<b className="text-ink-soft font-semibold tabular-nums">
{formatDate(data?.updated_at)}
</b>
</span>
<span className="inline-flex items-center gap-1">
סנכרון <SyncIndicator caseNumber={data?.case_number} />
</span>
</span>
{/* agents popover */}
<Popover>
<PopoverTrigger asChild>
<button type="button" className={PILL} title="סוכני התיק">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
<Users className="w-3.5 h-3.5" />
סוכנים
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-3">
<AgentStatusWidget caseNumber={caseNumber} />
</PopoverContent>
</Popover>
{/* manual status changer + status guide popover */}
<Popover>
<PopoverTrigger asChild>
<button type="button" className={PILL} title="שינוי סטטוס ידני">
<Settings2 className="w-3.5 h-3.5" />
שינוי
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-3 space-y-3">
<div className="text-[0.72rem] font-semibold text-ink-muted">
שינוי סטטוס ידני
</div>
<StatusChanger caseNumber={caseNumber} currentStatus={status} inline />
<StatusGuide />
</PopoverContent>
</Popover>
</div>
{/* pipeline */}
<HorizontalStepper status={status} />
</div>
);
}

View File

@@ -2,7 +2,17 @@
import { useGitStatus } from "@/lib/api/cases";
import { CheckCircle2, AlertCircle, Clock, CloudOff } from "lucide-react";
import { formatRelative } from "@/lib/format-date";
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "עכשיו";
if (mins < 60) return `לפני ${mins} דק׳`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `לפני ${hours} שע׳`;
const days = Math.floor(hours / 24);
return `לפני ${days} ימים`;
}
export function SyncIndicator({ caseNumber }: { caseNumber?: string }) {
const { data, isLoading } = useGitStatus(caseNumber);

View File

@@ -1,6 +1,6 @@
"use client";
import { PHASES, phaseIndexOf, type CaseStatus, type PhaseKey } from "@/lib/api/case-status";
import { PHASES, type CaseStatus, type PhaseKey } from "@/lib/api/case-status";
import { STATUS_LABELS, STATUS_ICONS, STATUS_DESCRIPTIONS } from "@/components/cases/status-badge";
import {
FolderInput, ClipboardList, Brain, PenLine, CheckCircle2,
@@ -21,6 +21,11 @@ const PHASE_ICONS: Record<PhaseKey, LucideIcon> = {
done: CheckCircle2,
};
function phaseIndexOf(status?: CaseStatus): number {
if (!status) return -1;
return PHASES.findIndex((p) => p.statuses.includes(status));
}
export function WorkflowTimeline({ status }: { status?: CaseStatus }) {
const currentIdx = phaseIndexOf(status);

View File

@@ -2,7 +2,6 @@
import { useEffect, useRef, useState } from "react";
import { useSaveChairPosition } from "@/lib/api/research";
import { formatTime } from "@/lib/format-date";
/*
* Chair-position editor for a single threshold claim or issue.
@@ -45,10 +44,8 @@ export function ChairEditor({
if (trimmed === lastSaved.current.trim()) return;
setState({ kind: "saving" });
try {
const res = await mutate.mutateAsync({ sectionId, position: trimmed });
/* Track what the backend read back, not what we sent — "✓ נשמר" must
mean "persisted and re-readable", or a later blur skips the save. */
lastSaved.current = res?.position ?? trimmed;
await mutate.mutateAsync({ sectionId, position: trimmed });
lastSaved.current = trimmed;
setState({ kind: "saved", at: new Date() });
} catch (e) {
setState({
@@ -90,7 +87,10 @@ function SaveIndicator({ state }: { state: SaveState }) {
return <span className="text-[0.72rem] text-ink-muted"> שומר</span>;
}
if (state.kind === "saved") {
const time = formatTime(state.at);
const time = state.at.toLocaleTimeString("he-IL", {
hour: "2-digit",
minute: "2-digit",
});
return <span className="text-[0.72rem] text-success"> נשמר {time}</span>;
}
return <span className="text-[0.72rem] text-danger"> {state.message}</span>;

Some files were not shown because too many files have changed in this diff Show More