Files
legal-ai/mcp-server/src/legal_mcp/tools/precedent_library.py
Chaim ccc5a73bc8
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
feat(x11): treatment-aware citation authority wired into research agents (#154)
The internal citation graph fed only RANKING (raw in-degree), and the per-citation
TREATMENT was never classified — so a precedent distinguished N times got the same
authority boost as one followed N times (INV-COR2 violation), and the signal never
reached the agents' reasoning. Wires the full path:

Phase 1 — scripts/classify_citation_treatments.py: classify each linked edge's
  treatment (followed/distinguished/…) from its match_context via
  corroboration.classify_treatment (Opus 4.8 @ xhigh, local), filling
  precedent_internal_citations.treatment. Idempotent.
Phase 2 — db.refresh_verified_layer: count only NON-negative treatments toward
  verified/cite_count (INV-COR2/COR4). Unclassified counts as neutral-positive so
  the signal degrades gracefully before classification runs.
Phase 3 — db.citation_authority(ids): per-precedent {total, positive, negative,
  unclassified, by_treatment}. Surfaced as `cited_by` in search_precedent_library
  hits and precedent_library_get, and `treatment` per incoming citation.
Phase 4 — legal-researcher/analyst/writer prompts: weigh & ARGUE authority
  ("הלכה שאומצה ב-N החלטות ועדת-ערר"), flag distinguished/overruled, never invent
  the count (INV-AH; writer is read-only of the analyst).

Auto-approval stays kill-switched off (chair gate preserved, INV-G10). No schema
change (treatment column already existed). Operational: run the classifier +
refresh_verified_layer over the 379 edges, then sync agents across companies.

Invariants: G2 (one classifier + one authority query, reused), INV-COR2/COR3/COR4
(negative never corroborates; point-specific; ≥N), INV-G10 (no auto-approval),
INV-AH (no invented numbers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:04:04 +00:00

490 lines
19 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 canonical_synthesis, 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:
"""הרצה מחדש של חילוץ ההלכות לפסיקה קיימת. הלכות שאושרו/פורסמו נשמרות (INV-G10); רק הלכות שלא-נבדקו מוחלפות."""
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,
)
# X11 Phase 2 (#154): attach the incoming-citation authority breakdown so the
# research agent can WEIGH and ARGUE authority ("הלכה שאומצה ב-N החלטות ועדת-ערר")
# — and steer clear of a precedent that committees repeatedly distinguished /
# overruled. Batched: one query for the whole result page.
try:
ids = {str(r.get("case_law_id")) for r in results if r.get("case_law_id")}
if ids:
auth = await db.citation_authority([UUID(i) for i in ids])
for r in results:
cb = auth.get(str(r.get("case_law_id")))
if cb:
r["cited_by"] = cb
except Exception: # noqa: BLE001 — authority is an additive signal; never break search
pass
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,
canonical_statement: str = "",
) -> str:
"""אישור / דחייה / עריכה של הלכה שחולצה אוטומטית.
Args:
halacha_id: מזהה ההלכה.
status: pending_review / approved / rejected / published.
reviewer: שם המאשר (ברירת מחדל: דפנה).
rule_statement: עריכת ניסוח הכלל (ריק = ללא שינוי).
reasoning_summary: עריכת תמצית ההיגיון (ריק = ללא שינוי).
subject_tags: עריכת תגיות (None = ללא שינוי).
practice_areas: עריכת תחומים (None = ללא שינוי).
canonical_statement: עריכת הניסוח הקנוני הרחב של העיקרון (ריק = ללא שינוי).
"""
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("הלכה לא נמצאה")
# V41: propagate canonical_statement edit to the canonical principle.
if canonical_statement and row.get("canonical_id"):
try:
await db.update_canonical_statement(
UUID(str(row["canonical_id"])), canonical_statement,
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(
"halacha_review: failed to update canonical_statement: %s", e,
)
return _ok(row)
async def halachot_pending(
limit: int = 100,
include_low_quality: bool = False,
instance_type: str = "original",
) -> str:
"""תור ההלכות הממתינות לאישור (review_status='pending_review').
כברירת-מחדל (#84.1, #84.3, V41) התור **מסונן** — הלכות עם דגל-איכות כלשהו
(application / ציטוט-לא-מאומת / קטוע / obiter / restatement דק / לא-נתמך /
near-duplicate) מוסתרות (הן שייכות ל'דורש תיקון-חילוץ', לא לתור-האישור),
ו**ממוין לפי עדיפות** (טופלו-לרעה תחילה, אז הכי לא-ודאיים, אז הישנים).
V41: כברירת-מחדל מציג רק instance_type='original' (עקרונות חדשים, לא ציטוטים).
העברת instance_type='' מציגה הכל (כולל ציטוטים).
Args:
limit: מספר מקסימלי.
include_low_quality: True כדי לחשוף גם פריטים מסומני-איכות (בקט 'דורש תיקון').
instance_type: 'original' (ברירת מחדל) / 'citation' / 'application' / '' (הכל).
"""
rows = await db.list_halachot(
review_status="pending_review",
instance_type=instance_type or None,
limit=limit,
exclude_low_quality=not include_low_quality,
order_by_priority=True,
)
return _ok(rows)
async def canonical_halacha_list(
practice_area: str = "",
review_status: str = "",
limit: int = 50,
offset: int = 0,
) -> str:
"""רשימת עקרונות משפטיים קנוניים — שאילתת נוחות לסוכני-הכתיבה.
כל פריט כולל principle_label לפי מקורו (#152): 'הלכה' (פס"ד מחוזי/עליון מחייב),
'כלל פרשני' (החלטת ועדת-ערר), או 'עיקרון' (פסיקה משכנעת).
Args:
practice_area: סינון לפי תחום עיסוק (ריק = הכל).
review_status: pending_synthesis / pending_review / approved / published (ריק = הכל).
limit: מספר מקסימלי (עד 200).
offset: עמוד (pagination).
"""
rows = await db.list_canonical_halachot(
practice_area=practice_area or None,
review_status=review_status or None,
limit=min(limit, 200),
offset=offset,
)
return _ok(rows)
async def canonical_halacha_get(canonical_id: str) -> str:
"""שלוף עיקרון קנוני אחד (canonical_statement, practice_areas, subject_tags,
review_status, instance_count) + כל האינסטנסים שלו (לפי פסיקה).
Args:
canonical_id: מזהה (UUID) של העיקרון הקנוני.
"""
try:
cid = UUID(canonical_id)
except ValueError:
return _err("canonical_id לא תקין")
row = await db.get_canonical_halacha(cid)
if row is None:
return _err("עיקרון קנוני לא נמצא")
return _ok(row)
async def canonical_synthesize_pending(limit: int = 20) -> str:
"""סנתז ניסוח-קנוני לעקרונות הממתינים (review_status='pending_synthesis'). V41 Phase 4.
לכל עיקרון: מודל מקומי (claude_session) מזקק ניסוח אחד, כללי ומעוגן בציטוטי-המופעים
(INV-AH), שער-drift דוחה סטייה גדולה מדי, והסטטוס מתקדם ל-pending_review לשער-היו"ר
(G10). on-demand / burst ידני; המסה הראשונית מטופלת ב-backfill_canonical_synthesis.py.
Args:
limit: מספר מקסימלי לסבב (עד 100). רב-instance מטופלים ראשונים.
"""
pool = await db.get_pool()
rows = await pool.fetch(
"SELECT id::text AS id FROM canonical_halachot "
"WHERE review_status='pending_synthesis' "
"ORDER BY instance_count DESC, created_at LIMIT $1",
min(max(limit, 1), 100),
)
if not rows:
return _ok({"processed": 0, "results": [], "message": "אין עקרונות ממתינים לסינתזה"})
results = []
counts: dict[str, int] = {}
for r in rows:
res = await canonical_synthesis.synthesize_and_apply(UUID(r["id"]))
counts[res["status"]] = counts.get(res["status"], 0) + 1
results.append({
"canonical_id": res["canonical_id"], "status": res["status"],
"drift_cosine": res.get("drift_cosine"), "reason": res.get("reason", ""),
})
return _ok({"processed": len(results), "by_status": counts, "results": results})