Compare commits
6 Commits
1e43c12418
...
dfb2ffe7ce
| Author | SHA1 | Date | |
|---|---|---|---|
| dfb2ffe7ce | |||
| 99574f0eba | |||
| 085de937da | |||
| 9e6a441076 | |||
| aa2548ff5d | |||
| 23e088d25e |
@@ -95,6 +95,19 @@ WS2 (#200) מבחין בין **מסמך-עיקרי** (מסמך-מהות שהיו
|
|||||||
`document_list` / `case_get` / API-המסמכים. אינדקס חלקי `idx_documents_primary(case_id) WHERE
|
`document_list` / `case_get` / API-המסמכים. אינדקס חלקי `idx_documents_primary(case_id) WHERE
|
||||||
is_primary` משרת את שאילתת "מסמכים-עיקריים שטרם-נותחו" (#201).
|
is_primary` משרת את שאילתת "מסמכים-עיקריים שטרם-נותחו" (#201).
|
||||||
|
|
||||||
|
**דגל "לא-נותח" — per-מסמך (#201, SCHEMA_V49).** שני שדות מתעדים את *אירוע* חילוץ-הטענות
|
||||||
|
של המסמך (לא נגזרים מ-`doc_type`): `claims_extracted_at TIMESTAMPTZ` (נחתם ע"י
|
||||||
|
`claims_extractor` **אחרי** ששמרנו את טענות המסמך) ו-`claims_extraction_status TEXT`
|
||||||
|
(`pending`/`completed`/`no_claims`/`failed`). `_row_to_doc` חושף `claims_analyzed`
|
||||||
|
(`claims_extracted_at IS NOT NULL`). מקור-האמת היחיד ל"האם המסמך נכלל בניתוח" הוא
|
||||||
|
`claims_extracted_at` — לא משוחזר ממקום אחר. אינדקס חלקי `idx_documents_claims_pending(case_id)
|
||||||
|
WHERE is_primary AND claims_extracted_at IS NULL` (יחד עם `is_primary` של V47) הופך את
|
||||||
|
"מסמך-עיקרי שנוסף-אחרי-ניתוח ולא-נכלל" (`db.primary_docs_not_analyzed`) לשאילתה זולה.
|
||||||
|
נחשף ב-`workflow_status` (`primary_docs_not_analyzed` + next-step). הניתוח-מחדש המאחד
|
||||||
|
(`reanalyze_claims`, [04-analysis-writing §1.3](04-analysis-writing.md)) מחלץ-מחדש רק את
|
||||||
|
המסמכים-העיקריים החדשים/לא-נותחו, **מאחד** עם הטענות הקיימות, ומריץ צבירה-מחדש (G2 — מסלול
|
||||||
|
קנוני אחד, לא fork).
|
||||||
|
|
||||||
### 2ד. ישויות-נגזרות (אחסון-ניתוחים)
|
### 2ד. ישויות-נגזרות (אחסון-ניתוחים)
|
||||||
|
|
||||||
מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי
|
מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
### 1.3 ניתוח-פרוטוקול — ניתוח פרוטוקול-דיון השוואתי (WS4 / #203)
|
### 1.3 ניתוח-פרוטוקול — ניתוח פרוטוקול-דיון השוואתי (WS4 / #203)
|
||||||
|
|
||||||
> **בעלוּת תת-סעיף:** §1.3 (זה) שייך ל-WS4 (ניתוח-פרוטוקול). תת-סעיף סיכום-טענות-הצדדים
|
> **בעלוּת תת-סעיף:** §1.3 (זה) שייך ל-WS4 (ניתוח-פרוטוקול). תת-סעיף סיכום-טענות-הצדדים
|
||||||
> (PR-אחות #358) הוא **תת-סעיף נפרד** תחת §1 — אין חפיפת-מספור בין השניים.
|
> (PR-אחות #358) הוא **תת-סעיף נפרד** (§1.4) תחת §1 — אין חפיפת-מספור בין השניים.
|
||||||
|
|
||||||
אחרי הדיון, פרוטוקול (`doc_type='protocol'`) מנותח **מול הטיעונים המאוגדים** —
|
אחרי הדיון, פרוטוקול (`doc_type='protocol'`) מנותח **מול הטיעונים המאוגדים** —
|
||||||
`analyze_protocol(case_number)` (→ `services/protocol_analyzer.py`). הניתוח מסווג כל
|
`analyze_protocol(case_number)` (→ `services/protocol_analyzer.py`). הניתוח מסווג כל
|
||||||
@@ -75,6 +75,48 @@ knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arg
|
|||||||
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
|
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
|
||||||
`model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`).
|
`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`.
|
||||||
|
|
||||||
|
### 1.5 ניתוח-מחדש מאחד אחרי מסמך-עיקרי חדש (#201)
|
||||||
|
|
||||||
|
תיק מנותח לרוב מכתב-הערר לבד, ומסמך-עיקרי (תשובה/התנגדות/פרוטוקול…) מגיע מאוחר יותר.
|
||||||
|
כל מסמך נושא **דגל "לא-נותח"** (`claims_extracted_at`, [02-data-model §2ג](02-data-model.md)),
|
||||||
|
ו-`workflow_status` מסמן "מסמך-עיקרי שטרם-נכלל בניתוח". `reanalyze_claims(case_number,
|
||||||
|
reanalyze_all_primary=False)` סוגר את הפער **בלי force-delete גורף**:
|
||||||
|
|
||||||
|
1. **snapshot לפני** — הטיעונים המאוגדים הנוכחיים (בסיס בדיקת-ההשפעה).
|
||||||
|
2. **חילוץ-מאחד** — מחלץ רק את המסמכים-העיקריים החדשים/לא-נותחו (`db.primary_docs_not_analyzed`)
|
||||||
|
דרך **אותו** `claims_extractor.extract_and_store_claims`; `store_claims` מחליף רק את טענות
|
||||||
|
*אותו* מסמך (לפי `source_document`), כך שטענות ממסמכים שכבר-נותחו **נשמרות** (האיחוד).
|
||||||
|
3. **צבירה-מחדש** — `aggregate_claims_to_arguments(force=True)` מחשב את הטיעונים מחדש מתוך
|
||||||
|
מערך-הטענות המאוחד השלם. זהו **מסלול-החישוב הקנוני** ([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)),
|
||||||
|
לא מסלול-עיבוד מקביל; הוא מוחק רק `legal_arguments` (נגזר), לעולם לא `claims`.
|
||||||
|
4. **בדיקת-השפעה ליו"ר** — snapshot אחרי + `_impact_diff` מחזיר per-צד אילו טיעונים
|
||||||
|
נוספו/הוסרו ואיך השתנה תמהיל-העדיפויות ("מאזן-ההמלצה"), עם דגל `changed`. הפלט **מוצג**
|
||||||
|
ליו"ר ולא מוחל אוטומטית ([G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)).
|
||||||
|
|
||||||
|
ראה `tools/legal_arguments.py` (`reanalyze_claims`, `_snapshot`, `_impact_diff`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. ארכיטקטורת 12 הבלוקים (סיכום)
|
## 2. ארכיטקטורת 12 הבלוקים (סיכום)
|
||||||
|
|||||||
@@ -572,6 +572,18 @@ async def get_legal_arguments(
|
|||||||
return await la_tools.get_legal_arguments(case_number, party)
|
return await la_tools.get_legal_arguments(case_number, party)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def reanalyze_claims(
|
||||||
|
case_number: str,
|
||||||
|
reanalyze_all_primary: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""ניתוח-מחדש מאחד אחרי הוספת מסמך-עיקרי: מחלץ ממסמכים-עיקריים חדשים/לא-נותחו בלבד,
|
||||||
|
מאחד עם הטענות הקיימות (לא מוחק הכול), מריץ צבירה-מחדש, ומחזיר בדיקת-השפעה
|
||||||
|
(diff טיעונים/המלצה לפני↔אחרי) ליו"ר. reanalyze_all_primary=True = כל המסמכים-העיקריים.
|
||||||
|
"""
|
||||||
|
return await la_tools.reanalyze_claims(case_number, reanalyze_all_primary)
|
||||||
|
|
||||||
|
|
||||||
# References
|
# References
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def extract_references(
|
async def extract_references(
|
||||||
@@ -835,6 +847,12 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
|||||||
return await drafting.export_interim_draft(case_number, output_path)
|
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()
|
@mcp.tool()
|
||||||
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
||||||
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""
|
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""
|
||||||
|
|||||||
@@ -182,16 +182,21 @@ def _add_runs_with_inline_bold(paragraph: Paragraph, text: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _clear_body(doc: DocumentT) -> None:
|
def _clear_body(doc: DocumentT) -> None:
|
||||||
"""Remove every paragraph currently in the document body.
|
"""Remove ALL body content (paragraphs, tables, stray bookmarks),
|
||||||
|
keeping only sectPr.
|
||||||
|
|
||||||
The template ships with example paragraphs ("רקע", "דיון והכרעה"…)
|
The template ships with example paragraphs ("רקע", "דיון והכרעה"…) *and
|
||||||
that we don't want in the output. Section properties (sectPr) are
|
three sample tables* (header / panel / signatures). Removing only ``w:p``
|
||||||
kept so page size / margins / RTL / footer remain intact.
|
left the tables behind — including the signatures table, which then floated
|
||||||
|
to the top against the header table. Mirror the decision exporter's
|
||||||
|
``_clear_body`` (G2 symmetry): drop everything except sectPr, which carries
|
||||||
|
page setup including bidi.
|
||||||
"""
|
"""
|
||||||
body = doc.element.body
|
body = doc.element.body
|
||||||
for p in list(body.findall(qn("w:p"))):
|
sectPr_tag = qn("w:sectPr")
|
||||||
body.remove(p)
|
for child in list(body):
|
||||||
# Leave sectPr alone — it carries page setup including bidi.
|
if child.tag != sectPr_tag:
|
||||||
|
body.remove(child)
|
||||||
|
|
||||||
|
|
||||||
def _add_paragraph(doc: DocumentT, text: str, style: str) -> Paragraph:
|
def _add_paragraph(doc: DocumentT, text: str, style: str) -> Paragraph:
|
||||||
@@ -386,9 +391,9 @@ def _group_precedents(
|
|||||||
return case_level, by_section
|
return case_level, by_section
|
||||||
|
|
||||||
|
|
||||||
def _next_version(export_dir: Path) -> int:
|
def _next_version(export_dir: Path, prefix: str = "ניתוח-משפטי") -> int:
|
||||||
"""Return the next version number for ניתוח-משפטי-v{N}.docx."""
|
"""Return the next version number for {prefix}-v{N}.docx in export_dir."""
|
||||||
existing = sorted(export_dir.glob("ניתוח-משפטי-v*.docx"))
|
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
|
||||||
next_ver = 1
|
next_ver = 1
|
||||||
for p in existing:
|
for p in existing:
|
||||||
try:
|
try:
|
||||||
@@ -511,3 +516,77 @@ async def build_analysis_docx(case_number: str) -> Path:
|
|||||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
)
|
)
|
||||||
return out_path
|
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
|
||||||
|
|||||||
@@ -360,6 +360,10 @@ async def extract_and_store_claims(
|
|||||||
claims = await extract_claims_with_ai(text, doc_type, party_hint)
|
claims = await extract_claims_with_ai(text, doc_type, party_hint)
|
||||||
|
|
||||||
if not claims:
|
if not claims:
|
||||||
|
# Stamp the document as analysed even with zero claims, so it is not
|
||||||
|
# perpetually flagged "not-analysed" (#201 — the not-analysed flag is
|
||||||
|
# about whether extraction *ran*, not whether it found anything).
|
||||||
|
await db.mark_document_claims_extracted(document_id, status="no_claims")
|
||||||
return {"status": "no_claims", "total": 0, "source": source_name}
|
return {"status": "no_claims", "total": 0, "source": source_name}
|
||||||
|
|
||||||
# Determine claim_type from document type and title
|
# Determine claim_type from document type and title
|
||||||
@@ -368,6 +372,10 @@ async def extract_and_store_claims(
|
|||||||
c["claim_type"] = claim_type
|
c["claim_type"] = claim_type
|
||||||
|
|
||||||
stored = await db.store_claims(case_id, claims, source_document=source_name)
|
stored = await db.store_claims(case_id, claims, source_document=source_name)
|
||||||
|
# Mark this document analysed (WS2 / #201). store_claims already replaced
|
||||||
|
# this document's prior claims by source_document — so re-running is a clean
|
||||||
|
# per-document refresh that does NOT touch other documents' claims (the merge).
|
||||||
|
await db.mark_document_claims_extracted(document_id, status="completed")
|
||||||
|
|
||||||
# Summarize by role
|
# Summarize by role
|
||||||
role_counts: dict[str, int] = {}
|
role_counts: dict[str, int] = {}
|
||||||
|
|||||||
@@ -1870,6 +1870,23 @@ CREATE INDEX IF NOT EXISTS idx_protocol_analysis_case ON protocol_analysis(case_
|
|||||||
CREATE INDEX IF NOT EXISTS idx_protocol_analysis_doc ON protocol_analysis(document_id);
|
CREATE INDEX IF NOT EXISTS idx_protocol_analysis_doc ON protocol_analysis(document_id);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# V49 (WS2 / task #201): per-document "claims analysed" tracking — the "not-analysed"
|
||||||
|
# flag. `claims_extracted_at` is stamped by claims_extractor AFTER a document's claims
|
||||||
|
# are stored; `claims_extraction_status` records the outcome
|
||||||
|
# ('pending' | 'completed' | 'no_claims' | 'failed'). These describe the per-document
|
||||||
|
# extraction *event* (the single source of truth for "has this doc been analysed") —
|
||||||
|
# NOT derived from doc_type. They let us detect a PRIMARY document (is_primary, V47)
|
||||||
|
# added after the analysis ran and not yet included → "primary doc not yet analysed".
|
||||||
|
# The partial index pairs with idx_documents_primary so that query is cheap. Idempotent:
|
||||||
|
# ADD COLUMN IF NOT EXISTS; re-running is a no-op. (V48 = protocol_analysis, #357.)
|
||||||
|
SCHEMA_V49_SQL = """
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS claims_extracted_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS claims_extraction_status TEXT
|
||||||
|
NOT NULL DEFAULT 'pending';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_claims_pending
|
||||||
|
ON documents(case_id) WHERE is_primary AND claims_extracted_at IS NULL;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Stable, arbitrary key for the session-level advisory lock that serialises
|
# Stable, arbitrary key for the session-level advisory lock that serialises
|
||||||
# schema DDL across processes. Every short-lived process (cron drains, services)
|
# schema DDL across processes. Every short-lived process (cron drains, services)
|
||||||
@@ -1941,6 +1958,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
|||||||
await conn.execute(SCHEMA_V46_SQL)
|
await conn.execute(SCHEMA_V46_SQL)
|
||||||
await conn.execute(SCHEMA_V47_SQL)
|
await conn.execute(SCHEMA_V47_SQL)
|
||||||
await conn.execute(SCHEMA_V48_SQL)
|
await conn.execute(SCHEMA_V48_SQL)
|
||||||
|
await conn.execute(SCHEMA_V49_SQL)
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -2321,6 +2339,14 @@ def _row_to_doc(row: asyncpg.Record) -> dict:
|
|||||||
else is_primary_doc_type(d.get("doc_type"))
|
else is_primary_doc_type(d.get("doc_type"))
|
||||||
d["is_primary"] = is_primary
|
d["is_primary"] = is_primary
|
||||||
d["doc_category"] = "primary" if is_primary else "secondary"
|
d["doc_category"] = "primary" if is_primary else "secondary"
|
||||||
|
# Per-document claims-analysis state (WS2 / #201). `claims_analyzed` is the
|
||||||
|
# human-facing "has this doc been included in the claims analysis" flag —
|
||||||
|
# True iff claims extraction has been stamped for it. Single source of truth:
|
||||||
|
# claims_extracted_at. Defaults keep pre-migration rows well-formed.
|
||||||
|
extracted_at = d.get("claims_extracted_at")
|
||||||
|
if "claims_extraction_status" not in d or d.get("claims_extraction_status") is None:
|
||||||
|
d["claims_extraction_status"] = "pending"
|
||||||
|
d["claims_analyzed"] = extracted_at is not None
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -2370,6 +2396,47 @@ async def get_claims(case_id: UUID, party_role: str | None = None) -> list[dict]
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Per-document claims-analysis tracking (WS2 / #201) ──────────────
|
||||||
|
|
||||||
|
async def mark_document_claims_extracted(
|
||||||
|
doc_id: UUID, status: str = "completed",
|
||||||
|
) -> None:
|
||||||
|
"""Stamp a document as having had its claims extracted (WS2 / #201).
|
||||||
|
|
||||||
|
Sets ``claims_extracted_at = now()`` and records the outcome in
|
||||||
|
``claims_extraction_status`` ('completed' | 'no_claims' | 'failed').
|
||||||
|
This is the single source of truth for the per-document "not-analysed"
|
||||||
|
flag — it is written at the point claims are stored, not derived later.
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE documents SET claims_extracted_at = now(), "
|
||||||
|
"claims_extraction_status = $2 WHERE id = $1",
|
||||||
|
doc_id, status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def primary_docs_not_analyzed(case_id: UUID) -> list[dict]:
|
||||||
|
"""Return PRIMARY documents in a case that have NOT yet been analysed.
|
||||||
|
|
||||||
|
"Primary" = ``is_primary`` (the V47 generated column, single source of
|
||||||
|
truth = PRIMARY_DOC_TYPES). "Not analysed" = ``claims_extracted_at IS NULL``.
|
||||||
|
Backed by the partial index ``idx_documents_claims_pending``. Surfaces the
|
||||||
|
"there is a primary document not yet included in the analysis" condition
|
||||||
|
(#201). Returns the same shape as ``list_documents`` rows.
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT * FROM documents "
|
||||||
|
"WHERE case_id = $1 AND is_primary AND claims_extracted_at IS NULL "
|
||||||
|
"ORDER BY created_at",
|
||||||
|
case_id,
|
||||||
|
)
|
||||||
|
return [_row_to_doc(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# ── Decisions ──────────────────────────────────────────────────────
|
# ── Decisions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def create_decision(
|
async def create_decision(
|
||||||
|
|||||||
@@ -192,14 +192,29 @@ def _apply_list_numbering(paragraph, num_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _clear_body(doc) -> None:
|
def _clear_body(doc) -> None:
|
||||||
"""Remove all paragraphs in the document body while keeping sectPr.
|
"""Remove ALL body content (paragraphs, tables, stray bookmarks) while
|
||||||
|
keeping only sectPr.
|
||||||
|
|
||||||
The template ships with sample paragraphs we don't want. Section
|
The template ships with sample paragraphs *and three sample tables*
|
||||||
properties (page size, margins, bidi) stay intact.
|
(institutional header, panel, and a **signatures** table at the end —
|
||||||
|
"ניתנה פה אחד" + יו"ר/מזכירה). Removing only ``w:p`` left those tables
|
||||||
|
behind: the leftover header/panel tables injected block-א–ד data that was
|
||||||
|
never extracted from the protocol (violates the empty-unless-extracted
|
||||||
|
rule), and the leftover signatures table collapsed up against the header
|
||||||
|
table at the **top** of the document (the chair-reported bug: חתימות צמודות
|
||||||
|
לבלוק-ד במקום בסוף). The decision is a derived artifact rebuilt purely from
|
||||||
|
``decision_blocks`` (INV-EX1) — signatures come from block-yod-bet, rendered
|
||||||
|
last in block order — so the template's sample tables are stale scaffolding
|
||||||
|
and must go. Section properties (page size, margins, bidi) stay intact.
|
||||||
"""
|
"""
|
||||||
body = doc.element.body
|
body = doc.element.body
|
||||||
for p in list(body.findall(qn("w:p"))):
|
sectPr_tag = qn("w:sectPr")
|
||||||
body.remove(p)
|
for child in list(body):
|
||||||
|
# Keep section properties (page setup / bidi). Drop everything else:
|
||||||
|
# sample paragraphs (w:p), sample tables (w:tbl), and any stray
|
||||||
|
# bookmark markers the template left dangling between them.
|
||||||
|
if child.tag != sectPr_tag:
|
||||||
|
body.remove(child)
|
||||||
|
|
||||||
|
|
||||||
# ── Bookmark helpers ──────────────────────────────────────────────
|
# ── Bookmark helpers ──────────────────────────────────────────────
|
||||||
|
|||||||
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))
|
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:
|
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
||||||
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.
|
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import argument_aggregator, db
|
from legal_mcp.services import argument_aggregator, claims_extractor, db
|
||||||
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
|
# Party labels reused across this module (display + impact diff).
|
||||||
|
_PARTY_HE = {
|
||||||
|
"appellant": "עוררים",
|
||||||
|
"respondent": "משיבים",
|
||||||
|
"committee": "ועדה מקומית",
|
||||||
|
"permit_applicant": "מבקשי היתר",
|
||||||
|
"unknown": "צד לא מזוהה",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def aggregate_claims_to_arguments(
|
async def aggregate_claims_to_arguments(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
@@ -55,16 +64,9 @@ async def get_legal_arguments(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Group by party for nicer display.
|
# Group by party for nicer display.
|
||||||
party_he = {
|
|
||||||
"appellant": "עוררים",
|
|
||||||
"respondent": "משיבים",
|
|
||||||
"committee": "ועדה מקומית",
|
|
||||||
"permit_applicant": "מבקשי היתר",
|
|
||||||
"unknown": "צד לא מזוהה",
|
|
||||||
}
|
|
||||||
by_party: dict[str, list[dict]] = {}
|
by_party: dict[str, list[dict]] = {}
|
||||||
for a in args:
|
for a in args:
|
||||||
label = party_he.get(a["party"], a["party"])
|
label = _PARTY_HE.get(a["party"], a["party"])
|
||||||
by_party.setdefault(label, []).append(a)
|
by_party.setdefault(label, []).append(a)
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
@@ -72,3 +74,147 @@ async def get_legal_arguments(
|
|||||||
"total": len(args),
|
"total": len(args),
|
||||||
"by_party": by_party,
|
"by_party": by_party,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Re-analysis with merge + impact diff (WS2 / #201) ───────────────
|
||||||
|
|
||||||
|
def _snapshot(args: list[dict]) -> dict:
|
||||||
|
"""Build a comparable snapshot of aggregated arguments for the impact diff.
|
||||||
|
|
||||||
|
Captures, per party: the set of argument titles and a priority histogram —
|
||||||
|
enough to show the chair what changed without dumping full bodies. The
|
||||||
|
priority mix is the deterministic "recommendation" signal: it summarises the
|
||||||
|
balance of threshold/substantive/procedural/relief arguments per side.
|
||||||
|
"""
|
||||||
|
by_party: dict[str, dict] = {}
|
||||||
|
for a in args:
|
||||||
|
party = a.get("party", "unknown")
|
||||||
|
bucket = by_party.setdefault(party, {"titles": [], "priorities": {}})
|
||||||
|
bucket["titles"].append((a.get("argument_title") or "").strip())
|
||||||
|
pr = a.get("priority", "substantive")
|
||||||
|
bucket["priorities"][pr] = bucket["priorities"].get(pr, 0) + 1
|
||||||
|
return {"total": len(args), "by_party": by_party}
|
||||||
|
|
||||||
|
|
||||||
|
def _impact_diff(before: dict, after: dict) -> dict:
|
||||||
|
"""Diff two argument snapshots → a chair-facing before↔after summary.
|
||||||
|
|
||||||
|
Reports, per party, which argument titles were added/removed and how the
|
||||||
|
priority mix (the "recommendation" balance) shifted. ``changed`` is a quick
|
||||||
|
boolean the UI/chair can gate on. Nothing here is auto-applied — it is purely
|
||||||
|
surfaced for the human gate (G10).
|
||||||
|
"""
|
||||||
|
parties = sorted(set(before["by_party"]) | set(after["by_party"]))
|
||||||
|
per_party: list[dict] = []
|
||||||
|
any_change = False
|
||||||
|
for party in parties:
|
||||||
|
b = before["by_party"].get(party, {"titles": [], "priorities": {}})
|
||||||
|
a = after["by_party"].get(party, {"titles": [], "priorities": {}})
|
||||||
|
b_titles, a_titles = set(b["titles"]), set(a["titles"])
|
||||||
|
added = sorted(a_titles - b_titles)
|
||||||
|
removed = sorted(b_titles - a_titles)
|
||||||
|
prio_before = dict(sorted(b["priorities"].items()))
|
||||||
|
prio_after = dict(sorted(a["priorities"].items()))
|
||||||
|
changed = bool(added or removed or prio_before != prio_after)
|
||||||
|
any_change = any_change or changed
|
||||||
|
per_party.append({
|
||||||
|
"party": party,
|
||||||
|
"party_he": _PARTY_HE.get(party, party),
|
||||||
|
"count_before": len(b["titles"]),
|
||||||
|
"count_after": len(a["titles"]),
|
||||||
|
"added_arguments": added,
|
||||||
|
"removed_arguments": removed,
|
||||||
|
"priority_before": prio_before,
|
||||||
|
"priority_after": prio_after,
|
||||||
|
"changed": changed,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"changed": any_change,
|
||||||
|
"total_before": before["total"],
|
||||||
|
"total_after": after["total"],
|
||||||
|
"by_party": per_party,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def reanalyze_claims(
|
||||||
|
case_number: str,
|
||||||
|
reanalyze_all_primary: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""ניתוח-מחדש מאחד של טענות — מחלץ ממסמכים-עיקריים חדשים/לא-נותחו בלבד,
|
||||||
|
מאחד עם הטענות הקיימות (לא מוחק הכול), מריץ צבירה-מחדש, ומחזיר בדיקת-השפעה
|
||||||
|
(diff טיעונים/המלצה לפני↔אחרי) ליו"ר.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: מספר תיק הערר.
|
||||||
|
reanalyze_all_primary: True = לחלץ מחדש מכל המסמכים-העיקריים (ולא רק
|
||||||
|
מאלה שטרם-נותחו). ברירת-מחדל False = רק מסמכים-עיקריים חדשים/לא-נותחו.
|
||||||
|
|
||||||
|
מנגנון האיחוד (לא force-delete): כל מסמך מחולץ דרך
|
||||||
|
``claims_extractor.extract_and_store_claims``, ש-``store_claims`` שלו מחליף רק
|
||||||
|
את טענות *אותו* מסמך (לפי ``source_document``) — כך טענות ממסמכים שכבר-נותחו
|
||||||
|
נשמרות. הצבירה-מחדש (``aggregate_claims_to_arguments(force=True)``) מחשבת את
|
||||||
|
הטיעונים מחדש מתוך **מערך-הטענות המאוחד השלם** — מסלול-החישוב הקנוני, לא מסלול
|
||||||
|
מקביל (G2).
|
||||||
|
"""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
|
# 1. BEFORE snapshot — current aggregated arguments (the impact baseline).
|
||||||
|
before_args = await argument_aggregator.get_legal_arguments(case_id)
|
||||||
|
before = _snapshot(before_args)
|
||||||
|
|
||||||
|
# 2. Select PRIMARY documents to (re)extract. Default: only those not yet
|
||||||
|
# analysed (the not-analysed flag). reanalyze_all_primary widens to every
|
||||||
|
# primary doc. Source of truth for "primary" = is_primary (V47).
|
||||||
|
if reanalyze_all_primary:
|
||||||
|
all_docs = await db.list_documents(case_id)
|
||||||
|
targets = [d for d in all_docs if d.get("is_primary")]
|
||||||
|
else:
|
||||||
|
targets = await db.primary_docs_not_analyzed(case_id)
|
||||||
|
|
||||||
|
if not targets:
|
||||||
|
return ok({
|
||||||
|
"case_number": case_number,
|
||||||
|
"status": "no_pending_documents",
|
||||||
|
"message": "אין מסמכים-עיקריים חדשים/לא-נותחו. לא בוצע ניתוח-מחדש.",
|
||||||
|
"documents_analyzed": [],
|
||||||
|
"impact": _impact_diff(before, before),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. Extract+merge claims from the selected documents only. Each call replaces
|
||||||
|
# only that document's claims (merge), never the whole case (no force-delete).
|
||||||
|
analyzed: list[dict] = []
|
||||||
|
for doc in targets:
|
||||||
|
text = await db.get_document_text(UUID(doc["id"]))
|
||||||
|
if not text:
|
||||||
|
await db.mark_document_claims_extracted(UUID(doc["id"]), status="no_claims")
|
||||||
|
analyzed.append({"document": doc["title"], "status": "empty_text", "total": 0})
|
||||||
|
continue
|
||||||
|
res = await claims_extractor.extract_and_store_claims(
|
||||||
|
case_id=case_id,
|
||||||
|
document_id=UUID(doc["id"]),
|
||||||
|
text=text,
|
||||||
|
doc_type=doc["doc_type"],
|
||||||
|
)
|
||||||
|
analyzed.append({"document": doc["title"], **res})
|
||||||
|
|
||||||
|
# 4. Re-aggregate from the now-complete merged claim set. force=True is the
|
||||||
|
# canonical recompute (deletes only legal_arguments, NOT claims) — same
|
||||||
|
# path aggregate_claims_to_arguments always uses, not a fork.
|
||||||
|
agg = await argument_aggregator.aggregate_claims_to_arguments(case_id, force=True)
|
||||||
|
|
||||||
|
# 5. AFTER snapshot + impact diff for the chair.
|
||||||
|
after_args = await argument_aggregator.get_legal_arguments(case_id)
|
||||||
|
after = _snapshot(after_args)
|
||||||
|
impact = _impact_diff(before, after)
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
"case_number": case_number,
|
||||||
|
"status": "completed",
|
||||||
|
"documents_analyzed": analyzed,
|
||||||
|
"aggregation": agg,
|
||||||
|
"impact": impact,
|
||||||
|
})
|
||||||
|
|||||||
@@ -46,8 +46,19 @@ async def workflow_status(case_number: str) -> str:
|
|||||||
"extraction": doc["extraction_status"],
|
"extraction": doc["extraction_status"],
|
||||||
"chunks": chunk_map.get(doc["id"], 0),
|
"chunks": chunk_map.get(doc["id"], 0),
|
||||||
"pages": doc.get("page_count"),
|
"pages": doc.get("page_count"),
|
||||||
|
# WS2 / #201: primary/secondary + whether claims were analysed.
|
||||||
|
"is_primary": doc.get("is_primary", False),
|
||||||
|
"claims_analyzed": doc.get("claims_analyzed", False),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# WS2 / #201: PRIMARY documents added after the analysis ran and not yet
|
||||||
|
# included → the "not-analysed" condition that should prompt a merging
|
||||||
|
# re-analysis (reanalyze_claims). Derived from is_primary + claims_extracted_at.
|
||||||
|
pending_primary = await db.primary_docs_not_analyzed(case_id)
|
||||||
|
primary_not_analyzed = [
|
||||||
|
{"title": d["title"], "type": d["doc_type"]} for d in pending_primary
|
||||||
|
]
|
||||||
|
|
||||||
# Check draft status
|
# Check draft status
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
@@ -64,15 +75,19 @@ async def workflow_status(case_number: str) -> str:
|
|||||||
"documents": doc_status,
|
"documents": doc_status,
|
||||||
"total_documents": len(docs),
|
"total_documents": len(docs),
|
||||||
"total_chunks": sum(chunk_map.values()),
|
"total_chunks": sum(chunk_map.values()),
|
||||||
|
"primary_docs_not_analyzed": primary_not_analyzed,
|
||||||
"has_draft": has_draft,
|
"has_draft": has_draft,
|
||||||
"draft_size_bytes": draft_size,
|
"draft_size_bytes": draft_size,
|
||||||
"next_steps": _suggest_next_steps(case, docs, has_draft),
|
"next_steps": _suggest_next_steps(case, docs, has_draft, primary_not_analyzed),
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok(status)
|
return ok(status)
|
||||||
|
|
||||||
|
|
||||||
def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]:
|
def _suggest_next_steps(
|
||||||
|
case: dict, docs: list, has_draft: bool,
|
||||||
|
primary_not_analyzed: list | None = None,
|
||||||
|
) -> list[str]:
|
||||||
"""Suggest next steps based on case state."""
|
"""Suggest next steps based on case state."""
|
||||||
steps = []
|
steps = []
|
||||||
doc_types = {d["doc_type"] for d in docs}
|
doc_types = {d["doc_type"] for d in docs}
|
||||||
@@ -85,6 +100,13 @@ def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]:
|
|||||||
if "response" not in doc_types:
|
if "response" not in doc_types:
|
||||||
steps.append("העלה תשובת ועדה/משיבים")
|
steps.append("העלה תשובת ועדה/משיבים")
|
||||||
|
|
||||||
|
# WS2 / #201: a primary document was added but not yet analysed.
|
||||||
|
if primary_not_analyzed:
|
||||||
|
titles = ", ".join(d["title"] for d in primary_not_analyzed)
|
||||||
|
steps.append(
|
||||||
|
f"מסמך-עיקרי לא-נותח ({titles}) — הרץ ניתוח-מחדש מאחד (reanalyze_claims)"
|
||||||
|
)
|
||||||
|
|
||||||
pending = [d for d in docs if d["extraction_status"] == "pending"]
|
pending = [d for d in docs if d["extraction_status"] == "pending"]
|
||||||
if pending:
|
if pending:
|
||||||
steps.append(f"עיבוד {len(pending)} מסמכים ממתינים")
|
steps.append(f"עיבוד {len(pending)} מסמכים ממתינים")
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ from lxml import etree
|
|||||||
from legal_mcp.services.docx_exporter import (
|
from legal_mcp.services.docx_exporter import (
|
||||||
_BOOKMARK_ID_START,
|
_BOOKMARK_ID_START,
|
||||||
HEBREW_FONT,
|
HEBREW_FONT,
|
||||||
|
TEMPLATE_PATH,
|
||||||
_add_styled_paragraph,
|
_add_styled_paragraph,
|
||||||
|
_clear_body,
|
||||||
_insert_bookmark_end,
|
_insert_bookmark_end,
|
||||||
_insert_bookmark_start,
|
_insert_bookmark_start,
|
||||||
_mark_paragraph_rtl,
|
_mark_paragraph_rtl,
|
||||||
@@ -168,6 +170,83 @@ def test_block_dalet_does_not_use_title_style() -> None:
|
|||||||
assert any("החלטה" in t for t in texts)
|
assert any("החלטה" in t for t in texts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_block_dalet_is_heading_only_ignores_db_content() -> None:
|
||||||
|
"""ת2 — block-ד is the 'החלטה' heading marker, NOT a data block.
|
||||||
|
|
||||||
|
Whatever content sits in decision_blocks for block-dalet must be ignored;
|
||||||
|
the only non-empty text line produced is the literal heading 'החלטה'.
|
||||||
|
"""
|
||||||
|
doc = Document()
|
||||||
|
_write_block_to_docx(
|
||||||
|
doc, "block-dalet", title="כותרת",
|
||||||
|
content="נתון ישן שצריך להיות מתועלם\nשורה שנייה",
|
||||||
|
)
|
||||||
|
text_lines = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||||
|
assert text_lines == ["החלטה"], (
|
||||||
|
f"block-dalet must render ONLY the heading 'החלטה', got {text_lines}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _clear_body strips ALL scaffolding (ת1 + ת3 root cause) ────────
|
||||||
|
# The template ships 3 sample tables (header / panel / signatures). Removing
|
||||||
|
# only <w:p> left them behind: header/panel tables injected block-א–ד data
|
||||||
|
# never extracted from the protocol (ת1), and the signatures table floated to
|
||||||
|
# the top against the header (ת3 — חתימות צמודות לבלוק-ד במקום בסוף). The
|
||||||
|
# decision is rebuilt purely from decision_blocks (INV-EX1), so the template's
|
||||||
|
# sample tables are stale scaffolding and must be cleared.
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_body_removes_tables_and_leaves_only_sectpr() -> None:
|
||||||
|
doc = Document(str(TEMPLATE_PATH))
|
||||||
|
body = doc.element.body
|
||||||
|
# Sanity: the template really ships sample tables (else this test is moot).
|
||||||
|
assert len(body.findall(qn("w:tbl"))) > 0, "template expected to ship sample tables"
|
||||||
|
|
||||||
|
_clear_body(doc)
|
||||||
|
|
||||||
|
remaining = [c.tag.split("}")[-1] for c in list(body)]
|
||||||
|
assert remaining == ["sectPr"], (
|
||||||
|
f"_clear_body must leave only sectPr, got {remaining}"
|
||||||
|
)
|
||||||
|
assert len(body.findall(qn("w:tbl"))) == 0, "no template tables may survive"
|
||||||
|
assert len(body.findall(qn("w:p"))) == 0, "no template paragraphs may survive"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signatures_block_renders_after_body_not_at_top() -> None:
|
||||||
|
"""ת3 — block-yod-bet (חתימות) renders LAST, after a cleared body.
|
||||||
|
|
||||||
|
Regression for the bug where the leftover template signatures table sat at
|
||||||
|
the top against the header table. After the _clear_body fix, signatures
|
||||||
|
come only from block-yod-bet, which the export loop emits last in block
|
||||||
|
order.
|
||||||
|
"""
|
||||||
|
doc = Document(str(TEMPLATE_PATH))
|
||||||
|
_clear_body(doc)
|
||||||
|
|
||||||
|
# Emit a header block, a body block, then signatures last (canonical order).
|
||||||
|
_write_block_to_docx(doc, "block-alef", title="", content="מדינת ישראל")
|
||||||
|
_write_block_to_docx(doc, "block-vav", title="", content="רקע עובדתי\n1. המקרקעין.")
|
||||||
|
_write_block_to_docx(
|
||||||
|
doc, "block-yod-bet", title="",
|
||||||
|
content='ניתנה פה אחד היום.\nדפנה תמיר, עו"ד',
|
||||||
|
)
|
||||||
|
|
||||||
|
text_lines = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||||
|
assert text_lines[0] == "מדינת ישראל", text_lines
|
||||||
|
# The signatures block (block-yod-bet) emits each line as its own paragraph,
|
||||||
|
# so its content must occupy the document's tail — after the body block.
|
||||||
|
body_idx = text_lines.index("רקע עובדתי")
|
||||||
|
sig_idx = next(i for i, t in enumerate(text_lines) if "ניתנה פה אחד" in t)
|
||||||
|
assert sig_idx > body_idx, (
|
||||||
|
f"signatures must come AFTER the body, got tail={text_lines[-3:]}"
|
||||||
|
)
|
||||||
|
assert any("דפנה תמיר" in t for t in text_lines[sig_idx:]), text_lines[sig_idx:]
|
||||||
|
# No template signatures table ("מזכירת ועדת ערר") leaked anywhere.
|
||||||
|
assert all(
|
||||||
|
"מזכירת ועדת ערר" not in (p.text or "") for p in doc.paragraphs
|
||||||
|
), "template signatures-table text leaked into the body"
|
||||||
|
|
||||||
|
|
||||||
# ── Heading overrides, numbered-list, dash strip ──────────────────
|
# ── Heading overrides, numbered-list, dash strip ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
76
mcp-server/tests/test_reanalyze_impact_diff.py
Normal file
76
mcp-server/tests/test_reanalyze_impact_diff.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Unit tests for the re-analysis impact diff — WS2 / task #201.
|
||||||
|
|
||||||
|
`_snapshot` + `_impact_diff` are pure functions (no DB) that produce the
|
||||||
|
before↔after summary surfaced to the chair after a merging re-analysis. They
|
||||||
|
must report per-party argument titles added/removed and the shift in the
|
||||||
|
priority ("recommendation balance") mix, and set ``changed`` correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from legal_mcp.tools.legal_arguments import _impact_diff, _snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _arg(party, title, priority="substantive"):
|
||||||
|
return {"party": party, "argument_title": title, "priority": priority}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_change_is_flagged_false():
|
||||||
|
args = [_arg("appellant", "זכות עמידה"), _arg("committee", "שיהוי")]
|
||||||
|
snap = _snapshot(args)
|
||||||
|
diff = _impact_diff(snap, snap)
|
||||||
|
assert diff["changed"] is False
|
||||||
|
assert diff["total_before"] == diff["total_after"] == 2
|
||||||
|
# Every party entry reports changed=False.
|
||||||
|
assert all(p["changed"] is False for p in diff["by_party"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_added_argument_detected():
|
||||||
|
before = _snapshot([_arg("appellant", "זכות עמידה")])
|
||||||
|
after = _snapshot([_arg("appellant", "זכות עמידה"), _arg("appellant", "תחולת תמא 38")])
|
||||||
|
diff = _impact_diff(before, after)
|
||||||
|
assert diff["changed"] is True
|
||||||
|
assert diff["total_before"] == 1
|
||||||
|
assert diff["total_after"] == 2
|
||||||
|
appellant = next(p for p in diff["by_party"] if p["party"] == "appellant")
|
||||||
|
assert appellant["added_arguments"] == ["תחולת תמא 38"]
|
||||||
|
assert appellant["removed_arguments"] == []
|
||||||
|
assert appellant["party_he"] == "עוררים"
|
||||||
|
|
||||||
|
|
||||||
|
def test_removed_argument_detected():
|
||||||
|
before = _snapshot([_arg("committee", "שיהוי"), _arg("committee", "סמכות")])
|
||||||
|
after = _snapshot([_arg("committee", "שיהוי")])
|
||||||
|
diff = _impact_diff(before, after)
|
||||||
|
committee = next(p for p in diff["by_party"] if p["party"] == "committee")
|
||||||
|
assert committee["removed_arguments"] == ["סמכות"]
|
||||||
|
assert committee["count_before"] == 2
|
||||||
|
assert committee["count_after"] == 1
|
||||||
|
assert diff["changed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_priority_mix_shift_is_a_change():
|
||||||
|
# Same title, but priority moved threshold -> substantive: the recommendation
|
||||||
|
# balance shifted, so the chair must see a change even with identical titles.
|
||||||
|
before = _snapshot([_arg("appellant", "סמכות", priority="threshold")])
|
||||||
|
after = _snapshot([_arg("appellant", "סמכות", priority="substantive")])
|
||||||
|
diff = _impact_diff(before, after)
|
||||||
|
appellant = next(p for p in diff["by_party"] if p["party"] == "appellant")
|
||||||
|
assert appellant["added_arguments"] == []
|
||||||
|
assert appellant["removed_arguments"] == []
|
||||||
|
assert appellant["priority_before"] == {"threshold": 1}
|
||||||
|
assert appellant["priority_after"] == {"substantive": 1}
|
||||||
|
assert appellant["changed"] is True
|
||||||
|
assert diff["changed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_party_appears():
|
||||||
|
before = _snapshot([_arg("appellant", "זכות עמידה")])
|
||||||
|
after = _snapshot([_arg("appellant", "זכות עמידה"), _arg("respondent", "מענה")])
|
||||||
|
diff = _impact_diff(before, after)
|
||||||
|
parties = {p["party"] for p in diff["by_party"]}
|
||||||
|
assert parties == {"appellant", "respondent"}
|
||||||
|
respondent = next(p for p in diff["by_party"] if p["party"] == "respondent")
|
||||||
|
assert respondent["count_before"] == 0
|
||||||
|
assert respondent["count_after"] == 1
|
||||||
|
assert respondent["added_arguments"] == ["מענה"]
|
||||||
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")
|
@app.put("/api/cases/{case_number}/research/analysis/upload")
|
||||||
async def api_research_analysis_upload(
|
async def api_research_analysis_upload(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user