Le premier essai (length=2048 + ngram_size=2) a fait chuter le F1 agrege de
`tech-step-eval.test.ts` de 0.815 a 0.753, sous le plancher de 0.8. Balayage
mesure (fr, 25 iters, ngram=1, jeu de dev = derniere utterance tenue hors
entrainement ; collisions = tokens distincts partageant un bucket, sur 920) :
length=262144 dev 97.3% conf 0.809 2/920 collisions 400 s <- defaut
length=65536 dev 97.3% conf 0.809 10/920 collisions 106 s <- retenu
length=16384 dev 97.3% conf 0.808 50/920 collisions 35 s
length=2048 dev 97.3% conf 0.816 305/920 collisions 13 s <- livre puis recule
Deux corrections :
- ngram_size 2 -> 1. Les bigrammes rendent le classifieur nettement plus
confiant (0.809 -> 0.919), ce qui degrade la justesse bout-en-bout :
`_classifyClause` (tech-step-matcher.ts) ne retombe sur l'ancre NER que
sous CONFIDENCE_THRESHOLD, donc une classification confiante mais fausse
sur une technique voisine (blindBake au lieu de bake, poach au lieu de
boil) passe le seuil et ecrase l'ancre correcte. C'etait la seule cause
de la chute du F1.
- length 2048 -> 65536. A 2048, 305/920 tokens entrent en collision (33 %)
et la confiance moyenne se decale (0.816 vs 0.809). 65536 : 10 collisions,
confiance identique au bit pres au defaut spaCy, memes verdicts sur les
probes difficiles. ~5x plus rapide (vs ~30x pour 2048), 15x moins de
parametres. Descendre plus bas est possible mais reste du travail pour la
phase ou le jeu d'evaluation aura ete elargi et pourra le valider.
initialize() fr+en (chargement + entrainement) : 208.9 s contre ~930 s.
Suite pytest : 46 passed en 219.8 s (l'essentiel est le fixture qui entraine
le vrai corpus des deux locales, session-scoped).
start_period / timeout d'attente : 300s -> 600s (plafond pour runner lent).
Non verifie ici : le gate F1 lui-meme (tech-step-eval.test.ts, Postgres +
service requis, pas de Docker dans cet environnement) -> CI. Les 6 autres
echecs du job `test` (resetDatabase / seedReferenceData : contrainte unique
sur `key`, timeouts de hook) sont pre-existants sur `main` @ 81f7cb9 et sans
rapport avec ce changement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
216 lines
12 KiB
Markdown
216 lines
12 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 **~110 secondes pour `fr`**
|
|
/ **~75 secondes pour `en`**, soit **~3 minutes** pour les deux locales
|
|
combinées.
|
|
|
|
Ç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 ne contient que ~920 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,99 %. Le
|
|
passer à `65536` (voir `_TEXTCAT_MODEL_LENGTH` dans `locale_pipeline.py` pour
|
|
le balayage complet) rend l'entraînement **~5x plus rapide sans changement
|
|
mesurable de comportement** — même accuracy dev, confiance identique au bit
|
|
près, mêmes verdicts sur les probes difficiles. `2048` (30x) est atteignable
|
|
mais décale mesurablement la distribution des scores ; c'est du travail pour
|
|
la phase où le jeu d'évaluation aura été élargi.
|
|
|
|
`docker-compose.yml` et `.github/workflows/ci.yml` gardent un
|
|
`start_period`/timeout d'attente confortable — un plafond pour runner lent,
|
|
pas une cible.
|
|
|
|
## 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.
|