mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Écran Voix R5 (maquetté, jamais construit) implémenté sur décision du référent : open-source et local, pas d'API externe. - apps/ai : faster-whisper (CTranslate2, CPU, MIT) opt-in (AI_TRANSCRIPTION=off|locale|deterministe, défaut off) ; endpoint /internal/transcrire — l'audio ne survit JAMAIS à l'appel (fichier temporaire supprimé quoi qu'il arrive) ; indexer_bilans inclut désormais InterventionReport.note anonymisée (champ existant depuis R2, jamais eu d'écran jusqu'ici) ; 29 pytest. - Contrat (77 opérations) : POST /assistant/transcribe (multipart). - API : proxy multipart vers siop2-ai (WORK_ORDERS.edit — même droit que la saisie du bilan) ; 2 tests e2e (80 tests API au total). - Mobile : expo-audio + expo-file-system, bouton dicter/terminer sur l'écran de clôture, purge locale après transcription, « Joindre la description à l'OT » (corrige un bug latent : enfilerBilan ignorait silencieusement les mises à jour de note). - Docker : siop2-ai embarque le modèle Whisper au build (1,54→2,19 Go), construit et vérifié (transcription réelle en conteneur, non-root). - Vérifié réellement : transcription fidèle (voix de synthèse française) en direct, bout en bout via l'API, dans le conteneur Docker construit, et chaîne corpus complète (note → clôture → réindexation → recherche sémantique). - Base de dev locale réinitialisée avec accord explicite du référent (prisma migrate reset, bloqué par défaut pour un agent IA) après pollution par les tests manuels de la recette terrain précédente. Reste : test tactile sur iPhone physique (bouton dicter) — bloqué par une connexion USB qui ne s'est pas rétablie malgré câble/port/ redémarrage essayés à plusieurs reprises, reporté comme la recette Android. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
211 lines
7.5 KiB
TypeScript
211 lines
7.5 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,
|
||
};
|
||
|
||
const REPONSE_TRANSCRIRE = { texte: 'Porte cabine qui rebondit, cellule encrassée.' };
|
||
|
||
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 if (req.url === '/internal/transcrire') res.end(JSON.stringify(REPONSE_TRANSCRIRE));
|
||
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('transcribe : dictée (D5) — texte transcrit, réservé à qui édite les OT', async () => {
|
||
const res = await http()
|
||
.post('/assistant/transcribe')
|
||
.set(auth(ahmed))
|
||
.attach('file', Buffer.from('faux-audio'), { filename: 'note.m4a', contentType: 'audio/m4a' })
|
||
.expect(200);
|
||
expect(res.body).toEqual({ text: 'Porte cabine qui rebondit, cellule encrassée.' });
|
||
expect(requetesRecues.at(-1)!.url).toBe('/internal/transcrire');
|
||
expect(requetesRecues.at(-1)!.jeton).toBe('jeton-de-test');
|
||
// Karim (Demandeur) n'a pas WORK_ORDERS.edit
|
||
await http()
|
||
.post('/assistant/transcribe')
|
||
.set(auth(karim))
|
||
.attach('file', Buffer.from('faux-audio'), { filename: 'note.m4a', contentType: 'audio/m4a' })
|
||
.expect(403);
|
||
});
|
||
|
||
it('transcribe : refuse proprement sans fichier joint', async () => {
|
||
await http().post('/assistant/transcribe').set(auth(ahmed)).expect(400);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|