Add ability to mark case_law records as related (e.g. same appeal
through ועדת ערר → מנהלי → עליון):
- DB: case_law_relations join table (bidirectional, V11 migration)
- DB CRUD: add/remove/get_case_law_relations
- Service: get_precedent() now returns related_cases[]
- MCP: precedent_link_cases + precedent_unlink_cases tools
- REST: POST/DELETE /api/precedent-library/{id}/relations
- UI: RelatedCasesSection on detail page with search dialog and unlink
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
313 lines
11 KiB
Python
313 lines
11 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 ``precedent_search_library`` for that.
|
||
- ``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 json
|
||
from uuid import UUID
|
||
|
||
from legal_mcp.services import db, precedent_library
|
||
|
||
|
||
def _ok(payload) -> str:
|
||
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
||
|
||
|
||
def _err(msg: str) -> str:
|
||
return json.dumps({"error": msg}, ensure_ascii=False)
|
||
|
||
|
||
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 חובה")
|
||
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 = "",
|
||
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,
|
||
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_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 json.dumps([], ensure_ascii=False)
|
||
results = await precedent_library.search_library(
|
||
query=query.strip(),
|
||
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,
|
||
)
|
||
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) -> str:
|
||
"""תור ההלכות הממתינות לאישור (review_status='pending_review')."""
|
||
rows = await db.list_halachot(review_status="pending_review", limit=limit)
|
||
return _ok(rows)
|