/** * 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 { 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 { 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 { 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 { 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[]): 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 { 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({ 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(); }