Backend for the halacha approval-queue triage (#84). The keyboard UI, batch actions and defer/reject (#84.4–6) already shipped; this adds the gating, prioritization and metrics the queue was missing. db.list_halachot — two opt-in triage controls: * exclude_low_quality (#84.1): drop items carrying ANY quality_flag (application / quote_unverified / truncated / non_decision / thin / nli_unsupported / near_duplicate) — they belong in a 'needs extraction fix' bucket, not the chair's approve queue. * order_by_priority (#84.3): active-learning order — negatively-treated first, then most-uncertain (lowest confidence), then oldest — instead of FIFO, so the highest-value decisions surface first. halachot_pending (MCP) — now gated + prioritized BY DEFAULT; include_low_quality= true reveals the needs-fix bucket. The agent review path benefits immediately. GET /api/halachot — same two params, default OFF (non-breaking; the UI opts in). metrics.halacha_backlog (#84.7) — splits pending into clean vs flagged, adds deferred, reviewed_total, approve_ratio, and a pending_by_flag breakdown, so the backlog distinguishes real review work from extraction noise. Deferred (documented): #84.2 near-duplicate cluster cards and wiring the UI fetch to the new params require frontend work + an api:types regen AFTER this deploys (the new query params aren't in prod's OpenAPI until then) — a clean follow-up. The backend fully supports both now. Verified against the live DB (read-only): - pending 177 → gated-clean 110, 0 flagged items leak into the clean queue. - priority order surfaces the lowest-confidence items first (0.55, 0.55, ...). - backlog: pending_clean=110 / pending_flagged=67 / approve_ratio=0.916, pending_by_flag={nli_unsupported:59, quote_unverified:3, thin:3, truncated:2}. - pytest tests/test_halacha_quality.py — 52 passed (no regression). Invariants: G1 (gate at source — SQL filter, not post-hoc); G2 (no parallel path — same list_halachot); §6 (flagged items routed to a bucket, never dropped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
378 lines
14 KiB
Python
378 lines
14 KiB
Python
"""MCP tools for the External Precedent Library.
|
||
|
||
This is distinct from:
|
||
|
||
- ``precedents`` (case_precedents table) — chair-attached quotes scoped to
|
||
a specific case section. Use ``search_case_precedents`` for that (GAP-49:
|
||
renamed from the misleading ``precedent_search_library``).
|
||
- ``style_corpus`` (Daphna's prior decisions) — searched via
|
||
``search_decisions`` for style/voice.
|
||
|
||
The precedent library is the **authoritative law** corpus: external court
|
||
rulings and other appeals committees' decisions, with halachot extracted
|
||
and reviewed by the chair.
|
||
|
||
All halachot enter as ``pending_review`` and are invisible to search until
|
||
the chair approves them — per project review policy.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from uuid import UUID
|
||
|
||
from legal_mcp.services import db, precedent_library, telemetry
|
||
from legal_mcp.tools.envelope import empty, err as _err, ok as _ok # GAP-48: SSoT envelope
|
||
|
||
|
||
async def precedent_library_upload(
|
||
file_path: str,
|
||
citation: str,
|
||
case_name: str = "",
|
||
court: str = "",
|
||
decision_date: str = "",
|
||
source_type: str = "",
|
||
precedent_level: str = "",
|
||
practice_area: str = "",
|
||
appeal_subtype: str = "",
|
||
subject_tags: list[str] | None = None,
|
||
is_binding: bool = True,
|
||
headnote: str = "",
|
||
summary: str = "",
|
||
) -> str:
|
||
"""העלאת פסיקה חיצונית לקורפוס הסמכותי + חילוץ הלכות אוטומטי.
|
||
|
||
Args:
|
||
file_path: נתיב מלא לקובץ PDF/DOCX/RTF/TXT/MD.
|
||
citation: מראה המקום ("עע\\"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית").
|
||
case_name: שם קצר.
|
||
court: ערכאה (עליון / מנהלי / ועדת ערר ארצית / ועדת ערר מחוזית).
|
||
decision_date: ISO date (YYYY-MM-DD), אופציונלי.
|
||
source_type: court_ruling / appeals_committee.
|
||
precedent_level: עליון / מנהלי / ועדת_ערר_ארצית / ועדת_ערר_מחוזית.
|
||
practice_area: rishuy_uvniya / betterment_levy / compensation_197.
|
||
subject_tags: תגיות נושא (חניה, קווי_בניין, וכד').
|
||
|
||
Returns: JSON עם case_law_id, מספר chunks, מספר הלכות שנכנסו לתור אישור.
|
||
"""
|
||
if not citation.strip():
|
||
return _err("citation חובה")
|
||
# Citation guard: appeals-committee decisions must go through
|
||
# internal_decision_upload (with chair_name + district). The legacy
|
||
# path always stored source_kind='external_upload' and left
|
||
# chair_name/district empty — see TaskMaster #30(ב).
|
||
_norm = citation.strip()
|
||
_committee_prefixes = ("ערר ", "ערר(", "ערר ", "בל\"מ ", "בל\"מ(", "ARAR ")
|
||
if any(_norm.startswith(p) for p in _committee_prefixes):
|
||
return _err(
|
||
"ציטוט שמתחיל ב-'ערר' או 'בל\"מ' הוא החלטת ועדת ערר. "
|
||
"השתמש ב-internal_decision_upload (דורש chair_name + district), "
|
||
"לא ב-precedent_library_upload."
|
||
)
|
||
try:
|
||
result = await precedent_library.ingest_precedent(
|
||
file_path=file_path,
|
||
citation=citation,
|
||
case_name=case_name,
|
||
court=court,
|
||
decision_date=decision_date or None,
|
||
source_type=source_type,
|
||
precedent_level=precedent_level,
|
||
practice_area=practice_area,
|
||
appeal_subtype=appeal_subtype,
|
||
subject_tags=subject_tags or [],
|
||
is_binding=is_binding,
|
||
headnote=headnote,
|
||
summary=summary,
|
||
)
|
||
except Exception as e:
|
||
return _err(str(e))
|
||
return _ok(result)
|
||
|
||
|
||
async def precedent_library_list(
|
||
practice_area: str = "",
|
||
court: str = "",
|
||
precedent_level: str = "",
|
||
source_type: str = "",
|
||
search: str = "",
|
||
source_kind: str = "external_upload",
|
||
limit: int = 100,
|
||
) -> str:
|
||
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים."""
|
||
rows = await precedent_library.list_precedents(
|
||
practice_area=practice_area,
|
||
court=court,
|
||
precedent_level=precedent_level,
|
||
source_type=source_type,
|
||
search=search,
|
||
source_kind=source_kind,
|
||
limit=limit,
|
||
)
|
||
return _ok(rows)
|
||
|
||
|
||
async def precedent_library_get(case_law_id: str) -> str:
|
||
"""פסיקה ספציפית עם כל ההלכות שלה (כולל ממתינות לאישור)."""
|
||
try:
|
||
cid = UUID(case_law_id)
|
||
except ValueError:
|
||
return _err("case_law_id לא תקין")
|
||
record = await precedent_library.get_precedent(cid)
|
||
if not record:
|
||
return _err("פסיקה לא נמצאה")
|
||
return _ok(record)
|
||
|
||
|
||
async def precedent_link_cases(
|
||
case_law_id_a: str,
|
||
case_law_id_b: str,
|
||
relation_type: str = "same_case_chain",
|
||
) -> str:
|
||
"""קישור שתי פסיקות כקשורות זו לזו (דו-כיווני). idempotent.
|
||
|
||
Args:
|
||
case_law_id_a: UUID של פסיקה ראשונה.
|
||
case_law_id_b: UUID של פסיקה שנייה.
|
||
relation_type: same_case_chain | overruled_by | distinguished
|
||
"""
|
||
try:
|
||
a = UUID(case_law_id_a)
|
||
b = UUID(case_law_id_b)
|
||
except ValueError:
|
||
return _err("case_law_id לא תקין")
|
||
rec_a = await db.get_case_law(a)
|
||
rec_b = await db.get_case_law(b)
|
||
if not rec_a:
|
||
return _err(f"פסיקה {case_law_id_a} לא נמצאה")
|
||
if not rec_b:
|
||
return _err(f"פסיקה {case_law_id_b} לא נמצאה")
|
||
await db.add_case_law_relation(a, b, relation_type)
|
||
return _ok({
|
||
"linked": True,
|
||
"relation_type": relation_type,
|
||
"a": {"id": case_law_id_a, "case_number": rec_a.get("case_number"), "court": rec_a.get("court")},
|
||
"b": {"id": case_law_id_b, "case_number": rec_b.get("case_number"), "court": rec_b.get("court")},
|
||
})
|
||
|
||
|
||
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
|
||
"""הסרת קישור בין שתי פסיקות (דו-כיווני).
|
||
|
||
Args:
|
||
case_law_id_a: UUID של פסיקה ראשונה.
|
||
case_law_id_b: UUID של פסיקה שנייה.
|
||
"""
|
||
try:
|
||
a = UUID(case_law_id_a)
|
||
b = UUID(case_law_id_b)
|
||
except ValueError:
|
||
return _err("case_law_id לא תקין")
|
||
await db.remove_case_law_relation(a, b)
|
||
return _ok({"unlinked": True, "a": case_law_id_a, "b": case_law_id_b})
|
||
|
||
|
||
async def precedent_library_delete(case_law_id: str) -> str:
|
||
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
|
||
try:
|
||
cid = UUID(case_law_id)
|
||
except ValueError:
|
||
return _err("case_law_id לא תקין")
|
||
ok = await precedent_library.delete_precedent(cid)
|
||
return _ok({"deleted": ok, "case_law_id": case_law_id})
|
||
|
||
|
||
async def precedent_extract_halachot(case_law_id: str) -> str:
|
||
"""הרצה מחדש של חילוץ ההלכות לפסיקה קיימת. הלכות קודמות נמחקות."""
|
||
try:
|
||
cid = UUID(case_law_id)
|
||
except ValueError:
|
||
return _err("case_law_id לא תקין")
|
||
try:
|
||
result = await precedent_library.reextract_halachot(cid)
|
||
except Exception as e:
|
||
return _err(str(e))
|
||
return _ok(result)
|
||
|
||
|
||
async def precedent_extract_metadata(case_law_id: str) -> str:
|
||
"""חילוץ מטא-דאטה (case_name קצר, summary, headnote, key_quote, subject_tags, appeal_subtype, date, level, court, source_type) מהטקסט. ממלא רק שדות ריקים — לא דורס מה שכבר הוזן."""
|
||
try:
|
||
cid = UUID(case_law_id)
|
||
except ValueError:
|
||
return _err("case_law_id לא תקין")
|
||
try:
|
||
result = await precedent_library.reextract_metadata(cid)
|
||
except Exception as e:
|
||
return _err(str(e))
|
||
return _ok(result)
|
||
|
||
|
||
async def precedent_reindex(case_law_id: str) -> str:
|
||
"""re-chunk + re-embed פסיקה קיימת מה-full_text השמור (FU-3/GAP-09).
|
||
|
||
לתיקון drift של embeddings או אחרי שינוי-תוכן. אינו מריץ OCR/LLM — רק
|
||
chunking + voyage embeddings. idempotent (מוחק ובונה chunks מחדש).
|
||
"""
|
||
try:
|
||
cid = UUID(case_law_id)
|
||
except ValueError:
|
||
return _err("case_law_id לא תקין")
|
||
try:
|
||
from legal_mcp.services import ingest
|
||
result = await ingest.reindex_case_law(cid)
|
||
except Exception as e:
|
||
return _err(str(e))
|
||
return _ok(result)
|
||
|
||
|
||
async def extraction_status() -> str:
|
||
"""סטטוס תור-החילוץ — כמה פסיקות ממתינות לחילוץ metadata/halacha (INV-TOOL4 / GAP-45).
|
||
|
||
חושף את התור ש-precedent_process_pending מרוקן: עומק-תור + גיל הבקשה
|
||
הוותיקה ביותר לכל סוג. read-only — אינו מרוקן את התור.
|
||
"""
|
||
try:
|
||
status = await db.extraction_queue_status()
|
||
except Exception as e:
|
||
return _err(str(e))
|
||
return _ok(status)
|
||
|
||
|
||
async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str:
|
||
"""ריקון תור בקשות חילוץ שנערמו ע"י כפתורי ה-UI. kind: 'metadata' או 'halacha'.
|
||
|
||
הכפתור ב-UI מסמן ב-DB שהפסיקה מבקשת חילוץ. כלי זה (שרץ מקומית עם CLI)
|
||
סורק את התור ומריץ את ה-extractor לכל פריט. אחרי הצלחה הסימון מתנקה.
|
||
"""
|
||
if kind not in {"metadata", "halacha"}:
|
||
return _err("kind חייב להיות 'metadata' או 'halacha'")
|
||
try:
|
||
result = await precedent_library.process_pending_extractions(
|
||
kind=kind, limit=limit,
|
||
)
|
||
except Exception as e:
|
||
return _err(str(e))
|
||
return _ok(result)
|
||
|
||
|
||
async def search_precedent_library(
|
||
query: str,
|
||
practice_area: str = "",
|
||
court: str = "",
|
||
precedent_level: str = "",
|
||
appeal_subtype: str = "",
|
||
is_binding: bool | None = None,
|
||
subject_tag: str = "",
|
||
limit: int = 10,
|
||
include_halachot: bool = True,
|
||
) -> str:
|
||
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית.
|
||
|
||
מחזיר תוצאות מעורבות: הלכות (rule-level, מאושרות בלבד) + קטעי טקסט
|
||
(passage-level). הלכות מקבלות boost קל בדירוג כי הן מזוקקות מראש.
|
||
|
||
Args:
|
||
query: שאילתת חיפוש בעברית.
|
||
practice_area: rishuy_uvniya / betterment_levy / compensation_197.
|
||
court: סינון לפי ערכאה (substring).
|
||
precedent_level: עליון / מנהלי / ועדת_ערר_ארצית / ועדת_ערר_מחוזית.
|
||
appeal_subtype: סינון לתת-סוג.
|
||
is_binding: True/False (None = ללא סינון).
|
||
subject_tag: סינון לפי תגית נושא (לדוגמה "מועד_קביעת_שומה").
|
||
limit: מספר תוצאות מקסימלי.
|
||
include_halachot: האם לכלול הלכות (ברירת מחדל: כן).
|
||
|
||
Returns: רשימה מדורגת. כל פריט הוא {"type": "halacha"|"passage", "score", ...}.
|
||
"""
|
||
if not query or len(query.strip()) < 2:
|
||
return empty("שאילתה קצרה מדי (פחות מ-2 תווים).")
|
||
q = query.strip()
|
||
t0 = time.perf_counter()
|
||
results = await precedent_library.search_library(
|
||
query=q,
|
||
practice_area=practice_area,
|
||
court=court,
|
||
precedent_level=precedent_level,
|
||
appeal_subtype=appeal_subtype,
|
||
is_binding=is_binding,
|
||
subject_tag=subject_tag,
|
||
limit=limit,
|
||
include_halachot=include_halachot,
|
||
)
|
||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||
telemetry.log_search_bg(
|
||
search_type="precedent_library",
|
||
query=q,
|
||
results=results,
|
||
duration_ms=elapsed_ms,
|
||
practice_area=practice_area or None,
|
||
user_agent="unknown",
|
||
)
|
||
return _ok(results)
|
||
|
||
|
||
async def halacha_review(
|
||
halacha_id: str,
|
||
status: str,
|
||
reviewer: str = "דפנה",
|
||
rule_statement: str = "",
|
||
reasoning_summary: str = "",
|
||
subject_tags: list[str] | None = None,
|
||
practice_areas: list[str] | None = None,
|
||
) -> str:
|
||
"""אישור / דחייה / עריכה של הלכה שחולצה אוטומטית.
|
||
|
||
Args:
|
||
halacha_id: מזהה ההלכה.
|
||
status: pending_review / approved / rejected / published.
|
||
reviewer: שם המאשר (ברירת מחדל: דפנה).
|
||
rule_statement: עריכת ניסוח הכלל (ריק = ללא שינוי).
|
||
reasoning_summary: עריכת תמצית ההיגיון (ריק = ללא שינוי).
|
||
subject_tags: עריכת תגיות (None = ללא שינוי).
|
||
practice_areas: עריכת תחומים (None = ללא שינוי).
|
||
"""
|
||
if status not in {"pending_review", "approved", "rejected", "published"}:
|
||
return _err(
|
||
"status לא חוקי. ערכים תקינים: "
|
||
"pending_review / approved / rejected / published"
|
||
)
|
||
try:
|
||
hid = UUID(halacha_id)
|
||
except ValueError:
|
||
return _err("halacha_id לא תקין")
|
||
|
||
row = await db.update_halacha(
|
||
halacha_id=hid,
|
||
review_status=status,
|
||
reviewer=reviewer,
|
||
rule_statement=rule_statement or None,
|
||
reasoning_summary=reasoning_summary or None,
|
||
subject_tags=subject_tags,
|
||
practice_areas=practice_areas,
|
||
)
|
||
if row is None:
|
||
return _err("הלכה לא נמצאה")
|
||
return _ok(row)
|
||
|
||
|
||
async def halachot_pending(limit: int = 100, include_low_quality: bool = False) -> str:
|
||
"""תור ההלכות הממתינות לאישור (review_status='pending_review').
|
||
|
||
כברירת-מחדל (#84.1, #84.3) התור **מסונן** — הלכות עם דגל-איכות כלשהו
|
||
(application / ציטוט-לא-מאומת / קטוע / obiter / restatement דק / לא-נתמך /
|
||
near-duplicate) מוסתרות (הן שייכות ל'דורש תיקון-חילוץ', לא לתור-האישור),
|
||
ו**ממוין לפי עדיפות** (טופלו-לרעה תחילה, אז הכי לא-ודאיים, אז הישנים).
|
||
|
||
Args:
|
||
limit: מספר מקסימלי.
|
||
include_low_quality: True כדי לחשוף גם פריטים מסומני-איכות (בקט 'דורש תיקון').
|
||
"""
|
||
rows = await db.list_halachot(
|
||
review_status="pending_review",
|
||
limit=limit,
|
||
exclude_low_quality=not include_low_quality,
|
||
order_by_priority=True,
|
||
)
|
||
return _ok(rows)
|