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

@@ -63,5 +63,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- **R5 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R5.1 socle `apps/ai`** : ADR-004 (embeddings locaux fastembed 384d, pgvector via migration Prisma `r5_ia` (`RagChunk` + corpus sur Document), génération opt-in mode extractif par défaut, service jamais exposé joint par l'API seule), pipeline d'ingestion anonymisé D4 (fonction pure testée, PDF paginés + bilans codés), `/internal/reindex` + `/internal/search` sous jeton de service, 14 pytest + ruff + job CI `ai` (embeddeur déterministe en CI). Vérifié en réel : corpus seedé indexé en 7 s, recherche sémantique concluante, 0 identité dans les chunks. - **R5 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R5.1 socle `apps/ai`** : ADR-004 (embeddings locaux fastembed 384d, pgvector via migration Prisma `r5_ia` (`RagChunk` + corpus sur Document), génération opt-in mode extractif par défaut, service jamais exposé joint par l'API seule), pipeline d'ingestion anonymisé D4 (fonction pure testée, PDF paginés + bilans codés), `/internal/reindex` + `/internal/search` sous jeton de service, 14 pytest + ruff + job CI `ai` (embeddeur déterministe en CI). Vérifié en réel : corpus seedé indexé en 7 s, recherche sémantique concluante, 0 identité dans les chunks.
- **R5.1+ génération opt-in** : `Generateur` ADR-004 §3 extractif par défaut, `AI_GENERATION=api` + `AI_API_KEY` (exigée au boot, jamais loguée ; Dokploy secrets) + `AI_MODEL` (défaut claude-opus-4-8), SDK anthropic en extra optionnel, citations [n] obligatoires, repli extractif sur tout échec (dont `stop_reason=refusal`). **R5.2 assistant au contrat** : `POST /assistant/ask` (WORK_ORDERS.view) + `POST /assistant/suggest-bilan` (WORK_ORDERS.edit) proxifiés par NestJS (traduction dialecte interne contrat, 503 propre), suggestion = codes EXISTANTS seulement (confiance + « N bilans similaires »), normalisation fastembed corrigée (débusquée en chaîne réelle), 23 pytest, tests API sur stub HTTP. - **R5.1+ génération opt-in** : `Generateur` ADR-004 §3 extractif par défaut, `AI_GENERATION=api` + `AI_API_KEY` (exigée au boot, jamais loguée ; Dokploy secrets) + `AI_MODEL` (défaut claude-opus-4-8), SDK anthropic en extra optionnel, citations [n] obligatoires, repli extractif sur tout échec (dont `stop_reason=refusal`). **R5.2 assistant au contrat** : `POST /assistant/ask` (WORK_ORDERS.view) + `POST /assistant/suggest-bilan` (WORK_ORDERS.edit) proxifiés par NestJS (traduction dialecte interne contrat, 503 propre), suggestion = codes EXISTANTS seulement (confiance + « N bilans similaires »), normalisation fastembed corrigée (débusquée en chaîne réelle), 23 pytest, tests API sur stub HTTP.
- **R5.3 — écrans IA** : page web Assistant (chat sourcé, refus honnête chiffré, « Ouvrir » vers PDF/OT), Bibliothèque = corpus administrable (statut d'indexation par document, interrupteur d'exclusion, « Réindexer tout », bandeau 09-08), suggestions fiche OT Appliquer » = geste humain, liseré « suggéré » retiré au choix manuel) et clôture mobile (chips, « réseau requis » hors-ligne) ; contrat 76 opérations (corpus sur Document, `PATCH /documents/{id}/corpus`, `POST /assistant/reindex` ci-contract vérifie aussi le client mobile) ; seuils `AI_SEUIL_*` par env ; job e2e CI avec `siop2-ai` (embeddeur déterministe, seuils calibrés sur mesures réelles), 16/16 Playwright, 78 tests API ; chaîne vérifiée au vrai modèle ONNX (web 7/7, mobile Expo web 6/6, 0 erreur console). - **R5.3 — écrans IA** : page web Assistant (chat sourcé, refus honnête chiffré, « Ouvrir » vers PDF/OT), Bibliothèque = corpus administrable (statut d'indexation par document, interrupteur d'exclusion, « Réindexer tout », bandeau 09-08), suggestions fiche OT Appliquer » = geste humain, liseré « suggéré » retiré au choix manuel) et clôture mobile (chips, « réseau requis » hors-ligne) ; contrat 76 opérations (corpus sur Document, `PATCH /documents/{id}/corpus`, `POST /assistant/reindex` ci-contract vérifie aussi le client mobile) ; seuils `AI_SEUIL_*` par env ; job e2e CI avec `siop2-ai` (embeddeur déterministe, seuils calibrés sur mesures réelles), 16/16 Playwright, 78 tests API ; chaîne vérifiée au vrai modèle ONNX (web 7/7, mobile Expo web 6/6, 0 erreur console).
- 🔄 **Reprise ici** : recette R5 (doit passer SANS clé API) durcissement production (Dockerfile `siop2-ai`, compose Dokploy, calibrage des seuils sur corpus client) tag `release/r5`. Restes : recette R4 sur téléphone (Expo Go), redéploiement Dokploy de `release/r3`, secret `DOKPLOY_WEBHOOK_URL`. - **Recette R5 sans clé API + durcissement** (17/07) : la recette a invalidé MiniLM-384 (page-réponse classée derrière des passages sans rapport, 0,24 vs 0,41) **bascule mesurée vers `paraphrase-multilingual-mpnet-base-v2` 768 d** (ADR-004 amendé, migration `r5_embeddings_mpnet`, découpage ~350 car., seuils 0,45/0,40/0,55) ; recette type (réponse sourcée p. 2, refus honnête, D1-D5, tout en extractif) ; revue pixel publiée (artefact, 3 arbitrages) ; Dockerfile `siop2-ai` (modèle au build, non-root), compose Dokploy (service interne, `AI_SERVICE_TOKEN` requis, génération opt-in), runbook §5-6 (service IA, calibrage seuils client, réindexation post-déploiement).
- 🔄 **Reprise ici** : validation du référent (revue pixel R5 + 3 arbitrages) tag `release/r5`. Restes : recette R4 sur téléphone (Expo Go), redéploiement Dokploy (`release/r3` puis r5), secret `DOKPLOY_WEBHOOK_URL`, calibrage `AI_SEUIL_*` sur corpus SPELEV réel.
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5.

9
apps/ai/.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.venv
.pytest_cache
.ruff_cache
__pycache__
tests
README.md
.env
.env.example
Dockerfile

38
apps/ai/Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# SIOP V2 — image du service IA (siop2-ai, ADR-004).
# Contexte de build : apps/ai (le service est autonome, pas de dépendance au
# monorepo). Étage 1 : uv sync + téléchargement du modèle ONNX AU BUILD
# (ADR-004 §1 — jamais au démarrage) ; étage 2 : runtime minimal non-root.
# Ce service n'est JAMAIS exposé publiquement : seul siop2-api le contacte,
# porteur du secret AI_SERVICE_TOKEN (ADR-004 §4).
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS builder
WORKDIR /app
ENV UV_LINK_MODE=copy \
FASTEMBED_CACHE_PATH=/opt/fastembed
# Manifestes d'abord (cache de couche), puis le code. L'installation du projet
# reste éditable (.pth → /app/src) : src est donc copié dans l'image finale.
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev \
--extra embeddings --extra generation
COPY src src
RUN uv sync --frozen --no-dev \
--extra embeddings --extra generation
# Le modèle d'embeddings est EMBARQUÉ dans l'image : pas de téléchargement au
# boot (démarrage prévisible, marche sans accès à Hugging Face en production).
RUN uv run python -c "from siop_ai.embeddings import EmbeddeurLocal; EmbeddeurLocal()"
FROM python:3.11-slim-bookworm
WORKDIR /app
ENV PATH=/app/.venv/bin:$PATH \
FASTEMBED_CACHE_PATH=/opt/fastembed
RUN useradd --system --create-home siop
COPY --from=builder --chown=siop:siop /app/.venv /app/.venv
COPY --from=builder --chown=siop:siop /app/src /app/src
COPY --from=builder --chown=siop:siop /opt/fastembed /opt/fastembed
USER siop
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz', timeout=4).status==200 else 1)"
CMD ["uvicorn", "siop_ai.app:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -30,8 +30,8 @@ CHAMPS_BILAN = {
} }
# Défauts — surchargés par la config (AI_SEUIL_*) : calibrage en recette. # Défauts — surchargés par la config (AI_SEUIL_*) : calibrage en recette.
SEUIL_PERTINENCE = 0.30 # en dessous : le corpus ne porte pas la réponse SEUIL_PERTINENCE = 0.45 # en dessous : le corpus ne porte pas la réponse
SEUIL_SUGGESTION = 0.35 SEUIL_SUGGESTION = 0.40
SEUIL_CONFIANCE_FORTE = 0.55 SEUIL_CONFIANCE_FORTE = 0.55

View File

@@ -26,8 +26,8 @@ class Reglages(BaseSettings):
ai_model: str = "claude-opus-4-8" ai_model: str = "claude-opus-4-8"
# Seuils de similarité — constantes de départ, calibrables par env # Seuils de similarité — constantes de départ, calibrables par env
# (recette sur corpus réel ; abaissés en CI e2e — embeddeur déterministe). # (recette sur corpus réel ; abaissés en CI e2e — embeddeur déterministe).
ai_seuil_pertinence: float = 0.30 ai_seuil_pertinence: float = 0.45
ai_seuil_suggestion: float = 0.35 ai_seuil_suggestion: float = 0.40
ai_seuil_confiance_forte: float = 0.55 ai_seuil_confiance_forte: float = 0.55
model_config = {"env_prefix": "", "case_sensitive": False} model_config = {"env_prefix": "", "case_sensitive": False}

View File

@@ -1,14 +1,19 @@
"""Découpage du texte en extraits indexables — pur et testé. """Découpage du texte en extraits indexables — pur et testé.
Paragraphes regroupés jusqu'à ~900 caractères, avec un chevauchement de Paragraphes regroupés jusqu'à ~350 caractères, avec un chevauchement de
queue pour ne pas couper une prescription en deux. Un extrait trop long est queue pour ne pas couper une prescription en deux. Un extrait trop long est
scindé sur les phrases. scindé sur les phrases.
Le grain est court À DESSEIN (recette R5) : sur des pages entières, la phrase
qui répond se noie dans son contexte et les scores question→passage ne
séparent plus le pertinent du voisin de domaine ; à ~350 caractères, la marge
revient — et l'extrait cité à l'écran reste lisible d'un coup d'œil.
""" """
import re import re
TAILLE_CIBLE = 900 TAILLE_CIBLE = 350
CHEVAUCHEMENT = 150 CHEVAUCHEMENT = 80
TAILLE_MINIMALE = 40 # en deçà : bruit (titres orphelins, numéros de page) TAILLE_MINIMALE = 40 # en deçà : bruit (titres orphelins, numéros de page)

View File

@@ -1,13 +1,17 @@
"""Embeddeurs (ADR-004) : le vrai modèle local ONNX, et un déterministe pour """Embeddeurs (ADR-004) : le vrai modèle local ONNX, et un déterministe pour
tests/CI — même interface, mêmes 384 dimensions, aucun téléchargement en test. tests/CI — même interface, mêmes dimensions (DIMENSIONS), aucun téléchargement en test.
""" """
import hashlib import hashlib
import math import math
from typing import Protocol from typing import Protocol
DIMENSIONS = 384 DIMENSIONS = 768
MODELE_LOCAL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" # mpnet remplace MiniLM-384 : décision de recette R5 (journal 17/07) — sur le
# banc français, MiniLM classait la page-réponse DERRIÈRE des passages sans
# rapport (0,24 vs 0,41) ; mpnet rétablit le classement et une marge
# signal/bruit exploitable (≥ 0,46 vs ≤ 0,42).
MODELE_LOCAL = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2"
class Embeddeur(Protocol): class Embeddeur(Protocol):

View File

@@ -13,9 +13,11 @@ from siop_ai.embeddings import EmbeddeurDeterministe
def test_les_seuils_sont_ordonnes(): def test_les_seuils_sont_ordonnes():
# pertinence < suggestion < confiance forte : un extrait tout juste # Pertinence et suggestion vivent dans des pipelines distincts (question →
# pertinent ne devient jamais une suggestion « forte » par accident. # passages vs description → libellés) : pas d'ordre imposé entre eux.
assert 0 < SEUIL_PERTINENCE <= SEUIL_SUGGESTION < SEUIL_CONFIANCE_FORTE < 1 # L'invariant : une suggestion tout juste retenue n'est jamais « forte ».
assert 0 < SEUIL_PERTINENCE < 1
assert 0 < SEUIL_SUGGESTION < SEUIL_CONFIANCE_FORTE < 1
def test_champs_bilan_couvrent_les_six_champs_du_contrat(): def test_champs_bilan_couvrent_les_six_champs_du_contrat():

View File

@@ -0,0 +1,8 @@
-- Décision de recette R5 (journal 17/07) : le modèle d'embeddings passe de
-- MiniLM (384 dims) à paraphrase-multilingual-mpnet-base-v2 (768 dims) —
-- MiniLM classait la page-réponse derrière des passages sans rapport.
-- Les chunks sont re-dérivables : on vide l'index et on change la dimension ;
-- une réindexation (bouton « Réindexer tout » ou /assistant/reindex) reconstruit tout.
TRUNCATE "RagChunk";
ALTER TABLE "RagChunk" DROP COLUMN "embedding";
ALTER TABLE "RagChunk" ADD COLUMN "embedding" vector(768) NOT NULL;

View File

@@ -536,7 +536,7 @@ model RagChunk {
/// Repère humain de la source : « p. 42 », « bilan du 17/07/2026 »… /// Repère humain de la source : « p. 42 », « bilan du 17/07/2026 »…
locator String locator String
content String content String
embedding Unsupported("vector(384)") embedding Unsupported("vector(768)")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([documentId]) @@index([documentId])

View File

@@ -50,7 +50,8 @@ export default function PageBibliotheque() {
<div className="entete-page"> <div className="entete-page">
<h1>Bibliothèque corpus de l'assistant</h1> <h1>Bibliothèque corpus de l'assistant</h1>
<span className="filajout"> <span className="filajout">
{documents?.length ?? 0} documents · {indexes} indexés · {tailleLisible(totalOctets)} {documents?.length ?? 0} document{(documents?.length ?? 0) > 1 ? 's' : ''} · {indexes}{' '}
indexé{indexes > 1 ? 's' : ''} · {tailleLisible(totalOctets)}
</span> </span>
<div className="actions"> <div className="actions">
{administreCorpus ? ( {administreCorpus ? (

View File

@@ -10,10 +10,23 @@
## Décision ## Décision
1. **Embeddings : locaux, sur CPU**`fastembed` (ONNX, sans PyTorch) avec 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 `sentence-transformers/paraphrase-multilingual-mpnet-base-v2` (768 dimensions, multilingue,
client ne quittent JAMAIS le serveur pour l'indexation ni pour la recherche. Le modèle est ~1 Go, registre fastembed). Les textes du client ne quittent JAMAIS le serveur pour
téléchargé au build de l'image (pas au démarrage). En **tests/CI : embeddeur déterministe l'indexation ni pour la recherche. Le modèle est téléchargé au build de l'image (pas au
par hachage** (pas de téléchargement, pas de flottement) derrière la même interface. 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 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 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. 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-api :3000 (NestJS)
│── siop2-postgres :5432 (pgvector + PostGIS) │── siop2-postgres :5432 (pgvector + PostGIS)
│── siop2-redis :6379 │── 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 ; - **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** | | `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_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`) | | `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 : 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> POSTGRES_PASSWORD=<openssl rand -hex 24>
MINIO_ROOT_PASSWORD=<openssl rand -hex 24> MINIO_ROOT_PASSWORD=<openssl rand -hex 24>
JWT_SECRET=<openssl rand -hex 32> JWT_SECRET=<openssl rand -hex 32>
AI_SERVICE_TOKEN=<openssl rand -hex 32>
DEMO_MODE=true DEMO_MODE=true
DEMO_MODE_I_KNOW=true DEMO_MODE_I_KNOW=true
SEED_ON_START=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 d'échec de migration, le conteneur s'arrête **sans** servir de trafic (l'ancienne
version reste visible côté web). 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. - **Rollback applicatif** : Dokploy → Deployments → redéployer le commit précédent.
Les migrations Prisma étant additives (convention projet : jamais de `DROP` sans 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 Sauvegarde PostgreSQL planifiée côté Dokploy (onglet Backups) — à configurer
à la première mise en production réelle. à la première mise en production réelle.
## 6. Répétition locale (sans Dokploy) ## 8. Répétition locale (sans Dokploy)
```bash ```bash
# depuis la racine — construit et lance les 5 services comme en production # 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 ## 2026-07-17 — Pr. Daaif (+ Claude) — R5.3 : les écrans de l'IA (web + mobile) et le corpus administrable
**Actions** **Actions**

View File

@@ -51,6 +51,39 @@ services:
timeout: 3s timeout: 3s
retries: 10 retries: 10
# Service IA (R5, ADR-004) : JAMAIS sur dokploy-network — seul siop2-api le
# contacte, avec le secret partagé AI_SERVICE_TOKEN. Le modèle d'embeddings
# est dans l'image (pas de téléchargement au boot). Sans AI_API_KEY, le
# service tourne en mode extractif — pleinement fonctionnel (recette R5).
siop2-ai:
container_name: siop2-ai
build:
context: ../apps/ai
dockerfile: Dockerfile
image: siop2/ai:latest
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-siop}:${POSTGRES_PASSWORD}@siop2-postgres:5432/${POSTGRES_DB:-siop}
MINIO_ENDPOINT: siop2-minio
MINIO_PORT: 9000
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-siop}
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
AI_SERVICE_TOKEN: ${AI_SERVICE_TOKEN:?définir AI_SERVICE_TOKEN dans Dokploy}
# Génération opt-in (ADR-004 §3) : off par défaut ; pour l'activer,
# AI_GENERATION=api + AI_API_KEY (secret Dokploy, jamais dans ce fichier).
AI_GENERATION: ${AI_GENERATION:-off}
AI_API_KEY: ${AI_API_KEY:-}
AI_MODEL: ${AI_MODEL:-claude-opus-4-8}
# Seuils de similarité — à calibrer sur le corpus client (runbook §7)
AI_SEUIL_PERTINENCE: ${AI_SEUIL_PERTINENCE:-0.45}
AI_SEUIL_SUGGESTION: ${AI_SEUIL_SUGGESTION:-0.40}
AI_SEUIL_CONFIANCE_FORTE: ${AI_SEUIL_CONFIANCE_FORTE:-0.55}
depends_on:
siop2-postgres:
condition: service_healthy
siop2-minio:
condition: service_healthy
siop2-api: siop2-api:
container_name: siop2-api container_name: siop2-api
build: build:
@@ -68,6 +101,9 @@ services:
MINIO_PORT: 9000 MINIO_PORT: 9000
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-siop} MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-siop}
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD} MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
# R5 : l'assistant passe par le service interne (503 propre s'il dort)
AI_SERVICE_URL: http://siop2-ai:8000
AI_SERVICE_TOKEN: ${AI_SERVICE_TOKEN}
# ADR-002 — production client : les 3 variables restent ABSENTES. # ADR-002 — production client : les 3 variables restent ABSENTES.
# Instance de démonstration publique UNIQUEMENT : # Instance de démonstration publique UNIQUEMENT :
# DEMO_MODE=true + DEMO_MODE_I_KNOW=true (double verrou) + SEED_ON_START=true # DEMO_MODE=true + DEMO_MODE_I_KNOW=true (double verrou) + SEED_ON_START=true