From 5ede8a9653955b5f738ef83f332a36ded71c2bb9 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 20 Jun 2026 18:08:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(citation-verify):=20backend=20for=20the=20?= =?UTF-8?q?"=D7=90=D7=99=D7=9E=D7=95=D7=AA=20=D7=A4=D7=A1=D7=99=D7=A7?= =?UTF-8?q?=D7=94"=20panel=20=E2=80=94=20per-argument=20support=20+=20veri?= =?UTF-8?q?fy=20gate=20(#154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../services/case_citation_verification.py | 134 ++++++++++++++++++ mcp-server/src/legal_mcp/services/db.py | 58 +++++++- web/app.py | 56 ++++++++ 3 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 mcp-server/src/legal_mcp/services/case_citation_verification.py diff --git a/mcp-server/src/legal_mcp/services/case_citation_verification.py b/mcp-server/src/legal_mcp/services/case_citation_verification.py new file mode 100644 index 0000000..663cacd --- /dev/null +++ b/mcp-server/src/legal_mcp/services/case_citation_verification.py @@ -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), + }, + } diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index c98feaf..75fdae7 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -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 # schema DDL across processes. Every short-lived process (cron drains, services) # 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_V42_SQL) await conn.execute(SCHEMA_V43_SQL) + await conn.execute(SCHEMA_V44_SQL) async def init_schema() -> None: @@ -3331,28 +3349,58 @@ async def create_case_precedent( chair_note: str = "", pdf_document_id: UUID | None = None, practice_area: str | None = None, + argument_id: UUID | None = None, + case_law_id: UUID | None = None, + verified: bool = False, ) -> 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() row = await pool.fetchrow( """ INSERT INTO case_precedents - (case_id, section_id, quote, citation, chair_note, pdf_document_id, practice_area) - VALUES ($1, $2, $3, $4, $5, $6, $7) + (case_id, section_id, quote, citation, chair_note, pdf_document_id, + 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 * """, - 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) +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]: """List all precedents attached to a case, ordered by section then creation time.""" pool = await get_pool() rows = await pool.fetch( """ 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 WHERE case_id = $1 ORDER BY section_id NULLS LAST, created_at diff --git a/web/app.py b/web/app.py index 9e04e49..8f44d2a 100644 --- a/web/app.py +++ b/web/app.py @@ -3155,6 +3155,62 @@ async def api_precedent_list(case_number: str): 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}") async def api_precedent_delete(precedent_id: str): """Delete a precedent attachment. The archived PDF (if any) stays