"""Détient un `LocalePipeline` par locale supportée — le seul état mutable partagé du process (une instance vit pour toute la durée de vie d'`uvicorn`, montée sur `app.state`, voir `main.py`). Volontairement une classe "registre" séparée de `LocalePipeline` lui-même : `LocalePipeline` ne connaît qu'une seule locale, ce module route `process` vers la bonne instance selon le `locale` reçu dans la requête — même séparation de responsabilité que `TechStepClassifierService` (une seule instance, un seul `NlpManager` multi-langues) avait implicitement via node-nlp, explicitée ici puisque spaCy charge un modèle par langue. """ import logging from .locale_pipeline import SUPPORTED_LOCALES, LocalePipeline, ProcessResult, TrainEntry from .training_data import entries_for_locale logger = logging.getLogger(__name__) class PipelineRegistry: def __init__(self) -> None: self._pipelines: dict[str, LocalePipeline] = { locale: LocalePipeline(locale) for locale in SUPPORTED_LOCALES } def initialize(self) -> None: """Charge le modèle spaCy de base *et* entraîne chaque locale connue depuis `training_data.TECH_STEP_TRAINING_DATA` — appelé une fois au démarrage du process (`main.py`'s `lifespan`), avant que `uvicorn` n'accepte de requêtes. Contrairement à la version précédente de ce service (où `apps/api` poussait le corpus via `POST /v1/train` à son propre warm-up), ce service est maintenant entièrement autonome : `apps/api` ne connaît plus aucune technique, seulement le résultat de `POST /v1/process`. `GET /health` ne répond `200` qu'une fois cette méthode terminée (chargement *et* entraînement) — pas seulement le chargement — pour que `docker-compose.yml`'s `depends_on: ... condition: service_healthy` (et la boucle d'attente équivalente en CI) ne laisse jamais `apps/api` démarrer face à un service qui répondrait mais ne saurait encore rien détecter. """ logger.info("tech-step NLP initializing pipelines", extra={"locales": list(self._pipelines)}) for locale, pipeline in self._pipelines.items(): pipeline.preload() entries = [TrainEntry(**entry) for entry in entries_for_locale(locale)] label_count, example_count, synonym_count = pipeline.train(entries) logger.info( "tech-step NLP pipeline trained", extra={ "locale": locale, "labelCount": label_count, # Nombre réel d'exemples donnés au textcat (utterances # *et* synonyms combinés — voir `LocalePipeline.train`), # pas seulement le compte d'`utterances` du corpus. "exampleCount": example_count, "synonymCount": synonym_count, }, ) logger.info("tech-step NLP pipelines ready", extra={"locales": list(self._pipelines)}) def process(self, locale: str, text: str) -> ProcessResult: pipeline = self._pipelines.get(locale) if pipeline is None: # Une locale que ce service ne sait structurellement pas # charger (pas de modèle spaCy connu) se comporte comme une # locale "jamais entraînée" côté `process` — reproduit le test # `apps/api` existant ("returns an empty sequence for a locale # nothing was trained on"), qui ne distingue pas les deux cas. return ProcessResult(entities=[], intent=None, score=0.0) return pipeline.process(text) registry = PipelineRegistry()