Leçon 5 — Agent et interface

Graphiques et interface web

9 min de lecture

Première démonstration au comité de direction : « Taux de retour par catégorie » s'affiche en courbe. Le modèle avait suggéré ligne, l'interface avait obéi, et vingt-six catégories rangées par ordre alphabétique formaient une ligne brisée que personne ne savait lire. Le chiffre était juste, le graphique était faux. La leçon en tire une règle : le modèle peut suggérer un graphique, mais c'est la forme du résultat qui décide.

Le mécanisme : la forme décide, la suggestion est filtrée

La forme d'un résultat, c'est le nombre de dimensions, leur type (temps ou catégorie) et le nombre de séries. Elle est connue du compilateur avant toute exécution : chaque colonne porte un role (dimension ou metrique), un type et un format, tirés du YAML.

flowchart TD
  R["Résultat : colonnes typées + lignes"] --> Z{"0 ligne ?"}
  Z -->|"oui"| T["tableau seul"]
  Z -->|"non"| D{"Nombre de dimensions"}
  D -->|"0"| N["nombre (indicateurs)"]
  D -->|"1 temps"| L["ligne (barres si 1 point)"]
  D -->|"1 catégorie"| B["barres horizontales triées"]
  D -->|"temps + catégorie"| S{"séries ≤ 8 ?"}
  S -->|"oui"| LM["lignes multiples"]
  S -->|"non"| C["carte de chaleur"]
  D -->|"2 catégories"| C
  N & L & B & LM & C --> F{"Suggestion du modèle compatible ?"}
  F -->|"oui"| OK["suggestion retenue"]
  F -->|"non"| AUTO["choix automatique, raison tracée"]
FormeChoix automatiqueSuggestions acceptéesPourquoi
aucune dimensionnombrenombreun indicateur ne se dessine pas
1 dimension temporelleligneligne, barressix années se lisent aussi bien en barres
1 dimension catégoriellebarres triéesbarresune ligne entre catégories invente une continuité
temps × catégorie, ≤ 8 sérieslignes_multipleslignes_multiples, carte_chaleurau-delà de 8 couleurs, la légende devient illisible
2 catégoriescarte_chaleurcarte_chaleurune cellule par couple

Le tableau des données est toujours affiché sous le graphique : « tableau » n'est donc jamais une option de visualisation, seulement le cas « 0 ligne ».

La mise en œuvre

Choisir

files/assistant/graphiques.py (extrait)
compatibles = {"nombre": {"nombre"}, "ligne": {"ligne", "barres"}, "barres": {"barres"},
               "lignes_multiples": {"lignes_multiples", "carte_chaleur"},
               "carte_chaleur": {"carte_chaleur"}}[auto]
if suggestion and suggestion != auto:
    if suggestion in compatibles:
        return suggestion, f"suggestion du modèle retenue ({suggestion})"
    return auto, f"suggestion « {suggestion} » incompatible avec la forme du résultat"
return auto, "choix automatique selon la forme du résultat"

La raison est stockée avec la requête (raison_graphique) : quand un utilisateur conteste un graphique, le journal dit qui l'a choisi. Le code complet est dans graphiques.py.

Produire la spécification Vega-Lite

Vega-Lite est une grammaire de graphiques déclarative : un JSON décrit les données, la marque (bar, line, rect) et l'encodage des champs sur les axes et la couleur. Le serveur produit ce JSON ; le navigateur le rend avec vega-embed. Aucune ligne de code de dessin n'est écrite.

files/assistant/graphiques.py (extrait)
elif type_ == "barres":   # barres horizontales triées : libellés longs lisibles
    v = {"mark": {"type": "bar", "tooltip": True, "cornerRadiusEnd": 3},
         "encoding": {"y": _champ(dims[0], sort="-x"), "x": _champ(m), "tooltip": info}}

Trois détails comptent plus que la marque :

  • Les formats viennent du YAML. taux_retour a le format pourcentage, donc l'axe reçoit .1% : la valeur 0,284 s'affiche 28,4 %, sans que le modèle ait à le savoir.
  • Une vue par métrique. Chiffre d'affaires et panier moyen n'ont pas la même échelle ; deux métriques produisent deux vues empilées (vconcat), jamais deux axes Y superposés.
  • Le temps devient discret dans une carte de chaleur. timeUnit: yearmonth et type ordinal, sinon Vega-Lite dessine des cellules d'un pixel de large.

Afficher sans faire confiance au texte

L'interface (web/app.js) lit la réponse de POST /api/question : reponse, limites, suites (venant du modèle) et requetes (venant de l'état de session, donc de BigQuery). Pour chaque réponse :

ÉlémentSourceRendu
texte, limitesmodèletextContent, jamais innerHTML
graphiquespec calculée par le codevegaEmbed(zone, spec, {renderer: 'svg', formatLocale: FR_NOMBRES})
indicateurs (nombre)lignes BigQuerytuiles, format français
donnéeslignes BigQuerytableau dépliable, 100 lignes au plus
SQL, paramètres, octets facturés, coût, duréetrace de la requêtebloc <details> dépliable
questions de suitemodèle (schéma Reponse)boutons qui reposent la question dans la même conversation

Le texte du modèle est traité comme une donnée non fiable : une valeur de catégorie qui contiendrait <img onerror=...> dans la table source serait affichée telle quelle, sans être interprétée. La locale française (FR_NOMBRES, FR_DATES) et les couleurs lues dans la feuille de style rendent les graphiques cohérents avec le reste de la page, en clair comme en sombre.

Lancer l'interface

make run              # http://localhost:8080 ; PORT=8091 make run si le port est pris

Résultat obtenu le 2026-10-15, conversation de deux questions. La seconde, une suite courte (« Et par rayon, trimestre par trimestre ? »), réutilise le contexte de la première ; deux rayons et huit trimestres donnent deux séries, donc des lignes multiples :

Suite de conversation : chiffre d'affaires trimestriel par rayon, lignes multiples, limites et questions de suite

Le bloc « SQL généré » se déplie sur la requête exacte et ses paramètres. La ligne de résumé porte les octets facturés (20,97 Mo, le minimum de 10 Mo par table lue, deux tables), le coût et la durée :

SQL déplié avec les paramètres et le coût de la requête

Une question hors catalogue ne produit aucune requête : la réponse explique ce qui manque et propose des suites que l'outil sait traiter.

Refus d'une demande d'adresses e-mail : aucune requête exécutée

Ce qui casse en production

SymptômeCauseParade
Courbe entre catégories, ou camembert à 26 partsl'interface suit la suggestion du modèlechoix par la forme, suggestion filtrée (test_suggestion_compatible_retenue_incompatible_ignoree)
Axe en 0,284 au lieu de 28,4 %format perdu entre la base et le graphiqueformat porté par la métrique dans le YAML, appliqué par FORMATS_D3
Carte de chaleur en traits d'un pixelaxe temporel continu sur une marque recttimeUnit + type ordinal
Page figée sur une réponse de 5 000 lignesdonnées intégrales envoyées et rendueslimite ≤ 500 côté compilateur, tableau affiché sur 100 lignes
Script injecté via une valeur de la basetexte inséré avec innerHTMLtextContent partout, rendu SVG par Vega
Graphiques absents derrière un proxy d'entrepriseVega chargé depuis un CDN filtréversions figées (vega@5.30.0) ; servir les trois fichiers depuis web/ si le CDN est bloqué

La règle de décision

  • Laissez le code choisir dès que l'utilisateur ne peut pas changer le graphique lui-même. La suggestion du modèle sert à départager deux lectures valides, pas à en imposer une invalide.
  • Vega-Lite plutôt qu'une bibliothèque impérative quand le serveur produit le graphique : la spécification est un JSON testable par pytest (test_spec_vega_lite), stockable et rejouable.
  • Surveillez la part des suggestions refusées dans les journaux : une hausse signale une instruction ou un catalogue à revoir.

Pratique

Un utilisateur demande « Chiffre d'affaires mensuel 2025 par pays ». Le résultat a 12 mois × 15 pays. Le modèle suggère lignes_multiples. Que s'affiche-t-il et pourquoi ?

Le service sécurité demande pourquoi l'interface n'utilise pas un rendu Markdown du texte du modèle, plus agréable à lire. Que répondez-vous ?

Exercice. Ajoutez la forme « 1 dimension catégorielle avec au plus 5 valeurs et une métrique au format pourcentage » rendue en barres verticales plutôt qu'horizontales. Où modifiez-vous le code, et quel test écrivez-vous d'abord ?

Voir la solution

Le choix du type ne change pas (barres) : seule la spécification change, dans spec().

elif type_ == "barres" and len(lignes) <= 5 and m["format"] == "pourcentage":
    v = {"mark": {"type": "bar", "tooltip": True},
         "encoding": {"x": _champ(dims[0], sort="-y"), "y": _champ(m), "tooltip": info}}

À placer avant la branche barres horizontale, et horizontal doit devenir faux dans ce cas pour garder une hauteur fixe. Test d'abord :

def test_barres_verticales_pour_peu_de_taux():
    TAUX = {**M, "nom": "taux_retour", "format": "pourcentage"}
    s = g.spec("barres", [CAT, TAUX], lignes(3))
    assert s["encoding"]["x"]["field"] == "categorie" and s["encoding"]["y"]["axis"]["format"] == ".1%"

make test passe de 57 à 58 tests. L'évaluation n'est pas touchée : elle juge le type de graphique, pas son orientation.

  • Je sais pourquoi la forme du résultat décide du graphique, et quand une suggestion du modèle est acceptée
  • Je sais produire une spécification Vega-Lite côté serveur et la tester sans navigateur
  • Je sais séparer ce qui vient du modèle (texte) et ce qui vient de BigQuery (données, graphique)
  • J'ai lancé make run et déplié le SQL d'une réponse
Suivant