batchCooking/experiments/llm-tech-step-poc/src/hybrid-tech-step-poc.ts
Nicolas c73c62328d refactor(experiments): retire le script apps/api, ajoute NLP frais + pipeline hybride
É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>
2026-08-21 21:13:20 +02:00

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();
}