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..b161805 --- /dev/null +++ b/experiments/llm-tech-step-poc/README.md @@ -0,0 +1,210 @@ +# 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 +`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 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/) (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 (`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 quatre partagent le même code (`src/shared/`) : la taxonomie +`KitchenActionType`/`KitchenAction`/`RecipeStepAnalysis` +(`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 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). 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 + +```bash +cd experiments/llm-tech-step-poc +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 +["Troubleshooting"](https://node-llama-cpp.withcat.ai/guide/troubleshooting) +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` + +```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". | + +> **Résultats obtenus** (Windows, backend Vulkan, une machine) : sur les 7 +> 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 +``` + +**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. + +## 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 +``` + +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 : + +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. + +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. + +## 4. Pipeline hybride — `pnpm bench:hybrid` + +```bash +pnpm bench:hybrid +``` + +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. + +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 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 + 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 + 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 new file mode 100644 index 0000000..fb9d6b6 --- /dev/null +++ b/experiments/llm-tech-step-poc/package.json @@ -0,0 +1,30 @@ +{ + "name": "llm-tech-step-poc", + "version": "0.0.0", + "private": true, + "type": "module", + "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", + "ollama": "^0.6.3" + }, + "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..c804701 --- /dev/null +++ b/experiments/llm-tech-step-poc/pnpm-lock.yaml @@ -0,0 +1,2174 @@ +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) + 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 + 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} + + '@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'} + 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'} + + '@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'} + + 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} + + 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} + + 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 + + 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'} + + 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'} + + 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'} + + 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'} + + 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} + + 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'} + + 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} + + 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'} + + 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'} + + 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} + + 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'} + + 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} + + 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} + + 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} + + 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'} + + 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 + + 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'} + + 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'} + + 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'} + + 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} + + 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'} + 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 + + 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'} + + 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'} + + 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': + 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': {} + + '@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 + + '@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': {} + + '@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: {} + + 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 + + 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: {} + + 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 + + codepage@1.15.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@10.0.1: {} + + crc-32@1.2.2: {} + + 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: {} + + doublearray@0.0.2: {} + + 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: {} + + 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: {} + + filenamify@6.0.0: + dependencies: + filename-reserved-regex: 3.0.0 + + frac@1.1.2: {} + + 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: {} + + 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: {} + + 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 + + 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 + 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 + + 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 + + ollama@0.6.3: + dependencies: + whatwg-fetch: 3.6.20 + + 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 + + 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: + 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: {} + + whatwg-fetch@3.6.20: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@6.0.1: + 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: {} + + 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: {} + + 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 new file mode 100644 index 0000000..85c5015 --- /dev/null +++ b/experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts @@ -0,0 +1,445 @@ +/** + * PoC autonome — détection d'actions culinaires via un mini LLM local + * (`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`). + * + * 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 + * 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 --ignore-workspace + * 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"; +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 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 + * `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", + 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 : 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", + 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: RecipeStepAnalysis["actions"]; +} + +/** + * 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. + * + * 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. + */ +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. +- 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. Contrairement à l'hypothèse initiale ("plus de paramètres, donc + * 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 à + * cette taille dans la pratique — et, empiriquement (voir ci-dessus), pas + * plus rapide non plus sur ce benchmark. Gardé comme point de comparaison + * plutôt que retiré : la latence relative entre les deux dépend du + * backend d'inférence (CUDA/Vulkan/CPU pur) et du matériel, un résultat + * obtenu sur une seule machine ne généralise pas forcément. + * + * Les deux sont quantisés en `Q4_K_M` — le compromis taille/qualité standard + * pour de l'inférence CPU (~4.5 bits/poids, largement suffisant pour une + * tâche d'extraction structurée, contrairement à de la génération créative + * longue où une quantisation plus fine se voit davantage). + */ +export 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 — et, empiriquement sur ce benchmark, aussi la latence la plus basse malgré la taille plus grande.", + }, + "llama-3.2-1b": { + hfUri: "hf:bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M", + rationale: + "Plus petit, structuration JSON moins fiable à 1B, et pas plus rapide qu'un Qwen 1.5B sur ce benchmark — conservé comme point de comparaison, pas comme choix latence.", + }, +}; + +/** Répertoire où les modèles GGUF téléchargés sont mis en cache — à côté de ce fichier, jamais commité (voir `.gitignore` du dossier). */ +const MODELS_DIRECTORY = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "models"); + +/** + * Résout le chemin du fichier GGUF à charger : un chemin local explicite + * via `LLM_TECH_STEP_MODEL_PATH` prime toujours (utile hors-ligne, ou en CI + * où un téléchargement réseau à la volée n'est pas souhaitable) ; sinon, + * {@link RECOMMENDED_MODELS} est résolu par `resolveModelFile`, qui + * télécharge le fichier une seule fois dans {@link MODELS_DIRECTORY} puis le + * réutilise tel quel aux exécutions suivantes. + */ +async function resolveModelPath(modelKey: RecommendedModelKey): Promise { + 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`. + * + * 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é 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. + */ + 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. + * + * 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 +// --------------------------------------------------------------------------- + +/** 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`, + ); + 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 `LLM_TECH_STEP_MODEL` + * (`"qwen2.5-1.5b"` par défaut, voir {@link RECOMMENDED_MODELS}), lance le + * 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 = + process.env.LLM_TECH_STEP_MODEL === "llama-3.2-1b" ? "llama-3.2-1b" : "qwen2.5-1.5b"; + console.info( + `[poc] modèle sélectionné : ${modelKey} (${RECOMMENDED_MODELS[modelKey].rationale})`, + ); + + const analyzer = new LocalLlmStepAnalyzer(); + const rssBeforeLoad = process.memoryUsage().rss; + // `performance.now()` plutôt que `console.time`/`console.timeEnd` — la + // config Biome du repo n'autorise que error/warn/info/debug/table/assert + // sur `console` (voir `biome.json`, `suspicious.noConsole`), pas `time`. + const loadStartedAt = performance.now(); + try { + await analyzer.initialize(modelKey); + } catch (err) { + console.error( + "[poc] échec du chargement du modèle — vérifier LLM_TECH_STEP_MODEL_PATH / la connexion réseau pour le téléchargement initial", + err, + ); + process.exitCode = 1; + return; + } + const loadDurationMs = performance.now() - loadStartedAt; + const modelRssMb = (process.memoryUsage().rss - rssBeforeLoad) / (1024 * 1024); + console.info( + `[poc] modèle chargé en ${loadDurationMs.toFixed(0)} ms (+${modelRssMb.toFixed(1)} Mo RSS)`, + ); + + // Absorbe ici le coût caché du tout premier appel d'inférence (voir + // LocalLlmStepAnalyzer.warmUp) plutôt que de laisser la première phrase + // du benchmark le payer — sans ça, sa latence n'est pas comparable aux + // six autres. + const warmUpStartedAt = performance.now(); + try { + await analyzer.warmUp(); + } catch (err) { + console.error("[poc] échec du warm-up — le benchmark continue quand même", err); + } + console.info(`[poc] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`); + + try { + const benchmarkStartedAt = performance.now(); + const samples = await runBenchmark({ + 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, (result) => result.actions.length, "actions détectées"); + } finally { + try { + await analyzer.dispose(); + } catch (err) { + console.error("[poc] erreur lors de la libération du modèle", err); + } + } +} + +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/ollama-tech-step-poc.ts b/experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts new file mode 100644 index 0000000..44339fc --- /dev/null +++ b/experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts @@ -0,0 +1,382 @@ +/** + * 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 { + /** 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. */ + 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}. + * + * 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()."); + } + + 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, + }); + + 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...`, + ); + } + } + } + + 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 }, + ); + } + + /** + * 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(); +} 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..9f52cce --- /dev/null +++ b/experiments/llm-tech-step-poc/src/shared/test-sentences.ts @@ -0,0 +1,289 @@ +/** + * 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). */ +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. 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"). + * 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. + * 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. + * 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[] = [ + { + 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.", + }, +]; + +/** + * 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 + * 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(" "); +} + +/** + * 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 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, + { + 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.`, + }, + { + 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.`, + }, +]; 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; + } +} 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"] +}