fix(protocol): בחירת פרוטוקול ועדת-ערר לפי protocol_scope + document_id (#223)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 11s

analyze_protocol בחר את מסמך-הפרוטוקול הראשון (doc_type='protocol') והתעלם
מ-protocol_scope. בתיק עם כמה פרוטוקולים — נספח ועדה-מקומית + דיון ועדת-הערר —
הוא ניתח את הלא-נכון (התגלה ב-1043-02-26: ניתח "נספח 18 השתלשלות פרוטוקולים"
במקום "פרוטוקול דיון 23.6.26").

- _find_protocol מעדיף פרוטוקול ועדת-ערר (scope שאינו 'lower'); פרוטוקול
  'lower' (ועדה מקומית/מחוזית) לא נכנס להשוואה — הוא רקע לבלוק ו בלבד.
- כמה פרוטוקולי-ערר → האחרון (created_at) גובר; document_id מכוון במפורש.
- analyze_protocol (service + drafting tool + MCP tool) מקבל document_id/
  target_document_id אופציונלי.
- _protocol_scope: קורא metadata.protocol_scope (ריק=appeal, כמו ה-UI).
- 5 בדיקות חדשות: דילוג-lower, all-lower→None, most-recent, targeting, unknown-id.

טווח: Fix1 (קוד). ניקוי 1043 (תיוג נספח 18 כ-lower + מחיקת 11 רשומות שגויות
+ הרצה מכוונת ל-23.6.26) דורש טעינה-מחדש של ה-MCP — אחרי מיזוג. פרוצדורת
ניתוח-מסמך-נוסף הכללית — המשך ב-#223.

Invariants: G1 (נרמול-במקור — קריאת scope מ-metadata, לא ניחוש-בקריאה) ·
G2 (אותו נתיב ניתוח, בורר מדויק) · §6 (no_protocol מחזיר סטטוס מפורש, לא בליעה).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 09:59:30 +00:00
parent e5c7455284
commit f4ce8332fe
4 changed files with 139 additions and 18 deletions

View File

@@ -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()

View File

@@ -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"])

View File

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

View File

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