feat(citation-verify): backend for the "אימות פסיקה" panel — per-argument support + verify gate (#154)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 12s

Backend half of the citation-verification tab (frontend follows in a separate PR):

- Schema V44: case_precedents gains argument_id (the legal argument it supports),
  case_law_id (the corpus ruling — for cited_by + dedup), verified (the INV-AH gate
  the writer respects) + verified_at. All nullable; chair_note already existed.
- db: create_case_precedent(+argument_id/case_law_id/verified),
  set_case_precedent_verified(id, verified, chair_note), list_case_precedents(+cols).
- services/case_citation_verification.build_view(case): per legal_argument →
  in-corpus suggestions (search_library per issue) each with the cited_by authority
  breakdown (db.citation_authority, X11) + merged attach/verify state + per-issue
  radar (case_digest_radar grouped by matched issue). Pure read/assembly; reuses the
  one corpus search + one authority query + one radar (G2).
- endpoints: GET /api/cases/{n}/citation-verification (the view) and
  POST .../verify (upsert verify/un-verify + chair note).

Validated on 1044-03-26: 14 arguments, all with support; 317/10 surfaces with
אומץ×11/אובחן×1; radar leads attributed per issue. Auto-approval untouched (chair
gate, INV-G10). Verify defaults to false — nothing is authoritative until the chair
marks it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:08:21 +00:00
parent 33c10e4147
commit 5ede8a9653
3 changed files with 243 additions and 5 deletions

View File

@@ -0,0 +1,134 @@
"""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 logging
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
async def build_view(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)
out_args: list[dict] = []
n_verified = 0
for a in args:
aid = str(a["id"])
title = (a.get("argument_title") or "").strip()
topic = (a.get("legal_topic") or "").strip()
query = f"{ctx} {title}. {topic}".strip()
hits = []
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 float(h.get("score", 0) or 0) >= _SUGGEST_FLOOR]
authority = await db.citation_authority(clids) if clids else {}
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 float(h.get("score", 0) or 0) < _SUGGEST_FLOOR:
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,
"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),
},
}

View File

@@ -1731,6 +1731,23 @@ UPDATE case_law SET district = 'חיפה'
""" """
SCHEMA_V44_SQL = """
-- Citation-verification panel (#154): the chair verifies each corpus precedent
-- against the specific legal ARGUMENT before the writer may cite it (INV-AH gate).
-- case_precedents gains: the argument it supports, the resolved case_law row
-- (for the cited_by authority signal + dedup), a verified flag (the gate), and a
-- verification timestamp. chair_note already exists. All nullable so legacy
-- section-scoped rows stay valid.
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS argument_id UUID
REFERENCES legal_arguments(id) ON DELETE SET NULL;
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS case_law_id UUID
REFERENCES case_law(id) ON DELETE SET NULL;
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS verified BOOLEAN DEFAULT false;
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS verified_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_case_precedents_argument ON case_precedents(argument_id);
"""
# Stable, arbitrary key for the session-level advisory lock that serialises # Stable, arbitrary key for the session-level advisory lock that serialises
# schema DDL across processes. Every short-lived process (cron drains, services) # schema DDL across processes. Every short-lived process (cron drains, services)
# re-runs the idempotent migrations on startup; without this lock two processes # re-runs the idempotent migrations on startup; without this lock two processes
@@ -1796,6 +1813,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
await conn.execute(SCHEMA_V41_SQL) await conn.execute(SCHEMA_V41_SQL)
await conn.execute(SCHEMA_V42_SQL) await conn.execute(SCHEMA_V42_SQL)
await conn.execute(SCHEMA_V43_SQL) await conn.execute(SCHEMA_V43_SQL)
await conn.execute(SCHEMA_V44_SQL)
async def init_schema() -> None: async def init_schema() -> None:
@@ -3331,28 +3349,58 @@ async def create_case_precedent(
chair_note: str = "", chair_note: str = "",
pdf_document_id: UUID | None = None, pdf_document_id: UUID | None = None,
practice_area: str | None = None, practice_area: str | None = None,
argument_id: UUID | None = None,
case_law_id: UUID | None = None,
verified: bool = False,
) -> dict: ) -> dict:
"""Insert a new precedent attached to a case.""" """Insert a new precedent attached to a case.
``argument_id``/``case_law_id``/``verified`` (X11 #154) link the attachment to
the specific legal argument it supports and the corpus ruling, and mark whether
the chair verified it (the INV-AH gate the writer respects)."""
pool = await get_pool() pool = await get_pool()
row = await pool.fetchrow( row = await pool.fetchrow(
""" """
INSERT INTO case_precedents INSERT INTO case_precedents
(case_id, section_id, quote, citation, chair_note, pdf_document_id, practice_area) (case_id, section_id, quote, citation, chair_note, pdf_document_id,
VALUES ($1, $2, $3, $4, $5, $6, $7) practice_area, argument_id, case_law_id, verified, verified_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
CASE WHEN $10 THEN now() ELSE NULL END)
RETURNING * RETURNING *
""", """,
case_id, section_id, quote, citation, chair_note, pdf_document_id, practice_area, case_id, section_id, quote, citation, chair_note, pdf_document_id,
practice_area, argument_id, case_law_id, verified,
) )
return dict(row) return dict(row)
async def set_case_precedent_verified(
precedent_id: UUID, verified: bool, chair_note: str | None = None,
) -> dict | None:
"""Toggle the chair-verification gate on an attached precedent (#154), optionally
updating the chair note. Sets verified_at when verifying, clears it when un-verifying."""
pool = await get_pool()
sets = ["verified = $2", "verified_at = CASE WHEN $2 THEN now() ELSE NULL END",
"updated_at = now()"]
params: list = [precedent_id, verified]
if chair_note is not None:
sets.append(f"chair_note = ${len(params) + 1}")
params.append(chair_note)
row = await pool.fetchrow(
f"UPDATE case_precedents SET {', '.join(sets)} WHERE id = $1 RETURNING *",
*params,
)
return dict(row) if row else None
async def list_case_precedents(case_id: UUID) -> list[dict]: async def list_case_precedents(case_id: UUID) -> list[dict]:
"""List all precedents attached to a case, ordered by section then creation time.""" """List all precedents attached to a case, ordered by section then creation time."""
pool = await get_pool() pool = await get_pool()
rows = await pool.fetch( rows = await pool.fetch(
""" """
SELECT id, case_id, section_id, quote, citation, chair_note, SELECT id, case_id, section_id, quote, citation, chair_note,
pdf_document_id, practice_area, created_at, updated_at pdf_document_id, practice_area, argument_id, case_law_id,
verified, verified_at, created_at, updated_at
FROM case_precedents FROM case_precedents
WHERE case_id = $1 WHERE case_id = $1
ORDER BY section_id NULLS LAST, created_at ORDER BY section_id NULLS LAST, created_at

View File

@@ -3155,6 +3155,62 @@ async def api_precedent_list(case_number: str):
return envelope_unwrap(parsed) return envelope_unwrap(parsed)
# ── Citation-verification panel (X11 Phase 2 / #154) — "אימות פסיקה" tab ──
@app.get("/api/cases/{case_number}/citation-verification")
async def api_citation_verification(case_number: str):
"""Per legal-argument: supporting corpus precedents (with cited_by authority),
their verify state + chair note, and per-issue radar (unlinked digests). Powers
the compose 'אימות פסיקה' tab — the chair verifies before the writer cites."""
from legal_mcp.services import case_citation_verification as ccv
view = await ccv.build_view(case_number)
if view.get("status") == "case_not_found":
raise HTTPException(404, f"תיק {case_number} לא נמצא")
return view
class CitationVerifyRequest(BaseModel):
argument_id: str
case_law_id: str = ""
quote: str = ""
citation: str = ""
chair_note: str | None = None
verified: bool = True
attached_id: str = "" # set when the precedent row already exists
@app.post("/api/cases/{case_number}/citation-verification/verify")
async def api_citation_verify(case_number: str, req: CitationVerifyRequest):
"""Verify / un-verify a precedent for a specific argument (the INV-AH gate the
writer respects). Upsert: PATCH an existing attachment, else attach a new one
linked to the argument + corpus ruling and mark it."""
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
try:
if req.attached_id:
row = await db.set_case_precedent_verified(
UUID(req.attached_id), req.verified, req.chair_note)
if not row:
raise HTTPException(404, "שיוך-פסיקה לא נמצא")
return row
if not req.quote.strip() or not req.citation.strip():
raise HTTPException(400, "quote ו-citation חובה לצירוף חדש")
cid = case["id"]
row = await db.create_case_precedent(
case_id=UUID(cid) if isinstance(cid, str) else cid,
quote=req.quote, citation=req.citation,
chair_note=req.chair_note or "",
argument_id=UUID(req.argument_id) if req.argument_id else None,
case_law_id=UUID(req.case_law_id) if req.case_law_id else None,
verified=req.verified,
)
return row
except ValueError:
raise HTTPException(400, "מזהה לא תקין")
@app.delete("/api/precedents/{precedent_id}") @app.delete("/api/precedents/{precedent_id}")
async def api_precedent_delete(precedent_id: str): async def api_precedent_delete(precedent_id: str):
"""Delete a precedent attachment. The archived PDF (if any) stays """Delete a precedent attachment. The archived PDF (if any) stays