/** * PoC autonome — même tâche que `llm-tech-step-poc.ts` (extraction JSON * contrainte par schéma d'une séquence d'actions culinaires), même * `SYSTEM_PROMPT` (importé tel quel, voir sa doc), mais via * [Ollama](https://ollama.com/) au lieu de `node-llama-cpp` — un troisième * point de comparaison, architecturalement différent des deux autres * moteurs LLM/NLP de ce PoC plutôt qu'une simple redite : * * - **`node-llama-cpp`** charge le binding natif llama.cpp DANS ce process * Node (mêmes poids, même mémoire, même thread pool que le script). * - **Ollama** est un serveur HTTP local **séparé** (`ollama serve`, lancé * par l'app de bureau ou en CLI) — ce script n'est qu'un client HTTP fin * (`ollama` sur npm, aucune dépendance native, aucun binding à compiler à * l'installation) qui lui parle en local (`http://127.0.0.1:11434` par * défaut). Conséquences directes, documentées où elles s'appliquent : * - Pas de téléchargement/cache GGUF géré par ce projet — Ollama gère ses * propres modèles (`~/.ollama/models`), récupérés via `ollama.pull()` * (voir {@link OllamaStepAnalyzer.initialize}). * - **Le delta de RSS de ce process ne mesure RIEN d'utile ici** : * l'inférence tourne dans le process `ollama serve`, pas dans celui-ci * — contrairement à `node-llama-cpp`, où le binding natif partage la * mémoire du process Node. Cette colonne du récapitulatif reste * affichée (même harness que les deux autres moteurs) mais est à * ignorer pour ce script, voir `README.md`. * - Nécessite Ollama installé et **son serveur déjà lancé** en dehors de * ce script (pas de "just works" comme le binding embarqué) — * {@link OllamaStepAnalyzer.initialize} échoue avec un message explicite * si le serveur n'est pas joignable plutôt qu'une erreur `fetch` brute. * - Le schéma JSON imposé au modèle (`format`, voir * {@link KITCHEN_ACTIONS_JSON_SCHEMA}) accepte du JSON Schema standard * (`type: ["string", "null"]` pour un champ nullable) — plus simple que * le détour `oneOf: [{type:"null"}, {type:"..."}]` qu'exige la * grammaire GBNF de node-llama-cpp (voir `llm-tech-step-poc.ts`), un * autre point de comparaison entre les deux mécanismes de contrainte. * * Usage : voir `README.md`. En bref : * * ```bash * ollama serve # dans un terminal séparé, si pas déjà lancé * cd experiments/llm-tech-step-poc * pnpm install --ignore-workspace * pnpm bench:ollama * ``` */ import { performance } from "node:perf_hooks"; import { Ollama } from "ollama"; import { SYSTEM_PROMPT } from "./llm-tech-step-poc.js"; import { type BenchmarkSample, printSummaryTable, runBenchmark, } from "./shared/benchmark-harness.js"; import { KitchenActionType, type RecipeStepAnalysis } from "./shared/kitchen-action.js"; import { isMainModule } from "./shared/module-entry.js"; import type { BenchmarkSentence } from "./shared/test-sentences.js"; // --------------------------------------------------------------------------- // Schéma JSON — passé tel quel à Ollama via `format` // --------------------------------------------------------------------------- /** * Schéma JSON standard (pas de dialecte GBNF-spécifique) — Ollama valide/ * contraint la génération directement contre ce schéma via son paramètre * `format`. Champ à champ, en miroir strict de `KitchenAction` * (`shared/kitchen-action.ts`), même remarque que côté `node-llama-cpp` : * ça n'impose qu'une SYNTAXE JSON valide, jamais la justesse sémantique du * contenu — c'est {@link SYSTEM_PROMPT} qui porte la sémantique. */ const KITCHEN_ACTION_JSON_SCHEMA = { type: "object", properties: { action: { type: "string", enum: Object.values(KitchenActionType) }, verb: { type: "string" }, ingredients: { type: "array", items: { type: "string" } }, durationMinutes: { type: ["number", "null"] }, temperature: { type: ["string", "null"] }, utensils: { type: "array", items: { type: "string" } }, }, required: ["action", "verb", "ingredients", "durationMinutes", "temperature", "utensils"], }; /** Racine du schéma — même choix qu'en `node-llama-cpp` (`{ actions: [...] }` plutôt qu'un tableau nu), `originalText` volontairement absent, voir `llm-tech-step-poc.ts` pour le raisonnement complet. */ const KITCHEN_ACTIONS_JSON_SCHEMA = { type: "object", properties: { actions: { type: "array", items: KITCHEN_ACTION_JSON_SCHEMA }, }, required: ["actions"], }; /** Forme attendue du JSON renvoyé par Ollama (`response.message.content`, une chaîne à parser) une fois conforme à {@link KITCHEN_ACTIONS_JSON_SCHEMA}. */ interface KitchenActionsSchemaResult { actions: RecipeStepAnalysis["actions"]; } // --------------------------------------------------------------------------- // Modèles recommandés // --------------------------------------------------------------------------- /** Mêmes deux familles de modèles que `llm-tech-step-poc.ts` (voir son comparatif) — pour rester comparable, référencées ici par leur tag Ollama plutôt qu'une URI `hf:`. */ export type RecommendedOllamaModelKey = "qwen2.5-1.5b" | "llama-3.2-1b"; interface RecommendedOllamaModel { /** Tag tel qu'Ollama le résout (`ollama pull `) — voir https://ollama.com/library. */ tag: string; rationale: string; } const RECOMMENDED_MODELS: Record = { "qwen2.5-1.5b": { tag: "qwen2.5:1.5b", rationale: "Même choix par défaut que côté node-llama-cpp : meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON.", }, "llama-3.2-1b": { tag: "llama3.2:1b", rationale: "Alternative plus légère — voir le comparatif détaillé et les résultats empiriques dans le README et dans llm-tech-step-poc.ts.", }, }; const DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434"; // --------------------------------------------------------------------------- // OllamaStepAnalyzer // --------------------------------------------------------------------------- /** * Client Ollama enrobé comme service d'analyse d'étapes de recette — vraie * `class` (pas un objet littéral), même convention que * `LocalLlmStepAnalyzer`/`NlpTechStepClassifier` : possède un état réel (le * client HTTP, le tag du modèle sélectionné), même si ici l'état lourd * (les poids du modèle) vit dans le process `ollama serve` séparé, pas * dans cette instance. */ export class OllamaStepAnalyzer { private readonly _client: Ollama; private readonly _host: string; /** Tag du modèle une fois résolu/pull par `initialize()`. `undefined` avant. */ private _modelTag: string | undefined; public constructor(host: string = DEFAULT_OLLAMA_HOST) { this._host = host; this._client = new Ollama({ host }); } /** * Vérifie/télécharge le modèle (`ollama pull`, no-op quasi instantané si * déjà présent localement — Ollama compare les manifestes de couches * avant de retélécharger quoi que ce soit) et journalise la progression * par palier de statut plutôt que de rester muet le temps du * téléchargement (potentiellement plusieurs centaines de Mo au premier * pull d'un modèle). * * Échoue avec un message explicite (plutôt que l'erreur `fetch` brute * remontée par `ollama-js`) si le serveur Ollama n'est pas joignable — * contrairement à `node-llama-cpp`, ce PoC dépend d'un process externe * que ce script ne lance pas lui-même. */ public async initialize(modelKey: RecommendedOllamaModelKey): Promise { const tag = RECOMMENDED_MODELS[modelKey].tag; console.info(`[ollama] vérification/pull du modèle "${tag}" sur ${this._host}...`); try { await this._ensureModelPulled(tag); } catch (err) { throw new Error( `OllamaStepAnalyzer: impossible de joindre Ollama sur ${this._host} — le serveur est-il lancé (\`ollama serve\`, ou l'app de bureau Ollama) ?`, { cause: err }, ); } this._modelTag = tag; } /** Force un premier appel factice — même rôle que le warm-up des deux autres moteurs : le premier vrai appel `chat()` déclenche le chargement des poids en mémoire côté serveur Ollama, un coût cependant nettement moins visible ici qu'avec node-llama-cpp car mutualisé/mis en cache par le serveur entre plusieurs process clients. */ public async warmUp(): Promise { await this.analyzeStep("Faites chauffer une poêle."); } /** Analyse une étape de recette et renvoie sa séquence ordonnée d'actions, via `ollama.chat()` contraint par {@link KITCHEN_ACTIONS_JSON_SCHEMA}. */ public async analyzeStep(stepText: string): Promise { if (this._modelTag === undefined) { throw new Error("OllamaStepAnalyzer.initialize() must be awaited before analyzeStep()."); } const response = await this._client.chat({ model: this._modelTag, messages: [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: stepText }, ], format: KITCHEN_ACTIONS_JSON_SCHEMA, // Température 0 — génération déterministe, cohérent avec l'usage // d'un schéma imposé : on veut la sortie la plus prévisible possible // pour ce qui reste discrétionnaire (le contenu, pas la syntaxe). options: { temperature: 0 }, stream: false, }); let parsed: KitchenActionsSchemaResult; try { parsed = JSON.parse(response.message.content) as KitchenActionsSchemaResult; } catch (err) { throw new Error( `OllamaStepAnalyzer: réponse non-JSON malgré le schéma imposé — "${response.message.content}"`, { cause: err }, ); } return { originalText: stepText, actions: parsed.actions }; } /** * Décharge le modèle de la mémoire du serveur Ollama (`keep_alive: 0`) — * best effort, purement pour ne pas laisser le modèle chargé * indéfiniment après ce benchmark : `ollama serve` tourne indépendamment * de ce script (pas lancé ni arrêté par lui), donc rien d'autre à * libérer côté process Node. */ public async dispose(): Promise { if (this._modelTag === undefined) return; try { await this._client.chat({ model: this._modelTag, messages: [], keep_alive: 0 }); } catch (err) { console.error("[ollama] échec du déchargement du modèle (non bloquant)", err); } } /** Lance un `pull` en streaming et journalise chaque changement de statut (`pulling manifest`, `downloading`, `verifying sha256 digest`...) avec le pourcentage quand Ollama le fournit. */ private async _ensureModelPulled(tag: string): Promise { const progress = await this._client.pull({ model: tag, stream: true }); let lastStatus = ""; for await (const part of progress) { if (part.status === lastStatus) continue; lastStatus = part.status; const percent = part.completed !== undefined && part.total !== undefined && part.total > 0 ? ` (${Math.round((part.completed / part.total) * 100)}%)` : ""; console.info(`[ollama] ${part.status}${percent}`); } } } // --------------------------------------------------------------------------- // Benchmark // --------------------------------------------------------------------------- /** Imprime le détail de chaque échantillon — même format que `llm-tech-step-poc.ts`, pour comparer les deux moteurs LLM à l'œil ligne à ligne. */ 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.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(", "), })), ); } } /** * Point d'entrée : charge le modèle choisi via `OLLAMA_TECH_STEP_MODEL` * (`"qwen2.5-1.5b"` par défaut), contre le serveur Ollama de * `OLLAMA_TECH_STEP_HOST` (`http://127.0.0.1:11434` par défaut), lance le * benchmark sur les 11 phrases partagées, imprime les résultats détaillés * puis le récapitulatif, et décharge le modèle avant de quitter. */ async function main(): Promise { const modelKey: RecommendedOllamaModelKey = process.env.OLLAMA_TECH_STEP_MODEL === "llama-3.2-1b" ? "llama-3.2-1b" : "qwen2.5-1.5b"; const host = process.env.OLLAMA_TECH_STEP_HOST ?? DEFAULT_OLLAMA_HOST; console.info( `[ollama] modèle sélectionné : ${modelKey} (${RECOMMENDED_MODELS[modelKey].rationale})`, ); const analyzer = new OllamaStepAnalyzer(host); try { await analyzer.initialize(modelKey); } catch (err) { console.error("[ollama] échec de l'initialisation", err); process.exitCode = 1; return; } const warmUpStartedAt = performance.now(); try { await analyzer.warmUp(); } catch (err) { console.error("[ollama] échec du warm-up — le benchmark continue quand même", err); } console.info(`[ollama] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`); try { const benchmarkStartedAt = performance.now(); const samples = await runBenchmark({ logPrefix: "[ollama]", countOf: (result) => result.actions.length, countLabel: "action(s) détectée(s)", analyze: (sentence: BenchmarkSentence) => analyzer.analyzeStep(sentence.text), }); console.info( `[ollama] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`, ); printDetailedResults(samples); console.info( "\n[ollama] rappel : la colonne 'RSS moy.' ci-dessous ne mesure rien d'utile pour ce moteur — l'inférence tourne dans le process `ollama serve`, pas dans ce script (voir le doc-comment en tête de fichier).", ); printSummaryTable(samples, (result) => result.actions.length, "actions détectées"); } finally { try { await analyzer.dispose(); } catch (err) { console.error("[ollama] erreur lors de la libération du modèle", err); } } } if (isMainModule(import.meta.url)) { await main(); }