mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Compare commits
8 Commits
release/r4
...
release/r5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cccfaaabc9 | ||
|
|
b69c54ed0f | ||
|
|
28eecc1fb9 | ||
|
|
45ae491827 | ||
|
|
76c2ccdfb1 | ||
|
|
d4d73a6f76 | ||
|
|
837dcba1db | ||
|
|
199fce69d0 |
88
.github/workflows/ci.yml
vendored
88
.github/workflows/ci.yml
vendored
@@ -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 l’image 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
6
.gitignore
vendored
@@ -7,3 +7,9 @@ coverage/
|
||||
*.tsbuildinfo
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Python (apps/ai)
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
@@ -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 R0→R5.
|
||||
|
||||
9
apps/ai/.dockerignore
Normal file
9
apps/ai/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.venv
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
__pycache__
|
||||
tests
|
||||
README.md
|
||||
.env
|
||||
.env.example
|
||||
Dockerfile
|
||||
20
apps/ai/.env.example
Normal file
20
apps/ai/.env.example
Normal 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
38
apps/ai/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# SIOP V2 — image du service IA (siop2-ai, ADR-004).
|
||||
# Contexte de build : apps/ai (le service est autonome, pas de dépendance au
|
||||
# monorepo). Étage 1 : uv sync + téléchargement du modèle ONNX AU BUILD
|
||||
# (ADR-004 §1 — jamais au démarrage) ; étage 2 : runtime minimal non-root.
|
||||
# Ce service n'est JAMAIS exposé publiquement : seul siop2-api le contacte,
|
||||
# porteur du secret AI_SERVICE_TOKEN (ADR-004 §4).
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
ENV UV_LINK_MODE=copy \
|
||||
FASTEMBED_CACHE_PATH=/opt/fastembed
|
||||
|
||||
# Manifestes d'abord (cache de couche), puis le code. L'installation du projet
|
||||
# reste éditable (.pth → /app/src) : src est donc copié dans l'image finale.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-install-project --no-dev \
|
||||
--extra embeddings --extra generation
|
||||
COPY src src
|
||||
RUN uv sync --frozen --no-dev \
|
||||
--extra embeddings --extra generation
|
||||
|
||||
# Le modèle d'embeddings est EMBARQUÉ dans l'image : pas de téléchargement au
|
||||
# boot (démarrage prévisible, marche sans accès à Hugging Face en production).
|
||||
RUN uv run python -c "from siop_ai.embeddings import EmbeddeurLocal; EmbeddeurLocal()"
|
||||
|
||||
FROM python:3.11-slim-bookworm
|
||||
WORKDIR /app
|
||||
ENV PATH=/app/.venv/bin:$PATH \
|
||||
FASTEMBED_CACHE_PATH=/opt/fastembed
|
||||
RUN useradd --system --create-home siop
|
||||
COPY --from=builder --chown=siop:siop /app/.venv /app/.venv
|
||||
COPY --from=builder --chown=siop:siop /app/src /app/src
|
||||
COPY --from=builder --chown=siop:siop /opt/fastembed /opt/fastembed
|
||||
USER siop
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz', timeout=4).status==200 else 1)"
|
||||
CMD ["uvicorn", "siop_ai.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
33
apps/ai/README.md
Normal file
33
apps/ai/README.md
Normal 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
42
apps/ai/pyproject.toml
Normal 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"]
|
||||
1
apps/ai/src/siop_ai/__init__.py
Normal file
1
apps/ai/src/siop_ai/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""SIOP V2 — service IA (R5). L'IA propose, l'humain valide (D1)."""
|
||||
62
apps/ai/src/siop_ai/anonymisation.py
Normal file
62
apps/ai/src/siop_ai/anonymisation.py
Normal 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
115
apps/ai/src/siop_ai/app.py
Normal 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]}
|
||||
153
apps/ai/src/siop_ai/assistant.py
Normal file
153
apps/ai/src/siop_ai/assistant.py
Normal 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
|
||||
46
apps/ai/src/siop_ai/config.py
Normal file
46
apps/ai/src/siop_ai/config.py
Normal 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
|
||||
57
apps/ai/src/siop_ai/decoupage.py
Normal file
57
apps/ai/src/siop_ai/decoupage.py
Normal 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
|
||||
65
apps/ai/src/siop_ai/embeddings.py
Normal file
65
apps/ai/src/siop_ai/embeddings.py
Normal 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()
|
||||
94
apps/ai/src/siop_ai/generation.py
Normal file
94
apps/ai/src/siop_ai/generation.py
Normal 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()
|
||||
175
apps/ai/src/siop_ai/ingestion.py
Normal file
175
apps/ai/src/siop_ai/ingestion.py
Normal 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,
|
||||
)
|
||||
57
apps/ai/src/siop_ai/recherche.py
Normal file
57
apps/ai/src/siop_ai/recherche.py
Normal 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
|
||||
]
|
||||
33
apps/ai/tests/test_anonymisation.py
Normal file
33
apps/ai/tests/test_anonymisation.py
Normal 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
37
apps/ai/tests/test_app.py
Normal 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)
|
||||
51
apps/ai/tests/test_assistant.py
Normal file
51
apps/ai/tests/test_assistant.py
Normal 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
|
||||
24
apps/ai/tests/test_decoupage.py
Normal file
24
apps/ai/tests/test_decoupage.py
Normal 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]
|
||||
26
apps/ai/tests/test_embeddings.py
Normal file
26
apps/ai/tests/test_embeddings.py
Normal 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)
|
||||
62
apps/ai/tests/test_generation.py
Normal file
62
apps/ai/tests/test_generation.py
Normal 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
1870
apps/ai/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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] : []),
|
||||
],
|
||||
|
||||
39
apps/api/src/assistant/assistant.controller.ts
Normal file
39
apps/api/src/assistant/assistant.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/assistant/assistant.module.ts
Normal file
9
apps/api/src/assistant/assistant.module.ts
Normal 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 {}
|
||||
123
apps/api/src/assistant/assistant.service.ts
Normal file
123
apps/api/src/assistant/assistant.service.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
186
apps/api/test/assistant.e2e-spec.ts
Normal file
186
apps/api/test/assistant.e2e-spec.ts
Normal 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 s’applique : 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 d’indexation, 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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 — l’IA 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 s’enregistre sans votre geste — les chips pré-remplissent les sélecteurs
|
||||
ci-dessous.
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Carte>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
243
apps/mobile/src/api/schema.d.ts
vendored
243
apps/mobile/src/api/schema.d.ts
vendored
@@ -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 — l’humain 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 à l’ingestion — 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 d’indexation */
|
||||
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: {
|
||||
|
||||
149
apps/web/e2e/parcours-r5.spec.ts
Normal file
149
apps/web/e2e/parcours-r5.spec.ts
Normal 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');
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
57
apps/web/src/api/assistant.ts
Normal file
57
apps/web/src/api/assistant.ts
Normal 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'] }),
|
||||
});
|
||||
}
|
||||
243
apps/web/src/api/schema.d.ts
vendored
243
apps/web/src/api/schema.d.ts
vendored
@@ -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 — l’humain 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 à l’ingestion — 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 d’indexation */
|
||||
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: {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'] },
|
||||
],
|
||||
},
|
||||
|
||||
166
apps/web/src/pages/assistant.tsx
Normal file
166
apps/web/src/pages/assistant.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
<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) => (
|
||||
<VignetteDoc
|
||||
key={d.id}
|
||||
doc={d}
|
||||
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
|
||||
/>
|
||||
<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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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); }
|
||||
|
||||
576
docs/02-design/maquettes/maquette-r5.html
Normal file
576
docs/02-design/maquettes/maquette-r5.html
Normal 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>
|
||||
60
docs/03-architecture/adr/ADR-004-modeles-ia.md
Normal file
60
docs/03-architecture/adr/ADR-004-modeles-ia.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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 — l’humain 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 à l’ingestion — D4)",
|
||||
"tags": [
|
||||
"assistant"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Bilan d’indexation",
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 — l’humain 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 à l’ingestion — D4)',
|
||||
tags: ['assistant'],
|
||||
responses: {
|
||||
200: { description: 'Bilan d’indexation', name: 'ReindexResult', schema: ReindexResultSchema },
|
||||
503: { description: 'Service IA indisponible' },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'globalSearch',
|
||||
method: 'get',
|
||||
|
||||
@@ -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';
|
||||
|
||||
73
packages/shared/src/schemas/assistant.ts
Normal file
73
packages/shared/src/schemas/assistant.ts
Normal 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>;
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user