feat(api): ajoute le pendant node-nlp du benchmark tech-step

Nouveau script apps/api/src/scripts/bench-tech-step-classifier.ts, calqué
sur experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts : mêmes 7
phrases de TEST_SENTENCES (recopiées à l'identique), même structure de
sortie (logs itératifs par répétition, tableau récapitulatif
latence/RSS/nombre de détections), pour que les deux pipelines soient
directement comparables phrase par phrase.

Réutilise techStepClassifier.warmUp() (déjà prévu pour absorber le coût de
l'entraînement + l'init paresseuse de node-nlp) et résout les techStepId en
key lisible pour l'affichage détaillé. Nécessite une base Postgres avec
TechStep seedée (matchTechStepSpans résout ses uid vers de vrais ids).

README du PoC LLM mis à jour : la section "Méthodologie de comparaison"
pointe vers ce script réel plutôt que le snippet REPL manuel qu'elle
suggérait avant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-21 19:47:15 +02:00
parent 6574d8e4a8
commit 0582822a78
2 changed files with 265 additions and 15 deletions

View file

@ -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<Map<number, string>> {
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<BenchmarkSample[]> {
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<number, string>,
): 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<void> {
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);
});

View file

@ -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