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

11 KiB

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 :

{"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 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-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

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.