Aller au contenu
English

Séparer les responsabilités

  • Leçon 6
  • 40 min
  • Module 2 : Créer un petit service FastAPI

Ce que vous saurez faire

Divisez l’API en parties qui ont chacune une tâche : la couche HTTP, les modèles de données et le classificateur. Les cours suivants remplacent le classificateur à mots-clés par un modèle entraîné. Avec cette structure, ce changement touche un seul module. L’API qui l’entoure reste la même.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 40 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

Testé avec Python 3.14.6 et FastAPI 0.142.2 sur macOS, et Pyodide 314.0 (navigateur).

Une responsabilité est un type de tâche. main.py en mélange actuellement trois :

Responsabilité Question à laquelle elle répond Module
HTTP Quelle méthode et quel chemin? Quel code d’état? routes.py
Données À quoi ressemblent un billet de soutien valide et un résultat valide? models.py
Classification Quelle catégorie et quelle priorité pour ce texte? classifier.py

Le classificateur ne doit rien savoir de HTTP. Il reçoit du texte et retourne une prédiction, comme les fonctions pures dans Le contrat d’une fonction. Vous pouvez ainsi le vérifier sans serveur et le remplacer sans modifier les routes. main.py sert seulement à assembler les parties pour créer l’application.

Le classificateur reçoit aussi de meilleures règles. Il compte les mots-clés de chaque catégorie et choisit celle qui en a le plus. Il marque un billet comme urgent (priorité 1) quand celui-ci contient un mot qui signale une urgence. Le résultat est une petite dataclass (classe de données), Prediction, sans HTTP ni Pydantic.

ticket_api/classifier.py
from dataclasses import dataclass
KEYWORDS = {
"billing": ("invoice", "charged", "refund", "payment"),
"login": ("password", "sign in", "log in", "two-factor", "locked out"),
"shipping": ("parcel", "delivery", "courier", "tracking"),
"account": ("account", "company name", "user"),
}
URGENT_WORDS = ("cannot", "locked out", "charged twice", "lost")
@dataclass(frozen=True)
class Prediction:
category: str
priority: int
confidence: float
class KeywordClassifier:
"""A deterministic stand-in for a trained model: the same text always
gives the same prediction."""
version = "keywords-1.0"
def predict(self, text: str) -> Prediction:
lowered = text.lower()
scores = {
category: sum(word in lowered for word in words)
for category, words in KEYWORDS.items()
}
best = max(scores, key=scores.get)
if scores[best] == 0:
return Prediction(category="other", priority=3, confidence=0.2)
urgent = any(word in lowered for word in URGENT_WORDS)
confidence = min(0.5 + 0.2 * scores[best], 0.95)
return Prediction(
category=best, priority=1 if urgent else 2, confidence=round(confidence, 2)
)
Partie Signification
sum(word in lowered for word in words) Compte les mots-clés d’une catégorie dans le texte. True (vrai) compte pour 1.
max(scores, key=scores.get) La catégorie avec le compte le plus élevé. En cas d’égalité, la première dans KEYWORDS gagne. Le résultat reste donc toujours le même.
confidence 0,7 pour un mot-clé, 0,9 pour deux, jamais plus de 0,95. Un modèle entraîné donne une vraie probabilité. Celui-ci en imite seulement la forme.
version Chaque réponse indique quel classificateur l’a produite. Quand le classificateur change, les clients peuvent le voir.

Le classificateur utilise seulement Python. Vous pouvez donc l’essayer ici même :

Exécutez du Python dans votre navigateur

Prédisez les résultats, puis exécutez le classificateur

Avant de l’exécuter, prédisez les trois résultats. Le deuxième texte contient un mot lié à la facturation et un mot lié à l’expédition. Quelle catégorie gagne, et pourquoi? Ajoutez ensuite votre propre texte.

Dépendances : fournir son classificateur à la route

Section intitulée « Dépendances : fournir son classificateur à la route »

La route a besoin d’un objet classificateur. Elle pourrait en créer un elle-même ou importer un objet global. Mais un test, ou une version future avec un vrai modèle, ne pourrait alors pas lui en fournir un autre. FastAPI offre une meilleure façon : une dépendance. Vous écrivez une fonction qui fournit l’objet, et la route le demande avec Depends :

ticket_api/routes.py
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from ticket_api.classifier import KeywordClassifier
from ticket_api.models import Classification, TicketIn
router = APIRouter()
def get_classifier(request: Request) -> KeywordClassifier:
return request.app.state.classifier
@router.post("/classify")
def classify(
ticket: TicketIn,
classifier: Annotated[KeywordClassifier, Depends(get_classifier)],
) -> Classification:
prediction = classifier.predict(f"{ticket.subject}\n{ticket.body}")
return Classification(
category=prediction.category,
priority=prediction.priority,
confidence=prediction.confidence,
model_version=classifier.version,
)
Partie Signification
APIRouter() Un groupe de routes dans son propre module. main.py l’ajoute à l’application.
get_classifier(request) La dépendance. Elle retourne l’unique classificateur que l’application garde dans app.state.
Annotated[KeywordClassifier, Depends(get_classifier)] « Ce paramètre est un KeywordClassifier, et FastAPI l’obtient en appelant get_classifier. »

Pour chaque requête, FastAPI appelle get_classifier et transmet le résultat à classify. La route ne sait pas d’où vient le classificateur. C’est l’injection de dépendances : l’appelant fournit ce dont une fonction a besoin, plutôt que la fonction le crée. Dans le module 6, un test l’utilise pour fournir à la route un classificateur volontairement en panne.

Les modèles passent dans models.py sans changement, et main.py devient court :

ticket_api/main.py
from fastapi import FastAPI
from ticket_api.classifier import KeywordClassifier
from ticket_api.routes import router
app = FastAPI(title="Ticket Classifier API")
app.state.classifier = KeywordClassifier()
app.include_router(router)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}

app.state.classifier = KeywordClassifier() crée le classificateur une seule fois, au démarrage de l’application. Un modèle entraîné peut prendre plusieurs secondes à charger. Il ne doit donc pas se recharger à chaque requête.

Une requête POST /classify avec un corps JSON arrive à FastAPI, qui vérifie le corps selon TicketIn. S'il est invalide, la réponse est 422 et indique tous les problèmes. S'il est valide, FastAPI appelle la fonction de route classify dans routes.py avec le TicketIn et le classificateur fourni par la dépendance get_classifier. La route envoie le texte à KeywordClassifier.predict dans classifier.py, reçoit une Prediction et retourne une Classification. Celle-ci devient la réponse JSON 200.
FastAPI vérifie l'entrée et fournit le classificateur. La route relie HTTP au classificateur. Le classificateur ne sait rien de HTTP.
Description textuelle du diagramme

Une requête POST /classify arrive avec un corps JSON. FastAPI vérifie le corps selon TicketIn. S’il est invalide, FastAPI répond 422 avec tous les problèmes, et la route ne s’exécute jamais. S’il est valide, FastAPI appelle get_classifier pour obtenir l’objet classificateur. Ensuite, il appelle la fonction de route classify dans routes.py avec le billet et le classificateur. La route envoie le texte à KeywordClassifier.predict dans classifier.py, qui retourne une Prediction. La route la transforme en Classification, et FastAPI l’envoie comme réponse JSON 200.

  1. Créez ticket_api/classifier.py avec le code de l’exemple détaillé.

  2. Créez ticket_api/models.py. Déplacez-y Category, TicketIn et Classification depuis main.py, avec les importations nécessaires (Literal, BaseModel, Field).

  3. Créez ticket_api/routes.py avec le code ci-dessus.

  4. Remplacez ticket_api/main.py par la version courte. Supprimez-en KEYWORDS : le classificateur a maintenant sa propre copie.

  5. Enregistrez et vérifiez le terminal du serveur. S’il affiche une ImportError (erreur d’importation), comparez les lignes d’importation avec le code ci-dessus.

  6. Envoyez de nouveau requests/billing.json :

    Sortie
    {"category":"billing","priority":1,"confidence":0.9,"model_version":"keywords-1.0"}

    Le résultat diffère de la première version du module 2 : deux mots de facturation donnent 0.9. L’expression « charged twice » (facturé deux fois) rend le billet urgent.

  7. Envoyez requests/empty-subject.json. Il donne encore 422. Les modèles n’ont pas changé, seulement leur module.

À vous de jouer : un point de terminaison de classification avec un schéma documenté

Section intitulée « À vous de jouer : un point de terminaison de classification avec un schéma documenté »

Le service de soutien veut une nouvelle catégorie, refund (remboursement), distincte de billing (facturation). Elle doit reconnaître les mots refund et money back (remboursement). Faites le changement dans un seul module, et choisissez lequel. Ajoutez refund à la fin de KEYWORDS. Ensuite :

  1. Prédisez le résultat pour {"subject": "Refund for the lost parcel"}, et vérifiez-le avec curl.
  2. Prédisez le résultat pour {"subject": "I want my money back"}, et vérifiez-le.
  3. Ouvrez http://127.0.0.1:8000/docs dans votre navigateur et trouvez Classification sous Schemas (schémas), au bas de la page. Est-ce que refund figure dans sa liste de catégories?
Afficher la réponse

Les mots-clés appartiennent au classificateur. C’est donc classifier.py qui change : ajoutez "refund": ("refund", "money back") et retirez "refund" de billing.

  1. shipping (expédition), priorité 1, état 200. Le texte contient un mot de shipping (parcel, colis) et un mot de refund. Il y a égalité, et shipping vient en premier dans KEYWORDS, donc cette catégorie gagne. lost (perdu) rend le billet urgent.
  2. 500 Internal Server Error (erreur interne du serveur). refund gagne, mais Classification accepte seulement les cinq valeurs de Category. Pydantic refuse donc le résultat, comme dans l’erreur fréquente de la leçon précédente.
  3. Non. Le schéma présente encore les cinq anciennes catégories.

Un deuxième module doit donc changer : ajoutez "refund" à Category dans models.py. Une nouvelle valeur de sortie change le contrat avec les clients. Il est donc normal de devoir modifier models.py, et pas seulement le classificateur. Annulez les deux changements avant le prochain module. Le reste du cours utilise les cinq catégories.

Erreur courante

ImportError après avoir déplacé du code

Symptôme : après avoir déplacé du code dans un nouveau module, le terminal du serveur affiche une erreur. Chaque requête échoue avec Couldn't connect to server (connexion au serveur impossible) :

ImportError: cannot import name 'TicketIn' from 'ticket_api.routes'

ou NameError: name 'Field' is not defined (le nom « Field » n’est pas défini).

Cause : une ligne d’importation pointe encore vers l’ancien module, ou le nouveau module utilise un nom sans l’importer. Le serveur de développement ne peut pas charger l’application. Rien n’écoute donc sur le port.

Solution :

  1. Lisez la dernière ligne de l’erreur : quel nom et quel module indique-t-elle?
  2. Vérifiez que le nom est défini dans le module indiqué par la ligne d’importation. TicketIn se trouve maintenant dans ticket_api.models.
  3. Vérifiez que chaque module importe tous les noms qu’il utilise, par exemple Field dans models.py.
  4. Enregistrez. Le serveur de développement réessaie automatiquement.

Vérifiez votre compréhension

Pourquoi le classificateur retourne-t-il une dataclass Prediction plutôt que le modèle Classification de models.py?

Afficher la réponse

Le classificateur ne doit pas dépendre de l’API. Classification fait partie du contrat HTTP : il contient le champ model_version et les limites sur lesquelles les clients comptent. Prediction est seulement la réponse du classificateur. Un nouveau classificateur peut être, par exemple, un modèle entraîné dans un cours suivant. En les gardant séparés, ce nouveau classificateur doit seulement retourner une Prediction. Le contrat avec les clients ne change pas.

Vérifiez votre compréhension

Vérification du module : envoyez un billet valide, un billet sans sujet et un billet avec un sujet de 200 caractères. Donnez le code d'état de chacun. Expliquez qui l'a décidé : FastAPI, votre route ou le classificateur.

Afficher la réponse

Le billet valide donne 200 : FastAPI a vérifié le corps, la route a interrogé le classificateur et retourné la Classification. Le sujet manquant donne 422, décidé par FastAPI selon TicketIn (missing, manquant, à ["body", "subject"]), avant l’exécution de la route. Le sujet de 200 caractères donne aussi 422, décidé par FastAPI (string_too_long, chaîne trop longue), à cause de max_length=120. Le classificateur décide seulement du contenu d’une réponse 200, jamais du code d’état.

Récapitulatif

  • Gardez chaque responsabilité dans son module : HTTP dans routes.py, les données dans models.py, la classification dans classifier.py. main.py assemble l’application.
  • Le classificateur utilise seulement Python. Il ne sait rien de HTTP et retourne une Prediction.
  • Une dépendance (Depends) fournit à une route ce dont elle a besoin. La route ne le crée pas.
  • Créez les objets coûteux, comme un modèle, une seule fois au démarrage de l’application. Gardez-les dans app.state.

Vous avez maintenant une API fonctionnelle et structurée. Dans le prochain module, vous concevez son contrat : des routes claires, un format d’erreur unique et un accès sécuritaire depuis le navigateur.

Leçon suivante : Conception des ressources et des points de terminaison