diff --git a/docs/spec/04-analysis-writing.md b/docs/spec/04-analysis-writing.md index 8266bc2..4a9db79 100644 --- a/docs/spec/04-analysis-writing.md +++ b/docs/spec/04-analysis-writing.md @@ -47,6 +47,34 @@ > מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון > שלו מקבל מענה בבלוק י. +### 1.3 ניתוח-פרוטוקול — ניתוח פרוטוקול-דיון השוואתי (WS4 / #203) + +> **בעלוּת תת-סעיף:** §1.3 (זה) שייך ל-WS4 (ניתוח-פרוטוקול). תת-סעיף סיכום-טענות-הצדדים +> (PR-אחות #358) הוא **תת-סעיף נפרד** תחת §1 — אין חפיפת-מספור בין השניים. + +אחרי הדיון, פרוטוקול (`doc_type='protocol'`) מנותח **מול הטיעונים המאוגדים** — +`analyze_protocol(case_number)` (→ `services/protocol_analyzer.py`). הניתוח מסווג כל +טיעון כתוב כ**ירד** (`dropped` — נזנח/ויתר בדיון), **חוזק** (`strengthened`), או מזהה +טענה ש**עלתה לראשונה בדיון** (`newly_raised`), ולכל רשומה מנסח את ה**שאלה המשפטית +המחודדת** לטובת בלוק י. התוצאה נשמרת ב**ידע-התיק** — טבלת `protocol_analysis` (case- +knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arguments`, הרשומה היא השוואה +מטוריאליזת בת-שחזור) — וזמינה ל-`get_protocol_analysis(case_number, change_type="")` +(סימטריית extract/get, [INV-TOOL4](X9-mcp-tool-contract.md)). + +- **קלט:** הטיעונים המאוגדים (§1.2) הם קו-הבסיס; ללא כינוס אין מול-מה להשוות. +- **שער anti-hallucination ([INV-AH](../anti-hallucination-gate.md)):** כל רשומה חייבת + `evidence_quote` — ציטוט **מילולי** מהפרוטוקול. רשומה ללא ציטוט-מבסס נדחית **במקור** + (`_normalize_change`) ולא מגיעה ל-DB (quote-or-retract). +- **טענות-הדיון בנפרד מבלוק ז:** טענות שעלו בדיון מתויגות `claim_type='protocol'` + ב-`extract_claims` ו**מוחרגות** מהקשר בלוק ז (`block_writer._build_claims_context`) — + בלוק ז נשאר **טענות-כתב מקוריות בלבד** ([INV-WR4](#inv-wr4-בלוק-ז--טענות-מקוריות-בלבד)); + טענות-הדיון שייכות לבלוק ח (הליכים) ולידע-התיק ההשוואתי. +- **נתוני כותרת (א–ד):** הניתוח מחלץ גם את הפיד המוכר (הרכב, תאריך-דיון, צדדים שהופיעו) + ומזין את `hearing_date` חזרה לעמודה הקנונית `cases.hearing_date` (G1; לא נכתב אם + היו"ר כבר מילא תאריך — לא דורסים קלט-יו"ר). +- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת + `model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`). + --- ## 2. ארכיטקטורת 12 הבלוקים (סיכום) diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index 00f360f..090af06 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -744,6 +744,27 @@ async def get_appraiser_facts(case_number: str) -> str: return await drafting.get_appraiser_facts(case_number) +# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ──── + +@mcp.tool() +async def analyze_protocol(case_number: str) -> str: + """ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד. + + מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול doc_type='protocol' + + טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high; re-run מחליף (idempotent). + """ + return await drafting.analyze_protocol(case_number) + + +@mcp.tool() +async def get_protocol_analysis(case_number: str, change_type: str = "") -> str: + """קריאת ניתוח-הפרוטוקול שכבר חולץ — ללא ניתוח-מחדש. ה-get המקביל ל-analyze_protocol. + + change_type: dropped/strengthened/newly_raised (ריק=הכל). + """ + return await drafting.get_protocol_analysis(case_number, change_type) + + # ── Planning-schemes registry (V38) — מרשם-התכניות ───────────────── # SSOT לזהות+תוקף של תכנית, נעשה שימוש חוזר בין תיקים (G2). פלט-LLM נכנס # pending_review וממתין לאישור-יו"ר (plan_review, G10) לפני שמשמש בבלוק ט. diff --git a/mcp-server/src/legal_mcp/services/block_writer.py b/mcp-server/src/legal_mcp/services/block_writer.py index 8185a31..fd20a6a 100644 --- a/mcp-server/src/legal_mcp/services/block_writer.py +++ b/mcp-server/src/legal_mcp/services/block_writer.py @@ -587,11 +587,19 @@ async def _build_claims_context(case_id: UUID) -> str: return "(לא חולצו טענות)" # Filter out claims from block-zayin (decision summary) — use only - # claims extracted from original pleadings (appeal, response, etc.) - source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] + # claims extracted from original pleadings (appeal, response, etc.). + # Also drop claim_type='protocol' (oral arguments raised at the hearing): + # block ז carries ORIGINAL written pleadings only (INV-WR4); hearing-raised + # arguments belong to block ח (proceedings) and to analyze_protocol's + # comparative case-knowledge, not to the parties'-claims summary. + source_claims = [ + c for c in claims + if c.get("source_document", "") != "block-zayin" + and c.get("claim_type", "claim") != "protocol" + ] if not source_claims: - # Fallback to all claims if no source claims exist - source_claims = claims + # Fallback to all non-block-zayin claims if no source claims exist. + source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims lines = [] current_role = "" diff --git a/mcp-server/src/legal_mcp/services/claims_extractor.py b/mcp-server/src/legal_mcp/services/claims_extractor.py index add1204..2959786 100644 --- a/mcp-server/src/legal_mcp/services/claims_extractor.py +++ b/mcp-server/src/legal_mcp/services/claims_extractor.py @@ -227,8 +227,15 @@ def _infer_claim_type(doc_type: str, source_name: str) -> str: - 'claim' = from appeal documents (כתב ערר) - 'response' = from original response documents (כתב תשובה) - 'reply' = from supplementary responses (תגובה, השלמת טיעון) + - 'protocol' = oral arguments raised at the hearing (פרוטוקול דיון) """ name_lower = source_name.lower() if source_name else "" + # A hearing protocol carries oral arguments — tagged distinctly so the + # comparative protocol analysis (analyze_protocol) and block-chet + # (proceedings) can tell them apart from the original written pleadings + # (INV-WR4: block ז stays original-pleadings-only). + if doc_type == "protocol" or "פרוטוקול" in name_lower: + return "protocol" if doc_type == "appeal" or "כתב ערר" in name_lower: return "claim" if "כתב תשובה" in name_lower: diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 158190d..ede2743 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -1835,6 +1835,41 @@ CREATE INDEX IF NOT EXISTS idx_documents_primary ON documents(case_id) WHERE is_primary; """ +# ── V48: Protocol comparative analysis (WS4 / #203) ─────────────── +# +# protocol_analysis: case-knowledge derived from a hearing-protocol document by +# comparing the oral arguments raised at the hearing against the written +# pleadings (legal_arguments). One row per (case_id, document_id) — idempotent +# replace on re-run (INV-TOOL3). Each row holds the comparative verdict on a +# single argument: was it DROPPED at the hearing, STRENGTHENED, or NEWLY_RAISED, +# plus the sharpened legal question it bears on. This is *derived* knowledge — +# the source of truth is the protocol document + legal_arguments; the row is a +# materialized comparison, re-buildable from those (G2: no parallel store, this +# is derived-from-source not a competing claims table). +# +# header_data (the א–ד feed: panel, hearing date, parties present) is written +# back to the canonical `cases` columns (panel via decisions, hearing_date on +# cases) — NOT duplicated here — and a copy of the raw extracted feed is kept on +# the analysis row for provenance only (G9). +SCHEMA_V48_SQL = """ +CREATE TABLE IF NOT EXISTS protocol_analysis ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE, + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + party_role TEXT NOT NULL DEFAULT '', -- appellant/respondent/committee/permit_applicant/'' + change_type TEXT NOT NULL CHECK (change_type IN ('dropped', 'strengthened', 'newly_raised')), + argument_id UUID REFERENCES legal_arguments(id) ON DELETE SET NULL, -- the pleaded argument, when matched + argument_title TEXT NOT NULL DEFAULT '', -- snapshot of the argument / new point + summary TEXT NOT NULL, -- what changed at the hearing, in prose + sharpened_question TEXT NOT NULL DEFAULT '', -- the legal question this sharpens (for the discussion) + evidence_quote TEXT NOT NULL DEFAULT '', -- verbatim protocol excerpt (INV-AH: quote-or-retract) + page_number INTEGER, + created_at TIMESTAMPTZ DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_protocol_analysis_case ON protocol_analysis(case_id, change_type); +CREATE INDEX IF NOT EXISTS idx_protocol_analysis_doc ON protocol_analysis(document_id); +""" + # Stable, arbitrary key for the session-level advisory lock that serialises # schema DDL across processes. Every short-lived process (cron drains, services) @@ -1905,6 +1940,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None: await conn.execute(SCHEMA_V45_SQL) await conn.execute(SCHEMA_V46_SQL) await conn.execute(SCHEMA_V47_SQL) + await conn.execute(SCHEMA_V48_SQL) async def init_schema() -> None: @@ -4029,6 +4065,83 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]: return conflicts +# ── Protocol comparative analysis (V48 / WS4 #203) ──────────────── + +async def replace_protocol_analysis( + case_id: UUID, + document_id: UUID, + rows: list[dict], +) -> int: + """Replace all protocol_analysis rows for a given protocol document (idempotent). + + Each row dict: change_type ('dropped'|'strengthened'|'newly_raised'), + party_role, argument_id (UUID|None), argument_title, summary, + sharpened_question, evidence_quote, page_number (optional). + + Idempotent on document_id (INV-TOOL3): re-running analyze_protocol replaces + the prior verdict for that protocol rather than appending duplicates. + """ + pool = await get_pool() + async with pool.acquire() as conn: + async with conn.transaction(): + await conn.execute( + "DELETE FROM protocol_analysis WHERE document_id = $1", document_id, + ) + for r in rows: + await conn.execute( + """INSERT INTO protocol_analysis + (case_id, document_id, party_role, change_type, argument_id, + argument_title, summary, sharpened_question, evidence_quote, page_number) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)""", + case_id, document_id, + r.get("party_role", "") or "", + r["change_type"], + r.get("argument_id"), + r.get("argument_title", "") or "", + r["summary"], + r.get("sharpened_question", "") or "", + r.get("evidence_quote", "") or "", + r.get("page_number"), + ) + return len(rows) + + +async def list_protocol_analysis( + case_id: UUID, + change_type: str | None = None, +) -> list[dict]: + """List protocol_analysis rows for a case, optionally filtered by change_type. + + The read side of the extract/get symmetry (INV-TOOL4) for analyze_protocol. + """ + pool = await get_pool() + async with pool.acquire() as conn: + if change_type: + rows = await conn.fetch( + """SELECT * FROM protocol_analysis + WHERE case_id = $1 AND change_type = $2 + ORDER BY change_type, party_role, created_at""", + case_id, change_type, + ) + else: + rows = await conn.fetch( + """SELECT * FROM protocol_analysis + WHERE case_id = $1 + ORDER BY change_type, party_role, created_at""", + case_id, + ) + results = [] + for r in rows: + d = dict(r) + d["id"] = str(d["id"]) + d["case_id"] = str(d["case_id"]) + d["document_id"] = str(d["document_id"]) + if d.get("argument_id") is not None: + d["argument_id"] = str(d["argument_id"]) + results.append(d) + return results + + # ── Plans registry (V38) ────────────────────────────────────────── # Canonical registry of planning schemes (תכניות). SSOT for a plan's identity + # validity, reused across cases (G2). See SCHEMA_V38_SQL for the data contract. diff --git a/mcp-server/src/legal_mcp/services/protocol_analyzer.py b/mcp-server/src/legal_mcp/services/protocol_analyzer.py new file mode 100644 index 0000000..9ef2d11 --- /dev/null +++ b/mcp-server/src/legal_mcp/services/protocol_analyzer.py @@ -0,0 +1,341 @@ +"""ניתוח פרוטוקול-דיון השוואתי (WS4 / #203). + +מנתח פרוטוקול דיון של ועדת הערר מול כתבי-הטענות הכתובים: אילו טענות **ירדו** +(נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות** — ומחדד את השאלות המשפטיות +לקראת פרק הדיון. התוצאה נכנסת ל"ידע-התיק" (טבלת protocol_analysis), זמינה +לסוכני הניתוח והכתיבה. + +בנוסף, מחלץ את **נתוני הכותרת (א–ד)** מהפרוטוקול בפורמט-ההזנה המוכר — +הרכב הוועדה, תאריך הדיון, והצדדים שהופיעו — ומזין אותם חזרה לעמודות הקנוניות +(`cases.hearing_date`, panel דרך decisions; G2: לא כפילות). + +הפרדת-אחריות (claude_session.py): כל קריאת-LLM כאן רצה רק מה-MCP server המקומי +(אין claude CLI בקונטיינר). הקריאה היחידה היא דרך claude_session, מעוגנת ל- +Opus 4.8 + effort=high כמתבקש בניתוח-השוואתי (reference_claude_generation_path). +""" + +from __future__ import annotations + +import json +import logging +from uuid import UUID + +from legal_mcp.services import claude_session, db + +logger = logging.getLogger(__name__) + +# Generation pinned per reference_claude_generation_path: Opus 4.8 rejects +# temperature/top_p/top_k (400) — the only knob is effort. Comparative legal +# reasoning over the protocol vs. pleadings is high-effort. +ANALYSIS_MODEL = "claude-opus-4-8" +ANALYSIS_EFFORT = "high" +HEADER_EFFORT = "medium" # א–ד is mechanical extraction, not reasoning + +# Valid change verdicts — mirror the DB CHECK on protocol_analysis.change_type. +VALID_CHANGE_TYPES = {"dropped", "strengthened", "newly_raised"} +VALID_PARTY_ROLES = {"appellant", "respondent", "committee", "permit_applicant", ""} + +PARTY_LABELS_HE = { + "appellant": "עוררים", + "respondent": "משיבים", + "committee": "ועדה מקומית", + "permit_applicant": "מבקשי היתר", +} + + +# ── Comparative analysis: pleadings vs. protocol ────────────────── + +COMPARE_PROMPT = """אתה מנתח משפטי בכיר בועדת ערר לתכנון ובנייה. לפניך **פרוטוקול דיון** +ולצדו **הטיעונים המשפטיים שהוגשו בכתב** לפני הדיון. תפקידך: ניתוח השוואתי מדויק +של מה שקרה בדיון ביחס לכתבי-הטענות. + +## מה לזהות (לכל צד בנפרד): +1. **dropped (ירדה)** — טענה שהוגשה בכתב אך הצד **זנח** אותה בדיון: לא חזר עליה, + הודה שאינה עומדת, או ויתר עליה במפורש. +2. **strengthened (חוזקה)** — טענה כתובה שהצד **חיזק** בדיון: הוסיף נימוק, אסמכתא, + הבהרה, או דגש שהפך אותה למרכזית. +3. **newly_raised (חדשה)** — טענה/סוגיה ש**עלתה לראשונה בדיון** ולא הופיעה בכתבי + הטענות כלל. + +## כללים קריטיים (anti-hallucination — חובה): +- **ציטוט-או-הימנעות:** לכל קביעה חייב להיות `evidence_quote` — ציטוט **מילולי** + מהפרוטוקול (עד 200 תווים) שמבסס אותה. אם אין ציטוט תומך — אל תכלול את הרשומה. +- אל תמציא טענות שלא נאמרו. אל תסיק "ירדה" רק כי לא הוזכרה — רק אם יש ראיה פוזיטיבית + לזניחה/ויתור, או שהיא טענה כתובה מרכזית שכלל לא עלתה והצד עסק בנושא. +- **חידוד-השאלה:** לכל רשומה, נסח את `sharpened_question` — השאלה המשפטית הממוקדת + שהשינוי בדיון מחדד (לטובת פרק הדיון). אם לא רלוונטי — השאר ריק. +- שייך כל רשומה ל-`party_role` הנכון: appellant / respondent / committee / permit_applicant. +- אם רשומה תואמת טיעון כתוב קיים — החזר את ה-`argument_id` שלו מהרשימה למטה. לטענה חדשה + (newly_raised) — argument_id ריק. + +## פלט: +החזר JSON array בלבד — ללא markdown, ללא הסברים: +[ + {{ + "change_type": "dropped" | "strengthened" | "newly_raised", + "party_role": "appellant" | "respondent" | "committee" | "permit_applicant", + "argument_id": "uuid-של-הטיעון-הכתוב או null", + "argument_title": "כותרת קצרה של הטענה/הסוגיה", + "summary": "מה השתנה בדיון, במשפט-שניים", + "sharpened_question": "השאלה המשפטית שהשינוי מחדד, או ריק", + "evidence_quote": "ציטוט מילולי מהפרוטוקול", + "page_number": null + }} +] +אם אין שינויים בני-ביסוס — החזר []. + +## הטיעונים שהוגשו בכתב: +{arguments_json} + +## פרוטוקול הדיון: +--- תחילת פרוטוקול --- +{protocol_text} +--- סוף פרוטוקול --- +""" + + +# ── Header (א–ד) extraction in the known feed format ────────────── + +HEADER_PROMPT = """אתה מחלץ נתוני-כותרת מפרוטוקול דיון של ועדת ערר לתכנון ובנייה. +חלץ אך-ורק עובדות מנהליות המופיעות במפורש בפרוטוקול — אל תמציא ואל תסיק. + +## פלט: +החזר JSON object בלבד (ללא markdown): +{{ + "hearing_date": "YYYY-MM-DD אם תאריך הדיון מופיע, אחרת ריק", + "panel_members": ["שמות חברי ההרכב כפי שמופיעים, כולל תוארם; ריק אם לא צוין"], + "appellants_present": ["שמות העוררים/באי-כוחם שהופיעו בדיון"], + "respondents_present": ["שמות המשיבים/באי-כוחם שהופיעו בדיון"] +}} +שדה שלא צוין בפרוטוקול — החזר ריק ([] או ""). + +## פרוטוקול: +--- תחילת פרוטוקול --- +{protocol_text} +--- סוף פרוטוקול --- +""" + +# A single protocol rarely exceeds the model's context, but cap defensively so a +# pathological OCR dump doesn't blow the prompt budget. +MAX_PROTOCOL_CHARS = 120_000 + + +def _find_protocol(docs: list[dict]) -> dict | None: + """The protocol document for the case, if present (doc_type or title).""" + for d in docs: + if d.get("doc_type") == "protocol": + return d + for d in docs: + if "פרוטוקול" in (d.get("title") or ""): + return d + return None + + +def _compact_arguments(arguments: list[dict]) -> list[dict]: + """Strip aggregated arguments down to what the comparison needs.""" + out = [] + for a in arguments: + out.append({ + "argument_id": str(a["id"]), + "party_role": a.get("party", ""), + "title": a.get("argument_title", ""), + "body": a.get("argument_body", ""), + "topic": a.get("legal_topic", ""), + }) + return out + + +def _normalize_change(raw: dict, valid_argument_ids: set[str]) -> dict | None: + """Validate & normalize one comparative-analysis row from Claude. + + Returns None for unusable rows (missing change_type/summary, or — per the + anti-hallucination gate — no supporting evidence_quote). + """ + if not isinstance(raw, dict): + return None + change_type = (raw.get("change_type") or "").strip() + if change_type not in VALID_CHANGE_TYPES: + return None + summary = (raw.get("summary") or "").strip() + evidence_quote = (raw.get("evidence_quote") or "").strip() + # INV-AH (quote-or-retract): drop any verdict the model couldn't anchor to a + # verbatim protocol excerpt — at source, so unfounded rows never reach the DB. + if not summary or not evidence_quote: + return None + + party_role = (raw.get("party_role") or "").strip() + if party_role not in VALID_PARTY_ROLES: + party_role = "" + + # argument_id only kept when it actually belongs to this case's arguments; + # a newly_raised point has none, and a hallucinated id is dropped (FK safety). + argument_id = None + raw_aid = raw.get("argument_id") + if raw_aid and str(raw_aid) in valid_argument_ids: + try: + argument_id = UUID(str(raw_aid)) + except (ValueError, TypeError): + argument_id = None + + page = raw.get("page_number") + if not isinstance(page, int): + page = None + + return { + "change_type": change_type, + "party_role": party_role, + "argument_id": argument_id, + "argument_title": (raw.get("argument_title") or "").strip(), + "summary": summary, + "sharpened_question": (raw.get("sharpened_question") or "").strip(), + "evidence_quote": evidence_quote[:200], + "page_number": page, + } + + +async def _extract_header(protocol_text: str, case_id: UUID) -> dict: + """Extract א–ד header data and write it back to canonical case fields. + + Returns the raw extracted feed (for provenance) plus a record of what was + written back. Header data is NOT stored in protocol_analysis — it lives in + the canonical `cases`/`decisions` columns (G2: single source of truth). + """ + prompt = HEADER_PROMPT.format(protocol_text=protocol_text[:MAX_PROTOCOL_CHARS]) + try: + feed = await claude_session.query_json( + prompt, model=ANALYSIS_MODEL, effort=HEADER_EFFORT, tools="", + ) + except Exception as e: # noqa: BLE001 — surface, don't swallow (§6 no silent swallow) + logger.warning("protocol header extraction failed for case %s: %s", case_id, e) + return {"status": "header_failed", "error": str(e)} + + if not isinstance(feed, dict): + return {"status": "header_no_data"} + + applied: dict = {} + # hearing_date → canonical cases.hearing_date (only if the case lacks one, + # so a chair-entered date is never overwritten by extraction). + hearing_date = (feed.get("hearing_date") or "").strip() + if hearing_date: + from datetime import date as date_type + try: + parsed = date_type.fromisoformat(hearing_date) + case = await db.get_case(case_id) + if case and not case.get("hearing_date"): + await db.update_case(case_id, hearing_date=parsed) + applied["hearing_date"] = hearing_date + except ValueError: + logger.info("protocol header: unparseable hearing_date %r", hearing_date) + + return { + "status": "ok", + "feed": { + "hearing_date": hearing_date, + "panel_members": feed.get("panel_members") or [], + "appellants_present": feed.get("appellants_present") or [], + "respondents_present": feed.get("respondents_present") or [], + }, + "applied_to_case": applied, + } + + +async def analyze_protocol(case_id: UUID) -> dict: + """Comparative analysis of the case's hearing protocol vs. its pleadings. + + 1. Locates the protocol document. + 2. Pulls the aggregated legal_arguments (the written-pleadings baseline). + 3. Asks Claude (Opus 4.8, effort=high) to classify each as dropped / + strengthened, and to surface newly-raised arguments + sharpened questions. + 4. Stores the verdicts in protocol_analysis (case-knowledge, idempotent). + 5. Extracts the א–ד header feed and writes hearing_date back to the case. + + Returns a serializable summary dict. + """ + docs = await db.list_documents(case_id) + protocol = _find_protocol(docs) + if not protocol: + return { + "status": "no_protocol", + "message": "לא נמצא פרוטוקול דיון בתיק (doc_type='protocol'). העלה פרוטוקול והרץ שוב.", + } + + document_id = UUID(protocol["id"]) + protocol_text = await db.get_document_text(document_id) + if not protocol_text or not protocol_text.strip(): + return { + "status": "no_text", + "message": "לפרוטוקול אין טקסט מחולץ. ודא שהעיבוד הסתיים והרץ שוב.", + "document_id": str(document_id), + } + + # Use the aggregator's read path (single source of truth — no parallel + # query for the same legal_arguments data, G2). + from legal_mcp.services import argument_aggregator + arguments = await argument_aggregator.get_legal_arguments(case_id) + + if not arguments: + return { + "status": "no_arguments", + "message": ( + "אין טיעונים מאוגדים להשוואה. הרץ extract_claims + " + "aggregate_claims_to_arguments על כתבי-הטענות תחילה." + ), + "document_id": str(document_id), + } + + compact_args = _compact_arguments(arguments) + valid_argument_ids = {a["argument_id"] for a in compact_args} + + prompt = COMPARE_PROMPT.format( + arguments_json=json.dumps(compact_args, ensure_ascii=False, indent=2), + protocol_text=protocol_text[:MAX_PROTOCOL_CHARS], + ) + + try: + raw_result = await claude_session.query_json( + prompt, model=ANALYSIS_MODEL, effort=ANALYSIS_EFFORT, tools="", + ) + except RuntimeError as e: + msg = str(e) + if "Claude CLI not found" in msg: + return { + "status": "llm_unavailable", + "message": ( + "Claude CLI לא זמין. הניתוח ההשוואתי חייב לרוץ מה-MCP server " + "המקומי, לא מהקונטיינר." + ), + "document_id": str(document_id), + } + return {"status": "error", "message": msg, "document_id": str(document_id)} + + if not isinstance(raw_result, list): + logger.warning( + "analyze_protocol: Claude returned non-list (%s) for case %s", + type(raw_result).__name__, case_id, + ) + raw_result = [] + + rows: list[dict] = [] + for entry in raw_result: + norm = _normalize_change(entry, valid_argument_ids) + if norm: + rows.append(norm) + + stored = await db.replace_protocol_analysis(case_id, document_id, rows) + + # Extract header (א–ד) and write hearing_date back to the canonical case. + header = await _extract_header(protocol_text, case_id) + + by_change: dict[str, int] = {} + for r in rows: + by_change[r["change_type"]] = by_change.get(r["change_type"], 0) + 1 + + return { + "status": "completed", + "document_id": str(document_id), + "protocol_title": protocol.get("title", ""), + "total": stored, + "by_change": by_change, + "arguments_compared": len(compact_args), + "header": header, + } diff --git a/mcp-server/src/legal_mcp/tools/documents.py b/mcp-server/src/legal_mcp/tools/documents.py index 46e6efa..8fa7a38 100644 --- a/mcp-server/src/legal_mcp/tools/documents.py +++ b/mcp-server/src/legal_mcp/tools/documents.py @@ -350,11 +350,15 @@ async def extract_claims( if not docs: return empty(f"אין מסמכים בתיק {case_number}.") - # Filter to claims documents (appeal, response) or specific doc + # Filter to claims documents or a specific doc. protocol carries oral + # arguments raised at the hearing — extracted with claim_type='protocol' + # (claims_extractor._infer_claim_type) so analyze_protocol can compare them + # against the written pleadings, while block ז (write_block_zayin) excludes + # them to stay original-pleadings-only (INV-WR4). if doc_title: docs = [d for d in docs if doc_title.lower() in d["title"].lower()] else: - docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection")] + docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection", "protocol")] if not docs: return empty("לא נמצאו כתבי טענות בתיק.") diff --git a/mcp-server/src/legal_mcp/tools/drafting.py b/mcp-server/src/legal_mcp/tools/drafting.py index 572fd49..37a3426 100644 --- a/mcp-server/src/legal_mcp/tools/drafting.py +++ b/mcp-server/src/legal_mcp/tools/drafting.py @@ -580,6 +580,73 @@ async def get_appraiser_facts(case_number: str) -> str: return err(str(e)) +async def analyze_protocol(case_number: str) -> str: + """ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203). + + מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**, + מחדד את השאלות המשפטיות, ומחלץ את נתוני הכותרת (א–ד) מהפרוטוקול. התוצאה נכנסת + ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון + מוזן חזרה לעמודה הקנונית cases.hearing_date. + + דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים + (aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high); + re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent). + + Args: + case_number: מספר תיק הערר + """ + from legal_mcp.services import protocol_analyzer + + 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 protocol_analyzer.analyze_protocol(case_id) + await audit.log_action_safe( + "analyze_protocol", case_id=case_id, + details={"status": result.get("status"), "total": result.get("total", 0)}, + ) + return ok(result) + except Exception as e: + return err(str(e)) + + +async def get_protocol_analysis(case_number: str, change_type: str = "") -> str: + """קריאת ניתוח-הפרוטוקול שכבר חולץ — ללא הרצת ניתוח-מחדש (INV-TOOL4). + + ה-get המקביל ל-analyze_protocol: מחזיר את רשומות ידע-התיק השמורות + (ירדה/חוזקה/חדשה + שאלה-מחודדת + ציטוט-מבסס), בלי קריאת-LLM יקרה. + מחזיר רשימה ריקה אם הניתוח טרם רץ (status=ok, count=0) — לא שגיאה. + + Args: + case_number: מספר תיק הערר + change_type: סינון (dropped/strengthened/newly_raised). ריק = הכל. + """ + from legal_mcp.services import protocol_analyzer + + case = await db.get_case_by_number(case_number) + if not case: + return err(f"תיק {case_number} לא נמצא.") + if change_type and change_type not in protocol_analyzer.VALID_CHANGE_TYPES: + return err( + f"change_type לא תקין: {change_type}", + data={"allowed": sorted(protocol_analyzer.VALID_CHANGE_TYPES)}, + ) + case_id = UUID(case["id"]) + try: + rows = await db.list_protocol_analysis( + case_id, change_type=change_type or None, + ) + return ok({ + "case_number": case_number, + "count": len(rows), + "analysis": rows, + }) + except Exception as e: + return err(str(e)) + + async def write_interim_draft(case_number: str, instructions: str = "") -> str: """כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט), טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין — diff --git a/mcp-server/tests/test_protocol_analyzer.py b/mcp-server/tests/test_protocol_analyzer.py new file mode 100644 index 0000000..d14ed5a --- /dev/null +++ b/mcp-server/tests/test_protocol_analyzer.py @@ -0,0 +1,141 @@ +"""Tests for the comparative protocol analyzer (WS4 / #203). + +Covers the pure (non-LLM, non-DB) logic: the anti-hallucination normalization +gate, protocol-document discovery, claim_type tagging for protocols, and the +block-ז filter that keeps oral hearing arguments out of the original-pleadings +summary (INV-WR4). +""" +from __future__ import annotations + +import pytest + +from legal_mcp.services import protocol_analyzer as pa +from legal_mcp.services.claims_extractor import _infer_claim_type + + +# ── claim_type tagging — protocol distinct from written pleadings ─────────── + +@pytest.mark.parametrize("doc_type,title,expected", [ + ("protocol", "פרוטוקול דיון", "protocol"), + ("appeal", "פרוטוקול הדיון מיום 1.1", "protocol"), # title-based + ("appeal", "כתב ערר", "claim"), + ("response", "כתב תשובה", "response"), + ("response", "תגובת המשיבה", "reply"), +]) +def test_infer_claim_type(doc_type, title, expected): + assert _infer_claim_type(doc_type, title) == expected + + +# ── _find_protocol ───────────────────────────────────────────────────────── + +def test_find_protocol_by_doc_type(): + docs = [ + {"id": "1", "doc_type": "appeal", "title": "כתב ערר"}, + {"id": "2", "doc_type": "protocol", "title": "פרוטוקול"}, + ] + assert pa._find_protocol(docs)["id"] == "2" + + +def test_find_protocol_by_title_fallback(): + docs = [ + {"id": "1", "doc_type": "reference", "title": "פרוטוקול הדיון"}, + {"id": "2", "doc_type": "appeal", "title": "כתב ערר"}, + ] + assert pa._find_protocol(docs)["id"] == "1" + + +def test_find_protocol_none(): + docs = [{"id": "1", "doc_type": "appeal", "title": "כתב ערר"}] + assert pa._find_protocol(docs) is None + + +# ── _normalize_change — anti-hallucination gate (INV-AH) ─────────────────── + +_AID = "11111111-1111-1111-1111-111111111111" + + +def test_normalize_change_valid(): + row = pa._normalize_change( + { + "change_type": "dropped", + "party_role": "appellant", + "argument_id": _AID, + "argument_title": "טענת השיהוי", + "summary": "העורר ויתר על הטענה בדיון.", + "sharpened_question": "האם נותרה טענת סף?", + "evidence_quote": "ב\"כ העורר: איננו עומדים על טענת השיהוי.", + "page_number": 3, + }, + valid_argument_ids={_AID}, + ) + assert row is not None + assert row["change_type"] == "dropped" + assert str(row["argument_id"]) == _AID + assert row["page_number"] == 3 + + +def test_normalize_change_drops_row_without_evidence_quote(): + # quote-or-retract: no verbatim support → row is rejected at source. + row = pa._normalize_change( + {"change_type": "strengthened", "summary": "חוזקה", "evidence_quote": ""}, + valid_argument_ids=set(), + ) + assert row is None + + +def test_normalize_change_drops_row_without_summary(): + row = pa._normalize_change( + {"change_type": "dropped", "summary": "", "evidence_quote": "ציטוט"}, + valid_argument_ids=set(), + ) + assert row is None + + +def test_normalize_change_rejects_bad_change_type(): + row = pa._normalize_change( + {"change_type": "modified", "summary": "x", "evidence_quote": "y"}, + valid_argument_ids=set(), + ) + assert row is None + + +def test_normalize_change_drops_hallucinated_argument_id(): + # An argument_id that isn't one of this case's arguments must be dropped + # (FK safety) — but the row itself, being newly_raised-shaped, survives. + row = pa._normalize_change( + { + "change_type": "newly_raised", + "party_role": "respondent", + "argument_id": "99999999-9999-9999-9999-999999999999", + "summary": "סוגיה חדשה שעלתה בדיון.", + "evidence_quote": "ב\"כ המשיבה העלה לראשונה את שאלת הסמכות.", + }, + valid_argument_ids={_AID}, + ) + assert row is not None + assert row["argument_id"] is None + assert row["change_type"] == "newly_raised" + + +def test_normalize_change_invalid_party_role_blanked(): + row = pa._normalize_change( + { + "change_type": "strengthened", + "party_role": "judge", # not a valid party + "summary": "חוזקה הטענה.", + "evidence_quote": "ציטוט מבסס.", + }, + valid_argument_ids=set(), + ) + assert row is not None + assert row["party_role"] == "" + + +def test_normalize_change_truncates_quote(): + long_quote = "א" * 500 + row = pa._normalize_change( + {"change_type": "dropped", "summary": "x", "evidence_quote": long_quote}, + valid_argument_ids=set(), + ) + assert row is not None + assert len(row["evidence_quote"]) == 200