É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>
243 lines
9.6 KiB
TypeScript
243 lines
9.6 KiB
TypeScript
/**
|
|
* PoC autonome — pipeline HYBRIDE combinant le classifieur `node-nlp` frais
|
|
* de `nlp-tech-step-poc.ts` (rapide, ~26 fois moins gourmand en latence
|
|
* mesuré dans les runs précédents de ce PoC) et le LLM local de
|
|
* `llm-tech-step-poc.ts` (plus lent, mais qui généralise mieux sur les
|
|
* phrases où le NLP échoue franchement — voir les cas piège de
|
|
* `shared/test-sentences.ts`).
|
|
*
|
|
* Principe — "fast path, escalade sur signal faible" :
|
|
*
|
|
* 1. Le NLP analyse l'étape en premier, TOUJOURS (chemin rapide, quelques
|
|
* centaines de ms).
|
|
* 2. Sa {@link NlpStepAnalysis.overallConfidence} (le score BRUT, jamais
|
|
* masqué — voir la doc de `nlp-tech-step-poc.ts`) est comparée à
|
|
* {@link NLP_TRUST_THRESHOLD}.
|
|
* 3. Score suffisant ET au moins une action trouvée -> le résultat NLP est
|
|
* gardé tel quel (`source: "nlp"`).
|
|
* 4. Score insuffisant (ou aucune action trouvée du tout) -> le résultat
|
|
* NLP est ENTIÈREMENT écarté, l'étape est réanalysée par le LLM
|
|
* (`source: "llm"`), plus lent mais dont ce PoC a déjà montré qu'il
|
|
* généralise mieux sur les phrases où le NLP échoue (actions
|
|
* implicites, pronoms clitiques cassant un matching de phrase, etc.).
|
|
*
|
|
* Limite assumée du PoC : le résultat NLP ne porte QUE `action`/`verb`
|
|
* (voir `NlpTechStepClassifier`, structurellement incapable d'extraire
|
|
* ingrédients/durée/température/ustensiles) — quand le chemin NLP est pris,
|
|
* les autres champs de {@link KitchenAction} restent vides/`null`, jamais
|
|
* inventés. Le chemin LLM, lui, remplit tous les champs. C'est un compromis
|
|
* délibéré "rapide-et-grossier vs. lent-et-riche", pas un défaut à corriger
|
|
* — un vrai système hybride ferait probablement remonter le champ
|
|
* `source` jusqu'à l'UI pour ne promettre que ce que chaque chemin fournit
|
|
* réellement.
|
|
*
|
|
* Usage :
|
|
*
|
|
* ```bash
|
|
* cd experiments/llm-tech-step-poc
|
|
* pnpm install --ignore-workspace
|
|
* pnpm bench:hybrid
|
|
* ```
|
|
*/
|
|
|
|
import { performance } from "node:perf_hooks";
|
|
import {
|
|
LocalLlmStepAnalyzer,
|
|
RECOMMENDED_MODELS,
|
|
type RecommendedModelKey,
|
|
} from "./llm-tech-step-poc.js";
|
|
import { type NlpStepAnalysis, NlpTechStepClassifier } from "./nlp-tech-step-poc.js";
|
|
import {
|
|
type BenchmarkSample,
|
|
printSummaryTable,
|
|
runBenchmark,
|
|
} from "./shared/benchmark-harness.js";
|
|
import type { KitchenAction } from "./shared/kitchen-action.js";
|
|
import { isMainModule } from "./shared/module-entry.js";
|
|
import type { BenchmarkSentence } from "./shared/test-sentences.js";
|
|
|
|
/**
|
|
* Seuil de confiance NLP en dessous duquel une étape est réanalysée par le
|
|
* LLM plutôt que de garder le résultat NLP. Paramètre PROPRE à ce PoC —
|
|
* délibérément plus permissif que `CONFIDENCE_THRESHOLD` de
|
|
* `tech-step-matcher.ts` (`0.75`, empiriquement ajusté contre son propre
|
|
* corpus de production) : ce classifieur-ci a un corpus bien plus compact
|
|
* (voir `nlp-tech-step-poc.ts`), un seuil aussi strict escaladerait presque
|
|
* tout vers le LLM et ne testerait jamais vraiment le chemin rapide. `0.6`
|
|
* est un point de départ raisonnable pour un PoC, pas une valeur
|
|
* empiriquement optimisée — à ajuster en observant le récapitulatif
|
|
* (colonne `moteur`) sur des étapes réelles.
|
|
*/
|
|
const NLP_TRUST_THRESHOLD = 0.6;
|
|
|
|
/** Quel moteur a produit le résultat final pour une étape. */
|
|
export type HybridSource = "nlp" | "llm";
|
|
|
|
/** Résultat du pipeline hybride pour une étape — mêmes `actions` que les deux autres moteurs, plus la traçabilité de quel chemin a été pris et pourquoi. */
|
|
export interface HybridStepAnalysis {
|
|
originalText: string;
|
|
source: HybridSource;
|
|
/** Confiance globale renvoyée par le NLP — calculée et conservée MÊME quand le LLM finit par traiter l'étape, pour que le benchmark montre ce qui a déclenché l'escalade. */
|
|
nlpConfidence: number;
|
|
actions: KitchenAction[];
|
|
}
|
|
|
|
/** Convertit les matches du classifieur NLP en `KitchenAction[]` — seuls `action`/`verb` sont réellement connus, voir le doc-comment en tête de fichier. */
|
|
function toKitchenActions(nlpResult: NlpStepAnalysis): KitchenAction[] {
|
|
return nlpResult.matches.map((match) => ({
|
|
action: match.action,
|
|
verb: match.matchedText,
|
|
ingredients: [],
|
|
durationMinutes: null,
|
|
temperature: null,
|
|
utensils: [],
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Combine {@link NlpTechStepClassifier} et {@link LocalLlmStepAnalyzer}
|
|
* derrière une seule méthode `analyzeStep` — vraie `class` (pas un objet
|
|
* littéral), même convention que les deux moteurs qu'elle orchestre : elle
|
|
* possède un état réel (les deux moteurs sous-jacents), pas juste des
|
|
* fonctions groupées sans état.
|
|
*/
|
|
export class HybridStepAnalyzer {
|
|
private readonly _nlp: NlpTechStepClassifier;
|
|
private readonly _llm: LocalLlmStepAnalyzer;
|
|
|
|
public constructor() {
|
|
this._nlp = new NlpTechStepClassifier();
|
|
this._llm = new LocalLlmStepAnalyzer();
|
|
}
|
|
|
|
/** Initialise le LLM (chargement du modèle) — le NLP n'a pas de phase d'initialisation séparée, son entraînement est mémoïsé au premier appel (voir `NlpTechStepClassifier`). */
|
|
public async initialize(modelKey: RecommendedModelKey): Promise<void> {
|
|
await this._llm.initialize(modelKey);
|
|
}
|
|
|
|
/** Warm-up des deux moteurs — voir la doc de chacun (`NlpTechStepClassifier.warmUp`/`LocalLlmStepAnalyzer.warmUp`) pour pourquoi c'est nécessaire séparément du benchmark. */
|
|
public async warmUp(): Promise<void> {
|
|
await this._nlp.warmUp();
|
|
await this._llm.warmUp();
|
|
}
|
|
|
|
/**
|
|
* Analyse une étape : NLP d'abord (toujours), LLM seulement si le score
|
|
* NLP est sous {@link NLP_TRUST_THRESHOLD} ou qu'aucune action n'a été
|
|
* trouvée du tout — voir le doc-comment en tête de fichier pour le détail
|
|
* de la logique de décision.
|
|
*/
|
|
public async analyzeStep(sentence: BenchmarkSentence): Promise<HybridStepAnalysis> {
|
|
const nlpResult = await this._nlp.analyzeStep(sentence.text, sentence.locale);
|
|
const nlpIsTrustworthy =
|
|
nlpResult.overallConfidence >= NLP_TRUST_THRESHOLD && nlpResult.matches.length > 0;
|
|
|
|
if (nlpIsTrustworthy) {
|
|
return {
|
|
originalText: sentence.text,
|
|
source: "nlp",
|
|
nlpConfidence: nlpResult.overallConfidence,
|
|
actions: toKitchenActions(nlpResult),
|
|
};
|
|
}
|
|
|
|
const llmResult = await this._llm.analyzeStep(sentence.text);
|
|
return {
|
|
originalText: sentence.text,
|
|
source: "llm",
|
|
nlpConfidence: nlpResult.overallConfidence,
|
|
actions: llmResult.actions,
|
|
};
|
|
}
|
|
|
|
/** Libère le LLM (le NLP n'a pas de ressource native à libérer). */
|
|
public async dispose(): Promise<void> {
|
|
await this._llm.dispose();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Benchmark
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Imprime le détail de chaque échantillon — quel moteur a répondu, avec quelle confiance NLP, et les actions obtenues. */
|
|
function printDetailedResults(samples: readonly BenchmarkSample<HybridStepAnalysis>[]): void {
|
|
for (const sample of samples) {
|
|
console.info(
|
|
`\n[${sample.sentence.id}] (${sample.sentence.locale}) — ${sample.latencyMs.toFixed(0)} ms, moteur: ${sample.result.source} (confiance NLP ${sample.result.nlpConfidence.toFixed(2)})`,
|
|
);
|
|
console.info(` texte : ${sample.sentence.text}`);
|
|
console.info(` attendu : ${sample.sentence.note}`);
|
|
console.table(
|
|
sample.result.actions.map((action) => ({
|
|
action: action.action,
|
|
verbe: action.verb,
|
|
ingrédients: action.ingredients.join(", "),
|
|
"durée (min)": action.durationMinutes ?? "—",
|
|
température: action.temperature ?? "—",
|
|
ustensiles: action.utensils.join(", "),
|
|
})),
|
|
);
|
|
}
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const modelKey: RecommendedModelKey =
|
|
process.env.LLM_TECH_STEP_MODEL === "llama-3.2-1b" ? "llama-3.2-1b" : "qwen2.5-1.5b";
|
|
console.info(
|
|
`[hybrid] modèle LLM de secours : ${modelKey} (${RECOMMENDED_MODELS[modelKey].rationale})`,
|
|
);
|
|
console.info(`[hybrid] seuil de confiance NLP : ${NLP_TRUST_THRESHOLD}`);
|
|
|
|
const analyzer = new HybridStepAnalyzer();
|
|
try {
|
|
console.info("[hybrid] initialisation (chargement du modèle LLM de secours)...");
|
|
await analyzer.initialize(modelKey);
|
|
} catch (err) {
|
|
console.error("[hybrid] échec de l'initialisation", err);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const warmUpStartedAt = performance.now();
|
|
try {
|
|
await analyzer.warmUp();
|
|
} catch (err) {
|
|
console.error("[hybrid] échec du warm-up — le benchmark continue quand même", err);
|
|
}
|
|
console.info(`[hybrid] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`);
|
|
|
|
try {
|
|
const benchmarkStartedAt = performance.now();
|
|
const samples = await runBenchmark<HybridStepAnalysis>({
|
|
logPrefix: "[hybrid]",
|
|
countOf: (result) => result.actions.length,
|
|
countLabel: "action(s) détectée(s)",
|
|
analyze: (sentence) => analyzer.analyzeStep(sentence),
|
|
});
|
|
console.info(
|
|
`[hybrid] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`,
|
|
);
|
|
printDetailedResults(samples);
|
|
printSummaryTable(samples, (result) => result.actions.length, "actions détectées", [
|
|
{
|
|
label: "moteur",
|
|
valueOf: (lastSample) => lastSample.result.source,
|
|
},
|
|
{
|
|
label: "confiance NLP",
|
|
valueOf: (lastSample) => lastSample.result.nlpConfidence.toFixed(2),
|
|
},
|
|
]);
|
|
} finally {
|
|
try {
|
|
await analyzer.dispose();
|
|
} catch (err) {
|
|
console.error("[hybrid] erreur lors de la libération des moteurs", err);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isMainModule(import.meta.url)) {
|
|
await main();
|
|
}
|