Merge remote-tracking branch 'origin/main' into rebase-359
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s

# Conflicts:
#	docs/spec/04-analysis-writing.md
This commit is contained in:
2026-06-30 12:20:57 +00:00
23 changed files with 2202 additions and 78 deletions

View File

@@ -480,11 +480,38 @@ Conclusion → Rule → Explanation → Application → Conclusion.
## 3. כללי גזירת פרמטרים ## 3. כללי גזירת פרמטרים
פרמטרי העיבוד נגזרים ממאפייני התוכן, לא נקבעים שרירותית: פרמטרי העיבוד נגזרים ממאפייני התוכן, לא נקבעים שרירותית.
### Temperature — נגזר מסוג הייצור > **⚠️ עדכון מנגנון-הייצור (#204 / WS5).** מסלול-הייצור הוא `claude_session.query` →
> `claude -p` (מנוי claude.ai, עלות-שולית-אפס, מקומי-בלבד — ראה
> `reference_claude_generation_path`). ב-**Opus 4.7/4.8 הפרמטר `temperature` (וגם
> `top_p`/`top_k`) הוסר — שליחתו מחזירה HTTP 400**. הידית היחידה היא **`effort`**
> (low/medium/high/xhigh/max; ברירת-מחדל high). **המודל נעוץ ל-`claude-opus-4-8`
> לכל הבלוקים** ב-`block_writer.GENERATION_MODEL` — אין עוד פיצול sonnet/opus.
> טבלת ה-Temperature והעמודה `decision_blocks.temperature` הן **מטא-דאטה-מת
> (deprecated, תמיד 0)** ונשמרות רק לתאימות-עמודה; אל תקראו מהן. הטבלאות למטה
> נשמרות כתיעוד-המקור-הקונספטואלי בלבד — `effort` מחליף את "Thinking budget".
| Generation type | Temperature | נימוק | ### Effort — נגזר ממורכבות קוגניטיבית (הידית החיה)
מיפוי ה-`effort` בפועל ב-`block_writer.BLOCK_CONFIG` (#204):
| בלוק | gen_type | effort | נימוק |
|------|----------|--------|-------|
| ה (פתיחה) | paraphrase | medium | פתיחה קצרה, מבנה קבוע |
| ו (רקע) | reproduction | medium | שעתוק ניטרלי מרובה-מקורות |
| ז (טענות) | paraphrase | high | קיבוץ/סינתזת-טענות לפי-נושא |
| ח (הליכים) | reproduction | medium | תיעוד כרונולוגי |
| ט (תכניות) | guided-synthesis | high | ציטוט-תכניות מדויק (INV-AH) |
| י (דיון) | rhetorical-construction | xhigh | חשיבה רב-שלבית CREAC (מחוץ-לתחום-WS5) |
| יא (סיכום) | paraphrase | high | סינתזה אופרטיבית |
| א–ד, יב | template-fill | — (script) | מילוי-שדות, ללא LLM |
### Temperature — (DEPRECATED) נגזר היסטורית מסוג הייצור
> מסמך-מקור בלבד — לא נשלח ל-API (ראה האזהרה למעלה).
| Generation type | Temperature (היסטורי) | נימוק |
|----------------|-------------|-------| |----------------|-------------|-------|
| template-fill | 0 | אין צורך בשפה — מילוי שדות | | template-fill | 0 | אין צורך בשפה — מילוי שדות |
| reproduction | 0 | נאמנות מוחלטת למקור. אפס יצירתיות | | reproduction | 0 | נאמנות מוחלטת למקור. אפס יצירתיות |
@@ -493,18 +520,11 @@ Conclusion → Rule → Explanation → Application → Conclusion.
| analytical-reasoning | 0.3-0.4 | צריך ליצור קשרים בין עקרונות משפטיים | | analytical-reasoning | 0.3-0.4 | צריך ליצור קשרים בין עקרונות משפטיים |
| rhetorical-construction | 0.4-0.5 | טווח ביטוי רחב לכתיבה משכנעת ואלגנטית | | rhetorical-construction | 0.4-0.5 | טווח ביטוי רחב לכתיבה משכנעת ואלגנטית |
### Thinking budget — נגזר ממורכבות קוגניטיבית ### Model — (DEPRECATED) נגזר היסטורית מדרישת דיוק
| Cognitive task | Budget | נימוק | > כעת נעוץ `claude-opus-4-8` לכל בלוקי-ה-AI (`GENERATION_MODEL`). הטבלה היסטורית.
|---------------|--------|-------|
| template-fill / lookup | off | אין צורך בחשיבה |
| sequential-extraction | low | חילוץ מידע חד-שלבי |
| multi-source-integration | medium | צריך להצליב מקורות |
| legal-analysis-with-CREAC | max (16K+) | חשיבה רב-שלבית: מסקנה → כלל → הסבר → יישום |
### Model — נגזר מדרישת דיוק | Accuracy profile | Model (היסטורי) | נימוק |
| Accuracy profile | Model | נימוק |
|-----------------|-------|-------| |-----------------|-------|-------|
| factual-precision | sonnet | מהיר, מדויק לחילוץ עובדות | | factual-precision | sonnet | מהיר, מדויק לחילוץ עובדות |
| precision + interpretation | opus | נדרש לפרשנות תכנית / ציטוט מובנה | | precision + interpretation | opus | נדרש לפרשנות תכנית / ציטוט מובנה |

View File

@@ -19,7 +19,7 @@
| ישות | תפקיד | מזהה-קנוני | שדות-מפתח (מאומתים `db.py`) | | ישות | תפקיד | מזהה-קנוני | שדות-מפתח (מאומתים `db.py`) |
|------|--------|-------------|------------------------------| |------|--------|-------------|------------------------------|
| `cases` | תיק ערר חי (1xxx/8xxx/9xxx) | `case_number` + `proceeding_type` | `title`, `status`, `practice_area`, `appeal_subtype`, `proceeding_type`, `chair_name` (`db.py:74-91,182-189,747,912`) | | `cases` | תיק ערר חי (1xxx/8xxx/9xxx) | `case_number` + `proceeding_type` | `title`, `status`, `practice_area`, `appeal_subtype`, `proceeding_type`, `chair_name` (`db.py:74-91,182-189,747,912`) |
| `documents` | מסמך-מקור משויך לתיק | `id` (UUID); FK→`cases` | `doc_type`, `title`, `file_path`, `extracted_text`, `extraction_status`, `page_count` (`db.py:93-104`) | | `documents` | מסמך-מקור משויך לתיק | `id` (UUID); FK→`cases` | `doc_type`, `is_primary` (**נגזר** מ-`doc_type`, ראה [§2ג](#2ג-מסמך-עיקרי-primary-document)), `title`, `file_path`, `extracted_text`, `extraction_status`, `page_count` (`db.py:93-104`) |
| `document_chunks` | chunk של מסמך-תיק + embedding | `id`; FK→`documents`/`cases` | `chunk_index`, `content`, `section_type`, `embedding vector(1024)`, `page_number` (`db.py:106-116`) | | `document_chunks` | chunk של מסמך-תיק + embedding | `id`; FK→`documents`/`cases` | `chunk_index`, `content`, `section_type`, `embedding vector(1024)`, `page_number` (`db.py:106-116`) |
| `case_law` | קורפוס פסיקה — חיצוני **וגם** החלטות-ועדה | ראה [§2 + INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות) | `case_name`, `court`, `practice_area`, `source_kind`, `proceeding_type`, `source_type`, `headnote`, `summary`, `subject_tags`, `extraction_status`, `halacha_extraction_status` (`db.py:366-378,522-526,599-611,883,907`) | | `case_law` | קורפוס פסיקה — חיצוני **וגם** החלטות-ועדה | ראה [§2 + INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות) | `case_name`, `court`, `practice_area`, `source_kind`, `proceeding_type`, `source_type`, `headnote`, `summary`, `subject_tags`, `extraction_status`, `halacha_extraction_status` (`db.py:366-378,522-526,599-611,883,907`) |
| `precedent_chunks` | chunk של פסק-דין מואנדקס (`source_kind='external_upload'`/`internal_committee`) | `id`; FK→`case_law` | `chunk_index`, `content`, `section_type`, `page_number`, `embedding vector(1024)`, `content_tsv` (`db.py:624-634,776`) | | `precedent_chunks` | chunk של פסק-דין מואנדקס (`source_kind='external_upload'`/`internal_committee`) | `id`; FK→`case_law` | `chunk_index`, `content`, `section_type`, `page_number`, `embedding vector(1024)`, `content_tsv` (`db.py:624-634,776`) |
@@ -80,7 +80,25 @@ proceeding_type)`. לכן המזהה הקנוני הוא **(`case_number` מנו
- `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק. - `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק.
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`. - `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
### 2ג. ישויות-נגזרות (אחסון-ניתוחים) ### 2ג. מסמך-עיקרי (primary document)
WS2 (#200) מבחין בין **מסמך-עיקרי** (מסמך-מהות שהיו"ר עוקבת אחריו לניתוח) לבין מסמך-משני.
**הרשימה הקנונית של doc_types עיקריים** (מאושרת-יו"ר): `appeal` (כתב-ערר) · `response`
(תשובה/תגובה) · `objection` (התנגדות) · `protocol` (פרוטוקול-דיון) · `appraisal` (שומה) ·
`decision` (החלטת-ועדה). **כל doc_type אחר משני** (`plan`/`permit`/`court_decision`/
`exhibit`/`reference`).
**`is_primary` נגזר, לא נכתב** ([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)/[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)/
[INV-DM7](#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד)): מקור-האמת היחיד הוא
`doc_type` + הרשימה הקנונית `PRIMARY_DOC_TYPES` (`db.py`). העמודה
`documents.is_primary BOOLEAN GENERATED ALWAYS AS (doc_type = ANY(PRIMARY_DOC_TYPES)) STORED`
(SCHEMA_V47) מחושבת ע"י Postgres — **אין מסלול-כתיבה מקביל ולא יכולה לסטות** מ-`doc_type`,
בדיוק כמו ה-tsvectors ב-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). `_row_to_doc` גוזר גם בקריאה
(לרשומות טרום-מיגרציה) ומוסיף `doc_category ∈ {primary, secondary}` לפלט. נחשף דרך
`document_list` / `case_get` / API-המסמכים. אינדקס חלקי `idx_documents_primary(case_id) WHERE
is_primary` משרת את שאילתת "מסמכים-עיקריים שטרם-נותחו" (#201).
### 2ד. ישויות-נגזרות (אחסון-ניתוחים)
מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי
ה-provenance של [X8](X8-field-provenance.md) ולשערי [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant): ה-provenance של [X8](X8-field-provenance.md) ולשערי [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant):

View File

@@ -52,11 +52,61 @@
> חילוץ, **לא** על מסלול-ניתוח שני: (א) **ניתוח-מחדש מאחד** (WS2, #200/#201) — מחלץ מ-מסמכים > חילוץ, **לא** על מסלול-ניתוח שני: (א) **ניתוח-מחדש מאחד** (WS2, #200/#201) — מחלץ מ-מסמכים
> חדשים/מעודכנים בלבד ומאחד עם הקיים (לא מוחק-ומחשב-מחדש), על-בסיס דגל-הניתוח per-מסמך > חדשים/מעודכנים בלבד ומאחד עם הקיים (לא מוחק-ומחשב-מחדש), על-בסיס דגל-הניתוח per-מסמך
> ([02-data-model §2ב](02-data-model.md#2ב-חוזה-תמציתי-לישויות-נוספות)); (ב) **סיכום-מנהלים** > ([02-data-model §2ב](02-data-model.md#2ב-חוזה-תמציתי-לישויות-נוספות)); (ב) **סיכום-מנהלים**
> (WS3, #202, `summarize_party_claims`) — נגזר מ-`claims`/`legal_arguments`, **מסמך-פרוזה נפרד** > (WS3, #202, `summarize_party_claims` — ראה §1.4) — נגזר מ-`claims`/`legal_arguments`, **מסמך-פרוזה נפרד**
> מטיוטת-הביניים (G2: תצוגה-נגזרת, לא מקור-אמת שני); (ג) **ניתוח-פרוטוקול** (WS4, #203, > מטיוטת-הביניים (G2: תצוגה-נגזרת, לא מקור-אמת שני); (ג) **ניתוח-פרוטוקול** (WS4, #203,
> `analyze_protocol`) — `protocol` נכנס לחילוץ ההשוואתי (ירד/חוזק/חדש) ומזין ידע-תיק, ונשאר > `analyze_protocol` — ראה §1.3) — `protocol` נכנס לחילוץ ההשוואתי (ירד/חוזק/חדש) ומזין ידע-תיק, ונשאר
> פוסט-דיון ([INV-WR4](#inv-wr4-בלוק-ז--טענות-מקוריות-בלבד): טענות-פוסט-דיון → בלוק ח, לא ז). > פוסט-דיון ([INV-WR4](#inv-wr4-בלוק-ז--טענות-מקוריות-בלבד): טענות-פוסט-דיון → בלוק ח, לא ז).
> חוזי-הכלים המלאים בבעלות המשימות הנ"ל; כאן רק עיגון-הספ שהם מקיימים G2. > חוזי-הכלים המלאים בבעלות המשימות הנ"ל (§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`).
### 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`.
--- ---

View File

@@ -43,6 +43,16 @@
> וגם **טיוטת-ביניים** (`mode="interim"` — תת-קבוצת בלוקים בסדר חדש: רקע→תכניות→טענות→הליכים, > וגם **טיוטת-ביניים** (`mode="interim"` — תת-קבוצת בלוקים בסדר חדש: רקע→תכניות→טענות→הליכים,
> `export_interim_draft`, `drafting.py:511`). שני המצבים שולפים מאותה טבלה — וריאציית-תצוגה > `export_interim_draft`, `drafting.py:511`). שני המצבים שולפים מאותה טבלה — וריאציית-תצוגה
> של אותו מקור-אמת, לא מסלול שני. > של אותו מקור-אמת, לא מסלול שני.
>
> **דטרמיניזם-מבני של טיוטת-הביניים (#204 / WS5).** סדר-בלוקי-הביניים בייצוא קבוע
> ב-`docx_exporter._INTERIM_BLOCK_ORDER` (רקע→תכניות→טענות→הליכים, עם בלוקי-תבנית א–ה
> אופציונליים בתחילה). הבלוקים עצמם נכתבים דרך **אותו מסלול-כתיבה קנוני** של ההחלטה
> המלאה (`block_writer.write_and_store_block` → `write_block`, G2 — אין מסלול-ייצור
> מקביל), אך כעת **נעוצים למודל יחיד (`claude-opus-4-8`) עם `effort` per-בלוק ובפרומפט-
> מבני קבוע** (למשל בלוק-ה פותח תמיד ב"לפנינו ערר…"). התוצאה: אותו תיק מפיק את אותו
> **מבנה** בכל ריצה (אי-עקביות-הניסוח הקודמת נבעה מפרומפט-חופשי + מודל-ברירת-מחדל לא-נעוץ).
> טמפרטורה אינה במסלול (Opus 4.7/4.8 דוחים אותה); ראה `block_writer.GENERATION_MODEL`
> + `BLOCK_CONFIG[*].effort`.
> - `analysis_docx_exporter.py` (`build_analysis_docx`, `:401`) — מייצא את מסמך **הניתוח > - `analysis_docx_exporter.py` (`build_analysis_docx`, `:401`) — מייצא את מסמך **הניתוח
> המשפטי** (`analysis-and-research.md`) שכתב `legal-analyst`, לא את בלוקי-ההחלטה. זהו תוצר-עזר > המשפטי** (`analysis-and-research.md`) שכתב `legal-analyst`, לא את בלוקי-ההחלטה. זהו תוצר-עזר
> שונה (שלב ניתוח, לא החלטה) — והוא המסלול שהסקיל מתעד בעיקר. שניהם חולקים את **אותו טמפלט > שונה (שלב ניתוח, לא החלטה) — והוא המסלול שהסקיל מתעד בעיקר. שניהם חולקים את **אותו טמפלט

View File

@@ -744,6 +744,27 @@ async def get_appraiser_facts(case_number: str) -> str:
return await drafting.get_appraiser_facts(case_number) return await drafting.get_appraiser_facts(case_number)
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
@mcp.tool()
async def analyze_protocol(case_number: str) -> str:
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד.
מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול doc_type='protocol' +
טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high; re-run מחליף (idempotent).
"""
return await drafting.analyze_protocol(case_number)
@mcp.tool()
async def get_protocol_analysis(case_number: str, change_type: str = "") -> str:
"""קריאת ניתוח-הפרוטוקול שכבר חולץ — ללא ניתוח-מחדש. ה-get המקביל ל-analyze_protocol.
change_type: dropped/strengthened/newly_raised (ריק=הכל).
"""
return await drafting.get_protocol_analysis(case_number, change_type)
# ── Planning-schemes registry (V38) — מרשם-התכניות ───────────────── # ── Planning-schemes registry (V38) — מרשם-התכניות ─────────────────
# SSOT לזהות+תוקף של תכנית, נעשה שימוש חוזר בין תיקים (G2). פלט-LLM נכנס # SSOT לזהות+תוקף של תכנית, נעשה שימוש חוזר בין תיקים (G2). פלט-LLM נכנס
# pending_review וממתין לאישור-יו"ר (plan_review, G10) לפני שמשמש בבלוק ט. # pending_review וממתין לאישור-יו"ר (plan_review, G10) לפני שמשמש בבלוק ט.
@@ -814,6 +835,12 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
return await drafting.export_interim_draft(case_number, output_path) return await drafting.export_interim_draft(case_number, output_path)
@mcp.tool()
async def summarize_party_claims(case_number: str, instructions: str = "") -> str:
"""סיכום-מנהלים מזוקק של טענות הצדדים — מסמך פרוזה נפרד מטיוטת-ההחלטה, להכנת היו"ר לדיון. נגזר מ-legal_arguments/claims (אותו מקור), נעוץ Opus 4.8, נשמר וניתן-לייצוא."""
return await drafting.summarize_party_claims(case_number, instructions)
@mcp.tool() @mcp.tool()
async def apply_user_edit(case_number: str, edit_filename: str) -> str: async def apply_user_edit(case_number: str, edit_filename: str) -> str:
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר.""" """רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""

View File

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

View File

@@ -35,29 +35,53 @@ logger = logging.getLogger(__name__)
# ── Block configuration ─────────────────────────────────────────── # ── Block configuration ───────────────────────────────────────────
# Output token limits per Anthropic docs: # Generation is structurally deterministic (#204 / WS5): every AI block is
# Opus 4.7: up to 128K output tokens (new tokenizer — ~35% more tokens) # pinned to ONE model (Opus 4.8) with a per-block reasoning `effort`. The
# Sonnet 4.6: up to 64K output tokens # generation path is claude_session.query → `claude -p` (subscription, near-zero
# Streaming required when max_tokens > 21,333 # cost; local-only — see reference_claude_generation_path / claude_session
# docstring). Opus 4.7/4.8 REMOVED temperature/top_p/top_k (sending them → HTTP
# 400); the only knob is `effort` (low/medium/high/xhigh/max; default high).
#
# DEPRECATED FIELDS — DO NOT REINTRODUCE: earlier revisions carried per-block
# `temp` (0/0.1/0.4) and `model` ("sonnet"/"opus"/"script"). Both were DEAD
# METADATA: write_block called claude_session.query(prompt, …) WITHOUT model/
# temp/effort, so generation ran on the CLI's default model with no temperature
# knob at all (the table only stored a number in decision_blocks.temperature and
# fed MODEL_MAP→timeout). They are removed here so the table no longer misleads.
# The conceptual origin (gen_type → thinking-budget) now lives in `effort`; see
# docs/block-schema.md §3.
#
# `gen_type` is retained (documentation/UI metadata + audit `generation_type`).
# `model` below is the DISPATCH key only: "script" = template-fill (no LLM),
# "ai" = generated via the pinned Opus model. It is NOT a model alias anymore.
#
# Output token note (Anthropic): Opus 4.8 supports large outputs; streaming is
# handled by the CLI. `max_tokens` is advisory context for callers, not sent.
GENERATION_MODEL = "claude-opus-4-8" # single pinned model for every AI block (#204)
BLOCK_CONFIG = { BLOCK_CONFIG = {
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "temp": 0, "model": "script"}, "block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "model": "script"},
"block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "temp": 0, "model": "script"}, "block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "model": "script"},
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "temp": 0, "model": "script"}, "block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "model": "script"},
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "temp": 0, "model": "script"}, "block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "model": "script"},
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "temp": 0.2, "model": "sonnet", "max_tokens": 4096}, "block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "model": "ai", "effort": "medium", "max_tokens": 4096},
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 16384}, "block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 16384},
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 16384}, "block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "model": "ai", "effort": "high", "max_tokens": 16384},
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 8192}, "block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 8192},
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "temp": 0.2, "model": "opus", "max_tokens": 16384}, "block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "model": "ai", "effort": "high", "max_tokens": 16384},
"block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "temp": 0.4, "model": "opus", "max_tokens": 16384}, "block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "model": "ai", "effort": "xhigh", "max_tokens": 16384},
"block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 8192}, "block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "model": "ai", "effort": "high", "max_tokens": 8192},
"block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "temp": 0, "model": "script"}, "block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "model": "script"},
} }
MODEL_MAP = { # Default effort when a block lacks an explicit one (defensive; every AI block
"sonnet": "claude-sonnet-4-20250514", # above sets one). High is the safe default per the generation-path reference.
"opus": "claude-opus-4-7", DEFAULT_EFFORT = "high"
}
# Blocks that take longer (deep reasoning) get the LONG timeout. The interim set
# [he, vav, tet, zayin, chet] and the discussion block all run on the same pinned
# Opus model, so timeout is driven by effort, not by a model split.
_LONG_EFFORTS = frozenset({"high", "xhigh", "max"})
# ── Template blocks (א-ד, יב) ──────────────────────────────────── # ── Template blocks (א-ד, יב) ────────────────────────────────────
@@ -120,11 +144,14 @@ TEMPLATE_WRITERS = {
BLOCK_PROMPTS = { BLOCK_PROMPTS = {
"block-he": """כתוב את בלוק הפתיחה (בלוק ה) של החלטת ועדת ערר. "block-he": """כתוב את בלוק הפתיחה (בלוק ה) של החלטת ועדת ערר.
## כללים: ## מבנה קבוע (חובה — אותו מבנה בכל תיק):
- פתח ב"לפנינו ערר..." או "עניינה של החלטה זו..." - **המשפט הראשון פותח תמיד במילה "לפנינו"** — נוסח קבוע: "לפנינו ערר על החלטת
- הגדר "להלן" מרכזיים: הוועדה המקומית, התכנית/הבקשה, המגרש [הוועדה המקומית] מיום [תאריך] בעניין [נושא הבקשה/התכנית]." (אל תשתמש ב"עניינה של
- 1-2 סעיפים בלבד החלטה זו" או בכל פתיח חלופי — הפתיח אחיד.)
- אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים - סעיף 1: הצגת הערר במשפט הקבוע לעיל + הגדרת ה"להלן" המרכזיים בסדר קבוע:
הוועדה המקומית → התכנית/הבקשה → המגרש/המקרקעין.
- סעיף 2 (רק אם נדרש להשלמת "להלן" נוספים): הגדרות-נוספות בלבד.
- **בדיוק 1-2 סעיפים.** אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים.
- מספור: 1. - מספור: 1.
## פרטי התיק: ## פרטי התיק:
@@ -328,6 +355,7 @@ async def write_block(
case_id: UUID, case_id: UUID,
block_id: str, block_id: str,
instructions: str = "", instructions: str = "",
effort_override: str | None = None,
) -> dict: ) -> dict:
"""כתיבת בלוק יחיד בהחלטה. """כתיבת בלוק יחיד בהחלטה.
@@ -335,6 +363,11 @@ async def write_block(
case_id: מזהה התיק case_id: מזהה התיק
block_id: מזהה הבלוק (block-alef, block-he, block-yod, ...) block_id: מזהה הבלוק (block-alef, block-he, block-yod, ...)
instructions: הנחיות נוספות instructions: הנחיות נוספות
effort_override: optional per-call reasoning effort (low/medium/high/
xhigh/max). When set, overrides BLOCK_CONFIG[block_id].effort for
THIS call only — used by the #208 model/effort calibration harness
to A/B efforts without mutating the pinned defaults. Production
callers leave it None and get the deterministic per-block effort.
Returns: Returns:
dict עם content, word_count, block_id, generation_type dict עם content, word_count, block_id, generation_type
@@ -441,20 +474,34 @@ async def write_block(
f"Reduce documents or call extract_appraiser_facts first." f"Reduce documents or call extract_appraiser_facts first."
) )
# Call Claude via Claude Code session (no API) # Call Claude via Claude Code session (no API). #204: pin the model + per-block
model_key = block_cfg["model"] # reasoning effort so generation is structurally deterministic — these were
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT # previously NOT forwarded (the source of inconsistency). model/effort flow
content = await claude_session.query(prompt, timeout=timeout, tools="") # prose gen — no tool_use → no error_max_turns # through claude_session.query → `claude -p --model … --effort …`.
effort = effort_override or block_cfg.get("effort", DEFAULT_EFFORT)
timeout = claude_session.LONG_TIMEOUT if effort in _LONG_EFFORTS else claude_session.DEFAULT_TIMEOUT
content = await claude_session.query(
prompt,
timeout=timeout,
model=GENERATION_MODEL,
effort=effort,
tools="", # prose gen — no tool_use → no error_max_turns
)
sources = await _collect_block_sources(case_id, block_id) sources = await _collect_block_sources(case_id, block_id)
sources["case_law_ids"] = _precedent_case_law_ids sources["case_law_ids"] = _precedent_case_law_ids
result = _build_result(block_id, content, block_cfg) result = _build_result(block_id, content, block_cfg)
# Record the EFFECTIVE effort (override wins) so the harness can attribute
# the measured distance to the effort that actually produced the text.
if result.get("effort") is not None:
result["effort"] = effort
result["sources"] = sources result["sources"] = sources
return result return result
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict: def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
word_count = len(content.split()) word_count = len(content.split())
is_ai = block_cfg["model"] == "ai"
return { return {
"block_id": block_id, "block_id": block_id,
"block_index": block_cfg["index"], "block_index": block_cfg["index"],
@@ -462,8 +509,14 @@ def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
"content": content, "content": content,
"word_count": word_count, "word_count": word_count,
"generation_type": block_cfg["gen_type"], "generation_type": block_cfg["gen_type"],
"model_used": block_cfg["model"], # AI blocks record the pinned model; template blocks record "script".
"temperature": block_cfg["temp"], "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
# → HTTP 400). Kept only to satisfy decision_blocks.temperature
# NUMERIC(3,2); always 0. Read `effort` instead.
"temperature": 0,
} }
@@ -544,11 +597,19 @@ async def _build_claims_context(case_id: UUID) -> str:
return "(לא חולצו טענות)" return "(לא חולצו טענות)"
# Filter out claims from block-zayin (decision summary) — use only # Filter out claims from block-zayin (decision summary) — use only
# claims extracted from original pleadings (appeal, response, etc.) # claims extracted from original pleadings (appeal, response, etc.).
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] # Also drop claim_type='protocol' (oral arguments raised at the hearing):
# block ז carries ORIGINAL written pleadings only (INV-WR4); hearing-raised
# arguments belong to block ח (proceedings) and to analyze_protocol's
# comparative case-knowledge, not to the parties'-claims summary.
source_claims = [
c for c in claims
if c.get("source_document", "") != "block-zayin"
and c.get("claim_type", "claim") != "protocol"
]
if not source_claims: if not source_claims:
# Fallback to all claims if no source claims exist # Fallback to all non-block-zayin claims if no source claims exist.
source_claims = claims source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
lines = [] lines = []
current_role = "" current_role = ""

View File

@@ -227,8 +227,15 @@ def _infer_claim_type(doc_type: str, source_name: str) -> str:
- 'claim' = from appeal documents (כתב ערר) - 'claim' = from appeal documents (כתב ערר)
- 'response' = from original response documents (כתב תשובה) - 'response' = from original response documents (כתב תשובה)
- 'reply' = from supplementary responses (תגובה, השלמת טיעון) - 'reply' = from supplementary responses (תגובה, השלמת טיעון)
- 'protocol' = oral arguments raised at the hearing (פרוטוקול דיון)
""" """
name_lower = source_name.lower() if source_name else "" name_lower = source_name.lower() if source_name else ""
# A hearing protocol carries oral arguments — tagged distinctly so the
# comparative protocol analysis (analyze_protocol) and block-chet
# (proceedings) can tell them apart from the original written pleadings
# (INV-WR4: block ז stays original-pleadings-only).
if doc_type == "protocol" or "פרוטוקול" in name_lower:
return "protocol"
if doc_type == "appeal" or "כתב ערר" in name_lower: if doc_type == "appeal" or "כתב ערר" in name_lower:
return "claim" return "claim"
if "כתב תשובה" in name_lower: if "כתב תשובה" in name_lower:

View File

@@ -18,6 +18,43 @@ from legal_mcp.services import court_citation, halacha_quality, principles
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Primary-document classification (WS2 / task #200) ────────────────
# Canonical, single-source list of "primary" (מסמך עיקרי) doc_types — the
# substantive case documents the chair tracks for analysis (appeal, the
# replies/objections, the hearing protocol, the appraisal, and the
# committee decision). Every OTHER doc_type (plan, permit, court_decision,
# exhibit, reference…) is secondary. Chair-approved set (plan WS2 §1).
#
# G1/G2/INV-DM7: `is_primary` is DERIVED from `doc_type`, never an
# independently-written column. There is ONE source of truth: this tuple.
# The DB column `documents.is_primary` is a GENERATED ALWAYS … STORED column
# (V47) computed by Postgres from `doc_type`, so it can never drift; the
# Python helper below mirrors the same list for read-time derivation and for
# building the generated-column expression. No parallel write path.
PRIMARY_DOC_TYPES: tuple[str, ...] = (
"appeal", # כתב-ערר
"response", # תשובה / תגובה
"objection", # התנגדות
"protocol", # פרוטוקול-דיון
"appraisal", # שומה
"decision", # החלטת-ועדה
)
def is_primary_doc_type(doc_type: str | None) -> bool:
"""Whether a doc_type is a 'primary' (מסמך עיקרי) document. Single source
of truth = PRIMARY_DOC_TYPES; mirrors the V47 generated column."""
return doc_type in PRIMARY_DOC_TYPES
def _primary_doc_types_sql_array() -> str:
"""SQL ARRAY[...] literal of PRIMARY_DOC_TYPES for the generated column.
Values are fixed identifiers from this module (no user input) — safe to
inline; kept derived from the tuple so the list has ONE definition."""
quoted = ", ".join("'" + t.replace("'", "''") + "'" for t in PRIMARY_DOC_TYPES)
return f"ARRAY[{quoted}]::text[]"
_pool: asyncpg.Pool | None = None _pool: asyncpg.Pool | None = None
_schema_ready: bool = False _schema_ready: bool = False
_init_lock: asyncio.Lock = asyncio.Lock() _init_lock: asyncio.Lock = asyncio.Lock()
@@ -1782,6 +1819,57 @@ CREATE INDEX IF NOT EXISTS idx_decision_lessons_vec
ON decision_lessons USING ivfflat (embedding vector_cosine_ops) WITH (lists = 30); ON decision_lessons USING ivfflat (embedding vector_cosine_ops) WITH (lists = 30);
""" """
# V47 (WS2 / task #200): "primary document" (מסמך עיקרי) concept.
# `documents.is_primary` is a GENERATED ALWAYS … STORED column derived purely
# from `doc_type` against the canonical PRIMARY_DOC_TYPES list (built from the
# single-source tuple via _primary_doc_types_sql_array). Because Postgres
# computes it, there is NO parallel write path and it can never drift from
# doc_type (G1/G2/INV-DM7 — same drift-free pattern as the GENERATED tsvectors,
# INV-DM3). Idempotent: ADD COLUMN IF NOT EXISTS; the generated expression is
# fixed, so re-running is a no-op. The partial index serves the
# "primary docs not yet analysed" queries that #201 builds on.
SCHEMA_V47_SQL = f"""
ALTER TABLE documents ADD COLUMN IF NOT EXISTS is_primary BOOLEAN
GENERATED ALWAYS AS (doc_type = ANY({_primary_doc_types_sql_array()})) STORED;
CREATE INDEX IF NOT EXISTS idx_documents_primary
ON documents(case_id) WHERE is_primary;
"""
# ── V48: Protocol comparative analysis (WS4 / #203) ───────────────
#
# protocol_analysis: case-knowledge derived from a hearing-protocol document by
# comparing the oral arguments raised at the hearing against the written
# pleadings (legal_arguments). One row per (case_id, document_id) — idempotent
# replace on re-run (INV-TOOL3). Each row holds the comparative verdict on a
# single argument: was it DROPPED at the hearing, STRENGTHENED, or NEWLY_RAISED,
# plus the sharpened legal question it bears on. This is *derived* knowledge —
# the source of truth is the protocol document + legal_arguments; the row is a
# materialized comparison, re-buildable from those (G2: no parallel store, this
# is derived-from-source not a competing claims table).
#
# header_data (the א–ד feed: panel, hearing date, parties present) is written
# back to the canonical `cases` columns (panel via decisions, hearing_date on
# cases) — NOT duplicated here — and a copy of the raw extracted feed is kept on
# the analysis row for provenance only (G9).
SCHEMA_V48_SQL = """
CREATE TABLE IF NOT EXISTS protocol_analysis (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
party_role TEXT NOT NULL DEFAULT '', -- appellant/respondent/committee/permit_applicant/''
change_type TEXT NOT NULL CHECK (change_type IN ('dropped', 'strengthened', 'newly_raised')),
argument_id UUID REFERENCES legal_arguments(id) ON DELETE SET NULL, -- the pleaded argument, when matched
argument_title TEXT NOT NULL DEFAULT '', -- snapshot of the argument / new point
summary TEXT NOT NULL, -- what changed at the hearing, in prose
sharpened_question TEXT NOT NULL DEFAULT '', -- the legal question this sharpens (for the discussion)
evidence_quote TEXT NOT NULL DEFAULT '', -- verbatim protocol excerpt (INV-AH: quote-or-retract)
page_number INTEGER,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_protocol_analysis_case ON protocol_analysis(case_id, change_type);
CREATE INDEX IF NOT EXISTS idx_protocol_analysis_doc ON protocol_analysis(document_id);
"""
# Stable, arbitrary key for the session-level advisory lock that serialises # Stable, arbitrary key for the session-level advisory lock that serialises
# schema DDL across processes. Every short-lived process (cron drains, services) # schema DDL across processes. Every short-lived process (cron drains, services)
@@ -1851,6 +1939,8 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
await conn.execute(SCHEMA_V44_SQL) await conn.execute(SCHEMA_V44_SQL)
await conn.execute(SCHEMA_V45_SQL) await conn.execute(SCHEMA_V45_SQL)
await conn.execute(SCHEMA_V46_SQL) await conn.execute(SCHEMA_V46_SQL)
await conn.execute(SCHEMA_V47_SQL)
await conn.execute(SCHEMA_V48_SQL)
async def init_schema() -> None: async def init_schema() -> None:
@@ -2223,6 +2313,14 @@ def _row_to_doc(row: asyncpg.Record) -> dict:
d["case_id"] = str(d["case_id"]) d["case_id"] = str(d["case_id"])
if isinstance(d.get("metadata"), str): if isinstance(d.get("metadata"), str):
d["metadata"] = json.loads(d["metadata"]) d["metadata"] = json.loads(d["metadata"])
# Primary/secondary classification (WS2 / #200). `is_primary` is the
# generated DB column (V47); derive it at read-time too so the field is
# always present even on rows fetched before the migration ran, and expose
# a human-facing `doc_category`. Single source of truth: PRIMARY_DOC_TYPES.
is_primary = bool(d["is_primary"]) if d.get("is_primary") is not None \
else is_primary_doc_type(d.get("doc_type"))
d["is_primary"] = is_primary
d["doc_category"] = "primary" if is_primary else "secondary"
return d return d
@@ -3967,6 +4065,83 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]:
return conflicts return conflicts
# ── Protocol comparative analysis (V48 / WS4 #203) ────────────────
async def replace_protocol_analysis(
case_id: UUID,
document_id: UUID,
rows: list[dict],
) -> int:
"""Replace all protocol_analysis rows for a given protocol document (idempotent).
Each row dict: change_type ('dropped'|'strengthened'|'newly_raised'),
party_role, argument_id (UUID|None), argument_title, summary,
sharpened_question, evidence_quote, page_number (optional).
Idempotent on document_id (INV-TOOL3): re-running analyze_protocol replaces
the prior verdict for that protocol rather than appending duplicates.
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute(
"DELETE FROM protocol_analysis WHERE document_id = $1", document_id,
)
for r in rows:
await conn.execute(
"""INSERT INTO protocol_analysis
(case_id, document_id, party_role, change_type, argument_id,
argument_title, summary, sharpened_question, evidence_quote, page_number)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)""",
case_id, document_id,
r.get("party_role", "") or "",
r["change_type"],
r.get("argument_id"),
r.get("argument_title", "") or "",
r["summary"],
r.get("sharpened_question", "") or "",
r.get("evidence_quote", "") or "",
r.get("page_number"),
)
return len(rows)
async def list_protocol_analysis(
case_id: UUID,
change_type: str | None = None,
) -> list[dict]:
"""List protocol_analysis rows for a case, optionally filtered by change_type.
The read side of the extract/get symmetry (INV-TOOL4) for analyze_protocol.
"""
pool = await get_pool()
async with pool.acquire() as conn:
if change_type:
rows = await conn.fetch(
"""SELECT * FROM protocol_analysis
WHERE case_id = $1 AND change_type = $2
ORDER BY change_type, party_role, created_at""",
case_id, change_type,
)
else:
rows = await conn.fetch(
"""SELECT * FROM protocol_analysis
WHERE case_id = $1
ORDER BY change_type, party_role, created_at""",
case_id,
)
results = []
for r in rows:
d = dict(r)
d["id"] = str(d["id"])
d["case_id"] = str(d["case_id"])
d["document_id"] = str(d["document_id"])
if d.get("argument_id") is not None:
d["argument_id"] = str(d["argument_id"])
results.append(d)
return results
# ── Plans registry (V38) ────────────────────────────────────────── # ── Plans registry (V38) ──────────────────────────────────────────
# Canonical registry of planning schemes (תכניות). SSOT for a plan's identity + # Canonical registry of planning schemes (תכניות). SSOT for a plan's identity +
# validity, reused across cases (G2). See SCHEMA_V38_SQL for the data contract. # validity, reused across cases (G2). See SCHEMA_V38_SQL for the data contract.

View File

@@ -192,14 +192,29 @@ def _apply_list_numbering(paragraph, num_id: int) -> None:
def _clear_body(doc) -> None: def _clear_body(doc) -> None:
"""Remove all paragraphs in the document body while keeping sectPr. """Remove ALL body content (paragraphs, tables, stray bookmarks) while
keeping only sectPr.
The template ships with sample paragraphs we don't want. Section The template ships with sample paragraphs *and three sample tables*
properties (page size, margins, bidi) stay intact. (institutional header, panel, and a **signatures** table at the end —
"ניתנה פה אחד" + יו"ר/מזכירה). Removing only ``w:p`` left those tables
behind: the leftover header/panel tables injected block-א–ד data that was
never extracted from the protocol (violates the empty-unless-extracted
rule), and the leftover signatures table collapsed up against the header
table at the **top** of the document (the chair-reported bug: חתימות צמודות
לבלוק-ד במקום בסוף). The decision is a derived artifact rebuilt purely from
``decision_blocks`` (INV-EX1) — signatures come from block-yod-bet, rendered
last in block order — so the template's sample tables are stale scaffolding
and must go. Section properties (page size, margins, bidi) stay intact.
""" """
body = doc.element.body body = doc.element.body
for p in list(body.findall(qn("w:p"))): sectPr_tag = qn("w:sectPr")
body.remove(p) for child in list(body):
# Keep section properties (page setup / bidi). Drop everything else:
# sample paragraphs (w:p), sample tables (w:tbl), and any stray
# bookmark markers the template left dangling between them.
if child.tag != sectPr_tag:
body.remove(child)
# ── Bookmark helpers ────────────────────────────────────────────── # ── Bookmark helpers ──────────────────────────────────────────────

View File

@@ -0,0 +1,208 @@
"""סיכום-מנהלים של טענות הצדדים — מסמך פרוזה מזוקק להכנת היו"ר לדיון (WS3, #202).
מסמך זה הוא **נפרד ומובחן מטיוטת-הביניים** (החלטת-יו"ר, תוכנית workflow-redesign §WS3) —
תמצית מנהלים קצרה ומוקפדת של טענות הצדדים והתשובות זו-לזו, להכנה לדיון בעל-פה. אין הוא
משכפל את כתבי-הטענות המלאים: הוא מזקק.
מקור-האמת היחיד (G2): הוא נגזר מ-``legal_arguments`` (טיעונים מאוגדים) ומ-``claims``
הקיימים בתיק — לא מחלץ-מחדש ולא קורא לכתבי-הטענות הגולמיים. אם הטיעונים המאוגדים טרם
חושבו, הוא נופל-חזרה ל-claims הגולמיים (אותו מקור).
הייצור עובר ``claude_session.query`` → ``claude -p`` (CLI, מנוי claude.ai, עלות-שולית-אפס),
נעוץ ל-Opus 4.8 + effort=high — משימת זיקוק/סינתזה. **local-only**: לקונטיינר אין claude CLI;
לכן הכלי רץ רק מ-MCP-server המקומי, וה-endpoint ב-app.py רק מגיש את הקובץ השמור (לא מייצר).
ההזהרה נגד-הזיה (INV-AH): המסמך מתוחם לחלוטין לטענות-התיק עצמן. אסור להמציא טענה, הלכה
או עובדה שאינה מופיעה בקלט; טענה שאינה ברורה — מצוין במפורש, לא משלים.
"""
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
from uuid import UUID
from legal_mcp import config
from legal_mcp.services import (
argument_aggregator,
block_writer,
claude_session,
db,
git_sync,
storage,
)
logger = logging.getLogger(__name__)
# Saved alongside the legal analysis (same research folder, same git+S3 mirror path).
_SUMMARY_FILENAME = "party-claims-summary.md"
_PARTY_HE = {
"appellant": "העוררים",
"respondent": "המשיבים",
"committee": "הוועדה המקומית",
"permit_applicant": "מבקשי ההיתר",
"unknown": "צד לא מזוהה",
}
def summary_file_path(case_number: str) -> Path:
"""Resolve the saved party-claims-summary.md path for a case."""
return config.find_case_dir(case_number) / "documents" / "research" / _SUMMARY_FILENAME
async def _build_arguments_context(case_id: UUID) -> tuple[str, int, str]:
"""Build the grounded source block from the case's OWN claims/arguments.
Preference order (single source of truth, no re-extraction — G2):
1. ``legal_arguments`` — the aggregated, de-duplicated arguments per party.
2. fallback to raw ``claims`` when arguments were never aggregated.
Returns ``(context_text, item_count, source_kind)`` where ``source_kind`` is
``"arguments"`` or ``"claims"`` (or ``"none"`` when neither exists).
"""
args = await argument_aggregator.get_legal_arguments(case_id)
if args:
by_party: dict[str, list[dict]] = {}
for a in args:
by_party.setdefault(a["party"], []).append(a)
lines: list[str] = []
for party, items in by_party.items():
lines.append(f"\n## {_PARTY_HE.get(party, party)}")
for a in items:
title = (a.get("argument_title") or "").strip()
body = (a.get("argument_body") or "").strip()
topic = (a.get("legal_topic") or "").strip()
header = f"- **{title}**" if title else "-"
if topic:
header += f" [{topic}]"
lines.append(header)
if body:
lines.append(f" {body}")
return "\n".join(lines), len(args), "arguments"
# Fallback — raw claims, the same underlying source.
claims = await db.get_claims(case_id)
# Exclude block-zayin (decision-summary) claims — original pleadings only.
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
if not source_claims:
return "", 0, "none"
lines = []
current_role = ""
role_he = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
n = 0
for c in source_claims:
if c["party_role"] != current_role:
current_role = c["party_role"]
lines.append(f"\n## {role_he.get(current_role, current_role)}")
n += 1
lines.append(f"- טענה #{n}: {c['claim_text']}")
return "\n".join(lines), len(source_claims), "claims"
_PROMPT_TEMPLATE = """אתה מכין עבור יו"ר ועדת הערר (עו"ד דפנה תמיר) **סיכום-מנהלים מזוקק של טענות הצדדים** — מסמך פרוזה קצר שמטרתו אחת: להכין את היו"ר לדיון בעל-פה. זהו מסמך **נפרד** מטיוטת-ההחלטה ואינו חלק ממנה.
## פרטי התיק:
{case_context}
## הקלט — טענות/טיעוני הצדדים (מקור-האמת היחיד):
{arguments_context}
## כללי-כתיבה מחייבים:
- **זיקוק, לא שכפול.** אל תעתיק את כתבי-הטענות. תמצת כל צד ל-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

@@ -0,0 +1,341 @@
"""ניתוח פרוטוקול-דיון השוואתי (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 _find_protocol(docs: list[dict]) -> dict | None:
"""The protocol document for the case, if present (doc_type or title)."""
for d in docs:
if d.get("doc_type") == "protocol":
return d
for d in docs:
if "פרוטוקול" in (d.get("title") or ""):
return d
return None
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)
return {
"status": "ok",
"feed": {
"hearing_date": hearing_date,
"panel_members": feed.get("panel_members") or [],
"appellants_present": feed.get("appellants_present") or [],
"respondents_present": feed.get("respondents_present") or [],
},
"applied_to_case": applied,
}
async def analyze_protocol(case_id: UUID) -> dict:
"""Comparative analysis of the case's hearing protocol vs. its pleadings.
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)
if not protocol:
return {
"status": "no_protocol",
"message": "לא נמצא פרוטוקול דיון בתיק (doc_type='protocol'). העלה פרוטוקול והרץ שוב.",
}
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,6 +15,7 @@ import re
from uuid import UUID from uuid import UUID
from legal_mcp.services import db from legal_mcp.services import db
from legal_mcp.services.learning_loop import compute_diff_stats
from legal_mcp.services.lessons import ANTI_PATTERNS, GOLDEN_RATIOS, canonical_outcome from legal_mcp.services.lessons import ANTI_PATTERNS, GOLDEN_RATIOS, canonical_outcome
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -122,6 +123,93 @@ def golden_ratio_adherence(block_word_counts: dict[str, int], outcome: str) -> d
return {"outcome": outcome, "total_words": total, "sections": sections, "max_deviation": max_dev} return {"outcome": outcome, "total_words": total, "sections": sections, "max_deviation": max_dev}
def split_final_by_section(final_text: str) -> dict[str, str]:
"""Group a signed final decision into golden-ratio sections (#208 calibration).
Reuses the SAME structure-aware splitter as measure_corpus_ratios
(chunker._split_into_sections + _CHUNK_SECTION_TO_GOLDEN) — no parallel
parsing path (G2). Returns {golden_section: concatenated_text} for the
sections that map to an AI block (background/claims/discussion/summary).
A section type that does not map (e.g. headers) is dropped, never silently
folded into another section. Best-effort: an unsplittable final returns {}.
"""
from legal_mcp.services.chunker import _split_into_sections
by_section: dict[str, list[str]] = {}
for stype, stext in _split_into_sections(final_text or ""):
g = _CHUNK_SECTION_TO_GOLDEN.get(stype)
if g and stext.strip():
by_section.setdefault(g, []).append(stext.strip())
return {sec: "\n\n".join(parts) for sec, parts in by_section.items()}
def block_distance_to_final(
block_id: str,
regenerated_text: str,
final_section_text: str,
outcome: str,
section_target_total_words: int | None = None,
) -> dict:
"""Distance of ONE regenerated block from the chair's matching final section.
The per-(block, effort) measurement cell for the #208 model/effort
calibration harness. Pure/deterministic (no LLM, no DB) — reuses the
existing style-distance primitives so the harness has no parallel metric
path (G2 / INV-G8 eval-harness):
• change_percent — compute_diff_stats(regen, final_section)
(learning_loop, the SAME diff the pairing
ledger stores). Lower ⇒ the draft already
reads like the final ⇒ less chair rewriting.
• anti_pattern_total — count_anti_patterns(regen) (lessons.ANTI_PATTERNS).
Lower ⇒ closer to Dafna's continuous-narrative
voice; the CLEANEST style signal (07-learning §0.7).
• golden_ratio_deviation_pp — |regen %-of-total final %-of-total| for this
block's section. 0 ⇒ same structural weight as
the final. Requires the final's total words
(section_target_total_words); otherwise None
(we never fabricate a denominator).
Returns the three metrics + a single composite `distance` (normalized,
lower=closer) the harness ranks efforts by.
"""
outcome = canonical_outcome(outcome)
diff = compute_diff_stats(regenerated_text or "", final_section_text or "")
change_percent = diff["change_percent"]
anti_total = count_anti_patterns(regenerated_text or "")["total"]
section = _BLOCK_TO_SECTION.get(block_id)
regen_words = len((regenerated_text or "").split())
final_words = len((final_section_text or "").split())
ratio_dev: float | None = None
if section and section_target_total_words and section_target_total_words > 0:
# Replace the final's own block contribution with the regen's, holding
# the rest of the final constant, to compare structural weight fairly.
regen_total = section_target_total_words - final_words + regen_words
if regen_total > 0:
regen_pct = regen_words / regen_total * 100
final_pct = final_words / section_target_total_words * 100
ratio_dev = round(abs(regen_pct - final_pct), 1)
# Composite: normalize each component to ~[0,1] and average the present ones.
# change_percent/100, anti_total/10 (10+ hits is already very bad), ratio/20.
comps: list[float] = [min(change_percent / 100.0, 1.0), min(anti_total / 10.0, 1.0)]
if ratio_dev is not None:
comps.append(min(ratio_dev / 20.0, 1.0))
distance = round(sum(comps) / len(comps), 4)
return {
"block_id": block_id,
"section": section,
"regen_words": regen_words,
"final_words": final_words,
"change_percent": change_percent,
"anti_pattern_total": anti_total,
"golden_ratio_deviation_pp": ratio_dev,
"distance": distance,
}
async def style_distance(case_number: str) -> dict: async def style_distance(case_number: str) -> dict:
"""Assemble the 3 style-distance components for one case (T7).""" """Assemble the 3 style-distance components for one case (T7)."""
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)

View File

@@ -264,6 +264,11 @@ async def document_get_text(case_number: str, doc_title: str = "") -> str:
async def document_list(case_number: str) -> str: async def document_list(case_number: str) -> str:
"""רשימת מסמכים בתיק. """רשימת מסמכים בתיק.
כל מסמך כולל `doc_type`, וכן את הסיווג הנגזר `is_primary` (bool) ו-
`doc_category` ("primary"/"secondary") — מסמך-עיקרי (ערר/תשובה/התנגדות/
פרוטוקול/שומה/החלטת-ועדה) מול משני. נגזר מ-`doc_type` (db.PRIMARY_DOC_TYPES),
אינו נכתב ידנית.
Args: Args:
case_number: מספר תיק הערר case_number: מספר תיק הערר
""" """
@@ -345,11 +350,15 @@ async def extract_claims(
if not docs: if not docs:
return empty(f"אין מסמכים בתיק {case_number}.") return empty(f"אין מסמכים בתיק {case_number}.")
# Filter to claims documents (appeal, response) or specific doc # Filter to claims documents or a specific doc. protocol carries oral
# arguments raised at the hearing — extracted with claim_type='protocol'
# (claims_extractor._infer_claim_type) so analyze_protocol can compare them
# against the written pleadings, while block ז (write_block_zayin) excludes
# them to stay original-pleadings-only (INV-WR4).
if doc_title: if doc_title:
docs = [d for d in docs if doc_title.lower() in d["title"].lower()] docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
else: else:
docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection")] docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection", "protocol")]
if not docs: if not docs:
return empty("לא נמצאו כתבי טענות בתיק.") return empty("לא נמצאו כתבי טענות בתיק.")

View File

@@ -516,9 +516,18 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
# ── Interim draft (pre-ruling) ──────────────────────────────────── # ── Interim draft (pre-ruling) ────────────────────────────────────
# Blocks written for the interim draft, in display order. # Blocks written for the interim draft, in a FIXED order.
# This is the same content the chair sees in the final decision (same template, # This is the same content the chair sees in the final decision (same template,
# same skill, same prompts) — minus opening, ruling, summary, signatures. # same skill, same prompts, same single canonical write path — block_writer) —
# minus ruling, summary, signatures.
#
# Determinism (#204 / WS5): the interim draft is STRUCTURALLY deterministic.
# Each block is generated by block_writer.write_block, which now pins the model
# (Opus 4.8) and a per-block reasoning effort, and uses fixed structural prompts
# (e.g. block-he's opening is always "לפנינו ערר…"). The display order on export
# is fixed separately by docx_exporter._INTERIM_BLOCK_ORDER. There is no parallel
# generation path (G2): write_interim_draft is a thin orchestrator over the same
# write_and_store_block used by the full decision.
_INTERIM_BLOCKS = ["block-he", "block-vav", "block-tet", "block-zayin", "block-chet"] _INTERIM_BLOCKS = ["block-he", "block-vav", "block-tet", "block-zayin", "block-chet"]
@@ -571,6 +580,73 @@ async def get_appraiser_facts(case_number: str) -> str:
return err(str(e)) return err(str(e))
async def analyze_protocol(case_number: str) -> str:
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203).
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
מחדד את השאלות המשפטיות, ומחלץ את נתוני הכותרת (א–ד) מהפרוטוקול. התוצאה נכנסת
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
מוזן חזרה לעמודה הקנונית cases.hearing_date.
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
Args:
case_number: מספר תיק הערר
"""
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"])
try:
result = await protocol_analyzer.analyze_protocol(case_id)
await audit.log_action_safe(
"analyze_protocol", case_id=case_id,
details={"status": result.get("status"), "total": result.get("total", 0)},
)
return ok(result)
except Exception as e:
return err(str(e))
async def get_protocol_analysis(case_number: str, change_type: str = "") -> str:
"""קריאת ניתוח-הפרוטוקול שכבר חולץ — ללא הרצת ניתוח-מחדש (INV-TOOL4).
ה-get המקביל ל-analyze_protocol: מחזיר את רשומות ידע-התיק השמורות
(ירדה/חוזקה/חדשה + שאלה-מחודדת + ציטוט-מבסס), בלי קריאת-LLM יקרה.
מחזיר רשימה ריקה אם הניתוח טרם רץ (status=ok, count=0) — לא שגיאה.
Args:
case_number: מספר תיק הערר
change_type: סינון (dropped/strengthened/newly_raised). ריק = הכל.
"""
from legal_mcp.services import protocol_analyzer
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
if change_type and change_type not in protocol_analyzer.VALID_CHANGE_TYPES:
return err(
f"change_type לא תקין: {change_type}",
data={"allowed": sorted(protocol_analyzer.VALID_CHANGE_TYPES)},
)
case_id = UUID(case["id"])
try:
rows = await db.list_protocol_analysis(
case_id, change_type=change_type or None,
)
return ok({
"case_number": case_number,
"count": len(rows),
"analysis": rows,
})
except Exception as e:
return err(str(e))
async def write_interim_draft(case_number: str, instructions: str = "") -> str: async def write_interim_draft(case_number: str, instructions: str = "") -> str:
"""כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט), """כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט),
טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין — טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין —
@@ -658,6 +734,34 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
return err(str(e)) return err(str(e))
async def summarize_party_claims(case_number: str, instructions: str = "") -> str:
"""הפקת סיכום-מנהלים מזוקק של טענות הצדדים — מסמך פרוזה **נפרד מטיוטת-הביניים**,
להכנת היו"ר לדיון בעל-פה. תמצית קצרה ומוקפדת (לא שכפול כתבי-הטענות), נגזרת
מהטיעונים המאוגדים (legal_arguments) או מ-claims הגולמיים — אותו מקור-אמת,
בלי חילוץ-מחדש. נשמר ל-data/cases/{n}/documents/research/ (git + S3) וניתן-לייצוא.
הייצור עובר claude_session (claude -p), נעוץ Opus 4.8 + effort=high. מתוחם
לטענות-התיק בלבד (anti-hallucination) — אינו ממציא טענה/הלכה/עובדה שאינה בקלט.
Args:
case_number: מספר תיק הערר
instructions: הנחיות נוספות מהיו"ר (אופציונלי)
"""
from legal_mcp.services import party_claims_summary
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"])
try:
result = await party_claims_summary.summarize_party_claims(
case_id, case_number, instructions,
)
return ok(result)
except ValueError as e:
return err(str(e))
async def apply_user_edit(case_number: str, edit_filename: str) -> str: async def apply_user_edit(case_number: str, edit_filename: str) -> str:
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק. """רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.

View File

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

View File

@@ -0,0 +1,212 @@
"""Tests for the party-claims executive summary (#202, WS3).
Cover the deterministic, offline parts:
- ``_build_arguments_context`` source selection: legal_arguments preferred,
raw claims fallback, "none" when empty (G2 — single source, no re-extract).
- ``summarize_party_claims`` pins Opus 4.8 + effort=high and grounds the
prompt strictly in the case's own claims (anti-hallucination / INV-AH).
- ``_render_markdown_body`` maps markdown headings/bullets onto template styles.
Generation itself (claude -p) is local-only and not exercised here — the
claude_session call is monkeypatched.
"""
from __future__ import annotations
import asyncio
from uuid import uuid4
from legal_mcp.services import party_claims_summary as pcs
def _run(coro):
return asyncio.run(coro)
# ── source selection (G2: arguments preferred, claims fallback) ────
def test_arguments_preferred_over_claims(monkeypatch):
cid = uuid4()
async def _get_args(_case_id):
return [
{"party": "appellant", "argument_title": "חריגת בנייה",
"argument_body": "הבנייה חורגת מקו הבניין", "legal_topic": "קווי בניין"},
{"party": "respondent", "argument_title": "התיישנות",
"argument_body": "", "legal_topic": ""},
]
async def _get_claims(_case_id, *a, **k): # must NOT be consulted
raise AssertionError("claims fallback used despite arguments existing")
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _get_args)
monkeypatch.setattr(pcs.db, "get_claims", _get_claims)
ctx, n, kind = _run(pcs._build_arguments_context(cid))
assert kind == "arguments"
assert n == 2
assert "העוררים" in ctx and "המשיבים" in ctx
assert "חריגת בנייה" in ctx
def test_falls_back_to_claims_when_no_arguments(monkeypatch):
cid = uuid4()
async def _no_args(_case_id):
return []
async def _claims(_case_id, *a, **k):
return [
{"party_role": "appellant", "claim_text": "טענה אחת", "source_document": "appeal"},
{"party_role": "respondent", "claim_text": "טענה שתיים", "source_document": "response"},
]
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _no_args)
monkeypatch.setattr(pcs.db, "get_claims", _claims)
ctx, n, kind = _run(pcs._build_arguments_context(cid))
assert kind == "claims"
assert n == 2
assert "טענה אחת" in ctx
def test_block_zayin_claims_excluded(monkeypatch):
"""Decision-summary (block-zayin) claims are not original pleadings."""
cid = uuid4()
async def _no_args(_case_id):
return []
async def _claims(_case_id, *a, **k):
return [
{"party_role": "appellant", "claim_text": "מקורית", "source_document": "appeal"},
{"party_role": "appellant", "claim_text": "מסיכום", "source_document": "block-zayin"},
]
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _no_args)
monkeypatch.setattr(pcs.db, "get_claims", _claims)
ctx, n, kind = _run(pcs._build_arguments_context(cid))
assert kind == "claims"
assert n == 1
assert "מקורית" in ctx and "מסיכום" not in ctx
def test_none_when_no_source(monkeypatch):
cid = uuid4()
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments",
lambda _c: _aw([]))
monkeypatch.setattr(pcs.db, "get_claims", lambda _c, *a, **k: _aw([]))
ctx, n, kind = _run(pcs._build_arguments_context(cid))
assert kind == "none" and n == 0 and ctx == ""
async def _aw(v):
return v
# ── generation pins Opus 4.8 + high effort, grounded in claims ─────
def test_summarize_pins_opus_and_high_effort(monkeypatch, tmp_path):
cid = uuid4()
captured: dict = {}
async def _get_case(_case_id):
return {"case_number": "8125-09-24", "title": "t", "appellants": ["א"],
"respondents": ["ב"], "subject": "היטל השבחה", "property_address": "",
"appeal_type": "betterment_levy", "practice_area": "betterment_levy"}
async def _get_decision(_case_id):
return None
async def _args(_case_id):
return [{"party": "appellant", "argument_title": "X",
"argument_body": "Y", "legal_topic": ""}]
async def _fake_query(prompt, *, timeout=None, model=None, effort=None, tools=None):
captured["model"] = model
captured["effort"] = effort
captured["tools"] = tools
captured["prompt"] = prompt
return "## טענות העוררים\nתמצית."
async def _style(_practice_area=""):
return "(style)"
monkeypatch.setattr(pcs.db, "get_case", _get_case)
monkeypatch.setattr(pcs.db, "get_decision_by_case", _get_decision)
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _args)
monkeypatch.setattr(pcs.block_writer, "_build_style_context", _style)
monkeypatch.setattr(pcs.claude_session, "query", _fake_query)
monkeypatch.setattr(pcs.config, "find_case_dir", lambda cn: tmp_path / cn)
monkeypatch.setattr(pcs.config, "DATA_DIR", tmp_path)
async def _mirror(*a, **k):
return None
monkeypatch.setattr(pcs.storage, "mirror", _mirror)
monkeypatch.setattr(pcs.git_sync, "commit_and_push", lambda *a, **k: True)
result = _run(pcs.summarize_party_claims(cid, "8125-09-24"))
# The whole point of #202's generation-path constraint:
assert captured["model"] == "claude-opus-4-8"
assert captured["effort"] == "high"
assert captured["tools"] == "" # prose, no tool_use
# Grounded strictly in the case's own arguments (anti-hallucination).
assert "אנטי-הזיה" in captured["prompt"]
assert "Y" in captured["prompt"] # the actual argument body is in-context
assert result["source_kind"] == "arguments"
out = (tmp_path / "8125-09-24" / "documents" / "research" / "party-claims-summary.md")
assert out.exists()
assert "סיכום-מנהלים" in out.read_text(encoding="utf-8")
def test_summarize_raises_when_no_claims(monkeypatch, tmp_path):
cid = uuid4()
monkeypatch.setattr(pcs.db, "get_case", lambda _c: _aw({"case_number": "9000-01-25"}))
monkeypatch.setattr(pcs.db, "get_decision_by_case", lambda _c: _aw(None))
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", lambda _c: _aw([]))
monkeypatch.setattr(pcs.db, "get_claims", lambda _c, *a, **k: _aw([]))
try:
_run(pcs.summarize_party_claims(cid, "9000-01-25"))
raise AssertionError("expected ValueError on empty source")
except ValueError as e:
assert "טענות" in str(e)
# ── markdown → DOCX rendering uses template heading styles ─────────
def test_render_markdown_body_maps_styles():
from docx import Document
from legal_mcp.services import analysis_docx_exporter as ax
doc = Document()
md = (
"# כותרת ראשית\n\n"
"---\n"
"## טענות העוררים\n"
"- טענה אחת\n"
"1. סעיף ממוספר\n"
"טקסט רגיל.\n"
)
ax._render_markdown_body(doc, md)
styles = [p.style.name for p in doc.paragraphs]
texts = [p.text for p in doc.paragraphs]
# HR is skipped; headings become Heading styles.
assert "כותרת ראשית" in texts
assert "Heading 1" in styles # # / ## → Heading 1
assert any("טענה אחת" in t for t in texts)
assert not any(t.strip() in {"---", "#"} for t in texts)
def test_next_version_is_prefix_scoped(tmp_path):
from legal_mcp.services import analysis_docx_exporter as ax
(tmp_path / "ניתוח-משפטי-v3.docx").write_bytes(b"x")
# An analysis file at v3 must NOT bump the summary's version.
assert ax._next_version(tmp_path, "סיכום-מנהלים-טענות") == 1
assert ax._next_version(tmp_path, "ניתוח-משפטי") == 4

View File

@@ -0,0 +1,141 @@
"""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
# ── _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

@@ -76,6 +76,7 @@
| `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest <path>`. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) | | `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest <path>`. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) |
| `ab_halacha_opus48.py` | python | **A/B לא-הרסני לחילוץ הלכות (Claude)** — מריץ מחדש חילוץ הלכות על פסק-דין בודד דרך מודל/effort נבחרים (`AB_MODEL`/`AB_EFFORT`, ברירת-מחדל `claude-opus-4-8`/`xhigh`) ומשווה לסטטיסטיקות ההלכות הקיימות ב-DB **בלי למחוק/לכתוב כלום**. משכפל את `halacha_extractor.extract()` (אותם פרומפטים, בחירת-צ'אנקים, אימות-ציטוט) ומחליף רק את קריאת ה-LLM ב-`claude -p --model --effort`. מפיק `data/ab_halacha_<case>_<effort>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data .venv/bin/python scripts/ab_halacha_opus48.py <case_law_id>`. **ממצא 2026-05-31 (שטיין 1128-08-20):** Opus 4.8@xhigh חילץ 51 מול 124 בייצור (100% quote-verified מול 96%) אך ביטחון מכויל-נמוך יותר (חציון 0.75 מול 0.82) — ולכן **לא** מקטין את תור-האישור-הידני תחת sweep אוטו-אישור conf≥0.78 (26 מול 24). שיפור איכות, לא צמצום-תור. | ידני (החלטת מודל-חילוץ) | | `ab_halacha_opus48.py` | python | **A/B לא-הרסני לחילוץ הלכות (Claude)** — מריץ מחדש חילוץ הלכות על פסק-דין בודד דרך מודל/effort נבחרים (`AB_MODEL`/`AB_EFFORT`, ברירת-מחדל `claude-opus-4-8`/`xhigh`) ומשווה לסטטיסטיקות ההלכות הקיימות ב-DB **בלי למחוק/לכתוב כלום**. משכפל את `halacha_extractor.extract()` (אותם פרומפטים, בחירת-צ'אנקים, אימות-ציטוט) ומחליף רק את קריאת ה-LLM ב-`claude -p --model --effort`. מפיק `data/ab_halacha_<case>_<effort>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data .venv/bin/python scripts/ab_halacha_opus48.py <case_law_id>`. **ממצא 2026-05-31 (שטיין 1128-08-20):** Opus 4.8@xhigh חילץ 51 מול 124 בייצור (100% quote-verified מול 96%) אך ביטחון מכויל-נמוך יותר (חציון 0.75 מול 0.82) — ולכן **לא** מקטין את תור-האישור-הידני תחת sweep אוטו-אישור conf≥0.78 (26 מול 24). שיפור איכות, לא צמצום-תור. | ידני (החלטת מודל-חילוץ) |
| `ab_halacha_codex.py` | python | **A/B לא-הרסני לחילוץ הלכות (Codex/gpt-5.5)** — עמית ל-`ab_halacha_opus48` אך מחליף את `claude -p` ב-`codex exec --model gpt-5.5` (אימות ChatGPT, ללא OPENAI_API_KEY). אותם פרומפטים ואותו הסקת quote-verification. הפלט האחרון של הסוכן (`-o FILE`) נפענח כ-JSON. `AB_MODEL` (default `gpt-5.5`), `AB_REASONING` low/medium/high/xhigh (default `medium`), `AB_CONCURRENCY` (default 1), `CODEX_BIN`. מפיק `data/ab_halacha_codex_<case>_<model>_<reasoning>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/ab_halacha_codex.py <case_law_id>`. **ממצא 2026-06-17 (8181-21 האוניברסיטה העברית):** gpt-5.5@medium חילץ 27 מול 28 של Opus (quote-verified 100%/100%), ביטחון חציון 0.86 מול 0.78 — אך **0 פריטים מתחת ל-0.7** (לעומת 9/28 של Opus = 32%), דבר המצביע על over-confidence. holding↑ (12 מול 7), procedural↓ (4 מול 7). **מסקנה: ריאלי כ-fallback חירום; לא מוכן לייצור ללא כיול-ביטחון.** | ידני (בנצ'מרק מודל codex) | | `ab_halacha_codex.py` | python | **A/B לא-הרסני לחילוץ הלכות (Codex/gpt-5.5)** — עמית ל-`ab_halacha_opus48` אך מחליף את `claude -p` ב-`codex exec --model gpt-5.5` (אימות ChatGPT, ללא OPENAI_API_KEY). אותם פרומפטים ואותו הסקת quote-verification. הפלט האחרון של הסוכן (`-o FILE`) נפענח כ-JSON. `AB_MODEL` (default `gpt-5.5`), `AB_REASONING` low/medium/high/xhigh (default `medium`), `AB_CONCURRENCY` (default 1), `CODEX_BIN`. מפיק `data/ab_halacha_codex_<case>_<model>_<reasoning>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/ab_halacha_codex.py <case_law_id>`. **ממצא 2026-06-17 (8181-21 האוניברסיטה העברית):** gpt-5.5@medium חילץ 27 מול 28 של Opus (quote-verified 100%/100%), ביטחון חציון 0.86 מול 0.78 — אך **0 פריטים מתחת ל-0.7** (לעומת 9/28 של Opus = 32%), דבר המצביע על over-confidence. holding↑ (12 מול 7), procedural↓ (4 מול 7). **מסקנה: ריאלי כ-fallback חירום; לא מוכן לייצור ללא כיול-ביטחון.** | ידני (בנצ'מרק מודל codex) |
| `calibrate_effort.py` | python | **#208 (WS5/Q1, INV-G8 eval-harness) — כיול model×effort של הכותב מול הסופיים.** A/B per-(תיק,בלוק,effort) על `draft_final_pairs` בעלי `final_text`: מייצר מחדש כל בלוק דרך מסלול-הייצור (`block_writer.write_block(effort_override=…)``claude_session.query``claude -p`, Opus 4.8 נעוץ, **מקומי-בלבד**) ומודד מול ה**סקשן** המתאים בסופי דרך `services/style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2): `change_percent` (compute_diff_stats) · `anti_pattern_total` (`lessons.ANTI_PATTERNS`, הסיגנל הנקי-לסגנון) · `golden_ratio_deviation_pp` · `distance` מרוכב. **ממליץ** per-בלוק על ה-effort בעל ה-distance-הממוצע-הנמוך (לצד ברירת-המחדל מ-#204). מפיק `data/eval/effort-calibration-<ts>.{json,md}`. ⚠️ **גודל-מדגם מודפס בראש הדוח — עדות-כיוון, לא רגרסיה** (מעט סופיים-עלויים). `--self-test` (offline, אפס DB/CLI — מוכיח את לוגיקת-המדידה) · `--dry-run` (תכנון-גריד) · `--efforts`/`--blocks`/`--case`/`--repeats`. **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצה: `POSTGRES_PASSWORD=… mcp-server/.venv/bin/python scripts/calibrate_effort.py`. | ידני — לכיול ברירות-effort של הכותב |
| `monitor_halacha_quality.py` | python | מנטר איכות חילוץ הלכות. בודק drift של `avg(confidence)` בין baseline היסטורי לחלון אחרון. מחזיר JSON מטריקות + alert ב-stderr אם drift > threshold (ברירת מחדל 5%). 2 סדרות: trusted (approved+published) ו-all_extracted. תומך `--window N` / `--threshold X` / `--min-sample N` / `--silent` / `--exit-on-alert`. רץ ב-container או מקומית עם `mcp-server/.venv` (אין תלות ב-LLM, רק SQL). **תזמון מומלץ**: `0 8 * * 1` (יום ראשון 08:00, שבועי) | `0 8 * * 1` (לתזמן) | | `monitor_halacha_quality.py` | python | מנטר איכות חילוץ הלכות. בודק drift של `avg(confidence)` בין baseline היסטורי לחלון אחרון. מחזיר JSON מטריקות + alert ב-stderr אם drift > threshold (ברירת מחדל 5%). 2 סדרות: trusted (approved+published) ו-all_extracted. תומך `--window N` / `--threshold X` / `--min-sample N` / `--silent` / `--exit-on-alert`. רץ ב-container או מקומית עם `mcp-server/.venv` (אין תלות ב-LLM, רק SQL). **תזמון מומלץ**: `0 8 * * 1` (יום ראשון 08:00, שבועי) | `0 8 * * 1` (לתזמן) |
| `audit_training_corpus.py` | python | audit של `style_corpus` — לכל החלטה: שדות מטא-דאטה מאוכלסים (`summary`/`outcome`/`key_principles`/`appeal_subtype`/`subject_categories`), קישור ל-`documents` (FK + chunks + embeddings). מפיק `data/audit/corpus-YYYY-MM-DD.json` + summary בקונסול. דרוש `POSTGRES_URL` או POSTGRES_*. אין תלויות חיצוניות מלבד asyncpg. **רץ מהמכונה המקומית** (לא קונטיינר) — חיבור ישיר ל-Postgres :5433 | ידני / קדם-עבודה לפני enrichment של מטא-דאטה | | `audit_training_corpus.py` | python | audit של `style_corpus` — לכל החלטה: שדות מטא-דאטה מאוכלסים (`summary`/`outcome`/`key_principles`/`appeal_subtype`/`subject_categories`), קישור ל-`documents` (FK + chunks + embeddings). מפיק `data/audit/corpus-YYYY-MM-DD.json` + summary בקונסול. דרוש `POSTGRES_URL` או POSTGRES_*. אין תלויות חיצוניות מלבד asyncpg. **רץ מהמכונה המקומית** (לא קונטיינר) — חיבור ישיר ל-Postgres :5433 | ידני / קדם-עבודה לפני enrichment של מטא-דאטה |
| `backfill_style_exemplars.py` | python | **T1 (style-acquisition)** — מאכלס `style_exemplars` מקורפוס דפנה (`style_corpus` + `internal_committee` chair=דפנה): מפצל לסעיפים (`chunker._split_into_sections`) → פסקאות (25-450 מילים) → embed (Voyage) → שמירה עם `section`/`outcome`/`practice_area`. מאפשר לכותב לאחזר פסקאות-בלוק אמיתיות של דפנה (T2/T3). מקור-סגנון בלבד (INV-LRN5). אידמפוטנטי (מנקה per-decision). `--dry-run` (default) / `--apply`. דורש POSTGRES_URL + Voyage. **רץ מקומית** (venv). | ידני (`python scripts/backfill_style_exemplars.py --apply`) | | `backfill_style_exemplars.py` | python | **T1 (style-acquisition)** — מאכלס `style_exemplars` מקורפוס דפנה (`style_corpus` + `internal_committee` chair=דפנה): מפצל לסעיפים (`chunker._split_into_sections`) → פסקאות (25-450 מילים) → embed (Voyage) → שמירה עם `section`/`outcome`/`practice_area`. מאפשר לכותב לאחזר פסקאות-בלוק אמיתיות של דפנה (T2/T3). מקור-סגנון בלבד (INV-LRN5). אידמפוטנטי (מנקה per-decision). `--dry-run` (default) / `--apply`. דורש POSTGRES_URL + Voyage. **רץ מקומית** (venv). | ידני (`python scripts/backfill_style_exemplars.py --apply`) |

419
scripts/calibrate_effort.py Normal file
View File

@@ -0,0 +1,419 @@
#!/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: for each block, the effort with the lowest MEAN composite distance
across cases (ties → fewer anti-patterns → lower change_percent). 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 os
import sys
from datetime import datetime, timezone
from pathlib import Path
from statistics import mean
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 out of
# WS5's interim scope but mappable, so it's included when present.
CALIBRATABLE_BLOCKS = ["block-he", "block-vav", "block-zayin", "block-chet", "block-tet", "block-yod", "block-yod-alef"]
DEFAULT_EFFORTS = ["low", "medium", "high", "xhigh"]
VALID_EFFORTS = {"low", "medium", "high", "xhigh", "max"}
# ── pure helpers (offline-testable) ──────────────────────────────────────────
def recommend_effort(cells: list[dict]) -> dict | None:
"""Pick the best effort for ONE block from its scored cells.
cells: [{"effort","distance","anti_pattern_total","change_percent","n"}].
Lowest mean composite distance wins; ties broken by fewer anti-patterns,
then lower change_percent. Pure → unit-tested in --self-test.
"""
if not cells:
return None
ranked = sorted(
cells,
key=lambda c: (c["distance"], c["anti_pattern_total"], c["change_percent"]),
)
return ranked[0]
def aggregate_cell(per_run: list[dict]) -> dict:
"""Mean each metric across repeated generations of the same (case, block, effort)."""
if not per_run:
return {"distance": 1.0, "anti_pattern_total": 0.0, "change_percent": 100.0,
"golden_ratio_deviation_pp": None, "n": 0}
ratios = [r["golden_ratio_deviation_pp"] for r in per_run if r.get("golden_ratio_deviation_pp") is not None]
return {
"distance": round(mean(r["distance"] for r in per_run), 4),
"anti_pattern_total": round(mean(r["anti_pattern_total"] for r in per_run), 2),
"change_percent": round(mean(r["change_percent"] for r in per_run), 2),
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
"n": len(per_run),
}
def _current_default(block_id: str) -> str | None:
from legal_mcp.services.block_writer import BLOCK_CONFIG, DEFAULT_EFFORT
cfg = BLOCK_CONFIG.get(block_id, {})
if cfg.get("model") != "ai":
return None
return cfg.get("effort", DEFAULT_EFFORT)
# ── 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: lowest distance wins; tie → fewer anti-patterns.
rec = recommend_effort([
{"effort": "low", "distance": 0.40, "anti_pattern_total": 5, "change_percent": 40, "n": 1},
{"effort": "high", "distance": 0.20, "anti_pattern_total": 2, "change_percent": 25, "n": 1},
{"effort": "xhigh", "distance": 0.20, "anti_pattern_total": 1, "change_percent": 22, "n": 1},
])
chk("recommend picks lowest distance", rec["distance"] == 0.20)
chk("recommend tie → fewer anti-patterns", rec["effort"] == "xhigh")
chk("recommend empty ⇒ None", recommend_effort([]) is None)
# 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)
print("ALL PASS" if ok else "*** FAILURES ***")
return 0 if ok else 1
# ── live A/B (host-only — needs DB + `claude` CLI) ───────────────────────────
async def _finals_for_calibration(case_filter: str | None) -> list[dict]:
"""draft_final_pairs whose final_text is populated (the held-out comparison set)."""
from legal_mcp.services import db
pairs = await db.list_draft_final_pairs(limit=500)
out: list[dict] = []
for p in pairs:
if case_filter and p.get("case_number") != case_filter:
continue
full = await db.get_draft_final_pair(p["id"])
if full and (full.get("final_text") or "").strip():
out.append(full)
return out
async def _score_cell(case_id, block_id: str, effort: str, final_section: str,
final_total_words: int, outcome: str, repeats: int) -> dict:
"""Generate `block_id` at `effort` `repeats` times; score each vs the final section."""
from legal_mcp.services import block_writer
from legal_mcp.services.style_distance import block_distance_to_final
runs: list[dict] = []
for _ in range(repeats):
res = await block_writer.write_block(case_id, block_id, effort_override=effort)
scored = block_distance_to_final(
block_id, res.get("content", ""), final_section, outcome,
section_target_total_words=final_total_words,
)
runs.append(scored)
agg = aggregate_cell(runs)
agg["effort"] = effort
agg["runs"] = runs
return agg
async def _run(args) -> dict:
from uuid import UUID
from legal_mcp.services import db
from legal_mcp.services.lessons import canonical_outcome
from legal_mcp.services.style_distance import split_final_by_section, _BLOCK_TO_SECTION
efforts = args.efforts
blocks = args.blocks
finals = await _finals_for_calibration(args.case)
cases_meta = []
for f in finals:
case = await db.get_case_by_number(f["case_number"]) if f.get("case_number") else None
if not case:
continue
decision = await db.get_decision_by_case(UUID(case["id"]))
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
sections = split_final_by_section(f.get("final_text", ""))
final_total_words = len((f.get("final_text", "") or "").split())
cases_meta.append({
"case_number": f["case_number"], "case_id": case["id"],
"outcome": outcome, "sections": sections, "final_total_words": final_total_words,
})
# grid plan: (block → cases that have its section)
plan: dict[str, list[dict]] = {}
for block_id in blocks:
section = _BLOCK_TO_SECTION.get(block_id)
plan[block_id] = [c for c in cases_meta if section and c["sections"].get(section)]
total_cells = sum(len(plan[b]) for b in blocks) * len(efforts) * args.repeats
grid_summary = {
"n_finals": len(cases_meta),
"finals": [c["case_number"] for c in cases_meta],
"blocks": blocks, "efforts": efforts, "repeats": args.repeats,
"total_generations": total_cells,
"per_block_n": {b: len(plan[b]) for b in blocks},
}
if args.dry_run:
return {"dry_run": True, "grid": grid_summary, "by_block": {}}
by_block: dict[str, dict] = {}
for block_id in blocks:
section = _BLOCK_TO_SECTION.get(block_id)
per_effort_runs: dict[str, list[dict]] = {e: [] for e in efforts}
per_case: list[dict] = []
for c in plan[block_id]:
final_section = c["sections"][section]
case_cells = []
for effort in efforts:
cell = await _score_cell(
UUID(c["case_id"]), block_id, effort, final_section,
c["final_total_words"], c["outcome"], args.repeats,
)
per_effort_runs[effort].append(cell)
case_cells.append({k: cell[k] for k in
("effort", "distance", "anti_pattern_total",
"change_percent", "golden_ratio_deviation_pp", "n")})
per_case.append({"case_number": c["case_number"], "cells": case_cells})
# mean across cases for each effort → one comparable row per effort
effort_rows = []
for effort in efforts:
rows = per_effort_runs[effort]
if not rows:
continue
ratios = [r["golden_ratio_deviation_pp"] for r in rows if r.get("golden_ratio_deviation_pp") is not None]
effort_rows.append({
"effort": effort,
"distance": round(mean(r["distance"] for r in rows), 4),
"anti_pattern_total": round(mean(r["anti_pattern_total"] for r in rows), 2),
"change_percent": round(mean(r["change_percent"] for r in rows), 2),
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
"n": len(rows),
})
rec = recommend_effort(effort_rows)
by_block[block_id] = {
"section": section,
"current_default": _current_default(block_id),
"recommended": rec["effort"] if rec else None,
"efforts": effort_rows,
"per_case": per_case,
}
return {"dry_run": False, "grid": grid_summary, "by_block": by_block}
def _ts() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
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"]
lines = [
f"# #208 — כיול model×effort מול הסופיים — {ts}\n",
f"> ⚠️ **גודל-מדגם: {n} סופיים** ({', '.join(g['finals']) or ''}). "
"זוהי **עדות-כיוון, לא רגרסיה** — מעט תיקים בעלי סופי-עלוי. "
"ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n",
f"- בלוקים: {', '.join(g['blocks'])}",
f"- efforts: {', '.join(g['efforts'])} · repeats/cell: {g['repeats']}",
f"- סך ייצורי-מודל: {g['total_generations']}",
"",
]
if result.get("dry_run"):
lines += ["## DRY-RUN — תכנון הגריד בלבד (ללא ייצור)\n",
"| block | #cases | current default |", "|---|---|---|"]
for b in g["blocks"]:
lines.append(f"| {b} | {g['per_block_n'].get(b,0)} | {_current_default(b) or ''} |")
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
return jp, mp
lines += ["## המלצה per-בלוק (distance נמוך = קרוב יותר לדפנה)\n",
"| block | section | current | **recommended** | n |", "|---|---|---|---|---|"]
for b, bd in result["by_block"].items():
rec = bd.get("recommended") or ""
mark = "" if rec == bd.get("current_default") else " ⬅︎"
n_b = bd["efforts"][0]["n"] if bd.get("efforts") else 0
lines.append(f"| {b} | {bd.get('section','')} | {bd.get('current_default') or ''} | **{rec}**{mark} | {n_b} |")
lines.append("")
for b, bd in result["by_block"].items():
lines += [f"### {b} ({bd.get('section','')})\n",
"| effort | distance | anti_total | change% | ratioΔpp | n |",
"|---|---|---|---|---|---|"]
for r in bd.get("efforts", []):
star = "" if r["effort"] == bd.get("recommended") else ""
ratio = r["golden_ratio_deviation_pp"]
lines.append(
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
f"{r['change_percent']} | {ratio if ratio is not None else ''} | {r['n']} |")
lines.append("")
lines.append("> change% מערבב סגנון עם שלמות-תוכן (07-learning §0.7); "
"anti_total הוא הסיגנל הנקי-יותר לסגנון.\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("--dry-run", action="store_true", help="plan the A/B grid over existing finals, no model calls")
ap.add_argument("--efforts", default=",".join(DEFAULT_EFFORTS),
help=f"comma effort grid (default {','.join(DEFAULT_EFFORTS)})")
ap.add_argument("--blocks", default=",".join(CALIBRATABLE_BLOCKS),
help="comma block ids to calibrate")
ap.add_argument("--case", default=None, help="restrict to a single case_number")
ap.add_argument("--repeats", type=int, default=1, help="generations per cell (avg out gen noise)")
args = ap.parse_args()
if args.self_test:
return _self_test()
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 CALIBRATABLE_BLOCKS]
if bad_b:
print(f"non-calibratable block(s): {bad_b}. valid: {CALIBRATABLE_BLOCKS}", file=sys.stderr)
return 2
result = await _run(args)
ts = _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 ''}")
print(f" report: {mp}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

@@ -49,9 +49,9 @@ function BlockRow({ block }: { block: McpBlock }) {
<Badge variant="outline" className="text-[0.7rem] font-mono" dir="ltr"> <Badge variant="outline" className="text-[0.7rem] font-mono" dir="ltr">
{block.model} {block.model}
</Badge> </Badge>
{isLLM && block.temperature !== null && ( {isLLM && block.effort !== null && (
<Badge variant="outline" className="text-[0.7rem]"> <Badge variant="outline" className="text-[0.7rem]" dir="ltr">
temp&nbsp;<span className="tabular-nums">{block.temperature}</span> effort&nbsp;<span className="tabular-nums">{block.effort}</span>
</Badge> </Badge>
)} )}
{block.max_tokens !== null && ( {block.max_tokens !== null && (

View File

@@ -176,7 +176,9 @@ export type McpBlock = {
title: string; title: string;
gen_type: string; gen_type: string;
model: string; model: string;
temperature: number | null; // The live generation knob (#204). Replaces the deprecated `temperature` —
// Opus 4.7/4.8 have no temperature param. null for template ("script") blocks.
effort: string | null;
max_tokens: number | null; max_tokens: number | null;
creac_role: string | null; creac_role: string | null;
jwm_purpose: string | null; jwm_purpose: string | null;

View File

@@ -3063,6 +3063,58 @@ async def api_research_analysis_export_docx(case_number: str):
) )
# ── Party-claims executive summary (party-claims-summary.md) — #202 ─
# A distilled prose document of the parties' claims, SEPARATE from the interim
# draft, to prepare the chair for the hearing. Generation is local-only (the
# summarize_party_claims MCP tool via claude_session); these endpoints only
# SERVE the saved file — they never generate (the container has no claude CLI).
@app.get("/api/cases/{case_number}/research/party-claims-summary")
async def api_party_claims_summary(case_number: str):
"""Return the raw markdown of the party-claims executive summary."""
from legal_mcp.services import party_claims_summary as pcs
path = pcs.summary_file_path(case_number)
if not path.exists():
raise HTTPException(404, "טרם הופק סיכום-מנהלים לטענות הצדדים לתיק זה")
return {"markdown": path.read_text(encoding="utf-8")}
@app.get("/api/cases/{case_number}/research/party-claims-summary/download")
async def api_party_claims_summary_download(case_number: str):
"""Download the raw party-claims-summary.md file."""
from legal_mcp.services import party_claims_summary as pcs
path = pcs.summary_file_path(case_number)
if not path.exists():
raise HTTPException(404, "טרם הופק סיכום-מנהלים לטענות הצדדים לתיק זה")
return await serve_blob(
path,
media_type="text/markdown; charset=utf-8",
filename=f"party-claims-summary-{case_number}.md",
)
@app.get("/api/cases/{case_number}/research/party-claims-summary/export-docx")
async def api_party_claims_summary_export_docx(case_number: str):
"""Export the party-claims summary as a DOCX using דפנה's template styles."""
from legal_mcp.services.analysis_docx_exporter import build_party_claims_summary_docx
try:
path = await build_party_claims_summary_docx(case_number)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except Exception as e:
logger.exception("Failed to export party-claims summary DOCX for %s", case_number)
raise HTTPException(500, f"שגיאה בייצוא: {e}")
case_dir = config.find_case_dir(case_number)
if case_dir.exists():
commit_and_push(case_dir, f"סיכום-מנהלים: {path.name}")
return await serve_blob(
path,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
filename=path.name,
)
@app.put("/api/cases/{case_number}/research/analysis/upload") @app.put("/api/cases/{case_number}/research/analysis/upload")
async def api_research_analysis_upload( async def api_research_analysis_upload(
case_number: str, case_number: str,
@@ -4626,15 +4678,21 @@ async def api_mcp_blocks():
"block-yod-alef": "Disposition", "block-yod-alef": "Disposition",
"block-yod-bet": "Disposition (signatures)", "block-yod-bet": "Disposition (signatures)",
} }
from legal_mcp.services.block_writer import GENERATION_MODEL
blocks = [] blocks = []
for block_id, cfg in sorted(BLOCK_CONFIG.items(), key=lambda kv: kv[1]["index"]): for block_id, cfg in sorted(BLOCK_CONFIG.items(), key=lambda kv: kv[1]["index"]):
is_ai = cfg["model"] == "ai"
blocks.append({ blocks.append({
"id": block_id, "id": block_id,
"index": cfg["index"], "index": cfg["index"],
"title": cfg["title"], "title": cfg["title"],
"gen_type": cfg["gen_type"], "gen_type": cfg["gen_type"],
"model": cfg["model"], # AI blocks are pinned to one model (#204); template blocks = "script".
"temperature": cfg.get("temp"), "model": GENERATION_MODEL if is_ai else cfg["model"],
# The live generation knob (replaces the deprecated `temperature`,
# which is not a real param on Opus 4.7/4.8). None for template blocks.
"effort": cfg.get("effort") if is_ai else None,
"max_tokens": cfg.get("max_tokens"), "max_tokens": cfg.get("max_tokens"),
"creac_role": CREAC_ROLE.get(block_id), "creac_role": CREAC_ROLE.get(block_id),
"jwm_purpose": JWM_PURPOSE.get(block_id), "jwm_purpose": JWM_PURPOSE.get(block_id),