diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index 4a9e557..2966297 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -759,13 +759,15 @@ async def get_appraiser_facts(case_number: str) -> str: # ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ──── @mcp.tool() -async def analyze_protocol(case_number: str) -> str: +async def analyze_protocol(case_number: str, document_id: str = "") -> str: """ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד. - מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול doc_type='protocol' + - טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high; re-run מחליף (idempotent). + בוחר אוטומטית פרוטוקול ועדת-ערר (scope שאינו 'lower'); document_id מכוון לדיון + מדויק כשיש כמה פרוטוקולים. מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול + doc_type='protocol' + טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high; + re-run מחליף (idempotent). """ - return await drafting.analyze_protocol(case_number) + return await drafting.analyze_protocol(case_number, document_id=document_id) @mcp.tool() diff --git a/mcp-server/src/legal_mcp/services/protocol_analyzer.py b/mcp-server/src/legal_mcp/services/protocol_analyzer.py index 9ef2d11..b96e6c5 100644 --- a/mcp-server/src/legal_mcp/services/protocol_analyzer.py +++ b/mcp-server/src/legal_mcp/services/protocol_analyzer.py @@ -120,15 +120,47 @@ HEADER_PROMPT = """אתה מחלץ נתוני-כותרת מפרוטוקול די 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 _protocol_scope(doc: dict) -> str: + """Protocol scope from ``metadata.protocol_scope``. + + Absent/'' defaults to ``'appeal'`` (ועדת הערר), matching the + document-type-editor convention where only the non-default ``'lower'`` + (ועדה מקומית/מחוזית) is persisted. + """ + meta = doc.get("metadata") or {} + return (meta.get("protocol_scope") or "").strip() or "appeal" + + +def _find_protocol( + docs: list[dict], document_id: UUID | None = None, +) -> dict | None: + """The ועדת-הערר hearing protocol to compare against the pleadings. + + - ``document_id`` given → return exactly that document (explicit target), + so a case with several protocols can be pointed at the right hearing. + - Otherwise prefer a protocol scoped to the appeals committee + (``scope != 'lower'``). A ``'lower'`` protocol is the local/district + committee's proceedings — it feeds background (block ו) only and must NOT + drive the hearing-vs-pleadings comparison (#223). If every protocol is + lower-scoped there is no ערר-hearing to compare → return None. + - Among appeal-scoped protocols the most recent (by ``created_at``) wins — + the operative hearing; ties fall back to list order. + """ + if document_id is not None: + return next( + (d for d in docs if str(d.get("id")) == str(document_id)), None, + ) + + protocols = [d for d in docs if d.get("doc_type") == "protocol"] + if not protocols: + # Legacy fallback: untyped docs whose title says "פרוטוקול". + protocols = [d for d in docs if "פרוטוקול" in (d.get("title") or "")] + + appeal_scoped = [d for d in protocols if _protocol_scope(d) != "lower"] + if not appeal_scoped: + return None + appeal_scoped.sort(key=lambda d: d.get("created_at") or "", reverse=True) + return appeal_scoped[0] def _compact_arguments(arguments: list[dict]) -> list[dict]: @@ -239,9 +271,15 @@ async def _extract_header(protocol_text: str, case_id: UUID) -> dict: } -async def analyze_protocol(case_id: UUID) -> dict: +async def analyze_protocol( + case_id: UUID, target_document_id: UUID | None = None, +) -> dict: """Comparative analysis of the case's hearing protocol vs. its pleadings. + ``target_document_id`` pins the analysis to a specific protocol document — + required when a case holds several protocols (e.g. a lower-committee annex + plus the ועדת-הערר hearing) and the auto-pick would be ambiguous (#223). + 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 / @@ -252,11 +290,20 @@ async def analyze_protocol(case_id: UUID) -> dict: Returns a serializable summary dict. """ docs = await db.list_documents(case_id) - protocol = _find_protocol(docs) + protocol = _find_protocol(docs, document_id=target_document_id) if not protocol: + if target_document_id is not None: + return { + "status": "no_protocol", + "message": f"מסמך {target_document_id} לא נמצא בתיק.", + } return { "status": "no_protocol", - "message": "לא נמצא פרוטוקול דיון בתיק (doc_type='protocol'). העלה פרוטוקול והרץ שוב.", + "message": ( + "לא נמצא פרוטוקול ועדת-ערר בתיק (doc_type='protocol' עם " + "protocol_scope שאינו 'lower'). העלה פרוטוקול-דיון, או תייג את " + "הפרוטוקול הקיים כ-appeal, והרץ שוב." + ), } document_id = UUID(protocol["id"]) diff --git a/mcp-server/src/legal_mcp/tools/drafting.py b/mcp-server/src/legal_mcp/tools/drafting.py index 4a10768..98b5dd2 100644 --- a/mcp-server/src/legal_mcp/tools/drafting.py +++ b/mcp-server/src/legal_mcp/tools/drafting.py @@ -580,7 +580,7 @@ async def get_appraiser_facts(case_number: str) -> str: return err(str(e)) -async def analyze_protocol(case_number: str) -> str: +async def analyze_protocol(case_number: str, document_id: str = "") -> str: """ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203). מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**, @@ -588,12 +588,17 @@ async def analyze_protocol(case_number: str) -> str: ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון מוזן חזרה לעמודה הקנונית cases.hearing_date. + בוחר אוטומטית את פרוטוקול **ועדת-הערר** (protocol_scope שאינו 'lower'); + פרוטוקול ועדה-מקומית ('lower') אינו נכנס להשוואה. כשיש כמה פרוטוקולי-ערר — + ציין document_id כדי לכוון לדיון המדויק (#223). + דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים (aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high); re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent). Args: case_number: מספר תיק הערר + document_id: מזהה מסמך-הפרוטוקול לכיוון מדויק (ריק = בחירה אוטומטית) """ from legal_mcp.services import protocol_analyzer @@ -601,8 +606,16 @@ async def analyze_protocol(case_number: str) -> str: if not case: return err(f"תיק {case_number} לא נמצא.") case_id = UUID(case["id"]) + target_doc_id: UUID | None = None + if document_id.strip(): + try: + target_doc_id = UUID(document_id.strip()) + except ValueError: + return err(f"document_id לא תקין: {document_id}") try: - result = await protocol_analyzer.analyze_protocol(case_id) + result = await protocol_analyzer.analyze_protocol( + case_id, target_document_id=target_doc_id, + ) await audit.log_action_safe( "analyze_protocol", case_id=case_id, details={"status": result.get("status"), "total": result.get("total", 0)}, diff --git a/mcp-server/tests/test_protocol_analyzer.py b/mcp-server/tests/test_protocol_analyzer.py index d14ed5a..03b7e76 100644 --- a/mcp-server/tests/test_protocol_analyzer.py +++ b/mcp-server/tests/test_protocol_analyzer.py @@ -49,6 +49,65 @@ def test_find_protocol_none(): assert pa._find_protocol(docs) is None +def test_find_protocol_skips_lower_scope(): + # A local/district-committee protocol (scope='lower') must NOT be picked for + # the hearing-vs-pleadings comparison; the ועדת-ערר one wins (#223). + docs = [ + {"id": "lower", "doc_type": "protocol", "title": "נספח 18 — פרוטוקולי ועדה מקומית", + "metadata": {"protocol_scope": "lower"}, "created_at": "2026-01-01"}, + {"id": "appeal", "doc_type": "protocol", "title": "פרוטוקול דיון 23.6.26", + "metadata": {}, "created_at": "2026-06-23"}, + ] + assert pa._find_protocol(docs)["id"] == "appeal" + + +def test_find_protocol_all_lower_returns_none(): + # If every protocol is lower-scoped there is no ערר-hearing to compare. + docs = [ + {"id": "1", "doc_type": "protocol", "title": "פרוטוקול מקומי", + "metadata": {"protocol_scope": "lower"}}, + ] + assert pa._find_protocol(docs) is None + + +def test_find_protocol_prefers_most_recent_appeal(): + docs = [ + {"id": "old", "doc_type": "protocol", "title": "פרוטוקול א", + "metadata": {}, "created_at": "2026-03-01"}, + {"id": "new", "doc_type": "protocol", "title": "פרוטוקול ב", + "metadata": {}, "created_at": "2026-06-23"}, + ] + assert pa._find_protocol(docs)["id"] == "new" + + +def test_find_protocol_explicit_document_id_overrides(): + # Explicit target wins even over scope/recency heuristics — including the + # ability to point at a lower-scoped doc if the caller insists. + docs = [ + {"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol", + "title": "פרוטוקול ערר", "metadata": {}, "created_at": "2026-06-23"}, + {"id": "aaaaaaaa-0000-0000-0000-000000000002", "doc_type": "protocol", + "title": "פרוטוקול מקומי", "metadata": {"protocol_scope": "lower"}, + "created_at": "2026-01-01"}, + ] + from uuid import UUID + picked = pa._find_protocol( + docs, document_id=UUID("aaaaaaaa-0000-0000-0000-000000000002"), + ) + assert picked["id"] == "aaaaaaaa-0000-0000-0000-000000000002" + + +def test_find_protocol_unknown_document_id_returns_none(): + from uuid import UUID + docs = [ + {"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol", + "title": "פרוטוקול", "metadata": {}}, + ] + assert pa._find_protocol( + docs, document_id=UUID("bbbbbbbb-0000-0000-0000-000000000009"), + ) is None + + # ── _normalize_change — anti-hallucination gate (INV-AH) ─────────────────── _AID = "11111111-1111-1111-1111-111111111111"