6 Commits

Author SHA1 Message Date
dfb2ffe7ce feat(analysis): דגל "לא-נותח" + ניתוח-מחדש מאחד + בדיקת-השפעה (#201)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
WS2b של עיצוב-מחדש זרימת-העבודה. מאפשר ניתוח כתב-הערר לבד ואז איחוד
מסמך-עיקרי שנוסף מאוחר, בלי force-delete גורף, עם diff ליו"ר.

- SCHEMA_V49: documents.claims_extracted_at + claims_extraction_status
  (אירוע-חילוץ per-מסמך, לא נגזר מ-doc_type), + אינדקס חלקי
  idx_documents_claims_pending. idempotent (ADD COLUMN IF NOT EXISTS).
  V48 שמור ל-#357.
- claims_extractor חותם את המסמך אחרי שמירת/אי-מציאת טענות.
- db.primary_docs_not_analyzed (is_primary V47 + claims_extracted_at IS NULL)
  + mark_document_claims_extracted; _row_to_doc חושף claims_analyzed.
- reanalyze_claims (כלי-MCP): snapshot→חילוץ-מאחד רק למסמכים חדשים/לא-נותחו
  (store_claims מחליף per-source ⇒ טענות אחרות נשמרות)→aggregate force=True
  (מסלול קנוני, מוחק רק legal_arguments)→_impact_diff before↔after ליו"ר.
- workflow_status חושף primary_docs_not_analyzed + next-step.
- ספ: 02-data-model §2ג (דגל לא-נותח), 04-analysis-writing §1.3.
- 5 בדיקות-יחידה ל-_impact_diff/_snapshot (פונקציות טהורות).

Invariants:
- G1 (נרמול-במקור): claims_extracted_at נחתם בנקודת-החילוץ, claims_analyzed
  נגזר ממנו — לא תיקון-בקריאה.
- G2 (מסלול קנוני יחיד / merge-not-fork): reanalyze מרחיב את מסלול
  claims_extractor+argument_aggregator הקיים, לא forks; האיחוד דרך
  store_claims per-source; aggregate force=True מוחק רק נגזר (legal_arguments).
- INV-DM (מודל-נתונים): דגל-אירוע per-מסמך, אינדקס חלקי, מקור-אמת יחיד.
- G10 (שער-אנושי): בדיקת-ההשפעה מוצגת ליו"ר, לא מוחלת אוטומטית.
- INV-TOOL idempotency: SCHEMA_V49 idempotent; ניתוח-מחדש חוזר על מסמך
  כבר-נותח הוא refresh נקי (store_methods replace per-source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:19:28 +00:00
99574f0eba Merge pull request 'fix(export): תיקוני-תבנית DOCX — חתימות לסוף, א–ד ללא נתונים, "החלטה"=כותרת (#205)' (#355) from worktree-agent-a4e6a6d273c4c3379 into main
Some checks failed
G12 Leak-Guard / leak-guard (push) Has been cancelled
Lint — undefined names / undefined-names (push) Has been cancelled
Build & Deploy / build-and-deploy (push) Has been cancelled
2026-06-30 12:18:41 +00:00
085de937da Merge pull request 'feat(analysis): סיכום-מנהלים מזוקק של טענות הצדדים — מסמך הכנה לדיון (#202, WS3)' (#358) from worktree-agent-abc25c98bc1092fa3 into main
Some checks failed
G12 Leak-Guard / leak-guard (push) Has been cancelled
Build & Deploy / build-and-deploy (push) Has been cancelled
Lint — undefined names / undefined-names (push) Has been cancelled
2026-06-30 12:18:09 +00:00
9e6a441076 Merge remote-tracking branch 'origin/main' into rebase-358
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
# Conflicts:
#	docs/spec/04-analysis-writing.md
2026-06-30 12:16:41 +00:00
aa2548ff5d feat(analysis): סיכום-מנהלים מזוקק של טענות הצדדים — מסמך הכנה לדיון (#202, WS3)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
מסמך פרוזה נפרד מטיוטת-הביניים (החלטת-יו"ר): תמצית מוקפדת של טענות+תשובות
להכנת היו"ר לדיון. נגזר מ-legal_arguments/claims הקיימים — אותו מקור-אמת,
ללא חילוץ-מחדש (G2). מתוחם לטענות-התיק בלבד (INV-AH).

- כלי-MCP חדש summarize_party_claims (drafting.py + server.py); ייצור עובר
  claude_session נעוץ Opus 4.8 + effort=high (זיקוק/סינתזה); local-only.
- שירות party_claims_summary.py: בחירת-מקור arguments→claims, פרומפט מעוגן-מקור,
  שמירה ל-data/cases/{n}/documents/research/ (git + S3).
- ייצוא DOCX בסגנון-תבנית דפנה (build_party_claims_summary_docx) + endpoints
  read/download/export-docx ב-web/app.py (מגישים בלבד — אין claude CLI בקונטיינר).
- _next_version הוכלל ל-prefix-scoped (תאימות-לאחור) למניעת התנגשות-גרסאות.
- ספ 04-analysis-writing.md §1.3 + 8 בדיקות אופליין (מקור/נעיצת-מודל/markdown→docx).

Invariants: G2 (מקור-אמת יחיד, בלי מסלול מקביל) · X9 TOOL1/TOOL4 (envelope +
extract↔serve) · INV-AH (עיגון-מקור, abstention על טענה לא-ברורה) ·
claude_session local-only · G9 (provenance — נשמר durably + git/S3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:22:20 +00:00
23e088d25e fix(export): clear template tables in _clear_body — חתימות לסוף, א–ד ללא נתונים (#205)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
תיקוני-תבנית DOCX (WS5 ת1–ת3): הטמפלט decision_template.docx מכיל 3 טבלאות-
דוגמה (כותרת מוסדית / הרכב / חתימות). _clear_body הסיר רק <w:p> והשאיר את
הטבלאות. כתוצאה:

- ת3 (שורש): טבלת-החתימות שרדה במיקום-הטמפלט (למעלה, צמוד לטבלת-הכותרת/בלוק-ד),
  וכל תוכן-ההחלטה נדחף אחריה. החתימות הופיעו בראש המסמך במקום בסוף.
- ת1: טבלאות הכותרת/הרכב הזריקו נתוני בלוק-א–ד שלא חולצו מהפרוטוקול.

התיקון בשכבת-הרינדור (G1 — לתקן במקור, לא לשכפל סימפטום): _clear_body מסיר
כעת את כל גוף-המסמך (פסקאות, טבלאות, ו-bookmarks יתומים) ומשאיר רק sectPr.
ההחלטה משוחזרת כולה מ-decision_blocks (INV-EX1) — החתימות מגיעות מ-block-yod-bet
שמרונדר אחרון בסדר-הבלוקים, ולכן טבלאות-הדוגמה הן פיגום מיושן שצריך להימחק.

ת2 כבר תקין: block-dalet מרנדר רק את הכותרת "החלטה" (Heading 1, מתעלם מתוכן-
ה-DB) — הוסף test מפורש לנעילה.

תוקן גם ב-analysis_docx_exporter._clear_body (אותו באג, סימטריה G2).

Tests: 4 regression חדשים + 27 docx-tests עוברים.

Invariants: INV-EX1 (DOCX נתון-נגזר מ-decision_blocks, ללא דליפת תוכן-טמפלט) ·
G1 (תיקון-במקור) · G2 (סימטריה בין שני ה-exporters) · G11/block-schema
(block-ד = כותרת בלבד, block-יב = חתימות בסוף).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:16:39 +00:00
15 changed files with 1092 additions and 27 deletions

View File

@@ -95,6 +95,19 @@ WS2 (#200) מבחין בין **מסמך-עיקרי** (מסמך-מהות שהיו
`document_list` / `case_get` / API-המסמכים. אינדקס חלקי `idx_documents_primary(case_id) WHERE
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ד. ישויות-נגזרות (אחסון-ניתוחים)
מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי

View File

@@ -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,48 @@ 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.11.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 הבלוקים (סיכום)

View File

@@ -572,6 +572,18 @@ async def get_legal_arguments(
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
@mcp.tool()
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)
@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 אם חסר."""

View File

@@ -182,16 +182,21 @@ def _add_runs_with_inline_bold(paragraph: Paragraph, text: str) -> 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 ("רקע", "דיון והכרעה"…)
that we don't want in the output. Section properties (sectPr) are
kept so page size / margins / RTL / footer remain intact.
The template ships with example paragraphs ("רקע", "דיון והכרעה"…) *and
three sample tables* (header / panel / signatures). Removing only ``w:p``
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
for p in list(body.findall(qn("w:p"))):
body.remove(p)
# Leave sectPr alone — it carries page setup including bidi.
sectPr_tag = qn("w:sectPr")
for child in list(body):
if child.tag != sectPr_tag:
body.remove(child)
def _add_paragraph(doc: DocumentT, text: str, style: str) -> Paragraph:
@@ -386,9 +391,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 +516,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

View File

@@ -360,6 +360,10 @@ async def extract_and_store_claims(
claims = await extract_claims_with_ai(text, doc_type, party_hint)
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}
# Determine claim_type from document type and title
@@ -368,6 +372,10 @@ async def extract_and_store_claims(
c["claim_type"] = claim_type
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
role_counts: dict[str, int] = {}

View File

@@ -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);
"""
# 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
# 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_V47_SQL)
await conn.execute(SCHEMA_V48_SQL)
await conn.execute(SCHEMA_V49_SQL)
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"))
d["is_primary"] = is_primary
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
@@ -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]
# ── 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 ──────────────────────────────────────────────────────
async def create_decision(

View File

@@ -192,14 +192,29 @@ def _apply_list_numbering(paragraph, num_id: int) -> 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
properties (page size, margins, bidi) stay intact.
The template ships with sample paragraphs *and three sample tables*
(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
for p in list(body.findall(qn("w:p"))):
body.remove(p)
sectPr_tag = qn("w:sectPr")
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 ──────────────────────────────────────────────

View 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}
## כללי-כתיבה מחייבים:
- **זיקוק, לא שכפול.** אל תעתיק את כתבי-הטענות. תמצת כל צד ל-25 משפטי-מפתח. היעד: עמוד אחד עד שניים, לא יותר.
- **מבוסס-קלט בלבד (חובה אנטי-הזיה).** הסתמך אך-ורק על הטענות שבקלט לעיל. אסור להמציא טענה, הלכה, פסק-דין, מספר או עובדה שאינם בקלט. טענה שאינה ברורה או חסרה — ציין זאת במפורש ("הטענה לא פורטה"), אל תשלים מדמיונך.
- **ניטרלי.** הצג את עמדת כל צד בנאמנות, בלי להעריך, בלי להכריע ובלי לרמוז על תוצאה. אין זו החלטה — זו הכנה.
- **מבנה קבוע:**
1. פסקת-פתיחה קצרה (משפט–שניים): במה עוסק הערר ומיהם הצדדים.
2. **טענות העוררים** — תמצית מזוקקת.
3. **טענות המשיבים / הוועדה / מבקשי ההיתר** — תמצית מזוקקת (כל צד שקיים בקלט).
4. **נקודות-המחלוקת המרכזיות** — 24 הסוגיות שעליהן ניצבת ההכרעה, כשאלות פתוחות (לא תשובות).
- **סגנון:** עברית משפטית בהירה, גוף-שלישי לתיאור הצדדים. בלי כותרת "החלטה". בלי חתימות.
## מדריך-סגנון (לטון בלבד — אל תיישם מבנה-החלטה):
{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}).",
}

View File

@@ -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:
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.

View File

@@ -4,9 +4,18 @@ from __future__ import annotations
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
# Party labels reused across this module (display + impact diff).
_PARTY_HE = {
"appellant": "עוררים",
"respondent": "משיבים",
"committee": "ועדה מקומית",
"permit_applicant": "מבקשי היתר",
"unknown": "צד לא מזוהה",
}
async def aggregate_claims_to_arguments(
case_number: str,
@@ -55,16 +64,9 @@ async def get_legal_arguments(
)
# Group by party for nicer display.
party_he = {
"appellant": "עוררים",
"respondent": "משיבים",
"committee": "ועדה מקומית",
"permit_applicant": "מבקשי היתר",
"unknown": "צד לא מזוהה",
}
by_party: dict[str, list[dict]] = {}
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)
return ok({
@@ -72,3 +74,147 @@ async def get_legal_arguments(
"total": len(args),
"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,
})

View File

@@ -46,8 +46,19 @@ async def workflow_status(case_number: str) -> str:
"extraction": doc["extraction_status"],
"chunks": chunk_map.get(doc["id"], 0),
"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
from pathlib import Path
from legal_mcp import config
@@ -64,15 +75,19 @@ async def workflow_status(case_number: str) -> str:
"documents": doc_status,
"total_documents": len(docs),
"total_chunks": sum(chunk_map.values()),
"primary_docs_not_analyzed": primary_not_analyzed,
"has_draft": has_draft,
"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)
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."""
steps = []
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:
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"]
if pending:
steps.append(f"עיבוד {len(pending)} מסמכים ממתינים")

View File

@@ -14,7 +14,9 @@ from lxml import etree
from legal_mcp.services.docx_exporter import (
_BOOKMARK_ID_START,
HEBREW_FONT,
TEMPLATE_PATH,
_add_styled_paragraph,
_clear_body,
_insert_bookmark_end,
_insert_bookmark_start,
_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)
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 ──────────────────

View 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

View 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"] == ["מענה"]

View File

@@ -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,