diff --git a/apps/api/src/scripts/bench-tech-step-classifier.ts b/apps/api/src/scripts/bench-tech-step-classifier.ts deleted file mode 100644 index c138914..0000000 --- a/apps/api/src/scripts/bench-tech-step-classifier.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { performance } from "node:perf_hooks"; -import { prisma } from "../db/prisma.js"; -import { - type TechStepMatch, - techStepClassifier, -} from "../lib/recipe-matching/tech-step-matcher.js"; - -/** - * Benchmark du pipeline `node-nlp` existant (`TechStepClassifierService`, - * `tech-step-matcher.ts`) — le pendant, côté classifieur NLP classique déjà - * en production, du PoC LLM local dans - * `experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts`. Même structure - * de sortie (logs itératifs par répétition, tableau récapitulatif latence/ - * RAM) et **mêmes 7 phrases de test** ({@link TEST_SENTENCES} ci-dessous, - * recopiées à l'identique — même texte, même `id`, même `locale` — depuis - * `TEST_SENTENCES` du PoC LLM ; les deux fichiers ne peuvent pas partager un - * module commun sans casser l'autonomie du PoC LLM, volontairement hors du - * workspace pnpm, donc synchroniser les deux à la main si l'un des deux - * change) pour que les deux runs soient directement comparables phrase par - * phrase. - * - * Contrairement au PoC LLM, ce script **nécessite une vraie base Postgres** - * accessible (`.env`/`.env.test` selon l'environnement) avec la table - * `TechStep` seedée (`pnpm --filter api prisma:seed`) — `matchTechStepSpans` - * résout ses `uid` de training data vers de vrais `TechStep.id`, voir - * `tech-step-matcher.ts`. - * - * Usage : - * - * ```bash - * pnpm --filter api exec tsx src/scripts/bench-tech-step-classifier.ts - * ``` - * - * Sortie non isomorphe à celle du PoC LLM : `TechStepMatch` renvoie un - * `techStepId` (résolu ici en `key` pour être lisible) + des spans de - * caractères dans la taxonomie fine à ~26 techniques de - * `tech-step-training-data.ts` (`cook`, `fry`, `melt`, `deglaze`, - * `simmer`...), pas la structure `KitchenAction` (verbe/ingrédients/durée/ - * température/ustensiles) sur la taxonomie à 7 catégories du PoC LLM — la - * comparaison porte sur le nombre de techniques détectées par phrase, si la - * technique choisie est correcte, et le comportement sur la phrase-piège - * FR sans verbe littéral (`fr-action-implicite`), pas sur une égalité - * champ à champ. Voir la section "Méthodologie de comparaison" du - * `README.md` du PoC LLM. - */ - -/** Une phrase de test — même forme que `BenchmarkSentence` du PoC LLM. */ -interface BenchmarkSentence { - id: string; - locale: "fr" | "en"; - text: string; - note: string; -} - -/** - * Copie exacte de `TEST_SENTENCES` dans - * `experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts` — voir le - * commentaire de ce fichier pour le détail de ce que chaque phrase teste - * (multi-actions, action implicite, actions parallèles, action - * conditionnelle, simultanéité + négation, double `REST` + seuil de - * cuisson). - */ -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 — même valeur que le PoC LLM, pour des runs comparables. */ -const REPETITIONS_PER_SENTENCE = 3; - -/** Une mesure individuelle (une répétition, une phrase). */ -interface BenchmarkSample { - sentence: BenchmarkSentence; - latencyMs: number; - /** Delta de RSS du process Node — même caveat que côté PoC LLM : une approximation bruitée par le GC, pas une mesure isolée. */ - rssDeltaBytes: number; - matches: TechStepMatch[]; -} - -/** `TechStep.id -> key` — résolu une fois avant le benchmark pour afficher des noms de technique lisibles plutôt que des ids bruts. */ -async function loadTechStepKeyById(): Promise> { - const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } }); - return new Map(techSteps.map((techStep) => [techStep.id, techStep.key])); -} - -/** - * Exécute {@link REPETITIONS_PER_SENTENCE} analyses par phrase de - * {@link TEST_SENTENCES}, en journalisant chaque répétition au fur et à - * mesure (même raisonnement que `runBenchmark` du PoC LLM : un run complet - * ne doit pas rester muet jusqu'au récapitulatif final). - */ -async function runBenchmark(): Promise { - 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( - `[bench] (${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 matches = await techStepClassifier.matchTechStepSpans(sentence.text, sentence.locale); - const latencyMs = performance.now() - startedAt; - const rssDeltaBytes = process.memoryUsage().rss - rssBefore; - samples.push({ sentence, latencyMs, rssDeltaBytes, matches }); - console.info( - `[bench] -> ${latencyMs.toFixed(0)} ms, ${matches.length} technique(s) détectée(s), RSS ${rssDeltaBytes >= 0 ? "+" : ""}${(rssDeltaBytes / (1024 * 1024)).toFixed(1)} Mo`, - ); - } catch (err) { - console.error(`[bench] -> échec sur "${sentence.id}" (répétition ${repetition})`, err); - } - } - } - return samples; -} - -/** Une ligne de sortie détaillée, une par échantillon — matière première pour comparer à l'œil avec le PoC LLM. */ -function printDetailedResults( - samples: readonly BenchmarkSample[], - techStepKeyById: ReadonlyMap, -): 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.matches.map((match) => ({ - technique: techStepKeyById.get(match.techStepId) ?? `#${match.techStepId}`, - mot_clé: sample.sentence.text.slice(match.start, match.end), - clause: sample.sentence.text.slice(match.contextStart, match.contextEnd).trim(), - })), - ); - } -} - -/** Une ligne du tableau récapitulatif final — mêmes colonnes que le PoC LLM (à `actions détectées` près, ici `techniques détectées`). */ -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; - "techniques 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) : "—", - "techniques détectées": lastSample?.matches.length ?? 0, - }; - }); - console.info("\n=== Récapitulatif ==="); - console.table(rows); -} - -/** - * Point d'entrée : warm-up du classifieur (`techStepClassifier.warmUp()` — - * entraînement + init paresseuse de node-nlp, déjà prévu pour ça, voir sa - * doc dans `tech-step-matcher.ts`), benchmark sur {@link TEST_SENTENCES}, - * résultats détaillés puis récapitulatif, puis fermeture de la connexion - * Prisma. - */ -async function main(): Promise { - console.info("[bench] warm-up du classifieur (entraînement node-nlp)..."); - const warmUpStartedAt = performance.now(); - await techStepClassifier.warmUp(); - console.info(`[bench] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`); - - const techStepKeyById = await loadTechStepKeyById(); - - const benchmarkStartedAt = performance.now(); - const samples = await runBenchmark(); - console.info( - `[bench] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`, - ); - - printDetailedResults(samples, techStepKeyById); - printSummaryTable(samples); -} - -main() - .then(() => prisma.$disconnect()) - .catch(async (err) => { - console.error(err); - await prisma.$disconnect(); - process.exit(1); - }); diff --git a/experiments/llm-tech-step-poc/README.md b/experiments/llm-tech-step-poc/README.md index 66e11dc..0d629a1 100644 --- a/experiments/llm-tech-step-poc/README.md +++ b/experiments/llm-tech-step-poc/README.md @@ -1,4 +1,4 @@ -# PoC — détection d'actions culinaires par mini LLM local +# PoC — détection d'actions culinaires : NLP, LLM local, hybride Expérimentation autonome, **hors du monorepo pnpm** (`pnpm-workspace.yaml` ne référence que `apps/*`/`packages/*`) : ce dossier a son propre @@ -6,12 +6,24 @@ référence que `apps/*`/`packages/*`) : ce dossier a son propre Docker de `apps/api`. Objectif : comparer, sur la même tâche (structurer une étape de recette en -séquence ordonnée d'actions), le pipeline `node-nlp` déjà en place -(`apps/api/src/lib/recipe-matching/tech-step-matcher.ts` — -`TechStepClassifierService`) à un mini LLM instruct tournant 100 % en local -via [`node-llama-cpp`](https://node-llama-cpp.withcat.ai/), avec sortie JSON -strictement contrainte par un schéma (GBNF grammar), sur trois axes : -précision, robustesse multilingue FR/EN, latence. +séquence ordonnée d'actions culinaires) et le même jeu de 7 phrases, trois +moteurs qui tournent tous 100 % en local : + +| Script | Moteur | Ce qu'il apporte | +|---|---|---| +| `pnpm bench` | Mini LLM instruct via [`node-llama-cpp`](https://node-llama-cpp.withcat.ai/), sortie JSON contrainte par schéma (GBNF grammar) | Généralise sans vocabulaire fixé à l'avance — au prix d'une latence de plusieurs secondes. | +| `pnpm bench:nlp` | Classifieur `node-nlp` frais (NER + découpage en clauses + classification d'intention), entraîné directement sur la taxonomie à 7 catégories de ce PoC | Rapide (centaines de ms), mais borné à son vocabulaire d'entraînement. | +| `pnpm bench:hybrid` | NLP d'abord, LLM en secours si le score NLP est trop faible | Le meilleur des deux : rapide sur le cas courant, généralise sur le cas difficile. | + +Les trois partagent le même code (`src/shared/`) : la taxonomie +`KitchenActionType`/`KitchenAction`/`RecipeStepAnalysis` +(`shared/kitchen-action.ts`), les 7 phrases de test +(`shared/test-sentences.ts`), et le harness de mesure/affichage +(`shared/benchmark-harness.ts`) — un seul jeu de phrases et un seul format +de sortie pour que les trois runs soient directement comparables, plutôt que +recopiés à la main dans trois scripts (le défaut d'une toute première +version de ce PoC, où le pendant NLP vivait dans `apps/api` et copiait les +phrases manuellement). ## Installation @@ -32,24 +44,30 @@ compilation locale — nécessite alors un toolchain C++, voir sa doc ["Troubleshooting"](https://node-llama-cpp.withcat.ai/guide/troubleshooting) en cas d'échec). -## Modèle +## 1. Benchmark LLM seul — `pnpm bench` -Le script télécharge automatiquement (une seule fois, mis en cache dans -`experiments/llm-tech-step-poc/models/`, jamais commité) le GGUF choisi via -`LLM_TECH_STEP_MODEL` : +```bash +pnpm bench +``` + +Télécharge le modèle GGUF choisi via `LLM_TECH_STEP_MODEL` (une seule fois, +mis en cache dans `experiments/llm-tech-step-poc/models/`, jamais commité), +le charge, fait un appel de warm-up (chronométré à part — le tout premier +appel d'inférence sur un contexte fraîchement créé paie un coût caché de +plusieurs secondes que le chargement du modèle ne couvre pas), puis lance 3 +répétitions sur chacune des 7 phrases de test. | Valeur (défaut en gras) | Modèle | Pourquoi | |---|---|---| | **`qwen2.5-1.5b`** | Qwen2.5-1.5B-Instruct, `Q4_K_M` | Meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON — et, empiriquement (voir `Résultats obtenus` ci-dessous), aussi la latence la plus basse des deux sur ce benchmark, malgré ses ~50 % de paramètres en plus. | -| `llama-3.2-1b` | Llama-3.2-1B-Instruct, `Q4_K_M` | ~35 % de paramètres en moins, FR officiellement supporté, mais structuration JSON moins fiable à 1B — et pas plus rapide non plus dans les runs obtenus jusqu'ici. Conservé comme point de comparaison, pas comme choix "latence d'abord" (l'hypothèse initiale du README). | +| `llama-3.2-1b` | Llama-3.2-1B-Instruct, `Q4_K_M` | ~35 % de paramètres en moins, FR officiellement supporté, mais structuration JSON moins fiable à 1B — et pas plus rapide non plus dans les runs obtenus jusqu'ici. Conservé comme point de comparaison, pas comme choix "latence d'abord". | > **Résultats obtenus** (Windows, backend Vulkan, une machine) : sur les 7 -> phrases de `TEST_SENTENCES`, Qwen2.5-1.5B a été systématiquement plus -> rapide que Llama-3.2-1B malgré sa taille plus grande — l'inverse de -> l'hypothèse a priori "moins de paramètres = plus rapide". Résultat gardé -> ici tel quel plutôt que la doc pré-run corrigée après coup silencieusement -> — mais un seul run sur une seule machine/un seul backend ne généralise pas -> forcément (CUDA/CPU pur donneraient possiblement un classement différent). +> phrases, Qwen2.5-1.5B a été systématiquement plus rapide que Llama-3.2-1B +> malgré sa taille plus grande — l'inverse de l'hypothèse a priori "moins de +> paramètres = plus rapide". Un seul run sur une seule machine/un seul +> backend ne généralise pas forcément (CUDA/CPU pur donneraient +> possiblement un classement différent). ```bash LLM_TECH_STEP_MODEL=llama-3.2-1b pnpm bench @@ -59,71 +77,83 @@ LLM_TECH_STEP_MODEL=llama-3.2-1b pnpm bench pointe directement vers un fichier déjà téléchargé, sans passer par la résolution/téléchargement Hugging Face. -## Lancer le benchmark +## 2. Benchmark NLP seul — `pnpm bench:nlp` ```bash -pnpm bench +pnpm bench:nlp ``` -Charge le modèle, fait un appel de warm-up (chronométré et affiché à part — -le tout premier appel d'inférence sur un contexte fraîchement créé paie un -coût caché de plusieurs secondes que le chargement du modèle ne couvre pas ; -sans ce warm-up c'est la première phrase du benchmark qui l'absorbe, -faussant sa latence par rapport aux six autres), puis lance 3 répétitions -sur chacune des 7 phrases de test -(4 FR + 3 EN, voir `TEST_SENTENCES` dans -[`src/llm-tech-step-poc.ts`](./src/llm-tech-step-poc.ts)) — les 3 premières -couvrent le cas courant (actions enchaînées, dont le cas piège documenté dans -`tech-step-matcher.ts` lui-même : "jusqu'à ce que le beurre ait disparu dans -la poêle", aucun verbe de cuisson littéral, seul le sens implique `COOK`), -les 4 suivantes poussent délibérément plus loin pour chercher le point de -rupture : actions simultanées plutôt que séquentielles, action conditionnelle -("if the batter looks too thick..."), négation explicite d'action ("sans -jamais laisser bouillir"), fin de cuisson par état/test de résultat plutôt -que par durée fixe, et un champ température qui désigne un seuil de cuisson -à cœur plutôt qu'un réglage de feu. Imprime, par phrase : le JSON détaillé de -chaque action détectée, puis un tableau récapitulatif (latence moyenne/min/max, -delta RSS moyen, nombre d'actions détectées). +Aucun téléchargement, aucune base de données — tourne en quelques secondes. +`NlpTechStepClassifier` (`src/nlp-tech-step-poc.ts`) est un classifieur +`node-nlp` **frais**, écrit pour ce PoC plutôt qu'une réutilisation de +`TechStepClassifierService` (`apps/api/src/lib/recipe-matching/ +tech-step-matcher.ts`) — deux raisons : -## Méthodologie de comparaison avec le pipeline `node-nlp` +1. **Comparaison vraiment terme à terme** : `TechStepClassifierService` + classe sur la taxonomie fine à ~26 techniques de + `tech-step-training-data.ts` (DB-backed), pas sur les 7 catégories de + `KitchenActionType` que le LLM produit — les nombres de détections + n'étaient pas directement comparables. Ce classifieur-ci est entraîné + directement sur les 7 mêmes catégories. +2. **Un score de confiance exploitable** : `TechStepClassifierService` + masque son score en retombant silencieusement sur l'ancre NER dès qu'il + est sous son seuil interne — utile en prod, mais ça cache le signal dont + le pipeline hybride (section 3) a besoin pour décider quand basculer + vers le LLM. Ce classifieur-ci renvoie toujours le score BRUT. -Ce script reste volontairement autonome (aucune dépendance vers `apps/api`, -donc pas de connexion Postgres requise pour le faire tourner). Le pendant -côté `node-nlp` vit dans `apps/api/src/scripts/bench-tech-step-classifier.ts` -— mêmes 7 phrases de `TEST_SENTENCES` (recopiées à l'identique, texte/`id`/ -`locale`, à synchroniser à la main si l'une des deux listes change), même -format de sortie (logs itératifs par répétition, tableau récapitulatif) : +Même pipeline NER → découpage en clauses → classification par clause que +`tech-step-matcher.ts`, implémentation propre à ce PoC (simplifiée : pas de +priorité aux frontières de phrase dans le découpage). Le corpus +d'entraînement (`TRAINING_DATA` dans `nlp-tech-step-poc.ts`) préfère un +synonyme mono-mot ("revenir") à une phrase figée ("faites revenir") quand +c'est possible — une leçon tirée d'un run antérieur de ce PoC : "faites +revenir" (2 mots) ratait "faites-**les**-revenir" (le pronom clitique +français insère un mot entre les deux et casse un matching de phrase +contiguë), un synonyme mono-mot matche quel que soit ce qui le précède. + +## 3. Pipeline hybride — `pnpm bench:hybrid` ```bash -pnpm --filter api exec tsx src/scripts/bench-tech-step-classifier.ts +pnpm bench:hybrid ``` -(nécessite une base Postgres accessible et `TechStep` seedée — voir -`apps/api/prisma/seed.ts` — puisque `matchTechStepSpans` résout ses `uid` -vers de vrais `TechStep.id`). +Combine les deux : le NLP analyse TOUJOURS en premier (chemin rapide) ; si +sa confiance globale (le minimum de confiance de ses clauses) est sous +`NLP_TRUST_THRESHOLD` (`0.6`, tunable dans `hybrid-tech-step-poc.ts`) ou +qu'il n'a rien trouvé du tout, son résultat est ENTIÈREMENT écarté et +l'étape est réanalysée par le LLM. Le récapitulatif affiche, par phrase, +quel moteur a répondu (`moteur`) et la confiance NLP qui a déclenché la +décision (`confiance NLP`) — de quoi ajuster le seuil en observant sur +quelles phrases le pipeline bascule. -Les deux sorties ne sont pas directement isomorphes (`TechStepMatch` renvoie -un `techStepId` (résolu en `key` par ce script pour être lisible) + des spans -de caractères, dans la taxonomie fine à ~26 techniques de -`tech-step-training-data.ts`, contre un `KitchenAction` structuré -ingrédients/durée/température/ustensiles sur la taxonomie à 7 catégories de -ce PoC) — la comparaison porte sur : le nombre d'actions/techniques -détectées par phrase, si la catégorie/technique choisie est correcte, et le -comportement sur la phrase piège FR sans verbe littéral -(`fr-action-implicite`). +Nécessite le modèle LLM (même téléchargement/options `LLM_TECH_STEP_MODEL`/ +`LLM_TECH_STEP_MODEL_PATH` que la section 1) puisqu'il reste le moteur de +secours. + +**Limite assumée** : quand le chemin NLP est pris, seuls `action`/`verb` +sont réellement connus — `ingredients`/`utensils` restent `[]` et +`durationMinutes`/`temperature` restent `null`, jamais inventés (le +classifieur NLP ne peut structurellement pas les extraire). Seul le chemin +LLM remplit tous les champs. 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. ## Limites de ce PoC - Pas de jeu d'évaluation étiqueté ni de métrique de précision automatisée — les 7 phrases sont inspectées à l'œil, pas notées. -- La grammaire GBNF ne garantit qu'une syntaxe JSON conforme au schéma, - jamais la justesse sémantique du contenu (catégorie choisie, durée - correctement extraite...) — voir le commentaire sur - `KITCHEN_ACTION_JSON_SCHEMA` dans le script. +- La grammaire GBNF (moteur LLM) ne garantit qu'une syntaxe JSON conforme au + schéma, jamais la justesse sémantique du contenu. +- Le corpus du classifieur NLP frais est volontairement compact (PoC, pas un + remplacement du corpus production `tech-step-training-data.ts`) — des + formes non couvertes (conjugaisons, synonymes absents) manqueront, comme + pour n'importe quel corpus fini. +- `NLP_TRUST_THRESHOLD` (`0.6`) est un point de départ raisonnable, pas une + valeur empiriquement optimisée — à ajuster en observant la colonne + `moteur` du récapitulatif hybride sur des étapes réelles. - Le delta de RSS process est une approximation de la RAM réellement utilisée - par l'inférence (le binding natif alloue dans le même process, donc le RSS - la capture, mais au bruit du GC/de l'allocateur près) — pas une mesure - isolée du seul processus llama.cpp. -- Latence mesurée en CPU pur (pas de configuration GPU dans ce PoC) — un + (un binding natif alloue dans le même process, donc le RSS la capture, + mais au bruit du GC/de l'allocateur près) — pas une mesure isolée. +- Latence LLM mesurée en CPU pur (pas de configuration GPU dans ce PoC) — un déploiement réel voudrait évaluer l'offload GPU (`gpuLayers` dans les options `loadModel`) si la cible dispose d'un GPU. diff --git a/experiments/llm-tech-step-poc/package.json b/experiments/llm-tech-step-poc/package.json index babc67e..efac5e9 100644 --- a/experiments/llm-tech-step-poc/package.json +++ b/experiments/llm-tech-step-poc/package.json @@ -3,13 +3,16 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "PoC autonome : détection d'actions culinaires dans une étape de recette via un mini LLM local (node-llama-cpp, sortie JSON contrainte par schéma), à comparer au pipeline node-nlp de apps/api/src/lib/recipe-matching/tech-step-matcher.ts.", + "description": "PoC autonome : trois moteurs de détection d'actions culinaires dans une étape de recette (LLM local via node-llama-cpp, classifieur node-nlp frais, pipeline hybride NLP+LLM), benchmarkés sur le même jeu de phrases.", "scripts": { "bench": "tsx src/llm-tech-step-poc.ts", + "bench:nlp": "tsx src/nlp-tech-step-poc.ts", + "bench:hybrid": "tsx src/hybrid-tech-step-poc.ts", "typecheck": "tsc --noEmit" }, "dependencies": { - "node-llama-cpp": "^3.20.0" + "node-llama-cpp": "^3.20.0", + "node-nlp": "4.27.0" }, "devDependencies": { "@types/node": "^22.9.0", diff --git a/experiments/llm-tech-step-poc/pnpm-lock.yaml b/experiments/llm-tech-step-poc/pnpm-lock.yaml index 4febaec..d34a9ef 100644 --- a/experiments/llm-tech-step-poc/pnpm-lock.yaml +++ b/experiments/llm-tech-step-poc/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: node-llama-cpp: specifier: ^3.20.0 version: 3.20.0(typescript@5.9.3) + node-nlp: + specifier: 4.27.0 + version: 4.27.0 devDependencies: '@types/node': specifier: ^22.9.0 @@ -194,6 +197,218 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==, tarball: https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz} + '@microsoft/recognizers-text-choice@1.3.1': + resolution: {integrity: sha512-HubunMJVq/OetmdvcAmBh5skMlg+yiScm3V2wNyNZIVvLgli4+8nzbg/W/fI9dpaf6wv9ZQ7d2IYvn8swJBo3A==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-choice/-/recognizers-text-choice-1.3.1.tgz} + engines: {node: '>=10.3.0'} + + '@microsoft/recognizers-text-data-types-timex-expression@1.3.1': + resolution: {integrity: sha512-jarJIFIJZBqeofy3hh0vdQo1yOmTM+jCjj6/zmo9JunsQ6LO750eZHCg9eLptQhsvq321XCt5xdRNLCwU8YeNA==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-data-types-timex-expression/-/recognizers-text-data-types-timex-expression-1.3.1.tgz} + engines: {node: '>=10.3.0'} + + '@microsoft/recognizers-text-date-time@1.3.2': + resolution: {integrity: sha512-fUEGOTccS55ZY0erzjS1bunJYA9lGXjcZoru5oPOlnxbJS4Lk0ylgdH2Ub2EjAyqr8DIJhdLNOEesCdAXMvlNg==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-date-time/-/recognizers-text-date-time-1.3.2.tgz} + engines: {node: '>=10.3.0'} + + '@microsoft/recognizers-text-number-with-unit@1.3.1': + resolution: {integrity: sha512-gzCpPP4zQ5Vb+RHaWjzP2t1c+mj6GYOsFoI2NyJkm8OZ52XI+x9SJCgrrD2ujzjOd5/CQVC46rE22rfGwXLDkA==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-number-with-unit/-/recognizers-text-number-with-unit-1.3.1.tgz} + engines: {node: '>=10.3.0'} + + '@microsoft/recognizers-text-number@1.3.1': + resolution: {integrity: sha512-JBxhSdihdQLQilCtqISEBw5kM+CNGTXzy5j5hNoZECNUEvBUPkAGNEJAeQPMP5abrYks29aSklnSvSyLObXaNQ==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-number/-/recognizers-text-number-1.3.1.tgz} + engines: {node: '>=10.3.0'} + + '@microsoft/recognizers-text-sequence@1.3.1': + resolution: {integrity: sha512-J7Kg35hpm0NcFHmu69Bb4q7DPDiSpCd8ApUZqNm59itIjrQJHpSdl9HF6JxuQQz0Ftc/li5ZLqSuupJAmA/sgg==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-sequence/-/recognizers-text-sequence-1.3.1.tgz} + engines: {node: '>=10.3.0'} + + '@microsoft/recognizers-text-suite@1.3.0': + resolution: {integrity: sha512-uqG4vzy5N2CmBaeINny0bLdnGp0jDbT1moNoLC+Yim3G8kHOU9lpDfwA6VN6HTYaDM5854SNMEzLjJdS1TPFTw==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-suite/-/recognizers-text-suite-1.3.0.tgz} + engines: {node: '>=10.3.0'} + + '@microsoft/recognizers-text@1.3.1': + resolution: {integrity: sha512-HikLoRUgSzM4OKP3JVBzUUp3Q7L4wgI17p/3rERF01HVmopcujY3i6wgx8PenCwbenyTNxjr1AwSDSVuFlYedQ==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text/-/recognizers-text-1.3.1.tgz} + engines: {node: '>=10.3.0'} + + '@nlpjs/builtin-duckling@4.26.1': + resolution: {integrity: sha512-3qkH955X2g5MXV1EqT3fTAT/lLEdiqqe5IgBDyr+MQB7FOV9R3YhqGIn3DFOl+TSm/tP5n/BAEptkTNn/TOpmQ==, tarball: https://registry.npmjs.org/@nlpjs/builtin-duckling/-/builtin-duckling-4.26.1.tgz} + + '@nlpjs/builtin-microsoft@4.26.1': + resolution: {integrity: sha512-AODgzTcfYUf5Ozm00aQnHImDum7Idtl0F9dSPoaXpfj7rZqP8hPZ7iWwdGTAvISH/da2YhjPOU65QSYk2YpjFA==, tarball: https://registry.npmjs.org/@nlpjs/builtin-microsoft/-/builtin-microsoft-4.26.1.tgz} + + '@nlpjs/core-loader@4.26.1': + resolution: {integrity: sha512-IiRtn65bdiUSQHy2kusco2fmhk39u2Mc2c5Fsm9+9EVG6BtJCmVEFU/btAzGDAmxEA/E4qKecaAT4LvcW6TPbA==, tarball: https://registry.npmjs.org/@nlpjs/core-loader/-/core-loader-4.26.1.tgz} + + '@nlpjs/core@4.26.1': + resolution: {integrity: sha512-M/PeFddsi3y7Z1piFJxsLGm5/xdMhcrpOsml7s6CTEgYo8iduaT30HDd61tZxDyvvJseU6uFqlXSn7XKkAcC1g==, tarball: https://registry.npmjs.org/@nlpjs/core/-/core-4.26.1.tgz} + + '@nlpjs/emoji@4.26.1': + resolution: {integrity: sha512-Q0PoXwIvaB1bnRXK4U/YD7mrqaz29Yfed3s2au0iXl1bffUgoG+hs4GORCvyy7DFCCLlc9d5yDM3oLIX/ggZ+Q==, tarball: https://registry.npmjs.org/@nlpjs/emoji/-/emoji-4.26.1.tgz} + + '@nlpjs/evaluator@4.26.1': + resolution: {integrity: sha512-WeUrC8qq7+V8Jhkkjc2yiXdzy9V0wbETv8/qasQmL0QmEuwBDJF+fvfl4z2vWpBb0vW07A8aNrFElKELzbpkdg==, tarball: https://registry.npmjs.org/@nlpjs/evaluator/-/evaluator-4.26.1.tgz} + + '@nlpjs/lang-all@4.26.1': + resolution: {integrity: sha512-UzRm1JRRAyQqilEOxQ2ySMOitKbhPk5iKYbjD8FREDcPjreUvDxVuQsYUOvYucmEyFcZU2U/TdJx+fX9/bcaKQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-all/-/lang-all-4.26.1.tgz} + + '@nlpjs/lang-ar@4.26.1': + resolution: {integrity: sha512-MUlVtabt9ltG7WyzCQpFJymLJlnEqp3mxhgN9JHyFH7oZMK3REvMovFfvEUAbfiYrJEv/BN5KKLL7yrvUeaHtg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ar/-/lang-ar-4.26.1.tgz} + + '@nlpjs/lang-bn@4.26.1': + resolution: {integrity: sha512-sim1iZKBDdehi/yBUKrLW51QvS9uB+sXW7lj+THVqBy5UsnEQvt4gzE0NsC873uJMh66vt2AlHkhzgPH0qH/nQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-bn/-/lang-bn-4.26.1.tgz} + + '@nlpjs/lang-ca@4.26.1': + resolution: {integrity: sha512-fD4R5tcAB0uYtNxSEF20b1KmF6nUQSbiJqrIUJI5yis4ObjCYRQnSh4bjVDKUKxyONjbD6L8EaK5GrY1/jkwFQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ca/-/lang-ca-4.26.1.tgz} + + '@nlpjs/lang-cs@4.26.1': + resolution: {integrity: sha512-CqI6VB8toaJ/MlP1D4K9BctA6GpZJhMKyEy+OX9xavDe4r4ao/SxlSaIYK3izK0k+J38lJWC5lXYGazfCdTGjA==, tarball: https://registry.npmjs.org/@nlpjs/lang-cs/-/lang-cs-4.26.1.tgz} + + '@nlpjs/lang-da@4.26.1': + resolution: {integrity: sha512-krI/ojeDSi329ENM/hLIsbUh1x4XRTKAbtPcbFxAY6XVhcSVoWPO7L77jFTL1NQeE1oGRFzGHaeC9hZJ8phVbA==, tarball: https://registry.npmjs.org/@nlpjs/lang-da/-/lang-da-4.26.1.tgz} + + '@nlpjs/lang-de@4.26.1': + resolution: {integrity: sha512-HfZQwsE5FICq9taVZDiyktmdAePVF5948NM80et0d9mx43RWDFhHKQYgtJPwfQXtdCoQtOM5TOJ2FanGwzPeaA==, tarball: https://registry.npmjs.org/@nlpjs/lang-de/-/lang-de-4.26.1.tgz} + + '@nlpjs/lang-el@4.26.1': + resolution: {integrity: sha512-pcOvuSwPCXxI+2xNZZzM4V5pTRDntYoJi0SP/ic2nV4IPQ0nU2j16dYfg1HlvET/E6iN1VTqghrCaf10SMkDGA==, tarball: https://registry.npmjs.org/@nlpjs/lang-el/-/lang-el-4.26.1.tgz} + + '@nlpjs/lang-en-min@4.26.1': + resolution: {integrity: sha512-1sJZ7dy7ysqzbsB8IklguvB88J8EPIv4XGVkZCcwecKtOw+fp5LAsZ3TJVmEf18iK1gD4cEGr7qZg5fpPxTpWQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-en-min/-/lang-en-min-4.26.1.tgz} + + '@nlpjs/lang-en@4.26.1': + resolution: {integrity: sha512-GVoJpOjyk5TtBAqo/fxsiuuH7jXycyakGT0gw5f01u9lOmUnpJegvXyGff/Nb0j14pXcGHXOhmpWrcTrG2B0LQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-en/-/lang-en-4.26.1.tgz} + + '@nlpjs/lang-es@4.26.1': + resolution: {integrity: sha512-fIPQt+WPcNdyxZOCMkOPlMb4Y1iE585QxjB9IAdFz8ZtVg7mc4dlv5f46ud7ppdMh84iLOuOdo6pzu2Cqm14lw==, tarball: https://registry.npmjs.org/@nlpjs/lang-es/-/lang-es-4.26.1.tgz} + + '@nlpjs/lang-eu@4.26.1': + resolution: {integrity: sha512-Ha8GHTbgQYd7dwHM8aWHDyxmbUNUcyu/5xlBKqqBOPxysDyZ6Ad0tvj0FmJBy6mYhqmFTPBnEAo69cfuFSqWIQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-eu/-/lang-eu-4.26.1.tgz} + + '@nlpjs/lang-fa@4.26.1': + resolution: {integrity: sha512-qJCmNXgJZnfNXUnKnxvEGEzSFBdQT4XU7/rMxuFmSJqmQY7fH/Vsmi5CKF94VRBPOIV4ULlEJuLpUWHXRmOnVQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-fa/-/lang-fa-4.26.1.tgz} + + '@nlpjs/lang-fi@4.26.1': + resolution: {integrity: sha512-W/rUcrzSh3KE07q2vOsssTpU1sbX32gbBzKPZfRJ2ZUF4afO+eHxmAywikXubP4kiU3JxVNLvXXEjuGD3SBUbA==, tarball: https://registry.npmjs.org/@nlpjs/lang-fi/-/lang-fi-4.26.1.tgz} + + '@nlpjs/lang-fr@4.26.1': + resolution: {integrity: sha512-LTA852atCJnHtKDmtjx/ui5AnvEIkrPx+MJQ2mB3gn8ko6i2UITnJgPmJE9Kej5bLasVZOAJvU/SrfXEmnPGOw==, tarball: https://registry.npmjs.org/@nlpjs/lang-fr/-/lang-fr-4.26.1.tgz} + + '@nlpjs/lang-ga@4.26.1': + resolution: {integrity: sha512-JsP1CZ8r3Jd6o/Az7cN3exz0HDP3FNYLzh4Vi6ksEkdKF0yCjJ9G5dXZYqS9qFIN5ffemWn29G4WRELY6QH/cQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ga/-/lang-ga-4.26.1.tgz} + + '@nlpjs/lang-gl@4.26.1': + resolution: {integrity: sha512-y1NNu6NVy/6o5UNfihgg0WkSlVr4IvKA5W193CpRLZWS4FccQDmnFFhyYWRkshyDbgEsfsZ0Rs3BoE82+T2Ubg==, tarball: https://registry.npmjs.org/@nlpjs/lang-gl/-/lang-gl-4.26.1.tgz} + + '@nlpjs/lang-hi@4.26.1': + resolution: {integrity: sha512-Fw9rXqF5l8q9etJG5uOlEFpnMVjQEWMaCIgQfEcA1yTvieSV8mpoSvQkEZl+DFhww+azareoJ7ZCkx0gJ9UDuQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-hi/-/lang-hi-4.26.1.tgz} + + '@nlpjs/lang-hu@4.26.1': + resolution: {integrity: sha512-7dPUn5/ZpLZmsdRwO+dtORuMIiIpnsWbgSLIKdOLh8irhgUR+M2bYTfkdnKcrEcHzHPP8Svn7pU0xk7OKSUA1w==, tarball: https://registry.npmjs.org/@nlpjs/lang-hu/-/lang-hu-4.26.1.tgz} + + '@nlpjs/lang-hy@4.26.1': + resolution: {integrity: sha512-T2brpLGDJryAwWmjtnmY8Ot6ZUkCz+/nRR9/QM1PybvZIqOVLjJqA49bqjJfT5DMN89HbwC7I/15NTT0y09i1Q==, tarball: https://registry.npmjs.org/@nlpjs/lang-hy/-/lang-hy-4.26.1.tgz} + + '@nlpjs/lang-id@4.26.1': + resolution: {integrity: sha512-rVuIkYFKdltFhMT/a2ZxD9ovoZSVZF7OPuqYjTXW9xKd3Ff32yUrzcf/pHXlqmZOSltqOH3E5jZRRDkHvgUOjQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-id/-/lang-id-4.26.1.tgz} + + '@nlpjs/lang-it@4.26.1': + resolution: {integrity: sha512-BZA3QnfQGW91gYaybRmHnCAPBvQggtmHZJrAmuBZUKUS12HoQm8uybjw2fZO+vahEeUQceKNDISRcT1eLLijog==, tarball: https://registry.npmjs.org/@nlpjs/lang-it/-/lang-it-4.26.1.tgz} + + '@nlpjs/lang-ja@4.26.1': + resolution: {integrity: sha512-QgkuJOkHguRFyfnckH2It5/Kg8zecnOMJsHxYeuDC4tBF7jL/5xqWis+679lYLsXtAkrG8+fjVcBbjyopP0KHg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ja/-/lang-ja-4.26.1.tgz} + + '@nlpjs/lang-ko@4.26.1': + resolution: {integrity: sha512-Q0N8bLJJ829ILWCKH1UQWPSNyuLaEURAXCawkDju4pt33DBLcpqz9IzO9dnqiFc+fjSgVzZ7WMaLT18hXZQ9vg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ko/-/lang-ko-4.26.1.tgz} + + '@nlpjs/lang-lt@4.26.1': + resolution: {integrity: sha512-SeYZxRhdCy+ClQNnF/u0MAtcDui/ocdk4NtgNOCuwNTNuzhN3t3rfGeArfBGmZeg1SIeBLUDE9dsTxYCv5AOEg==, tarball: https://registry.npmjs.org/@nlpjs/lang-lt/-/lang-lt-4.26.1.tgz} + + '@nlpjs/lang-ms@4.26.1': + resolution: {integrity: sha512-KxWBS+tFY2U8z9UrjQIqMM40npGDOskP5DcWhaEE3zuhzf3RTDYjy8sdz34jVd0fBdbPihX133h3bFibg2Cm7w==, tarball: https://registry.npmjs.org/@nlpjs/lang-ms/-/lang-ms-4.26.1.tgz} + + '@nlpjs/lang-ne@4.26.1': + resolution: {integrity: sha512-K3E2l+0LTESv+dO+ZTIdvNa+zwMJvvnMiFYYkKvJst6lhc8JgvGOsPxGsjJn6PDhI3wyfQu+dg3b+bnVPu4FDA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ne/-/lang-ne-4.26.1.tgz} + + '@nlpjs/lang-nl@4.26.1': + resolution: {integrity: sha512-I/mP1RRbUN4BQ+8NXAl2FKaLHbb7f6S8JVjxHQ0sKHT4BgQ3+r0yO+DVcEsHg+vWRiY1Fyzh0gq0PhLVnF6HnA==, tarball: https://registry.npmjs.org/@nlpjs/lang-nl/-/lang-nl-4.26.1.tgz} + + '@nlpjs/lang-no@4.26.1': + resolution: {integrity: sha512-a0CLL2c/OCzbg7J7ugyrsAksI96XhkQ3IeBbbx60o5o/9wsFNik6cPWrkpoE5xNtw7gLlAJWabwDiZXkl8Zrcw==, tarball: https://registry.npmjs.org/@nlpjs/lang-no/-/lang-no-4.26.1.tgz} + + '@nlpjs/lang-pl@4.26.1': + resolution: {integrity: sha512-nrDXlq+TzQLE5IpXPIlFMzd8OpquvApWsouh6fmLsD9HZLZI4O3w1M4sXXLzE+9Ggu9Cy1m1QJ0/i7XCcv115g==, tarball: https://registry.npmjs.org/@nlpjs/lang-pl/-/lang-pl-4.26.1.tgz} + + '@nlpjs/lang-pt@4.26.1': + resolution: {integrity: sha512-p6yZHaJ0e+n0avMHpdDw5PMk4HkKXjPbOMbrlg0dF+VRqChjxfH478Q423rDyzu/4MzDsIYB+p6KzL9AARKXpg==, tarball: https://registry.npmjs.org/@nlpjs/lang-pt/-/lang-pt-4.26.1.tgz} + + '@nlpjs/lang-ro@4.26.1': + resolution: {integrity: sha512-baUdTA0DWpDR0Tn6fxo+RDN/6gbuINLCARtHwap2UR/HKQWP2XoH/DIvcjZpwUTalr5MQjso31epcdeRRapczA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ro/-/lang-ro-4.26.1.tgz} + + '@nlpjs/lang-ru@4.26.1': + resolution: {integrity: sha512-NaZ2DAOGxWG2Us9IyIDs3m6vhGpUaUJRVgzzHHyX3LO3xEYjZmtnA0jEpBaTOe2PuNHThv0WCZUNn9BSurV3PA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ru/-/lang-ru-4.26.1.tgz} + + '@nlpjs/lang-sl@4.26.1': + resolution: {integrity: sha512-QBJwcJt+oKUpAnHKNJkLkx9Xm1n4dUPC5GPYfAXTnJZf0hNWJSY21GicdWi7Vu/qFJ3ghIqtSP8D7KIPLnibNw==, tarball: https://registry.npmjs.org/@nlpjs/lang-sl/-/lang-sl-4.26.1.tgz} + + '@nlpjs/lang-sr@4.26.1': + resolution: {integrity: sha512-drH3+UqTW637uLWsnLrcp8jEKUGxV61ZgCBjNkVQNEv1/jbpSg6IqgynSY2JyhtnlV0f870KS0HvSbyo5AD4Ng==, tarball: https://registry.npmjs.org/@nlpjs/lang-sr/-/lang-sr-4.26.1.tgz} + + '@nlpjs/lang-sv@4.26.1': + resolution: {integrity: sha512-2axkrYFC02tAlxCWeiEKISbe4dSteciP1CIggO/dZglnnLWgdF+g7kOeYMn7abCfFVSnh5vLqfDkrwnyIqt7Ag==, tarball: https://registry.npmjs.org/@nlpjs/lang-sv/-/lang-sv-4.26.1.tgz} + + '@nlpjs/lang-ta@4.26.1': + resolution: {integrity: sha512-keeh+croa1TAirV9Fd3OQMo5IkAlTGNWTNweHbi/htYMX0MKOPYxyqg+VH2bml+57VY2aUj/WYgV/p3ATx9EfQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ta/-/lang-ta-4.26.1.tgz} + + '@nlpjs/lang-th@4.26.1': + resolution: {integrity: sha512-2SWZhrln3rMw8/DsRc9yS5bi3qEdGfw2pq9Uejx/UYED5zvvL6kh9AiCJZT4k0wMBGEwWUV6HxJ0Pq/jOTHogg==, tarball: https://registry.npmjs.org/@nlpjs/lang-th/-/lang-th-4.26.1.tgz} + + '@nlpjs/lang-tl@4.26.1': + resolution: {integrity: sha512-AzmLtg28tm0VXCm0Q0EY3OtA3m4oYxaqh4VX6uhB4J+PoEsIkm0py12SJxMNIsh/r98pobCumH8KH9bvHQoCAg==, tarball: https://registry.npmjs.org/@nlpjs/lang-tl/-/lang-tl-4.26.1.tgz} + + '@nlpjs/lang-tr@4.26.1': + resolution: {integrity: sha512-p30uuXvE9pZeU/5XkrQfvxRgiAOBmP3EyBFGV/+P05PEogaqbsmmtVCgCnR63yeRvVnGbToPBPjRK3OO1y4AEQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-tr/-/lang-tr-4.26.1.tgz} + + '@nlpjs/lang-uk@4.26.1': + resolution: {integrity: sha512-PVEvmlhvl6BL3e/Q4qjMPsnwON3cWEYvDh9dg+Si+sjD2Edu9tajolJKcQ6ZA4I8dXrld5xuXx+DEBH/uB4uWQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-uk/-/lang-uk-4.26.1.tgz} + + '@nlpjs/lang-zh@4.26.1': + resolution: {integrity: sha512-kwqeqeEgMAMvucVX9HNE1p6s/2APP23ZsS8Um/lNvtswb4gL5jjYF9kyCvRfqlPBQSWWdRv7wwcnNXOvXYkxcQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-zh/-/lang-zh-4.26.1.tgz} + + '@nlpjs/language-min@4.25.0': + resolution: {integrity: sha512-g8jtbDbqtRm+dlD/1Vnb4VWfKbKteApEGVTqIMxYkk6N/HMhvLZ5J2svrxzrB98a/HZ0fb//YBfFgymnz9Oukg==, tarball: https://registry.npmjs.org/@nlpjs/language-min/-/language-min-4.25.0.tgz} + + '@nlpjs/language@4.25.0': + resolution: {integrity: sha512-tUF6QENoUQ/E26RYc32IgsttStSF9cNO4ySN+BQECn8VpjukWdwbMw073MlOLXzjfeobxa+3hCVrmPPcW+V3UA==, tarball: https://registry.npmjs.org/@nlpjs/language/-/language-4.25.0.tgz} + + '@nlpjs/ner@4.27.0': + resolution: {integrity: sha512-ptwkxriJdmgHSH9TfP10JQ1jviaSl2SupSFGUvTuWkuJhobQd3hbnlSq40V6XYvJNmqh9M9zEab/AKeghxYOTA==, tarball: https://registry.npmjs.org/@nlpjs/ner/-/ner-4.27.0.tgz} + + '@nlpjs/neural@4.25.0': + resolution: {integrity: sha512-Oz20denGiBe0DlQsS7lN4TNrATN1nXlHKc/HB6jJPegjVmgJVCugDaHwIGoV7qOWyA6F2fRRwOgD+quNT2gVpg==, tarball: https://registry.npmjs.org/@nlpjs/neural/-/neural-4.25.0.tgz} + + '@nlpjs/nlg@4.26.1': + resolution: {integrity: sha512-PCJWiZ7464ChXXUGvjBZIFtoqkC24Oy6X63HgQrSv+63svz22Y5Cmu1MYLk77Nb+4keWv+hKhFJKDkvJoOpBVg==, tarball: https://registry.npmjs.org/@nlpjs/nlg/-/nlg-4.26.1.tgz} + + '@nlpjs/nlp@4.27.0': + resolution: {integrity: sha512-q6X7sY6TYVnQRZJKF/6mfLFlNA5oRYLhgQ5k3i1IBqH9lbWTAZJr31w/dCf97HXaYaj+vJp3h0ucfNumme9EIw==, tarball: https://registry.npmjs.org/@nlpjs/nlp/-/nlp-4.27.0.tgz} + + '@nlpjs/nlu@4.27.0': + resolution: {integrity: sha512-j4DUdoXS/y/Xag6ysYXx7Ve8NBmUVViUSCJhj3r49+zGyYtyVAHuVcqSej5q0tJjn0JSMT+6+ip8klON1q8ixw==, tarball: https://registry.npmjs.org/@nlpjs/nlu/-/nlu-4.27.0.tgz} + + '@nlpjs/request@4.25.0': + resolution: {integrity: sha512-MPVYWfFZY03WyFL7GWkUkv8tw968OXsdxFSJEvjXHzhiCe/vAlPCWbvoR+VnoQTgzLHxs/KIF6sIF2s9AzsLmQ==, tarball: https://registry.npmjs.org/@nlpjs/request/-/request-4.25.0.tgz} + + '@nlpjs/sentiment@4.26.1': + resolution: {integrity: sha512-U2WmcW3w6yDDO45+Y7v5e6DPQj8e0x+RUUePPyRu2uIZmUtIKG+qCPMWnNLMmYQZoSQEFxmMMlLcGDC7tN7o3w==, tarball: https://registry.npmjs.org/@nlpjs/sentiment/-/sentiment-4.26.1.tgz} + + '@nlpjs/similarity@4.26.1': + resolution: {integrity: sha512-QutSBFGo/huNuz60PgqCjub0oBd9S8MLrjme33U5GzxuSvToQzXtn9/ynIia8qDm009D09VXV+LPeNE4h7yuSg==, tarball: https://registry.npmjs.org/@nlpjs/similarity/-/similarity-4.26.1.tgz} + + '@nlpjs/slot@4.26.1': + resolution: {integrity: sha512-mK8EEy5O+mRGne822PIKMxHSFh8j+iC7hGJ6T31XdFsNhFEYXLI/0dmeBstZgTSKBTe27HNFgCCwuGb77u0o9w==, tarball: https://registry.npmjs.org/@nlpjs/slot/-/slot-4.26.1.tgz} + + '@nlpjs/xtables@4.25.0': + resolution: {integrity: sha512-+baCtMZIp+aDqODLQs8Wyyke5qUqQkL8AGWsZzwYuJV8S7xdW2+XklRnHnkFc3p3foC248TkzG5L8j9r6INOtg==, tarball: https://registry.npmjs.org/@nlpjs/xtables/-/xtables-4.25.0.tgz} + '@node-llama-cpp/linux-arm64@3.20.0': resolution: {integrity: sha512-WFAffebfOLqBaZMfNsORns1G5vLMRVthxw/moDzON7TGYH6PTQN97h5YkLfRoSmZne/rtpHXH2LdYg0vFNAgnQ==, tarball: https://registry.npmjs.org/@node-llama-cpp/linux-arm64/-/linux-arm64-3.20.0.tgz} engines: {node: '>=20.0.0'} @@ -340,9 +555,21 @@ packages: resolution: {integrity: sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==, tarball: https://registry.npmjs.org/@tinyhttp/content-disposition/-/content-disposition-2.2.4.tgz} engines: {node: '>=12.17.0'} + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==, tarball: https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz} + engines: {node: '>= 10'} + '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==, tarball: https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz} + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==, tarball: https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz} + engines: {node: '>=0.8'} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==, tarball: https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz} + engines: {node: '>= 6.0.0'} + ansi-escapes@6.2.1: resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==, tarball: https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz} engines: {node: '>=14.16'} @@ -366,10 +593,20 @@ packages: async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==, tarball: https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz} + async@2.6.4: + resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==, tarball: https://registry.npmjs.org/async/-/async-2.6.4.tgz} + + bignumber.js@7.2.1: + resolution: {integrity: sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==, tarball: https://registry.npmjs.org/bignumber.js/-/bignumber.js-7.2.1.tgz} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==, tarball: https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz} engines: {node: '>= 0.8'} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==, tarball: https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz} + engines: {node: '>=0.8'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==, tarball: https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -406,6 +643,10 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==, tarball: https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz} + engines: {node: '>=0.8'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, tarball: https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz} engines: {node: '>=7.0.0'} @@ -417,6 +658,11 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==, tarball: https://registry.npmjs.org/commander/-/commander-10.0.1.tgz} engines: {node: '>=14'} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==, tarball: https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz} + engines: {node: '>=0.8'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, tarball: https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz} engines: {node: '>= 8'} @@ -434,6 +680,9 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==, tarball: https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz} engines: {node: '>=4.0.0'} + doublearray@0.0.2: + resolution: {integrity: sha512-aw55FtZzT6AmiamEj2kvmR6BuFqvYgKZUkfQ7teqVRNqD5UE0rw8IeW/3gieHNKQ5sPuDKlljWEn4bzv5+1bHw==, tarball: https://registry.npmjs.org/doublearray/-/doublearray-0.0.2.tgz} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==, tarball: https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz} @@ -453,6 +702,24 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==, tarball: https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz} engines: {node: '>=6'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==, tarball: https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz} + engines: {node: '>=6.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==, tarball: https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz} + engines: {node: '>=4'} + hasBin: true + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==, tarball: https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==, tarball: https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz} + engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz} @@ -464,6 +731,10 @@ packages: resolution: {integrity: sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==, tarball: https://registry.npmjs.org/filenamify/-/filenamify-6.0.0.tgz} engines: {node: '>=16'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==, tarball: https://registry.npmjs.org/frac/-/frac-1.1.2.tgz} + engines: {node: '>=0.8'} + fs-extra@11.4.0: resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==, tarball: https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz} engines: {node: '>=14.14'} @@ -484,6 +755,17 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, tarball: https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz} + grapheme-splitter@1.0.4: + resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==, tarball: https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==, tarball: https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz} + engines: {node: '>= 6'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==, tarball: https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz} + engines: {node: '>= 6'} + ignore@7.0.6: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==, tarball: https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz} engines: {node: '>= 4'} @@ -522,6 +804,9 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==, tarball: https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz} + kuromoji@0.1.2: + resolution: {integrity: sha512-V0dUf+C2LpcPEXhoHLMAop/bOht16Dyr+mDiIE39yX3vqau7p80De/koFqpiTcL1zzdZlc3xuHZ8u5gjYRfFaQ==, tarball: https://registry.npmjs.org/kuromoji/-/kuromoji-0.1.2.tgz} + lifecycle-utils@2.1.0: resolution: {integrity: sha512-AnrXnE2/OF9PHCyFg0RSqsnQTzV991XaZA/buhFDoc58xU7rhSCDgCz/09Lqpsn4MpoPHt7TRAXV1kWZypFVsA==, tarball: https://registry.npmjs.org/lifecycle-utils/-/lifecycle-utils-2.1.0.tgz} @@ -531,6 +816,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==, tarball: https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==, tarball: https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz} + log-symbols@7.0.1: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==, tarball: https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz} engines: {node: '>=18'} @@ -579,6 +867,9 @@ packages: typescript: optional: true + node-nlp@4.27.0: + resolution: {integrity: sha512-LnkhOUPXX0CMFbSzJ1gHI+7Yb3ULLip5gRsqedXb6pryjcRCbNzPgHXcH/6G9B1vSbDfO+y3X2B4QZpfP12OyQ==, tarball: https://registry.npmjs.org/node-nlp/-/node-nlp-4.27.0.tgz} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==, tarball: https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz} engines: {node: '>=18'} @@ -668,6 +959,14 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==, tarball: https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz} engines: {node: '>=20'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==, tarball: https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz} + engines: {node: '>=0.10.0'} + + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==, tarball: https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz} + engines: {node: '>=0.8'} + stdin-discarder@0.3.2: resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==, tarball: https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz} engines: {node: '>=18'} @@ -742,10 +1041,23 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==, tarball: https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz} + engines: {node: '>=0.8'} + + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==, tarball: https://registry.npmjs.org/word/-/word-0.3.0.tgz} + engines: {node: '>=0.8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==, tarball: https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz} engines: {node: '>=10'} + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==, tarball: https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz} + engines: {node: '>=0.8'} + hasBin: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, tarball: https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz} engines: {node: '>=10'} @@ -766,6 +1078,9 @@ packages: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==, tarball: https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz} engines: {node: '>=18'} + zlibjs@0.3.1: + resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==, tarball: https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz} + snapshots: '@esbuild/aix-ppc64@0.28.2': @@ -860,6 +1175,341 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} + '@microsoft/recognizers-text-choice@1.3.1': + dependencies: + '@microsoft/recognizers-text': 1.3.1 + grapheme-splitter: 1.0.4 + + '@microsoft/recognizers-text-data-types-timex-expression@1.3.1': {} + + '@microsoft/recognizers-text-date-time@1.3.2': + dependencies: + '@microsoft/recognizers-text': 1.3.1 + '@microsoft/recognizers-text-number': 1.3.1 + '@microsoft/recognizers-text-number-with-unit': 1.3.1 + lodash: 4.18.1 + + '@microsoft/recognizers-text-number-with-unit@1.3.1': + dependencies: + '@microsoft/recognizers-text': 1.3.1 + '@microsoft/recognizers-text-number': 1.3.1 + lodash: 4.18.1 + + '@microsoft/recognizers-text-number@1.3.1': + dependencies: + '@microsoft/recognizers-text': 1.3.1 + bignumber.js: 7.2.1 + lodash: 4.18.1 + + '@microsoft/recognizers-text-sequence@1.3.1': + dependencies: + '@microsoft/recognizers-text': 1.3.1 + grapheme-splitter: 1.0.4 + + '@microsoft/recognizers-text-suite@1.3.0': + dependencies: + '@microsoft/recognizers-text': 1.3.1 + '@microsoft/recognizers-text-choice': 1.3.1 + '@microsoft/recognizers-text-data-types-timex-expression': 1.3.1 + '@microsoft/recognizers-text-date-time': 1.3.2 + '@microsoft/recognizers-text-number': 1.3.1 + '@microsoft/recognizers-text-number-with-unit': 1.3.1 + '@microsoft/recognizers-text-sequence': 1.3.1 + + '@microsoft/recognizers-text@1.3.1': {} + + '@nlpjs/builtin-duckling@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/builtin-microsoft@4.26.1': + dependencies: + '@microsoft/recognizers-text-suite': 1.3.0 + '@nlpjs/core': 4.26.1 + + '@nlpjs/core-loader@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + '@nlpjs/request': 4.25.0 + transitivePeerDependencies: + - supports-color + + '@nlpjs/core@4.26.1': {} + + '@nlpjs/emoji@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/evaluator@4.26.1': + dependencies: + escodegen: 2.1.0 + esprima: 4.0.1 + + '@nlpjs/lang-all@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + '@nlpjs/lang-ar': 4.26.1 + '@nlpjs/lang-bn': 4.26.1 + '@nlpjs/lang-ca': 4.26.1 + '@nlpjs/lang-cs': 4.26.1 + '@nlpjs/lang-da': 4.26.1 + '@nlpjs/lang-de': 4.26.1 + '@nlpjs/lang-el': 4.26.1 + '@nlpjs/lang-en': 4.26.1 + '@nlpjs/lang-es': 4.26.1 + '@nlpjs/lang-eu': 4.26.1 + '@nlpjs/lang-fa': 4.26.1 + '@nlpjs/lang-fi': 4.26.1 + '@nlpjs/lang-fr': 4.26.1 + '@nlpjs/lang-ga': 4.26.1 + '@nlpjs/lang-gl': 4.26.1 + '@nlpjs/lang-hi': 4.26.1 + '@nlpjs/lang-hu': 4.26.1 + '@nlpjs/lang-hy': 4.26.1 + '@nlpjs/lang-id': 4.26.1 + '@nlpjs/lang-it': 4.26.1 + '@nlpjs/lang-ja': 4.26.1 + '@nlpjs/lang-ko': 4.26.1 + '@nlpjs/lang-lt': 4.26.1 + '@nlpjs/lang-ms': 4.26.1 + '@nlpjs/lang-ne': 4.26.1 + '@nlpjs/lang-nl': 4.26.1 + '@nlpjs/lang-no': 4.26.1 + '@nlpjs/lang-pl': 4.26.1 + '@nlpjs/lang-pt': 4.26.1 + '@nlpjs/lang-ro': 4.26.1 + '@nlpjs/lang-ru': 4.26.1 + '@nlpjs/lang-sl': 4.26.1 + '@nlpjs/lang-sr': 4.26.1 + '@nlpjs/lang-sv': 4.26.1 + '@nlpjs/lang-ta': 4.26.1 + '@nlpjs/lang-th': 4.26.1 + '@nlpjs/lang-tl': 4.26.1 + '@nlpjs/lang-tr': 4.26.1 + '@nlpjs/lang-uk': 4.26.1 + '@nlpjs/lang-zh': 4.26.1 + '@nlpjs/language': 4.25.0 + + '@nlpjs/lang-ar@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-bn@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-ca@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-cs@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-da@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-de@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-el@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-en-min@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-en@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + '@nlpjs/lang-en-min': 4.26.1 + + '@nlpjs/lang-es@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-eu@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-fa@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-fi@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-fr@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-ga@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-gl@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-hi@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-hu@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-hy@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-id@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-it@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-ja@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + kuromoji: 0.1.2 + + '@nlpjs/lang-ko@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-lt@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-ms@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + '@nlpjs/lang-id': 4.26.1 + + '@nlpjs/lang-ne@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-nl@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-no@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-pl@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-pt@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-ro@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-ru@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-sl@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-sr@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-sv@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-ta@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-th@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-tl@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-tr@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-uk@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/lang-zh@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/language-min@4.25.0': {} + + '@nlpjs/language@4.25.0': {} + + '@nlpjs/ner@4.27.0': + dependencies: + '@nlpjs/core': 4.26.1 + '@nlpjs/language-min': 4.25.0 + '@nlpjs/similarity': 4.26.1 + + '@nlpjs/neural@4.25.0': {} + + '@nlpjs/nlg@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + + '@nlpjs/nlp@4.27.0': + dependencies: + '@nlpjs/core': 4.26.1 + '@nlpjs/ner': 4.27.0 + '@nlpjs/nlg': 4.26.1 + '@nlpjs/nlu': 4.27.0 + '@nlpjs/sentiment': 4.26.1 + '@nlpjs/slot': 4.26.1 + + '@nlpjs/nlu@4.27.0': + dependencies: + '@nlpjs/core': 4.26.1 + '@nlpjs/language-min': 4.25.0 + '@nlpjs/neural': 4.25.0 + '@nlpjs/similarity': 4.26.1 + + '@nlpjs/request@4.25.0': + dependencies: + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + transitivePeerDependencies: + - supports-color + + '@nlpjs/sentiment@4.26.1': + dependencies: + '@nlpjs/core': 4.26.1 + '@nlpjs/language-min': 4.25.0 + '@nlpjs/neural': 4.25.0 + + '@nlpjs/similarity@4.26.1': {} + + '@nlpjs/slot@4.26.1': {} + + '@nlpjs/xtables@4.25.0': + dependencies: + xlsx: 0.18.5 + '@node-llama-cpp/linux-arm64@3.20.0': optional: true @@ -946,10 +1596,20 @@ snapshots: '@tinyhttp/content-disposition@2.2.4': {} + '@tootallnate/once@2.0.1': {} + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 + adler-32@1.3.1: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ansi-escapes@6.2.1: {} ansi-regex@5.0.1: {} @@ -966,8 +1626,19 @@ snapshots: dependencies: retry: 0.13.1 + async@2.6.4: + dependencies: + lodash: 4.18.1 + + bignumber.js@7.2.1: {} + bytes@3.1.2: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chalk@5.6.2: {} chmodrp@1.0.2: {} @@ -1004,6 +1675,8 @@ snapshots: transitivePeerDependencies: - supports-color + codepage@1.15.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1012,6 +1685,8 @@ snapshots: commander@10.0.1: {} + crc-32@1.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1024,6 +1699,8 @@ snapshots: deep-extend@0.6.0: {} + doublearray@0.0.2: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -1061,6 +1738,20 @@ snapshots: escalade@3.2.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + eventemitter3@5.0.4: {} filename-reserved-regex@3.0.0: {} @@ -1069,6 +1760,8 @@ snapshots: dependencies: filename-reserved-regex: 3.0.0 + frac@1.1.2: {} + fs-extra@11.4.0: dependencies: graceful-fs: 4.2.11 @@ -1084,6 +1777,23 @@ snapshots: graceful-fs@4.2.11: {} + grapheme-splitter@1.0.4: {} + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.1 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ignore@7.0.6: {} ini@1.3.8: {} @@ -1132,12 +1842,20 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + kuromoji@0.1.2: + dependencies: + async: 2.6.4 + doublearray: 0.0.2 + zlibjs: 0.3.1 + lifecycle-utils@2.1.0: {} lifecycle-utils@4.3.1: {} lodash.debounce@4.0.8: {} + lodash@4.18.1: {} + log-symbols@7.0.1: dependencies: is-unicode-supported: 2.1.0 @@ -1214,6 +1932,26 @@ snapshots: transitivePeerDependencies: - supports-color + node-nlp@4.27.0: + dependencies: + '@nlpjs/builtin-duckling': 4.26.1 + '@nlpjs/builtin-microsoft': 4.26.1 + '@nlpjs/core-loader': 4.26.1 + '@nlpjs/emoji': 4.26.1 + '@nlpjs/evaluator': 4.26.1 + '@nlpjs/lang-all': 4.26.1 + '@nlpjs/language': 4.25.0 + '@nlpjs/neural': 4.25.0 + '@nlpjs/nlg': 4.26.1 + '@nlpjs/nlp': 4.27.0 + '@nlpjs/nlu': 4.27.0 + '@nlpjs/request': 4.25.0 + '@nlpjs/sentiment': 4.26.1 + '@nlpjs/similarity': 4.26.1 + '@nlpjs/xtables': 4.25.0 + transitivePeerDependencies: + - supports-color + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -1303,6 +2041,13 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + source-map@0.6.1: + optional: true + + ssf@0.11.2: + dependencies: + frac: 1.1.2 + stdin-discarder@0.3.2: {} stdout-update@4.0.1: @@ -1373,12 +2118,26 @@ snapshots: dependencies: isexe: 4.0.0 + wmf@1.0.2: {} + + word@0.3.0: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + y18n@5.0.8: {} yallist@5.0.0: {} @@ -1396,3 +2155,5 @@ snapshots: yargs-parser: 21.1.1 yoctocolors@2.2.0: {} + + zlibjs@0.3.1: {} diff --git a/experiments/llm-tech-step-poc/src/hybrid-tech-step-poc.ts b/experiments/llm-tech-step-poc/src/hybrid-tech-step-poc.ts new file mode 100644 index 0000000..f63891d --- /dev/null +++ b/experiments/llm-tech-step-poc/src/hybrid-tech-step-poc.ts @@ -0,0 +1,243 @@ +/** + * 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(); +} diff --git a/experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts b/experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts index ab9f37c..4225833 100644 --- a/experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts +++ b/experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts @@ -1,20 +1,16 @@ /** * 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`). + * (`node-llama-cpp`), à comparer au classifieur `node-nlp` frais de + * `nlp-tech-step-poc.ts` (et, au-delà, au pipeline `node-nlp` de production + * dans `apps/api/src/lib/recipe-matching/tech-step-matcher.ts`). * - * 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 + * Ce PoC teste une approche différente d'un classifieur par clause : 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 vocabulaire fixé à l'avance, 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 NLP léger). Tourne 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 @@ -33,7 +29,7 @@ * * ```bash * cd experiments/llm-tech-step-poc - * pnpm install + * pnpm install --ignore-workspace * pnpm bench * ``` */ @@ -47,92 +43,29 @@ import { 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[]; -} +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 — 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/ + * Schéma JSON d'une action, 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. + * `KitchenAction` (`shared/kitchen-action.ts`) : 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", @@ -150,8 +83,12 @@ const KITCHEN_ACTION_JSON_SCHEMA = { /** * 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}. + * racine de la grammaire JSON). `originalText` n'y figure pas : 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. */ const KITCHEN_ACTIONS_JSON_SCHEMA = { type: "object", @@ -163,7 +100,7 @@ const KITCHEN_ACTIONS_JSON_SCHEMA = { /** Forme brute que renvoie `grammar.parse()` pour {@link KITCHEN_ACTIONS_JSON_SCHEMA} — reconverti en {@link RecipeStepAnalysis} par {@link LocalLlmStepAnalyzer.analyzeStep}. */ interface KitchenActionsGrammarResult { - actions: KitchenAction[]; + actions: RecipeStepAnalysis["actions"]; } /** @@ -222,10 +159,11 @@ interface RecommendedModel { * 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. + * plus lent"), des runs antérieurs de ce PoC (Windows, backend Vulkan) + * l'ont aussi montré 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 à @@ -240,7 +178,7 @@ interface RecommendedModel { * 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 = { +export const RECOMMENDED_MODELS: Record = { "qwen2.5-1.5b": { hfUri: "hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M", rationale: @@ -346,13 +284,12 @@ export class LocalLlmStepAnalyzer { * (`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 + * KV, initialisation paresseuse du tokenizer) — mesuré 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). + * établi. */ public async warmUp(): Promise { await this.analyzeStep("Faites chauffer une poêle."); @@ -406,160 +343,8 @@ export class LocalLlmStepAnalyzer { // 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 { - 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 { +/** Imprime le détail (action/verbe/ingrédients/durée/température/ustensiles) de chaque échantillon — matière première pour comparer à l'œil avec les autres moteurs. */ +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`, @@ -567,7 +352,7 @@ function printDetailedResults(samples: readonly BenchmarkSample[]): void { console.info(` texte : ${sample.sentence.text}`); console.info(` attendu : ${sample.sentence.note}`); console.table( - sample.analysis.actions.map((action) => ({ + sample.result.actions.map((action) => ({ action: action.action, verbe: action.verb, ingrédients: action.ingredients.join(", "), @@ -579,53 +364,12 @@ function printDetailedResults(samples: readonly BenchmarkSample[]): void { } } -/** 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. + * benchmark sur les 7 phrases partagées (`shared/test-sentences.ts`), + * imprime les résultats détaillés puis le récapitulatif, et libère le + * modèle avant de quitter. */ async function main(): Promise { const modelKey: RecommendedModelKey = @@ -670,11 +414,17 @@ async function main(): Promise { 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`); + const samples = await runBenchmark({ + logPrefix: "[poc]", + countOf: (result) => result.actions.length, + countLabel: "action(s) détectée(s)", + analyze: (sentence: BenchmarkSentence) => analyzer.analyzeStep(sentence.text), + }); + console.info( + `[poc] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`, + ); printDetailedResults(samples); - printSummaryTable(samples); + printSummaryTable(samples, (result) => result.actions.length, "actions détectées"); } finally { try { await analyzer.dispose(); @@ -684,4 +434,6 @@ async function main(): Promise { } } -await main(); +if (isMainModule(import.meta.url)) { + await main(); +} diff --git a/experiments/llm-tech-step-poc/src/nlp-tech-step-poc.ts b/experiments/llm-tech-step-poc/src/nlp-tech-step-poc.ts new file mode 100644 index 0000000..ce43bd4 --- /dev/null +++ b/experiments/llm-tech-step-poc/src/nlp-tech-step-poc.ts @@ -0,0 +1,671 @@ +/** + * PoC autonome — classifieur `node-nlp` FRAIS, entraîné directement sur la + * taxonomie à 7 catégories de {@link KitchenActionType} (partagée avec + * `llm-tech-step-poc.ts`), plutôt qu'une réutilisation de + * `TechStepClassifierService` (`apps/api/src/lib/recipe-matching/ + * tech-step-matcher.ts`, taxonomie fine à ~26 techniques, DB-backed). Deux + * raisons de repartir de zéro plutôt que de réutiliser l'existant : + * + * 1. **Comparaison vraiment terme à terme** : la V1 de ce PoC comparait un + * LLM sortant du `KitchenActionType` (7 catégories) à `node-nlp` sortant + * des `TechStep` (~26 techniques) — deux taxonomies différentes rendaient + * le nombre de détections difficile à comparer directement. Entraîné ici + * sur la même taxonomie que le LLM, ses sorties sont directement + * comparables catégorie par catégorie. + * 2. **Un score de confiance EXPLOITABLE par le pipeline hybride** + * (`hybrid-tech-step-poc.ts`) : `TechStepClassifierService` masque son + * score en retombant silencieusement sur l'ancre NER dès qu'il est sous + * son seuil interne (voir sa propre doc, point 3) — utile pour son usage + * en prod, mais ça cache exactement le signal dont un pipeline hybride a + * besoin pour décider quand basculer vers le LLM. Ce classifieur-ci + * renvoie toujours le score BRUT du classifieur, jamais masqué. + * + * Même pipeline NER -> découpage en clauses -> classification par clause + * que `tech-step-matcher.ts` (même principe, implémentation propre à ce + * PoC — {@link splitIntoClauses} ici est une version simplifiée : split au + * plus proche espace du milieu de l'écart entre deux candidats, sans la + * priorité aux frontières de phrase de la version production). Aucune + * dépendance à `apps/api`/Postgres — un `TechStep.uid -> id` n'existe pas + * ici, les catégories `KitchenActionType` sont directement les noms + * d'intention node-nlp, pas de résolution DB nécessaire. + * + * Usage : + * + * ```bash + * cd experiments/llm-tech-step-poc + * pnpm install --ignore-workspace + * pnpm bench:nlp + * ``` + */ + +import { performance } from "node:perf_hooks"; +import { NlpManager } from "node-nlp"; +import { + type BenchmarkSample, + printSummaryTable, + runBenchmark, +} from "./shared/benchmark-harness.js"; +import { KitchenActionType } from "./shared/kitchen-action.js"; +import { isMainModule } from "./shared/module-entry.js"; +import type { BenchmarkSentence } from "./shared/test-sentences.js"; + +// --------------------------------------------------------------------------- +// Corpus d'entraînement — 7 catégories, FR + EN +// --------------------------------------------------------------------------- + +/** Vocabulaire d'une catégorie pour une langue — mêmes noms de champs que `tech-step-training-data.ts` (`synonyms` pour la NER, `utterances` pour la classification d'intention), format familier plutôt que réinventé. */ +interface CategoryLocaleData { + /** Mots/courtes expressions repérés par la NER (entités enum) — servent d'ancres pour {@link splitIntoClauses}. */ + synonyms: string[]; + /** Phrases complètes utilisées pour entraîner la classification d'intention — la partie qui porte vraiment le sens, au-delà du mot-clé brut. */ + utterances: string[]; +} + +/** Le vocabulaire complet d'une catégorie de {@link KitchenActionType}, dans les deux langues. */ +interface CategoryTrainingData { + action: KitchenActionType; + fr: CategoryLocaleData; + en: CategoryLocaleData; +} + +/** + * Corpus volontairement compact (PoC, pas un remplacement du corpus + * production `tech-step-training-data.ts`) mais couvrant les 7 catégories + * dans les deux langues. Les synonymes préfèrent un seul mot distinctif + * ("revenir" plutôt que "faire revenir"/"faites revenir") quand c'est + * possible plutôt qu'une phrase figée : une leçon tirée du run précédent de + * ce PoC, où `apps/api`'s "faites revenir" (deux mots) ratait "faites-**les**- + * revenir" — le pronom clitique français insère un mot entre les deux et + * casse un matching de phrase contiguë. Un synonyme mono-mot comme + * "revenir" matche quel que soit ce qui le précède. + */ +const TRAINING_DATA: readonly CategoryTrainingData[] = [ + { + action: KitchenActionType.CUT, + fr: { + synonyms: [ + "émincer", + "émincez", + "éminçez", + "couper", + "coupez", + "hacher", + "hachez", + "trancher", + "tranchez", + "ciseler", + "ciselez", + "éplucher", + "épluchez", + "découper", + "découpez", + ], + utterances: [ + "émincer finement les oignons", + "couper les légumes en petits dés", + "hacher l'ail très finement avant de l'ajouter", + "éplucher puis trancher les carottes", + ], + }, + en: { + synonyms: [ + "dice", + "diced", + "dicing", + "chop", + "chopped", + "chopping", + "slice", + "sliced", + "slicing", + "mince", + "minced", + "mincing", + "peel", + "peeled", + "peeling", + ], + utterances: [ + "dice the tomatoes into small cubes", + "chop the onions finely before cooking", + "slice the carrots into thin rounds", + "peel and mince the garlic cloves", + ], + }, + }, + { + action: KitchenActionType.COOK, + fr: { + synonyms: [ + "cuire", + "cuisez", + "revenir", + "mijoter", + "mijotez", + "bouillir", + "frire", + "griller", + "grillez", + "rôtir", + "rôtissez", + "fondre", + "chauffer", + "chauffez", + ], + utterances: [ + "faire revenir les oignons à la poêle avec un peu d'huile", + "laisser mijoter à feu doux pendant vingt minutes", + "faire fondre le beurre jusqu'à ce qu'il disparaisse dans la poêle", + "cuire les pâtes dans une grande casserole d'eau bouillante", + ], + }, + en: { + synonyms: [ + "cook", + "cooked", + "cooking", + "fry", + "fried", + "frying", + "simmer", + "simmered", + "simmering", + "boil", + "boiled", + "boiling", + "grill", + "grilled", + "grilling", + "roast", + "roasted", + "melt", + "melted", + "melting", + "sauté", + "sautéed", + "sear", + "seared", + ], + utterances: [ + "simmer everything in a saucepan over low heat", + "grill the chicken over medium-high heat", + "melt the butter in a small saucepan", + "cook the pasta in a large pot of boiling water", + ], + }, + }, + { + action: KitchenActionType.MIX, + fr: { + synonyms: [ + "mélanger", + "mélangez", + "fouetter", + "fouettez", + "incorporer", + "incorporez", + "remuer", + "remuez", + "battre", + "battez", + ], + utterances: [ + "mélanger la farine et le sucre dans un saladier", + "fouetter les œufs jusqu'à ce qu'ils blanchissent", + "incorporer délicatement la crème fouettée", + "remuer sans arrêt jusqu'à épaississement", + ], + }, + en: { + synonyms: [ + "mix", + "mixed", + "mixing", + "whisk", + "whisked", + "whisking", + "fold", + "folded", + "folding", + "stir", + "stirred", + "stirring", + "combine", + "combined", + "beat", + "beaten", + "beating", + ], + utterances: [ + "whisk the eggs and sugar together until pale and fluffy", + "fold in the sifted flour gently", + "stir constantly until the mixture thickens", + "combine all the dry ingredients in a bowl", + ], + }, + }, + { + action: KitchenActionType.REST, + fr: { + synonyms: ["reposer", "reposez", "mariner", "marinez", "refroidir", "refroidissez"], + utterances: [ + "laisser reposer la pâte pendant trente minutes", + "laisser mariner la viande toute une nuit au réfrigérateur", + "laisser refroidir avant de découper", + ], + }, + en: { + synonyms: [ + "rest", + "rested", + "resting", + "marinate", + "marinated", + "marinating", + "chill", + "chilled", + "chilling", + "cool", + "cooled", + "cooling", + ], + utterances: [ + "let the dough rest for thirty minutes", + "marinate the chicken in the fridge overnight", + "let it cool completely before slicing", + "let it rest for a few minutes before serving", + ], + }, + }, + { + action: KitchenActionType.SEASON, + fr: { + synonyms: [ + "assaisonner", + "assaisonnez", + "saler", + "salez", + "poivrer", + "poivrez", + "épicer", + "épicez", + ], + utterances: [ + "assaisonner avec du sel et du poivre", + "saler et poivrer selon le goût", + "épicer généreusement avant de servir", + ], + }, + en: { + synonyms: [ + "season", + "seasoned", + "seasoning", + "salt", + "salted", + "pepper", + "peppered", + "spice", + "spiced", + ], + utterances: [ + "season with salt and pepper", + "add spices to taste", + "salt and pepper generously before cooking", + ], + }, + }, + { + action: KitchenActionType.PREHEAT, + fr: { + synonyms: ["préchauffer", "préchauffez", "préchauffage"], + utterances: [ + "préchauffer le four à cent quatre-vingts degrés", + "préchauffer la poêle avant d'ajouter l'huile", + ], + }, + en: { + synonyms: ["preheat", "preheated", "preheating"], + utterances: [ + "preheat the oven to 350 degrees", + "preheat the pan over medium heat before adding oil", + ], + }, + }, + { + action: KitchenActionType.OTHER, + fr: { + synonyms: [ + "réserver", + "réservez", + "égoutter", + "égouttez", + "dresser", + "dressez", + "servir", + "servez", + "transférer", + "transférez", + ], + utterances: [ + "réserver de côté pendant la préparation du reste", + "égoutter les pâtes en gardant un peu d'eau de cuisson", + "dresser harmonieusement dans l'assiette", + ], + }, + en: { + synonyms: [ + "set aside", + "drain", + "drained", + "draining", + "plate", + "plated", + "plating", + "serve", + "served", + "transfer", + "transferred", + "pat dry", + ], + utterances: [ + "set it aside for later use", + "drain the pasta reserving some cooking water", + "pat the chicken thighs dry with paper towel", + "transfer everything to a serving dish", + ], + }, + }, +]; + +// --------------------------------------------------------------------------- +// NER -> découpage en clauses (implémentation propre à ce PoC, simplifiée) +// --------------------------------------------------------------------------- + +/** Une mention candidate d'une catégorie, trouvée par NER — le matériau brut dont {@link splitIntoClauses} découpe des clauses. */ +interface CategoryCandidate { + action: KitchenActionType; + start: number; + end: number; +} + +/** Une clause découpée autour d'un candidat (ou l'unique clause "tout le texte" si aucun candidat n'a été trouvé). */ +interface StepClause { + start: number; + end: number; + anchor: CategoryCandidate | null; +} + +/** + * Point de coupe entre deux candidats consécutifs — l'espace le plus proche + * du milieu de l'écart `[gapStart, gapEnd)`, ou le milieu brut si l'écart ne + * contient aucun espace. Version simplifiée de l'équivalent + * `tech-step-matcher.ts` : pas de priorité aux frontières de phrase, un + * compromis PoC assumé (voir le doc-comment en tête de fichier) — un span + * de clause légèrement moins net qu'en production, mais qui ne coupe jamais + * un mot en deux. + */ +function findGapSplitPoint(text: string, gapStart: number, gapEnd: number): number { + if (gapStart >= gapEnd) return gapStart; + const midpoint = Math.floor((gapStart + gapEnd) / 2); + let best: number | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (let i = gapStart; i < gapEnd; i++) { + if (!/\s/.test(text[i] ?? "")) continue; + const distance = Math.abs(i - midpoint); + if (distance < bestDistance) { + best = i; + bestDistance = distance; + } + } + return best ?? midpoint; +} + +/** + * Découpe `text` en clauses autour de `candidates`, une clause par + * candidat — même principe que `tech-step-matcher.ts` : zéro candidat -> tout + * le texte est une clause sans ancre ; un candidat -> tout le texte est une + * clause avec cette ancre ; deux ou plus -> une clause par candidat, coupée + * à {@link findGapSplitPoint} entre chaque paire consécutive. + */ +function splitIntoClauses(text: string, candidates: readonly CategoryCandidate[]): StepClause[] { + if (candidates.length === 0) { + return [{ start: 0, end: text.length, anchor: null }]; + } + const sorted = [...candidates].sort((a, b) => a.start - b.start); + const [first, ...rest] = sorted; + if (first === undefined) { + return [{ start: 0, end: text.length, anchor: null }]; + } + const clauses: StepClause[] = []; + let clauseStart = 0; + let anchor = first; + for (const next of rest) { + const splitPoint = findGapSplitPoint(text, anchor.end, next.start); + clauses.push({ start: clauseStart, end: splitPoint, anchor }); + clauseStart = splitPoint; + anchor = next; + } + clauses.push({ start: clauseStart, end: text.length, anchor }); + return clauses; +} + +// --------------------------------------------------------------------------- +// NlpTechStepClassifier +// --------------------------------------------------------------------------- + +/** Une action détectée dans une clause, avec le score BRUT du classifieur — jamais masqué par un repli silencieux, voir le doc-comment en tête de fichier (point 2). */ +export interface NlpActionMatch { + action: KitchenActionType; + /** Score du classifieur `node-nlp` pour cette clause, `[0, 1]` — `0` quand le classifieur n'a rien reconnu du tout (`intent === "None"`) et qu'aucune ancre NER n'existe pour retomber dessus. */ + confidence: number; + /** Mot-clé ayant ancré cette clause (le texte de l'ancre NER), ou le texte de la clause entière si aucune ancre n'existe. */ + matchedText: string; + /** Texte complet de la clause classifiée. */ + clauseText: string; + start: number; + end: number; + contextStart: number; + contextEnd: number; +} + +/** Résultat complet de l'analyse d'une étape par le classifieur NLP. */ +export interface NlpStepAnalysis { + originalText: string; + matches: NlpActionMatch[]; + /** + * Confiance au niveau de l'étape entière — le MINIMUM des confidences de + * ses clauses (une étape n'est fiable que si TOUTES ses clauses le sont), + * ou `0` si aucune clause n'a produit de match. C'est ce champ que + * `hybrid-tech-step-poc.ts` compare à son seuil pour décider d'escalader + * vers le LLM. + */ + overallConfidence: number; +} + +/** `value` est-elle une des 7 valeurs de {@link KitchenActionType} ? — `node-nlp` renvoie l'intent sous forme de `string` brute, à valider avant de la traiter comme une vraie catégorie. */ +function isKitchenActionType(value: string): value is KitchenActionType { + return (Object.values(KitchenActionType) as string[]).includes(value); +} + +/** + * Classifieur `node-nlp` frais pour ce PoC — vraie `class` (pas un objet + * littéral), même convention que `TechStepClassifierService`/ + * `LocalLlmStepAnalyzer` : possède un état réel (modèle entraîné), + * coûteux à reconstruire, jamais recréé par appel. + */ +export class NlpTechStepClassifier { + private readonly _manager: NlpManager; + /** Mémoïse l'entraînement — `undefined` jusqu'au premier appel, chaque appelant (concurrent ou non) attend ensuite la même promesse plutôt que de ré-entraîner. */ + private _trained: Promise | undefined; + + public constructor() { + this._manager = new NlpManager({ + languages: ["fr", "en"], + forceNER: true, + nlu: { log: false }, + // Seuil `1` (exact, après normalisation casse/accents/stemming de + // node-nlp) plutôt que le défaut `0.8` (fuzzy/Levenshtein) — même + // raisonnement que `tech-step-matcher.ts` : la précision de la NER + // compte plus que son rappel ici, elle ne fait que proposer des + // candidats de découpage, c'est la classification d'intention qui + // doit vraiment avoir raison. + ner: { threshold: 1 }, + // Jamais de persistance sur disque — le corpus en code est la seule + // source de vérité, un modèle stale sur disque masquerait + // silencieusement une mise à jour du corpus. + autoSave: false, + autoLoad: false, + }); + } + + /** Force l'entraînement plus l'initialisation paresseuse de node-nlp (stemmers/tokenizers par langue, chargés au premier `process()` réel) à se faire maintenant, avant tout appel mesuré par le benchmark. */ + public async warmUp(): Promise { + await this.analyzeStep("Faites chauffer une poêle.", "fr"); + } + + /** Analyse une étape et renvoie ses matches + sa confiance globale. Voir le doc-comment en tête de fichier pour le pipeline NER -> clauses -> classification. */ + public async analyzeStep(text: string, locale: "fr" | "en"): Promise { + await this._ensureTrained(); + if (text.trim().length === 0) { + return { originalText: text, matches: [], overallConfidence: 0 }; + } + + const nerResult = await this._manager.process(locale, text); + const candidates: CategoryCandidate[] = nerResult.entities + .filter((entity) => entity.type === "enum" && isKitchenActionType(entity.entity)) + .map((entity) => ({ + // Le filtre ci-dessus garantit `isKitchenActionType(entity.entity)` + // — cast plutôt que refiltrer, `Array.prototype.filter` n'affine + // pas le type de `entity.entity` (`string`) tout seul. + action: entity.entity as KitchenActionType, + start: entity.start, + // node-nlp's `end` est inclusif — `+1` convertit vers `[start, end)`. + end: entity.end + 1, + })); + + const clauses = splitIntoClauses(text, candidates); + const matches: NlpActionMatch[] = []; + for (const clause of clauses) { + const clauseText = text.slice(clause.start, clause.end).trim(); + let action: KitchenActionType; + let confidence: number; + if (clauseText.length === 0) { + action = clause.anchor?.action ?? KitchenActionType.OTHER; + confidence = 0; + } else { + const result = await this._manager.process(locale, clauseText); + if (result.intent !== "None" && isKitchenActionType(result.intent)) { + action = result.intent; + confidence = result.score; + } else { + // Contrairement à `TechStepClassifierService`, pas de repli + // silencieux sur un score "rescapé" : le score `0` reflète + // honnêtement qu'aucune classification fiable n'a eu lieu, + // l'ancre NER sert seulement à choisir QUELLE catégorie + // afficher, pas à masquer que la confiance réelle est nulle. + action = clause.anchor?.action ?? KitchenActionType.OTHER; + confidence = 0; + } + } + const matchedText = + clause.anchor !== null ? text.slice(clause.anchor.start, clause.anchor.end) : clauseText; + matches.push({ + action, + confidence, + matchedText, + clauseText, + start: clause.anchor?.start ?? clause.start, + end: clause.anchor?.end ?? clause.end, + contextStart: clause.start, + contextEnd: clause.end, + }); + } + + const overallConfidence = + matches.length === 0 ? 0 : Math.min(...matches.map((match) => match.confidence)); + return { originalText: text, matches, overallConfidence }; + } + + private async _ensureTrained(): Promise { + if (this._trained === undefined) { + this._trained = this._train(); + } + try { + await this._trained; + } catch (err) { + // Un entraînement raté doit pouvoir être retenté au prochain appel, + // pas laisser tout appel futur échouer contre la même promesse figée. + this._trained = undefined; + throw err; + } + } + + private async _train(): Promise { + for (const entry of TRAINING_DATA) { + for (const [locale, data] of [ + ["fr", entry.fr], + ["en", entry.en], + ] as const) { + if (data.synonyms.length > 0) { + this._manager.addNamedEntityText(entry.action, entry.action, [locale], data.synonyms); + } + for (const utterance of data.utterances) { + this._manager.addDocument(locale, utterance, entry.action); + } + } + } + await this._manager.train(); + } +} + +// --------------------------------------------------------------------------- +// Benchmark +// --------------------------------------------------------------------------- + +/** Imprime le détail (action/confiance/mot-clé/clause) de chaque échantillon — matière première pour comparer à l'œil avec le LLM. */ +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, confiance globale ${sample.result.overallConfidence.toFixed(2)}`, + ); + console.info(` texte : ${sample.sentence.text}`); + console.info(` attendu : ${sample.sentence.note}`); + console.table( + sample.result.matches.map((match) => ({ + action: match.action, + confiance: match.confidence.toFixed(2), + mot_clé: match.matchedText, + clause: match.clauseText, + })), + ); + } +} + +async function main(): Promise { + const classifier = new NlpTechStepClassifier(); + + console.info("[nlp] warm-up (entraînement + init paresseuse de node-nlp)..."); + const warmUpStartedAt = performance.now(); + await classifier.warmUp(); + console.info(`[nlp] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`); + + const benchmarkStartedAt = performance.now(); + const samples = await runBenchmark({ + logPrefix: "[nlp]", + countOf: (result) => result.matches.length, + countLabel: "action(s) détectée(s)", + analyze: (sentence: BenchmarkSentence) => + classifier.analyzeStep(sentence.text, sentence.locale), + }); + console.info( + `[nlp] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`, + ); + + printDetailedResults(samples); + printSummaryTable(samples, (result) => result.matches.length, "actions détectées"); +} + +if (isMainModule(import.meta.url)) { + await main(); +} diff --git a/experiments/llm-tech-step-poc/src/shared/benchmark-harness.ts b/experiments/llm-tech-step-poc/src/shared/benchmark-harness.ts new file mode 100644 index 0000000..5cb7003 --- /dev/null +++ b/experiments/llm-tech-step-poc/src/shared/benchmark-harness.ts @@ -0,0 +1,138 @@ +/** + * Harness de benchmark partagé par les trois moteurs de ce PoC — logs + * itératifs par répétition, mesure latence/RSS, tableau récapitulatif. + * Générique sur `TResult` (la forme de sortie de chaque moteur diffère : + * `RecipeStepAnalysis` pour le LLM, `NlpStepAnalysis` pour le classifieur + * NLP, `HybridStepAnalysis` pour le pipeline hybride) pour que les trois + * scripts réutilisent exactement le même code de mesure/affichage plutôt + * que de le tripler. + */ +import { performance } from "node:perf_hooks"; +import type { BenchmarkSentence } from "./test-sentences.js"; +import { TEST_SENTENCES } from "./test-sentences.js"; + +/** Nombre de répétitions mesurées par phrase — même valeur pour les trois moteurs, pour des runs comparables. */ +export const REPETITIONS_PER_SENTENCE = 3; + +/** Une mesure individuelle (une répétition, une phrase) — la matière première des tableaux récapitulatifs. */ +export 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 : `process.memoryUsage()` ne voit que le tas V8, mais un binding natif (llama.cpp) alloue dans le même process, donc le RSS (mémoire résidente totale du process) le capture, au bruit du GC près. */ + rssDeltaBytes: number; + result: TResult; +} + +/** Formate un delta de RSS en Mo avec un signe explicite (`+`/`-`), pour l'affichage. */ +export function formatRssDelta(rssDeltaBytes: number): string { + const megabytes = rssDeltaBytes / (1024 * 1024); + return `${megabytes >= 0 ? "+" : ""}${megabytes.toFixed(1)} Mo`; +} + +/** Paramètres de {@link runBenchmark} — un par moteur (LLM/NLP/hybride), voir chaque appelant. */ +export interface BenchmarkRunOptions { + /** Préfixe des logs itératifs, ex. `"[poc]"`, `"[nlp]"`, `"[hybrid]"`. */ + logPrefix: string; + /** Nombre d'éléments détectés dans un résultat — alimente le log par répétition et la colonne de comptage du récapitulatif. */ + countOf: (result: TResult) => number; + /** Libellé de ce qui est compté, ex. `"action(s) détectée(s)"` ou `"technique(s) détectée(s)"`. */ + countLabel: string; + /** Lance une analyse pour une phrase donnée. Une erreur est journalisée et n'interrompt pas les répétitions suivantes — un run qui plante entièrement à la première réponse mal formée serait bien moins utile qu'un rapport partiel. */ + analyze: (sentence: BenchmarkSentence) => Promise; +} + +/** + * Exécute {@link REPETITIONS_PER_SENTENCE} analyses par phrase de + * {@link TEST_SENTENCES} et renvoie toutes les mesures individuelles, + * journalisant 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, 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 ces scripts de benchmark + * (contrairement au code applicatif, où `console` est réservé à + * `LoggerService` — n'existe pas ici, PoC autonome sans app autour). + */ +export async function runBenchmark( + options: BenchmarkRunOptions, +): Promise[]> { + const { logPrefix, countOf, countLabel, analyze } = options; + 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( + `${logPrefix} (${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 result = await analyze(sentence); + const latencyMs = performance.now() - startedAt; + const rssDeltaBytes = process.memoryUsage().rss - rssBefore; + samples.push({ sentence, latencyMs, rssDeltaBytes, result }); + console.info( + `${logPrefix} -> ${latencyMs.toFixed(0)} ms, ${countOf(result)} ${countLabel}, RSS ${formatRssDelta(rssDeltaBytes)}`, + ); + } catch (err) { + console.error( + `${logPrefix} -> échec sur "${sentence.id}" (répétition ${repetition})`, + err, + ); + } + } + } + return samples; +} + +/** Une colonne supplémentaire du tableau récapitulatif, au-delà des colonnes communes — ex. la colonne "moteur" du pipeline hybride. */ +export interface SummaryExtraColumn { + label: string; + /** Calculée à partir de la DERNIÈRE répétition de la phrase — même logique que la colonne de comptage commune, voir {@link printSummaryTable}. */ + valueOf: (lastSample: BenchmarkSample) => string | number; +} + +/** + * Agrège des {@link BenchmarkSample}s par phrase et imprime le tableau + * récapitulatif du benchmark (latence moyenne/min/max, delta RSS moyen, + * nombre d'éléments détectés, plus toute colonne additionnelle spécifique + * au moteur). Le compte d'éléments détectés est pris sur la DERNIÈRE + * répétition plutôt que moyenné : un nombre d'actions n'a pas de moyenne + * sensée (une info qualitative, pas une mesure continue) — la dernière + * répétition sert d'échantillon représentatif, comme dans les runs + * précédents de ce PoC. + */ +export function printSummaryTable( + samples: readonly BenchmarkSample[], + countOf: (result: TResult) => number, + countColumnLabel: string, + extraColumns: readonly SummaryExtraColumn[] = [], +): void { + const rows = 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); + const row: Record = { + 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) : "—", + [countColumnLabel]: lastSample !== undefined ? countOf(lastSample.result) : 0, + }; + for (const column of extraColumns) { + row[column.label] = lastSample !== undefined ? column.valueOf(lastSample) : "—"; + } + return row; + }); + console.info("\n=== Récapitulatif ==="); + console.table(rows); +} diff --git a/experiments/llm-tech-step-poc/src/shared/kitchen-action.ts b/experiments/llm-tech-step-poc/src/shared/kitchen-action.ts new file mode 100644 index 0000000..f41d394 --- /dev/null +++ b/experiments/llm-tech-step-poc/src/shared/kitchen-action.ts @@ -0,0 +1,65 @@ +/** + * Types métier partagés par les trois moteurs de ce PoC + * (`llm-tech-step-poc.ts`, `nlp-tech-step-poc.ts`, `hybrid-tech-step-poc.ts`) + * — une seule taxonomie/forme de sortie pour que leurs résultats restent + * comparables terme à terme, plutôt que chaque moteur inventant la sienne. + */ + +/** + * Taxonomie fermée des actions culinaires que chaque moteur classe. + * Volontairement large (`OTHER` en filet de sécurité) plutôt qu'exhaustive + * comme les ~25 `TechStep` de `apps/api` : ce PoC teste la *structuration* + * d'une étape en séquence d'actions typées, pas 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. Le LLM + * (`llm-tech-step-poc.ts`) remplit tous les champs en une passe ; le + * classifieur NLP (`nlp-tech-step-poc.ts`) ne peut structurellement fournir + * qu'`action`/`verb` (voir sa propre doc) — `ingredients`/`utensils` restent + * `[]` et `durationMinutes`/`temperature` restent `null` dans ce cas, jamais + * inventés. + */ +export interface KitchenAction { + /** Catégorie de l'action, parmi {@link KitchenActionType}. */ + action: KitchenActionType; + /** Verbe/mot-clé littéral repéré 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é ou non extrait par ce moteur. */ + ingredients: string[]; + /** Durée en minutes si l'étape en mentionne une (heures/secondes converties) ; `null` sinon ou non extrait par ce moteur. */ + durationMinutes: number | null; + /** Mention littérale de température/intensité de feu (ex. "180°C", "feu doux", "medium heat") ; `null` sinon ou non extrait par ce moteur. */ + temperature: string | null; + /** Ustensiles/équipements nommés pour cette action ; tableau vide si aucun n'est nommé ou non extrait par ce moteur. */ + 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. + */ +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[]; +} diff --git a/experiments/llm-tech-step-poc/src/shared/module-entry.ts b/experiments/llm-tech-step-poc/src/shared/module-entry.ts new file mode 100644 index 0000000..2dbb82d --- /dev/null +++ b/experiments/llm-tech-step-poc/src/shared/module-entry.ts @@ -0,0 +1,16 @@ +import { pathToFileURL } from "node:url"; + +/** + * `true` quand le module appelant est le point d'entrée du process (lancé + * directement via `tsx fichier.ts`), `false` quand il est seulement importé + * pour l'une de ses exports — `hybrid-tech-step-poc.ts` importe + * `NlpTechStepClassifier` depuis `nlp-tech-step-poc.ts` et + * `LocalLlmStepAnalyzer` depuis `llm-tech-step-poc.ts`. Chacun de ces trois + * scripts lance son propre benchmark via `await main()` en toute fin de + * fichier ; sans cette garde, importer un module pour sa seule classe + * exportée déclencherait aussi SON benchmark complet (téléchargement de + * modèle compris) comme effet de bord de l'import — jamais voulu. + */ +export function isMainModule(moduleUrl: string): boolean { + return process.argv[1] !== undefined && moduleUrl === pathToFileURL(process.argv[1]).href; +} diff --git a/experiments/llm-tech-step-poc/src/shared/test-sentences.ts b/experiments/llm-tech-step-poc/src/shared/test-sentences.ts new file mode 100644 index 0000000..dfa2cfa --- /dev/null +++ b/experiments/llm-tech-step-poc/src/shared/test-sentences.ts @@ -0,0 +1,99 @@ +/** + * Les 7 phrases de test partagées par les trois moteurs de ce PoC — un seul + * jeu de phrases, importé par `llm-tech-step-poc.ts`, `nlp-tech-step-poc.ts` + * et `hybrid-tech-step-poc.ts`, pour que leurs runs soient directement + * comparables phrase par phrase sans risque de désynchronisation (un défaut + * de la toute première version de ce PoC, où le pendant `node-nlp` vivait + * dans `apps/api` et recopiait ces phrases à la main). + */ + +/** Une phrase de test, avec sa langue et ce qui la rend "complexe" (documentation, non exploité par le code). */ +export interface BenchmarkSentence { + id: string; + locale: "fr" | "en"; + text: string; + /** Ce qui rend cette phrase intéressante à tester — affiché dans les résultats de chaque moteur pour donner du contexte à la comparaison. */ + 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 de chaque moteur, pas juste confirmer qu'il + * gère 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, une phrase-piège sans verbe de technique littéral : aucune action + * n'est nommée explicitement, seul le sens implique une cuisson + * (`COOK`, fonte du beurre) — le test le plus direct de "précision + * sémantique, pas seulement mot-clé". + * 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 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. + */ +export 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 : 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.", + }, +]; diff --git a/experiments/llm-tech-step-poc/src/types/node-nlp.d.ts b/experiments/llm-tech-step-poc/src/types/node-nlp.d.ts new file mode 100644 index 0000000..dae28fb --- /dev/null +++ b/experiments/llm-tech-step-poc/src/types/node-nlp.d.ts @@ -0,0 +1,52 @@ +/** + * Typage ambiant minimal pour `node-nlp` (aucun type officiel/DefinitelyTyped + * n'existe) — déclare uniquement la surface de `NlpManager` que + * `nlp-tech-step-poc.ts` appelle réellement, vérifié contre le vrai package + * (v4.27.0). Copie volontaire de l'équivalent déjà présent côté + * `apps/api/src/types/node-nlp.d.ts` : ce PoC est délibérément autonome, + * hors du workspace pnpm (voir le commentaire en tête de `README.md`), donc + * ne peut pas importer ce fichier depuis `apps/api`. + */ +declare module "node-nlp" { + /** Constructeur options utilisées ici — `NlpManager` en accepte plus, seules celles utilisées sont typées. */ + export interface NlpManagerOptions { + languages?: string[]; + forceNER?: boolean; + nlu?: { log?: boolean }; + ner?: { threshold?: number }; + /** Défaut `true` — persiste le modèle entraîné sur disque (`model.nlp` dans `process.cwd()` par défaut). Toujours `false` ici, voir le constructeur de `NlpTechStepClassifier`. */ + autoSave?: boolean; + /** Défaut `true` — charge depuis le fichier au lieu de ré-entraîner s'il existe déjà. Toujours `false` ici, même raison. */ + autoLoad?: boolean; + } + + /** Une entité rapportée par `NlpManager.process` — sous-ensemble lu par `nlp-tech-step-poc.ts`. */ + export interface NlpEntity { + entity: string; + start: number; + end: number; + type: string; + accuracy?: number; + sourceText?: string; + } + + /** Résultat de `NlpManager.process` — réduit aux champs lus ici (l'objet réel en porte bien plus). */ + export interface NlpProcessResult { + intent: string; + score: number; + entities: NlpEntity[]; + } + + export class NlpManager { + public constructor(options?: NlpManagerOptions); + public addNamedEntityText( + entityName: string, + optionName: string, + languages: string[], + texts: string[], + ): void; + public addDocument(locale: string, utterance: string, intent: string): void; + public train(): Promise; + public process(locale: string, text: string): Promise; + } +}