Données, faits et structure proposée par l'IA
Le fichier arrive du contrôle de gestion un jeudi soir : six lignes, cinq colonnes. Une virgule décimale au lieu d'un point, une part solaire à 140 % ou un site en double, et l'infographie publiera une absurdité. À l'autre bout de la chaîne, le titre doit accrocher : c'est là que le modèle de langage rend service, à condition qu'il n'écrive jamais un chiffre lui-même.
Le mécanisme : faits calculés, gabarits remplis
sequenceDiagram
participant CSV
participant Code as donnees.py
participant LLM as Gemini (texte)
participant Val as structure.py
CSV->>Code: 6 lignes
Code->>Code: valider, calculer les faits, formater en français
Code->>LLM: faits + liste des variables autorisées
LLM-->>Val: JSON : titre "Consommation électrique : {variation} en {annee}"
Val->>Val: chiffre en dur ? variable inconnue ? trop long ? cause inventée ?
Val-->>Code: Structure remplie, ou structure par défaut
Le modèle voit les données, choisit l'angle et la formulation, mais renvoie des gabarits : du texte avec des variables entre accolades. Le code les remplit avec des valeurs calculées et formatées. Un chiffre faux devient impossible par construction, pas par relecture.
Étape 1 : valider le CSV
def charger_csv(chemin: Path) -> list[Site]:
with open(chemin, newline="", encoding="utf-8-sig") as f:
lecteur = csv.DictReader(f)
manquantes = set(COLONNES) - set(lecteur.fieldnames or [])
if manquantes:
raise DonneesInvalides(f"colonnes manquantes : {sorted(manquantes)}")
...
if not 0 <= site.part_solaire <= 100:
raise DonneesInvalides(f"ligne {n} : part solaire hors de [0, 100]")
if not 2 <= len(sites) <= 12:
raise DonneesInvalides(f"{len(sites)} sites : une infographie lisible en montre de 2 à 12")Trois choix délibérés : utf-8-sig absorbe le BOM qu'ajoute Excel ; la virgule décimale française est acceptée ; au-delà de 12 lignes, on refuse plutôt que de produire un graphique illisible. Ce plafond est un réglage maison : ajustez-le, mais gardez-en un.
Étape 2 : calculer les faits
def calculer_faits(sites: list[Site]) -> Faits:
t25 = sum(s.conso_2025 for s in sites)
t26 = sum(s.conso_2026 for s in sites)
return Faits(
sites=tuple(sites), total_2025=t25, total_2026=t26,
variation_pct=(t26 - t25) / t25 * 100,
# moyenne pondérée par la consommation : un petit site très solaire ne gonfle pas le chiffre
part_solaire_moyenne=sum(s.conso_2026 * s.part_solaire for s in sites) / t26 if t26 else 0.0,
meilleur_site=min(sites, key=lambda s: s.variation_pct),
)La moyenne simple des parts solaires donnerait 32,2 % ; la moyenne pondérée par la consommation donne 30 %. Le second chiffre est le vrai « pourcentage d'électricité solaire du réseau ». Ce choix de méthode doit figurer sur l'infographie : c'est pourquoi le libellé du KPI dit « (moyenne pondérée) ».
Un cas limite réel de ce jeu de données : Hub Centre baisse de 23,02 % et Dépôt Sud de 23,01 %. Les deux s'affichent « −23 % ». Le code départage au centième ; un humain qui lirait l'image arrondie ne le pourrait pas.
Le formatage français est centralisé :
ESPACE = " " # espace insécable : « 1 840 MWh » ne se coupe jamais en fin de ligne
MOINS = "−" # vrai signe moins typographique
def fr_nombre(x: float, decimales: int = 0) -> str:
brut = f"{abs(x):,.{decimales}f}".replace(",", ESPACE).replace(".", ",")
return (MOINS if x < 0 and round(abs(x), decimales) else "") + brutLe contrôle de gestion envoie un CSV où une ligne indique part_solaire_pct = 47,0 et une autre 0.35. Que doit faire le pipeline ?
47,0 est une virgule décimale française, acceptée. 0.35 est probablement une fraction au lieu d'un pourcentage : la convertir en silence revient à deviner l'unité. Notre charger_csv accepte cette valeur (elle est dans [0, 100]) ; c'est une limite connue, à compléter par une règle métier (« part solaire < 1 : unité à confirmer ») si vos sources mélangent les conventions.
Étape 3 : faire proposer la structure
Le modèle reçoit les données et la liste des variables autorisées, avec leur valeur affichée, et doit répondre selon un schéma pydantic (sortie JSON structurée) :
class Proposition(BaseModel):
angle: str = Field(description="Le message principal en une phrase, pour l'éditeur")
titre: str = Field(description="Titre court, avec des variables {…} à la place des chiffres")
sous_titre: str = Field(description="Une phrase de contexte, variables {…} pour tout chiffre")
kpis: list[str] = Field(description="Exactement 3 clés parmi : " + ", ".join(LIBELLES_KPI))
titre_graphique: str
texte_alternatif: strPuis le code remplit, ou refuse :
def remplir(gabarit: str, valeurs: dict[str, str], champ: str) -> str:
gabarit = BAISSE_DE.sub(lambda m: f"{m.group(1)}{m.group(2)} de {{variation_absolue}}", gabarit)
inconnues = set(VARIABLE.findall(gabarit)) - set(valeurs)
if inconnues:
raise PropositionRefusee(f"{champ} : variables inconnues {sorted(inconnues)}")
if re.search(r"\d", VARIABLE.sub("", gabarit)):
raise PropositionRefusee(f"{champ} : chiffre écrit par le modèle « {gabarit} »")
texte = VARIABLE.sub(lambda m: valeurs[m.group(1)], gabarit).strip()
texte = re.sub(r"(MWh|%)(?:[ ]+\1)+(?!\w)", r"\1", texte)
...Même l'année passe par une variable ({annee}) : la règle « aucun chiffre » ne souffre pas d'exception, sinon elle ne se vérifie plus par une expression régulière.
Ce que le modèle a réellement proposé
Onze appels réels à gemini-3.5-flash le 2026-10-15 (temperature 0,7) pendant la mise au point. Chaque ligne a donné une règle du code :
| Proposition reçue (remplie) | Problème | Règle ajoutée |
|---|---|---|
| « Évolution de la consommation en 2026 » | titre sans le message chiffré | refus si le titre ne contient aucune variable |
| « … 6 954 MWh MWh en 2026 » | le modèle a écrit {total_2026} MWh | suppression de l'unité en double |
| « Réduction de −16,8 % de la consommation » | double négation | baisse de {variation} devient baisse de {variation_absolue} |
| « … 6 954 MWh grâce à une part solaire de 30 % » | lien de cause que les données ne prouvent pas | tournures causales envoyées en relecture humaine (a_relire) |
| « Sur les 6 sites, le volume atteint 6 954 MWh contre 8 360 MWh en 2025, avec 30 % de part solaire. » (97 caractères) | 3 lignes dans la colonne étroite de l'affiche, qui n'en prévoyait que 2 | limite abaissée à 100 caractères, boîte de 3 lignes |
| « Consommation électrique en 2026 : −16,8 % » | aucun | accepté |
Le modèle n'a écrit aucun chiffre en dur sur ces essais. Il a pourtant produit cinq textes non publiables sur onze. Sans le contrôle, ils seraient partis.
Le modèle propose le sous-titre « La baisse s'explique par l'installation de panneaux solaires sur {nb_sites} sites ». Aucun chiffre en dur, toutes les variables existent. Que fait le pipeline, et est-ce le bon comportement ?
La cause inventée est un risque de contenu, pas de format : un humain doit trancher (peut-être que la direction confirme les panneaux). Refuser toute la proposition serait trop brutal ; l'accepter sans signal laisserait passer une affirmation non sourcée. La règle CAUSALITE de structure.py repère « explique », « grâce à », « porté par »…
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
PropositionRefusee à chaque run | la consigne liste mal les variables ou les limites | lire le log, ajuster la consigne, pas la validation |
| Titre générique tous les jours | repli silencieux sur la structure par défaut | source: defaut dans manifeste.json ; alerter si trop fréquent |
| Le texte change à chaque run | temperature 0,7 | figer la structure validée dans le manifeste et la réutiliser pour les déclinaisons |
| Excel exporte en Windows-1252 | accents cassés (« Entrepôt ») | exiger UTF-8 à la source ; charger_csv échoue tôt sur un nom illisible |
Règle de décision
- Le modèle texte propose l'angle et les mots, jamais les valeurs.
- Chaque règle de validation naît d'un échec observé : gardez le tableau ci-dessus à jour dans votre dépôt.
- Une proposition refusée n'arrête pas la production : la structure par défaut est sobre, mais juste.
Exercice
Ajoutez une variable {site_le_plus_solaire} (Dépôt Sud, 47 %) utilisable par le modèle, puis écrivez le test qui prouve qu'un titre « Dépôt Sud : 47 % solaire » écrit en dur est refusé.
Voir la solution
Dans Faits.textes() :
plus_solaire = max(self.sites, key=lambda s: s.part_solaire)
return {
...,
"site_le_plus_solaire": plus_solaire.nom,
"part_solaire_max": fr_pct(plus_solaire.part_solaire),
}Le test :
def test_titre_solaire_en_dur_refuse(faits):
s = proposer_structure(faits, lambda p: _proposition(titre="Dépôt Sud : 47 % solaire"))
assert s.source == "defaut"
s = proposer_structure(faits, lambda p: _proposition(titre="{site_le_plus_solaire} : {part_solaire_max} solaire"))
assert s.titre == "Dépôt Sud : 47 % solaire"La consigne envoyée au modèle se met à jour seule : consigne() liste toutes les clés de textes().
Résultat obtenu
Extrait de sorties/manifeste.json du run final, 2026-10-15 21:06 :
"structure": {
"source": "modele",
"titre": "Baisse de 16,8 % de la consommation en 2026",
"sous_titre": "En 2026, les 6 sites consomment 6 954 MWh, avec une part solaire de 30 %.",
"kpis": [["total_2026", "6 954 MWh", "consommés en 2026"],
["variation", "−16,8 %", "par rapport à 2025"],
["part_solaire", "30 %", "d'électricité solaire (moyenne pondérée)"]],
"a_relire": []
}Et les tests de cette leçon passent sans clé : make test donne 23 passed.
Je sais faire
- Valider un CSV et refuser tôt les valeurs hors domaine
- Calculer les faits et choisir la bonne moyenne
- Faire proposer des gabarits à un modèle et les remplir par le code
- Transformer chaque proposition ratée en règle de validation testée