From 7a7249af3891c048b7ed1c2c318c267697c0085b Mon Sep 17 00:00:00 2001 From: Chaim Date: Tue, 30 Jun 2026 11:16:26 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(documents):=20=D7=9E=D7=A1=D7=9E=D7=9A?= =?UTF-8?q?-=D7=A2=D7=99=D7=A7=D7=A8=D7=99=20=E2=80=94=20is=5Fprimary=20?= =?UTF-8?q?=D7=A0=D7=92=D7=96=D7=A8=20+=20=D7=A8=D7=A9=D7=99=D7=9E=D7=94?= =?UTF-8?q?=20=D7=A7=D7=A0=D7=95=D7=A0=D7=99=D7=AA=20(#200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 (#200): מבחין בין מסמך-עיקרי (מסמך-מהות שהיו"ר עוקבת אחריו לניתוח) למשני. רשימה קנונית מאושרת-יו"ר: appeal/response/objection/protocol/ appraisal/decision; כל doc_type אחר = משני. - db.PRIMARY_DOC_TYPES = מקור-אמת יחיד (tuple) + is_primary_doc_type(). - SCHEMA_V47: documents.is_primary BOOLEAN GENERATED ALWAYS AS (doc_type = ANY(PRIMARY_DOC_TYPES)) STORED — Postgres גוזר, אין מסלול- כתיבה מקביל, לא יכול לסטות מ-doc_type (G1/G2/INV-DM7, כמו ה-tsvectors). אידמפוטנטי (ADD COLUMN IF NOT EXISTS, ביטוי קבוע) + אינדקס חלקי idx_documents_primary לשאילתת "עיקריים שטרם-נותחו" (#201). - _row_to_doc גוזר גם בקריאה (רשומות טרום-מיגרציה) + מוסיף doc_category ∈ {primary, secondary}. נחשף דרך document_list / case_get / API. - 02-data-model.md §2ג מתעד את השדה הנגזר. Invariants: G1 (נרמול-במקור — נגזר, לא נכתב), G2/INV-DM7 (מקור-אמת יחיד, אין עמודה כפולה/מסלול מקביל), INV-DM3 (דפוס GENERATED drift-free), INV-DM6 (ללא enum-TEXT חדש — סיווג נגזר). §6 (אין בליעה שקטה). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/spec/02-data-model.md | 22 +++++++- mcp-server/src/legal_mcp/services/db.py | 62 +++++++++++++++++++++ mcp-server/src/legal_mcp/tools/documents.py | 5 ++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/docs/spec/02-data-model.md b/docs/spec/02-data-model.md index 66cd0dc..0b265e0 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`) | @@ -77,7 +77,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/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 3d4c3bc..158190d 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,22 @@ 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; +""" + # Stable, arbitrary key for the session-level advisory lock that serialises # schema DDL across processes. Every short-lived process (cron drains, services) @@ -1851,6 +1904,7 @@ 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) async def init_schema() -> None: @@ -2223,6 +2277,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 diff --git a/mcp-server/src/legal_mcp/tools/documents.py b/mcp-server/src/legal_mcp/tools/documents.py index b02c4cc..46e6efa 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: מספר תיק הערר """ From aad2de92056035d0d18dfd740ce930de9a15234f Mon Sep 17 00:00:00 2001 From: Chaim Date: Tue, 30 Jun 2026 11:18:56 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(drafting):=20=D7=98=D7=99=D7=95=D7=98?= =?UTF-8?q?=D7=AA-=D7=91=D7=99=D7=A0=D7=99=D7=99=D7=9D=20=D7=93=D7=98?= =?UTF-8?q?=D7=A8=D7=9E=D7=99=D7=A0=D7=99=D7=A1=D7=98=D7=99=D7=AA=20?= =?UTF-8?q?=E2=80=94=20=D7=A0=D7=A2=D7=99=D7=A6=D7=AA=20Opus=204.8=20+=20e?= =?UTF-8?q?ffort=20per-=D7=91=D7=9C=D7=95=D7=A7=20(#204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit מנגנון-הייצור (claude_session→claude -p) לא העביר model/effort: BLOCK_CONFIG הגדיר temp+model per-בלוק שהיו מטא-דאטה-מת (block_writer.py:447 העביר רק prompt/timeout/tools). הייצור רץ על מודל ברירת-המחדל של ה-CLI עם פרומפטים-חופשיים → מקור אי-העקביות בטיוטת-הביניים. תיקון (עקביות-מבנית, WS5): - נעיצת model="claude-opus-4-8" (GENERATION_MODEL) לכל בלוקי-ה-AI + effort per-בלוק שמועבר בפועל דרך claude_session.query → `claude -p --model --effort`. מפת-effort: ה=medium · ו=medium · ז=high · ח=medium · ט=high · יא=high · י=xhigh. - ניסוח-פתיחה קבוע לבלוק-ה (תמיד "לפנינו ערר…", סוף ל-OR "עניינה של החלטה זו"). - מבנה-פנימי קבוע לבלוק-ה; סדר-בלוקים בייצוא כבר קבוע (_INTERIM_BLOCK_ORDER). - הסרת שדות temp/model המתים מ-BLOCK_CONFIG; "temp" deprecated (Opus 4.7/4.8 דוחים temperature→400). decision_blocks.temperature נשמר=0 לתאימות-עמודה בלבד. - timeout נגזר מ-effort (לא מפיצול sonnet/opus שכבר לא קיים). - עדכון endpoint reference /api/settings/mcp/blocks + blocks-tab + McpBlock לחשוף effort. - ספ: docs/block-schema.md §3 (effort מחליף temperature), docs/spec/06-export.md (דטרמיניזם-מבני של טיוטת-הביניים). G2 (מקור-אמת יחיד) — אין מסלול-ייצור מקביל: write_interim_draft מתזמר את אותו write_and_store_block→write_block של ההחלטה המלאה. claude_session-local-only — הייצור נשאר דרך גשר-ה-CLI, לא SDK/קונטיינר. INV-EX1 — סדר-ייצוא דטרמיניסטי נשמר; G11 — עקרונות-הכתיבה ל-12 הבלוקים נשמרים. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/block-schema.md | 46 +++++--- docs/spec/06-export.md | 10 ++ .../src/legal_mcp/services/block_writer.py | 105 ++++++++++++------ mcp-server/src/legal_mcp/tools/drafting.py | 13 ++- .../app/settings/_components/blocks-tab.tsx | 6 +- web-ui/src/lib/api/settings.ts | 4 +- web/app.py | 10 +- 7 files changed, 142 insertions(+), 52 deletions(-) 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/06-export.md b/docs/spec/06-export.md index ca3fc8d..0601d73 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/services/block_writer.py b/mcp-server/src/legal_mcp/services/block_writer.py index 672ff22..8185a31 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. ## פרטי התיק: @@ -441,10 +468,19 @@ 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 = 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 @@ -455,6 +491,7 @@ async def write_block( 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 +499,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, } diff --git a/mcp-server/src/legal_mcp/tools/drafting.py b/mcp-server/src/legal_mcp/tools/drafting.py index 05c1269..572fd49 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"] 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..b3d2485 100644 --- a/web/app.py +++ b/web/app.py @@ -4626,15 +4626,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),