Lección 2 — La matière : données, textes, visuels

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

8 min de lectura

Este contenido aún no está traducido. Se muestra la versión original.

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
Siguiente