mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
@@ -2,6 +2,7 @@ import { DynamicModule, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AnalyticsModule } from './analytics/analytics.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { AssistantModule } from './assistant/assistant.module';
|
||||
import { AssetsModule } from './assets/assets.module';
|
||||
import { DocumentsModule } from './documents/documents.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
@@ -63,6 +64,7 @@ export class AppModule {
|
||||
DocumentsModule,
|
||||
AnalyticsModule,
|
||||
SearchModule,
|
||||
AssistantModule,
|
||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||
],
|
||||
|
||||
31
apps/api/src/assistant/assistant.controller.ts
Normal file
31
apps/api/src/assistant/assistant.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
||||
import {
|
||||
AssistantAskSchema,
|
||||
SuggestBilanSchema,
|
||||
type AssistantAsk,
|
||||
type SuggestBilan,
|
||||
} from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { AssistantService } from './assistant.service';
|
||||
|
||||
@Controller('assistant')
|
||||
export class AssistantController {
|
||||
constructor(private readonly assistant: AssistantService) {}
|
||||
|
||||
/** Poser une question — qui lit les OT peut interroger le corpus. */
|
||||
@Post('ask')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('WORK_ORDERS', 'view')
|
||||
ask(@Body(new ZodValidationPipe(AssistantAskSchema)) body: AssistantAsk) {
|
||||
return this.assistant.ask(body);
|
||||
}
|
||||
|
||||
/** Suggérer des codes — réservé à qui remplit des bilans (D1). */
|
||||
@Post('suggest-bilan')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('WORK_ORDERS', 'edit')
|
||||
suggest(@Body(new ZodValidationPipe(SuggestBilanSchema)) body: SuggestBilan) {
|
||||
return this.assistant.suggestBilan(body);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/assistant/assistant.module.ts
Normal file
9
apps/api/src/assistant/assistant.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AssistantController } from './assistant.controller';
|
||||
import { AssistantService } from './assistant.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AssistantController],
|
||||
providers: [AssistantService],
|
||||
})
|
||||
export class AssistantModule {}
|
||||
107
apps/api/src/assistant/assistant.service.ts
Normal file
107
apps/api/src/assistant/assistant.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import type {
|
||||
AssistantAnswer,
|
||||
AssistantAsk,
|
||||
BilanField,
|
||||
BilanSuggestionsResponse,
|
||||
SuggestBilan,
|
||||
} from '@siop/shared';
|
||||
import { loadEnv } from '../config/env';
|
||||
|
||||
/** Proxy vers `siop2-ai` (ADR-004 §4) : le service IA n'est JAMAIS public —
|
||||
* l'API porte l'auth utilisateur (matrice) et le jeton de service interne.
|
||||
* Il traduit aussi le dialecte interne (français, snake_case) vers le
|
||||
* contrat (@siop/shared) — une seule vérité côté clients. */
|
||||
|
||||
interface ExtraitInterne {
|
||||
source_type: 'DOCUMENT' | 'WORK_ORDER';
|
||||
document_id: string | null;
|
||||
work_order_id: string | null;
|
||||
titre: string;
|
||||
locator: string;
|
||||
content: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
const MODES = { extractif: 'EXTRACTIVE', genere: 'GENERATED', refus: 'REFUSAL' } as const;
|
||||
const CONFIANCES = { FORTE: 'HIGH', MOYENNE: 'MEDIUM' } as const;
|
||||
|
||||
@Injectable()
|
||||
export class AssistantService {
|
||||
private readonly journal = new Logger(AssistantService.name);
|
||||
private readonly env = loadEnv();
|
||||
|
||||
private async appeler<T>(chemin: string, corps: unknown): Promise<T> {
|
||||
let reponse: Response;
|
||||
try {
|
||||
reponse = await fetch(`${this.env.AI_SERVICE_URL}${chemin}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Service-Token': this.env.AI_SERVICE_TOKEN,
|
||||
},
|
||||
body: JSON.stringify(corps),
|
||||
});
|
||||
} catch {
|
||||
this.journal.warn(`Service IA injoignable (${chemin})`);
|
||||
throw new ServiceUnavailableException(
|
||||
'Assistant indisponible pour le moment — réessayez dans un instant.',
|
||||
);
|
||||
}
|
||||
if (!reponse.ok) {
|
||||
this.journal.warn(`Service IA a refusé ${chemin} (${reponse.status})`);
|
||||
throw new ServiceUnavailableException(
|
||||
'Assistant indisponible pour le moment — réessayez dans un instant.',
|
||||
);
|
||||
}
|
||||
return (await reponse.json()) as T;
|
||||
}
|
||||
|
||||
async ask(dto: AssistantAsk): Promise<AssistantAnswer> {
|
||||
const brut = await this.appeler<{
|
||||
mode: keyof typeof MODES;
|
||||
answer: string | null;
|
||||
extraits: ExtraitInterne[];
|
||||
corpus: { documents: number; bilans: number };
|
||||
}>('/internal/ask', { question: dto.question });
|
||||
|
||||
return {
|
||||
mode: MODES[brut.mode],
|
||||
answer: brut.answer,
|
||||
excerpts: brut.extraits.map((e) => ({
|
||||
sourceType: e.source_type,
|
||||
documentId: e.document_id,
|
||||
workOrderId: e.work_order_id,
|
||||
title: e.titre,
|
||||
locator: e.locator,
|
||||
content: e.content,
|
||||
score: e.score,
|
||||
})),
|
||||
corpus: { documents: brut.corpus.documents, reports: brut.corpus.bilans },
|
||||
};
|
||||
}
|
||||
|
||||
async suggestBilan(dto: SuggestBilan): Promise<BilanSuggestionsResponse> {
|
||||
const brut = await this.appeler<{
|
||||
suggestions: {
|
||||
field: BilanField;
|
||||
value_id: string;
|
||||
label: string;
|
||||
confidence: keyof typeof CONFIANCES;
|
||||
similar_reports: number;
|
||||
score: number;
|
||||
}[];
|
||||
}>('/internal/suggest', { description: dto.description });
|
||||
|
||||
return {
|
||||
suggestions: brut.suggestions.map((s) => ({
|
||||
field: s.field,
|
||||
valueId: s.value_id,
|
||||
label: s.label,
|
||||
confidence: CONFIANCES[s.confidence],
|
||||
similarReports: s.similar_reports,
|
||||
score: s.score,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ const EnvSchema = z.object({
|
||||
// Vide = aucun CORS (défaut sûr) — le web de prod passe par le proxy nginx
|
||||
// même-origine, les apps natives n'envoient pas d'Origin.
|
||||
CORS_ORIGINS: z.string().default(''),
|
||||
// R5 (ADR-004 §4) : le service IA interne — seul l'API le contacte.
|
||||
AI_SERVICE_URL: z.string().default('http://localhost:8000'),
|
||||
AI_SERVICE_TOKEN: z.string().default('dev-only-ai-token'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof EnvSchema>;
|
||||
|
||||
163
apps/api/test/assistant.e2e-spec.ts
Normal file
163
apps/api/test/assistant.e2e-spec.ts
Normal 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 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('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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user