# 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 met plusieurs minutes à devenir `healthy`** — contrairement à
node-nlp (entraînement quasi instantané), entraîner le `textcat` sur le
corpus réel (~74 techniques, chaque technique entraînée sur ses `synonyms`
en plus de ses `utterances` — voir `locale_pipeline.py`) prend de l'ordre
de 540 secondes pour `fr` / 390 secondes pour `en` (mesuré localement,
sans GPU), donc environ 930 secondes (~15-16 minutes) pour `fr`+`en`
combinés à chaque démarrage du process — chaque technique a désormais le
même nombre d'`utterances` par locale (voir `training_data.py`'s own doc
comment), légèrement plus qu'avant ce rééquilibrage. `docker-compose.yml`
et `.github/workflows/ci.yml` ont un `start_period`/timeout d'attente
généreux pour ça (`1200s`) — voir leurs propres commentaires. C'est un
compromis
assumé, pas un défaut de configuration à corriger : moins d'itérations
entraîne plus vite mais laisse des verdicts corrects sous
`CONFIDENCE_THRESHOLD` (voir le commentaire de cette constante,
`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`, et celui de
`_TRAINING_ITERATIONS`/`_TRAINING_BATCH_SIZE` dans `locale_pipeline.py`
pour le détail du compromis).

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

- **Démarrage lent** (~15-16 minutes) — voir "Temps de démarrage" ci-dessus.
  Une optimisation possible non explorée : parallélisation de
  l'entraînement `fr`/`en` (actuellement séquentiel,
  `PipelineRegistry.initialize`).
- **`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).
- **Textcat bag-of-words** (`spacy.TextCatBOW.v3`) — suffisant pour le
  corpus actuel une fois correctement entraîné, mais n'exploite pas les
  vecteurs de mots des modèles `md` chargés. Migrable vers une architecture
  tok2vec/similarité sans changer le contrat HTTP, si le F1 mesuré par
  `apps/api/src/scripts/calibrate-tech-step-threshold.ts` le justifie un
  jour.
- **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.
