ADR-012 — Introduction d'une couche IA (narration, observabilité, anomalie, chat sur données structurées)¶
Date : 2026-06-04 (révisé 2026-06-05 — couche IA COMPLÈTE et en production : A+D+B+C livrées, doc finale C7)
Statut : Acceptée — couche IA COMPLÈTE et déployée sur main : extensions A (narration) + D (observabilité LLM) + B (détection d'anomalie) + C (chat sur données structurées) livrées en production. Tags successifs : v1.2.0-ai (A+D), v1.3.0-anomaly (B), v1.4.0-chat-api (C backend), v1.5.0-chat (C front C6) et v1.5.1-chat-ux (polish chat). La frontière hexagonale réelle (QueryPort + PrismaQueryAdapter, C1) est matérialisée et en prod — l'« hexagonal » du README cesse d'être une dette pour devenir une décision technique localisée et vérifiable. Cet ADR est tenu à jour par la doc finale C7 (alignement doc ↔ code, le gap pointé par l'audit étant clos).
Terminologie C : on parle désormais de « chat sur données structurées par function-calling », retrieval-augmenté au sens large (le LLM reçoit des faits récupérés en base avant de répondre), mais jamais vectoriel — ni embeddings, ni vector store. Le mot « RAG » est volontairement abandonné dans cet ADR car il évoque une architecture vectorielle qui n'est pas celle retenue. Le retrieval est structuré : function-calling sur un jeu fermé de requêtes Prisma whitelistées (le
QueryPort).Honnêteté D : D livre une observabilité LLM réelle et maison (décorateur
ObservingLlmClient→ modèleLlmCallRun: coût/latence/tokens par appel, agrégés surGET /ai/status). Le proxy LiteLLM n'est PAS déployé : le code est LiteLLM-ready (client OpenAI-compatible activable viaLITELLM_BASE_URL, derrière la même interfaceLlmClient), point d'extension assumé — au même titre que les ponts cloud/Snowflake évoqués. Ne pas lire « LiteLLM déployé ».
Implémentation extension A (branche feat/ai-layer) :
1296c8dcadrage — audit INTELLITEK + ADR-012 + corrections doc (versions, compteur tests)3dfa994A1 — modèleInsight+ migration additive (cache de narration)9da4d08A2 —computeNarrationFeatures(extraction de features pure, groundée)f119fb9A3 — client Mistral derrière interface isolée + service narration (cache idempotent)258133aA4 — endpointGET /stations/:id/narrative(dégradation gracieuse, grounding exposé)a9f5bb4A5 — façadeuseStationNarrative+ bouton « Générer le résumé » dans le drawerf33520cA3-bis — affinage du prompt après smoke test Mistral réel (texte brut, tendance nommée, valeurs, pas d'ISO ;PROMPT_VERSIONv4)
Implémentation extension D (branche feat/ai-layer) :
75f6b01D0 — compteurs d'ingestion honnêtes (createdvsunchangedviacreateMany)bdeeedaD1+D2 — modèleLlmCallRun(migration additive) +computeCostUsd(tarifs publics, pur)ef929a8D3 — décorateurObservingLlmClient(1LlmCallRun/appel, coût/latence, remplitInsight.costUsd)e25edafD4 —GET /api/v1/ai/status(agrégats du jour) + DTOAiStatusResponsed647931D5 — badge IA global (réutiliseMStatusBadge) +useAiStatusStore
Validé en live : narration réelle (Mistral) → LlmCallRun SUCCESS, /ai/status callsToday/errorRate/costUsdToday/avgLatencyMs, Insight.costUsd renseigné (≈ $0.00006/appel).
Implémentation extension B (branche feat/ai-anomaly, basée sur main post-A+D) :
b3ee662B1 — détecteur statistique pur (z-score, hystérésis),apps/api/src/anomaly/anomaly-detection.ts— aucune I/O, aucun LLM, entièrement testabled5ca657B2 — persistance épisodique (Alertréactivé, 1 alerte ouverte par{station, paramètre}) + scan post-ingestion isolé (anomaly-scan.ts, lit après l'upsert, ne bloque jamais le chemin critique)728bf45B3 —GET /api/v1/alerts(DTO partagéAlertDTO, mêmes enveloppe/validation questations)d24af46B1-bis — déseasonnalisation horaire : le z-score compare le point candidat à la distribution de la même heure UTC des jours précédents (et non à toute la fenêtre), scan chargé sur 14 j353ce24B4 — front :useAlertsStore+ façadeuseAlertsPanel,OAlertsPanel(vue réseau : épisodes ouverts + historique récent, état « réseau nominal »), signalement carte (halo pulsant + tooltip honnête)
Décisions structurantes de B :
- Module
apps/api/src/anomaly/= statistique, PAS « IA ». Le détecteur est un z-score déseasonnalisé pur et déterministe ; aucun appel LLM n'entre dans la décision d'anomalie. Si un jour le LLM intervient, il ne fera que narrer un verdict déjà produit — jamais décider. Cette honnêteté de cadrage est revendiquée dans l'UI (« anomalie statistique […], non calibrée hydrologie experte »). - Déseasonnalisation par bucket heure-de-jour ([mémoire B1-bis]) : les rivières glaciaires du Rhône valaisan ont un cycle diurne marqué. Un z-score sur fenêtre entière flague chaque creux de la phase diurne comme un faux
BELOWsynchrone. Bucketer par heure UTC retire cette composante. Effet mesuré (10 j réels, 6 stations LIVE) : la synchronicité diurne des BELOW chute (CV inter-heures 0,61 → 0,30) ; le nombre d'épisodes réels sous hystérésis reste ~stable (14 → 15) — le 1er jet comptait des instants soutenus, pas des épisodes. σ resserré ⇒ plus sensible aux tendances multi-jours (une vraie récession bassin fire à toutes les heures, à raison). - Scan isolé, post-tick, jamais inline :
runAnomalyScanlit les mesures après l'upsert d'ingestion ; un échec de détection ne peut ni bloquer ni corrompre le flux LINDAS (cf. alternative « IA inline » rejetée). - Limites assumées : seuils 2σ/3σ + hystérésis 2σ/1,5σ non calibrés par une expertise hydrologique ; garde
minBucketSamples(cold-start →nullhonnête plutôt que fire sur un σ fragile). Étiquetage UI explicite, dans la lignée de la transparence de sourcing (ADR-008).
Implémentation extension C (backend feat/ai-chat puis front feat/ai-chat-ui / feat/chat-ux, basés sur main post-A+D+B) — C1-C7 LIVRÉS EN PROD (décisions D1–D7 ci-dessous respectées) :
f1c7b44C1 — frontière hexagonaleQueryPort+PrismaQueryAdapter(backend pur, zéro LLM) : le port définit le contrat des 4 fonctions whitelistées, l'adapter délègue aux services existants (listStations,listAlerts) + un helper d'agrégation (D2) ; testable sans DB via un port en mémoire9a1f03fC2 — tool-calling surLlmClient(Mistral) + observabilité chat réutilisant D (LlmCallRun, operation'chat')57a4a93C3a — specs des 4 tools + dispatcher + prompt grounding-strict (hors périmètre → « je ne peux pas répondre »)ebffb6aC3b — orchestrateur chat : boucle tool bornée à 2 rounds (3 appels LLM max, D5), grounding strict41641e6C4 — rate-guard unifié MAISON (zéro dépendance, D3+D4) : plafond coût $0,50/j surcostUsdTodaytotal (lecture observabilité D) + fenêtres fixes par IP 5/min & 20/j ; dépassement → refus propre, aucun appel LLM émisb9c22ceC5 — endpoint publicPOST /api/v1/ask— guardé, groundé, gracieux (429 rate/coût · 400 validation · 200 réponse · 503 LLM indisponible)b84655bC6a — façadeuseDataChat+api-client.ask(POST/ask, dégradation gracieuse 429/503/400) (D7)92a4367C6b — organismeOChatPanelen section dédiée scrollable : UI de chat groundé, trace des tools appelés, disclaimer « réponses assistées par IA » (D7)7caeef3chat-v2 — affinage prompt (phrases autonomes complètes, grounding intact)fe4cea0chat-ux — découvrabilité : questions d'exemple cliquables + intro enrichie2f4df8fchat-v3 — affinage prompt (répond à la méta-question, grounding intact)
344 tests verts (208 api + 136 web), gate complet OK. Pas de migration, pas de nouveau secret (réutilise MISTRAL_API_KEY et l'observabilité D). C est complet et en production (backend mergé via PR #3, tag v1.4.0-chat-api ; front C6 via PR #4, tag v1.5.0-chat ; polish UX via PR #5, tag v1.5.1-chat-ux). L'« hexagonal » réel (frontière QueryPort/PrismaQueryAdapter, C1) est matérialisé et déployé. La doc finale C7 (ce commit) aligne la doc « haute » (cet ADR, le README) puis détaillée (arc42 §5/§6/§8) sur le code livré.
Contexte¶
Après les livraisons v1.0.0-crealp (2026-04-22) et v1.1.0-refactor (2026-04-23), AlpiMonitor est une SPA hydrologique stable et documentée (arc42, 11 ADR, 173 tests verts, prod live). Un audit de réappropriation (docs/audit/intellitek-audit.md, 2026-06-04) a confirmé une base saine et identifié un seul vrai écart doc ↔ code : le README revendiquait une « hexagonal API » alors que le backend est un monolithe en couches fines, volontairement plat (apps/api/src/domain/ vide, Prisma appelé directement dans stations-service.ts, pas de repository ni de ports/adapters). Le wording a été corrigé (README.md) en « monolithe en couches fines, pragmatique (YAGNI) ».
Le projet entre dans une nouvelle phase : ajouter une couche d'intelligence (analyse/narration LLM, observabilité des appels, détection d'anomalie, chat sur les données). L'enjeu de cadrage est triple :
- Ne rien casser — le flux existant (LINDAS SPARQL → Prisma → API → SPA) est en production et sous tags protégés. La couche IA doit s'y greffer sans le modifier.
- Rester défendable — chaque brique doit pouvoir être justifiée en entretien (INTELLITEK), comme le reste du projet. Pas d'« IA magique », pas de dépendance opaque.
- Transformer la dette en atout — l'« hexagonal » jamais implémenté devient un objectif réel, matérialisé là où il a un sens technique (la frontière chat/RAG), pas un buzzword rétro-collé partout.
Décision¶
Principe d'ancrage : une couche isolée, jamais une modification du flux existant¶
- Backend : nouveau module
apps/api/src/ai/autonome. Il consomme les sorties existantes (DTO mesures,IngestionRun) mais ne modifie ni l'ingestion, ni les routesstations/status/health. - Persistance : les sorties IA sont persistées et traçées comme l'ingestion — nouveau modèle
Insight(et plus tardLlmCallRun) reprenant les patterns deIngestionRun: provenance, modèle utilisé, horodatage, hash de payload, idempotence. Migration Prisma additive uniquement (jamais destructrice). - Frontend : nouveaux organismes (
O…) + nouvelles façades (use…) suivant ADR-002 (ABEM) et ADR-010 (façades,lib/pur). Aucun composant existant réécrit. - Sécurité : appels LLM server-side uniquement. La CSP actuelle (
connect-src 'self' https://api.alpimonitor.fr) interdit déjà tout appel LLM direct depuis le navigateur — c'est un atout, pas un obstacle. Clés API par variable d'environnement, jamais committées. - Déterminisme & test : les features envoyées aux prompts (delta, min/max, franchissement de seuil) sont pré-calculées en fonctions pures (extension de
lib/charts/chart-model.tscôté front et/ou utilitaires purs côté back) → prompts reproductibles et testables.
Provider LLM et grounding strict¶
- Provider A = Mistral — choisi pour la souveraineté FR/EU (cohérent avec le public CREALP) et la simplicité d'une tâche cadrée. Interchangeable : le passage par LiteLLM en D rend le fournisseur substituable sans réécriture.
- Pas de proxy en A : appel direct Mistral via l'interface client isolée + logging métrique minimal (coût/latence/tokens) persisté en base. Le proxy LiteLLM arrive en D, derrière la même interface → bascule transparente.
- Grounding STRICT : le LLM ne reçoit que les features A2 (valeurs calculées : delta, min/max, dernière valeur, franchissement de seuil). Il ne prédit rien, n'invente rien, n'extrapole rien — sa seule tâche est de formuler en langage naturel des faits déjà calculés. Toute valeur chiffrée du texte doit provenir des features. Appels server-side only.
- Langue paramétrée : sortie FR par défaut, langue passée en paramètre (jamais hardcodée) → extensible sans refonte.
Ordre de livraison validé : A → D → B → C¶
| Ordre | Extension | Ancrage principal | Statut cible |
|---|---|---|---|
| 1 | A — Narration LLM des séries | appel direct Mistral derrière une interface client isolée (ai/llm-client.ts) + logging métrique minimal (coût/latence/tokens) en base ; ai/narration-service.ts, endpoint GET /api/v1/stations/:id/narrative, modèle Insight (cache idempotent), façade useStationNarrative rendue dans OStationDrawer |
Cœur de livraison |
| 2 | D — Observabilité LLM (proxy LiteLLM) | insertion de LiteLLM derrière la MÊME interface client (bascule transparente, A→D sans réécriture du service) ; observabilité enrichie (LlmCallRun, fallback multi-modèles) ; GET /api/v1/ai/status ; badge réutilisant MStatusBadge |
Cœur de livraison |
| 3 | B — Détection d'anomalie | hook après upsert dans le tick d'ingestion (ou job séparé) ; réactive le modèle Alert déjà présent (AlertType.STATISTICAL_ANOMALY existe déjà) ; GET /api/v1/alerts ; OAlertsPanel |
Cœur de livraison — livré (feat/ai-anomaly) |
| 4 | C — Chat sur données structurées | module ai/chat/, endpoint POST /api/v1/ask, frontière QueryPort + PrismaQueryAdapter, function-calling sur 4 fonctions whitelistées (boucle bornée à 2 rounds), garde unifié (rate-limit IP + plafond coût), OChatPanel + useDataChat (section dédiée scrollable) |
Vitrine stretch — livrée en prod |
- A → D rapprochés : dès A, l'appel passe par une interface client isolée et un logging métrique minimal en base (coût/latence/tokens) — l'observabilité existe au jour 1 sans proxy. D insère ensuite LiteLLM derrière cette même interface (bascule transparente, le service narration ne change pas). Cohérent avec la culture observabilité du projet (
/status,IngestionRun). - B réactive l'existant : le modèle
Alertet son enumSTATISTICAL_ANOMALYont été conçus dès l'init (migration20260420120655_init) ; B les active sans migration destructrice — preuve que le schéma initial anticipait l'extension. - Point d'arrêt défendable : A + D + B finis et défendables. C était une vitrine stretch à livrer seulement si le temps le permettait — finalement livrée en prod (cf. §« Implémentation extension C »).
L'« hexagonal » devient un objectif réel, localisé à C¶
Plutôt que de saupoudrer le terme partout, la frontière hexagonale (ports/adapters) est introduite là où elle a une vraie valeur technique : le chat sur données structurées (extension C). Un QueryPort (interface du domaine, les 4 fonctions whitelistées que le LLM peut appeler) implémenté par un PrismaQueryAdapter isole la logique de requêtage de l'infrastructure Prisma. C'est le seul endroit où l'inversion de dépendance se justifie : substituabilité du backend de données et — surtout — testabilité du raisonnement sans DB (un QueryPort en mémoire prouve que la boucle de chat se teste sans Prisma). Le reste du backend reste volontairement plat (YAGNI, ADR-003).
Cadrage détaillé extension C (décisions arrêtées 2026-06-05)¶
Référence des stories C1→C7. Chaque décision est figée ; toute déviation exige un amendement de cet ADR.
- D1 — Nommage & module. Le code vit dans
apps/api/src/ai/chat/(jamaisai/rag/). Partout : « chat sur données structurées par function-calling ». Aucun vectoriel (ni embeddings, ni vector store). Toute mention « rag » de l'ADR est réécrite en « chat ». - D2 — Adapter délégant.
PrismaQueryAdapterdélègue aux services existants (listStations,listAlerts) + un seul helper d'agrégation (réutilise le pattern degetStationMeasurements). Pas de réécriture de requêtes, pas de text-to-SQL. - D3 — Rate-guard MAISON (zéro dépendance). 5 req/min/IP et 20 req/jour/IP, unifié avec le garde-fou coût dans un seul garde testable (pas de
@fastify/rate-limit, cohérent avec « Helmet + rate limiting non wired » du non-scope — ici c'est une implémentation maison ciblée et défendable). - D4 — Garde-fou coût. Plafond $0,50/jour sur
costUsdTodayTOTAL (narration A + chat C, lecture de l'observabilité D). Dépassement → refus 429 propre, AUCUN appel LLM émis. - D5 — Boucle tool bornée. 2 rounds maximum (soit 3 appels LLM max : round 1 → tools, round 2 → tools, round 3 → réponse finale forcée). Borne dure au-delà.
- D6 — Les 4 fonctions whitelistées (= le
QueryPort) :
| Fonction | Rôle | Retour |
|---|---|---|
find_stations |
résolveur nom → id | identité SANS valeurs : id, name, riverName, dataSource, paramètres disponibles |
get_latest_measurements |
dernière valeur par paramètre | parameter + valeur + unit + status + recordedAt |
get_measurement_stats |
agrégat sur fenêtre | first, last, min, max, avg, deltaAbs, deltaPct, sampleSize (+ unit, fenêtre) |
list_alerts |
alertes (réutilise B) | AlertDTO[] (status open|closed|all, stationId?) |
La séparation identité (sans valeurs) / valeurs est volontaire : find_stations résout les noms vers des id sans divulguer de mesures ; le LLM doit explicitement demander les valeurs via les trois autres fonctions.
- D7 — Front. Le chat est une section dédiée scrollable (organisme OChatPanel + façade useDataChat), livrée en C6 — pas un widget flottant.
Garde-fous transverses¶
- Function-calling sur 4 fonctions whitelistées (le
QueryPort) ≫ SQL/text-to-SQL libre généré par le LLM (sécurité + déterminisme + testabilité). Le LLM choisit parmi 4 fonctions typées ; il n'écrit jamais de requête. - Retrieval = Prisma structuré, ni vector store ni embeddings : les données sont structurées et de faible volume ; un vector store serait de la sur-ingénierie (cf. alternative écartée).
- Grounding STRICT (étendu à C) : hors du périmètre des 4 fonctions, la réponse est « je ne peux pas répondre » — le LLM ne brode pas sur des données qu'il n'a pas récupérées.
- Provider abstrait derrière une interface client maison dès A (
ai/llm-client.ts) : A appelle Mistral directement ; D insère LiteLLM derrière cette interface sans toucher au service. Le code produit ne connaît jamais le fournisseur en direct. - Sorties IA jamais présentées comme vérité hydrologique : la narration est un résumé assisté, étiqueté « résumé assisté par IA » dans l'UI (cohérent avec la transparence de sourcing ADR-008).
Conséquences¶
Positives¶
- Aucun risque pour la prod existante : couche additive, flux LINDAS intact, tags
v1.xprotégés, branche dédiéefeat/ai-layer. - Continuité méthodologique : réutilise les patterns éprouvés (provenance/traçabilité d'
IngestionRun, façades ADR-010, ABEM ADR-002, observabilité/status). Un relecteur voit la cohérence, pas un greffon. - Dette transformée en signal : le mot « hexagonal », au lieu d'être un mensonge du README, devient une décision technique localisée et justifiée (C).
- Réactivation de l'existant :
Alert/STATISTICAL_ANOMALYcessent d'être des modèles morts.
Négatives¶
- Nouvelle dépendance externe (Mistral en A, + proxy LiteLLM en D) — coût récurrent, latence réseau, surface de sécurité (clés). Mitigé par appels server-side, env vars, interface client isolée, logging métrique dès A.
- Surface de test accrue — prompts à tester via features pré-calculées déterministes ; appels LLM à mocker.
- Budget — A + D + B représentent l'essentiel de l'effort ; C peut ne pas être livré (assumé via le point d'arrêt).
Trade-offs assumés¶
- Retrieval Prisma structuré plutôt que vectoriel (embeddings/vector store) — pivot possible vers un vector store post-MVP si le volume/variété des données l'exige.
- Hexagonal localisé à C uniquement — le reste reste plat ; on n'hexagonalise pas le backend entier « par cohérence ».
- Narration LLM cachée en DB (
Insight) — fraîcheur vs coût : on ne rappelle pas le LLM à chaque vue ; invalidation sur changement de fenêtre/hash de données.
Alternatives écartées¶
IA côté client (appel LLM depuis le navigateur)¶
- Bénéfice : pas de backend à modifier, pas de clé à héberger côté serveur.
- Coût : clé API exposée, CSP à relâcher (régression sécurité), pas d'observabilité ni de cache mutualisé.
- Verdict : rejeté. La CSP actuelle l'interdit déjà à raison.
RAG vectoriel dès le départ (embeddings + vector store)¶
- Bénéfice : extensible à de la donnée non structurée.
- Coût : sur-ingénierie pour ~7 stations de données structurées ; un container/service de plus ; moins déterministe et plus dur à défendre.
- Verdict : rejeté pour le MVP. Retrieval Prisma + function-calling suffit et se défend mieux.
Proxy LiteLLM dès A (plutôt qu'appel direct Mistral en A, proxy en D)¶
- Bénéfice : observabilité « complète » (fallback multi-modèles) dès le premier appel.
- Coût : alourdit A d'un service d'infra supplémentaire à déployer/configurer avant même d'avoir une narration qui marche ; couple la première livraison à LiteLLM.
- Verdict : rejeté. A appelle Mistral directement derrière une interface client isolée + logging métrique minimal en base (coût/latence/tokens) — l'observabilité essentielle existe dès J1. D insère LiteLLM derrière la même interface (bascule transparente). On découple ainsi « avoir une narration » de « avoir un proxy d'observabilité avancé ».
Appels LLM directs au provider sans aucune abstraction (ni interface, ni proxy jamais)¶
- Bénéfice : zéro indirection.
- Coût : couplage dur à Mistral dans tout le code produit, pas de point d'insertion pour LiteLLM en D, pas de suivi coût/latence.
- Verdict : rejeté. L'interface client isolée dès A est le minimum non négociable — c'est elle qui rend la bascule D transparente.
Modifier le flux d'ingestion existant pour y intégrer l'IA inline¶
- Bénéfice : un seul pipeline.
- Coût : couple un flux prod stable à une dépendance externe faillible (LLM) ; un échec LLM pourrait dégrader l'ingestion.
- Verdict : rejeté. La couche IA reste isolée ; B peut lire après l'upsert mais n'en bloque jamais le chemin critique.
Références¶
docs/audit/intellitek-audit.md— audit de réappropriation 2026-06-04 (carto, gap doc↔code, mapping INTELLITEK, points d'extension IA)- ADR-001 — monostack TypeScript (la couche IA reste en TS côté app ; le proxy LiteLLM est une infra, pas du code produit Python embarqué)
- ADR-003 — monolithe Fastify, parti-pris qui cadre le « reste plat » hors frontière C
- ADR-007 — LINDAS, flux d'ingestion que la couche IA consomme sans modifier
- ADR-008 — transparence du sourcing, principe repris pour étiqueter les sorties IA
- ADR-010 — façades +
lib/pur, patterns réutilisés côté front pour la couche IA