8 Commits

Author SHA1 Message Date
pr-daaif
cccfaaabc9 feat(r5): bibliothèque-corpus en tableau (arbitrage de recette du référent)
- La liste des documents passe en tableau, conforme à l'écran 5 de
  maquette-r5 (la bibliothèque sera surtout des PDF) : Document,
  Rattaché à, Indexation, Corpus (ASSETS.edit), actions.
- Les vignettes restent aux cartes « Documents » des fiches OT/ascenseur
  (photos) ; StatutCorpus/InterrupteurCorpus extraits en composants.
- Une image non indexable affiche son interrupteur éteint quel que soit
  l'état stocké — l'interrupteur montre la réalité du corpus.
- e2e adapté aux lignes de tableau, 16/16 Playwright rejoués ; artefact
  de revue pixel mis à jour (arbitrages rendus).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:40:28 +01:00
pr-daaif
b69c54ed0f 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>
2026-07-17 21:24:33 +01:00
pr-daaif
28eecc1fb9 feat(r5.3): écrans IA — assistant web, corpus administrable, suggestions OT/mobile
- Contrat (76 opérations) : Document expose inCorpus/indexedAt/chunkCount,
  PATCH /documents/{id}/corpus (ASSETS.edit), POST /assistant/reindex
  (bilan chiffré) ; ci-contract vérifie désormais aussi le client mobile.
- Web : page /assistant (chat sourcé — extraits exacts cités, Ouvrir vers
  PDF authentifié ou fiche OT, avertissement permanent ; refus honnête
  chiffré avec action utile) ; Bibliothèque = corpus (bandeau 09-08,
  statut d'indexation par document, interrupteur d'exclusion PDF,
  Réindexer tout) ; fiche OT : « Décrire pour suggérer » (Appliquer =
  geste humain, liseré « suggéré » retiré au choix manuel).
- Mobile : chips de suggestion dans la clôture (un appui = un champ
  pré-rempli, « réseau requis » hors-ligne — la file R4 n'en dépend pas).
- apps/ai : seuils AI_SEUIL_* configurables par env (CI + calibrage).
- CI e2e : service siop2-ai (embeddeur déterministe, seuils calibrés sur
  mesures : match 0,66 vs bruit 0,11) + parcours R5 Playwright (PDF généré
  xref valide → réindexation → réponse sourcée → refus → suggestion).
- Vérifié : 16/16 Playwright, 78 tests API, 23 pytest, 17 jest-expo ;
  chaîne réelle au vrai modèle ONNX (web 7/7, mobile Expo web 6/6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:26:22 +01:00
pr-daaif
45ae491827 feat(r5.2): assistant au contrat + suggestion de codes de bilan
apps/ai : /internal/ask — seuil de pertinence, extraits sourcés ou
refus honnête portant la taille du corpus cherché (D2), rédaction via
le Generateur opt-in ; /internal/suggest — similarité sémantique entre
la description libre et les libellés ACTIFS des référentiels, un code
par champ, confiance FORTE/MOYENNE, « N bilans similaires sur ce
parc ». Sans LLM : déterministe, explicable. 23 pytest.

Contrat (74 opérations) : POST /assistant/ask → AssistantAnswer
(EXTRACTIVE/GENERATED/REFUSAL, extraits cités, corpus cherché) et
POST /assistant/suggest-bilan (codes existants seulement) ; clients
web/mobile régénérés.

API NestJS : module assistant — proxy vers siop2-ai (AI_SERVICE_URL/
AI_SERVICE_TOKEN, ADR-004 §4), permissions matrice (ask=view,
suggest=edit), traduction interne→contrat, 503 propre si service
éteint. 6 e2e sur stub HTTP (76 tests API).

Bug débusqué par la vraie chaîne : fastembed ne norme pas ses
vecteurs — la similarité des suggestions dépassait 1 (pgvector
normalisait dans son opérateur, masquant l'écart). Normalisation à
l'encodage + réindexation : bilans en tête (0.41), refus hors corpus,
scores cosinus ≤ 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:05:08 +01:00
pr-daaif
76c2ccdfb1 feat(r5.1): clé API de génération configurable (ADR-004 §3 en actes)
Demande du référent : la génération opt-in devient réellement
configurable. AI_GENERATION=off|api, AI_API_KEY (exigée en mode api —
le boot refuse sinon, jamais loguée, /healthz n'expose que le mode),
AI_MODEL (défaut claude-opus-4-8).

generation.py : interface Generateur — GenerateurExtractif (contrat de
base sans LLM) et GenerateurAPI (SDK officiel anthropic, dépendance
optionnelle --extra generation, absente des tests/CI). Consigne :
citations [n] obligatoires depuis les extraits anonymisés, jamais
d'invention, rappel de validation humaine. Tout échec (refus du
modèle, quota, réseau) retombe silencieusement sur l'extractif.

5 tests ajoutés (19 pytest) + .env.example. Vérifié en réel : boot
refusé api-sans-clé, générateur construit, healthz sans secret.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:38:36 +01:00
pr-daaif
d4d73a6f76 fix(ci): PostgreSQL de CI = l'image infra (pgvector + PostGIS)
La migration r5_ia exige pgvector, absent de postgis/postgis — la
bascule annoncée en R1 (« pgvector arrivera en R5 ») est faite : les
jobs api et e2e construisent infra/postgres et le démarrent par étape
(les services GitHub ne savent pas builder). La migration crée aussi
l'extension elle-même (idempotent, même patron que PostGIS en r1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:27:03 +01:00
pr-daaif
837dcba1db feat(r5.1): socle apps/ai — ingestion anonymisée + recherche sémantique
ADR-004 : embeddings locaux sur CPU (fastembed ONNX,
paraphrase-multilingual-MiniLM-L12-v2, 384 dims — les textes du client
ne quittent jamais le serveur), pgvector dans le Postgres existant
(RagChunk possédé par Prisma, migration r5_ia + état de corpus sur
Document), génération opt-in (mode extractif par défaut : la recette
passe sans clé API), service siop2-ai jamais exposé — joint par l'API
NestJS seule (X-Service-Token).

apps/ai (FastAPI + uv) : pipeline PDF MinIO → texte paginé (pypdf) →
anonymisation D4 (e-mails, téléphones marocains, noms connus de la
base, insensible casse/accents — fonction pure testée) → découpage
avec chevauchement (testé) → embeddings → RagChunk localisé (« p. 42 »,
« bilan du 17/07 »). Bilans codés clôturés ingérés. Exclusion de
corpus (D3) appliquée à l'ingestion ET à la lecture.

14 pytest + ruff, embeddeur déterministe en CI (aucun téléchargement),
job CI ai (uv), deploy en dépend. Vérifié en réel avec le vrai modèle :
corpus seedé réindexé en 7 s (PDF réel → 31 extraits paginés + 3
bilans), recherche sémantique concluante, e-mails → ⟨contact⟩,
0 identité dans les chunks (contrôle SQL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:22:15 +01:00
pr-daaif
199fce69d0 feat(r5): maquettes HD IA — 6 écrans à valider, l'IA propose, l'humain valide
Design d'abord : aucune ligne de code apps/ai avant validation.
Assistant RAG sourcé (citations numérotées → document/page/extrait
exact, ouvrables), refus explicite hors corpus (anti-hallucination
visible), suggestion de codes de bilan web + mobile (codes existants
seulement, pré-remplissage « suggéré », enregistrement humain), corpus
& ingestion administrable (bibliothèque R3 + bilans codés, exclusion
réversible, bandeau 09-08), voix en option (opt-in, transcription
relue, audio purgé immédiatement). Bi-thème, tokens répliqués.

5 décisions soumises avec les écrans : aucune écriture automatique,
sourcé ou silencieux, corpus fermé et visible, anonymisation à
l'ingestion (09-08), voix purgée. ADR-004 (modèles/embeddings) au
lancement du socle R5.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:37:28 +01:00
62 changed files with 6471 additions and 54 deletions

View File

@@ -44,14 +44,15 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @siop/shared build
- name: Régénérer la spec et le client typé
- name: Régénérer la spec et les clients typés
run: |
pnpm --filter @siop/shared contract
pnpm --filter @siop/web generate:client
pnpm --filter @siop/mobile generate:client
- name: Vérifier qu'aucun artefact ne dérive du contrat
run: |
if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts; then
echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client)."
if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts apps/mobile/src/api/schema.d.ts; then
echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client && pnpm --filter @siop/mobile generate:client)."
exit 1
fi
@@ -59,18 +60,8 @@ jobs:
name: api (tests + couverture ≥ 70 %)
runs-on: ubuntu-latest
services:
# R1+ : la migration r1_referentiel exige PostGIS (colonne générée geography).
# pgvector arrivera en R5 (bascule alors sur limage infra/postgres).
postgres:
image: postgis/postgis:18-3.6
env:
POSTGRES_USER: siop
POSTGRES_PASSWORD: siop
POSTGRES_DB: siop
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U siop -d siop"
--health-interval 5s --health-timeout 3s --health-retries 10
# PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
# services ne savent pas builder, il démarre donc par étape ci-dessous.
redis:
image: redis:7.4-alpine
ports: ['6379:6379']
@@ -86,6 +77,15 @@ jobs:
MINIO_SECRET_KEY: siop-minio
steps:
- uses: actions/checkout@v5
- name: Démarrer PostgreSQL (image infra — pgvector + PostGIS, comme partout)
run: |
docker build -t siop2/postgres infra/postgres
docker run -d --name postgres -p 5432:5432 \
-e POSTGRES_USER=siop -e POSTGRES_PASSWORD=siop -e POSTGRES_DB=siop \
siop2/postgres
for i in $(seq 1 30); do
docker exec postgres pg_isready -U siop -d siop && break; sleep 2;
done
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
run: |
docker run -d --name minio -p 9000:9000 \
@@ -133,20 +133,25 @@ jobs:
- run: pnpm --filter @siop/mobile typecheck
- run: pnpm --filter @siop/mobile test
ai:
name: ai (pytest + ruff — uv)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v5
- run: uv sync
working-directory: apps/ai
- run: uv run ruff check src tests
working-directory: apps/ai
- run: uv run pytest
working-directory: apps/ai
e2e:
name: e2e (parcours démo Playwright)
runs-on: ubuntu-latest
services:
postgres:
image: postgis/postgis:18-3.6
env:
POSTGRES_USER: siop
POSTGRES_PASSWORD: siop
POSTGRES_DB: siop
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U siop -d siop"
--health-interval 5s --health-timeout 3s --health-retries 10
# PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
# services ne savent pas builder, il démarre donc par étape ci-dessous.
redis:
image: redis:7.4-alpine
ports: ['6379:6379']
@@ -162,6 +167,15 @@ jobs:
MINIO_SECRET_KEY: siop-minio
steps:
- uses: actions/checkout@v5
- name: Démarrer PostgreSQL (image infra — pgvector + PostGIS, comme partout)
run: |
docker build -t siop2/postgres infra/postgres
docker run -d --name postgres -p 5432:5432 \
-e POSTGRES_USER=siop -e POSTGRES_PASSWORD=siop -e POSTGRES_DB=siop \
siop2/postgres
for i in $(seq 1 30); do
docker exec postgres pg_isready -U siop -d siop && break; sleep 2;
done
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
run: |
docker run -d --name minio -p 9000:9000 \
@@ -177,8 +191,30 @@ jobs:
- run: pnpm --filter @siop/api prisma:generate
- run: pnpm --filter @siop/api exec prisma migrate deploy
- run: pnpm --filter @siop/api build
- uses: astral-sh/setup-uv@v5
- name: Démarrer le service IA (R5 — embeddeur déterministe, seuils CI)
working-directory: apps/ai
env:
AI_EMBEDDINGS: deterministe
AI_SERVICE_TOKEN: dev-only-ai-token
# L'embeddeur déterministe (tri-grammes hachés) produit des scores
# bien plus bas que le vrai modèle : seuils calibrés sur mesures
# réelles (vrai match ≈ 0,66 ; bruit de collisions ≈ 0,11 ; codes
# de bilan pertinents ≈ 0,14-0,16 ; parasites ≈ 0,08).
AI_SEUIL_PERTINENCE: '0.20'
AI_SEUIL_SUGGESTION: '0.10'
AI_SEUIL_CONFIANCE_FORTE: '0.30'
run: |
uv sync
nohup uv run uvicorn siop_ai.app:app --host 127.0.0.1 --port 8000 > /tmp/siop-ai.log 2>&1 &
for i in $(seq 1 30); do
curl -fsS http://127.0.0.1:8000/healthz && break; sleep 1;
done
- run: pnpm --filter @siop/web exec playwright install --with-deps chromium
- run: pnpm --filter @siop/web e2e
- name: Journal du service IA en cas d'échec
if: failure()
run: cat /tmp/siop-ai.log || true
- name: Traces Playwright en cas d'échec
if: failure()
uses: actions/upload-artifact@v4
@@ -195,7 +231,7 @@ jobs:
deploy:
name: deploy (Dokploy — siop2.apps.enset.top)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, contract, api, web, mobile, e2e]
needs: [lint, contract, api, web, mobile, ai, e2e]
runs-on: ubuntu-latest
environment: production
steps:

6
.gitignore vendored
View File

@@ -7,3 +7,9 @@ coverage/
*.tsbuildinfo
test-results/
playwright-report/
# Python (apps/ai)
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/

View File

@@ -59,5 +59,11 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- **R4.2 — terrain en ligne** : scanner QR réel (expo-camera, `analyseScan` testée, résolution locale D4, QR étrangers refusés), fiche ascenseur (D3, historique scopé), fiche OT (machine à états, coûts figés, garde par `closureBlockers` API), clôture terrain (bilan 6 champs au pouce), préventif grille cochable (appui long = N/A, `aria-checked`). Écritures en ligne assumées (bandeaux) la file est R4.3. Vérifié 12/12 en Expo web (dont clôture complète d'un OT de test), 12 tests jest-expo.
- **R4.3 — file & verrou** : verrou optimiste serveur (`baseUpdatedAt` 409 contextualisé, toute écriture avance la version, web inchangé, ADR-003 sécurité/routage mobile) ; file persistée rejouée dans l'ordre (propagation de version intra-lot, arrêt sur conflit, patchs optimistes, photos D5 compressées en file), écran Synchro & conflits (rejouer sur version à jour / abandonner), préchargement parc+référentiels, purge complète à la déconnexion. Recette « mode avion » 13/13 en Expo web (conflit tranché par l'humain, serveur DONE + bilan), 17 tests jest-expo, 74 tests API.
- 🏁 **R4 CLOSE (17/07/2026, tag `release/r4`)** : posé sur décision du référent avec la recette « mode avion » validée 13/13 en Expo web piloté ; **la recette sur téléphone (Expo Go — vrai mode avion, scan caméra) est reportée et reste due avant toute production client mobile**.
- 🔄 **Reprise ici** : R5 IA design d'abord : maquettes (assistant RAG sourcé, suggestion de codes de bilan) + décisions à acter, AUCUN code `apps/ai` avant validation. Restes : recette R4 sur appareil, redéploiement Dokploy de `release/r3`.
- **R5 — maquettes rédigées** (17/07) : `maquette-r5.html`, 6 écrans (assistant RAG à citations sources document/page/extrait, refus explicite hors corpus, suggestion de bilan web+mobile à validation humaine, corpus & ingestion administrable, voix opt-in avec purge) + 5 décisions à acter (aucune écriture automatique, sourcé ou silencieux, corpus fermé, anonymisation 09-08, voix purgée).
- **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.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).
- **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).
- **Revue pixel R5 validée par le référent (17/07)** 1 correction appliquée sur arbitrage : la Bibliothèque-corpus passe en **tableau** (Document/Rattaché à/Indexation/Corpus + actions ; interrupteur éteint pour les non-indexables), vignettes conservées sur les cartes Documents des fiches ; « Appliquer » direct et calibrage au runbook validés tels quels. 16/16 Playwright rejoués.
- 🔄 **Reprise ici** : tag `release/r5` (sur le mot du référent). Restes : recette R4 sur téléphone (Expo Go), redéploiement Dokploy (`release/r3` puis r5 avec `AI_SERVICE_TOKEN`), 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.

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

20
apps/ai/.env.example Normal file
View File

@@ -0,0 +1,20 @@
# Copier en .env pour le dev local (défauts alignés sur infra/docker-compose.yml).
DATABASE_URL=postgresql://siop:siop@localhost:5432/siop
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_ACCESS_KEY=siop
MINIO_SECRET_KEY=siop-minio
MINIO_BUCKET=siop2
# Seule l'API NestJS connaît ce secret (ADR-004 §4 — service jamais public).
AI_SERVICE_TOKEN=dev-only-ai-token
# Embeddings : locale (fastembed ONNX, CPU) | deterministe (tests/CI)
AI_EMBEDDINGS=locale
# Génération (ADR-004 §3) : off = mode extractif (défaut — la recette passe
# sans clé) | api = rédaction par le LLM sur textes déjà anonymisés (D4).
# AI_GENERATION=api exige AI_API_KEY (le boot refuse sinon).
AI_GENERATION=off
AI_API_KEY=
AI_MODEL=claude-opus-4-8

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"]

33
apps/ai/README.md Normal file
View File

@@ -0,0 +1,33 @@
# @siop/ai — service IA (R5)
FastAPI + uv (ADR-001), **jamais exposé** : seule l'API NestJS le contacte avec
`X-Service-Token` (ADR-004 §4). Maquettes et décisions D1-D5 validées le 17/07/2026.
## Lancer (dev)
```bash
cd apps/ai
uv sync --extra embeddings # le vrai modèle ONNX (CPU, ~120 Mo au premier run)
uv run uvicorn siop_ai.app:app --port 8000
# CI / tests : uv sync && uv run pytest (embeddeur déterministe, aucun téléchargement)
```
Variables (défauts dev dans `config.py`, gabarit dans `.env.example`) :
`DATABASE_URL`, `MINIO_*`, `AI_SERVICE_TOKEN`, `AI_EMBEDDINGS=locale|deterministe`,
et la génération opt-in (ADR-004 §3) : `AI_GENERATION=off|api`, **`AI_API_KEY`**
(exigée en mode api — le boot refuse sinon, jamais loguée ni exposée),
`AI_MODEL` (défaut `claude-opus-4-8`). Mode api : `uv sync --extra generation`
(SDK officiel `anthropic`) ; tout échec du LLM retombe sur le mode extractif.
## Ce que porte R5.1 (socle)
- **Ingestion anonymisée (D4)** : PDF de la bibliothèque (MinIO) page par page +
bilans codés clôturés → anonymisation (e-mails, téléphones, noms connus de la
base) → découpage → embeddings locaux → `RagChunk` (pgvector, schéma Prisma).
- **Recherche sémantique** `/internal/search` : extraits sourcés (document + page
ou bilan daté) avec score — la brique de « sourcé ou silencieux » (D2).
- `/internal/reindex` idempotent ; l'exclusion de corpus (`Document.inCorpus`,
D3) s'applique à l'ingestion ET à la lecture.
La suite : R5.2 assistant (mode extractif puis génération opt-in) + suggestion de
bilan ; R5.3 écrans ; durcissement : Dockerfile + compose Dokploy (`siop2-ai`).

42
apps/ai/pyproject.toml Normal file
View File

@@ -0,0 +1,42 @@
[project]
name = "siop-ai"
version = "0.1.0"
description = "SIOP V2 — service IA (R5) : ingestion anonymisée, recherche sémantique, assistant sourcé (ADR-004)"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"pydantic-settings>=2.6",
"asyncpg>=0.30",
"pypdf>=5.1",
"minio>=7.2",
]
[project.optional-dependencies]
# Le vrai modèle (ONNX, CPU) — absent des tests/CI (embeddeur déterministe).
embeddings = ["fastembed>=0.4"]
# Génération opt-in (ADR-004 §3) — absente des tests/CI (repli extractif).
generation = ["anthropic>=0.75"]
[dependency-groups]
dev = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
"httpx>=0.27",
"ruff>=0.8",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/siop_ai"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
[tool.ruff]
line-length = 100
src = ["src", "tests"]

View File

@@ -0,0 +1 @@
"""SIOP V2 — service IA (R5). L'IA propose, l'humain valide (D1)."""

View File

@@ -0,0 +1,62 @@
"""Anonymisation à l'ingestion (D4, loi 09-08) : identités et coordonnées ne
partent JAMAIS dans les index vectoriels ni dans les prompts.
Fonction pure, testée : e-mails, téléphones (formats marocains et
internationaux), et les noms de personnes CONNUS de la base (utilisateurs,
gardiens, contacts tiers) fournis par l'appelant.
"""
import re
import unicodedata
JETON_PERSONNE = "⟨personne⟩"
JETON_CONTACT = "⟨contact⟩"
_EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
# 06 12 34 56 78 · 0612345678 · +212 6 12 34 56 78 · 05 22-34-56-78…
_TELEPHONE = re.compile(r"(?:\+?\d{1,3}[\s.-]?)?(?:0|\(0\))?\d(?:[\s.-]?\d{2}){4}")
def _sans_accents(texte: str) -> str:
return "".join(
c for c in unicodedata.normalize("NFD", texte) if unicodedata.category(c) != "Mn"
)
def anonymiser(texte: str, noms_connus: list[str] | None = None) -> str:
"""Remplace coordonnées et noms connus par des jetons neutres.
Les noms sont remplacés insensiblement à la casse ET aux accents
(« Idrissi » attrape « idrissi »), prénom seul compris quand il est
assez long pour ne pas mutiler le texte technique.
"""
resultat = _EMAIL.sub(JETON_CONTACT, texte)
resultat = _TELEPHONE.sub(JETON_CONTACT, resultat)
for nom in sorted(noms_connus or [], key=len, reverse=True):
nom = nom.strip()
if len(nom) < 3:
continue
morceaux = [nom] + [m for m in nom.split() if len(m) >= 4]
for morceau in morceaux:
motif = re.compile(
r"\b" + re.escape(_sans_accents(morceau)) + r"\b", re.IGNORECASE
)
# on cherche sur une copie sans accents mais on remplace l'original
copie = _sans_accents(resultat)
sortie: list[str] = []
position = 0
for correspondance in motif.finditer(copie):
sortie.append(resultat[position : correspondance.start()])
sortie.append(JETON_PERSONNE)
position = correspondance.end()
sortie.append(resultat[position:])
resultat = "".join(sortie)
# jetons collés en double (« prénom nom » remplacés séparément)
resultat = re.sub(
rf"{re.escape(JETON_PERSONNE)}(\s+{re.escape(JETON_PERSONNE)})+",
JETON_PERSONNE,
resultat,
)
return resultat

115
apps/ai/src/siop_ai/app.py Normal file
View File

@@ -0,0 +1,115 @@
"""Service IA — JAMAIS exposé publiquement (ADR-004 §4) : seule l'API NestJS
le contacte, avec le secret partagé `X-Service-Token`. Les permissions des
utilisateurs restent l'affaire de l'API — ici, un seul appelant de confiance.
"""
from contextlib import asynccontextmanager
from dataclasses import asdict
import asyncpg
from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel, Field
from .assistant import repondre, suggerer_bilan
from .config import Reglages, charger_reglages
from .embeddings import construire_embeddeur
from .generation import construire_generateur
from .ingestion import reindexer_tout
from .recherche import chercher
@asynccontextmanager
async def cycle_de_vie(app: FastAPI):
reglages = charger_reglages()
app.state.reglages = reglages
app.state.embeddeur = construire_embeddeur(reglages.ai_embeddings)
app.state.generateur = construire_generateur(
reglages.ai_generation, reglages.ai_api_key, reglages.ai_model
)
app.state.pool = await asyncpg.create_pool(reglages.database_url, min_size=1, max_size=5)
yield
await app.state.pool.close()
app = FastAPI(title="SIOP V2 — service IA (R5)", lifespan=cycle_de_vie)
def verifier_jeton(
x_service_token: str = Header(default=""),
) -> None:
reglages: Reglages = app.state.reglages
if x_service_token != reglages.ai_service_token:
raise HTTPException(status_code=401, detail="Jeton de service invalide")
@app.get("/healthz")
async def sante() -> dict:
"""Sonde interne (compose/Dokploy) — ne révèle ni corpus ni secret."""
reglages = getattr(app.state, "reglages", None) or charger_reglages()
return {
"status": "ok",
"service": "siop2-ai",
"generation": reglages.ai_generation, # « off » = extractif — jamais la clé
}
@app.post("/internal/reindex", dependencies=[Depends(verifier_jeton)])
async def reindexer() -> dict:
async with app.state.pool.acquire() as cnx:
resultat = await reindexer_tout(cnx, app.state.reglages, app.state.embeddeur)
return asdict(resultat)
class RequeteRecherche(BaseModel):
question: str = Field(min_length=3, max_length=500)
limite: int = Field(default=5, ge=1, le=10)
@app.post("/internal/search", dependencies=[Depends(verifier_jeton)])
async def rechercher(corps: RequeteRecherche) -> dict:
async with app.state.pool.acquire() as cnx:
extraits = await chercher(cnx, app.state.embeddeur, corps.question, corps.limite)
return {"extraits": [asdict(e) for e in extraits]}
@app.post("/internal/ask", dependencies=[Depends(verifier_jeton)])
async def demander(corps: RequeteRecherche) -> dict:
"""L'assistant D2 : extraits sourcés au-dessus du seuil, ou refus honnête
(ce qui a été cherché) — la rédaction n'existe qu'en mode génératif."""
async with app.state.pool.acquire() as cnx:
reponse = await repondre(
cnx,
app.state.embeddeur,
app.state.generateur,
corps.question,
corps.limite,
seuil_pertinence=app.state.reglages.ai_seuil_pertinence,
)
return {
"mode": reponse.mode,
"answer": reponse.answer,
"extraits": [asdict(e) for e in reponse.extraits],
"corpus": {
"documents": reponse.documents_corpus,
"bilans": reponse.bilans_corpus,
},
}
class RequeteSuggestion(BaseModel):
description: str = Field(min_length=10, max_length=2000)
@app.post("/internal/suggest", dependencies=[Depends(verifier_jeton)])
async def suggerer(corps: RequeteSuggestion) -> dict:
"""Suggestion de codes de bilan (D1) : uniquement des codes EXISTANTS,
avec confiance et « N bilans similaires » — l'humain applique, ou pas."""
async with app.state.pool.acquire() as cnx:
suggestions = await suggerer_bilan(
cnx,
app.state.embeddeur,
corps.description,
seuil_suggestion=app.state.reglages.ai_seuil_suggestion,
seuil_confiance_forte=app.state.reglages.ai_seuil_confiance_forte,
)
return {"suggestions": [asdict(s) for s in suggestions]}

View File

@@ -0,0 +1,153 @@
"""L'assistant (D2 — « sourcé ou silencieux ») et la suggestion de bilan.
- `repondre` : recherche sémantique → extraits au-dessus du seuil de
pertinence, ou refus HONNÊTE qui dit ce qui a été cherché (écran 2 des
maquettes). La rédaction est déléguée au `Generateur` (opt-in ADR-004 §3) ;
sans lui, le mode extractif est la réponse.
- `suggerer_bilan` : similarité sémantique entre la description libre et les
libellés ACTIFS des référentiels (l'IA ne peut suggérer que des codes
existants) + comptage des bilans similaires du parc. Sans LLM : rapide,
déterministe, explicable.
"""
from dataclasses import dataclass
import asyncpg
from .embeddings import Embeddeur
from .generation import Generateur
from .recherche import ExtraitTrouve, chercher
# Répliques des libellés français de @siop/shared (BILAN_FIELD_LABELS) —
# utilisés pour contextualiser les embeddings des codes.
CHAMPS_BILAN = {
"DOOR_STATE": "état des portes",
"CABIN_POSITION": "position cabine",
"ANOMALY": "anomalie constatée",
"EXTERNAL_CAUSE": "cause extérieure",
"ACTION_TAKEN": "action réalisée",
"COMPONENT_CONCERNED": "élément concerné",
}
# Défauts — surchargés par la config (AI_SEUIL_*) : calibrage en recette.
SEUIL_PERTINENCE = 0.45 # en dessous : le corpus ne porte pas la réponse
SEUIL_SUGGESTION = 0.40
SEUIL_CONFIANCE_FORTE = 0.55
@dataclass
class ReponseAssistant:
mode: str # « extractif » | « genere » | « refus »
answer: str | None
extraits: list[ExtraitTrouve]
documents_corpus: int
bilans_corpus: int
async def _taille_corpus(cnx: asyncpg.Connection) -> tuple[int, int]:
ligne = await cnx.fetchrow(
'''
SELECT
(SELECT count(DISTINCT "documentId") FROM "RagChunk"
WHERE "sourceType" = 'DOCUMENT') AS documents,
(SELECT count(*) FROM "RagChunk" WHERE "sourceType" = 'WORK_ORDER') AS bilans
'''
)
return ligne["documents"], ligne["bilans"]
async def repondre(
cnx: asyncpg.Connection,
embeddeur: Embeddeur,
generateur: Generateur,
question: str,
limite: int = 5,
seuil_pertinence: float = SEUIL_PERTINENCE,
) -> ReponseAssistant:
documents, bilans = await _taille_corpus(cnx)
extraits = await chercher(cnx, embeddeur, question, limite)
pertinents = [e for e in extraits if e.score >= seuil_pertinence]
if not pertinents:
# D2 : refus explicite — on dit ce qu'on a cherché, on n'invente rien.
return ReponseAssistant(
mode="refus",
answer=None,
extraits=[],
documents_corpus=documents,
bilans_corpus=bilans,
)
redige = generateur.rediger(question, pertinents)
return ReponseAssistant(
mode="genere" if redige else "extractif",
answer=redige,
extraits=pertinents,
documents_corpus=documents,
bilans_corpus=bilans,
)
@dataclass
class SuggestionBilan:
field: str
value_id: str
label: str
confidence: str # « FORTE » | « MOYENNE »
similar_reports: int # bilans du parc portant déjà ce code (« 9 bilans similaires »)
score: float
def _cosinus(a: list[float], b: list[float]) -> float:
return sum(x * y for x, y in zip(a, b)) # vecteurs déjà normés
async def suggerer_bilan(
cnx: asyncpg.Connection,
embeddeur: Embeddeur,
description: str,
seuil_suggestion: float = SEUIL_SUGGESTION,
seuil_confiance_forte: float = SEUIL_CONFIANCE_FORTE,
) -> list[SuggestionBilan]:
valeurs = await cnx.fetch(
'SELECT id, field, label FROM "ReferenceValue" WHERE "isActive" ORDER BY field, label'
)
if not valeurs:
return []
textes = [description] + [
f"{CHAMPS_BILAN.get(v['field'], v['field'])} : {v['label']}" for v in valeurs
]
vecteurs = embeddeur.encoder(textes)
v_description, v_valeurs = vecteurs[0], vecteurs[1:]
# Le meilleur code par champ, au-dessus du seuil — jamais plus d'une
# suggestion par champ, jamais un code inventé.
meilleurs: dict[str, tuple[asyncpg.Record, float]] = {}
for valeur, vecteur in zip(valeurs, v_valeurs):
score = _cosinus(v_description, vecteur)
if score < seuil_suggestion:
continue
champ = valeur["field"]
if champ not in meilleurs or score > meilleurs[champ][1]:
meilleurs[champ] = (valeur, score)
suggestions: list[SuggestionBilan] = []
for valeur, score in meilleurs.values():
# « 9 bilans similaires sur ce parc » : les bilans clôturés portant ce code
similaires = await cnx.fetchval(
'SELECT count(*) FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\' AND content ILIKE $1',
f"%{valeur['label']}%",
)
suggestions.append(
SuggestionBilan(
field=valeur["field"],
value_id=str(valeur["id"]),
label=valeur["label"],
confidence="FORTE" if score >= seuil_confiance_forte else "MOYENNE",
similar_reports=similaires,
score=round(score, 4),
)
)
suggestions.sort(key=lambda s: s.score, reverse=True)
return suggestions

View File

@@ -0,0 +1,46 @@
"""Configuration — validée au démarrage, comme l'API NestJS (même philosophie)."""
from pydantic_settings import BaseSettings
class Reglages(BaseSettings):
# Postgres partagé (schéma possédé par Prisma — apps/api)
database_url: str = "postgresql://siop:siop@localhost:5432/siop"
# MinIO en direct sur le réseau privé (ADR-004 §4 — jamais exposé)
minio_endpoint: str = "localhost"
minio_port: int = 9000
minio_use_ssl: bool = False
minio_access_key: str = "siop"
minio_secret_key: str = "siop-minio"
minio_bucket: str = "siop2"
# Le service n'est JAMAIS public : seul l'API NestJS le contacte,
# porteuse de ce secret partagé (ADR-004 §4).
ai_service_token: str = "dev-only-ai-token"
# Embeddings : « locale » (fastembed ONNX) ou « deterministe » (tests/CI)
ai_embeddings: str = "locale"
# Génération (ADR-004 §3) : « off » = mode extractif (défaut honnête,
# la recette passe sans clé) ; « api » = rédaction par le LLM externe,
# sur textes DÉJÀ anonymisés (D4), citations obligatoires.
ai_generation: str = "off"
ai_api_key: str = "" # requise seulement si ai_generation=api — jamais loguée
ai_model: str = "claude-opus-4-8"
# Seuils de similarité — constantes de départ, calibrables par env
# (recette sur corpus réel ; abaissés en CI e2e — embeddeur déterministe).
ai_seuil_pertinence: float = 0.45
ai_seuil_suggestion: float = 0.40
ai_seuil_confiance_forte: float = 0.55
model_config = {"env_prefix": "", "case_sensitive": False}
def charger_reglages() -> Reglages:
reglages = Reglages()
# asyncpg ne comprend pas le paramètre ?schema= de Prisma
if "?" in reglages.database_url:
reglages.database_url = reglages.database_url.split("?")[0]
# Même philosophie que l'API NestJS : une config invalide refuse de booter.
if reglages.ai_generation not in ("off", "api"):
raise ValueError("AI_GENERATION doit valoir « off » ou « api »")
if reglages.ai_generation == "api" and not reglages.ai_api_key:
raise ValueError("AI_GENERATION=api exige AI_API_KEY (voir ADR-004 §3)")
return reglages

View File

@@ -0,0 +1,57 @@
"""Découpage du texte en extraits indexables — pur et testé.
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
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
TAILLE_CIBLE = 350
CHEVAUCHEMENT = 80
TAILLE_MINIMALE = 40 # en deçà : bruit (titres orphelins, numéros de page)
def _phrases(texte: str) -> list[str]:
return [p.strip() for p in re.split(r"(?<=[.!?;])\s+", texte) if p.strip()]
def decouper(texte: str) -> list[str]:
paragraphes = [p.strip() for p in re.split(r"\n\s*\n", texte) if p.strip()]
extraits: list[str] = []
courant = ""
def pousser() -> None:
nonlocal courant
nettoye = courant.strip()
if len(nettoye) >= TAILLE_MINIMALE:
extraits.append(nettoye)
courant = ""
for paragraphe in paragraphes:
paragraphe = re.sub(r"\s+", " ", paragraphe)
if len(courant) + len(paragraphe) + 1 > TAILLE_CIBLE and courant:
queue = courant[-CHEVAUCHEMENT:]
pousser()
courant = queue + " "
while len(paragraphe) > TAILLE_CIBLE:
phrases = _phrases(paragraphe)
if len(phrases) <= 1:
courant += paragraphe[:TAILLE_CIBLE]
paragraphe = paragraphe[TAILLE_CIBLE - CHEVAUCHEMENT :]
pousser()
continue
morceau = ""
while phrases and len(morceau) + len(phrases[0]) + 1 <= TAILLE_CIBLE:
morceau += phrases.pop(0) + " "
courant += morceau
pousser()
paragraphe = " ".join(phrases)
courant += paragraphe + " "
pousser()
return extraits

View File

@@ -0,0 +1,65 @@
"""Embeddeurs (ADR-004) : le vrai modèle local ONNX, et un déterministe pour
tests/CI — même interface, mêmes dimensions (DIMENSIONS), aucun téléchargement en test.
"""
import hashlib
import math
from typing import Protocol
DIMENSIONS = 768
# 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):
def encoder(self, textes: list[str]) -> list[list[float]]: ...
class EmbeddeurDeterministe:
"""Sac de tri-grammes haché puis normalisé : stable, sans réseau, et les
textes proches partagent des composantes — assez pour tester le circuit
complet (ingestion → pgvector → similarité)."""
def encoder(self, textes: list[str]) -> list[list[float]]:
return [self._un(t) for t in textes]
def _un(self, texte: str) -> list[float]:
vecteur = [0.0] * DIMENSIONS
mots = texte.lower().split()
grammes = mots + [" ".join(mots[i : i + 3]) for i in range(max(0, len(mots) - 2))]
for gramme in grammes:
empreinte = hashlib.sha256(gramme.encode()).digest()
indice = int.from_bytes(empreinte[:4], "big") % DIMENSIONS
signe = 1.0 if empreinte[4] % 2 == 0 else -1.0
vecteur[indice] += signe
norme = math.sqrt(sum(v * v for v in vecteur)) or 1.0
return [v / norme for v in vecteur]
class EmbeddeurLocal:
"""fastembed (ONNX, CPU) — chargé paresseusement, jamais importé en test.
Sortie NORMÉE : fastembed ne garantit pas des vecteurs unitaires, or la
similarité par produit scalaire (suggestions) l'exige — pgvector, lui,
normalise dans son opérateur cosinus, ce qui masquait l'écart."""
def __init__(self) -> None:
from fastembed import TextEmbedding # import différé (dépendance optionnelle)
self._modele = TextEmbedding(model_name=MODELE_LOCAL)
def encoder(self, textes: list[str]) -> list[list[float]]:
vecteurs = []
for vecteur in self._modele.embed(textes):
liste = vecteur.tolist()
norme = math.sqrt(sum(v * v for v in liste)) or 1.0
vecteurs.append([v / norme for v in liste])
return vecteurs
def construire_embeddeur(mode: str) -> Embeddeur:
if mode == "deterministe":
return EmbeddeurDeterministe()
return EmbeddeurLocal()

View File

@@ -0,0 +1,94 @@
"""Génération des réponses rédigées (ADR-004 §3) — OPT-IN par configuration.
- « off » (défaut) : mode extractif — pas de LLM, l'assistant montrera les
extraits sourcés tels quels. La recette R5 passe entièrement dans ce mode.
- « api » : rédaction par Claude (SDK officiel), sur des extraits DÉJÀ
anonymisés (D4), avec l'obligation de ne rien affirmer hors extraits (D2).
Tout échec (refus, réseau, quota) retombe sur le mode extractif — jamais
d'erreur utilisateur à cause du LLM.
"""
import logging
from typing import Protocol
from .recherche import ExtraitTrouve
journal = logging.getLogger("siop_ai.generation")
CONSIGNE = """Tu es l'assistant SIOP d'une société de maintenance d'ascenseurs.
Tu réponds en français, UNIQUEMENT à partir des extraits fournis (notices et
historiques d'intervention du parc, déjà anonymisés).
Règles absolues :
- chaque affirmation porte sa citation [n] renvoyant à un extrait fourni ;
- si les extraits ne portent pas la réponse, dis-le et n'invente RIEN ;
- reste bref et opérationnel : un technicien te lit sur le terrain ;
- termine toujours par le rappel que l'humain vérifie avant d'agir."""
class Generateur(Protocol):
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None: ...
class GenerateurExtractif:
"""Le contrat de base : pas de rédaction — l'appelant montre les extraits.
`None` signifie « pas de texte généré », jamais une erreur."""
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None:
return None
def construire_invite(question: str, extraits: list[ExtraitTrouve]) -> str:
"""L'invite utilisateur — fonction pure, testée : la question et les
extraits numérotés, rien d'autre (les textes sont déjà anonymisés)."""
blocs = [
f"[{rang}] {e.titre} · {e.locator}\n{e.content}"
for rang, e in enumerate(extraits, start=1)
]
return "Extraits du corpus :\n\n" + "\n\n".join(blocs) + f"\n\nQuestion : {question}"
class GenerateurAPI:
"""Rédaction par Claude — import différé : la dépendance `anthropic` est
optionnelle (groupe `generation`), absente des tests/CI."""
def __init__(self, api_key: str, modele: str) -> None:
from anthropic import Anthropic
self._client = Anthropic(api_key=api_key)
self._modele = modele
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None:
import anthropic
if not extraits:
return None # sourcé ou silencieux (D2) : rien à citer = rien à rédiger
try:
reponse = self._client.messages.create(
model=self._modele,
max_tokens=2048, # réponses courtes et sourcées, par conception
thinking={"type": "adaptive"},
system=CONSIGNE,
messages=[
{"role": "user", "content": construire_invite(question, extraits)}
],
)
if reponse.stop_reason == "refusal":
journal.warning("Génération refusée par le modèle — repli extractif")
return None
texte = "".join(b.text for b in reponse.content if b.type == "text").strip()
return texte or None
except anthropic.RateLimitError:
journal.warning("Quota API atteint — repli extractif")
return None
except anthropic.APIStatusError as e:
journal.warning("API génération %s — repli extractif", e.status_code)
return None
except anthropic.APIConnectionError:
journal.warning("API génération injoignable — repli extractif")
return None
def construire_generateur(mode: str, api_key: str, modele: str) -> Generateur:
if mode == "api":
return GenerateurAPI(api_key, modele)
return GenerateurExtractif()

View File

@@ -0,0 +1,175 @@
"""Ingestion du corpus (D3) : bibliothèque R3 (PDF, MinIO) + bilans codés.
Chaque texte passe par l'anonymisation (D4) AVANT découpage et embeddings.
Les chunks vivent dans `RagChunk` (pgvector, schéma possédé par Prisma).
"""
import io
import json
from dataclasses import dataclass
import asyncpg
from minio import Minio
from pypdf import PdfReader
from .anonymisation import anonymiser
from .config import Reglages
from .decoupage import decouper
from .embeddings import Embeddeur
@dataclass
class ResultatIngestion:
documents_indexes: int
documents_ignores: int
bilans_indexes: int
extraits: int
def _vecteur_sql(vecteur: list[float]) -> str:
return "[" + ",".join(f"{v:.6f}" for v in vecteur) + "]"
async def noms_a_anonymiser(cnx: asyncpg.Connection) -> list[str]:
"""Toutes les identités connues de la base (D4) : utilisateurs, gardiens,
contacts tiers, demandeurs du portail."""
lignes = await cnx.fetch(
'''
SELECT "displayName" AS nom FROM "User"
UNION SELECT "guardianName" FROM "Location" WHERE "guardianName" IS NOT NULL
UNION SELECT "contactName" FROM "Partner" WHERE "contactName" IS NOT NULL
'''
)
return [ligne["nom"] for ligne in lignes if ligne["nom"]]
def extraire_texte_pdf(octets: bytes) -> list[tuple[str, str]]:
"""[(texte, localisation)] par page — la citation doit pointer la page."""
lecteur = PdfReader(io.BytesIO(octets))
pages: list[tuple[str, str]] = []
for numero, page in enumerate(lecteur.pages, start=1):
texte = page.extract_text() or ""
if texte.strip():
pages.append((texte, f"p. {numero}"))
return pages
async def indexer_documents(
cnx: asyncpg.Connection,
reglages: Reglages,
embeddeur: Embeddeur,
noms: list[str],
) -> tuple[int, int, int]:
minio = Minio(
f"{reglages.minio_endpoint}:{reglages.minio_port}",
access_key=reglages.minio_access_key,
secret_key=reglages.minio_secret_key,
secure=reglages.minio_use_ssl,
)
documents = await cnx.fetch(
'SELECT id, "fileName", "storageKey", "contentType", "inCorpus" FROM "Document"'
)
indexes, ignores, total_extraits = 0, 0, 0
for doc in documents:
# réindexation idempotente : on repart de zéro pour ce document
await cnx.execute('DELETE FROM "RagChunk" WHERE "documentId" = $1', doc["id"])
if not doc["inCorpus"] or doc["contentType"] != "application/pdf":
await cnx.execute(
'UPDATE "Document" SET "indexedAt" = NULL, "chunkCount" = 0 WHERE id = $1',
doc["id"],
)
ignores += 1
continue
reponse = minio.get_object(reglages.minio_bucket, doc["storageKey"])
try:
octets = reponse.read()
finally:
reponse.close()
reponse.release_conn()
extraits: list[tuple[str, str]] = []
for texte_page, localisation in extraire_texte_pdf(octets):
texte_sur = anonymiser(texte_page, noms)
extraits += [(morceau, localisation) for morceau in decouper(texte_sur)]
if extraits:
vecteurs = embeddeur.encoder([contenu for contenu, _ in extraits])
await cnx.executemany(
'''
INSERT INTO "RagChunk"
(id, "sourceType", "documentId", locator, content, embedding)
VALUES (gen_random_uuid(), 'DOCUMENT', $1, $2, $3, $4::vector)
''',
[
(doc["id"], localisation, contenu, _vecteur_sql(vecteur))
for (contenu, localisation), vecteur in zip(extraits, vecteurs)
],
)
await cnx.execute(
'UPDATE "Document" SET "indexedAt" = now(), "chunkCount" = $2 WHERE id = $1',
doc["id"],
len(extraits),
)
indexes += 1
total_extraits += len(extraits)
return indexes, ignores, total_extraits
async def indexer_bilans(
cnx: asyncpg.Connection, embeddeur: Embeddeur, noms: list[str]
) -> tuple[int, int]:
"""Les bilans codés clôturés — « sur votre parc, ce réglage a déjà… »."""
await cnx.execute('DELETE FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\'')
bilans = await cnx.fetch(
'''
SELECT wo.id, wo.reference, wo.title, wo."completedAt",
a.reference AS asset_ref, a.brand, a.model,
(SELECT json_object_agg(rv.field, rv.label)
FROM "InterventionReport" ir2
JOIN "ReferenceValue" rv ON rv.id IN (
ir2."doorStateId", ir2."cabinPositionId", ir2."anomalyId",
ir2."externalCauseId", ir2."actionTakenId", ir2."componentConcernedId")
WHERE ir2."workOrderId" = wo.id) AS bilan
FROM "WorkOrder" wo
JOIN "InterventionReport" ir ON ir."workOrderId" = wo.id
JOIN "Asset" a ON a.id = wo."assetId"
WHERE wo.status = 'DONE'
'''
)
lignes = []
for bilan in bilans:
champs = json.loads(bilan["bilan"]) if bilan["bilan"] else {}
codes = " ; ".join(f"{champ} : {label}" for champ, label in champs.items())
contenu = anonymiser(
f"Intervention {bilan['reference']}{bilan['title']}. "
f"Appareil {bilan['asset_ref']} ({bilan['brand']} {bilan['model'] or ''}). "
f"Bilan codé : {codes}.",
noms,
)
quand = bilan["completedAt"].date().isoformat() if bilan["completedAt"] else "date inconnue"
lignes.append((bilan["id"], f"bilan du {quand}", contenu))
if lignes:
vecteurs = embeddeur.encoder([contenu for _, _, contenu in lignes])
await cnx.executemany(
'''
INSERT INTO "RagChunk"
(id, "sourceType", "workOrderId", locator, content, embedding)
VALUES (gen_random_uuid(), 'WORK_ORDER', $1, $2, $3, $4::vector)
''',
[
(wo_id, localisation, contenu, _vecteur_sql(vecteur))
for (wo_id, localisation, contenu), vecteur in zip(lignes, vecteurs)
],
)
return len(lignes), len(lignes)
async def reindexer_tout(
cnx: asyncpg.Connection, reglages: Reglages, embeddeur: Embeddeur
) -> ResultatIngestion:
noms = await noms_a_anonymiser(cnx)
docs_ok, docs_non, extraits_docs = await indexer_documents(cnx, reglages, embeddeur, noms)
bilans, extraits_bilans = await indexer_bilans(cnx, embeddeur, noms)
return ResultatIngestion(
documents_indexes=docs_ok,
documents_ignores=docs_non,
bilans_indexes=bilans,
extraits=extraits_docs + extraits_bilans,
)

View File

@@ -0,0 +1,57 @@
"""Recherche sémantique dans le corpus (pgvector, distance cosinus).
Ne renvoie QUE des extraits sourcés — la brique de « sourcé ou silencieux ».
"""
from dataclasses import dataclass
import asyncpg
from .embeddings import Embeddeur
from .ingestion import _vecteur_sql
@dataclass
class ExtraitTrouve:
source_type: str
document_id: str | None
work_order_id: str | None
titre: str # nom de fichier ou référence d'OT
locator: str
content: str
score: float # similarité cosinus (0..1)
async def chercher(
cnx: asyncpg.Connection,
embeddeur: Embeddeur,
question: str,
limite: int = 5,
) -> list[ExtraitTrouve]:
vecteur = _vecteur_sql(embeddeur.encoder([question])[0])
lignes = await cnx.fetch(
'''
SELECT c."sourceType", c."documentId", c."workOrderId", c.locator, c.content,
1 - (c.embedding <=> $1::vector) AS score,
COALESCE(d."fileName", wo.reference, '?') AS titre
FROM "RagChunk" c
LEFT JOIN "Document" d ON d.id = c."documentId"
LEFT JOIN "WorkOrder" wo ON wo.id = c."workOrderId"
WHERE c."documentId" IS NULL OR d."inCorpus" -- l'exclusion D3 s'applique aussi à la lecture
ORDER BY c.embedding <=> $1::vector
LIMIT $2
''',
vecteur,
limite,
)
return [
ExtraitTrouve(
source_type=ligne["sourceType"],
document_id=str(ligne["documentId"]) if ligne["documentId"] else None,
work_order_id=str(ligne["workOrderId"]) if ligne["workOrderId"] else None,
titre=ligne["titre"],
locator=ligne["locator"],
content=ligne["content"],
score=float(ligne["score"]),
)
for ligne in lignes
]

View File

@@ -0,0 +1,33 @@
"""D4 (loi 09-08) : rien d'identifiant ne doit survivre à l'ingestion."""
from siop_ai.anonymisation import JETON_CONTACT, JETON_PERSONNE, anonymiser
def test_emails_et_telephones_marocains():
texte = "Appeler M. Alami au 06 12 34 56 78 ou +212 5 22 34 56 78, sinon gardien@residence.ma"
resultat = anonymiser(texte, ["M. Alami"])
assert "06 12" not in resultat
assert "+212" not in resultat
assert "gardien@residence.ma" not in resultat
assert resultat.count(JETON_CONTACT) == 3
def test_noms_connus_meme_sans_accents_ni_casse():
texte = "Intervention validée par salma idrissi puis contrôlée par AHMED BENALI."
resultat = anonymiser(texte, ["Salma Idrissi", "Ahmed Benali"])
assert "idrissi" not in resultat.lower()
assert "benali" not in resultat.lower()
assert resultat.count(JETON_PERSONNE) == 2
def test_prenom_seul_est_attrape_mais_pas_les_mots_courts():
resultat = anonymiser("Vu avec Ahmed sur site.", ["Ahmed Benali"])
assert "Ahmed" not in resultat
# « NC-31 » ou « vis » ne doivent jamais être mutilés par un nom court
resultat2 = anonymiser("Contact NC-31 réglé, vis serrées.", ["N. C."])
assert "NC-31" in resultat2
def test_le_texte_technique_reste_intact():
texte = "Serrer les coulisseaux au couple de 25 N·m ; jeu latéral 0,5 mm."
assert anonymiser(texte, ["Salma Idrissi"]) == texte

37
apps/ai/tests/test_app.py Normal file
View File

@@ -0,0 +1,37 @@
"""Le service n'est jamais public : sans le jeton de service, 401 partout
(la santé exceptée — sonde d'infra qui ne révèle rien)."""
from fastapi.testclient import TestClient
from siop_ai.app import app
def test_sante_publique_interne():
reponse = _client_sans_db().get("/healthz")
assert reponse.status_code == 200
assert reponse.json()["service"] == "siop2-ai"
def test_endpoints_internes_refuses_sans_jeton():
client = _client_sans_db()
assert client.post("/internal/reindex").status_code == 401
assert client.post("/internal/search", json={"question": "couple de serrage ?"}).status_code == 401
def test_question_trop_courte_rejetee_avant_tout():
reponse = _client_sans_db().post(
"/internal/search",
json={"question": "ab"},
headers={"X-Service-Token": "dev-only-ai-token"},
)
assert reponse.status_code == 422
def _client_sans_db() -> TestClient:
"""TestClient HORS gestionnaire de contexte : le lifespan (pool DB,
modèle d'embeddings) ne tourne pas — on pose l'état minimal. Les tests
d'intégration DB se font en local (recette), pas en CI (convention R5.1)."""
from siop_ai.config import charger_reglages
app.state.reglages = charger_reglages()
return TestClient(app, raise_server_exceptions=False)

View File

@@ -0,0 +1,51 @@
"""Logique de l'assistant testée sans base : le seuil « sourcé ou silencieux »
et la sélection des suggestions — la partie SQL est couverte par la recette
réelle (convention R5.1 : pytest purs en CI)."""
from siop_ai.assistant import (
CHAMPS_BILAN,
SEUIL_CONFIANCE_FORTE,
SEUIL_PERTINENCE,
SEUIL_SUGGESTION,
_cosinus,
)
from siop_ai.embeddings import EmbeddeurDeterministe
def test_les_seuils_sont_ordonnes():
# Pertinence et suggestion vivent dans des pipelines distincts (question →
# passages vs description → libellés) : pas d'ordre imposé entre eux.
# 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():
assert set(CHAMPS_BILAN) == {
"DOOR_STATE",
"CABIN_POSITION",
"ANOMALY",
"EXTERNAL_CAUSE",
"ACTION_TAKEN",
"COMPONENT_CONCERNED",
}
def test_similarite_discrimine_le_bon_code():
"""Le cœur de la suggestion : une description de panne de porte doit être
plus proche du code « portes » que d'un code sans rapport."""
embeddeur = EmbeddeurDeterministe()
description, porte, treuil = embeddeur.encoder(
[
"la porte cabine rebondit, cellule encrassée, nettoyage barrière porte",
"anomalie constatée : cellule ou barrière de porte encrassée",
"élément concerné : treuil et moteur de traction",
]
)
assert _cosinus(description, porte) > _cosinus(description, treuil)
def test_cosinus_de_vecteurs_normes():
embeddeur = EmbeddeurDeterministe()
[v] = embeddeur.encoder(["contrôle mensuel des portes palières"])
assert abs(_cosinus(v, v) - 1.0) < 1e-6

View File

@@ -0,0 +1,24 @@
from siop_ai.decoupage import CHEVAUCHEMENT, TAILLE_CIBLE, decouper
def test_texte_court_un_seul_extrait():
extraits = decouper("Serrer les vis de fixation des coulisseaux au couple de 25 N·m.")
assert len(extraits) == 1
def test_les_miettes_sont_ecartees():
assert decouper("p. 3\n\n7\n\n") == []
def test_long_texte_decoupe_avec_chevauchement():
paragraphe = "La procédure de maintenance impose un contrôle mensuel des organes. " * 40
extraits = decouper(paragraphe)
assert len(extraits) >= 2
assert all(len(e) <= TAILLE_CIBLE + CHEVAUCHEMENT for e in extraits)
def test_paragraphes_courts_regroupes():
texte = "\n\n".join(f"Étape {i} : vérifier le verrouillage de la porte palière." for i in range(6))
extraits = decouper(texte)
assert len(extraits) == 1
assert "Étape 0" in extraits[0] and "Étape 5" in extraits[0]

View File

@@ -0,0 +1,26 @@
from siop_ai.embeddings import DIMENSIONS, EmbeddeurDeterministe
def test_dimensions_et_normalisation():
[vecteur] = EmbeddeurDeterministe().encoder(["couple de serrage des guides"])
assert len(vecteur) == DIMENSIONS
assert abs(sum(v * v for v in vecteur) - 1.0) < 1e-6
def test_stable_et_discriminant():
embeddeur = EmbeddeurDeterministe()
a1 = embeddeur.encoder(["couple de serrage des coulisseaux de guides"])[0]
a2 = embeddeur.encoder(["couple de serrage des coulisseaux de guides"])[0]
b = embeddeur.encoder(["planning des congés du personnel administratif"])[0]
cosinus = lambda x, y: sum(p * q for p, q in zip(x, y)) # noqa: E731 — vecteurs normés
assert a1 == a2
assert cosinus(a1, b) < 0.35 < cosinus(a1, a1)
def test_textes_proches_plus_similaires_que_textes_eloignes():
embeddeur = EmbeddeurDeterministe()
question = embeddeur.encoder(["quel couple de serrage pour les guides ?"])[0]
notice = embeddeur.encoder(["serrer les coulisseaux de guides au couple de 25 N·m"])[0]
horsujet = embeddeur.encoder(["le syndic organise une assemblée générale annuelle"])[0]
cosinus = lambda x, y: sum(p * q for p, q in zip(x, y)) # noqa: E731
assert cosinus(question, notice) > cosinus(question, horsujet)

View File

@@ -0,0 +1,62 @@
"""ADR-004 §3 — la génération est OPT-IN et configurable : clé, modèle, mode.
Sans clé, le mode extractif est le contrat ; en mode api sans clé, le boot
refuse (config validée au démarrage, comme l'API NestJS)."""
import pytest
from siop_ai.config import charger_reglages
from siop_ai.generation import GenerateurExtractif, construire_generateur, construire_invite
from siop_ai.recherche import ExtraitTrouve
def _extrait(titre: str, locator: str, content: str) -> ExtraitTrouve:
return ExtraitTrouve(
source_type="DOCUMENT",
document_id="d-1",
work_order_id=None,
titre=titre,
locator=locator,
content=content,
score=0.9,
)
def test_defaut_extractif_sans_cle():
generateur = construire_generateur("off", "", "claude-opus-4-8")
assert isinstance(generateur, GenerateurExtractif)
assert generateur.rediger("couple de serrage ?", [_extrait("n", "p. 1", "x")]) is None
def test_mode_api_sans_cle_refuse_au_boot(monkeypatch):
monkeypatch.setenv("AI_GENERATION", "api")
monkeypatch.delenv("AI_API_KEY", raising=False)
with pytest.raises(ValueError, match="AI_API_KEY"):
charger_reglages()
def test_mode_inconnu_refuse_au_boot(monkeypatch):
monkeypatch.setenv("AI_GENERATION", "toujours")
with pytest.raises(ValueError, match="AI_GENERATION"):
charger_reglages()
def test_cle_et_modele_configurables(monkeypatch):
monkeypatch.setenv("AI_GENERATION", "api")
monkeypatch.setenv("AI_API_KEY", "sk-test-123")
monkeypatch.setenv("AI_MODEL", "claude-opus-4-8")
reglages = charger_reglages()
assert reglages.ai_api_key == "sk-test-123"
assert reglages.ai_model == "claude-opus-4-8"
def test_invite_numerote_les_extraits_et_rien_d_autre():
invite = construire_invite(
"quel couple de serrage pour les guides ?",
[
_extrait("Notice Gen2.pdf", "p. 42", "Serrer à 25 N·m."),
_extrait("OT-2026-0341", "bilan du 2026-07-17", "Coulisseaux remplacés."),
],
)
assert "[1] Notice Gen2.pdf · p. 42" in invite
assert "[2] OT-2026-0341 · bilan du 2026-07-17" in invite
assert invite.endswith("Question : quel couple de serrage pour les guides ?")

1870
apps/ai/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
-- R5 (ADR-004) : pgvector — même patron que PostGIS en r1_referentiel
CREATE EXTENSION IF NOT EXISTS vector;
-- CreateEnum
CREATE TYPE "RagSourceType" AS ENUM ('DOCUMENT', 'WORK_ORDER');
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "chunkCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "inCorpus" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "indexedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "RagChunk" (
"id" UUID NOT NULL,
"sourceType" "RagSourceType" NOT NULL,
"documentId" UUID,
"workOrderId" UUID,
"locator" TEXT NOT NULL,
"content" TEXT NOT NULL,
"embedding" vector(384) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RagChunk_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RagChunk_documentId_idx" ON "RagChunk"("documentId");
-- CreateIndex
CREATE INDEX "RagChunk_workOrderId_idx" ON "RagChunk"("workOrderId");
-- AddForeignKey
ALTER TABLE "RagChunk" ADD CONSTRAINT "RagChunk_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RagChunk" ADD CONSTRAINT "RagChunk_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;

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

@@ -233,6 +233,7 @@ model WorkOrder {
completedAt DateTime?
cancelledAt DateTime?
events WorkOrderEvent[]
ragChunks RagChunk[]
checklist ChecklistItem[]
report InterventionReport?
request Request?
@@ -513,7 +514,36 @@ model Document {
uploadedById String? @db.Uuid
uploadedBy User? @relation(fields: [uploadedById], references: [id])
createdAt DateTime @default(now())
// R5 (D3) : le corpus est visible et réversible, document par document.
inCorpus Boolean @default(true)
indexedAt DateTime? // null = jamais indexé
chunkCount Int @default(0)
chunks RagChunk[]
@@index([assetId])
@@index([workOrderId])
}
/// R5 — extraits indexés du corpus (ADR-004) : bibliothèque + bilans codés,
/// ANONYMISÉS À L'INGESTION (D4). Écrit par apps/ai, schéma possédé par Prisma.
model RagChunk {
id String @id @default(uuid()) @db.Uuid
sourceType RagSourceType
documentId String? @db.Uuid
document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
workOrderId String? @db.Uuid
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
/// Repère humain de la source : « p. 42 », « bilan du 17/07/2026 »…
locator String
content String
embedding Unsupported("vector(768)")
createdAt DateTime @default(now())
@@index([documentId])
@@index([workOrderId])
}
enum RagSourceType {
DOCUMENT
WORK_ORDER
}

View File

@@ -2,6 +2,7 @@ import { DynamicModule, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AnalyticsModule } from './analytics/analytics.module';
import { SearchModule } from './search/search.module';
import { AssistantModule } from './assistant/assistant.module';
import { AssetsModule } from './assets/assets.module';
import { DocumentsModule } from './documents/documents.module';
import { AuthModule } from './auth/auth.module';
@@ -63,6 +64,7 @@ export class AppModule {
DocumentsModule,
AnalyticsModule,
SearchModule,
AssistantModule,
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
...(demoModeEnabled() ? [DemoAuthModule] : []),
],

View File

@@ -0,0 +1,39 @@
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
import {
AssistantAskSchema,
SuggestBilanSchema,
type AssistantAsk,
type SuggestBilan,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { AssistantService } from './assistant.service';
@Controller('assistant')
export class AssistantController {
constructor(private readonly assistant: AssistantService) {}
/** Poser une question — qui lit les OT peut interroger le corpus. */
@Post('ask')
@HttpCode(200)
@RequirePermission('WORK_ORDERS', 'view')
ask(@Body(new ZodValidationPipe(AssistantAskSchema)) body: AssistantAsk) {
return this.assistant.ask(body);
}
/** Réindexer le corpus — même droit que la gestion du référentiel (D3). */
@Post('reindex')
@HttpCode(200)
@RequirePermission('ASSETS', 'edit')
reindex() {
return this.assistant.reindex();
}
/** Suggérer des codes — réservé à qui remplit des bilans (D1). */
@Post('suggest-bilan')
@HttpCode(200)
@RequirePermission('WORK_ORDERS', 'edit')
suggest(@Body(new ZodValidationPipe(SuggestBilanSchema)) body: SuggestBilan) {
return this.assistant.suggestBilan(body);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AssistantController } from './assistant.controller';
import { AssistantService } from './assistant.service';
@Module({
controllers: [AssistantController],
providers: [AssistantService],
})
export class AssistantModule {}

View File

@@ -0,0 +1,123 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import type {
AssistantAnswer,
AssistantAsk,
BilanField,
BilanSuggestionsResponse,
ReindexResult,
SuggestBilan,
} from '@siop/shared';
import { loadEnv } from '../config/env';
/** Proxy vers `siop2-ai` (ADR-004 §4) : le service IA n'est JAMAIS public —
* l'API porte l'auth utilisateur (matrice) et le jeton de service interne.
* Il traduit aussi le dialecte interne (français, snake_case) vers le
* contrat (@siop/shared) — une seule vérité côté clients. */
interface ExtraitInterne {
source_type: 'DOCUMENT' | 'WORK_ORDER';
document_id: string | null;
work_order_id: string | null;
titre: string;
locator: string;
content: string;
score: number;
}
const MODES = { extractif: 'EXTRACTIVE', genere: 'GENERATED', refus: 'REFUSAL' } as const;
const CONFIANCES = { FORTE: 'HIGH', MOYENNE: 'MEDIUM' } as const;
@Injectable()
export class AssistantService {
private readonly journal = new Logger(AssistantService.name);
private readonly env = loadEnv();
private async appeler<T>(chemin: string, corps: unknown): Promise<T> {
let reponse: Response;
try {
reponse = await fetch(`${this.env.AI_SERVICE_URL}${chemin}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Token': this.env.AI_SERVICE_TOKEN,
},
body: JSON.stringify(corps),
});
} catch {
this.journal.warn(`Service IA injoignable (${chemin})`);
throw new ServiceUnavailableException(
'Assistant indisponible pour le moment — réessayez dans un instant.',
);
}
if (!reponse.ok) {
this.journal.warn(`Service IA a refusé ${chemin} (${reponse.status})`);
throw new ServiceUnavailableException(
'Assistant indisponible pour le moment — réessayez dans un instant.',
);
}
return (await reponse.json()) as T;
}
async ask(dto: AssistantAsk): Promise<AssistantAnswer> {
const brut = await this.appeler<{
mode: keyof typeof MODES;
answer: string | null;
extraits: ExtraitInterne[];
corpus: { documents: number; bilans: number };
}>('/internal/ask', { question: dto.question });
return {
mode: MODES[brut.mode],
answer: brut.answer,
excerpts: brut.extraits.map((e) => ({
sourceType: e.source_type,
documentId: e.document_id,
workOrderId: e.work_order_id,
title: e.titre,
locator: e.locator,
content: e.content,
score: e.score,
})),
corpus: { documents: brut.corpus.documents, reports: brut.corpus.bilans },
};
}
async reindex(): Promise<ReindexResult> {
const brut = await this.appeler<{
documents_indexes: number;
documents_ignores: number;
bilans_indexes: number;
extraits: number;
}>('/internal/reindex', {});
return {
documentsIndexed: brut.documents_indexes,
documentsSkipped: brut.documents_ignores,
reportsIndexed: brut.bilans_indexes,
chunks: brut.extraits,
};
}
async suggestBilan(dto: SuggestBilan): Promise<BilanSuggestionsResponse> {
const brut = await this.appeler<{
suggestions: {
field: BilanField;
value_id: string;
label: string;
confidence: keyof typeof CONFIANCES;
similar_reports: number;
score: number;
}[];
}>('/internal/suggest', { description: dto.description });
return {
suggestions: brut.suggestions.map((s) => ({
field: s.field,
valueId: s.value_id,
label: s.label,
confidence: CONFIANCES[s.confidence],
similarReports: s.similar_reports,
score: s.score,
})),
};
}
}

View File

@@ -18,6 +18,9 @@ const EnvSchema = z.object({
// Vide = aucun CORS (défaut sûr) — le web de prod passe par le proxy nginx
// même-origine, les apps natives n'envoient pas d'Origin.
CORS_ORIGINS: z.string().default(''),
// R5 (ADR-004 §4) : le service IA interne — seul l'API le contacte.
AI_SERVICE_URL: z.string().default('http://localhost:8000'),
AI_SERVICE_TOKEN: z.string().default('dev-only-ai-token'),
});
export type Env = z.infer<typeof EnvSchema>;

View File

@@ -7,6 +7,7 @@ import {
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
StreamableFile,
@@ -14,11 +15,17 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { DOCUMENT_MAX_BYTES } from '@siop/shared';
import {
DOCUMENT_MAX_BYTES,
DocumentCorpusUpdateSchema,
type DocumentCorpusUpdate,
} from '@siop/shared';
import {
AuthenticatedUser,
CurrentUser,
} from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { DocumentsService } from './documents.service';
@Controller('documents')
@@ -69,6 +76,16 @@ export class DocumentsController {
});
}
/** Corpus IA (D3) : réservé aux gestionnaires du référentiel. */
@Patch(':id/corpus')
@RequirePermission('ASSETS', 'edit')
setCorpus(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(DocumentCorpusUpdateSchema)) body: DocumentCorpusUpdate,
) {
return this.documents.setCorpus(id, body.inCorpus);
}
@Delete(':id')
@HttpCode(204)
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {

View File

@@ -155,6 +155,21 @@ export class DocumentsService {
workOrderReference: row.workOrder?.reference ?? null,
uploadedByName: row.uploadedBy?.displayName ?? null,
createdAt: row.createdAt.toISOString(),
inCorpus: row.inCorpus,
indexedAt: row.indexedAt?.toISOString() ?? null,
chunkCount: row.chunkCount,
};
}
/** Interrupteur corpus (D3) — effectif à la prochaine réindexation. */
async setCorpus(id: string, inCorpus: boolean): Promise<DocumentDto> {
const doc = await this.prisma.document.findUnique({ where: { id } });
if (!doc) throw new NotFoundException('Document inconnu');
const updated = await this.prisma.document.update({
where: { id },
data: { inCorpus },
include: documentInclude,
});
return this.toDto(updated);
}
}

View File

@@ -0,0 +1,186 @@
/**
* E2E R5.2 — assistant au contrat : le service IA reste interne, l'API porte
* l'auth et la matrice ; le dialecte interne est traduit vers @siop/shared.
* Le service IA est joué par un STUB HTTP local (la vraie chaîne se vérifie
* en recette réelle — convention R5.1).
*/
process.env.DEMO_MODE = 'true';
import { createServer, type Server } from 'node:http';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module';
const REPONSE_ASK = {
mode: 'extractif',
answer: null,
extraits: [
{
source_type: 'DOCUMENT',
document_id: '7d7bfa5c-2f43-4f9e-9e59-3c1f0a5df001',
work_order_id: null,
titre: 'Notice Gen2.pdf',
locator: 'p. 42',
content: 'Serrer les coulisseaux au couple de 25 N·m.',
score: 0.61,
},
],
corpus: { documents: 6, bilans: 214 },
};
const REPONSE_SUGGEST = {
suggestions: [
{
field: 'ANOMALY',
value_id: '7d7bfa5c-2f43-4f9e-9e59-3c1f0a5df002',
label: 'Cellule/barrière encrassée',
confidence: 'FORTE',
similar_reports: 9,
score: 0.62,
},
],
};
const REPONSE_REINDEX = {
documents_indexes: 6,
documents_ignores: 2,
bilans_indexes: 214,
extraits: 180,
};
describe('Assistant (e2e — stub du service IA)', () => {
let app: INestApplication;
let stub: Server;
let ahmed: string; // Technicien : view + edit sur WORK_ORDERS, ASSETS en lecture
let karim: string; // Demandeur : aucun droit WORK_ORDERS
let rachid: string; // Vue seule : view sans edit
let nadia: string; // Gestionnaire : ASSETS.edit — administre le corpus
const requetesRecues: { url: string; jeton: string | undefined }[] = [];
const http = () => request(app.getHttpServer());
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
beforeAll(async () => {
// Stub du service IA sur un port éphémère
stub = createServer((req, res) => {
requetesRecues.push({
url: req.url ?? '',
jeton: req.headers['x-service-token'] as string | undefined,
});
res.setHeader('Content-Type', 'application/json');
if (req.url === '/internal/ask') res.end(JSON.stringify(REPONSE_ASK));
else if (req.url === '/internal/suggest') res.end(JSON.stringify(REPONSE_SUGGEST));
else if (req.url === '/internal/reindex') res.end(JSON.stringify(REPONSE_REINDEX));
else {
res.statusCode = 404;
res.end('{}');
}
});
await new Promise<void>((resolve) => stub.listen(0, '127.0.0.1', resolve));
const adresse = stub.address();
const port = typeof adresse === 'object' && adresse ? adresse.port : 0;
process.env.AI_SERVICE_URL = `http://127.0.0.1:${port}`;
process.env.AI_SERVICE_TOKEN = 'jeton-de-test';
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken as string;
};
ahmed = await login('Technicien');
karim = await login('Demandeur');
rachid = await login('Vue seule');
nadia = await login('Gestionnaire');
});
afterAll(async () => {
await app?.close();
await new Promise<void>((resolve) => stub.close(() => resolve()));
});
it('ask : traduit le dialecte interne vers le contrat, avec le jeton de service', async () => {
const res = await http()
.post('/assistant/ask')
.set(auth(ahmed))
.send({ question: 'quel couple de serrage pour les guides ?' })
.expect(200);
expect(res.body.mode).toBe('EXTRACTIVE');
expect(res.body.excerpts[0]).toMatchObject({
sourceType: 'DOCUMENT',
title: 'Notice Gen2.pdf',
locator: 'p. 42',
});
expect(res.body.corpus).toEqual({ documents: 6, reports: 214 });
const derniere = requetesRecues.at(-1)!;
expect(derniere.url).toBe('/internal/ask');
expect(derniere.jeton).toBe('jeton-de-test'); // ADR-004 §4
});
it('suggest-bilan : codes existants traduits (FORTE → HIGH, snake → camel)', async () => {
const res = await http()
.post('/assistant/suggest-bilan')
.set(auth(ahmed))
.send({ description: 'porte cabine qui rebondit, cellule encrassée, nettoyage fait' })
.expect(200);
expect(res.body.suggestions[0]).toMatchObject({
field: 'ANOMALY',
confidence: 'HIGH',
similarReports: 9,
});
});
it('la matrice sapplique : demandeur sans WORK_ORDERS → 403 sur ask', async () => {
await http()
.post('/assistant/ask')
.set(auth(karim))
.send({ question: 'où sont les notices ?' })
.expect(403);
});
it('vue seule : ask autorisé (view), suggest refusé (edit requis — D1)', async () => {
await http()
.post('/assistant/ask')
.set(auth(rachid))
.send({ question: 'historique du parc ?' })
.expect(200);
await http()
.post('/assistant/suggest-bilan')
.set(auth(rachid))
.send({ description: 'une description suffisamment longue ici' })
.expect(403);
});
it('reindex : traduit le bilan dindexation, réservé à ASSETS.edit (D3)', async () => {
const res = await http().post('/assistant/reindex').set(auth(nadia)).expect(200);
expect(res.body).toEqual({
documentsIndexed: 6,
documentsSkipped: 2,
reportsIndexed: 214,
chunks: 180,
});
expect(requetesRecues.at(-1)!.url).toBe('/internal/reindex');
// Ahmed (Technicien) lit le parc mais n'administre pas le corpus
await http().post('/assistant/reindex').set(auth(ahmed)).expect(403);
});
it('question trop courte : 400 avant tout appel au service IA', async () => {
const avant = requetesRecues.length;
await http().post('/assistant/ask').set(auth(ahmed)).send({ question: 'ab' }).expect(400);
expect(requetesRecues.length).toBe(avant);
});
it('service IA éteint : 503 propre, jamais un 500', async () => {
await new Promise<void>((resolve) => stub.close(() => resolve()));
await http()
.post('/assistant/ask')
.set(auth(ahmed))
.send({ question: 'le service est-il là ?' })
.expect(503);
});
});

View File

@@ -87,6 +87,36 @@ describe('Bibliothèque & analytics (e2e)', () => {
await http().get(`/documents/${envoye.body.id}/download`).set(auth(nadia)).expect(404);
});
it('corpus (R5, D3) : nouveau document inclus par défaut, bascule réversible et gardée', async () => {
const { body: assets } = await http().get('/assets').set(auth(nadia));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const envoye = await http()
.post('/documents')
.set(auth(nadia))
.field('kind', 'NOTICE')
.field('assetId', a1.id)
.attach('file', PNG_1PX, { filename: `corpus-${suffix}.png`, contentType: 'image/png' })
.expect(201);
// le contrat expose l'état d'indexation — jamais indexé à la naissance
expect(envoye.body).toMatchObject({ inCorpus: true, indexedAt: null, chunkCount: 0 });
const exclu = await http()
.patch(`/documents/${envoye.body.id}/corpus`)
.set(auth(nadia))
.send({ inCorpus: false })
.expect(200);
expect(exclu.body.inCorpus).toBe(false);
// Karim (Demandeur) n'administre pas le corpus
await http()
.patch(`/documents/${envoye.body.id}/corpus`)
.set(auth(karim))
.send({ inCorpus: true })
.expect(403);
await http().delete(`/documents/${envoye.body.id}`).set(auth(nadia)).expect(204);
});
it('refus typés : format, rattachement manquant, cible inconnue, permission', async () => {
const { body: assets } = await http().get('/assets').set(auth(nadia));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');

View File

@@ -1,16 +1,17 @@
import { router, useLocalSearchParams } from 'expo-router';
import { useState } from 'react';
import { ScrollView, Text, View } from 'react-native';
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
BILAN_FIELD_LABELS,
BILAN_FIELDS,
REQUIRED_BILAN_FIELDS,
type BilanField,
type BilanSuggestion,
type ReportUpsert,
} from '@siop/shared';
import { useQueryClient } from '@tanstack/react-query';
import { useReferenceValues, useWorkOrder } from '@/api/exploitation';
import { useReferenceValues, useSuggestionBilan, useWorkOrder } from '@/api/exploitation';
import { useHorsLigne } from '@/auth/session';
import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
import { enfilerBilan, enfilerTransition } from '@/file/actions';
@@ -95,6 +96,10 @@ export default function PageCloture() {
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre={`Clôturer ${ot.reference}`} />
<CarteSuggestion
horsLigne={horsLigne}
surApplication={(s) => setChoix((c) => ({ ...c, [s.field]: s.valueId }))}
/>
<Carte titre="Bilan d'intervention — requis pour clôturer">
<View style={{ gap: 10 }}>
{[0, 2, 4].map((rang) => (
@@ -151,3 +156,118 @@ export default function PageCloture() {
</SafeAreaView>
);
}
/** Écran 4 des maquettes R5 : décrire au pouce → chips suggérées (D1 — un
* appui = un choix humain, les chips ne font que pré-remplir les sélecteurs).
* L'IA est un service serveur : hors-ligne, la suggestion attend le réseau
* — la clôture en file R4, elle, n'en a pas besoin. */
function CarteSuggestion({
horsLigne,
surApplication,
}: {
horsLigne: boolean;
surApplication: (s: BilanSuggestion) => void;
}) {
const t = useTokens();
const suggerer = useSuggestionBilan();
const [description, setDescription] = useState('');
const [appliquees, setAppliquees] = useState<Set<BilanField>>(new Set());
const suggestions = suggerer.data?.suggestions ?? [];
return (
<Carte titre="Décrire pour suggérer (optionnel)">
<TextInput
multiline
value={description}
onChangeText={setDescription}
maxLength={2000}
placeholder="Décrivez la panne et ce que vous avez fait…"
placeholderTextColor={t.encre3}
accessibilityLabel="Décrire pour suggérer"
style={{
minHeight: 64,
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
backgroundColor: t.surface,
padding: 9,
color: t.encre,
fontFamily: 'Manrope_500Medium',
fontSize: 13,
textAlignVertical: 'top',
}}
/>
<BoutonTel
libelle={
horsLigne
? 'Suggérer — réseau requis'
: suggerer.isPending
? 'Analyse…'
: '✨ Suggérer les codes'
}
variante="contour"
desactive={horsLigne || suggerer.isPending || description.trim().length < 10}
surAppui={() => {
setAppliquees(new Set());
suggerer.mutate(description.trim());
}}
/>
{suggerer.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
{suggerer.error.message}
</Text>
) : null}
{suggerer.isSuccess && suggestions.length === 0 ? (
<Text style={{ color: t.encre2, fontFamily: 'Manrope_500Medium', fontSize: 12 }}>
Aucun code assez proche lIA ne devine pas : choisissez dans les sélecteurs.
</Text>
) : null}
{suggestions.length > 0 ? (
<>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
{suggestions.map((s) => {
const faite = appliquees.has(s.field);
return (
<Pressable
key={s.field}
accessibilityRole="button"
accessibilityLabel={`Appliquer ${BILAN_FIELD_LABELS[s.field]} : ${s.label}`}
disabled={faite}
onPress={() => {
surApplication(s);
setAppliquees((avant) => new Set([...avant, s.field]));
}}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 5,
borderWidth: 1.5,
borderColor: t.primaire,
backgroundColor: faite ? t.primaire : t.primaireDoux,
borderRadius: 999,
paddingVertical: 6,
paddingHorizontal: 10,
}}
>
<Text
style={{
color: faite ? '#fff' : t.primaire,
fontFamily: 'Manrope_700Bold',
fontSize: 12,
}}
>
{faite ? '✓' : '✨'} {BILAN_FIELD_LABELS[s.field]} : {s.label}
</Text>
</Pressable>
);
})}
</View>
<Text style={{ color: t.encre3, fontFamily: 'Manrope_500Medium', fontSize: 11 }}>
Rien ne senregistre sans votre geste les chips pré-remplissent les sélecteurs
ci-dessous.
</Text>
</>
) : null}
</Carte>
);
}

View File

@@ -91,6 +91,15 @@ export function useCocheChecklist(otId: string) {
});
}
/** Suggestion R5 (D1) : l'IA est un service SERVEUR — la suggestion demande le
* réseau, la clôture en file R4 fonctionne sans elle. */
export function useSuggestionBilan() {
return useMutation({
mutationFn: async (description: string) =>
unwrap(await api.POST('/assistant/suggest-bilan', { body: { description } })),
});
}
export function useBilan(otId: string) {
const invalide = useInvalideOT(otId);
return useMutation({

View File

@@ -407,6 +407,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/documents/{id}/corpus": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** Inclure/exclure du corpus IA (D3 — réversible, effectif à la prochaine réindexation) */
patch: operations["updateDocumentCorpus"];
trace?: never;
};
"/documents/{id}": {
parameters: {
query?: never;
@@ -441,6 +458,57 @@ export interface paths {
patch?: never;
trace?: never;
};
"/assistant/ask": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Assistant R5 — sourcé ou silencieux : extraits cités ou refus honnête (D2) */
post: operations["askAssistant"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assistant/suggest-bilan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Suggérer des codes de bilan depuis une description libre (D1 — lhumain valide) */
post: operations["suggestBilan"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assistant/reindex": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Réindexer le corpus (bibliothèque PDF + bilans clôturés, anonymisés à lingestion — D4) */
post: operations["reindexAssistant"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/search": {
parameters: {
query?: never;
@@ -1403,6 +1471,9 @@ export interface components {
uploadedByName: string | null;
/** Format: date-time */
createdAt: string;
inCorpus: boolean;
indexedAt: string | null;
chunkCount: number;
}[];
};
Document: {
@@ -1418,6 +1489,12 @@ export interface components {
uploadedByName: string | null;
/** Format: date-time */
createdAt: string;
inCorpus: boolean;
indexedAt: string | null;
chunkCount: number;
};
DocumentCorpusUpdate: {
inCorpus: boolean;
};
AnalyticsSummary: {
months: number;
@@ -1446,6 +1523,50 @@ export interface components {
total: number;
}[];
};
AssistantAnswer: {
/** @enum {string} */
mode: "EXTRACTIVE" | "GENERATED" | "REFUSAL";
answer: string | null;
excerpts: {
/** @enum {string} */
sourceType: "DOCUMENT" | "WORK_ORDER";
documentId: string | null;
workOrderId: string | null;
title: string;
locator: string;
content: string;
score: number;
}[];
corpus: {
documents: number;
reports: number;
};
};
AssistantAsk: {
question: string;
};
BilanSuggestions: {
suggestions: {
/** @enum {string} */
field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED";
/** Format: uuid */
valueId: string;
label: string;
/** @enum {string} */
confidence: "HIGH" | "MEDIUM";
similarReports: number;
score: number;
}[];
};
SuggestBilan: {
description: string;
};
ReindexResult: {
documentsIndexed: number;
documentsSkipped: number;
reportsIndexed: number;
chunks: number;
};
SearchResponse: {
workOrders: {
/** Format: uuid */
@@ -2918,6 +3039,39 @@ export interface operations {
};
};
};
updateDocumentCorpus: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DocumentCorpusUpdate"];
};
};
responses: {
/** @description Document mis à jour */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Document"];
};
};
/** @description Inconnu */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteDocument: {
parameters: {
query?: never;
@@ -2967,6 +3121,95 @@ export interface operations {
};
};
};
askAssistant: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AssistantAsk"];
};
};
responses: {
/** @description Réponse sourcée ou refus */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AssistantAnswer"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
suggestBilan: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SuggestBilan"];
};
};
responses: {
/** @description Suggestions (codes existants seulement) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BilanSuggestions"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
reindexAssistant: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Bilan dindexation */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReindexResult"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
globalSearch: {
parameters: {
query: {

View File

@@ -0,0 +1,149 @@
import { expect, test } from '@playwright/test';
/**
* Recette R5 (plan de releases) : la bibliothèque EST le corpus (bandeau
* 09-08, statut d'indexation, interrupteur, réindexation explicite) ; puis
* l'assistant « sourcé ou silencieux » (réponse citée depuis le PDF téléversé,
* refus honnête et chiffré sinon) ; enfin la suggestion de bilan sur un OT
* (codes existants, appliqués par le geste humain, liseré « suggéré »).
* Le service IA tourne avec l'embeddeur déterministe et des seuils abaissés
* (ci.yml) — c'est le CIRCUIT qui se recette, le vrai modèle se recette en
* local (journal R5.1).
*/
const suffix = Date.now().toString(36);
/** PDF 1 page minimal mais VALIDE (xref calculée) — pypdf doit pouvoir en
* extraire le texte : c'est ce qui alimente l'index côté service IA. */
function pdfMinimal(texte: string): Buffer {
const contenu = `BT /F1 12 Tf 72 720 Td (${texte.replace(/[()\\]/g, '\\$&')}) Tj ET`;
const objets = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R ' +
'/Resources << /Font << /F1 5 0 R >> >> >>',
`<< /Length ${contenu.length} >>\nstream\n${contenu}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
];
let corps = '%PDF-1.4\n';
const offsets: number[] = [];
objets.forEach((objet, i) => {
offsets.push(corps.length);
corps += `${i + 1} 0 obj\n${objet}\nendobj\n`;
});
const debutXref = corps.length;
corps += `xref\n0 ${objets.length + 1}\n0000000000 65535 f \n`;
for (const offset of offsets) corps += `${String(offset).padStart(10, '0')} 00000 n \n`;
corps += `trailer\n<< /Size ${objets.length + 1} /Root 1 0 R >>\nstartxref\n${debutXref}\n%%EOF`;
return Buffer.from(corps, 'latin1');
}
async function connexionDemo(page: import('@playwright/test').Page, nom: RegExp) {
await page.goto('/connexion');
await page.getByRole('button', { name: nom }).click();
await expect(page.locator('.topbar')).toBeVisible();
}
test('recette R5 : corpus → réindexation → assistant sourcé → refus honnête', async ({ page }) => {
test.setTimeout(180_000); // deux réindexations complètes du corpus réel
const fichier = `e2e-notice-${suffix}.pdf`;
// 1 · Nadia (gestionnaire, ASSETS.edit) — la bibliothèque est le corpus
await connexionDemo(page, /Nadia Berrada/);
await page.getByRole('link', { name: 'Fichiers' }).click();
await expect(page.getByRole('heading', { name: /corpus de l'assistant/ })).toBeVisible();
await expect(page.locator('.avert')).toContainText('Loi 09-08'); // l'anonymisation est DITE
// 2 · Téléverser une notice PDF de test rattachée à A1
await page.getByRole('button', { name: 'Téléverser' }).click();
await page.getByLabel("Rattacher à l'appareil *").selectOption({ index: 1 });
await page.getByLabel('Choisir un fichier').setInputFiles({
name: fichier,
mimeType: 'application/pdf',
buffer: pdfMinimal(
'Couple de serrage des coulisseaux de guides : 25 Nm. Verifier le jeu lateral.',
),
});
await page.getByRole('button', { name: 'Téléverser', exact: true }).last().click();
const vignette = page.locator('tr', { hasText: fichier }); // tableau du corpus (arbitrage référent)
await expect(vignette).toBeVisible();
await expect(vignette.locator('.st')).toHaveText('à indexer'); // jamais indexé à la naissance
// 3 · Réindexer tout — geste explicite, bilan chiffré, statut à jour
await page.getByRole('button', { name: 'Réindexer tout' }).click();
// l'ingestion réelle (MinIO + pypdf + embeddings) peut dépasser les 5 s
await expect(page.getByText('Réindexation terminée')).toBeVisible({ timeout: 60_000 });
await expect(vignette.locator('.st')).toContainText('indexé ·');
await expect(vignette.locator('.st')).toContainText('extrait');
// 4 · L'assistant répond SOURCÉ depuis ce PDF (embeddeur déterministe :
// la question reprend les mots de la notice)
await page.getByRole('link', { name: 'Assistant' }).click();
await expect(page.getByText('répond UNIQUEMENT depuis votre bibliothèque')).toBeVisible();
await page
.getByLabel('Poser une question')
.fill('couple de serrage des coulisseaux de guides ?');
await page.getByRole('button', { name: 'Envoyer' }).click();
const source = page.locator('.source', { hasText: fichier });
await expect(source).toBeVisible();
await expect(source.locator('.extrait')).toContainText('25 Nm'); // l'extrait EXACT
await expect(source.locator('.ou')).toContainText('p. 1'); // la citation pointe la page
await expect(page.locator('.msg-r .avert')).toContainText('vous validez'); // pas un disclaimer caché
// 5 · Sans source au-dessus du seuil : refus honnête, chiffré, avec l'action utile
await page.getByLabel('Poser une question').fill('xylophone quantique zorglub ?');
await page.getByRole('button', { name: 'Envoyer' }).click();
const refus = page.locator('.refus');
await expect(refus).toBeVisible();
await expect(refus).toContainText('je préfère ne pas inventer');
await expect(refus).toContainText(/J'ai cherché dans \d+ documents? indexés? et \d+ bilans?/);
await expect(refus.getByRole('link', { name: /Téléverser la notice/ })).toBeVisible();
// 6 · L'interrupteur exclut la notice — réversible, effectif à la réindexation
await page.getByRole('link', { name: 'Fichiers' }).click();
await vignette.getByRole('switch').click();
await expect(vignette.locator('.st')).toHaveText('exclu du corpus');
await page.getByRole('button', { name: 'Réindexer tout' }).click();
await expect(page.getByText('Réindexation terminée')).toBeVisible({ timeout: 60_000 });
// Ménage : la notice de test sort de la bibliothèque
page.on('dialog', (d) => void d.accept());
await vignette.getByRole('button', { name: 'Supprimer' }).click();
await expect(vignette).toHaveCount(0);
});
test('recette R5 : suggestion de bilan — codes existants, appliqués par l\'humain', async ({
page,
}) => {
const titre = `E2E Suggestion ${suffix}`;
// 1 · Nadia crée un OT de dépannage sur A1
await connexionDemo(page, /Nadia Berrada/);
await page.getByRole('link', { name: 'Ordres de travail' }).click();
await page.getByRole('button', { name: '+ Nouvel OT' }).click();
await page.getByLabel('Objet *').fill(titre);
await page.getByLabel('Équipement *').selectOption({ index: 1 });
await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click();
await expect(page.getByRole('heading', { name: titre })).toBeVisible();
// 2 · Décrire pour suggérer — l'IA propose des codes EXISTANTS, justifiés
await page
.getByLabel('Décrire pour suggérer (optionnel)')
.fill('Frottement mécanique sur les portes, nettoyage et graissage effectués, essais OK.');
await page.getByRole('button', { name: /Suggérer les codes/ }).click();
const suggestions = page.locator('.suggestion');
await expect(suggestions.first()).toBeVisible();
await expect(page.locator('.confiance').first()).toContainText('confiance');
// 3 · « Appliquer les N » : les sélecteurs se pré-remplissent, liseré « suggéré »
await page.getByRole('button', { name: /Appliquer les \d/ }).click();
await expect(page.locator('.champ-b[data-suggere]').first()).toBeVisible();
const anomalie = page.locator('#bilan-ANOMALY');
await expect(anomalie).not.toHaveValue('');
// 4 · Un choix MANUEL retire le liseré du champ concerné (l'humain a repris la main)
const champAnomalie = page.locator('.champ-b', { has: anomalie });
await expect(champAnomalie).toHaveAttribute('data-suggere', 'true');
await anomalie.selectOption({ index: 1 });
await expect(champAnomalie).not.toHaveAttribute('data-suggere', 'true');
});

View File

@@ -14,6 +14,10 @@ const API_ENV = {
DATABASE_URL:
process.env.DATABASE_URL ?? 'postgresql://siop:siop@localhost:5432/siop',
REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379',
// R5 : le service IA écoute sur 8000 (CI : embeddeur déterministe,
// seuils abaissés — voir ci.yml ; localement : lancez apps/ai avant).
AI_SERVICE_URL: process.env.AI_SERVICE_URL ?? 'http://localhost:8000',
AI_SERVICE_TOKEN: process.env.AI_SERVICE_TOKEN ?? 'dev-only-ai-token',
};
export default defineConfig({

View File

@@ -5,6 +5,7 @@ import { Coquille } from '@/layout/coquille';
import PageAchats from '@/pages/achats';
import PageActivation from '@/pages/activation';
import PageAscenseurs from '@/pages/ascenseurs';
import PageAssistant from '@/pages/assistant';
import PageBibliotheque from '@/pages/bibliotheque';
import PageCategories from '@/pages/categories';
import PageCompteurs from '@/pages/compteurs';
@@ -67,6 +68,7 @@ export default function App() {
<Route path="/tiers" element={dansCoquille(<PageTiers />)} />
<Route path="/bibliotheque" element={dansCoquille(<PageBibliotheque />)} />
<Route path="/statistiques" element={dansCoquille(<PageStatistiques />)} />
<Route path="/assistant" element={dansCoquille(<PageAssistant />)} />
</Routes>
</BrowserRouter>
</QueryClientProvider>

View File

@@ -0,0 +1,57 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { AssistantAsk, SuggestBilan } from '@siop/shared';
import { api } from './client';
/** Hooks R5 — l'assistant passe par l'API NestJS (le service IA n'est jamais
* appelé du navigateur, ADR-004 §4). Le 503 est un état ATTENDU du contrat
* (service éteint ou pas encore déployé) : les écrans l'affichent posément. */
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
if (res.response.status === 503) {
throw new Error('Assistant indisponible pour le moment — réessayez dans un instant.');
}
if (res.error || res.data === undefined) {
const message =
(res.error as { message?: string } | undefined)?.message ??
`Le serveur a répondu ${res.response.status}`;
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
}
return res.data;
}
export function useAskAssistant() {
return useMutation({
mutationFn: async (body: AssistantAsk) =>
unwrap(await api.POST('/assistant/ask', { body })),
});
}
export function useSuggestBilan() {
return useMutation({
mutationFn: async (body: SuggestBilan) =>
unwrap(await api.POST('/assistant/suggest-bilan', { body })),
});
}
export function useReindexAssistant() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => unwrap(await api.POST('/assistant/reindex')),
// la réindexation met à jour indexedAt/chunkCount de chaque document
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['documents'] }),
});
}
export function useSetDocumentCorpus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { id: string; inCorpus: boolean }) =>
unwrap(
await api.PATCH('/documents/{id}/corpus', {
params: { path: { id: input.id } },
body: { inCorpus: input.inCorpus },
}),
),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['documents'] }),
});
}

View File

@@ -407,6 +407,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/documents/{id}/corpus": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** Inclure/exclure du corpus IA (D3 — réversible, effectif à la prochaine réindexation) */
patch: operations["updateDocumentCorpus"];
trace?: never;
};
"/documents/{id}": {
parameters: {
query?: never;
@@ -441,6 +458,57 @@ export interface paths {
patch?: never;
trace?: never;
};
"/assistant/ask": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Assistant R5 — sourcé ou silencieux : extraits cités ou refus honnête (D2) */
post: operations["askAssistant"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assistant/suggest-bilan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Suggérer des codes de bilan depuis une description libre (D1 — lhumain valide) */
post: operations["suggestBilan"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assistant/reindex": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Réindexer le corpus (bibliothèque PDF + bilans clôturés, anonymisés à lingestion — D4) */
post: operations["reindexAssistant"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/search": {
parameters: {
query?: never;
@@ -1403,6 +1471,9 @@ export interface components {
uploadedByName: string | null;
/** Format: date-time */
createdAt: string;
inCorpus: boolean;
indexedAt: string | null;
chunkCount: number;
}[];
};
Document: {
@@ -1418,6 +1489,12 @@ export interface components {
uploadedByName: string | null;
/** Format: date-time */
createdAt: string;
inCorpus: boolean;
indexedAt: string | null;
chunkCount: number;
};
DocumentCorpusUpdate: {
inCorpus: boolean;
};
AnalyticsSummary: {
months: number;
@@ -1446,6 +1523,50 @@ export interface components {
total: number;
}[];
};
AssistantAnswer: {
/** @enum {string} */
mode: "EXTRACTIVE" | "GENERATED" | "REFUSAL";
answer: string | null;
excerpts: {
/** @enum {string} */
sourceType: "DOCUMENT" | "WORK_ORDER";
documentId: string | null;
workOrderId: string | null;
title: string;
locator: string;
content: string;
score: number;
}[];
corpus: {
documents: number;
reports: number;
};
};
AssistantAsk: {
question: string;
};
BilanSuggestions: {
suggestions: {
/** @enum {string} */
field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED";
/** Format: uuid */
valueId: string;
label: string;
/** @enum {string} */
confidence: "HIGH" | "MEDIUM";
similarReports: number;
score: number;
}[];
};
SuggestBilan: {
description: string;
};
ReindexResult: {
documentsIndexed: number;
documentsSkipped: number;
reportsIndexed: number;
chunks: number;
};
SearchResponse: {
workOrders: {
/** Format: uuid */
@@ -2918,6 +3039,39 @@ export interface operations {
};
};
};
updateDocumentCorpus: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DocumentCorpusUpdate"];
};
};
responses: {
/** @description Document mis à jour */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Document"];
};
};
/** @description Inconnu */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteDocument: {
parameters: {
query?: never;
@@ -2967,6 +3121,95 @@ export interface operations {
};
};
};
askAssistant: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AssistantAsk"];
};
};
responses: {
/** @description Réponse sourcée ou refus */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AssistantAnswer"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
suggestBilan: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SuggestBilan"];
};
};
responses: {
/** @description Suggestions (codes existants seulement) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BilanSuggestions"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
reindexAssistant: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Bilan dindexation */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReindexResult"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
globalSearch: {
parameters: {
query: {

View File

@@ -6,6 +6,7 @@ import {
type DocumentDto,
type DocumentKind,
} from '@siop/shared';
import { useSetDocumentCorpus } from '@/api/assistant';
import {
blobDocument,
ouvrirDocument,
@@ -63,7 +64,58 @@ function ApercuVignette({ doc }: { doc: DocumentDto }) {
);
}
export function VignetteDoc({ doc, surSuppression }: { doc: DocumentDto; surSuppression?: (id: string) => void }) {
/** Statut corpus (R5, D3) : lisible d'un coup d'œil, jamais ambigu. */
export function StatutCorpus({ doc }: { doc: DocumentDto }) {
if (doc.contentType !== 'application/pdf') {
return <span className="st exclu">image non indexable</span>;
}
if (!doc.inCorpus) return <span className="st exclu">exclu du corpus</span>;
if (!doc.indexedAt) return <span className="st encours">à indexer</span>;
const quand = new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(doc.indexedAt));
return (
<span className="st ok">
indexé · {quand} · {doc.chunkCount} extrait{doc.chunkCount > 1 ? 's' : ''}
</span>
);
}
/** Interrupteur d'inclusion au corpus (R5, D3) — PDF seulement, réversible.
* Une image non indexable s'affiche ÉTEINTE quel que soit l'état stocké :
* l'interrupteur montre la réalité du corpus, pas une colonne de base. */
export function InterrupteurCorpus({ doc }: { doc: DocumentDto }) {
const bascule = useSetDocumentCorpus();
return (
<button
type="button"
role="switch"
aria-checked={doc.contentType === 'application/pdf' && doc.inCorpus}
aria-label={doc.inCorpus ? 'Exclure du corpus' : 'Inclure au corpus'}
title={
doc.contentType !== 'application/pdf'
? 'Seuls les PDF sont indexables'
: doc.inCorpus
? 'Exclure du corpus (effectif à la prochaine réindexation)'
: 'Inclure au corpus (effectif à la prochaine réindexation)'
}
className="interrupteur corpus"
disabled={bascule.isPending || doc.contentType !== 'application/pdf'}
onClick={() => bascule.mutate({ id: doc.id, inCorpus: !doc.inCorpus })}
/>
);
}
export function VignetteDoc({
doc,
surSuppression,
}: {
doc: DocumentDto;
surSuppression?: (id: string) => void;
}) {
return (
<div className="doc">
<ApercuVignette doc={doc} />

View File

@@ -82,6 +82,13 @@ export const IcoStatistiques = () => (
</svg>
);
export const IcoAssistant = () => (
<svg {...base}>
<path d="M12 3l1.8 4.7L18.5 9l-4.7 1.8L12 15.5l-1.8-4.7L5.5 9l4.7-1.3z" />
<path d="M18.5 15l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9z" />
</svg>
);
export const IcoPersonnes = () => (
<svg {...base}>
<circle cx="9" cy="8" r="3.5" />

View File

@@ -13,6 +13,7 @@ import { api } from '@/api/client';
import { usePermissions } from '@/auth/use-permissions';
import {
IcoAscenseurs,
IcoAssistant,
IcoCategories,
IcoDemandes,
IcoFichiers,
@@ -75,6 +76,7 @@ const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
groupe: 'Pilotage',
liens: [
{ libelle: 'Statistiques', icone: IcoStatistiques, route: '/statistiques', permission: ['ANALYTICS', 'view'] },
{ libelle: 'Assistant', icone: IcoAssistant, route: '/assistant', permission: ['WORK_ORDERS', 'view'] },
{ libelle: 'Personnes', icone: IcoPersonnes, route: '/personnes', permission: ['PEOPLE_TEAMS', 'view'] },
],
},

View File

@@ -0,0 +1,166 @@
import { useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { AssistantAnswer, AssistantExcerpt } from '@siop/shared';
import { useAskAssistant } from '@/api/assistant';
import { ouvrirDocument } from '@/api/gestion';
import { usePermissions } from '@/auth/use-permissions';
import { Button } from '@/components/ui/button';
/** Écrans 1-2 des maquettes R5 : chat « sourcé ou silencieux » (D2).
* Chaque réponse cite ses extraits EXACTS ; sans source au-dessus du seuil,
* le refus est honnête et chiffré (ce qui a été cherché). L'avertissement
* « l'IA propose, vous validez » est permanent, pas un disclaimer caché. */
interface Echange {
question: string;
reponse?: AssistantAnswer;
erreur?: string;
}
function Source({ extrait, no }: { extrait: AssistantExcerpt; no: number }) {
const navigate = useNavigate();
const ouvrir = () => {
if (extrait.sourceType === 'DOCUMENT' && extrait.documentId) {
void ouvrirDocument(extrait.documentId);
} else if (extrait.workOrderId) {
navigate(`/ot/${extrait.workOrderId}`);
}
};
return (
<div className="source">
<span className="no">{no}</span>
<div>
<b>{extrait.title}</b>
<div className="ou">
{extrait.sourceType === 'DOCUMENT' ? 'Bibliothèque' : 'Historique'} · {extrait.locator}
</div>
<div className="extrait">« {extrait.content} »</div>
</div>
<button type="button" className="ouvrir" onClick={ouvrir}>
{extrait.sourceType === 'DOCUMENT' ? 'Ouvrir' : "Ouvrir l'OT"}
</button>
</div>
);
}
function Reponse({ reponse, surReformuler }: { reponse: AssistantAnswer; surReformuler: () => void }) {
const { can } = usePermissions();
if (reponse.mode === 'REFUSAL') {
return (
<div className="refus">
<b>Je ne trouve pas de source fiable dans votre bibliothèque je préfère ne pas inventer.</b>
<div className="pourquoi">
J'ai cherché dans {reponse.corpus.documents} document
{reponse.corpus.documents > 1 ? 's' : ''} indexé
{reponse.corpus.documents > 1 ? 's' : ''} et {reponse.corpus.reports} bilan
{reponse.corpus.reports > 1 ? 's' : ''} d'intervention : rien d'assez proche de votre
question.
</div>
<div className="actions-sug">
{can('ASSETS', 'edit') ? (
<Link to="/bibliotheque" className="btn prim">
Téléverser la notice dans la bibliothèque
</Link>
) : null}
<Button onClick={surReformuler}>Reformuler ma question</Button>
</div>
</div>
);
}
return (
<div className="msg-r">
{reponse.mode === 'GENERATED' && reponse.answer ? (
<p style={{ whiteSpace: 'pre-wrap' }}>{reponse.answer}</p>
) : (
<p>
Voici ce que portent vos sources — les extraits sont cités tels quels
{reponse.excerpts.map((_, i) => (
<span key={i} className="cite">{i + 1}</span>
))}
.
</p>
)}
<div className="sources">
{reponse.excerpts.map((extrait, i) => (
<Source key={`${extrait.locator}-${i}`} extrait={extrait} no={i + 1} />
))}
</div>
<div className="avert">⚠ L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil.</div>
</div>
);
}
export default function PageAssistant() {
const ask = useAskAssistant();
const [question, setQuestion] = useState('');
const [echanges, setEchanges] = useState<Echange[]>([]);
const saisieRef = useRef<HTMLInputElement>(null);
const envoyer = () => {
const q = question.trim();
if (q.length < 3 || ask.isPending) return;
setQuestion('');
setEchanges((liste) => [...liste, { question: q }]);
ask.mutate(
{ question: q },
{
onSuccess: (reponse) =>
setEchanges((liste) =>
liste.map((e, i) => (i === liste.length - 1 ? { ...e, reponse } : e)),
),
onError: (erreur) =>
setEchanges((liste) =>
liste.map((e, i) => (i === liste.length - 1 ? { ...e, erreur: erreur.message } : e)),
),
},
);
};
return (
<>
<div className="entete-page">
<h1>Assistant</h1>
<span className="filajout">répond UNIQUEMENT depuis votre bibliothèque et vos historiques</span>
</div>
<div className="chat" aria-live="polite">
{echanges.length === 0 ? (
<div className="carte" style={{ color: 'var(--encre-2)', maxWidth: 760 }}>
Posez une question sur vos notices, vos historiques d'intervention ou vos procédures —
chaque réponse cite ses sources (document et page, ou bilan d'OT). Quand le corpus ne
porte pas la réponse, l'assistant le dit au lieu d'inventer.
</div>
) : null}
{echanges.map((echange, i) => (
<div key={i} style={{ display: 'contents' }}>
<div className="msg-q">{echange.question}</div>
{echange.reponse ? (
<Reponse reponse={echange.reponse} surReformuler={() => saisieRef.current?.focus()} />
) : echange.erreur ? (
<div className="refus" role="alert">
<b>{echange.erreur}</b>
</div>
) : (
<div className="msg-r" style={{ color: 'var(--encre-3)' }}>Recherche dans le corpus</div>
)}
</div>
))}
</div>
<div className="saisie-chat">
<input
ref={saisieRef}
value={question}
maxLength={500}
placeholder="Poser une question (notices, historiques, procédures)…"
aria-label="Poser une question"
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') envoyer();
}}
/>
<Button variant="prim" disabled={question.trim().length < 3 || ask.isPending} onClick={envoyer}>
Envoyer
</Button>
</div>
</>
);
}

View File

@@ -5,17 +5,26 @@ import {
DOCUMENT_MAX_BYTES,
type DocumentKind,
} from '@siop/shared';
import { useDeleteDocument, useDocuments, useUploadDocument } from '@/api/gestion';
import { useReindexAssistant } from '@/api/assistant';
import {
ouvrirDocument,
telechargerDocument,
useDeleteDocument,
useDocuments,
useUploadDocument,
} from '@/api/gestion';
import { useAssetOptions } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { tailleLisible, VignetteDoc } from '@/components/carte-documents';
import { InterrupteurCorpus, StatutCorpus, tailleLisible } from '@/components/carte-documents';
import { Button } from '@/components/ui/button';
import { Modale } from '@/components/ui/modale';
/** Écran 7 des maquettes R3 : notices, certificats, photos — toujours
* RATTACHÉS (appareil ou OT). Ces documents nourriront le RAG en R5. */
/** Écran 7 des maquettes R3, devenu écran 5 de R5 : la bibliothèque EST le
* corpus de l'assistant — statut d'indexation visible, interrupteur
* d'exclusion réversible, réindexation par geste explicite (D3). */
export default function PageBibliotheque() {
const { can } = usePermissions();
const reindex = useReindexAssistant();
const [kind, setKind] = useState<DocumentKind | ''>('');
const [assetId, setAssetId] = useState('');
const { data: options } = useAssetOptions();
@@ -29,7 +38,9 @@ export default function PageBibliotheque() {
const [survol, setSurvol] = useState(false);
const totalOctets = (documents ?? []).reduce((s, d) => s + d.size, 0);
const indexes = (documents ?? []).filter((d) => d.indexedAt && d.inCorpus).length;
const peutEditer = can('WORK_ORDERS', 'edit') || can('ASSETS', 'edit');
const administreCorpus = can('ASSETS', 'edit');
const surDepot = (e: DragEvent) => {
e.preventDefault();
@@ -43,16 +54,38 @@ export default function PageBibliotheque() {
return (
<>
<div className="entete-page">
<h1>Bibliothèque</h1>
<h1>Bibliothèque corpus de l'assistant</h1>
<span className="filajout">
{documents?.length ?? 0} documents · {tailleLisible(totalOctets)}
{documents?.length ?? 0} document{(documents?.length ?? 0) > 1 ? 's' : ''} · {indexes}{' '}
indexé{indexes > 1 ? 's' : ''} · {tailleLisible(totalOctets)}
</span>
<div className="actions">
{administreCorpus ? (
<Button disabled={reindex.isPending} onClick={() => reindex.mutate()}>
{reindex.isPending ? 'Réindexation' : 'Réindexer tout'}
</Button>
) : null}
{peutEditer ? (
<Button variant="prim" onClick={() => setModale(true)}>Téléverser</Button>
) : null}
</div>
</div>
<div className="avert">
🛡 Loi 09-08 — anonymisation à l'ingestion : noms, téléphones et e-mails des personnes ne
sont JAMAIS envoyés dans les index ni aux modèles.
</div>
{reindex.isSuccess ? (
<div className="carte" style={{ color: 'var(--encre-2)', fontSize: 13 }}>
Réindexation terminée : {reindex.data.documentsIndexed} document
{reindex.data.documentsIndexed > 1 ? 's' : ''} indexé
{reindex.data.documentsIndexed > 1 ? 's' : ''}, {reindex.data.documentsSkipped} ignoré
{reindex.data.documentsSkipped > 1 ? 's' : ''} (exclus ou non indexables),{' '}
{reindex.data.reportsIndexed} bilans, {reindex.data.chunks} extraits.
</div>
) : null}
{reindex.isError ? (
<p className="erreur-form" role="alert">{reindex.error.message}</p>
) : null}
<div className="filtres">
<select
aria-label="Filtrer par type"
@@ -94,21 +127,88 @@ export default function PageBibliotheque() {
</button>
) : null}
{documents?.length ? (
<div className="docs">
{documents.map((d) => (
<VignetteDoc
key={d.id}
doc={d}
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
/>
))}
<div className="carte" style={{ padding: 0 }}>
<div className="table">
<table>
<thead>
<tr>
<th>Document</th>
<th>Rattaché à</th>
<th>Indexation</th>
{administreCorpus ? <th>Corpus</th> : null}
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{documents.map((d) => (
<tr key={d.id}>
<td>
<b>{d.fileName}</b>
<span className="sous">
{DOCUMENT_KIND_LABELS[d.kind]} · {tailleLisible(d.size)} ·{' '}
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(
new Date(d.createdAt),
)}
{d.uploadedByName ? ` · ${d.uploadedByName}` : ''}
</span>
</td>
<td>
{d.assetReference ? `Asc. ${d.assetReference}` : null}
{d.assetReference && d.workOrderReference ? ' · ' : null}
{d.workOrderReference ?? null}
{!d.assetReference && !d.workOrderReference ? '—' : null}
</td>
<td>
<StatutCorpus doc={d} />
</td>
{administreCorpus ? (
<td>
<InterrupteurCorpus doc={d} />
</td>
) : null}
<td style={{ whiteSpace: 'nowrap', textAlign: 'right' }}>
<span style={{ display: 'inline-flex', gap: 10 }}>
<button
type="button"
onClick={() => void ouvrirDocument(d.id)}
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
>
Ouvrir
</button>
<button
type="button"
onClick={() => void telechargerDocument(d.id, d.fileName)}
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
>
Télécharger
</button>
{peutEditer ? (
<button
type="button"
onClick={() => {
if (window.confirm(`Supprimer « ${d.fileName} » ?`)) {
suppression.mutate(d.id);
}
}}
style={{ color: 'var(--danger)', fontWeight: 600, fontSize: 12.5 }}
>
Supprimer
</button>
) : null}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className="carte" style={{ color: 'var(--encre-2)' }}>Aucun document.</div>
)}
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
Ces documents nourriront l'assistant RAG en R5 (réponses citant leurs sources) —
le rattachement propre commence ici.
Le corpus de l'assistant, c'est cette bibliothèque (PDF indexés) plus les bilans
d'intervention codés — rien d'externe. Les réponses citent leurs sources.
</div>
{peutEditer ? (
<ModaleTeleversement

View File

@@ -5,9 +5,11 @@ import {
REQUIRED_BILAN_FIELDS,
WORK_ORDER_TYPE_LABELS,
type BilanField,
type BilanSuggestion,
type WorkOrderDetail,
type WorkOrderStatus,
} from '@siop/shared';
import { useSuggestBilan } from '@/api/assistant';
import {
useCommentWorkOrder,
usePatchChecklist,
@@ -478,10 +480,108 @@ const CHAMPS_BILAN: { champ: BilanField; cle: keyof NonNullable<WorkOrderDetail[
{ champ: 'COMPONENT_CONCERNED', cle: 'componentConcerned', dto: 'componentConcernedId' },
];
/** Écran 3 des maquettes R5 : décrire la panne en français libre → l'IA
* propose des codes EXISTANTS avec justification et confiance (D1). Rien ne
* s'écrit sans le geste humain : « Appliquer » est ce geste — chaque champ
* appliqué garde son liseré « suggéré » jusqu'à modification manuelle. */
function ZoneSuggestion({
surAppliquer,
appliqueTout,
}: {
surAppliquer: (s: BilanSuggestion) => void;
appliqueTout: (liste: BilanSuggestion[]) => void;
}) {
const suggerer = useSuggestBilan();
const [description, setDescription] = useState('');
const [masquees, setMasquees] = useState(false);
const suggestions = masquees ? [] : (suggerer.data?.suggestions ?? []);
return (
<>
<div className="champ-b" style={{ gridColumn: '1 / -1' }}>
<label htmlFor="sug-description">Décrire pour suggérer (optionnel)</label>
<textarea
id="sug-description"
className="zone-libre"
placeholder="Décrivez la panne et ce que vous avez fait — l'IA proposera les codes du bilan…"
value={description}
maxLength={2000}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="actions-sug">
<Button
variant="prim"
disabled={description.trim().length < 10 || suggerer.isPending}
onClick={() => {
setMasquees(false);
suggerer.mutate({ description: description.trim() });
}}
>
{suggerer.isPending ? 'Analyse…' : '✨ Suggérer les codes'}
</Button>
<span style={{ fontSize: 11.5, color: 'var(--encre-3)', alignSelf: 'center' }}>
La description n'écrit rien toute seule — vous appliquez, ou pas.
</span>
</div>
{suggerer.isError ? (
<p className="erreur-form" role="alert">{suggerer.error.message}</p>
) : null}
{suggerer.isSuccess && suggestions.length === 0 && !masquees ? (
<p style={{ fontSize: 12.5, color: 'var(--encre-2)' }}>
Aucun code assez proche de cette description — l'IA ne devine pas : choisissez dans les
sélecteurs.
</p>
) : null}
{suggestions.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
{suggestions.map((s) => (
<div className="suggestion" key={s.field}>
<span className="ia"></span>
<div>
<b>
{BILAN_FIELD_LABELS[s.field]} « {s.label} »
</b>
<div className="just">
{s.similarReports > 0
? `${s.similarReports} bilan${s.similarReports > 1 ? 's' : ''} similaire${s.similarReports > 1 ? 's' : ''} sur ce parc`
: 'proche de votre description'}
</div>
</div>
<span className="confiance">
confiance {s.confidence === 'HIGH' ? 'forte' : 'moyenne'}
</span>
<Button onClick={() => surAppliquer(s)}>Appliquer</Button>
</div>
))}
<div className="actions-sug">
<Button variant="prim" onClick={() => appliqueTout(suggestions)}>
Appliquer les {suggestions.length} (pré-remplir)
</Button>
<Button onClick={() => setMasquees(true)}>Ignorer</Button>
</div>
</div>
) : null}
</div>
</>
);
}
function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
const { data: valeurs } = useReferenceValues();
const maj = useUpsertReport(ot.id);
const bloqueurBilan = ot.closureBlockers.find((b) => b.includes('bilan'));
const [suggeres, setSuggeres] = useState<Set<BilanField>>(new Set());
const modifiable = peutEditer && ot.status !== 'DONE' && ot.status !== 'CANCELLED';
const appliquer = (liste: BilanSuggestion[]) => {
const corps = Object.fromEntries(
liste.map((s) => [CHAMPS_BILAN.find((c) => c.champ === s.field)!.dto, s.valueId]),
);
maj.mutate(corps, {
onSuccess: () =>
setSuggeres((avant) => new Set([...avant, ...liste.map((s) => s.field)])),
});
};
return (
<div className="carte">
@@ -491,13 +591,19 @@ function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boole
— requis pour clôturer
</span>
</h2>
{modifiable ? (
<ZoneSuggestion
surAppliquer={(s) => appliquer([s])}
appliqueTout={appliquer}
/>
) : null}
<div className="bilan">
{CHAMPS_BILAN.map(({ champ, cle, dto }) => {
const options = (valeurs ?? []).filter((v) => v.field === champ && v.isActive);
const valeur = ot.report?.[cle as 'doorState'] ?? null;
const requis = REQUIRED_BILAN_FIELDS.includes(champ);
return (
<div className="champ-b" key={champ}>
<div className="champ-b" key={champ} data-suggere={suggeres.has(champ) || undefined}>
<label htmlFor={`bilan-${champ}`}>
{BILAN_FIELD_LABELS[champ]} {requis ? <em>*</em> : null}
</label>
@@ -505,7 +611,15 @@ function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boole
id={`bilan-${champ}`}
disabled={!peutEditer || maj.isPending || ot.status === 'DONE' || ot.status === 'CANCELLED'}
value={valeur?.id ?? ''}
onChange={(e) => maj.mutate({ [dto]: e.target.value || null })}
onChange={(e) => {
// choix manuel : le liseré « suggéré » n'a plus lieu d'être
setSuggeres((avant) => {
const suite = new Set(avant);
suite.delete(champ);
return suite;
});
maj.mutate({ [dto]: e.target.value || null });
}}
>
<option value="">Sélectionner…</option>
{options.map((o) => (

View File

@@ -1016,3 +1016,91 @@ table {
border: 1px solid var(--bordure);
margin-bottom: 4px;
}
/* ═══ R5 · Assistant (chat sourcé — maquette-r5, écrans 1-2) ═══ */
.chat { display: flex; flex-direction: column; gap: 12px; max-width: 760px; }
.msg-q {
align-self: flex-end; background: var(--primaire); color: #fff;
border-radius: 14px 14px 4px 14px; padding: 10px 14px; max-width: 75%; font-size: 13.5px;
}
.msg-r {
background: var(--surface); border: 1px solid var(--bordure);
border-radius: 14px 14px 14px 4px; padding: 12px 14px; max-width: 88%;
font-size: 13.5px; display: flex; flex-direction: column; gap: 10px;
}
.msg-r p b { color: var(--encre); }
.cite {
display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px;
border-radius: 5px; background: var(--primaire-doux); color: var(--primaire);
font-size: 10.5px; font-weight: 800; vertical-align: 2px; margin: 0 1px;
}
.sources {
display: flex; flex-direction: column; gap: 6px;
border-top: 1px dashed var(--bordure); padding-top: 10px;
}
.source {
display: flex; gap: 10px; align-items: flex-start; background: var(--surface-2);
border-radius: 9px; padding: 8px 10px;
}
.source .no {
flex: none; width: 18px; height: 18px; border-radius: 5px; background: var(--primaire-doux);
color: var(--primaire); display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 800;
}
.source b { font-size: 12.5px; }
.source .ou { color: var(--encre-2); font-size: 11.5px; }
.source .extrait {
color: var(--encre-2); font-size: 12px; font-style: italic;
border-left: 2px solid var(--safran); padding-left: 8px; margin-top: 3px;
}
.source .ouvrir {
margin-left: auto; color: var(--primaire); font-weight: 700; font-size: 12px; white-space: nowrap;
}
.avert {
display: flex; gap: 8px; align-items: center; background: var(--safran-doux); color: var(--alerte);
border-radius: 9px; padding: 8px 10px; font-size: 12px; font-weight: 600;
}
.saisie-chat { display: flex; gap: 8px; max-width: 760px; }
.saisie-chat input {
flex: 1; border: 1.5px solid var(--bordure-forte); border-radius: 10px;
background: var(--surface); padding: 11px 13px; font: inherit; color: var(--encre);
}
.refus {
background: var(--surface); border: 1.5px dashed var(--bordure-forte); border-radius: 14px;
padding: 12px 14px; max-width: 88%; font-size: 13.5px;
display: flex; flex-direction: column; gap: 8px;
}
.refus b { color: var(--encre); }
.refus .pourquoi { color: var(--encre-2); font-size: 12.5px; }
/* ═══ R5 · Suggestion de bilan (écran 3) ═══ */
.zone-libre {
width: 100%; min-height: 74px; border: 1.5px solid var(--bordure-forte); border-radius: 10px;
background: var(--surface); padding: 10px 12px; font: inherit; font-size: 13px; color: var(--encre);
}
.suggestion {
display: flex; gap: 10px; align-items: flex-start; border: 1.5px solid var(--primaire);
background: var(--primaire-doux); border-radius: 10px; padding: 10px 12px;
}
.suggestion .ia { flex: none; font-size: 15px; }
.suggestion b { font-size: 13px; }
.suggestion .just { color: var(--encre-2); font-size: 12px; }
.confiance {
margin-left: auto; font-size: 10.5px; font-weight: 800; color: var(--primaire); white-space: nowrap;
}
.actions-sug { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
/* Liseré « suggéré » : la valeur vient d'une suggestion APPLIQUÉE par l'humain */
.champ-b[data-suggere] { position: relative; }
.champ-b[data-suggere] select { border-color: var(--primaire); background: var(--primaire-doux); }
.champ-b[data-suggere]::after {
content: 'suggéré'; position: absolute; top: 14px; right: 8px; font-size: 9px; font-weight: 800;
color: var(--primaire); background: var(--surface); padding: 0 5px; border-radius: 99px;
border: 1px solid var(--primaire); pointer-events: none;
}
/* ═══ R5 · Corpus (écran 5) ═══ */
.st.ok { color: var(--st-termine); }
.st.ok::before { background: var(--st-termine); }
.st.exclu { color: var(--encre-3); }
.st.exclu::before { background: var(--encre-3); }
.interrupteur.corpus[aria-checked='true'] { background: var(--succes); }

View File

@@ -0,0 +1,576 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SIOP V2 — Maquettes HD · R5 IA</title>
</head>
<body>
<!--
SIOP V2 — Maquettes HD de la release R5 « IA » (design-first).
À VALIDER PAR LE RÉFÉRENT AVANT TOUT CODE `apps/ai` (principe n°1).
Deux capacités (cadrage) : assistant RAG qui CITE SES SOURCES, et
suggestion de codes de bilan depuis une description libre. Voix : si
budget temps, avec purge de l'audio (loi 09-08).
Principe permanent : L'IA PROPOSE, L'HUMAIN VALIDE — aucune écriture
automatique. Tokens répliqués de ../tokens.css, bi-thème.
-->
<style>
/* ═══ Tokens (répliques de ../tokens.css) ═══ */
:root{
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
--safran:#dd8a0b;--safran-doux:#fdf3e3;
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
--police-ui:"Manrope","Avenir Next","Segoe UI Variable",system-ui,sans-serif;
--rayon:10px;--rayon-controle:8px;--ombre-menu:0 8px 24px rgb(16 30 54/.14);
}
@media (prefers-color-scheme:dark){:root{
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
--safran:#e89a1f;--safran-doux:#33270f;
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
--bordure:#2b3850;--bordure-forte:#3d4d6b;
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
}}
:root[data-theme="light"]{
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
--safran:#dd8a0b;--safran-doux:#fdf3e3;
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
--ombre-menu:0 8px 24px rgb(16 30 54/.14);
}
:root[data-theme="dark"]{
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
--safran:#e89a1f;--safran-doux:#33270f;
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
--bordure:#2b3850;--bordure-forte:#3d4d6b;
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
}
/* ═══ Base ═══ */
*{box-sizing:border-box;margin:0}
html,body{background:var(--fond)}
body{font-family:var(--police-ui);color:var(--encre);font-size:14px;line-height:1.5}
button{font:inherit;cursor:pointer;border:0;background:none;color:inherit}
button:focus-visible,a:focus-visible{outline:2px solid var(--primaire);outline-offset:2px;border-radius:4px}
.num{font-variant-numeric:tabular-nums}
/* ═══ Barre de revue ═══ */
.revue{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:12px;flex-wrap:wrap;
padding:10px 16px;background:var(--nav-fond);color:var(--nav-encre);font-size:13px}
.revue b{color:var(--nav-encre-vif);letter-spacing:.02em}
.revue .onglets{display:flex;gap:4px;flex-wrap:wrap}
.revue .onglets button{padding:6px 10px;border-radius:6px;color:var(--nav-encre)}
.revue .onglets button:hover{background:rgb(255 255 255/.08);color:var(--nav-encre-vif)}
.revue .onglets button[aria-selected="true"]{background:rgb(255 255 255/.12);color:var(--nav-encre-vif);
box-shadow:inset 3px 0 0 var(--safran)}
.revue .droite{margin-left:auto}
.revue .theme{padding:6px 10px;border:1px solid var(--nav-bordure);border-radius:6px}
.note-ecran{max-width:1180px;margin:14px auto 0;padding:0 20px;color:var(--encre-2);font-size:13px}
.note-ecran b{color:var(--encre)}
/* ═══ Coquille web (simplifiée — la vraie est celle des releases passées) ═══ */
.ecran{display:none;max-width:1180px;margin:14px auto 44px;padding:0 20px}
.ecran.actif{display:block}
.app{display:grid;grid-template-columns:210px 1fr;min-height:680px;border:1px solid var(--bordure);
border-radius:14px;overflow:hidden;background:var(--fond)}
.nav{background:var(--nav-fond);color:var(--nav-encre);padding:16px 0;font-size:13px}
.nav .logo{display:flex;align-items:center;gap:8px;padding:0 16px 14px;color:var(--nav-encre-vif);
font-weight:800;font-size:17px;letter-spacing:.04em}
.nav .logo i{color:var(--safran);font-style:normal}
.nav .groupe{padding:10px 16px 3px;font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:#6b7f9e}
.nav a{display:block;padding:7px 16px;color:var(--nav-encre);text-decoration:none;border-left:3px solid transparent}
.nav a.actif{background:rgb(255 255 255/.09);color:var(--nav-encre-vif);border-left-color:var(--safran)}
.contenu{padding:18px 20px;display:flex;flex-direction:column;gap:12px}
.entete{display:flex;align-items:center;gap:10px}
.entete h2{font-size:18px}
.entete .fin{margin-left:auto;font-size:12px;color:var(--encre-3)}
.carte{background:var(--surface);border:1px solid var(--bordure);border-radius:var(--rayon);padding:14px}
.carte h3{font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--encre-2);margin-bottom:8px}
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 13px;border-radius:var(--rayon-controle);
border:1px solid var(--bordure-forte);font-weight:600;font-size:13px;background:var(--surface)}
.btn.prim{background:var(--primaire);border-color:var(--primaire);color:#fff}
.btn.ghost{border-color:transparent;color:var(--primaire)}
/* ═══ Assistant (chat sourcé) ═══ */
.chat{display:flex;flex-direction:column;gap:12px;max-width:760px}
.msg-q{align-self:flex-end;background:var(--primaire);color:#fff;border-radius:14px 14px 4px 14px;
padding:10px 14px;max-width:75%;font-size:13.5px}
.msg-r{background:var(--surface);border:1px solid var(--bordure);border-radius:14px 14px 14px 4px;
padding:12px 14px;max-width:88%;font-size:13.5px;display:flex;flex-direction:column;gap:10px}
.msg-r p b{color:var(--encre)}
.cite{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;
border-radius:5px;background:var(--primaire-doux);color:var(--primaire);font-size:10.5px;font-weight:800;
vertical-align:2px;margin:0 1px;cursor:pointer}
.sources{display:flex;flex-direction:column;gap:6px;border-top:1px dashed var(--bordure);padding-top:10px}
.source{display:flex;gap:10px;align-items:flex-start;background:var(--surface-2);border-radius:9px;padding:8px 10px}
.source .no{flex:none;width:18px;height:18px;border-radius:5px;background:var(--primaire-doux);
color:var(--primaire);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800}
.source b{font-size:12.5px}
.source .ou{color:var(--encre-2);font-size:11.5px}
.source .extrait{color:var(--encre-2);font-size:12px;font-style:italic;border-left:2px solid var(--safran);
padding-left:8px;margin-top:3px}
.source .ouvrir{margin-left:auto;color:var(--primaire);font-weight:700;font-size:12px;white-space:nowrap}
.avert{display:flex;gap:8px;align-items:center;background:var(--safran-doux);color:var(--alerte);
border-radius:9px;padding:8px 10px;font-size:12px;font-weight:600}
.saisie-chat{display:flex;gap:8px;max-width:760px}
.saisie-chat input{flex:1;border:1.5px solid var(--bordure-forte);border-radius:10px;background:var(--surface);
padding:11px 13px;font:inherit;color:var(--encre)}
.refus{background:var(--surface);border:1.5px dashed var(--bordure-forte);border-radius:14px;
padding:12px 14px;max-width:88%;font-size:13.5px;display:flex;flex-direction:column;gap:8px}
.refus b{color:var(--encre)}
.refus .pourquoi{color:var(--encre-2);font-size:12.5px}
/* ═══ Suggestion de bilan ═══ */
.grille-2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.zone-libre{width:100%;min-height:74px;border:1.5px solid var(--bordure-forte);border-radius:10px;
background:var(--surface);padding:10px 12px;font:inherit;font-size:13px;color:var(--encre)}
.suggestion{display:flex;gap:10px;align-items:flex-start;border:1.5px solid var(--primaire);
background:var(--primaire-doux);border-radius:10px;padding:10px 12px}
.suggestion .ia{flex:none;font-size:15px}
.suggestion b{font-size:13px}
.suggestion .just{color:var(--encre-2);font-size:12px}
.confiance{margin-left:auto;font-size:10.5px;font-weight:800;color:var(--primaire);white-space:nowrap}
.actions-sug{display:flex;gap:8px;margin-top:4px}
.champ{display:flex;flex-direction:column;gap:4px;font-size:12px}
.champ label{font-weight:700;color:var(--encre-2);font-size:11px}
.champ label em{color:var(--danger);font-style:normal}
.select{border:1.5px solid var(--bordure-forte);border-radius:9px;background:var(--surface);
padding:8px 10px;font-size:13px;color:var(--encre)}
.select.pre{border-color:var(--primaire);background:var(--primaire-doux);position:relative}
.select.pre::after{content:'suggéré';position:absolute;top:-8px;right:8px;font-size:9px;font-weight:800;
color:var(--primaire);background:var(--surface);padding:0 5px;border-radius:99px;border:1px solid var(--primaire)}
.select.vide{color:var(--encre-3)}
/* ═══ Corpus ═══ */
table{border-collapse:collapse;width:100%;font-size:13px}
th{text-align:left;font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--encre-2);
background:var(--surface-2);padding:8px 10px}
td{padding:8px 10px;border-top:1px solid var(--bordure)}
.st{font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:999px;white-space:nowrap}
.st.ok{color:var(--st-termine);background:var(--st-termine-fond)}
.st.encours{color:var(--st-ouvert);background:var(--st-ouvert-fond)}
.st.exclu{color:var(--encre-3);background:var(--surface-2)}
.interrupteur{width:34px;height:19px;border-radius:99px;background:var(--succes);position:relative;display:inline-block}
.interrupteur::after{content:'';position:absolute;top:2px;right:2px;width:15px;height:15px;border-radius:50%;background:#fff}
.interrupteur.off{background:var(--bordure-forte)}
.interrupteur.off::after{right:auto;left:2px}
/* ═══ Téléphone (repris de maquette-r4) ═══ */
.tels{display:flex;gap:28px;justify-content:center;flex-wrap:wrap}
.tel{width:310px;border:1px solid var(--bordure-forte);border-radius:28px;overflow:hidden;background:var(--fond);
box-shadow:var(--ombre-menu);display:flex;flex-direction:column}
.tel .encoche{height:26px;background:var(--nav-fond);display:flex;justify-content:center;align-items:center}
.tel .encoche i{width:70px;height:7px;border-radius:999px;background:rgb(255 255 255/.18)}
.tel .legende{text-align:center;font-size:11.5px;font-weight:700;letter-spacing:.05em;
text-transform:uppercase;color:var(--encre-3);padding:8px;background:var(--surface-2)}
.ecran-tel{padding:14px;display:flex;flex-direction:column;gap:10px;min-height:540px}
.entete-tel{display:flex;align-items:center;gap:8px;font-weight:800;font-size:15px}
.entete-tel i{color:var(--safran);font-style:normal}
.carte-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:12px;padding:12px;
display:flex;flex-direction:column;gap:8px;font-size:12.5px}
.carte-tel h4{font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--encre-2)}
.btn-tel{display:flex;justify-content:center;align-items:center;gap:8px;padding:11px;border-radius:10px;
font-weight:700;font-size:13.5px;background:var(--primaire);color:#fff}
.btn-tel.contour{background:var(--surface);color:var(--primaire);border:1.5px solid var(--primaire)}
.chip-sug{display:inline-flex;align-items:center;gap:5px;border:1.5px solid var(--primaire);
background:var(--primaire-doux);color:var(--primaire);border-radius:999px;padding:4px 10px;
font-size:11.5px;font-weight:700}
.micro{width:52px;height:52px;border-radius:50%;background:var(--prio-bloque);color:#fff;display:flex;
align-items:center;justify-content:center;font-size:20px;align-self:center}
.onde{display:flex;gap:3px;align-items:center;justify-content:center;height:26px}
.onde i{width:3px;border-radius:2px;background:var(--primaire)}
</style>
<header class="revue">
<b>SIOP · Maquettes R5 — IA</b>
<nav class="onglets" role="tablist" aria-label="Écrans">
<button role="tab" aria-selected="true" data-cible="assistant">1 · Assistant sourcé</button>
<button role="tab" aria-selected="false" data-cible="sans-source">2 · Sans source = refus</button>
<button role="tab" aria-selected="false" data-cible="suggestion-bilan">3 · Suggestion de bilan (web)</button>
<button role="tab" aria-selected="false" data-cible="suggestion-mobile">4 · Suggestion (mobile)</button>
<button role="tab" aria-selected="false" data-cible="corpus">5 · Corpus & ingestion</button>
<button role="tab" aria-selected="false" data-cible="voix">6 · Voix (option)</button>
</nav>
<div class="droite"><button class="theme" id="btn-theme">Thème : auto</button></div>
</header>
<!-- ═══ 1 · ASSISTANT SOURCÉ ═══ -->
<p class="note-ecran" data-note="assistant"><b>Intention :</b> la recette type du cadrage, à l'écran.
Chaque affirmation porte ses <b>citations numérotées</b> ; chaque source montre le document (bibliothèque
R3), la page et l'<b>extrait exact</b>, avec « Ouvrir » vers le document. L'avertissement « l'IA propose »
est permanent, pas un disclaimer caché. L'assistant vit dans la sidebar (groupe Pilotage).</p>
<section class="ecran actif" id="assistant">
<div class="app">
<aside class="nav">
<div class="logo"><i></i> SIOP</div>
<div class="groupe">Pilotage</div>
<a href="#">Statistiques</a>
<a class="actif" href="#">Assistant</a>
<a href="#">Personnes</a>
</aside>
<div class="contenu">
<div class="entete">
<h2>Assistant</h2>
<span class="fin">répond UNIQUEMENT depuis votre bibliothèque et vos historiques</span>
</div>
<div class="chat">
<div class="msg-q">Quel couple de serrage pour les coulisseaux de guides du Gen2 ?</div>
<div class="msg-r">
<p>Pour l'Otis <b>Gen2 Premier</b>, la notice prescrit un couple de serrage de
<b>25 N·m</b> pour les vis de fixation des coulisseaux de guides
<span class="cite">1</span>, avec contrôle du jeu latéral à 0,5 mm maximum
<span class="cite">1</span>. Sur votre parc, ce réglage a été appliqué lors du
remplacement des coulisseaux de l'ascenseur A1 le 17 juillet
<span class="cite">2</span>.</p>
<div class="sources">
<div class="source">
<span class="no">1</span>
<div>
<b>Notice Otis Gen2 Premier.pdf</b>
<div class="ou">Bibliothèque · Asc. A1 · page 42, § « Guidage cabine »</div>
<div class="extrait">« Serrer les vis de fixation des coulisseaux au couple de 25 N·m ;
jeu latéral maximal admissible : 0,5 mm. »</div>
</div>
<a class="ouvrir" href="#">Ouvrir p. 42</a>
</div>
<div class="source">
<span class="no">2</span>
<div>
<b>OT-2026-0341 — Bruit anormal en gaine</b>
<div class="ou">Historique · clôturé le 17/07/2026 · bilan : Guides / Remplacement pièce</div>
<div class="extrait">« Coulisseau de guide 16 mm × 2 — frottement guides, remplacement effectué. »</div>
</div>
<a class="ouvrir" href="#">Ouvrir l'OT</a>
</div>
</div>
<div class="avert">⚠ L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil.</div>
</div>
</div>
<div class="saisie-chat">
<input placeholder="Poser une question (notices, historiques, procédures)…">
<button class="btn prim">Envoyer</button>
</div>
</div>
</div>
</section>
<!-- ═══ 2 · SANS SOURCE = REFUS ═══ -->
<p class="note-ecran" data-note="sans-source" hidden><b>Intention :</b> l'anti-hallucination rendue
VISIBLE : quand le corpus ne porte pas la réponse, l'assistant le dit, montre ce qu'il a cherché, et
propose l'action utile (téléverser la notice manquante dans la bibliothèque R3). Jamais de réponse
inventée « pour faire plaisir ».</p>
<section class="ecran" id="sans-source">
<div class="app">
<aside class="nav">
<div class="logo"><i></i> SIOP</div>
<div class="groupe">Pilotage</div>
<a href="#">Statistiques</a>
<a class="actif" href="#">Assistant</a>
<a href="#">Personnes</a>
</aside>
<div class="contenu">
<div class="entete"><h2>Assistant</h2></div>
<div class="chat">
<div class="msg-q">Procédure de remplacement du variateur de fréquence du monte-charge KONE ?</div>
<div class="refus">
<b>Je ne trouve pas de source fiable dans votre bibliothèque — je préfère ne pas inventer.</b>
<div class="pourquoi">J'ai cherché dans 6 documents indexés et 214 bilans d'intervention :
aucune notice KONE, aucun historique de variateur sur le monte-charge MC-1.</div>
<div class="actions-sug">
<button class="btn prim">Téléverser la notice KONE dans la bibliothèque</button>
<button class="btn">Reformuler ma question</button>
</div>
</div>
</div>
<div class="saisie-chat">
<input placeholder="Poser une question…">
<button class="btn prim">Envoyer</button>
</div>
</div>
</div>
</section>
<!-- ═══ 3 · SUGGESTION DE BILAN (WEB) ═══ -->
<p class="note-ecran" data-note="suggestion-bilan" hidden><b>Intention :</b> le technicien (ou le
dispatcher) décrit la panne en français libre — l'IA propose les codes du bilan R2 avec sa
<b>justification</b> et sa confiance. « Appliquer » PRÉ-REMPLIT les sélecteurs (liseré « suggéré ») —
rien n'est enregistré tant que l'humain n'a pas cliqué « Enregistrer le bilan ». Les référentiels
restent la seule vérité : l'IA ne peut suggérer QUE des codes existants.</p>
<section class="ecran" id="suggestion-bilan">
<div class="app">
<aside class="nav">
<div class="logo"><i></i> SIOP</div>
<div class="groupe">Exploitation</div>
<a class="actif" href="#">Ordres de travail</a>
<a href="#">Demandes</a>
</aside>
<div class="contenu">
<div class="entete">
<h2>OT-2026-0352 — Portes qui rebondissent au 3ᵉ</h2>
<span class="fin">fiche OT · carte Bilan d'intervention</span>
</div>
<div class="carte">
<h3>Décrire pour suggérer (optionnel)</h3>
<textarea class="zone-libre">La porte cabine rebondissait à la fermeture au 3e étage, cellule encrassée, j'ai nettoyé la barrière et réglé le contact NC-31, essais OK sur 10 cycles.</textarea>
<div class="actions-sug">
<button class="btn prim">✨ Suggérer les codes</button>
<span style="font-size:11.5px;color:var(--encre-3);align-self:center">
La description reste dans l'OT — elle n'écrit rien toute seule.
</span>
</div>
</div>
<div class="carte">
<h3>3 suggestions — rien n'est enregistré sans vous</h3>
<div style="display:flex;flex-direction:column;gap:8px">
<div class="suggestion"><span class="ia"></span>
<div><b>Anomalie constatée → « Cellule/barrière encrassée »</b>
<div class="just">« cellule encrassée », « nettoyé la barrière » dans votre description</div></div>
<span class="confiance">confiance forte</span>
</div>
<div class="suggestion"><span class="ia"></span>
<div><b>Action réalisée → « Nettoyage + réglage »</b>
<div class="just">« j'ai nettoyé… et réglé le contact »</div></div>
<span class="confiance">confiance forte</span>
</div>
<div class="suggestion"><span class="ia"></span>
<div><b>Élément concerné → « Portes »</b>
<div class="just">« porte cabine », « contact NC-31 » — 9 bilans similaires sur ce parc</div></div>
<span class="confiance">confiance moyenne</span>
</div>
</div>
<div class="actions-sug">
<button class="btn prim">Appliquer les 3 (pré-remplir)</button>
<button class="btn">Ignorer</button>
</div>
</div>
<div class="carte">
<h3>Bilan d'intervention — requis pour clôturer</h3>
<div class="grille-2">
<div class="champ"><label>État des portes <em>*</em></label>
<div class="select vide">Sélectionner…</div></div>
<div class="champ"><label>Anomalie constatée</label>
<div class="select pre">Cellule/barrière encrassée</div></div>
<div class="champ"><label>Action réalisée <em>*</em></label>
<div class="select pre">Nettoyage + réglage</div></div>
<div class="champ"><label>Élément concerné <em>*</em></label>
<div class="select pre">Portes</div></div>
</div>
<div class="actions-sug" style="margin-top:10px">
<button class="btn prim">Enregistrer le bilan</button>
<span style="font-size:11.5px;color:var(--alerte);align-self:center;font-weight:600">
⚠ « État des portes » reste à choisir — l'IA ne l'a pas deviné, elle ne devine pas.
</span>
</div>
</div>
</div>
</div>
</section>
<!-- ═══ 4 · SUGGESTION (MOBILE) ═══ -->
<p class="note-ecran" data-note="suggestion-mobile" hidden><b>Intention :</b> la même capacité au pouce,
dans l'écran de clôture R4 : décrire (ou dicter, écran 6), recevoir des <b>chips suggérées</b>, les
appliquer d'un geste — les sélecteurs restent la vérité et la garde de clôture ne change pas.
Hors-ligne : la suggestion attend le réseau (l'IA est un service serveur), la clôture en file R4
fonctionne sans elle.</p>
<section class="ecran" id="suggestion-mobile">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Clôture — suggestion depuis la description</div>
<div class="ecran-tel">
<div class="entete-tel"><i></i> Clôturer OT-2026-0352</div>
<div class="carte-tel">
<h4>Décrire pour suggérer</h4>
<div style="border:1.5px solid var(--bordure-forte);border-radius:9px;background:var(--surface);padding:9px;color:var(--encre);font-size:12.5px">
Porte cabine qui rebondit, cellule encrassée, nettoyage + réglage du contact, essais OK.
</div>
<div class="btn-tel contour">✨ Suggérer les codes</div>
</div>
<div class="carte-tel">
<h4>Suggestions — appuyez pour appliquer</h4>
<div style="display:flex;flex-wrap:wrap;gap:6px">
<span class="chip-sug">✨ Anomalie : Cellule encrassée</span>
<span class="chip-sug">✨ Action : Nettoyage + réglage</span>
<span class="chip-sug">✨ Élément : Portes</span>
</div>
<div style="font-size:11px;color:var(--encre-3)">Rien ne s'enregistre sans votre geste —
les chips pré-remplissent les sélecteurs ci-dessous.</div>
</div>
<div class="carte-tel">
<h4>Bilan d'intervention</h4>
<div style="display:flex;flex-direction:column;gap:6px;font-size:12.5px">
<div>État des portes * : <b style="color:var(--encre-3)">à choisir</b></div>
<div>Anomalie : <b style="color:var(--primaire)">Cellule encrassée (suggéré)</b></div>
<div>Action * : <b style="color:var(--primaire)">Nettoyage + réglage (suggéré)</b></div>
<div>Élément * : <b style="color:var(--primaire)">Portes (suggéré)</b></div>
</div>
</div>
<div class="btn-tel" style="background:var(--bordure-forte)">Clôturer (bilan incomplet)</div>
</div>
</div>
</div>
</section>
<!-- ═══ 5 · CORPUS & INGESTION ═══ -->
<p class="note-ecran" data-note="corpus" hidden><b>Intention :</b> le corpus est la bibliothèque R3 +
les bilans codés — <b>rien d'externe</b>, et c'est VISIBLE : chaque document montre son statut
d'indexation, un interrupteur l'exclut du corpus, « Réindexer » est un geste explicite. Bandeau
loi 09-08 : l'anonymisation à l'ingestion est dite, pas cachée dans une CGU.</p>
<section class="ecran" id="corpus">
<div class="app">
<aside class="nav">
<div class="logo"><i></i> SIOP</div>
<div class="groupe">Ressources</div>
<a href="#">Stock & achats</a>
<a href="#">Tiers</a>
<a class="actif" href="#">Fichiers</a>
</aside>
<div class="contenu">
<div class="entete">
<h2>Bibliothèque — corpus de l'assistant</h2>
<span class="fin">6 documents · 5 indexés · 214 bilans codés inclus</span>
<button class="btn" style="margin-left:12px">Réindexer tout</button>
</div>
<div class="avert">🛡 Loi 09-08 — anonymisation à l'ingestion : noms, téléphones et e-mails des
personnes ne sont JAMAIS envoyés dans les index ni aux modèles.</div>
<div class="carte" style="padding:0">
<table>
<thead><tr><th>Document</th><th>Rattaché à</th><th>Indexation</th><th>Corpus</th></tr></thead>
<tbody>
<tr><td><b>Notice Otis Gen2 Premier.pdf</b></td><td>Asc. A1</td>
<td><span class="st ok">indexé · 17 juil. 09:12 · 38 extraits</span></td>
<td><span class="interrupteur"></span></td></tr>
<tr><td><b>Schindler 3300 — manuel.pdf</b></td><td>Asc. B1 · B2</td>
<td><span class="st ok">indexé · 17 juil. 09:12 · 51 extraits</span></td>
<td><span class="interrupteur"></span></td></tr>
<tr><td><b>Certificat parachute 2026.pdf</b></td><td>Asc. A1</td>
<td><span class="st ok">indexé · 17 juil. 09:13 · 4 extraits</span></td>
<td><span class="interrupteur"></span></td></tr>
<tr><td><b>Notice KONE MonoSpace.pdf</b></td><td>MC-1</td>
<td><span class="st encours">indexation en cours…</span></td>
<td><span class="interrupteur"></span></td></tr>
<tr><td><b>photo-guide-frottement.jpg</b></td><td>OT-2026-0341</td>
<td><span class="st exclu">image — non indexable</span></td>
<td><span class="interrupteur off"></span></td></tr>
<tr><td><b>Contrat cadre SPELEV 2026.pdf</b></td><td>Tour Atlas</td>
<td><span class="st exclu">exclu par l'admin</span></td>
<td><span class="interrupteur off"></span></td></tr>
</tbody>
</table>
</div>
<div class="carte">
<h3>Historiques inclus</h3>
<div style="font-size:13px;color:var(--encre-2)">214 bilans d'intervention codés (R2→R4),
anonymisés — c'est ce qui permet « sur votre parc, ce réglage a déjà été appliqué… ».</div>
</div>
</div>
</div>
</section>
<!-- ═══ 6 · VOIX (OPTION) ═══ -->
<p class="note-ecran" data-note="voix" hidden><b>Intention :</b> l'option « si budget temps » du
cadrage : dicter au lieu de taper, mains sales sur le palier. L'audio est transcrit puis
<b>PURGÉ immédiatement</b> (loi 09-08) — le bandeau le dit au moment même. La transcription devient la
description libre de l'écran 4 : même circuit, même validation humaine.</p>
<section class="ecran" id="voix">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Dictée — pendant l'enregistrement</div>
<div class="ecran-tel">
<div class="entete-tel"><i></i> Décrire l'intervention</div>
<div class="carte-tel" style="align-items:center;gap:12px;padding:22px 12px">
<div class="micro">🎙</div>
<div class="onde">
<i style="height:8px"></i><i style="height:18px"></i><i style="height:12px"></i>
<i style="height:22px"></i><i style="height:10px"></i><i style="height:16px"></i>
<i style="height:7px"></i><i style="height:20px"></i><i style="height:11px"></i>
</div>
<div style="font-size:13px;color:var(--encre-2);text-align:center">« …cellule encrassée,
j'ai nettoyé la barrière et réglé le contact… »</div>
<div class="btn-tel" style="background:var(--danger);width:100%">■ Terminer la dictée</div>
</div>
<div class="carte-tel">
<h4>Confidentialité (loi 09-08)</h4>
<div style="font-size:12px;color:var(--encre-2)">🛡 L'audio est transcrit puis
<b style="color:var(--encre)">supprimé immédiatement</b> — seule la transcription (que vous
relisez) est conservée, dans l'OT. Dictée <b style="color:var(--encre)">opt-in</b> :
jamais d'écoute sans ce bouton.</div>
</div>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Après — transcription relue, audio purgé</div>
<div class="ecran-tel">
<div class="entete-tel"><i></i> Décrire l'intervention</div>
<div class="carte-tel">
<h4>Transcription — relisez avant d'envoyer</h4>
<div style="border:1.5px solid var(--bordure-forte);border-radius:9px;background:var(--surface);padding:9px;font-size:12.5px">
Porte cabine qui rebondissait à la fermeture au 3ᵉ, cellule encrassée, nettoyage de la
barrière et réglage du contact NC-31, essais concluants sur dix cycles.
</div>
<div style="font-size:11px;color:var(--st-termine);font-weight:700">✓ Audio purgé (2,4 s après transcription)</div>
</div>
<div class="carte-tel">
<h4>Et ensuite</h4>
<div style="display:flex;flex-direction:column;gap:8px">
<div class="btn-tel contour">✨ Suggérer les codes depuis ce texte</div>
<div class="btn-tel">Joindre la description à l'OT</div>
</div>
</div>
</div>
</div>
</div>
</section>
<script>
(function(){
var onglets=document.querySelectorAll('.revue .onglets button');
var ecrans=document.querySelectorAll('.ecran');
var notes=document.querySelectorAll('.note-ecran');
onglets.forEach(function(b){b.addEventListener('click',function(){
onglets.forEach(function(x){x.setAttribute('aria-selected','false')});
b.setAttribute('aria-selected','true');
var cible=b.dataset.cible;
ecrans.forEach(function(e){e.classList.toggle('actif',e.id===cible)});
notes.forEach(function(n){n.hidden=(n.dataset.note!==cible)});
window.scrollTo({top:0});
})});
var ordre=['auto','light','dark'];var i=0;
var bt=document.getElementById('btn-theme');
bt.addEventListener('click',function(){
i=(i+1)%3;var m=ordre[i];
if(m==='auto'){document.documentElement.removeAttribute('data-theme')}
else{document.documentElement.setAttribute('data-theme',m)}
bt.textContent='Thème : '+(m==='auto'?'auto':(m==='light'?'clair':'sombre'));
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,60 @@
# 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.

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,152 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
---
## 2026-07-17 — Pr. Daaif (+ Claude) — Arbitrages de recette R5 rendus : le corpus passe en tableau
**Actions**
- **Arbitrage du référent** sur la revue pixel : « Ça serait mieux d'utiliser un tableau car la plupart des
fichiers vont être des PDF » — le reste validé (« Appliquer » à écriture directe, calibrage au runbook).
- **Bibliothèque en tableau**, conforme à l'écran 5 de maquette-r5 : colonnes Document (nom + type · taille ·
date · auteur), Rattaché à, Indexation, Corpus (interrupteur, visible pour ASSETS.edit), actions
Ouvrir/Télécharger/Supprimer. Les vignettes restent aux cartes « Documents » des fiches (photos d'OT).
- Détail aligné sur la maquette : une image non indexable affiche son interrupteur **éteint** quel que soit
l'état stocké — l'interrupteur montre la réalité du corpus, pas une colonne de base.
- e2e adapté (lignes de tableau), **16/16 Playwright rejoués** ; artefact de revue pixel mis à jour (même URL),
arbitrages marqués rendus.
**Décisions**
- Revue pixel R5 **validée par le référent** (1 correction appliquée). Le tag `release/r5` peut être posé.
**Prochaine étape** : tag `release/r5` sur le mot du référent. Restes : recette R4 sur téléphone (Expo Go),
redéploiement Dokploy (`release/r3` puis r5 avec `AI_SERVICE_TOKEN`), calibrage `AI_SEUIL_*` sur corpus SPELEV.
---
## 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**
- **Contrat** (76 opérations) : `Document` expose son état de corpus (`inCorpus`, `indexedAt`, `chunkCount`), `PATCH /documents/{id}/corpus` (bascule réversible, ASSETS.edit), `POST /assistant/reindex` (bilan chiffré traduit du dialecte interne). Clients web **et mobile** régénérés — le job ci-contract vérifie désormais les deux.
- **Web — page Assistant** (sidebar Pilotage, WORK_ORDERS.view) : chat fidèle aux écrans 1-2 de maquette-r5 — réponse avec **extraits exacts cités** (« Ouvrir » → PDF en onglet authentifié, OT → fiche), avertissement « l'IA propose, vous validez » permanent ; **refus honnête chiffré** (« j'ai cherché dans N documents et M bilans ») avec l'action utile (téléverser la notice).
- **Web — Bibliothèque = corpus** (écran 5) : bandeau 09-08 (l'anonymisation est DITE), statut par document (indexé · date · extraits / à indexer / exclu / image non indexable), interrupteur d'exclusion (PDF seulement), « Réindexer tout » avec bilan. **Fiche OT** (écran 3) : « Décrire pour suggérer » dans la carte Bilan — suggestions justifiées (« N bilans similaires », confiance), « Appliquer » = le geste humain qui écrit (cohérent avec la carte à enregistrement direct), **liseré « suggéré »** retiré dès qu'un choix manuel reprend la main.
- **Mobile — clôture** (écran 4) : décrire au pouce → **chips suggérées** (un appui = un champ pré-rempli, ✓), note D1 visible ; hors-ligne le bouton dit « réseau requis » (l'IA est un service serveur — la clôture en file R4 n'en dépend pas).
- **`apps/ai`** : seuils par env (`AI_SEUIL_*`) — nécessaires à la CI et au calibrage de recette.
- **CI** : le job e2e démarre `siop2-ai` (embeddeur déterministe) et le parcours R5 se recette en vrai : upload d'un **PDF généré avec xref valide** → réindexation → statut → réponse sourcée (extrait « 25 Nm », p. 1) → refus sur charabia → exclusion → suggestion appliquée sur un OT créé. **16/16 Playwright** (recettes R0→R5).
- **Vérifications réelles** : seuils CI **mesurés** (vrai match 0,66 vs bruit de collisions 0,11 → pertinence 0,20 ; codes pertinents 0,14-0,16 vs parasite 0,08 → suggestion 0,10) ; chaîne complète au **vrai modèle ONNX** (réindexation, ask sourcé p. 8/p. 10, suggestions étagées avec vrais comptes de bilans) ; parcours **mobile Expo web 6/6** et **web 7/7** au vrai modèle, 0 erreur console. 78 tests API (2 nouveaux : bascule corpus gardée, reindex traduit + 403).
**Décisions**
- Sur le web, « Appliquer » écrit immédiatement (comme tout le reste de la carte Bilan R2) : le clic EST la validation humaine — l'esprit de la maquette (« rien sans vous ») est porté par le geste, pas par un bouton « Enregistrer » séparé. À montrer en revue pixel.
- Les seuils par défaut du vrai modèle (0,30/0,35/0,55) restent **à calibrer en recette sur le corpus client réel** : mesuré ce jour sur un corpus non-métier, le multilingual-MiniLM donne des scores plats (hors-sujet 0,35-0,43 vs pertinent 0,24-0,28) — c'est exactement pourquoi ils sont configurables.
**Prochaine étape** : recette R5 complète (doit passer SANS clé API), durcissement production (Dockerfile `siop2-ai` + compose Dokploy), puis tag `release/r5`. Restes : recette R4 sur appareil, redéploiement Dokploy de `release/r3`.
---
## 2026-07-17 — Pr. Daaif (+ Claude) — R5.2 : l'assistant au contrat + suggestion de codes de bilan
**Actions**
- **`apps/ai`** : `/internal/ask` — recherche → **seuil de pertinence** → extraits sourcés, ou **refus honnête** portant la taille du corpus cherché (« 1 document, 3 bilans » — l'écran 2 des maquettes aura ses chiffres) ; la rédaction passe par le `Generateur` (opt-in — mode extractif par défaut). `/internal/suggest` — similarité sémantique entre la description libre et les libellés **actifs** des référentiels (contextualisés « anomalie constatée : … »), un seul code par champ, au-dessus du seuil, confiance FORTE/MOYENNE + « N bilans similaires sur ce parc » (comptés dans les chunks d'historique). Sans LLM : déterministe, explicable. 23 pytest.
- **Contrat** (74 opérations) : `POST /assistant/ask``AssistantAnswer` (mode EXTRACTIVE/GENERATED/REFUSAL, extraits cités document/page ou bilan daté, corpus cherché) ; `POST /assistant/suggest-bilan` → codes existants seulement. Clients web/mobile régénérés.
- **API NestJS** : module `assistant` — proxy vers `siop2-ai` (`AI_SERVICE_URL`/`AI_SERVICE_TOKEN` à l'env, ADR-004 §4), permissions par la matrice (`ask` = WORK_ORDERS.view ; `suggest` = WORK_ORDERS.edit — qui remplit des bilans), traduction du dialecte interne vers le contrat, **503 propre** si le service IA est éteint (jamais un 500). 6 tests e2e sur un **stub HTTP** (76 tests API, 14 suites).
- **Vérifié sur la vraie chaîne** (NestJS → ai → pgvector, vrai modèle) — et elle a débusqué un bug : fastembed ne renvoie pas des vecteurs normés, la similarité des suggestions dépassait 1 (pgvector normalisait dans son opérateur, ce qui masquait l'écart). **Normalisation à l'encodage** + réindexation : bilans du parc trouvés en tête (0.41), refus honnête hors corpus, suggestions cosinus ≤ 1 à confiances étagées.
**Décisions**
- Les seuils (pertinence 0.30, suggestion 0.35, confiance forte 0.55) sont des constantes de départ — à calibrer en recette sur le corpus réel du client.
**Prochaine étape** : R5.3 — les écrans validés (Assistant sidebar, corpus dans la Bibliothèque, suggestions dans les fiches OT web et clôture mobile). Restes : recette R4 sur appareil, redéploiement Dokploy de `release/r3`.
---
## 2026-07-17 — Pr. Daaif (+ Claude) — R5.1+ : clé API de génération configurable (demande du référent)
**Actions**
- **`generation.py`** : l'interface `Generateur` d'ADR-004 §3 prend corps — `GenerateurExtractif` (contrat de base, aucun LLM) et `GenerateurAPI` (SDK officiel `anthropic`, dépendance **optionnelle** `--extra generation`, absente des tests/CI). Consigne système : citations [n] obligatoires depuis les extraits fournis, « n'invente RIEN », rappel de validation humaine. Tout échec (refus du modèle, quota, réseau) **retombe sur l'extractif** — jamais d'erreur utilisateur imputable au LLM ; `stop_reason == "refusal"` traité explicitement.
- **Configuration complète et validée au boot** : `AI_GENERATION=off|api`, `AI_API_KEY` (exigée en mode api — le démarrage refuse sinon, message clair), `AI_MODEL` (défaut `claude-opus-4-8`). `.env.example` posé ; `/healthz` expose le **mode** (off/api), jamais la clé ; le générateur rejoint l'état de l'app (R5.2 le consommera).
- 5 tests ajoutés (19 pytest au total) : défaut extractif, refus de boot api-sans-clé, mode inconnu refusé, clé/modèle lus de l'env, invite pure numérotant les extraits. Vérifié en réel : boot refusé sans clé, `GenerateurAPI` construit avec le SDK, healthz sans secret.
**Décisions**
- La clé vit en variable d'environnement (Dokploy secrets), pas en base ni dans l'UI — cohérent avec `JWT_SECRET` et ADR-002.
**Prochaine étape** : R5.2 — assistant au contrat (proxy NestJS), le mode extractif d'abord, la rédaction branchée sur ce `Generateur`. Restes : recette R4 sur appareil, redéploiement Dokploy de `release/r3`.
---
## 2026-07-17 — Pr. Daaif (+ Claude) — R5.1 : socle `apps/ai` — ingestion anonymisée + recherche sémantique
**Actions**
- **ADR-004 actée** : embeddings **locaux sur CPU** (fastembed ONNX, `paraphrase-multilingual-MiniLM-L12-v2`, 384 dims — les textes du client ne quittent jamais le serveur), pgvector dans le Postgres existant (table `RagChunk` **possédée par Prisma**, migration `r5_ia` + état de corpus sur Document), génération **opt-in** (`AI_GENERATION=off` par défaut : mode extractif honnête, la recette doit passer sans clé API), suggestion de bilan sans LLM (similarité sémantique, explicable). Topologie : `siop2-ai` jamais exposé, joint par l'API NestJS seule (`X-Service-Token`).
- **`apps/ai` posé** (FastAPI + uv, python 3.11) : config validée au démarrage, `/healthz`, `/internal/reindex` (idempotent) et `/internal/search` protégés par le jeton de service. **Pipeline** : PDF MinIO → texte par page (pypdf) → **anonymisation D4** (e-mails, téléphones marocains, noms connus de la base — insensible casse/accents, fonction pure) → découpage (~900 car., chevauchement, testé) → embeddings → `RagChunk` avec localisateur (« p. 42 », « bilan du 17/07 »). Bilans codés clôturés ingérés aussi (« sur votre parc… »). L'exclusion de corpus (D3) s'applique à l'ingestion ET à la lecture.
- **14 pytest verts + ruff** (embeddeur **déterministe** en test/CI — aucun téléchargement, même interface 384 dims) ; **job CI `ai`** (uv), deploy en dépend.
- **Vérifié en réel avec le vrai modèle ONNX** : réindexation du corpus seedé en 7 s (1 PDF réel → 31 extraits paginés, 3 bilans), recherche sémantique concluante (PDF trouvé par le sens, bilans du parc par « frottement des guides »), e-mails du PDF remplacés par ⟨contact⟩, **0 identité dans les chunks** (contrôle SQL sur les 9 noms seedés).
**Décisions**
- Convention de test R5 : pytest unitaires purs en CI (sans DB ni réseau) ; l'intégration réelle (DB + MinIO + modèle) se vérifie en local et en recette.
**Prochaine étape** : R5.2 — l'assistant au contrat (proxy NestJS authentifié → `siop2-ai`, mode extractif sourcé « sourcé ou silencieux ») + suggestion de codes de bilan. Restes : recette R4 sur appareil, redéploiement Dokploy de `release/r3`.
---
## 2026-07-17 — Pr. Daaif (+ Claude) — R5 ouverte : maquettes IA (design d'abord)
**Actions**
- **`maquette-r5.html`** (docs/02-design/maquettes) : 6 écrans — **Assistant sourcé** (recette type du cadrage : « couple de serrage des guides Gen2 ? » → réponse à citations numérotées, sources document/page/**extrait exact** ouvrables), **Sans source = refus** (l'anti-hallucination visible : « je préfère ne pas inventer » + ce qui a été cherché + action utile), **Suggestion de bilan** web et mobile (description libre → codes EXISTANTS des référentiels, justification + confiance, « Appliquer » pré-remplit avec liseré « suggéré », rien d'enregistré sans le geste humain), **Corpus & ingestion** (bibliothèque R3 + bilans codés, statut par document, exclusion réversible, bandeau 09-08), **Voix** (option « si budget » : dictée opt-in, transcription relue, audio purgé immédiatement). Bi-thème, tokens répliqués, vérifiée en navigateur (6 onglets + sombre, zéro erreur console).
- Artefact de validation publié (6 écrans + preuve bi-thème + **5 décisions à acter** : D1 aucune écriture automatique, D2 sourcé ou silencieux, D3 corpus fermé et visible, D4 anonymisation à l'ingestion 09-08, D5 voix opt-in avec purge).
**Décisions**
- **→ Levé le 17/07/2026 : maquettes R5 et les 5 décisions (D1-D5) VALIDÉES par le référent.** Lancement R5.1 (socle `apps/ai` + ADR-004).
**Prochaine étape** : R5.1 — ADR-004 (embeddings/modèles), migration `r5_ia` (chunks pgvector, état de corpus), pipeline d'ingestion anonymisé et testé. Restes : recette R4 sur appareil, redéploiement Dokploy de `release/r3`.
---
## 2026-07-17 — Pr. Daaif (+ Claude) — R4 CLOSE : tag `release/r4` (recette téléphone reportée)
**Actions**

View File

@@ -1096,6 +1096,56 @@
}
}
},
"/documents/{id}/corpus": {
"patch": {
"operationId": "updateDocumentCorpus",
"summary": "Inclure/exclure du corpus IA (D3 — réversible, effectif à la prochaine réindexation)",
"tags": [
"documents"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DocumentCorpusUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Document mis à jour",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Document"
}
}
}
},
"404": {
"description": "Inconnu"
}
}
}
},
"/documents/{id}": {
"delete": {
"operationId": "deleteDocument",
@@ -1165,6 +1215,113 @@
}
}
},
"/assistant/ask": {
"post": {
"operationId": "askAssistant",
"summary": "Assistant R5 — sourcé ou silencieux : extraits cités ou refus honnête (D2)",
"tags": [
"assistant"
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssistantAsk"
}
}
}
},
"responses": {
"200": {
"description": "Réponse sourcée ou refus",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssistantAnswer"
}
}
}
},
"503": {
"description": "Service IA indisponible"
}
}
}
},
"/assistant/suggest-bilan": {
"post": {
"operationId": "suggestBilan",
"summary": "Suggérer des codes de bilan depuis une description libre (D1 — lhumain valide)",
"tags": [
"assistant"
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SuggestBilan"
}
}
}
},
"responses": {
"200": {
"description": "Suggestions (codes existants seulement)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BilanSuggestions"
}
}
}
},
"503": {
"description": "Service IA indisponible"
}
}
}
},
"/assistant/reindex": {
"post": {
"operationId": "reindexAssistant",
"summary": "Réindexer le corpus (bibliothèque PDF + bilans clôturés, anonymisés à lingestion — D4)",
"tags": [
"assistant"
],
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Bilan dindexation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReindexResult"
}
}
}
},
"503": {
"description": "Service IA indisponible"
}
}
}
},
"/search": {
"get": {
"operationId": "globalSearch",
@@ -4729,6 +4886,26 @@
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
},
"inCorpus": {
"type": "boolean"
},
"indexedAt": {
"anyOf": [
{
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
},
{
"type": "null"
}
]
},
"chunkCount": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
}
},
"required": [
@@ -4740,7 +4917,10 @@
"assetReference",
"workOrderReference",
"uploadedByName",
"createdAt"
"createdAt",
"inCorpus",
"indexedAt",
"chunkCount"
],
"additionalProperties": false
}
@@ -4814,6 +4994,26 @@
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
},
"inCorpus": {
"type": "boolean"
},
"indexedAt": {
"anyOf": [
{
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
},
{
"type": "null"
}
]
},
"chunkCount": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
}
},
"required": [
@@ -4825,7 +5025,23 @@
"assetReference",
"workOrderReference",
"uploadedByName",
"createdAt"
"createdAt",
"inCorpus",
"indexedAt",
"chunkCount"
],
"additionalProperties": false
},
"DocumentCorpusUpdate": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"inCorpus": {
"type": "boolean"
}
},
"required": [
"inCorpus"
],
"additionalProperties": false
},
@@ -4975,6 +5191,242 @@
],
"additionalProperties": false
},
"AssistantAnswer": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": [
"EXTRACTIVE",
"GENERATED",
"REFUSAL"
]
},
"answer": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"excerpts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"sourceType": {
"type": "string",
"enum": [
"DOCUMENT",
"WORK_ORDER"
]
},
"documentId": {
"anyOf": [
{
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
{
"type": "null"
}
]
},
"workOrderId": {
"anyOf": [
{
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
{
"type": "null"
}
]
},
"title": {
"type": "string"
},
"locator": {
"type": "string"
},
"content": {
"type": "string"
},
"score": {
"type": "number"
}
},
"required": [
"sourceType",
"documentId",
"workOrderId",
"title",
"locator",
"content",
"score"
],
"additionalProperties": false
}
},
"corpus": {
"type": "object",
"properties": {
"documents": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"reports": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
}
},
"required": [
"documents",
"reports"
],
"additionalProperties": false
}
},
"required": [
"mode",
"answer",
"excerpts",
"corpus"
],
"additionalProperties": false
},
"AssistantAsk": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"question": {
"type": "string",
"minLength": 3,
"maxLength": 500
}
},
"required": [
"question"
],
"additionalProperties": false
},
"BilanSuggestions": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"suggestions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"field": {
"type": "string",
"enum": [
"DOOR_STATE",
"CABIN_POSITION",
"ANOMALY",
"EXTERNAL_CAUSE",
"ACTION_TAKEN",
"COMPONENT_CONCERNED"
]
},
"valueId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"label": {
"type": "string"
},
"confidence": {
"type": "string",
"enum": [
"HIGH",
"MEDIUM"
]
},
"similarReports": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"score": {
"type": "number"
}
},
"required": [
"field",
"valueId",
"label",
"confidence",
"similarReports",
"score"
],
"additionalProperties": false
}
}
},
"required": [
"suggestions"
],
"additionalProperties": false
},
"SuggestBilan": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"description": {
"type": "string",
"minLength": 10,
"maxLength": 2000
}
},
"required": [
"description"
],
"additionalProperties": false
},
"ReindexResult": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"documentsIndexed": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"documentsSkipped": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"reportsIndexed": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"chunks": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
}
},
"required": [
"documentsIndexed",
"documentsSkipped",
"reportsIndexed",
"chunks"
],
"additionalProperties": false
},
"SearchResponse": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",

View File

@@ -51,6 +51,39 @@ services:
timeout: 3s
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:
container_name: siop2-api
build:
@@ -68,6 +101,9 @@ services:
MINIO_PORT: 9000
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-siop}
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.
# Instance de démonstration publique UNIQUEMENT :
# DEMO_MODE=true + DEMO_MODE_I_KNOW=true (double verrou) + SEED_ON_START=true

View File

@@ -46,10 +46,18 @@ import {
} from './schemas/portail';
import {
AnalyticsSummarySchema,
DocumentCorpusUpdateSchema,
DocumentSchema,
DocumentsResponseSchema,
} from './schemas/documents';
import { SearchResponseSchema } from './schemas/search';
import {
AssistantAnswerSchema,
AssistantAskSchema,
BilanSuggestionsResponseSchema,
ReindexResultSchema,
SuggestBilanSchema,
} from './schemas/assistant';
import {
ConsumePartSchema,
LaborTimeCreateSchema,
@@ -485,6 +493,19 @@ export const API_CONTRACT: ApiOperation[] = [
404: { description: 'Inconnu' },
},
},
{
operationId: 'updateDocumentCorpus',
method: 'patch',
path: '/documents/{id}/corpus',
summary: 'Inclure/exclure du corpus IA (D3 — réversible, effectif à la prochaine réindexation)',
tags: ['documents'],
pathParams: ['id'],
request: { name: 'DocumentCorpusUpdate', schema: DocumentCorpusUpdateSchema },
responses: {
200: { description: 'Document mis à jour', name: 'Document', schema: DocumentSchema },
404: { description: 'Inconnu' },
},
},
{
operationId: 'deleteDocument',
method: 'delete',
@@ -508,6 +529,41 @@ export const API_CONTRACT: ApiOperation[] = [
200: { description: 'Synthèse', name: 'AnalyticsSummary', schema: AnalyticsSummarySchema },
},
},
{
operationId: 'askAssistant',
method: 'post',
path: '/assistant/ask',
summary: 'Assistant R5 — sourcé ou silencieux : extraits cités ou refus honnête (D2)',
tags: ['assistant'],
request: { name: 'AssistantAsk', schema: AssistantAskSchema },
responses: {
200: { description: 'Réponse sourcée ou refus', name: 'AssistantAnswer', schema: AssistantAnswerSchema },
503: { description: 'Service IA indisponible' },
},
},
{
operationId: 'suggestBilan',
method: 'post',
path: '/assistant/suggest-bilan',
summary: 'Suggérer des codes de bilan depuis une description libre (D1 — lhumain valide)',
tags: ['assistant'],
request: { name: 'SuggestBilan', schema: SuggestBilanSchema },
responses: {
200: { description: 'Suggestions (codes existants seulement)', name: 'BilanSuggestions', schema: BilanSuggestionsResponseSchema },
503: { description: 'Service IA indisponible' },
},
},
{
operationId: 'reindexAssistant',
method: 'post',
path: '/assistant/reindex',
summary: 'Réindexer le corpus (bibliothèque PDF + bilans clôturés, anonymisés à lingestion — D4)',
tags: ['assistant'],
responses: {
200: { description: 'Bilan dindexation', name: 'ReindexResult', schema: ReindexResultSchema },
503: { description: 'Service IA indisponible' },
},
},
{
operationId: 'globalSearch',
method: 'get',

View File

@@ -12,4 +12,5 @@ export * from './schemas/users-admin';
export * from './schemas/referentiel';
export * from './schemas/health';
export * from './schemas/search';
export * from './schemas/assistant';
export * from './contract';

View File

@@ -0,0 +1,73 @@
import { z } from 'zod';
import { BILAN_FIELDS } from '../exploitation';
/** Assistant R5 (D1/D2 validées) : sourcé ou silencieux, l'humain valide.
* Le web/mobile parlent à l'API NestJS ; le service `siop2-ai` reste
* interne (ADR-004 §4). */
export const ASSISTANT_MIN_CHARS = 3;
export const AssistantAskSchema = z.object({
question: z.string().min(ASSISTANT_MIN_CHARS).max(500),
});
export type AssistantAsk = z.infer<typeof AssistantAskSchema>;
export const AssistantExcerptSchema = z.object({
sourceType: z.enum(['DOCUMENT', 'WORK_ORDER']),
documentId: z.uuid().nullable(),
workOrderId: z.uuid().nullable(),
title: z.string(), // nom de fichier ou référence d'OT
locator: z.string(), // « p. 42 », « bilan du 17/07/2026 »
content: z.string(), // l'extrait EXACT — la citation montre sa source
score: z.number(),
});
export type AssistantExcerpt = z.infer<typeof AssistantExcerptSchema>;
export const AssistantAnswerSchema = z.object({
/** REFUSAL = le corpus ne porte pas la réponse — l'assistant le dit (D2). */
mode: z.enum(['EXTRACTIVE', 'GENERATED', 'REFUSAL']),
/** Rédaction (mode génératif opt-in, ADR-004 §3) — null en extractif/refus. */
answer: z.string().nullable(),
excerpts: z.array(AssistantExcerptSchema),
/** Ce qui a été cherché — le refus honnête l'affiche (« 6 documents, 214 bilans »). */
corpus: z.object({
documents: z.number().int(),
reports: z.number().int(),
}),
});
export type AssistantAnswer = z.infer<typeof AssistantAnswerSchema>;
// ————— Suggestion de codes de bilan (sans LLM — ADR-004 §3) —————
export const SuggestBilanSchema = z.object({
description: z.string().min(10).max(2000),
});
export type SuggestBilan = z.infer<typeof SuggestBilanSchema>;
export const BilanSuggestionSchema = z.object({
field: z.enum(BILAN_FIELDS),
/** Toujours un code EXISTANT du référentiel — l'IA n'invente pas de valeur. */
valueId: z.uuid(),
label: z.string(),
confidence: z.enum(['HIGH', 'MEDIUM']),
/** « N bilans similaires sur ce parc » — la justification vérifiable. */
similarReports: z.number().int(),
score: z.number(),
});
export type BilanSuggestion = z.infer<typeof BilanSuggestionSchema>;
export const BilanSuggestionsResponseSchema = z.object({
suggestions: z.array(BilanSuggestionSchema),
});
export type BilanSuggestionsResponse = z.infer<typeof BilanSuggestionsResponseSchema>;
// ————— Réindexation du corpus (bibliothèque = corpus, D3) —————
export const ReindexResultSchema = z.object({
documentsIndexed: z.number().int(),
/** Exclus du corpus (interrupteur) ou format non indexable (non-PDF). */
documentsSkipped: z.number().int(),
reportsIndexed: z.number().int(),
chunks: z.number().int(),
});
export type ReindexResult = z.infer<typeof ReindexResultSchema>;

View File

@@ -28,9 +28,18 @@ export const DocumentSchema = z.object({
workOrderReference: z.string().nullable(),
uploadedByName: z.string().nullable(),
createdAt: z.iso.datetime(),
/** R5 (D3) : corpus visible et réversible, document par document. */
inCorpus: z.boolean(),
indexedAt: z.iso.datetime().nullable(), // null = jamais indexé
chunkCount: z.number().int(),
});
export type DocumentDto = z.infer<typeof DocumentSchema>;
export const DocumentCorpusUpdateSchema = z.object({
inCorpus: z.boolean(),
});
export type DocumentCorpusUpdate = z.infer<typeof DocumentCorpusUpdateSchema>;
export const DocumentsResponseSchema = z.object({
documents: z.array(DocumentSchema),
});