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:
@@ -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.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 הבלוקים (סיכום)
|
||||
|
||||
@@ -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 אם חסר."""
|
||||
|
||||
@@ -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}).",
|
||||
}
|
||||
@@ -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:
|
||||
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.
|
||||
|
||||
|
||||
212
mcp-server/tests/test_party_claims_summary.py
Normal file
212
mcp-server/tests/test_party_claims_summary.py
Normal 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
|
||||
52
web/app.py
52
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,
|
||||
|
||||
Reference in New Issue
Block a user