A digest pointing at a ruling we don't hold yet ("unlinked") was captured globally
(missing_precedents inbox) but never surfaced IN THE CONTEXT of the case being
decided — so a relevant ruling known only via a digest could fall through the cracks
at the moment it matters. Adds the case-contextual radar:
- db.search_digests_semantic: new `linked_only` filter (False = unlinked-only target set).
- digest_library.case_digest_radar(case_number): builds the case topic from title +
appeal_subtype + the analyst's claims, embeds once, matches against UNLINKED digests,
and returns leads enriched with the underlying ruling's gap status + suggested action
(new_lead / gap_open / fetched / available_link).
- MCP tool `digest_radar` + endpoint GET /api/cases/{n}/digest-radar (for the agent and
the future case-page lead).
INV-DIG1 preserved: radar only — every lead points at the underlying RULING (fetch /
upload / link), never cites the digest. Read-only.
Validated on 8124-09-24 (היטל השבחה): 5 on-topic unlinked digests, scores 0.64–0.72.
The visible case-page panel is a separate UI change → goes through the design gate.
Invariants: G2 (reuses the one digest semantic-search + gap/citation resolvers),
INV-DIG1 (no digest citation), INV-DIG3 (gap surfacing). No schema change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
200 lines
7.7 KiB
Python
200 lines
7.7 KiB
Python
"""MCP tools for the Digests radar (X12).
|
||
|
||
A digest ("כל יום" daily one-pager, Ofer Toister) is a SECONDARY, discovery-
|
||
layer source that POINTS at a ruling. It is distinct from the three citation
|
||
corpora:
|
||
|
||
- ``search_precedent_library`` — authoritative external court rulings.
|
||
- ``search_internal_decisions`` — appeals-committee decisions.
|
||
- ``search_decisions`` — Dafna's prior decisions (style corpus).
|
||
|
||
A digest is NEVER cited in a decision (INV-DIG1) and NEVER enters the halacha
|
||
pipeline (INV-DIG2). ``search_digests`` is a research compass: it surfaces the
|
||
relevant digest + the UNDERLYING ruling's citation, which is then ingested into
|
||
the precedent library and cited from there.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from uuid import UUID
|
||
|
||
from legal_mcp.services import db, digest_library, telemetry
|
||
from legal_mcp.tools.envelope import empty, err as _err, ok as _ok
|
||
|
||
|
||
async def digest_upload(
|
||
file_path: str,
|
||
yomon_number: str = "",
|
||
digest_date: str = "",
|
||
practice_area: str = "",
|
||
appeal_subtype: str = "",
|
||
subject_tags: list[str] | None = None,
|
||
) -> str:
|
||
"""העלאת יומון ("כל יום") לקורפוס-הגילוי + חילוץ מטא-דאטה אוטומטי.
|
||
|
||
היומון הוא מקור-משני המצביע על פסק הדין המקורי — אינו מצוטט בהחלטה.
|
||
Args:
|
||
file_path: נתיב מלא לקובץ PDF/DOCX של היומון.
|
||
yomon_number: מספר היומון (אופציונלי — יחולץ מהטקסט אם ריק).
|
||
digest_date: ISO date של גיליון היומון (אופציונלי).
|
||
practice_area: rishuy_uvniya / betterment_levy / compensation_197.
|
||
subject_tags: תגיות נושא (אופציונלי — יחולצו אם ריק).
|
||
Returns: JSON עם digest_id, מספר היומון, מראה-המקום, וקישור-אוטומטי אם נמצא.
|
||
"""
|
||
try:
|
||
result = await digest_library.ingest_digest(
|
||
file_path=file_path,
|
||
yomon_number=yomon_number,
|
||
digest_date=digest_date or None,
|
||
practice_area=practice_area,
|
||
appeal_subtype=appeal_subtype,
|
||
subject_tags=subject_tags or None,
|
||
)
|
||
except Exception as e:
|
||
return _err(str(e))
|
||
return _ok(result)
|
||
|
||
|
||
async def digest_list(
|
||
practice_area: str = "",
|
||
concept_tag: str = "",
|
||
linked: bool | None = None,
|
||
search: str = "",
|
||
limit: int = 100,
|
||
) -> str:
|
||
"""רשימת יומונים בקורפוס-הגילוי, עם פילטרים. linked=false → יומונים שהפסק
|
||
המקורי שלהם עוד לא נקלט לספריית הפסיקה (פער-ידע גלוי)."""
|
||
rows = await digest_library.list_digests(
|
||
practice_area=practice_area,
|
||
concept_tag=concept_tag,
|
||
linked=linked,
|
||
search=search,
|
||
limit=limit,
|
||
)
|
||
return _ok(rows)
|
||
|
||
|
||
async def digest_get(digest_id: str) -> str:
|
||
"""יומון ספציפי לפי מזהה."""
|
||
try:
|
||
cid = UUID(digest_id)
|
||
except ValueError:
|
||
return _err("digest_id לא תקין")
|
||
record = await digest_library.get_digest(cid)
|
||
if not record:
|
||
return _err("יומון לא נמצא")
|
||
return _ok(record)
|
||
|
||
|
||
async def digest_link(digest_id: str, case_law_id: str) -> str:
|
||
"""קישור ידני של יומון לפסק הדין המקורי בספריית הפסיקה (INV-DIG3)."""
|
||
try:
|
||
UUID(digest_id)
|
||
UUID(case_law_id)
|
||
except ValueError:
|
||
return _err("מזהה לא תקין")
|
||
try:
|
||
result = await digest_library.link_digest(digest_id, case_law_id)
|
||
except Exception as e:
|
||
return _err(str(e))
|
||
return _ok(result)
|
||
|
||
|
||
async def digest_relink(digest_id: str) -> str:
|
||
"""ניסיון-קישור מחדש: בודק אם פסק הדין המקורי של היומון כבר בספרייה ומקשר."""
|
||
try:
|
||
UUID(digest_id)
|
||
except ValueError:
|
||
return _err("digest_id לא תקין")
|
||
try:
|
||
result = await digest_library.relink_digest(digest_id)
|
||
except Exception as e:
|
||
return _err(str(e))
|
||
return _ok(result)
|
||
|
||
|
||
async def digest_delete(digest_id: str) -> str:
|
||
"""מחיקת יומון מקורפוס-הגילוי."""
|
||
try:
|
||
cid = UUID(digest_id)
|
||
except ValueError:
|
||
return _err("digest_id לא תקין")
|
||
ok_ = await digest_library.delete_digest(cid)
|
||
if not ok_:
|
||
return _err("יומון לא נמצא")
|
||
return _ok({"deleted": True, "digest_id": digest_id})
|
||
|
||
|
||
async def search_digests(
|
||
query: str,
|
||
practice_area: str = "",
|
||
subject_tag: str = "",
|
||
concept_tag: str = "",
|
||
limit: int = 10,
|
||
) -> str:
|
||
"""חיפוש סמנטי בקורפוס-הגילוי (יומוני "כל יום"). מצפן-מחקר בלבד — מחזיר את
|
||
היומון הרלוונטי + מראה-המקום של הפסק המקורי (radar). היומון אינו מצוטט
|
||
בהחלטה (INV-DIG1); הצטט מהפסק המקורי דרך search_precedent_library."""
|
||
if not query or len(query.strip()) < 2:
|
||
return empty("שאילתה קצרה מדי (פחות מ-2 תווים).")
|
||
q = query.strip()
|
||
t0 = time.perf_counter()
|
||
results = await digest_library.search_digests(
|
||
query=q,
|
||
practice_area=practice_area,
|
||
subject_tag=subject_tag,
|
||
concept_tag=concept_tag,
|
||
limit=limit,
|
||
)
|
||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||
telemetry.log_search_bg(
|
||
search_type="digests",
|
||
query=q,
|
||
results=results,
|
||
duration_ms=elapsed_ms,
|
||
practice_area=practice_area or None,
|
||
user_agent="unknown",
|
||
)
|
||
if not results:
|
||
return empty("לא נמצאו יומונים תואמים.")
|
||
return _ok(results)
|
||
|
||
|
||
async def digest_process_pending(limit: int = 20) -> str:
|
||
"""ריקון תור היומונים שהועלו מה-UI וממתינים לעיבוד-LLM מקומי. מריץ חילוץ-
|
||
מטא-דאטה + embedding + autolink על כל יומון בסטטוס 'pending', מקומית עם
|
||
ה-CLI (claude_session local-only). מנקה לסטטוס 'completed'."""
|
||
try:
|
||
result = await digest_library.process_pending_digests(limit=limit)
|
||
except Exception as e:
|
||
return _err(str(e))
|
||
return _ok(result)
|
||
|
||
|
||
async def digest_radar(case_number: str, limit: int = 5, min_score: float = 0.45) -> str:
|
||
"""רדאר-יומונים הקשרי-לתיק (X12) — "שים לב" ליו"ר.
|
||
|
||
מחזיר יומונים **לא-מקושרים** (פס"ד שעוד אין לנו בקורפוס) שהנושא שלהם קרוב
|
||
סמנטית לתיק הזה — כדי שפס"ד רלוונטי שמוכר רק דרך יומון לא ייפול בין הכיסאות
|
||
בזמן הכרעת התיק. נושא-התיק נבנה מ-title + appeal_subtype + טענות-הצדדים, ומותאם
|
||
מול יומונים לא-מקושרים. כל ליד נושא את מראה-המקום של הפס"ד המקורי, סטטוס-הפער
|
||
(new_lead / gap_open / fetched / available_link) ופעולה מוצעת.
|
||
|
||
INV-DIG1: זהו radar — היומון לעולם אינו מצוטט; הליד מצביע על **הפס"ד** (להזמין
|
||
משיכה / להעלות / לקשר), לא על היומון. read-only.
|
||
|
||
Args:
|
||
case_number: מספר התיק (למשל "8124-09-24").
|
||
limit: מספר לידים מקסימלי.
|
||
min_score: סף-דמיון סמנטי (0-1) לסינון רעש.
|
||
"""
|
||
if not case_number.strip():
|
||
return _err("case_number חובה")
|
||
try:
|
||
result = await digest_library.case_digest_radar(
|
||
case_number.strip(), limit=max(1, int(limit)), min_score=float(min_score))
|
||
except Exception as e:
|
||
return _err(str(e))
|
||
return _ok(result)
|