fix(style-acq T9): מספור-אוטומטי אמיתי בייצוא DOCX #88

Merged
chaim merged 1 commits from worktree-style-acquisition-mvp into main 2026-06-06 19:24:03 +00:00
3 changed files with 91 additions and 12 deletions

View File

@@ -184,9 +184,9 @@
### 3.1 ❌ אסור: רשימת-מיני ממוספרת בתוך פסקת-אנליזה (פיצול טיעון ל-`(1)...(2)...`) ### 3.1 ❌ אסור: רשימת-מיני ממוספרת בתוך פסקת-אנליזה (פיצול טיעון ל-`(1)...(2)...`)
**ב-0/33** מהחלטות הסופיות יש `(1) ... (2) ... (3) ...` המפצל טיעון בתוך פסקת אנליזה אחת. טענות וניתוח נכתבים כ**נרטיב רציף** עם ביטויי-מעבר ("עוד נטען", "באשר ל-", "יתרה מכך"), לא כרשימת-מיני. **ב-0/33** מהחלטות הסופיות יש `(1) ... (2) ... (3) ...` המפצל טיעון בתוך פסקת אנליזה אחת. טענות וניתוח נכתבים כ**נרטיב רציף** עם ביטויי-מעבר ("עוד נטען", "באשר ל-", "יתרה מכך"), לא כרשימת-מיני.
**ההחלטה כן ממוספרת — תמיד.** פסקאות ההחלטה ממוספרות סדרתית (1, 2, 3 ... עד הסוף), כמקובל בפסיקה. **המספור מוחל אוטומטית בשלב ייצוא ה-DOCX (Word auto-numbering)** ולכן אינו מופיע בחילוץ-טקסט גולמי — אל תסיק מהיעדרו בטקסט שאין מספור. **ההחלטה כן ממוספרת — תמיד.** פסקאות ההחלטה ממוספרות סדרתית (1, 2, 3 ... עד הסוף), כמקובל בפסיקה.
⚠️ **הכותב לא יקליד מספרים כטקסט ידני** ("12. ", "13. ") בתוך התוכן — הם נוצרים אוטומטית בייצוא, ומספרים ידניים שוברים את ה-copy/paste וכופלים מספור. כתוב את הפסקה ללא מספר מוביל. **הכותב מקדים כל פסקת-החלטה ב-"N. " בתחילת שורה** (1., 2., 3. ... סדרתי). זהו ה-signal שמנוע-הייצוא מזהה (`docx_exporter._NUM_PREFIX_RE`): הוא **מסיר את הקידומת הידנית וממיר אותה למספור-אוטומטי אמיתי של Word** (`_ensure_decision_numbering` — רשימה עשרונית רציפה, RTL). כך ה-DOCX מתמספר מעצמו (מתעדכן בעריכה, copy/paste נקי ללא ספרות תועות).
**ב-3/3 טיוטות AI** הופיעו מספרים ידניים בטקסט — שהוסרו/הומרו לאוטומטיים בעריכה. (תיקון 2026-06-06: ההנחה הקודמת ש"ההחלטות החדשות ללא מספור" הייתה ארטיפקט-חילוץ.) ⚠️ **המספר חייב להיות בתחילת השורה בלבד** — מספר *באמצע* פסקה הוא רשימת-מיני אסורה (§3.1 לעיל). (תיקון 2026-06-06: ההנחה ש"ההחלטות החדשות ללא מספור" הייתה ארטיפקט-חילוץ; וההנחה ש"הכותב לא יקליד מספרים" שגויה — הקידומת בתחילת-שורה היא ה-signal לייצוא, שמומר ל-auto-numbering.)
### 3.2 ⚠️ מותנה: כותרת משנה בלב בלוק י ### 3.2 ⚠️ מותנה: כותרת משנה בלב בלוק י

View File

@@ -112,6 +112,84 @@ def _suppress_paragraph_numbering(paragraph) -> None:
pPr.append(numPr) pPr.append(numPr)
def _ensure_decision_numbering(doc) -> int:
"""T9 — define a single continuous decimal list (RTL) and return its numId.
Dafna's decisions are ALWAYS sequentially numbered (1. 2. 3. ...). The template
ships no numbering definition, so previously the body paragraphs were stripped of
their manual "N." prefix and styled "List Paragraph" — which carries NO numPr,
yielding UNNUMBERED output. Here we inject one decimal abstractNum + num into the
numbering part once per document; body paragraphs then reference it (real Word
auto-numbering → renumbers automatically, copy-pastes cleanly).
"""
cached = getattr(doc, "_decision_num_id", None)
if cached is not None:
return cached
numbering = doc.part.numbering_part.element # <w:numbering>
def _next_id(tag: str, attr: str) -> int:
ids = [int(el.get(qn(attr))) for el in numbering.findall(qn(tag))
if el.get(qn(attr)) and el.get(qn(attr)).isdigit()]
return (max(ids) + 1) if ids else 1
abstract_id = _next_id("w:abstractNum", "w:abstractNumId")
num_id = _next_id("w:num", "w:numId")
abstract = OxmlElement("w:abstractNum")
abstract.set(qn("w:abstractNumId"), str(abstract_id))
mlt = OxmlElement("w:multiLevelType")
mlt.set(qn("w:val"), "singleLevel")
abstract.append(mlt)
lvl = OxmlElement("w:lvl")
lvl.set(qn("w:ilvl"), "0")
for tag, val in (("w:start", "1"), ("w:numFmt", "decimal"),
("w:lvlText", "%1."), ("w:lvlJc", "right")):
el = OxmlElement(tag)
el.set(qn("w:val"), val)
lvl.append(el)
lvl_ppr = OxmlElement("w:pPr")
ind = OxmlElement("w:ind")
ind.set(qn("w:start"), "720")
ind.set(qn("w:hanging"), "360")
lvl_ppr.append(ind)
lvl.append(lvl_ppr)
abstract.append(lvl)
num = OxmlElement("w:num")
num.set(qn("w:numId"), str(num_id))
anum_ref = OxmlElement("w:abstractNumId")
anum_ref.set(qn("w:val"), str(abstract_id))
num.append(anum_ref)
# abstractNum elements must precede num elements in <w:numbering>.
last_abstract = numbering.findall(qn("w:abstractNum"))
if last_abstract:
last_abstract[-1].addnext(abstract)
else:
numbering.insert(0, abstract)
numbering.append(num)
doc._decision_num_id = num_id
return num_id
def _apply_list_numbering(paragraph, num_id: int) -> None:
"""Attach paragraph to the continuous decision list (real auto-numbering)."""
pPr = paragraph._p.get_or_add_pPr()
existing = pPr.find(qn("w:numPr"))
if existing is not None:
pPr.remove(existing)
numPr = OxmlElement("w:numPr")
ilvl = OxmlElement("w:ilvl")
ilvl.set(qn("w:val"), "0")
nid = OxmlElement("w:numId")
nid.set(qn("w:val"), str(num_id))
numPr.append(ilvl)
numPr.append(nid)
pPr.append(numPr)
def _clear_body(doc) -> None: def _clear_body(doc) -> None:
"""Remove all paragraphs in the document body while keeping sectPr. """Remove all paragraphs in the document body while keeping sectPr.
@@ -485,12 +563,15 @@ def _write_block_to_docx(doc, block_id: str, title: str, content: str) -> None:
_add_image_placeholder(doc, stripped.strip("[]📷 ")) _add_image_placeholder(doc, stripped.strip("[]📷 "))
continue continue
# Numbered body paragraph ("1. text") → List Paragraph with auto-num. # Numbered body paragraph ("1. text") → real Word auto-numbering (T9).
# The literal prefix is dropped; Word renders "1. 2. 3. ..." via numId. # The literal prefix is dropped and a numPr referencing the document's
# continuous decimal list is attached, so Word renders "1. 2. 3. ..."
# itself (renumbers on edit, copy-pastes without stray digits).
num_match = _NUM_PREFIX_RE.match(stripped) num_match = _NUM_PREFIX_RE.match(stripped)
if num_match: if num_match:
body_text = num_match.group(2).strip() body_text = num_match.group(2).strip()
_add_styled_paragraph(doc, body_text, style="List Paragraph") para = _add_styled_paragraph(doc, body_text, style="List Paragraph")
_apply_list_numbering(para, _ensure_decision_numbering(doc))
continue continue
_add_styled_paragraph(doc, stripped, style="Normal") _add_styled_paragraph(doc, stripped, style="Normal")

View File

@@ -43,13 +43,11 @@ GOLDEN_RATIOS: dict[str, dict[str, tuple[int, int]]] = {
} }
# ── Anti-patterns (what Dafna avoids) — detectable signals for style-distance (T7) ── # ── Anti-patterns (what Dafna avoids) — detectable signals for style-distance (T7) ──
# Derived from daphna-voice-fingerprint.md §3 (corrected 2026-06-06: sequential # Derived from daphna-voice-fingerprint.md §3 (corrected 2026-06-06). NOTE: a leading
# paragraph numbering is REQUIRED — applied as Word auto-numbering at export — so the # "N." per paragraph is NOT an anti-pattern — it is the REQUIRED signal the DOCX
# anti-pattern is MANUAL numbers typed as text, not numbering itself). # exporter converts to real Word auto-numbering (docx_exporter._ensure_decision_numbering).
# The real anti-patterns are mid-paragraph mini-lists, markdown, and bullets.
ANTI_PATTERNS: list[dict] = [ ANTI_PATTERNS: list[dict] = [
{"name": "manual_paragraph_numbers",
"regex": r"(?m)^\s*\d{1,3}\.\s",
"note": "מספרים ידניים כטקסט בראש פסקה — אמורים להיות auto-numbering בייצוא"},
{"name": "inline_numbered_fragments", {"name": "inline_numbered_fragments",
"regex": r"\([0-9]\)[^\n]{0,200}\([0-9]\)", "regex": r"\([0-9]\)[^\n]{0,200}\([0-9]\)",
"note": "פיצול טיעון לרשימת-מיני (1)...(2) בתוך פסקת-אנליזה"}, "note": "פיצול טיעון לרשימת-מיני (1)...(2) בתוך פסקת-אנליזה"},