# ADR-004 — Modèles et topologie du service IA (R5)

- **Statut** : acceptée (R5.1, 17 juillet 2026)
- **Contexte** : les maquettes R5 et les décisions D1-D5 sont validées (l'IA propose/l'humain
  valide, sourcé ou silencieux, corpus fermé, anonymisation 09-08, voix purgée). Reste à
  trancher COMMENT : quels modèles, où tournent-ils, qui parle à qui. Contraintes : serveur
  Dokploy du partenaire (CPU, pas de GPU garanti), corpus en français, données d'un client
  marocain (loi 09-08), CI sans secret, démo qui marche sans dépendance externe.

## Décision

1. **Embeddings : locaux, sur CPU** — `fastembed` (ONNX, sans PyTorch) avec
   `sentence-transformers/paraphrase-multilingual-mpnet-base-v2` (768 dimensions, multilingue,
   ~1 Go, registre fastembed). Les textes du client ne quittent JAMAIS le serveur pour
   l'indexation ni pour la recherche. Le modèle est téléchargé au build de l'image (pas au
   démarrage). En **tests/CI : embeddeur déterministe par hachage** (pas de téléchargement,
   pas de flottement) derrière la même interface.

   > **Amendé en recette R5 (17/07/2026)** : le choix initial, MiniLM-L12-v2 (384 d, ~220 Mo),
   > a été invalidé par la mesure — sur un banc français question→passage, il classait la
   > page contenant la réponse DERRIÈRE des passages sans rapport (0,24 contre 0,41) et ne
   > laissait aucune marge pour le seuil de refus D2. `mpnet-base-v2` rétablit le classement
   > et une marge signal/bruit exploitable (pertinent ≥ 0,46 ; hors-corpus ≤ 0,42) pour un
   > coût CPU encore raisonnable. `multilingual-e5-large` (1024 d, 2,2 Go) classait aussi
   > correctement mais ses scores compressés (0,73-0,90) et son poids l'écartent — à
   > réévaluer au calibrage sur le corpus client si la marge de mpnet s'avère insuffisante.
   > Migration `r5_embeddings_mpnet` : colonne pgvector 384 → 768, index vidé (re-dérivable
   > par réindexation). Seuils par défaut recalés : pertinence 0,45, suggestion 0,40,
   > confiance forte 0,55.
2. **Stockage vectoriel : pgvector** dans le PostgreSQL existant (extension déjà installée
   depuis R0) — table `RagChunk` gérée par la **migration Prisma** (le schéma reste la
   propriété d'`apps/api`, source unique). Pas de base vectorielle de plus à opérer.
3. **Génération (réponses rédigées de l'assistant) : opt-in par configuration.**
   - Sans clé (`AI_GENERATION=off`, défaut) : l'assistant fonctionne en **mode extractif** —
     il montre les meilleurs extraits sourcés et une synthèse templatée, sans LLM. La démo,
     la CI et un déploiement sans budget API restent pleinement fonctionnels et honnêtes.
   - Avec clé (`AI_GENERATION=api` + `AI_API_KEY`, modèle configurable par `AI_MODEL`,
     défaut `claude-opus-4-8`) : rédaction par le SDK officiel Anthropic, sur textes DÉJÀ
     anonymisés (D4), avec l'obligation de citer les extraits fournis — jamais au-delà.
     La config est validée au boot (api sans clé = refus de démarrer) ; la clé n'apparaît
     ni dans les journaux ni au `/healthz` (qui n'expose que le mode). Tout échec du LLM
     (refus, quota, réseau) retombe silencieusement sur le mode extractif.
   - La **suggestion de codes de bilan** n'utilise PAS de LLM : similarité sémantique
     (embeddings locaux) entre la description et les libellés des référentiels + les bilans
     historiques du parc. Déterministe, testable, explicable (« 9 bilans similaires »).
4. **Topologie** : `apps/ai` (FastAPI/uv, conteneur `siop2-ai`) n'est **jamais exposé** —
   réseau interne Dokploy uniquement, comme MinIO. Les utilisateurs passent par l'API NestJS
   (auth JWT + matrice de permissions réutilisées) qui proxifie vers `siop2-ai`
   (`AI_SERVICE_URL` + secret partagé `AI_SERVICE_TOKEN`). `apps/ai` lit Postgres
   (métadonnées, bilans, écriture des chunks) et MinIO (octets des PDF) en direct sur le
   réseau privé — la règle d'or ESLint d'ADR-001 concerne le code TypeScript de l'API,
   la topologie « MinIO jamais exposé » du runbook reste respectée.

## Conséquences

- L'anonymisation (D4) s'applique **à l'ingestion** — les index ne contiennent jamais
  d'identités ; le mode génératif n'envoie donc que des textes déjà nettoyés.
- Le mode extractif est le contrat de base : toute recette R5 doit passer SANS clé API.
- Si le partenaire veut un jour une génération 100 % locale (llama.cpp…), seul le point 3
  change — interface `Generateur` prévue pour ça.
