batchCooking/services/tech-step-intent-service/intent_service/locale_pipeline.py
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

331 lines
15 KiB
Python

"""Pipeline spaCy pour UNE locale — l'équivalent Python de ce que
`node-nlp`'s `NlpManager` faisait pour cette locale dans
`TechStepClassifierService` (`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) :
NER par entités enum (ici un `PhraseMatcher`) + classification d'intention
(ici un `textcat`), les deux entraînés à partir du corpus possédé par ce
service lui-même (`training_data.TECH_STEP_TRAINING_DATA` — plus poussé par
`apps/api` via HTTP, voir `pipeline_registry.py`).
Le modèle de base spaCy (tokenizer + vecteurs + le composant
`diacritics_normalizer` défini plus bas) est chargé une seule fois
(`preload()`, appelé au démarrage du process — voir `main.py` — pas
paresseusement au premier `train()`, pour que `GET /health` ne devienne
`200` qu'une fois ce coût payé) puis réutilisé à chaque `train()` : seul le
`textcat` (retiré puis rajouté à neuf) et le `PhraseMatcher` (remplacé) sont
reconstruits à chaque appel, jamais le tokenizer/les vecteurs. Rien n'est
jamais persisté sur disque — `training_data.py` reste l'unique source de
vérité, reconstruite en mémoire depuis zéro à chaque démarrage du process.
"""
from __future__ import annotations
import random
from dataclasses import dataclass, field
import spacy
from spacy.language import Language
from spacy.matcher import PhraseMatcher
from spacy.tokens import Doc, Span
from spacy.training import Example
from spacy.util import filter_spans, minibatch
from .text_normalization import normalize_text
# Modèle spaCy de base par locale — voir pyproject.toml pour la version
# pinnée exacte. `md` (pas `sm`) : conserve les vecteurs de mots, inutilisés
# par le pipeline v1 (textcat bag-of-words) mais retenus pour l'ambition
# future de similarité sémantique (voir le README de ce service).
SUPPORTED_LOCALES = {
"fr": "fr_core_news_md",
"en": "en_core_web_md",
}
# Composants du modèle de base non utilisés par ce pipeline (on ne s'appuie
# ni sur le NER générique de spaCy, ni sur l'analyse syntaxique/morphologique
# — seuls le tokenizer et les vecteurs de mots restent nécessaires) : les
# exclure au chargement évite le coût mémoire/CPU de composants qui ne
# tourneraient jamais.
_EXCLUDED_COMPONENTS = ["parser", "ner", "tagger", "morphologizer", "attribute_ruler", "lemmatizer"]
_TEXTCAT_PIPE_NAME = "textcat"
# Nombre d'itérations d'entraînement du textcat et taille de minibatch —
# calibrés empiriquement contre le corpus réel (`training_data.py`), pas
# seulement contre les petits corpus jouets des tests de ce fichier. Trop
# peu d'itérations laisse des clauses correctement classifiées (bon argmax)
# mais avec une confiance dérisoire (`0.02`-`0.08` observé à 5-15
# itérations) — bien en dessous de tout seuil raisonnable pour
# `CONFIDENCE_THRESHOLD` (`tech-step-matcher.ts`).
#
# `150` convenait au corpus original (~26 techniques) mais ne passe plus à
# l'échelle une fois le corpus élargi à ~74 : le temps d'entraînement croît
# avec le nombre de classes autant qu'avec les itérations (mesuré :
# ~150s pour seulement 30 itérations sur 74 classes, contre ~110s pour 150
# itérations sur 26 classes) — `150` sur 74 classes dépassait 17 minutes
# rien que pour une locale, constaté en CI. `40` est le meilleur compromis
# trouvé empiriquement sur ce corpus élargi : ~200s par locale (~400s pour
# fr+en combinés au démarrage), avec des scores exploitables sur tous les
# cas testés à la main (melt ~0.89, preheat ~0.76, compote ~0.76, zest
# ~0.64, julienne ~0.56, cook/bake ~0.33, simmer ~0.25 — le plus faible
# observé, toujours correct en argmax et de toute façon ancré par NER) et
# un bruit hors-vocabulaire qui reste négligeable (anglais via le
# classifieur français : `~0.02`). Une vraie repasse de
# `calibrate-tech-step-threshold.ts` contre `TECH_STEP_EVAL_DATASET` reste
# nécessaire pour confirmer/affiner ces deux valeurs (voir
# `CONFIDENCE_THRESHOLD`'s propre commentaire, `tech-step-matcher.ts`) — ce
# qui suit est une mesure manuelle ponctuelle, pas un remplacement de cette
# calibration.
_TRAINING_ITERATIONS = 40
_TRAINING_BATCH_SIZE = 16
# Abaissé de `0.2` avec le reste de cette recalibration — `0.1` régularise
# encore contre la petite taille du corpus par technique tout en laissant
# plus de signal passer à chaque pas, ce qui a mesurablement aidé la
# confiance finale sans signe de sur-ajustement (le bruit hors-vocabulaire
# reste aussi bas qu'avant, voir ci-dessus).
_TRAINING_DROPOUT = 0.1
# Seed fixe — un warm-up reproductible d'un redémarrage à l'autre (même
# corpus en entrée) est préférable à un score qui varie légèrement à chaque
# déploiement pour la même donnée, en particulier pendant la calibration du
# seuil de confiance côté apps/api.
_TRAINING_SEED = 0
@Language.factory("diacritics_normalizer")
def _create_diacritics_normalizer(nlp: Language, name: str) -> "_DiacriticsNormalizer":
return _DiacriticsNormalizer()
class _DiacriticsNormalizer:
"""Composant de pipeline réécrivant `token.norm_` avec `normalize_text()`
(le port Python de `normalizeText()` côté `apps/api`) pour chaque token.
Point clé : ce composant tourne aussi bien sur les `Doc` construits pour
les *patterns* du `PhraseMatcher` (voir `LocalePipeline.train`) que sur
le *texte cible* passé à `process()` — les deux passent donc par
exactement la même normalisation, ce qui garantit qu'un synonyme comme
"mijoter" matche indifféremment "MIJOTER"/"mijoté"/"Mijotée" dans le
texte, reproduisant le comportement `ner.threshold: 1` (exact après
normalisation, sans tolérance floue Levenshtein) de l'ancien `NlpManager`.
Indépendant des `entries` entraînées — ajouté une seule fois par
`preload()`, jamais retiré/rajouté par `train()`.
"""
def __call__(self, doc: Doc) -> Doc:
for token in doc:
token.norm_ = normalize_text(token.text)
return doc
@dataclass
class TrainEntry:
"""Une technique à entraîner pour une locale — construit par
`PipelineRegistry.initialize()` depuis `training_data.entries_for_locale`."""
uid: str
synonyms: list[str] = field(default_factory=list)
utterances: list[str] = field(default_factory=list)
@dataclass
class Entity:
"""Une mention candidate trouvée par le `PhraseMatcher` — offsets
caractère `[start, end)`, miroir de `EntityPayload` (`schemas.py`)."""
uid: str
start: int
end: int
@dataclass
class ProcessResult:
"""Résultat complet d'un `process()` — miroir de `ProcessResponse`
(`schemas.py`)."""
entities: list[Entity]
intent: str | None
score: float
class UnsupportedLocaleError(ValueError):
"""`locale` ne correspond à aucun modèle spaCy connu (voir
`SUPPORTED_LOCALES`) — distinct d'une locale simplement "pas encore
entraînée" (`LocalePipeline.is_trained is False`), qui n'est pas une
erreur (voir `process()`)."""
class LocalePipeline:
"""Pipeline spaCy (NER par phrases + textcat) pour une locale donnée.
Un `PipelineRegistry` (voir `pipeline_registry.py`) en détient une
instance par locale supportée.
"""
def __init__(self, locale: str) -> None:
if locale not in SUPPORTED_LOCALES:
raise UnsupportedLocaleError(f"Unsupported locale: {locale!r}")
self._locale = locale
self._model_name = SUPPORTED_LOCALES[locale]
# `None` tant que `preload()` n'a pas tourné.
self._base_nlp: Language | None = None
# `None` tant qu'aucun `train()` n'a réussi — `process()` traite ça
# comme "rien à trouver" plutôt qu'une erreur, exactement le
# comportement testé côté `apps/api` pour "une locale jamais
# entraînée".
self._matcher: PhraseMatcher | None = None
self._trained = False
@property
def is_trained(self) -> bool:
return self._trained
def preload(self) -> None:
"""Charge le modèle spaCy de base (tokenizer + vecteurs) et le
composant `diacritics_normalizer` — idempotent, sans effet si déjà
chargé. Appelé au démarrage du process pour les deux locales
connues (voir `main.py`), pas paresseusement au premier `train()`.
"""
if self._base_nlp is not None:
return
nlp = spacy.load(self._model_name, exclude=_EXCLUDED_COMPONENTS)
nlp.add_pipe("diacritics_normalizer", first=True)
self._base_nlp = nlp
def train(self, entries: list[TrainEntry]) -> tuple[int, int, int]:
"""Reconstruit le `textcat` et le `PhraseMatcher` de ce pipeline à
partir de `entries` (le tokenizer/les vecteurs restent ceux chargés
par `preload()`). Retourne `(label_count, utterance_count,
synonym_count)` pour la réponse `/v1/train`.
`entries` vide retombe à `is_trained == False` plutôt que de lever —
un appelant qui n'a rien à entraîner pour cette locale obtient le
même comportement que "jamais entraîné", pas une erreur 500.
"""
self.preload()
assert self._base_nlp is not None # garanti par preload() ci-dessus
if _TEXTCAT_PIPE_NAME in self._base_nlp.pipe_names:
self._base_nlp.remove_pipe(_TEXTCAT_PIPE_NAME)
if not entries:
self._matcher = None
self._trained = False
return (0, 0, 0)
nlp = self._base_nlp
# `nlp.make_doc()` ne fait tourner *que* le tokenizer, pas les
# composants du pipeline — le `diacritics_normalizer` ajouté par
# `preload()` ne tournerait donc jamais sur les `Doc` de patterns
# s'ils n'étaient construits qu'avec `make_doc()`, alors que
# `process()` appelle `nlp(text)` (le pipeline complet) sur le texte
# cible. Sans ce correctif, un synonyme accentué comme "préchauffer"
# n'aurait jamais matché "PRÉCHAUFFER"/"Préchauffer" : trouvé en
# calibrant contre les cas exacts de `tech-step-matcher.test.ts`
# (fr, la locale la plus concernée par les accents) — un synonyme
# sans diacritique comme "faire fondre" masquait le bug en semblant
# fonctionner par coïncidence. Appliquer explicitement le même
# composant aux deux côtés garantit qu'ils passent par la même
# normalisation.
diacritics_normalizer = nlp.get_pipe("diacritics_normalizer")
matcher = PhraseMatcher(nlp.vocab, attr="NORM")
synonym_count = 0
for entry in entries:
if not entry.synonyms:
continue
patterns = [diacritics_normalizer(nlp.make_doc(synonym)) for synonym in entry.synonyms]
matcher.add(entry.uid, patterns)
synonym_count += len(entry.synonyms)
# `textcat` (exclusive_classes) exige au moins deux labels (voir
# spaCy's error E867) — jamais un problème avec le vrai corpus
# (`TECH_STEP_TRAINING_DATA` a ~27 techniques), mais un `entries` à
# un seul élément resterait structurellement valide pour le NER
# seul : ne pas planter, juste ne pas construire de textcat du tout
# (`process()` retombe alors sur `intent: null` via son garde
# `if not cats`, exactement comme "rien à classifier").
examples: list[Example] = []
if len(entries) >= 2:
textcat = nlp.add_pipe(
_TEXTCAT_PIPE_NAME,
config={
"model": {
"@architectures": "spacy.TextCatBOW.v3",
"exclusive_classes": True,
"ngram_size": 1,
"no_output_layer": False,
},
},
)
for entry in entries:
textcat.add_label(entry.uid)
for entry in entries:
for utterance in entry.utterances:
doc = nlp.make_doc(utterance)
cats = {other.uid: 0.0 for other in entries}
cats[entry.uid] = 1.0
examples.append(Example.from_dict(doc, {"cats": cats}))
rng = random.Random(_TRAINING_SEED)
if examples:
optimizer = nlp.initialize(lambda: examples)
for _ in range(_TRAINING_ITERATIONS):
rng.shuffle(examples)
for batch in minibatch(examples, size=_TRAINING_BATCH_SIZE):
nlp.update(batch, sgd=optimizer, drop=_TRAINING_DROPOUT)
else:
# Des `entries` avec des `uid` mais aucune `utterance` nulle
# part (corpus incomplet) : le textcat a des labels mais rien
# pour apprendre à les distinguer — toujours initialisé pour
# rester un pipeline valide ; `process()` renverra alors un
# score ~uniforme entre labels. Ce n'est pas ce module qui doit
# juger la qualité du corpus reçu (voir `tech-step-eval-runner.ts`
# côté apps/api pour ce rôle).
nlp.initialize()
self._matcher = matcher
self._trained = True
return (len(entries), len(examples), synonym_count)
def process(self, text: str) -> ProcessResult:
"""Reproduit la forme de `NlpManager.process(locale, text)` : les
entités candidates (NER) et le verdict du classifieur d'intention
sur `text` tel quel — que ce soit la description complète ou une
clause déjà découpée côté `apps/api`, ce module ne le sait pas et ne
s'en soucie pas, exactement comme l'ancien `NlpManager`.
"""
if not self._trained or self._base_nlp is None or self._matcher is None or not text.strip():
return ProcessResult(entities=[], intent=None, score=0.0)
doc = self._base_nlp(text)
# A technique's own synonym list can legitimately contain one phrase
# nested inside another (`melt`'s "fondre" is a literal substring of
# its own "faire fondre") — the `PhraseMatcher` reports *both* as
# separate matches at overlapping positions, which without
# resolution would hand `splitIntoClauses` (apps/api) two candidates
# for what a human reads as one mention, producing the same
# techStepId twice in the final result. `filter_spans` keeps only
# the longest match at each position (so "faire fondre" wins over
# the "fondre" it contains) — found by a real regression in
# `tech-step-matcher.test.ts`'s "detects several distinct
# techniques..." case once this service replaced node-nlp (which
# apparently resolved this internally; nothing here recreates that
# by choice, `filter_spans` is spaCy's own documented tool for
# exactly this "one span per position" problem, e.g. as used for
# NER-style outputs).
matched_spans = [
Span(doc, start, end, label=match_id) for match_id, start, end in self._matcher(doc)
]
entities = sorted(
(
Entity(uid=self._base_nlp.vocab.strings[span.label], start=span.start_char, end=span.end_char)
for span in filter_spans(matched_spans)
),
key=lambda entity: entity.start,
)
cats = doc.cats
if not cats:
return ProcessResult(entities=entities, intent=None, score=0.0)
intent = max(cats, key=cats.get)
return ProcessResult(entities=entities, intent=intent, score=cats[intent])