Ce module traite de ce qui précède la première ligne de code, puis de la manière de conduire la génération une fois lancée. Les sorties affichées ont été capturées sur le dépôt QuizOps, aux tags tp3-depart et tp4-depart.
Un prompt est la seule chose que le modèle connaît de votre intention. Entre humains, le contexte partagé comble les trous ; un modèle remplace chaque trou par la moyenne de ce qu'il a lu.
Ajoute un système de scores.
Restent ouverts : score par partie ou cumulé par joueur, stockage en mémoire ou en base, identification du joueur, tous les scores ou les meilleurs, combien, et ce que l'API renvoie.
| Vous écrivez | Le modèle doit deviner |
|---|---|
| « proprement » | quelles conventions, quel niveau d'abstraction |
| « optimisé » | pour la vitesse, la mémoire, la lisibilité ? |
| « comme d'habitude » | quelle habitude, il n'a pas de mémoire |
| « robuste » | quels cas d'erreur, quel comportement en cas d'échec |
| « simple » | simple à lire, à modifier, à déployer ? |
| « moderne » | selon quelle référence, à quelle date |
Chacun de ces mots se remplace par un critère vérifiable : « robuste » devient « une liste de réponses vide renvoie 0, pas une exception ».
Une instruction explicite est écrite dans le prompt, une instruction implicite est ce que vous tenez pour évident et que vous n'écrivez pas. Le modèle n'applique que les explicites, qu'elles portent sur l'outillage, sur la qualité du code ou sur les fichiers autorisés :
Python 3.13, FastAPI, SQLite. Pas d'ORM, pas de dépendance
en dehors de fastapi, uvicorn et pytest.
Gère explicitement le cas de la liste vide. Aucune exception
attrapée sans traitement. Les noms de variables en français.
Modifie uniquement app/main.py. Ne touche pas au front ni aux tests.
Sans périmètre, un agent refactorise un fichier voisin « pour être cohérent », renomme une fonction, ajoute un module. Un prompt affirme parfois un fait faux sans le dire, comme ici où l'option --strict-mode d'uvicorn n'existe pas :
$ claude -p "Documente dans README.md l'option --strict-mode d'uvicorn que le projet utilise au lancement." --permission-mode acceptEdits
⏺ Bash(grep -rn "strict-mode\|strict_mode" ~/projets/quizops --include="*.py" --includ…)
⎿ --- (+2 lignes)
⏺ I checked the codebase and uvicorn's actual CLI: there's no `--strict-mode` option anywhere.
- No occurrence of `strict-mode`/`strict_mode` in the repo (code, configs, or CLAUDE.md). [...]
Le git diff README.md qui suit ne renvoie rien : l'agent a vérifié avant d'écrire, et n'a rien écrit. La vérification n'est pas systématique, et un fait faux glissé dans un prompt se retrouve souvent tel quel dans le code.
CONTEXTE Où on est, ce qui existe déjà, ce que l'agent doit lire
TÂCHE Ce qu'il faut produire, en un verbe et un objet
CONTRAINTES Les limites : techniques, périmètre, qualité
FORMAT La forme attendue de la réponse ou du code
VÉRIFICATION La commande qui tranche, et ce qu'elle doit afficher
Si la tâche ne tient pas en un verbe et un objet, c'est qu'il y en a deux : découpez. Prompt faible :
Écris-moi un script qui range des fichiers.
Prompt structuré :
Contexte : j'ai un dossier ~/Téléchargements qui contient plusieurs
centaines de fichiers en vrac, sur un poste Linux.
Tâche : écris un script Python qui range ces fichiers dans des
sous-dossiers selon leur extension (images/, documents/, archives/, autres/).
Contraintes :
- Python 3.13, bibliothèque standard uniquement
- Le dossier est passé en argument, aucun chemin codé en dur
- Un fichier de même nom à destination est renommé avec un suffixe
numérique plutôt qu'écrasé, et les sous-dossiers ne sont pas déplacés
Format : le script complet, puis trois lignes expliquant comment le lancer.
Quand un résultat déçoit, cherchez la case vide. Un code étranger au projet manque de contexte ; un code qui fait trop de choses manque de périmètre ; un cas limite non traité manque d'une contrainte.
@app/main.py fait lire le fichier entier ; vingt lignes ciblées valent souvent mieux que six cents dont cinq comptent, et un extrait donné comme modèle à imiter est ce qui se respecte le mieux :
Voici le style que j'utilise dans ce projet :
def charger_questions(chemin: Path) -> list[Question]:
"""Charge les questions depuis un fichier JSON."""
Écris `enregistrer_score` dans le même style : annotations de type
complètes, docstring d'une ligne, noms en français.
Nommer le fichier suffit à déclencher sa lecture, et la réponse cesse alors de varier d'un appel à l'autre :
$ claude -p "Combien de questions par catégorie dans data/questions.json ? Réponds en une ligne."
⏺ Read(~/projets/quizops/data/questions.json)
⎿ 1 [ (+86 lignes)
⏺ 6 questions "docker" et 6 questions "kubernetes".
Le même prompt relancé répond docker : 6, kubernetes : 6. : la formulation change, le fait ne change pas, parce qu'il vient du fichier. Ne donnez pas pour autant le dépôt entier « au cas où », le bruit noie le signal et la facture double ; jamais de secret ni de donnée personnelle réelle dans un prompt.
Une convention écrite dans le prompt ou dans CLAUDE.md pèse moins qu'un exemple, et un exemple pèse moins qu'une interdiction visant un comportement par défaut nommé.
| Comportement par défaut | L'interdiction qui l'évite |
|---|---|
| Ajoute des commentaires qui paraphrasent le code | « Pas de commentaire qui répète ce que fait la ligne » |
| Attrape les exceptions largement | « Aucun except Exception sans traitement » |
| Introduit une bibliothèque tierce | « Bibliothèque standard uniquement » |
| Crée des couches d'abstraction | « Une seule fonction, pas de classe » |
| Refactorise le code voisin | « Ne modifie que le fichier indiqué » |
| Traduit les noms en anglais | « Noms de variables et de fonctions en français » |
Les deux appels partent du même état (tp4-depart), en acceptEdits, avec le même modèle. Le premier tient en une phrase, Ajoute un système de scores. ; le second nomme les fichiers, interdit les dépendances et impose la vérification :
Ajoute le classement des meilleurs scores à QuizOps.
Contexte : les scores sont déjà enregistrés dans la table scores par
app/database.py. Le style du front est dans @static/app.js.
Tâche : une route GET /api/scores qui renvoie les dix meilleurs scores triés du
plus haut au plus bas (pseudo, score, date), et l'affichage de cette liste sur
l'écran de fin de partie.
Contraintes : aucune nouvelle dépendance ; la requête SQL dans app/database.py,
la route dans app/main.py ; ne modifie pas les tests existants ; les tests de
tests/test_classement.py doivent passer.
Vérification : lance uv run pytest -q et uvx ruff check . avant de conclure, et
donne-moi le résultat.
| Mesure relevée à la fin de la session | Prompt vague | Prompt structuré |
|---|---|---|
| Fichiers modifiés | 5 | 5 |
Insertions (git diff --stat) |
38 | 40 |
uv run pytest -q |
14 passed |
18 passed |
| Tokens de sortie | 7 633 | 3 260 |
Le prompt vague a produit un classement qui fonctionne, ruff compris : il a lu de lui-même le PRD et tests/a-venir/test_classement.py, présents dans le dépôt, et s'en est servi comme spécification.
⏺ Read(~/projets/quizops/tests/a-venir/test_classement.py)
⎿ 1 """Classement : les meilleurs scores enregistrés.""" (+39 lignes)
⏺ Bash(uv run pytest -q)
⎿ .............. [100%] (+1 lignes)
⏺ Bash(git add app/database.py app/main.py static/app.js static/index.html static/styl…)
⎿ This command requires approval
Ce qu'il n'a pas fait : déplacer le test hors de tests/a-venir/, si bien que la suite reste à 14 passed et que rien ne prouve le contrat. Il n'a pas rendu compte non plus, terminant sur une demande d'approbation de commit, là où le prompt structuré conclut par la mesure :
⏺ Tout est vert : `uv run pytest -q` → **18 passed**, `uvx ruff check .` → **All checks passed**
La structure a coûté deux fois moins de tokens de sortie. Ce qu'elle achète, c'est la prévisibilité du périmètre et le compte rendu, pas la fonctionnalité.
« Il me faut une base PostgreSQL » est une décision technique prise avant d'avoir formulé le problème ; « les scores doivent survivre au redémarrage du serveur » décrit l'usage, laisse la solution ouverte et se teste.
| Question | Ce qu'elle décide | Pour QuizOps |
|---|---|---|
| Pour qui ? | Les utilisateurs, leur niveau technique | Un apprenant qui révise, un formateur qui consulte les scores |
| Pour résoudre quoi ? | Le problème réel, pas la fonctionnalité | Se tester sur Docker et Kubernetes en cinq minutes |
| Quoi de minimal ? | Le plus petit périmètre qui rend service | Dix questions tirées au hasard, un score affiché et enregistré |
| Quoi de plus tard ? | Ce qui est hors du périmètre initial | Comptes utilisateurs, édition des questions, minuteur, multijoueur |
| Quelles contraintes ? | Techniques, de délai, d'équipe | Python 3.13, FastAPI, SQLite, front sans framework |
| Comment sait-on que c'est fini ? | Les critères d'acceptation | Une partie complète se joue et le score s'affiche |
Sans la quatrième réponse, un agent élargit de lui-même : authentification, tableau de bord d'administration, système de rôles. Ces réponses sont le socle du PRD rédigé au TP 2.
Un lot est livrable, testable, et tient dans une session de travail.
Par couche technique
Lot 1 : la base ; Lot 2 : l'API ; Lot 3 : le front
→ rien ne fonctionne avant la fin du lot 3
Par fonctionnalité
Lot 1 : filtrer par catégorie ; Lot 2 : refuser une catégorie inconnue
Lot 3 : lister les catégories et les proposer à l'écran
→ quelque chose marche dès la fin du lot 1
Les lots de QuizOps sont écrits dans le PRD, chacun avec son fichier de tests et sa commande de contrôle :
$ sed -n '/^## Lots à venir/,/^### Lot 2/p' PRD.md
## Lots à venir
### Lot 1 : filtrer par catégorie
`GET /api/questions?categorie=docker` ne renvoie que des questions Docker. Tests : `tests/a-venir/test_categories_lot1.py`. Contrôle : `curl -s 'http://127.0.0.1:8000/api/questions?categorie=docker' | grep -c kubernetes` renvoie `0`.
### Lot 2 : refuser une catégorie inconnue
Les tests existent avant le code et sont ignorés par pytest tant qu'ils n'ont pas quitté tests/a-venir/. Le TP 3 déplace le premier fichier puis fait écrire le lot 1.
Un composant est critique si le corriger plus tard coûte beaucoup plus cher que le faire correctement maintenant. Le PRD en nomme trois pour QuizOps, dont l'entrée de POST /api/answers ; voici ce qu'elle fait d'une partie vide, sans Claude, sur tp4-depart :
$ uv run uvicorn app.main:app --port 8031 &
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8031/api/answers \
-H 'Content-Type: application/json' -d '{"pseudo":"alice","reponses":[]}'
500
Le journal du serveur donne la cause :
INFO: 127.0.0.1:55879 - "POST /api/answers HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application [...]
File "~/projets/quizops/app/main.py", line 37, in soumettre_reponses
score = quiz.calculer_score(partie["reponses"], QUESTIONS_PAR_ID)
File "~/projets/quizops/app/quiz.py", line 36, in calculer_score
return round(bonnes / len(repondues) * 100)
~~~~~~~^~~~~~~~~~~~~~~~
ZeroDivisionError: division by zero
La route accepte un dictionnaire brut, sans schéma : rien ne valide le pseudo, ni la liste des réponses, ni l'index de choix. Le modèle de données, la validation des entrées et le schéma de base se relisent ligne à ligne ; cette division par zéro traverse le cours et est traitée au module 04.
Un PRD (Product Requirements Document) est le document de référence du projet, et en vibe coding c'est aussi un fichier que l'agent lit. Celui de QuizOps tient en dix sections :
$ grep '^## ' PRD.md
## Objectif
## Utilisateurs
## Fonctionnalités existantes
## Hors périmètre
## Contraintes techniques
## Modèle de données
## API
## Critères d'acceptation
## Lots à venir
## Composants critiques
Pour un projet qui démarre, le gabarit minimal tient en sept sections :
# PRD : <nom du projet>
## 1. Objectif Quel problème, pour qui, quel bénéfice.
## 2. Utilisateurs Qui s'en sert, niveau technique, contexte.
## 3. Fonctionnalités du MVP Ce qui doit exister pour rendre service (F1, F2...).
## 4. Hors périmètre Ce qu'on ne fait pas maintenant, et pourquoi.
## 5. Contraintes techniques Langage, cadriciels, base, interdictions explicites.
## 6. Modèle de données Les entités et leurs champs, composant critique numéro un.
## 7. Critères d'acceptation Une phrase vérifiable par fonctionnalité, à cocher.
Un critère se coche ou non, sans discussion, et il nomme une valeur ou une commande :
## Critères d'acceptation
- [ ] `GET /api/questions` renvoie exactement dix questions, sans doublon et sans le champ `reponse`.
- [ ] Dix bonnes réponses donnent un score de 100 ; dix mauvaises donnent 0.
- [ ] Une partie de dix questions dont cinq seulement ont reçu une réponse, toutes justes, donne 50.
- [ ] Chaque partie terminée ajoute exactement une ligne dans la table `scores`.
- [ ] La suite `uv run pytest -q` est verte avant chaque commit.
« L'application est rapide » et « les scores sont bien gérés » ne se cochent pas. La troisième ligne ci-dessus est déjà écrite comme test dans tests/a-venir/test_score_partiel.py, activé au TP 5.
En mode plan, Claude lit, explore et propose, sans rien modifier ; Maj+Tab fait défiler les modes jusqu'à plan mode on en cours de session.
$ claude --permission-mode plan
Sur la demande de classement vue plus haut, l'agent délègue l'exploration à deux sous-agents en parallèle, puis un troisième rédige le plan dans un fichier :
⏺ Agent(Explore: Explore backend score-related code)
⏺ Agent(Explore: Explore frontend end-of-game screen)
⏺ Read(~/projets/quizops/tests/a-venir/test_classement.py)
⎿ 1 """Classement : les meilleurs scores enregistrés.""" (+39 lignes)
⏺ Agent(Plan: Design implementation plan for scores leaderboard)
⏺ Write(~/.claude/plans/ajoute-le-classement-des-sequential-toucan.md)
⎿ File created successfully at: ~/.claude/plans/ajoute-le-classement-des-sequential-tou…
Le plan se termine par la liste des fichiers à toucher, avant toute écriture :
### Critical Files for Implementation
- app/database.py
- app/main.py
- tests/a-venir/test_classement.py (à déplacer vers tests/test_classement.py)
- static/app.js, static/index.html, static/style.css
Le git status --short lancé juste après ne renvoie rien : aucun fichier du dépôt n'a bougé. Un plan se relit en cherchant d'abord ce qui manque par rapport à la demande, puis ce qui s'y est ajouté sans avoir été demandé, l'ordre des étapes se discutant ensuite. En session interactive, l'outil ExitPlanMode présente le plan et attend votre accord avant de basculer en écriture ; il est indisponible en mode -p, où l'agent le signale lui-même :
⏺ ExitPlanMode({"plan": "Le plan est écrit dans ~/.claude/plans/ajoute…)
⎿ <tool_use_error>Error: No such tool available: ExitPlanMode. ExitPlanMode is disabled for this ses…
La réflexion étendue donne au modèle un budget de raisonnement avant qu'il réponde. Option+T l'active ou la coupe, /effort en règle le niveau, Ctrl+O déplie le raisonnement dans le transcript.
/effort low | medium | high | xhigh | max
Le budget se paie en tokens et en secondes : il vaut le coup sur un plan, un diagnostic ou une décision d'architecture, rarement sur un renommage. Ces trois gestes n'ont pas de sortie capturable en claude -p et ne sont donc pas montrés ici ; le TP 4 fait mesurer l'écart avec /cost.
Une fonctionnalité bien calibrée se décrit en une phrase commençant par un verbe, se vérifie en une action et touche un à trois fichiers.
Trop gros
« Ajoute les catégories, la persistance des scores et le tableau
des meilleurs scores. »
Bien calibré
« Ajoute un paramètre optionnel `categorie` à GET /api/questions
qui filtre les questions sur ce champ. »
Après chaque itération : exécuter, vérifier ce qui marchait avant, committer si c'est bon. Si trois allers-retours consécutifs n'ont pas résolu le problème, revenez au dernier commit propre et reformulez autrement.
Répéter la même chose plus fort n'apporte aucune information, là où une reformulation utile dit ce qui a été fait, ce qui était attendu et l'écart entre les deux :
« Non, refais »
« Ce n'est toujours pas bon »
« Tu n'as pas compris, recommence »
« Le tri se fait par date, je le voulais par score décroissant. »
« Tu as créé un fichier app/scores.py ; je voulais que tout reste
dans app/main.py. »
« La fonction lève une exception quand la liste est vide ; elle doit
renvoyer 0. »
Ne résumez jamais une erreur : « il y a une histoire de division par zéro » retire le fichier et la ligne, collez la trace intégrale. Demander un compte rendu plutôt qu'une correction évite la salve de modifications non sollicitées :
$ claude -p "Relis la fonction calculer_score dans app/quiz.py et liste, en trois puces maximum, les cas limites qu'elle ne gère pas. Ne modifie rien."
Cas limites non gérés par `calculer_score` (app/quiz.py:32-36) :
- **Liste `reponses` vide ou entièrement sans `choix`** : `repondues` devient vide → division
par zéro (`ZeroDivisionError`) puisque `len(repondues)` vaut 0.
- **Réponses avec `question_id` inconnu de `questions_par_id`** : elles sont comptées dans
`repondues` mais jamais dans `bonnes`, ce qui fausse le score à la baisse.
- **Doublons de `question_id`** dans `reponses` : rien n'empêche de compter plusieurs fois la
même question.
Quand une contrainte du début est oubliée ou qu'un bug corrigé revient, le contexte est saturé : /compact, ou /clear et repartir du code actuel, comme vu au module Fondamentaux.
Le Best-of-N consiste à lancer le même prompt plusieurs fois puis à choisir sur des mesures. Le prompt structuré du classement a été rejoué à l'identique sur les trois modèles, depuis le même état de départ.
| Modèle | git diff --stat |
uv run pytest -q |
Tokens de sortie | Coût de l'appel |
|---|---|---|---|---|
| Haiku | 4 fichiers, 37 insertions | 18 passed |
4 600 | 0,081 $ |
| Sonnet | 5 fichiers, 40 insertions | 18 passed |
3 260 | 0,193 $ |
| Opus | 5 fichiers, 50 insertions | 18 passed |
5 422 | 0,533 $ |
uvx ruff check . affiche All checks passed! dans les trois cas. Haiku a livré le contrat pour un sixième du prix d'Opus, en laissant de côté la feuille de style ; Opus a ajouté ce que les tests ne demandaient pas, un tri secondaire par date puis par identifiant pour départager les scores égaux et un formatage de date qui évite l'interprétation locale d'un horodatage UTC. Quand les tests bornent la tâche, le modèle le moins cher suffit souvent, et la comparaison porte sur le diff, la suite de tests, le linter et la facture, jamais sur le texte de la réponse.
/model change de modèle sans quitter la session ni perdre le contexte, et /cost sert de preuve avant et après la bascule ; la mesure se fait sur QuizOps au TP 4.
/model haiku renommer, reformater, extraire une fonction
/model sonnet l'implémentation courante
/model opus le plan, le diagnostic, la décision d'architecture
Une docstring ou un README se régénèrent depuis le code, à condition de nommer la source et d'interdire l'invention :
$ claude -p "Mets à jour README.md depuis @app/main.py : la liste des routes, leur méthode et leur rôle en une ligne chacune. N'ajoute aucune route qui n'existe pas dans le fichier. Ne touche à rien d'autre." --permission-mode acceptEdits
$ git diff --stat README.md
La preuve est le diff, pas le texte produit : le nombre de lignes touchées d'un côté, les routes citées comparées à app/main.py de l'autre. Cet enchaînement n'a pas été capturé pour ce module.
Laissé libre, un agent produit un gros main.py puis un utils.py qui attire tout ce qui n'a pas de place. Le découpage se décide avant la génération et s'écrit dans CLAUDE.md, que l'agent relit à chaque session, avec l'interdiction qui va avec : « Pas de module utils.py : chaque fonction va dans le module de son sujet ».
$ grep -A 8 '^## Structure' CLAUDE.md
## Structure
- `app/main.py` : les routes `/api/...` et le service des fichiers statiques
- `app/quiz.py` : tirage des questions et calcul du score
- `app/donnees.py` : chargement et validation de `data/questions.json`
- `app/database.py` : accès SQLite (`quizops.db`, jamais versionné)
- `tests/` : la suite pytest ; `tests/a-venir/` : les tests des lots pas encore livrés, ignorés par pytest
- `outils/` : scripts du formateur, à ne pas modifier
Quand un fichier ne se décrit plus en une phrase, demandez d'abord l'état des lieux, puis la séparation avec le périmètre et l'interdiction de changer le comportement :
Liste les responsabilités de app/main.py, une ligne par responsabilité,
et signale les fonctions qui n'ont rien à voir avec les routes.
Ne modifie aucun fichier.
Déplace les fonctions de calcul listées ci-dessus vers app/quiz.py.
Contraintes : aucun changement de comportement, aucune signature modifiée,
ne touche pas aux tests. Lance uv run pytest -q et donne-moi le résultat.
Un renommage traverse le code, les tests et parfois les gabarits HTML ; la demande nomme l'ancien et le nouveau, et laisse l'agent chercher les occurrences :
Renomme la fonction calculer_score en calculer_pourcentage dans tout le
dépôt, y compris les appels et les tests qui la nomment. Ne change rien
d'autre. Lance uv run pytest -q avant de conclure.
C'est le seul cas où toucher aux tests est autorisé, et il doit être dit explicitement dans le prompt.
Deux commandes tranchent, quelle que soit la qualité de l'explication rendue. Relevées à la fin de l'essai structuré du classement :
$ git diff --stat | tail -1
5 files changed, 40 insertions(+)
$ uv run pytest -q
18 passed in 0.17s
Un diff qui touche des fichiers hors du périmètre annoncé est un refus, même si les tests passent ; un compteur de tests qui baisse est un refus, même si le diff est propre.
Note : la consigne « ne modifie pas les tests » figure dans le
CLAUDE.mdde QuizOps sous la forme « Ne modifie jamais un test existant sans le dire explicitement ». Un agent qui fait passer une suite en changeant l'attendu a supprimé la preuve.
git status --short reste vide tant que le plan n'est pas approuvé.18 passed dans les trois cas, pour un coût qui varie d'un facteur six.TP associés : TP 2 : Du Code au Cahier des Charges, TP 3 : Faire Évoluer du Code Existant, TP 4 : Comparer et Choisir
➡️ Module suivant : Validation, Tests et Fiabilisation