feat(export): INV-EX4 single-template source + fix edit-path justification
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s

מקור-תבנית-יחיד לכל מפיקי-מסמכי-ההחלטה: כולם מחילים סגנונות דרך
skills/docx/decision_template.docx בלבד; אסור hand-setting של פונט/גודל.

- docs/spec/06-export.md — INV-EX4 חדש (מקור-תבנית-יחיד, →G2)
- docx_reviser: מסלול-העריכה (revise_draft/apply_user_edit) הכניס פסקאות
  עם jc=right קשיח + David/sz ידני → איבד יישור דו-צדדי של התבנית.
  עכשיו מחיל pStyle מן-הטמפלט (Normal/Heading 2/Quote) + jc=both קנוני,
  בלי rFonts/sz ידני (הגופן/גודל מגיעים מהסגנון).
- שומר-CI test_docx_template_single_source — נכשל על בניית rFonts/sz
  במפיקים מחוץ לטמפלט (חריג מסומן # INV-EX4-ok ל-_mark_run_rtl,
  שמחזק את גופן-התבנית David כעקיפת באג-RTL של Word).
- אומת: build_party_claims_summary_docx (סיכום-מנהלים, WS3) + export_decision
  (סופי/טיוטת-ביניים) כבר תבניתיים ותואמי-INV-EX4.
- ארכוב scripts/exec_summary_1043.py (one-off; הוחלף ב-build_party_claims_summary_docx).
- עדכון 2 טסטים שאימתו את ההתנהגות הישנה (rFonts על run מוכנס).

Invariants: מקיים INV-EX4 (חדש), INV-G2 (מקור-יחיד, אין מסלול-סגנון מקביל),
INV-EX1 (DOCX נגזר). נוגע ב-docx_reviser/docx_exporter/analysis_docx_exporter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 17:07:21 +00:00
parent 73d2f15915
commit a8dd8ee152
8 changed files with 253 additions and 42 deletions

View File

@@ -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 <w:p>."""
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
<w:pStyle> and an <w:rtl/> 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 ────────────────────────

View File

@@ -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)."
)

View File

@@ -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 <w:rtl/> 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")