feat(pipeline): durable execution for final_halacha via LangGraph (P0, X16/INV-DUR1, #114)

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>
This commit is contained in:
2026-06-10 09:52:35 +00:00
parent 20781398ee
commit e7d8b24d7c
6 changed files with 303 additions and 33 deletions

View File

@@ -21,8 +21,16 @@ 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
@@ -35,6 +43,8 @@ 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
@@ -59,54 +69,89 @@ async def main(args: argparse.Namespace) -> int:
print(f"✗ תיק {case_number} לא נמצא")
return 1
chair = case.get("chair_name") or "דפנה תמיר"
# [0] extract the halachot the decision ITSELF states (its own row in case_law) —
# so they are not left pending. Idempotent: skip when already completed or on dry-run.
row = await _decision_law_row(case_number)
if not row:
print(f"[0/4] ההחלטה {case_number} אינה ב-case_law עדיין — דילוג על חילוץ-הלכות")
elif row.get("halacha_extraction_status") == "completed":
print(f"[0/4] חילוץ-הלכות מההחלטה — דולג (כבר completed)")
elif args.dry_run:
print(f"[0/4] חילוץ-הלכות מההחלטה — מדולג (dry-run)")
else:
# 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'))}")
except Exception as e:
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}"}
# [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')}")
except Exception:
print(f" (citations returned: {str(raw)[:160]})")
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"}
# [2] corroboration signal + policy (whole corpus backfill) — skipped on dry-run
if args.dry_run:
print("[2/4] corroboration_rebuild — מדולג (dry-run)")
else:
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}")
except Exception as e:
return {"corroboration": "done"}
except Exception as e: # non-fatal
print(f" ⚠ corroboration failed (non-fatal): {e}")
return {"corroboration": f"error:{e}"}
# [3] three-judge halacha panel
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))
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 rc or 0
return int(results.get("panel_rc", 0) or 0)
if __name__ == "__main__":
@@ -117,4 +162,7 @@ if __name__ == "__main__":
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())))