Files
legal-ai/mcp-server/src/legal_mcp/tools/digests.py
Chaim 06281996ca feat(digests): Phase 2 — API endpoints + /digests UI (X12)
משטחי-משתמש לקורפוס היומונים: endpoints ב-FastAPI + דף UI נפרד /digests
(לדפדוף, חיפוש, העלאה, וקישור לפסק המקורי). היומון נשאר מקור-משני המצביע
על הפסק — אינו מצוטט בהחלטה (INV-DIG1) ואינו מחלץ הלכות (INV-DIG2).

Backend (container-safe + local split):
- digest_library: פוצל ל-create_pending_digest (CONTAINER-SAFE: stage+
  extract_text+create row 'pending', בלי LLM) ↔ enrich_digest/
  process_pending_digests (local: LLM+embed+autolink). ingest_digest מאחד.
- db.list_pending_digests; MCP digest_process_pending (tool+server) — חלופה
  ל-batch script לריקון התור.
- web/app.py: 10 endpoints /api/digests/* (upload/list/search/queue-pending/
  get/patch/delete/link/relink/unlink). upload=INSERT-only pending (ה-LLM רץ
  מקומית — claude_session local-only). כולם מחזירים dict בדפוס precedent.

Frontend (Next 16, ללא api:types — hooks עם טיפוסים hand-written כמו
precedent-library.ts):
- lib/api/digests.ts — hooks (useDigests/useDigestSearch/useDigestPending/
  useUploadDigest/useLink/Relink/Unlink/Delete/Update).
- דף /digests נפרד (לא כרטיסייה ב-/precedents — לשמור גבול סמכותי/משני,
  INV-DIG1): טאבים יומונים/חיפוש + DigestCard (badge קישור-לפסק) +
  DigestUploadDialog + pending badge. nav + header-context.

אומת: backend round-trip מלא (create_pending→list_pending→process_pending→
search→restore); web-ui מתקמפל (webpack/tsc נקי, route /digests נוצר).
הערה: build דיפולטי (turbopack) נכשל ב-worktree עקב symlink ל-node_modules —
ב-CI/Docker (node_modules אמיתי) עובד; אומת עם --webpack.

Invariants: מקיים INV-DIG1/2 (upload לא מחלץ הלכות, UI מציג "מצביע לא
מצוטט"), INV-DIG3 (link/relink/queue). G4 (אין בליעה — שגיאות→toast/HTTP),
G2 (מסלול נפרד, לא מקביל). X6 (חוזה UI↔API — endpoints בדפוס precedent;
hooks hand-written כמו שאר ה-domain modules).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:11:05 +00:00

173 lines
6.3 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)