Files
erp/.claude/skills/arcodange-email-ingest/scripts/test_extract.py
T
arcodangeandClaude Opus 5 4d1e3ecb23 fix(email-ingest): extraction testable et pinnée au golden set + adc-008
L'extraction de champs vivait dans un heredoc à l'intérieur d'email-inspect.sh :
impossible à exécuter isolément, donc jamais mesurée, donc fausse sans que
personne puisse le voir. Sur la facture Darnis F1048 elle renvoyait le numéro de
TVA d'Arcodange comme référence de facture, et aucune date.

- extract_fields.py : l'extraction sort du shell et devient un module.
- test_extract.py : régression contre les 16 factures hand-vérifiées de
  fleet/golden/invoice-extract/. Score par champ, et une valeur FAUSSE pèse plus
  qu'une valeur absente — un humain recopie ce qui s'affiche.

Valeurs fausses : 4 → 0. Exactitude ref 62,5 → 75 %, date 62,5 → 75 %,
HT 68,8 → 75 %, TTC 81,2 → 93,8 %.

Cinq bugs réels, dont trois invisibles sans test :
- « Nº » sur les factures françaises est U+00BA (ordinal masculin), pas le signe
  degré. La classe [°o] le rate, le motif principal échoue, et le repli attrape
  le premier jeton ref-shaped du document — très souvent un numéro de TVA.
- Le filtre anti-TVA rejetait « FR73261832 », qui est la vraie référence OVH : un
  numéro FR fait exactement 11 caractères après le préfixe.
- « Montant total (HT) » était lu comme un TTC.
- Une référence coupée par la colonne (« 06-01-26- » / « payment-366753 ») était
  renvoyée amputée : le recollage doit précéder le scan, sinon la queue seule est
  trouvée en premier.
- Un `\b` après `€` ne peut jamais matcher en fin de ligne (€ n'est pas un
  caractère de mot) — la TVA n'était jamais extraite.

adc-008 : une facture fournisseur s'enregistre à SA date, même future, tant que
l'exercice (année civile) ne bascule pas. Le document fait foi ; altérer sa date
ferait diverger l'écriture de sa pièce justificative (CGI art. 289 VII).
Registre validé : 8 règles, 8 ADC, 0 erreur.

scopes.ts : 1232 (factures fournisseur) ajouté à prod-write — oubli initial,
révélé par un 403 en production sur F1048. Le pipeline s'est arrêté sans écrire.

Appliqué en production via le pipeline gated : FAF2026014 (Darnis F1048),
218,50 HT + 43,70 TVA = 262,20 TTC, validée, non réglée.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
2026-08-13 10:47:30 +02:00

102 lines
3.9 KiB
Python

#!/usr/bin/env python3
"""Regression test for extract_fields.py against the erp#39 golden set.
The email skill's extraction was a heredoc inside a shell script: impossible to
run in isolation, therefore never measured, therefore silently wrong. This pins
it to the 16 hand-verified supplier invoices in fleet/golden/invoice-extract/.
Scored per field, and a WRONG value counts against us far more than a missing
one: emitting Arcodange's VAT number as an invoice reference is worse than
emitting nothing, because a human copies it without looking.
python3 test_extract.py # summary
python3 test_extract.py --verbose # per-document diff
"""
from __future__ import annotations
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from extract_fields import extract, parse_amount
REPO = os.path.realpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", ".."))
GOLDEN = os.path.join(REPO, "fleet", "golden", "invoice-extract")
VERBOSE = "--verbose" in sys.argv
def norm_date(s):
"""dd/mm/yyyy or yyyy-mm-dd → yyyy-mm-dd; anything else stays as-is."""
if not s:
return None
s = s.strip()
if len(s) == 10 and s[4] == "-":
return s
for sep in ("/", ".", "-"):
p = s.split(sep)
if len(p) == 3 and all(x.strip().isdigit() for x in p):
d, m, y = (x.strip() for x in p)
if len(y) == 2:
y = "20" + y
if len(y) == 4:
return f"{y}-{int(m):02d}-{int(d):02d}"
return s
def main() -> int:
inputs = os.path.join(GOLDEN, "inputs")
if not os.path.isdir(inputs):
print(f"golden set not found at {GOLDEN} — skipping", file=sys.stderr)
return 0
stats = {f: {"ok": 0, "wrong": 0, "missing": 0} for f in ("invoice_ref", "invoice_date_raw", "total_ht", "total_ttc")}
docs = 0
for name in sorted(os.listdir(inputs)):
if not name.endswith(".json"):
continue
doc = json.load(open(os.path.join(inputs, name)))
exp_path = os.path.join(GOLDEN, "expected", name)
if not os.path.exists(exp_path):
continue
exp = json.load(open(exp_path))
got = extract(doc["text"])
docs += 1
checks = {
"invoice_ref": (exp.get("ref_supplier"), got.get("invoice_ref")),
"invoice_date_raw": (exp.get("date_issue"), norm_date(got.get("invoice_date_raw"))),
"total_ht": (exp.get("totals", {}).get("ht"), parse_amount(got.get("total_ht") or "")),
"total_ttc": (exp.get("totals", {}).get("ttc"), parse_amount(got.get("total_ttc") or "")),
}
for field, (want, have) in checks.items():
if have is None:
stats[field]["missing"] += 1
verdict = "MANQUE"
elif isinstance(want, (int, float)) and isinstance(have, (int, float)):
ok = abs(float(want) - float(have)) <= 0.01
stats[field]["ok" if ok else "wrong"] += 1
verdict = "ok" if ok else "FAUX"
else:
ok = str(want) == str(have)
stats[field]["ok" if ok else "wrong"] += 1
verdict = "ok" if ok else "FAUX"
if VERBOSE and verdict != "ok":
print(f" {name[:34]:<36}{field:<18}{verdict:<8}attendu={want!r} obtenu={have!r}")
print(f"\n{docs} documents du golden set\n")
print(f"{'champ':<20}{'exact':>7}{'FAUX':>7}{'absent':>9} {'exactitude'}")
print("-" * 62)
total_wrong = 0
for f, s in stats.items():
total = s["ok"] + s["wrong"] + s["missing"]
total_wrong += s["wrong"]
print(f"{f:<20}{s['ok']:>7}{s['wrong']:>7}{s['missing']:>9} {100*s['ok']/total:.1f} %")
print("-" * 62)
print(f"\nvaleurs FAUSSES (le pire cas — un humain les recopie) : {total_wrong}")
return 1 if total_wrong else 0
if __name__ == "__main__":
sys.exit(main())