הרכבת תצוגת אימות-הפסיקה עולה 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 עוברים.
311 lines
14 KiB
Python
311 lines
14 KiB
Python
"""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),
|
||
},
|
||
}
|