La matinée où j'ai tourné l'écran
Début avril, mon bot Rembrandt savait déjà naviguer dans l'ERP. Dix-huit outils câblés, multi-turn jusqu'à trois rounds, il retrouvait un élève par son nom, listait les impayés d'un atelier, ouvrait la fiche d'un cours. Quand on lui demandait « compte-moi les inscrits actifs sur Maisons-Laffitte », il livrait. Quand on lui demandait « quel est le reste à encaisser par atelier sur l'année en cours », il pédalait dans la semoule, recyclait des outils de recherche nominale et finissait par renvoyer vers une page d'admin que personne ne consultait. Le bot ne savait pas répondre aux questions analytiques composées, et je le savais.
Vendredi 18 avril, dix heures trente. Françoise pivote sur sa chaise depuis son cockpit à trois écrans, l'Excel pointeuse à gauche, Sage à droite, et me lance par-dessus la cloison : « Michel, sur ceux qui sont en CCF cette année, il en reste combien à encaisser d'ici juin ? » Je n'ai pas l'outil dans le bot. Je le sais avant qu'elle ait fini sa phrase. J'ouvre l'onglet Supabase SQL Editor sur mon poste, je tape la requête à la main, jointure inscriptions × echeances_inscription × contacts, filtre sur le mode de paiement, somme du montant_prevu moins montant_paye sur les échéances ouvertes. Vingt secondes. Je tourne l'écran. Elle plisse les yeux, lit le chiffre, le note sur son post-it, et lâche : « Bon allez, c'est ça. » Elle repivote vers Sage. Je ferme l'onglet sans rien dire.
Le déclic
Le dimanche 20 avril au soir, je tombe sur l'annonce Databricks de Genie Agent Mode. Je la lis en diagonale. Une phrase suffit, plan iteratively, run multiple SQL queries, learn from each result, deliver comprehensive reports. Je referme l'onglet en sachant que je vais coder ça le week-end suivant.
C'était le bon dessin. Une couche sémantique qui décrit les tables au modèle, un planificateur qui rédige le SQL, un validateur qui le filtre avant exécution, un commentateur qui rend la réponse en français à l'utilisateur. Rien d'inédit, sauf qu'avec Claude Code je pouvais le poser proprement en quinze jours pour mon contexte. J'ai écrit l'ADR-0020 le lundi suivant, on est partis.
La construction
La Phase 1 a posé le semantic layer en TypeScript, pas en YAML. Sept tables whitelistées, une par fichier, typées contre Database['public']['Tables'], colonnes en langage métier, métriques canoniques, jointures déclarées. Le typage TS donne deux choses que YAML ne donne pas : refactoring sûr quand le schéma bouge, erreur de compilation si le contrat dérive d'un nom de colonne. Registry unique consommé par le pipeline.
// lib/analytics/semantic/tables/echeances_inscription.ts — état pré-fix du 26/04
columns: {
statut: {
type: 'text',
description:
"Statut du paiement : `encaisse` (cash reçu), `a_payer`, `en_retard`, `annule`",
refAdr: ['ADR-0015'],
},
},
metrics: {
ca_encaisse: {
formula: "SUM(montant_paye) FILTER (WHERE statut = 'encaisse')",
description: 'CA cash effectivement reçu (ADR-0015 modèle cash).',
},
reste_a_encaisser: {
formula:
"SUM(montant_prevu - COALESCE(montant_paye,0)) " +
"FILTER (WHERE statut IN ('a_payer','en_retard'))",
description: 'Créances ouvertes.',
},
},
La Phase 2 a fermé la base à clé. Rôle Postgres agent_readonly en SELECT strict sur les sept tables, validateur SQL applicatif (lib/analytics/sql-validator.ts) sur node-sql-parser au-dessus. Double ceinture. Le validateur refuse DML, hors whitelist, exige le tenantFilter via le claim site_filter du JWT. Vingt tests sur vingt verts.
J'aurais pu m'arrêter là. J'ai voulu mesurer.
La Phase 3 a routé le tout : Sonnet 4.6 pour le plan en tool-use, Haiku 4.5 pour le commentaire post-exécution. Haiku facture la sortie cinq fois moins que Sonnet sur du français standard, p50 passe de quinze à douze secondes.
À ce stade, j'avais le sentiment d'avoir fait du travail propre. C'est précisément à ce stade que j'ai posé un piège que je n'ai pas vu pendant six jours.
Le piège silencieux
Smoke test des dix questions de l'eval-set, 26/04 début d'après-midi. Question numéro huit, « combien reste-t-il à encaisser par atelier sur l'année 2025-2026 ». Sonnet planifie, le validateur accepte, la RPC agent_query_run revient verte, Haiku rédige le commentaire en français correct. Aucune exception, aucun warning Sentry. Coche, question neuf.
Ce que je n'ai pas regardé sur le moment, parce que rien ne m'y poussait, c'est la valeur de result_row_count dans agent_runs pour ce run précis.
-- généré par Sonnet 4.6, validé par node-sql-parser, exécuté par agent_readonly
SELECT c.atelier,
SUM(e.montant_prevu - COALESCE(e.montant_paye, 0))
FILTER (WHERE e.statut IN ('a_payer', 'en_retard')) AS reste_a_encaisser
FROM echeances_inscription e
JOIN contacts c ON c.id = e.contact_id
WHERE c.site = ANY($1::text[]) -- site_filter, claim JWT
AND c.statut <> 'liste_rouge'
GROUP BY c.atelier
ORDER BY reste_a_encaisser DESC NULLS LAST
LIMIT 1000;
Et le commentaire Haiku, rendu à l'utilisateur, qui rationalise l'absence :
Sur l'année 2025-2026, le reste à encaisser par atelier ressort à zéro sur l'ensemble des sites. Cela peut signaler que les prélèvements de l'année sont à jour, ou que les échéances ouvertes sont enregistrées sous un autre statut. Pour une vue plus fine, consulter
/finance/cash.
Le SQL est correct selon le contrat. La RPC le confirme. Et le contrat est faux.
La requête à la main
Le doute m'est venu le soir, à froid, en relisant les dix runs dans /admin/rembrandt/analytics-runs. Trois questions sur les dix avaient un result_row_count à zéro alors qu'elles concernaient des chiffres dont je connaissais l'ordre de grandeur. J'ai ouvert psql, j'ai tapé la requête la plus courte du monde.
rembrandt=> SELECT statut, COUNT(*) FROM echeances_inscription
GROUP BY statut ORDER BY 2 DESC;
statut | count
-----------+-------
preleve | 1630
planifie | 158
annule | 1
(3 rows)
Trois statuts, mille sept cent quatre-vingt-neuf lignes au total, et aucune valeur en commun avec les quatre que j'avais déclarées dans le semantic layer. Aucun encaisse. Aucun a_payer. Aucun en_retard.
Le semantic layer documentait encaisse | a_payer | en_retard | annule. La base contenait preleve | planifie | annule. Les trois métriques canoniques ca_encaisse, reste_a_encaisser, nb_echeances_en_retard filtraient toutes sur des valeurs qui n'existaient pas. Sonnet faisait son travail, le validateur faisait son travail, Postgres faisait son travail, et la réponse rendue à l'utilisateur était rigoureusement zéro, présentée en français propre.
L'origine du drift est ridicule. La Phase 1 du semantic layer s'était appuyée sur docs/agent-analytique/eval-set-v1.md, document que j'avais rédigé moi-même en intentions conceptuelles. La migration Postgres, posée des semaines plus tôt par un autre raisonnement (workflow Stripe, prélèvement, planification), avait inscrit preleve | planifie | annule. J'ai écrit la couche sémantique en regardant la doc au lieu d'interroger la base.
La règle
Sculley et al. ont publié en 2015 un papier devenu canonique, Hidden Technical Debt in Machine Learning Systems. Leur notion de configuration debt : un système accumule de la dette dans la couche qui le décrit autant que dans le code qui le fait tourner. La couche sémantique d'un agent SQL est exactement cette couche-là.
Une couche sémantique est une deuxième base de données. Elle a son schéma, ses contraintes, et comme toute base elle dérive si on ne l'audite pas. Ce que le pattern Genie n'élimine pas, c'est le risque schéma. Il le déplace sur la couche de traduction qu'il introduit, et il rend l'erreur silencieuse parce que le SQL produit reste valide.
Le piège n'était pas dans Genie. Le piège était dans l'idée que je m'étais faite de mes propres données.
Ce que tu peux copier
Seeder les enums depuis la base, pas depuis la doc. Un script qui lit la base au moment de la génération du module TS, et le contrat colle au schéma sans intervention humaine. La doc reste un guide d'écriture, pas une source.
// scripts/sync-semantic-enums.ts — exécuté en pre-commit ou en CI
import { admin } from '@/lib/supabase-admin'
import { writeFileSync } from 'node:fs'
const targets = [
['echeances_inscription', 'statut'],
['inscriptions', 'statut'],
['contacts', 'statut'],
] as const
for (const [table, col] of targets) {
const { data, error } = await admin.from(table).select(col)
if (error) throw error
const values = [...new Set(data?.map((r) => r[col]).filter(Boolean))]
const out = `export const ${table}_${col}_enum = ${JSON.stringify(values)} as const\n`
writeFileSync(`lib/analytics/semantic/generated/${table}.${col}.ts`, out)
}
Tester la cohérence en CI. Le test échoue si la couche déclare un statut que la base ne contient plus, ou inversement. Six jours de drift se réduisent à six secondes.
// __tests__/semantic-drift.test.ts
import { describe, it, expect } from 'vitest'
import { semanticTables } from '@/lib/analytics/semantic'
import { admin } from '@/lib/supabase-admin'
describe('semantic layer drift', () => {
for (const table of semanticTables) {
for (const [col, def] of Object.entries(table.columns)) {
if (!def.enum) continue
it(`${table.name}.${col} matches DB`, async () => {
const { data } = await admin.from(table.name).select(col)
const real = new Set(data?.map((r) => r[col]).filter(Boolean))
for (const v of real) expect(def.enum).toContain(v)
})
}
}
})
Surfacer agent_runs.result_row_count = 0 dans une page admin avec filtre sept jours glissants. La table est déjà là, elle ne demande qu'à être lue. Un graphe de la part de runs à zéro par jour, et le drift apparaît à l'œil.
Si tu maintiens un semantic layer en TS sur Postgres, le test ci-dessus se branche en moins d'une heure et te dit immédiatement où tu mens à ton agent. Sur Rembrandt ce signal n'existait pas avant ce vendredi-là.
Code compagnon : rembrandt-samples/semantic-layer-drift/ — script seed enums + test Vitest de drift + schéma agent_runs avec index canary zero-row, MIT, prêt à copier.

Top comments (0)