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:
parent
6574d8e4a8
commit
0582822a78
2 changed files with 265 additions and 15 deletions
250
apps/api/src/scripts/bench-tech-step-classifier.ts
Normal file
250
apps/api/src/scripts/bench-tech-step-classifier.ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -88,17 +88,14 @@ delta RSS moyen, nombre d'actions détectées).
|
||||||
## Méthodologie de comparaison avec le pipeline `node-nlp`
|
## Méthodologie de comparaison avec le pipeline `node-nlp`
|
||||||
|
|
||||||
Ce script reste volontairement autonome (aucune dépendance vers `apps/api`,
|
Ce script reste volontairement autonome (aucune dépendance vers `apps/api`,
|
||||||
donc pas de connexion Postgres requise pour le faire tourner). Pour comparer
|
donc pas de connexion Postgres requise pour le faire tourner). Le pendant
|
||||||
manuellement sur les mêmes phrases :
|
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
|
```bash
|
||||||
// Dans apps/api, un script ponctuel (ou un REPL tsx) :
|
pnpm --filter api exec tsx src/scripts/bench-tech-step-classifier.ts
|
||||||
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",
|
|
||||||
));
|
|
||||||
```
|
```
|
||||||
|
|
||||||
(nécessite une base Postgres accessible et `TechStep` seedée — voir
|
(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`).
|
vers de vrais `TechStep.id`).
|
||||||
|
|
||||||
Les deux sorties ne sont pas directement isomorphes (`TechStepMatch` renvoie
|
Les deux sorties ne sont pas directement isomorphes (`TechStepMatch` renvoie
|
||||||
un `techStepId` + des spans de caractères contre un `KitchenAction`
|
un `techStepId` (résolu en `key` par ce script pour être lisible) + des spans
|
||||||
structuré avec ingrédients/durée/température/ustensiles) — la comparaison
|
de caractères, dans la taxonomie fine à ~26 techniques de
|
||||||
porte sur : le nombre d'actions/techniques détectées par phrase, si la
|
`tech-step-training-data.ts`, contre un `KitchenAction` structuré
|
||||||
catégorie/technique choisie est correcte, et le comportement sur la phrase
|
ingrédients/durée/température/ustensiles sur la taxonomie à 7 catégories de
|
||||||
piège FR sans verbe littéral.
|
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
|
## Limites de ce PoC
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue