mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
- 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 <noreply@anthropic.com>
187 lines
6.4 KiB
TypeScript
187 lines
6.4 KiB
TypeScript
/**
|
||
* E2E R5.2 — assistant au contrat : le service IA reste interne, l'API porte
|
||
* l'auth et la matrice ; le dialecte interne est traduit vers @siop/shared.
|
||
* Le service IA est joué par un STUB HTTP local (la vraie chaîne se vérifie
|
||
* en recette réelle — convention R5.1).
|
||
*/
|
||
process.env.DEMO_MODE = 'true';
|
||
|
||
import { createServer, type Server } from 'node:http';
|
||
import { INestApplication } from '@nestjs/common';
|
||
import { Test } from '@nestjs/testing';
|
||
import request from 'supertest';
|
||
import { AppModule } from '../src/app.module';
|
||
|
||
const REPONSE_ASK = {
|
||
mode: 'extractif',
|
||
answer: null,
|
||
extraits: [
|
||
{
|
||
source_type: 'DOCUMENT',
|
||
document_id: '7d7bfa5c-2f43-4f9e-9e59-3c1f0a5df001',
|
||
work_order_id: null,
|
||
titre: 'Notice Gen2.pdf',
|
||
locator: 'p. 42',
|
||
content: 'Serrer les coulisseaux au couple de 25 N·m.',
|
||
score: 0.61,
|
||
},
|
||
],
|
||
corpus: { documents: 6, bilans: 214 },
|
||
};
|
||
|
||
const REPONSE_SUGGEST = {
|
||
suggestions: [
|
||
{
|
||
field: 'ANOMALY',
|
||
value_id: '7d7bfa5c-2f43-4f9e-9e59-3c1f0a5df002',
|
||
label: 'Cellule/barrière encrassée',
|
||
confidence: 'FORTE',
|
||
similar_reports: 9,
|
||
score: 0.62,
|
||
},
|
||
],
|
||
};
|
||
|
||
const REPONSE_REINDEX = {
|
||
documents_indexes: 6,
|
||
documents_ignores: 2,
|
||
bilans_indexes: 214,
|
||
extraits: 180,
|
||
};
|
||
|
||
describe('Assistant (e2e — stub du service IA)', () => {
|
||
let app: INestApplication;
|
||
let stub: Server;
|
||
let ahmed: string; // Technicien : view + edit sur WORK_ORDERS, ASSETS en lecture
|
||
let karim: string; // Demandeur : aucun droit WORK_ORDERS
|
||
let rachid: string; // Vue seule : view sans edit
|
||
let nadia: string; // Gestionnaire : ASSETS.edit — administre le corpus
|
||
const requetesRecues: { url: string; jeton: string | undefined }[] = [];
|
||
const http = () => request(app.getHttpServer());
|
||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||
|
||
beforeAll(async () => {
|
||
// Stub du service IA sur un port éphémère
|
||
stub = createServer((req, res) => {
|
||
requetesRecues.push({
|
||
url: req.url ?? '',
|
||
jeton: req.headers['x-service-token'] as string | undefined,
|
||
});
|
||
res.setHeader('Content-Type', 'application/json');
|
||
if (req.url === '/internal/ask') res.end(JSON.stringify(REPONSE_ASK));
|
||
else if (req.url === '/internal/suggest') res.end(JSON.stringify(REPONSE_SUGGEST));
|
||
else if (req.url === '/internal/reindex') res.end(JSON.stringify(REPONSE_REINDEX));
|
||
else {
|
||
res.statusCode = 404;
|
||
res.end('{}');
|
||
}
|
||
});
|
||
await new Promise<void>((resolve) => stub.listen(0, '127.0.0.1', resolve));
|
||
const adresse = stub.address();
|
||
const port = typeof adresse === 'object' && adresse ? adresse.port : 0;
|
||
process.env.AI_SERVICE_URL = `http://127.0.0.1:${port}`;
|
||
process.env.AI_SERVICE_TOKEN = 'jeton-de-test';
|
||
|
||
const moduleRef = await Test.createTestingModule({
|
||
imports: [AppModule.forRoot()],
|
||
}).compile();
|
||
app = moduleRef.createNestApplication();
|
||
await app.init();
|
||
const { body } = await http().get('/auth/demo-accounts');
|
||
const login = async (roleName: string) => {
|
||
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||
.accessToken as string;
|
||
};
|
||
ahmed = await login('Technicien');
|
||
karim = await login('Demandeur');
|
||
rachid = await login('Vue seule');
|
||
nadia = await login('Gestionnaire');
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await app?.close();
|
||
await new Promise<void>((resolve) => stub.close(() => resolve()));
|
||
});
|
||
|
||
it('ask : traduit le dialecte interne vers le contrat, avec le jeton de service', async () => {
|
||
const res = await http()
|
||
.post('/assistant/ask')
|
||
.set(auth(ahmed))
|
||
.send({ question: 'quel couple de serrage pour les guides ?' })
|
||
.expect(200);
|
||
expect(res.body.mode).toBe('EXTRACTIVE');
|
||
expect(res.body.excerpts[0]).toMatchObject({
|
||
sourceType: 'DOCUMENT',
|
||
title: 'Notice Gen2.pdf',
|
||
locator: 'p. 42',
|
||
});
|
||
expect(res.body.corpus).toEqual({ documents: 6, reports: 214 });
|
||
const derniere = requetesRecues.at(-1)!;
|
||
expect(derniere.url).toBe('/internal/ask');
|
||
expect(derniere.jeton).toBe('jeton-de-test'); // ADR-004 §4
|
||
});
|
||
|
||
it('suggest-bilan : codes existants traduits (FORTE → HIGH, snake → camel)', async () => {
|
||
const res = await http()
|
||
.post('/assistant/suggest-bilan')
|
||
.set(auth(ahmed))
|
||
.send({ description: 'porte cabine qui rebondit, cellule encrassée, nettoyage fait' })
|
||
.expect(200);
|
||
expect(res.body.suggestions[0]).toMatchObject({
|
||
field: 'ANOMALY',
|
||
confidence: 'HIGH',
|
||
similarReports: 9,
|
||
});
|
||
});
|
||
|
||
it('la matrice s’applique : demandeur sans WORK_ORDERS → 403 sur ask', async () => {
|
||
await http()
|
||
.post('/assistant/ask')
|
||
.set(auth(karim))
|
||
.send({ question: 'où sont les notices ?' })
|
||
.expect(403);
|
||
});
|
||
|
||
it('vue seule : ask autorisé (view), suggest refusé (edit requis — D1)', async () => {
|
||
await http()
|
||
.post('/assistant/ask')
|
||
.set(auth(rachid))
|
||
.send({ question: 'historique du parc ?' })
|
||
.expect(200);
|
||
await http()
|
||
.post('/assistant/suggest-bilan')
|
||
.set(auth(rachid))
|
||
.send({ description: 'une description suffisamment longue ici' })
|
||
.expect(403);
|
||
});
|
||
|
||
it('reindex : traduit le bilan d’indexation, réservé à ASSETS.edit (D3)', async () => {
|
||
const res = await http().post('/assistant/reindex').set(auth(nadia)).expect(200);
|
||
expect(res.body).toEqual({
|
||
documentsIndexed: 6,
|
||
documentsSkipped: 2,
|
||
reportsIndexed: 214,
|
||
chunks: 180,
|
||
});
|
||
expect(requetesRecues.at(-1)!.url).toBe('/internal/reindex');
|
||
// Ahmed (Technicien) lit le parc mais n'administre pas le corpus
|
||
await http().post('/assistant/reindex').set(auth(ahmed)).expect(403);
|
||
});
|
||
|
||
it('question trop courte : 400 avant tout appel au service IA', async () => {
|
||
const avant = requetesRecues.length;
|
||
await http().post('/assistant/ask').set(auth(ahmed)).send({ question: 'ab' }).expect(400);
|
||
expect(requetesRecues.length).toBe(avant);
|
||
});
|
||
|
||
it('service IA éteint : 503 propre, jamais un 500', async () => {
|
||
await new Promise<void>((resolve) => stub.close(() => resolve()));
|
||
await http()
|
||
.post('/assistant/ask')
|
||
.set(auth(ahmed))
|
||
.send({ question: 'le service est-il là ?' })
|
||
.expect(503);
|
||
});
|
||
});
|