Add dafna-decision-template skill — knowledge for template-based DOCX export
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 6s

Documents the rules and decisions behind building DOCX files from דפנה's
decision template (טיוטת החלטה.dotx). The implementation lives in
mcp-server/src/legal_mcp/services/analysis_docx_exporter.py; this skill
captures the "why" so future improvements don't need to rediscover it.

Contents:
  SKILL.md                       5 critical rules, style mapping table,
                                 export flow, line classification,
                                 dash policy, placeholder handling,
                                 troubleshooting, future TODOs
  references/dotx-to-docx.md     why python-docx can't open .dotx +
                                 the conversion recipe
  references/rtl-runs.md         why <w:rtl/> is required on every run
                                 (otherwise Hebrew falls back to
                                 Times New Roman)
  references/style-mapping.md    XML dump of every template style,
                                 with the Title-via-theme gotcha
  references/line-classification.md  the 7 regex categories in
                                 _classify_line() with real examples

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 18:57:57 +00:00
parent 726498126d
commit bfec8bdaa3
5 changed files with 701 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
# המרת `.dotx` → `.docx` עבור python-docx
## למה
python-docx **לא יודע לפתוח** קובצי Word Template (`.dotx`). ניסיון לפתיחה
זורק:
```
ValueError: file 'X.dotx' is not a Word file, content type is
'application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml'
```
כי `main document part` של `.dotx` מסומן כ-template, לא כ-document.
## הפתרון — המרה חד-פעמית
קובץ `.dotx` הוא ZIP שכולל את אותם parts כמו `.docx` + `word/glossary/`
(building blocks). להמרה:
1. פתח את ה-ZIP.
2. **הסר** את כל ה-parts תחת `word/glossary/`.
3. **תקן** `[Content_Types].xml`:
- החלף `template.main+xml` ב-`document.main+xml`
- הסר `<Override>` entries שמצביעים ל-`/word/glossary/...`
4. **תקן** `word/_rels/document.xml.rels`:
- הסר את ה-`<Relationship>` עם
`Type=".../relationships/glossaryDocument"`
5. שמור מחדש כ-ZIP עם סיומת `.docx`.
## הסקריפט
[`scripts/convert_decision_template.py`](../../../scripts/convert_decision_template.py)
(בשורש הפרויקט) עושה את זה. הרץ אותו:
- פעם אחת אחרי clone של הפרויקט (אם `skills/docx/decision_template.docx`
לא קיים).
- בכל פעם שדפנה מעדכנת את `data/training/טיוטת החלטה.dotx`.
```bash
python scripts/convert_decision_template.py
# → skills/docx/decision_template.docx
```
הסקריפט כולל verification שבודק שהקובץ שנוצר נטען נקי ב-python-docx
ושהסגנונות הקריטיים (Normal, Heading 2, Quote, List Paragraph, Title)
נמצאים בו.
## למה לא `docxtpl`?
`docxtpl` מיועד ל-**placeholder substitution** סגנון Jinja2
(`{{ variable }}`). הטמפלט שלנו לא מכיל placeholders — אנחנו מרכיבים
תוכן דינמי. `python-docx` על טמפלט `.docx` נקי מספיק לגמרי.
## הימנע מ-
- **אל תנסה** להוריד את ה-glossary רק מ-`[Content_Types].xml` בלי להסיר
את ה-`<Relationship>` שמפנה אליו → תקבל dangling reference.
- **אל תנסה** להשתמש ב-`.dotx` ישירות דרך `zipfile` + לבנות Document
ידנית — חוסך 10 שורות אבל מאבד את כל ה-robustness של python-docx.

View File

@@ -0,0 +1,115 @@
# סיווג שורות — `_classify_line()`
כל שורה של content מה-MD עוברת דרך `_classify_line()` שמחזירה
`(kind, clean_text)`. הקטגוריה מכתיבה איזה סגנון Word יוחל.
## טבלת הקטגוריות
| kind | regex | clean_text | נמפה ל-style |
|------|--------|-----------|--------------|
| `label_heading` | `^\s*\*\*([^\n*]+?):\*\*\s*$` | `"$1"` | `Heading 2` |
| `label_heading` (plain) | `^\s*([^\n:]{2,40}):\s*$` | `"$1"` | `Heading 2` |
| `inline_label` | `^\s*\*\*([^\n*]+?):\*\*\s+(.+)$` | `"$1\x00$2"` | `Normal` + bold label + value |
| `numbered` | `^\s*(\d+)[.)]\s+(.+)$` | `"$2"` | `List Paragraph` |
| `bullet` | `^\s*[\-\u2022\*\u25CF\u25E6]\s+(.+)$` | `"$1"` | `Normal` (marker stripped) |
| `heb_letter` | `^\s*\([א-ת]\)\s+` | **full line** (marker kept) | `List Paragraph` + `_strip_numpr()` |
| `plain` | fallback | line | `Normal` |
## סדר הבדיקות (חשוב!)
```
1. STANDALONE_LABEL_RE (**X:**)
2. INLINE_LABEL_RE (**X:** value)
3. NUMBERED_LINE_RE (1. X)
4. BULLET_LINE_RE (- X) + re-check inside:
4a. STANDALONE inside → label_heading
4b. INLINE inside → inline_label
5. HEB_LETTER_LINE_RE ((א) X)
6. PLAIN_LABEL_RE (X:) — last because it's broad
7. plain
```
## דוגמאות מהשטח
### input: `- **נקודות פתוחות:**`
- `BULLET_LINE_RE` תופס → `inner = "**נקודות פתוחות:**"`
- `STANDALONE_LABEL_RE` על ה-inner → `label_heading`, text = `"נקודות פתוחות"`
- יוצא כ-Heading 2.
### input: `- **נקודות פתוחות:** האם המקדם...`
- `BULLET_LINE_RE` תופס → inner = `"**נקודות פתוחות:** האם..."`
- `INLINE_LABEL_RE` על ה-inner → `inline_label`
- יוצא כ-Normal עם label "נקודות פתוחות:" bold + value רגיל.
### input: `1. **שאלה עקרונית:** האם נספח...`
- `NUMBERED_LINE_RE` תופס → `"**שאלה עקרונית:** האם נספח..."`
- יוצא כ-List Paragraph. ה-`**...**` בתוכו יעובד על ידי
`_add_runs_with_inline_bold()` (bold inline run).
### input: `(א) נספח הבינוי של תכנית...`
- `HEB_LETTER_LINE_RE` תופס
- יוצא כ-List Paragraph עם `_strip_numpr()` — כי המחבר כבר כתב "(א)".
### input: `העורר טוען כי:`
- לא תואם regex ספציפי
- `PLAIN_LABEL_RE` תופס (23 תווים, מסתיים ב-`:`)
- יוצא כ-Heading 2.
### input: `פסקה ארוכה: עם עוד תוכן ופסיק, וסוגיה מורכבת.`
- `PLAIN_LABEL_RE` לא תופס (יותר מ-40 תווים לפני `:`)
- נשאר `plain` → Normal.
## למה הגבלת `PLAIN_LABEL_RE` ל-`{2,40}`
בלי הגבלה, כל פסקה עם `:` במקום כלשהו הייתה הופכת ל-Heading 2. דוגמה
שצריך למנוע:
```
טענה חשובה כאן: היא שהוועדה שגתה בכל אופן.
```
אין כאן כוונה לכותרת — `:` הוא חלק ממשפט. ההגבלה ל-40 תווים מסננת את
רוב המקרים האלה כי רוב headings אמיתיים הם קצרים.
40 תווים זה ניחוש — אפשר לכוון אם מגלים false positives/negatives.
## inline bold — `_add_runs_with_inline_bold()`
אחרי סיווג, הטקסט עדיין יכול להכיל `**word**` באמצע. הפונקציה מחלקת
את המחרוזת ל-runs מתחלפים:
```
"העורר טוען **שהתוצאה** שגויה"
→ [
Run("העורר טוען ", bold=None),
Run("שהתוצאה", bold=True),
Run(" שגויה", bold=None),
]
```
כל run מסומן RTL בנפרד. יוצא ב-Word עם הדגש המקומי בלבד על המילה
`שהתוצאה`.
## השלמת התמונה
```
md content
↓ splitlines()
for line in lines:
↓ _classify_line(line)
→ (kind, clean_text)
↓ _emit_content_line(doc, line)
→ paragraph with chosen style
↓ _add_runs_with_inline_bold(paragraph, clean_text)
→ runs with inline **bold** rendered
↓ _mark_run_rtl / _mark_paragraph_rtl
→ Hebrew renders in David (cs slot)
```
## הוספת קטגוריה חדשה
אם יש דפוס שלא מזוהה ורצוי למפות אותו:
1. הוסף regex constant (למעלה בקובץ, אחרי הקיימים).
2. הוסף branch ב-`_classify_line()` לפי הסדר הנכון (ספציפי לפני כללי).
3. הוסף branch ב-`_emit_content_line()` עם הסגנון המתאים.
4. הוסף test case ב-references/line-classification.md (כאן).
5. הרץ על תיק מייצג (למשל 8070-25) וראה שהתוצאה נכונה.

View File

@@ -0,0 +1,81 @@
# למה `<w:rtl/>` חובה בכל run
## הבעיה
כשאתה יוצר `run` ב-python-docx על סגנון עברי מוגדר היטב (למשל Normal עם
`cs="David"`) — עברית עדיין יוצאת ב-Times New Roman.
## הסיבה
Word משתמש ב-3 font slots בתוך `<w:rFonts>`:
- `w:ascii` — תווים לטיניים
- `w:hAnsi` — אותיות מיוחדות אירופיות
- `w:cs` (complex script) — עברית, ערבית, תאית
ההחלטה איזה slot להשתמש נעשית **לפי סוג הטקסט ב-run** ולפי **דגל רמת
הריצה `<w:rtl/>`**. בלי הדגל, Word יכול להתייחס לטקסט העברי כ-LTR
(למשל כשהוא מתערבב עם ספרות/לטינית) ולבחור את `ascii` — Times New Roman.
## הפתרון
מסמן כל run עברי כ-complex-script:
```python
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
def _mark_run_rtl(run):
rPr = run._r.get_or_add_rPr()
if rPr.find(qn("w:rtl")) is None:
rPr.append(OxmlElement("w:rtl"))
```
וגם ברמת ה-paragraph (למקרה ש-paragraph mark עצמו משפיע):
```python
def _mark_paragraph_rtl(paragraph):
pPr = paragraph._p.get_or_add_pPr()
rPr = pPr.find(qn("w:rPr"))
if rPr is None:
rPr = OxmlElement("w:rPr"); pPr.append(rPr)
if rPr.find(qn("w:rtl")) is None:
rPr.append(OxmlElement("w:rtl"))
```
## תופעות לוואי של חוסר RTL ברמת ה-run
1. **Font fallback ל-Times New Roman** — הסימפטום הנפוץ ביותר.
2. **BiDi reordering של פיסוק** — נקודתיים, פסיקים, סוגריים עוברים למקום
הלא נכון. הסימפטום: `"(א)"` הופך ל-`")א("`.
3. **מספרים "נוגדים" ברצף עברי**`"בשנת 2024 פסקנו"` יכול להיראות
עם המספר במיקום הלא נכון.
## איך לבדוק שה-RTL חל
```python
from docx.oxml.ns import qn
for p in doc.paragraphs:
for r in p.runs:
rPr = r._r.find(qn("w:rPr"))
has_rtl = rPr is not None and rPr.find(qn("w:rtl")) is not None
if not has_rtl and any('\u0590' <= c <= '\u05FF' for c in r.text):
print(f"Missing RTL: {r.text[:40]!r}")
```
## זה לא מספיק רק ברמת הסגנון
זו תפיסה מוטעית נפוצה: "אם הסגנון כולל `<w:rtl/>` ב-`rPr`, ירש כל ריצה".
**לא נכון**. סגנון נותן ברירת מחדל ל-runs שעדיין לא נוצרו ב-Word GUI —
אבל runs שנוצרו דרך python-docx מקבלים `rPr` ריק, שלא תורש אוטומטית
את ה-rtl מהסגנון. לכן חייבים להוסיף ידנית.
## הטמפלט של דפנה כדוגמה
בוחנים את `word/document.xml` של הטמפלט המקורי — כל ריצה עברית כוללת:
```xml
<w:r><w:rPr><w:rFonts w:hint="cs"/><w:rtl/></w:rPr><w:t>רקע</w:t></w:r>
```
`<w:rtl/>` נמצא שם **במפורש**. אנחנו מחקים את זה.

View File

@@ -0,0 +1,155 @@
# מיפוי סגנונות — `טיוטת החלטה.dotx`
מבוסס על ניתוח `word/styles.xml` של הטמפלט. כל הגדרות מצוטטות מה-XML
המקורי.
## סגנונות בסיסיים (Word-idioms)
### `Normal` (styleId: `a1`)
ברירת המחדל לפסקאות רגילות. **כל שאר הסגנונות יורשים ממנו**
(`basedOn="a1"`).
```xml
<rPr>
<rFonts ascii="Times New Roman" hAnsi="Times New Roman" cs="David"/>
<sz val="26"/> <!-- 13pt -->
<szCs val="26"/>
</rPr>
<pPr>
<bidi/> <!-- RTL -->
<spacing before="120" after="120" line="360" lineRule="auto"/>
<ind left="-454"/> <!-- negative left indent -->
<jc val="both"/> <!-- justify -->
</pPr>
```
**מה זה אומר לך:**
- עברית תצא ב-David 13pt (דרך `cs`)
- לטינית תצא ב-Times New Roman 13pt (דרך `ascii`)
- יישור justify (דו-צדדי), RTL
- מרווח 1.5 שורות (`line=360` = 1.5 × 240)
### `Heading 1` (styleId: `10`, name: `heading 1`)
לכותרת ראשית (כמו "ניתוח משפטי וכתיבת עמדה בערר X").
```xml
<basedOn val="a1"/> <!-- יורש מ-Normal → cs="David" -->
<rPr><rFonts asciiTheme="majorHAnsi" .../><sz val="40"/></rPr> <!-- 20pt -->
```
Heading 1 **אינו** מציין cs fonts מפורשות, אבל יורש `cs="David"` מ-Normal.
זה מבדיל אותו מ-`Title`, שמפנה ל-theme ריק.
### `Heading 2` (styleId: `2`)
לכותרות מקטעים ותת-מקטעים.
```xml
<basedOn val="a1"/>
<pPr><keepNext/><spacing before="160"/><ind left="-567"/></pPr>
<rPr><b/><bCs/><u val="single"/></rPr> <!-- bold + underline -->
```
### `Title` (styleId: `a5`) — ⚠️ הימנע
```xml
<rPr><rFonts asciiTheme="majorHAnsi" cstheme="majorBidi" .../></rPr>
```
מפנה ל-theme. ב-`theme1.xml`: `majorFont.cs = ""` (ריק). → עברית נופלת
ל-`majorFont.latin = "Aptos Display"`. **השתמש ב-Heading 1 במקום.**
### `Subtitle` (styleId: `a7`) — ⚠️ אותה בעיה של Title
לא בשימוש.
## סגנונות תוכן
### `Quote` (styleId: `a9`)
```xml
<basedOn val="a1"/>
<pPr><spacing before="0" after="0" line="276"/>
<ind left="680" right="170"/></pPr> <!-- הזחה דו-צדדית -->
<rPr><b/><bCs/></rPr> <!-- bold -->
```
לציטוטי פסיקה. הזחה פנימה, bold.
### `List Paragraph` (styleId: `a0`)
```xml
<basedOn val="a1"/>
<pPr>
<numPr><numId val="1"/></numPr> <!-- auto-numbering -->
<spacing after="0"/>
<ind left="-125" hanging="357"/>
</pPr>
<rPr><rFonts ascii="David" hAnsi="David"/><sz val="28"/></rPr> <!-- 14pt -->
```
**numId=1 מפנה ל-abstractNumId=16**, שמוגדר כ-`decimal` עם `lvlText="%1."`.
כלומר Word יוסיף "1.", "2.", "3." אוטומטית לפני הטקסט.
**חשוב:** `List Paragraph` הוא היחיד בטמפלט עם numbering אוטומטי. אין
bullet style מוכן. לרשימות עם (א)(ב), **הסר את ה-numPr** ברמת הפסקה:
```python
from docx.oxml.ns import qn
def _strip_numpr(paragraph):
pPr = paragraph._p.get_or_add_pPr()
for numPr in pPr.findall(qn("w:numPr")):
pPr.remove(numPr)
```
## סגנונות נוספים בטמפלט (לא בשימוש כרגע)
| styleId | שם | מה |
|---------|----|----|
| `P00`, `P11`, `P22` | תבניות מותאמות אישית | ישן, נראה שלא בשימוש פעיל |
| `12`, `21`, `31` | פיסקת רשימה 1/2/3 | ללא numPr — שימוש ויזואלי בלבד |
| `-` | פיסקת רשימה - ללא מספור | מיועד ל-bullets ללא מספור |
| `14` | ציטוט1 | וריאציה ישנה של Quote |
| `af2` / `af4` | header / footer | לכותרות עליונות/תחתונות |
## theme (`word/theme/theme1.xml`)
```xml
<majorFont>
<latin typeface="Aptos Display"/>
<cs typeface=""/> <!-- ריק! -->
</majorFont>
<minorFont>
<latin typeface="Aptos"/>
<cs typeface=""/> <!-- ריק! -->
</minorFont>
```
**ה-cs ריק ב-theme** — זו הסיבה ש-`Title` (שמפנה ל-theme) לא עובד
לעברית. אל תסמוך על theme; השתמש ב-styles שמגדירים cs מפורש (Normal +
כל מה שיורש ממנו).
## numbering definitions
`word/numbering.xml` כולל ~22 abstractNum מוגדרים. הרלוונטי לנו:
```
numId=1 → abstractNumId=16 → numFmt=decimal, lvlText="%1."
```
יש גם hebrew1 (`numFmt=hebrew1` = א., ב., ג.) ב-`abstractNumId=0, 1`.
אף סגנון מוכן לא מפנה אליהם. אם תרצה בעתיד רשימה ממוספרת בעברית עם
Word auto-numbering — יש להזריק `numPr` ידנית עם `numId=29` (שמפנה
ל-abstractNumId=0).
## גדלים — רפרנס מהיר
| style | ascii | cs (עברית) |
|-------|-------|-----------|
| Normal | 13pt | 13pt |
| Heading 1 | 20pt | 18pt |
| Heading 2 | 13pt (ירושה) + bold + underline | אותו דבר |
| Title | 28pt (Aptos Display — לא עברית!) | — |
| List Paragraph | 14pt (David) | 14pt |
| Quote | 13pt (ירושה) + bold | 13pt |
## טיפ
כדי לבדוק את כל הסגנונות שבמסמך:
```python
from docx import Document
d = Document("skills/docx/decision_template.docx")
for s in sorted(d.styles, key=lambda x: x.name):
print(s.type, s.name, s.style_id)
```