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 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/docs/spec/04-analysis-writing.md b/docs/spec/04-analysis-writing.md
index 3128f6e..4a9db79 100644
--- a/docs/spec/04-analysis-writing.md
+++ b/docs/spec/04-analysis-writing.md
@@ -47,7 +47,10 @@
> מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון
> שלו מקבל מענה בבלוק י.
-### 1.3 ניתוח פרוטוקול-דיון השוואתי (WS4 / #203)
+### 1.3 ניתוח-פרוטוקול — ניתוח פרוטוקול-דיון השוואתי (WS4 / #203)
+
+> **בעלוּת תת-סעיף:** §1.3 (זה) שייך ל-WS4 (ניתוח-פרוטוקול). תת-סעיף סיכום-טענות-הצדדים
+> (PR-אחות #358) הוא **תת-סעיף נפרד** תחת §1 — אין חפיפת-מספור בין השניים.
אחרי הדיון, פרוטוקול (`doc_type='protocol'`) מנותח **מול הטיעונים המאוגדים** —
`analyze_protocol(case_number)` (→ `services/protocol_analyzer.py`). הניתוח מסווג כל
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 23041dd..fd20a6a 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/services/db.py b/mcp-server/src/legal_mcp/services/db.py
index 366c979..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,7 +1819,23 @@ CREATE INDEX IF NOT EXISTS idx_decision_lessons_vec
ON decision_lessons USING ivfflat (embedding vector_cosine_ops) WITH (lists = 30);
"""
-# ── V47: Protocol comparative analysis (WS4 / #203) ───────────────
+# 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
@@ -1798,7 +1851,7 @@ CREATE INDEX IF NOT EXISTS idx_decision_lessons_vec
# 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_V47_SQL = """
+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,
@@ -1887,6 +1940,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
await conn.execute(SCHEMA_V45_SQL)
await conn.execute(SCHEMA_V46_SQL)
await conn.execute(SCHEMA_V47_SQL)
+ await conn.execute(SCHEMA_V48_SQL)
async def init_schema() -> None:
@@ -2259,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
@@ -4003,7 +4065,7 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]:
return conflicts
-# ── Protocol comparative analysis (V47 / WS4 #203) ────────────────
+# ── Protocol comparative analysis (V48 / WS4 #203) ────────────────
async def replace_protocol_analysis(
case_id: UUID,
diff --git a/mcp-server/src/legal_mcp/tools/documents.py b/mcp-server/src/legal_mcp/tools/documents.py
index dfaf037..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: מספר תיק הערר
"""
diff --git a/mcp-server/src/legal_mcp/tools/drafting.py b/mcp-server/src/legal_mcp/tools/drafting.py
index 794944a..37a3426 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),