Dépôt compagnon du module Méthodologie de projet (parcours MLOps / LLMOps).
Un jardin partagé municipal reçoit des messages d'adhérents (fuite d'eau,
outil cassé, réservation de parcelle…). Ce projet propose automatiquement
une catégorie (modèle ML mocké) et une priorité (appel LLM mocké)
pour chaque message, avec un refus propre (needs_review) quand le message
est vide ou hors périmètre.
Tout est déterministe et hors ligne : aucune clé d'API réelle, aucun réseau, aucun entraînement. Le but est de montrer la méthode, pas le modèle.
Le dépôt évolue par itérations additives : chaque phase ajoute des briques, aucune ne réécrit les précédentes. Un tag Git marque la fin de chaque phase :
| Tag | Phase | Semaine | Contenu ajouté |
|---|---|---|---|
p1 |
Phase 1 — Make it work | S8 | le plus petit slice propre (CLI + mocks + secrets hors code) |
p2 |
Phase 2 — Make it explicit | S12 | contrats Pydantic, tests, artefact modèle (train), Docker par cycle de vie |
p3 |
Phase 3 — Make it reliable | S16 | API FastAPI, timeout + retry, évaluation batch, métrique |
p4 |
Phase 4 — Make it clean & operable | S22 | ADR, runbook, détection de drift (job), déploiement + rollback |
git checkout p1 # voir le dépôt tel qu'il était en fin de Phase 1
git checkout p2 # ... et ainsi de suite : p3, p4
git checkout main # revenir à la version finalePrérequis : uv (et Docker à partir de la Phase 2).
# 1. Configurer les variables d'environnement (secrets hors du code)
cp .env.example .env
# 2. Installer les dépendances (aucune en Phase 1)
uv sync # crée .venv et installe les dépendances depuis uv.lock
# 3. Classifier un message
uv run python -m src.cli "fuite d'eau sous le robinet"
# {"category": "plomberie", "priority": "haute", "status": "ok"}
uv run python -m src.cli "bonjour, où est la clé de la cabane ?"
# {"category": "autre", "priority": "basse", "status": "ok"}
uv run python -m src.cli "???"
# {"category": null, "priority": null, "status": "needs_review"}Le dépôt suit la règle « un service par cycle de vie, pas par fonction » :
train tourne parfois puis s'arrête (il produit l'artefact du modèle) ;
l'application, elle, se relance à la demande. Les deux ne communiquent
que par l'artefact models/category_model_v1.json.
# Les tests passent sans aucune configuration
uv run pytest
# « Entraîner » le modèle : produit models/category_model_v1.json puis s'arrête
docker compose run train
# Relance reproductible par un tiers
docker compose up --build appLe service app devient api : troisième cycle de vie visible.
docker compose up --build -d api # tourne TOUJOURS
docker compose run evaluate # PLANIFIÉ : trace un run puis s'arrête
docker compose ps -a # Up / Exited(0) : les cycles de vie sous vos yeux
docker compose down # nettoyage quand vous avez fini
curl -s -X POST http://localhost:8000/classify \
-H "Content-Type: application/json" \
-d '{"message": "fuite d'"'"'eau sous le robinet"}'
# {"request_id": "…", "category": "plomberie", "priority": "haute", "status": "ok"}
curl -s http://localhost:8000/health
# {"status": "ok"}
curl -s http://localhost:8000/metrics
# {"requests": 1, "latency_p95_ms": …, "threshold_ms": 500}Seuils documentés : latence p95 de /classify < 500 ms (voir /metrics) ;
accuracy d'évaluation ≥ 0,8 (sinon evaluate sort en code 1 et la version ne
doit pas être promue). Le request_id présent dans chaque réponse et chaque
log permet de retrouver la requête fautive.
La panne du provider est simulée (pas subie) dans
tests/test_reliability.py : timeout → 503 + request_id, retry borné
à 1 essai + 2 tentatives, entrée invalide → 422.
# Détection de drift : distribution récente vs jeu de référence
docker compose run --rm drift
# DRIFT ALERT si la part d'une catégorie s'écarte de plus de 25 points
# Déployer une version taguée (build + smoke test + bascule de l'api)
./scripts/deploy.sh deploy v1.1
# Rollback en moins de 5 minutes (image précédente, pas de rebuild)
./scripts/deploy.sh rollbackLe code de src/ n'a pas été réorganisé en domain/ports/adapters :
aucune friction ne le justifie encore. La décision, le raisonnement et
les symptômes qui la feraient réviser sont documentés dans
docs/adr_001_structuration.md.
Les procédures d'exploitation (alertes, diagnostic, rollback) sont dans
docs/runbook.md.
Ce dépôt reste volontairement léger : runs.jsonl au lieu de MLflow, un
JSON au lieu d'un model registry, un script stdlib au lieu d'Evidently.
Pourquoi ? Pour que vous ressentiez la friction avant de choisir l'outil. Quand votre projet dépassera ~10 runs, comparer des lignes de JSONL deviendra pénible — vous saurez alors POURQUOI vous installez MLflow, pas juste parce qu'il est dans le planning. Même logique pour DVC (données trop lourdes pour Git), Langfuse (traces d'appels LLM), Airflow (batch multi-étapes) et Prometheus/Grafana (tendances multi-jours).
Ce dépôt n'est donc pas un standard à reproduire à l'identique : c'est le point de départ minimal qui rend chaque outil justifiable le jour venu.
jardin-classify/
├── .env.example # template des variables attendues (le .env reste local)
├── .gitignore # exclut .env, caches Python
├── README.md
├── pyproject.toml # dépendances (uv), ajoutées phase par phase
├── uv.lock # versions exactes, reproductibles partout
├── Dockerfile # (p2) image commune aux services
├── docker-compose.yml # (p2) un service par cycle de vie — (p3) api + train + evaluate
├── .dockerignore # (p2) jamais de .env dans l'image
├── prompts/
│ └── classify_v1.txt # (p2) prompt versionné
├── data/
│ ├── test_set_v1.jsonl # (p2) jeu de test versionné (9 cas dont vide + ambigu)
│ └── production_sample.jsonl # (p4) messages récents pour le drift
├── experiments/
│ └── runs.jsonl # (p2) historique léger des runs (params + métrique)
├── monitoring/
│ └── drift_check.py # (p4) compare la distribution récente à la référence
├── docs/
│ ├── adr_001_structuration.md # (p4) pourquoi on ne restructure PAS src/
│ └── runbook.md # (p4) alertes, diagnostic, rollback
├── scripts/
│ └── deploy.sh # (p4) déploiement + rollback < 5 min
├── models/
│ └── category_model_v1.json # (p2) ARTEFACT produit par src/train.py
├── src/
│ ├── __init__.py
│ ├── cli.py # point d'entrée CLI
│ ├── classify.py # orchestre ML + LLM + refus
│ ├── train.py # (p2) job d'entraînement mocké -> artefact
│ ├── category_model.py # MLCategoryModel : charge l'artefact (modifié p2)
│ ├── llm_client.py # LLMClient mocké — (p3) + timeout, retry borné
│ ├── config.py # lecture des variables d'environnement
│ ├── contracts.py # (p2) contrats Pydantic entrée/sortie
│ ├── api.py # (p3) FastAPI : /classify, /health, /metrics
│ └── evaluate.py # (p3) évaluation batch -> run dans runs.jsonl
└── tests/
├── test_characterization.py # (p2) protège le contrat observable
├── test_train.py # (p2) l'artefact produit est chargeable
└── test_reliability.py # (p3) timeout simulé, retry borné, 422