Files
legal-ai/docs/superpowers/plans/2026-05-31-x11-citation-corroboration-phase1.md
2026-05-31 18:50:50 +00:00

22 KiB
Raw Blame History

X11 Citation Corroboration — Phase 1 (Signal) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the citation-corroboration signal — classify each incoming citation's treatment, link it to the specific halacha it supports, and expose per-halacha corroboration — without yet changing the approval gate (that is Phase 2).

Architecture: A new schema version (V24) adds a treatment column to precedent_internal_citations and a halacha_citation_corroboration link table. A new service corroboration.py does three deterministic-or-LLM steps: (1) classify treatment of a citing passage via Opus 4.8 (INV-COR2), (2) match a citing passage to one halacha via pgvector cosine over halachot.embedding (INV-COR3), (3) aggregate distinct positive citations per halacha (INV-COR4). A read-only MCP tool reports the result with full provenance (INV-COR6). Auto-approval (INV-COR4/G10) and enrichment are Phase 2 (separate plan).

Tech Stack: Python 3.12, asyncpg + pgvector, FastMCP (@mcp.tool()), claude_session.query_json(model=, effort=), embeddings.embed_texts, pytest (offline deterministic tests mirroring tests/test_fu2b_reconcile.py).

Spec: docs/spec/X11-citation-corroboration.md (INV-COR1COR6). Prerequisite (clean identity graph) is done (Shafer merge + corpus scan).


File Structure

  • Create: mcp-server/src/legal_mcp/services/corroboration.py — the corroboration service (classify · match · aggregate · persist). One responsibility: turn the citation graph into per-halacha corroboration rows.
  • Modify: mcp-server/src/legal_mcp/services/db.py — add SCHEMA_V24_SQL + helpers store_corroboration, list_corroboration_for_halacha.
  • Modify: mcp-server/src/legal_mcp/server.py — register read-only MCP tool halacha_corroboration.
  • Create: mcp-server/tests/test_corroboration.py — offline deterministic tests (treatment parse, aggregation, independence rule).

Treatment vocabulary (INV-COR2): positive = {followed, explained}; negative = {distinguished, criticized, questioned, overruled}; neutral = {mentioned}. Defined once in corroboration.py.


Files:

  • Modify: mcp-server/src/legal_mcp/services/db.py (add SCHEMA_V24_SQL constant near the other SCHEMA_V23_SQL; register it in _run_schema_migrations, db.py:54-ish)

  • Step 1: Add the schema constant

Add after the SCHEMA_V23_SQL = """...""" block:

SCHEMA_V24_SQL = """
-- X11: citation corroboration (treatment + halacha-level link)
ALTER TABLE precedent_internal_citations
    ADD COLUMN IF NOT EXISTS treatment TEXT DEFAULT '';

CREATE TABLE IF NOT EXISTS halacha_citation_corroboration (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    halacha_id UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
    citing_case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
    citing_decision_id UUID REFERENCES decisions(id) ON DELETE SET NULL,
    source_citation_id UUID NOT NULL,      -- the precedent_internal_citations / case_law_citations row
    treatment TEXT NOT NULL,                -- followed/explained/distinguished/criticized/questioned/overruled/mentioned
    match_score NUMERIC(4,3) DEFAULT 0,     -- cosine(context, rule_statement)
    match_context TEXT DEFAULT '',
    created_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE (halacha_id, source_citation_id)
);
CREATE INDEX IF NOT EXISTS idx_hcc_halacha ON halacha_citation_corroboration(halacha_id);
"""
  • Step 2: Register it in _run_schema_migrations

In _run_schema_migrations (db.py), after await conn.execute(SCHEMA_V23_SQL) add:

        await conn.execute(SCHEMA_V24_SQL)

And update the log line to "Database schema initialized (v1-v24)".

  • Step 3: Apply + verify against the dev DB

Run (from mcp-server/):

DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data .venv/bin/python -c "
import asyncio; from legal_mcp.services import db
async def m():
    pool=await db.get_pool()
    cols=await pool.fetch(\"SELECT column_name FROM information_schema.columns WHERE table_name='precedent_internal_citations' AND column_name='treatment'\")
    t=await pool.fetchval(\"SELECT to_regclass('halacha_citation_corroboration')\")
    print('treatment col:', bool(cols), '| table:', t)
asyncio.run(m())"

Expected: treatment col: True | table: halacha_citation_corroboration

  • Step 4: Commit
git add mcp-server/src/legal_mcp/services/db.py
git commit -m "feat(db): V24 — citation treatment column + halacha corroboration link table (X11)"

Task 2: Treatment classifier (deterministic parse, unit-tested)

The LLM call classifies a citing passage; the parse/validate of its output is the deterministic, unit-tested unit (mirrors halacha_extractor._coerce_halacha).

Files:

  • Create: mcp-server/src/legal_mcp/services/corroboration.py

  • Test: mcp-server/tests/test_corroboration.py

  • Step 1: Write the failing test

# tests/test_corroboration.py
from __future__ import annotations
import pytest
from legal_mcp.services import corroboration as cor

@pytest.mark.parametrize("raw,expected", [
    ({"treatment": "followed"}, "followed"),
    ({"treatment": "OVERRULED"}, "overruled"),     # case-insensitive
    ({"treatment": "bananas"}, "mentioned"),        # unknown -> neutral default
    ({}, "mentioned"),                               # missing -> neutral default
])
def test_coerce_treatment(raw, expected):
    assert cor._coerce_treatment(raw) == expected

def test_treatment_polarity():
    assert cor.is_positive("followed") and cor.is_positive("explained")
    assert cor.is_negative("distinguished") and cor.is_negative("overruled")
    assert not cor.is_positive("mentioned") and not cor.is_negative("mentioned")
  • Step 2: Run test to verify it fails

Run: cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q Expected: FAIL — ModuleNotFoundError: corroboration / _coerce_treatment undefined.

  • Step 3: Write minimal implementation
# src/legal_mcp/services/corroboration.py
"""X11 citation corroboration — classify treatment, match to halacha, aggregate.

Phase 1: builds the SIGNAL only (no approval changes). See docs/spec/X11.
All LLM calls go through the local claude_session bridge (Opus 4.8 @ xhigh),
same architectural rule as the other extractors (local MCP only).
"""
from __future__ import annotations
import logging
from legal_mcp import config
from legal_mcp.config import parse_llm_json
from legal_mcp.services import claude_session

logger = logging.getLogger(__name__)

TREATMENT_POSITIVE = {"followed", "explained"}
TREATMENT_NEGATIVE = {"distinguished", "criticized", "questioned", "overruled"}
TREATMENT_NEUTRAL = {"mentioned"}
_VALID_TREATMENT = TREATMENT_POSITIVE | TREATMENT_NEGATIVE | TREATMENT_NEUTRAL

def is_positive(t: str) -> bool: return t in TREATMENT_POSITIVE
def is_negative(t: str) -> bool: return t in TREATMENT_NEGATIVE

def _coerce_treatment(raw: dict) -> str:
    t = str((raw or {}).get("treatment", "")).strip().lower()
    return t if t in _VALID_TREATMENT else "mentioned"
  • Step 4: Run test to verify it passes

Run: cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q Expected: PASS (3 params + polarity).

  • Step 5: Add the LLM classifier call (integration, not unit-tested)

Append to corroboration.py:

_TREATMENT_PROMPT = """אתה משפטן בכיר. נתון ציטוט של פסק/החלטה קודמים בתוך החלטה מאוחרת.
סווג כיצד ההחלטה המאוחרת **מטפלת** בתקדים המצוטט, לפי אחת מהקטגוריות:
- followed   — אימצה והחילה את ההלכה.
- explained  — הסבירה/הזכירה בלי לחלוק.
- distinguished — אבחנה (קבעה שלא חל בנסיבות).
- criticized — מתחה ביקורת בלי לבטל.
- questioned — הטילה ספק.
- overruled  — דחתה/ביטלה את ההלכה.
- mentioned  — אזכור-אגב בלי טיפול.
החזר JSON בלבד: {"treatment": "<קטגוריה>"}.
"""

async def classify_treatment(cited_citation: str, context: str) -> str:
    """Return one treatment label for how `context` treats `cited_citation`."""
    user = f"תקדים מצוטט: {cited_citation}\n\n--- ההקשר המצטט ---\n{context}\n--- סוף ---"
    try:
        result = await claude_session.query_json(
            user, system=_TREATMENT_PROMPT,
            model=config.HALACHA_EXTRACT_MODEL or None,
            effort=config.HALACHA_EXTRACT_EFFORT or None,
        )
    except Exception as e:
        logger.warning("classify_treatment failed: %s", e)
        return "mentioned"
    return _coerce_treatment(result if isinstance(result, dict) else {})
  • Step 6: Commit
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/tests/test_corroboration.py
git commit -m "feat(corroboration): treatment classifier + polarity (INV-COR2, X11)"

Task 3: Halacha matcher — pgvector cosine, threshold (INV-COR3)

The matcher links a citing passage to the single best halacha of the cited precedent. The threshold decision is the deterministic unit; the pgvector lookup is integration.

Files:

  • Modify: mcp-server/src/legal_mcp/services/corroboration.py

  • Modify: mcp-server/src/legal_mcp/services/db.py (add nearest_halacha_for_vector)

  • Test: mcp-server/tests/test_corroboration.py

  • Step 1: Write the failing test (threshold gate is the unit)

Append to tests/test_corroboration.py:

def test_match_accepts_above_threshold():
    # (halacha_id, similarity) above floor -> accepted
    assert cor.accept_match(("h1", 0.62), floor=0.50) == "h1"

def test_match_rejects_below_threshold():
    # below floor -> None (INV-COR3: don't attach to a different legal point)
    assert cor.accept_match(("h1", 0.41), floor=0.50) is None

def test_match_rejects_empty():
    assert cor.accept_match(None, floor=0.50) is None
  • Step 2: Run test to verify it fails

Run: cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py::test_match_accepts_above_threshold -q Expected: FAIL — accept_match undefined.

  • Step 3: Implement the threshold gate + env floor

Add to config.py (near HALACHA_EXTRACT_*):

HALACHA_CORROBORATION_MATCH_FLOOR = float(os.environ.get("HALACHA_CORROBORATION_MATCH_FLOOR", "0.50"))
HALACHA_CORROBORATION_MIN_CITES = int(os.environ.get("HALACHA_CORROBORATION_MIN_CITES", "2"))

Add to corroboration.py:

def accept_match(best: tuple[str, float] | None, floor: float = config.HALACHA_CORROBORATION_MATCH_FLOOR) -> str | None:
    """Return the halacha_id iff similarity clears the floor (INV-COR3)."""
    if not best:
        return None
    halacha_id, sim = best
    return halacha_id if sim >= floor else None
  • Step 4: Run test to verify it passes

Run: cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q Expected: PASS (all, incl. Task 2).

  • Step 5: Add the pgvector lookup (integration)

Add to db.py:

async def nearest_halacha_for_vector(case_law_id: UUID, vec: list[float]) -> tuple[str, float] | None:
    """Best-matching halacha of `case_law_id` for a context embedding (cosine)."""
    pool = await get_pool()
    row = await pool.fetchrow(
        "SELECT id::text AS id, 1 - (embedding <=> $2) AS sim "
        "FROM halachot WHERE case_law_id = $1 AND embedding IS NOT NULL "
        "ORDER BY embedding <=> $2 LIMIT 1",
        case_law_id, vec,
    )
    return (row["id"], float(row["sim"])) if row else None
  • Step 6: Commit
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/src/legal_mcp/services/db.py mcp-server/src/legal_mcp/config.py mcp-server/tests/test_corroboration.py
git commit -m "feat(corroboration): halacha matcher + cosine threshold (INV-COR3, X11)"

Task 4: Aggregator — distinct positive citations, negative-flag (INV-COR4)

Pure function: given per-citation results for one halacha, count distinct positive citing sources and detect any negative treatment.

Files:

  • Modify: mcp-server/src/legal_mcp/services/corroboration.py

  • Test: mcp-server/tests/test_corroboration.py

  • Step 1: Write the failing test

Append to tests/test_corroboration.py:

def _link(src, treatment):
    return {"source_id": src, "treatment": treatment}

def test_aggregate_counts_distinct_positive():
    links = [_link("d1","followed"), _link("d1","explained"), _link("d2","followed")]
    agg = cor.aggregate(links, min_cites=2)
    assert agg["positive_sources"] == 2          # d1 counted once (INV-COR4 independence)
    assert agg["has_negative"] is False
    assert agg["corroborated"] is True

def test_aggregate_negative_blocks():
    links = [_link("d1","followed"), _link("d2","followed"), _link("d3","distinguished")]
    agg = cor.aggregate(links, min_cites=2)
    assert agg["has_negative"] is True
    assert agg["corroborated"] is False          # any negative -> not corroborated (INV-COR2)

def test_aggregate_below_threshold():
    agg = cor.aggregate([_link("d1","followed")], min_cites=2)
    assert agg["corroborated"] is False           # single source insufficient (INV-COR4)
  • Step 2: Run test to verify it fails

Run: cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py::test_aggregate_counts_distinct_positive -q Expected: FAIL — aggregate undefined.

  • Step 3: Implement

Add to corroboration.py:

def aggregate(links: list[dict], min_cites: int = config.HALACHA_CORROBORATION_MIN_CITES) -> dict:
    """Aggregate per-halacha corroboration (INV-COR4/COR2).

    links: [{"source_id": str, "treatment": str}, ...] already matched to ONE halacha.
    positive_sources = count of DISTINCT source_id whose treatment is positive.
    has_negative = any negative treatment present.
    corroborated = positive_sources >= min_cites AND not has_negative.
    """
    positive = {l["source_id"] for l in links if is_positive(l["treatment"])}
    has_negative = any(is_negative(l["treatment"]) for l in links)
    return {
        "positive_sources": len(positive),
        "has_negative": has_negative,
        "corroborated": len(positive) >= min_cites and not has_negative,
    }
  • Step 4: Run test to verify it passes

Run: cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q Expected: PASS (all).

  • Step 5: Commit
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/tests/test_corroboration.py
git commit -m "feat(corroboration): aggregator — distinct positive + negative-flag (INV-COR4, X11)"

Task 5: Orchestration + persistence (build the signal for one precedent)

Wires Tasks 24 over a precedent's incoming citations and stores halacha_citation_corroboration rows (INV-COR6 provenance). Integration (no new offline unit).

Files:

  • Modify: mcp-server/src/legal_mcp/services/corroboration.py

  • Modify: mcp-server/src/legal_mcp/services/db.py (add store_corroboration, incoming_citations_for_precedent)

  • Step 1: Add DB helpers

# db.py
async def incoming_citations_for_precedent(case_law_id: UUID) -> list[dict]:
    """All incoming citations (both graphs) with their context + source id."""
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT id::text AS source_id, source_case_law_id::text AS citing_case_law_id, "
        "       NULL::text AS citing_decision_id, match_context AS context "
        "FROM precedent_internal_citations WHERE cited_case_law_id = $1 "
        "UNION ALL "
        "SELECT id::text, NULL, decision_id::text, context_text "
        "FROM case_law_citations WHERE case_law_id = $1",
        case_law_id,
    )
    return [dict(r) for r in rows]

async def store_corroboration(halacha_id: str, source_id: str, citing_case_law_id, citing_decision_id, treatment: str, score: float, context: str) -> None:
    pool = await get_pool()
    await pool.execute(
        "INSERT INTO halacha_citation_corroboration "
        "(halacha_id, citing_case_law_id, citing_decision_id, source_citation_id, treatment, match_score, match_context) "
        "VALUES ($1,$2,$3,$4,$5,$6,$7) "
        "ON CONFLICT (halacha_id, source_citation_id) DO UPDATE SET "
        "treatment=EXCLUDED.treatment, match_score=EXCLUDED.match_score",
        halacha_id, citing_case_law_id, citing_decision_id, source_id, treatment, score, context,
    )
  • Step 2: Add the orchestrator
# corroboration.py
from uuid import UUID
from legal_mcp.services import db, embeddings

async def build_for_precedent(case_law_id: str | UUID) -> dict:
    """For one cited precedent: classify+match+store each incoming citation. Idempotent."""
    if isinstance(case_law_id, str):
        case_law_id = UUID(case_law_id)
    cits = await db.incoming_citations_for_precedent(case_law_id)
    linked = 0
    for c in cits:
        ctx = (c.get("context") or "").strip()
        if not ctx:
            continue
        vecs = await embeddings.embed_texts([ctx], input_type="query")
        best = await db.nearest_halacha_for_vector(case_law_id, vecs[0])
        halacha_id = accept_match(best)
        if not halacha_id:
            continue
        treatment = await classify_treatment(c.get("citing_case_law_id") or c.get("citing_decision_id") or "", ctx)
        await db.store_corroboration(
            halacha_id, c["source_id"],
            c.get("citing_case_law_id"), c.get("citing_decision_id"),
            treatment, best[1], ctx,
        )
        linked += 1
    return {"citations": len(cits), "linked": linked}
  • Step 3: Integration smoke-test on Shafer (the fixed precedent, 7 citations)

Run (from mcp-server/):

DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data .venv/bin/python -c "
import asyncio; from legal_mcp.services import corroboration as cor
print(asyncio.run(cor.build_for_precedent('9024da7b-f408-4b6f-808f-c514a83728e4')))"

Expected: {'citations': 7, 'linked': <0..7>} and no exception. Inspect halacha_citation_corroboration rows for treatment/score sanity.

  • Step 4: Commit
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/src/legal_mcp/services/db.py
git commit -m "feat(corroboration): orchestrator + persistence over both citation graphs (X11)"

Task 6: Read-only MCP tool halacha_corroboration

Exposes per-halacha corroboration + provenance to the chair/agents (INV-COR6). No approval change — reporting only.

Files:

  • Modify: mcp-server/src/legal_mcp/services/db.py (add list_corroboration_for_halacha)

  • Modify: mcp-server/src/legal_mcp/server.py (register tool)

  • Step 1: Add the DB read

# db.py
async def list_corroboration_for_halacha(halacha_id: UUID) -> list[dict]:
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT treatment, match_score, match_context, citing_case_law_id::text, "
        "       citing_decision_id::text, created_at "
        "FROM halacha_citation_corroboration WHERE halacha_id = $1 "
        "ORDER BY match_score DESC", halacha_id,
    )
    return [dict(r) for r in rows]
  • Step 2: Register the MCP tool (copy an existing @mcp.tool() idiom in server.py)
@mcp.tool()
async def halacha_corroboration(halacha_id: str) -> dict:
    """החזר את ה-corroboration של הלכה: הציטוטים שמתקפים אותה, הטיפול, וסיכום (X11, read-only)."""
    from uuid import UUID
    from legal_mcp.services import corroboration as cor, db
    links = await db.list_corroboration_for_halacha(UUID(halacha_id))
    agg = cor.aggregate(
        [{"source_id": (l["citing_case_law_id"] or l["citing_decision_id"] or ""), "treatment": l["treatment"]} for l in links]
    )
    return {"halacha_id": halacha_id, "summary": agg, "citations": links}
  • Step 3: Verify the tool loads (restart MCP server is required to pick up new tools)

Run: cd mcp-server && .venv/bin/python -c "from legal_mcp import server; print('halacha_corroboration' in [t.name for t in server.mcp._tool_manager.list_tools()])" Expected: True (adjust the introspection call to the FastMCP version if needed; the point is the import succeeds and the tool registers).

  • Step 4: Commit
git add mcp-server/src/legal_mcp/services/db.py mcp-server/src/legal_mcp/server.py
git commit -m "feat(mcp): halacha_corroboration read-only tool (INV-COR6, X11)"

Out of scope (Phase 2 — separate plan)

  • Auto-approval wiring (INV-COR4/COR5 + G10 behavior): make corroborated == True set halachot.review_status='approved' with reviewer='corroborated (≥N judicial citations)', leaving the uncorroborated/negative tail in the chair gate. Sensitive — gated on Dafna validating the signal from Phase 1 first.
  • Enrichment (INV-COR3 secondary): sharpen rule_statement from citing framing.
  • Backfill: run build_for_precedent across all precedents with halachot + incoming citations.
  • Treatment backfill of case_law_citations.citation_type (currently default 'support').

Self-Review

Spec coverage: INV-COR2 (Task 2 classifier + polarity), INV-COR3 (Task 3 match floor + pgvector), INV-COR4 (Task 4 distinct positive + min_cites), INV-COR6 (Task 5 store provenance + Task 6 report). INV-COR1 (principle, no code) and INV-COR5 (chair-gate preserved) are honored by not changing approval in Phase 1 — explicitly deferred to Phase 2. ✔ Placeholders: none — every code step has concrete content; the LLM-dependent steps carry the exact prompt + I/O contract; tuning values (MATCH_FLOOR, MIN_CITES) are real env-defaults, not TBDs. Type consistency: accept_match returns halacha_id|None; aggregate consumes [{"source_id","treatment"}]; _coerce_treatment/is_positive/is_negative share the _VALID_TREATMENT sets. build_for_precedent uses accept_match + classify_treatment + store_corroboration with matching signatures. ✔