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:
parent
1bf97ce6de
commit
74a0052431
9 changed files with 214 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in a new issue