Backend (Fastify + Prisma)¶
Décomposition C4-C3 du container API. Monolithe Fastify hébergeant à la fois les endpoints REST et le cron d'ingestion LINDAS (ADR-003).
Vue C4 niveau 3 backend (exportée depuis Structurizr) :
5.B.1 Organisation du code¶
apps/api/src/
├── routes/ # Définition Fastify (route + schema Zod + handler)
│ ├── health.ts # GET /api/v1/health
│ ├── status.ts # GET /api/v1/status
│ ├── ai.ts # GET /api/v1/ai/status (couche IA D, ADR-012)
│ ├── alerts.ts # GET /api/v1/alerts (couche IA B, ADR-012)
│ ├── ask.ts # POST /api/v1/ask — chat sur données structurées (couche IA C, ADR-012)
│ └── stations.ts # GET /stations, /stations/:id/measurements, /stations/:id/narrative
├── services/ # Orchestration métier (use cases)
│ ├── stations-service.ts
│ ├── alerts-service.ts # réconciliation épisodique Alert + lecture /alerts (couche IA B, ADR-012)
│ └── narrative-service.ts # mesures → features → narration (couche IA, ADR-012)
├── ai/ # Couche IA (ADR-012 — A narration, D observabilité, C chat)
│ ├── narration-features.ts # extraction de features pure et groundée (A2)
│ ├── llm-client.ts # interface LlmClient isolée + impl Mistral (A3) + completeWithTools (C2)
│ ├── narration-prompt.ts # prompt déterministe + PROMPT_VERSION (A3)
│ ├── narration-service.ts # cache Insight idempotent + générateur (A3)
│ ├── observing-llm-client.ts # décorateur → 1 LlmCallRun/appel, coût/latence (D3)
│ ├── llm-pricing.ts # computeCostUsd — tarifs publics, pur (D2)
│ └── chat/ # Chat sur données structurées (extension C)
│ ├── query-port.ts # QueryPort = les 4 fonctions whitelistées (frontière hexagonale, C1)
│ ├── prisma-query-adapter.ts # impl Prisma déléguant aux services existants (C1/D2)
│ ├── tools.ts # specs des 4 tools + dispatcher vers le QueryPort (C3a)
│ ├── chat-prompt.ts # prompt grounding-strict (C3a)
│ ├── chat-service.ts # orchestrateur — boucle tool bornée 2 rounds (C3b)
│ └── rate-guard.ts # garde unifié maison : cost-cap + rate-limit IP (C4)
├── anomaly/ # Détection d'anomalie STATISTIQUE (ADR-012, extension B — PAS de LLM)
│ ├── anomaly-detection.ts # z-score déseasonnalisé par heure, pur + testable (B1/B1-bis)
│ └── anomaly-scan.ts # scan isolé post-ingestion → réconcilie les épisodes (B2)
├── domain/ # Entités métier pures (sans dépendance framework)
├── plugins/ # Plugins Fastify transverses
│ ├── cors.ts # CORS allowlist via CORS_ORIGINS
│ ├── prisma.ts # Singleton PrismaClient
│ ├── llm.ts # Singleton LlmClient (Mistral ; LiteLLM en D)
│ ├── ingestion.ts # Cron 10 min + orchestration ingestion
│ └── ...
├── ingestion/
│ └── lindas/ # Parser SPARQL + upsert idempotent + archive gzip
│ ├── fetch.ts
│ ├── parser.ts
│ ├── persist.ts
│ └── archive.ts
├── schemas/ # Zod (request/response) — certains ré-exportés depuis packages/shared
├── utils/
└── index.ts # bootstrap Fastify + register plugins + listen
Les couches domain/ + services/ sont fines aujourd'hui — le code principal vit dans routes/ (handlers) et plugins/ingestion.ts. Une séparation plus stricte se justifiera quand la surface métier grandira.
5.B.2 Endpoints REST¶
Tous versionnés sous /api/v1, base URL conventionnelle. Huit endpoints :
GET /api/v1/health— liveness probe minimaliste. Non authentifié.{ status, timestamp, database }. Retourne 503 si Postgres est inaccessible. Consommé par Coolify/Traefik pour déterminer si le container est prêt à recevoir du trafic.GET /api/v1/status— observabilité élargie. Non authentifié. Exposeingestion.lastRun(dernierIngestionRun, dontmeasurementsCreatedCountetmeasurementsUnchangedCount— cf. D0, compteurs honnêtes créés vs ré-vus),ingestion.lastSuccessAt,ingestion.healthyThresholdMinutes,ingestion.today.{runsCount, measurementsCreatedSum, successRate}. Consommé par le badgeMStatusBadgedansOHeroSection(polling 60 s).GET /api/v1/ai/status— observabilité de la couche IA (ADR-012, D). Non authentifié. Agrège lesLlmCallRundu jour :ai.{callsToday, errorRate, costUsdToday, avgLatencyMs, lastCallAt}. DB-down → 503. Consommé par un secondMStatusBadgeglobal dansOHeroSection(opérationnel/dégradé/indisponible).GET /api/v1/alerts— liste read-only des épisodes d'anomalie statistique (ADR-012, B). Non authentifié. Query params (Zod) :status(open|closed|all, défautopen),stationId?,type?,limit?(défaut 50, max 200). Retourne{ data: AlertDTO[] }triéopenedAt DESC; chaqueAlertDTOportelevel,parameter,triggerValue,thresholdValue,openedAt/closedAtet un sacmetadataexposant les stats groundées (mean/std/z/hourBucket/bucketSampleSize/fenêtre). Consommé parOAlertsPanel(vue réseau) ; le compteur d'épisodes ouverts par station alimente aussiactiveAlertsCountsur/stations(halo carte).GET /api/v1/stations— liste des stations actives avec leurslatestMeasurementspar paramètre (DISCHARGE,WATER_LEVEL). Champs clés :ofevCode,dataSource(LIVE|RESEARCH|SEED),sourcingStatus(CONFIRMED|ILLUSTRATIVE). Query params :catchmentId?,isActive?.GET /api/v1/stations/:id/measurements— séries temporelles d'une station sur une fenêtre[from, to]. Query params obligatoires :from,to(ISO 8601). Optionnel :parameter=DISCHARGE. Retourne{ data: { stationId, from, to, aggregate: 'raw', series: MeasurementSeries[] } }.GET /api/v1/stations/:id/narrative— résumé LLM groundé d'un paramètre sur une fenêtre (couche IA, ADR-012). Query params :parameter(requis),from,to(ISO 8601),lang?(défautfr). Validation Zod ; 404 station inconnue, 400 requête invalide. La couche IA ne renvoie jamais d'erreur HTTP : réponse 200 avec un état métierstate: generated | cached | unavailable(+reason: insufficient_data | llm_error | config_error),text(null si indisponible) etgrounding(trend, deltaAbs, deltaPct, status, completeness) exposé pour transparence. DTOStationNarrativeDTOdanspackages/shared.POST /api/v1/ask— chat sur données structurées par function-calling (couche IA C, ADR-012). Non authentifié, public. Body (Zod) :{ question, language? }(défautfr). C'est la surface la plus sensible de la couche IA — les gardes C1→C4 y convergent, dans l'ordre : (1) rate-guard par IP + plafond coût (C4) court-circuite en 429 avant tout parsing ou appel LLM ; (2) validation Zod → 400 ; (3) chat groundé sur leQueryPort→ 200{ answer, used: ToolUse[], language, generatedAt }(used= trace honnête des tools appelés) ; (4) unLlmErrordégrade gracieusement en 503AI_UNAVAILABLE, jamais un 500 brut. DTOAskResponseDTOdanspackages/shared. Détail du flux en §5.B.8 et §6.4 runtime.
Chaque endpoint est typé via un schéma Zod (schemas/) appliqué à la fois en validation request et en shape response. Les DTO sont définis dans packages/shared/src/types/ — même source de vérité côté front et back.
Épopées PRD hors scope v1 (admin thresholds, alerts CRUD/notifications, stations/:id détail complet, /auth/login, /compare) — tracées dans §3.3 périmètre et dans le PRD. La lecture d'anomalies (GET /alerts, détection statistique) est en revanche livrée via la couche IA additive (ADR-012, B) ; seules l'écriture/admin et les notifications restent exclues.
5.B.3 Plugin d'ingestion LINDAS¶
apps/api/src/plugins/ingestion.ts orchestre le cycle :
- Scheduling —
node-cronavec expressionINGESTION_SCHEDULE(défaut*/10 * * * *). Démarrage auonReadyhook Fastify, cleanup suronClose. - Fetch —
undiciHTTP POST vershttps://lindas.admin.ch/queryavec la requête SPARQL cible (apps/api/src/ingestion/lindas/fetch.ts). Timeout 10 s, retry 3× backoff exponentiel. - Parse — validation Zod des bindings
SPARQL results JSONavant persistance (parser.ts). Un binding malformé skip sans faire échouer le run entier. - Persist — upsert idempotent sur clé composite
{stationId, parameter, recordedAt}via Prisma (persist.ts). Rejouer deux fois le même tick n'insère jamais de doublon. - Archive —
gzipdu payload SPARQL brut sur disque dans/app/var/lindas-archive/YYYY-MM-DD/<timestamp>-<hash>.json.gz(named volume Docker). Rotation 30 jours. Échec d'archive non-fatal (loguéwarn, cf. post-mortem 2026-04-22 EACCES). - Trace — chaque tick persiste un
IngestionRun(source: 'LINDAS_HYDRO',status: SUCCESS | FAILURE | PARTIAL, compteurs, durée, hash payload). Lu par/status.
Environnement contrôlable via :
INGESTION_ENABLED(défauttrueen prod, souventfalseen tests pour désactiver le cron)INGESTION_SCHEDULE(cron expression)INGESTION_HEALTHY_THRESHOLD_MINUTES(défaut 30, piloté par/status.ingestion.healthyThresholdMinutes)
5.B.4 Plugins transverses¶
plugins/cors.ts— CORS allowlist viaCORS_ORIGINS(env, séparé par virgules). Aucune étoile en prod.plugins/prisma.ts— singletonPrismaClientattaché au lifecycle Fastify (onClose→prisma.$disconnect()).- Pino logger — JSON stdout par défaut, niveaux structurés. Format
{ level, time, pid, hostname, msg, ... }. Pas de PII. Agrégeable par n'importe quel stack (Loki, Datadog, CloudWatch) en v2.
Pas wired en v1 — reportés post-candidature :
- Helmet (headers sécurité côté API — les 6 headers nginx côté SPA couvrent l'essentiel ; l'API est read-only public).
- Rate limiting global (
@fastify/rate-limit) — démonstration publique, pas de protection DDoS nécessaire pour la démo. Exception assumée : le seul endpoint coûteux (POST /ask, appels LLM payants) est protégé par un rate-guard maison ciblé (plafond coût + fenêtres par IP, zéro dépendance) — cf. §5.B.8.
5.B.5 Entrypoint et boot¶
apps/api/entrypoint.sh orchestre le démarrage du container API :
prisma migrate deploy— fatal en cas d'échec (set -eu). Démarrer contre un schéma stale est pire que ne pas démarrer.prisma db seed— siSEED_ON_BOOT=true. Tolérant à l'échec (warn puis continue). Self-healing contre perte de données (post-mortem 2026-04-21).exec node dist/index.js—execpour quetini(PID 1) forwarde les signaux directement à Node.
Le seed n'insère que les tables de contexte (stations, glaciers, captages, seuils) via upsert idempotent. Les tables opérationnelles (Measurement, IngestionRun, Alert) sont exclusivement alimentées par le cron. Un re-seed ne détruit jamais l'historique ingéré.
5.B.6 Couche IA — narration (ADR-012, extension A)¶
Couche additive et isolée : elle consomme les sorties existantes sans modifier le flux LINDAS → Prisma → API. Flux d'un appel GET /stations/:id/narrative :
- Mesures —
narrative-service.tsréutilisegetStationMeasurements(raw, fenêtre[from, to]) — d'où le 404 station inconnue hérité. - Features —
ai/narration-features.ts(fonction pure) calcule les indicateurs groundés : delta absolu (+ %), valeurs début/fin, min/max, complétude (points présents/attendus,sparse, plus grand trou), statut vs seuils (NORMAL/VIGILANCE/ALERTounull), tendance classifiée. Cadence de complétude :INGESTION_INTERVAL_MINUTES(adossée au cron,ingestion/cadence.ts). - Garde-fou — données insuffisantes (
!hasDataou pas de delta) ⇒state: 'unavailable'(reason: 'insufficient_data'), aucun appel LLM. - Narration —
ai/narration-service.tscalcule uninputHash(sha256 de features +PROMPT_VERSION+ langue + modèle) et lit le cacheInsight; cache hit ⇒ pas d'appel LLM (state: 'cached'). Sinon il appelle Mistral via l'interface isoléeLlmClient(ai/llm-client.ts), persiste l'Insight(texte + provenance + tokens/latence ;costUsdnull jusqu'à D) et renvoiestate: 'generated'. - Grounding STRICT — le LLM ne reçoit que les features ; il les reformule, ne prédit ni n'invente. Sortie étiquetée « résumé assisté par IA » côté UI.
- Dégradation gracieuse — toute erreur LLM (
config/network/http/timeout/parse) devientstate: 'unavailable'(reason: 'llm_error' | 'config_error'), jamais une 5xx : l'enrichissement est non-critique et ne pollue pas le monitoring d'erreurs serveur.
Interface LlmClient : A appelle Mistral directement (clé MISTRAL_API_KEY, server-side only).
Observabilité LLM (D) — un décorateur ObservingLlmClient enveloppe le client (interface inchangée) et persiste un LlmCallRun par appel (succès comme échec) : provider, modèle, tokens, coût estimé (computeCostUsd, tarifs publics Mistral), latence. Il remplit aussi LlmCompletion.costUsd → Insight.costUsd renseigné. Les erreurs de persistance sont avalées (l'observabilité ne casse jamais la feature). Agrégé par GET /ai/status. Le client est LiteLLM-ready (OpenAI-compatible via LITELLM_BASE_URL, derrière la même interface) mais le proxy LiteLLM n'est pas déployé — point d'extension assumé (ADR-012).
5.B.7 Détection d'anomalie statistique (ADR-012, extension B)¶
Cadrage honnête :
apps/api/src/anomaly/est statistique, pas « IA ». Aucun appel LLM n'entre dans la décision d'anomalie ; le verdict est un z-score pur et déterministe. Si le LLM intervenait un jour, il ne ferait que narrer un verdict déjà produit ici.
Couche additive et isolée, sur le même principe que la narration : elle lit après l'upsert d'ingestion et ne modifie jamais le flux LINDAS → Prisma.
- Détection pure —
anomaly/anomaly-detection.ts(detectAnomaly, sans I/O niDate.now(), entièrement unit-testable). Méthode : z-score déseasonnalisé par heure-de-jour UTC (B1-bis). Le point candidat (le plus récent) est comparé non à toute la fenêtre, mais à la distribution des points de la même heure UTC des jours précédents — « ce creux de 06:00 vs les creux de 06:00 des jours d'avant ». Les rivières glaciaires ont un cycle diurne marqué ; un z-score sur fenêtre entière flaguerait chaque creux diurne comme un fauxBELOW. Le bucketing horaire retire cette saisonnalité : seul un point inhabituel pour son heure fire. Seuils :|z| ≥ 2ouvre (3 →ALERT), hystérésis|z| < 1,5ferme (anti-flapping) ; gardesminBucketSamples(≈ 20 points même-heure) et σ≈0 →nullhonnête plutôt que fire sur un σ fragile (cold-start). - Scan post-ingestion —
anomaly/anomaly-scan.ts(runAnomalyScan) parcourt chaque station LIVE × capteur actif, charge 14 j de mesures (le scan surcharge à 14 j la fenêtre de référence du détecteur — dont le défaut interne est 7 j — pour que chaque bucket horaire ait ~2 semaines de points), et délègue àdetectAnomaly. Coquille I/O fine autour de la fonction pure. Lancé après le tick d'ingestion et isolé de lui : un échec de détection ne bloque ni ne corrompt le chemin critique. - Réconciliation épisodique —
services/alerts-service.tsmaintient l'invariant une seule alerte ouverte par{station, paramètre}: un verdict sans alerte ouverte en ouvre une ; un verdict avec alerte ouverte la met à jour en place ; un retour à la normale la ferme (closedAt). Les stats groundées du verdict (mean/std/z/hourBucket/bucketSampleSize/fenêtre) sont persistées dansAlert.metadatapour transparence et UI. - Réactivation sans migration — le modèle
Alertet l'enumSTATISTICAL_ANOMALYexistent depuis la migrationinit(2026-04-20) ; B les active sans aucune migration destructrice — le schéma initial anticipait l'extension.
Limites assumées (revendiquées dans l'UI, lignée ADR-008) : seuils/hystérésis non calibrés par une expertise hydrologique. Effet mesuré de la déseasonnalisation (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 reste ~stable (14 → 15) ; σ resserré ⇒ plus sensible aux tendances multi-jours (une vraie récession bassin fire à toutes les heures, à raison).
5.B.8 Chat sur données structurées (ADR-012, extension C)¶
Cadrage honnête : c'est du 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 LLM choisit parmi 4 fonctions typées whitelistées ; il n'écrit jamais de requête (pas de text-to-SQL). C'est aussi le seul endroit du backend où la frontière hexagonale (ports/adapters) est réelle — ailleurs le code reste volontairement plat (YAGNI, ADR-003).
Le module apps/api/src/ai/chat/ est une couche additive et isolée, comme la narration et l'anomalie. Cinq blocs :
QueryPort— la frontière hexagonale (C1).query-port.tsest une interface de domaine pure : elle ne connaît ni Prisma, ni Fastify, ni aucune infrastructure. Le port est la whitelist : les 4 — et seulement 4 — fonctions que le LLM peut appeler (D6) :
| Fonction | Rôle | Retour |
|---|---|---|
findStations |
résolveur nom → id | identité SANS valeurs : id, name, riverName, dataSource, paramètres disponibles |
getLatestMeasurements |
dernière valeur par paramètre | parameter + valeur + unit + status + recordedAt |
getMeasurementStats |
agrégat sur fenêtre [from, to) |
first/last/min/max/avg/deltaAbs/deltaPct/sampleSize (+ unit, fenêtre) ; null = absence honnête |
listAlerts |
alertes (réutilise B) | AlertDTO[] (status open|closed|all, stationId?) |
La séparation identité (sans valeurs) / valeurs est volontaire : findStations résout les noms sans divulguer de mesures ; le LLM doit explicitement demander les valeurs via les 3 autres fonctions.
-
PrismaQueryAdapter— la seule impl infrastructure (C1/D2).prisma-query-adapter.tsimplémente le port en déléguant aux services existants (listStations,listAlerts) + un seul helper d'agrégation (réutilise le pattern degetStationMeasurements). Pas de réécriture de requêtes. UnQueryPorten mémoire (fake) prouve que tout le raisonnement de chat se teste sans DB. -
Tools + dispatcher (C3a).
tools.tsporte les specs function-calling des 4 fonctions (schémas exposés au LLM) + un dispatcher qui route un tool-call validé vers la méthodeQueryPortcorrespondante. Hors whitelist → refus, jamais d'exécution arbitraire.chat-prompt.tsporte le prompt grounding-strict. -
Orchestrateur (C3b).
chat-service.tspilote la boucle tool-calling entre Mistral et leQueryPort. À chaque round le modèle répond en prose (terminé) ou demande des tool-calls ; le dispatcher les exécute, leurs résultats sont réinjectés, le modèle est rappelé. Boucle bornée DUR à 2 rounds (D5) : le 3ᵉ appel LLM est forcé entool_choice: 'none'→ il ne peut produire que la réponse finale, jamais de 4ᵉ appel. Le service reste Prisma-free (il dépend duQueryPort), donc testable avec un fake en mémoire. -
Rate-guard unifié maison (C4).
rate-guard.tsest un seul garde testable (zéro dépendance) qui unifie deux protections du budget LLM, dans l'ordre : (1) plafond coût $0,50/jour sur lecostUsdTodayTOTAL (narration A + chat C), lu en read-only depuis la même source d'observabilité que/ai/status(D) — au-delà → refuscost_capsans consommer de slot ni émettre d'appel LLM (disjoncteur, pas quota) ; (2) fenêtres fixes par IP : 5/min & 20/jour — un slot n'est consommé que si la requête passe. Tout le temporel (now,getCostUsdToday) est injecté → garde déterministe en test, zéro DB, zéro flakiness d'horloge. Choix maison ciblé et défendable (cohérent avec « rate limiting global non wired » du non-scope).
Observabilité (réutilise D). Chaque appel LLM du chat passe par le décorateur ObservingLlmClient avec operation: 'chat' → un LlmCallRun par appel (coût/latence/tokens), agrégé dans /ai/status et compté dans le plafond coût ci-dessus. Aucune table ni secret nouveaux : C réutilise MISTRAL_API_KEY et l'observabilité D.
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. Seule exception bornée (chat-v3, chat-prompt.ts) : une méta-question (« que peux-tu faire ? ») obtient une description du périmètre — les 4 capacités et 1–2 exemples — sans aucun appel de fonction ; le grounding reste absolu pour toute vraie question (une demande de météo ou de prévision reste un refus). La trace used (tools réellement appelés) est renvoyée au front pour transparence.