batchCooking/experiments/llm-tech-step-poc/README.md
Nicolas c73c62328d refactor(experiments): retire le script apps/api, ajoute NLP frais + pipeline hybride
Étape 1 — retire apps/api/src/scripts/bench-tech-step-classifier.ts
(DB-backed, taxonomie ~26 techniques non comparable terme à terme au LLM).

Étape 2 — reconstruit tout dans experiments/llm-tech-step-poc, entièrement
autonome (aucune dépendance Postgres/apps/api) :

- shared/kitchen-action.ts, shared/test-sentences.ts,
  shared/benchmark-harness.ts : types, 7 phrases de test et harness de
  mesure/affichage désormais partagés par les trois scripts (plus de
  recopie manuelle entre fichiers).
- nlp-tech-step-poc.ts : classifieur node-nlp FRAIS (NER + clauses +
  classification), entraîné directement sur la taxonomie à 7 catégories du
  LLM plutôt que réutiliser TechStepClassifierService — comparaison terme à
  terme, et surtout un score de confiance BRUT jamais masqué (contrairement
  au repli silencieux sur l'ancre NER de la version production), condition
  nécessaire au pipeline hybride. Corpus qui préfère les synonymes mono-mot
  ("revenir") aux phrases figées, pour ne pas se faire piéger par les
  pronoms clitiques français ("faites-les-revenir").
- hybrid-tech-step-poc.ts : NLP toujours en premier (chemin rapide), LLM en
  secours si la confiance NLP passe sous NLP_TRUST_THRESHOLD (0.6, tunable)
  ou qu'aucune action n'est trouvée — récapitulatif avec colonnes "moteur"
  et "confiance NLP" pour observer les bascules.
- llm-tech-step-poc.ts : inchangé fonctionnellement, migré vers les modules
  partagés.
- shared/module-entry.ts (isMainModule) : garde chaque script pour que
  l'import de ses classes (par hybrid-tech-step-poc.ts) ne déclenche pas
  aussi son propre benchmark comme effet de bord.

pnpm bench / bench:nlp / bench:hybrid. README réécrit en conséquence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 21:13:20 +02:00

159 lines
8.5 KiB
Markdown

# 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 7 phrases, trois
moteurs qui tournent tous 100 % en local :
| Script | Moteur | Ce qu'il apporte |
|---|---|---|
| `pnpm bench` | Mini LLM instruct via [`node-llama-cpp`](https://node-llama-cpp.withcat.ai/), sortie JSON contrainte par schéma (GBNF grammar) | Généralise sans vocabulaire fixé à l'avance — au prix d'une latence de plusieurs secondes. |
| `pnpm bench:nlp` | Classifieur `node-nlp` frais (NER + découpage en clauses + classification d'intention), entraîné directement sur la taxonomie à 7 catégories de ce PoC | Rapide (centaines de ms), mais borné à son vocabulaire d'entraînement. |
| `pnpm bench:hybrid` | NLP d'abord, LLM en secours si le score NLP est trop faible | Le meilleur des deux : rapide sur le cas courant, généralise sur le cas difficile. |
Les trois partagent le même code (`src/shared/`) : la taxonomie
`KitchenActionType`/`KitchenAction`/`RecipeStepAnalysis`
(`shared/kitchen-action.ts`), les 7 phrases de test
(`shared/test-sentences.ts`), et le harness de mesure/affichage
(`shared/benchmark-harness.ts`) — un seul jeu de phrases et un seul format
de sortie pour que les trois runs soient directement comparables, plutôt que
recopiés à la main dans trois scripts (le défaut d'une toute première
version de ce PoC, où le pendant NLP vivait dans `apps/api` et copiait les
phrases manuellement).
## Installation
```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).
## 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 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.
## 3. 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 7 phrases sont inspectées à l'œil, pas notées.
- La grammaire GBNF (moteur LLM) ne garantit qu'une syntaxe JSON conforme au
schéma, jamais la justesse sémantique du contenu.
- 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
(un binding natif alloue dans le même process, donc le RSS la capture,
mais au bruit du GC/de l'allocateur près) — pas une mesure isolée.
- Latence LLM mesurée en CPU pur (pas de configuration GPU dans ce PoC) — un
déploiement réel voudrait évaluer l'offload GPU (`gpuLayers` dans les
options `loadModel`) si la cible dispose d'un GPU.