diff --git a/docs/block-schema.md b/docs/block-schema.md index a94be18..1837995 100644 --- a/docs/block-schema.md +++ b/docs/block-schema.md @@ -480,11 +480,38 @@ Conclusion → Rule → Explanation → Application → Conclusion. ## 3. כללי גזירת פרמטרים -פרמטרי העיבוד נגזרים ממאפייני התוכן, לא נקבעים שרירותית: +פרמטרי העיבוד נגזרים ממאפייני התוכן, לא נקבעים שרירותית. -### Temperature — נגזר מסוג הייצור +> **⚠️ עדכון מנגנון-הייצור (#204 / WS5).** מסלול-הייצור הוא `claude_session.query` → +> `claude -p` (מנוי claude.ai, עלות-שולית-אפס, מקומי-בלבד — ראה +> `reference_claude_generation_path`). ב-**Opus 4.7/4.8 הפרמטר `temperature` (וגם +> `top_p`/`top_k`) הוסר — שליחתו מחזירה HTTP 400**. הידית היחידה היא **`effort`** +> (low/medium/high/xhigh/max; ברירת-מחדל high). **המודל נעוץ ל-`claude-opus-4-8` +> לכל הבלוקים** ב-`block_writer.GENERATION_MODEL` — אין עוד פיצול sonnet/opus. +> טבלת ה-Temperature והעמודה `decision_blocks.temperature` הן **מטא-דאטה-מת +> (deprecated, תמיד 0)** ונשמרות רק לתאימות-עמודה; אל תקראו מהן. הטבלאות למטה +> נשמרות כתיעוד-המקור-הקונספטואלי בלבד — `effort` מחליף את "Thinking budget". -| Generation type | Temperature | נימוק | +### Effort — נגזר ממורכבות קוגניטיבית (הידית החיה) + +מיפוי ה-`effort` בפועל ב-`block_writer.BLOCK_CONFIG` (#204): + +| בלוק | gen_type | effort | נימוק | +|------|----------|--------|-------| +| ה (פתיחה) | paraphrase | medium | פתיחה קצרה, מבנה קבוע | +| ו (רקע) | reproduction | medium | שעתוק ניטרלי מרובה-מקורות | +| ז (טענות) | paraphrase | high | קיבוץ/סינתזת-טענות לפי-נושא | +| ח (הליכים) | reproduction | medium | תיעוד כרונולוגי | +| ט (תכניות) | guided-synthesis | high | ציטוט-תכניות מדויק (INV-AH) | +| י (דיון) | rhetorical-construction | xhigh | חשיבה רב-שלבית CREAC (מחוץ-לתחום-WS5) | +| יא (סיכום) | paraphrase | high | סינתזה אופרטיבית | +| א–ד, יב | template-fill | — (script) | מילוי-שדות, ללא LLM | + +### Temperature — (DEPRECATED) נגזר היסטורית מסוג הייצור + +> מסמך-מקור בלבד — לא נשלח ל-API (ראה האזהרה למעלה). + +| Generation type | Temperature (היסטורי) | נימוק | |----------------|-------------|-------| | template-fill | 0 | אין צורך בשפה — מילוי שדות | | reproduction | 0 | נאמנות מוחלטת למקור. אפס יצירתיות | @@ -493,18 +520,11 @@ Conclusion → Rule → Explanation → Application → Conclusion. | analytical-reasoning | 0.3-0.4 | צריך ליצור קשרים בין עקרונות משפטיים | | rhetorical-construction | 0.4-0.5 | טווח ביטוי רחב לכתיבה משכנעת ואלגנטית | -### Thinking budget — נגזר ממורכבות קוגניטיבית +### Model — (DEPRECATED) נגזר היסטורית מדרישת דיוק -| Cognitive task | Budget | נימוק | -|---------------|--------|-------| -| template-fill / lookup | off | אין צורך בחשיבה | -| sequential-extraction | low | חילוץ מידע חד-שלבי | -| multi-source-integration | medium | צריך להצליב מקורות | -| legal-analysis-with-CREAC | max (16K+) | חשיבה רב-שלבית: מסקנה → כלל → הסבר → יישום | +> כעת נעוץ `claude-opus-4-8` לכל בלוקי-ה-AI (`GENERATION_MODEL`). הטבלה היסטורית. -### Model — נגזר מדרישת דיוק - -| Accuracy profile | Model | נימוק | +| Accuracy profile | Model (היסטורי) | נימוק | |-----------------|-------|-------| | factual-precision | sonnet | מהיר, מדויק לחילוץ עובדות | | precision + interpretation | opus | נדרש לפרשנות תכנית / ציטוט מובנה | diff --git a/docs/spec/02-data-model.md b/docs/spec/02-data-model.md index 988887f..195a72c 100644 --- a/docs/spec/02-data-model.md +++ b/docs/spec/02-data-model.md @@ -19,7 +19,7 @@ | ישות | תפקיד | מזהה-קנוני | שדות-מפתח (מאומתים `db.py`) | |------|--------|-------------|------------------------------| | `cases` | תיק ערר חי (1xxx/8xxx/9xxx) | `case_number` + `proceeding_type` | `title`, `status`, `practice_area`, `appeal_subtype`, `proceeding_type`, `chair_name` (`db.py:74-91,182-189,747,912`) | -| `documents` | מסמך-מקור משויך לתיק | `id` (UUID); FK→`cases` | `doc_type`, `title`, `file_path`, `extracted_text`, `extraction_status`, `page_count` (`db.py:93-104`) | +| `documents` | מסמך-מקור משויך לתיק | `id` (UUID); FK→`cases` | `doc_type`, `is_primary` (**נגזר** מ-`doc_type`, ראה [§2ג](#2ג-מסמך-עיקרי-primary-document)), `title`, `file_path`, `extracted_text`, `extraction_status`, `page_count` (`db.py:93-104`) | | `document_chunks` | chunk של מסמך-תיק + embedding | `id`; FK→`documents`/`cases` | `chunk_index`, `content`, `section_type`, `embedding vector(1024)`, `page_number` (`db.py:106-116`) | | `case_law` | קורפוס פסיקה — חיצוני **וגם** החלטות-ועדה | ראה [§2 + INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות) | `case_name`, `court`, `practice_area`, `source_kind`, `proceeding_type`, `source_type`, `headnote`, `summary`, `subject_tags`, `extraction_status`, `halacha_extraction_status` (`db.py:366-378,522-526,599-611,883,907`) | | `precedent_chunks` | chunk של פסק-דין מואנדקס (`source_kind='external_upload'`/`internal_committee`) | `id`; FK→`case_law` | `chunk_index`, `content`, `section_type`, `page_number`, `embedding vector(1024)`, `content_tsv` (`db.py:624-634,776`) | @@ -80,7 +80,25 @@ proceeding_type)`. לכן המזהה הקנוני הוא **(`case_number` מנו - `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק. - `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`. -### 2ג. ישויות-נגזרות (אחסון-ניתוחים) +### 2ג. מסמך-עיקרי (primary document) + +WS2 (#200) מבחין בין **מסמך-עיקרי** (מסמך-מהות שהיו"ר עוקבת אחריו לניתוח) לבין מסמך-משני. +**הרשימה הקנונית של doc_types עיקריים** (מאושרת-יו"ר): `appeal` (כתב-ערר) · `response` +(תשובה/תגובה) · `objection` (התנגדות) · `protocol` (פרוטוקול-דיון) · `appraisal` (שומה) · +`decision` (החלטת-ועדה). **כל doc_type אחר משני** (`plan`/`permit`/`court_decision`/ +`exhibit`/`reference`). + +**`is_primary` נגזר, לא נכתב** ([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)/[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)/ +[INV-DM7](#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד)): מקור-האמת היחיד הוא +`doc_type` + הרשימה הקנונית `PRIMARY_DOC_TYPES` (`db.py`). העמודה +`documents.is_primary BOOLEAN GENERATED ALWAYS AS (doc_type = ANY(PRIMARY_DOC_TYPES)) STORED` +(SCHEMA_V47) מחושבת ע"י Postgres — **אין מסלול-כתיבה מקביל ולא יכולה לסטות** מ-`doc_type`, +בדיוק כמו ה-tsvectors ב-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). `_row_to_doc` גוזר גם בקריאה +(לרשומות טרום-מיגרציה) ומוסיף `doc_category ∈ {primary, secondary}` לפלט. נחשף דרך +`document_list` / `case_get` / API-המסמכים. אינדקס חלקי `idx_documents_primary(case_id) WHERE +is_primary` משרת את שאילתת "מסמכים-עיקריים שטרם-נותחו" (#201). + +### 2ד. ישויות-נגזרות (אחסון-ניתוחים) מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי ה-provenance של [X8](X8-field-provenance.md) ולשערי [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant): diff --git a/docs/spec/04-analysis-writing.md b/docs/spec/04-analysis-writing.md index af66b0f..21b405a 100644 --- a/docs/spec/04-analysis-writing.md +++ b/docs/spec/04-analysis-writing.md @@ -52,11 +52,61 @@ > חילוץ, **לא** על מסלול-ניתוח שני: (א) **ניתוח-מחדש מאחד** (WS2, #200/#201) — מחלץ מ-מסמכים > חדשים/מעודכנים בלבד ומאחד עם הקיים (לא מוחק-ומחשב-מחדש), על-בסיס דגל-הניתוח per-מסמך > ([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, -> `analyze_protocol`) — `protocol` נכנס לחילוץ ההשוואתי (ירד/חוזק/חדש) ומזין ידע-תיק, ונשאר +> `analyze_protocol` — ראה §1.3) — `protocol` נכנס לחילוץ ההשוואתי (ירד/חוזק/חדש) ומזין ידע-תיק, ונשאר > פוסט-דיון ([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.1–1.2, **ללא חילוץ-מחדש** ובלי לקרוא לכתבי-הטענות + ישירות. אין מסלול-נתונים מקביל. +- **זיקוק, לא שכפול:** התמצית מתמצתת כל צד למשפטי-מפתח ומוסיפה פרק "נקודות-המחלוקת המרכזיות" + כשאלות פתוחות — לא משכפלת את כתבי-הטענות ולא מכריעה. +- **עיגון-מקור (INV-AH):** הפרומפט מתוחם לחלוטין לטענות-התיק שבקלט; אסור להמציא טענה/הלכה/ + פסק-דין/עובדה שאינם בקלט — טענה לא-ברורה מצוינת במפורש ([anti-hallucination-gate](../anti-hallucination-gate.md)). +- **ייצור local-only:** עובר `claude_session` → `claude -p` נעוץ ל-Opus 4.8 + `effort=high` + (משימת זיקוק/סינתזה). הקונטיינר חסר ה-CLI — לכן הייצור הוא כלי-MCP מקומי בלבד; endpoints + ב-`web/app.py` רק **מגישים/מייצאים** את הקובץ השמור, לא מייצרים. +- **שמירה + ייצוא:** נשמר ל-`data/cases/{n}/documents/research/party-claims-summary.md` + (git + S3, באותו מסלול-אחסון של `analysis-and-research.md`); ניתן-לייצוא ל-DOCX בסגנון-תבנית + דפנה (`build_party_claims_summary_docx`). מימוש: `tools/drafting.py` · + `services/party_claims_summary.py` · `services/analysis_docx_exporter.py`. --- diff --git a/docs/spec/06-export.md b/docs/spec/06-export.md index bb5c1e2..c5db4c6 100644 --- a/docs/spec/06-export.md +++ b/docs/spec/06-export.md @@ -43,6 +43,16 @@ > וגם **טיוטת-ביניים** (`mode="interim"` — תת-קבוצת בלוקים בסדר חדש: רקע→תכניות→טענות→הליכים, > `export_interim_draft`, `drafting.py:511`). שני המצבים שולפים מאותה טבלה — וריאציית-תצוגה > של אותו מקור-אמת, לא מסלול שני. +> +> **דטרמיניזם-מבני של טיוטת-הביניים (#204 / WS5).** סדר-בלוקי-הביניים בייצוא קבוע +> ב-`docx_exporter._INTERIM_BLOCK_ORDER` (רקע→תכניות→טענות→הליכים, עם בלוקי-תבנית א–ה +> אופציונליים בתחילה). הבלוקים עצמם נכתבים דרך **אותו מסלול-כתיבה קנוני** של ההחלטה +> המלאה (`block_writer.write_and_store_block` → `write_block`, G2 — אין מסלול-ייצור +> מקביל), אך כעת **נעוצים למודל יחיד (`claude-opus-4-8`) עם `effort` per-בלוק ובפרומפט- +> מבני קבוע** (למשל בלוק-ה פותח תמיד ב"לפנינו ערר…"). התוצאה: אותו תיק מפיק את אותו +> **מבנה** בכל ריצה (אי-עקביות-הניסוח הקודמת נבעה מפרומפט-חופשי + מודל-ברירת-מחדל לא-נעוץ). +> טמפרטורה אינה במסלול (Opus 4.7/4.8 דוחים אותה); ראה `block_writer.GENERATION_MODEL` +> + `BLOCK_CONFIG[*].effort`. > - `analysis_docx_exporter.py` (`build_analysis_docx`, `:401`) — מייצא את מסמך **הניתוח > המשפטי** (`analysis-and-research.md`) שכתב `legal-analyst`, לא את בלוקי-ההחלטה. זהו תוצר-עזר > שונה (שלב ניתוח, לא החלטה) — והוא המסלול שהסקיל מתעד בעיקר. שניהם חולקים את **אותו טמפלט diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index 00f360f..f29f6da 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -744,6 +744,27 @@ async def get_appraiser_facts(case_number: str) -> str: return await drafting.get_appraiser_facts(case_number) +# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ──── + +@mcp.tool() +async def analyze_protocol(case_number: str) -> 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) — מרשם-התכניות ───────────────── # SSOT לזהות+תוקף של תכנית, נעשה שימוש חוזר בין תיקים (G2). פלט-LLM נכנס # 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) +@mcp.tool() +async def summarize_party_claims(case_number: str, instructions: str = "") -> str: + """סיכום-מנהלים מזוקק של טענות הצדדים — מסמך פרוזה נפרד מטיוטת-ההחלטה, להכנת היו"ר לדיון. נגזר מ-legal_arguments/claims (אותו מקור), נעוץ Opus 4.8, נשמר וניתן-לייצוא.""" + return await drafting.summarize_party_claims(case_number, instructions) + + @mcp.tool() async def apply_user_edit(case_number: str, edit_filename: str) -> str: """רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר.""" diff --git a/mcp-server/src/legal_mcp/services/analysis_docx_exporter.py b/mcp-server/src/legal_mcp/services/analysis_docx_exporter.py index c0b92cc..987e4d5 100644 --- a/mcp-server/src/legal_mcp/services/analysis_docx_exporter.py +++ b/mcp-server/src/legal_mcp/services/analysis_docx_exporter.py @@ -182,16 +182,21 @@ def _add_runs_with_inline_bold(paragraph: Paragraph, text: str) -> None: def _clear_body(doc: DocumentT) -> None: - """Remove every paragraph currently in the document body. + """Remove ALL body content (paragraphs, tables, stray bookmarks), + keeping only sectPr. - The template ships with example paragraphs ("רקע", "דיון והכרעה"…) - that we don't want in the output. Section properties (sectPr) are - kept so page size / margins / RTL / footer remain intact. + The template ships with example paragraphs ("רקע", "דיון והכרעה"…) *and + three sample tables* (header / panel / signatures). Removing only ``w:p`` + left the tables behind — including the signatures table, which then floated + to the top against the header table. Mirror the decision exporter's + ``_clear_body`` (G2 symmetry): drop everything except sectPr, which carries + page setup including bidi. """ body = doc.element.body - for p in list(body.findall(qn("w:p"))): - body.remove(p) - # Leave sectPr alone — it carries page setup including bidi. + sectPr_tag = qn("w:sectPr") + for child in list(body): + if child.tag != sectPr_tag: + body.remove(child) def _add_paragraph(doc: DocumentT, text: str, style: str) -> Paragraph: @@ -386,9 +391,9 @@ def _group_precedents( return case_level, by_section -def _next_version(export_dir: Path) -> int: - """Return the next version number for ניתוח-משפטי-v{N}.docx.""" - existing = sorted(export_dir.glob("ניתוח-משפטי-v*.docx")) +def _next_version(export_dir: Path, prefix: str = "ניתוח-משפטי") -> int: + """Return the next version number for {prefix}-v{N}.docx in export_dir.""" + existing = sorted(export_dir.glob(f"{prefix}-v*.docx")) next_ver = 1 for p in existing: try: @@ -511,3 +516,77 @@ async def build_analysis_docx(case_number: str) -> Path: content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", ) return out_path + + +# ── Generic markdown → DOCX (party-claims summary, #202) ─────────── + +# ATX-heading markers → template heading style. The party-claims summary is a +# plain markdown document (not the structured analysis-and-research.md), so it +# is rendered line-by-line through the same template machinery. +_ATX_HEADING_RE = re.compile(r"^(#{1,4})\s+(.+)$") +_HR_RE = re.compile(r"^\s*[-*_]{3,}\s*$") + + +def _render_markdown_body(doc: DocumentT, markdown: str) -> None: + """Render a markdown string into the doc using the template styles. + + Handles ATX headings (# .. ####), horizontal rules (skipped), and delegates + every other line to _emit_content_line (bullets, numbered, bold-labels, plain). + """ + for raw in markdown.splitlines(): + line = raw.rstrip() + if not line.strip(): + continue + if _HR_RE.match(line): + continue + m = _ATX_HEADING_RE.match(line.strip()) + if m: + level = len(m.group(1)) + # # → Heading 1 (doc title), ## → Heading 1, ### / #### → Heading 2. + style = "Heading 1" if level <= 2 else "Heading 2" + _add_paragraph(doc, m.group(2).strip(), style) + continue + _emit_content_line(doc, line) + + +async def build_party_claims_summary_docx(case_number: str) -> Path: + """Build a DOCX of the party-claims executive summary using the template + styles, saved versioned under the case's exports folder. + + Reads the saved party-claims-summary.md (produced by the summarize_party_claims + MCP tool — generation is local-only). Raises FileNotFoundError if the summary + or the template is missing. + """ + from legal_mcp.services import party_claims_summary + + if not TEMPLATE_PATH.exists(): + raise FileNotFoundError( + f"Template not found at {TEMPLATE_PATH}. " + "Run: python scripts/convert_decision_template.py" + ) + + summary_path = party_claims_summary.summary_file_path(case_number) + if not summary_path.exists(): + raise FileNotFoundError( + f"Party-claims summary not found for case {case_number}. " + "Run summarize_party_claims first." + ) + + markdown = summary_path.read_text(encoding="utf-8") + + doc = Document(str(TEMPLATE_PATH)) + _clear_body(doc) + _render_markdown_body(doc, markdown) + + export_dir = config.find_case_dir(case_number) / "exports" + export_dir.mkdir(parents=True, exist_ok=True) + _PREFIX = "סיכום-מנהלים-טענות" + out_path = export_dir / f"{_PREFIX}-v{_next_version(export_dir, _PREFIX)}.docx" + buf = io.BytesIO() + doc.save(buf) + await storage.put_bytes( + out_path.relative_to(config.DATA_DIR).as_posix(), buf.getvalue(), + bucket=storage.Bucket.DOCUMENTS, + content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + return out_path diff --git a/mcp-server/src/legal_mcp/services/block_writer.py b/mcp-server/src/legal_mcp/services/block_writer.py index 672ff22..3799706 100644 --- a/mcp-server/src/legal_mcp/services/block_writer.py +++ b/mcp-server/src/legal_mcp/services/block_writer.py @@ -35,29 +35,53 @@ logger = logging.getLogger(__name__) # ── Block configuration ─────────────────────────────────────────── -# Output token limits per Anthropic docs: -# Opus 4.7: up to 128K output tokens (new tokenizer — ~35% more tokens) -# Sonnet 4.6: up to 64K output tokens -# Streaming required when max_tokens > 21,333 +# Generation is structurally deterministic (#204 / WS5): every AI block is +# pinned to ONE model (Opus 4.8) with a per-block reasoning `effort`. The +# generation path is claude_session.query → `claude -p` (subscription, near-zero +# cost; local-only — see reference_claude_generation_path / claude_session +# docstring). Opus 4.7/4.8 REMOVED temperature/top_p/top_k (sending them → HTTP +# 400); the only knob is `effort` (low/medium/high/xhigh/max; default high). +# +# DEPRECATED FIELDS — DO NOT REINTRODUCE: earlier revisions carried per-block +# `temp` (0/0.1/0.4) and `model` ("sonnet"/"opus"/"script"). Both were DEAD +# METADATA: write_block called claude_session.query(prompt, …) WITHOUT model/ +# temp/effort, so generation ran on the CLI's default model with no temperature +# knob at all (the table only stored a number in decision_blocks.temperature and +# fed MODEL_MAP→timeout). They are removed here so the table no longer misleads. +# The conceptual origin (gen_type → thinking-budget) now lives in `effort`; see +# docs/block-schema.md §3. +# +# `gen_type` is retained (documentation/UI metadata + audit `generation_type`). +# `model` below is the DISPATCH key only: "script" = template-fill (no LLM), +# "ai" = generated via the pinned Opus model. It is NOT a model alias anymore. +# +# Output token note (Anthropic): Opus 4.8 supports large outputs; streaming is +# handled by the CLI. `max_tokens` is advisory context for callers, not sent. +GENERATION_MODEL = "claude-opus-4-8" # single pinned model for every AI block (#204) + BLOCK_CONFIG = { - "block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "temp": 0, "model": "script"}, - "block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "temp": 0, "model": "script"}, - "block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "temp": 0, "model": "script"}, - "block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "temp": 0, "model": "script"}, - "block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "temp": 0.2, "model": "sonnet", "max_tokens": 4096}, - "block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 16384}, - "block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 16384}, - "block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 8192}, - "block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "temp": 0.2, "model": "opus", "max_tokens": 16384}, - "block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "temp": 0.4, "model": "opus", "max_tokens": 16384}, - "block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 8192}, - "block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "temp": 0, "model": "script"}, + "block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "model": "script"}, + "block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "model": "script"}, + "block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "model": "script"}, + "block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "model": "script"}, + "block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "model": "ai", "effort": "medium", "max_tokens": 4096}, + "block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 16384}, + "block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "model": "ai", "effort": "high", "max_tokens": 16384}, + "block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 8192}, + "block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "model": "ai", "effort": "high", "max_tokens": 16384}, + "block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "model": "ai", "effort": "xhigh", "max_tokens": 16384}, + "block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "model": "ai", "effort": "high", "max_tokens": 8192}, + "block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "model": "script"}, } -MODEL_MAP = { - "sonnet": "claude-sonnet-4-20250514", - "opus": "claude-opus-4-7", -} +# Default effort when a block lacks an explicit one (defensive; every AI block +# above sets one). High is the safe default per the generation-path reference. +DEFAULT_EFFORT = "high" + +# Blocks that take longer (deep reasoning) get the LONG timeout. The interim set +# [he, vav, tet, zayin, chet] and the discussion block all run on the same pinned +# Opus model, so timeout is driven by effort, not by a model split. +_LONG_EFFORTS = frozenset({"high", "xhigh", "max"}) # ── Template blocks (א-ד, יב) ──────────────────────────────────── @@ -120,11 +144,14 @@ TEMPLATE_WRITERS = { BLOCK_PROMPTS = { "block-he": """כתוב את בלוק הפתיחה (בלוק ה) של החלטת ועדת ערר. -## כללים: -- פתח ב"לפנינו ערר..." או "עניינה של החלטה זו..." -- הגדר "להלן" מרכזיים: הוועדה המקומית, התכנית/הבקשה, המגרש -- 1-2 סעיפים בלבד -- אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים +## מבנה קבוע (חובה — אותו מבנה בכל תיק): +- **המשפט הראשון פותח תמיד במילה "לפנינו"** — נוסח קבוע: "לפנינו ערר על החלטת + [הוועדה המקומית] מיום [תאריך] בעניין [נושא הבקשה/התכנית]." (אל תשתמש ב"עניינה של + החלטה זו" או בכל פתיח חלופי — הפתיח אחיד.) +- סעיף 1: הצגת הערר במשפט הקבוע לעיל + הגדרת ה"להלן" המרכזיים בסדר קבוע: + הוועדה המקומית → התכנית/הבקשה → המגרש/המקרקעין. +- סעיף 2 (רק אם נדרש להשלמת "להלן" נוספים): הגדרות-נוספות בלבד. +- **בדיוק 1-2 סעיפים.** אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים. - מספור: 1. ## פרטי התיק: @@ -328,6 +355,7 @@ async def write_block( case_id: UUID, block_id: str, instructions: str = "", + effort_override: str | None = None, ) -> dict: """כתיבת בלוק יחיד בהחלטה. @@ -335,6 +363,11 @@ async def write_block( case_id: מזהה התיק block_id: מזהה הבלוק (block-alef, block-he, block-yod, ...) instructions: הנחיות נוספות + effort_override: optional per-call reasoning effort (low/medium/high/ + xhigh/max). When set, overrides BLOCK_CONFIG[block_id].effort for + THIS call only — used by the #208 model/effort calibration harness + to A/B efforts without mutating the pinned defaults. Production + callers leave it None and get the deterministic per-block effort. Returns: dict עם content, word_count, block_id, generation_type @@ -441,20 +474,34 @@ async def write_block( f"Reduce documents or call extract_appraiser_facts first." ) - # Call Claude via Claude Code session (no API) - model_key = block_cfg["model"] - timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT - content = await claude_session.query(prompt, timeout=timeout, tools="") # prose gen — no tool_use → no error_max_turns + # Call Claude via Claude Code session (no API). #204: pin the model + per-block + # reasoning effort so generation is structurally deterministic — these were + # previously NOT forwarded (the source of inconsistency). model/effort flow + # through claude_session.query → `claude -p --model … --effort …`. + effort = effort_override or block_cfg.get("effort", DEFAULT_EFFORT) + timeout = claude_session.LONG_TIMEOUT if effort in _LONG_EFFORTS else claude_session.DEFAULT_TIMEOUT + content = await claude_session.query( + prompt, + timeout=timeout, + model=GENERATION_MODEL, + effort=effort, + tools="", # prose gen — no tool_use → no error_max_turns + ) sources = await _collect_block_sources(case_id, block_id) sources["case_law_ids"] = _precedent_case_law_ids result = _build_result(block_id, content, block_cfg) + # Record the EFFECTIVE effort (override wins) so the harness can attribute + # the measured distance to the effort that actually produced the text. + if result.get("effort") is not None: + result["effort"] = effort result["sources"] = sources return result def _build_result(block_id: str, content: str, block_cfg: dict) -> dict: word_count = len(content.split()) + is_ai = block_cfg["model"] == "ai" return { "block_id": block_id, "block_index": block_cfg["index"], @@ -462,8 +509,14 @@ def _build_result(block_id: str, content: str, block_cfg: dict) -> dict: "content": content, "word_count": word_count, "generation_type": block_cfg["gen_type"], - "model_used": block_cfg["model"], - "temperature": block_cfg["temp"], + # AI blocks record the pinned model; template blocks record "script". + "model_used": GENERATION_MODEL if is_ai else block_cfg["model"], + # The real generation knob (#204). None for template/script blocks. + "effort": block_cfg.get("effort", DEFAULT_EFFORT) if is_ai else None, + # DEPRECATED: temperature is not a real knob on Opus 4.7/4.8 (sending it + # → 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 "(לא חולצו טענות)" # Filter out claims from block-zayin (decision summary) — use only - # claims extracted from original pleadings (appeal, response, etc.) - source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] + # claims extracted from original pleadings (appeal, response, etc.). + # Also drop claim_type='protocol' (oral arguments raised at the hearing): + # block ז carries ORIGINAL written pleadings only (INV-WR4); hearing-raised + # arguments belong to block ח (proceedings) and to analyze_protocol's + # comparative case-knowledge, not to the parties'-claims summary. + source_claims = [ + c for c in claims + if c.get("source_document", "") != "block-zayin" + and c.get("claim_type", "claim") != "protocol" + ] if not source_claims: - # Fallback to all claims if no source claims exist - source_claims = claims + # Fallback to all non-block-zayin claims if no source claims exist. + source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims lines = [] current_role = "" diff --git a/mcp-server/src/legal_mcp/services/claims_extractor.py b/mcp-server/src/legal_mcp/services/claims_extractor.py index add1204..2959786 100644 --- a/mcp-server/src/legal_mcp/services/claims_extractor.py +++ b/mcp-server/src/legal_mcp/services/claims_extractor.py @@ -227,8 +227,15 @@ def _infer_claim_type(doc_type: str, source_name: str) -> str: - 'claim' = from appeal documents (כתב ערר) - 'response' = from original response documents (כתב תשובה) - 'reply' = from supplementary responses (תגובה, השלמת טיעון) + - 'protocol' = oral arguments raised at the hearing (פרוטוקול דיון) """ name_lower = source_name.lower() if source_name else "" + # A hearing protocol carries oral arguments — tagged distinctly so the + # comparative protocol analysis (analyze_protocol) and block-chet + # (proceedings) can tell them apart from the original written pleadings + # (INV-WR4: block ז stays original-pleadings-only). + if doc_type == "protocol" or "פרוטוקול" in name_lower: + return "protocol" if doc_type == "appeal" or "כתב ערר" in name_lower: return "claim" if "כתב תשובה" in name_lower: diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 3d4c3bc..ede2743 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -18,6 +18,43 @@ from legal_mcp.services import court_citation, halacha_quality, principles 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 _schema_ready: bool = False _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); """ +# 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 # 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_V45_SQL) await conn.execute(SCHEMA_V46_SQL) + await conn.execute(SCHEMA_V47_SQL) + await conn.execute(SCHEMA_V48_SQL) async def init_schema() -> None: @@ -2223,6 +2313,14 @@ def _row_to_doc(row: asyncpg.Record) -> dict: d["case_id"] = str(d["case_id"]) if isinstance(d.get("metadata"), str): 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 @@ -3967,6 +4065,83 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]: return conflicts +# ── Protocol comparative analysis (V48 / WS4 #203) ──────────────── + +async def replace_protocol_analysis( + case_id: UUID, + document_id: UUID, + rows: list[dict], +) -> int: + """Replace all protocol_analysis rows for a given protocol document (idempotent). + + Each row dict: change_type ('dropped'|'strengthened'|'newly_raised'), + party_role, argument_id (UUID|None), argument_title, summary, + sharpened_question, evidence_quote, page_number (optional). + + Idempotent on document_id (INV-TOOL3): re-running analyze_protocol replaces + the prior verdict for that protocol rather than appending duplicates. + """ + pool = await get_pool() + async with pool.acquire() as conn: + async with conn.transaction(): + await conn.execute( + "DELETE FROM protocol_analysis WHERE document_id = $1", document_id, + ) + for r in rows: + await conn.execute( + """INSERT INTO protocol_analysis + (case_id, document_id, party_role, change_type, argument_id, + argument_title, summary, sharpened_question, evidence_quote, page_number) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)""", + case_id, document_id, + r.get("party_role", "") or "", + r["change_type"], + r.get("argument_id"), + r.get("argument_title", "") or "", + r["summary"], + r.get("sharpened_question", "") or "", + r.get("evidence_quote", "") or "", + r.get("page_number"), + ) + return len(rows) + + +async def list_protocol_analysis( + case_id: UUID, + change_type: str | None = None, +) -> list[dict]: + """List protocol_analysis rows for a case, optionally filtered by change_type. + + The read side of the extract/get symmetry (INV-TOOL4) for analyze_protocol. + """ + pool = await get_pool() + async with pool.acquire() as conn: + if change_type: + rows = await conn.fetch( + """SELECT * FROM protocol_analysis + WHERE case_id = $1 AND change_type = $2 + ORDER BY change_type, party_role, created_at""", + case_id, change_type, + ) + else: + rows = await conn.fetch( + """SELECT * FROM protocol_analysis + WHERE case_id = $1 + ORDER BY change_type, party_role, created_at""", + case_id, + ) + results = [] + for r in rows: + d = dict(r) + d["id"] = str(d["id"]) + d["case_id"] = str(d["case_id"]) + d["document_id"] = str(d["document_id"]) + if d.get("argument_id") is not None: + d["argument_id"] = str(d["argument_id"]) + results.append(d) + return results + + # ── Plans registry (V38) ────────────────────────────────────────── # Canonical registry of planning schemes (תכניות). SSOT for a plan's identity + # validity, reused across cases (G2). See SCHEMA_V38_SQL for the data contract. diff --git a/mcp-server/src/legal_mcp/services/docx_exporter.py b/mcp-server/src/legal_mcp/services/docx_exporter.py index 48d6c94..368a5ea 100644 --- a/mcp-server/src/legal_mcp/services/docx_exporter.py +++ b/mcp-server/src/legal_mcp/services/docx_exporter.py @@ -192,14 +192,29 @@ def _apply_list_numbering(paragraph, num_id: int) -> None: def _clear_body(doc) -> None: - """Remove all paragraphs in the document body while keeping sectPr. + """Remove ALL body content (paragraphs, tables, stray bookmarks) while + keeping only sectPr. - The template ships with sample paragraphs we don't want. Section - properties (page size, margins, bidi) stay intact. + The template ships with sample paragraphs *and three sample tables* + (institutional header, panel, and a **signatures** table at the end — + "ניתנה פה אחד" + יו"ר/מזכירה). Removing only ``w:p`` left those tables + behind: the leftover header/panel tables injected block-א–ד data that was + never extracted from the protocol (violates the empty-unless-extracted + rule), and the leftover signatures table collapsed up against the header + table at the **top** of the document (the chair-reported bug: חתימות צמודות + לבלוק-ד במקום בסוף). The decision is a derived artifact rebuilt purely from + ``decision_blocks`` (INV-EX1) — signatures come from block-yod-bet, rendered + last in block order — so the template's sample tables are stale scaffolding + and must go. Section properties (page size, margins, bidi) stay intact. """ body = doc.element.body - for p in list(body.findall(qn("w:p"))): - body.remove(p) + sectPr_tag = qn("w:sectPr") + for child in list(body): + # Keep section properties (page setup / bidi). Drop everything else: + # sample paragraphs (w:p), sample tables (w:tbl), and any stray + # bookmark markers the template left dangling between them. + if child.tag != sectPr_tag: + body.remove(child) # ── Bookmark helpers ────────────────────────────────────────────── diff --git a/mcp-server/src/legal_mcp/services/party_claims_summary.py b/mcp-server/src/legal_mcp/services/party_claims_summary.py new file mode 100644 index 0000000..ca1838f --- /dev/null +++ b/mcp-server/src/legal_mcp/services/party_claims_summary.py @@ -0,0 +1,208 @@ +"""סיכום-מנהלים של טענות הצדדים — מסמך פרוזה מזוקק להכנת היו"ר לדיון (WS3, #202). + +מסמך זה הוא **נפרד ומובחן מטיוטת-הביניים** (החלטת-יו"ר, תוכנית workflow-redesign §WS3) — +תמצית מנהלים קצרה ומוקפדת של טענות הצדדים והתשובות זו-לזו, להכנה לדיון בעל-פה. אין הוא +משכפל את כתבי-הטענות המלאים: הוא מזקק. + +מקור-האמת היחיד (G2): הוא נגזר מ-``legal_arguments`` (טיעונים מאוגדים) ומ-``claims`` +הקיימים בתיק — לא מחלץ-מחדש ולא קורא לכתבי-הטענות הגולמיים. אם הטיעונים המאוגדים טרם +חושבו, הוא נופל-חזרה ל-claims הגולמיים (אותו מקור). + +הייצור עובר ``claude_session.query`` → ``claude -p`` (CLI, מנוי claude.ai, עלות-שולית-אפס), +נעוץ ל-Opus 4.8 + effort=high — משימת זיקוק/סינתזה. **local-only**: לקונטיינר אין claude CLI; +לכן הכלי רץ רק מ-MCP-server המקומי, וה-endpoint ב-app.py רק מגיש את הקובץ השמור (לא מייצר). + +ההזהרה נגד-הזיה (INV-AH): המסמך מתוחם לחלוטין לטענות-התיק עצמן. אסור להמציא טענה, הלכה +או עובדה שאינה מופיעה בקלט; טענה שאינה ברורה — מצוין במפורש, לא משלים. +""" + +from __future__ import annotations + +import logging +from datetime import date +from pathlib import Path +from uuid import UUID + +from legal_mcp import config +from legal_mcp.services import ( + argument_aggregator, + block_writer, + claude_session, + db, + git_sync, + storage, +) + +logger = logging.getLogger(__name__) + +# Saved alongside the legal analysis (same research folder, same git+S3 mirror path). +_SUMMARY_FILENAME = "party-claims-summary.md" + +_PARTY_HE = { + "appellant": "העוררים", + "respondent": "המשיבים", + "committee": "הוועדה המקומית", + "permit_applicant": "מבקשי ההיתר", + "unknown": "צד לא מזוהה", +} + + +def summary_file_path(case_number: str) -> Path: + """Resolve the saved party-claims-summary.md path for a case.""" + return config.find_case_dir(case_number) / "documents" / "research" / _SUMMARY_FILENAME + + +async def _build_arguments_context(case_id: UUID) -> tuple[str, int, str]: + """Build the grounded source block from the case's OWN claims/arguments. + + Preference order (single source of truth, no re-extraction — G2): + 1. ``legal_arguments`` — the aggregated, de-duplicated arguments per party. + 2. fallback to raw ``claims`` when arguments were never aggregated. + + Returns ``(context_text, item_count, source_kind)`` where ``source_kind`` is + ``"arguments"`` or ``"claims"`` (or ``"none"`` when neither exists). + """ + args = await argument_aggregator.get_legal_arguments(case_id) + if args: + by_party: dict[str, list[dict]] = {} + for a in args: + by_party.setdefault(a["party"], []).append(a) + + lines: list[str] = [] + for party, items in by_party.items(): + lines.append(f"\n## {_PARTY_HE.get(party, party)}") + for a in items: + title = (a.get("argument_title") or "").strip() + body = (a.get("argument_body") or "").strip() + topic = (a.get("legal_topic") or "").strip() + header = f"- **{title}**" if title else "-" + if topic: + header += f" [{topic}]" + lines.append(header) + if body: + lines.append(f" {body}") + return "\n".join(lines), len(args), "arguments" + + # Fallback — raw claims, the same underlying source. + claims = await db.get_claims(case_id) + # Exclude block-zayin (decision-summary) claims — original pleadings only. + source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims + if not source_claims: + return "", 0, "none" + + lines = [] + current_role = "" + role_he = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים", + "committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"} + n = 0 + for c in source_claims: + if c["party_role"] != current_role: + current_role = c["party_role"] + lines.append(f"\n## {role_he.get(current_role, current_role)}") + n += 1 + lines.append(f"- טענה #{n}: {c['claim_text']}") + return "\n".join(lines), len(source_claims), "claims" + + +_PROMPT_TEMPLATE = """אתה מכין עבור יו"ר ועדת הערר (עו"ד דפנה תמיר) **סיכום-מנהלים מזוקק של טענות הצדדים** — מסמך פרוזה קצר שמטרתו אחת: להכין את היו"ר לדיון בעל-פה. זהו מסמך **נפרד** מטיוטת-ההחלטה ואינו חלק ממנה. + +## פרטי התיק: +{case_context} + +## הקלט — טענות/טיעוני הצדדים (מקור-האמת היחיד): +{arguments_context} + +## כללי-כתיבה מחייבים: +- **זיקוק, לא שכפול.** אל תעתיק את כתבי-הטענות. תמצת כל צד ל-2–5 משפטי-מפתח. היעד: עמוד אחד עד שניים, לא יותר. +- **מבוסס-קלט בלבד (חובה אנטי-הזיה).** הסתמך אך-ורק על הטענות שבקלט לעיל. אסור להמציא טענה, הלכה, פסק-דין, מספר או עובדה שאינם בקלט. טענה שאינה ברורה או חסרה — ציין זאת במפורש ("הטענה לא פורטה"), אל תשלים מדמיונך. +- **ניטרלי.** הצג את עמדת כל צד בנאמנות, בלי להעריך, בלי להכריע ובלי לרמוז על תוצאה. אין זו החלטה — זו הכנה. +- **מבנה קבוע:** + 1. פסקת-פתיחה קצרה (משפט–שניים): במה עוסק הערר ומיהם הצדדים. + 2. **טענות העוררים** — תמצית מזוקקת. + 3. **טענות המשיבים / הוועדה / מבקשי ההיתר** — תמצית מזוקקת (כל צד שקיים בקלט). + 4. **נקודות-המחלוקת המרכזיות** — 2–4 הסוגיות שעליהן ניצבת ההכרעה, כשאלות פתוחות (לא תשובות). +- **סגנון:** עברית משפטית בהירה, גוף-שלישי לתיאור הצדדים. בלי כותרת "החלטה". בלי חתימות. + +## מדריך-סגנון (לטון בלבד — אל תיישם מבנה-החלטה): +{style_context} + +החזר אך-ורק את גוף מסמך הסיכום (Markdown), בלי הקדמות ובלי הסברים על מה שעשית.""" + + +async def summarize_party_claims(case_id: UUID, case_number: str, + instructions: str = "") -> dict: + """Generate the distilled executive summary of party claims and save it. + + Grounded strictly in the case's own claims/legal_arguments (INV-AH). + Generation is pinned to Opus 4.8 + effort=high (distillation/synthesis). + """ + case = await db.get_case(case_id) + if not case: + raise ValueError(f"Case {case_id} not found") + + decision = await db.get_decision_by_case(case_id) + + arguments_context, n_items, source_kind = await _build_arguments_context(case_id) + if source_kind == "none": + raise ValueError( + "אין טענות לסכם. הרץ extract_claims (ורצוי aggregate_claims_to_arguments) קודם." + ) + + case_context = block_writer._build_case_context(case, decision) + style_context = await block_writer._build_style_context(case.get("practice_area", "")) + + prompt = _PROMPT_TEMPLATE.format( + case_context=case_context, + arguments_context=arguments_context, + style_context=style_context, + ) + if instructions: + prompt += f"\n\n## הנחיות נוספות מהיו\"ר:\n{instructions}" + + # Generation: claude_session → claude -p, pinned Opus 4.8 + high effort. + # tools="" — pure prose, no tool_use (avoids error_max_turns). LONG_TIMEOUT: + # full-case context can be large. + content = await claude_session.query( + prompt, + timeout=claude_session.LONG_TIMEOUT, + model="claude-opus-4-8", + effort="high", + tools="", + ) + content = (content or "").strip() + if not content: + raise ValueError("הסיכום חזר ריק מ-claude -p.") + + today = date.today().strftime("%d.%m.%Y") + header = ( + f"# סיכום-מנהלים — טענות הצדדים\n\n" + f"**תיק:** {case['case_number']} \n" + f"**נושא:** {case.get('subject', '')} \n" + f"**הופק:** {today} · מסמך הכנה לדיון (נפרד מטיוטת-ההחלטה)\n\n" + f"---\n\n" + ) + document = header + content + "\n" + + # Save to data/cases/{n}/documents/research/ — same path as the legal analysis, + # so it is git-synced + S3-mirrored by the existing machinery. + out_path = summary_file_path(case_number) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(document, encoding="utf-8") # noqa: STG1 — mirrored below + try: + key = out_path.resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix() + await storage.mirror(key, document.encode("utf-8"), bucket=storage.Bucket.DOCUMENTS) + except ValueError: + pass + + case_dir = config.find_case_dir(case_number) + if case_dir.exists(): + git_sync.commit_and_push(case_dir, f"סיכום-מנהלים: טענות הצדדים ({case_number})") + + return { + "case_number": case_number, + "path": str(out_path), + "source_kind": source_kind, + "source_items": n_items, + "word_count": len(content.split()), + "message": f"סיכום-מנהלים נוצר ({len(content.split())} מילים, מקור: {source_kind}).", + } diff --git a/mcp-server/src/legal_mcp/services/protocol_analyzer.py b/mcp-server/src/legal_mcp/services/protocol_analyzer.py new file mode 100644 index 0000000..9ef2d11 --- /dev/null +++ b/mcp-server/src/legal_mcp/services/protocol_analyzer.py @@ -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, + } diff --git a/mcp-server/src/legal_mcp/services/style_distance.py b/mcp-server/src/legal_mcp/services/style_distance.py index 5718d85..843e661 100644 --- a/mcp-server/src/legal_mcp/services/style_distance.py +++ b/mcp-server/src/legal_mcp/services/style_distance.py @@ -15,6 +15,7 @@ import re from uuid import UUID from legal_mcp.services import db +from legal_mcp.services.learning_loop import compute_diff_stats from legal_mcp.services.lessons import ANTI_PATTERNS, GOLDEN_RATIOS, canonical_outcome logger = logging.getLogger(__name__) @@ -122,6 +123,93 @@ def golden_ratio_adherence(block_word_counts: dict[str, int], outcome: str) -> d return {"outcome": outcome, "total_words": total, "sections": sections, "max_deviation": max_dev} +def split_final_by_section(final_text: str) -> dict[str, str]: + """Group a signed final decision into golden-ratio sections (#208 calibration). + + Reuses the SAME structure-aware splitter as measure_corpus_ratios + (chunker._split_into_sections + _CHUNK_SECTION_TO_GOLDEN) — no parallel + parsing path (G2). Returns {golden_section: concatenated_text} for the + sections that map to an AI block (background/claims/discussion/summary). + A section type that does not map (e.g. headers) is dropped, never silently + folded into another section. Best-effort: an unsplittable final returns {}. + """ + from legal_mcp.services.chunker import _split_into_sections + + by_section: dict[str, list[str]] = {} + for stype, stext in _split_into_sections(final_text or ""): + g = _CHUNK_SECTION_TO_GOLDEN.get(stype) + if g and stext.strip(): + by_section.setdefault(g, []).append(stext.strip()) + return {sec: "\n\n".join(parts) for sec, parts in by_section.items()} + + +def block_distance_to_final( + block_id: str, + regenerated_text: str, + final_section_text: str, + outcome: str, + section_target_total_words: int | None = None, +) -> dict: + """Distance of ONE regenerated block from the chair's matching final section. + + The per-(block, effort) measurement cell for the #208 model/effort + calibration harness. Pure/deterministic (no LLM, no DB) — reuses the + existing style-distance primitives so the harness has no parallel metric + path (G2 / INV-G8 eval-harness): + + • change_percent — compute_diff_stats(regen, final_section) + (learning_loop, the SAME diff the pairing + ledger stores). Lower ⇒ the draft already + reads like the final ⇒ less chair rewriting. + • anti_pattern_total — count_anti_patterns(regen) (lessons.ANTI_PATTERNS). + Lower ⇒ closer to Dafna's continuous-narrative + voice; the CLEANEST style signal (07-learning §0.7). + • golden_ratio_deviation_pp — |regen %-of-total − final %-of-total| for this + block's section. 0 ⇒ same structural weight as + the final. Requires the final's total words + (section_target_total_words); otherwise None + (we never fabricate a denominator). + + Returns the three metrics + a single composite `distance` (normalized, + lower=closer) the harness ranks efforts by. + """ + outcome = canonical_outcome(outcome) + diff = compute_diff_stats(regenerated_text or "", final_section_text or "") + change_percent = diff["change_percent"] + anti_total = count_anti_patterns(regenerated_text or "")["total"] + + section = _BLOCK_TO_SECTION.get(block_id) + regen_words = len((regenerated_text or "").split()) + final_words = len((final_section_text or "").split()) + ratio_dev: float | None = None + if section and section_target_total_words and section_target_total_words > 0: + # Replace the final's own block contribution with the regen's, holding + # the rest of the final constant, to compare structural weight fairly. + regen_total = section_target_total_words - final_words + regen_words + if regen_total > 0: + regen_pct = regen_words / regen_total * 100 + final_pct = final_words / section_target_total_words * 100 + ratio_dev = round(abs(regen_pct - final_pct), 1) + + # Composite: normalize each component to ~[0,1] and average the present ones. + # change_percent/100, anti_total/10 (10+ hits is already very bad), ratio/20. + comps: list[float] = [min(change_percent / 100.0, 1.0), min(anti_total / 10.0, 1.0)] + if ratio_dev is not None: + comps.append(min(ratio_dev / 20.0, 1.0)) + distance = round(sum(comps) / len(comps), 4) + + return { + "block_id": block_id, + "section": section, + "regen_words": regen_words, + "final_words": final_words, + "change_percent": change_percent, + "anti_pattern_total": anti_total, + "golden_ratio_deviation_pp": ratio_dev, + "distance": distance, + } + + async def style_distance(case_number: str) -> dict: """Assemble the 3 style-distance components for one case (T7).""" case = await db.get_case_by_number(case_number) diff --git a/mcp-server/src/legal_mcp/tools/documents.py b/mcp-server/src/legal_mcp/tools/documents.py index b02c4cc..8fa7a38 100644 --- a/mcp-server/src/legal_mcp/tools/documents.py +++ b/mcp-server/src/legal_mcp/tools/documents.py @@ -264,6 +264,11 @@ async def document_get_text(case_number: str, doc_title: str = "") -> str: async def document_list(case_number: str) -> str: """רשימת מסמכים בתיק. + כל מסמך כולל `doc_type`, וכן את הסיווג הנגזר `is_primary` (bool) ו- + `doc_category` ("primary"/"secondary") — מסמך-עיקרי (ערר/תשובה/התנגדות/ + פרוטוקול/שומה/החלטת-ועדה) מול משני. נגזר מ-`doc_type` (db.PRIMARY_DOC_TYPES), + אינו נכתב ידנית. + Args: case_number: מספר תיק הערר """ @@ -345,11 +350,15 @@ async def extract_claims( if not docs: return empty(f"אין מסמכים בתיק {case_number}.") - # Filter to claims documents (appeal, response) or specific doc + # Filter to claims documents or a specific doc. protocol carries oral + # arguments raised at the hearing — extracted with claim_type='protocol' + # (claims_extractor._infer_claim_type) so analyze_protocol can compare them + # against the written pleadings, while block ז (write_block_zayin) excludes + # them to stay original-pleadings-only (INV-WR4). if doc_title: docs = [d for d in docs if doc_title.lower() in d["title"].lower()] else: - docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection")] + docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection", "protocol")] if not docs: return empty("לא נמצאו כתבי טענות בתיק.") diff --git a/mcp-server/src/legal_mcp/tools/drafting.py b/mcp-server/src/legal_mcp/tools/drafting.py index 05c1269..4a10768 100644 --- a/mcp-server/src/legal_mcp/tools/drafting.py +++ b/mcp-server/src/legal_mcp/tools/drafting.py @@ -516,9 +516,18 @@ async def export_docx(case_number: str, output_path: str = "") -> str: # ── Interim draft (pre-ruling) ──────────────────────────────────── -# Blocks written for the interim draft, in display order. +# Blocks written for the interim draft, in a FIXED order. # This is the same content the chair sees in the final decision (same template, -# same skill, same prompts) — minus opening, ruling, summary, signatures. +# same skill, same prompts, same single canonical write path — block_writer) — +# minus ruling, summary, signatures. +# +# Determinism (#204 / WS5): the interim draft is STRUCTURALLY deterministic. +# Each block is generated by block_writer.write_block, which now pins the model +# (Opus 4.8) and a per-block reasoning effort, and uses fixed structural prompts +# (e.g. block-he's opening is always "לפנינו ערר…"). The display order on export +# is fixed separately by docx_exporter._INTERIM_BLOCK_ORDER. There is no parallel +# generation path (G2): write_interim_draft is a thin orchestrator over the same +# write_and_store_block used by the full decision. _INTERIM_BLOCKS = ["block-he", "block-vav", "block-tet", "block-zayin", "block-chet"] @@ -571,6 +580,73 @@ async def get_appraiser_facts(case_number: str) -> str: 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: """כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט), טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין — @@ -658,6 +734,34 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str: return err(str(e)) +async def summarize_party_claims(case_number: str, instructions: str = "") -> str: + """הפקת סיכום-מנהלים מזוקק של טענות הצדדים — מסמך פרוזה **נפרד מטיוטת-הביניים**, + להכנת היו"ר לדיון בעל-פה. תמצית קצרה ומוקפדת (לא שכפול כתבי-הטענות), נגזרת + מהטיעונים המאוגדים (legal_arguments) או מ-claims הגולמיים — אותו מקור-אמת, + בלי חילוץ-מחדש. נשמר ל-data/cases/{n}/documents/research/ (git + S3) וניתן-לייצוא. + + הייצור עובר claude_session (claude -p), נעוץ Opus 4.8 + effort=high. מתוחם + לטענות-התיק בלבד (anti-hallucination) — אינו ממציא טענה/הלכה/עובדה שאינה בקלט. + + Args: + case_number: מספר תיק הערר + instructions: הנחיות נוספות מהיו"ר (אופציונלי) + """ + from legal_mcp.services import party_claims_summary + + case = await db.get_case_by_number(case_number) + if not case: + return err(f"תיק {case_number} לא נמצא.") + case_id = UUID(case["id"]) + try: + result = await party_claims_summary.summarize_party_claims( + case_id, case_number, instructions, + ) + return ok(result) + except ValueError as e: + return err(str(e)) + + async def apply_user_edit(case_number: str, edit_filename: str) -> str: """רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק. diff --git a/mcp-server/tests/test_docx_exporter_bookmarks.py b/mcp-server/tests/test_docx_exporter_bookmarks.py index 7340aa6..7baea5d 100644 --- a/mcp-server/tests/test_docx_exporter_bookmarks.py +++ b/mcp-server/tests/test_docx_exporter_bookmarks.py @@ -14,7 +14,9 @@ from lxml import etree from legal_mcp.services.docx_exporter import ( _BOOKMARK_ID_START, HEBREW_FONT, + TEMPLATE_PATH, _add_styled_paragraph, + _clear_body, _insert_bookmark_end, _insert_bookmark_start, _mark_paragraph_rtl, @@ -168,6 +170,83 @@ def test_block_dalet_does_not_use_title_style() -> None: assert any("החלטה" in t for t in texts) +def test_block_dalet_is_heading_only_ignores_db_content() -> None: + """ת2 — block-ד is the 'החלטה' heading marker, NOT a data block. + + Whatever content sits in decision_blocks for block-dalet must be ignored; + the only non-empty text line produced is the literal heading 'החלטה'. + """ + doc = Document() + _write_block_to_docx( + doc, "block-dalet", title="כותרת", + content="נתון ישן שצריך להיות מתועלם\nשורה שנייה", + ) + text_lines = [p.text for p in doc.paragraphs if p.text.strip()] + assert text_lines == ["החלטה"], ( + f"block-dalet must render ONLY the heading 'החלטה', got {text_lines}" + ) + + +# ── _clear_body strips ALL scaffolding (ת1 + ת3 root cause) ──────── +# The template ships 3 sample tables (header / panel / signatures). Removing +# only 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 ────────────────── diff --git a/mcp-server/tests/test_party_claims_summary.py b/mcp-server/tests/test_party_claims_summary.py new file mode 100644 index 0000000..ce2bc65 --- /dev/null +++ b/mcp-server/tests/test_party_claims_summary.py @@ -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 diff --git a/mcp-server/tests/test_protocol_analyzer.py b/mcp-server/tests/test_protocol_analyzer.py new file mode 100644 index 0000000..d14ed5a --- /dev/null +++ b/mcp-server/tests/test_protocol_analyzer.py @@ -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 diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index d319a34..b5bc609 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -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 `. רץ עם 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__.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data .venv/bin/python scripts/ab_halacha_opus48.py `. **ממצא 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___.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/ab_halacha_codex.py `. **ממצא 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-.{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` (לתזמן) | | `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`) | diff --git a/scripts/calibrate_effort.py b/scripts/calibrate_effort.py new file mode 100644 index 0000000..2c8f064 --- /dev/null +++ b/scripts/calibrate_effort.py @@ -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())) diff --git a/web-ui/src/app/settings/_components/blocks-tab.tsx b/web-ui/src/app/settings/_components/blocks-tab.tsx index 3aee0e5..a6ef70b 100644 --- a/web-ui/src/app/settings/_components/blocks-tab.tsx +++ b/web-ui/src/app/settings/_components/blocks-tab.tsx @@ -49,9 +49,9 @@ function BlockRow({ block }: { block: McpBlock }) { {block.model} - {isLLM && block.temperature !== null && ( - - temp {block.temperature} + {isLLM && block.effort !== null && ( + + effort {block.effort} )} {block.max_tokens !== null && ( diff --git a/web-ui/src/lib/api/settings.ts b/web-ui/src/lib/api/settings.ts index 048e461..3cf29cd 100644 --- a/web-ui/src/lib/api/settings.ts +++ b/web-ui/src/lib/api/settings.ts @@ -176,7 +176,9 @@ export type McpBlock = { title: string; gen_type: 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; creac_role: string | null; jwm_purpose: string | null; diff --git a/web/app.py b/web/app.py index 6fde202..a24bcd6 100644 --- a/web/app.py +++ b/web/app.py @@ -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") async def api_research_analysis_upload( case_number: str, @@ -4626,15 +4678,21 @@ async def api_mcp_blocks(): "block-yod-alef": "Disposition", "block-yod-bet": "Disposition (signatures)", } + from legal_mcp.services.block_writer import GENERATION_MODEL + blocks = [] for block_id, cfg in sorted(BLOCK_CONFIG.items(), key=lambda kv: kv[1]["index"]): + is_ai = cfg["model"] == "ai" blocks.append({ "id": block_id, "index": cfg["index"], "title": cfg["title"], "gen_type": cfg["gen_type"], - "model": cfg["model"], - "temperature": cfg.get("temp"), + # AI blocks are pinned to one model (#204); template blocks = "script". + "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"), "creac_role": CREAC_ROLE.get(block_id), "jwm_purpose": JWM_PURPOSE.get(block_id),