Files
erp/fleet/atoms/invoice-extract/scripts/validators.py
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

236 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""invoice-extract validators — deterministic post-conditions (atom.yaml invariants).
The LLM proposes, this module disposes: pre-screens hostile documents before any
model sees them, checks every invariant after extraction, and anchors every
critical value to a literal excerpt of the source text (anti-hallucination: a
value absent from its source can never appear in output). A failure quarantines
— refuse, never repair. Stdlib only.
"""
from __future__ import annotations
import re
from datetime import date, datetime
VALID_RATES = {0, 2.1, 5.5, 10, 20}
CRITICAL_AMOUNT_FIELDS = ("totals.ht", "totals.tva", "totals.ttc")
# --- deterministic pre-screens (run BEFORE any model call) -------------------
# Tight, tested against the full real corpus (zero hits) — see eval evidence.
INSTRUCTION_PATTERNS = [
r"merci d.ignorer",
r"ignore +(the|any|all|previous)",
r"skip +validation",
r"already +approved",
r"record +(it +)?for +payment",
r"remplace toute instruction",
r"instruction pr[eé]c[eé]dente",
r"<\s*/?\s*system\s*>",
r"tool[_ ]?call",
r"create_payment|dolibarr\.",
r"(supprim\w+|delete)\w*\W.{0,60}(record|enregistrement|entry|pr[eé]c[eé]dent)",
r"nouvelles coordonn[eé]es bancaires",
r"(votre|your) +(syst[eè]me|system|op[eé]rateur|operator|assistant|ai\b)",
r"corrige\w*\W.{0,40}(tva|vat)|correct\w*\W.{0,40}(vat|tva) +field",
r"system +override",
r"when +extracting",
r"instead of the printed",
r"do not mention",
r"set +totals?\S* +to +\d",
r"output +iban",
]
IBAN_RE = re.compile(r"\b([A-Z]{2}\d{2}(?:[  ]?[A-Z0-9]{2,4}){2,8})\b")
def iban_valid(iban: str) -> bool:
s = re.sub(r"[  ]", "", iban).upper()
if not re.fullmatch(r"[A-Z]{2}\d{2}[A-Z0-9]{10,30}", s):
return False
rearranged = s[4:] + s[:4]
digits = "".join(str(int(c, 36)) for c in rearranged)
return int(digits) % 97 == 1
def siren_valid(siren: str) -> bool:
s = re.sub(r"\D", "", siren)
if len(s) != 9:
return False
total = 0
for i, ch in enumerate(s): # Luhn, rightmost digit position 1
d = int(ch)
if (len(s) - i) % 2 == 0:
d *= 2
if d > 9:
d -= 9
total += d
return total % 10 == 0
def screen_document(text: str) -> tuple[list[str], list[str]]:
"""Screen raw document text BEFORE any model call.
Returns (quarantine_reasons, escalate_flags): instruction-shaped content is
a hard quarantine; multiple valid IBANs alone force the Claude escalation
leg (never silently resolved — e.g. a multi-document PDF); record-time
corroboration is erp#41's linter stage.
"""
reasons, flags = [], []
low = text.lower()
for pat in INSTRUCTION_PATTERNS:
m = re.search(pat, low)
if m:
reasons.append(f"instruction-pattern: /{pat}/ matched {m.group(0)[:60]!r}")
ibans = {re.sub(r"[ \u202f\xa0]", "", m.group(1)) for m in IBAN_RE.finditer(text)}
ibans = {i for i in ibans if iban_valid(i)}
if len(ibans) >= 2:
flags.append(f"multi-iban: {len(ibans)} distinct valid IBANs — IBAN choice must be escalated")
return reasons, flags
# --- number / date normalization --------------------------------------------
def _num_variants(value: float) -> list[str]:
"""Literal spellings a European invoice may use for this amount."""
out = []
for thousands, decimal in ((" ", ","), (" ", ","), ("", ","), ("", ","), (",", "."), ("", ".")):
s = f"{value:,.2f}" # 2,000.00
s = s.replace(",", "\0").replace(".", decimal).replace("\0", thousands)
out.append(s)
if value == int(value):
out.append(s[:-3]) # bare integer form: 2 000
return out
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:
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
def locate(value, kind: str, text: str) -> str | None:
"""Find a literal excerpt of `text` that spells `value`; None if absent."""
if value is None:
return None
if kind == "amount":
for v in _num_variants(float(value)):
if v in text:
return v
elif kind == "date":
low = text.lower()
for v in _date_variants(str(value)):
if v.lower() in low:
return v
elif kind == "iban":
target = re.sub(r"[  ]", "", str(value)).upper()
for m in IBAN_RE.finditer(text):
if re.sub(r"[  ]", "", m.group(1)).upper() == target:
return m.group(1)
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
# --- post-extraction invariants ----------------------------------------------
def check(payload: dict, text: str, source_sha256: str) -> tuple[dict | None, list[str]]:
"""Validate one extraction. Returns (enriched payload, []) or (None, reasons)."""
r: list[str] = []
t = payload.get("totals") or {}
per_rate = payload.get("per_rate") or []
rc = bool(payload.get("reverse_charge"))
try:
ht, tva, ttc = float(t["ht"]), float(t["tva"]), float(t["ttc"])
except (KeyError, TypeError, ValueError):
return None, ["totals missing or non-numeric"]
if abs(ht + tva - ttc) > 0.01:
r.append(f"arithmetic: HT {ht} + TVA {tva} != TTC {ttc}")
if per_rate:
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")
siren = (payload.get("supplier") or {}).get("siren")
if siren and not siren_valid(siren):
r.append(f"SIREN checksum failed: {siren}")
iban = payload.get("iban")
if iban and not iban_valid(iban):
r.append(f"IBAN mod-97 failed: {iban}")
di, dd = payload.get("date_issue"), payload.get("date_due")
try:
d_issue = datetime.strptime(di, "%Y-%m-%d").date() if di else None
d_due = datetime.strptime(dd, "%Y-%m-%d").date() if dd else None
if d_issue and d_issue > date(2030, 1, 1):
r.append("date_issue implausibly far in the future")
if d_issue and d_due and d_issue > d_due:
r.append("date_issue > date_due")
except ValueError:
r.append("dates not ISO YYYY-MM-DD")
# anti-hallucination anchor: every critical value must exist literally in source
prov = {}
anchors = [("totals.ht", ht, "amount"), ("totals.tva", tva, "amount"), ("totals.ttc", ttc, "amount"),
("ref_supplier", payload.get("ref_supplier"), "ref"), ("date_issue", di, "date")]
if dd:
anchors.append(("date_due", dd, "date"))
if iban:
anchors.append(("iban", iban, "iban"))
for field, value, kind in anchors:
if value in (None, ""):
r.append(f"critical field absent: {field}")
continue
# TVA of 0.00 under reverse charge is often not spelled out — anchor waived
if kind == "amount" and float(value) == 0.0 and rc and field == "totals.tva":
prov[field] = {"source_sha256": source_sha256, "raw_excerpt": "(reverse charge: TVA 0 not printed)"}
continue
ex = locate(value, kind, text)
if ex is None:
r.append(f"provenance: {field}={value!r} not found literally in source text")
else:
prov[field] = {"source_sha256": source_sha256, "raw_excerpt": ex}
if r:
return None, r
out = dict(payload)
out["provenance"] = prov
return out, []