Merge pull request 'feat(analysis): סיכום-מנהלים מזוקק של טענות הצדדים — מסמך הכנה לדיון (#202, WS3)' (#358) from worktree-agent-abc25c98bc1092fa3 into main
Some checks failed
G12 Leak-Guard / leak-guard (push) Has been cancelled
Build & Deploy / build-and-deploy (push) Has been cancelled
Lint — undefined names / undefined-names (push) Has been cancelled

This commit was merged in pull request #358.
This commit is contained in:
2026-06-30 12:18:09 +00:00
7 changed files with 606 additions and 4 deletions

View File

@@ -50,7 +50,7 @@
### 1.3 ניתוח-פרוטוקול — ניתוח פרוטוקול-דיון השוואתי (WS4 / #203)
> **בעלוּת תת-סעיף:** §1.3 (זה) שייך ל-WS4 (ניתוח-פרוטוקול). תת-סעיף סיכום-טענות-הצדדים
> (PR-אחות #358) הוא **תת-סעיף נפרד** תחת §1 — אין חפיפת-מספור בין השניים.
> (PR-אחות #358) הוא **תת-סעיף נפרד** (§1.4) תחת §1 — אין חפיפת-מספור בין השניים.
אחרי הדיון, פרוטוקול (`doc_type='protocol'`) מנותח **מול הטיעונים המאוגדים**
`analyze_protocol(case_number)` (→ `services/protocol_analyzer.py`). הניתוח מסווג כל
@@ -75,6 +75,28 @@ knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arg
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
`model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`).
### 1.4 סיכום-מנהלים של טענות הצדדים (מסמך-הכנה לדיון, WS3/#202)
`summarize_party_claims(case_number, instructions="")` מפיק **מסמך-פרוזה מזוקק** של
טענות הצדדים — תמצית-מנהלים קצרה ומוקפדת שמטרתה **להכין את היו"ר לדיון בעל-פה**. זהו
מסמך **נפרד ומובחן מטיוטת-ההחלטה ומטיוטת-הביניים** (החלטת-יו"ר, [תוכנית workflow-redesign](../../.claude/plans/groovy-doodling-token.md)
§WS3) — אינו חלק מ-12-הבלוקים ואינו נכתב לתבנית ההחלטה.
- **מקור-אמת יחיד (G2):** המסמך נגזר מ-`legal_arguments` (טיעונים מאוגדים) או, כ-fallback,
מ-`claims` הגולמיים — **אותו מקור** של §§1.11.2, **ללא חילוץ-מחדש** ובלי לקרוא לכתבי-הטענות
ישירות. אין מסלול-נתונים מקביל.
- **זיקוק, לא שכפול:** התמצית מתמצתת כל צד למשפטי-מפתח ומוסיפה פרק "נקודות-המחלוקת המרכזיות"
כשאלות פתוחות — לא משכפלת את כתבי-הטענות ולא מכריעה.
- **עיגון-מקור (INV-AH):** הפרומפט מתוחם לחלוטין לטענות-התיק שבקלט; אסור להמציא טענה/הלכה/
פסק-דין/עובדה שאינם בקלט — טענה לא-ברורה מצוינת במפורש ([anti-hallucination-gate](../anti-hallucination-gate.md)).
- **ייצור local-only:** עובר `claude_session``claude -p` נעוץ ל-Opus 4.8 + `effort=high`
(משימת זיקוק/סינתזה). הקונטיינר חסר ה-CLI — לכן הייצור הוא כלי-MCP מקומי בלבד; endpoints
ב-`web/app.py` רק **מגישים/מייצאים** את הקובץ השמור, לא מייצרים.
- **שמירה + ייצוא:** נשמר ל-`data/cases/{n}/documents/research/party-claims-summary.md`
(git + S3, באותו מסלול-אחסון של `analysis-and-research.md`); ניתן-לייצוא ל-DOCX בסגנון-תבנית
דפנה (`build_party_claims_summary_docx`). מימוש: `tools/drafting.py` ·
`services/party_claims_summary.py` · `services/analysis_docx_exporter.py`.
---
## 2. ארכיטקטורת 12 הבלוקים (סיכום)

View File

@@ -835,6 +835,12 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
return await drafting.export_interim_draft(case_number, output_path)
@mcp.tool()
async def summarize_party_claims(case_number: str, instructions: str = "") -> str:
"""סיכום-מנהלים מזוקק של טענות הצדדים — מסמך פרוזה נפרד מטיוטת-ההחלטה, להכנת היו"ר לדיון. נגזר מ-legal_arguments/claims (אותו מקור), נעוץ Opus 4.8, נשמר וניתן-לייצוא."""
return await drafting.summarize_party_claims(case_number, instructions)
@mcp.tool()
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""

View File

@@ -386,9 +386,9 @@ def _group_precedents(
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"))
def _next_version(export_dir: Path, prefix: str = "ניתוח-משפטי") -> int:
"""Return the next version number for {prefix}-v{N}.docx in export_dir."""
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
next_ver = 1
for p in existing:
try:
@@ -511,3 +511,77 @@ async def build_analysis_docx(case_number: str) -> Path:
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
return out_path
# ── Generic markdown → DOCX (party-claims summary, #202) ───────────
# ATX-heading markers → template heading style. The party-claims summary is a
# plain markdown document (not the structured analysis-and-research.md), so it
# is rendered line-by-line through the same template machinery.
_ATX_HEADING_RE = re.compile(r"^(#{1,4})\s+(.+)$")
_HR_RE = re.compile(r"^\s*[-*_]{3,}\s*$")
def _render_markdown_body(doc: DocumentT, markdown: str) -> None:
"""Render a markdown string into the doc using the template styles.
Handles ATX headings (# .. ####), horizontal rules (skipped), and delegates
every other line to _emit_content_line (bullets, numbered, bold-labels, plain).
"""
for raw in markdown.splitlines():
line = raw.rstrip()
if not line.strip():
continue
if _HR_RE.match(line):
continue
m = _ATX_HEADING_RE.match(line.strip())
if m:
level = len(m.group(1))
# # → Heading 1 (doc title), ## → Heading 1, ### / #### → Heading 2.
style = "Heading 1" if level <= 2 else "Heading 2"
_add_paragraph(doc, m.group(2).strip(), style)
continue
_emit_content_line(doc, line)
async def build_party_claims_summary_docx(case_number: str) -> Path:
"""Build a DOCX of the party-claims executive summary using the template
styles, saved versioned under the case's exports folder.
Reads the saved party-claims-summary.md (produced by the summarize_party_claims
MCP tool — generation is local-only). Raises FileNotFoundError if the summary
or the template is missing.
"""
from legal_mcp.services import party_claims_summary
if not TEMPLATE_PATH.exists():
raise FileNotFoundError(
f"Template not found at {TEMPLATE_PATH}. "
"Run: python scripts/convert_decision_template.py"
)
summary_path = party_claims_summary.summary_file_path(case_number)
if not summary_path.exists():
raise FileNotFoundError(
f"Party-claims summary not found for case {case_number}. "
"Run summarize_party_claims first."
)
markdown = summary_path.read_text(encoding="utf-8")
doc = Document(str(TEMPLATE_PATH))
_clear_body(doc)
_render_markdown_body(doc, markdown)
export_dir = config.find_case_dir(case_number) / "exports"
export_dir.mkdir(parents=True, exist_ok=True)
_PREFIX = "סיכום-מנהלים-טענות"
out_path = export_dir / f"{_PREFIX}-v{_next_version(export_dir, _PREFIX)}.docx"
buf = io.BytesIO()
doc.save(buf)
await storage.put_bytes(
out_path.relative_to(config.DATA_DIR).as_posix(), buf.getvalue(),
bucket=storage.Bucket.DOCUMENTS,
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
return out_path

View File

@@ -0,0 +1,208 @@
"""סיכום-מנהלים של טענות הצדדים — מסמך פרוזה מזוקק להכנת היו"ר לדיון (WS3, #202).
מסמך זה הוא **נפרד ומובחן מטיוטת-הביניים** (החלטת-יו"ר, תוכנית workflow-redesign §WS3) —
תמצית מנהלים קצרה ומוקפדת של טענות הצדדים והתשובות זו-לזו, להכנה לדיון בעל-פה. אין הוא
משכפל את כתבי-הטענות המלאים: הוא מזקק.
מקור-האמת היחיד (G2): הוא נגזר מ-``legal_arguments`` (טיעונים מאוגדים) ומ-``claims``
הקיימים בתיק — לא מחלץ-מחדש ולא קורא לכתבי-הטענות הגולמיים. אם הטיעונים המאוגדים טרם
חושבו, הוא נופל-חזרה ל-claims הגולמיים (אותו מקור).
הייצור עובר ``claude_session.query`` → ``claude -p`` (CLI, מנוי claude.ai, עלות-שולית-אפס),
נעוץ ל-Opus 4.8 + effort=high — משימת זיקוק/סינתזה. **local-only**: לקונטיינר אין claude CLI;
לכן הכלי רץ רק מ-MCP-server המקומי, וה-endpoint ב-app.py רק מגיש את הקובץ השמור (לא מייצר).
ההזהרה נגד-הזיה (INV-AH): המסמך מתוחם לחלוטין לטענות-התיק עצמן. אסור להמציא טענה, הלכה
או עובדה שאינה מופיעה בקלט; טענה שאינה ברורה — מצוין במפורש, לא משלים.
"""
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
from uuid import UUID
from legal_mcp import config
from legal_mcp.services import (
argument_aggregator,
block_writer,
claude_session,
db,
git_sync,
storage,
)
logger = logging.getLogger(__name__)
# Saved alongside the legal analysis (same research folder, same git+S3 mirror path).
_SUMMARY_FILENAME = "party-claims-summary.md"
_PARTY_HE = {
"appellant": "העוררים",
"respondent": "המשיבים",
"committee": "הוועדה המקומית",
"permit_applicant": "מבקשי ההיתר",
"unknown": "צד לא מזוהה",
}
def summary_file_path(case_number: str) -> Path:
"""Resolve the saved party-claims-summary.md path for a case."""
return config.find_case_dir(case_number) / "documents" / "research" / _SUMMARY_FILENAME
async def _build_arguments_context(case_id: UUID) -> tuple[str, int, str]:
"""Build the grounded source block from the case's OWN claims/arguments.
Preference order (single source of truth, no re-extraction — G2):
1. ``legal_arguments`` — the aggregated, de-duplicated arguments per party.
2. fallback to raw ``claims`` when arguments were never aggregated.
Returns ``(context_text, item_count, source_kind)`` where ``source_kind`` is
``"arguments"`` or ``"claims"`` (or ``"none"`` when neither exists).
"""
args = await argument_aggregator.get_legal_arguments(case_id)
if args:
by_party: dict[str, list[dict]] = {}
for a in args:
by_party.setdefault(a["party"], []).append(a)
lines: list[str] = []
for party, items in by_party.items():
lines.append(f"\n## {_PARTY_HE.get(party, party)}")
for a in items:
title = (a.get("argument_title") or "").strip()
body = (a.get("argument_body") or "").strip()
topic = (a.get("legal_topic") or "").strip()
header = f"- **{title}**" if title else "-"
if topic:
header += f" [{topic}]"
lines.append(header)
if body:
lines.append(f" {body}")
return "\n".join(lines), len(args), "arguments"
# Fallback — raw claims, the same underlying source.
claims = await db.get_claims(case_id)
# Exclude block-zayin (decision-summary) claims — original pleadings only.
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
if not source_claims:
return "", 0, "none"
lines = []
current_role = ""
role_he = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
n = 0
for c in source_claims:
if c["party_role"] != current_role:
current_role = c["party_role"]
lines.append(f"\n## {role_he.get(current_role, current_role)}")
n += 1
lines.append(f"- טענה #{n}: {c['claim_text']}")
return "\n".join(lines), len(source_claims), "claims"
_PROMPT_TEMPLATE = """אתה מכין עבור יו"ר ועדת הערר (עו"ד דפנה תמיר) **סיכום-מנהלים מזוקק של טענות הצדדים** — מסמך פרוזה קצר שמטרתו אחת: להכין את היו"ר לדיון בעל-פה. זהו מסמך **נפרד** מטיוטת-ההחלטה ואינו חלק ממנה.
## פרטי התיק:
{case_context}
## הקלט — טענות/טיעוני הצדדים (מקור-האמת היחיד):
{arguments_context}
## כללי-כתיבה מחייבים:
- **זיקוק, לא שכפול.** אל תעתיק את כתבי-הטענות. תמצת כל צד ל-25 משפטי-מפתח. היעד: עמוד אחד עד שניים, לא יותר.
- **מבוסס-קלט בלבד (חובה אנטי-הזיה).** הסתמך אך-ורק על הטענות שבקלט לעיל. אסור להמציא טענה, הלכה, פסק-דין, מספר או עובדה שאינם בקלט. טענה שאינה ברורה או חסרה — ציין זאת במפורש ("הטענה לא פורטה"), אל תשלים מדמיונך.
- **ניטרלי.** הצג את עמדת כל צד בנאמנות, בלי להעריך, בלי להכריע ובלי לרמוז על תוצאה. אין זו החלטה — זו הכנה.
- **מבנה קבוע:**
1. פסקת-פתיחה קצרה (משפט–שניים): במה עוסק הערר ומיהם הצדדים.
2. **טענות העוררים** — תמצית מזוקקת.
3. **טענות המשיבים / הוועדה / מבקשי ההיתר** — תמצית מזוקקת (כל צד שקיים בקלט).
4. **נקודות-המחלוקת המרכזיות** — 24 הסוגיות שעליהן ניצבת ההכרעה, כשאלות פתוחות (לא תשובות).
- **סגנון:** עברית משפטית בהירה, גוף-שלישי לתיאור הצדדים. בלי כותרת "החלטה". בלי חתימות.
## מדריך-סגנון (לטון בלבד — אל תיישם מבנה-החלטה):
{style_context}
החזר אך-ורק את גוף מסמך הסיכום (Markdown), בלי הקדמות ובלי הסברים על מה שעשית."""
async def summarize_party_claims(case_id: UUID, case_number: str,
instructions: str = "") -> dict:
"""Generate the distilled executive summary of party claims and save it.
Grounded strictly in the case's own claims/legal_arguments (INV-AH).
Generation is pinned to Opus 4.8 + effort=high (distillation/synthesis).
"""
case = await db.get_case(case_id)
if not case:
raise ValueError(f"Case {case_id} not found")
decision = await db.get_decision_by_case(case_id)
arguments_context, n_items, source_kind = await _build_arguments_context(case_id)
if source_kind == "none":
raise ValueError(
"אין טענות לסכם. הרץ extract_claims (ורצוי aggregate_claims_to_arguments) קודם."
)
case_context = block_writer._build_case_context(case, decision)
style_context = await block_writer._build_style_context(case.get("practice_area", ""))
prompt = _PROMPT_TEMPLATE.format(
case_context=case_context,
arguments_context=arguments_context,
style_context=style_context,
)
if instructions:
prompt += f"\n\n## הנחיות נוספות מהיו\"ר:\n{instructions}"
# Generation: claude_session → claude -p, pinned Opus 4.8 + high effort.
# tools="" — pure prose, no tool_use (avoids error_max_turns). LONG_TIMEOUT:
# full-case context can be large.
content = await claude_session.query(
prompt,
timeout=claude_session.LONG_TIMEOUT,
model="claude-opus-4-8",
effort="high",
tools="",
)
content = (content or "").strip()
if not content:
raise ValueError("הסיכום חזר ריק מ-claude -p.")
today = date.today().strftime("%d.%m.%Y")
header = (
f"# סיכום-מנהלים — טענות הצדדים\n\n"
f"**תיק:** {case['case_number']} \n"
f"**נושא:** {case.get('subject', '')} \n"
f"**הופק:** {today} · מסמך הכנה לדיון (נפרד מטיוטת-ההחלטה)\n\n"
f"---\n\n"
)
document = header + content + "\n"
# Save to data/cases/{n}/documents/research/ — same path as the legal analysis,
# so it is git-synced + S3-mirrored by the existing machinery.
out_path = summary_file_path(case_number)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(document, encoding="utf-8") # noqa: STG1 — mirrored below
try:
key = out_path.resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix()
await storage.mirror(key, document.encode("utf-8"), bucket=storage.Bucket.DOCUMENTS)
except ValueError:
pass
case_dir = config.find_case_dir(case_number)
if case_dir.exists():
git_sync.commit_and_push(case_dir, f"סיכום-מנהלים: טענות הצדדים ({case_number})")
return {
"case_number": case_number,
"path": str(out_path),
"source_kind": source_kind,
"source_items": n_items,
"word_count": len(content.split()),
"message": f"סיכום-מנהלים נוצר ({len(content.split())} מילים, מקור: {source_kind}).",
}

View File

@@ -734,6 +734,34 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
return err(str(e))
async def summarize_party_claims(case_number: str, instructions: str = "") -> str:
"""הפקת סיכום-מנהלים מזוקק של טענות הצדדים — מסמך פרוזה **נפרד מטיוטת-הביניים**,
להכנת היו"ר לדיון בעל-פה. תמצית קצרה ומוקפדת (לא שכפול כתבי-הטענות), נגזרת
מהטיעונים המאוגדים (legal_arguments) או מ-claims הגולמיים — אותו מקור-אמת,
בלי חילוץ-מחדש. נשמר ל-data/cases/{n}/documents/research/ (git + S3) וניתן-לייצוא.
הייצור עובר claude_session (claude -p), נעוץ Opus 4.8 + effort=high. מתוחם
לטענות-התיק בלבד (anti-hallucination) — אינו ממציא טענה/הלכה/עובדה שאינה בקלט.
Args:
case_number: מספר תיק הערר
instructions: הנחיות נוספות מהיו"ר (אופציונלי)
"""
from legal_mcp.services import party_claims_summary
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"])
try:
result = await party_claims_summary.summarize_party_claims(
case_id, case_number, instructions,
)
return ok(result)
except ValueError as e:
return err(str(e))
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.

View File

@@ -0,0 +1,212 @@
"""Tests for the party-claims executive summary (#202, WS3).
Cover the deterministic, offline parts:
- ``_build_arguments_context`` source selection: legal_arguments preferred,
raw claims fallback, "none" when empty (G2 — single source, no re-extract).
- ``summarize_party_claims`` pins Opus 4.8 + effort=high and grounds the
prompt strictly in the case's own claims (anti-hallucination / INV-AH).
- ``_render_markdown_body`` maps markdown headings/bullets onto template styles.
Generation itself (claude -p) is local-only and not exercised here — the
claude_session call is monkeypatched.
"""
from __future__ import annotations
import asyncio
from uuid import uuid4
from legal_mcp.services import party_claims_summary as pcs
def _run(coro):
return asyncio.run(coro)
# ── source selection (G2: arguments preferred, claims fallback) ────
def test_arguments_preferred_over_claims(monkeypatch):
cid = uuid4()
async def _get_args(_case_id):
return [
{"party": "appellant", "argument_title": "חריגת בנייה",
"argument_body": "הבנייה חורגת מקו הבניין", "legal_topic": "קווי בניין"},
{"party": "respondent", "argument_title": "התיישנות",
"argument_body": "", "legal_topic": ""},
]
async def _get_claims(_case_id, *a, **k): # must NOT be consulted
raise AssertionError("claims fallback used despite arguments existing")
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _get_args)
monkeypatch.setattr(pcs.db, "get_claims", _get_claims)
ctx, n, kind = _run(pcs._build_arguments_context(cid))
assert kind == "arguments"
assert n == 2
assert "העוררים" in ctx and "המשיבים" in ctx
assert "חריגת בנייה" in ctx
def test_falls_back_to_claims_when_no_arguments(monkeypatch):
cid = uuid4()
async def _no_args(_case_id):
return []
async def _claims(_case_id, *a, **k):
return [
{"party_role": "appellant", "claim_text": "טענה אחת", "source_document": "appeal"},
{"party_role": "respondent", "claim_text": "טענה שתיים", "source_document": "response"},
]
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _no_args)
monkeypatch.setattr(pcs.db, "get_claims", _claims)
ctx, n, kind = _run(pcs._build_arguments_context(cid))
assert kind == "claims"
assert n == 2
assert "טענה אחת" in ctx
def test_block_zayin_claims_excluded(monkeypatch):
"""Decision-summary (block-zayin) claims are not original pleadings."""
cid = uuid4()
async def _no_args(_case_id):
return []
async def _claims(_case_id, *a, **k):
return [
{"party_role": "appellant", "claim_text": "מקורית", "source_document": "appeal"},
{"party_role": "appellant", "claim_text": "מסיכום", "source_document": "block-zayin"},
]
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _no_args)
monkeypatch.setattr(pcs.db, "get_claims", _claims)
ctx, n, kind = _run(pcs._build_arguments_context(cid))
assert kind == "claims"
assert n == 1
assert "מקורית" in ctx and "מסיכום" not in ctx
def test_none_when_no_source(monkeypatch):
cid = uuid4()
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments",
lambda _c: _aw([]))
monkeypatch.setattr(pcs.db, "get_claims", lambda _c, *a, **k: _aw([]))
ctx, n, kind = _run(pcs._build_arguments_context(cid))
assert kind == "none" and n == 0 and ctx == ""
async def _aw(v):
return v
# ── generation pins Opus 4.8 + high effort, grounded in claims ─────
def test_summarize_pins_opus_and_high_effort(monkeypatch, tmp_path):
cid = uuid4()
captured: dict = {}
async def _get_case(_case_id):
return {"case_number": "8125-09-24", "title": "t", "appellants": ["א"],
"respondents": ["ב"], "subject": "היטל השבחה", "property_address": "",
"appeal_type": "betterment_levy", "practice_area": "betterment_levy"}
async def _get_decision(_case_id):
return None
async def _args(_case_id):
return [{"party": "appellant", "argument_title": "X",
"argument_body": "Y", "legal_topic": ""}]
async def _fake_query(prompt, *, timeout=None, model=None, effort=None, tools=None):
captured["model"] = model
captured["effort"] = effort
captured["tools"] = tools
captured["prompt"] = prompt
return "## טענות העוררים\nתמצית."
async def _style(_practice_area=""):
return "(style)"
monkeypatch.setattr(pcs.db, "get_case", _get_case)
monkeypatch.setattr(pcs.db, "get_decision_by_case", _get_decision)
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _args)
monkeypatch.setattr(pcs.block_writer, "_build_style_context", _style)
monkeypatch.setattr(pcs.claude_session, "query", _fake_query)
monkeypatch.setattr(pcs.config, "find_case_dir", lambda cn: tmp_path / cn)
monkeypatch.setattr(pcs.config, "DATA_DIR", tmp_path)
async def _mirror(*a, **k):
return None
monkeypatch.setattr(pcs.storage, "mirror", _mirror)
monkeypatch.setattr(pcs.git_sync, "commit_and_push", lambda *a, **k: True)
result = _run(pcs.summarize_party_claims(cid, "8125-09-24"))
# The whole point of #202's generation-path constraint:
assert captured["model"] == "claude-opus-4-8"
assert captured["effort"] == "high"
assert captured["tools"] == "" # prose, no tool_use
# Grounded strictly in the case's own arguments (anti-hallucination).
assert "אנטי-הזיה" in captured["prompt"]
assert "Y" in captured["prompt"] # the actual argument body is in-context
assert result["source_kind"] == "arguments"
out = (tmp_path / "8125-09-24" / "documents" / "research" / "party-claims-summary.md")
assert out.exists()
assert "סיכום-מנהלים" in out.read_text(encoding="utf-8")
def test_summarize_raises_when_no_claims(monkeypatch, tmp_path):
cid = uuid4()
monkeypatch.setattr(pcs.db, "get_case", lambda _c: _aw({"case_number": "9000-01-25"}))
monkeypatch.setattr(pcs.db, "get_decision_by_case", lambda _c: _aw(None))
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", lambda _c: _aw([]))
monkeypatch.setattr(pcs.db, "get_claims", lambda _c, *a, **k: _aw([]))
try:
_run(pcs.summarize_party_claims(cid, "9000-01-25"))
raise AssertionError("expected ValueError on empty source")
except ValueError as e:
assert "טענות" in str(e)
# ── markdown → DOCX rendering uses template heading styles ─────────
def test_render_markdown_body_maps_styles():
from docx import Document
from legal_mcp.services import analysis_docx_exporter as ax
doc = Document()
md = (
"# כותרת ראשית\n\n"
"---\n"
"## טענות העוררים\n"
"- טענה אחת\n"
"1. סעיף ממוספר\n"
"טקסט רגיל.\n"
)
ax._render_markdown_body(doc, md)
styles = [p.style.name for p in doc.paragraphs]
texts = [p.text for p in doc.paragraphs]
# HR is skipped; headings become Heading styles.
assert "כותרת ראשית" in texts
assert "Heading 1" in styles # # / ## → Heading 1
assert any("טענה אחת" in t for t in texts)
assert not any(t.strip() in {"---", "#"} for t in texts)
def test_next_version_is_prefix_scoped(tmp_path):
from legal_mcp.services import analysis_docx_exporter as ax
(tmp_path / "ניתוח-משפטי-v3.docx").write_bytes(b"x")
# An analysis file at v3 must NOT bump the summary's version.
assert ax._next_version(tmp_path, "סיכום-מנהלים-טענות") == 1
assert ax._next_version(tmp_path, "ניתוח-משפטי") == 4

View File

@@ -3063,6 +3063,58 @@ async def api_research_analysis_export_docx(case_number: str):
)
# ── Party-claims executive summary (party-claims-summary.md) — #202 ─
# A distilled prose document of the parties' claims, SEPARATE from the interim
# draft, to prepare the chair for the hearing. Generation is local-only (the
# summarize_party_claims MCP tool via claude_session); these endpoints only
# SERVE the saved file — they never generate (the container has no claude CLI).
@app.get("/api/cases/{case_number}/research/party-claims-summary")
async def api_party_claims_summary(case_number: str):
"""Return the raw markdown of the party-claims executive summary."""
from legal_mcp.services import party_claims_summary as pcs
path = pcs.summary_file_path(case_number)
if not path.exists():
raise HTTPException(404, "טרם הופק סיכום-מנהלים לטענות הצדדים לתיק זה")
return {"markdown": path.read_text(encoding="utf-8")}
@app.get("/api/cases/{case_number}/research/party-claims-summary/download")
async def api_party_claims_summary_download(case_number: str):
"""Download the raw party-claims-summary.md file."""
from legal_mcp.services import party_claims_summary as pcs
path = pcs.summary_file_path(case_number)
if not path.exists():
raise HTTPException(404, "טרם הופק סיכום-מנהלים לטענות הצדדים לתיק זה")
return await serve_blob(
path,
media_type="text/markdown; charset=utf-8",
filename=f"party-claims-summary-{case_number}.md",
)
@app.get("/api/cases/{case_number}/research/party-claims-summary/export-docx")
async def api_party_claims_summary_export_docx(case_number: str):
"""Export the party-claims summary as a DOCX using דפנה's template styles."""
from legal_mcp.services.analysis_docx_exporter import build_party_claims_summary_docx
try:
path = await build_party_claims_summary_docx(case_number)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except Exception as e:
logger.exception("Failed to export party-claims summary DOCX for %s", case_number)
raise HTTPException(500, f"שגיאה בייצוא: {e}")
case_dir = config.find_case_dir(case_number)
if case_dir.exists():
commit_and_push(case_dir, f"סיכום-מנהלים: {path.name}")
return await serve_blob(
path,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
filename=path.name,
)
@app.put("/api/cases/{case_number}/research/analysis/upload")
async def api_research_analysis_upload(
case_number: str,