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:
pr-daaif
2026-07-17 15:05:08 +01:00
parent 76c2ccdfb1
commit 45ae491827
17 changed files with 1215 additions and 2 deletions

View File

@@ -50,6 +50,12 @@ import {
DocumentsResponseSchema,
} from './schemas/documents';
import { SearchResponseSchema } from './schemas/search';
import {
AssistantAnswerSchema,
AssistantAskSchema,
BilanSuggestionsResponseSchema,
SuggestBilanSchema,
} from './schemas/assistant';
import {
ConsumePartSchema,
LaborTimeCreateSchema,
@@ -508,6 +514,30 @@ export const API_CONTRACT: ApiOperation[] = [
200: { description: 'Synthèse', name: 'AnalyticsSummary', schema: AnalyticsSummarySchema },
},
},
{
operationId: 'askAssistant',
method: 'post',
path: '/assistant/ask',
summary: 'Assistant R5 — sourcé ou silencieux : extraits cités ou refus honnête (D2)',
tags: ['assistant'],
request: { name: 'AssistantAsk', schema: AssistantAskSchema },
responses: {
200: { description: 'Réponse sourcée ou refus', name: 'AssistantAnswer', schema: AssistantAnswerSchema },
503: { description: 'Service IA indisponible' },
},
},
{
operationId: 'suggestBilan',
method: 'post',
path: '/assistant/suggest-bilan',
summary: 'Suggérer des codes de bilan depuis une description libre (D1 — lhumain valide)',
tags: ['assistant'],
request: { name: 'SuggestBilan', schema: SuggestBilanSchema },
responses: {
200: { description: 'Suggestions (codes existants seulement)', name: 'BilanSuggestions', schema: BilanSuggestionsResponseSchema },
503: { description: 'Service IA indisponible' },
},
},
{
operationId: 'globalSearch',
method: 'get',

View File

@@ -12,4 +12,5 @@ export * from './schemas/users-admin';
export * from './schemas/referentiel';
export * from './schemas/health';
export * from './schemas/search';
export * from './schemas/assistant';
export * from './contract';

View File

@@ -0,0 +1,62 @@
import { z } from 'zod';
import { BILAN_FIELDS } from '../exploitation';
/** Assistant R5 (D1/D2 validées) : sourcé ou silencieux, l'humain valide.
* Le web/mobile parlent à l'API NestJS ; le service `siop2-ai` reste
* interne (ADR-004 §4). */
export const ASSISTANT_MIN_CHARS = 3;
export const AssistantAskSchema = z.object({
question: z.string().min(ASSISTANT_MIN_CHARS).max(500),
});
export type AssistantAsk = z.infer<typeof AssistantAskSchema>;
export const AssistantExcerptSchema = z.object({
sourceType: z.enum(['DOCUMENT', 'WORK_ORDER']),
documentId: z.uuid().nullable(),
workOrderId: z.uuid().nullable(),
title: z.string(), // nom de fichier ou référence d'OT
locator: z.string(), // « p. 42 », « bilan du 17/07/2026 »
content: z.string(), // l'extrait EXACT — la citation montre sa source
score: z.number(),
});
export type AssistantExcerpt = z.infer<typeof AssistantExcerptSchema>;
export const AssistantAnswerSchema = z.object({
/** REFUSAL = le corpus ne porte pas la réponse — l'assistant le dit (D2). */
mode: z.enum(['EXTRACTIVE', 'GENERATED', 'REFUSAL']),
/** Rédaction (mode génératif opt-in, ADR-004 §3) — null en extractif/refus. */
answer: z.string().nullable(),
excerpts: z.array(AssistantExcerptSchema),
/** Ce qui a été cherché — le refus honnête l'affiche (« 6 documents, 214 bilans »). */
corpus: z.object({
documents: z.number().int(),
reports: z.number().int(),
}),
});
export type AssistantAnswer = z.infer<typeof AssistantAnswerSchema>;
// ————— Suggestion de codes de bilan (sans LLM — ADR-004 §3) —————
export const SuggestBilanSchema = z.object({
description: z.string().min(10).max(2000),
});
export type SuggestBilan = z.infer<typeof SuggestBilanSchema>;
export const BilanSuggestionSchema = z.object({
field: z.enum(BILAN_FIELDS),
/** Toujours un code EXISTANT du référentiel — l'IA n'invente pas de valeur. */
valueId: z.uuid(),
label: z.string(),
confidence: z.enum(['HIGH', 'MEDIUM']),
/** « N bilans similaires sur ce parc » — la justification vérifiable. */
similarReports: z.number().int(),
score: z.number(),
});
export type BilanSuggestion = z.infer<typeof BilanSuggestionSchema>;
export const BilanSuggestionsResponseSchema = z.object({
suggestions: z.array(BilanSuggestionSchema),
});
export type BilanSuggestionsResponse = z.infer<typeof BilanSuggestionsResponseSchema>;