fix(precedents): חילוץ מספר-תיק קנוני מהציטוט — לא ציטוט-מלא כמזהה (#137) #260

Merged
chaim merged 1 commits from worktree-case-number-from-citation into main 2026-06-15 03:22:18 +00:00
7 changed files with 208 additions and 6 deletions

View File

@@ -117,6 +117,23 @@ def normalize_case_number(raw: str) -> str:
return cleaned.replace("/", "-").strip("-") return cleaned.replace("/", "-").strip("-")
def case_number_from_citation(citation: str) -> str:
"""Canonical ``case_number`` extracted from a full citation, or ``''``.
Returns the normalized number token only (e.g. ``85074-04-25``) — NEVER the
full citation string with party names / court / date. This is the
identifier-field rule from X1 (INV-ID2): a citation like
``ערר (ת"א 85074-04-25) רפאל לוי ואח' נ' הוועדה … - חולון`` yields
``85074-04-25``, not the whole display string.
Reuses ``classify`` (the one canonical citation parser) so callers that need
a case_number out of an arbitrary citation never roll their own regex (#137,
G2). Returns ``''`` when no number can be parsed — the caller MUST treat that
as "needs a manual case_number" and never fall back to the raw citation.
"""
return classify(citation).case_number_norm
def _split_filed(num_norm: str) -> tuple[str, str, str] | None: def _split_filed(num_norm: str) -> tuple[str, str, str] | None:
"""Split a normalized NNNNN-MM-YY number into (file, month, year). """Split a normalized NNNNN-MM-YY number into (file, month, year).

View File

@@ -1757,12 +1757,23 @@ def _canonical_case_number(s: str) -> str:
Used at the write boundary for identifier-keyed corpora (internal Used at the write boundary for identifier-keyed corpora (internal
committee decisions, active cases). NOT for external precedents, whose committee decisions, active cases). NOT for external precedents, whose
canonical identifier is the full citation. canonical identifier is the full citation.
Extracts the case-number TOKEN — the leading run of digits and internal
separators after the proceeding-type prefix — and drops any trailing display
text. Without this, a full citation mis-passed as the identifier (e.g.
``ערר (ת"א 85074-04-25) רפאל לוי נ' …``) left party names glued to the number
(``85074-04-25) רפאל לוי …``), breaking equality lookups (#137, INV-ID2). A
clean number is returned unchanged.
""" """
s = (s or "").strip() s = (s or "").strip()
m = re.search(r"\d", s) m = re.search(r"\d", s)
if m: if not m:
return ""
s = s[m.start():] s = s[m.start():]
return s.strip().replace("/", "-") token = re.match(r"\d[\d/\-]*", s)
if token:
s = token.group(0)
return s.strip().rstrip("/-").replace("/", "-")
def _content_hash(text: str) -> str: def _content_hash(text: str) -> str:

View File

@@ -0,0 +1,34 @@
"""Unit tests for db._canonical_case_number — #137 / INV-ID2 / X1 §1.
The write-time canonicalizer must extract the case-number TOKEN only and drop
any trailing display text (party names) that a mis-passed full citation glued
onto the number. A clean number is returned unchanged; no month is invented.
"""
from __future__ import annotations
from legal_mcp.services.db import _canonical_case_number as canon
def test_clean_numbers_unchanged():
assert canon("8137-24") == "8137-24"
assert canon("85074-09-24") == "85074-09-24"
assert canon("8126-03-25") == "8126-03-25"
# Legacy two-part number — month is NOT invented (X1 §1).
assert canon("8126-25") == "8126-25"
def test_prefix_stripped():
assert canon("ערר 8137/24") == "8137-24"
assert canon('בל"מ 85074-09-24') == "85074-09-24"
def test_trailing_party_names_dropped():
# The #137 symptom: ingest left "85074-04-25) רפאל לוי …" in the identifier.
assert canon("85074-04-25) רפאל לוי ואח' נ' הוועדה המקומית - חולון") == "85074-04-25"
assert canon("8137-24 פלוני נ' אלמוני") == "8137-24"
def test_empty_and_no_digit():
assert canon("") == ""
assert canon("ללא מספר") == ""

View File

@@ -2,7 +2,11 @@
from __future__ import annotations from __future__ import annotations
from legal_mcp.services.court_citation import classify, normalize_case_number from legal_mcp.services.court_citation import (
case_number_from_citation,
classify,
normalize_case_number,
)
def test_admin_filed_format_the_example(): def test_admin_filed_format_the_example():
@@ -80,6 +84,28 @@ def test_normalize_case_number():
assert normalize_case_number("1110/20") == "1110-20" assert normalize_case_number("1110/20") == "1110-20"
def test_case_number_from_citation_strips_party_names():
"""#137 — a full ועדת-ערר citation yields ONLY the number, never the
display string with party names (INV-ID2). This is the exact precedent
1bf0bae0 that planted ``85074-04-25) רפאל לוי …`` into case_number."""
cit = 'ערר (ת"א 85074-04-25) רפאל לוי ואח\' נ\' הוועדה המקומית - חולון'
assert case_number_from_citation(cit) == "85074-04-25"
def test_case_number_from_citation_various_forms():
assert case_number_from_citation('ערר (ת"א 1198-12-25) זאטוס') == "1198-12-25"
assert case_number_from_citation("85074-04-25") == "85074-04-25"
assert case_number_from_citation('בל"מ 85074-09-24') == "85074-09-24"
assert case_number_from_citation("ערר 8137/24") == "8137-24"
def test_case_number_from_citation_empty_when_unparseable():
"""No number → '' so the caller demands a manual number, never the raw
citation (the #137 fallback that caused the bug)."""
assert case_number_from_citation("") == ""
assert case_number_from_citation("פסק דין בלי מספר") == ""
def test_supreme_with_net_format_triple(): def test_supreme_with_net_format_triple():
"""A Supreme prefix carrying a נט-format number exposes the triple so the """A Supreme prefix carrying a נט-format number exposes the triple so the
orchestrator can route it to Tier-1 (נט המשפט serves Supreme too).""" orchestrator can route it to Tier-1 (נט המשפט serves Supreme too)."""

View File

@@ -21,6 +21,7 @@
| `test_retrieval_by_name.py` | python | בדיקת אחזור-לפי-שם (#52/RC-A) — מאמת ש`search_precedent_library`/`search_internal_decisions` מדרגים את ההחלטה עצמה (אגסי) מעל מי שמצטט אותה, + רגרסיות לשאילתות מהותיות. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/test_retrieval_by_name.py` (exit 0 = עבר). | ידני אחרי שינוי שכבת חיפוש | | `test_retrieval_by_name.py` | python | בדיקת אחזור-לפי-שם (#52/RC-A) — מאמת ש`search_precedent_library`/`search_internal_decisions` מדרגים את ההחלטה עצמה (אגסי) מעל מי שמצטט אותה, + רגרסיות לשאילתות מהותיות. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/test_retrieval_by_name.py` (exit 0 = עבר). | ידני אחרי שינוי שכבת חיפוש |
| `fu2b_reconcile_internal_case_numbers.py` | python | **FU-2b (GAP-07/08) — תיאום `case_number` של `internal_committee`** מציטוט-מלא למספר-בסיס קנוני (X1: trim·prefix-strip·`/``-`, חודש נשמר). דטרמיניסטי (token יחיד; 0/>1 → flag). `--dry-run` (ברירת-מחדל) מפיק טבלת-תיאום ל-`data/audit/fu2b-reconciliation-*.{csv,md}` עם flags (DUP_CHECK / PROC_MISMATCH / MISMATCH). `--apply --approved <csv>` מגבה ואז מעדכן רק שורות שאושרו ע"י היו"ר. scope: internal בלבד (external → #68). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) | | `fu2b_reconcile_internal_case_numbers.py` | python | **FU-2b (GAP-07/08) — תיאום `case_number` של `internal_committee`** מציטוט-מלא למספר-בסיס קנוני (X1: trim·prefix-strip·`/``-`, חודש נשמר). דטרמיניסטי (token יחיד; 0/>1 → flag). `--dry-run` (ברירת-מחדל) מפיק טבלת-תיאום ל-`data/audit/fu2b-reconciliation-*.{csv,md}` עם flags (DUP_CHECK / PROC_MISMATCH / MISMATCH). `--apply --approved <csv>` מגבה ואז מעדכן רק שורות שאושרו ע"י היו"ר. scope: internal בלבד (external → #68). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) |
| `fu2c_reconcile_external_case_numbers.py` | python | **FU-2c (GAP-08, #68) — תיאום `case_number` של פסיקה חיצונית** (`source_kind <> internal_committee`) מציטוט-מלא לצורה קנונית **מציין-הליך + docket** (החלטת-יו"ר 2026-05-31, Option A: `/` נשמר, *לא* `-`; תואם db.py:369 ו-INV-ID2). דטרמיניסטי (designator+docket; 0/>1 docket → flag). `--dry-run` (ברירת-מחדל) מפיק `data/audit/fu2c-reconciliation-*.{csv,md}` עם flags (MISMATCH / NO_CITATION / CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK). `--apply --approved <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) | | `fu2c_reconcile_external_case_numbers.py` | python | **FU-2c (GAP-08, #68) — תיאום `case_number` של פסיקה חיצונית** (`source_kind <> internal_committee`) מציטוט-מלא לצורה קנונית **מציין-הליך + docket** (החלטת-יו"ר 2026-05-31, Option A: `/` נשמר, *לא* `-`; תואם db.py:369 ו-INV-ID2). דטרמיניסטי (designator+docket; 0/>1 docket → flag). `--dry-run` (ברירת-מחדל) מפיק `data/audit/fu2c-reconciliation-*.{csv,md}` עם flags (MISMATCH / NO_CITATION / CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK). `--apply --approved <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) |
| `fix_137_committee_case_number.py` | python | **#137 — תיקון-נתון חד-פעמי**: רשומת `internal_committee` בודדת (1bf0bae0) שבה ציטוט-מלא זיהם את שדה-המזהה (case_number=`85074/0425`, case_name=ציטוט שלם) — הפרת INV-ID2 ממסלול `missing_precedent_upload` (לפני תיקון-הקוד ב-#137). מתקן `case_number``85074-04-25`, `case_name`→צדדים, ו-token ב-`citation_formatted`. אומת היחיד עם `_canonical_case_number(num)≠num` ב-internal_committee (138 ה"מזוהמים" האחרים = מקור-חיצוני/cited_only מקודמים-קידומת, X1 §5 — מחוץ-לתחום). `document_id=NULL`, 0 ציטוטים-נכנסים → ללא נתיב/קובץ לשנות. guard-התנגשות על `(case_number,proceeding_type)`. אידמפוטנטי, dry-run כברירת-מחדל / `--apply`. הרצה: `HOME=/home/chaim PYTHONPATH=mcp-server/src mcp-server/.venv/bin/python scripts/fix_137_committee_case_number.py --apply`. | חד-פעמי (בוצע 2026-06-15) |
| `eval_gold_bootstrap.py` | python | **FU-5 (GAP-11) — bootstrap ל-gold-set** של הערכת-אחזור ל-`data/eval/gold-set.jsonl`. שני מקורות: `--source citations` (cited==relevant מ-`search_relevance_feedback`; ריק עד שייצברו ציטוטים) ו-`--source known_item` (query=שם-תיק → relevant=עצמו; אות אמיתי היום). Idempotent — שומר שורות `source=chair`, מחדש `bootstrap_*`. דורש POSTGRES. | לפני eval; חוזר כשנצבר ground-truth | | `eval_gold_bootstrap.py` | python | **FU-5 (GAP-11) — bootstrap ל-gold-set** של הערכת-אחזור ל-`data/eval/gold-set.jsonl`. שני מקורות: `--source citations` (cited==relevant מ-`search_relevance_feedback`; ריק עד שייצברו ציטוטים) ו-`--source known_item` (query=שם-תיק → relevant=עצמו; אות אמיתי היום). Idempotent — שומר שורות `source=chair`, מחדש `bootstrap_*`. דורש POSTGRES. | לפני eval; חוזר כשנצבר ground-truth |
| `eval_retrieval.py` | python | **FU-5 (GAP-11, INV-RET4/G8) — harness הערכת-אחזור** — מריץ את מסלול-האחזור בייצור (`search_library`/`search_internal`) על ה-gold-set, מחשב precision@k/recall@k/MRR/nDCG@k (k=5,10), מצרף overall+per-corpus+per-PA ל-`data/eval/eval-report-<ts>.{json,md}` + delta מול `data/eval/baseline.json` (מתעד retrieval_config). `--self-test` בודק את המטריקות offline; `--update-baseline` מאמץ snapshot. **שער-CI במשמעת:** הרץ לפני/אחרי כל שינוי בשכבת-האחזור באותו קונפיג. דורש POSTGRES+VOYAGE_API_KEY. | לפני/אחרי שינוי RRF/k/embedder/rerank | | `eval_retrieval.py` | python | **FU-5 (GAP-11, INV-RET4/G8) — harness הערכת-אחזור** — מריץ את מסלול-האחזור בייצור (`search_library`/`search_internal`) על ה-gold-set, מחשב precision@k/recall@k/MRR/nDCG@k (k=5,10), מצרף overall+per-corpus+per-PA ל-`data/eval/eval-report-<ts>.{json,md}` + delta מול `data/eval/baseline.json` (מתעד retrieval_config). `--self-test` בודק את המטריקות offline; `--update-baseline` מאמץ snapshot. **שער-CI במשמעת:** הרץ לפני/אחרי כל שינוי בשכבת-האחזור באותו קונפיג. דורש POSTGRES+VOYAGE_API_KEY. | לפני/אחרי שינוי RRF/k/embedder/rerank |
| `legal-court-fetch-service.config.cjs` | pm2/js | **שירות-מארח Tier-1 לאחזור פסקי-דין מנט המשפט (X13)** — 2 apps: (א) `legal-court-fetch-xvfb` (Xvfb :99, צג-וירטואלי ל-Camoufox); (ב) `legal-court-fetch-service` (`python -m legal_mcp.court_fetch_service.server`, bound `10.0.1.1:8771`, Bearer `COURT_FETCH_SHARED_SECRET` מ-`~/.legal-court-fetch-service.env`, `DISPLAY=:99`). מריץ Camoufox דרך חבילת-הפייתון (in-process) כי הקונטיינר לא יכול דפדפן. תלות: `pip install -e "mcp-server[court-fetch]" && python -m camoufox fetch`. אחזור = ניווט→צופה→`GetImages`(X-Requested-With)→PDF, ללא CAPTCHA; כשל→`ok:false`→orchestrator מסלים ל-fallback אנושי. **אומת על עת"מ 46111-12-22 (34 עמ').** מראָה לדפוס `legal-chat-service.config.cjs`. ספ: `docs/spec/X13-court-fetch.md`. התקנה: `pm2 start scripts/legal-court-fetch-service.config.cjs && pm2 save`. בריאות: `curl http://10.0.1.1:8771/health`. | pm2 (host-side) | | `legal-court-fetch-service.config.cjs` | pm2/js | **שירות-מארח Tier-1 לאחזור פסקי-דין מנט המשפט (X13)** — 2 apps: (א) `legal-court-fetch-xvfb` (Xvfb :99, צג-וירטואלי ל-Camoufox); (ב) `legal-court-fetch-service` (`python -m legal_mcp.court_fetch_service.server`, bound `10.0.1.1:8771`, Bearer `COURT_FETCH_SHARED_SECRET` מ-`~/.legal-court-fetch-service.env`, `DISPLAY=:99`). מריץ Camoufox דרך חבילת-הפייתון (in-process) כי הקונטיינר לא יכול דפדפן. תלות: `pip install -e "mcp-server[court-fetch]" && python -m camoufox fetch`. אחזור = ניווט→צופה→`GetImages`(X-Requested-With)→PDF, ללא CAPTCHA; כשל→`ok:false`→orchestrator מסלים ל-fallback אנושי. **אומת על עת"מ 46111-12-22 (34 עמ').** מראָה לדפוס `legal-chat-service.config.cjs`. ספ: `docs/spec/X13-court-fetch.md`. התקנה: `pm2 start scripts/legal-court-fetch-service.config.cjs && pm2 save`. בריאות: `curl http://10.0.1.1:8771/health`. | pm2 (host-side) |

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""One-off data fix for TaskMaster #137 — committee precedent whose identifier
field was polluted by a full citation.
Background: ``missing_precedent_upload`` (committee branch) fell back to the raw
``citation`` when the form left ``case_number`` blank, so a citation like
``ערר (ת"א 85074-04-25) רפאל לוי ואח' נ' הוועדה … - חולון`` landed as the
identifier / display name (INV-ID2 violation). The code fix (PR for #137)
prevents recurrence; this script corrects the one already-stored row.
Scope: a SINGLE internal_committee row (1bf0bae0). Verified the only
internal_committee row where ``_canonical_case_number(case_number) != case_number``
(the legacy 138 "polluted" matches are external/cited_only PREFIXED numbers —
the X1 §5 external-identifier item, deliberately out of scope here). The row has
``document_id = NULL`` (no file/storage key to rename) and 0 incoming citations,
so only three columns change: ``case_number``, ``case_name``, ``citation_formatted``.
Idempotent: if the row already carries the canonical number it is a no-op.
Dry-run by default; pass ``--apply`` to write.
Run (local, reads ~/.env for POSTGRES_URL):
HOME=/home/chaim PYTHONPATH=mcp-server/src python scripts/fix_137_committee_case_number.py [--apply]
"""
from __future__ import annotations
import asyncio
import sys
import asyncpg
from legal_mcp import config
# The verified target row + its corrected values (see module docstring).
CASE_LAW_ID = "1bf0bae0-1cb7-4110-ba1b-b956e42b0355"
BAD_CASE_NUMBER = "85074/0425"
NEW_CASE_NUMBER = "85074-04-25"
NEW_CASE_NAME = "רפאל לוי ואח' נ' הוועדה המקומית לתכנון ובניה - חולון"
async def main(apply: bool) -> int:
conn = await asyncpg.connect(config.POSTGRES_URL)
try:
row = await conn.fetchrow(
"SELECT case_number, case_name, citation_formatted, proceeding_type "
"FROM case_law WHERE id = $1",
CASE_LAW_ID,
)
if row is None:
print(f"row {CASE_LAW_ID} not found — nothing to do")
return 0
if row["case_number"] == NEW_CASE_NUMBER:
print(f"already canonical (case_number={NEW_CASE_NUMBER!r}) — no-op")
return 0
if row["case_number"] != BAD_CASE_NUMBER:
print(
f"UNEXPECTED current case_number={row['case_number']!r} "
f"(expected {BAD_CASE_NUMBER!r}) — refusing to guess; inspect manually"
)
return 1
# Collision guard: the (case_number, proceeding_type) partial-unique key.
clash = await conn.fetchval(
"SELECT id FROM case_law WHERE source_kind='internal_committee' "
"AND case_number = $1 AND proceeding_type = $2 AND id <> $3",
NEW_CASE_NUMBER, row["proceeding_type"], CASE_LAW_ID,
)
if clash:
print(f"COLLISION: {NEW_CASE_NUMBER!r}/{row['proceeding_type']!r} "
f"already exists as {clash} — aborting")
return 1
new_citation = (row["citation_formatted"] or "").replace(
BAD_CASE_NUMBER, NEW_CASE_NUMBER)
print("WILL UPDATE:")
print(f" case_number: {row['case_number']!r} -> {NEW_CASE_NUMBER!r}")
print(f" case_name: {row['case_name']!r}\n -> {NEW_CASE_NAME!r}")
print(f" citation_formatted: {row['citation_formatted']!r}\n"
f" -> {new_citation!r}")
if not apply:
print("\n(dry-run — pass --apply to write)")
return 0
await conn.execute(
"UPDATE case_law SET case_number = $2, case_name = $3, "
"citation_formatted = $4 WHERE id = $1",
CASE_LAW_ID, NEW_CASE_NUMBER, NEW_CASE_NAME, new_citation,
)
print("\n✓ updated")
return 0
finally:
await conn.close()
if __name__ == "__main__":
sys.exit(asyncio.run(main("--apply" in sys.argv)))

View File

@@ -7081,6 +7081,7 @@ async def bulletin_upload(file: UploadFile = File(...)):
from legal_mcp.services import internal_decisions as int_decisions_service # noqa: E402 from legal_mcp.services import internal_decisions as int_decisions_service # noqa: E402
from legal_mcp.services import court_citation # noqa: E402
@app.post("/api/internal-decisions/upload") @app.post("/api/internal-decisions/upload")
@@ -7917,8 +7918,20 @@ async def missing_precedent_upload(
or int_decisions_service._district_from_court(citation) or int_decisions_service._district_from_court(citation)
or PLACEHOLDER_PENDING_EXTRACTION or PLACEHOLDER_PENDING_EXTRACTION
) )
# case_number for the committee decision (not the cited-in case) # case_number for the committee decision (not the cited-in case).
committee_case_number = case_number.strip() or citation # When the form leaves it blank, derive the canonical number from
# the citation — NEVER fall back to the raw citation string, which
# planted the full display text (party names) into the identifier
# field (#137, INV-ID2). If no number can be parsed, demand one.
committee_case_number = (
case_number.strip()
or court_citation.case_number_from_citation(citation)
)
if not committee_case_number:
raise HTTPException(
400,
"לא ניתן לחלץ מספר-תיק מהציטוט — נא להזין מספר-תיק ידנית.",
)
result = await int_decisions_service.ingest_internal_decision( result = await int_decisions_service.ingest_internal_decision(
case_number=committee_case_number, case_number=committee_case_number,
case_name=(case_name.strip() or mp.get("case_name") or "").strip(), case_name=(case_name.strip() or mp.get("case_name") or "").strip(),