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>
This commit is contained in:
2026-06-07 18:11:05 +00:00
parent 955675eb1f
commit 06281996ca
13 changed files with 1305 additions and 120 deletions

View File

@@ -5827,6 +5827,210 @@ async def precedent_queue_pending(kind: str = "metadata", limit: int = 20):
return {"items": items, "count": len(items)}
# ── Digests radar (X12) — secondary discovery layer ─────────────────
# A digest is a SECONDARY source that POINTS at a ruling (INV-DIG1/2): never
# cited, never extracts halachot. These endpoints are DB/voyage-only and run
# in the container; the LLM enrichment of an uploaded digest is deferred to the
# local MCP drainer (digest_process_pending) — see the upload endpoint.
from legal_mcp.services import digest_library as digest_service # noqa: E402
class DigestUpdateRequest(BaseModel):
yomon_number: str | None = None
digest_date: str | None = None
concept_tag: str | None = None
headline_holding: str | None = None
summary: str | None = None
underlying_citation: str | None = None
underlying_court: str | None = None
underlying_date: str | None = None
underlying_judge: str | None = None
practice_area: str | None = None
appeal_subtype: str | None = None
subject_tags: list[str] | None = None
class DigestLinkRequest(BaseModel):
case_law_id: str
@app.post("/api/digests/upload")
async def digest_upload(
file: UploadFile = File(...),
yomon_number: str = Form(""),
digest_date: str = Form(""),
practice_area: str = Form(""),
appeal_subtype: str = Form(""),
subject_tags: str = Form("[]"),
):
"""Upload a "כל יום" digest. Container-safe: stages the file and extracts
text, creating a row with extraction_status='pending'. LLM enrichment
(concept/headline/citation + embedding + autolink) is deferred to the local
MCP drainer ``digest_process_pending`` (claude CLI not in container)."""
if practice_area and practice_area not in _PRACTICE_AREAS:
raise HTTPException(400, "practice_area לא תקין")
suffix = Path(file.filename or "").suffix.lower()
if suffix not in ALLOWED_EXTENSIONS:
raise HTTPException(400, f"סוג קובץ לא נתמך: {suffix}")
try:
tags = json.loads(subject_tags) if subject_tags else []
if not isinstance(tags, list):
tags = []
except json.JSONDecodeError:
tags = []
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
staged = UPLOAD_DIR / f"digest_{uuid4().hex[:8]}_{file.filename}"
size = 0
with staged.open("wb") as out:
while chunk := await file.read(1024 * 1024):
size += len(chunk)
if size > MAX_FILE_SIZE:
staged.unlink(missing_ok=True)
raise HTTPException(413, "קובץ גדול מדי")
out.write(chunk)
try:
result = await digest_service.create_pending_digest(
file_path=staged,
yomon_number=yomon_number.strip(),
digest_date=digest_date or None,
practice_area=practice_area,
appeal_subtype=appeal_subtype.strip(),
subject_tags=tags,
)
except ValueError as e:
raise HTTPException(400, str(e))
finally:
staged.unlink(missing_ok=True)
return result
@app.get("/api/digests")
async def digest_list(
practice_area: str = "",
concept_tag: str = "",
linked: bool | None = None,
search: str = "",
limit: int = 100,
offset: int = 0,
):
rows = await digest_service.list_digests(
practice_area=practice_area, concept_tag=concept_tag, linked=linked,
search=search, limit=limit, offset=offset,
)
return {"items": rows, "count": len(rows)}
@app.get("/api/digests/search")
async def digest_search(
q: str,
practice_area: str = "",
subject_tag: str = "",
concept_tag: str = "",
limit: int = 10,
):
if not q or len(q.strip()) < 2:
return {"items": [], "count": 0}
results = await digest_service.search_digests(
query=q.strip(), practice_area=practice_area,
subject_tag=subject_tag, concept_tag=concept_tag, limit=limit,
)
return {"items": results, "count": len(results)}
@app.get("/api/digests/queue/pending")
async def digest_queue_pending(limit: int = 20):
"""Digests awaiting local LLM enrichment (UI badge 'N ממתינים לעיבוד')."""
items = await db.list_pending_digests(limit=limit)
return {"items": items, "count": len(items)}
@app.get("/api/digests/{digest_id}")
async def digest_get(digest_id: str):
try:
cid = UUID(digest_id)
except ValueError:
raise HTTPException(400, "digest_id לא תקין")
record = await digest_service.get_digest(cid)
if not record:
raise HTTPException(404, "יומון לא נמצא")
return record
@app.patch("/api/digests/{digest_id}")
async def digest_update(digest_id: str, req: DigestUpdateRequest):
try:
cid = UUID(digest_id)
except ValueError:
raise HTTPException(400, "digest_id לא תקין")
fields = {k: v for k, v in req.model_dump(exclude_unset=True).items() if v is not None}
if "practice_area" in fields and fields["practice_area"] not in _PRACTICE_AREAS:
raise HTTPException(400, "practice_area לא תקין")
for dk in ("digest_date", "underlying_date"):
if dk in fields and fields[dk]:
try:
from datetime import date as date_type
fields[dk] = date_type.fromisoformat(str(fields[dk])[:10])
except ValueError:
raise HTTPException(400, f"{dk} לא תקין")
record = await digest_service.update_digest(cid, **fields)
if not record:
raise HTTPException(404, "יומון לא נמצא")
return record
@app.delete("/api/digests/{digest_id}")
async def digest_delete(digest_id: str):
try:
cid = UUID(digest_id)
except ValueError:
raise HTTPException(400, "digest_id לא תקין")
ok = await digest_service.delete_digest(cid)
if not ok:
raise HTTPException(404, "יומון לא נמצא")
return {"deleted": True, "digest_id": digest_id}
@app.post("/api/digests/{digest_id}/link")
async def digest_link(digest_id: str, req: DigestLinkRequest):
"""Link a digest to its underlying ruling in the precedent library (INV-DIG3)."""
try:
UUID(digest_id)
UUID(req.case_law_id)
except ValueError:
raise HTTPException(400, "מזהה לא תקין")
try:
return await digest_service.link_digest(digest_id, req.case_law_id)
except ValueError as e:
raise HTTPException(404, str(e))
@app.post("/api/digests/{digest_id}/relink")
async def digest_relink(digest_id: str):
"""Re-run autolink: link to the underlying ruling if it is now in the library."""
try:
UUID(digest_id)
except ValueError:
raise HTTPException(400, "digest_id לא תקין")
try:
return await digest_service.relink_digest(digest_id)
except ValueError as e:
raise HTTPException(404, str(e))
@app.delete("/api/digests/{digest_id}/link")
async def digest_unlink(digest_id: str):
"""Clear a digest's link to the underlying ruling."""
try:
UUID(digest_id)
except ValueError:
raise HTTPException(400, "digest_id לא תקין")
try:
return await digest_service.unlink_digest(digest_id)
except ValueError as e:
raise HTTPException(404, str(e))
from legal_mcp.services import internal_decisions as int_decisions_service # noqa: E402