diff --git a/docs/daphna-voice-fingerprint.md b/docs/daphna-voice-fingerprint.md index f6a692f..35b3e0e 100644 --- a/docs/daphna-voice-fingerprint.md +++ b/docs/daphna-voice-fingerprint.md @@ -184,9 +184,9 @@ ### 3.1 ❌ אסור: רשימת-מיני ממוספרת בתוך פסקת-אנליזה (פיצול טיעון ל-`(1)...(2)...`) **ב-0/33** מהחלטות הסופיות יש `(1) ... (2) ... (3) ...` המפצל טיעון בתוך פסקת אנליזה אחת. טענות וניתוח נכתבים כ**נרטיב רציף** עם ביטויי-מעבר ("עוד נטען", "באשר ל-", "יתרה מכך"), לא כרשימת-מיני. -✅ **ההחלטה כן ממוספרת — תמיד.** פסקאות ההחלטה ממוספרות סדרתית (1, 2, 3 ... עד הסוף), כמקובל בפסיקה. **המספור מוחל אוטומטית בשלב ייצוא ה-DOCX (Word auto-numbering)** ולכן אינו מופיע בחילוץ-טקסט גולמי — אל תסיק מהיעדרו בטקסט שאין מספור. -⚠️ **הכותב לא יקליד מספרים כטקסט ידני** ("12. ", "13. ") בתוך התוכן — הם נוצרים אוטומטית בייצוא, ומספרים ידניים שוברים את ה-copy/paste וכופלים מספור. כתוב את הפסקה ללא מספר מוביל. -**ב-3/3 טיוטות AI** הופיעו מספרים ידניים בטקסט — שהוסרו/הומרו לאוטומטיים בעריכה. (תיקון 2026-06-06: ההנחה הקודמת ש"ההחלטות החדשות ללא מספור" הייתה ארטיפקט-חילוץ.) +✅ **ההחלטה כן ממוספרת — תמיד.** פסקאות ההחלטה ממוספרות סדרתית (1, 2, 3 ... עד הסוף), כמקובל בפסיקה. +✅ **הכותב מקדים כל פסקת-החלטה ב-"N. " בתחילת שורה** (1., 2., 3. ... סדרתי). זהו ה-signal שמנוע-הייצוא מזהה (`docx_exporter._NUM_PREFIX_RE`): הוא **מסיר את הקידומת הידנית וממיר אותה למספור-אוטומטי אמיתי של Word** (`_ensure_decision_numbering` — רשימה עשרונית רציפה, RTL). כך ה-DOCX מתמספר מעצמו (מתעדכן בעריכה, copy/paste נקי ללא ספרות תועות). +⚠️ **המספר חייב להיות בתחילת השורה בלבד** — מספר *באמצע* פסקה הוא רשימת-מיני אסורה (§3.1 לעיל). (תיקון 2026-06-06: ההנחה ש"ההחלטות החדשות ללא מספור" הייתה ארטיפקט-חילוץ; וההנחה ש"הכותב לא יקליד מספרים" שגויה — הקידומת בתחילת-שורה היא ה-signal לייצוא, שמומר ל-auto-numbering.) ### 3.2 ⚠️ מותנה: כותרת משנה בלב בלוק י diff --git a/mcp-server/src/legal_mcp/services/docx_exporter.py b/mcp-server/src/legal_mcp/services/docx_exporter.py index 312c74a..2f9bbfc 100644 --- a/mcp-server/src/legal_mcp/services/docx_exporter.py +++ b/mcp-server/src/legal_mcp/services/docx_exporter.py @@ -112,6 +112,84 @@ def _suppress_paragraph_numbering(paragraph) -> None: 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 # + + 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 . + 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: """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("[]📷 ")) continue - # Numbered body paragraph ("1. text") → List Paragraph with auto-num. - # The literal prefix is dropped; Word renders "1. 2. 3. ..." via numId. + # Numbered body paragraph ("1. text") → real Word auto-numbering (T9). + # 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) if num_match: 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 _add_styled_paragraph(doc, stripped, style="Normal") diff --git a/mcp-server/src/legal_mcp/services/lessons.py b/mcp-server/src/legal_mcp/services/lessons.py index a91c4d3..29448dc 100644 --- a/mcp-server/src/legal_mcp/services/lessons.py +++ b/mcp-server/src/legal_mcp/services/lessons.py @@ -43,13 +43,11 @@ GOLDEN_RATIOS: dict[str, dict[str, tuple[int, int]]] = { } # ── Anti-patterns (what Dafna avoids) — detectable signals for style-distance (T7) ── -# Derived from daphna-voice-fingerprint.md §3 (corrected 2026-06-06: sequential -# paragraph numbering is REQUIRED — applied as Word auto-numbering at export — so the -# anti-pattern is MANUAL numbers typed as text, not numbering itself). +# Derived from daphna-voice-fingerprint.md §3 (corrected 2026-06-06). NOTE: a leading +# "N." per paragraph is NOT an anti-pattern — it is the REQUIRED signal the DOCX +# 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] = [ - {"name": "manual_paragraph_numbers", - "regex": r"(?m)^\s*\d{1,3}\.\s", - "note": "מספרים ידניים כטקסט בראש פסקה — אמורים להיות auto-numbering בייצוא"}, {"name": "inline_numbered_fragments", "regex": r"\([0-9]\)[^\n]{0,200}\([0-9]\)", "note": "פיצול טיעון לרשימת-מיני (1)...(2) בתוך פסקת-אנליזה"},