batchCooking/services/tech-step-intent-service/README.md
Nicolas 393300205d
Some checks failed
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 6m39s
CI / lint (push) Successful in 1m41s
CI / intent-service-test (push) Successful in 3m4s
CI / test (push) Failing after 12m43s
perf(tech-steps): corrige la largeur du textcat, entrainement 32x plus rapide
Le service mettait ~930 s (15-16 min) a devenir `healthy`, ce que le README
presentait comme "un compromis assume, pas un defaut de configuration a
corriger". Mauvais diagnostic : le cout ne venait ni des iterations ni de la
taille du corpus, mais du parametre `length` de `spacy.TextCatBOW.v3`, laisse
a son defaut spaCy de 262144 faute d'etre passe explicitement.

Avec `no_output_layer: False` et 74 techniques, la couche de sortie faisait
19 398 730 parametres mis a jour par Adam a chaque pas de gradient, pour une
table de hachage vide a 99 % (le corpus tient dans quelques milliers de
tokens distincts).

Mesure contre le vrai corpus (fr, 25 iterations, jeu de dev = derniere
utterance de chaque technique tenue hors entrainement) :

  length=262144 ngram=1  dev 97.3%  conf 0.809  394.7 s   <- avant
  length=2048   ngram=2  dev 97.3%  conf 0.919   12.3 s   <- apres

Meme accuracy, meilleure confiance, 32x plus rapide. Les bigrammes profitent
des locutions du corpus ("faire fondre", "laisser mijoter") qu'un sac
d'unigrammes ne distingue pas de leurs constituants isoles.

PipelineRegistry.initialize() (fr+en, chargement + entrainement) mesure a
28,5 s contre ~930 s. Suite pytest complete : 46 passed en 40,6 s.

- locale_pipeline.py : `_TEXTCAT_MODEL_LENGTH` / `_TEXTCAT_NGRAM_SIZE`
  explicites et commentes avec les mesures ; historique de calibration de
  `_TRAINING_ITERATIONS` corrige (il attribuait le cout aux iterations)
- README : sections "Temps de demarrage" et "Limitations connues" reecrites
  (la couverture d'evaluation, 26/74 techniques, y devient la limitation
  bloquante ; ajout des collisions de classes observees)
- docker-compose.yml / ci.yml : commentaires citant ~930 s corriges,
  start_period et timeout 1200s -> 300s (plafond pour runner lent, pas cible)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:01:06 +02:00

212 lines
11 KiB
Markdown

# tech-step-intent-service
Microservice de détection d'intention (technique de cuisine) — remplace le
pipeline `node-nlp` qui vivait dans `apps/api`
(`TechStepClassifierService`, `apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) :
1. **NER par phrases** (`spacy.matcher.PhraseMatcher`) — trouve les mentions
candidates d'une technique dans un texte, à partir des `synonyms` de
chaque technique.
2. **Classification d'intention** (`textcat` spaCy, bag-of-words) — verdict
de la technique qu'une clause de texte *signifie*, entraîné sur les
`utterances` de chaque technique (y compris des paraphrases n'utilisant
jamais le mot-clé lui-même).
3. **NER par phrases, ustensiles** (`spacy.matcher.PhraseMatcher`, second
matcher indépendant) — trouve les mentions d'un ustensile de cuisine
(`intent_service/utensil_vocabulary.py`, `UTENSIL_VOCABULARY`), sans
`textcat` associé : contrairement à une technique, un ustensile mentionné
n'a pas besoin d'être interprété selon le contexte. Renvoyé dans la même
liste `entities` que les techniques, discriminé par `kind`.
Basé sur **spaCy** (`fr_core_news_md`/`en_core_web_md`) plutôt que node-nlp —
écosystème NLP plus robuste/maintenu, avec l'ambition à terme (hors scope de
ce service en l'état) de pouvoir aussi absorber ce que fait aujourd'hui
`services/tech-step-llm-worker` une fois ce pipeline assez riche pour s'en
passer (les modèles `md`, avec vecteurs de mots, sont conservés dans ce but,
même si rien ici ne s'en sert encore).
## Ce service est entièrement autonome
Contrairement à sa toute première version, **ce service possède désormais
son propre corpus** — `intent_service/training_data.py`
(`TECH_STEP_TRAINING_DATA`), revu par PR comme le reste du code. Il
s'entraîne lui-même une seule fois, à son propre démarrage
(`PipelineRegistry.initialize()`, appelé par `main.py`'s `lifespan`), et ne
persiste jamais rien sur disque — un redémarrage du process réentraîne
toujours from scratch depuis ce fichier. `apps/api` ne connaît plus aucune
technique ni aucun synonyme : il n'appelle plus que `POST /v1/process` (plus
de `POST /v1/train`, supprimé).
Workflow mainteneur pour changer le corpus :
1. Éditer `intent_service/training_data.py` à la main (informé par le
rapport de `apps/api/src/scripts/list-pending-training-suggestions.ts`)
pour une technique, ou `intent_service/utensil_vocabulary.py` pour un
ustensile (pas de rapport équivalent pour ce dernier — pas de mécanisme
de correction utilisateur sur les ustensiles aujourd'hui). Chaque
technique doit garder le même nombre d'`utterances` que les autres, par
locale (voir `training_data.py`'s own doc comment) — une technique
ajoutée avec moins que le max courant, exécuter `augment_utterances.py`
(racine de ce service) pour rééquilibrer, puis **impérativement**
relancer l'étape 3 ci-dessous avant de committer : chaque tentative
passée d'élargir ce corpus (voir l'historique Git de
`training_data.py`) a dû être ajustée ou annulée après coup faute
d'avoir vérifié le F1 avant de pousser.
2. **Redémarrer ce service** (`docker compose restart tech-step-intent-service`,
ou simplement redéployer) — le nouveau corpus n'a d'effet qu'une fois
réentraîné au démarrage, contrairement à l'ancienne version qui pouvait
être réentraînée à chaud via `POST /v1/train`.
3. Depuis `apps/api`, lancer `pnpm --filter api exec tsx
src/scripts/retrain-tech-steps.ts` — vérifie le F1 contre
`TECH_STEP_EVAL_DATASET` avant de backfiller les recettes existantes.
## Pourquoi ce service vit hors du workspace pnpm
Même raisonnement que `services/tech-step-llm-worker` : un service Python
n'a rien à faire dans `pnpm-workspace.yaml` (qui ne couvre que
`apps/*`/`packages/*`), et ses dépendances (spaCy, ses modèles) ne doivent
jamais se retrouver dans l'image `apps/api`. **Aucun accès direct à
Postgres** non plus — la résolution `TechStep.key -> id` reste entièrement
côté `apps/api` (`TechStepClassifierService`), ce service ne manipule que
des `uid` (chaînes opaques) tout du long.
## Contrat HTTP
Voir `intent_service/schemas.py` pour le détail exact. En résumé :
- `GET /health` — sans authentification, `200` une fois ce service
entièrement prêt : modèles spaCy de base chargés **et** les deux locales
entraînées (pas de lazy-load, voir `intent_service/main.py`) — voir
"Temps de démarrage" plus bas pour ce que ça implique en pratique.
- `POST /v1/process` — `{ locale, text }` → `{ entities: [{ uid, start, end, kind }], intent, score }`,
`kind` valant `"technique"` ou `"utensil"` selon le `PhraseMatcher` qui a
trouvé la mention (voir point 3 ci-dessus). `apps/api`'s `tech-step-matcher.ts`
filtre par `kind` pour savoir laquelle des deux résoudre (`TechStep`/`Utensil`).
`/v1/process` exige le header `X-Intent-Service-Secret` (voir
`intent_service/security.py`), qui doit matcher `INTENT_SERVICE_SECRET`
côté `apps/api`.
## Temps de démarrage
Ce service s'entraîne à chaque démarrage du process (voir plus bas pourquoi
rien n'est persisté), ce qui prend de l'ordre de **15 secondes par locale**,
soit ~30 secondes pour `fr`+`en` combinés.
Ça a longtemps été **~930 secondes (15-16 minutes)**, décrit ici comme « un
compromis assumé, pas un défaut de configuration à corriger ». C'était un
mauvais diagnostic : le coût ne venait ni des itérations ni de la taille du
corpus, mais du paramètre `length` de `spacy.TextCatBOW.v3`, laissé à son
défaut spaCy de `262144` alors que le corpus tient dans quelques milliers de
tokens distincts — soit une couche de sortie de 19,4 millions de paramètres
mise à jour à chaque pas de gradient, pour une table de hachage vide à 99 %.
Le passer à `2048` (voir `_TEXTCAT_MODEL_LENGTH` dans `locale_pipeline.py`
pour le tableau de mesures complet) rend l'entraînement **30x plus rapide à
qualité strictement identique** — même accuracy, même confiance.
`docker-compose.yml` et `.github/workflows/ci.yml` gardent un
`start_period`/timeout d'attente confortable, mais il n'a plus besoin d'être
dimensionné en minutes — voir leurs propres commentaires.
## Logs
`intent_service/logging_config.py` branche un format JSON structuré (une
ligne par évènement — `timestamp`/`level`/`message` + champs métier fusionnés
— même convention que `LoggerService` côté `apps/api`) sur toute la
journalisation de ce service, niveau `LOG_LEVEL` (`INFO` par défaut, voir
`.env.example`). `routes/process.py` journalise chaque appel avec son input
et son output complets, `pipeline_registry.py` journalise le déroulement de
l'entraînement au démarrage :
```json
{"timestamp": "...", "level": "info", "message": "tech-step NLP process", "locale": "fr", "text": "faire fondre le beurre", "entities": [{"uid": "melt", "start": 6, "end": 13, "kind": "technique"}], "intent": "melt", "score": 0.93}
```
Le chatter interne de spaCy (`"spacy"` logger — chargement de vocabulaire,
etc.) est explicitement mis à `WARNING` pour ne pas noyer ces lignes.
## Setup
Ce service utilise [`uv`](https://docs.astral.sh/uv/) pour ses dépendances
(`uv.lock` committé, `uv sync --frozen` partout — Dockerfile, CI, dev).
```bash
cd services/tech-step-intent-service
uv sync
cp .env.example .env
# édite .env : génère un INTENT_SERVICE_SECRET, identique à celui d'apps/api
uv run uvicorn intent_service.main:app --reload --port 8000
```
`apps/api` (natif, `pnpm dev:api`, ou sa suite Mocha) doit pointer
`INTENT_SERVICE_BASE_URL=http://localhost:8000` et le même
`INTENT_SERVICE_SECRET` (voir `apps/api/.env.example`).
## Running via Docker Compose
`docker-compose.yml` (racine) définit un service `tech-step-intent-service`
aux côtés de `postgres`/`app`/`tech-step-llm-worker` — **pas optionnel**,
contrairement au worker LLM : sans lui, `apps/api` ne peut plus détecter
aucune technique de cuisine. `app` attend qu'il soit `healthy`
(`depends_on: condition: service_healthy`) avant de démarrer — voir "Temps
de démarrage" ci-dessus pour combien de temps ça prend en pratique.
## Testing
```bash
uv run pytest
```
`tests/test_locale_pipeline_entities.py` rejoue les cas d'offsets caractère
exacts et d'insensibilité accents/casse de
`apps/api/test/recipe-matching/tech-step-matcher.test.ts` — le point de
fidélité le plus critique de ce service (voir le plan de migration).
`tests/test_utensil_matching.py` couvre le second `PhraseMatcher`
(ustensiles) de la même façon, contre le vocabulaire réel (statique, pas
besoin d'un jeu de test dédié comme pour les techniques).
`tests/conftest.py`'s fixture `client` (scope "session") ne s'entraîne
qu'une seule fois pour toute la suite — c'est *le vrai corpus complet*,
pas un jeu jouet, donc la première utilisation de cette fixture prend le
même temps qu'un vrai démarrage (voir "Temps de démarrage" ci-dessus).
Aucun test ici ne dépend d'une vraie base Postgres ni d'`apps/api` en
service — à l'inverse, la suite Mocha d'`apps/api`
(`tech-step-matcher.test.ts`/`recipe-translation.test.ts`) exige elle une
vraie instance de ce service tournant (voir `apps/api/.env.test`), conforme
à la convention du repo de ne jamais mocker un service interne.
## Limitations connues
- **`TECH_STEP_EVAL_DATASET` ne couvre que 26 des 74 techniques** (41 cas,
33 `fr` / 8 `en`) — 48 techniques (65 %) n'ont *aucune* couverture
d'évaluation. Le F1 agrégé de `0.815` porte sur ~39 spans, ce qui ne
permet de mesurer ni le F1 par technique, ni les confusions entre
techniques voisines. **C'est la limitation la plus bloquante de la
liste** : tant qu'elle tient, aucun changement d'architecture ou de
corpus n'est mesurable autrement qu'à l'intuition.
- **`CONFIDENCE_THRESHOLD` côté `apps/api` est un placeholder** depuis
l'élargissement du corpus à ~74 techniques (calibré à la main, pas via
une vraie repasse de `calibrate-tech-step-threshold.ts` contre
`TECH_STEP_EVAL_DATASET` — voir le commentaire de cette constante).
Conséquence directe du point précédent.
- **Textcat bag-of-words** (`spacy.TextCatBOW.v3`) — n'exploite pas les
vecteurs de mots des modèles `md` chargés. Une mesure ponctuelle donne
`spacy.TextCatReduce.v1` (tok2vec + vecteurs statiques) à 98,6 %
d'accuracy dev et 0,984 de confiance moyenne, contre 97,3 % / 0,919 pour
le BOW actuel. **Mais** les mêmes sondes montrent des erreurs *confiantes*
sur les classes voisines (`"enfourner la tarte"` → `blindBake` 0,711 avec
`bake` à 0,004 ; `"cuire les pâtes dans l'eau bouillante"` → `poach`
0,801) : une confiance plus haute ne veut pas dire plus juste, et
neutralise `CONFIDENCE_THRESHOLD` comme filet de sécurité. À ne trancher
qu'avec un F1 par classe, donc après le premier point de cette liste.
- **Des techniques se recouvrent réellement** (`bake`/`blindBake`,
`boil`/`poach`/`blanch`/`cook`, `rest`/`proof`) — 74 classes mutuellement
exclusives à ~7 exemples chacune, aux frontières floues, est un cadrage
qui plafonne quelle que soit l'architecture. À instruire (fusion de
classes ? `textcat_multilabel` ?) seulement si une matrice de confusion le
confirme.
- **Reconstruit tout le pipeline à chaque démarrage** (pas de persistance,
pas de fusion incrémentale) — un choix délibéré (voir
`LocalePipeline.train`), pas une limitation à lever : `training_data.py`
doit toujours rester l'unique source de vérité, jamais un état sur disque
qui pourrait dériver.