From f7bf437f6765f069a062293f6d13f3cab1fd74bb Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 20 Jun 2026 11:46:47 +0000 Subject: [PATCH] fix(corpus): re-link orphan citations when a cited ruling is uploaded (G1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit precedent_internal_citations resolves cited_case_law_id only at extraction time. When a decision cites a ruling that isn't in the corpus yet, the edge is stored with cited_case_law_id=NULL. The ruling is often uploaded days later — but the orphan edge was never re-resolved, so the citation graph permanently under-counted real connectivity (same stale-derived-value pattern as the searchable flag). Worse, external rulings store case_number WITHOUT the court prefix ("3213/97") while citations carry it ("ע\"א 3213/97"), so a large share of "missing" citations were actually present-but-unlinked. Fix: - new citation_extractor.relink_orphan_citations(case_law_id=None): re-resolves orphan edges via the canonical _resolve_case_law_id (no parallel matching, G2); scoped to one row, or full sweep when None. Idempotent. - ingest_document calls it on every upload (non-fatal) so the graph self-heals. Existing backlog already re-linked via a one-off sweep against the live DB: linked 128 edges → in-corpus distinct rulings 67→178, unlinked distinct 262→143. So real "cited but not uploaded" ≈ 143, not the 262 the stale graph implied. Invariants: G1 (re-resolve the derived link at the write) · G2 (single resolver). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../legal_mcp/services/citation_extractor.py | 39 +++++++++++++++++++ mcp-server/src/legal_mcp/services/ingest.py | 11 ++++++ 2 files changed, 50 insertions(+) diff --git a/mcp-server/src/legal_mcp/services/citation_extractor.py b/mcp-server/src/legal_mcp/services/citation_extractor.py index b0d0297..f59688b 100644 --- a/mcp-server/src/legal_mcp/services/citation_extractor.py +++ b/mcp-server/src/legal_mcp/services/citation_extractor.py @@ -432,3 +432,42 @@ async def get_cited_case_law_ids(source_case_law_ids: list[UUID]) -> dict[str, l 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 diff --git a/mcp-server/src/legal_mcp/services/ingest.py b/mcp-server/src/legal_mcp/services/ingest.py index e34a34d..39b5b18 100644 --- a/mcp-server/src/legal_mcp/services/ingest.py +++ b/mcp-server/src/legal_mcp/services/ingest.py @@ -233,6 +233,17 @@ async def ingest_document( await db.request_metadata_extraction(case_law_id) await db.request_halacha_extraction(case_law_id) await db.recompute_searchable(case_law_id) + # Citations resolve to a corpus row only at extraction time. A ruling + # uploaded AFTER it was first cited leaves orphan edges (NULL link), + # so re-link them to this freshly-ingested row now — the citation + # graph self-heals instead of permanently under-counting (G1). Non-fatal. + try: + from legal_mcp.services import citation_extractor as _ce + relinked = await _ce.relink_orphan_citations(case_law_id) + if relinked: + logger.info("relinked %d orphan citation(s) -> %s", relinked, case_law_id) + except Exception as e: # noqa: BLE001 — relink is best-effort + logger.warning("citation relink failed (non-fatal): %s", e) await progress("completed", 100, f"נקלט: {stored_chunks} chunks. חילוץ הלכות ומטא-דאטה ממתינים בתור.")