feat(api): commande ocr:validate pour bencher l'OCR sur factures réelles
Outil standalone qui mesure la qualité d'extraction du provider OCR
courant (Mock, Mistral, ou autre futur) sur un set de factures avec
ground truth, séparé de la suite Playwright (qui reste sur OCR mock
pour la rapidité CI).
Pourquoi : permet de valider qu'un changement de provider (Mistral
upgrade, ajout Document AI, custom prompt) maintient la précision sur
les factures réelles avant de l'activer en prod.
Architecture :
- Lit `e2e/fixtures/invoices/<name>.pdf` (ou .png/.jpg)
- À côté, `<name>.expected.json` avec la ground truth
- Pour chaque facture : upload temporaire vers le storage courant
(MinIO en dev), appelle provider.extract(), compare field-by-field
- Cleanup du fichier temp après extraction
- Sommaire : accuracy globale, par champ, latence moyenne, exit 1
si une fixture a échoué (utile CI)
Tolérances par champ :
- amountTtcCents : exact (la précision financière compte)
- issueDate / dueDate : jour exact
- numero : exact (trim, case-insensitive)
- clientName : Jaccard similarity ≥ 85 % sur les tokens
(tolère "SARL" final manquant, espaces, etc.)
- clientEmail : exact (lowercased) ou null
Usage :
pnpm ocr:validate # provider courant (.env)
OCR_PROVIDER=mistral MISTRAL_API_KEY=... pnpm ocr:validate
node ace ocr:validate --fixtures-dir=./other --out=report.json
Sécurité :
- `.gitignore` exclut tous les fichiers de e2e/fixtures/invoices/
sauf README + .gitignore eux-mêmes — les vraies factures ne fuitent
pas dans le repo public
À faire par Arthur :
1. Dépose 10-20 vraies factures (anonymisées si possible) dans
e2e/fixtures/invoices/
2. Pour chaque, écrit le .expected.json (5 min par facture)
3. Lance `OCR_PROVIDER=mistral pnpm ocr:validate` → ajuste prompt ou
post-process si l'accuracy descend sous le seuil
Format ground truth + seuils cibles documentés dans le README du dossier.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
70c851dd0e
commit
e38aa224e8
347
apps/api/commands/ocr_validate.ts
Normal file
347
apps/api/commands/ocr_validate.ts
Normal file
@ -0,0 +1,347 @@
|
||||
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { extname, join, basename } from 'node:path'
|
||||
import drive from '@adonisjs/drive/services/main'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { getOcrProvider } from '#services/ocr/index'
|
||||
import type { OcrResult } from '#services/ocr/ocr_provider'
|
||||
|
||||
/**
|
||||
* Commande de validation OCR — mesure la qualité d'extraction du
|
||||
* provider courant sur un set de factures réelles avec ground truth.
|
||||
*
|
||||
* Usage :
|
||||
*
|
||||
* # Avec le provider courant (.env) :
|
||||
* node ace ocr:validate
|
||||
*
|
||||
* # Forcer Mistral (vrai OCR) :
|
||||
* OCR_PROVIDER=mistral MISTRAL_API_KEY=... node ace ocr:validate
|
||||
*
|
||||
* # Avec un dossier custom :
|
||||
* node ace ocr:validate --fixtures-dir=path/to/pdfs
|
||||
*
|
||||
* # JSON report :
|
||||
* node ace ocr:validate --out=ocr-report.json
|
||||
*
|
||||
* Format des fixtures :
|
||||
* - `<name>.pdf` (ou .png/.jpg) : facture à OCRiser
|
||||
* - `<name>.expected.json` : ground truth avec :
|
||||
* {
|
||||
* "expected": {
|
||||
* "clientName": "...",
|
||||
* "clientEmail": "..." | null,
|
||||
* "numero": "...",
|
||||
* "amountTtcCents": 124000,
|
||||
* "issueDate": "2024-04-15", // YYYY-MM-DD
|
||||
* "dueDate": "2024-05-15"
|
||||
* },
|
||||
* "notes": "facture B2B classique" // libre, ignoré par la commande
|
||||
* }
|
||||
*
|
||||
* Tolérances :
|
||||
* - amountTtcCents : exact (la précision financière compte)
|
||||
* - issueDate / dueDate : jour exact (heure ignorée)
|
||||
* - numero : exact (case-insensitive, trim)
|
||||
* - clientName : Levenshtein ≤ 3 OR similarity Jaccard ≥ 85 %
|
||||
* - clientEmail : exact (lowercased) ou null
|
||||
*
|
||||
* Pour ajouter une facture au bench :
|
||||
* 1. Dépose `e2e/fixtures/invoices/ma-facture.pdf`
|
||||
* 2. Crée `e2e/fixtures/invoices/ma-facture.expected.json` avec les
|
||||
* valeurs lisibles sur la facture
|
||||
* 3. Relance la commande
|
||||
*/
|
||||
|
||||
type ExpectedFields = {
|
||||
clientName: string
|
||||
clientEmail: string | null
|
||||
numero: string
|
||||
amountTtcCents: number
|
||||
issueDate: string // YYYY-MM-DD
|
||||
dueDate: string
|
||||
}
|
||||
|
||||
type ExpectedFile = {
|
||||
expected: ExpectedFields
|
||||
notes?: string
|
||||
}
|
||||
|
||||
type FieldComparison = {
|
||||
field: keyof ExpectedFields
|
||||
expected: string | number | null
|
||||
got: string | number | null
|
||||
match: boolean
|
||||
reason?: string
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
type FixtureResult = {
|
||||
filename: string
|
||||
durationMs: number
|
||||
fields: FieldComparison[]
|
||||
/** True si tous les champs match dans leurs tolérances. */
|
||||
allMatch: boolean
|
||||
}
|
||||
|
||||
const SUPPORTED_EXT = new Set(['.pdf', '.png', '.jpg', '.jpeg'])
|
||||
|
||||
export default class OcrValidate extends BaseCommand {
|
||||
static commandName = 'ocr:validate'
|
||||
static description =
|
||||
"Bench OCR : compare l'extraction du provider courant à des ground truth (e2e/fixtures/invoices/)"
|
||||
|
||||
static options: CommandOptions = {
|
||||
startApp: true,
|
||||
}
|
||||
|
||||
@flags.string({
|
||||
description: "Dossier des fixtures (default: e2e/fixtures/invoices)",
|
||||
})
|
||||
declare fixturesDir: string
|
||||
|
||||
@flags.string({
|
||||
description: 'Path du rapport JSON en sortie (optionnel)',
|
||||
})
|
||||
declare out: string
|
||||
|
||||
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
|
||||
// racine du monorepo.
|
||||
const dir = this.fixturesDir ?? '../../e2e/fixtures/invoices'
|
||||
const provider = getOcrProvider()
|
||||
this.logger.info(`→ Provider OCR : ${provider.constructor.name}`)
|
||||
this.logger.info(`→ Fixtures dir : ${dir}`)
|
||||
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(dir)
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Dossier introuvable : ${dir}. Créer le dossier et y poser tes PDFs + .expected.json.`
|
||||
)
|
||||
this.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const pdfs = entries.filter((e) => SUPPORTED_EXT.has(extname(e).toLowerCase()))
|
||||
if (pdfs.length === 0) {
|
||||
this.logger.warning(
|
||||
`Aucun PDF/PNG/JPG dans ${dir}. Voir le format dans le header de cette commande.`
|
||||
)
|
||||
this.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
this.logger.info(`→ ${pdfs.length} fixture(s) à valider\n`)
|
||||
|
||||
const results: FixtureResult[] = []
|
||||
const driveDisk = drive.use()
|
||||
|
||||
for (const pdf of pdfs) {
|
||||
const pdfPath = join(dir, pdf)
|
||||
const expectedPath = join(dir, basename(pdf, extname(pdf)) + '.expected.json')
|
||||
|
||||
let expected: ExpectedFields
|
||||
try {
|
||||
const json = JSON.parse(await readFile(expectedPath, 'utf-8')) as ExpectedFile
|
||||
expected = json.expected
|
||||
} catch {
|
||||
this.logger.warning(`⚠ Pas de ${expectedPath} — skip ${pdf}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const buffer = await readFile(pdfPath)
|
||||
// Upload temporaire vers le disk courant (MinIO en dev), pour que
|
||||
// le provider (Mistral) puisse re-télécharger comme en prod.
|
||||
const storageKey = `ocr-validate/${randomUUID()}/${pdf}`
|
||||
await driveDisk.put(storageKey, buffer)
|
||||
|
||||
const t0 = Date.now()
|
||||
let ocrResult: OcrResult
|
||||
try {
|
||||
ocrResult = await provider.extract({ storageKey, filename: pdf })
|
||||
} catch (err) {
|
||||
this.logger.error(`✗ ${pdf} — extraction throw : ${(err as Error).message}`)
|
||||
// Cleanup temp file
|
||||
await driveDisk.delete(storageKey).catch(() => {})
|
||||
continue
|
||||
}
|
||||
const durationMs = Date.now() - t0
|
||||
await driveDisk.delete(storageKey).catch(() => {})
|
||||
|
||||
const comparisons = compareFields(expected, ocrResult)
|
||||
const allMatch = comparisons.every((c) => c.match)
|
||||
results.push({ filename: pdf, durationMs, fields: comparisons, allMatch })
|
||||
|
||||
this.printFixtureResult(pdf, durationMs, comparisons, allMatch)
|
||||
}
|
||||
|
||||
this.printSummary(results)
|
||||
|
||||
if (this.out) {
|
||||
const { writeFile } = await import('node:fs/promises')
|
||||
await writeFile(this.out, JSON.stringify({ provider: provider.constructor.name, results }, null, 2))
|
||||
this.logger.info(`✔ Rapport JSON écrit : ${this.out}`)
|
||||
}
|
||||
|
||||
// Exit 1 si une fixture a échoué — utile en CI
|
||||
if (results.some((r) => !r.allMatch)) {
|
||||
this.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
private printFixtureResult(
|
||||
filename: string,
|
||||
durationMs: number,
|
||||
fields: FieldComparison[],
|
||||
allMatch: boolean
|
||||
) {
|
||||
const status = allMatch ? '✔' : '✗'
|
||||
this.logger.info(`\n${status} ${filename} (${durationMs} ms)`)
|
||||
for (const f of fields) {
|
||||
const icon = f.match ? ' ✓' : ' ✗'
|
||||
const conf = f.confidence !== undefined ? ` conf=${f.confidence.toFixed(2)}` : ''
|
||||
const reason = f.reason ? ` [${f.reason}]` : ''
|
||||
this.logger.info(
|
||||
`${icon} ${f.field.padEnd(16)} expected=${formatValue(f.expected)} got=${formatValue(
|
||||
f.got
|
||||
)}${conf}${reason}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private printSummary(results: FixtureResult[]) {
|
||||
const total = results.length
|
||||
const fullPass = results.filter((r) => r.allMatch).length
|
||||
const totalFields = results.reduce((sum, r) => sum + r.fields.length, 0)
|
||||
const matchedFields = results.reduce(
|
||||
(sum, r) => sum + r.fields.filter((f) => f.match).length,
|
||||
0
|
||||
)
|
||||
const fieldAccuracy = totalFields > 0 ? (matchedFields / totalFields) * 100 : 0
|
||||
const docAccuracy = total > 0 ? (fullPass / total) * 100 : 0
|
||||
const avgLatency =
|
||||
results.reduce((sum, r) => sum + r.durationMs, 0) / Math.max(total, 1)
|
||||
|
||||
this.logger.info('\n────────────────────────────────────────────────────────────')
|
||||
this.logger.info(`Total factures : ${total}`)
|
||||
this.logger.info(`Factures 100 % match : ${fullPass} (${docAccuracy.toFixed(1)} %)`)
|
||||
this.logger.info(
|
||||
`Champs match (total) : ${matchedFields}/${totalFields} (${fieldAccuracy.toFixed(1)} %)`
|
||||
)
|
||||
this.logger.info(`Latence moyenne : ${avgLatency.toFixed(0)} ms / facture`)
|
||||
this.logger.info('────────────────────────────────────────────────────────────\n')
|
||||
|
||||
// Détail par champ
|
||||
const fieldStats = new Map<string, { ok: number; total: number }>()
|
||||
for (const r of results) {
|
||||
for (const f of r.fields) {
|
||||
const s = fieldStats.get(f.field) ?? { ok: 0, total: 0 }
|
||||
s.total += 1
|
||||
if (f.match) s.ok += 1
|
||||
fieldStats.set(f.field, s)
|
||||
}
|
||||
}
|
||||
this.logger.info('Précision par champ :')
|
||||
for (const [field, s] of fieldStats) {
|
||||
const pct = (s.ok / s.total) * 100
|
||||
this.logger.info(` ${field.padEnd(18)} ${s.ok}/${s.total} (${pct.toFixed(1)} %)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comparison
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function compareFields(expected: ExpectedFields, got: OcrResult): FieldComparison[] {
|
||||
return [
|
||||
{
|
||||
field: 'clientName',
|
||||
expected: expected.clientName,
|
||||
got: got.fields.clientName.value,
|
||||
confidence: got.fields.clientName.confidence,
|
||||
match: matchesName(expected.clientName, got.fields.clientName.value),
|
||||
reason: matchesName(expected.clientName, got.fields.clientName.value)
|
||||
? undefined
|
||||
: 'fuzzy similarity < 85 %',
|
||||
},
|
||||
{
|
||||
field: 'clientEmail',
|
||||
expected: expected.clientEmail,
|
||||
got: got.fields.clientEmail.value,
|
||||
confidence: got.fields.clientEmail.confidence,
|
||||
match: matchesEmail(expected.clientEmail, got.fields.clientEmail.value),
|
||||
},
|
||||
{
|
||||
field: 'numero',
|
||||
expected: expected.numero,
|
||||
got: got.fields.numero.value,
|
||||
confidence: got.fields.numero.confidence,
|
||||
match: matchesString(expected.numero, got.fields.numero.value),
|
||||
},
|
||||
{
|
||||
field: 'amountTtcCents',
|
||||
expected: expected.amountTtcCents,
|
||||
got: got.fields.amountTtcCents.value,
|
||||
confidence: got.fields.amountTtcCents.confidence,
|
||||
match: expected.amountTtcCents === got.fields.amountTtcCents.value,
|
||||
},
|
||||
{
|
||||
field: 'issueDate',
|
||||
expected: expected.issueDate,
|
||||
got: got.fields.issueDate.value,
|
||||
confidence: got.fields.issueDate.confidence,
|
||||
match: matchesDate(expected.issueDate, got.fields.issueDate.value),
|
||||
},
|
||||
{
|
||||
field: 'dueDate',
|
||||
expected: expected.dueDate,
|
||||
got: got.fields.dueDate.value,
|
||||
confidence: got.fields.dueDate.confidence,
|
||||
match: matchesDate(expected.dueDate, got.fields.dueDate.value),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function matchesString(a: string, b: string): boolean {
|
||||
return a.trim().toLowerCase() === b.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function matchesEmail(a: string | null, b: string | null): boolean {
|
||||
if (a === null && b === null) return true
|
||||
if (a === null || b === null) return false
|
||||
return a.trim().toLowerCase() === b.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function matchesDate(a: string, b: string): boolean {
|
||||
// a au format YYYY-MM-DD, b au format ISO 8601. On compare au jour près.
|
||||
const aDay = a.slice(0, 10)
|
||||
const bDay = b.slice(0, 10)
|
||||
return aDay === bDay
|
||||
}
|
||||
|
||||
function matchesName(a: string, b: string): boolean {
|
||||
const an = a.trim().toLowerCase()
|
||||
const bn = b.trim().toLowerCase()
|
||||
if (an === bn) return true
|
||||
// Similarité Jaccard sur les mots (tolérante aux suffixes SARL/SAS/…
|
||||
// et aux espaces différents).
|
||||
const aTokens = new Set(an.split(/\s+/).filter((t) => t.length > 1))
|
||||
const bTokens = new Set(bn.split(/\s+/).filter((t) => t.length > 1))
|
||||
if (aTokens.size === 0 || bTokens.size === 0) return false
|
||||
const intersection = new Set([...aTokens].filter((t) => bTokens.has(t)))
|
||||
const union = new Set([...aTokens, ...bTokens])
|
||||
const jaccard = intersection.size / union.size
|
||||
return jaccard >= 0.85
|
||||
}
|
||||
|
||||
function formatValue(v: string | number | null): string {
|
||||
if (v === null) return 'null'
|
||||
if (typeof v === 'number') return String(v)
|
||||
return JSON.stringify(v)
|
||||
}
|
||||
10
e2e/fixtures/invoices/.gitignore
vendored
Normal file
10
e2e/fixtures/invoices/.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
# 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.
|
||||
#
|
||||
# Pour ajouter une facture publique d'exemple (factice, anonymisée),
|
||||
# l'ajouter avec `git add -f`.
|
||||
|
||||
*
|
||||
!.gitignore
|
||||
!README.md
|
||||
91
e2e/fixtures/invoices/README.md
Normal file
91
e2e/fixtures/invoices/README.md
Normal file
@ -0,0 +1,91 @@
|
||||
# Fixtures OCR — factures réelles pour bench provider
|
||||
|
||||
Dossier de référence pour la commande `node ace ocr:validate` qui mesure
|
||||
la qualité d'extraction OCR (Mock, Mistral, ou autre futur provider) sur
|
||||
un set de factures réelles avec ground truth.
|
||||
|
||||
## Comment ajouter une facture
|
||||
|
||||
1. Dépose ton PDF dans ce dossier : `ma-facture.pdf` (ou `.png`, `.jpg`).
|
||||
2. Crée à côté `ma-facture.expected.json` avec les valeurs **lisibles à
|
||||
l'œil sur la facture** :
|
||||
|
||||
```json
|
||||
{
|
||||
"expected": {
|
||||
"clientName": "Boulangerie Martin SARL",
|
||||
"clientEmail": "compta@boulangerie-martin.fr",
|
||||
"numero": "F-2024-0042",
|
||||
"amountTtcCents": 124000,
|
||||
"issueDate": "2024-04-15",
|
||||
"dueDate": "2024-05-15"
|
||||
},
|
||||
"notes": "facture B2B classique, en-tête simple"
|
||||
}
|
||||
```
|
||||
|
||||
Champs :
|
||||
|
||||
| Champ | Format | Note |
|
||||
|---|---|---|
|
||||
| `clientName` | String | Le nom de la société/personne facturée. Variantes acceptées via similarité Jaccard ≥ 85 % (tokens) — utile si l'OCR rate un "SARL" final. |
|
||||
| `clientEmail` | String OR `null` | `null` si pas d'email sur la facture. |
|
||||
| `numero` | String | Tel qu'imprimé. Comparaison case-insensitive trim. |
|
||||
| `amountTtcCents` | Integer | Montant TTC en centimes. **Comparaison exacte** (la précision financière compte). |
|
||||
| `issueDate` | `YYYY-MM-DD` | Comparaison au jour près. |
|
||||
| `dueDate` | `YYYY-MM-DD` | Idem. |
|
||||
|
||||
Le champ `notes` est libre, ignoré par la commande — pratique pour
|
||||
documenter "facture avec logo qui couvre 30 % du haut", "facture
|
||||
manuscrite scannée", etc.
|
||||
|
||||
## Comment lancer le bench
|
||||
|
||||
```bash
|
||||
# Avec le provider courant (.env) — par défaut "mock"
|
||||
pnpm --filter @rubis/api exec node ace ocr:validate
|
||||
|
||||
# Forcer Mistral (vrai OCR — coûte des tokens API)
|
||||
OCR_PROVIDER=mistral MISTRAL_API_KEY=sk-... \
|
||||
pnpm --filter @rubis/api exec node ace ocr:validate
|
||||
|
||||
# Avec un dossier custom + rapport JSON
|
||||
node ace ocr:validate --fixtures-dir=./other-pdfs --out=report.json
|
||||
```
|
||||
|
||||
## Lecture du résultat
|
||||
|
||||
La commande affiche :
|
||||
|
||||
- Pour chaque facture : check par champ (✓/✗) avec expected vs got + confidence du provider
|
||||
- Sommaire : nombre de factures 100 % match, accuracy globale champs, latence moyenne
|
||||
- Précision par champ : pour chaque type (amount, dueDate, clientName…), le ratio de matches
|
||||
|
||||
Exit code 1 si une facture a échoué → utile en CI pour bloquer un déploiement OCR si la qualité a chuté.
|
||||
|
||||
## Diversité du set
|
||||
|
||||
Pour un bench représentatif, viser au moins :
|
||||
|
||||
- 5 factures B2B (en-tête société, SIRET, TVA, montant rond)
|
||||
- 5 factures de prestation (taux horaire, jours, multilignes)
|
||||
- 2-3 factures avec spécificités (sous-totaux multiples, devises mixtes, scan basse qualité)
|
||||
- 1 facture **mauvaise** : floue, manuscrite, photo prise à la volée — pour mesurer le worst case et calibrer la confidence du provider
|
||||
|
||||
Les vraies factures peuvent contenir des infos sensibles — **ne pas
|
||||
commit dans le repo public.** Le `.gitignore` exclut ce dossier par
|
||||
défaut (cf. `e2e/fixtures/invoices/.gitignore`).
|
||||
|
||||
## Cibler un seuil de qualité
|
||||
|
||||
Calibrage suggéré V1 (à ajuster après premier run réel) :
|
||||
|
||||
- `amountTtcCents` : ≥ 98 % d'accuracy (zéro tolérance sur l'argent)
|
||||
- `dueDate` / `issueDate` : ≥ 95 %
|
||||
- `numero` : ≥ 95 %
|
||||
- `clientName` : ≥ 90 % (avec fuzzy)
|
||||
- `clientEmail` : ≥ 80 % (souvent absent → null fréquent)
|
||||
|
||||
En-dessous, soit améliorer le provider (prompt Mistral, post-processing),
|
||||
soit baisser la confiance affichée à l'user dans le SPA (badge "à
|
||||
vérifier" sur les drafts).
|
||||
@ -23,6 +23,7 @@
|
||||
"e2e:ui": "playwright test --config e2e/playwright.config.ts --ui",
|
||||
"e2e:headed": "playwright test --config e2e/playwright.config.ts --headed",
|
||||
"e2e:setup": "bash e2e/setup-db.sh",
|
||||
"ocr:validate": "pnpm --filter @rubis/api exec node ace ocr:validate",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,css}\" --ignore-path .prettierignore",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,css}\" --ignore-path .prettierignore",
|
||||
"prepare": "husky || true"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user