feat(r5): recette sans clé API + durcissement production siop2-ai

Recette (mode extractif, aucune clé) — elle a invalidé le modèle R5.1 :
- MiniLM-384 classait la page-réponse DERRIÈRE des passages sans rapport
  (0,24 vs 0,41 sur la question type du plan) → bascule mesurée vers
  paraphrase-multilingual-mpnet-base-v2 (768 d, local/CPU), ADR-004 amendé
  avec le banc comparatif (e5-large écarté : 2,2 Go, scores compressés).
- Migration r5_embeddings_mpnet : pgvector 384 → 768, index vidé
  (re-dérivable par « Réindexer tout »).
- Découpage affiné (~350 caractères) : la phrase-réponse ne se noie plus,
  l'extrait cité est lisible ; seuils par défaut recalés 0,45/0,40/0,55.
- Rejouée après bascule : réponse sourcée p. 2 en tête, refus honnête
  chiffré, suggestions étagées — 16/16 Playwright, 78 API, 23 pytest.
- Revue pixel publiée (6 écrans réels vs maquettes, 3 arbitrages).

Durcissement :
- apps/ai/Dockerfile : uv, modèle ONNX téléchargé AU BUILD (ADR-004 §1),
  non-root, healthcheck ; répétition locale conteneurisée validée
  (healthz, reindex via MinIO/pgvector, 401 sans jeton, refus de boot
  api-sans-clé, réponse sourcée depuis le conteneur).
- Compose Dokploy : siop2-ai interne (jamais sur dokploy-network,
  AI_SERVICE_TOKEN requis, génération opt-in, seuils par env) ;
  siop2-api branché (AI_SERVICE_URL).
- Runbook §5-6 : service IA en production, calibrage des seuils sur le
  corpus client, réindexation post-déploiement.

Le tag release/r5 attend la validation de la revue pixel par le référent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-17 21:24:33 +01:00
parent 28eecc1fb9
commit b69c54ed0f
15 changed files with 213 additions and 23 deletions

View File

@@ -10,10 +10,23 @@
## Décision
1. **Embeddings : locaux, sur CPU**`fastembed` (ONNX, sans PyTorch) avec
`sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` (384 dimensions, multilingue, ~120 Mo, 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.
`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.

View File

@@ -19,7 +19,8 @@ domaine (Traefik/Dokploy) ──▶ siop2-web :80 (nginx, statique)
siop2-api :3000 (NestJS)
│── siop2-postgres :5432 (pgvector + PostGIS)
│── siop2-redis :6379
── siop2-minio :9000
── siop2-minio :9000
└── siop2-ai :8000 (FastAPI — R5, interne)
```
- **Convention `siop2-`** (leçon v1) : le réseau Dokploy est partagé entre projets ;
@@ -42,6 +43,11 @@ domaine (Traefik/Dokploy) ──▶ siop2-web :80 (nginx, statique)
| `DEMO_MODE_I_KNOW` | — | double verrou ADR-002 : requis si `DEMO_MODE=true` en production, sinon l'API **refuse de démarrer** |
| `SEED_ON_START` | — | `true` sur l'instance de démonstration : rôles, matrice et comptes démo au boot (idempotent) |
| `SEED_DEMO_PASSWORD` | — | mot de passe commun des comptes démo (défaut `Demo!2026`) |
| `AI_SERVICE_TOKEN` | ✅ (R5) | secret partagé api ↔ service IA (générer : `openssl rand -hex 32`) — le service IA refuse tout appel sans lui |
| `AI_GENERATION` | — | `off` (défaut, mode extractif — la recette R5 passe ainsi) ou `api` (rédaction LLM, exige `AI_API_KEY`) |
| `AI_API_KEY` | — | clé API Anthropic — **uniquement** si `AI_GENERATION=api` ; le service refuse de démarrer si elle manque en mode api |
| `AI_MODEL` | — | défaut `claude-opus-4-8` |
| `AI_SEUIL_PERTINENCE` / `AI_SEUIL_SUGGESTION` / `AI_SEUIL_CONFIANCE_FORTE` | — | défauts 0,45 / 0,40 / 0,55 — **à calibrer sur le corpus client** (voir §7) |
Profils types :
@@ -64,6 +70,7 @@ Dans le service **Compose** du projet Dokploy (déjà créé pour l'instance ENS
POSTGRES_PASSWORD=<openssl rand -hex 24>
MINIO_ROOT_PASSWORD=<openssl rand -hex 24>
JWT_SECRET=<openssl rand -hex 32>
AI_SERVICE_TOKEN=<openssl rand -hex 32>
DEMO_MODE=true
DEMO_MODE_I_KNOW=true
SEED_ON_START=true
@@ -103,7 +110,36 @@ Les migrations de la release s'appliquent au démarrage du conteneur API ; en ca
d'échec de migration, le conteneur s'arrête **sans** servir de trafic (l'ancienne
version reste visible côté web).
## 5. Incidents & retours arrière
## 5. Le service IA en production (R5)
- `siop2-ai` **ne rejoint jamais** `dokploy-network` : il n'a pas de domaine, pas de
port publié — seul `siop2-api` le contacte, avec `AI_SERVICE_TOKEN`. S'il est
éteint, l'assistant répond « indisponible » (503 propre) et **tout le reste de
l'application fonctionne**.
- Le modèle d'embeddings (mpnet multilingue, ~1 Go) est **dans l'image** : premier
build long (téléchargement au build), démarrages rapides ensuite, aucun accès à
Hugging Face requis en production.
- **Après chaque déploiement qui change le modèle ou le découpage** (ex. migration
`r5_embeddings_mpnet`) : l'index est vide — cliquer **« Réindexer tout »** dans
Bibliothèque (ou `POST /assistant/reindex`) pour reconstruire le corpus.
- Mode génératif : ajouter `AI_GENERATION=api` + `AI_API_KEY` dans l'environnement
Dokploy puis redéployer le service `siop2-ai` seul. La clé ne transite jamais par
le dépôt ni par les journaux (`/healthz` n'expose que le mode).
## 6. Calibrage des seuils sur le corpus client
Les seuils par défaut ont été mesurés sur un banc synthétique (journal 17/07/2026).
Sur le vrai corpus SPELEV (notices réelles), rejouer une dizaine de questions métier
et quelques questions hors corpus via l'écran Assistant, puis ajuster :
- trop de refus → baisser `AI_SEUIL_PERTINENCE` par pas de 0,03 ;
- réponses « à côté » sur des questions hors corpus → le monter ;
- suggestions de bilan trop rares/trop bruyantes → jouer sur `AI_SEUIL_SUGGESTION`.
Chaque ajustement = variable d'environnement Dokploy + redéploiement de `siop2-ai`
(pas de rebuild, pas de réindexation).
## 7. Incidents & retours arrière
- **Rollback applicatif** : Dokploy → Deployments → redéployer le commit précédent.
Les migrations Prisma étant additives (convention projet : jamais de `DROP` sans
@@ -115,7 +151,7 @@ version reste visible côté web).
Sauvegarde PostgreSQL planifiée côté Dokploy (onglet Backups) — à configurer
à la première mise en production réelle.
## 6. Répétition locale (sans Dokploy)
## 8. Répétition locale (sans Dokploy)
```bash
# depuis la racine — construit et lance les 5 services comme en production

View File

@@ -4,6 +4,43 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
---
## 2026-07-17 — Pr. Daaif (+ Claude) — Recette R5 (sans clé API) + durcissement production `siop2-ai`
**Actions**
- **Recette rejouée au vrai modèle, mode extractif (aucune clé API)** sur un corpus mis en scène (notice
Otis Gen2 générée, 2 pages). Elle a **invalidé le modèle d'embeddings choisi en R5.1** : sur la question
type du plan (« quel couple de serrage pour les guides du Gen2 ? »), MiniLM-384 classait la page-réponse
DERRIÈRE des passages sans rapport (0,24 contre 0,41). Banc comparatif mesuré : `mpnet-base-v2` multilingue
(768 d) rétablit le classement et une marge signal/bruit exploitable (pertinent ≥ 0,46, hors-corpus ≤ 0,42) ;
`multilingual-e5-large` (1 024 d, 2,2 Go) classait bien aussi mais scores compressés (0,73-0,90) et poids
rédhibitoires. **Bascule vers mpnet** (ADR-004 amendé, migration `r5_embeddings_mpnet` : pgvector 384 → 768,
index vidé — re-dérivable par « Réindexer tout ») + **découpage affiné** (~350 caractères : la phrase-réponse
ne se noie plus, l'extrait cité est lisible) + seuils par défaut recalés (0,45 / 0,40 / 0,55).
- **Recette validée après bascule** : réponse sourcée p. 2 en tête ✓, refus honnête chiffré sur question hors
corpus ✓ (« routeur wifi » → refus ; charabia → refus), suggestions étagées ✓, D1-D5 tenues, le tout SANS
clé. 16/16 Playwright (l'embeddeur déterministe suit les 768 dims sans recalibrage), 78 tests API, 23 pytest.
- **Revue pixel** : captures réelles des 6 écrans (web ×4, liseré « suggéré », mobile) face aux écrans de
maquette-r5 — artefact publié pour le référent avec 3 points d'arbitrage (« Appliquer » à écriture directe,
vignettes vs tableau du corpus, refus « voisin de domaine » dépendant du calibrage client).
- **Durcissement production** : `apps/ai/Dockerfile` (uv, venv non éditable, **modèle ONNX téléchargé au
build** — ADR-004 §1, non-root, healthcheck) ; compose Dokploy : service `siop2-ai` interne (jamais sur
`dokploy-network`, secret `AI_SERVICE_TOKEN` requis, génération opt-in par variables, seuils calibrables),
`siop2-api` branché (`AI_SERVICE_URL`) ; runbook enrichi (§2 variables, §5 service IA, §6 calibrage des
seuils sur corpus client, réindexation post-déploiement).
**Décisions**
- Le refus « voisin de domaine » (question ascenseur absente du corpus) reste dépendant du calibrage : jamais
d'invention (extraits réels cités), mais pas toujours un refus. Les seuils sont des variables d'environnement
pour être calibrés sur le corpus SPELEV réel — procédure au runbook §6.
- Le tag `release/r5` attend la validation de la revue pixel par le référent.
**Prochaine étape** : validation du référent (revue pixel + arbitrages) → tag `release/r5`. Restes : recette
R4 sur téléphone (Expo Go), redéploiement Dokploy (`release/r3` puis r5), secret `DOKPLOY_WEBHOOK_URL`.
---
## 2026-07-17 — Pr. Daaif (+ Claude) — R5.3 : les écrans de l'IA (web + mobile) et le corpus administrable
**Actions**