diff --git a/docs/spec/06-export.md b/docs/spec/06-export.md
index c5db4c6..9e06d4a 100644
--- a/docs/spec/06-export.md
+++ b/docs/spec/06-export.md
@@ -151,6 +151,30 @@ output) · Council of Europe / CEPEJ (2018, under user control) · Federal Judic
`export_blocked` (אכוף-זרימה בלבד, ניתן-לעקיפה). נסגר ע"י ה-hard-block לעיל; מאומת ב-
`test_export_qa_gate.py` (חסום ללא-QA · חסום בכשל-קריטי · עובר כשנקי).
+### INV-EX4: מקור-תבנית-יחיד — כל מפיק-מסמך מחיל סגנונות-טמפלט בלבד (→G2)
+**כלל:** **כל** מפיק של מסמך-Word הקשור-להחלטה — סופי (`export_docx`), טיוטת-ביניים
+(`export_interim_draft`), סיכום-מנהלים (`party-claims-summary`), ומסמך-הניתוח
+(`build_analysis_docx`), וכן מסלול-העריכה (`revise_draft`/`apply_user_edit` →
+`docx_reviser`) — **חייב לטעון את הטמפלט היחיד** `skills/docx/decision_template.docx`
+ולהחיל עיצוב **אך-ורק דרך שמות-הסגנונות שמוגדרים בו** (`paragraph.style = "…"` /
+``). **אסור hand-setting** של משפחת-גופן (`rFonts`), גודל-גופן
+(`sz`/`szCs`), או שוליים — אלה נגזרים מ-`styles.xml` של הטמפלט (cascade). היחיד שמותר
+לכפות מפורשות הוא **יישור-הגוף הקנוני `jc=both`** היכן שהוא דורס סגנון שמגיע ממורכז
+(`_set_paragraph_jc`, `docx_exporter.py:80`) — כי זו ההתנהגות-הקנונית, לא עיצוב-אד-הוק.
+סמני-כיווניות (``, run ``) מותרים (כיווניות, לא עיצוב). **טמפלט אחד = מקור-סגנון
+יחיד; אין שכבת-סגנון מקבילה.**
+**מקורות:** SSOT (Single Source of Truth) · Martin Fowler — Canonical Data Model · ECMA-376
+(OOXML) §17.7.2 — הורשת-סגנונות-פסקה (paragraph style inheritance / cascade) | סטטוס: verified
+**אכיפה:** `docx_exporter` ו-`analysis_docx_exporter` טוענים `TEMPLATE_PATH` ומחילים style-by-name
+(ראה §2); `docx_reviser` מחיל `pStyle` מן-הטמפלט + `jc=both` (לא פונט/גודל ידני). שומר-CI
+`test_docx_template_single_source.py` גורף את מודולי-מפיקי-ה-DOCX ונכשל על `rFonts`/`sz`/`szCs`
+ידני מחוץ לטוען-הטמפלט. אוכף את
+[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) בשכבת-הרינדור.
+**הפרה ידועה — ✅ נסגרה:** `docx_reviser._build_paragraph` כפה `jc=right` + David/`sz` ידני על כל
+פסקה שהוכנסה במסלול-העריכה (`revise_draft`/`apply_user_edit`) → פלט מסלול-העריכה איבד את היישור
+הדו-צדדי של הטמפלט והכניס עיצוב-אד-הוק. נסגר ע"י החלת `pStyle` מן-הטמפלט; מאומת ב-
+`test_docx_reviser.py` + `test_docx_template_single_source.py`.
+
---
## 5. Current vs Target
diff --git a/mcp-server/src/legal_mcp/services/docx_exporter.py b/mcp-server/src/legal_mcp/services/docx_exporter.py
index 368a5ea..4ad2602 100644
--- a/mcp-server/src/legal_mcp/services/docx_exporter.py
+++ b/mcp-server/src/legal_mcp/services/docx_exporter.py
@@ -43,10 +43,16 @@ HEBREW_FONT = "David"
def _mark_run_rtl(run) -> None:
- """Force David font on all four slots, then add ."""
+ """Force David font on all four slots, then add .
+
+ INV-EX4 note: this reinforces the template's OWN font (David, == the
+ template's cs slot) to work around Word's RTL ascii-slot fallback to
+ Times New Roman — it is NOT an ad-hoc font/size override. Marked
+ INV-EX4-ok for the single-source guard for that reason.
+ """
rPr = run._r.get_or_add_rPr()
if rPr.find(qn("w:rFonts")) is None:
- fonts = OxmlElement("w:rFonts")
+ fonts = OxmlElement("w:rFonts") # INV-EX4-ok: reinforces template font David
fonts.set(qn("w:ascii"), HEBREW_FONT)
fonts.set(qn("w:hAnsi"), HEBREW_FONT)
fonts.set(qn("w:cs"), HEBREW_FONT)
diff --git a/mcp-server/src/legal_mcp/services/docx_reviser.py b/mcp-server/src/legal_mcp/services/docx_reviser.py
index ee79d73..a329f43 100644
--- a/mcp-server/src/legal_mcp/services/docx_reviser.py
+++ b/mcp-server/src/legal_mcp/services/docx_reviser.py
@@ -199,27 +199,86 @@ def _find_enclosing_paragraph(element: etree._Element) -> etree._Element | None:
return None
+# ── Template style resolution (INV-EX4) ────────────────────────────
+# Every inserted paragraph carries a pointing at one of the
+# template's named styles — font (David), size, and indent come from
+# styles.xml, NOT from hand-set rFonts/sz here. Single template = single
+# style source (INV-EX4 / G2). docx_exporter.py applies the same styles by
+# name via python-docx; here (raw lxml) we resolve name → styleId.
+
+# StyleType → candidate style names in the template (case-insensitive).
+# Mirrors the names docx_exporter uses (paragraph.style = "Heading 2"/"Normal"/"Quote").
+_STYLE_NAME_CANDIDATES: dict[str, tuple[str, ...]] = {
+ "body": ("Normal", "Default Paragraph Font", "Body Text"),
+ "bold": ("Normal", "Body Text"),
+ "heading": ("Heading 2", "heading 2"),
+ "quote": ("Quote", "quote", "Intense Quote"),
+}
+
+
+def _resolve_style_ids(members: dict[str, bytes]) -> dict[str, str]:
+ """Map each StyleType → the template's w:styleId, read from styles.xml.
+
+ Returns {} when styles.xml is absent — callers then fall back to
+ minimal inline emphasis (so a missing style never crashes rendering).
+ """
+ raw = members.get("word/styles.xml")
+ if not raw:
+ return {}
+ try:
+ styles_tree = etree.fromstring(raw)
+ except etree.XMLSyntaxError: # pragma: no cover - corrupt template
+ return {}
+ # Build a lowercase lookup over both the style's display name and its id.
+ by_key: dict[str, str] = {}
+ for style in styles_tree.iterfind(".//w:style", NSMAP):
+ sid = style.get(_w("styleId"))
+ if not sid:
+ continue
+ by_key.setdefault(sid.lower(), sid)
+ name_el = style.find(_w("name"))
+ if name_el is not None:
+ nm = name_el.get(_w("val"))
+ if nm:
+ by_key.setdefault(nm.lower(), sid)
+ resolved: dict[str, str] = {}
+ for style_type, candidates in _STYLE_NAME_CANDIDATES.items():
+ for cand in candidates:
+ sid = by_key.get(cand.lower())
+ if sid is not None:
+ resolved[style_type] = sid
+ break
+ return resolved
+
+
+def _emphasis_fallback(style: StyleType, *, has_pstyle: bool) -> tuple[bool, bool]:
+ """(bold, italic) for a run. When the paragraph carries a resolved
+ template pStyle, emphasis comes from the style (return no inline override),
+ except 'bold' which is inline emphasis with no dedicated style. When no
+ pStyle resolved, fall back to minimal inline emphasis so heading/quote
+ stay visually distinct."""
+ if style == "bold":
+ return True, False
+ if has_pstyle:
+ return False, False
+ return (style == "heading"), (style == "quote")
+
+
# ── Paragraph builders ─────────────────────────────────────────────
-def _build_run(text: str, *, bold: bool = False, italic: bool = False,
- font: str = "David", size_half_pt: int | None = None) -> etree._Element:
- """Build a w:r (run) element with RTL/David defaults and given text."""
+def _build_run(text: str, *, bold: bool = False, italic: bool = False) -> etree._Element:
+ """Build a w:r (run): RTL marker + optional bold/italic + text.
+
+ Font (David) and size are NOT set here — they derive from the paragraph's
+ template style (INV-EX4). The marker is still required so Word
+ picks the style's complex-script (cs=David) slot rather than the ascii
+ (Times New Roman) slot for Hebrew (same reason as _mark_run_rtl in
+ analysis_docx_exporter.py).
+ """
r = etree.Element(_w("r"))
rPr = etree.SubElement(r, _w("rPr"))
- rFonts = etree.SubElement(rPr, _w("rFonts"))
- rFonts.set(_w("ascii"), font)
- rFonts.set(_w("hAnsi"), font)
- rFonts.set(_w("cs"), font)
- rFonts.set(_w("hint"), "cs")
-
- if size_half_pt is not None:
- sz = etree.SubElement(rPr, _w("sz"))
- sz.set(_w("val"), str(size_half_pt))
- szCs = etree.SubElement(rPr, _w("szCs"))
- szCs.set(_w("val"), str(size_half_pt))
-
if bold:
etree.SubElement(rPr, _w("b"))
etree.SubElement(rPr, _w("bCs"))
@@ -235,28 +294,36 @@ def _build_run(text: str, *, bold: bool = False, italic: bool = False,
return r
-def _build_paragraph(text: str, *, style: StyleType = "body") -> etree._Element:
- """Build a w:p (paragraph) with RTL + David + given text."""
+def _build_paragraph(text: str, *, style: StyleType = "body",
+ style_id: str | None = None) -> etree._Element:
+ """Build a w:p (paragraph) carrying the template's named style.
+
+ The paragraph references `style_id` (resolved from the template's
+ styles.xml) via — font/size/indent come from there, not from
+ hand-set rFonts/sz (INV-EX4). The only explicit paragraph-level override
+ is the canonical body alignment jc=both, matching docx_exporter's
+ _set_paragraph_jc (which forces both over style-center headings).
+ """
p = etree.Element(_w("p"))
pPr = etree.SubElement(p, _w("pPr"))
+
+ if style_id:
+ pStyle = etree.SubElement(pPr, _w("pStyle"))
+ pStyle.set(_w("val"), style_id)
+
bidi = etree.SubElement(pPr, _w("bidi"))
bidi.set(_w("val"), "1")
- # Right alignment for body/RTL
+ # Canonical body alignment (two-sided justification) — overrides any
+ # style-center, exactly as docx_exporter._set_paragraph_jc does.
jc = etree.SubElement(pPr, _w("jc"))
- jc.set(_w("val"), "right")
+ jc.set(_w("val"), "both")
rPr_p = etree.SubElement(pPr, _w("rPr"))
etree.SubElement(rPr_p, _w("rtl"))
- bold = style in ("heading", "bold")
- italic = style == "quote"
- size = None
- if style == "heading":
- size = 28 # 14pt
- elif style == "quote":
- size = 22 # 11pt
- run = _build_run(text, bold=bold, italic=italic, size_half_pt=size)
+ bold, italic = _emphasis_fallback(style, has_pstyle=bool(style_id))
+ run = _build_run(text, bold=bold, italic=italic)
p.append(run)
return p
@@ -275,7 +342,7 @@ def _wrap_in_ins(elements: list[etree._Element], *, ins_id: int,
def _make_tracked_paragraph_insert(
text: str, *, style: StyleType, ins_id: int, author: str, date_iso: str,
- mark_id: int | None = None,
+ mark_id: int | None = None, style_id: str | None = None,
) -> etree._Element:
"""Build a whole tracked-inserted paragraph.
@@ -286,7 +353,7 @@ def _make_tracked_paragraph_insert(
"""
if mark_id is None:
mark_id = ins_id
- p = _build_paragraph(text, style=style)
+ p = _build_paragraph(text, style=style, style_id=style_id)
pPr = p.find(_w("pPr"))
assert pPr is not None
rPr = pPr.find(_w("rPr"))
@@ -339,6 +406,7 @@ def _apply_insert(
ins_id: int,
author: str,
date_iso: str,
+ style_ids: dict[str, str] | None = None,
) -> RevisionResult:
"""Apply insert_after / insert_before relative to a bookmark."""
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
@@ -362,6 +430,7 @@ def _apply_insert(
revision.content, style=revision.style,
ins_id=ins_id, mark_id=ins_id + 1,
author=author, date_iso=date_iso,
+ style_id=(style_ids or {}).get(revision.style),
)
parent = enclosing_p.getparent()
@@ -407,6 +476,7 @@ def _apply_replace(
del_id: int,
author: str,
date_iso: str,
+ style_ids: dict[str, str] | None = None,
) -> RevisionResult:
"""Replace = delete the existing paragraph + insert new one after it."""
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
@@ -428,6 +498,7 @@ def _apply_replace(
revision.content, style=revision.style,
ins_id=ins_id, mark_id=ins_id + 1,
author=author, date_iso=date_iso,
+ style_id=(style_ids or {}).get(revision.style),
)
idx = list(parent).index(enclosing_p)
parent.insert(idx + 1, new_p)
@@ -473,6 +544,10 @@ def apply_tracked_revisions(
members, doc_tree, settings_tree = _load_docx_xml(source_path)
_ensure_track_revisions(settings_tree)
+ # Resolve template style ids once (INV-EX4) — inserted paragraphs inherit
+ # font/size/indent from these named styles instead of hand-set rFonts/sz.
+ style_ids = _resolve_style_ids(members)
+
next_id = _next_revision_id(doc_tree)
batch = RevisionBatchResult()
@@ -480,7 +555,8 @@ def apply_tracked_revisions(
try:
if rev.type in ("insert_after", "insert_before"):
result = _apply_insert(doc_tree, rev, ins_id=next_id,
- author=author, date_iso=date_iso)
+ author=author, date_iso=date_iso,
+ style_ids=style_ids)
# insert consumes 2 IDs: run-wrapper + paragraph-mark
next_id += 2
elif rev.type == "delete":
@@ -490,7 +566,8 @@ def apply_tracked_revisions(
elif rev.type == "replace":
result = _apply_replace(doc_tree, rev,
ins_id=next_id, del_id=next_id + 2,
- author=author, date_iso=date_iso)
+ author=author, date_iso=date_iso,
+ style_ids=style_ids)
# replace consumes 3 IDs: ins-run, ins-mark, del
next_id += 3
else:
diff --git a/mcp-server/tests/test_docx_reviser.py b/mcp-server/tests/test_docx_reviser.py
index aa478e6..c97f688 100644
--- a/mcp-server/tests/test_docx_reviser.py
+++ b/mcp-server/tests/test_docx_reviser.py
@@ -130,6 +130,16 @@ def _find_ins_with_runs(tree: etree._Element) -> etree._Element | None:
return None
+def _find_enclosing_p(element: etree._Element) -> etree._Element | None:
+ """Walk up to the enclosing ."""
+ cur = element
+ while cur is not None:
+ if cur.tag == _w("p"):
+ return cur
+ cur = cur.getparent()
+ return None
+
+
def test_insert_after_ins_has_author_and_date(sample_docx: Path, tmp_path: Path) -> None:
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="insert_after",
@@ -147,7 +157,10 @@ def test_insert_after_ins_has_author_and_date(sample_docx: Path, tmp_path: Path)
assert date_str.endswith("Z") # ISO 8601 UTC
-def test_insert_after_uses_rtl_and_david(sample_docx: Path, tmp_path: Path) -> None:
+def test_insert_after_uses_rtl_and_template_style(sample_docx: Path, tmp_path: Path) -> None:
+ """INV-EX4: an inserted paragraph carries the template's named style via
+ and an run marker — but does NOT hand-set rFonts/sz
+ (font/size come from the style). jc is the canonical body 'both'."""
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="insert_after",
anchor_bookmark="block-alef", content="מוסף")
@@ -162,10 +175,22 @@ def test_insert_after_uses_rtl_and_david(sample_docx: Path, tmp_path: Path) -> N
assert run is not None
rPr = run.find(_w("rPr"))
assert rPr is not None
+ # RTL marker preserved (so Word picks the style's cs=David slot)…
assert rPr.find(_w("rtl")) is not None
- rFonts = rPr.find(_w("rFonts"))
- assert rFonts is not None
- assert rFonts.get(_w("ascii")) == "David"
+ # …but NO hand-set font/size on the run (INV-EX4).
+ assert rPr.find(_w("rFonts")) is None
+ assert rPr.find(_w("sz")) is None
+ assert rPr.find(_w("szCs")) is None
+
+ # The enclosing paragraph references a template style + canonical jc=both.
+ para = _find_enclosing_p(ins)
+ assert para is not None
+ pPr = para.find(_w("pPr"))
+ assert pPr is not None
+ pStyle = pPr.find(_w("pStyle"))
+ assert pStyle is not None and pStyle.get(_w("val")) # Normal → real styleId
+ jc = pPr.find(_w("jc"))
+ assert jc is not None and jc.get(_w("val")) == "both"
# ── apply_tracked_revisions: insert_before ────────────────────────
diff --git a/mcp-server/tests/test_docx_template_single_source.py b/mcp-server/tests/test_docx_template_single_source.py
new file mode 100644
index 0000000..45bc977
--- /dev/null
+++ b/mcp-server/tests/test_docx_template_single_source.py
@@ -0,0 +1,76 @@
+"""שומר INV-EX4 — מקור-תבנית-יחיד לכל מפיקי-מסמכי-ההחלטה.
+
+כל מפיק של מסמך-Word הקשור-להחלטה חייב להחיל עיצוב דרך הסגנונות שמוגדרים
+בטמפלט היחיד (skills/docx/decision_template.docx), ולא לקבוע פונט/גודל ידנית
+(``rFonts`` / ``sz`` / ``szCs``) — אלה נגזרים מ-styles.xml של הטמפלט.
+
+הטסט גורף את מודולי-המפיקים ונכשל אם נבנה אלמנט ``w:rFonts`` / ``w:sz`` /
+``w:szCs`` בקוד, אלא אם השורה נושאת את הסמן המפורש ``# INV-EX4-ok`` (חריג
+מתועד — למשל חיזוק גופן-התבנית עצמו David כעקיפת באג-RTL של Word).
+
+ספ: docs/spec/06-export.md §INV-EX4.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+import pytest
+
+from legal_mcp.services import (
+ analysis_docx_exporter,
+ docx_exporter,
+ docx_reviser,
+ docx_retrofit,
+)
+
+# Every module that builds/edits a decision-related Word document.
+_PRODUCER_MODULES = [
+ docx_exporter,
+ analysis_docx_exporter,
+ docx_reviser,
+ docx_retrofit,
+]
+
+# Construction (not lookup) of a hand-set font/size element:
+# OxmlElement("w:rFonts") (python-docx producers)
+# etree.SubElement(rPr, _w("sz")) (raw-lxml producer: docx_reviser)
+_HANDSET_RE = re.compile(
+ r'OxmlElement\(\s*["\']w:(?:rFonts|sz|szCs)["\']'
+ r'|SubElement\([^)]*_w\(\s*["\'](?:rFonts|sz|szCs)["\']'
+)
+
+# Producers that build a document FROM SCRATCH must load the single template.
+# (docx_reviser/docx_retrofit operate on an already-templated existing doc.)
+_FROM_SCRATCH_MODULES = [docx_exporter, analysis_docx_exporter]
+
+
+def _module_path(module) -> Path:
+ return Path(module.__file__)
+
+
+def test_no_handset_font_or_size_in_producers() -> None:
+ """INV-EX4: no hand-set rFonts/sz/szCs in any producer (only via the
+ template's named styles), unless explicitly marked ``# INV-EX4-ok``."""
+ violations: list[str] = []
+ for module in _PRODUCER_MODULES:
+ path = _module_path(module)
+ for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
+ if _HANDSET_RE.search(line) and "INV-EX4-ok" not in line:
+ violations.append(f"{path.name}:{lineno}: {line.strip()}")
+ assert not violations, (
+ "INV-EX4 violation — hand-set font/size outside the template styles "
+ "(use a named template style, or mark with # INV-EX4-ok):\n "
+ + "\n ".join(violations)
+ )
+
+
+@pytest.mark.parametrize("module", _FROM_SCRATCH_MODULES)
+def test_from_scratch_producers_load_the_single_template(module) -> None:
+ """INV-EX4: from-scratch producers reference the one template file."""
+ source = _module_path(module).read_text(encoding="utf-8")
+ assert "decision_template.docx" in source, (
+ f"{_module_path(module).name} must load the single template "
+ "skills/docx/decision_template.docx (INV-EX4)."
+ )
diff --git a/mcp-server/tests/test_track_changes_e2e.py b/mcp-server/tests/test_track_changes_e2e.py
index 6f5dff2..21e0809 100644
--- a/mcp-server/tests/test_track_changes_e2e.py
+++ b/mcp-server/tests/test_track_changes_e2e.py
@@ -207,8 +207,9 @@ def test_multiple_revisions_all_tracked_independently(tmp_path: Path) -> None:
def test_rtl_preserved_in_tracked_insertion(tmp_path: Path) -> None:
- """Inserted paragraph must have bidi + rtl + David font so it renders
- correctly in Word alongside the user's content."""
+ """Inserted paragraph must have bidi + an run marker so Hebrew
+ renders via the template's cs=David slot — but font/size come from the
+ template's named paragraph style (INV-EX4), not hand-set rFonts/sz."""
user_file = tmp_path / "user.docx"
_make_user_edited_docx(user_file)
docx_retrofit.retrofit_bookmarks(user_file, backup=False)
@@ -229,9 +230,10 @@ def test_rtl_preserved_in_tracked_insertion(tmp_path: Path) -> None:
if any("עברית RTL" in (t.text or "") for t in text_els):
rPr = r.find(_w("rPr"))
assert rPr is not None
+ # RTL marker preserved…
assert rPr.find(_w("rtl")) is not None
- rFonts = rPr.find(_w("rFonts"))
- assert rFonts is not None
- assert rFonts.get(_w("ascii")) == "David"
+ # …but NO hand-set font/size on the run (INV-EX4).
+ assert rPr.find(_w("rFonts")) is None
+ assert rPr.find(_w("sz")) is None
return
pytest.fail("tracked insertion with 'עברית RTL' not found")
diff --git a/scripts/exec_summary_1043.py b/scripts/.archive/exec_summary_1043.py
similarity index 100%
rename from scripts/exec_summary_1043.py
rename to scripts/.archive/exec_summary_1043.py
diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md
index 8e3d646..3d41bc6 100644
--- a/scripts/SCRIPTS.md
+++ b/scripts/SCRIPTS.md
@@ -174,6 +174,7 @@
| Script | Original Purpose | Superseded By |
|--------|-----------------|---------------|
+| `exec_summary_1043.py` | חד-פעמי — רינדור DOCX של סיכום-מנהלים לתיק 1043-02-26 דרך עוזרי-התבנית של `analysis_docx_exporter` (פרוטוטיפ). | `analysis_docx_exporter.build_party_claims_summary_docx()` + endpoint `GET /api/cases/{n}/research/party-claims-summary/export-docx` (WS3 #202; תבניתי, INV-EX4) |
| `backfill_pattern_frequency.py` | עדכון תדירות דפוסי סגנון ב-DB | `web/app.py::_extract_pattern_variants()` |
| `batch_upload_training.py` | העלאת קורפוס אימון (16 קבצים) | Web UI: `/api/training/upload` |
| `benchmark_embeddings.py` | השוואת מודלי embeddings (voyage-3 vs voyage-4) | הושלם — voyage-3-large נבחר |