Files
siop2/apps/api/test/assistant.e2e-spec.ts
pr-daaif 45ae491827 feat(r5.2): assistant au contrat + suggestion de codes de bilan
apps/ai : /internal/ask — seuil de pertinence, extraits sourcés ou
refus honnête portant la taille du corpus cherché (D2), rédaction via
le Generateur opt-in ; /internal/suggest — similarité sémantique entre
la description libre et les libellés ACTIFS des référentiels, un code
par champ, confiance FORTE/MOYENNE, « N bilans similaires sur ce
parc ». Sans LLM : déterministe, explicable. 23 pytest.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:05:08 +01:00

164 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
});
});