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
This commit is contained in:
2026-07-19 00:31:21 +02:00
co-authored by Claude Fable 5
parent bdd3d63b61
commit e9d4a2bcb2
38 changed files with 1678 additions and 44 deletions
@@ -103,21 +103,25 @@ def _num_variants(value: float) -> list[str]:
return out
DATE_FMTS_OUT = ["%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%d.%m.%Y", "%B %d, %Y", "%b %d, %Y", "%d %B %Y"]
DATE_FMTS_OUT = ["%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%d.%m.%Y"]
FR_MONTHS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet",
"août", "septembre", "octobre", "novembre", "décembre"]
FR_MONTHS_ABBR = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.",
"août", "sept.", "oct.", "nov.", "déc."]
EN_MONTHS = ["january", "february", "march", "april", "may", "june", "july",
"august", "september", "october", "november", "december"]
def _date_variants(iso: str) -> list[str]:
d = datetime.strptime(iso, "%Y-%m-%d").date()
out = []
for fmt in DATE_FMTS_OUT:
try:
out.append(d.strftime(fmt))
except ValueError:
pass
out.append(f"{d.day} {FR_MONTHS[d.month - 1]} {d.year}")
out.append(f"{d.day:02d} {FR_MONTHS[d.month - 1]} {d.year}")
out.append(d.strftime(fmt))
for months in (FR_MONTHS, FR_MONTHS_ABBR, EN_MONTHS, [m[:3] for m in EN_MONTHS]):
m = months[d.month - 1]
for day in (str(d.day), f"{d.day:02d}"):
out.append(f"{day} {m} {d.year}") # 2 avr. 2026 / 24 octobre 2025
out.append(f"{m} {day}, {d.year}") # april 2, 2026 / apr 02, 2026
return out
@@ -130,8 +134,9 @@ def locate(value, kind: str, text: str) -> str | None:
if v in text:
return v
elif kind == "date":
low = text.lower()
for v in _date_variants(str(value)):
if v in text:
if v.lower() in low:
return v
elif kind == "iban":
target = re.sub(r"[  ]", "", str(value)).upper()
@@ -141,6 +146,16 @@ def locate(value, kind: str, text: str) -> str | None:
else: # ref and other verbatim strings
if str(value) in text:
return str(value)
# layout texts wrap long refs across lines: match modulo whitespace
nows = re.sub(r"\s+", "", str(value))
if nows and nows in re.sub(r"\s+", "", text):
return str(value) + " (whitespace-wrapped in source)"
# two-column layouts interleave foreign text at the wrap point: accept
# when both halves of one split are literal in the source (each ≥ 4 chars)
s = str(value)
for i in range(4, len(s) - 3):
if s[:i] in text and s[i:] in text:
return f"{s[:i]}{s[i:]} (line-wrapped across columns)"
return None
@@ -160,13 +175,16 @@ def check(payload: dict, text: str, source_sha256: str) -> tuple[dict | None, li
if abs(ht + tva - ttc) > 0.01:
r.append(f"arithmetic: HT {ht} + TVA {tva} != TTC {ttc}")
if per_rate:
if abs(sum(float(x["ht"]) for x in per_rate) - ht) > 0.01:
r.append("arithmetic: sum(per_rate.ht) != totals.ht")
if abs(sum(float(x["tva"]) for x in per_rate) - tva) > 0.01:
r.append("arithmetic: sum(per_rate.tva) != totals.tva")
for x in per_rate:
if float(x["rate"]) not in VALID_RATES and not rc:
r.append(f"rate {x['rate']} outside {sorted(VALID_RATES)} without reverse_charge")
try:
if abs(sum(float(x["ht"]) for x in per_rate) - ht) > 0.01:
r.append("arithmetic: sum(per_rate.ht) != totals.ht")
if abs(sum(float(x["tva"]) for x in per_rate) - tva) > 0.01:
r.append("arithmetic: sum(per_rate.tva) != totals.tva")
for x in per_rate:
if float(x["rate"]) not in VALID_RATES and not rc:
r.append(f"rate {x['rate']} outside {sorted(VALID_RATES)} without reverse_charge")
except (KeyError, TypeError, ValueError):
r.append("per_rate malformed (null or non-numeric rate/ht/tva)")
else:
r.append("per_rate empty")