mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
@@ -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 R0→R5.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
9
apps/ai/.dockerignore
Normal file
9
apps/ai/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.venv
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
__pycache__
|
||||||
|
tests
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
Dockerfile
|
||||||
38
apps/ai/Dockerfile
Normal file
38
apps/ai/Dockerfile
Normal 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"]
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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])
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user