From 4c3e00a08aacf049ce928514bd6f849731fa6330 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 21 Aug 2026 18:58:28 +0200 Subject: [PATCH 01/10] =?UTF-8?q?feat(experiments):=20ajoute=20un=20PoC=20?= =?UTF-8?q?de=20d=C3=A9tection=20d'actions=20culinaires=20par=20mini=20LLM?= =?UTF-8?q?=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fichier TypeScript autonome (hors du workspace pnpm) qui compare le pipeline node-nlp existant (tech-step-matcher.ts) à un mini LLM instruct local via node-llama-cpp : sortie JSON strictement contrainte par schéma (grammaire GBNF, createGrammarForJsonSchema), interfaces RecipeStepAnalysis/ KitchenAction, recommandation de modèle (Qwen2.5-1.5B-Instruct Q4_K_M par défaut, Llama-3.2-1B-Instruct Q4_K_M en alternative), et un benchmark simple (performance.now() + delta RSS) sur 3 phrases complexes FR/EN, dont le cas piège sans verbe littéral déjà documenté dans tech-step-matcher.ts. Co-Authored-By: Claude Sonnet 5 --- experiments/llm-tech-step-poc/.gitignore | 3 + experiments/llm-tech-step-poc/README.md | 105 ++ experiments/llm-tech-step-poc/package.json | 25 + experiments/llm-tech-step-poc/pnpm-lock.yaml | 1398 +++++++++++++++++ .../src/llm-tech-step-poc.ts | 567 +++++++ experiments/llm-tech-step-poc/tsconfig.json | 11 + 6 files changed, 2109 insertions(+) create mode 100644 experiments/llm-tech-step-poc/.gitignore create mode 100644 experiments/llm-tech-step-poc/README.md create mode 100644 experiments/llm-tech-step-poc/package.json create mode 100644 experiments/llm-tech-step-poc/pnpm-lock.yaml create mode 100644 experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts create mode 100644 experiments/llm-tech-step-poc/tsconfig.json diff --git a/experiments/llm-tech-step-poc/.gitignore b/experiments/llm-tech-step-poc/.gitignore new file mode 100644 index 0000000..5a2a383 --- /dev/null +++ b/experiments/llm-tech-step-poc/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +models/ diff --git a/experiments/llm-tech-step-poc/README.md b/experiments/llm-tech-step-poc/README.md new file mode 100644 index 0000000..56a9d53 --- /dev/null +++ b/experiments/llm-tech-step-poc/README.md @@ -0,0 +1,105 @@ +# PoC — détection d'actions culinaires par mini LLM local + +Expérimentation autonome, **hors du monorepo pnpm** (`pnpm-workspace.yaml` ne +référence que `apps/*`/`packages/*`) : ce dossier a son propre +`package.json`/`tsconfig.json` et ne pollue ni les dépendances ni le build +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. + +## Installation + +```bash +cd experiments/llm-tech-step-poc +pnpm install +``` + +`node-llama-cpp` télécharge/compile son binding natif llama.cpp à +l'installation (binaire prébuilt pour les plateformes courantes, sinon +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 + +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` : + +| 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 à taille comparable — recommandation par défaut vu que "robustesse FR/EN" est un critère explicite de ce PoC. | +| `llama-3.2-1b` | Llama-3.2-1B-Instruct, `Q4_K_M` | ~35 % de paramètres en moins (plus rapide/plus léger), FR officiellement supporté, mais structuration JSON moins fiable à 1B — utile en comparaison "latence d'abord". | + +```bash +LLM_TECH_STEP_MODEL=llama-3.2-1b pnpm bench +``` + +**Hors-ligne / CI** : `LLM_TECH_STEP_MODEL_PATH=/chemin/vers/un.gguf 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 + +```bash +pnpm bench +``` + +Charge le modèle, puis lance 3 répétitions sur chacune des 3 phrases de test +(2 FR + 1 EN, voir `TEST_SENTENCES` dans +[`src/llm-tech-step-poc.ts`](./src/llm-tech-step-poc.ts) — l'une d'elles est +délibérément le cas piège documenté dans le commentaire de +`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`). +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). + +## Méthodologie de comparaison avec le pipeline `node-nlp` + +Ce script reste volontairement autonome (aucune dépendance vers `apps/api`, +donc pas de connexion Postgres requise pour le faire tourner). Pour comparer +manuellement sur les mêmes phrases : + +```ts +// Dans apps/api, un script ponctuel (ou un REPL tsx) : +import { techStepClassifier } from "./src/lib/recipe-matching/tech-step-matcher.js"; + +console.log(await techStepClassifier.matchTechStepSpans( + "É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.", + "fr", +)); +``` + +(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`). + +Les deux sorties ne sont pas directement isomorphes (`TechStepMatch` renvoie +un `techStepId` + des spans de caractères contre un `KitchenAction` +structuré avec ingrédients/durée/température/ustensiles) — 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. + +## Limites de ce PoC + +- Pas de jeu d'évaluation étiqueté ni de métrique de précision automatisée + — les 3 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. +- 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 + 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 new file mode 100644 index 0000000..babc67e --- /dev/null +++ b/experiments/llm-tech-step-poc/package.json @@ -0,0 +1,25 @@ +{ + "name": "llm-tech-step-poc", + "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.", + "scripts": { + "bench": "tsx src/llm-tech-step-poc.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "node-llama-cpp": "^3.20.0" + }, + "devDependencies": { + "@types/node": "^22.9.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild", + "node-llama-cpp" + ] + } +} diff --git a/experiments/llm-tech-step-poc/pnpm-lock.yaml b/experiments/llm-tech-step-poc/pnpm-lock.yaml new file mode 100644 index 0000000..4febaec --- /dev/null +++ b/experiments/llm-tech-step-poc/pnpm-lock.yaml @@ -0,0 +1,1398 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + node-llama-cpp: + specifier: ^3.20.0 + version: 3.20.0(typescript@5.9.3) + devDependencies: + '@types/node': + specifier: ^22.9.0 + version: 22.20.1 + tsx: + specifier: ^4.19.2 + version: 4.23.12 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@huggingface/jinja@0.5.9': + resolution: {integrity: sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==, tarball: https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz} + engines: {node: '>=18'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==, tarball: https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz} + engines: {node: '>=18.0.0'} + + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==, tarball: https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz} + + '@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} + + '@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'} + cpu: [arm64, x64] + os: [linux] + + '@node-llama-cpp/linux-armv7l@3.20.0': + resolution: {integrity: sha512-VUWc9U8QzgfNVcAB2BoapxBJK3wQt8EnBkstRWITTxIb4PLQjARM8Lobuz0p8gMJja4W0yWJLCN0YzkLqn51Qw==, tarball: https://registry.npmjs.org/@node-llama-cpp/linux-armv7l/-/linux-armv7l-3.20.0.tgz} + engines: {node: '>=20.0.0'} + cpu: [arm, x64] + os: [linux] + + '@node-llama-cpp/linux-riscv64@3.20.0': + resolution: {integrity: sha512-U5CV75ECl+RV8WhxKeucSyO3sjtrAudDJ3l8cBMc1V8G5CUWPpHHyS/7bL4a2l8mY+Y+LvI08VSNSiRW5GlXjw==, tarball: https://registry.npmjs.org/@node-llama-cpp/linux-riscv64/-/linux-riscv64-3.20.0.tgz} + engines: {node: '>=20.0.0'} + cpu: [riscv64] + os: [linux] + + '@node-llama-cpp/linux-x64-cuda-ext@3.20.0': + resolution: {integrity: sha512-2XwxFr0K+bLnmhcmXHeR2xM+RZ3LCisrSTJN9LHFkkctCsuZ9mw292XLq5V5b4oJxa3d28UJm5G0WJjnvVd17Q==, tarball: https://registry.npmjs.org/@node-llama-cpp/linux-x64-cuda-ext/-/linux-x64-cuda-ext-3.20.0.tgz} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + + '@node-llama-cpp/linux-x64-cuda@3.20.0': + resolution: {integrity: sha512-XWGGj12nK82NWju5+H5r/b0AY5Fv/zFZHdBwqB+JEyLSyzeGEH7Hc8P6bXBhcxNbGdCWd6wEx/EdZ5PSJrn79Q==, tarball: https://registry.npmjs.org/@node-llama-cpp/linux-x64-cuda/-/linux-x64-cuda-3.20.0.tgz} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + + '@node-llama-cpp/linux-x64-vulkan@3.20.0': + resolution: {integrity: sha512-xTzv4cuTpsmmQgqvWWZvcvURHfPgUQdQzVXvYN1bwAEYq2DRVckEKrHPJ48824sFmdIRRJqDerniqEXiZlDPzA==, tarball: https://registry.npmjs.org/@node-llama-cpp/linux-x64-vulkan/-/linux-x64-vulkan-3.20.0.tgz} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + + '@node-llama-cpp/linux-x64@3.20.0': + resolution: {integrity: sha512-zCSTd5m4MDrLWzgUvOvuGGzHm9DiEONZt+srgHYhV4Ppu/T5TL3kENj7lHY1cmQccKNgepABiyb9KcigjZbvSQ==, tarball: https://registry.npmjs.org/@node-llama-cpp/linux-x64/-/linux-x64-3.20.0.tgz} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + + '@node-llama-cpp/mac-arm64-metal@3.20.0': + resolution: {integrity: sha512-QeFyyTZWicxKGzyoYwR1VtBGM8R1/oHjai9DC6KSg3T8WYpZd3mqATy24GPPVxgg20yEXUCbNl4xyKXZsLD0dQ==, tarball: https://registry.npmjs.org/@node-llama-cpp/mac-arm64-metal/-/mac-arm64-metal-3.20.0.tgz} + engines: {node: '>=20.0.0'} + cpu: [arm64, x64] + os: [darwin] + + '@node-llama-cpp/mac-x64@3.20.0': + resolution: {integrity: sha512-3/B1uT0dNkhGTVkjTpI6OlHdUsic9NWDeocO0GHeq134LmQan34rERpR7JJgyv50iXufah+mvumFD/VZvfUqqQ==, tarball: https://registry.npmjs.org/@node-llama-cpp/mac-x64/-/mac-x64-3.20.0.tgz} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [darwin] + + '@node-llama-cpp/win-arm64@3.20.0': + resolution: {integrity: sha512-UDx5NBXVRtcLaoQsF1gZiYlgXQYfxLbFVb4j4sa7Jgq/b6oq2lTAjeos7sYsMPRKa+S/BB/rDFis6FsRDJur7w==, tarball: https://registry.npmjs.org/@node-llama-cpp/win-arm64/-/win-arm64-3.20.0.tgz} + engines: {node: '>=20.0.0'} + cpu: [arm64, x64] + os: [win32] + + '@node-llama-cpp/win-x64-cuda-ext@3.20.0': + resolution: {integrity: sha512-BTnHmJ7xTzrvv8CWGYnG+2eygjZ4xAvQujtvkfC8N187bRR1fmTA+R7dbrqC2b3fXKtOvyPwOB9NNl+d2FuCmQ==, tarball: https://registry.npmjs.org/@node-llama-cpp/win-x64-cuda-ext/-/win-x64-cuda-ext-3.20.0.tgz} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@node-llama-cpp/win-x64-cuda@3.20.0': + resolution: {integrity: sha512-VPV0Ayw3TP9gGiuwlyWD5x97dQSNMKcbWFXsREJ0KLVRxJXihZdZTrjRaudQX1T1d0lJUeLhDmQUGpDh/8jf1w==, tarball: https://registry.npmjs.org/@node-llama-cpp/win-x64-cuda/-/win-x64-cuda-3.20.0.tgz} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@node-llama-cpp/win-x64-vulkan@3.20.0': + resolution: {integrity: sha512-7V2SjNejon668+xmtlZ36u2FmtIT2fOfQbGjT5zJ6ydW1Xec53VsX1VwQdikx+79Y9/gEp7L2GtBcX6bc0LsCQ==, tarball: https://registry.npmjs.org/@node-llama-cpp/win-x64-vulkan/-/win-x64-vulkan-3.20.0.tgz} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@node-llama-cpp/win-x64@3.20.0': + resolution: {integrity: sha512-Mbh9n74DCB5zTw02cme7Kp9nVg9X5Wvf+SNMWkXx5o3rGLuiSijGqRIktPOO3aHQwshB/RXC4j6I34HCPsgdgg==, tarball: https://registry.npmjs.org/@node-llama-cpp/win-x64/-/win-x64-3.20.0.tgz} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@reflink/reflink-darwin-arm64@0.1.19': + resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==, tarball: https://registry.npmjs.org/@reflink/reflink-darwin-arm64/-/reflink-darwin-arm64-0.1.19.tgz} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@reflink/reflink-darwin-x64@0.1.19': + resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==, tarball: https://registry.npmjs.org/@reflink/reflink-darwin-x64/-/reflink-darwin-x64-0.1.19.tgz} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@reflink/reflink-linux-arm64-gnu@0.1.19': + resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==, tarball: https://registry.npmjs.org/@reflink/reflink-linux-arm64-gnu/-/reflink-linux-arm64-gnu-0.1.19.tgz} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@reflink/reflink-linux-arm64-musl@0.1.19': + resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==, tarball: https://registry.npmjs.org/@reflink/reflink-linux-arm64-musl/-/reflink-linux-arm64-musl-0.1.19.tgz} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@reflink/reflink-linux-x64-gnu@0.1.19': + resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==, tarball: https://registry.npmjs.org/@reflink/reflink-linux-x64-gnu/-/reflink-linux-x64-gnu-0.1.19.tgz} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@reflink/reflink-linux-x64-musl@0.1.19': + resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==, tarball: https://registry.npmjs.org/@reflink/reflink-linux-x64-musl/-/reflink-linux-x64-musl-0.1.19.tgz} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@reflink/reflink-win32-arm64-msvc@0.1.19': + resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==, tarball: https://registry.npmjs.org/@reflink/reflink-win32-arm64-msvc/-/reflink-win32-arm64-msvc-0.1.19.tgz} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@reflink/reflink-win32-x64-msvc@0.1.19': + resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==, tarball: https://registry.npmjs.org/@reflink/reflink-win32-x64-msvc/-/reflink-win32-x64-msvc-0.1.19.tgz} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@reflink/reflink@0.1.19': + resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==, tarball: https://registry.npmjs.org/@reflink/reflink/-/reflink-0.1.19.tgz} + engines: {node: '>= 10'} + + '@simple-git/args-pathspec@1.0.3': + resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==, tarball: https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz} + + '@simple-git/argv-parser@1.1.1': + resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==, tarball: https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz} + + '@tinyhttp/content-disposition@2.2.4': + resolution: {integrity: sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==, tarball: https://registry.npmjs.org/@tinyhttp/content-disposition/-/content-disposition-2.2.4.tgz} + engines: {node: '>=12.17.0'} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==, tarball: https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz} + + 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'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==, tarball: https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz} + engines: {node: '>=8'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==, tarball: https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==, tarball: https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==, tarball: https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz} + engines: {node: '>=12'} + + 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} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==, tarball: https://registry.npmjs.org/bytes/-/bytes-3.1.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} + + chmodrp@1.0.2: + resolution: {integrity: sha512-TdngOlFV1FLTzU0o1w8MB6/BFywhtLC0SzRTGJU7T9lmdjlCWeMRt1iVo0Ki+ldwNk0BqNiKoc8xpLZEQ8mY1w==, tarball: https://registry.npmjs.org/chmodrp/-/chmodrp-1.0.2.tgz} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==, tarball: https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz} + engines: {node: '>=18'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==, tarball: https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==, tarball: https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==, tarball: https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz} + engines: {node: '>=6'} + + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==, tarball: https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz} + engines: {node: '>=18.20'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==, tarball: https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz} + engines: {node: '>=12'} + + cmake-js@8.0.0: + resolution: {integrity: sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==, tarball: https://registry.npmjs.org/cmake-js/-/cmake-js-8.0.0.tgz} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + 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'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, tarball: https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==, tarball: https://registry.npmjs.org/commander/-/commander-10.0.1.tgz} + engines: {node: '>=14'} + + 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'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==, tarball: https://registry.npmjs.org/debug/-/debug-4.4.3.tgz} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-extend@0.6.0: + 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'} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==, tarball: https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==, tarball: https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz} + + env-var@7.5.0: + resolution: {integrity: sha512-mKZOzLRN0ETzau2W2QXefbFjo5EF4yWq28OyKb9ICdeNhHJlOE/pHHnz4hdYJ9cNZXcJHo5xN4OT4pzuSHSNvA==, tarball: https://registry.npmjs.org/env-var/-/env-var-7.5.0.tgz} + engines: {node: '>=10'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==, tarball: https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz} + engines: {node: '>=6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz} + + filename-reserved-regex@3.0.0: + resolution: {integrity: sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==, tarball: https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + filenamify@6.0.0: + resolution: {integrity: sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==, tarball: https://registry.npmjs.org/filenamify/-/filenamify-6.0.0.tgz} + engines: {node: '>=16'} + + 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'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==, tarball: https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==, tarball: https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz} + engines: {node: '>=18'} + + 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} + + 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'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==, tarball: https://registry.npmjs.org/ini/-/ini-1.3.8.tgz} + + ipull@3.9.5: + resolution: {integrity: sha512-5w/yZB5lXmTfsvNawmvkCjYo4SJNuKQz/av8TC1UiOyfOHyaM+DReqbpU2XpWYfmY+NIUbRRH8PUAWsxaS+IfA==, tarball: https://registry.npmjs.org/ipull/-/ipull-3.9.5.tgz} + engines: {node: '>=18.0.0'} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==, tarball: https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==, tarball: https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz} + engines: {node: '>=18'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==, tarball: https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==, tarball: https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, tarball: https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==, tarball: https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz} + engines: {node: '>=20'} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==, tarball: https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.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} + + lifecycle-utils@4.3.1: + resolution: {integrity: sha512-sHZNkjBVcR0e/2indpmvpOj++4e1mUyniN94wqdbtQaXYoAJRlYxpOJ3TwBmp7PwgHqsoAkiTQu+aLa9bss7Jw==, tarball: https://registry.npmjs.org/lifecycle-utils/-/lifecycle-utils-4.3.1.tgz} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==, tarball: https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.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'} + + lowdb@7.0.1: + resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==, tarball: https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz} + engines: {node: '>=18'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==, tarball: https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz} + engines: {node: '>=18'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, tarball: https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==, tarball: https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==, tarball: https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz} + engines: {node: '>= 18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, tarball: https://registry.npmjs.org/ms/-/ms-2.1.3.tgz} + + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz} + engines: {node: ^18 || >=20} + hasBin: true + + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==, tarball: https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz} + engines: {node: ^18 || ^20 || >= 21} + + node-api-headers@1.9.0: + resolution: {integrity: sha512-2oNILP4jXwRB4ywnYKjVk1YyJ96n2D4EOVJO6S3oYZ5PtbJrw3Yt9TpAuX3nBLMuzn74rnfGQrv13pS9vC+YiA==, tarball: https://registry.npmjs.org/node-api-headers/-/node-api-headers-1.9.0.tgz} + + node-llama-cpp@3.20.0: + resolution: {integrity: sha512-KnET3ttADYLCobjMnMTLkWkLt87rPRDhNzTZgGOCt8m8yTmdQW2sfLNmulGBA9laFvcyRIMOsgQST8lunPmMgw==, tarball: https://registry.npmjs.org/node-llama-cpp/-/node-llama-cpp-3.20.0.tgz} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + 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'} + + ora@9.4.1: + resolution: {integrity: sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==, tarball: https://registry.npmjs.org/ora/-/ora-9.4.1.tgz} + engines: {node: '>=20'} + + parse-ms@3.0.0: + resolution: {integrity: sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==, tarball: https://registry.npmjs.org/parse-ms/-/parse-ms-3.0.0.tgz} + engines: {node: '>=12'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==, tarball: https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz} + engines: {node: '>=18'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, tarball: https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz} + engines: {node: '>=8'} + + pretty-bytes@6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==, tarball: https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz} + engines: {node: ^14.13.1 || >=16.0.0} + + pretty-ms@8.0.0: + resolution: {integrity: sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==, tarball: https://registry.npmjs.org/pretty-ms/-/pretty-ms-8.0.0.tgz} + engines: {node: '>=14.16'} + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==, tarball: https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz} + engines: {node: '>=18'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==, tarball: https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==, tarball: https://registry.npmjs.org/rc/-/rc-1.2.8.tgz} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==, tarball: https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz} + engines: {node: '>=0.10.0'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==, tarball: https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz} + engines: {node: '>=18'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==, tarball: https://registry.npmjs.org/retry/-/retry-0.12.0.tgz} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==, tarball: https://registry.npmjs.org/retry/-/retry-0.13.1.tgz} + engines: {node: '>= 4'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==, tarball: https://registry.npmjs.org/semver/-/semver-7.8.5.tgz} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, tarball: https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, tarball: https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==, tarball: https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==, tarball: https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz} + engines: {node: '>=14'} + + simple-git@3.36.0: + resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==, tarball: https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz} + + sleep-promise@9.1.0: + resolution: {integrity: sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA==, tarball: https://registry.npmjs.org/sleep-promise/-/sleep-promise-9.1.0.tgz} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==, tarball: https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==, tarball: https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz} + engines: {node: '>=20'} + + 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'} + + stdout-update@4.0.1: + resolution: {integrity: sha512-wiS21Jthlvl1to+oorePvcyrIkiG/6M3D3VTmDUlJm7Cy6SbFhKkAvX+YBuHLxck/tO3mrdpC/cNesigQc3+UQ==, tarball: https://registry.npmjs.org/stdout-update/-/stdout-update-4.0.1.tgz} + engines: {node: '>=16.0.0'} + + steno@4.0.2: + resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==, tarball: https://registry.npmjs.org/steno/-/steno-4.0.2.tgz} + engines: {node: '>=18'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==, tarball: https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==, tarball: https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz} + engines: {node: '>=18'} + + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==, tarball: https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz} + engines: {node: '>=20'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==, tarball: https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==, tarball: https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==, tarball: https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz} + engines: {node: '>=0.10.0'} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==, tarball: https://registry.npmjs.org/tar/-/tar-7.5.22.tgz} + engines: {node: '>=18'} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==, tarball: https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, tarball: https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==, tarball: https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz} + engines: {node: '>= 10.0.0'} + + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==, tarball: https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz} + + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==, tarball: https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz} + engines: {node: ^20.17.0 || >=22.9.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, tarball: https://registry.npmjs.org/which/-/which-2.0.2.tgz} + engines: {node: '>= 8'} + hasBin: true + + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==, tarball: https://registry.npmjs.org/which/-/which-6.0.1.tgz} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + 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'} + + 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'} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==, tarball: https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz} + engines: {node: '>=18'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==, tarball: https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==, tarball: https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz} + engines: {node: '>=12'} + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==, tarball: https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz} + engines: {node: '>=18'} + +snapshots: + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@huggingface/jinja@0.5.9': {} + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@kwsites/promise-deferred@1.1.1': {} + + '@node-llama-cpp/linux-arm64@3.20.0': + optional: true + + '@node-llama-cpp/linux-armv7l@3.20.0': + optional: true + + '@node-llama-cpp/linux-riscv64@3.20.0': + optional: true + + '@node-llama-cpp/linux-x64-cuda-ext@3.20.0': + optional: true + + '@node-llama-cpp/linux-x64-cuda@3.20.0': + optional: true + + '@node-llama-cpp/linux-x64-vulkan@3.20.0': + optional: true + + '@node-llama-cpp/linux-x64@3.20.0': + optional: true + + '@node-llama-cpp/mac-arm64-metal@3.20.0': + optional: true + + '@node-llama-cpp/mac-x64@3.20.0': + optional: true + + '@node-llama-cpp/win-arm64@3.20.0': + optional: true + + '@node-llama-cpp/win-x64-cuda-ext@3.20.0': + optional: true + + '@node-llama-cpp/win-x64-cuda@3.20.0': + optional: true + + '@node-llama-cpp/win-x64-vulkan@3.20.0': + optional: true + + '@node-llama-cpp/win-x64@3.20.0': + optional: true + + '@reflink/reflink-darwin-arm64@0.1.19': + optional: true + + '@reflink/reflink-darwin-x64@0.1.19': + optional: true + + '@reflink/reflink-linux-arm64-gnu@0.1.19': + optional: true + + '@reflink/reflink-linux-arm64-musl@0.1.19': + optional: true + + '@reflink/reflink-linux-x64-gnu@0.1.19': + optional: true + + '@reflink/reflink-linux-x64-musl@0.1.19': + optional: true + + '@reflink/reflink-win32-arm64-msvc@0.1.19': + optional: true + + '@reflink/reflink-win32-x64-msvc@0.1.19': + optional: true + + '@reflink/reflink@0.1.19': + optionalDependencies: + '@reflink/reflink-darwin-arm64': 0.1.19 + '@reflink/reflink-darwin-x64': 0.1.19 + '@reflink/reflink-linux-arm64-gnu': 0.1.19 + '@reflink/reflink-linux-arm64-musl': 0.1.19 + '@reflink/reflink-linux-x64-gnu': 0.1.19 + '@reflink/reflink-linux-x64-musl': 0.1.19 + '@reflink/reflink-win32-arm64-msvc': 0.1.19 + '@reflink/reflink-win32-x64-msvc': 0.1.19 + optional: true + + '@simple-git/args-pathspec@1.0.3': {} + + '@simple-git/argv-parser@1.1.1': + dependencies: + '@simple-git/args-pathspec': 1.0.3 + + '@tinyhttp/content-disposition@2.2.4': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + ansi-escapes@6.2.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.3.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + + bytes@3.1.2: {} + + chalk@5.6.2: {} + + chmodrp@1.0.2: {} + + chownr@3.0.0: {} + + ci-info@4.4.0: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-spinners@3.4.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cmake-js@8.0.0: + dependencies: + debug: 4.4.3 + fs-extra: 11.4.0 + node-api-headers: 1.9.0 + rc: 1.2.8 + semver: 7.8.5 + tar: 7.5.22 + url-join: 4.0.1 + which: 6.0.1 + yargs: 17.7.3 + transitivePeerDependencies: + - supports-color + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@10.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-extend@0.6.0: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + env-var@7.5.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + eventemitter3@5.0.4: {} + + filename-reserved-regex@3.0.0: {} + + filenamify@6.0.0: + dependencies: + filename-reserved-regex: 3.0.0 + + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + graceful-fs@4.2.11: {} + + ignore@7.0.6: {} + + ini@1.3.8: {} + + ipull@3.9.5: + dependencies: + '@tinyhttp/content-disposition': 2.2.4 + async-retry: 1.3.3 + chalk: 5.6.2 + ci-info: 4.4.0 + cli-spinners: 2.9.2 + commander: 10.0.1 + eventemitter3: 5.0.4 + filenamify: 6.0.0 + fs-extra: 11.4.0 + is-unicode-supported: 2.1.0 + lifecycle-utils: 2.1.0 + lodash.debounce: 4.0.8 + lowdb: 7.0.1 + pretty-bytes: 6.1.1 + pretty-ms: 8.0.0 + sleep-promise: 9.1.0 + slice-ansi: 7.1.2 + stdout-update: 4.0.1 + strip-ansi: 7.2.0 + optionalDependencies: + '@reflink/reflink': 0.1.19 + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + + is-interactive@2.0.0: {} + + is-unicode-supported@2.1.0: {} + + isexe@2.0.0: {} + + isexe@4.0.0: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + lifecycle-utils@2.1.0: {} + + lifecycle-utils@4.3.1: {} + + lodash.debounce@4.0.8: {} + + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.2.0 + + lowdb@7.0.1: + dependencies: + steno: 4.0.2 + + mimic-function@5.0.1: {} + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + ms@2.1.3: {} + + nanoid@5.1.16: {} + + node-addon-api@8.9.2: {} + + node-api-headers@1.9.0: {} + + node-llama-cpp@3.20.0(typescript@5.9.3): + dependencies: + '@huggingface/jinja': 0.5.9 + async-retry: 1.3.3 + bytes: 3.1.2 + chalk: 5.6.2 + chmodrp: 1.0.2 + cmake-js: 8.0.0 + cross-spawn: 7.0.6 + env-var: 7.5.0 + filenamify: 6.0.0 + fs-extra: 11.4.0 + ignore: 7.0.6 + ipull: 3.9.5 + is-unicode-supported: 2.1.0 + lifecycle-utils: 4.3.1 + log-symbols: 7.0.1 + nanoid: 5.1.16 + node-addon-api: 8.9.2 + ora: 9.4.1 + pretty-ms: 9.3.0 + proper-lockfile: 4.1.2 + semver: 7.8.5 + simple-git: 3.36.0 + slice-ansi: 8.0.0 + stdout-update: 4.0.1 + strip-ansi: 7.2.0 + validate-npm-package-name: 7.0.2 + which: 6.0.1 + yargs: 17.7.3 + optionalDependencies: + '@node-llama-cpp/linux-arm64': 3.20.0 + '@node-llama-cpp/linux-armv7l': 3.20.0 + '@node-llama-cpp/linux-riscv64': 3.20.0 + '@node-llama-cpp/linux-x64': 3.20.0 + '@node-llama-cpp/linux-x64-cuda': 3.20.0 + '@node-llama-cpp/linux-x64-cuda-ext': 3.20.0 + '@node-llama-cpp/linux-x64-vulkan': 3.20.0 + '@node-llama-cpp/mac-arm64-metal': 3.20.0 + '@node-llama-cpp/mac-x64': 3.20.0 + '@node-llama-cpp/win-arm64': 3.20.0 + '@node-llama-cpp/win-x64': 3.20.0 + '@node-llama-cpp/win-x64-cuda': 3.20.0 + '@node-llama-cpp/win-x64-cuda-ext': 3.20.0 + '@node-llama-cpp/win-x64-vulkan': 3.20.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + ora@9.4.1: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.2 + + parse-ms@3.0.0: {} + + parse-ms@4.0.0: {} + + path-key@3.1.1: {} + + pretty-bytes@6.1.1: {} + + pretty-ms@8.0.0: + dependencies: + parse-ms: 3.0.0 + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + require-directory@2.1.1: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retry@0.12.0: {} + + retry@0.13.1: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-git@3.36.0: + dependencies: + '@kwsites/file-exists': 1.1.1 + '@kwsites/promise-deferred': 1.1.1 + '@simple-git/args-pathspec': 1.0.3 + '@simple-git/argv-parser': 1.1.1 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + sleep-promise@9.1.0: {} + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + stdin-discarder@0.3.2: {} + + stdout-update@4.0.1: + dependencies: + ansi-escapes: 6.2.1 + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + steno@4.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + strip-json-comments@2.0.1: {} + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + universalify@2.0.1: {} + + url-join@4.0.1: {} + + validate-npm-package-name@7.0.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@6.0.1: + dependencies: + isexe: 4.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@5.0.8: {} + + yallist@5.0.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yoctocolors@2.2.0: {} 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 new file mode 100644 index 0000000..f16d048 --- /dev/null +++ b/experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts @@ -0,0 +1,567 @@ +/** + * PoC autonome — détection d'actions culinaires via un mini LLM local + * (`node-llama-cpp`), à comparer au pipeline `node-nlp` déjà en place dans + * `apps/api/src/lib/recipe-matching/tech-step-matcher.ts` + * (`TechStepClassifierService`). + * + * Objectif de la comparaison : ce pipeline `node-nlp` classe une étape en + * UNE technique par clause (NER pour repérer les candidats -> découpage en + * clauses -> classification d'intention par clause). Ce PoC teste une + * approche différente : demander à un petit LLM instruct local d'extraire + * en une seule passe la séquence ORDONNÉE de toutes les actions atomiques + * d'une étape, sous forme d'un JSON structuré — sans borne de vocabulaire + * fixée à l'avance (pas de liste de synonymes/utterances à maintenir), au + * prix d'une latence et d'une empreinte mémoire bien plus élevées (un + * modèle de ~1 à 2 Md de paramètres contre un classifieur bayésien/NER + * léger). Les deux pipelines tournent entièrement en local, sans appel + * réseau à l'inférence (le seul accès réseau de ce fichier est le + * téléchargement ponctuel du modèle GGUF, voir {@link resolveModelPath}). + * + * Portée volontairement limitée à un fichier autonome, hors du monorepo + * pnpm (`pnpm-workspace.yaml` ne référence que `apps/*`/`packages/*`) : + * c'est un script d'expérimentation jetable, pas un module destiné à être + * consommé par `apps/api` — même statut que les scripts one-off déjà + * exemptés de la convention "un `try`/`catch` par `await`" du repo + * (`prisma/seed.ts`, `apps/api/src/scripts/seed-runtime.ts`) : ici aussi, + * laisser une erreur se propager telle quelle jusqu'au point d'appel qui + * décide quoi en faire (le run complet du benchmark, ou `main()` en tout + * dernier ressort) est plus lisible qu'un `catch { throw err; }` répété + * sans rien y ajouter. + * + * Usage : voir `README.md` à côté de ce fichier (installation, modèle, + * variables d'environnement). En bref : + * + * ```bash + * cd experiments/llm-tech-step-poc + * pnpm install + * pnpm bench + * ``` + */ + +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { fileURLToPath } from "node:url"; +import { + getLlama, + LlamaChatSession, + type LlamaJsonSchemaGrammar, + resolveModelFile, +} from "node-llama-cpp"; + +// --------------------------------------------------------------------------- +// Types métier — RecipeStepAnalysis / KitchenAction +// --------------------------------------------------------------------------- + +/** + * Taxonomie fermée des actions culinaires que le LLM peut poser sur une + * action extraite. Volontairement large (`OTHER` en filet de sécurité) plutôt + * qu'exhaustive comme les ~25 `TechStep` de la base : ce PoC teste la + * *structuration* d'une étape en séquence d'actions typées, pas encore un + * remplacement à iso-vocabulaire du catalogue `TechStep` existant. + */ +export enum KitchenActionType { + /** Travail au couteau — émincer, couper en dés, hacher, éplucher, trancher. */ + CUT = "CUT", + /** Cuisson à proprement parler — faire revenir, mijoter, bouillir, cuire au four, griller, fondre. */ + COOK = "COOK", + /** Combiner/mélanger des ingrédients entre eux, sans cuisson — mélanger, fouetter, incorporer. */ + MIX = "MIX", + /** Laisser reposer/refroidir/mariner/lever, sans intervention active. */ + REST = "REST", + /** Assaisonner — sel, poivre, épices, herbes, condiments. */ + SEASON = "SEASON", + /** Préchauffage d'un four, d'une poêle ou d'un appareil avant utilisation. */ + PREHEAT = "PREHEAT", + /** Toute action ne rentrant dans aucune des catégories ci-dessus (dresser, égoutter, réserver, transférer...). */ + OTHER = "OTHER", +} + +/** + * Une action atomique extraite d'une étape de recette — l'équivalent, côté + * LLM, de ce qu'un `TechStepMatch` (`tech-step-matcher.ts`) représente côté + * pipeline `node-nlp`, mais enrichi des attributs qu'un LLM générativiste + * peut extraire en une seule passe (ingrédients, durée, température, + * ustensiles) là où le pipeline `node-nlp` ne renvoie qu'un id de technique + * plus ses spans de texte. + */ +export interface KitchenAction { + /** Catégorie de l'action, parmi {@link KitchenActionType}. */ + action: KitchenActionType; + /** Verbe littéral employé dans le texte (langue d'origine, non traduit) — ex. "émincez", "dice". */ + verb: string; + /** Ingrédients sur lesquels porte spécifiquement cette action ; tableau vide si aucun n'est nommé. */ + ingredients: string[]; + /** Durée en minutes si l'étape en mentionne une (heures/secondes converties) ; `null` sinon. */ + durationMinutes: number | null; + /** Mention littérale de température/intensité de feu (ex. "180°C", "feu doux", "medium heat") ; `null` sinon. */ + temperature: string | null; + /** Ustensiles/équipements nommés pour cette action ; tableau vide si aucun n'est nommé. */ + utensils: string[]; +} + +/** + * Résultat complet de l'analyse d'une étape — la séquence ORDONNÉE + * d'actions qu'elle décrit, alignée sur le texte source pour traçabilité + * dans les résultats du benchmark. + * + * `originalText` n'est volontairement PAS demandé au LLM (donc pas dans le + * schéma JSON imposé par la grammaire, voir {@link KITCHEN_ACTIONS_JSON_SCHEMA}) + * : le faire recopier le texte d'entrée gaspillerait des tokens de + * génération et risquerait une recopie légèrement différente de l'original + * (espaces, ponctuation) sans aucun bénéfice — ce champ est réattaché + * programmatiquement par {@link LocalLlmStepAnalyzer.analyzeStep} à partir + * de l'argument d'entrée, pas de la réponse du modèle. + */ +export interface RecipeStepAnalysis { + /** Texte source de l'étape, tel que passé à `analyzeStep`. */ + originalText: string; + /** Séquence ordonnée d'actions détectées ; vide si l'étape n'en décrit aucune. */ + actions: KitchenAction[]; +} + +// --------------------------------------------------------------------------- +// Schéma JSON — grammaire GBNF imposée à la génération +// --------------------------------------------------------------------------- + +/** + * Schéma JSON d'une {@link KitchenAction}, dans le sous-ensemble supporté + * par `LlamaChatSession`+`llama.createGrammarForJsonSchema` (object/array/ + * string/number/enum/oneOf — pas d'union `type: [...]` pour les champs + * nullable, node-llama-cpp veut `oneOf: [{type:"null"}, {type:"..."}]`, + * voir la doc "Using Grammar"). Champ à champ, en miroir strict de + * {@link KitchenAction} : la grammaire ne fait qu'imposer une SYNTAXE JSON + * valide conforme à ce schéma, elle ne garantit pas que le modèle choisisse + * la bonne catégorie/le bon champ — c'est le rôle du prompt système + * ({@link SYSTEM_PROMPT}) de guider la sémantique. + */ +const KITCHEN_ACTION_JSON_SCHEMA = { + type: "object", + properties: { + action: { enum: Object.values(KitchenActionType) }, + verb: { type: "string" }, + ingredients: { type: "array", items: { type: "string" } }, + durationMinutes: { oneOf: [{ type: "null" }, { type: "number" }] }, + temperature: { oneOf: [{ type: "null" }, { type: "string" }] }, + utensils: { type: "array", items: { type: "string" } }, + }, + required: ["action", "verb", "ingredients", "durationMinutes", "temperature", "utensils"], +} as const; + +/** + * Racine du schéma imposé au modèle — un objet `{ actions: [...] }` plutôt + * qu'un tableau nu en racine (node-llama-cpp exige un `type: "object"` en + * racine de la grammaire JSON). `originalText` n'y figure pas, voir le + * commentaire sur {@link RecipeStepAnalysis.originalText}. + */ +const KITCHEN_ACTIONS_JSON_SCHEMA = { + type: "object", + properties: { + actions: { type: "array", items: KITCHEN_ACTION_JSON_SCHEMA }, + }, + required: ["actions"], +} as const; + +/** Forme brute que renvoie `grammar.parse()` pour {@link KITCHEN_ACTIONS_JSON_SCHEMA} — reconverti en {@link RecipeStepAnalysis} par {@link LocalLlmStepAnalyzer.analyzeStep}. */ +interface KitchenActionsGrammarResult { + actions: KitchenAction[]; +} + +/** + * Instructions système — porte toute la sémantique que la grammaire GBNF ne + * peut pas imposer (elle ne contraint que la forme JSON, jamais le + * contenu) : la définition de chaque catégorie de {@link KitchenActionType}, + * et ce qu'extraire pour chaque champ. Explicitement bilingue dans son + * énoncé même (plutôt que deux prompts FR/EN séparés à maintenir) — le but + * du benchmark est justement de voir si un seul prompt, sur un modèle + * multilingue, tient la route en français ET en anglais sans bascule + * explicite de langue. + */ +const SYSTEM_PROMPT = `You are a culinary instruction parser. You receive ONE recipe step, written in either French or English. Break it down into the ordered sequence of atomic actions it describes, and respond with ONLY the JSON object required by the schema — no prose, no markdown code fences, no explanation. + +Action taxonomy (pick exactly one per action): +- CUT: knife work — chopping, dicing, mincing, slicing, peeling. +- COOK: applying heat to actually cook food — frying, sautéing, simmering, boiling, baking, grilling, melting. +- MIX: combining/stirring/whisking/folding ingredients together, with no heat involved. +- REST: letting something sit, cool, chill, marinate or rise without active handling. +- SEASON: adding salt, pepper, spices, herbs or condiments to flavor a dish. +- PREHEAT: bringing an oven, pan or appliance up to temperature before it is used. +- OTHER: anything not covered above (plating, straining, transferring, reserving...). + +For each action extract: +- verb: the literal action verb from the source text, in its original language. +- ingredients: the ingredients this specific action applies to (empty array if none named). +- durationMinutes: a single number in minutes if a duration is stated (convert hours/seconds), otherwise null. +- temperature: the literal temperature/heat-level mention (e.g. "180°C", "feu doux", "medium heat"), otherwise null. +- utensils: any cookware/tools named for this action (empty array if none named). + +Keep the actions in the order they happen in the text. A step can describe several sequential actions, even when no explicit verb names a technique (e.g. "until the butter has disappeared into the pan" means melting butter, i.e. COOK).`; + +// --------------------------------------------------------------------------- +// Modèles recommandés +// --------------------------------------------------------------------------- + +/** Une des deux familles de modèles GGUF évaluées par ce PoC — voir le comparatif dans `README.md`. */ +export type RecommendedModelKey = "qwen2.5-1.5b" | "llama-3.2-1b"; + +/** Un modèle GGUF candidat, référencé par son URI `hf:` (résolu/téléchargé par `resolveModelFile`, voir la doc "Downloading Models" de node-llama-cpp). */ +interface RecommendedModel { + /** URI `hf::` — node-llama-cpp résout et télécharge (une seule fois, mis en cache) le fichier GGUF correspondant depuis Hugging Face. */ + hfUri: string; + /** Pourquoi ce modèle, en une phrase — voir aussi le comparatif détaillé dans `README.md`. */ + rationale: string; +} + +/** + * Les deux modèles recommandés pour cette tâche, choisis parmi les + * instruct GGUF ~1-1.5 Md de paramètres (assez petits pour tourner en CPU + * pur avec une latence de l'ordre de la seconde, assez récents pour bien + * suivre des instructions de structuration JSON) : + * + * - **Qwen2.5-1.5B-Instruct** (recommandation par défaut) : corpus + * d'entraînement nettement plus multilingue que la famille Llama à + * taille comparable, et meilleur suivi d'instructions de structuration + * (extraction JSON, function calling) dans les benchmarks publiés par + * Qwen — le compromis précision/latence le plus favorable ici vu que le + * critère "robustesse FR/EN" est un objectif explicite du PoC. + * - **Llama-3.2-1B-Instruct** (alternative) : ~35 % de paramètres en + * moins, donc plus rapide et plus léger en RAM ; le FR fait partie de ses + * langues officiellement supportées, mais avec un suivi d'instructions de + * structuration plus fragile à cette taille dans la pratique — utile + * comme point de comparaison "latence d'abord" plutôt que comme premier + * choix. + * + * Les deux sont quantisés en `Q4_K_M` — le compromis taille/qualité standard + * pour de l'inférence CPU (~4.5 bits/poids, largement suffisant pour une + * tâche d'extraction structurée, contrairement à de la génération créative + * longue où une quantisation plus fine se voit davantage). + */ +const RECOMMENDED_MODELS: Record = { + "qwen2.5-1.5b": { + hfUri: "hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M", + rationale: + "Meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON à taille comparable.", + }, + "llama-3.2-1b": { + hfUri: "hf:bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M", + rationale: + "Plus petit/plus rapide ; FR officiellement supporté mais structuration JSON moins fiable à 1B.", + }, +}; + +/** Répertoire où les modèles GGUF téléchargés sont mis en cache — à côté de ce fichier, jamais commité (voir `.gitignore` du dossier). */ +const MODELS_DIRECTORY = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "models"); + +/** + * Résout le chemin du fichier GGUF à charger : un chemin local explicite + * via `LLM_TECH_STEP_MODEL_PATH` prime toujours (utile hors-ligne, ou en CI + * où un téléchargement réseau à la volée n'est pas souhaitable) ; sinon, + * {@link RECOMMENDED_MODELS} est résolu par `resolveModelFile`, qui + * télécharge le fichier une seule fois dans {@link MODELS_DIRECTORY} puis le + * réutilise tel quel aux exécutions suivantes. + */ +async function resolveModelPath(modelKey: RecommendedModelKey): Promise { + const explicitPath = process.env.LLM_TECH_STEP_MODEL_PATH; + if (explicitPath !== undefined && explicitPath.length > 0) return explicitPath; + return await resolveModelFile(RECOMMENDED_MODELS[modelKey].hfUri, MODELS_DIRECTORY); +} + +// --------------------------------------------------------------------------- +// LocalLlmStepAnalyzer — enrobage node-llama-cpp +// --------------------------------------------------------------------------- + +/** + * Charge un modèle GGUF local et l'expose comme un service d'analyse + * d'étapes de recette — vraie `class` (pas un objet littéral), même + * convention que `TechStepClassifierService` : elle possède un état réel + * (modèle chargé, contexte, grammaire compilée) coûteux à reconstruire, + * jamais recréé par appel. + */ +export class LocalLlmStepAnalyzer { + /** Instance `node-llama-cpp` — porte d'entrée vers le binding natif llama.cpp. `undefined` avant `initialize()`. */ + private _llama: Awaited> | undefined; + /** Modèle GGUF chargé en mémoire. `undefined` avant `initialize()`. */ + private _model: + | Awaited>["loadModel"]>> + | undefined; + /** Contexte d'inférence (fenêtre de contexte + cache KV) dérivé de `_model`. `undefined` avant `initialize()`. */ + private _context: + | Awaited< + ReturnType< + Awaited>["loadModel"]>>["createContext"] + > + > + | undefined; + /** + * Grammaire GBNF compilée depuis {@link KITCHEN_ACTIONS_JSON_SCHEMA} — + * compilée une seule fois, réutilisée à chaque `analyzeStep`. Typée + * explicitement via le type générique `LlamaJsonSchemaGrammar` + * (plutôt qu'un `Awaited>` sur la méthode générique + * `createGrammarForJsonSchema`, qui perd le type précis du schéma faute + * d'argument concret à cet endroit) pour que `grammar.parse()` renvoie un + * type déjà aligné sur {@link KitchenActionsGrammarResult}. `undefined` + * avant `initialize()`. + */ + private _grammar: LlamaJsonSchemaGrammar | undefined; + + /** + * Charge le modèle (téléchargement au besoin, voir {@link resolveModelPath}), + * crée son contexte d'inférence et compile la grammaire JSON — la partie + * coûteuse (souvent plusieurs secondes, dominée par le chargement des + * poids depuis disque), à faire une seule fois avant tout `analyzeStep`. + */ + public async initialize(modelKey: RecommendedModelKey): Promise { + const modelPath = await resolveModelPath(modelKey); + this._llama = await getLlama(); + this._model = await this._llama.loadModel({ modelPath }); + this._context = await this._model.createContext({ contextSize: 4096 }); + this._grammar = await this._llama.createGrammarForJsonSchema(KITCHEN_ACTIONS_JSON_SCHEMA); + } + + /** + * Analyse une étape de recette et renvoie sa séquence ordonnée d'actions. + * + * Une séquence `LlamaContextSequence` dédiée est allouée pour CET appel + * puis libérée en sortie (`finally`), plutôt que de réutiliser une session + * de chat partagée : `LlamaChatSession` accumule l'historique de + * conversation à chaque `prompt()`, ce qui aurait fait grandir le contexte + * (et donc la latence mesurée) au fil des phrases du benchmark au lieu de + * mesurer chaque étape dans des conditions comparables. Le contexte par + * défaut n'autorise qu'une seule séquence active à la fois + * (`createContext()` sans `sequences` explicite) — d'où la libération + * immédiate, indispensable pour que l'appel suivant puisse en allouer une + * nouvelle. + */ + public async analyzeStep(stepText: string): Promise { + if (this._llama === undefined || this._context === undefined || this._grammar === undefined) { + throw new Error("LocalLlmStepAnalyzer.initialize() must be awaited before analyzeStep()."); + } + const context = this._context; + const grammar = this._grammar; + const sequence = context.getSequence(); + try { + const session = new LlamaChatSession({ + contextSequence: sequence, + systemPrompt: SYSTEM_PROMPT, + }); + const response = await session.prompt(stepText, { grammar }); + // La grammaire garantit un JSON syntaxiquement conforme au schéma — + // ce cast ne fait que réattacher le type nommé `KitchenActionsGrammarResult` + // (le schéma étant défini structurellement, pas de risque `any`). + const parsed = grammar.parse(response) as KitchenActionsGrammarResult; + return { originalText: stepText, actions: parsed.actions }; + } finally { + await sequence.dispose(); + } + } + + /** Libère le modèle et son contexte — à appeler une fois le benchmark terminé, la mémoire native n'étant pas gérée par le GC de V8. */ + public async dispose(): Promise { + await this._context?.dispose(); + await this._model?.dispose(); + } +} + +// --------------------------------------------------------------------------- +// Benchmark +// --------------------------------------------------------------------------- + +/** Une phrase de test du benchmark, avec sa langue et ce qui la rend "complexe" (documentation, non exploité par le code). */ +interface BenchmarkSentence { + id: string; + locale: "fr" | "en"; + text: string; + /** Ce qui rend cette phrase intéressante à tester — affiché dans les résultats pour donner du contexte à la comparaison manuelle avec le pipeline `node-nlp`. */ + note: string; +} + +/** + * Trois phrases complexes, FR et EN, choisies pour couvrir des difficultés + * différentes : + * + * 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. + */ +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).", + }, +]; + +/** 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. + */ +async function runBenchmark(analyzer: LocalLlmStepAnalyzer): Promise { + const samples: BenchmarkSample[] = []; + for (const sentence of TEST_SENTENCES) { + for (let repetition = 1; repetition <= REPETITIONS_PER_SENTENCE; repetition++) { + 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 }); + } catch (err) { + console.error(`[poc] échec sur "${sentence.id}" (répétition ${repetition})`, err); + } + } + } + return samples; +} + +/** Une ligne de sortie détaillée, une par échantillon — sert de matière première à la comparaison manuelle avec le pipeline `node-nlp`. */ +function printDetailedResults(samples: readonly BenchmarkSample[]): void { + for (const sample of samples) { + console.info( + `\n[${sample.sentence.id}] (${sample.sentence.locale}) — ${sample.latencyMs.toFixed(0)} ms`, + ); + console.info(` texte : ${sample.sentence.text}`); + console.info(` attendu : ${sample.sentence.note}`); + console.table( + sample.analysis.actions.map((action) => ({ + action: action.action, + verbe: action.verb, + ingrédients: action.ingredients.join(", "), + "durée (min)": action.durationMinutes ?? "—", + température: action.temperature ?? "—", + ustensiles: action.utensils.join(", "), + })), + ); + } +} + +/** Une ligne du tableau récapitulatif final — moyennes/min/max de latence et RAM par phrase, agrégées sur {@link REPETITIONS_PER_SENTENCE} répétitions. */ +interface BenchmarkSummaryRow { + phrase: string; + langue: string; + "runs OK": number; + "latence moy. (ms)": string; + "latence min (ms)": string; + "latence max (ms)": string; + "RSS moy. (Mo)": string; + "actions détectées": number; +} + +/** Agrège {@link BenchmarkSample}s par phrase et imprime le tableau récapitulatif du benchmark. */ +function printSummaryTable(samples: readonly BenchmarkSample[]): void { + const rows: BenchmarkSummaryRow[] = TEST_SENTENCES.map((sentence) => { + const sentenceSamples = samples.filter((sample) => sample.sentence.id === sentence.id); + const latencies = sentenceSamples.map((sample) => sample.latencyMs); + const avgLatency = latencies.reduce((sum, value) => sum + value, 0) / (latencies.length || 1); + const avgRssMb = + sentenceSamples.reduce((sum, sample) => sum + sample.rssDeltaBytes, 0) / + (sentenceSamples.length || 1) / + (1024 * 1024); + const lastSample = sentenceSamples.at(-1); + return { + phrase: sentence.id, + langue: sentence.locale, + "runs OK": sentenceSamples.length, + "latence moy. (ms)": latencies.length > 0 ? avgLatency.toFixed(0) : "—", + "latence min (ms)": latencies.length > 0 ? Math.min(...latencies).toFixed(0) : "—", + "latence max (ms)": latencies.length > 0 ? Math.max(...latencies).toFixed(0) : "—", + "RSS moy. (Mo)": sentenceSamples.length > 0 ? avgRssMb.toFixed(1) : "—", + "actions détectées": lastSample?.analysis.actions.length ?? 0, + }; + }); + console.info("\n=== Récapitulatif ==="); + console.table(rows); +} + +// --------------------------------------------------------------------------- +// Entrée du script +// --------------------------------------------------------------------------- + +/** + * Point d'entrée : charge le modèle choisi via `LLM_TECH_STEP_MODEL` + * (`"qwen2.5-1.5b"` par défaut, voir {@link RECOMMENDED_MODELS}), lance le + * benchmark sur {@link TEST_SENTENCES}, imprime les résultats détaillés puis + * le récapitulatif, et libère le modèle avant de quitter. + */ +async function main(): Promise { + const modelKey: RecommendedModelKey = + process.env.LLM_TECH_STEP_MODEL === "llama-3.2-1b" ? "llama-3.2-1b" : "qwen2.5-1.5b"; + console.info( + `[poc] modèle sélectionné : ${modelKey} (${RECOMMENDED_MODELS[modelKey].rationale})`, + ); + + const analyzer = new LocalLlmStepAnalyzer(); + const rssBeforeLoad = process.memoryUsage().rss; + // `performance.now()` plutôt que `console.time`/`console.timeEnd` — la + // config Biome du repo n'autorise que error/warn/info/debug/table/assert + // sur `console` (voir `biome.json`, `suspicious.noConsole`), pas `time`. + const loadStartedAt = performance.now(); + try { + await analyzer.initialize(modelKey); + } catch (err) { + console.error( + "[poc] échec du chargement du modèle — vérifier LLM_TECH_STEP_MODEL_PATH / la connexion réseau pour le téléchargement initial", + err, + ); + process.exitCode = 1; + return; + } + const loadDurationMs = performance.now() - loadStartedAt; + const modelRssMb = (process.memoryUsage().rss - rssBeforeLoad) / (1024 * 1024); + console.info( + `[poc] modèle chargé en ${loadDurationMs.toFixed(0)} ms (+${modelRssMb.toFixed(1)} Mo RSS)`, + ); + + try { + const benchmarkStartedAt = performance.now(); + const samples = await runBenchmark(analyzer); + const benchmarkDurationMs = performance.now() - benchmarkStartedAt; + console.info(`[poc] benchmark complet en ${benchmarkDurationMs.toFixed(0)} ms`); + printDetailedResults(samples); + printSummaryTable(samples); + } finally { + try { + await analyzer.dispose(); + } catch (err) { + console.error("[poc] erreur lors de la libération du modèle", err); + } + } +} + +await main(); diff --git a/experiments/llm-tech-step-poc/tsconfig.json b/experiments/llm-tech-step-poc/tsconfig.json new file mode 100644 index 0000000..a73d580 --- /dev/null +++ b/experiments/llm-tech-step-poc/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} From 0c1743c61419a7843f5dd2c9ce5afe23a0a09541 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 21 Aug 2026 19:04:00 +0200 Subject: [PATCH 02/10] feat(experiments): ajoute 4 phrases de test hyper complexes au PoC LLM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complète les 3 phrases initiales de TEST_SENTENCES avec 4 cas cherchant volontairement le point de rupture (au lieu de juste confirmer le cas courant) : actions simultanées plutôt que séquentielles ("pendant que..."), action conditionnelle noyée dans des actions fermes, négation explicite d'action ("sans jamais laisser bouillir"), fin de cuisson par état/test de résultat plutôt que par durée, et un champ température qui désigne un seuil de cuisson à cœur plutôt qu'un réglage de feu. README mis à jour (7 phrases, 4 FR + 3 EN). Co-Authored-By: Claude Sonnet 5 --- experiments/llm-tech-step-poc/README.md | 23 ++++---- .../src/llm-tech-step-poc.ts | 54 ++++++++++++++++++- 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/experiments/llm-tech-step-poc/README.md b/experiments/llm-tech-step-poc/README.md index 56a9d53..db2dc28 100644 --- a/experiments/llm-tech-step-poc/README.md +++ b/experiments/llm-tech-step-poc/README.md @@ -51,15 +51,20 @@ résolution/téléchargement Hugging Face. pnpm bench ``` -Charge le modèle, puis lance 3 répétitions sur chacune des 3 phrases de test -(2 FR + 1 EN, voir `TEST_SENTENCES` dans -[`src/llm-tech-step-poc.ts`](./src/llm-tech-step-poc.ts) — l'une d'elles est -délibérément le cas piège documenté dans le commentaire de +Charge le modèle, 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`). -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). +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). ## Méthodologie de comparaison avec le pipeline `node-nlp` @@ -91,7 +96,7 @@ piège FR sans verbe littéral. ## Limites de ce PoC - Pas de jeu d'évaluation étiqueté ni de métrique de précision automatisée - — les 3 phrases sont inspectées à l'œil, pas notées. + — 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 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 f16d048..8fd062f 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 @@ -375,8 +375,12 @@ interface BenchmarkSentence { } /** - * Trois phrases complexes, FR et EN, choisies pour couvrir des difficultés - * différentes : + * 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"). @@ -390,6 +394,28 @@ interface BenchmarkSentence { * 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[] = [ { @@ -410,6 +436,30 @@ const TEST_SENTENCES: readonly BenchmarkSentence[] = [ 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. */ From f0ffd9644e360ccffafc434fb2f7d6d6955936ae Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 21 Aug 2026 19:10:44 +0200 Subject: [PATCH 03/10] =?UTF-8?q?docs(experiments):=20documente=20le=20--i?= =?UTF-8?q?gnore-workspace=20n=C3=A9cessaire=20=C3=A0=20l'install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm install seul, lancé depuis experiments/llm-tech-step-poc, remonte au monorepo (pnpm-workspace.yaml) et n'installe rien pour ce dossier hors workspace — sans erreur visible. --ignore-workspace force pnpm à traiter le dossier comme un package standalone. Co-Authored-By: Claude Sonnet 5 --- experiments/llm-tech-step-poc/README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/experiments/llm-tech-step-poc/README.md b/experiments/llm-tech-step-poc/README.md index db2dc28..607735a 100644 --- a/experiments/llm-tech-step-poc/README.md +++ b/experiments/llm-tech-step-poc/README.md @@ -17,9 +17,15 @@ précision, robustesse multilingue FR/EN, latence. ```bash cd experiments/llm-tech-step-poc -pnpm install +pnpm install --ignore-workspace ``` +`--ignore-workspace` est nécessaire : ce dossier n'étant pas dans les globs +de `pnpm-workspace.yaml` (`apps/*`/`packages/*`), un `pnpm install` normal +remonte jusqu'à la racine du monorepo et n'installe **rien** ici (aucune +erreur, juste un `node_modules` vide/inutilisable) — piège trouvé en écrivant +ce PoC. + `node-llama-cpp` télécharge/compile son binding natif llama.cpp à l'installation (binaire prébuilt pour les plateformes courantes, sinon compilation locale — nécessite alors un toolchain C++, voir sa doc From 6574d8e4a8b13997dd29c67e1482fb1c3390f14b Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 21 Aug 2026 19:32:37 +0200 Subject: [PATCH 04/10] =?UTF-8?q?fix(experiments):=20ajoute=20un=20warm-up?= =?UTF-8?q?=20et=20des=20logs=20it=C3=A9ratifs=20au=20benchmark=20LLM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocalLlmStepAnalyzer.warmUp() : force le coût caché du tout premier appel d'inférence (spin-up threads llama.cpp, cache KV, tokenizer) avant le benchmark, plutôt que de laisser la première phrase l'absorber — constaté sur des runs réels (Qwen/Llama) où fr-multi-action montait jusqu'à ~28s contre ~5s pour ses autres répétitions. - initialize() et runBenchmark() journalisent maintenant chaque sous-étape (résolution du modèle, chargement des poids, contexte, grammaire, puis chaque répétition avec son résultat immédiat) au lieu de rester muets plusieurs minutes avant le récapitulatif final. - RECOMMENDED_MODELS / README corrigés suite aux runs réels de l'utilisateur : Qwen2.5-1.5B s'est montré systématiquement plus rapide que Llama-3.2-1B sur les deux machines testées, contredisant l'hypothèse a priori du README ("moins de paramètres = plus rapide") — gardé comme résultat empirique plutôt que corrigé silencieusement. Co-Authored-By: Claude Sonnet 5 --- experiments/llm-tech-step-poc/README.md | 19 +++- .../src/llm-tech-step-poc.ts | 92 ++++++++++++++++--- 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/experiments/llm-tech-step-poc/README.md b/experiments/llm-tech-step-poc/README.md index 607735a..38a3d35 100644 --- a/experiments/llm-tech-step-poc/README.md +++ b/experiments/llm-tech-step-poc/README.md @@ -40,8 +40,16 @@ Le script télécharge automatiquement (une seule fois, mis en cache dans | 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 à taille comparable — recommandation par défaut vu que "robustesse FR/EN" est un critère explicite de ce PoC. | -| `llama-3.2-1b` | Llama-3.2-1B-Instruct, `Q4_K_M` | ~35 % de paramètres en moins (plus rapide/plus léger), FR officiellement supporté, mais structuration JSON moins fiable à 1B — utile en comparaison "latence d'abord". | +| **`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). | + +> **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). ```bash LLM_TECH_STEP_MODEL=llama-3.2-1b pnpm bench @@ -57,7 +65,12 @@ résolution/téléchargement Hugging Face. pnpm bench ``` -Charge le modèle, puis lance 3 répétitions sur chacune des 7 phrases de test +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 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 8fd062f..ab9f37c 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 @@ -221,14 +221,19 @@ interface RecommendedModel { * d'entraînement nettement plus multilingue que la famille Llama à * taille comparable, et meilleur suivi d'instructions de structuration * (extraction JSON, function calling) dans les benchmarks publiés par - * Qwen — le compromis précision/latence le plus favorable ici vu que le - * critère "robustesse FR/EN" est un objectif explicite du PoC. + * Qwen. Contrairement à l'hypothèse initiale ("plus de paramètres, donc + * plus lent"), les runs de ce benchmark (Windows, backend Vulkan) le + * montrent aussi systématiquement PLUS RAPIDE que Llama-3.2-1B sur les 7 + * phrases de test, malgré ses ~50 % de paramètres en plus — le premier + * choix sur les deux axes ici, pas seulement sur la robustesse FR/EN. * - **Llama-3.2-1B-Instruct** (alternative) : ~35 % de paramètres en - * moins, donc plus rapide et plus léger en RAM ; le FR fait partie de ses - * langues officiellement supportées, mais avec un suivi d'instructions de - * structuration plus fragile à cette taille dans la pratique — utile - * comme point de comparaison "latence d'abord" plutôt que comme premier - * choix. + * moins, et le FR fait partie de ses langues officiellement supportées, + * mais avec un suivi d'instructions de structuration plus fragile à + * cette taille dans la pratique — et, empiriquement (voir ci-dessus), pas + * plus rapide non plus sur ce benchmark. Gardé comme point de comparaison + * plutôt que retiré : la latence relative entre les deux dépend du + * backend d'inférence (CUDA/Vulkan/CPU pur) et du matériel, un résultat + * obtenu sur une seule machine ne généralise pas forcément. * * Les deux sont quantisés en `Q4_K_M` — le compromis taille/qualité standard * pour de l'inférence CPU (~4.5 bits/poids, largement suffisant pour une @@ -239,12 +244,12 @@ const RECOMMENDED_MODELS: Record = { "qwen2.5-1.5b": { hfUri: "hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M", rationale: - "Meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON à taille comparable.", + "Meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON — et, empiriquement sur ce benchmark, aussi la latence la plus basse malgré la taille plus grande.", }, "llama-3.2-1b": { hfUri: "hf:bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M", rationale: - "Plus petit/plus rapide ; FR officiellement supporté mais structuration JSON moins fiable à 1B.", + "Plus petit, structuration JSON moins fiable à 1B, et pas plus rapide qu'un Qwen 1.5B sur ce benchmark — conservé comme point de comparaison, pas comme choix latence.", }, }; @@ -308,15 +313,51 @@ export class LocalLlmStepAnalyzer { * crée son contexte d'inférence et compile la grammaire JSON — la partie * coûteuse (souvent plusieurs secondes, dominée par le chargement des * poids depuis disque), à faire une seule fois avant tout `analyzeStep`. + * + * Journalise chaque sous-étape (`console.info`, autorisé par + * `biome.json` — voir `suspicious.noConsole`) : cette méthode reste muette + * pendant plusieurs secondes à secondes-longues sans ça (résolution/ + * téléchargement du modèle, chargement des poids, création du contexte, + * compilation de la grammaire), et rien ne dit à l'utilisateur laquelle + * de ces sous-étapes est en cours. */ public async initialize(modelKey: RecommendedModelKey): Promise { + console.info(`[poc] résolution du modèle "${modelKey}"...`); const modelPath = await resolveModelPath(modelKey); + console.info(`[poc] modèle : ${modelPath}`); + + console.info("[poc] initialisation de node-llama-cpp..."); this._llama = await getLlama(); + + console.info("[poc] chargement des poids en mémoire..."); this._model = await this._llama.loadModel({ modelPath }); + + console.info("[poc] création du contexte d'inférence..."); this._context = await this._model.createContext({ contextSize: 4096 }); + + console.info("[poc] compilation de la grammaire JSON..."); this._grammar = await this._llama.createGrammarForJsonSchema(KITCHEN_ACTIONS_JSON_SCHEMA); } + /** + * Force un premier appel d'inférence factice, séparément de + * `initialize()` et avant tout appel mesuré par le benchmark — même rôle + * que `TechStepClassifierService.warmUp()` côté `node-nlp` + * (`tech-step-matcher.ts`) : le tout premier `session.prompt()` sur un + * contexte fraîchement créé paie un coût caché que `initialize()` ne + * couvre pas (spin-up du pool de threads llama.cpp, allocation du cache + * KV, initialisation paresseuse du tokenizer) — mesuré ici entre 15 et + * 20+ secondes selon le modèle/matériel, contre quelques secondes pour + * les appels suivants sur la même phrase. Sans cet appel, c'est la + * première phrase du benchmark qui absorbe ce coût, faussant sa latence + * moyenne/max sans rapport avec le coût réel d'une inférence en régime + * établi (voir l'écart min/max observé sur `fr-multi-action` avant ce + * correctif : ~5s en moyenne, jusqu'à ~28s sur un run). + */ + public async warmUp(): Promise { + await this.analyzeStep("Faites chauffer une poêle."); + } + /** * Analyse une étape de recette et renvoie sa séquence ordonnée d'actions. * @@ -480,11 +521,25 @@ interface BenchmarkSample { * 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[] = []; - for (const sentence of TEST_SENTENCES) { + 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 { @@ -492,8 +547,11 @@ async function runBenchmark(analyzer: LocalLlmStepAnalyzer): Promise ${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); + console.error(`[poc] -> échec sur "${sentence.id}" (répétition ${repetition})`, err); } } } @@ -598,6 +656,18 @@ async function main(): Promise { `[poc] modèle chargé en ${loadDurationMs.toFixed(0)} ms (+${modelRssMb.toFixed(1)} Mo RSS)`, ); + // Absorbe ici le coût caché du tout premier appel d'inférence (voir + // LocalLlmStepAnalyzer.warmUp) plutôt que de laisser la première phrase + // du benchmark le payer — sans ça, sa latence n'est pas comparable aux + // six autres. + const warmUpStartedAt = performance.now(); + try { + await analyzer.warmUp(); + } catch (err) { + console.error("[poc] échec du warm-up — le benchmark continue quand même", err); + } + console.info(`[poc] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`); + try { const benchmarkStartedAt = performance.now(); const samples = await runBenchmark(analyzer); From 0582822a7891044ef070cb5ca8695d6c2685f5c7 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 21 Aug 2026 19:47:15 +0200 Subject: [PATCH 05/10] feat(api): ajoute le pendant node-nlp du benchmark tech-step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouveau script apps/api/src/scripts/bench-tech-step-classifier.ts, calqué sur experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts : mêmes 7 phrases de TEST_SENTENCES (recopiées à l'identique), même structure de sortie (logs itératifs par répétition, tableau récapitulatif latence/RSS/nombre de détections), pour que les deux pipelines soient directement comparables phrase par phrase. Réutilise techStepClassifier.warmUp() (déjà prévu pour absorber le coût de l'entraînement + l'init paresseuse de node-nlp) et résout les techStepId en key lisible pour l'affichage détaillé. Nécessite une base Postgres avec TechStep seedée (matchTechStepSpans résout ses uid vers de vrais ids). README du PoC LLM mis à jour : la section "Méthodologie de comparaison" pointe vers ce script réel plutôt que le snippet REPL manuel qu'elle suggérait avant. Co-Authored-By: Claude Sonnet 5 --- .../src/scripts/bench-tech-step-classifier.ts | 250 ++++++++++++++++++ experiments/llm-tech-step-poc/README.md | 30 +-- 2 files changed, 265 insertions(+), 15 deletions(-) create mode 100644 apps/api/src/scripts/bench-tech-step-classifier.ts diff --git a/apps/api/src/scripts/bench-tech-step-classifier.ts b/apps/api/src/scripts/bench-tech-step-classifier.ts new file mode 100644 index 0000000..c138914 --- /dev/null +++ b/apps/api/src/scripts/bench-tech-step-classifier.ts @@ -0,0 +1,250 @@ +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 38a3d35..66e11dc 100644 --- a/experiments/llm-tech-step-poc/README.md +++ b/experiments/llm-tech-step-poc/README.md @@ -88,17 +88,14 @@ delta RSS moyen, nombre d'actions détectées). ## Méthodologie de comparaison avec le pipeline `node-nlp` Ce script reste volontairement autonome (aucune dépendance vers `apps/api`, -donc pas de connexion Postgres requise pour le faire tourner). Pour comparer -manuellement sur les mêmes phrases : +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) : -```ts -// Dans apps/api, un script ponctuel (ou un REPL tsx) : -import { techStepClassifier } from "./src/lib/recipe-matching/tech-step-matcher.js"; - -console.log(await techStepClassifier.matchTechStepSpans( - "É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.", - "fr", -)); +```bash +pnpm --filter api exec tsx src/scripts/bench-tech-step-classifier.ts ``` (nécessite une base Postgres accessible et `TechStep` seedée — voir @@ -106,11 +103,14 @@ console.log(await techStepClassifier.matchTechStepSpans( vers de vrais `TechStep.id`). Les deux sorties ne sont pas directement isomorphes (`TechStepMatch` renvoie -un `techStepId` + des spans de caractères contre un `KitchenAction` -structuré avec ingrédients/durée/température/ustensiles) — 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. +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`). ## Limites de ce PoC From c73c62328d8555420c90cd208ce13e9b00e86d3f Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 21 Aug 2026 21:13:20 +0200 Subject: [PATCH 06/10] refactor(experiments): retire le script apps/api, ajoute NLP frais + pipeline hybride MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étape 1 — retire apps/api/src/scripts/bench-tech-step-classifier.ts (DB-backed, taxonomie ~26 techniques non comparable terme à terme au LLM). Étape 2 — reconstruit tout dans experiments/llm-tech-step-poc, entièrement autonome (aucune dépendance Postgres/apps/api) : - shared/kitchen-action.ts, shared/test-sentences.ts, shared/benchmark-harness.ts : types, 7 phrases de test et harness de mesure/affichage désormais partagés par les trois scripts (plus de recopie manuelle entre fichiers). - nlp-tech-step-poc.ts : classifieur node-nlp FRAIS (NER + clauses + classification), entraîné directement sur la taxonomie à 7 catégories du LLM plutôt que réutiliser TechStepClassifierService — comparaison terme à terme, et surtout un score de confiance BRUT jamais masqué (contrairement au repli silencieux sur l'ancre NER de la version production), condition nécessaire au pipeline hybride. Corpus qui préfère les synonymes mono-mot ("revenir") aux phrases figées, pour ne pas se faire piéger par les pronoms clitiques français ("faites-les-revenir"). - hybrid-tech-step-poc.ts : NLP toujours en premier (chemin rapide), LLM en secours si la confiance NLP passe sous NLP_TRUST_THRESHOLD (0.6, tunable) ou qu'aucune action n'est trouvée — récapitulatif avec colonnes "moteur" et "confiance NLP" pour observer les bascules. - llm-tech-step-poc.ts : inchangé fonctionnellement, migré vers les modules partagés. - shared/module-entry.ts (isMainModule) : garde chaque script pour que l'import de ses classes (par hybrid-tech-step-poc.ts) ne déclenche pas aussi son propre benchmark comme effet de bord. pnpm bench / bench:nlp / bench:hybrid. README réécrit en conséquence. Co-Authored-By: Claude Sonnet 5 --- .../src/scripts/bench-tech-step-classifier.ts | 250 ------ experiments/llm-tech-step-poc/README.md | 164 ++-- experiments/llm-tech-step-poc/package.json | 7 +- experiments/llm-tech-step-poc/pnpm-lock.yaml | 761 ++++++++++++++++++ .../src/hybrid-tech-step-poc.ts | 243 ++++++ .../src/llm-tech-step-poc.ts | 372 ++------- .../src/nlp-tech-step-poc.ts | 671 +++++++++++++++ .../src/shared/benchmark-harness.ts | 138 ++++ .../src/shared/kitchen-action.ts | 65 ++ .../src/shared/module-entry.ts | 16 + .../src/shared/test-sentences.ts | 99 +++ .../llm-tech-step-poc/src/types/node-nlp.d.ts | 52 ++ 12 files changed, 2209 insertions(+), 629 deletions(-) delete mode 100644 apps/api/src/scripts/bench-tech-step-classifier.ts create mode 100644 experiments/llm-tech-step-poc/src/hybrid-tech-step-poc.ts create mode 100644 experiments/llm-tech-step-poc/src/nlp-tech-step-poc.ts create mode 100644 experiments/llm-tech-step-poc/src/shared/benchmark-harness.ts create mode 100644 experiments/llm-tech-step-poc/src/shared/kitchen-action.ts create mode 100644 experiments/llm-tech-step-poc/src/shared/module-entry.ts create mode 100644 experiments/llm-tech-step-poc/src/shared/test-sentences.ts create mode 100644 experiments/llm-tech-step-poc/src/types/node-nlp.d.ts 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; + } +} From 9f274f9461dc6133943679e629dcee50d1910e87 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 21 Aug 2026 22:15:45 +0200 Subject: [PATCH 07/10] =?UTF-8?q?feat(experiments):=20ajoute=20une=20entr?= =?UTF-8?q?=C3=A9e=20de=20volum=C3=A9trie=20par=20locale=20(FR/EN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEST_SENTENCES gagne 2 entrées dérivées (fr-concat-volumetrie, en-concat-volumetrie) qui concatènent toutes les phrases de base d'une même locale en un seul step géant, calculées depuis les phrases existantes (jamais recopiées à la main) — pour isoler l'effet du seul volume de texte sur la durée de traitement de chaque moteur, indépendamment de la complexité déjà couverte par les 7 phrases existantes. Vérifié via bench:nlp : la latence croît nettement avec le volume (fr-concat ~2200ms vs 300-1100ms pour les phrases individuelles FR). Co-Authored-By: Claude Sonnet 5 --- .../src/shared/test-sentences.ts | 70 ++++++++++++++++--- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/experiments/llm-tech-step-poc/src/shared/test-sentences.ts b/experiments/llm-tech-step-poc/src/shared/test-sentences.ts index dfa2cfa..62f0292 100644 --- a/experiments/llm-tech-step-poc/src/shared/test-sentences.ts +++ b/experiments/llm-tech-step-poc/src/shared/test-sentences.ts @@ -1,10 +1,12 @@ /** - * 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). + * Les 9 phrases de test partagées par les trois moteurs de ce PoC (7 phrases + * de complexité variable + 2 concaténations pour tester l'effet du volume, + * voir {@link TEST_SENTENCES}) — 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). */ @@ -22,7 +24,14 @@ export interface BenchmarkSentence { * 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 : + * gère le cas courant. Deux entrées supplémentaires ({@link TEST_SENTENCES}, + * items 8 et 9) concatènent ensuite toutes les phrases d'une même locale en + * un seul "step" géant, pour isoler l'effet du seul VOLUME de texte sur la + * durée de traitement — les 7 phrases ci-dessous font varier la + * *complexité* à taille à peu près constante, elles ne disent rien sur + * comment chaque moteur se comporte face à un step simplement plus long + * (plus de tokens à faire générer/parcourir au LLM, plus de clauses à + * découper et classifier pour le NLP) : * * 1. FR, plusieurs actions explicites enchaînées avec une durée et un * ingrédient qui change de forme grammaticale ("les" reprend "oignons"). @@ -52,8 +61,13 @@ export interface BenchmarkSentence { * 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. + * 8. FR, la concaténation des 4 phrases FR ci-dessus (1, 3, 4, 6) en un + * seul step — même contenu, ~4x le volume de texte d'une phrase FR + * normale de ce jeu. + * 9. EN, la concaténation des 3 phrases EN ci-dessus (2, 5, 7) en un seul + * step — même principe côté EN. */ -export const TEST_SENTENCES: readonly BenchmarkSentence[] = [ +const BASE_SENTENCES: readonly BenchmarkSentence[] = [ { id: "fr-multi-action", locale: "fr", @@ -97,3 +111,43 @@ export const TEST_SENTENCES: readonly BenchmarkSentence[] = [ 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.", }, ]; + +/** + * Concatène les textes de {@link BASE_SENTENCES} d'une locale donnée, + * séparés par un espace, dans leur ordre d'apparition — calculé plutôt que + * recopié à la main pour ne jamais désynchroniser le step géant du contenu + * réel des 7 phrases de base (si l'une d'elles change de texte, la + * concaténation suit automatiquement). + */ +function concatenateByLocale(locale: BenchmarkSentence["locale"]): string { + return BASE_SENTENCES.filter((sentence) => sentence.locale === locale) + .map((sentence) => sentence.text) + .join(" "); +} + +const FR_SENTENCE_COUNT = BASE_SENTENCES.filter((sentence) => sentence.locale === "fr").length; +const EN_SENTENCE_COUNT = BASE_SENTENCES.filter((sentence) => sentence.locale === "en").length; + +/** + * {@link BASE_SENTENCES} (7 phrases, complexité variable à taille à peu + * près constante) plus deux entrées dérivées par locale (items 8 et 9 du + * doc-comment ci-dessus) qui concatènent toutes les phrases de cette locale + * en un seul step — pour isoler l'effet du VOLUME de texte sur la durée de + * traitement de chaque moteur, indépendamment de la complexité sémantique + * déjà couverte par les 7 premières. + */ +export const TEST_SENTENCES: readonly BenchmarkSentence[] = [ + ...BASE_SENTENCES, + { + id: "fr-concat-volumetrie", + locale: "fr", + text: concatenateByLocale("fr"), + note: `Concaténation des ${FR_SENTENCE_COUNT} étapes FR ci-dessus en un seul step — teste si la durée de traitement croît avec le volume de texte, indépendamment de sa complexité.`, + }, + { + id: "en-concat-volumetrie", + locale: "en", + text: concatenateByLocale("en"), + note: `Concaténation des ${EN_SENTENCE_COUNT} étapes EN ci-dessus en un seul step — même test de volumétrie côté EN.`, + }, +]; From b8e599106fd3dbf120a5a58637bd85dba41543fa Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 21 Aug 2026 22:48:39 +0200 Subject: [PATCH 08/10] =?UTF-8?q?feat(experiments):=20ajoute=20une=20entr?= =?UTF-8?q?=C3=A9e=20de=20volum=C3=A9trie=20par=20locale=20bas=C3=A9e=20su?= =?UTF-8?q?r=20TEST=5FRECIPE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEST_SENTENCES gagne 2 entrées supplémentaires (fr-recette-complete, en-recette-complete) qui concatènent les 7 étapes de TEST_RECIPE ("Tarte aux pommes rustique") en un seul step par locale — même principe que fr-concat-volumetrie/en-concat-volumetrie, mais sur du texte de recette réel plutôt qu'une concaténation de phrases-pièges synthétiques. TEST_RECIPE (déjà ajoutée localement) déplacée avant TEST_SENTENCES (nécessaire pour être référencée dans sa construction) et reformatée à la convention du fichier (clés non citées, virgules finales) ; contenu inchangé. Retire le stub dummyRecipeFr/dummyRecipeEn de benchmark-harness.ts : le harness générique n'a besoin d'aucun cas particulier, TEST_SENTENCES suffit. Co-Authored-By: Claude Sonnet 5 --- .../src/shared/test-sentences.ts | 162 ++++++++++++++++-- 1 file changed, 149 insertions(+), 13 deletions(-) diff --git a/experiments/llm-tech-step-poc/src/shared/test-sentences.ts b/experiments/llm-tech-step-poc/src/shared/test-sentences.ts index 62f0292..9f52cce 100644 --- a/experiments/llm-tech-step-poc/src/shared/test-sentences.ts +++ b/experiments/llm-tech-step-poc/src/shared/test-sentences.ts @@ -1,12 +1,12 @@ /** - * Les 9 phrases de test partagées par les trois moteurs de ce PoC (7 phrases - * de complexité variable + 2 concaténations pour tester l'effet du volume, - * voir {@link TEST_SENTENCES}) — 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). + * Les 11 phrases de test partagées par les trois moteurs de ce PoC (7 + * phrases de complexité variable + 2 concaténations synthétiques + 2 + * concaténations d'une vraie recette, voir {@link TEST_SENTENCES}) — 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). */ @@ -66,6 +66,13 @@ export interface BenchmarkSentence { * normale de ce jeu. * 9. EN, la concaténation des 3 phrases EN ci-dessus (2, 5, 7) en un seul * step — même principe côté EN. + * 10. FR, les 7 étapes de {@link TEST_RECIPE} ("Tarte aux pommes rustique") + * concaténées en un seul step — même principe volumétrique que les + * items 8/9, mais sur du texte de recette RÉEL (rédigé normalement, + * sans les tournures adversariales des phrases 1-7) plutôt qu'une + * concaténation de phrases-pièges synthétiques. + * 11. EN, les 7 étapes de {@link TEST_RECIPE} ("Rustic Apple Tart") + * concaténées en un seul step — même principe que l'item 10, côté EN. */ const BASE_SENTENCES: readonly BenchmarkSentence[] = [ { @@ -112,6 +119,111 @@ const BASE_SENTENCES: readonly BenchmarkSentence[] = [ }, ]; +/** + * Une vraie recette (titre, ingrédients, 7 étapes rédigées normalement en + * FR/EN) — matière première d'une volumétrie plus RÉALISTE que la + * concaténation de phrases-pièges synthétiques ci-dessus (items 8/9) : du + * texte de recette tel qu'un utilisateur l'écrirait vraiment, sans + * tournures adversariales délibérées. Seuls `steps[].text.fr`/`.en` sont + * utilisés par ce fichier ({@link concatenateRecipeByLocale}) — le reste + * (`ingredients`, `servings`, temps de préparation...) est conservé tel + * quel pour une éventuelle recette de test plus complète plus tard, pas + * exploité aujourd'hui. + */ +export const TEST_RECIPE = { + recipe_id: "apple_tart_001", + title: { + fr: "Tarte aux pommes rustique", + en: "Rustic Apple Tart", + }, + servings: 6, + prep_time_minutes: 20, + cook_time_minutes: 35, + ingredients: [ + { + id: "ing_1", + name: { fr: "pâte brisée", en: "shortcrust pastry" }, + quantity: 1, + unit: "piece", + }, + { + id: "ing_2", + name: { fr: "pommes Golden", en: "Golden Delicious apples" }, + quantity: 4, + unit: "pieces", + }, + { + id: "ing_3", + name: { fr: "beurre", en: "butter" }, + quantity: 30, + unit: "g", + }, + { + id: "ing_4", + name: { fr: "sucre vanillé", en: "vanilla sugar" }, + quantity: 1, + unit: "packet", + }, + { + id: "ing_5", + name: { fr: "compote de pommes", en: "applesauce" }, + quantity: 150, + unit: "g", + }, + ], + steps: [ + { + step_number: 1, + text: { + fr: "Préchauffez votre four à 180°C pendant 10 minutes.", + en: "Preheat your oven to 180°C (350°F) for 10 minutes.", + }, + }, + { + step_number: 2, + text: { + fr: "Épluchez et évidez les pommes, puis coupez-les en fines lamelles régulières sur votre planche à découper.", + en: "Peel and core the apples, then slice them into thin, even slices on your cutting board.", + }, + }, + { + step_number: 3, + text: { + fr: "Déroulez la pâte brisée dans un moule à tarte et piquez le fond avec une fourchette.", + en: "Unroll the shortcrust pastry into a tart pan and prick the bottom with a fork.", + }, + }, + { + step_number: 4, + text: { + fr: "Étalez la compote de pommes de manière égale sur le fond de pâte avec une spatule.", + en: "Spread the applesauce evenly over the pastry base using a spatula.", + }, + }, + { + step_number: 5, + text: { + fr: "Disposez les lamelles de pommes en rosette par-dessus la compote, puis parsemez de noisettes de beurre et de sucre vanillé.", + en: "Arrange the apple slices in a rosette pattern on top of the sauce, then dot with small knobs of butter and sprinkle with vanilla sugar.", + }, + }, + { + step_number: 6, + text: { + fr: "Enfournez à 180°C et laissez cuire pendant 35 minutes jusqu'à ce que les bordures soient bien dorées.", + en: "Bake at 180°C (350°F) for 35 minutes until the edges are golden brown.", + }, + }, + { + step_number: 7, + text: { + fr: "Sortez la tarte du four et laissez-la reposer au frais pendant 15 minutes avant de démouler et de servir.", + en: "Remove the tart from the oven and let it rest at room temperature for 15 minutes before unmolding and serving.", + }, + }, + ], +}; + /** * Concatène les textes de {@link BASE_SENTENCES} d'une locale donnée, * séparés par un espace, dans leur ordre d'apparition — calculé plutôt que @@ -125,16 +237,28 @@ function concatenateByLocale(locale: BenchmarkSentence["locale"]): string { .join(" "); } +/** + * Concatène les 7 étapes de {@link TEST_RECIPE} pour une locale donnée, + * dans leur ordre (`step_number`), séparées par un espace — même principe + * que {@link concatenateByLocale} mais sur la recette réelle plutôt que sur + * les phrases de test synthétiques. + */ +function concatenateRecipeByLocale(locale: BenchmarkSentence["locale"]): string { + return TEST_RECIPE.steps.map((step) => step.text[locale]).join(" "); +} + const FR_SENTENCE_COUNT = BASE_SENTENCES.filter((sentence) => sentence.locale === "fr").length; const EN_SENTENCE_COUNT = BASE_SENTENCES.filter((sentence) => sentence.locale === "en").length; /** * {@link BASE_SENTENCES} (7 phrases, complexité variable à taille à peu - * près constante) plus deux entrées dérivées par locale (items 8 et 9 du - * doc-comment ci-dessus) qui concatènent toutes les phrases de cette locale - * en un seul step — pour isoler l'effet du VOLUME de texte sur la durée de - * traitement de chaque moteur, indépendamment de la complexité sémantique - * déjà couverte par les 7 premières. + * près constante) plus quatre entrées dérivées par locale (items 8-11 du + * doc-comment ci-dessus) qui concatènent, respectivement, toutes les + * phrases de test d'une locale et toutes les étapes de {@link TEST_RECIPE} + * de cette même locale en un seul step chacune — pour isoler l'effet du + * VOLUME de texte sur la durée de traitement de chaque moteur, + * indépendamment de la complexité sémantique déjà couverte par les 7 + * premières, sur du texte synthétique ET sur du texte de recette réel. */ export const TEST_SENTENCES: readonly BenchmarkSentence[] = [ ...BASE_SENTENCES, @@ -150,4 +274,16 @@ export const TEST_SENTENCES: readonly BenchmarkSentence[] = [ text: concatenateByLocale("en"), note: `Concaténation des ${EN_SENTENCE_COUNT} étapes EN ci-dessus en un seul step — même test de volumétrie côté EN.`, }, + { + id: "fr-recette-complete", + locale: "fr", + text: concatenateRecipeByLocale("fr"), + note: `Les ${TEST_RECIPE.steps.length} étapes de "${TEST_RECIPE.title.fr}" concaténées en un seul step — même test de volumétrie que ci-dessus, mais sur du texte de recette réel plutôt qu'une concaténation de phrases-pièges synthétiques.`, + }, + { + id: "en-recette-complete", + locale: "en", + text: concatenateRecipeByLocale("en"), + note: `Les ${TEST_RECIPE.steps.length} étapes de "${TEST_RECIPE.title.en}" concaténées en un seul step — même principe côté EN.`, + }, ]; From f5f30923d012a73f72e40573aaf825dc666ca54d Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 21 Aug 2026 23:14:03 +0200 Subject: [PATCH 09/10] =?UTF-8?q?feat(experiments):=20ajoute=20un=204e=20m?= =?UTF-8?q?oteur=20=E2=80=94=20m=C3=AAme=20LLM=20via=20Ollama?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouveau src/ollama-tech-step-poc.ts : même tâche/SYSTEM_PROMPT (exporté depuis llm-tech-step-poc.ts et réutilisé tel quel) que le moteur node-llama-cpp, mais via Ollama — une implémentation architecturalement différente plutôt qu'une redite : - Ollama tourne comme serveur HTTP local séparé (ollama serve), pas comme binding natif dans ce process — le paquet npm ollama n'a aucune dépendance native (rien à compiler à l'install, contrairement à node-llama-cpp). - Modèle géré par Ollama lui-même (ollama.pull(), cache dans ~/.ollama/models), pas par ce projet — progression de pull journalisée palier par palier plutôt que silencieuse. - Schéma JSON imposé via `format` (JSON Schema standard, `type: ["string","null"]` pour un champ nullable) — plus simple que le détour `oneOf` qu'exige la grammaire GBNF de node-llama-cpp. - initialize() échoue avec un message explicite si le serveur Ollama n'est pas joignable, plutôt que l'erreur fetch brute. - Caveat documenté en tête de fichier et rappelé avant le récapitulatif : la colonne RSS du harness ne mesure rien d'utile ici, l'inférence tourne dans le process ollama serve, pas dans ce script. OllamaStepAnalyzer.dispose() décharge le modèle du serveur (keep_alive: 0, best effort). Env vars OLLAMA_TECH_STEP_MODEL/OLLAMA_TECH_STEP_HOST, scripts pnpm bench:ollama. Vérifié en conditions réelles (Ollama tournait déjà dans l'environnement) : pull + inférence structurée + parsing JSON fonctionnels, latence nettement inférieure à node-llama-cpp sur les mêmes phrases (748-1260 ms vs 3-13 s), delta RSS confirmé proche de zéro/bruit comme attendu. README mis à jour (4 moteurs, section Ollama avec tableau comparatif architectural, limites). Co-Authored-By: Claude Sonnet 5 --- experiments/llm-tech-step-poc/README.md | 89 ++++- experiments/llm-tech-step-poc/package.json | 6 +- experiments/llm-tech-step-poc/pnpm-lock.yaml | 15 + .../src/llm-tech-step-poc.ts | 8 +- .../src/ollama-tech-step-poc.ts | 328 ++++++++++++++++++ 5 files changed, 424 insertions(+), 22 deletions(-) create mode 100644 experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts diff --git a/experiments/llm-tech-step-poc/README.md b/experiments/llm-tech-step-poc/README.md index 0d629a1..b161805 100644 --- a/experiments/llm-tech-step-poc/README.md +++ b/experiments/llm-tech-step-poc/README.md @@ -6,24 +6,28 @@ 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 culinaires) et le même jeu de 7 phrases, trois +séquence ordonnée d'actions culinaires) et le même jeu de 11 phrases, quatre 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` | Mini LLM instruct via [`node-llama-cpp`](https://node-llama-cpp.withcat.ai/) (binding natif **dans ce process**), 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:ollama` | Le même LLM (même `SYSTEM_PROMPT`, même tâche), mais via [Ollama](https://ollama.com/) — un serveur HTTP **local séparé** plutôt qu'un binding embarqué | Compare l'impact de l'architecture (client-serveur vs in-process) à sémantique identique, pas juste un autre modèle. | | `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. | +| `pnpm bench:hybrid` | NLP d'abord, LLM (`node-llama-cpp`) 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 +Les quatre partagent le même code (`src/shared/`) : la taxonomie `KitchenActionType`/`KitchenAction`/`RecipeStepAnalysis` -(`shared/kitchen-action.ts`), les 7 phrases de test +(`shared/kitchen-action.ts`), les 11 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 +de sortie pour que les runs soient directement comparables, plutôt que +recopiés à la main dans chaque script (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). +phrases manuellement). Les deux moteurs LLM (`node-llama-cpp`/Ollama) +partagent en plus le `SYSTEM_PROMPT` lui-même (exporté par +`llm-tech-step-poc.ts`, importé par `ollama-tech-step-poc.ts`) — même +sémantique testée sur les deux, seul le mécanisme de contrainte JSON change. ## Installation @@ -42,7 +46,8 @@ ce PoC. l'installation (binaire prébuilt pour les plateformes courantes, sinon compilation locale — nécessite alors un toolchain C++, voir sa doc ["Troubleshooting"](https://node-llama-cpp.withcat.ai/guide/troubleshooting) -en cas d'échec). +en cas d'échec). `ollama` (le paquet npm) n'a lui aucune dépendance +native — c'est un simple client HTTP, rien à compiler. ## 1. Benchmark LLM seul — `pnpm bench` @@ -77,7 +82,46 @@ 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. -## 2. Benchmark NLP seul — `pnpm bench:nlp` +## 2. Benchmark LLM via Ollama — `pnpm bench:ollama` + +Prérequis : [Ollama](https://ollama.com/download) installé, **et son serveur +lancé** (`ollama serve` dans un terminal séparé, ou l'app de bureau Ollama +qui le lance automatiquement) — ce script ne démarre pas le serveur +lui-même, contrairement à `pnpm bench` qui charge son modèle directement. + +```bash +ollama serve # si pas déjà lancé (ou l'app de bureau Ollama) +pnpm bench:ollama +``` + +Même tâche, même `SYSTEM_PROMPT`, mêmes modèles (`OLLAMA_TECH_STEP_MODEL`, +mêmes valeurs `qwen2.5-1.5b`/`llama-3.2-1b` que `LLM_TECH_STEP_MODEL`) que +la section 1 — mais une **implémentation architecturalement différente**, +testée pour ça plutôt que comme une simple redite : + +| | `node-llama-cpp` (section 1) | Ollama (cette section) | +|---|---|---| +| Où tourne l'inférence | Dans CE process Node (binding natif) | Dans le process `ollama serve`, séparé | +| Installation | Binaire natif compilé/téléchargé au `pnpm install` | Client HTTP pur, rien à compiler | +| Gestion du modèle | `resolveModelFile` télécharge le GGUF dans `models/` de ce projet | `ollama pull` — Ollama gère son propre cache (`~/.ollama/models`) | +| Schéma JSON nullable | `oneOf: [{type:"null"}, {type:"..."}]` (contrainte de la grammaire GBNF) | `type: ["string", "null"]` (JSON Schema standard, plus simple) | +| Mesure RSS du benchmark | Fiable — le binding alloue dans ce process | **Sans intérêt** — l'inférence tourne ailleurs, voir ci-dessous | + +**La colonne `RSS moy.` du récapitulatif ne veut RIEN dire pour ce script** — +`process.memoryUsage()` mesure ce process Node, pas le process `ollama +serve` où l'inférence a réellement lieu. Le script l'affiche quand même +(même harness que les trois autres) mais rappelle ce point juste avant le +tableau. + +```bash +OLLAMA_TECH_STEP_MODEL=llama-3.2-1b pnpm bench:ollama +``` + +**Hôte Ollama personnalisé** (serveur distant, port non standard) : +`OLLAMA_TECH_STEP_HOST=http://mon-serveur:11434 pnpm bench:ollama` +(défaut : `http://127.0.0.1:11434`). + +## 3. Benchmark NLP seul — `pnpm bench:nlp` ```bash pnpm bench:nlp @@ -111,7 +155,7 @@ 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` +## 4. Pipeline hybride — `pnpm bench:hybrid` ```bash pnpm bench:hybrid @@ -141,9 +185,10 @@ 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 (moteur LLM) ne garantit qu'une syntaxe JSON conforme au - schéma, jamais la justesse sémantique du contenu. + — les 11 phrases sont inspectées à l'œil, pas notées. +- La contrainte JSON (grammaire GBNF ou JSON Schema Ollama) 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 @@ -152,8 +197,14 @@ chemin fournit réellement. 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 - (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. + pour `node-llama-cpp`/`node-nlp` (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, et carrément **sans valeur** pour `bench:ollama` + (l'inférence tourne dans `ollama serve`, un process séparé, voir la + section 2). +- Latence `node-llama-cpp` 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. Ollama, lui, + détecte et utilise l'accélération matérielle disponible automatiquement — + une différence qui peut à elle seule expliquer un écart de latence entre + les deux moteurs LLM, indépendamment du modèle choisi. diff --git a/experiments/llm-tech-step-poc/package.json b/experiments/llm-tech-step-poc/package.json index efac5e9..fb9d6b6 100644 --- a/experiments/llm-tech-step-poc/package.json +++ b/experiments/llm-tech-step-poc/package.json @@ -3,16 +3,18 @@ "version": "0.0.0", "private": true, "type": "module", - "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.", + "description": "PoC autonome : quatre moteurs de détection d'actions culinaires dans une étape de recette (LLM local via node-llama-cpp, le même LLM via Ollama, 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:ollama": "tsx src/ollama-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-nlp": "4.27.0" + "node-nlp": "4.27.0", + "ollama": "^0.6.3" }, "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 d34a9ef..c804701 100644 --- a/experiments/llm-tech-step-poc/pnpm-lock.yaml +++ b/experiments/llm-tech-step-poc/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: node-nlp: specifier: 4.27.0 version: 4.27.0 + ollama: + specifier: ^0.6.3 + version: 0.6.3 devDependencies: '@types/node': specifier: ^22.9.0 @@ -870,6 +873,9 @@ packages: 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} + ollama@0.6.3: + resolution: {integrity: sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==, tarball: https://registry.npmjs.org/ollama/-/ollama-0.6.3.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'} @@ -1031,6 +1037,9 @@ packages: resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==, tarball: https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz} engines: {node: ^20.17.0 || >=22.9.0} + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==, tarball: https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, tarball: https://registry.npmjs.org/which/-/which-2.0.2.tgz} engines: {node: '>= 8'} @@ -1952,6 +1961,10 @@ snapshots: transitivePeerDependencies: - supports-color + ollama@0.6.3: + dependencies: + whatwg-fetch: 3.6.20 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -2110,6 +2123,8 @@ snapshots: validate-npm-package-name@7.0.2: {} + whatwg-fetch@3.6.20: {} + which@2.0.2: dependencies: isexe: 2.0.0 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 4225833..85c5015 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 @@ -112,8 +112,14 @@ interface KitchenActionsGrammarResult { * du benchmark est justement de voir si un seul prompt, sur un modèle * multilingue, tient la route en français ET en anglais sans bascule * explicite de langue. + * + * Exporté et réutilisé tel quel par `ollama-tech-step-poc.ts` — les deux + * moteurs LLM de ce PoC doivent tester exactement la même sémantique/tâche, + * seul le mécanisme de contrainte JSON (grammaire GBNF ici, JSON Schema + * natif côté Ollama) diffère ; dupliquer ce texte risquerait de faire + * dériver les deux prompts sans que ce soit voulu. */ -const SYSTEM_PROMPT = `You are a culinary instruction parser. You receive ONE recipe step, written in either French or English. Break it down into the ordered sequence of atomic actions it describes, and respond with ONLY the JSON object required by the schema — no prose, no markdown code fences, no explanation. +export const SYSTEM_PROMPT = `You are a culinary instruction parser. You receive ONE recipe step, written in either French or English. Break it down into the ordered sequence of atomic actions it describes, and respond with ONLY the JSON object required by the schema — no prose, no markdown code fences, no explanation. Action taxonomy (pick exactly one per action): - CUT: knife work — chopping, dicing, mincing, slicing, peeling. diff --git a/experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts b/experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts new file mode 100644 index 0000000..379bc75 --- /dev/null +++ b/experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts @@ -0,0 +1,328 @@ +/** + * PoC autonome — même tâche que `llm-tech-step-poc.ts` (extraction JSON + * contrainte par schéma d'une séquence d'actions culinaires), même + * `SYSTEM_PROMPT` (importé tel quel, voir sa doc), mais via + * [Ollama](https://ollama.com/) au lieu de `node-llama-cpp` — un troisième + * point de comparaison, architecturalement différent des deux autres + * moteurs LLM/NLP de ce PoC plutôt qu'une simple redite : + * + * - **`node-llama-cpp`** charge le binding natif llama.cpp DANS ce process + * Node (mêmes poids, même mémoire, même thread pool que le script). + * - **Ollama** est un serveur HTTP local **séparé** (`ollama serve`, lancé + * par l'app de bureau ou en CLI) — ce script n'est qu'un client HTTP fin + * (`ollama` sur npm, aucune dépendance native, aucun binding à compiler à + * l'installation) qui lui parle en local (`http://127.0.0.1:11434` par + * défaut). Conséquences directes, documentées où elles s'appliquent : + * - Pas de téléchargement/cache GGUF géré par ce projet — Ollama gère ses + * propres modèles (`~/.ollama/models`), récupérés via `ollama.pull()` + * (voir {@link OllamaStepAnalyzer.initialize}). + * - **Le delta de RSS de ce process ne mesure RIEN d'utile ici** : + * l'inférence tourne dans le process `ollama serve`, pas dans celui-ci + * — contrairement à `node-llama-cpp`, où le binding natif partage la + * mémoire du process Node. Cette colonne du récapitulatif reste + * affichée (même harness que les deux autres moteurs) mais est à + * ignorer pour ce script, voir `README.md`. + * - Nécessite Ollama installé et **son serveur déjà lancé** en dehors de + * ce script (pas de "just works" comme le binding embarqué) — + * {@link OllamaStepAnalyzer.initialize} échoue avec un message explicite + * si le serveur n'est pas joignable plutôt qu'une erreur `fetch` brute. + * - Le schéma JSON imposé au modèle (`format`, voir + * {@link KITCHEN_ACTIONS_JSON_SCHEMA}) accepte du JSON Schema standard + * (`type: ["string", "null"]` pour un champ nullable) — plus simple que + * le détour `oneOf: [{type:"null"}, {type:"..."}]` qu'exige la + * grammaire GBNF de node-llama-cpp (voir `llm-tech-step-poc.ts`), un + * autre point de comparaison entre les deux mécanismes de contrainte. + * + * Usage : voir `README.md`. En bref : + * + * ```bash + * ollama serve # dans un terminal séparé, si pas déjà lancé + * cd experiments/llm-tech-step-poc + * pnpm install --ignore-workspace + * pnpm bench:ollama + * ``` + */ + +import { performance } from "node:perf_hooks"; +import { Ollama } from "ollama"; +import { SYSTEM_PROMPT } from "./llm-tech-step-poc.js"; +import { + type BenchmarkSample, + printSummaryTable, + runBenchmark, +} from "./shared/benchmark-harness.js"; +import { KitchenActionType, type RecipeStepAnalysis } from "./shared/kitchen-action.js"; +import { isMainModule } from "./shared/module-entry.js"; +import type { BenchmarkSentence } from "./shared/test-sentences.js"; + +// --------------------------------------------------------------------------- +// Schéma JSON — passé tel quel à Ollama via `format` +// --------------------------------------------------------------------------- + +/** + * Schéma JSON standard (pas de dialecte GBNF-spécifique) — Ollama valide/ + * contraint la génération directement contre ce schéma via son paramètre + * `format`. Champ à champ, en miroir strict de `KitchenAction` + * (`shared/kitchen-action.ts`), même remarque que côté `node-llama-cpp` : + * ça n'impose qu'une SYNTAXE JSON valide, jamais la justesse sémantique du + * contenu — c'est {@link SYSTEM_PROMPT} qui porte la sémantique. + */ +const KITCHEN_ACTION_JSON_SCHEMA = { + type: "object", + properties: { + action: { type: "string", enum: Object.values(KitchenActionType) }, + verb: { type: "string" }, + ingredients: { type: "array", items: { type: "string" } }, + durationMinutes: { type: ["number", "null"] }, + temperature: { type: ["string", "null"] }, + utensils: { type: "array", items: { type: "string" } }, + }, + required: ["action", "verb", "ingredients", "durationMinutes", "temperature", "utensils"], +}; + +/** Racine du schéma — même choix qu'en `node-llama-cpp` (`{ actions: [...] }` plutôt qu'un tableau nu), `originalText` volontairement absent, voir `llm-tech-step-poc.ts` pour le raisonnement complet. */ +const KITCHEN_ACTIONS_JSON_SCHEMA = { + type: "object", + properties: { + actions: { type: "array", items: KITCHEN_ACTION_JSON_SCHEMA }, + }, + required: ["actions"], +}; + +/** Forme attendue du JSON renvoyé par Ollama (`response.message.content`, une chaîne à parser) une fois conforme à {@link KITCHEN_ACTIONS_JSON_SCHEMA}. */ +interface KitchenActionsSchemaResult { + actions: RecipeStepAnalysis["actions"]; +} + +// --------------------------------------------------------------------------- +// Modèles recommandés +// --------------------------------------------------------------------------- + +/** Mêmes deux familles de modèles que `llm-tech-step-poc.ts` (voir son comparatif) — pour rester comparable, référencées ici par leur tag Ollama plutôt qu'une URI `hf:`. */ +export type RecommendedOllamaModelKey = "qwen2.5-1.5b" | "llama-3.2-1b"; + +interface RecommendedOllamaModel { + /** Tag tel qu'Ollama le résout (`ollama pull `) — voir https://ollama.com/library. */ + tag: string; + rationale: string; +} + +const RECOMMENDED_MODELS: Record = { + "qwen2.5-1.5b": { + tag: "qwen2.5:1.5b", + rationale: + "Même choix par défaut que côté node-llama-cpp : meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON.", + }, + "llama-3.2-1b": { + tag: "llama3.2:1b", + rationale: + "Alternative plus légère — voir le comparatif détaillé et les résultats empiriques dans le README et dans llm-tech-step-poc.ts.", + }, +}; + +const DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434"; + +// --------------------------------------------------------------------------- +// OllamaStepAnalyzer +// --------------------------------------------------------------------------- + +/** + * Client Ollama enrobé comme service d'analyse d'étapes de recette — vraie + * `class` (pas un objet littéral), même convention que + * `LocalLlmStepAnalyzer`/`NlpTechStepClassifier` : possède un état réel (le + * client HTTP, le tag du modèle sélectionné), même si ici l'état lourd + * (les poids du modèle) vit dans le process `ollama serve` séparé, pas + * dans cette instance. + */ +export class OllamaStepAnalyzer { + private readonly _client: Ollama; + private readonly _host: string; + /** Tag du modèle une fois résolu/pull par `initialize()`. `undefined` avant. */ + private _modelTag: string | undefined; + + public constructor(host: string = DEFAULT_OLLAMA_HOST) { + this._host = host; + this._client = new Ollama({ host }); + } + + /** + * Vérifie/télécharge le modèle (`ollama pull`, no-op quasi instantané si + * déjà présent localement — Ollama compare les manifestes de couches + * avant de retélécharger quoi que ce soit) et journalise la progression + * par palier de statut plutôt que de rester muet le temps du + * téléchargement (potentiellement plusieurs centaines de Mo au premier + * pull d'un modèle). + * + * Échoue avec un message explicite (plutôt que l'erreur `fetch` brute + * remontée par `ollama-js`) si le serveur Ollama n'est pas joignable — + * contrairement à `node-llama-cpp`, ce PoC dépend d'un process externe + * que ce script ne lance pas lui-même. + */ + public async initialize(modelKey: RecommendedOllamaModelKey): Promise { + const tag = RECOMMENDED_MODELS[modelKey].tag; + console.info(`[ollama] vérification/pull du modèle "${tag}" sur ${this._host}...`); + try { + await this._ensureModelPulled(tag); + } catch (err) { + throw new Error( + `OllamaStepAnalyzer: impossible de joindre Ollama sur ${this._host} — le serveur est-il lancé (\`ollama serve\`, ou l'app de bureau Ollama) ?`, + { cause: err }, + ); + } + this._modelTag = tag; + } + + /** Force un premier appel factice — même rôle que le warm-up des deux autres moteurs : le premier vrai appel `chat()` déclenche le chargement des poids en mémoire côté serveur Ollama, un coût cependant nettement moins visible ici qu'avec node-llama-cpp car mutualisé/mis en cache par le serveur entre plusieurs process clients. */ + public async warmUp(): Promise { + await this.analyzeStep("Faites chauffer une poêle."); + } + + /** Analyse une étape de recette et renvoie sa séquence ordonnée d'actions, via `ollama.chat()` contraint par {@link KITCHEN_ACTIONS_JSON_SCHEMA}. */ + public async analyzeStep(stepText: string): Promise { + if (this._modelTag === undefined) { + throw new Error("OllamaStepAnalyzer.initialize() must be awaited before analyzeStep()."); + } + + const response = await this._client.chat({ + model: this._modelTag, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: stepText }, + ], + format: KITCHEN_ACTIONS_JSON_SCHEMA, + // Température 0 — génération déterministe, cohérent avec l'usage + // d'un schéma imposé : on veut la sortie la plus prévisible possible + // pour ce qui reste discrétionnaire (le contenu, pas la syntaxe). + options: { temperature: 0 }, + stream: false, + }); + + let parsed: KitchenActionsSchemaResult; + try { + parsed = JSON.parse(response.message.content) as KitchenActionsSchemaResult; + } catch (err) { + throw new Error( + `OllamaStepAnalyzer: réponse non-JSON malgré le schéma imposé — "${response.message.content}"`, + { cause: err }, + ); + } + return { originalText: stepText, actions: parsed.actions }; + } + + /** + * Décharge le modèle de la mémoire du serveur Ollama (`keep_alive: 0`) — + * best effort, purement pour ne pas laisser le modèle chargé + * indéfiniment après ce benchmark : `ollama serve` tourne indépendamment + * de ce script (pas lancé ni arrêté par lui), donc rien d'autre à + * libérer côté process Node. + */ + public async dispose(): Promise { + if (this._modelTag === undefined) return; + try { + await this._client.chat({ model: this._modelTag, messages: [], keep_alive: 0 }); + } catch (err) { + console.error("[ollama] échec du déchargement du modèle (non bloquant)", err); + } + } + + /** Lance un `pull` en streaming et journalise chaque changement de statut (`pulling manifest`, `downloading`, `verifying sha256 digest`...) avec le pourcentage quand Ollama le fournit. */ + private async _ensureModelPulled(tag: string): Promise { + const progress = await this._client.pull({ model: tag, stream: true }); + let lastStatus = ""; + for await (const part of progress) { + if (part.status === lastStatus) continue; + lastStatus = part.status; + const percent = + part.completed !== undefined && part.total !== undefined && part.total > 0 + ? ` (${Math.round((part.completed / part.total) * 100)}%)` + : ""; + console.info(`[ollama] ${part.status}${percent}`); + } + } +} + +// --------------------------------------------------------------------------- +// Benchmark +// --------------------------------------------------------------------------- + +/** Imprime le détail de chaque échantillon — même format que `llm-tech-step-poc.ts`, pour comparer les deux moteurs LLM à l'œil ligne à ligne. */ +function printDetailedResults(samples: readonly BenchmarkSample[]): void { + for (const sample of samples) { + console.info( + `\n[${sample.sentence.id}] (${sample.sentence.locale}) — ${sample.latencyMs.toFixed(0)} ms`, + ); + console.info(` texte : ${sample.sentence.text}`); + console.info(` attendu : ${sample.sentence.note}`); + console.table( + sample.result.actions.map((action) => ({ + action: action.action, + verbe: action.verb, + ingrédients: action.ingredients.join(", "), + "durée (min)": action.durationMinutes ?? "—", + température: action.temperature ?? "—", + ustensiles: action.utensils.join(", "), + })), + ); + } +} + +/** + * Point d'entrée : charge le modèle choisi via `OLLAMA_TECH_STEP_MODEL` + * (`"qwen2.5-1.5b"` par défaut), contre le serveur Ollama de + * `OLLAMA_TECH_STEP_HOST` (`http://127.0.0.1:11434` par défaut), lance le + * benchmark sur les 11 phrases partagées, imprime les résultats détaillés + * puis le récapitulatif, et décharge le modèle avant de quitter. + */ +async function main(): Promise { + const modelKey: RecommendedOllamaModelKey = + process.env.OLLAMA_TECH_STEP_MODEL === "llama-3.2-1b" ? "llama-3.2-1b" : "qwen2.5-1.5b"; + const host = process.env.OLLAMA_TECH_STEP_HOST ?? DEFAULT_OLLAMA_HOST; + console.info( + `[ollama] modèle sélectionné : ${modelKey} (${RECOMMENDED_MODELS[modelKey].rationale})`, + ); + + const analyzer = new OllamaStepAnalyzer(host); + try { + await analyzer.initialize(modelKey); + } catch (err) { + console.error("[ollama] échec de l'initialisation", err); + process.exitCode = 1; + return; + } + + const warmUpStartedAt = performance.now(); + try { + await analyzer.warmUp(); + } catch (err) { + console.error("[ollama] échec du warm-up — le benchmark continue quand même", err); + } + console.info(`[ollama] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`); + + try { + const benchmarkStartedAt = performance.now(); + const samples = await runBenchmark({ + logPrefix: "[ollama]", + countOf: (result) => result.actions.length, + countLabel: "action(s) détectée(s)", + analyze: (sentence: BenchmarkSentence) => analyzer.analyzeStep(sentence.text), + }); + console.info( + `[ollama] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`, + ); + printDetailedResults(samples); + console.info( + "\n[ollama] rappel : la colonne 'RSS moy.' ci-dessous ne mesure rien d'utile pour ce moteur — l'inférence tourne dans le process `ollama serve`, pas dans ce script (voir le doc-comment en tête de fichier).", + ); + printSummaryTable(samples, (result) => result.actions.length, "actions détectées"); + } finally { + try { + await analyzer.dispose(); + } catch (err) { + console.error("[ollama] erreur lors de la libération du modèle", err); + } + } +} + +if (isMainModule(import.meta.url)) { + await main(); +} From 79ed5afdba8d81c7c327b1bb2c9fa2834f85a224 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 22 Aug 2026 00:12:33 +0200 Subject: [PATCH 10/10] =?UTF-8?q?fix(experiments):=20corrige=20les=20?= =?UTF-8?q?=C3=A9checs=20de=20parsing=20JSON=20intermittents=20du=20moteur?= =?UTF-8?q?=20Ollama?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostiqué et reproduit : sur un petit modèle (qwen2.5:0.5b) face à une phrase longue (fr-concat-volumetrie, fr-recette-complete), le modèle part en boucle de répétition dans le tableau `actions` et n'atteint jamais l'accolade fermante avant la limite de tokens (response.done_reason === "length", jusqu'à ~130 000 caractères observés). La grammaire imposée par `format` ne borne que la syntaxe token par token, pas la longueur du tableau. - options.repeat_penalty (1.3) décourage la boucle — réduit le dérapage d'un facteur ~18 sur le pire cas reproduit, sans l'éliminer à coup sûr. - options.num_predict (2048) borne le dégât si ça dérape quand même. - analyzeStep() réessaie jusqu'à 3 fois sur un parse invalide, avec une température légèrement relevée (0.3) à partir de la 2e tentative — à température 0 stricte, retenter à l'identique peut reproduire l'échec. Vérifié : 3/3 runs réussissent sur le pire cas reproduit après correctif, contre un échec systématique avant. Co-Authored-By: Claude Sonnet 5 --- .../src/ollama-tech-step-poc.ts | 100 ++++++++++++++---- 1 file changed, 77 insertions(+), 23 deletions(-) diff --git a/experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts b/experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts index 379bc75..44339fc 100644 --- a/experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts +++ b/experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts @@ -135,6 +135,9 @@ const DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434"; * dans cette instance. */ export class OllamaStepAnalyzer { + /** Nombre de tentatives avant d'abandonner sur une réponse JSON invalide — voir le doc-comment de {@link OllamaStepAnalyzer.analyzeStep}. */ + private static readonly _MAX_PARSE_ATTEMPTS = 3; + private readonly _client: Ollama; private readonly _host: string; /** Tag du modèle une fois résolu/pull par `initialize()`. `undefined` avant. */ @@ -177,36 +180,87 @@ export class OllamaStepAnalyzer { await this.analyzeStep("Faites chauffer une poêle."); } - /** Analyse une étape de recette et renvoie sa séquence ordonnée d'actions, via `ollama.chat()` contraint par {@link KITCHEN_ACTIONS_JSON_SCHEMA}. */ + /** + * Analyse une étape de recette et renvoie sa séquence ordonnée d'actions, + * via `ollama.chat()` contraint par {@link KITCHEN_ACTIONS_JSON_SCHEMA}. + * + * Réessaie jusqu'à {@link _MAX_PARSE_ATTEMPTS} fois si la réponse ne + * parse pas en JSON valide — un échec bien réel et reproduit en + * pratique, surtout sur les petits modèles (`qwen2.5:0.5b`, + * `smollm2:360m`...) face aux phrases longues de ce PoC + * (`fr-concat-volumetrie`, `fr-recette-complete`...) : le modèle part en + * boucle de répétition dans le tableau `actions` et n'atteint jamais + * l'accolade fermante avant la limite de tokens + * (`response.done_reason === "length"`, contenu de plusieurs dizaines de + * milliers de caractères observé en pratique). La grammaire imposée par + * `format` contraint la SYNTAXE token par token, elle ne borne pas la + * LONGUEUR du tableau — rien ne l'empêche de continuer à générer des + * éléments indéfiniment. + * + * `repeat_penalty`/`num_predict` réduisent nettement l'ampleur du + * dérapage (÷18 observé en pratique sur le pire cas) sans l'éliminer à + * coup sûr sur un modèle assez faible — d'où le retry, avec une + * température légèrement relevée à partir de la 2e tentative : à + * température 0 stricte, retenter avec des paramètres identiques peut + * reproduire l'échec (déterminisme), une température non nulle donne une + * vraie chance de sortir de la boucle. + */ public async analyzeStep(stepText: string): Promise { if (this._modelTag === undefined) { throw new Error("OllamaStepAnalyzer.initialize() must be awaited before analyzeStep()."); } - const response = await this._client.chat({ - model: this._modelTag, - messages: [ - { role: "system", content: SYSTEM_PROMPT }, - { role: "user", content: stepText }, - ], - format: KITCHEN_ACTIONS_JSON_SCHEMA, - // Température 0 — génération déterministe, cohérent avec l'usage - // d'un schéma imposé : on veut la sortie la plus prévisible possible - // pour ce qui reste discrétionnaire (le contenu, pas la syntaxe). - options: { temperature: 0 }, - stream: false, - }); + let lastParseError: unknown; + let lastRawContent = ""; + for (let attempt = 1; attempt <= OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS; attempt++) { + const response = await this._client.chat({ + model: this._modelTag, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: stepText }, + ], + format: KITCHEN_ACTIONS_JSON_SCHEMA, + options: { + // Température 0 sur la 1re tentative — génération déterministe, + // cohérent avec l'usage d'un schéma imposé : on veut la sortie la + // plus prévisible possible pour ce qui reste discrétionnaire (le + // contenu, pas la syntaxe). Relevée légèrement sur les tentatives + // suivantes uniquement, voir le doc-comment ci-dessus. + temperature: attempt === 1 ? 0 : 0.3, + // Décourage la boucle de répétition qui cause l'essentiel des + // échecs de parsing observés (voir doc-comment) — 1.3 plutôt que + // le défaut ~1.1 d'Ollama, choisi empiriquement contre le pire + // cas reproduit (phrase longue + petit modèle). + repeat_penalty: 1.3, + // Borne le dégât en cas de dérapage malgré repeat_penalty + // (arrête la génération avant plusieurs dizaines de milliers de + // caractères inutiles) sans pénaliser les cas normaux — même la + // phrase la plus longue de ce PoC (recette concaténée, jusqu'à + // une quinzaine d'actions) tient largement dans cette limite une + // fois correctement formée. + num_predict: 2048, + }, + stream: false, + }); - let parsed: KitchenActionsSchemaResult; - try { - parsed = JSON.parse(response.message.content) as KitchenActionsSchemaResult; - } catch (err) { - throw new Error( - `OllamaStepAnalyzer: réponse non-JSON malgré le schéma imposé — "${response.message.content}"`, - { cause: err }, - ); + try { + const parsed = JSON.parse(response.message.content) as KitchenActionsSchemaResult; + return { originalText: stepText, actions: parsed.actions }; + } catch (err) { + lastParseError = err; + lastRawContent = response.message.content; + if (attempt < OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS) { + console.error( + `[ollama] réponse JSON invalide (tentative ${attempt}/${OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS}, ${response.message.content.length} caractères, done_reason="${response.done_reason}") — nouvelle tentative...`, + ); + } + } } - return { originalText: stepText, actions: parsed.actions }; + + throw new Error( + `OllamaStepAnalyzer: réponse JSON invalide malgré le schéma imposé, après ${OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS} tentatives — dernière réponse (${lastRawContent.length} caractères) : "${lastRawContent.slice(0, 500)}${lastRawContent.length > 500 ? "..." : ""}"`, + { cause: lastParseError }, + ); } /**