batchCooking/services/tech-step-intent-service
Nicolas 6324a87445
Some checks failed
CI / e2e (push) Waiting to run
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
fix(tech-steps): recule sur 65536/unigramme apres regression F1 en CI
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>
2026-09-03 23:25:05 +02:00
..
intent_service fix(tech-steps): recule sur 65536/unigramme apres regression F1 en CI 2026-09-03 23:25:05 +02:00
tests feat(tech-steps): detecte les temperatures dans les etapes 2026-08-29 19:29:03 +02:00
.env.example feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy 2026-08-26 09:03:52 +02:00
.gitignore feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy 2026-08-26 09:03:52 +02:00
augment_utterances.py feat(recipes): associe ingredients, quantites et ustensiles aux techniques detectees (#75) 2026-08-26 19:50:52 +02:00
Dockerfile feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy 2026-08-26 09:03:52 +02:00
pyproject.toml feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy 2026-08-26 09:03:52 +02:00
README.md fix(tech-steps): recule sur 65536/unigramme apres regression F1 en CI 2026-09-03 23:25:05 +02:00
uv.lock feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy 2026-08-26 09:03:52 +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).
  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 :

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