diff --git a/apps/api/commands/ocr_validate.ts b/apps/api/commands/ocr_validate.ts new file mode 100644 index 0000000..5770314 --- /dev/null +++ b/apps/api/commands/ocr_validate.ts @@ -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 : + * - `.pdf` (ou .png/.jpg) : facture à OCRiser + * - `.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() + 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) +} diff --git a/e2e/fixtures/invoices/.gitignore b/e2e/fixtures/invoices/.gitignore new file mode 100644 index 0000000..641c29b --- /dev/null +++ b/e2e/fixtures/invoices/.gitignore @@ -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 diff --git a/e2e/fixtures/invoices/README.md b/e2e/fixtures/invoices/README.md new file mode 100644 index 0000000..adba98f --- /dev/null +++ b/e2e/fixtures/invoices/README.md @@ -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). diff --git a/package.json b/package.json index c50dd09..1fbf662 100644 --- a/package.json +++ b/package.json @@ -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"