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"]
| Forme | Choix automatique | Suggestions acceptées | Pourquoi |
|---|---|---|---|
| aucune dimension | nombre | nombre | un indicateur ne se dessine pas |
| 1 dimension temporelle | ligne | ligne, barres | six années se lisent aussi bien en barres |
| 1 dimension catégorielle | barres triées | barres | une ligne entre catégories invente une continuité |
| temps × catégorie, ≤ 8 séries | lignes_multiples | lignes_multiples, carte_chaleur | au-delà de 8 couleurs, la légende devient illisible |
| 2 catégories | carte_chaleur | carte_chaleur | une 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
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.
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_retoura le formatpourcentage, 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: yearmonthet typeordinal, 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ément | Source | Rendu |
|---|---|---|
| texte, limites | modèle | textContent, jamais innerHTML |
| graphique | spec calculée par le code | vegaEmbed(zone, spec, {renderer: 'svg', formatLocale: FR_NOMBRES}) |
indicateurs (nombre) | lignes BigQuery | tuiles, format français |
| données | lignes BigQuery | tableau dépliable, 100 lignes au plus |
| SQL, paramètres, octets facturés, coût, durée | trace de la requête | bloc <details> dépliable |
| questions de suite | modè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 prisRé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 :
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 :
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.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| Courbe entre catégories, ou camembert à 26 parts | l'interface suit la suggestion du modèle | choix 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 graphique | format porté par la métrique dans le YAML, appliqué par FORMATS_D3 |
| Carte de chaleur en traits d'un pixel | axe temporel continu sur une marque rect | timeUnit + type ordinal |
| Page figée sur une réponse de 5 000 lignes | données intégrales envoyées et rendues | limite ≤ 500 côté compilateur, tableau affiché sur 100 lignes |
| Script injecté via une valeur de la base | texte inséré avec innerHTML | textContent partout, rendu SVG par Vega |
| Graphiques absents derrière un proxy d'entreprise | Vega 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 seuil MAX_SERIES = 8 fait basculer le choix automatique en carte de chaleur, dont l'ensemble compatible ne contient que carte_chaleur : la suggestion est refusée avec sa raison. Les lignes multiples seraient tentantes, mais 15 couleurs ne se distinguent pas. Les barres empilées n'existent pas dans ce projet ; le tableau s'affiche toujours en plus, jamais à la place.
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 ?
La surface de risque est le passage de texte non fiable vers du HTML. Un rendu Markdown sans assainissement strict exécute ce que la base ou une question piégée y aura mis. Les autres réponses sont fausses : le coût de rendu est négligeable, et le modèle produit très bien du Markdown, c'est justement le problème.
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 runet déplié le SQL d'une réponse