feat: external precedent library with auto halacha extraction
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s

Adds a third corpus of legal authority distinct from style_corpus
(Daphna's prior decisions for voice) and case_precedents (chair-attached
quotes per case). The new corpus holds chair-uploaded court rulings and
other appeals committee decisions, with binding rules (הלכות) extracted
automatically and queued for chair approval.

Pipeline (web/app.py + services/precedent_library.py):
file → extract → chunk → Voyage embed → halacha_extractor → store +
publish progress over the existing Redis SSE channel.

Schema V7 (services/db.py): extends case_law with source_kind +
extraction status fields under a CHECK constraint pinning practice_area
to the three appeals committee domains (rishuy_uvniya, betterment_levy,
compensation_197). New precedent_chunks (vector(1024)) and halachot
tables (vector(1024) over rule_statement, IVFFlat indexes, gin on
practice_areas/subject_tags). Halachot start as pending_review; only
approved/published rows are visible to search_precedent_library.

Agents: legal-writer, legal-researcher, legal-analyst, legal-ceo,
legal-qa get search_precedent_library. legal-writer prompt explains
the three-corpus distinction and CREAC use; legal-qa now verifies that
every cited halacha resolves to an approved row in the corpus.

UI: /precedents page with four tabs — library / semantic search /
pending review (J/K nav, A/R/E shortcuts, badge count) / stats.
Reuses the existing upload-sheet progress + SSE pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 08:38:18 +00:00
parent a6edb75bbf
commit 7ee90dce31
23 changed files with 3853 additions and 67 deletions

View File

@@ -0,0 +1,234 @@
"""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 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)