diff --git a/apps/api/src/scripts/bench-tech-step-classifier.ts b/apps/api/src/scripts/bench-tech-step-classifier.ts new file mode 100644 index 0000000..c138914 --- /dev/null +++ b/apps/api/src/scripts/bench-tech-step-classifier.ts @@ -0,0 +1,250 @@ +import { performance } from "node:perf_hooks"; +import { prisma } from "../db/prisma.js"; +import { + type TechStepMatch, + techStepClassifier, +} from "../lib/recipe-matching/tech-step-matcher.js"; + +/** + * Benchmark du pipeline `node-nlp` existant (`TechStepClassifierService`, + * `tech-step-matcher.ts`) — le pendant, côté classifieur NLP classique déjà + * en production, du PoC LLM local dans + * `experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts`. Même structure + * de sortie (logs itératifs par répétition, tableau récapitulatif latence/ + * RAM) et **mêmes 7 phrases de test** ({@link TEST_SENTENCES} ci-dessous, + * recopiées à l'identique — même texte, même `id`, même `locale` — depuis + * `TEST_SENTENCES` du PoC LLM ; les deux fichiers ne peuvent pas partager un + * module commun sans casser l'autonomie du PoC LLM, volontairement hors du + * workspace pnpm, donc synchroniser les deux à la main si l'un des deux + * change) pour que les deux runs soient directement comparables phrase par + * phrase. + * + * Contrairement au PoC LLM, ce script **nécessite une vraie base Postgres** + * accessible (`.env`/`.env.test` selon l'environnement) avec la table + * `TechStep` seedée (`pnpm --filter api prisma:seed`) — `matchTechStepSpans` + * résout ses `uid` de training data vers de vrais `TechStep.id`, voir + * `tech-step-matcher.ts`. + * + * Usage : + * + * ```bash + * pnpm --filter api exec tsx src/scripts/bench-tech-step-classifier.ts + * ``` + * + * Sortie non isomorphe à celle du PoC LLM : `TechStepMatch` renvoie un + * `techStepId` (résolu ici en `key` pour être lisible) + des spans de + * caractères dans la taxonomie fine à ~26 techniques de + * `tech-step-training-data.ts` (`cook`, `fry`, `melt`, `deglaze`, + * `simmer`...), pas la structure `KitchenAction` (verbe/ingrédients/durée/ + * température/ustensiles) sur la taxonomie à 7 catégories du PoC LLM — la + * comparaison porte sur le nombre de techniques détectées par phrase, si la + * technique choisie est correcte, et le comportement sur la phrase-piège + * FR sans verbe littéral (`fr-action-implicite`), pas sur une égalité + * champ à champ. Voir la section "Méthodologie de comparaison" du + * `README.md` du PoC LLM. + */ + +/** Une phrase de test — même forme que `BenchmarkSentence` du PoC LLM. */ +interface BenchmarkSentence { + id: string; + locale: "fr" | "en"; + text: string; + note: string; +} + +/** + * Copie exacte de `TEST_SENTENCES` dans + * `experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts` — voir le + * commentaire de ce fichier pour le détail de ce que chaque phrase teste + * (multi-actions, action implicite, actions parallèles, action + * conditionnelle, simultanéité + négation, double `REST` + seuil de + * cuisson). + */ +const TEST_SENTENCES: readonly BenchmarkSentence[] = [ + { + id: "fr-multi-action", + locale: "fr", + text: "Émincez finement les oignons puis faites-les revenir 10 minutes à feu moyen dans une poêle avec un filet d'huile d'olive, puis réservez.", + note: "3 actions enchaînées (CUT, COOK, OTHER), durée + feu + ustensile explicites.", + }, + { + id: "en-multi-action", + locale: "en", + text: "Dice the tomatoes, season with salt and pepper, then simmer everything in a saucepan over low heat for about 15 minutes before letting it rest for 5 minutes.", + note: "4 actions enchaînées (CUT, SEASON, COOK, REST), deux durées distinctes à ne pas fusionner.", + }, + { + id: "fr-action-implicite", + locale: "fr", + text: "Dans une poêle chaude, faites chauffer une noix de beurre jusqu'à ce qu'il ait disparu, puis ajoutez les échalotes ciselées.", + note: "Cas piège documenté dans tech-step-matcher.ts : aucun verbe de cuisson littéral, seul le sens implique COOK (fonte du beurre).", + }, + { + id: "fr-actions-paralleles", + locale: "fr", + text: "Pendant que les pâtes cuisent 8 à 10 minutes dans une grande casserole d'eau bouillante salée, faites revenir l'ail et les champignons émincés à la poêle avec un peu de beurre jusqu'à ce qu'ils soient dorés, puis égouttez les pâtes en réservant un peu d'eau de cuisson avant de tout mélanger ensemble hors du feu.", + note: "Deux COOK simultanés (pas séquentiels) + OTHER (égoutter/réserver) + MIX final 'hors du feu' — teste la simultanéité, pas juste l'enchaînement.", + }, + { + id: "en-action-conditionnelle", + locale: "en", + text: "Whisk the eggs and sugar together until pale and fluffy, then gradually fold in the sifted flour; if the batter looks too thick, add a splash of milk, and bake at 350°F (175°C) for 25 to 30 minutes, or until a toothpick inserted in the center comes out clean.", + note: "Action conditionnelle ('if...') au milieu d'actions fermes + fin de cuisson par test de résultat plutôt que par durée fixe.", + }, + { + id: "fr-simultaneite-et-negation", + locale: "fr", + text: "Faites chauffer le lait avec la gousse de vanille fendue en deux jusqu'à frémissement, puis versez-le progressivement sur le mélange jaunes d'œufs-sucre-maïzena tout en fouettant énergiquement, avant de reverser le tout dans la casserole et de cuire à feu doux en remuant sans arrêt jusqu'à épaississement, sans jamais laisser bouillir.", + note: "MIX+COOK simultanés ('tout en fouettant'), négation explicite d'action ('sans jamais laisser bouillir') et fin de cuisson par état, pas par durée.", + }, + { + id: "en-double-rest-et-seuil-cuisson", + locale: "en", + text: "Marinate the chicken thighs in the yogurt mixture for at least 2 hours (overnight if possible), then remove them from the fridge 20 minutes before cooking, pat them dry, and grill over medium-high heat for 6-7 minutes per side until the internal temperature reaches 165°F, letting it rest for 5 minutes before slicing.", + note: "Deux REST de sens différent (marinade vs. retour à température ambiante) + durée 'par face' + température = seuil de cuisson à cœur, pas un réglage de feu.", + }, +]; + +/** Nombre de répétitions mesurées par phrase — même valeur que le PoC LLM, pour des runs comparables. */ +const REPETITIONS_PER_SENTENCE = 3; + +/** Une mesure individuelle (une répétition, une phrase). */ +interface BenchmarkSample { + sentence: BenchmarkSentence; + latencyMs: number; + /** Delta de RSS du process Node — même caveat que côté PoC LLM : une approximation bruitée par le GC, pas une mesure isolée. */ + rssDeltaBytes: number; + matches: TechStepMatch[]; +} + +/** `TechStep.id -> key` — résolu une fois avant le benchmark pour afficher des noms de technique lisibles plutôt que des ids bruts. */ +async function loadTechStepKeyById(): Promise> { + const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } }); + return new Map(techSteps.map((techStep) => [techStep.id, techStep.key])); +} + +/** + * Exécute {@link REPETITIONS_PER_SENTENCE} analyses par phrase de + * {@link TEST_SENTENCES}, en journalisant chaque répétition au fur et à + * mesure (même raisonnement que `runBenchmark` du PoC LLM : un run complet + * ne doit pas rester muet jusqu'au récapitulatif final). + */ +async function runBenchmark(): Promise { + const samples: BenchmarkSample[] = []; + const totalRuns = TEST_SENTENCES.length * REPETITIONS_PER_SENTENCE; + let runIndex = 0; + for (const [sentenceIndex, sentence] of TEST_SENTENCES.entries()) { + for (let repetition = 1; repetition <= REPETITIONS_PER_SENTENCE; repetition++) { + runIndex++; + console.info( + `[bench] (${runIndex}/${totalRuns}) phrase ${sentenceIndex + 1}/${TEST_SENTENCES.length} "${sentence.id}" (${sentence.locale}) — répétition ${repetition}/${REPETITIONS_PER_SENTENCE}...`, + ); + const rssBefore = process.memoryUsage().rss; + const startedAt = performance.now(); + try { + const matches = await techStepClassifier.matchTechStepSpans(sentence.text, sentence.locale); + const latencyMs = performance.now() - startedAt; + const rssDeltaBytes = process.memoryUsage().rss - rssBefore; + samples.push({ sentence, latencyMs, rssDeltaBytes, matches }); + console.info( + `[bench] -> ${latencyMs.toFixed(0)} ms, ${matches.length} technique(s) détectée(s), RSS ${rssDeltaBytes >= 0 ? "+" : ""}${(rssDeltaBytes / (1024 * 1024)).toFixed(1)} Mo`, + ); + } catch (err) { + console.error(`[bench] -> échec sur "${sentence.id}" (répétition ${repetition})`, err); + } + } + } + return samples; +} + +/** Une ligne de sortie détaillée, une par échantillon — matière première pour comparer à l'œil avec le PoC LLM. */ +function printDetailedResults( + samples: readonly BenchmarkSample[], + techStepKeyById: ReadonlyMap, +): void { + for (const sample of samples) { + console.info( + `\n[${sample.sentence.id}] (${sample.sentence.locale}) — ${sample.latencyMs.toFixed(0)} ms`, + ); + console.info(` texte : ${sample.sentence.text}`); + console.info(` attendu : ${sample.sentence.note}`); + console.table( + sample.matches.map((match) => ({ + technique: techStepKeyById.get(match.techStepId) ?? `#${match.techStepId}`, + mot_clé: sample.sentence.text.slice(match.start, match.end), + clause: sample.sentence.text.slice(match.contextStart, match.contextEnd).trim(), + })), + ); + } +} + +/** Une ligne du tableau récapitulatif final — mêmes colonnes que le PoC LLM (à `actions détectées` près, ici `techniques détectées`). */ +interface BenchmarkSummaryRow { + phrase: string; + langue: string; + "runs OK": number; + "latence moy. (ms)": string; + "latence min (ms)": string; + "latence max (ms)": string; + "RSS moy. (Mo)": string; + "techniques détectées": number; +} + +/** Agrège {@link BenchmarkSample}s par phrase et imprime le tableau récapitulatif du benchmark. */ +function printSummaryTable(samples: readonly BenchmarkSample[]): void { + const rows: BenchmarkSummaryRow[] = TEST_SENTENCES.map((sentence) => { + const sentenceSamples = samples.filter((sample) => sample.sentence.id === sentence.id); + const latencies = sentenceSamples.map((sample) => sample.latencyMs); + const avgLatency = latencies.reduce((sum, value) => sum + value, 0) / (latencies.length || 1); + const avgRssMb = + sentenceSamples.reduce((sum, sample) => sum + sample.rssDeltaBytes, 0) / + (sentenceSamples.length || 1) / + (1024 * 1024); + const lastSample = sentenceSamples.at(-1); + return { + phrase: sentence.id, + langue: sentence.locale, + "runs OK": sentenceSamples.length, + "latence moy. (ms)": latencies.length > 0 ? avgLatency.toFixed(0) : "—", + "latence min (ms)": latencies.length > 0 ? Math.min(...latencies).toFixed(0) : "—", + "latence max (ms)": latencies.length > 0 ? Math.max(...latencies).toFixed(0) : "—", + "RSS moy. (Mo)": sentenceSamples.length > 0 ? avgRssMb.toFixed(1) : "—", + "techniques détectées": lastSample?.matches.length ?? 0, + }; + }); + console.info("\n=== Récapitulatif ==="); + console.table(rows); +} + +/** + * Point d'entrée : warm-up du classifieur (`techStepClassifier.warmUp()` — + * entraînement + init paresseuse de node-nlp, déjà prévu pour ça, voir sa + * doc dans `tech-step-matcher.ts`), benchmark sur {@link TEST_SENTENCES}, + * résultats détaillés puis récapitulatif, puis fermeture de la connexion + * Prisma. + */ +async function main(): Promise { + console.info("[bench] warm-up du classifieur (entraînement node-nlp)..."); + const warmUpStartedAt = performance.now(); + await techStepClassifier.warmUp(); + console.info(`[bench] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`); + + const techStepKeyById = await loadTechStepKeyById(); + + const benchmarkStartedAt = performance.now(); + const samples = await runBenchmark(); + console.info( + `[bench] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`, + ); + + printDetailedResults(samples, techStepKeyById); + printSummaryTable(samples); +} + +main() + .then(() => prisma.$disconnect()) + .catch(async (err) => { + console.error(err); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/experiments/llm-tech-step-poc/README.md b/experiments/llm-tech-step-poc/README.md index 38a3d35..66e11dc 100644 --- a/experiments/llm-tech-step-poc/README.md +++ b/experiments/llm-tech-step-poc/README.md @@ -88,17 +88,14 @@ delta RSS moyen, nombre d'actions détectées). ## Méthodologie de comparaison avec le pipeline `node-nlp` Ce script reste volontairement autonome (aucune dépendance vers `apps/api`, -donc pas de connexion Postgres requise pour le faire tourner). Pour comparer -manuellement sur les mêmes phrases : +donc pas de connexion Postgres requise pour le faire tourner). Le pendant +côté `node-nlp` vit dans `apps/api/src/scripts/bench-tech-step-classifier.ts` +— mêmes 7 phrases de `TEST_SENTENCES` (recopiées à l'identique, texte/`id`/ +`locale`, à synchroniser à la main si l'une des deux listes change), même +format de sortie (logs itératifs par répétition, tableau récapitulatif) : -```ts -// Dans apps/api, un script ponctuel (ou un REPL tsx) : -import { techStepClassifier } from "./src/lib/recipe-matching/tech-step-matcher.js"; - -console.log(await techStepClassifier.matchTechStepSpans( - "Émincez finement les oignons puis faites-les revenir 10 minutes à feu moyen dans une poêle avec un filet d'huile d'olive, puis réservez.", - "fr", -)); +```bash +pnpm --filter api exec tsx src/scripts/bench-tech-step-classifier.ts ``` (nécessite une base Postgres accessible et `TechStep` seedée — voir @@ -106,11 +103,14 @@ console.log(await techStepClassifier.matchTechStepSpans( vers de vrais `TechStep.id`). Les deux sorties ne sont pas directement isomorphes (`TechStepMatch` renvoie -un `techStepId` + des spans de caractères contre un `KitchenAction` -structuré avec ingrédients/durée/température/ustensiles) — la comparaison -porte sur : le nombre d'actions/techniques détectées par phrase, si la -catégorie/technique choisie est correcte, et le comportement sur la phrase -piège FR sans verbe littéral. +un `techStepId` (résolu en `key` par ce script pour être lisible) + des spans +de caractères, dans la taxonomie fine à ~26 techniques de +`tech-step-training-data.ts`, contre un `KitchenAction` structuré +ingrédients/durée/température/ustensiles sur la taxonomie à 7 catégories de +ce PoC) — la comparaison porte sur : le nombre d'actions/techniques +détectées par phrase, si la catégorie/technique choisie est correcte, et le +comportement sur la phrase piège FR sans verbe littéral +(`fr-action-implicite`). ## Limites de ce PoC