diff --git a/docs/spec/04-analysis-writing.md b/docs/spec/04-analysis-writing.md index 8266bc2..56e9ddd 100644 --- a/docs/spec/04-analysis-writing.md +++ b/docs/spec/04-analysis-writing.md @@ -47,6 +47,28 @@ > מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון > שלו מקבל מענה בבלוק י. +### 1.3 סיכום-מנהלים של טענות הצדדים (מסמך-הכנה לדיון, WS3/#202) + +`summarize_party_claims(case_number, instructions="")` מפיק **מסמך-פרוזה מזוקק** של +טענות הצדדים — תמצית-מנהלים קצרה ומוקפדת שמטרתה **להכין את היו"ר לדיון בעל-פה**. זהו +מסמך **נפרד ומובחן מטיוטת-ההחלטה ומטיוטת-הביניים** (החלטת-יו"ר, [תוכנית workflow-redesign](../../.claude/plans/groovy-doodling-token.md) +§WS3) — אינו חלק מ-12-הבלוקים ואינו נכתב לתבנית ההחלטה. + +- **מקור-אמת יחיד (G2):** המסמך נגזר מ-`legal_arguments` (טיעונים מאוגדים) או, כ-fallback, + מ-`claims` הגולמיים — **אותו מקור** של §§1.1–1.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 הבלוקים (סיכום) diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index 00f360f..b7c516e 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -814,6 +814,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 אם חסר.""" diff --git a/mcp-server/src/legal_mcp/services/analysis_docx_exporter.py b/mcp-server/src/legal_mcp/services/analysis_docx_exporter.py index c0b92cc..8f609ec 100644 --- a/mcp-server/src/legal_mcp/services/analysis_docx_exporter.py +++ b/mcp-server/src/legal_mcp/services/analysis_docx_exporter.py @@ -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 diff --git a/mcp-server/src/legal_mcp/services/party_claims_summary.py b/mcp-server/src/legal_mcp/services/party_claims_summary.py new file mode 100644 index 0000000..ca1838f --- /dev/null +++ b/mcp-server/src/legal_mcp/services/party_claims_summary.py @@ -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} + +## כללי-כתיבה מחייבים: +- **זיקוק, לא שכפול.** אל תעתיק את כתבי-הטענות. תמצת כל צד ל-2–5 משפטי-מפתח. היעד: עמוד אחד עד שניים, לא יותר. +- **מבוסס-קלט בלבד (חובה אנטי-הזיה).** הסתמך אך-ורק על הטענות שבקלט לעיל. אסור להמציא טענה, הלכה, פסק-דין, מספר או עובדה שאינם בקלט. טענה שאינה ברורה או חסרה — ציין זאת במפורש ("הטענה לא פורטה"), אל תשלים מדמיונך. +- **ניטרלי.** הצג את עמדת כל צד בנאמנות, בלי להעריך, בלי להכריע ובלי לרמוז על תוצאה. אין זו החלטה — זו הכנה. +- **מבנה קבוע:** + 1. פסקת-פתיחה קצרה (משפט–שניים): במה עוסק הערר ומיהם הצדדים. + 2. **טענות העוררים** — תמצית מזוקקת. + 3. **טענות המשיבים / הוועדה / מבקשי ההיתר** — תמצית מזוקקת (כל צד שקיים בקלט). + 4. **נקודות-המחלוקת המרכזיות** — 2–4 הסוגיות שעליהן ניצבת ההכרעה, כשאלות פתוחות (לא תשובות). +- **סגנון:** עברית משפטית בהירה, גוף-שלישי לתיאור הצדדים. בלי כותרת "החלטה". בלי חתימות. + +## מדריך-סגנון (לטון בלבד — אל תיישם מבנה-החלטה): +{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}).", + } diff --git a/mcp-server/src/legal_mcp/tools/drafting.py b/mcp-server/src/legal_mcp/tools/drafting.py index 05c1269..ca039d8 100644 --- a/mcp-server/src/legal_mcp/tools/drafting.py +++ b/mcp-server/src/legal_mcp/tools/drafting.py @@ -658,6 +658,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: """רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק. diff --git a/mcp-server/tests/test_party_claims_summary.py b/mcp-server/tests/test_party_claims_summary.py new file mode 100644 index 0000000..ce2bc65 --- /dev/null +++ b/mcp-server/tests/test_party_claims_summary.py @@ -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 diff --git a/web/app.py b/web/app.py index 6fde202..7d85937 100644 --- a/web/app.py +++ b/web/app.py @@ -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,