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:
204
web/app.py
204
web/app.py
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user