feat(recipes): journalise chaque input/output du pipeline NLP

Ajoute un logging JSON structure (meme convention que LoggerService cote
apps/api) a services/tech-step-intent-service : chaque appel
POST /v1/process journalise locale/texte en entree et
entites/intent/score en sortie, chaque POST /v1/train journalise les uid
entraines et les compteurs resultants. Chatter interne de spaCy mis a
WARNING pour ne pas noyer ces lignes.

Bug trouve et corrige en verifiant les octets bruts d'un log reel (pas
juste son affichage terminal) : l'encodage par defaut de sys.stdout sur
Windows produisait de vrais octets UTF-8 invalides pour tout texte
accentue journalise (le francais des etapes de recette) — corrige par
sys.stdout.reconfigure(encoding="utf-8") au demarrage.

LOG_LEVEL configurable (INFO par defaut), documente dans le README du
service et .env.example.

Verifie : 30/30 pytest (3 nouveaux tests sur le formateur JSON), smoke
test HTTP reel confirmant au niveau des octets que les caracteres
accentues sont preserves, lint complet du monorepo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-25 21:34:40 +02:00
parent 1bf97ce6de
commit 74a0052431
9 changed files with 214 additions and 0 deletions

View file

@ -3,3 +3,9 @@
# apps/api/.env (voir apps/api/src/config/env.ts). Requis, pas de valeur par # apps/api/.env (voir apps/api/src/config/env.ts). Requis, pas de valeur par
# défaut : `Settings` (intent_service/config.py) refuse de démarrer sans. # défaut : `Settings` (intent_service/config.py) refuse de démarrer sans.
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optionnel — niveau du logging JSON structuré (intent_service/logging_config.py).
# INFO par défaut : chaque appel /v1/process et /v1/train journalise son
# input (locale/texte, entrées d'entraînement) et son output (entités,
# intent, score) à ce niveau.
# LOG_LEVEL=INFO

View file

@ -56,6 +56,22 @@ Voir `intent_service/schemas.py` pour le détail exact. En résumé :
(voir `intent_service/security.py`), qui doit matcher `INTENT_SERVICE_SECRET` (voir `intent_service/security.py`), qui doit matcher `INTENT_SERVICE_SECRET`
côté `apps/api`. côté `apps/api`.
## 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` et `routes/train.py` journalisent
chaque appel avec son input et son output complets :
```json
{"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 ## Setup
Ce service utilise [`uv`](https://docs.astral.sh/uv/) pour ses dépendances Ce service utilise [`uv`](https://docs.astral.sh/uv/) pour ses dépendances

View file

@ -40,5 +40,12 @@ class Settings(BaseSettings):
# jamais lu depuis `Settings` — une variable d'env dupliquant ce que la # jamais lu depuis `Settings` — une variable d'env dupliquant ce que la
# commande de démarrage fixe déjà explicitement n'aurait aucun lecteur. # commande de démarrage fixe déjà explicitement n'aurait aucun lecteur.
# Niveau du logging structuré (`logging_config.py`) — voir ce module pour
# le format. `INFO` par défaut : c'est à ce niveau que `routes/process.py`
# et `routes/train.py` journalisent chaque input/output du pipeline NLP,
# pour qu'un déploiement par défaut les voie sans configuration
# supplémentaire (`docker logs`/Portainer).
log_level: str = "INFO"
settings = Settings() settings = Settings()

View file

@ -0,0 +1,76 @@
"""Logging structuré — même convention que `LoggerService` côté `apps/api`
(`apps/api/src/lib/logger.service.ts`) : une ligne JSON par évènement
(`timestamp`, `level`, `message`, + le reste des champs fournis fusionné),
jamais du texte libre, pour rester grep/parse-able par `docker logs`/
Portainer ou un agrégateur de logs cohérent avec le reste du repo plutôt
qu'un format propre à ce seul service.
Configuré une fois au démarrage (`main.py`) plutôt que par un `print()` ad
hoc dans chaque route `routes/process.py`/`routes/train.py` appellent
`logging.getLogger(__name__)` normalement, ce module ne fait que brancher le
formateur JSON sur la racine du logging Python.
"""
import json
import logging
import sys
from datetime import UTC, datetime
from typing import Any
class _JsonFormatter(logging.Formatter):
"""Sérialise chaque `LogRecord` en une ligne JSON. Les champs
supplémentaires passés via `logger.info(msg, extra={...})` sont fusionnés
tels quels dans l'objet — c'est ce que `routes/process.py` utilise pour
joindre `locale`/`text`/`entities`/`intent`/`score` à la ligne."""
# Attributs standards de `LogRecord` — tout le reste posé sur le record
# (via `extra=`) est un champ métier ajouté par l'appelant, à fusionner
# dans la sortie JSON.
_STANDARD_ATTRS = frozenset(logging.LogRecord("", 0, "", 0, "", None, None).__dict__.keys())
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
"level": record.levelname.lower(),
"message": record.getMessage(),
}
extra_fields = {
key: value for key, value in record.__dict__.items() if key not in self._STANDARD_ATTRS
}
payload.update(extra_fields)
if record.exc_info:
payload["error"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, default=str)
def configure_logging(level: str) -> None:
"""Branche le formateur JSON sur la racine du logging Python — appelé
une fois au démarrage (`main.py`), avant que `routes/*` ne journalisent
quoi que ce soit."""
# L'encodage par défaut de `sys.stdout` suit la locale de l'OS/console,
# pas forcément UTF-8 — sur Windows en particulier, garder ce défaut
# produit de vrais octets invalides (pas juste un affichage terminal
# trompeur) pour tout texte accentué journalisé par `routes/process.py`
# (le texte réel des étapes de recette, en français) — trouvé en
# vérifiant les octets bruts d'un log réel, pas juste son affichage.
# `reconfigure` existe sur `sys.stdout` dans toute exécution Python
# normale (pas dans certains contextes embarqués/redirigés exotiques) —
# protégé par `hasattr` pour ne jamais faire planter le démarrage pour un
# souci de confort d'affichage.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(_JsonFormatter())
root = logging.getLogger()
root.handlers = [handler]
root.setLevel(level)
# spaCy/thinc journalisent leur propre chatter interne ("Created
# vocabulary", "Finished initializing nlp object"...) sur le logger
# `"spacy"`, qui propage jusqu'à la racine et se retrouverait donc
# mélangé aux lignes input/output de `routes/process.py`/`routes/train.py`
# — ce sont ces dernières que ce service existe pour rendre visibles, pas
# le détail interne de spaCy. `WARNING` laisse quand même remonter un
# vrai problème (dépréciation, échec partiel) sans le bruit `INFO`.
logging.getLogger("spacy").setLevel(logging.WARNING)

View file

@ -12,9 +12,16 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from .config import settings
from .logging_config import configure_logging
from .pipeline_registry import registry from .pipeline_registry import registry
from .routes import health, process, train from .routes import health, process, train
# Avant tout le reste : `routes/process.py`/`routes/train.py` journalisent
# dès la première requête, `preload_all()` ci-dessous journalise aussi (voir
# `pipeline_registry.py`) — le formateur JSON doit déjà être en place.
configure_logging(settings.log_level)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):

View file

@ -10,8 +10,12 @@ requête — même séparation de responsabilité que `TechStepClassifierService
via node-nlp, explicitée ici puisque spaCy charge un modèle par langue. 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, UnsupportedLocaleError from .locale_pipeline import SUPPORTED_LOCALES, LocalePipeline, ProcessResult, TrainEntry, UnsupportedLocaleError
logger = logging.getLogger(__name__)
class PipelineRegistry: class PipelineRegistry:
def __init__(self) -> None: def __init__(self) -> None:
@ -24,8 +28,10 @@ class PipelineRegistry:
une fois au démarrage du process (`main.py`), pas paresseusement au une fois au démarrage du process (`main.py`), pas paresseusement au
premier appel, pour que `GET /health` ne réponde `200` qu'une fois premier appel, pour que `GET /health` ne réponde `200` qu'une fois
ce coût payé (voir `LocalePipeline.preload`).""" ce coût payé (voir `LocalePipeline.preload`)."""
logger.info("tech-step NLP preloading base pipelines", extra={"locales": list(self._pipelines)})
for pipeline in self._pipelines.values(): for pipeline in self._pipelines.values():
pipeline.preload() pipeline.preload()
logger.info("tech-step NLP base pipelines ready", extra={"locales": list(self._pipelines)})
def train(self, locale: str, entries: list[TrainEntry]) -> tuple[int, int, int]: def train(self, locale: str, entries: list[TrainEntry]) -> tuple[int, int, int]:
pipeline = self._pipelines.get(locale) pipeline = self._pipelines.get(locale)

View file

@ -4,18 +4,38 @@ en remplacement direct de l'ancien `NlpManager.process(locale, text)`. Voir
`text` vide -> résultat vide, jamais une erreur). `text` vide -> résultat vide, jamais une erreur).
""" """
import logging
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from ..pipeline_registry import registry from ..pipeline_registry import registry
from ..schemas import EntityPayload, ProcessRequest, ProcessResponse from ..schemas import EntityPayload, ProcessRequest, ProcessResponse
from ..security import require_valid_secret from ..security import require_valid_secret
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_valid_secret)]) router = APIRouter(dependencies=[Depends(require_valid_secret)])
@router.post("/v1/process", response_model=ProcessResponse) @router.post("/v1/process", response_model=ProcessResponse)
def process(request: ProcessRequest) -> ProcessResponse: def process(request: ProcessRequest) -> ProcessResponse:
result = registry.process(request.locale, request.text) result = registry.process(request.locale, request.text)
# Une ligne par appel — input (`locale`/`text`) et output (`entities`/
# `intent`/`score`) réunis dans la même ligne JSON, pour pouvoir suivre
# exactement ce que le pipeline a décidé pour un texte donné (voir
# `logging_config.py` pour le format).
logger.info(
"tech-step NLP process",
extra={
"locale": request.locale,
"text": request.text,
"entities": [{"uid": entity.uid, "start": entity.start, "end": entity.end} for entity in result.entities],
"intent": result.intent,
"score": result.score,
},
)
return ProcessResponse( return ProcessResponse(
entities=[EntityPayload(uid=entity.uid, start=entity.start, end=entity.end) for entity in result.entities], entities=[EntityPayload(uid=entity.uid, start=entity.start, end=entity.end) for entity in result.entities],
intent=result.intent, intent=result.intent,

View file

@ -5,6 +5,8 @@ locale. Voir `LocalePipeline.train` pour ce que "reconstruit à neuf" signifie
concrètement. concrètement.
""" """
import logging
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from ..locale_pipeline import TrainEntry, UnsupportedLocaleError from ..locale_pipeline import TrainEntry, UnsupportedLocaleError
@ -12,6 +14,8 @@ from ..pipeline_registry import registry
from ..schemas import TrainRequest, TrainResponse from ..schemas import TrainRequest, TrainResponse
from ..security import require_valid_secret from ..security import require_valid_secret
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_valid_secret)]) router = APIRouter(dependencies=[Depends(require_valid_secret)])
@ -21,15 +25,31 @@ def train(request: TrainRequest) -> TrainResponse:
TrainEntry(uid=entry.uid, synonyms=entry.synonyms, utterances=entry.utterances) TrainEntry(uid=entry.uid, synonyms=entry.synonyms, utterances=entry.utterances)
for entry in request.entries for entry in request.entries
] ]
logger.info(
"tech-step NLP train starting",
extra={"locale": request.locale, "uids": [entry.uid for entry in entries]},
)
try: try:
label_count, utterance_count, synonym_count = registry.train(request.locale, entries) label_count, utterance_count, synonym_count = registry.train(request.locale, entries)
except UnsupportedLocaleError as err: except UnsupportedLocaleError as err:
logger.warning("tech-step NLP train rejected", extra={"locale": request.locale, "error": str(err)})
# 422, pas 500 : une locale non supportée dans une requête de # 422, pas 500 : une locale non supportée dans une requête de
# `apps/api` est une erreur de configuration/version-skew entre les # `apps/api` est une erreur de configuration/version-skew entre les
# deux services (voir le contrat documenté dans le plan de # deux services (voir le contrat documenté dans le plan de
# migration), pas un échec inattendu du service lui-même. # migration), pas un échec inattendu du service lui-même.
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(err)) from err raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(err)) from err
logger.info(
"tech-step NLP train done",
extra={
"locale": request.locale,
"labelCount": label_count,
"utteranceCount": utterance_count,
"synonymCount": synonym_count,
},
)
return TrainResponse( return TrainResponse(
locale=request.locale, locale=request.locale,
label_count=label_count, label_count=label_count,

View file

@ -0,0 +1,56 @@
"""Vérifie le format des lignes de log produites par
`logging_config._JsonFormatter` ce que `routes/process.py`/`routes/train.py`
utilisent pour journaliser l'input/l'output de chaque appel NLP."""
import json
import logging
from intent_service.logging_config import _JsonFormatter
def _make_record(**extra: object) -> logging.LogRecord:
record = logging.LogRecord(
name="intent_service.routes.process",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg="tech-step NLP process",
args=(),
exc_info=None,
)
for key, value in extra.items():
setattr(record, key, value)
return record
def test_formats_a_record_as_json_with_timestamp_level_and_message():
record = _make_record()
payload = json.loads(_JsonFormatter().format(record))
assert payload["message"] == "tech-step NLP process"
assert payload["level"] == "info"
assert "timestamp" in payload
def test_merges_extra_fields_into_the_top_level_payload():
record = _make_record(
locale="fr",
text="faire fondre le beurre",
entities=[{"uid": "melt", "start": 0, "end": 12}],
intent="melt",
score=0.93,
)
payload = json.loads(_JsonFormatter().format(record))
assert payload["locale"] == "fr"
assert payload["text"] == "faire fondre le beurre"
assert payload["entities"] == [{"uid": "melt", "start": 0, "end": 12}]
assert payload["intent"] == "melt"
assert payload["score"] == 0.93
def test_preserves_accented_characters_literally_not_escaped():
# `ensure_ascii=False` — un `docker logs` humain doit pouvoir lire
# directement "poêle", pas "poêle".
record = _make_record(text="Dans une poêle chaude")
line = _JsonFormatter().format(record)
assert "poêle" in line
assert "\\u00ea" not in line