Files
legal-ai/mcp-server/src/legal_mcp/tools/precedent_library.py
Chaim 420cb819f5 feat(halacha-triage): quality-gated + prioritized review queue + metrics (#84)
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>
2026-06-06 20:00:52 +00:00

378 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)