# 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.