Files
legal-ai/mcp-server/src/legal_mcp/services/case_citation_verification.py
Chaim 6c870ac691
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
perf(citation-view): cache מבוסס-טביעה — 24.6 שנ' לטעינה חוזרת → 0.01 שנ'
הרכבת תצוגת אימות-הפסיקה עולה 20+ שניות של embeds וחיפושים וקטוריים,
והתוצאה משתנה רק כשמשתנים הקלטים שלה. עד עכשיו כל פתיחת-דף שילמה את
המחיר המלא מחדש, וכל רענון תוך-כדי-טעינה הוסיף הרצה מקבילה — כך העומס
המקורי התגלגל ל-deadlocks ב-Postgres.

למה טביעה ולא TTL: יו"ר שמאמת תקדים חייב לראות זאת בטעינה **הבאה**, לא
כשיפוג טיימר. `_inputs_fingerprint` הוא שאילתה אחת זולה שמסכמת בדיוק את
מה שהתצוגה נגזרת ממנו — טיעונים · שיוכי-פסיקה · גודל-הקורפוס. השתנה
משהו → הטביעה זזה → נבנה מחדש. **הנכונות אינה תלויה בכך שמישהו יזכור
לבטל** (G1): הבדיקה יושבת במקור-האמת ולא בזיכרון של כל כותב. `invalidate()`
קיים לנוחות, לא לתקינות.

מה שונה
- cache פר-תיק, מפתח = טביעת-הקלטים. תצוגה שלמה אינה פגה בטיימר.
- **מנעול פר-תיק נגד היצף** — שש טעינות מקבילות מריצות הרכבה אחת. זה
  בדיוק התרחיש שהפיל את המערכת.
- תצוגה **חלקית** (תקציב-האחזור נגמר) נשמרת ל-90 שנ' בלבד: מספיק כדי
  לעצור סופת-רענונים, לא מספיק כדי שההצעות החסרות ייתקעו לנצח.
- `cached: true/false` בתשובה, ו-`use_cache=False` למי שצריך רענון כפוי.

מדידה מול הקורפוס החי (8124-09-24):
    טעינה קרה ........ 24.6 שנ'   cached=false
    טעינה חוזרת ...... 0.01 שנ'   cached=true      ← פי 2,074
    5 מקבילות ........ 0.02 שנ'   כולן מה-cache
    אחרי invalidate .. 24.8 שנ'   cached=false

invariants: G1 (השער במקור-האמת, לא בכל כותב) · §6 (חלקי מסומן ופג,
לא נשמר כשלם)

טסטים: 6 חדשים (tests/test_citation_view_cache.py) — כולל היצף-מקבילי,
פקיעת תצוגה חלקית, וביטול-אוטומטי בלי קריאה מפורשת ל-invalidate.
537 עוברים.
2026-08-05 13:35:39 +00:00

311 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Citation-verification view (X11 Phase 2 / #154) — the chair's "אימות פסיקה" tab.
Assembles, per legal ARGUMENT of a case, the supporting precedents the chair should
verify before the writer cites them:
• in-corpus suggestions — per-issue semantic retrieval over the authoritative
precedent library (``search_library``), each carrying the cumulative authority
signal (``cited_by``: followed/distinguished — db.citation_authority, X11).
• attached/verified state — any ``case_precedents`` row already attached to the
argument (verified flag + chair_note), merged onto the matching suggestion.
• radar — UNLINKED digests relevant to the same issue (rulings we don't hold yet),
from ``case_digest_radar`` grouped by matched issue.
Pure read/assembly — never writes, never cites (INV-DIG1/INV-AH). The chair verifies
through ``db.set_case_precedent_verified`` / attach; the writer consumes only verified
rows. Reuses the one corpus search + the one authority query + the one radar (G2).
"""
from __future__ import annotations
import asyncio
import logging
import time
from uuid import UUID
from legal_mcp.services import (
argument_aggregator,
db,
digest_library,
precedent_library,
)
logger = logging.getLogger(__name__)
_SUGGEST_PER_ISSUE = 4
_SUGGEST_FLOOR = 0.45
#: Concurrent per-argument retrievals. The fan-out used to be unbounded — one
#: task per legal argument — which is self-defeating, not merely risky: each
#: task opens a Voyage embed call, and past ~8 in flight Voyage throttles, so
#: every request stalls together. Measured on this corpus (32 arguments):
#:
#: one search alone ...... 1.2s
#: 32 unbounded .......... 30.4s ← 25× a single search
#: 32 at 8 concurrent .... 22.1s ← bounded is FASTER
#:
#: A 69-argument case (1069-04-26) therefore blew past the 30s proxy timeout and
#: returned 500, and overlapping page reloads piled contention into Postgres
#: deadlocks. Bounding the fan-out both fixes the failure and speeds it up.
_MAX_CONCURRENT_LOOKUPS = 8
def _passes_floor(hit: dict) -> bool:
"""Is this hit similar enough to the argument to suggest to the chair?
Reads ``relevance`` (always a cosine similarity), NOT ``score`` — ``score``
becomes a rank-fusion value (~0.008-0.02) as soon as the lexical leg returns
rows, and comparing that against a cosine-calibrated floor rejected every
hit. That is why this tab showed no supporting precedent for any argument.
``relevance is None`` means the row came from the lexical leg only, so no
cosine was ever computed. It is KEPT: it earned its place by BM25 rank
inside an already-tiny top-k, and dropping it would silently hide exact
phrase/docket matches — the very hits a chair searches for by name.
"""
rel = hit.get("relevance")
if rel is None:
return True
return float(rel) >= _SUGGEST_FLOOR
#: Wall-clock ceiling for the whole retrieval phase. The proxy gives up at 30s;
#: cutting ourselves off earlier lets us return the suggestions that DID land
#: instead of a 500 that shows the chair nothing. Partial results are labelled
#: (``retrieval_complete: false``) rather than passed off as the full picture.
_RETRIEVAL_BUDGET_S = 22.0
#: Cached views, keyed by case number → (fingerprint, expires_at|None, view).
#: Assembling a view costs 20+ seconds of Voyage embeds and vector searches, and
#: the result changes only when its inputs do. Keyed by a FINGERPRINT of those
#: inputs rather than a guessed TTL: a chair who verifies a precedent must see it
#: reflected on the next load, not after a timer expires.
_view_cache: dict[str, tuple[str, float | None, dict]] = {}
#: One lock per case so two concurrent loads of the same page don't both compute.
#: Reload-during-a-slow-load is how the original outage compounded into Postgres
#: deadlocks; the second reader now waits for the first and gets its result.
_view_locks: dict[str, asyncio.Lock] = {}
#: A partial view (retrieval budget exhausted) is still worth caching — otherwise
#: every reload re-pays 22 seconds — but only briefly, so the missing suggestions
#: get another chance while the corpus is quieter.
_PARTIAL_TTL_S = 90.0
async def _inputs_fingerprint(case_id: UUID) -> str:
"""Cheap signature of everything the view is derived from.
Changes when: arguments are re-aggregated, the chair attaches or verifies a
precedent, or the corpus grows (new rulings change the suggestions). One
round-trip — the point is to be far cheaper than the 20s it guards.
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
(SELECT count(*)::text || ':' || coalesce(max(updated_at)::text, '-')
FROM legal_arguments WHERE case_id = $1) AS args,
(SELECT count(*)::text || ':' || coalesce(max(updated_at)::text, '-')
FROM case_precedents WHERE case_id = $1) AS attached,
(SELECT count(*)::text || ':' || coalesce(max(created_at)::text, '-')
FROM case_law WHERE searchable = true) AS corpus
""",
case_id,
)
return f"{row['args']}|{row['attached']}|{row['corpus']}"
def invalidate(case_number: str = "") -> None:
"""Drop cached views — one case, or all when called with no argument.
Callers that mutate the inputs may use this for immediacy, but correctness
does not depend on it: the fingerprint check catches any change on the next
read regardless of whether anyone remembered to invalidate (G1 — the guard
lives at the source of truth, not in every writer's memory).
"""
if case_number:
_view_cache.pop(case_number, None)
else:
_view_cache.clear()
async def build_view(case_number: str, *, use_cache: bool = True) -> dict:
"""Assemble the citation-verification view, served from cache when unchanged."""
if not use_cache:
return await _build_view_uncached(case_number)
lock = _view_locks.setdefault(case_number, asyncio.Lock())
async with lock:
case = await db.get_case_by_number(case_number)
if not case:
return {"status": "case_not_found", "case_number": case_number,
"arguments": []}
cid = case["id"]
fp = await _inputs_fingerprint(UUID(cid) if isinstance(cid, str) else cid)
hit = _view_cache.get(case_number)
if hit is not None:
cached_fp, expires_at, view = hit
fresh = cached_fp == fp and (expires_at is None or time.monotonic() < expires_at)
if fresh:
return {**view, "cached": True}
view = await _build_view_uncached(case_number)
if view.get("status") == "ok":
expires_at = (None if view.get("retrieval_complete", True)
else time.monotonic() + _PARTIAL_TTL_S)
_view_cache[case_number] = (fp, expires_at, view)
return {**view, "cached": False}
async def _build_view_uncached(case_number: str) -> dict:
case = await db.get_case_by_number(case_number)
if not case:
return {"status": "case_not_found", "case_number": case_number, "arguments": []}
case_id = case["id"]
if isinstance(case_id, str):
case_id = UUID(case_id)
ctx = " ".join(x for x in [case.get("title") or "", case.get("appeal_subtype") or ""] if x).strip()
args = await argument_aggregator.get_legal_arguments(case_id)
# Attached precedents already on the case → grouped by argument_id, keyed by
# the resolved corpus ruling so we can merge verify-state onto a suggestion.
attached = await db.list_case_precedents(case_id)
attached_by_arg: dict[str, dict[str, dict]] = {}
for p in attached:
aid = str(p.get("argument_id") or "")
clid = str(p.get("case_law_id") or "")
if aid and clid:
attached_by_arg.setdefault(aid, {})[clid] = p
# Radar (unlinked digests) once, grouped by the issue label it matched.
radar_by_issue: dict[str, list[dict]] = {}
try:
radar = await digest_library.case_digest_radar(case_number, limit=12, min_score=0.42)
for lead in radar.get("leads", []):
for label in (lead.get("matched_issues") or [""]):
radar_by_issue.setdefault(label, []).append(lead)
except Exception as e: # noqa: BLE001 — radar is best-effort
logger.warning("citation_verification radar failed for %s: %s", case_number, e)
async def _fetch(a: dict) -> tuple[list[dict], dict]:
"""Per-argument corpus search + batched authority — run concurrently.
Each call is one Voyage embed + one vector search (+ one batched authority
query); independent across arguments, so they fan out rather than waterfall.
"""
title = (a.get("argument_title") or "").strip()
topic = (a.get("legal_topic") or "").strip()
query = f"{ctx} {title}. {topic}".strip()
hits: list[dict] = []
try:
hits = await precedent_library.search_library(
query=query, limit=_SUGGEST_PER_ISSUE, include_halachot=True)
except Exception as e: # noqa: BLE001
logger.warning("citation_verification search failed (%s): %s", title[:30], e)
# Resolve the authority breakdown for the hit set in one batched query.
clids = [UUID(str(h["case_law_id"])) for h in hits
if h.get("case_law_id") and _passes_floor(h)]
authority = await db.citation_authority(clids) if clids else {}
return hits, authority
# Fan out the expensive per-argument retrieval, but BOUNDED — see
# _MAX_CONCURRENT_LOOKUPS. gather preserves order, so the zip below still
# pairs each argument with its own result.
_sem = asyncio.Semaphore(_MAX_CONCURRENT_LOOKUPS)
async def _fetch_bounded(a: dict) -> tuple[list[dict], dict]:
async with _sem:
return await _fetch(a)
retrieval_complete = True
fetched: list[tuple[list[dict], dict]] = []
if args:
# Harvest whatever finished inside the budget, per argument. Deliberately
# NOT wait_for(gather(...)): that cancels every task on timeout, so one
# slow lookup would throw away the 30 that already succeeded and the page
# would show nothing at all.
tasks = [asyncio.ensure_future(_fetch_bounded(a)) for a in args]
done, pending = await asyncio.wait(tasks, timeout=_RETRIEVAL_BUDGET_S)
for t in pending:
t.cancel()
if pending:
# Let the cancellations settle before the caller's DB pool unwinds —
# a task cancelled mid-query otherwise surfaces as a stray
# "connection_lost" future with no owner.
await asyncio.gather(*pending, return_exceptions=True)
retrieval_complete = False
logger.warning(
"citation_verification: retrieval budget of %.0fs exhausted for %s "
"%d of %d arguments returned suggestions, the rest are empty",
_RETRIEVAL_BUDGET_S, case_number, len(done), len(args),
)
for t in tasks:
if t in done and not t.cancelled() and t.exception() is None:
fetched.append(t.result())
else:
fetched.append(([], {}))
out_args: list[dict] = []
n_verified = 0
for a, (hits, authority) in zip(args, fetched):
aid = str(a["id"])
title = (a.get("argument_title") or "").strip()
topic = (a.get("legal_topic") or "").strip()
seen: set[str] = set()
supporting: list[dict] = []
for h in hits:
clid = str(h.get("case_law_id") or "")
if not clid or clid in seen:
continue
if not _passes_floor(h):
continue
seen.add(clid)
att = attached_by_arg.get(aid, {}).get(clid)
if att and att.get("verified"):
n_verified += 1
supporting.append({
"case_law_id": clid,
"case_number": h.get("case_number") or "",
"case_name": h.get("case_name") or "",
"quote": h.get("supporting_quote") or h.get("rule_statement") or "",
"score": round(float(h.get("score", 0) or 0), 3),
"cited_by": authority.get(clid, {"total": 0, "positive": 0,
"negative": 0, "unclassified": 0,
"by_treatment": {}}),
"attached_id": str(att["id"]) if att else None,
"verified": bool(att.get("verified")) if att else False,
"chair_note": (att.get("chair_note") or "") if att else "",
})
out_args.append({
"argument_id": aid,
"title": title,
"legal_topic": topic,
"priority": a.get("priority") or "",
"party": a.get("party") or "",
"supporting": supporting,
"radar": radar_by_issue.get(title, []),
})
return {
"status": "ok",
"case_number": case_number,
# False when the retrieval budget ran out: the attached/verified rows and
# the radar are complete, but the corpus SUGGESTIONS are missing. The UI
# must say so — an empty suggestion list otherwise reads as "no precedent
# in the corpus supports this argument", which is a different claim.
"retrieval_complete": retrieval_complete,
"arguments": out_args,
"summary": {
"arguments_total": len(out_args),
"arguments_with_support": sum(1 for x in out_args if x["supporting"]),
"verified": n_verified,
"radar_leads": sum(len(x["radar"]) for x in out_args),
},
}