From 28eecc1fb90649bdfd917f9e78be9be61df63ec6 Mon Sep 17 00:00:00 2001 From: pr-daaif Date: Fri, 17 Jul 2026 17:26:22 +0100 Subject: [PATCH] =?UTF-8?q?feat(r5.3):=20=C3=A9crans=20IA=20=E2=80=94=20as?= =?UTF-8?q?sistant=20web,=20corpus=20administrable,=20suggestions=20OT/mob?= =?UTF-8?q?ile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Contrat (76 opérations) : Document expose inCorpus/indexedAt/chunkCount, PATCH /documents/{id}/corpus (ASSETS.edit), POST /assistant/reindex (bilan chiffré) ; ci-contract vérifie désormais aussi le client mobile. - Web : page /assistant (chat sourcé — extraits exacts cités, Ouvrir vers PDF authentifié ou fiche OT, avertissement permanent ; refus honnête chiffré avec action utile) ; Bibliothèque = corpus (bandeau 09-08, statut d'indexation par document, interrupteur d'exclusion PDF, Réindexer tout) ; fiche OT : « Décrire pour suggérer » (Appliquer = geste humain, liseré « suggéré » retiré au choix manuel). - Mobile : chips de suggestion dans la clôture (un appui = un champ pré-rempli, « réseau requis » hors-ligne — la file R4 n'en dépend pas). - apps/ai : seuils AI_SEUIL_* configurables par env (CI + calibrage). - CI e2e : service siop2-ai (embeddeur déterministe, seuils calibrés sur mesures : match 0,66 vs bruit 0,11) + parcours R5 Playwright (PDF généré xref valide → réindexation → réponse sourcée → refus → suggestion). - Vérifié : 16/16 Playwright, 78 tests API, 23 pytest, 17 jest-expo ; chaîne réelle au vrai modèle ONNX (web 7/7, mobile Expo web 6/6). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 29 ++- CLAUDE.md | 4 +- apps/ai/src/siop_ai/app.py | 15 +- apps/ai/src/siop_ai/assistant.py | 10 +- apps/ai/src/siop_ai/config.py | 5 + .../api/src/assistant/assistant.controller.ts | 8 + apps/api/src/assistant/assistant.service.ts | 16 ++ .../api/src/documents/documents.controller.ts | 19 +- apps/api/src/documents/documents.service.ts | 15 ++ apps/api/test/assistant.e2e-spec.ts | 25 ++- apps/api/test/documents-analytics.e2e-spec.ts | 30 +++ apps/mobile/app/ot/[id]/cloture.tsx | 124 ++++++++++++- apps/mobile/src/api/exploitation.ts | 9 + apps/mobile/src/api/schema.d.ts | 109 +++++++++++ apps/web/e2e/parcours-r5.spec.ts | 149 +++++++++++++++ apps/web/playwright.config.ts | 4 + apps/web/src/App.tsx | 2 + apps/web/src/api/assistant.ts | 57 ++++++ apps/web/src/api/schema.d.ts | 109 +++++++++++ apps/web/src/components/carte-documents.tsx | 54 +++++- apps/web/src/components/nav-icons.tsx | 7 + apps/web/src/layout/coquille.tsx | 2 + apps/web/src/pages/assistant.tsx | 166 +++++++++++++++++ apps/web/src/pages/bibliotheque.tsx | 39 +++- apps/web/src/pages/fiche-ot.tsx | 118 +++++++++++- apps/web/src/styles/app.css | 88 +++++++++ docs/journal/journal.md | 21 +++ docs/openapi.json | 175 +++++++++++++++++- packages/shared/src/contract.ts | 26 +++ packages/shared/src/schemas/assistant.ts | 11 ++ packages/shared/src/schemas/documents.ts | 9 + 31 files changed, 1431 insertions(+), 24 deletions(-) create mode 100644 apps/web/e2e/parcours-r5.spec.ts create mode 100644 apps/web/src/api/assistant.ts create mode 100644 apps/web/src/pages/assistant.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4336678..0f86fca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -190,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 diff --git a/CLAUDE.md b/CLAUDE.md index 0f2b9ac..dbe896d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,5 +61,7 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - 🏁 **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**. - ✅ **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. -- 🔄 **Reprise ici** : R5.2 — assistant au contrat (proxy NestJS authentifié → `siop2-ai`, « sourcé ou silencieux » en mode extractif) + suggestion de codes de bilan. Restes : recette R4 sur appareil, redéploiement Dokploy de `release/r3`. +- ✅ **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). +- 🔄 **Reprise ici** : recette R5 (doit passer SANS clé API) → durcissement production (Dockerfile `siop2-ai`, compose Dokploy, calibrage des seuils sur corpus client) → tag `release/r5`. Restes : recette R4 sur téléphone (Expo Go), redéploiement Dokploy de `release/r3`, secret `DOKPLOY_WEBHOOK_URL`. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5. diff --git a/apps/ai/src/siop_ai/app.py b/apps/ai/src/siop_ai/app.py index 5c537d8..2688f44 100644 --- a/apps/ai/src/siop_ai/app.py +++ b/apps/ai/src/siop_ai/app.py @@ -78,7 +78,12 @@ async def demander(corps: RequeteRecherche) -> dict: (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 + cnx, + app.state.embeddeur, + app.state.generateur, + corps.question, + corps.limite, + seuil_pertinence=app.state.reglages.ai_seuil_pertinence, ) return { "mode": reponse.mode, @@ -100,5 +105,11 @@ 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) + 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]} diff --git a/apps/ai/src/siop_ai/assistant.py b/apps/ai/src/siop_ai/assistant.py index 8ad4b9b..990386c 100644 --- a/apps/ai/src/siop_ai/assistant.py +++ b/apps/ai/src/siop_ai/assistant.py @@ -29,6 +29,7 @@ CHAMPS_BILAN = { "COMPONENT_CONCERNED": "élément concerné", } +# Défauts — surchargés par la config (AI_SEUIL_*) : calibrage en recette. SEUIL_PERTINENCE = 0.30 # en dessous : le corpus ne porte pas la réponse SEUIL_SUGGESTION = 0.35 SEUIL_CONFIANCE_FORTE = 0.55 @@ -61,10 +62,11 @@ async def repondre( 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] + 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. @@ -104,6 +106,8 @@ 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' @@ -122,7 +126,7 @@ async def suggerer_bilan( meilleurs: dict[str, tuple[asyncpg.Record, float]] = {} for valeur, vecteur in zip(valeurs, v_valeurs): score = _cosinus(v_description, vecteur) - if score < SEUIL_SUGGESTION: + if score < seuil_suggestion: continue champ = valeur["field"] if champ not in meilleurs or score > meilleurs[champ][1]: @@ -140,7 +144,7 @@ async def suggerer_bilan( field=valeur["field"], value_id=str(valeur["id"]), label=valeur["label"], - confidence="FORTE" if score >= SEUIL_CONFIANCE_FORTE else "MOYENNE", + confidence="FORTE" if score >= seuil_confiance_forte else "MOYENNE", similar_reports=similaires, score=round(score, 4), ) diff --git a/apps/ai/src/siop_ai/config.py b/apps/ai/src/siop_ai/config.py index 21f5f90..e942583 100644 --- a/apps/ai/src/siop_ai/config.py +++ b/apps/ai/src/siop_ai/config.py @@ -24,6 +24,11 @@ class Reglages(BaseSettings): 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.30 + ai_seuil_suggestion: float = 0.35 + ai_seuil_confiance_forte: float = 0.55 model_config = {"env_prefix": "", "case_sensitive": False} diff --git a/apps/api/src/assistant/assistant.controller.ts b/apps/api/src/assistant/assistant.controller.ts index 2da946f..4cc4ca8 100644 --- a/apps/api/src/assistant/assistant.controller.ts +++ b/apps/api/src/assistant/assistant.controller.ts @@ -21,6 +21,14 @@ export class AssistantController { 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) diff --git a/apps/api/src/assistant/assistant.service.ts b/apps/api/src/assistant/assistant.service.ts index 96d87e6..a2f86d7 100644 --- a/apps/api/src/assistant/assistant.service.ts +++ b/apps/api/src/assistant/assistant.service.ts @@ -4,6 +4,7 @@ import type { AssistantAsk, BilanField, BilanSuggestionsResponse, + ReindexResult, SuggestBilan, } from '@siop/shared'; import { loadEnv } from '../config/env'; @@ -81,6 +82,21 @@ export class AssistantService { }; } + async reindex(): Promise { + 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 { const brut = await this.appeler<{ suggestions: { diff --git a/apps/api/src/documents/documents.controller.ts b/apps/api/src/documents/documents.controller.ts index 565dfb0..4551860 100644 --- a/apps/api/src/documents/documents.controller.ts +++ b/apps/api/src/documents/documents.controller.ts @@ -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) { diff --git a/apps/api/src/documents/documents.service.ts b/apps/api/src/documents/documents.service.ts index 73ba6fc..e3ed91e 100644 --- a/apps/api/src/documents/documents.service.ts +++ b/apps/api/src/documents/documents.service.ts @@ -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 { + 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); + } } diff --git a/apps/api/test/assistant.e2e-spec.ts b/apps/api/test/assistant.e2e-spec.ts index 045fd1a..699ee24 100644 --- a/apps/api/test/assistant.e2e-spec.ts +++ b/apps/api/test/assistant.e2e-spec.ts @@ -42,12 +42,20 @@ const REPONSE_SUGGEST = { ], }; +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 + 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}` }); @@ -62,6 +70,7 @@ describe('Assistant (e2e — stub du service IA)', () => { 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('{}'); @@ -87,6 +96,7 @@ describe('Assistant (e2e — stub du service IA)', () => { ahmed = await login('Technicien'); karim = await login('Demandeur'); rachid = await login('Vue seule'); + nadia = await login('Gestionnaire'); }); afterAll(async () => { @@ -146,6 +156,19 @@ describe('Assistant (e2e — stub du service IA)', () => { .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); diff --git a/apps/api/test/documents-analytics.e2e-spec.ts b/apps/api/test/documents-analytics.e2e-spec.ts index 328abbc..9378a66 100644 --- a/apps/api/test/documents-analytics.e2e-spec.ts +++ b/apps/api/test/documents-analytics.e2e-spec.ts @@ -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'); diff --git a/apps/mobile/app/ot/[id]/cloture.tsx b/apps/mobile/app/ot/[id]/cloture.tsx index edd4ad1..16071dc 100644 --- a/apps/mobile/app/ot/[id]/cloture.tsx +++ b/apps/mobile/app/ot/[id]/cloture.tsx @@ -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() { + setChoix((c) => ({ ...c, [s.field]: s.valueId }))} + /> {[0, 2, 4].map((rang) => ( @@ -151,3 +156,118 @@ export default function PageCloture() { ); } + +/** É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>(new Set()); + const suggestions = suggerer.data?.suggestions ?? []; + + return ( + + + { + setAppliquees(new Set()); + suggerer.mutate(description.trim()); + }} + /> + {suggerer.isError ? ( + + {suggerer.error.message} + + ) : null} + {suggerer.isSuccess && suggestions.length === 0 ? ( + + Aucun code assez proche — l’IA ne devine pas : choisissez dans les sélecteurs. + + ) : null} + {suggestions.length > 0 ? ( + <> + + {suggestions.map((s) => { + const faite = appliquees.has(s.field); + return ( + { + 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, + }} + > + + {faite ? '✓' : '✨'} {BILAN_FIELD_LABELS[s.field]} : {s.label} + + + ); + })} + + + Rien ne s’enregistre sans votre geste — les chips pré-remplissent les sélecteurs + ci-dessous. + + + ) : null} + + ); +} diff --git a/apps/mobile/src/api/exploitation.ts b/apps/mobile/src/api/exploitation.ts index 40ff306..2714a21 100644 --- a/apps/mobile/src/api/exploitation.ts +++ b/apps/mobile/src/api/exploitation.ts @@ -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({ diff --git a/apps/mobile/src/api/schema.d.ts b/apps/mobile/src/api/schema.d.ts index 9491462..da530c5 100644 --- a/apps/mobile/src/api/schema.d.ts +++ b/apps/mobile/src/api/schema.d.ts @@ -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; @@ -475,6 +492,23 @@ export interface paths { 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; @@ -1437,6 +1471,9 @@ export interface components { uploadedByName: string | null; /** Format: date-time */ createdAt: string; + inCorpus: boolean; + indexedAt: string | null; + chunkCount: number; }[]; }; Document: { @@ -1452,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; @@ -1518,6 +1561,12 @@ export interface components { SuggestBilan: { description: string; }; + ReindexResult: { + documentsIndexed: number; + documentsSkipped: number; + reportsIndexed: number; + chunks: number; + }; SearchResponse: { workOrders: { /** Format: uuid */ @@ -2990,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; @@ -3101,6 +3183,33 @@ export interface operations { }; }; }; + 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: { diff --git a/apps/web/e2e/parcours-r5.spec.ts b/apps/web/e2e/parcours-r5.spec.ts new file mode 100644 index 0000000..77d460d --- /dev/null +++ b/apps/web/e2e/parcours-r5.spec.ts @@ -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('.doc', { hasText: fichier }); + 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'); +}); diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 99dd764..5be16e8 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -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({ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5657ca1..28d4f37 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { )} /> )} /> )} /> + )} /> diff --git a/apps/web/src/api/assistant.ts b/apps/web/src/api/assistant.ts new file mode 100644 index 0000000..8b2ba87 --- /dev/null +++ b/apps/web/src/api/assistant.ts @@ -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(res: { data?: T; error?: unknown; response: Response }): Promise { + 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'] }), + }); +} diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts index 9491462..da530c5 100644 --- a/apps/web/src/api/schema.d.ts +++ b/apps/web/src/api/schema.d.ts @@ -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; @@ -475,6 +492,23 @@ export interface paths { 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; @@ -1437,6 +1471,9 @@ export interface components { uploadedByName: string | null; /** Format: date-time */ createdAt: string; + inCorpus: boolean; + indexedAt: string | null; + chunkCount: number; }[]; }; Document: { @@ -1452,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; @@ -1518,6 +1561,12 @@ export interface components { SuggestBilan: { description: string; }; + ReindexResult: { + documentsIndexed: number; + documentsSkipped: number; + reportsIndexed: number; + chunks: number; + }; SearchResponse: { workOrders: { /** Format: uuid */ @@ -2990,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; @@ -3101,6 +3183,33 @@ export interface operations { }; }; }; + 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: { diff --git a/apps/web/src/components/carte-documents.tsx b/apps/web/src/components/carte-documents.tsx index 4f0af55..86a628a 100644 --- a/apps/web/src/components/carte-documents.tsx +++ b/apps/web/src/components/carte-documents.tsx @@ -6,6 +6,7 @@ import { type DocumentDto, type DocumentKind, } from '@siop/shared'; +import { useSetDocumentCorpus } from '@/api/assistant'; import { blobDocument, ouvrirDocument, @@ -63,11 +64,62 @@ 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. */ +function StatutCorpus({ doc }: { doc: DocumentDto }) { + if (doc.contentType !== 'application/pdf') { + return image — non indexable; + } + if (!doc.inCorpus) return exclu du corpus; + if (!doc.indexedAt) return à indexer; + const quand = new Intl.DateTimeFormat('fr-FR', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(doc.indexedAt)); + return ( + + indexé · {quand} · {doc.chunkCount} extrait{doc.chunkCount > 1 ? 's' : ''} + + ); +} + +export function VignetteDoc({ + doc, + corpus, + surSuppression, +}: { + doc: DocumentDto; + /** true = l'utilisateur administre le corpus (statut + interrupteur visibles). */ + corpus?: boolean; + surSuppression?: (id: string) => void; +}) { + const bascule = useSetDocumentCorpus(); return (
{DOCUMENT_KIND_LABELS[doc.kind]} + {corpus ? ( + + + +
+ ); +} + +function Reponse({ reponse, surReformuler }: { reponse: AssistantAnswer; surReformuler: () => void }) { + const { can } = usePermissions(); + if (reponse.mode === 'REFUSAL') { + return ( +
+ Je ne trouve pas de source fiable dans votre bibliothèque — je préfère ne pas inventer. +
+ 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. +
+
+ {can('ASSETS', 'edit') ? ( + + Téléverser la notice dans la bibliothèque + + ) : null} + +
+
+ ); + } + return ( +
+ {reponse.mode === 'GENERATED' && reponse.answer ? ( +

{reponse.answer}

+ ) : ( +

+ Voici ce que portent vos sources — les extraits sont cités tels quels + {reponse.excerpts.map((_, i) => ( + {i + 1} + ))} + . +

+ )} +
+ {reponse.excerpts.map((extrait, i) => ( + + ))} +
+
⚠ L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil.
+
+ ); +} + +export default function PageAssistant() { + const ask = useAskAssistant(); + const [question, setQuestion] = useState(''); + const [echanges, setEchanges] = useState([]); + const saisieRef = useRef(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 ( + <> +
+

Assistant

+ répond UNIQUEMENT depuis votre bibliothèque et vos historiques +
+
+ {echanges.length === 0 ? ( +
+ 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. +
+ ) : null} + {echanges.map((echange, i) => ( +
+
{echange.question}
+ {echange.reponse ? ( + saisieRef.current?.focus()} /> + ) : echange.erreur ? ( +
+ {echange.erreur} +
+ ) : ( +
Recherche dans le corpus…
+ )} +
+ ))} +
+
+ setQuestion(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') envoyer(); + }} + /> + +
+ + ); +} diff --git a/apps/web/src/pages/bibliotheque.tsx b/apps/web/src/pages/bibliotheque.tsx index 4cf7bc7..f3e44d2 100644 --- a/apps/web/src/pages/bibliotheque.tsx +++ b/apps/web/src/pages/bibliotheque.tsx @@ -5,6 +5,7 @@ import { DOCUMENT_MAX_BYTES, type DocumentKind, } from '@siop/shared'; +import { useReindexAssistant } from '@/api/assistant'; import { useDeleteDocument, useDocuments, useUploadDocument } from '@/api/gestion'; import { useAssetOptions } from '@/api/referentiel'; import { usePermissions } from '@/auth/use-permissions'; @@ -12,10 +13,12 @@ import { tailleLisible, VignetteDoc } 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(''); const [assetId, setAssetId] = useState(''); const { data: options } = useAssetOptions(); @@ -29,7 +32,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 +48,37 @@ export default function PageBibliotheque() { return ( <>
-

Bibliothèque

+

Bibliothèque — corpus de l'assistant

- {documents?.length ?? 0} documents · {tailleLisible(totalOctets)} + {documents?.length ?? 0} documents · {indexes} indexés · {tailleLisible(totalOctets)}
+ {administreCorpus ? ( + + ) : null} {peutEditer ? ( ) : null}
+
+ 🛡 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. +
+ {reindex.isSuccess ? ( +
+ 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. +
+ ) : null} + {reindex.isError ? ( +

{reindex.error.message}

+ ) : null}