From c27987ba729eb25de7d66f46d695f2555111f9ab Mon Sep 17 00:00:00 2001 From: Chaim Date: Mon, 15 Jun 2026 03:21:10 +0000 Subject: [PATCH] =?UTF-8?q?fix(precedents):=20=D7=97=D7=99=D7=9C=D7=95?= =?UTF-8?q?=D7=A5=20=D7=9E=D7=A1=D7=A4=D7=A8-=D7=AA=D7=99=D7=A7=20=D7=A7?= =?UTF-8?q?=D7=A0=D7=95=D7=A0=D7=99=20=D7=9E=D7=94=D7=A6=D7=99=D7=98=D7=95?= =?UTF-8?q?=D7=98=20=E2=80=94=20=D7=9C=D7=90=20=D7=A6=D7=99=D7=98=D7=95?= =?UTF-8?q?=D7=98-=D7=9E=D7=9C=D7=90=20=D7=9B=D7=9E=D7=96=D7=94=D7=94=20(#?= =?UTF-8?q?137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit בהעלאה דרך "פסיקה-חסרה" (ענף ועדת-ערר), כשטופס case_number ריק המסלול נפל-לאחור לציטוט המלא (committee_case_number = case_number.strip() or citation), כך שמחרוזת- תצוגה עם שמות-צדדים הושתלה בשדה-המזהה — הפרת INV-ID2/INV-ID1 (X1). נצפה על precedent 1bf0bae0 (ערר 85074-04-25 רפאל לוי/חולון): case_number=85074/0425, case_name=ציטוט שלם. תיקון (G1 — נרמול-במקור, G2 — שימוש-חוזר בפרסר הקנוני): - court_citation.case_number_from_citation(citation) — מחזיר את אסימון-המספר המנורמל בלבד (classify; '' כשאין מספר). חולץ נכון 85074-04-25 גם מתוך "ערר (ת\"א 85074-04-25) ...". reuse של הפרסר היחיד, בלי regex מקביל. - web/app.py (ענף ועדת-ערר): fallback דרך case_number_from_citation; אם אין מספר — HTTPException 400 "נא להזין מספר-תיק ידנית" במקום השתלת ציטוט-מלא. - db._canonical_case_number: מוקשח לחלץ את אסימון-המספר (זורק זנב שמות-צדדים), כך ששדה-המזהה לעולם לא נשמר מזוהם — גם בקריאה ישירה (committee + active cases). מספר נקי חוזר ללא שינוי; חודש לא מומצא (X1 §1). - תיקון-נתון: scripts/fix_137_committee_case_number.py (בוצע) — 1bf0bae0: case_number→85074-04-25, case_name→צדדים, token ב-citation_formatted. אומת היחיד עם canon(num)≠num ב-internal_committee. אידמפוטנטי. מחוץ-לתחום (תועד כ-follow-up): מסלול external (precedent_library) משתמש בציטוט- מלא כמזהה-מורשת — זהו פריט-המיגרציה X1 §5 (138 רשומות external/cited_only), לא הבאג הזה. prefill ב-UI של /missing-precedents — דורש שער Claude Design. בדיקות: test_court_citation (case_number_from_citation: party-strip/forms/empty), test_canonical_case_number (harden). כל 339 בדיקות mcp עוברות. guards נקיים. Invariants: G1 (נרמול-במקור), INV-ID1/ID2 (מזהה מנורמל, אין ציטוט-מלא כמזהה), G2 (פרסר יחיד), G12 (leak-guard נקי). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/legal_mcp/services/court_citation.py | 17 +++ mcp-server/src/legal_mcp/services/db.py | 17 ++- .../tests/test_canonical_case_number.py | 34 ++++++ mcp-server/tests/test_court_citation.py | 28 ++++- scripts/SCRIPTS.md | 1 + scripts/fix_137_committee_case_number.py | 100 ++++++++++++++++++ web/app.py | 17 ++- 7 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 mcp-server/tests/test_canonical_case_number.py create mode 100644 scripts/fix_137_committee_case_number.py diff --git a/mcp-server/src/legal_mcp/services/court_citation.py b/mcp-server/src/legal_mcp/services/court_citation.py index db14027..038640a 100644 --- a/mcp-server/src/legal_mcp/services/court_citation.py +++ b/mcp-server/src/legal_mcp/services/court_citation.py @@ -117,6 +117,23 @@ def normalize_case_number(raw: str) -> str: 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: """Split a normalized NNNNN-MM-YY number into (file, month, year). diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 9c26ce6..9bd375a 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -1757,12 +1757,23 @@ def _canonical_case_number(s: str) -> str: Used at the write boundary for identifier-keyed corpora (internal committee decisions, active cases). NOT for external precedents, whose 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() m = re.search(r"\d", s) - if m: - s = s[m.start():] - return s.strip().replace("/", "-") + if not m: + return "" + s = s[m.start():] + token = re.match(r"\d[\d/\-]*", s) + if token: + s = token.group(0) + return s.strip().rstrip("/-").replace("/", "-") def _content_hash(text: str) -> str: diff --git a/mcp-server/tests/test_canonical_case_number.py b/mcp-server/tests/test_canonical_case_number.py new file mode 100644 index 0000000..a0e6adc --- /dev/null +++ b/mcp-server/tests/test_canonical_case_number.py @@ -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("ללא מספר") == "" diff --git a/mcp-server/tests/test_court_citation.py b/mcp-server/tests/test_court_citation.py index 989d0e6..f172420 100644 --- a/mcp-server/tests/test_court_citation.py +++ b/mcp-server/tests/test_court_citation.py @@ -2,7 +2,11 @@ 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(): @@ -80,6 +84,28 @@ def test_normalize_case_number(): 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(): """A Supreme prefix carrying a נט-format number exposes the triple so the orchestrator can route it to Tier-1 (נט המשפט serves Supreme too).""" diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index 8187465..df62f85 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -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 = עבר). | ידני אחרי שינוי שכבת חיפוש | | `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 ` מגבה ואז מעדכן רק שורות שאושרו ע"י היו"ר. 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 ` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides ` (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_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-.{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) | diff --git a/scripts/fix_137_committee_case_number.py b/scripts/fix_137_committee_case_number.py new file mode 100644 index 0000000..e74d35a --- /dev/null +++ b/scripts/fix_137_committee_case_number.py @@ -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))) diff --git a/web/app.py b/web/app.py index fd1b30c..f5ee437 100644 --- a/web/app.py +++ b/web/app.py @@ -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 court_citation # noqa: E402 @app.post("/api/internal-decisions/upload") @@ -7917,8 +7918,20 @@ async def missing_precedent_upload( or int_decisions_service._district_from_court(citation) or PLACEHOLDER_PENDING_EXTRACTION ) - # case_number for the committee decision (not the cited-in case) - committee_case_number = case_number.strip() or citation + # case_number for the committee decision (not the cited-in case). + # 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( case_number=committee_case_number, case_name=(case_name.strip() or mp.get("case_name") or "").strip(),