From 45ae491827d734e4935d0bf6f504f84023a76b0f Mon Sep 17 00:00:00 2001 From: pr-daaif Date: Fri, 17 Jul 2026 15:05:08 +0100 Subject: [PATCH] feat(r5.2): assistant au contrat + suggestion de codes de bilan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/ai : /internal/ask — seuil de pertinence, extraits sourcés ou refus honnête portant la taille du corpus cherché (D2), rédaction via le Generateur opt-in ; /internal/suggest — similarité sémantique entre la description libre et les libellés ACTIFS des référentiels, un code par champ, confiance FORTE/MOYENNE, « N bilans similaires sur ce parc ». Sans LLM : déterministe, explicable. 23 pytest. Contrat (74 opérations) : POST /assistant/ask → AssistantAnswer (EXTRACTIVE/GENERATED/REFUSAL, extraits cités, corpus cherché) et POST /assistant/suggest-bilan (codes existants seulement) ; clients web/mobile régénérés. API NestJS : module assistant — proxy vers siop2-ai (AI_SERVICE_URL/ AI_SERVICE_TOKEN, ADR-004 §4), permissions matrice (ask=view, suggest=edit), traduction interne→contrat, 503 propre si service éteint. 6 e2e sur stub HTTP (76 tests API). Bug débusqué par la vraie chaîne : fastembed ne norme pas ses vecteurs — la similarité des suggestions dépassait 1 (pgvector normalisait dans son opérateur, masquant l'écart). Normalisation à l'encodage + réindexation : bilans en tête (0.41), refus hors corpus, scores cosinus ≤ 1. Co-Authored-By: Claude Fable 5 --- apps/ai/src/siop_ai/app.py | 33 ++ apps/ai/src/siop_ai/assistant.py | 149 ++++++++++ apps/ai/src/siop_ai/embeddings.py | 12 +- apps/ai/tests/test_assistant.py | 49 +++ apps/api/src/app.module.ts | 2 + .../api/src/assistant/assistant.controller.ts | 31 ++ apps/api/src/assistant/assistant.module.ts | 9 + apps/api/src/assistant/assistant.service.ts | 107 +++++++ apps/api/src/config/env.ts | 3 + apps/api/test/assistant.e2e-spec.ts | 163 ++++++++++ apps/mobile/src/api/schema.d.ts | 134 +++++++++ apps/web/src/api/schema.d.ts | 134 +++++++++ docs/journal/journal.md | 17 ++ docs/openapi.json | 281 ++++++++++++++++++ packages/shared/src/contract.ts | 30 ++ packages/shared/src/index.ts | 1 + packages/shared/src/schemas/assistant.ts | 62 ++++ 17 files changed, 1215 insertions(+), 2 deletions(-) create mode 100644 apps/ai/src/siop_ai/assistant.py create mode 100644 apps/ai/tests/test_assistant.py create mode 100644 apps/api/src/assistant/assistant.controller.ts create mode 100644 apps/api/src/assistant/assistant.module.ts create mode 100644 apps/api/src/assistant/assistant.service.ts create mode 100644 apps/api/test/assistant.e2e-spec.ts create mode 100644 packages/shared/src/schemas/assistant.ts diff --git a/apps/ai/src/siop_ai/app.py b/apps/ai/src/siop_ai/app.py index 3060610..5c537d8 100644 --- a/apps/ai/src/siop_ai/app.py +++ b/apps/ai/src/siop_ai/app.py @@ -10,6 +10,7 @@ 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 @@ -69,3 +70,35 @@ 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 + ) + 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) + 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 new file mode 100644 index 0000000..8ad4b9b --- /dev/null +++ b/apps/ai/src/siop_ai/assistant.py @@ -0,0 +1,149 @@ +"""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é", +} + +SEUIL_PERTINENCE = 0.30 # en dessous : le corpus ne porte pas la réponse +SEUIL_SUGGESTION = 0.35 +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, +) -> 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, +) -> 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 diff --git a/apps/ai/src/siop_ai/embeddings.py b/apps/ai/src/siop_ai/embeddings.py index 38d33fa..5bc97f7 100644 --- a/apps/ai/src/siop_ai/embeddings.py +++ b/apps/ai/src/siop_ai/embeddings.py @@ -36,7 +36,10 @@ class EmbeddeurDeterministe: class EmbeddeurLocal: - """fastembed (ONNX, CPU) — chargé paresseusement, jamais importé en test.""" + """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) @@ -44,7 +47,12 @@ class EmbeddeurLocal: self._modele = TextEmbedding(model_name=MODELE_LOCAL) def encoder(self, textes: list[str]) -> list[list[float]]: - return [vecteur.tolist() for vecteur in self._modele.embed(textes)] + 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: diff --git a/apps/ai/tests/test_assistant.py b/apps/ai/tests/test_assistant.py new file mode 100644 index 0000000..877b666 --- /dev/null +++ b/apps/ai/tests/test_assistant.py @@ -0,0 +1,49 @@ +"""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 < suggestion < confiance forte : un extrait tout juste + # pertinent ne devient jamais une suggestion « forte » par accident. + assert 0 < SEUIL_PERTINENCE <= 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 diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 75d2109..6b6f80a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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] : []), ], diff --git a/apps/api/src/assistant/assistant.controller.ts b/apps/api/src/assistant/assistant.controller.ts new file mode 100644 index 0000000..2da946f --- /dev/null +++ b/apps/api/src/assistant/assistant.controller.ts @@ -0,0 +1,31 @@ +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); + } + + /** 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); + } +} diff --git a/apps/api/src/assistant/assistant.module.ts b/apps/api/src/assistant/assistant.module.ts new file mode 100644 index 0000000..25428b7 --- /dev/null +++ b/apps/api/src/assistant/assistant.module.ts @@ -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 {} diff --git a/apps/api/src/assistant/assistant.service.ts b/apps/api/src/assistant/assistant.service.ts new file mode 100644 index 0000000..96d87e6 --- /dev/null +++ b/apps/api/src/assistant/assistant.service.ts @@ -0,0 +1,107 @@ +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import type { + AssistantAnswer, + AssistantAsk, + BilanField, + BilanSuggestionsResponse, + 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(chemin: string, corps: unknown): Promise { + 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 { + 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 suggestBilan(dto: SuggestBilan): Promise { + 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, + })), + }; + } +} diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index be2ce1c..0e2c6c8 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -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; diff --git a/apps/api/test/assistant.e2e-spec.ts b/apps/api/test/assistant.e2e-spec.ts new file mode 100644 index 0000000..045fd1a --- /dev/null +++ b/apps/api/test/assistant.e2e-spec.ts @@ -0,0 +1,163 @@ +/** + * 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, + }, + ], +}; + +describe('Assistant (e2e — stub du service IA)', () => { + let app: INestApplication; + let stub: Server; + let ahmed: string; // Technicien : view + edit sur WORK_ORDERS + let karim: string; // Demandeur : aucun droit WORK_ORDERS + let rachid: string; // Vue seule : view sans edit + 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 { + res.statusCode = 404; + res.end('{}'); + } + }); + await new Promise((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'); + }); + + afterAll(async () => { + await app?.close(); + await new Promise((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('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((resolve) => stub.close(() => resolve())); + await http() + .post('/assistant/ask') + .set(auth(ahmed)) + .send({ question: 'le service est-il là ?' }) + .expect(503); + }); +}); diff --git a/apps/mobile/src/api/schema.d.ts b/apps/mobile/src/api/schema.d.ts index 539c5d6..9491462 100644 --- a/apps/mobile/src/api/schema.d.ts +++ b/apps/mobile/src/api/schema.d.ts @@ -441,6 +441,40 @@ 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; + }; "/search": { parameters: { query?: never; @@ -1446,6 +1480,44 @@ 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; + }; SearchResponse: { workOrders: { /** Format: uuid */ @@ -2967,6 +3039,68 @@ 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; + }; + }; + }; globalSearch: { parameters: { query: { diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts index 539c5d6..9491462 100644 --- a/apps/web/src/api/schema.d.ts +++ b/apps/web/src/api/schema.d.ts @@ -441,6 +441,40 @@ 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; + }; "/search": { parameters: { query?: never; @@ -1446,6 +1480,44 @@ 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; + }; SearchResponse: { workOrders: { /** Format: uuid */ @@ -2967,6 +3039,68 @@ 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; + }; + }; + }; globalSearch: { parameters: { query: { diff --git a/docs/journal/journal.md b/docs/journal/journal.md index 3bffedb..5c9ae64 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -4,6 +4,23 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** --- +## 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** diff --git a/docs/openapi.json b/docs/openapi.json index 990b7a8..40ef967 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1165,6 +1165,84 @@ } } }, + "/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" + } + } + } + }, "/search": { "get": { "operationId": "globalSearch", @@ -4975,6 +5053,209 @@ ], "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 + }, "SearchResponse": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index 9c2e5f6..0920667 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -50,6 +50,12 @@ import { DocumentsResponseSchema, } from './schemas/documents'; import { SearchResponseSchema } from './schemas/search'; +import { + AssistantAnswerSchema, + AssistantAskSchema, + BilanSuggestionsResponseSchema, + SuggestBilanSchema, +} from './schemas/assistant'; import { ConsumePartSchema, LaborTimeCreateSchema, @@ -508,6 +514,30 @@ 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: 'globalSearch', method: 'get', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 12f1bcc..e7fdcef 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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'; diff --git a/packages/shared/src/schemas/assistant.ts b/packages/shared/src/schemas/assistant.ts new file mode 100644 index 0000000..58a0643 --- /dev/null +++ b/packages/shared/src/schemas/assistant.ts @@ -0,0 +1,62 @@ +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; + +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; + +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; + +// ————— 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; + +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; + +export const BilanSuggestionsResponseSchema = z.object({ + suggestions: z.array(BilanSuggestionSchema), +}); +export type BilanSuggestionsResponse = z.infer;