mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +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:
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user