Files
legal-ai/mcp-server/src/legal_mcp/services/citation_extractor.py
Chaim ccc5a73bc8
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
feat(x11): treatment-aware citation authority wired into research agents (#154)
The internal citation graph fed only RANKING (raw in-degree), and the per-citation
TREATMENT was never classified — so a precedent distinguished N times got the same
authority boost as one followed N times (INV-COR2 violation), and the signal never
reached the agents' reasoning. Wires the full path:

Phase 1 — scripts/classify_citation_treatments.py: classify each linked edge's
  treatment (followed/distinguished/…) from its match_context via
  corroboration.classify_treatment (Opus 4.8 @ xhigh, local), filling
  precedent_internal_citations.treatment. Idempotent.
Phase 2 — db.refresh_verified_layer: count only NON-negative treatments toward
  verified/cite_count (INV-COR2/COR4). Unclassified counts as neutral-positive so
  the signal degrades gracefully before classification runs.
Phase 3 — db.citation_authority(ids): per-precedent {total, positive, negative,
  unclassified, by_treatment}. Surfaced as `cited_by` in search_precedent_library
  hits and precedent_library_get, and `treatment` per incoming citation.
Phase 4 — legal-researcher/analyst/writer prompts: weigh & ARGUE authority
  ("הלכה שאומצה ב-N החלטות ועדת-ערר"), flag distinguished/overruled, never invent
  the count (INV-AH; writer is read-only of the analyst).

Auto-approval stays kill-switched off (chair gate preserved, INV-G10). No schema
change (treatment column already existed). Operational: run the classifier +
refresh_verified_layer over the 379 edges, then sync agents across companies.

Invariants: G2 (one classifier + one authority query, reused), INV-COR2/COR3/COR4
(negative never corroborates; point-specific; ≥N), INV-G10 (no auto-approval),
INV-AH (no invented numbers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:04:04 +00:00

489 lines
19 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""Internal citation graph extractor (TaskMaster #34).
When Daphna (or any other internal_committee chair) cites another committee
decision inside the body of a ruling, she uses fairly stable phrases:
"ונפנה לערר 1110/20 ירושלים שקופה …"
"כפי שקבעתי בערר 1041/24 …"
"בדומה לעמדתי בהחלטה ערר 8048/24 …"
"כפי שנקבע במחוז ת\"א בערר 1234/20 …"
"ראה החלטתי בערר 1015-01-24 …"
This module scans the ``full_text`` of internal-committee ``case_law`` rows,
extracts those citations via regex, tries to link each cited case_number to a
row already in ``case_law`` (any source_kind), and stores the result in
``precedent_internal_citations``. Unresolved citations are kept with
``cited_case_law_id = NULL`` so the chair can see what's missing from the
corpus (and ``search_internal_decisions`` can surface "cited but absent" gaps).
The result is a *citation graph* that downstream tools (search, researcher
agent) can join on to surface "decisions cited by this one" alongside
keyword/semantic hits — without re-running an LLM on every query.
Patterns are *intentionally* permissive: we accept stray Hebrew quote marks
(both straight ``"`` and curly ``״``), optional district parens, and several
trigger phrases. False positives are de-duplicated downstream by the
``UNIQUE (source_case_law_id, cited_case_number)`` constraint and by case-
number normalization (see ``_normalize_case_number``).
"""
from __future__ import annotations
import logging
import re
from typing import Iterator
from uuid import UUID
from legal_mcp.services import db
logger = logging.getLogger(__name__)
# ── Patterns ─────────────────────────────────────────────────────────
#
# Two pattern families:
# 1. Appeals-committee citations ("ערר" / "בל\"מ") — primary target.
# These are the ones we resolve against ``case_law``.
# 2. Court rulings ("עע\"מ", "בר\"מ", "עמ\"נ", "ע\"א", "בג\"ץ", "רע\"א").
# Stored as unlinked rows by default, so the researcher knows the
# decision quotes a higher court.
#
# Trigger words ("ונפנה", "כפי שקבעתי", "בדומה ל…", "ראה החלטתי",
# "כפי שנקבע") are *optional* — many citations appear without one (Daphna
# often introduces a quote with just "כפי שצוין בערר…"). We therefore
# match the citation core (prefix + number) and capture the surrounding
# sentence as context.
#
# Regex notes:
# * Hebrew gershayim/quotation: both straight (") and curly (״) are
# accepted via the character class [\"״].
# * Case numbers can be NNNN/YY, NNNN-YY, or NNNN-MM-YY (the third form
# is the Nevo "filed" format: 1015-01-24 means file #1015 of Jan 2024).
# * Optional district paren: ערר (ועדות ערר - תכנון ובנייה ירושלים)
# 1110/20 — we allow up to 60 chars of parenthetical content.
# * \b doesn't behave well with Hebrew, so we anchor by whitespace or
# punctuation lookarounds.
_TRIGGER = (
r"(?:ונפנה\s+ל|"
r"כפי\s+ש(?:קבעתי|נקבע|פסקתי)\s+ב|"
r"בדומה\s+ל(?:עמדתי\s+ב)?|"
r"ראה\s+(?:את\s+)?(?:החלטתי\s+ב|פסיקת\s+ה?ועדה\s+ב)?|"
r"בעניין\s+|"
r"בהחלטת(?:י|ה|נו)?\s+ב?)?"
)
# Optional district / committee parenthetical between the prefix and the
# case number. Matches things like "(ועדות ערר - תכנון ובנייה ירושלים)"
# or "(ירושלים)" or "(מרכז)". Up to 80 chars to be safe. Required actual
# parentheses (the `\(` and `\)` are NOT optional) — otherwise the regex
# greedily absorbs the next sentence's content and skips intermediate
# citations like "ראה גם ערר 1041/24 …\nכפי שקבעתי בערר (…) 1110/20".
_DISTRICT_PAREN = r"(?:\s*\([^)\n]{0,80}\)\s*)?"
# Case-number core: 3-5 digits, optional separator and 2-4 digits (and
# optional third group for the NNNN-MM-YY format).
_NUM_RX = r"(\d{3,5}(?:[-/]\d{2,4}(?:[-/]\d{2,4})?)?)"
_PATTERNS = [
# 1. Appeals-committee — ערר / בל"מ
(
"appeals_committee",
re.compile(
_TRIGGER
+ r"(ערר|בל[\"״]מ)"
+ _DISTRICT_PAREN
+ r"\s*"
+ _NUM_RX,
re.UNICODE,
),
),
# 2. Higher courts — עע"מ, בר"מ, עמ"נ, ע"א, בג"ץ, רע"א, דנ"א, בש"א
(
"court_ruling",
re.compile(
_TRIGGER
+ r"(עע[\"״]מ|בר[\"״]מ|עמ[\"״]נ|ע[\"״]א|בג[\"״]ץ|רע[\"״]א|דנ[\"״]א|בש[\"״]א)"
+ r"\s*"
+ _NUM_RX,
re.UNICODE,
),
),
]
# Context window for storing the match (characters before/after).
_CTX_BEFORE = 120
_CTX_AFTER = 240
def _normalize_case_number(raw: str) -> str:
"""Normalize a case-number for matching.
The same case can appear in the corpus as "1110/20", "1110-20",
"ערר 1110/20", "1110-01-20" — different rules for the third form,
which is the Nevo file format. We canonicalize by:
* stripping non-digit/separator chars
* unifying "/""-"
* lowercasing
The result is used only for matching, never for display.
"""
cleaned = re.sub(r"[^\d/\-]", "", raw or "")
return cleaned.replace("/", "-").strip("-")
def extract_citations_from_text(text: str) -> Iterator[dict]:
"""Yield citation dicts extracted from ``text``.
Each dict has:
prefix: matched prefix (ערר / בל\"מ / עע\"מ / …)
case_number: raw number as captured
case_number_norm: normalized (slashes → dashes, digits only)
raw: the full matched span
context: ±300 chars surrounding the match (whitespace normalized)
pattern_kind: 'appeals_committee' or 'court_ruling'
"""
if not text:
return
seen: set[tuple[str, str]] = set()
for kind, pattern in _PATTERNS:
for m in pattern.finditer(text):
# The `_TRIGGER` is wrapped in (?:...) so it does not add a
# capture group; group(1) is the prefix, group(2) is the number.
prefix = (m.group(1) or "").strip()
number = (m.group(2) or "").strip()
if not prefix or not number:
continue
norm = _normalize_case_number(number)
if not norm:
continue
key = (kind, norm)
if key in seen:
continue
seen.add(key)
start = max(0, m.start() - _CTX_BEFORE)
end = min(len(text), m.end() + _CTX_AFTER)
context = text[start:end].replace("\n", " ").strip()
context = re.sub(r"\s+", " ", context)
yield {
"prefix": prefix,
"case_number": number,
"case_number_norm": norm,
"raw": m.group(0).strip(),
"context": context[:1000],
"pattern_kind": kind,
}
async def _resolve_case_law_id(case_number_norm: str) -> UUID | None:
"""Try to resolve a normalized citation to an existing case_law row.
Strategy:
1. Exact match on normalized case_number column (after rewriting
existing case_numbers the same way).
2. Substring match — the corpus often stores the full Nevo header
("ערר (‏ועדות ערר - תכנון ובנייה ירושלים‏) 1110/20 …"), so we
search by ``case_number ILIKE '%1110/20%' OR '%1110-20%'``.
Returns None if no row matches.
"""
if not case_number_norm:
return None
pool = await db.get_pool()
# Build the two raw forms (with slash and with dash) for substring match.
parts = case_number_norm.split("-")
if len(parts) >= 2:
slash_form = "/".join(parts[:2]) if len(parts) == 2 else parts[0] + "/" + parts[-1]
else:
slash_form = case_number_norm
dash_form = case_number_norm
async with pool.acquire() as conn:
# Substring match on either form (covers full Nevo headers and short forms).
row = await conn.fetchrow(
"""
SELECT id FROM case_law
WHERE case_number ILIKE $1 OR case_number ILIKE $2
ORDER BY (source_kind = 'internal_committee') DESC,
LENGTH(case_number) ASC
LIMIT 1
""",
f"%{slash_form}%",
f"%{dash_form}%",
)
return UUID(str(row["id"])) if row else None
async def extract_and_store(case_law_id: UUID) -> dict:
"""Extract citations from a single ``case_law`` row's ``full_text``,
resolve them against the corpus, and INSERT into
``precedent_internal_citations`` (ON CONFLICT DO NOTHING).
Returns: {extracted: N, linked: M, new: K, skipped: S}
extracted — total distinct citations found in the text
linked — how many resolved to an existing case_law row
new — rows actually inserted (not pre-existing)
skipped — citations skipped (self-citation, already stored)
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, case_number, full_text FROM case_law WHERE id = $1",
case_law_id,
)
if not row:
return {"extracted": 0, "linked": 0, "new": 0, "skipped": 0, "error": "not_found"}
# A citation counts as the DECIDING body's reliance ONLY when it appears in
# the discussion/ruling sections — never where a party cites a ruling in its
# own arguments (block ז). Reuse the halacha extractor's section selection
# (G2: a single definition of "reasoning sections" — legal_analysis/ruling/
# conclusion + the discussion-anchor, excluding facts/claims/intro) so a
# ruling cited only in appellant_claims/parties_claims is never attributed to
# the chair. Fall back to full_text only for un-chunked rows.
from legal_mcp.services import halacha_extractor as _hx
disc_chunks, _used_fallback = await _hx._select_extractable_chunks(case_law_id)
text = "\n".join((c.get("content") or "") for c in disc_chunks).strip()
if not text:
text = row["full_text"] or ""
own_norm = _normalize_case_number(row["case_number"] or "")
extracted = 0
linked = 0
new_count = 0
skipped = 0
for cit in extract_citations_from_text(text):
extracted += 1
if cit["case_number_norm"] == own_norm:
# Self-citation (e.g. document headers repeating the case number).
skipped += 1
continue
cited_id = await _resolve_case_law_id(cit["case_number_norm"])
if cited_id is not None and cited_id == case_law_id:
skipped += 1
continue
if cited_id is not None:
linked += 1
async with pool.acquire() as conn:
result = await conn.execute(
"""
INSERT INTO precedent_internal_citations (
source_case_law_id, cited_case_number, cited_case_law_id,
match_context, match_pattern, confidence
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (source_case_law_id, cited_case_number) DO NOTHING
""",
case_law_id,
f"{cit['prefix']} {cit['case_number']}",
cited_id,
cit["context"],
cit["pattern_kind"],
0.90 if cited_id is not None else 0.75,
)
# asyncpg execute returns 'INSERT 0 N' — N is rows inserted.
try:
n_inserted = int(result.split()[-1])
except (ValueError, IndexError):
n_inserted = 0
if n_inserted == 1:
new_count += 1
else:
skipped += 1
return {
"extracted": extracted,
"linked": linked,
"new": new_count,
"skipped": skipped,
}
async def extract_all_internal_committee(
chair_name_filter: str = "",
limit: int = 0,
) -> dict:
"""Run extraction over every internal-committee row in ``case_law``.
Args:
chair_name_filter: if non-empty, restrict to rows where chair_name
matches (exact match). Useful for running on Daphna only.
limit: hard cap on number of rows processed (0 = no cap).
Returns: summary dict with per-row counts and aggregate totals.
"""
pool = await db.get_pool()
conditions = ["source_kind = 'internal_committee'", "full_text <> ''"]
params: list = []
if chair_name_filter:
conditions.append("chair_name = $1")
params.append(chair_name_filter)
where = " WHERE " + " AND ".join(conditions)
limit_clause = f" LIMIT {int(limit)}" if limit and limit > 0 else ""
sql = f"SELECT id, case_number FROM case_law{where} ORDER BY created_at{limit_clause}"
async with pool.acquire() as conn:
rows = await conn.fetch(sql, *params)
totals = {
"processed": 0,
"extracted": 0,
"linked": 0,
"new": 0,
"skipped": 0,
"failed": 0,
"chair_name_filter": chair_name_filter,
"row_count": len(rows),
}
for r in rows:
try:
stats = await extract_and_store(UUID(str(r["id"])))
totals["processed"] += 1
totals["extracted"] += stats.get("extracted", 0)
totals["linked"] += stats.get("linked", 0)
totals["new"] += stats.get("new", 0)
totals["skipped"] += stats.get("skipped", 0)
except Exception as e:
logger.exception("citation extraction failed for %s: %s", r["case_number"], e)
totals["failed"] += 1
return totals
async def list_citations_for_case_law(
case_law_id: UUID,
linked_only: bool = False,
) -> list[dict]:
"""Return all citations *from* the given case_law row (outgoing edges)."""
pool = await db.get_pool()
where = "pic.source_case_law_id = $1"
if linked_only:
where += " AND pic.cited_case_law_id IS NOT NULL"
sql = f"""
SELECT pic.id::text AS id,
pic.cited_case_number,
pic.cited_case_law_id::text AS cited_case_law_id,
pic.match_context,
pic.match_pattern,
pic.confidence::float AS confidence,
pic.created_at,
cl.case_number AS target_case_number,
cl.case_name AS target_case_name,
cl.chair_name AS target_chair_name,
cl.district AS target_district
FROM precedent_internal_citations pic
LEFT JOIN case_law cl ON cl.id = pic.cited_case_law_id
WHERE {where}
ORDER BY pic.created_at
"""
async with pool.acquire() as conn:
rows = await conn.fetch(sql, case_law_id)
return [dict(r) for r in rows]
async def list_citations_to_case_law(case_law_id: UUID) -> list[dict]:
"""Return all citations *to* the given case_law row (incoming edges).
Useful for "which Daphna decisions cite this ruling?" queries.
"""
pool = await db.get_pool()
sql = """
SELECT pic.id::text AS id,
pic.source_case_law_id::text AS source_case_law_id,
pic.cited_case_number,
pic.match_context,
pic.match_pattern,
pic.treatment,
pic.confidence::float AS confidence,
pic.created_at,
cl.case_number AS source_case_number,
cl.case_name AS source_case_name,
cl.chair_name AS source_chair_name,
cl.district AS source_district,
cl.precedent_level AS source_precedent_level,
cl.court AS source_court,
cl.date AS source_date
FROM precedent_internal_citations pic
JOIN case_law cl ON cl.id = pic.source_case_law_id
WHERE pic.cited_case_law_id = $1
ORDER BY pic.created_at DESC
"""
async with pool.acquire() as conn:
rows = await conn.fetch(sql, case_law_id)
return [dict(r) for r in rows]
async def get_cited_case_law_ids(source_case_law_ids: list[UUID]) -> dict[str, list[str]]:
"""Bulk-fetch outgoing citation case_law_ids for the given source rows.
Returns: {source_case_law_id (str): [cited_case_law_id (str), ...]} —
only including linked (resolved) citations.
Used by search.search_internal_decisions(include_cited_by=True) to
expand result sets with the precedents the hits themselves cite,
without running a separate roundtrip per row.
"""
if not source_case_law_ids:
return {}
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT source_case_law_id::text AS source_id,
cited_case_law_id::text AS cited_id
FROM precedent_internal_citations
WHERE source_case_law_id = ANY($1::uuid[])
AND cited_case_law_id IS NOT NULL
""",
list(source_case_law_ids),
)
out: dict[str, list[str]] = {}
for r in rows:
out.setdefault(r["source_id"], []).append(r["cited_id"])
return out
async def relink_orphan_citations(case_law_id: "UUID | None" = None) -> int:
"""Back-fill ``cited_case_law_id`` on orphan citation edges that now resolve.
Citations are resolved to a corpus row only at extraction time (see
``extract_and_store``). When a cited ruling is uploaded LATER, its existing
orphan edges (``cited_case_law_id IS NULL``) are never re-resolved, so the
citation graph under-counts real connectivity. Call on every precedent
upload (``case_law_id`` set → link only edges that resolve to that row) or as
a one-off sweep (``case_law_id=None`` → re-resolve every orphan). Reuses the
canonical resolver ``_resolve_case_law_id`` (no parallel matching logic, G2);
idempotent — a second run links nothing new.
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
orphans = await conn.fetch(
"SELECT DISTINCT cited_case_number FROM precedent_internal_citations "
"WHERE cited_case_law_id IS NULL AND coalesce(cited_case_number, '') <> ''"
)
linked = 0
for r in orphans:
cited = await _resolve_case_law_id(_normalize_case_number(r["cited_case_number"]))
if cited is None:
continue
if case_law_id is not None and cited != case_law_id:
continue
async with pool.acquire() as conn:
res = await conn.execute(
"UPDATE precedent_internal_citations "
"SET cited_case_law_id = $1, confidence = GREATEST(confidence, 0.90) "
"WHERE cited_case_law_id IS NULL AND cited_case_number = $2",
cited, r["cited_case_number"],
)
try:
linked += int(res.split()[-1])
except (ValueError, IndexError):
pass
return linked