- LocalLlmStepAnalyzer.warmUp() : force le coût caché du tout premier appel
d'inférence (spin-up threads llama.cpp, cache KV, tokenizer) avant le
benchmark, plutôt que de laisser la première phrase l'absorber — constaté
sur des runs réels (Qwen/Llama) où fr-multi-action montait jusqu'à ~28s
contre ~5s pour ses autres répétitions.
- initialize() et runBenchmark() journalisent maintenant chaque sous-étape
(résolution du modèle, chargement des poids, contexte, grammaire, puis
chaque répétition avec son résultat immédiat) au lieu de rester muets
plusieurs minutes avant le récapitulatif final.
- RECOMMENDED_MODELS / README corrigés suite aux runs réels de l'utilisateur :
Qwen2.5-1.5B s'est montré systématiquement plus rapide que Llama-3.2-1B
sur les deux machines testées, contredisant l'hypothèse a priori du README
("moins de paramètres = plus rapide") — gardé comme résultat empirique
plutôt que corrigé silencieusement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
687 lines
36 KiB
TypeScript
687 lines
36 KiB
TypeScript
/**
|
||
* PoC autonome — détection d'actions culinaires via un mini LLM local
|
||
* (`node-llama-cpp`), à comparer au pipeline `node-nlp` déjà en place dans
|
||
* `apps/api/src/lib/recipe-matching/tech-step-matcher.ts`
|
||
* (`TechStepClassifierService`).
|
||
*
|
||
* Objectif de la comparaison : ce pipeline `node-nlp` classe une étape en
|
||
* UNE technique par clause (NER pour repérer les candidats -> découpage en
|
||
* clauses -> classification d'intention par clause). Ce PoC teste une
|
||
* approche différente : demander à un petit LLM instruct local d'extraire
|
||
* en une seule passe la séquence ORDONNÉE de toutes les actions atomiques
|
||
* d'une étape, sous forme d'un JSON structuré — sans borne de vocabulaire
|
||
* fixée à l'avance (pas de liste de synonymes/utterances à maintenir), au
|
||
* prix d'une latence et d'une empreinte mémoire bien plus élevées (un
|
||
* modèle de ~1 à 2 Md de paramètres contre un classifieur bayésien/NER
|
||
* léger). Les deux pipelines tournent entièrement en local, sans appel
|
||
* réseau à l'inférence (le seul accès réseau de ce fichier est le
|
||
* téléchargement ponctuel du modèle GGUF, voir {@link resolveModelPath}).
|
||
*
|
||
* Portée volontairement limitée à un fichier autonome, hors du monorepo
|
||
* pnpm (`pnpm-workspace.yaml` ne référence que `apps/*`/`packages/*`) :
|
||
* c'est un script d'expérimentation jetable, pas un module destiné à être
|
||
* consommé par `apps/api` — même statut que les scripts one-off déjà
|
||
* exemptés de la convention "un `try`/`catch` par `await`" du repo
|
||
* (`prisma/seed.ts`, `apps/api/src/scripts/seed-runtime.ts`) : ici aussi,
|
||
* laisser une erreur se propager telle quelle jusqu'au point d'appel qui
|
||
* décide quoi en faire (le run complet du benchmark, ou `main()` en tout
|
||
* dernier ressort) est plus lisible qu'un `catch { throw err; }` répété
|
||
* sans rien y ajouter.
|
||
*
|
||
* Usage : voir `README.md` à côté de ce fichier (installation, modèle,
|
||
* variables d'environnement). En bref :
|
||
*
|
||
* ```bash
|
||
* cd experiments/llm-tech-step-poc
|
||
* pnpm install
|
||
* pnpm bench
|
||
* ```
|
||
*/
|
||
|
||
import path from "node:path";
|
||
import { performance } from "node:perf_hooks";
|
||
import { fileURLToPath } from "node:url";
|
||
import {
|
||
getLlama,
|
||
LlamaChatSession,
|
||
type LlamaJsonSchemaGrammar,
|
||
resolveModelFile,
|
||
} from "node-llama-cpp";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Types métier — RecipeStepAnalysis / KitchenAction
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Taxonomie fermée des actions culinaires que le LLM peut poser sur une
|
||
* action extraite. Volontairement large (`OTHER` en filet de sécurité) plutôt
|
||
* qu'exhaustive comme les ~25 `TechStep` de la base : ce PoC teste la
|
||
* *structuration* d'une étape en séquence d'actions typées, pas encore un
|
||
* remplacement à iso-vocabulaire du catalogue `TechStep` existant.
|
||
*/
|
||
export enum KitchenActionType {
|
||
/** Travail au couteau — émincer, couper en dés, hacher, éplucher, trancher. */
|
||
CUT = "CUT",
|
||
/** Cuisson à proprement parler — faire revenir, mijoter, bouillir, cuire au four, griller, fondre. */
|
||
COOK = "COOK",
|
||
/** Combiner/mélanger des ingrédients entre eux, sans cuisson — mélanger, fouetter, incorporer. */
|
||
MIX = "MIX",
|
||
/** Laisser reposer/refroidir/mariner/lever, sans intervention active. */
|
||
REST = "REST",
|
||
/** Assaisonner — sel, poivre, épices, herbes, condiments. */
|
||
SEASON = "SEASON",
|
||
/** Préchauffage d'un four, d'une poêle ou d'un appareil avant utilisation. */
|
||
PREHEAT = "PREHEAT",
|
||
/** Toute action ne rentrant dans aucune des catégories ci-dessus (dresser, égoutter, réserver, transférer...). */
|
||
OTHER = "OTHER",
|
||
}
|
||
|
||
/**
|
||
* Une action atomique extraite d'une étape de recette — l'équivalent, côté
|
||
* LLM, de ce qu'un `TechStepMatch` (`tech-step-matcher.ts`) représente côté
|
||
* pipeline `node-nlp`, mais enrichi des attributs qu'un LLM générativiste
|
||
* peut extraire en une seule passe (ingrédients, durée, température,
|
||
* ustensiles) là où le pipeline `node-nlp` ne renvoie qu'un id de technique
|
||
* plus ses spans de texte.
|
||
*/
|
||
export interface KitchenAction {
|
||
/** Catégorie de l'action, parmi {@link KitchenActionType}. */
|
||
action: KitchenActionType;
|
||
/** Verbe littéral employé dans le texte (langue d'origine, non traduit) — ex. "émincez", "dice". */
|
||
verb: string;
|
||
/** Ingrédients sur lesquels porte spécifiquement cette action ; tableau vide si aucun n'est nommé. */
|
||
ingredients: string[];
|
||
/** Durée en minutes si l'étape en mentionne une (heures/secondes converties) ; `null` sinon. */
|
||
durationMinutes: number | null;
|
||
/** Mention littérale de température/intensité de feu (ex. "180°C", "feu doux", "medium heat") ; `null` sinon. */
|
||
temperature: string | null;
|
||
/** Ustensiles/équipements nommés pour cette action ; tableau vide si aucun n'est nommé. */
|
||
utensils: string[];
|
||
}
|
||
|
||
/**
|
||
* Résultat complet de l'analyse d'une étape — la séquence ORDONNÉE
|
||
* d'actions qu'elle décrit, alignée sur le texte source pour traçabilité
|
||
* dans les résultats du benchmark.
|
||
*
|
||
* `originalText` n'est volontairement PAS demandé au LLM (donc pas dans le
|
||
* schéma JSON imposé par la grammaire, voir {@link KITCHEN_ACTIONS_JSON_SCHEMA})
|
||
* : le faire recopier le texte d'entrée gaspillerait des tokens de
|
||
* génération et risquerait une recopie légèrement différente de l'original
|
||
* (espaces, ponctuation) sans aucun bénéfice — ce champ est réattaché
|
||
* programmatiquement par {@link LocalLlmStepAnalyzer.analyzeStep} à partir
|
||
* de l'argument d'entrée, pas de la réponse du modèle.
|
||
*/
|
||
export interface RecipeStepAnalysis {
|
||
/** Texte source de l'étape, tel que passé à `analyzeStep`. */
|
||
originalText: string;
|
||
/** Séquence ordonnée d'actions détectées ; vide si l'étape n'en décrit aucune. */
|
||
actions: KitchenAction[];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Schéma JSON — grammaire GBNF imposée à la génération
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Schéma JSON d'une {@link KitchenAction}, dans le sous-ensemble supporté
|
||
* par `LlamaChatSession`+`llama.createGrammarForJsonSchema` (object/array/
|
||
* string/number/enum/oneOf — pas d'union `type: [...]` pour les champs
|
||
* nullable, node-llama-cpp veut `oneOf: [{type:"null"}, {type:"..."}]`,
|
||
* voir la doc "Using Grammar"). Champ à champ, en miroir strict de
|
||
* {@link KitchenAction} : la grammaire ne fait qu'imposer une SYNTAXE JSON
|
||
* valide conforme à ce schéma, elle ne garantit pas que le modèle choisisse
|
||
* la bonne catégorie/le bon champ — c'est le rôle du prompt système
|
||
* ({@link SYSTEM_PROMPT}) de guider la sémantique.
|
||
*/
|
||
const KITCHEN_ACTION_JSON_SCHEMA = {
|
||
type: "object",
|
||
properties: {
|
||
action: { enum: Object.values(KitchenActionType) },
|
||
verb: { type: "string" },
|
||
ingredients: { type: "array", items: { type: "string" } },
|
||
durationMinutes: { oneOf: [{ type: "null" }, { type: "number" }] },
|
||
temperature: { oneOf: [{ type: "null" }, { type: "string" }] },
|
||
utensils: { type: "array", items: { type: "string" } },
|
||
},
|
||
required: ["action", "verb", "ingredients", "durationMinutes", "temperature", "utensils"],
|
||
} as const;
|
||
|
||
/**
|
||
* Racine du schéma imposé au modèle — un objet `{ actions: [...] }` plutôt
|
||
* qu'un tableau nu en racine (node-llama-cpp exige un `type: "object"` en
|
||
* racine de la grammaire JSON). `originalText` n'y figure pas, voir le
|
||
* commentaire sur {@link RecipeStepAnalysis.originalText}.
|
||
*/
|
||
const KITCHEN_ACTIONS_JSON_SCHEMA = {
|
||
type: "object",
|
||
properties: {
|
||
actions: { type: "array", items: KITCHEN_ACTION_JSON_SCHEMA },
|
||
},
|
||
required: ["actions"],
|
||
} as const;
|
||
|
||
/** Forme brute que renvoie `grammar.parse()` pour {@link KITCHEN_ACTIONS_JSON_SCHEMA} — reconverti en {@link RecipeStepAnalysis} par {@link LocalLlmStepAnalyzer.analyzeStep}. */
|
||
interface KitchenActionsGrammarResult {
|
||
actions: KitchenAction[];
|
||
}
|
||
|
||
/**
|
||
* Instructions système — porte toute la sémantique que la grammaire GBNF ne
|
||
* peut pas imposer (elle ne contraint que la forme JSON, jamais le
|
||
* contenu) : la définition de chaque catégorie de {@link KitchenActionType},
|
||
* et ce qu'extraire pour chaque champ. Explicitement bilingue dans son
|
||
* énoncé même (plutôt que deux prompts FR/EN séparés à maintenir) — le but
|
||
* du benchmark est justement de voir si un seul prompt, sur un modèle
|
||
* multilingue, tient la route en français ET en anglais sans bascule
|
||
* explicite de langue.
|
||
*/
|
||
const SYSTEM_PROMPT = `You are a culinary instruction parser. You receive ONE recipe step, written in either French or English. Break it down into the ordered sequence of atomic actions it describes, and respond with ONLY the JSON object required by the schema — no prose, no markdown code fences, no explanation.
|
||
|
||
Action taxonomy (pick exactly one per action):
|
||
- CUT: knife work — chopping, dicing, mincing, slicing, peeling.
|
||
- COOK: applying heat to actually cook food — frying, sautéing, simmering, boiling, baking, grilling, melting.
|
||
- MIX: combining/stirring/whisking/folding ingredients together, with no heat involved.
|
||
- REST: letting something sit, cool, chill, marinate or rise without active handling.
|
||
- SEASON: adding salt, pepper, spices, herbs or condiments to flavor a dish.
|
||
- PREHEAT: bringing an oven, pan or appliance up to temperature before it is used.
|
||
- OTHER: anything not covered above (plating, straining, transferring, reserving...).
|
||
|
||
For each action extract:
|
||
- verb: the literal action verb from the source text, in its original language.
|
||
- ingredients: the ingredients this specific action applies to (empty array if none named).
|
||
- durationMinutes: a single number in minutes if a duration is stated (convert hours/seconds), otherwise null.
|
||
- temperature: the literal temperature/heat-level mention (e.g. "180°C", "feu doux", "medium heat"), otherwise null.
|
||
- utensils: any cookware/tools named for this action (empty array if none named).
|
||
|
||
Keep the actions in the order they happen in the text. A step can describe several sequential actions, even when no explicit verb names a technique (e.g. "until the butter has disappeared into the pan" means melting butter, i.e. COOK).`;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Modèles recommandés
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Une des deux familles de modèles GGUF évaluées par ce PoC — voir le comparatif dans `README.md`. */
|
||
export type RecommendedModelKey = "qwen2.5-1.5b" | "llama-3.2-1b";
|
||
|
||
/** Un modèle GGUF candidat, référencé par son URI `hf:` (résolu/téléchargé par `resolveModelFile`, voir la doc "Downloading Models" de node-llama-cpp). */
|
||
interface RecommendedModel {
|
||
/** URI `hf:<repo>:<quant>` — node-llama-cpp résout et télécharge (une seule fois, mis en cache) le fichier GGUF correspondant depuis Hugging Face. */
|
||
hfUri: string;
|
||
/** Pourquoi ce modèle, en une phrase — voir aussi le comparatif détaillé dans `README.md`. */
|
||
rationale: string;
|
||
}
|
||
|
||
/**
|
||
* Les deux modèles recommandés pour cette tâche, choisis parmi les
|
||
* instruct GGUF ~1-1.5 Md de paramètres (assez petits pour tourner en CPU
|
||
* pur avec une latence de l'ordre de la seconde, assez récents pour bien
|
||
* suivre des instructions de structuration JSON) :
|
||
*
|
||
* - **Qwen2.5-1.5B-Instruct** (recommandation par défaut) : corpus
|
||
* d'entraînement nettement plus multilingue que la famille Llama à
|
||
* taille comparable, et meilleur suivi d'instructions de structuration
|
||
* (extraction JSON, function calling) dans les benchmarks publiés par
|
||
* Qwen. Contrairement à l'hypothèse initiale ("plus de paramètres, donc
|
||
* plus lent"), les runs de ce benchmark (Windows, backend Vulkan) le
|
||
* montrent aussi systématiquement PLUS RAPIDE que Llama-3.2-1B sur les 7
|
||
* phrases de test, malgré ses ~50 % de paramètres en plus — le premier
|
||
* choix sur les deux axes ici, pas seulement sur la robustesse FR/EN.
|
||
* - **Llama-3.2-1B-Instruct** (alternative) : ~35 % de paramètres en
|
||
* moins, et le FR fait partie de ses langues officiellement supportées,
|
||
* mais avec un suivi d'instructions de structuration plus fragile à
|
||
* cette taille dans la pratique — et, empiriquement (voir ci-dessus), pas
|
||
* plus rapide non plus sur ce benchmark. Gardé comme point de comparaison
|
||
* plutôt que retiré : la latence relative entre les deux dépend du
|
||
* backend d'inférence (CUDA/Vulkan/CPU pur) et du matériel, un résultat
|
||
* obtenu sur une seule machine ne généralise pas forcément.
|
||
*
|
||
* Les deux sont quantisés en `Q4_K_M` — le compromis taille/qualité standard
|
||
* pour de l'inférence CPU (~4.5 bits/poids, largement suffisant pour une
|
||
* tâche d'extraction structurée, contrairement à de la génération créative
|
||
* longue où une quantisation plus fine se voit davantage).
|
||
*/
|
||
const RECOMMENDED_MODELS: Record<RecommendedModelKey, RecommendedModel> = {
|
||
"qwen2.5-1.5b": {
|
||
hfUri: "hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M",
|
||
rationale:
|
||
"Meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON — et, empiriquement sur ce benchmark, aussi la latence la plus basse malgré la taille plus grande.",
|
||
},
|
||
"llama-3.2-1b": {
|
||
hfUri: "hf:bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M",
|
||
rationale:
|
||
"Plus petit, structuration JSON moins fiable à 1B, et pas plus rapide qu'un Qwen 1.5B sur ce benchmark — conservé comme point de comparaison, pas comme choix latence.",
|
||
},
|
||
};
|
||
|
||
/** Répertoire où les modèles GGUF téléchargés sont mis en cache — à côté de ce fichier, jamais commité (voir `.gitignore` du dossier). */
|
||
const MODELS_DIRECTORY = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "models");
|
||
|
||
/**
|
||
* Résout le chemin du fichier GGUF à charger : un chemin local explicite
|
||
* via `LLM_TECH_STEP_MODEL_PATH` prime toujours (utile hors-ligne, ou en CI
|
||
* où un téléchargement réseau à la volée n'est pas souhaitable) ; sinon,
|
||
* {@link RECOMMENDED_MODELS} est résolu par `resolveModelFile`, qui
|
||
* télécharge le fichier une seule fois dans {@link MODELS_DIRECTORY} puis le
|
||
* réutilise tel quel aux exécutions suivantes.
|
||
*/
|
||
async function resolveModelPath(modelKey: RecommendedModelKey): Promise<string> {
|
||
const explicitPath = process.env.LLM_TECH_STEP_MODEL_PATH;
|
||
if (explicitPath !== undefined && explicitPath.length > 0) return explicitPath;
|
||
return await resolveModelFile(RECOMMENDED_MODELS[modelKey].hfUri, MODELS_DIRECTORY);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// LocalLlmStepAnalyzer — enrobage node-llama-cpp
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Charge un modèle GGUF local et l'expose comme un service d'analyse
|
||
* d'étapes de recette — vraie `class` (pas un objet littéral), même
|
||
* convention que `TechStepClassifierService` : elle possède un état réel
|
||
* (modèle chargé, contexte, grammaire compilée) coûteux à reconstruire,
|
||
* jamais recréé par appel.
|
||
*/
|
||
export class LocalLlmStepAnalyzer {
|
||
/** Instance `node-llama-cpp` — porte d'entrée vers le binding natif llama.cpp. `undefined` avant `initialize()`. */
|
||
private _llama: Awaited<ReturnType<typeof getLlama>> | undefined;
|
||
/** Modèle GGUF chargé en mémoire. `undefined` avant `initialize()`. */
|
||
private _model:
|
||
| Awaited<ReturnType<Awaited<ReturnType<typeof getLlama>>["loadModel"]>>
|
||
| undefined;
|
||
/** Contexte d'inférence (fenêtre de contexte + cache KV) dérivé de `_model`. `undefined` avant `initialize()`. */
|
||
private _context:
|
||
| Awaited<
|
||
ReturnType<
|
||
Awaited<ReturnType<Awaited<ReturnType<typeof getLlama>>["loadModel"]>>["createContext"]
|
||
>
|
||
>
|
||
| undefined;
|
||
/**
|
||
* Grammaire GBNF compilée depuis {@link KITCHEN_ACTIONS_JSON_SCHEMA} —
|
||
* compilée une seule fois, réutilisée à chaque `analyzeStep`. Typée
|
||
* explicitement via le type générique `LlamaJsonSchemaGrammar<Schema>`
|
||
* (plutôt qu'un `Awaited<ReturnType<...>>` sur la méthode générique
|
||
* `createGrammarForJsonSchema`, qui perd le type précis du schéma faute
|
||
* d'argument concret à cet endroit) pour que `grammar.parse()` renvoie un
|
||
* type déjà aligné sur {@link KitchenActionsGrammarResult}. `undefined`
|
||
* avant `initialize()`.
|
||
*/
|
||
private _grammar: LlamaJsonSchemaGrammar<typeof KITCHEN_ACTIONS_JSON_SCHEMA> | undefined;
|
||
|
||
/**
|
||
* Charge le modèle (téléchargement au besoin, voir {@link resolveModelPath}),
|
||
* crée son contexte d'inférence et compile la grammaire JSON — la partie
|
||
* coûteuse (souvent plusieurs secondes, dominée par le chargement des
|
||
* poids depuis disque), à faire une seule fois avant tout `analyzeStep`.
|
||
*
|
||
* Journalise chaque sous-étape (`console.info`, autorisé par
|
||
* `biome.json` — voir `suspicious.noConsole`) : cette méthode reste muette
|
||
* pendant plusieurs secondes à secondes-longues sans ça (résolution/
|
||
* téléchargement du modèle, chargement des poids, création du contexte,
|
||
* compilation de la grammaire), et rien ne dit à l'utilisateur laquelle
|
||
* de ces sous-étapes est en cours.
|
||
*/
|
||
public async initialize(modelKey: RecommendedModelKey): Promise<void> {
|
||
console.info(`[poc] résolution du modèle "${modelKey}"...`);
|
||
const modelPath = await resolveModelPath(modelKey);
|
||
console.info(`[poc] modèle : ${modelPath}`);
|
||
|
||
console.info("[poc] initialisation de node-llama-cpp...");
|
||
this._llama = await getLlama();
|
||
|
||
console.info("[poc] chargement des poids en mémoire...");
|
||
this._model = await this._llama.loadModel({ modelPath });
|
||
|
||
console.info("[poc] création du contexte d'inférence...");
|
||
this._context = await this._model.createContext({ contextSize: 4096 });
|
||
|
||
console.info("[poc] compilation de la grammaire JSON...");
|
||
this._grammar = await this._llama.createGrammarForJsonSchema(KITCHEN_ACTIONS_JSON_SCHEMA);
|
||
}
|
||
|
||
/**
|
||
* Force un premier appel d'inférence factice, séparément de
|
||
* `initialize()` et avant tout appel mesuré par le benchmark — même rôle
|
||
* que `TechStepClassifierService.warmUp()` côté `node-nlp`
|
||
* (`tech-step-matcher.ts`) : le tout premier `session.prompt()` sur un
|
||
* contexte fraîchement créé paie un coût caché que `initialize()` ne
|
||
* couvre pas (spin-up du pool de threads llama.cpp, allocation du cache
|
||
* KV, initialisation paresseuse du tokenizer) — mesuré ici entre 15 et
|
||
* 20+ secondes selon le modèle/matériel, contre quelques secondes pour
|
||
* les appels suivants sur la même phrase. Sans cet appel, c'est la
|
||
* première phrase du benchmark qui absorbe ce coût, faussant sa latence
|
||
* moyenne/max sans rapport avec le coût réel d'une inférence en régime
|
||
* établi (voir l'écart min/max observé sur `fr-multi-action` avant ce
|
||
* correctif : ~5s en moyenne, jusqu'à ~28s sur un run).
|
||
*/
|
||
public async warmUp(): Promise<void> {
|
||
await this.analyzeStep("Faites chauffer une poêle.");
|
||
}
|
||
|
||
/**
|
||
* Analyse une étape de recette et renvoie sa séquence ordonnée d'actions.
|
||
*
|
||
* Une séquence `LlamaContextSequence` dédiée est allouée pour CET appel
|
||
* puis libérée en sortie (`finally`), plutôt que de réutiliser une session
|
||
* de chat partagée : `LlamaChatSession` accumule l'historique de
|
||
* conversation à chaque `prompt()`, ce qui aurait fait grandir le contexte
|
||
* (et donc la latence mesurée) au fil des phrases du benchmark au lieu de
|
||
* mesurer chaque étape dans des conditions comparables. Le contexte par
|
||
* défaut n'autorise qu'une seule séquence active à la fois
|
||
* (`createContext()` sans `sequences` explicite) — d'où la libération
|
||
* immédiate, indispensable pour que l'appel suivant puisse en allouer une
|
||
* nouvelle.
|
||
*/
|
||
public async analyzeStep(stepText: string): Promise<RecipeStepAnalysis> {
|
||
if (this._llama === undefined || this._context === undefined || this._grammar === undefined) {
|
||
throw new Error("LocalLlmStepAnalyzer.initialize() must be awaited before analyzeStep().");
|
||
}
|
||
const context = this._context;
|
||
const grammar = this._grammar;
|
||
const sequence = context.getSequence();
|
||
try {
|
||
const session = new LlamaChatSession({
|
||
contextSequence: sequence,
|
||
systemPrompt: SYSTEM_PROMPT,
|
||
});
|
||
const response = await session.prompt(stepText, { grammar });
|
||
// La grammaire garantit un JSON syntaxiquement conforme au schéma —
|
||
// ce cast ne fait que réattacher le type nommé `KitchenActionsGrammarResult`
|
||
// (le schéma étant défini structurellement, pas de risque `any`).
|
||
const parsed = grammar.parse(response) as KitchenActionsGrammarResult;
|
||
return { originalText: stepText, actions: parsed.actions };
|
||
} finally {
|
||
await sequence.dispose();
|
||
}
|
||
}
|
||
|
||
/** Libère le modèle et son contexte — à appeler une fois le benchmark terminé, la mémoire native n'étant pas gérée par le GC de V8. */
|
||
public async dispose(): Promise<void> {
|
||
await this._context?.dispose();
|
||
await this._model?.dispose();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Benchmark
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Une phrase de test du benchmark, avec sa langue et ce qui la rend "complexe" (documentation, non exploité par le code). */
|
||
interface BenchmarkSentence {
|
||
id: string;
|
||
locale: "fr" | "en";
|
||
text: string;
|
||
/** Ce qui rend cette phrase intéressante à tester — affiché dans les résultats pour donner du contexte à la comparaison manuelle avec le pipeline `node-nlp`. */
|
||
note: string;
|
||
}
|
||
|
||
/**
|
||
* Sept phrases complexes, FR et EN, choisies pour couvrir des difficultés
|
||
* différentes — les trois premières sont la base initiale du PoC, les
|
||
* quatre suivantes poussent volontairement plus loin (simultanéité,
|
||
* conditions, négations, ambiguïté sémantique d'un même champ) pour
|
||
* chercher le point de rupture des deux pipelines, pas juste confirmer
|
||
* qu'ils gèrent le cas courant :
|
||
*
|
||
* 1. FR, plusieurs actions explicites enchaînées avec une durée et un
|
||
* ingrédient qui change de forme grammaticale ("les" reprend "oignons").
|
||
* 2. EN, même complexité multi-actions, pour comparer directement au 1. sur
|
||
* une structure de phrase équivalente dans l'autre langue.
|
||
* 3. FR, la phrase-piège citée dans la doc de `tech-step-matcher.ts` elle-même
|
||
* ("jusqu'à ce que le beurre ait disparu dans la poêle") : aucune action
|
||
* n'est nommée par un verbe de technique littéral, seul le sens implique
|
||
* une cuisson (`COOK`) — exactement le cas que le pipeline `node-nlp`
|
||
* existant a dû être spécifiquement entraîné à reconnaître (voir le
|
||
* point 3 de sa doc). Comparer les deux pipelines sur cette phrase
|
||
* précise est le test le plus direct de "précision sémantique, pas
|
||
* seulement mot-clé" que ce PoC cherche à évaluer.
|
||
* 4. FR, deux techniques qui se déroulent EN PARALLÈLE ("pendant que...")
|
||
* plutôt qu'en séquence — un pipeline qui suppose un ordre strictement
|
||
* chronologique (comme le découpage en clauses de `splitIntoClauses`,
|
||
* voir `tech-step-matcher.ts`) peut mal restituer que les deux actions
|
||
* se chevauchent dans le temps plutôt que de se succéder.
|
||
* 5. EN, une action CONDITIONNELLE ("if the batter looks too thick, add a
|
||
* splash of milk") noyée entre des actions fermes, plus une fin de
|
||
* cuisson exprimée comme un test de résultat ("until a toothpick comes
|
||
* out clean") et non comme une durée fixe — deux formes d'incertitude
|
||
* qu'un extracteur naïf a tendance à aplatir en une action normale.
|
||
* 6. FR, très technique (crème pâtissière) : une action MIX et une action
|
||
* COOK simultanées ("tout en fouettant" pendant qu'on verse le lait
|
||
* chaud), une NÉGATION explicite d'action ("sans jamais laisser
|
||
* bouillir" — l'inverse d'une action à ne pas enregistrer comme une
|
||
* vraie étape), et une fin de cuisson par état ("jusqu'à épaississement")
|
||
* plutôt que par durée.
|
||
* 7. EN, deux occurrences de `REST` au sens différent (mariner au
|
||
* réfrigérateur vs. laisser revenir à température ambiante avant
|
||
* cuisson) dans la même phrase, une durée "par face" (6-7 minutes per
|
||
* side, pas la durée totale), et un champ température qui désigne un
|
||
* SEUIL DE CUISSON à cœur (165°F) plutôt qu'un réglage de feu — un piège
|
||
* sémantique direct pour le champ `temperature` de {@link KitchenAction}.
|
||
*/
|
||
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 — atténue le bruit d'une mesure isolée sans allonger excessivement le run. */
|
||
const REPETITIONS_PER_SENTENCE = 3;
|
||
|
||
/** Une mesure individuelle (une répétition, une phrase) — la matière première des tableaux récapitulatifs imprimés en fin de run. */
|
||
interface BenchmarkSample {
|
||
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 par l'inférence : `process.memoryUsage()` ne voit que le tas V8, mais le binding natif llama.cpp alloue dans le même process, donc le RSS (mémoire résidente totale du process) le capture bien, au bruit du GC près. */
|
||
rssDeltaBytes: number;
|
||
analysis: RecipeStepAnalysis;
|
||
}
|
||
|
||
/**
|
||
* Exécute {@link REPETITIONS_PER_SENTENCE} analyses par phrase de
|
||
* {@link TEST_SENTENCES} et renvoie toutes les mesures individuelles.
|
||
* Une erreur sur une répétition est journalisée et n'interrompt pas les
|
||
* suivantes — un run de benchmark qui plante entièrement à la première
|
||
* réponse mal formée serait bien moins utile qu'un rapport partiel.
|
||
*
|
||
* Journalise 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 (7 phrases × 3 répétitions), 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 ce script de
|
||
* benchmark (contrairement au code applicatif, où `console` est réservé à
|
||
* `LoggerService` — n'existe pas ici, PoC autonome sans app autour).
|
||
*/
|
||
async function runBenchmark(analyzer: LocalLlmStepAnalyzer): 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(
|
||
`[poc] (${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 analysis = await analyzer.analyzeStep(sentence.text);
|
||
const latencyMs = performance.now() - startedAt;
|
||
const rssDeltaBytes = process.memoryUsage().rss - rssBefore;
|
||
samples.push({ sentence, latencyMs, rssDeltaBytes, analysis });
|
||
console.info(
|
||
`[poc] -> ${latencyMs.toFixed(0)} ms, ${analysis.actions.length} action(s) détectée(s), RSS ${rssDeltaBytes >= 0 ? "+" : ""}${(rssDeltaBytes / (1024 * 1024)).toFixed(1)} Mo`,
|
||
);
|
||
} catch (err) {
|
||
console.error(`[poc] -> échec sur "${sentence.id}" (répétition ${repetition})`, err);
|
||
}
|
||
}
|
||
}
|
||
return samples;
|
||
}
|
||
|
||
/** Une ligne de sortie détaillée, une par échantillon — sert de matière première à la comparaison manuelle avec le pipeline `node-nlp`. */
|
||
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`,
|
||
);
|
||
console.info(` texte : ${sample.sentence.text}`);
|
||
console.info(` attendu : ${sample.sentence.note}`);
|
||
console.table(
|
||
sample.analysis.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(", "),
|
||
})),
|
||
);
|
||
}
|
||
}
|
||
|
||
/** Une ligne du tableau récapitulatif final — moyennes/min/max de latence et RAM par phrase, agrégées sur {@link REPETITIONS_PER_SENTENCE} répétitions. */
|
||
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;
|
||
"actions 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) : "—",
|
||
"actions détectées": lastSample?.analysis.actions.length ?? 0,
|
||
};
|
||
});
|
||
console.info("\n=== Récapitulatif ===");
|
||
console.table(rows);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Entrée du script
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Point d'entrée : charge le modèle choisi via `LLM_TECH_STEP_MODEL`
|
||
* (`"qwen2.5-1.5b"` par défaut, voir {@link RECOMMENDED_MODELS}), lance le
|
||
* benchmark sur {@link TEST_SENTENCES}, imprime les résultats détaillés puis
|
||
* le récapitulatif, et libère le modèle avant de quitter.
|
||
*/
|
||
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(
|
||
`[poc] modèle sélectionné : ${modelKey} (${RECOMMENDED_MODELS[modelKey].rationale})`,
|
||
);
|
||
|
||
const analyzer = new LocalLlmStepAnalyzer();
|
||
const rssBeforeLoad = process.memoryUsage().rss;
|
||
// `performance.now()` plutôt que `console.time`/`console.timeEnd` — la
|
||
// config Biome du repo n'autorise que error/warn/info/debug/table/assert
|
||
// sur `console` (voir `biome.json`, `suspicious.noConsole`), pas `time`.
|
||
const loadStartedAt = performance.now();
|
||
try {
|
||
await analyzer.initialize(modelKey);
|
||
} catch (err) {
|
||
console.error(
|
||
"[poc] échec du chargement du modèle — vérifier LLM_TECH_STEP_MODEL_PATH / la connexion réseau pour le téléchargement initial",
|
||
err,
|
||
);
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
const loadDurationMs = performance.now() - loadStartedAt;
|
||
const modelRssMb = (process.memoryUsage().rss - rssBeforeLoad) / (1024 * 1024);
|
||
console.info(
|
||
`[poc] modèle chargé en ${loadDurationMs.toFixed(0)} ms (+${modelRssMb.toFixed(1)} Mo RSS)`,
|
||
);
|
||
|
||
// Absorbe ici le coût caché du tout premier appel d'inférence (voir
|
||
// LocalLlmStepAnalyzer.warmUp) plutôt que de laisser la première phrase
|
||
// du benchmark le payer — sans ça, sa latence n'est pas comparable aux
|
||
// six autres.
|
||
const warmUpStartedAt = performance.now();
|
||
try {
|
||
await analyzer.warmUp();
|
||
} catch (err) {
|
||
console.error("[poc] échec du warm-up — le benchmark continue quand même", err);
|
||
}
|
||
console.info(`[poc] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`);
|
||
|
||
try {
|
||
const benchmarkStartedAt = performance.now();
|
||
const samples = await runBenchmark(analyzer);
|
||
const benchmarkDurationMs = performance.now() - benchmarkStartedAt;
|
||
console.info(`[poc] benchmark complet en ${benchmarkDurationMs.toFixed(0)} ms`);
|
||
printDetailedResults(samples);
|
||
printSummaryTable(samples);
|
||
} finally {
|
||
try {
|
||
await analyzer.dispose();
|
||
} catch (err) {
|
||
console.error("[poc] erreur lors de la libération du modèle", err);
|
||
}
|
||
}
|
||
}
|
||
|
||
await main();
|