Étape 1 — retire apps/api/src/scripts/bench-tech-step-classifier.ts
(DB-backed, taxonomie ~26 techniques non comparable terme à terme au LLM).
Étape 2 — reconstruit tout dans experiments/llm-tech-step-poc, entièrement
autonome (aucune dépendance Postgres/apps/api) :
- shared/kitchen-action.ts, shared/test-sentences.ts,
shared/benchmark-harness.ts : types, 7 phrases de test et harness de
mesure/affichage désormais partagés par les trois scripts (plus de
recopie manuelle entre fichiers).
- nlp-tech-step-poc.ts : classifieur node-nlp FRAIS (NER + clauses +
classification), entraîné directement sur la taxonomie à 7 catégories du
LLM plutôt que réutiliser TechStepClassifierService — comparaison terme à
terme, et surtout un score de confiance BRUT jamais masqué (contrairement
au repli silencieux sur l'ancre NER de la version production), condition
nécessaire au pipeline hybride. Corpus qui préfère les synonymes mono-mot
("revenir") aux phrases figées, pour ne pas se faire piéger par les
pronoms clitiques français ("faites-les-revenir").
- hybrid-tech-step-poc.ts : NLP toujours en premier (chemin rapide), LLM en
secours si la confiance NLP passe sous NLP_TRUST_THRESHOLD (0.6, tunable)
ou qu'aucune action n'est trouvée — récapitulatif avec colonnes "moteur"
et "confiance NLP" pour observer les bascules.
- llm-tech-step-poc.ts : inchangé fonctionnellement, migré vers les modules
partagés.
- shared/module-entry.ts (isMainModule) : garde chaque script pour que
l'import de ses classes (par hybrid-tech-step-poc.ts) ne déclenche pas
aussi son propre benchmark comme effet de bord.
pnpm bench / bench:nlp / bench:hybrid. README réécrit en conséquence.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
138 lines
7.1 KiB
TypeScript
138 lines
7.1 KiB
TypeScript
/**
|
|
* Harness de benchmark partagé par les trois moteurs de ce PoC — logs
|
|
* itératifs par répétition, mesure latence/RSS, tableau récapitulatif.
|
|
* Générique sur `TResult` (la forme de sortie de chaque moteur diffère :
|
|
* `RecipeStepAnalysis` pour le LLM, `NlpStepAnalysis` pour le classifieur
|
|
* NLP, `HybridStepAnalysis` pour le pipeline hybride) pour que les trois
|
|
* scripts réutilisent exactement le même code de mesure/affichage plutôt
|
|
* que de le tripler.
|
|
*/
|
|
import { performance } from "node:perf_hooks";
|
|
import type { BenchmarkSentence } from "./test-sentences.js";
|
|
import { TEST_SENTENCES } from "./test-sentences.js";
|
|
|
|
/** Nombre de répétitions mesurées par phrase — même valeur pour les trois moteurs, pour des runs comparables. */
|
|
export const REPETITIONS_PER_SENTENCE = 3;
|
|
|
|
/** Une mesure individuelle (une répétition, une phrase) — la matière première des tableaux récapitulatifs. */
|
|
export interface BenchmarkSample<TResult> {
|
|
sentence: BenchmarkSentence;
|
|
latencyMs: number;
|
|
/** Delta de RSS du process Node entre juste avant et juste après cet appel — une approximation de la RAM réellement consommée : `process.memoryUsage()` ne voit que le tas V8, mais un binding natif (llama.cpp) alloue dans le même process, donc le RSS (mémoire résidente totale du process) le capture, au bruit du GC près. */
|
|
rssDeltaBytes: number;
|
|
result: TResult;
|
|
}
|
|
|
|
/** Formate un delta de RSS en Mo avec un signe explicite (`+`/`-`), pour l'affichage. */
|
|
export function formatRssDelta(rssDeltaBytes: number): string {
|
|
const megabytes = rssDeltaBytes / (1024 * 1024);
|
|
return `${megabytes >= 0 ? "+" : ""}${megabytes.toFixed(1)} Mo`;
|
|
}
|
|
|
|
/** Paramètres de {@link runBenchmark} — un par moteur (LLM/NLP/hybride), voir chaque appelant. */
|
|
export interface BenchmarkRunOptions<TResult> {
|
|
/** Préfixe des logs itératifs, ex. `"[poc]"`, `"[nlp]"`, `"[hybrid]"`. */
|
|
logPrefix: string;
|
|
/** Nombre d'éléments détectés dans un résultat — alimente le log par répétition et la colonne de comptage du récapitulatif. */
|
|
countOf: (result: TResult) => number;
|
|
/** Libellé de ce qui est compté, ex. `"action(s) détectée(s)"` ou `"technique(s) détectée(s)"`. */
|
|
countLabel: string;
|
|
/** Lance une analyse pour une phrase donnée. Une erreur est journalisée et n'interrompt pas les répétitions suivantes — un run qui plante entièrement à la première réponse mal formée serait bien moins utile qu'un rapport partiel. */
|
|
analyze: (sentence: BenchmarkSentence) => Promise<TResult>;
|
|
}
|
|
|
|
/**
|
|
* Exécute {@link REPETITIONS_PER_SENTENCE} analyses par phrase de
|
|
* {@link TEST_SENTENCES} et renvoie toutes les mesures individuelles,
|
|
* journalisant chaque répétition au fur et à mesure (avant ET après)
|
|
* plutôt que de rester muet jusqu'au récapitulatif final : un run complet
|
|
* peut prendre plusieurs minutes, et savoir où on en est — quelle phrase,
|
|
* quelle répétition, le résultat qui vient de tomber — vaut largement le
|
|
* bruit de sortie supplémentaire pour ces scripts de benchmark
|
|
* (contrairement au code applicatif, où `console` est réservé à
|
|
* `LoggerService` — n'existe pas ici, PoC autonome sans app autour).
|
|
*/
|
|
export async function runBenchmark<TResult>(
|
|
options: BenchmarkRunOptions<TResult>,
|
|
): Promise<BenchmarkSample<TResult>[]> {
|
|
const { logPrefix, countOf, countLabel, analyze } = options;
|
|
const samples: BenchmarkSample<TResult>[] = [];
|
|
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(
|
|
`${logPrefix} (${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 result = await analyze(sentence);
|
|
const latencyMs = performance.now() - startedAt;
|
|
const rssDeltaBytes = process.memoryUsage().rss - rssBefore;
|
|
samples.push({ sentence, latencyMs, rssDeltaBytes, result });
|
|
console.info(
|
|
`${logPrefix} -> ${latencyMs.toFixed(0)} ms, ${countOf(result)} ${countLabel}, RSS ${formatRssDelta(rssDeltaBytes)}`,
|
|
);
|
|
} catch (err) {
|
|
console.error(
|
|
`${logPrefix} -> échec sur "${sentence.id}" (répétition ${repetition})`,
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return samples;
|
|
}
|
|
|
|
/** Une colonne supplémentaire du tableau récapitulatif, au-delà des colonnes communes — ex. la colonne "moteur" du pipeline hybride. */
|
|
export interface SummaryExtraColumn<TResult> {
|
|
label: string;
|
|
/** Calculée à partir de la DERNIÈRE répétition de la phrase — même logique que la colonne de comptage commune, voir {@link printSummaryTable}. */
|
|
valueOf: (lastSample: BenchmarkSample<TResult>) => string | number;
|
|
}
|
|
|
|
/**
|
|
* Agrège des {@link BenchmarkSample}s par phrase et imprime le tableau
|
|
* récapitulatif du benchmark (latence moyenne/min/max, delta RSS moyen,
|
|
* nombre d'éléments détectés, plus toute colonne additionnelle spécifique
|
|
* au moteur). Le compte d'éléments détectés est pris sur la DERNIÈRE
|
|
* répétition plutôt que moyenné : un nombre d'actions n'a pas de moyenne
|
|
* sensée (une info qualitative, pas une mesure continue) — la dernière
|
|
* répétition sert d'échantillon représentatif, comme dans les runs
|
|
* précédents de ce PoC.
|
|
*/
|
|
export function printSummaryTable<TResult>(
|
|
samples: readonly BenchmarkSample<TResult>[],
|
|
countOf: (result: TResult) => number,
|
|
countColumnLabel: string,
|
|
extraColumns: readonly SummaryExtraColumn<TResult>[] = [],
|
|
): void {
|
|
const rows = 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);
|
|
const row: Record<string, string | number> = {
|
|
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) : "—",
|
|
[countColumnLabel]: lastSample !== undefined ? countOf(lastSample.result) : 0,
|
|
};
|
|
for (const column of extraColumns) {
|
|
row[column.label] = lastSample !== undefined ? column.valueOf(lastSample) : "—";
|
|
}
|
|
return row;
|
|
});
|
|
console.info("\n=== Récapitulatif ===");
|
|
console.table(rows);
|
|
}
|