batchCooking/services/tech-step-intent-service
Nicolas 065ef2a31a feat(recipes): rapatrie le corpus NLP cote Python et l'enrichit de 48 techniques
Changement d'architecture demande par l'utilisateur : le dataset
d'entrainement (TECH_STEP_TRAINING_DATA) quitte apps/api pour vivre
entierement dans services/tech-step-intent-service
(intent_service/training_data.py). Ce service est desormais autonome :
il s'entraine lui-meme une seule fois, a son propre demarrage
(PipelineRegistry.initialize, dans le lifespan FastAPI), sans plus
dependre d'un POST /v1/train pousse par apps/api (route supprimee).
apps/api ne connait plus aucune technique/synonyme, uniquement le
resultat de POST /v1/process.

Corpus enrichi avec les 48 techniques du lexique fourni (Arroser,
Appertiser, Braiser, Caraméliser, Confire, Julienne/Brunoise/Mirepoix/
Paysanne, Cuire à blanc/au bain-marie/à l'étouffée, Déglacer variantes,
Emulsionner, Glacer, Pocher, Réduire, Suer, Zester, etc.), soit 74
techniques au total (26 + 48). Integration complete bout en bout :
- reference-seed-data.ts : 48 nouvelles entrees TECH_STEPS
- apps/web/locales/fr/translation.json : libelles francais correspondants
- "Mitonner" fondu comme synonyme de simmer (pas une technique distincte,
  sa propre definition le dit)
- "Blanchir un oeuf" (whiskPale) distingue de "Blanchir un legume"
  (blanch, existant) via des synonymes en phrase complete plutot qu'au
  mot nu — filter_spans (deja en place) resout la collision par
  specificite

Impact performance mesure : le corpus elargi (74 classes vs 26) rend
l'entrainement bien plus lent a nombre d'iterations egal (150 iterations
depassait 17 minutes par run de test) — reduit a 40 iterations apres
mesures repetees en local (~200s/locale, ~400s pour fr+en combines).
docker-compose.yml (healthcheck start_period 600s), CI (timeout curl
600s) et le README du service documentent ce nouveau temps de demarrage.
CONFIDENCE_THRESHOLD recalibre a 0.2 par verification manuelle (0.75 puis
0.45 ne tenaient plus compte tenu du nombre de classes) — marque
explicitement comme placeholder en attendant une vraie repasse de
calibrate-tech-step-threshold.ts (necessite Postgres, indisponible dans
cet environnement).

Verifie : 28/28 tests pytest du service (suite complete re-ecrite pour
s'entrainer une seule fois par session sur le vrai corpus, fixture
partagee dans conftest.py), lint + build complets du monorepo. La suite
Mocha d'apps/api reste a confirmer via CI (le root hook mocha n'attend
plus l'entrainement, seulement CI's propre attente sur /health).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:59:13 +02:00
..
intent_service feat(recipes): rapatrie le corpus NLP cote Python et l'enrichit de 48 techniques 2026-08-25 22:59:13 +02:00
tests feat(recipes): rapatrie le corpus NLP cote Python et l'enrichit de 48 techniques 2026-08-25 22:59:13 +02:00
.env.example feat(recipes): journalise chaque input/output du pipeline NLP 2026-08-25 21:34:40 +02:00
.gitignore feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy 2026-08-25 20:11:30 +02:00
Dockerfile feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy 2026-08-25 20:11:30 +02:00
pyproject.toml feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy 2026-08-25 20:11:30 +02:00
README.md feat(recipes): rapatrie le corpus NLP cote Python et l'enrichit de 48 techniques 2026-08-25 22:59:13 +02:00
uv.lock feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy 2026-08-25 20:11:30 +02:00

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

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 corpusintent_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).
  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 }], intent, score }.

/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) prend de l'ordre de 200 secondes par locale (mesuré localement, sans GPU), donc environ 400 secondes (~7 minutes) pour fr+en combinés à chaque démarrage du process. docker-compose.yml et .github/workflows/ci.yml ont un start_period/timeout d'attente généreux pour ça — 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 :

{"timestamp": "...", "level": "info", "message": "tech-step NLP process", "locale": "fr", "text": "faire fondre le beurre", "entities": [{"uid": "melt", "start": 6, "end": 13}], "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 pour ses dépendances (uv.lock committé, uv sync --frozen partout — Dockerfile, CI, dev).

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

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/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 (~7 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.