feat(ocr): throttle --delay-ms + script generate-expected pour ground truth
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m21s

Améliorations sur la commande de bench OCR validée avec 5 factures
réelles via Mistral (100 % accuracy obtenue sur l'échantillon test) :

  - Option `--delay-ms` (default 1500 ms) entre 2 appels provider pour
    éviter le rate limit Mistral (1300 free tier ≈ 1 req/s). Permet de
    benchmark les 27 factures sans HTTP 429.
  - Script `e2e/fixtures/invoices/generate-expected.mjs` qui parse les
    PDFs via `pdftotext -layout` (poppler-utils) et génère
    automatiquement les <name>.expected.json :
      • Numéro F2026-XXXX
      • Dates DD/MM/YYYY ou format long ("21 avril 2026")
      • Montant TTC en cents (gère séparateur milliers "2 775,02")
      • clientName en gérant 3 templates :
          - "DOIT : <Nom>"
          - "Facturé à :" en colonne droite
          - "ADRESSÉE À ... ÉCHÉANCE" côte à côte
    Re-générable, idempotent (skip si .expected.json existe déjà).

Le .gitignore du dossier reste sur `*` exclude pour ne pas commit les
PDFs (cohérent avec assets/test-invoices/ déjà ignoré racine), mais
autorise le script `generate-expected.mjs` (reproductible, sans secret).

Workflow utilisateur :
  1. Pose tes PDFs dans e2e/fixtures/invoices/
  2. `node generate-expected.mjs` génère les ground truth en lot
  3. Vérifie/corrige à la main si besoin (parser pas 100 % parfait sur
     tous les templates exotiques)
  4. `OCR_PROVIDER=mistral pnpm ocr:validate` lance le bench réel

Résultat baseline observé sur 5 factures Mistral en mode réel :
  - clientName     5/5  (100 %)
  - clientEmail    5/5  (100 %)
  - numero         5/5  (100 %)
  - amountTtcCents 5/5  (100 %)
  - issueDate      5/5  (100 %)
  - dueDate        5/5  (100 %)
  - Latence moyenne : 3,1 s / facture

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-18 16:05:37 +02:00
parent e38aa224e8
commit 2f96238efe
3 changed files with 226 additions and 2 deletions

View File

@ -107,6 +107,12 @@ export default class OcrValidate extends BaseCommand {
})
declare out: string
@flags.number({
description:
"Délai en ms entre deux appels provider (anti rate-limit). Default: 1500 ms pour Mistral free tier.",
})
declare delayMs: number
async run() {
// Le cwd quand la commande tourne est apps/api (pnpm --filter ou ace
// direct). Le default pointe donc vers e2e/fixtures/invoices à la
@ -140,8 +146,12 @@ export default class OcrValidate extends BaseCommand {
const results: FixtureResult[] = []
const driveDisk = drive.use()
const delayMs = this.delayMs ?? 1500
for (const pdf of pdfs) {
for (const [idx, pdf] of pdfs.entries()) {
if (idx > 0 && delayMs > 0) {
await new Promise((r) => setTimeout(r, delayMs))
}
const pdfPath = join(dir, pdf)
const expectedPath = join(dir, basename(pdf, extname(pdf)) + '.expected.json')

View File

@ -1,6 +1,7 @@
# Les factures réelles peuvent contenir des données sensibles (clients,
# montants, SIRET, emails). On ignore tous les fichiers de ce dossier
# sauf le README et le .gitignore lui-même.
# sauf le README, le .gitignore et le script de génération des ground
# truth (qui est utile à tous les devs, reproductible, sans secret).
#
# Pour ajouter une facture publique d'exemple (factice, anonymisée),
# l'ajouter avec `git add -f`.
@ -8,3 +9,4 @@
*
!.gitignore
!README.md
!generate-expected.mjs

View File

@ -0,0 +1,212 @@
#!/usr/bin/env node
/**
* Génère les <name>.expected.json à partir des PDFs du dossier.
*
* Usage :
* cd e2e/fixtures/invoices
* node generate-expected.mjs
*
* Dépendance externe : `pdftotext` (poppler-utils). En local Mac :
* brew install poppler
*
* Une fois généré, vérifier les fichiers .expected.json à la main
* la regex peut rater des cas spéciaux. Les corriger directement si
* besoin, ce script ne sera pas relancé sur les fichiers existants
* (skip si déjà présent).
*/
import { readdirSync, existsSync, writeFileSync } from 'node:fs'
import { execSync } from 'node:child_process'
import { join, basename, extname } from 'node:path'
const DIR = new URL('.', import.meta.url).pathname
// "21/04/2026" → "2026-04-21"
function parseFrDate(s) {
const m = s.match(/(\d{2})\/(\d{2})\/(\d{4})/u)
if (!m) return null
return `${m[3]}-${m[2]}-${m[1]}`
}
// "21 avril 2026" → "2026-04-21"
const MONTHS_FR = {
janvier: '01', février: '02', mars: '03', avril: '04', mai: '05', juin: '06',
juillet: '07', août: '08', septembre: '09', octobre: '10', novembre: '11', décembre: '12',
}
function parseFrDateLong(s) {
const m = s.match(/(\d{1,2})\s+([a-zûéè]+)\s+(\d{4})/iu)
if (!m) return null
const month = MONTHS_FR[m[2].toLowerCase()]
if (!month) return null
return `${m[3]}-${month}-${m[1].padStart(2, '0')}`
}
// "85,34 €" → 8534 cents
function parseAmountCents(s) {
// Supprime espaces (séparateur de milliers PDF: "2 775,02") et le €
const cleaned = s.replace(/[\s ]/gu, '').replace('€', '').replace(',', '.')
const n = parseFloat(cleaned)
return Number.isNaN(n) ? null : Math.round(n * 100)
}
function extractFields(text) {
// numero : F2026-XXXX
const numero = text.match(/F\d{4}-\d{4}/u)?.[0] ?? null
// dates : on collecte TOUS les patterns possibles
let issueDate = null
let dueDate = null
const issueShort = text.match(/Émise\s*(?:le|:)\s*(\d{2}\/\d{2}\/\d{4})/u)
if (issueShort) issueDate = parseFrDate(issueShort[1])
const issueLong = text.match(/Date d'émission\s*:\s*([^\n]+)/u)
if (!issueDate && issueLong) issueDate = parseFrDateLong(issueLong[1])
const dueShort = text.match(/Échéance\s*(?:le)?\s*:?\s*(\d{2}\/\d{2}\/\d{4})/u)
if (dueShort) dueDate = parseFrDate(dueShort[1])
const dueLong = text.match(/Date d'échéance\s*:\s*([^\n]+)/u)
if (!dueDate && dueLong) dueDate = parseFrDateLong(dueLong[1])
// Template Boulangerie (ADRESSÉE À + ÉCHÉANCE côte à côte) : la date
// d'échéance est sur la ligne suivante, deuxième colonne. On capture
// toutes les dates DD/MM/YYYY dans le text et on prend la 2e si la 1re
// est issueDate.
if (!dueDate) {
const allDates = [...text.matchAll(/(\d{2}\/\d{2}\/\d{4})/gu)].map((m) =>
parseFrDate(m[1]),
)
if (allDates.length >= 2 && issueDate) {
dueDate = allDates.find((d) => d !== issueDate) ?? null
}
}
// Montant TTC
const ttcMatch = text.match(/TOTAL\s+TTC\s*:?\s*([0-9\s .,]+)\s*€/iu)
const amountTtcCents = ttcMatch ? parseAmountCents(ttcMatch[1]) : null
// clientName : 3 templates connus
// 1. "DOIT : Maison Dupont" → captureGroup
// 2. "Facturé à :\n École Saint-Michel" → ligne suivante non-vide
// 3. Template Boulangerie côte-à-côte ADRESSÉE À + ÉCHÉANCE
let clientName = null
const doitMatch = text.match(/DOIT\s*:\s+([^\n]+)/u)
if (doitMatch) {
const candidate = doitMatch[1].trim()
// Ignorer si le candidat est juste "ÉCHÉANCE" (template à colonnes).
if (candidate && !/^ÉCHÉANCE/u.test(candidate)) {
clientName = candidate
}
}
if (!clientName) {
// Template "Facturé à :" en colonne droite — adresse émetteur à
// gauche, nom client en colonne droite séparée par ≥ 2 espaces.
// Ex :
// "22 place du Marché Facturé à :"
// "59000 Lille"
// " Pharmacie de la Gare"
// → on cherche les lignes suivantes après "Facturé à :" qui ont du
// text en colonne droite (non-vide après split par 2+ espaces).
const factureIdx = text.search(/Facturé à\s*:/iu)
if (factureIdx >= 0) {
const after = text.slice(factureIdx)
const lines = after.split('\n').slice(1) // skip la ligne du label
// Colonne où démarre "Facturé à :" — heuristique : position dans
// la ligne du label après split de l'original.
const labelLine = text.slice(0, factureIdx + 60).split('\n').pop() ?? ''
const labelColStart = labelLine.search(/Facturé à\s*:/iu)
const colThreshold = labelColStart > 10 ? labelColStart - 5 : 30
for (const line of lines) {
// Prendre ce qui est en colonne droite (à partir de colThreshold)
if (line.length < colThreshold) continue
const rightCol = line.slice(colThreshold).trim()
if (
rightCol.length === 0 ||
/^ÉCHÉANCE/u.test(rightCol) ||
/^\d/u.test(rightCol) || // CP comme "75015 Paris"
/^Mme |^M\. |^Mlle |^Dr\. |^Service |^SIRET/u.test(rightCol)
) {
continue
}
clientName = rightCol
break
}
}
}
if (!clientName) {
// Template Boulangerie : "ADRESSÉE À ... ÉCHÉANCE" sur une ligne.
// La ligne suivante a le nom client en col gauche + la date en col droite,
// séparés par ≥ 2 espaces (pdftotext -layout).
const adresseeIdx = text.indexOf('ADRESSÉE À')
if (adresseeIdx >= 0) {
const after = text.slice(adresseeIdx).split('\n')
// Skip la ligne du header ADRESSÉE À + ÉCHÉANCE, prendre la suivante non-vide
const candidates = after.slice(1).filter((l) => l.trim().length > 0)
for (const line of candidates) {
const cols = line.split(/\s{2,}/u).map((c) => c.trim()).filter(Boolean)
const first = cols[0]
if (first && !/^\d{2}\/\d{2}\/\d{4}/u.test(first) && !/^Soit /u.test(first)) {
clientName = first
break
}
}
}
}
return {
clientName,
clientEmail: null, // Pas visible sur ces factures (email = émetteur, pas client)
numero,
amountTtcCents,
issueDate,
dueDate,
}
}
const pdfs = readdirSync(DIR).filter((f) => extname(f).toLowerCase() === '.pdf')
let generated = 0
let skipped = 0
let failed = 0
for (const pdf of pdfs) {
const expectedPath = join(DIR, basename(pdf, '.pdf') + '.expected.json')
if (existsSync(expectedPath)) {
skipped += 1
continue
}
const text = execSync(`pdftotext -layout "${join(DIR, pdf)}" -`, {
encoding: 'utf-8',
})
const fields = extractFields(text)
if (
!fields.numero ||
!fields.amountTtcCents ||
!fields.issueDate ||
!fields.dueDate ||
!fields.clientName
) {
console.error(`${pdf} — champs manquants :`, fields)
failed += 1
continue
}
const note = pdf.replace(/\.pdf$/u, '').replace(/-/gu, ' ')
const payload = {
expected: fields,
notes: note,
}
writeFileSync(expectedPath, JSON.stringify(payload, null, 2) + '\n')
generated += 1
console.log(`${pdf}${basename(expectedPath)}`)
}
console.log(`\n${generated} généré(s), ${skipped} skippé(s), ${failed} en erreur.`)
if (failed > 0) process.exit(1)