"""סיכום-מנהלים של טענות הצדדים — מסמך פרוזה מזוקק להכנת היו"ר לדיון (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}).", }