Merge pull request 'feat(analysis): סיכום-מנהלים מזוקק של טענות הצדדים — מסמך הכנה לדיון (#202, WS3)' (#358) from worktree-agent-abc25c98bc1092fa3 into main
This commit was merged in pull request #358.
This commit is contained in:
@@ -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
|
||||
|
||||
208
mcp-server/src/legal_mcp/services/party_claims_summary.py
Normal file
208
mcp-server/src/legal_mcp/services/party_claims_summary.py
Normal 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}
|
||||
|
||||
## כללי-כתיבה מחייבים:
|
||||
- **זיקוק, לא שכפול.** אל תעתיק את כתבי-הטענות. תמצת כל צד ל-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}).",
|
||||
}
|
||||
Reference in New Issue
Block a user