diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index f34afa5..3b6e936 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -418,6 +418,12 @@ async def digest_process_pending(limit: int = 20) -> str: return await digest_tools.digest_process_pending(_clamp_limit(limit)) +@mcp.tool() +async def digest_radar(case_number: str, limit: int = 5, min_score: float = 0.45) -> str: + """רדאר-יומונים הקשרי-לתיק (X12) — מחזיר יומונים לא-מקושרים (פס"ד שעוד אין לנו) שהנושא שלהם קרוב סמנטית לתיק, כדי שלא ייפול פס"ד רלוונטי בזמן ההכרעה. כל ליד מצביע על מראה-המקום של הפס"ד המקורי + סטטוס-הפער + פעולה מוצעת. ⚠️ radar בלבד — היומון אינו מצוטט (INV-DIG1).""" + return await digest_tools.digest_radar(case_number, _clamp_limit(limit), min_score) + + @mcp.tool() async def halacha_review( halacha_id: str, diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 524bd7b..c98feaf 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -5018,14 +5018,24 @@ async def search_digests_semantic( subject_tag: str = "", concept_tag: str = "", limit: int = 10, + linked_only: bool | None = None, ) -> list[dict]: """Pure-semantic search over the digests radar (X12). Single vector per row (no chunks/halachot), so no RRF here — see X12 §6. Joins the linked ruling's - citation when present so the researcher sees the pointer target directly.""" + citation when present so the researcher sees the pointer target directly. + + ``linked_only``: None = all digests (default); False = only UNLINKED digests + (``linked_case_law_id IS NULL`` — rulings we don't hold yet, the case radar's + target set); True = only linked digests. + """ pool = await get_pool() conditions = ["d.embedding IS NOT NULL"] params: list = [query_embedding, limit] idx = 3 + if linked_only is True: + conditions.append("d.linked_case_law_id IS NOT NULL") + elif linked_only is False: + conditions.append("d.linked_case_law_id IS NULL") if practice_area: conditions.append(f"d.practice_area = ${idx}") params.append(practice_area) diff --git a/mcp-server/src/legal_mcp/services/digest_library.py b/mcp-server/src/legal_mcp/services/digest_library.py index c455738..dc310e7 100644 --- a/mcp-server/src/legal_mcp/services/digest_library.py +++ b/mcp-server/src/legal_mcp/services/digest_library.py @@ -381,6 +381,77 @@ async def link_digest(digest_id: UUID | str, case_law_id: UUID | str) -> dict: } +async def case_digest_radar( + case_number: str, + limit: int = 5, + min_score: float = 0.45, +) -> dict: + """Case-contextual digest radar (X12) — the chair-facing "שים לב" lead. + + Surfaces UNLINKED digests (``linked_case_law_id IS NULL`` — rulings we don't hold + yet) whose TOPIC is semantically close to THIS case, so a relevant ruling we + only know about via a digest doesn't fall through the cracks while the case is + being decided. The case topic is built from title + appeal_subtype + the analyst's + extracted claims, embedded once, and matched against unlinked digests. Each lead + carries the underlying ruling's gap status + a suggested action. + + INV-DIG1: this is RADAR — the digest is never cited; the lead points at the + underlying *ruling* (fetch / upload / link), never the digest itself. Read-only. + """ + case = await db.get_case_by_number(case_number) + if not case: + return {"status": "case_not_found", "case_number": case_number, "leads": [], "count": 0} + case_id = case["id"] + if isinstance(case_id, str): + case_id = UUID(case_id) + parts = [case.get("title") or "", case.get("appeal_subtype") or ""] + try: + claims = await db.get_claims(case_id) + parts += [(c.get("claim_text") or "") for c in claims[:20]] + except Exception as e: # noqa: BLE001 — claims are optional enrichment + logger.warning("case_digest_radar: get_claims failed for %s: %s", case_number, e) + query = " ".join(p for p in parts if p).strip() + if not query: + return {"status": "no_topic", "case_number": case_number, "leads": [], "count": 0} + + vec = (await embeddings.embed_texts([query], input_type="query"))[0] + # Over-fetch, then filter by score + cap — semantic relevance, domain-agnostic + # (a cross-domain ruling on the same issue is still a valid lead). + hits = await db.search_digests_semantic(vec, limit=max(limit * 4, 20), linked_only=False) + + leads: list[dict] = [] + for h in hits: + score = float(h.get("score", 0) or 0) + if score < min_score: + continue + cit = (h.get("underlying_citation") or "").strip() + gap = await db.find_missing_precedent_by_citation(cit) if cit else None + in_corpus = await db.find_case_law_by_citation_fuzzy(cit) if cit else None + if in_corpus: + action = "available_link" # ruling actually IS in the corpus → just link the digest + elif gap and (gap.get("status") in ("uploaded", "closed")): + action = "fetched" # already obtained + elif gap: + action = "gap_open" # flagged as missing — can request a fetch + else: + action = "new_lead" # not even flagged yet — the highest-value alert + leads.append({ + "digest_id": str(h["id"]), + "yomon_number": h.get("yomon_number"), + "headline": h.get("headline_holding") or h.get("summary") or "", + "underlying_citation": cit, + "underlying_court": h.get("underlying_court") or "", + "score": round(score, 3), + "missing_precedent_id": str(gap["id"]) if gap else None, + "missing_precedent_status": gap.get("status") if gap else None, + "action": action, + }) + if len(leads) >= limit: + break + return {"status": "ok", "case_number": case_number, + "topic_query": query[:200], "leads": leads, "count": len(leads)} + + async def relink_digest(digest_id: UUID | str) -> dict: """Re-run autolink for an unlinked digest. No-op if already linked / no match.""" digest = await db.get_digest(digest_id) diff --git a/mcp-server/src/legal_mcp/tools/digests.py b/mcp-server/src/legal_mcp/tools/digests.py index 468e2ef..448565b 100644 --- a/mcp-server/src/legal_mcp/tools/digests.py +++ b/mcp-server/src/legal_mcp/tools/digests.py @@ -170,3 +170,30 @@ async def digest_process_pending(limit: int = 20) -> str: 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) diff --git a/web/app.py b/web/app.py index 07c6974..9e04e49 100644 --- a/web/app.py +++ b/web/app.py @@ -6444,6 +6444,17 @@ class DigestLinkRequest(BaseModel): case_law_id: str +@app.get("/api/cases/{case_number}/digest-radar") +async def api_case_digest_radar(case_number: str, limit: int = 5, min_score: float = 0.45): + """Case-contextual digest radar (X12) — UNLINKED digests whose topic is close to + this case (rulings we don't hold yet). Powers the case-page "📡 רדאר יומונים" lead + so a relevant ruling known only via a digest doesn't fall through the cracks while + the case is decided. INV-DIG1: points at the underlying ruling, never cites the + digest.""" + return await digest_service.case_digest_radar( + case_number, limit=max(1, min(int(limit), 20)), min_score=float(min_score)) + + @app.post("/api/digests/upload") async def digest_upload( file: UploadFile = File(...),