feat(r5.2): assistant au contrat + suggestion de codes de bilan

apps/ai : /internal/ask — seuil de pertinence, extraits sourcés ou
refus honnête portant la taille du corpus cherché (D2), rédaction via
le Generateur opt-in ; /internal/suggest — similarité sémantique entre
la description libre et les libellés ACTIFS des référentiels, un code
par champ, confiance FORTE/MOYENNE, « N bilans similaires sur ce
parc ». Sans LLM : déterministe, explicable. 23 pytest.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-17 15:05:08 +01:00
parent 76c2ccdfb1
commit 45ae491827
17 changed files with 1215 additions and 2 deletions

View File

@@ -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<void>((resolve) => stub.listen(0, '127.0.0.1', resolve));
const adresse = stub.address();
const port = typeof adresse === 'object' && adresse ? adresse.port : 0;
process.env.AI_SERVICE_URL = `http://127.0.0.1:${port}`;
process.env.AI_SERVICE_TOKEN = 'jeton-de-test';
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken as string;
};
ahmed = await login('Technicien');
karim = await login('Demandeur');
rachid = await login('Vue seule');
});
afterAll(async () => {
await app?.close();
await new Promise<void>((resolve) => stub.close(() => resolve()));
});
it('ask : traduit le dialecte interne vers le contrat, avec le jeton de service', async () => {
const res = await http()
.post('/assistant/ask')
.set(auth(ahmed))
.send({ question: 'quel couple de serrage pour les guides ?' })
.expect(200);
expect(res.body.mode).toBe('EXTRACTIVE');
expect(res.body.excerpts[0]).toMatchObject({
sourceType: 'DOCUMENT',
title: 'Notice Gen2.pdf',
locator: 'p. 42',
});
expect(res.body.corpus).toEqual({ documents: 6, reports: 214 });
const derniere = requetesRecues.at(-1)!;
expect(derniere.url).toBe('/internal/ask');
expect(derniere.jeton).toBe('jeton-de-test'); // ADR-004 §4
});
it('suggest-bilan : codes existants traduits (FORTE → HIGH, snake → camel)', async () => {
const res = await http()
.post('/assistant/suggest-bilan')
.set(auth(ahmed))
.send({ description: 'porte cabine qui rebondit, cellule encrassée, nettoyage fait' })
.expect(200);
expect(res.body.suggestions[0]).toMatchObject({
field: 'ANOMALY',
confidence: 'HIGH',
similarReports: 9,
});
});
it('la matrice sapplique : demandeur sans WORK_ORDERS → 403 sur ask', async () => {
await http()
.post('/assistant/ask')
.set(auth(karim))
.send({ question: 'où sont les notices ?' })
.expect(403);
});
it('vue seule : ask autorisé (view), suggest refusé (edit requis — D1)', async () => {
await http()
.post('/assistant/ask')
.set(auth(rachid))
.send({ question: 'historique du parc ?' })
.expect(200);
await http()
.post('/assistant/suggest-bilan')
.set(auth(rachid))
.send({ description: 'une description suffisamment longue ici' })
.expect(403);
});
it('question trop courte : 400 avant tout appel au service IA', async () => {
const avant = requetesRecues.length;
await http().post('/assistant/ask').set(auth(ahmed)).send({ question: 'ab' }).expect(400);
expect(requetesRecues.length).toBe(avant);
});
it('service IA éteint : 503 propre, jamais un 500', async () => {
await new Promise<void>((resolve) => stub.close(() => resolve()));
await http()
.post('/assistant/ask')
.set(auth(ahmed))
.send({ question: 'le service est-il là ?' })
.expect(503);
});
});