feat(precedents): איחוד cited_only↔missing_precedents — גזירת פסיקה-חסרה (#143, G2)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s

שתי מערכות מקבילות לאותו מושג ("פסיקה מצוטטת שטקסטה לא נקלט"): טבלת
missing_precedents (תור-רכישה ידני של היו"ר) מול case_law source_kind='cited_only'
(stubs מגרף-הציטוטים/X11). חפיפה≈0 → 31 ה-stubs לא הופיעו ב-/missing-precedents.

הכרעה (G2): missing_precedents = SoT-לתור-יחיד; cited_only = מקור-גילוי נגזר (כמו
יומונים מזינים radar). גוזרים רשומת missing_precedents 'open' לכל stub.

תיקון:
- court_citation.citation_dedup_key — מפתח-dedup **designator-aware**
  (`{designator}|{docket}`). **מתקן פגם בתוכנית-הניתוח:** dedup על מספר-בלבד היה
  ממזג בטעות אותו docket בערכאות שונות (בג"ץ 389/87 ≠ ע"א 389/87; 18 כאלה בקיים).
- סכמה V40: missing_precedents מקבל citation_norm (מפתח-dedup) + discovery_source
  (manual|cited_only|digest|writer) + index. **בלי UNIQUE** — הקורפוס מחזיק
  לגיטימית אותו docket בערכאות שונות; ייחודיות נאכפת designator-aware בנתיב-היצירה.
- create_missing_precedent: מחשב citation_norm בכתיבה (G1), מקבל discovery_source
  + linked_case_law_id. find_missing_precedent_by_citation: dedup דרך citation_norm
  (fallback ל-citation גולמי כשאין מספר).
- scripts/derive_missing_from_cited_only.py: backfill citation_norm ל-291 +
  גזירת 31 (dry-run: 31 ייווצרו, 0 deduped). linked_case_law_id=stub, status=open
  → promote-in-place בהעלאת-טקסט דרך ON CONFLICT הקיים. אידמפוטנטי.

תלוי-הקשר: #140 (הגדרת cited_only). מתואם עם #136 (digest→MP — אותו citation_norm
+ create path). תיקון-נתון יורץ אחרי הפריסה.

בדיקות: test_dedup_key_is_designator_aware (בג"ץ≠ע"א, ערר≠בל"מ, גרסאות-format
מתמזגות). כל 356 עוברות. guards נקיים.

Invariants: G2 (SoT-לתור יחיד, cited_only נגזר), G1 (citation_norm מנורמל בכתיבה),
G3 (idempotent upsert), G10 (שער-העלאה ידני נשמר), G12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 08:56:34 +00:00
parent bea2065640
commit 161e370a4c
5 changed files with 209 additions and 13 deletions

View File

@@ -134,6 +134,32 @@ def case_number_from_citation(citation: str) -> str:
return classify(citation).case_number_norm
def _norm_designator(prefix: str) -> str:
"""Collapse a court/proceeding prefix to a comparison token.
Strips gershayim variants and whitespace so ``עע"מ`` / ``עע״מ`` / ``עעמ``
all map to the same token, while DISTINCT courts stay distinct.
"""
return re.sub(r"[\s\"״׳']", "", prefix or "")
def citation_dedup_key(citation: str) -> str:
"""Designator-aware dedup key for a citation, or ``''`` if no number.
Returns ``f"{designator}|{case_number_norm}"`` — e.g.
``בג"ץ 389/87`` → ``בגץ|389-87`` and ``ע"א 389/87`` → ``עא|389-87`` are
DISTINCT keys, even though both share docket ``389-87``. This is the safe
dedup key for ``missing_precedents`` (#143/#136): deduping on the bare number
alone (``case_number_from_citation``) would WRONGLY MERGE the same docket
across different courts (18 such collisions already exist in the corpus). A
prefix-less citation (bare נט-format / unknown court) yields ``|<number>``.
"""
cit = classify(citation)
if not cit.case_number_norm:
return ""
return f"{_norm_designator(cit.court_prefix)}|{cit.case_number_norm}"
def _split_filed(num_norm: str) -> tuple[str, str, str] | None:
"""Split a normalized NNNNN-MM-YY number into (file, month, year).

View File

@@ -14,7 +14,7 @@ import asyncpg
from pgvector.asyncpg import register_vector
from legal_mcp import config
from legal_mcp.services import halacha_quality
from legal_mcp.services import court_citation, halacha_quality
logger = logging.getLogger(__name__)
@@ -1571,6 +1571,19 @@ SCHEMA_V39_SQL = """
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS parties TEXT DEFAULT '';
"""
# V40 (#143): missing_precedents gains a designator-aware dedup key
# (citation_norm) and a provenance tag (discovery_source: manual | cited_only |
# digest | writer). No UNIQUE constraint — the corpus legitimately holds the same
# docket across different courts (e.g. בג"ץ 389/87 vs ע"א 389/87), so uniqueness
# is enforced designator-aware in the create path, not by a DB constraint that
# would over-merge distinct precedents.
SCHEMA_V40_SQL = """
ALTER TABLE missing_precedents ADD COLUMN IF NOT EXISTS citation_norm TEXT DEFAULT '';
ALTER TABLE missing_precedents ADD COLUMN IF NOT EXISTS discovery_source TEXT DEFAULT 'manual';
CREATE INDEX IF NOT EXISTS idx_missing_precedents_citation_norm
ON missing_precedents(citation_norm);
"""
# Stable, arbitrary key for the session-level advisory lock that serialises
# schema DDL across processes. Every short-lived process (cron drains, services)
@@ -1589,7 +1602,7 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
await _apply_schema_ddl(conn)
finally:
await conn.execute("SELECT pg_advisory_unlock($1)", _MIGRATION_LOCK_KEY)
logger.info("Database schema initialized (v1-v38)")
logger.info("Database schema initialized (v1-v40)")
async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
@@ -1633,6 +1646,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
await conn.execute(SCHEMA_V37_SQL)
await conn.execute(SCHEMA_V38_SQL)
await conn.execute(SCHEMA_V39_SQL)
await conn.execute(SCHEMA_V40_SQL)
async def init_schema() -> None:
@@ -7259,27 +7273,40 @@ async def create_missing_precedent(
legal_issue: str | None = None,
claim_quote: str | None = None,
notes: str | None = None,
discovery_source: str = "manual",
linked_case_law_id: UUID | None = None,
) -> dict:
"""Create a new missing-precedent row (status='open' by default)."""
"""Create a new missing-precedent row (status='open' by default).
``citation_norm`` is computed at write-time (designator-aware, #143) so
duplicate gaps for the same court+docket dedup correctly without merging the
same docket across different courts. ``discovery_source`` records how the gap
surfaced (manual | cited_only | digest | writer); ``linked_case_law_id`` may
be set up-front when the canonical identity is already known (a cited_only
stub awaiting its text) while ``status`` stays 'open' until the text lands.
"""
if not citation.strip():
raise ValueError("citation is required")
if cited_by_party and cited_by_party not in ALLOWED_MP_PARTIES:
raise ValueError(
f"cited_by_party must be one of {sorted(ALLOWED_MP_PARTIES)}"
)
citation_norm = court_citation.citation_dedup_key(citation)
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""INSERT INTO missing_precedents (
citation, case_name, cited_in_case_id, cited_in_document_id,
cited_by_party, cited_by_party_name, legal_topic, legal_issue,
claim_quote, notes
claim_quote, notes, citation_norm, discovery_source,
linked_case_law_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *""",
citation.strip(), case_name, cited_in_case_id, cited_in_document_id,
cited_by_party, cited_by_party_name, legal_topic, legal_issue,
claim_quote, notes,
claim_quote, notes, citation_norm, discovery_source,
linked_case_law_id,
)
return _row_to_missing_precedent(row)
@@ -7443,20 +7470,31 @@ async def find_missing_precedent_by_citation(
citation: str,
case_id: UUID | None = None,
) -> dict | None:
"""Look up an existing row by citation string (exact match) and optionally
cited-in case_id. Used to deduplicate auto-creation by the researcher."""
"""Look up an existing gap by DESIGNATOR-AWARE normalized citation and
optionally cited-in case_id. Used to deduplicate auto-creation (researcher,
cited_only derivation #143, digest derivation #136).
Matches on ``citation_norm`` (``court|docket``) so formatting variants of the
same court+docket collapse to one gap, while the same docket across different
courts stays distinct. Falls back to an exact ``citation`` string match when
the citation carries no parseable number (citation_norm is empty)."""
norm = court_citation.citation_dedup_key(citation)
pool = await get_pool()
async with pool.acquire() as conn:
if norm:
match_col, match_val = "citation_norm", norm
else:
match_col, match_val = "citation", citation.strip()
if case_id is not None:
row = await conn.fetchrow(
"SELECT * FROM missing_precedents "
"WHERE citation = $1 AND cited_in_case_id = $2 LIMIT 1",
citation.strip(), case_id,
f"SELECT * FROM missing_precedents "
f"WHERE {match_col} = $1 AND cited_in_case_id = $2 LIMIT 1",
match_val, case_id,
)
else:
row = await conn.fetchrow(
"SELECT * FROM missing_precedents WHERE citation = $1 LIMIT 1",
citation.strip(),
f"SELECT * FROM missing_precedents WHERE {match_col} = $1 LIMIT 1",
match_val,
)
return _row_to_missing_precedent(row) if row else None

View File

@@ -4,11 +4,29 @@ from __future__ import annotations
from legal_mcp.services.court_citation import (
case_number_from_citation,
citation_dedup_key,
classify,
normalize_case_number,
)
def test_dedup_key_is_designator_aware():
"""#143 — same docket across DIFFERENT courts must NOT collapse to one key
(deduping on the bare number would wrongly merge distinct precedents)."""
bagatz = citation_dedup_key('בג"ץ 389/87')
civil = citation_dedup_key('ע"א 389/87')
assert bagatz and civil and bagatz != civil, (bagatz, civil)
# ...while gershayim/format variants of the SAME court+docket DO collapse.
assert citation_dedup_key('עע"מ 9057/09') == citation_dedup_key("עע״מ 9057-09")
# committee proceeding-type is part of the key (ערר ≠ בל"מ).
assert citation_dedup_key("ערר 1192/18") != citation_dedup_key('בל"מ 1192/18')
def test_dedup_key_empty_without_number():
assert citation_dedup_key("") == ""
assert citation_dedup_key("פסק דין בלי מספר") == ""
def test_admin_filed_format_the_example():
"""The plan's example: עת"מ 46111-12-22 → admin, parsed into (46111,12,22)."""
c = classify('עת"מ 46111-12-22 יכין-אפק בע"מ נ\' הוועדה המחוזית')