feat(digests): קורפוס יומונים כשכבת-גילוי (radar) — X12
מאגר חדש ליומוני "כל יום" (עפר טויסטר) כשכבת-גילוי מעל קורפוסי-הפסיקה:
מקור-משני המצביע על פסק הדין המקורי, נקלט לטבלה נפרדת `digests`, נחפש
סמנטית, ומקושר לפסק המקורי בספריית הפסיקה — אך לעולם אינו מצוטט בהחלטה
ואינו מחלץ הלכות.
Phase 0 (spec):
- docs/spec/X12-digests-radar.md — INV-DIG1 (מצביע לא מצוטט) /
INV-DIG2 (מסלול-קליטה נפרד, לא מקביל — מקיים G2) / INV-DIG3 (קישור-לפסק
הוא הגשר; חוסר-קישור = פער גלוי). עדכון אינדקס 00/03/README.
Phase 1 (MVP):
- SCHEMA_V30: טבלת `digests` (HNSW על embedding — לא ivfflat, להימנע מ-recall
cliff בקורפוס קטן/צומח) + GIN/FTS + UNIQUE חלקי ל-idempotent.
- services/digest_metadata_extractor.py — חילוץ-LLM (claude_session local-only,
ייבוא lazy): תג-מושג, כותרת-הלכה, מראה-מקום, שני-תאריכים מובחנים, תגיות.
- services/digest_library.py — מסלול קצר עצמאי (INV-DIG2): extract→hash→LLM→
embedding יחיד→autolink. לא משתמש ב-ingest.ingest_document.
- tools/digests.py + רישום 7 כלים ב-server.py (digest_upload/list/get/link/
relink/delete + search_digests).
- scripts/ingest_digests_batch.py — קליטה ידנית מ-data/digests/incoming.
- legal-researcher.md: שלב 2ב.0 (סריקת-radar לפני אימות) + סעיף-דוח ט +
3 כלים ב-frontmatter. HEARTBEAT §8: ניתוב יומון→digest_upload.
אומת end-to-end: 4 יומונים נקלטו (מטא-דאטה מדויק), חיפוש סמנטי מדרג נכון
("היטל השבחה"→5160, "תמא 38"→5158), link/relink/autolink/revert + מעטפת-MCP.
Invariants: מוסיף INV-DIG1/2/3 (X12). מקיים G2 (bounded context נפרד, לא
מסלול מקביל), G3 (idempotent upsert), G4 (אין בליעה שקטה — פער-קישור מוצף),
G9 (עקיבוּת — היומון מצביע על מקור עקיב). נוגע G7 (RRF) — נדחה, חיפוש
סמנטי-בלבד בשלב 1 (FTS index מוכן).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
161
mcp-server/src/legal_mcp/tools/digests.py
Normal file
161
mcp-server/src/legal_mcp/tools/digests.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user