Files
siop2/apps/api/test/assistant.e2e-spec.ts
pr-daaif 59ed6f3952 feat(r5): dictée — audio local (faster-whisper) → note → corpus (ADR-004 §5)
É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>
2026-07-22 11:55:47 +01:00

211 lines
7.5 KiB
TypeScript
Raw Permalink 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,
},
],
};
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 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('reindex : traduit le bilan dindexation, 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);
});
});