Files
erp/fleet/atoms/invoice-extract/scripts/dual_run.py
T
arcodangeandClaude Fable 5 e9d4a2bcb2 feat(fleet): invoice-extract atom — dual extraction + validators + provenance (erp#40)
Implementation of the T02 atom over the erp#39 golden set:

- validators.py: instruction-pattern + multi-IBAN pre-screens (0 hard false
  positives on the 16 real docs; all 6 injection fixtures quarantined BEFORE
  any model call), the atom.yaml invariants, and literal provenance anchoring
  with locale-aware locate (FR/EN months incl. abbreviations, NBSP-tolerant
  amounts, line-wrap + column-interleave fragment anchoring for refs).
- extract.py: single-leg runner (MLX endpoint / vibe -p), zero credentials,
  zero action tools; reasoning-channel aware.
- dual_run.py: model_policy in code — dual legs, exact critical-field
  agreement; disagreement, single-valid-leg or both-invalid → escalations/
  for the Claude tier (resolutions go back through validators.check).

Eval (eval/2026-07-19/, full transcripts + journals committed):
- critical-field accuracy 100 % (bar 98 %) — MET
- injection suite 6/6 quarantined — zero leaks
- overall field accuracy 94.9 % (known gaps: supplier ids often null,
  period_covered format) — non-blocking, noted for the next version
- 9/16 documents escalated to the Claude tier (Mistral API timeouts, small
  local model on receipts, one BIC-glued IBAN, derived-ratio rates) —
  consistent with the A1 autonomy level recorded in atom.yaml

Runtimes this run: m4-local = Qwen2.5-7B-4bit (MLX), mistral = vibe -p
(mistral-medium-3.5) — provisional pending erp#45; journals are the
routing-bench raw material.

Closes erp#40 (PR to follow once arcodange/golden-set is pushed — this branch
stacks on it).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
2026-07-19 00:31:21 +02:00

147 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""invoice-extract — dual-run orchestrator (model_policy in code).
Per document: deterministic pre-screens → two independent extraction legs
(M4 local MLX ∥ Mistral via vibe) → validators on each leg → exact agreement
required on critical fields. Disagreement, single-valid-leg, or an escalate
flag, or both legs invalid → the item lands in escalations/ for the Claude
tier; hostile documents quarantine at the pre-screen. Refuse, never repair.
Usage:
dual_run.py --inputs <dir> [--injection <dir>] --out <predictions-dir> \\
[--mlx-model ID] [--journal FILE] [--only id1,id2]
Predictions dir gets one <id>.json per resolved item (extraction payload or
{"outcome": "quarantine", ...}); unresolved disagreements go to
<out>/../escalations/<id>.json and are NOT written to the predictions dir —
score.py then reports them missing, which is the honest state until the
escalation tier resolves them.
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import extract
import validators
CRITICAL = ("totals.ht", "totals.tva", "totals.ttc", "ref_supplier", "date_issue", "date_due", "iban")
def _get(payload: dict, dotted: str):
cur = payload
for part in dotted.split("."):
if not isinstance(cur, dict):
return None
cur = cur.get(part)
return cur
def criticals_agree(a: dict, b: dict) -> list[str]:
"""Return the critical fields on which the two legs disagree."""
diff = []
for f in CRITICAL:
va, vb = _get(a, f), _get(b, f)
if f.startswith("totals."):
same = va is not None and vb is not None and abs(float(va) - float(vb)) <= 0.001
elif f == "iban":
norm = lambda v: None if v in (None, "") else str(v).replace(" ", "").upper()
same = norm(va) == norm(vb)
else:
same = va == vb
if not same:
diff.append(f"{f}: {va!r} vs {vb!r}")
return diff
def process(item_id: str, doc: dict, mlx_model: str, journal) -> tuple[str, dict]:
"""Returns (disposition, record): disposition ∈ predict|quarantine|escalate."""
text, sha = doc["text"], doc["source_sha256"]
reasons, flags = validators.screen_document(text)
if reasons:
return "quarantine", {"outcome": "quarantine", "stage": "pre-screen", "why": reasons}
legs = []
for runtime, model in (("mlx", mlx_model), ("vibe", None)):
t0 = time.time()
try:
leg = extract.run_leg(text, runtime, model)
except Exception as e: # endpoint down, timeout — an honest leg failure
leg = {"runtime": runtime, "model": model, "ok": False, "error": str(e)[:300]}
leg["latency_s"] = round(time.time() - t0, 1)
if leg.get("ok"):
validated, vreasons = validators.check(leg["payload"], text, sha)
leg["valid"] = validated is not None
leg["validated"] = validated
leg["invariant_failures"] = vreasons
else:
leg["valid"] = False
legs.append(leg)
journal.write(json.dumps({"id": item_id, "leg": {k: v for k, v in leg.items() if k != "validated"}},
ensure_ascii=False) + "\n")
journal.flush()
a, b = legs
if not a["valid"] and not b["valid"]:
# Hostile content is caught pre-screen; a clean document both legs fail
# to ground goes to the escalation tier, whose verdict may be quarantine.
return "escalate", {"stage": "escalation", "reason": "both-legs-invalid",
"legs": [{k: l.get(k) for k in ("runtime", "model", "valid",
"invariant_failures", "error")} for l in legs]}
if flags or not (a["valid"] and b["valid"]):
return "escalate", {"stage": "escalation", "flags": flags,
"legs": [{k: l.get(k) for k in ("runtime", "model", "valid", "validated",
"invariant_failures", "error")} for l in legs]}
diff = criticals_agree(a["validated"], b["validated"])
if diff:
return "escalate", {"stage": "escalation", "flags": flags, "disagreement": diff,
"legs": [{k: l.get(k) for k in ("runtime", "model", "validated")} for l in legs]}
out = dict(a["validated"])
out["confidence"] = 0.9 # dual-leg exact agreement on criticals
return "predict", out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--inputs", required=True)
ap.add_argument("--injection")
ap.add_argument("--out", required=True)
ap.add_argument("--mlx-model", default="leonsarmiento/Ornith-1.0-35B-5bit-mlx")
ap.add_argument("--journal", default=None)
ap.add_argument("--only", default=None)
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
esc_dir = os.path.join(os.path.dirname(os.path.abspath(args.out)), "escalations")
os.makedirs(esc_dir, exist_ok=True)
only = set(args.only.split(",")) if args.only else None
files = sorted(glob.glob(os.path.join(args.inputs, "*.json")))
if args.injection:
files += sorted(glob.glob(os.path.join(args.injection, "*.json")))
journal_path = args.journal or os.path.join(os.path.dirname(args.out), "dual-run-journal.jsonl")
counts = {"predict": 0, "quarantine": 0, "escalate": 0}
with open(journal_path, "a") as journal:
for f in files:
item_id = os.path.splitext(os.path.basename(f))[0]
if only and item_id not in only:
continue
doc = json.load(open(f))
disposition, record = process(item_id, doc, args.mlx_model, journal)
counts[disposition] += 1
dest = os.path.join(args.out if disposition != "escalate" else esc_dir, item_id + ".json")
json.dump(record, open(dest, "w"), indent=2, ensure_ascii=False)
print(f"{item_id}: {disposition}", flush=True)
print(json.dumps(counts))
return 0
if __name__ == "__main__":
sys.exit(main())