Aller au contenu

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èle LlmCallRun : coût/latence/tokens par appel, agrégés sur GET /ai/status). Le proxy LiteLLM n'est PAS déployé : le code est LiteLLM-ready (client OpenAI-compatible activable via LITELLM_BASE_URL, derrière la même interface LlmClient), 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) :

  • 1296c8d cadrage — audit INTELLITEK + ADR-012 + corrections doc (versions, compteur tests)
  • 3dfa994 A1 — modèle Insight + migration additive (cache de narration)
  • 9da4d08 A2 — computeNarrationFeatures (extraction de features pure, groundée)
  • f119fb9 A3 — client Mistral derrière interface isolée + service narration (cache idempotent)
  • 258133a A4 — endpoint GET /stations/:id/narrative (dégradation gracieuse, grounding exposé)
  • a9f5bb4 A5 — façade useStationNarrative + bouton « Générer le résumé » dans le drawer
  • f33520c A3-bis — affinage du prompt après smoke test Mistral réel (texte brut, tendance nommée, valeurs, pas d'ISO ; PROMPT_VERSION v4)

Implémentation extension D (branche feat/ai-layer) :

  • 75f6b01 D0 — compteurs d'ingestion honnêtes (created vs unchanged via createMany)
  • bdeeeda D1+D2 — modèle LlmCallRun (migration additive) + computeCostUsd (tarifs publics, pur)
  • ef929a8 D3 — décorateur ObservingLlmClient (1 LlmCallRun/appel, coût/latence, remplit Insight.costUsd)
  • e25edaf D4 — GET /api/v1/ai/status (agrégats du jour) + DTO AiStatusResponse
  • d647931 D5 — badge IA global (réutilise MStatusBadge) + 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) :

  • b3ee662 B1 — détecteur statistique pur (z-score, hystérésis), apps/api/src/anomaly/anomaly-detection.ts — aucune I/O, aucun LLM, entièrement testable
  • d5ca657 B2 — persistance épisodique (Alert ré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)
  • 728bf45 B3 — GET /api/v1/alerts (DTO partagé AlertDTO, mêmes enveloppe/validation que stations)
  • d24af46 B1-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 j
  • 353ce24 B4 — front : useAlertsStore + façade useAlertsPanel, 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 BELOW synchrone. 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 : runAnomalyScan lit 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 → null honnê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) :

  • f1c7b44 C1 — frontière hexagonale QueryPort + 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émoire
  • 9a1f03f C2 — tool-calling sur LlmClient (Mistral) + observabilité chat réutilisant D (LlmCallRun, operation 'chat')
  • 57a4a93 C3a — specs des 4 tools + dispatcher + prompt grounding-strict (hors périmètre → « je ne peux pas répondre »)
  • ebffb6a C3b — orchestrateur chat : boucle tool bornée à 2 rounds (3 appels LLM max, D5), grounding strict
  • 41641e6 C4 — rate-guard unifié MAISON (zéro dépendance, D3+D4) : plafond coût $0,50/j sur costUsdToday total (lecture observabilité D) + fenêtres fixes par IP 5/min & 20/j ; dépassement → refus propre, aucun appel LLM émis
  • b9c22ce C5 — endpoint public POST /api/v1/ask — guardé, groundé, gracieux (429 rate/coût · 400 validation · 200 réponse · 503 LLM indisponible)
  • b84655b C6a — façade useDataChat + api-client.ask (POST /ask, dégradation gracieuse 429/503/400) (D7)
  • 92a4367 C6b — organisme OChatPanel en section dédiée scrollable : UI de chat groundé, trace des tools appelés, disclaimer « réponses assistées par IA » (D7)
  • 7caeef3 chat-v2 — affinage prompt (phrases autonomes complètes, grounding intact)
  • fe4cea0 chat-ux — découvrabilité : questions d'exemple cliquables + intro enrichie
  • 2f4df8f chat-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 :

  1. 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.
  2. 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.
  3. 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 routes stations/status/health.
  • Persistance : les sorties IA sont persistées et traçées comme l'ingestion — nouveau modèle Insight (et plus tard LlmCallRun) reprenant les patterns de IngestionRun : 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.ts cô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 Alert et son enum STATISTICAL_ANOMALY ont été conçus dès l'init (migration 20260420120655_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/ (jamais ai/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. PrismaQueryAdapter délègue aux services existants (listStations, listAlerts) + un seul helper d'agrégation (réutilise le pattern de getStationMeasurements). 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 costUsdToday TOTAL (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.x protégés, branche dédiée feat/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_ANOMALY cessent 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