scripts/_pipeline_runtime.py — runtime עמידות משותף: עוטף רשימת-צעדים async ב-LangGraph StateGraph ליניארי עם AsyncSqliteSaver (checkpoint לכל צעד). קריסה/OOM ממשיכה מהצעד שנכשל במקום להריץ הכל מחדש. degradation חיננית: ללא langgraph → ריצה ליניארית כמו קודם (הכפתור לא נשבר). מימוש אחד לשני הפייפליינים (G2). final_halacha_pipeline.py — 4 הצעדים ([0]extract [1]citations [2]corroboration [3]panel) רצים דרך ה-runtime. CLI זהה + --fresh (ברירת-מחדל auto-resume). thread יציב לכל תיק; dry-run = preview נפרד (תמיד fresh). קריסה בפאנל [3] → resume מ-[3] (steps 0-2 שמורים). pyproject: extra "durable" (langgraph + langgraph-checkpoint-sqlite) — host-only, optional. data/checkpoints/ ב-.gitignore. גבול (X16 §1): LangGraph רק כמנוע-פנימי של הסקריפט — לא orchestrator (לא מסלול מקביל ל-Paperclip; G2/G12). #108 (atomic extract) קדם לזה כתנאי. אימות: test_pipeline_runtime.py — עם langgraph (venv-זמני): 3 passed (resume מדלג צעדים שהושלמו · fresh מריץ-מחדש · linear). בלי langgraph (venv משותף): 1 passed + 2 skipped (degradation). final_halacha מתקמפל ומיובא נקי בשני המצבים. הרצה end-to-end על הפייפליין החי (DB+LLM) — לאחר `pip install -e ".[durable]"` בעץ הראשי. Invariants: INV-DUR1 (עמידות), G2 (runtime יחיד), G3 (idempotency מחוזק). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
169 lines
8.3 KiB
Python
169 lines
8.3 KiB
Python
#!/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())))
|