מסמך פרוזה נפרד מטיוטת-הביניים (החלטת-יו"ר): תמצית מוקפדת של טענות+תשובות
להכנת היו"ר לדיון. נגזר מ-legal_arguments/claims הקיימים — אותו מקור-אמת,
ללא חילוץ-מחדש (G2). מתוחם לטענות-התיק בלבד (INV-AH).
- כלי-MCP חדש summarize_party_claims (drafting.py + server.py); ייצור עובר
claude_session נעוץ Opus 4.8 + effort=high (זיקוק/סינתזה); local-only.
- שירות party_claims_summary.py: בחירת-מקור arguments→claims, פרומפט מעוגן-מקור,
שמירה ל-data/cases/{n}/documents/research/ (git + S3).
- ייצוא DOCX בסגנון-תבנית דפנה (build_party_claims_summary_docx) + endpoints
read/download/export-docx ב-web/app.py (מגישים בלבד — אין claude CLI בקונטיינר).
- _next_version הוכלל ל-prefix-scoped (תאימות-לאחור) למניעת התנגשות-גרסאות.
- ספ 04-analysis-writing.md §1.3 + 8 בדיקות אופליין (מקור/נעיצת-מודל/markdown→docx).
Invariants: G2 (מקור-אמת יחיד, בלי מסלול מקביל) · X9 TOOL1/TOOL4 (envelope +
extract↔serve) · INV-AH (עיגון-מקור, abstention על טענה לא-ברורה) ·
claude_session local-only · G9 (provenance — נשמר durably + git/S3).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
209 lines
9.9 KiB
Python
209 lines
9.9 KiB
Python
"""סיכום-מנהלים של טענות הצדדים — מסמך פרוזה מזוקק להכנת היו"ר לדיון (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}).",
|
||
}
|