From 242e6cfd1149399f65cdd9e688ae39ab0fa5c49c Mon Sep 17 00:00:00 2001 From: Chaim Date: Fri, 12 Jun 2026 07:25:54 +0000 Subject: [PATCH] =?UTF-8?q?fix(learning):=20chair=5Fname=20=D7=91=D7=9E?= =?UTF-8?q?=D7=A7=D7=95=D7=A8=20=E2=80=94=20=D7=A1=D7=95=D7=A4=D7=99-?= =?UTF-8?q?=D7=95=D7=A2=D7=93=D7=94=20=D7=AA=D7=9E=D7=99=D7=93=20=D7=A0?= =?UTF-8?q?=D7=9B=D7=A0=D7=A1=20=D7=9C=D7=A7=D7=95=D7=A8=D7=A4=D7=95=D7=A1?= =?UTF-8?q?-=D7=94=D7=A4=D7=A1=D7=99=D7=A7=D7=94=20(TaskMaster=20#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit הבאג: שלב-הלמידה (ingest_final_version → ingest_internal_decision) מוסיף כל סופי כתקדים ציטוטי ב-case_law (source_kind=internal_committee), אך נכשל בשקט (non-fatal warning) כש-cases.chair_name ריק — בגלל constraint case_law_internal_chair_check. כך סופיים של 1194/1200/8070 לא נכנסו לקורפוס-הפסיקה. שורש: (1) chair_name לא נקבע בפתיחת תיק; (2) מסלול-ה-MCP העביר chair גולמי בעוד מסלול-ה-UI (web/) כבר פתר אותו דטרמיניסטית — **מסלולים מקבילים מתפצלים (הפרת INV-G2)**; (3) הכשל נבלע (נגד §6). תיקון-שורש (3 שכבות): 1. **SoT יחיד (INV-G2):** `config.committee_chair_for_case` — המקום היחיד שגם web/app.py וגם tools/workflow.py + db.create_case גוזרים ממנו chair (לפי תחילית מספר-התיק; override ל-env). web/ אחוד אליו (הוסרה הכפילות). 2. **נרמול-במקור (INV-G1):** `db.create_case` קובע chair_name תמיד לא-ריק; `cases.case_create` חושף param. `ingest_final_version` גוזר chair מה-SoT במקום הערך הגולמי → ה-constraint לא נופל. 3. **נראות (§6/feedback_silent_swallow):** כשל-העתק מוחזר ב-result (`internal_corpus_error`) ו-`final_learning_pipeline` מדפיס אזהרה — לא נבלע. backfill ל-11 תיקים עם chair ריק. `audit_corpus_integrity`: נוספו CHECK_D (תיקים מוכרעים ללא chair) + CHECK_E (סופי-final חסר מקורפוס-הפסיקה) — שניהם 0 כעת. invariants: מקיים INV-G1 (נרמול בכתיבה), INV-G2 (מסלול-יחיד, אוחד web↔MCP), §6 (אין בליעה שקטה). בדיקות: py_compile + 14 pytest (chair_seed_gate, audit_provenance) + integration של create_case (default+override) + הרצת ה-audit החי (A–E=0). Co-Authored-By: Claude Opus 4.8 (1M context) --- mcp-server/src/legal_mcp/config.py | 31 ++++++++++++ mcp-server/src/legal_mcp/services/db.py | 16 ++++-- mcp-server/src/legal_mcp/tools/cases.py | 5 ++ mcp-server/src/legal_mcp/tools/workflow.py | 13 ++++- scripts/SCRIPTS.md | 2 +- scripts/audit_corpus_integrity.py | 59 ++++++++++++++++++++-- scripts/final_learning_pipeline.py | 5 ++ web/app.py | 18 ++----- 8 files changed, 124 insertions(+), 25 deletions(-) diff --git a/mcp-server/src/legal_mcp/config.py b/mcp-server/src/legal_mcp/config.py index 4229688..94d5014 100644 --- a/mcp-server/src/legal_mcp/config.py +++ b/mcp-server/src/legal_mcp/config.py @@ -362,3 +362,34 @@ def parse_llm_json(raw: str): except json.JSONDecodeError: pass return None + + +# ── Committee chair — single source of truth (INV-G2) ───────────────── +# internal_committee rows REQUIRE a non-empty chair_name (DB constraint +# case_law_internal_chair_check). Our committee (CMP 1xxx, CMPA 8/9xxx) is +# chaired by Dafna Tamir; map by case-number prefix so adding a future chair +# stays a one-line local change. This resolver is the ONE place both the +# FastAPI final-upload path (web/app.py) and the MCP learning path +# (tools/workflow.py + services/db.create_case) derive the chair from — so +# the two cannot drift into parallel logic. Override via env for another +# committee. +COMMITTEE_CHAIR_DEFAULT = os.environ.get("DEFAULT_CHAIR_NAME", "דפנה תמיר") +COMMITTEE_CHAIR_BY_PREFIX = { + "1": COMMITTEE_CHAIR_DEFAULT, + "8": COMMITTEE_CHAIR_DEFAULT, + "9": COMMITTEE_CHAIR_DEFAULT, +} + + +def committee_chair_for_case(case: dict | None, case_number: str) -> str: + """Resolve the chair for one of OUR decisions deterministically (no LLM): + the case's own chair_name, else the committee default by case-number prefix. + + Never returns empty for a valid case number — this is how chair_name is + normalised at the source (INV-G1) so internal_committee corpus copies of + finals never silently fail the DB chair constraint. + """ + existing = ((case or {}).get("chair_name") or "").strip() + if existing: + return existing + return COMMITTEE_CHAIR_BY_PREFIX.get((case_number or "")[:1], COMMITTEE_CHAIR_DEFAULT) diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 26a2aba..278d1c1 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -1555,22 +1555,30 @@ async def create_case( practice_area: str = "", appeal_subtype: str = "", proceeding_type: str = "ערר", + # Default "" — resolved below to the committee chair (never stored empty). + # internal_committee corpus copies of this case's final REQUIRE a chair + # (DB constraint case_law_internal_chair_check); setting it at creation + # (INV-G1, source) keeps the learning loop's precedent copy from failing. + chair_name: str = "", ) -> dict: pool = await get_pool() case_id = uuid4() + canonical_number = _canonical_case_number(case_number) + resolved_chair = config.committee_chair_for_case( + {"chair_name": chair_name}, canonical_number) async with pool.acquire() as conn: await conn.execute( """INSERT INTO cases (id, case_number, title, appellants, respondents, subject, property_address, permit_number, committee_type, hearing_date, notes, expected_outcome, - practice_area, appeal_subtype, proceeding_type) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)""", - case_id, _canonical_case_number(case_number), title, + practice_area, appeal_subtype, proceeding_type, chair_name) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)""", + case_id, canonical_number, title, json.dumps(appellants or []), json.dumps(respondents or []), subject, property_address, permit_number, committee_type, hearing_date, notes, expected_outcome, - practice_area, appeal_subtype, proceeding_type, + practice_area, appeal_subtype, proceeding_type, resolved_chair, ) return await get_case(case_id) diff --git a/mcp-server/src/legal_mcp/tools/cases.py b/mcp-server/src/legal_mcp/tools/cases.py index 365fd3e..0020ef4 100644 --- a/mcp-server/src/legal_mcp/tools/cases.py +++ b/mcp-server/src/legal_mcp/tools/cases.py @@ -132,6 +132,7 @@ async def case_create( practice_area: str = "", appeal_subtype: str = "", proceeding_type: str = "", + chair_name: str = "", ) -> str: """יצירת תיק ערר חדש. @@ -153,6 +154,9 @@ async def case_create( appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197). ריק = יוסק אוטומטית ממספר התיק proceeding_type: 'ערר' / 'בל"מ'. ריק = יוסק מ-appeal_subtype/subject. + chair_name: שם יו"ר הוועדה. ריק = ברירת-המחדל של הוועדה לפי תחילית + מספר-התיק (SoT: config.committee_chair_for_case) — נשמר + תמיד לא-ריק כדי שהעתק-הסופי לקורפוס-הפסיקה לא ייכשל. """ # INV-TOOL3 / GAP-52: idempotent on case_number (already UNIQUE in schema). # Re-creating an existing case returns it instead of raising a unique-violation. @@ -204,6 +208,7 @@ async def case_create( practice_area=practice_area, appeal_subtype=appeal_subtype, proceeding_type=resolved_proc, + chair_name=chair_name, ) # If the user overrode the case-number convention (e.g. case 8500 marked diff --git a/mcp-server/src/legal_mcp/tools/workflow.py b/mcp-server/src/legal_mcp/tools/workflow.py index 8e3a2ef..1526d81 100644 --- a/mcp-server/src/legal_mcp/tools/workflow.py +++ b/mcp-server/src/legal_mcp/tools/workflow.py @@ -326,13 +326,20 @@ async def ingest_final_version( return err(str(e)) # Auto-ingest into internal committee decisions corpus (best-effort). + # chair_name is resolved via the shared SoT (config.committee_chair_for_case) + # — the SAME resolver the FastAPI upload path uses — so the two paths cannot + # drift (INV-G2) and the DB chair constraint is never hit on an empty chair + # (INV-G1: chair normalised at source). Failures are surfaced, not swallowed + # (engineering rule §6 / feedback_silent_swallow): the result carries the + # reason and final_learning_pipeline prints it. try: + from legal_mcp import config from legal_mcp.services import internal_decisions as int_svc await int_svc.ingest_internal_decision( case_number=case_number, case_name=case.get("title", ""), decision_date=case.get("decision_date"), - chair_name=case.get("chair_name", ""), + chair_name=config.committee_chair_for_case(case, case_number), district="ירושלים", practice_area=case.get("practice_area", ""), appeal_subtype=case.get("appeal_subtype", ""), @@ -340,8 +347,10 @@ async def ingest_final_version( ) result["internal_corpus_ingested"] = True except Exception as e: - logger.warning("ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e) + logger.warning( + "ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e) result["internal_corpus_ingested"] = False + result["internal_corpus_error"] = str(e) return ok(result) diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index 19c9e0b..91f4a96 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -67,7 +67,7 @@ | `halacha_rule_role_backfill.py` | python | **INV-DM7** — backfill חד-פעמי: מסווג-מחדש את ההלכות הישנות (`rule_type IN ('binding','persuasive')` — ערכי-סמכות שנשמרו במסווה תפקיד לפני פיצול הצירים) לאחד מחמשת **תפקידי-הכלל** (holding/interpretive/procedural/application/obiter) דרך claude_session המקומי (אפס עלות). **לא נוגע בסמכות** (נגזרת מ-`precedent_level`). `--apply` (ברירת-מחדל dry-run) / `--limit N` / `--concurrency`. כותב backup CSV ל-`data/audit/` תחילה. fail-safe (פריט שנכשל → נשמר ערך ישן). **חובה מקומי** (claude_session). | ידני חד-פעמי אחרי deploy של פיצול-הסמכות | | `halacha_batch_reconcile.py` | python | **#82.7** — dedup חוצה-פסקים offline (שמרני, **dry-run בלבד**). dedup-on-insert משווה רק תוך-פסק; כאן סף מחמיר (cosine ≥0.95, `--cosine`) ולא-הרסני: מאתר זוגות הלכות near-duplicate בין פסקים שונים (pgvector `<=>` exact) עם איתות לקסיקלי (Jaccard/Levenshtein) ומדווח ל-CSV ב-`data/audit/` לסקירת היו"ר. לא מדלג/ממזג/מוחק. `--include-pending`. **`--link`** רושם את הזוגות שנמצאו כ-`equivalent_halachot` (parallel authority, #84.2 — קישור-מקביל ברמת-הלכה, **לא** ציטוט; idempotent, לא-הרסני). רץ עם venv של mcp-server. אומת: 800 הלכות → 5 זוגות (קושרו). | ידני — דוח-סקירה / `--link` לקישור | | `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest `. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) | -| `audit_corpus_integrity.py` | python | בדיקה תקופתית של עקביות הקורפוס — 3 בדיקות SQL read-only על `case_law` ו-`cases`: (A) `external_upload` עם prefix פנימי `ערר`/`בל"מ`; (B) `internal_committee` חסר `chair_name`/`district`; (C) `cases.practice_area` מחוץ ל-{`rishuy_uvniya`, `betterment_levy`, `compensation_197`, `''`}. כותב log מצטבר ל-`data/logs/corpus_integrity_audit.log` ובמצב הפרות שולח wakeup ל-CEO ב-Paperclip (best-effort, רק אם `PAPERCLIP_API_URL`+`PAPERCLIP_API_KEY` מוגדרים). דגל: `--no-notify`. Idempotent, יוצא 0. **Cron יומי 07:00**: `0 7 * * * /home/chaim/legal-ai/mcp-server/.venv/bin/python /home/chaim/legal-ai/scripts/audit_corpus_integrity.py` | `0 7 * * *` (cron) | +| `audit_corpus_integrity.py` | python | בדיקה תקופתית של עקביות הקורפוס — 5 בדיקות SQL read-only על `case_law` ו-`cases`: (A) `external_upload` עם prefix פנימי `ערר`/`בל"מ`; (B) `internal_committee` חסר `chair_name`/`district`; (C) `cases.practice_area` מחוץ ל-{`rishuy_uvniya`, `betterment_levy`, `compensation_197`, `''`}; (D) תיקים מוכרעים (`final`/`exported`/`reviewed`) ללא `chair_name` (chair ריק מפיל בשקט את העתק-הסופי לקורפוס-הפסיקה — INV-G1); (E) תיקי `final` חתומים שחסרים מקורפוס-הפסיקה הפנימי (`internal_committee`). כותב log מצטבר ל-`data/logs/corpus_integrity_audit.log` ובמצב הפרות שולח wakeup ל-CEO ב-Paperclip (best-effort, רק אם `PAPERCLIP_API_URL`+`PAPERCLIP_API_KEY` מוגדרים). דגל: `--no-notify`. Idempotent, יוצא 0. **Cron יומי 07:00**: `0 7 * * * /home/chaim/legal-ai/mcp-server/.venv/bin/python /home/chaim/legal-ai/scripts/audit_corpus_integrity.py` | `0 7 * * *` (cron) | | `backfill_legal_arguments.py` | python | Backfill `legal_arguments` לתיקים עם `claims` קיימים (TaskMaster #36). מקבץ פרופוזיציות גולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד) דרך `argument_aggregator.aggregate_claims_to_arguments` (Claude CLI). תומך `--dry-run`/`--apply`/`--force`/`--case ...`. **חייב לרוץ מהמכונה המקומית** (לא קונטיינר) — `claude_session` דורש Claude CLI | ידני per-case (`python scripts/backfill_legal_arguments.py --apply --case 1017-03-26`) | | `upload_blam_decisions.py` | python | חד-פעמי (2026-05-26) — העלאת 2 החלטות בל"מ ל-`case_law` (8126/24 סופר נוח, 8047/23 הרנון) דרך `ingest_internal_decision` ישיר, עוקף MCP server שטרם נטען מחדש אחרי הוספת `proceeding_type`. **לא להריץ שוב** | חד-פעמי — להעביר ל-`.archive/` בהזדמנות | | `process_pending_blam.py` | python | חד-פעמי (2026-05-26) — הרצת metadata + halacha extraction על 2 החלטות בל"מ שעלו ב-`upload_blam_decisions.py`. עוקף MCP (אותו טעם). **לא להריץ שוב** | חד-פעמי — להעביר ל-`.archive/` בהזדמנות | diff --git a/scripts/audit_corpus_integrity.py b/scripts/audit_corpus_integrity.py index 6dee350..40c9e19 100644 --- a/scripts/audit_corpus_integrity.py +++ b/scripts/audit_corpus_integrity.py @@ -82,6 +82,28 @@ CHECK_C_SQL = ( " 'compensation_197', '') " "ORDER BY case_number" ) +# D. cases that reached a decided state but have no chair_name. An empty chair +# silently breaks the internal_committee corpus copy of the final +# (case_law_internal_chair_check) — chair must be set at source (INV-G1). +CHECK_D_SQL = ( + "SELECT id, case_number, status FROM cases " + "WHERE status IN ('final', 'exported', 'reviewed') " + "AND (chair_name IS NULL OR chair_name = '') " + "ORDER BY case_number" +) +# E. SIGNED finals that never landed in the citable precedent corpus +# (case_law, source_kind='internal_committee'). Only status='final' means the +# chair's signed decision was ingested — 'exported' is merely OUR draft DOCX +# and legitimately has no precedent copy. This is the exact failure the +# chair_name fix prevents going forward; the check catches any regression. +CHECK_E_SQL = ( + "SELECT c.id, c.case_number, c.status FROM cases c " + "WHERE c.status = 'final' " + "AND NOT EXISTS (SELECT 1 FROM case_law cl " + " WHERE cl.case_number = c.case_number " + " AND cl.source_kind = 'internal_committee') " + "ORDER BY c.case_number" +) logging.basicConfig( @@ -178,6 +200,8 @@ def _format_report( a_hits: list[dict], b_hits: list[dict], c_hits: list[dict], + d_hits: list[dict], + e_hits: list[dict], ts: datetime, ) -> str: parts: list[str] = [] @@ -215,6 +239,29 @@ def _format_report( if len(c_hits) > 50: parts.append(f" ... ({len(c_hits) - 50} more truncated)") parts.append("") + parts.append( + f"Check D (decided cases missing chair_name): {len(d_hits)} hit(s)" + ) + for row in d_hits[:50]: + parts.append( + f" - id={row['id']} case_number={row['case_number']!r} " + f"status={row.get('status')!r}" + ) + if len(d_hits) > 50: + parts.append(f" ... ({len(d_hits) - 50} more truncated)") + parts.append("") + parts.append( + f"Check E (signed-final cases missing from internal_committee " + f"precedent corpus): {len(e_hits)} hit(s)" + ) + for row in e_hits[:50]: + parts.append( + f" - id={row['id']} case_number={row['case_number']!r} " + f"status={row.get('status')!r}" + ) + if len(e_hits) > 50: + parts.append(f" ... ({len(e_hits) - 50} more truncated)") + parts.append("") return "\n".join(parts) @@ -225,12 +272,14 @@ async def main(args: argparse.Namespace) -> int: a_hits = await _run_check(conn, CHECK_A_SQL) b_hits = await _run_check(conn, CHECK_B_SQL) c_hits = await _run_check(conn, CHECK_C_SQL) + d_hits = await _run_check(conn, CHECK_D_SQL) + e_hits = await _run_check(conn, CHECK_E_SQL) finally: await conn.close() - total = len(a_hits) + len(b_hits) + len(c_hits) + total = len(a_hits) + len(b_hits) + len(c_hits) + len(d_hits) + len(e_hits) ts = datetime.now(timezone.utc) - report = _format_report(a_hits, b_hits, c_hits, ts) + report = _format_report(a_hits, b_hits, c_hits, d_hits, e_hits, ts) # Always write to log (creates dir + file if missing). LOG_PATH.parent.mkdir(parents=True, exist_ok=True) @@ -246,8 +295,8 @@ async def main(args: argparse.Namespace) -> int: return 0 logger.warning( - "found %d total violation(s) (A=%d, B=%d, C=%d)", - total, len(a_hits), len(b_hits), len(c_hits), + "found %d total violation(s) (A=%d, B=%d, C=%d, D=%d, E=%d)", + total, len(a_hits), len(b_hits), len(c_hits), len(d_hits), len(e_hits), ) if args.notify: @@ -256,6 +305,8 @@ async def main(args: argparse.Namespace) -> int: f"- Check A (external_upload עם prefix פנימי): {len(a_hits)}", f"- Check B (internal_committee חסר chair/district): {len(b_hits)}", f"- Check C (cases.practice_area לא תקין): {len(c_hits)}", + f"- Check D (תיקים מוכרעים ללא chair_name): {len(d_hits)}", + f"- Check E (סופיים חסרים מקורפוס-הפסיקה הפנימי): {len(e_hits)}", "", f"פירוט מלא: {LOG_PATH}", ] diff --git a/scripts/final_learning_pipeline.py b/scripts/final_learning_pipeline.py index 6bb8733..7afa7c6 100644 --- a/scripts/final_learning_pipeline.py +++ b/scripts/final_learning_pipeline.py @@ -112,6 +112,11 @@ async def main(args: argparse.Namespace) -> int: ds = d.get("diff_stats", {}) print(f" ✓ change {ds.get('change_percent')}% · lessons {d.get('lessons_count')} " f"· new_expr {d.get('new_expressions')}") + # Surface (do not swallow) a failed precedent-corpus copy so the final + # does not silently miss the citable internal_committee library. + if d.get("internal_corpus_ingested") is False: + print(f" ⚠️ קורפוס-פסיקה: ההעתק הפנימי (internal_committee) לא נוצר — " + f"{d.get('internal_corpus_error', 'סיבה לא ידועה')}", flush=True) return {"ingest": "done"} async def step_enroll(results: dict) -> dict: diff --git a/web/app.py b/web/app.py index 23a2255..9e209b7 100644 --- a/web/app.py +++ b/web/app.py @@ -3492,20 +3492,10 @@ async def api_mark_final(case_number: str, filename: str): } -# Our committee's standing chair — internal_committee rows REQUIRE a chair_name -# (DB constraint case_law_internal_chair_check). Both CMP (1xxx) and CMPA (8/9xxx) -# are currently chaired by Dafna Tamir; map by prefix so adding a chair later is local. -COMMITTEE_CHAIR_BY_PREFIX = {"1": "דפנה תמיר", "8": "דפנה תמיר", "9": "דפנה תמיר"} -COMMITTEE_CHAIR_DEFAULT = "דפנה תמיר" - - -def _committee_chair_for_case(case: dict, case_number: str) -> str: - """Resolve the chair for one of OUR decisions deterministically (no LLM): the case's - own chair_name, else the committee default by case-number prefix.""" - existing = (case.get("chair_name") or "").strip() - if existing: - return existing - return COMMITTEE_CHAIR_BY_PREFIX.get(case_number[:1], COMMITTEE_CHAIR_DEFAULT) +# Committee chair resolution lives in ONE place — config.committee_chair_for_case +# (INV-G2: the FastAPI upload path and the MCP learning path must not drift into +# parallel chair logic). Thin alias keeps call-sites here readable. +_committee_chair_for_case = config.committee_chair_for_case def _party_name(parties) -> str: