From a8dd8ee1524a33fa9a291848b98db19d47c5c936 Mon Sep 17 00:00:00 2001 From: Chaim Date: Tue, 30 Jun 2026 17:07:21 +0000 Subject: [PATCH] feat(export): INV-EX4 single-template source + fix edit-path justification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit מקור-תבנית-יחיד לכל מפיקי-מסמכי-ההחלטה: כולם מחילים סגנונות דרך 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) --- docs/spec/06-export.md | 24 +++ .../src/legal_mcp/services/docx_exporter.py | 10 +- .../src/legal_mcp/services/docx_reviser.py | 139 ++++++++++++++---- mcp-server/tests/test_docx_reviser.py | 33 ++++- .../tests/test_docx_template_single_source.py | 76 ++++++++++ mcp-server/tests/test_track_changes_e2e.py | 12 +- scripts/{ => .archive}/exec_summary_1043.py | 0 scripts/SCRIPTS.md | 1 + 8 files changed, 253 insertions(+), 42 deletions(-) create mode 100644 mcp-server/tests/test_docx_template_single_source.py rename scripts/{ => .archive}/exec_summary_1043.py (100%) 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 נבחר |