From 2f96238efe0074115c86bff5503682257ac2a67a Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 18 May 2026 16:05:37 +0200 Subject: [PATCH] feat(ocr): throttle --delay-ms + script generate-expected pour ground truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 .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 : " - "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 --- apps/api/commands/ocr_validate.ts | 12 +- e2e/fixtures/invoices/.gitignore | 4 +- e2e/fixtures/invoices/generate-expected.mjs | 212 ++++++++++++++++++++ 3 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 e2e/fixtures/invoices/generate-expected.mjs diff --git a/apps/api/commands/ocr_validate.ts b/apps/api/commands/ocr_validate.ts index 5770314..9ee7479 100644 --- a/apps/api/commands/ocr_validate.ts +++ b/apps/api/commands/ocr_validate.ts @@ -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') diff --git a/e2e/fixtures/invoices/.gitignore b/e2e/fixtures/invoices/.gitignore index 641c29b..cd2b693 100644 --- a/e2e/fixtures/invoices/.gitignore +++ b/e2e/fixtures/invoices/.gitignore @@ -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 diff --git a/e2e/fixtures/invoices/generate-expected.mjs b/e2e/fixtures/invoices/generate-expected.mjs new file mode 100644 index 0000000..c659c99 --- /dev/null +++ b/e2e/fixtures/invoices/generate-expected.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +/** + * Génère les .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)