feat(analysis): analyze_protocol — comparative hearing-protocol analysis (WS4 #203)
מנתח פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד שאלות משפטיות, ומחלץ נתוני כותרת (א–ד). התוצאה נכנסת לידע-התיק. - protocol_analyzer.py — ניתוח השוואתי דרך claude_session (Opus 4.8, effort=high, מקומי בלבד); שער anti-hallucination (quote-or-retract) ב-_normalize_change; חילוץ א–ד + הזנת hearing_date חזרה ל-cases (לא דורס קלט-יו"ר). - claims_extractor: protocol → claim_type='protocol'; extract_claims קולט protocol. - block_writer._build_claims_context מחריג claim_type='protocol' מבלוק ז (INV-WR4). - db: טבלת protocol_analysis (V47, idempotent per-doc) + replace/list helpers. - tools/drafting + server: analyze_protocol + get_protocol_analysis (extract/get). - ספ 04-analysis-writing §1.3 + 15 בדיקות. Invariants: G1 (נרמול-במקור: hearing_date לעמודה הקנונית), G2 (ידע-תיק נגזר ממקור-האמת protocol+legal_arguments, לא מסלול מקביל), INV-TOOL3 (idempotent per document_id), INV-TOOL4 (extract/get symmetry), INV-AH (quote-or-retract), INV-WR4 (בלוק ז = טענות-כתב מקוריות בלבד), claude_session local-only, G12 (נקי מ-Paperclip — leak-guard עובר). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,31 @@
|
|||||||
> מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון
|
> מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון
|
||||||
> שלו מקבל מענה בבלוק י.
|
> שלו מקבל מענה בבלוק י.
|
||||||
|
|
||||||
|
### 1.3 ניתוח פרוטוקול-דיון השוואתי (WS4 / #203)
|
||||||
|
|
||||||
|
אחרי הדיון, פרוטוקול (`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 הבלוקים (סיכום)
|
## 2. ארכיטקטורת 12 הבלוקים (סיכום)
|
||||||
|
|||||||
@@ -744,6 +744,27 @@ async def get_appraiser_facts(case_number: str) -> str:
|
|||||||
return await drafting.get_appraiser_facts(case_number)
|
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) — מרשם-התכניות ─────────────────
|
# ── Planning-schemes registry (V38) — מרשם-התכניות ─────────────────
|
||||||
# SSOT לזהות+תוקף של תכנית, נעשה שימוש חוזר בין תיקים (G2). פלט-LLM נכנס
|
# SSOT לזהות+תוקף של תכנית, נעשה שימוש חוזר בין תיקים (G2). פלט-LLM נכנס
|
||||||
# pending_review וממתין לאישור-יו"ר (plan_review, G10) לפני שמשמש בבלוק ט.
|
# pending_review וממתין לאישור-יו"ר (plan_review, G10) לפני שמשמש בבלוק ט.
|
||||||
|
|||||||
@@ -544,11 +544,19 @@ async def _build_claims_context(case_id: UUID) -> str:
|
|||||||
return "(לא חולצו טענות)"
|
return "(לא חולצו טענות)"
|
||||||
|
|
||||||
# Filter out claims from block-zayin (decision summary) — use only
|
# Filter out claims from block-zayin (decision summary) — use only
|
||||||
# claims extracted from original pleadings (appeal, response, etc.)
|
# claims extracted from original pleadings (appeal, response, etc.).
|
||||||
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"]
|
# 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:
|
if not source_claims:
|
||||||
# Fallback to all claims if no source claims exist
|
# Fallback to all non-block-zayin claims if no source claims exist.
|
||||||
source_claims = claims
|
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
current_role = ""
|
current_role = ""
|
||||||
|
|||||||
@@ -227,8 +227,15 @@ def _infer_claim_type(doc_type: str, source_name: str) -> str:
|
|||||||
- 'claim' = from appeal documents (כתב ערר)
|
- 'claim' = from appeal documents (כתב ערר)
|
||||||
- 'response' = from original response documents (כתב תשובה)
|
- 'response' = from original response documents (כתב תשובה)
|
||||||
- 'reply' = from supplementary responses (תגובה, השלמת טיעון)
|
- 'reply' = from supplementary responses (תגובה, השלמת טיעון)
|
||||||
|
- 'protocol' = oral arguments raised at the hearing (פרוטוקול דיון)
|
||||||
"""
|
"""
|
||||||
name_lower = source_name.lower() if source_name else ""
|
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:
|
if doc_type == "appeal" or "כתב ערר" in name_lower:
|
||||||
return "claim"
|
return "claim"
|
||||||
if "כתב תשובה" in name_lower:
|
if "כתב תשובה" in name_lower:
|
||||||
|
|||||||
@@ -1782,6 +1782,41 @@ CREATE INDEX IF NOT EXISTS idx_decision_lessons_vec
|
|||||||
ON decision_lessons USING ivfflat (embedding vector_cosine_ops) WITH (lists = 30);
|
ON decision_lessons USING ivfflat (embedding vector_cosine_ops) WITH (lists = 30);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# ── V47: 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_V47_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
|
# 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)
|
||||||
@@ -1851,6 +1886,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
|||||||
await conn.execute(SCHEMA_V44_SQL)
|
await conn.execute(SCHEMA_V44_SQL)
|
||||||
await conn.execute(SCHEMA_V45_SQL)
|
await conn.execute(SCHEMA_V45_SQL)
|
||||||
await conn.execute(SCHEMA_V46_SQL)
|
await conn.execute(SCHEMA_V46_SQL)
|
||||||
|
await conn.execute(SCHEMA_V47_SQL)
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -3967,6 +4003,83 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]:
|
|||||||
return conflicts
|
return conflicts
|
||||||
|
|
||||||
|
|
||||||
|
# ── Protocol comparative analysis (V47 / 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) ──────────────────────────────────────────
|
# ── Plans registry (V38) ──────────────────────────────────────────
|
||||||
# Canonical registry of planning schemes (תכניות). SSOT for a plan's identity +
|
# Canonical registry of planning schemes (תכניות). SSOT for a plan's identity +
|
||||||
# validity, reused across cases (G2). See SCHEMA_V38_SQL for the data contract.
|
# validity, reused across cases (G2). See SCHEMA_V38_SQL for the data contract.
|
||||||
|
|||||||
341
mcp-server/src/legal_mcp/services/protocol_analyzer.py
Normal file
341
mcp-server/src/legal_mcp/services/protocol_analyzer.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
@@ -345,11 +345,15 @@ async def extract_claims(
|
|||||||
if not docs:
|
if not docs:
|
||||||
return empty(f"אין מסמכים בתיק {case_number}.")
|
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:
|
if doc_title:
|
||||||
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
|
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
|
||||||
else:
|
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:
|
if not docs:
|
||||||
return empty("לא נמצאו כתבי טענות בתיק.")
|
return empty("לא נמצאו כתבי טענות בתיק.")
|
||||||
|
|||||||
@@ -571,6 +571,73 @@ async def get_appraiser_facts(case_number: str) -> str:
|
|||||||
return err(str(e))
|
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:
|
async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
||||||
"""כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט),
|
"""כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט),
|
||||||
טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין —
|
טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין —
|
||||||
|
|||||||
141
mcp-server/tests/test_protocol_analyzer.py
Normal file
141
mcp-server/tests/test_protocol_analyzer.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user