Leçon 2 — La matière : données, textes, visuels

Données, faits et structure proposée par l'IA

8 min de lecture

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

files/src/infographie/donnees.py
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

files/src/infographie/donnees.py
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é :

files/src/infographie/donnees.py
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 "") + brut

Le 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 ?

É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) :

files/src/infographie/structure.py
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: str

Puis le code remplit, ou refuse :

files/src/infographie/structure.py
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èmeRè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} MWhsuppression de l'unité en double
« Réduction de −16,8 % de la consommation »double négationbaisse 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 pastournures 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 2limite abaissée à 100 caractères, boîte de 3 lignes
« Consommation électrique en 2026 : −16,8 % »aucunaccepté

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 ?

Ce qui casse en production

SymptômeCauseParade
PropositionRefusee à chaque runla consigne liste mal les variables ou les limiteslire le log, ajuster la consigne, pas la validation
Titre générique tous les joursrepli silencieux sur la structure par défautsource: defaut dans manifeste.json ; alerter si trop fréquent
Le texte change à chaque runtemperature 0,7figer la structure validée dans le manifeste et la réutiliser pour les déclinaisons
Excel exporte en Windows-1252accents 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
Suivant