diff --git a/.claude/agents/HEARTBEAT.md b/.claude/agents/HEARTBEAT.md
index 3137ef4..a45ff42 100644
--- a/.claude/agents/HEARTBEAT.md
+++ b/.claude/agents/HEARTBEAT.md
@@ -15,10 +15,25 @@
הרץ את הרשימה הזו בכל heartbeat.
-## 1. זיהוי
+## 1. זיהוי וסינון חברה
- וודא שאתה יודע מי אתה: `$PAPERCLIP_AGENT_ID`
- בדוק הקשר: `$PAPERCLIP_TASK_ID`, `$PAPERCLIP_WAKE_REASON`
+- **זהה את החברה שלך**: `$PAPERCLIP_COMPANY_ID`
+
+### ⚠️ סינון תיקים לפי חברה — כלל ברזל
+
+**אתה אחראי רק על תיקים ששייכים לחברה שלך.** הספרה הראשונה של מספר התיק קובעת:
+
+| חברה | COMPANY_ID | סוגי תיקים | טווח מספרים |
+|------|------------|-------------|-------------|
+| ועדת ערר רישוי ובניה | `42a7acd0-30c5-4cbd-ac97-7424f65df294` | רישוי ובניה | **1xxx** |
+| ועדת ערר היטלי השבחה | `8639e837-4c9d-47fa-a76b-95788d651896` | היטל השבחה + פיצויים ס' 197 | **8xxx, 9xxx** |
+
+- אם `$PAPERCLIP_COMPANY_ID` = `42a7acd0...` → עבוד רק על תיקים שמתחילים ב-**1**
+- אם `$PAPERCLIP_COMPANY_ID` = `8639e837...` → עבוד רק על תיקים שמתחילים ב-**8** או **9**
+- **לעולם אל תיצור פרויקט, issue, או תוכן לתיק שלא בטווח שלך**
+- אם issue שהוקצה לך מכוון לתיק שלא בטווח שלך — סרב בנימוס ודווח ב-comment
## 2. בדוק תיבת דואר
@@ -102,11 +117,25 @@ curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
**אסור** לסיים issue כ-"done" אם יש כשל שלא טופל. "done" = הכל הושלם בהצלחה. אם משהו נכשל — "blocked".
### 4ג. העֵר את העוזר המשפטי (CEO) — חובה!
-אחרי כל סיום משימה (done או blocked), **העֵר את העוזר המשפטי** כדי שיבדוק תוצאות ויחליט על הצעד הבא:
+אחרי כל סיום משימה (done או blocked), **העֵר את העוזר המשפטי של החברה שלך** כדי שיבדוק תוצאות ויחליט על הצעד הבא:
+
+**⚠️ בחר CEO לפי חברה:**
+| חברה | COMPANY_ID | CEO Agent ID |
+|------|------------|-------------|
+| רישוי ובניה (CMP) | `42a7acd0-...` | `752cebdd-6748-4a04-aacd-c7ab0294ef33` |
+| היטלי השבחה (CMPA) | `8639e837-...` | `cdbfa8bc-3d61-41a4-a2e7-677ec7d34562` |
+
```bash
+# קבע CEO_ID לפי חברה:
+if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
+ CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562"
+else
+ CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33"
+fi
+
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
- "$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
+ "$PAPERCLIP_API_URL/api/agents/$CEO_ID/wakeup" \
-d '{"source":"automation","triggerDetail":"system","reason":"סוכן [שמך] סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
```
diff --git a/.claude/agents/legal-qa.md b/.claude/agents/legal-qa.md
index 5f63596..a66179b 100644
--- a/.claude/agents/legal-qa.md
+++ b/.claude/agents/legal-qa.md
@@ -24,6 +24,14 @@ tools:
עבוד תמיד בעברית.
+## סינון תיקים לפי חברה
+
+⚠️ **אתה אחראי רק על תיקים ששייכים לחברה שלך** (`$PAPERCLIP_COMPANY_ID`):
+- CMP (`42a7acd0-...`) → רק תיקים **1xxx** (רישוי ובניה)
+- CMPA (`8639e837-...`) → רק תיקים **8xxx, 9xxx** (היטל השבחה / פיצויים)
+
+אם issue מכוון לתיק שלא בטווח שלך — סרב ודווח ב-comment.
+
## 6 בדיקות
### 1. שלמות מבנית (structural_integrity)
diff --git a/.claude/agents/legal-researcher.md b/.claude/agents/legal-researcher.md
index a417beb..d1e70dc 100644
--- a/.claude/agents/legal-researcher.md
+++ b/.claude/agents/legal-researcher.md
@@ -27,6 +27,14 @@ tools:
עבוד תמיד בעברית.
+## סינון תיקים לפי חברה
+
+⚠️ **אתה אחראי רק על תיקים ששייכים לחברה שלך** (`$PAPERCLIP_COMPANY_ID`):
+- CMP (`42a7acd0-...`) → רק תיקים **1xxx** (רישוי ובניה)
+- CMPA (`8639e837-...`) → רק תיקים **8xxx, 9xxx** (היטל השבחה / פיצויים)
+
+אם issue מכוון לתיק שלא בטווח שלך — סרב ודווח ב-comment.
+
## לפני שאתה מתחיל — קרא!
1. **מתודולוגיה אנליטית**: `docs/decision-methodology.md` — במיוחד סעיפים ד.2 (התחל מלשון הטקסט), ד.3 (שלושה מקורות להנחה עליונה), ז (ציטוטים ואזכורי פסיקה)
diff --git a/mcp-server/src/legal_mcp/services/analysis_docx_exporter.py b/mcp-server/src/legal_mcp/services/analysis_docx_exporter.py
new file mode 100644
index 0000000..86bebf1
--- /dev/null
+++ b/mcp-server/src/legal_mcp/services/analysis_docx_exporter.py
@@ -0,0 +1,503 @@
+"""Export the legal analysis (analysis-and-research.md + precedents) to a
+DOCX file that uses דפנה's decision template styles.
+
+The template lives at `skills/docx/decision_template.docx` (converted once
+from `טיוטת החלטה.dotx` via `scripts/convert_decision_template.py`).
+We open it, wipe the sample body paragraphs, and write new content by
+applying style names only — never by hand-setting font/size/RTL/margins,
+because the template's styles.xml already carries those.
+
+Style mapping:
+ "Title" → the document title (case number, date)
+ "Heading 2" → top-level section headers
+ (טענות סף / סוגיות להכרעה / מסקנות)
+ "Normal" + bold → subsection headers (individual claim/issue)
+ "Normal" → field label (bold run) + value
+ "Quote" → precedent quote text
+ "Normal" (italic) → precedent citation
+
+Output: data/cases/{case_number}/exports/ניתוח-משפטי-v{N}.docx
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from typing import Any
+from uuid import UUID
+
+from docx import Document
+from docx.document import Document as DocumentT
+from docx.oxml.ns import qn
+from docx.oxml import OxmlElement
+from docx.text.paragraph import Paragraph
+from docx.text.run import Run
+
+from legal_mcp import config
+from legal_mcp.services import db, research_md
+
+
+def _mark_run_rtl(run: Run) -> None:
+ """Mark a run as complex-script (Hebrew/Arabic) so Word uses the `cs`
+ font slot from the style (David) rather than `ascii` (Times New Roman).
+
+ Without this, runs we add programmatically render Hebrew in the ascii
+ font — even though the paragraph style has ``.
+ """
+ rPr = run._r.get_or_add_rPr()
+ if rPr.find(qn("w:rtl")) is None:
+ rPr.append(OxmlElement("w:rtl"))
+
+
+def _mark_paragraph_rtl(paragraph: Paragraph) -> None:
+ """Add `` inside the paragraph's rPr so the paragraph mark
+ itself is treated as RTL. The paragraph style already sets bidi
+ direction, but empty paragraphs and trailing marks need this flag.
+ """
+ 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"))
+
+# Path to the converted template. Static — populated by
+# scripts/convert_decision_template.py.
+TEMPLATE_PATH = (
+ Path(__file__).resolve().parents[4]
+ / "skills"
+ / "docx"
+ / "decision_template.docx"
+)
+
+CHAIR_POSITION_LABEL = "עמדת ועדת הערר"
+CHAIR_POSITION_PLACEHOLDER = "[טרם מולאה עמדת ועדת הערר]"
+
+NUMBERED_LINE_RE = re.compile(r"^\s*(\d+)[.)]\s+(.+)$")
+BULLET_LINE_RE = re.compile(r"^\s*[\-\u2022\*\u25CF\u25E6]\s+(.+)$")
+# (א) (ב) (ג) ... — Hebrew-letter enumeration used by the authors.
+# We keep the marker inside the text (the author wrote it), but render the
+# paragraph as "List Paragraph" without the numPr so the visual indentation
+# matches the template's list style without adding a double "1." prefix.
+HEB_LETTER_LINE_RE = re.compile(r"^\s*\([א-ת]\)\s+")
+
+# A standalone **LABEL:** line (the whole trimmed line is wrapped in ** **)
+STANDALONE_LABEL_RE = re.compile(r"^\s*\*\*([^\n*]+?):\*\*\s*$")
+# A short standalone "XYZ:" line (no ** **) — acts as a sub-heading for the
+# paragraphs that follow. Limit to short phrases to avoid eating real
+# sentences that happen to end with a colon.
+PLAIN_LABEL_RE = re.compile(r"^\s*([^\n:]{2,40}):\s*$")
+# "**LABEL:** value" inline — bold label followed by prose on the same line.
+INLINE_LABEL_RE = re.compile(r"^\s*\*\*([^\n*]+?):\*\*\s+(.+)$")
+
+
+def _classify_line(line: str) -> tuple[str, str]:
+ """Return (kind, clean_text) where kind ∈ {numbered, bullet, heb_letter,
+ label_heading, inline_label, plain}.
+
+ clean_text conventions:
+ - numbered/bullet — marker stripped
+ - heb_letter — marker kept (author supplied it)
+ - label_heading — surrounding ** and trailing : stripped
+ - inline_label — "LABEL\x00VALUE" (NUL-separated; _emit splits it)
+ """
+ m = STANDALONE_LABEL_RE.match(line)
+ if m:
+ return "label_heading", m.group(1).strip()
+ m = INLINE_LABEL_RE.match(line)
+ if m:
+ return "inline_label", f"{m.group(1).strip()}\x00{m.group(2).strip()}"
+ m = NUMBERED_LINE_RE.match(line)
+ if m:
+ return "numbered", m.group(2).strip()
+ m = BULLET_LINE_RE.match(line)
+ if m:
+ inner = m.group(1).strip()
+ # A bullet whose only content is **LABEL:** is a heading, not a list item.
+ # E.g. "- **נקודות פתוחות:**"
+ m2 = STANDALONE_LABEL_RE.match(inner)
+ if m2:
+ return "label_heading", m2.group(1).strip()
+ # A bullet of the form "- **LABEL:** value" → inline label.
+ m3 = INLINE_LABEL_RE.match(inner)
+ if m3:
+ return "inline_label", f"{m3.group(1).strip()}\x00{m3.group(2).strip()}"
+ return "bullet", inner
+ if HEB_LETTER_LINE_RE.match(line):
+ return "heb_letter", line.strip()
+ m = PLAIN_LABEL_RE.match(line)
+ if m:
+ return "label_heading", m.group(1).strip()
+ return "plain", line.strip()
+
+
+def _strip_numpr(paragraph: Paragraph) -> None:
+ """Remove any from the paragraph's pPr.
+
+ Used when we want the visual styling of `List Paragraph` (indent,
+ font) without Word's auto-decimal "1." prefix — e.g. for Hebrew-
+ letter enumeration where the author wrote (א) (ב) (ג) manually.
+ """
+ pPr = paragraph._p.get_or_add_pPr()
+ for numPr in pPr.findall(qn("w:numPr")):
+ pPr.remove(numPr)
+
+
+# Characters that the code should never emit (user instruction: "no dashes").
+# Applied only to code-generated text, not to user content from the md file.
+_CODE_DASH_RE = re.compile(r"[\u2013\u2014]")
+
+# Markdown inline bold — `**...**`
+_INLINE_BOLD_RE = re.compile(r"\*\*([^\n*]+?)\*\*")
+
+
+def _no_dash(text: str) -> str:
+ """Strip em/en dashes from text the code emits (not from source content)."""
+ return _CODE_DASH_RE.sub("", text)
+
+
+def _add_runs_with_inline_bold(paragraph: Paragraph, text: str) -> None:
+ """Split `text` on `**...**` markers, adding alternating plain and bold
+ runs to `paragraph`. All runs are marked RTL and passed through
+ `_no_dash`.
+
+ This keeps `**טענה חשובה**` rendering as bold (as the author intended)
+ instead of leaving the literal asterisks in the output.
+ """
+ text = _no_dash(text)
+ pos = 0
+ for m in _INLINE_BOLD_RE.finditer(text):
+ if m.start() > pos:
+ plain = paragraph.add_run(text[pos : m.start()])
+ _mark_run_rtl(plain)
+ bold = paragraph.add_run(m.group(1))
+ bold.bold = True
+ _mark_run_rtl(bold)
+ pos = m.end()
+ if pos < len(text):
+ tail = paragraph.add_run(text[pos:])
+ _mark_run_rtl(tail)
+
+
+def _clear_body(doc: DocumentT) -> None:
+ """Remove every paragraph currently in the document body.
+
+ The template ships with example paragraphs ("רקע", "דיון והכרעה"…)
+ that we don't want in the output. Section properties (sectPr) are
+ kept so page size / margins / RTL / footer remain intact.
+ """
+ body = doc.element.body
+ for p in list(body.findall(qn("w:p"))):
+ body.remove(p)
+ # Leave sectPr alone — it carries page setup including bidi.
+
+
+def _add_paragraph(doc: DocumentT, text: str, style: str) -> Paragraph:
+ p = doc.add_paragraph(style=style)
+ _mark_paragraph_rtl(p)
+ if text:
+ _add_runs_with_inline_bold(p, text)
+ return p
+
+
+def _add_label_value(
+ doc: DocumentT, label: str, value: str, *, value_italic: bool = False
+) -> Paragraph:
+ """Add a paragraph with a bold label and an inline value.
+
+ Example rendering: **עמדת המבקשת:** The party argues that…
+ """
+ p = doc.add_paragraph(style="Normal")
+ _mark_paragraph_rtl(p)
+ run_label = p.add_run(f"{_no_dash(label)}: ")
+ run_label.bold = True
+ _mark_run_rtl(run_label)
+ if value:
+ if value_italic:
+ # Placeholder text — italic, no inline-bold handling.
+ run_value = p.add_run(_no_dash(value))
+ run_value.italic = True
+ _mark_run_rtl(run_value)
+ else:
+ _add_runs_with_inline_bold(p, value)
+ return p
+
+
+def _add_multiline_value(
+ doc: DocumentT, label: str, value: str
+) -> None:
+ """Render a field (label + value).
+
+ Multi-line values get the label as its own Heading 2 paragraph (so the
+ structure visually breaks between fields), then each body line as its
+ own paragraph routed through `_emit_content_line`.
+
+ Single-line values stay inline (bold label + text) — a Heading 2 for
+ a one-liner would look inflated.
+ """
+ lines = [ln for ln in value.splitlines() if ln.strip()]
+ if not lines:
+ _add_label_value(doc, label, "")
+ return
+ if len(lines) == 1:
+ kind, text = _classify_line(lines[0])
+ # Single-line — inline with label regardless of kind
+ _add_label_value(doc, label, text)
+ return
+ # Multi-line: label as Heading 2, then each line via _emit_content_line
+ _add_paragraph(doc, label, "Heading 2")
+ for line in lines:
+ _emit_content_line(doc, line)
+
+
+def _emit_content_line(doc: DocumentT, line: str) -> None:
+ """Render a single line of content using the right template style.
+
+ - `label_heading` (e.g. "**נקודות פתוחות:**" alone) → Heading 2
+ - `numbered` ("1. ...") → List Paragraph
+ (auto-decimal)
+ - `heb_letter` ("(א) ...") → List Paragraph
+ with numPr stripped
+ (author supplied
+ the marker)
+ - `bullet` ("- ...") → Normal (marker
+ stripped)
+ - `plain` → Normal
+ """
+ kind, text = _classify_line(line)
+
+ if kind == "label_heading":
+ _add_paragraph(doc, text, "Heading 2")
+ return
+
+ if kind == "inline_label":
+ label, value = text.split("\x00", 1)
+ _add_label_value(doc, label, value)
+ return
+
+ if kind == "numbered":
+ para = doc.add_paragraph(style="List Paragraph")
+ elif kind == "heb_letter":
+ para = doc.add_paragraph(style="List Paragraph")
+ _strip_numpr(para)
+ else:
+ para = doc.add_paragraph(style="Normal")
+ _mark_paragraph_rtl(para)
+ _add_runs_with_inline_bold(para, text)
+
+
+def _format_subsection_title(item: dict[str, Any], kind_label: str) -> str:
+ """Return '{kind_label} {number}: {title}' e.g. 'טענת סף 1: חוסר סמכות'."""
+ number = item.get("number") or ""
+ title = item.get("title", "").strip()
+ if number and title:
+ return f"{kind_label} {number}: {title}"
+ if title:
+ return title
+ return f"{kind_label} {number}".strip()
+
+
+def _write_subsection(
+ doc: DocumentT,
+ item: dict[str, Any],
+ precedents_for_item: list[dict[str, Any]],
+ kind_label: str,
+) -> None:
+ # Subsection header — bolded Normal paragraph, not a Heading,
+ # so it visually sits under the section's Heading 2.
+ header_text = _format_subsection_title(item, kind_label)
+ p = doc.add_paragraph(style="Normal")
+ _mark_paragraph_rtl(p)
+ run = p.add_run(_no_dash(header_text))
+ run.bold = True
+ _mark_run_rtl(run)
+
+ # Regular fields (party positions, legal questions, etc.)
+ for field in item.get("fields", []):
+ label = field.get("label", "").strip()
+ content = field.get("content", "").strip()
+ if not label:
+ continue
+ _add_multiline_value(doc, label, content)
+
+ # Chair position — special handling: always render, use placeholder if empty.
+ chair_position = (item.get("chair_position") or "").strip()
+ if chair_position:
+ _add_multiline_value(doc, CHAIR_POSITION_LABEL, chair_position)
+ else:
+ _add_label_value(
+ doc, CHAIR_POSITION_LABEL, CHAIR_POSITION_PLACEHOLDER,
+ value_italic=True,
+ )
+
+ # Precedents attached to this subsection
+ if precedents_for_item:
+ p = doc.add_paragraph(style="Normal")
+ _mark_paragraph_rtl(p)
+ run = p.add_run("פסיקה רלוונטית:")
+ run.bold = True
+ _mark_run_rtl(run)
+ for prec in precedents_for_item:
+ quote = (prec.get("quote") or "").strip()
+ citation = (prec.get("citation") or "").strip()
+ if quote:
+ _add_paragraph(doc, quote, "Quote")
+ if citation:
+ cite_p = doc.add_paragraph(style="Normal")
+ _mark_paragraph_rtl(cite_p)
+ cite_run = cite_p.add_run(_no_dash(citation))
+ cite_run.italic = True
+ _mark_run_rtl(cite_run)
+
+
+def _add_background_section(
+ doc: DocumentT, title: str, body: str | None
+) -> None:
+ """Render a background H2 section (e.g. "רקע דיוני") from a prose
+ body. Lines are routed through `_emit_content_line` so bullets,
+ `**labels:**`, and (א) enumerations all get the template styles.
+ """
+ if not body or not body.strip():
+ return
+ _add_paragraph(doc, title, "Heading 2")
+ for raw in body.splitlines():
+ if not raw.strip():
+ continue
+ _emit_content_line(doc, raw)
+
+
+def _group_precedents(
+ precedents: list[dict[str, Any]],
+) -> tuple[list[dict], dict[str, list[dict]]]:
+ """Split the flat precedent list into case-level and per-section maps.
+
+ Returns (case_level_precedents, {section_id: [precedents]}).
+ """
+ case_level: list[dict] = []
+ by_section: dict[str, list[dict]] = {}
+ for p in precedents:
+ sid = p.get("section_id")
+ if sid is None:
+ case_level.append(p)
+ else:
+ by_section.setdefault(sid, []).append(p)
+ return case_level, by_section
+
+
+def _next_version(export_dir: Path) -> int:
+ """Return the next version number for ניתוח-משפטי-v{N}.docx."""
+ existing = sorted(export_dir.glob("ניתוח-משפטי-v*.docx"))
+ next_ver = 1
+ for p in existing:
+ try:
+ ver = int(p.stem.split("-v")[1])
+ except (IndexError, ValueError):
+ continue
+ next_ver = max(next_ver, ver + 1)
+ return next_ver
+
+
+async def build_analysis_docx(case_number: str) -> Path:
+ """Build a DOCX of the legal analysis for a case using the template
+ styles, and save a versioned copy under the case's exports folder.
+
+ Raises FileNotFoundError if no analysis file or template exists.
+ """
+ if not TEMPLATE_PATH.exists():
+ raise FileNotFoundError(
+ f"Template not found at {TEMPLATE_PATH}. "
+ "Run: python scripts/convert_decision_template.py"
+ )
+
+ case_dir = config.find_case_dir(case_number)
+ analysis_path = case_dir / "documents" / "research" / "analysis-and-research.md"
+ if not analysis_path.exists():
+ raise FileNotFoundError(
+ f"Analysis file not found for case {case_number}"
+ )
+
+ parsed = research_md.parse(analysis_path)
+
+ # Resolve case_id so we can fetch precedents. Missing case → proceed
+ # without precedents rather than failing the export.
+ case_level_precedents: list[dict] = []
+ precedents_by_section: dict[str, list[dict]] = {}
+ case = await db.get_case_by_number(case_number)
+ if case:
+ precedents = await db.list_case_precedents(UUID(case["id"]))
+ case_level_precedents, precedents_by_section = _group_precedents(precedents)
+
+ doc = Document(str(TEMPLATE_PATH))
+ _clear_body(doc)
+
+ # Document title
+ header = parsed.get("header", {})
+ date = header.get("date", "").strip()
+ title_text = f"ניתוח משפטי וכתיבת עמדה בערר {case_number}"
+ _add_paragraph(doc, title_text, "Heading 1")
+ if date:
+ p_date = doc.add_paragraph(style="Normal")
+ _mark_paragraph_rtl(p_date)
+ run_date = p_date.add_run(f"תאריך: {date}")
+ _mark_run_rtl(run_date)
+
+ # Background sections — printed first so the reader gets context
+ # before any claims/precedents. These come only in the exported DOCX,
+ # not in the web UI (the UI renders them elsewhere).
+ _add_background_section(doc, "רקע לניתוח", parsed.get("represented_party"))
+ _add_background_section(doc, "רקע דיוני", parsed.get("procedural_background"))
+ _add_background_section(doc, "עובדות מוסכמות", parsed.get("agreed_facts"))
+ _add_background_section(
+ doc, "עובדות שנויות במחלוקת", parsed.get("disputed_facts")
+ )
+
+ # Case-level precedents appear at the top (they cut across claims/issues)
+ if case_level_precedents:
+ _add_paragraph(doc, "פסיקה כללית", "Heading 2")
+ for prec in case_level_precedents:
+ quote = (prec.get("quote") or "").strip()
+ citation = (prec.get("citation") or "").strip()
+ if quote:
+ _add_paragraph(doc, quote, "Quote")
+ if citation:
+ cp = doc.add_paragraph(style="Normal")
+ _mark_paragraph_rtl(cp)
+ cr = cp.add_run(_no_dash(citation))
+ cr.italic = True
+ _mark_run_rtl(cr)
+
+ # Threshold claims
+ threshold_claims = parsed.get("threshold_claims", [])
+ if threshold_claims:
+ _add_paragraph(doc, "טענות סף", "Heading 2")
+ for tc in threshold_claims:
+ _write_subsection(
+ doc, tc, precedents_by_section.get(tc["id"], []), "טענת סף"
+ )
+
+ # Issues
+ issues = parsed.get("issues", [])
+ if issues:
+ _add_paragraph(doc, "סוגיות להכרעה", "Heading 2")
+ for iss in issues:
+ _write_subsection(
+ doc, iss, precedents_by_section.get(iss["id"], []), "סוגיה"
+ )
+
+ # Conclusions
+ conclusions = (parsed.get("conclusions") or "").strip()
+ if conclusions:
+ _add_paragraph(doc, "מסקנות", "Heading 2")
+ for raw in conclusions.splitlines():
+ if not raw.strip():
+ continue
+ _emit_content_line(doc, raw)
+
+ # Save versioned
+ export_dir = case_dir / "exports"
+ export_dir.mkdir(parents=True, exist_ok=True)
+ version = _next_version(export_dir)
+ out_path = export_dir / f"ניתוח-משפטי-v{version}.docx"
+ doc.save(str(out_path))
+ return out_path
diff --git a/scripts/convert_decision_template.py b/scripts/convert_decision_template.py
new file mode 100644
index 0000000..676e688
--- /dev/null
+++ b/scripts/convert_decision_template.py
@@ -0,0 +1,102 @@
+"""Convert דפנה's decision .dotx template to a loadable .docx file.
+
+python-docx cannot open .dotx files directly (content type is
+`...template.main+xml` rather than `...document.main+xml`). This script
+produces a sibling .docx by rewriting [Content_Types].xml and dropping
+the `word/glossary/` part (which is template-specific and can interfere
+with plain Document() loading).
+
+The output preserves every style definition, numbering, fonts, and
+section properties — the only things we want from the template.
+
+Run once (or whenever the source .dotx changes):
+
+ python scripts/convert_decision_template.py
+
+Input: data/training/טיוטת החלטה.dotx
+Output: skills/docx/decision_template.docx
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+import zipfile
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+SRC = REPO_ROOT / "data" / "training" / "טיוטת החלטה.dotx"
+DST = REPO_ROOT / "skills" / "docx" / "decision_template.docx"
+
+TEMPLATE_CONTENT_TYPE = (
+ "application/vnd.openxmlformats-officedocument."
+ "wordprocessingml.template.main+xml"
+)
+DOCUMENT_CONTENT_TYPE = (
+ "application/vnd.openxmlformats-officedocument."
+ "wordprocessingml.document.main+xml"
+)
+
+
+def convert(src: Path, dst: Path) -> None:
+ if not src.exists():
+ raise FileNotFoundError(f"Template not found: {src}")
+ dst.parent.mkdir(parents=True, exist_ok=True)
+
+ with zipfile.ZipFile(src, "r") as zin:
+ names = zin.namelist()
+ with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
+ for name in names:
+ # Drop glossary part — template-only, confuses Document()
+ if name.startswith("word/glossary/"):
+ continue
+ data = zin.read(name)
+ if name == "[Content_Types].xml":
+ text = data.decode("utf-8")
+ text = text.replace(
+ TEMPLATE_CONTENT_TYPE, DOCUMENT_CONTENT_TYPE
+ )
+ # Drop every that points at /word/glossary/...
+ text = re.sub(
+ r']*?/>',
+ "",
+ text,
+ )
+ data = text.encode("utf-8")
+ elif name == "word/_rels/document.xml.rels":
+ # Strip the glossaryDocument relationship — the target
+ # part is being removed, so the ref would dangle.
+ text = data.decode("utf-8")
+ text = re.sub(
+ r']*?glossaryDocument[^>]*?/>',
+ "",
+ text,
+ )
+ data = text.encode("utf-8")
+ zout.writestr(name, data)
+
+
+def verify(dst: Path) -> None:
+ """Load with python-docx and print a few style names to confirm it works."""
+ from docx import Document
+
+ doc = Document(str(dst))
+ key_styles = {"Normal", "Heading 2", "Quote", "List Paragraph", "Title"}
+ found = {s.name for s in doc.styles if s.name in key_styles}
+ missing = key_styles - found
+ if missing:
+ print(f"WARN: missing styles: {missing}", file=sys.stderr)
+ else:
+ print(f"OK — all key styles present: {sorted(found)}")
+
+
+def main() -> None:
+ print(f"Source: {SRC}")
+ print(f"Dest: {DST}")
+ convert(SRC, DST)
+ print(f"Wrote {DST.stat().st_size:,} bytes")
+ verify(DST)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/skills/docx/decision_template.docx b/skills/docx/decision_template.docx
new file mode 100644
index 0000000..1d5e6cc
Binary files /dev/null and b/skills/docx/decision_template.docx differ
diff --git a/web-ui/src/app/cases/[caseNumber]/compose/page.tsx b/web-ui/src/app/cases/[caseNumber]/compose/page.tsx
index 6d7dbbd..acc2d0d 100644
--- a/web-ui/src/app/cases/[caseNumber]/compose/page.tsx
+++ b/web-ui/src/app/cases/[caseNumber]/compose/page.tsx
@@ -103,6 +103,18 @@ function AnalysisActions({
הורד ניתוח
)}
+ {hasAnalysis && (
+
+ )}