All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
The chair wanted a one-click "extract metadata" button on the edit sheet.
The constraint stays the same — claude_session needs the local CLI which
the container doesn't have, so the button can't run the extractor itself.
Compromise: button stamps a queue marker; the local MCP server drains the
queue on demand.
DB (V8): two nullable timestamps on case_law,
metadata_extraction_requested_at and halacha_extraction_requested_at,
with partial indexes for cheap "find pending" scans.
API:
POST /api/precedent-library/{id}/request-metadata → stamp the row
POST /api/precedent-library/{id}/request-halachot → same for halacha
GET /api/precedent-library/queue/pending?kind=... → read-only view
UI: Sparkles button in the edit sheet header. Click → toast tells the
chair what to run from Claude Code. The button never triggers the
extractor directly from the container.
MCP tool: precedent_process_pending(kind, limit) — runs from Claude Code
with the local CLI, picks up everything stamped, calls the extractor for
each, clears the timestamp on success. Failures keep the timestamp so the
next invocation retries them.
Architectural rule (claude_session local-only) is preserved end-to-end
and called out in the new endpoint comment + tool docstring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
265 lines
9.6 KiB
Python
265 lines
9.6 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_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)
|