#!/usr/bin/env python3 """One-shot LOCAL pipeline for the 'run-halacha' button (halacha validation). The /api/cases/{case}/final/run-halacha endpoint wakes the Hermes curator, which runs THIS single deterministic command (the 3-judge panel uses local DeepSeek+Gemini keys + the local claude CLI, so it can't run inside the container). Steps: [0] precedent_extract_halachot → extract the halachot the DECISION ITSELF states (its own case_law row), so they aren't left pending. Idempotent. [1] extract_internal_citations(chair) → links the citation graph for the chair's decisions (idempotent; ON CONFLICT DO NOTHING). [2] corroboration_rebuild → builds the citation-treatment signal and applies the corroborated→approved / overruled→pending policy (X11 Phase 2). [3] halacha_panel_approve --apply → 3 judges (Opus+DeepSeek+Gemini); agreement auto-approves/rejects (reversible, CSV-backed); splits/defects → chair (INV-G10). NB: per-precedent halacha extraction for newly-cited precedents is NOT automated here (it needs each cited precedent to be in the library with a known case_law_id) — the chair drives that from /precedents when a missing precedent is added. Local-only. Idempotent. The panel pass over the full pending queue can take minutes. Durable (X16 / INV-DUR1): the 4 steps run through scripts/_pipeline_runtime.py with a SQLite checkpoint per case (data/checkpoints/halacha.sqlite). A crash/OOM in the long panel [3] RESUMES from [3] on the next run instead of re-paying [0]–[2]. Default = auto-resume an interrupted run; ``--fresh`` forces a clean run from [0]. Requires the host extra ``pip install -e ".[durable]"`` (mcp-server); without it the steps run linearly (same as before) — the button never breaks. cd ~/legal-ai/mcp-server .venv/bin/python ../scripts/final_halacha_pipeline.py --case 8126-03-25 .venv/bin/python ../scripts/final_halacha_pipeline.py --case 8126-03-25 --fresh """ from __future__ import annotations import argparse import asyncio import json import sys from argparse import Namespace from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import _pipeline_runtime # noqa: E402 — durable runtime (X16); scripts/ on sys.path from legal_mcp import config # noqa: E402 from legal_mcp.services import corroboration, db # noqa: E402 from legal_mcp.tools.citations import extract_internal_citations # noqa: E402 from legal_mcp.tools.precedent_library import precedent_extract_halachot # noqa: E402 async def _decision_law_row(case_number: str) -> dict | None: """The case's own decision row in case_law (internal_committee), if enrolled.""" pool = await db.get_pool() async with pool.acquire() as conn: r = await conn.fetchrow( "SELECT id, halacha_extraction_status FROM case_law WHERE case_number = $1 " "AND source_kind = 'internal_committee' ORDER BY created_at DESC LIMIT 1", case_number, ) return dict(r) if r else None async def main(args: argparse.Namespace) -> int: case_number = args.case case = await db.get_case_by_number(case_number) if not case: print(f"✗ תיק {case_number} לא נמצא") return 1 chair = case.get("chair_name") or "דפנה תמיר" row = await _decision_law_row(case_number) # The 4 steps as durable nodes (X16 / INV-DUR1): each is checkpointed the # moment it finishes, so a crash/OOM in the long panel [3] resumes from [3] # instead of re-paying [0]–[2]. Steps [0] and [2] stay non-fatal (record the # error and continue); [1]/[3] may raise → the graph halts and the next run # resumes there. All steps are idempotent, so a fresh re-run is also safe. async def step_extract(results: dict) -> dict: # [0] extract the halachot the decision ITSELF states (its own case_law row). if not row: print(f"[0/4] ההחלטה {case_number} אינה ב-case_law עדיין — דילוג על חילוץ-הלכות") return {"extract": "skipped:not-enrolled"} if row.get("halacha_extraction_status") == "completed": print("[0/4] חילוץ-הלכות מההחלטה — דולג (כבר completed)") return {"extract": "skipped:completed"} if args.dry_run: print("[0/4] חילוץ-הלכות מההחלטה — מדולג (dry-run)") return {"extract": "skipped:dry-run"} print(f"[0/4] precedent_extract_halachot (החלטה {case_number})…", flush=True) try: raw0 = await precedent_extract_halachot(str(row["id"])) d0 = json.loads(raw0).get("data", {}) print(f" ✓ status={d0.get('status')} stored={d0.get('stored', d0.get('extracted'))}") return {"extract": d0.get("status", "done")} except Exception as e: # non-fatal — record and continue print(f" ⚠ halacha extraction failed (non-fatal): {e}") return {"extract": f"error:{e}"} async def step_citations(results: dict) -> dict: # [1] citation graph print(f"[1/4] extract_internal_citations (chair={chair})…", flush=True) raw = await extract_internal_citations(chair_name=chair, limit=0) try: d = json.loads(raw).get("data", {}) print(f" ✓ extracted {d.get('extracted')} · linked {d.get('linked')} " f"· new {d.get('new')}") return {"citations": "done"} except Exception: print(f" (citations returned: {str(raw)[:160]})") return {"citations": "unparsed"} async def step_corroboration(results: dict) -> dict: # [2] corroboration signal + policy (whole corpus backfill) — skip on dry-run. if args.dry_run: print("[2/4] corroboration_rebuild — מדולג (dry-run)") return {"corroboration": "skipped:dry-run"} print("[2/4] corroboration_rebuild (backfill)…", flush=True) try: cr = await corroboration.build_all() print(f" ✓ {cr}") return {"corroboration": "done"} except Exception as e: # non-fatal print(f" ⚠ corroboration failed (non-fatal): {e}") return {"corroboration": f"error:{e}"} async def step_panel(results: dict) -> dict: # [3] three-judge halacha panel (the long step durability protects). apply = not args.dry_run print(f"[3/4] halacha_panel_approve {'--apply' if apply else '(dry-run)'} " f"(Opus+DeepSeek+Gemini)…", flush=True) import halacha_panel_approve as hpa rc = await hpa.main(Namespace(limit=args.limit, concurrency=6, apply=apply)) return {"panel_rc": rc or 0} steps = [ _pipeline_runtime.Step("extract_decision_halachot", step_extract), _pipeline_runtime.Step("citations", step_citations), _pipeline_runtime.Step("corroboration", step_corroboration), _pipeline_runtime.Step("panel", step_panel), ] checkpoint_db = config.DATA_DIR / "checkpoints" / "halacha.sqlite" # Stable thread per case → an interrupted real run resumes; dry-runs are # previews (own thread, always fresh — never resume a stale preview). thread_id = f"halacha:{case_number}" + (":dryrun" if args.dry_run else "") results = await _pipeline_runtime.run_pipeline( steps, thread_id=thread_id, checkpoint_db=checkpoint_db, fresh=bool(args.fresh) or args.dry_run, ) print("\n✓ pipeline-אימות-הלכות הושלם" + (" (dry-run)" if args.dry_run else "")) return int(results.get("panel_rc", 0) or 0) if __name__ == "__main__": ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--case", required=True, help="case_number, e.g. 8126-03-25") ap.add_argument("--limit", type=int, default=0, help="cap pending halachot judged (0 = full queue)") ap.add_argument("--dry-run", dest="dry_run", action="store_true", help="citations only; skip corroboration writes; panel in dry-run") ap.add_argument("--fresh", action="store_true", help="ignore any incomplete checkpoint and run from step [0] " "(default: auto-resume an interrupted run; X16/INV-DUR1)") raise SystemExit(asyncio.run(main(ap.parse_args())))