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

@@ -10,6 +10,7 @@ import asyncpg
from fastapi import Depends, FastAPI, Header, HTTPException from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from .assistant import repondre, suggerer_bilan
from .config import Reglages, charger_reglages from .config import Reglages, charger_reglages
from .embeddings import construire_embeddeur from .embeddings import construire_embeddeur
from .generation import construire_generateur from .generation import construire_generateur
@@ -69,3 +70,35 @@ async def rechercher(corps: RequeteRecherche) -> dict:
async with app.state.pool.acquire() as cnx: async with app.state.pool.acquire() as cnx:
extraits = await chercher(cnx, app.state.embeddeur, corps.question, corps.limite) extraits = await chercher(cnx, app.state.embeddeur, corps.question, corps.limite)
return {"extraits": [asdict(e) for e in extraits]} return {"extraits": [asdict(e) for e in extraits]}
@app.post("/internal/ask", dependencies=[Depends(verifier_jeton)])
async def demander(corps: RequeteRecherche) -> dict:
"""L'assistant D2 : extraits sourcés au-dessus du seuil, ou refus honnête
(ce qui a été cherché) — la rédaction n'existe qu'en mode génératif."""
async with app.state.pool.acquire() as cnx:
reponse = await repondre(
cnx, app.state.embeddeur, app.state.generateur, corps.question, corps.limite
)
return {
"mode": reponse.mode,
"answer": reponse.answer,
"extraits": [asdict(e) for e in reponse.extraits],
"corpus": {
"documents": reponse.documents_corpus,
"bilans": reponse.bilans_corpus,
},
}
class RequeteSuggestion(BaseModel):
description: str = Field(min_length=10, max_length=2000)
@app.post("/internal/suggest", dependencies=[Depends(verifier_jeton)])
async def suggerer(corps: RequeteSuggestion) -> dict:
"""Suggestion de codes de bilan (D1) : uniquement des codes EXISTANTS,
avec confiance et « N bilans similaires » — l'humain applique, ou pas."""
async with app.state.pool.acquire() as cnx:
suggestions = await suggerer_bilan(cnx, app.state.embeddeur, corps.description)
return {"suggestions": [asdict(s) for s in suggestions]}

View File

@@ -0,0 +1,149 @@
"""L'assistant (D2 — « sourcé ou silencieux ») et la suggestion de bilan.
- `repondre` : recherche sémantique → extraits au-dessus du seuil de
pertinence, ou refus HONNÊTE qui dit ce qui a été cherché (écran 2 des
maquettes). La rédaction est déléguée au `Generateur` (opt-in ADR-004 §3) ;
sans lui, le mode extractif est la réponse.
- `suggerer_bilan` : similarité sémantique entre la description libre et les
libellés ACTIFS des référentiels (l'IA ne peut suggérer que des codes
existants) + comptage des bilans similaires du parc. Sans LLM : rapide,
déterministe, explicable.
"""
from dataclasses import dataclass
import asyncpg
from .embeddings import Embeddeur
from .generation import Generateur
from .recherche import ExtraitTrouve, chercher
# Répliques des libellés français de @siop/shared (BILAN_FIELD_LABELS) —
# utilisés pour contextualiser les embeddings des codes.
CHAMPS_BILAN = {
"DOOR_STATE": "état des portes",
"CABIN_POSITION": "position cabine",
"ANOMALY": "anomalie constatée",
"EXTERNAL_CAUSE": "cause extérieure",
"ACTION_TAKEN": "action réalisée",
"COMPONENT_CONCERNED": "élément concerné",
}
SEUIL_PERTINENCE = 0.30 # en dessous : le corpus ne porte pas la réponse
SEUIL_SUGGESTION = 0.35
SEUIL_CONFIANCE_FORTE = 0.55
@dataclass
class ReponseAssistant:
mode: str # « extractif » | « genere » | « refus »
answer: str | None
extraits: list[ExtraitTrouve]
documents_corpus: int
bilans_corpus: int
async def _taille_corpus(cnx: asyncpg.Connection) -> tuple[int, int]:
ligne = await cnx.fetchrow(
'''
SELECT
(SELECT count(DISTINCT "documentId") FROM "RagChunk"
WHERE "sourceType" = 'DOCUMENT') AS documents,
(SELECT count(*) FROM "RagChunk" WHERE "sourceType" = 'WORK_ORDER') AS bilans
'''
)
return ligne["documents"], ligne["bilans"]
async def repondre(
cnx: asyncpg.Connection,
embeddeur: Embeddeur,
generateur: Generateur,
question: str,
limite: int = 5,
) -> ReponseAssistant:
documents, bilans = await _taille_corpus(cnx)
extraits = await chercher(cnx, embeddeur, question, limite)
pertinents = [e for e in extraits if e.score >= SEUIL_PERTINENCE]
if not pertinents:
# D2 : refus explicite — on dit ce qu'on a cherché, on n'invente rien.
return ReponseAssistant(
mode="refus",
answer=None,
extraits=[],
documents_corpus=documents,
bilans_corpus=bilans,
)
redige = generateur.rediger(question, pertinents)
return ReponseAssistant(
mode="genere" if redige else "extractif",
answer=redige,
extraits=pertinents,
documents_corpus=documents,
bilans_corpus=bilans,
)
@dataclass
class SuggestionBilan:
field: str
value_id: str
label: str
confidence: str # « FORTE » | « MOYENNE »
similar_reports: int # bilans du parc portant déjà ce code (« 9 bilans similaires »)
score: float
def _cosinus(a: list[float], b: list[float]) -> float:
return sum(x * y for x, y in zip(a, b)) # vecteurs déjà normés
async def suggerer_bilan(
cnx: asyncpg.Connection,
embeddeur: Embeddeur,
description: str,
) -> list[SuggestionBilan]:
valeurs = await cnx.fetch(
'SELECT id, field, label FROM "ReferenceValue" WHERE "isActive" ORDER BY field, label'
)
if not valeurs:
return []
textes = [description] + [
f"{CHAMPS_BILAN.get(v['field'], v['field'])} : {v['label']}" for v in valeurs
]
vecteurs = embeddeur.encoder(textes)
v_description, v_valeurs = vecteurs[0], vecteurs[1:]
# Le meilleur code par champ, au-dessus du seuil — jamais plus d'une
# suggestion par champ, jamais un code inventé.
meilleurs: dict[str, tuple[asyncpg.Record, float]] = {}
for valeur, vecteur in zip(valeurs, v_valeurs):
score = _cosinus(v_description, vecteur)
if score < SEUIL_SUGGESTION:
continue
champ = valeur["field"]
if champ not in meilleurs or score > meilleurs[champ][1]:
meilleurs[champ] = (valeur, score)
suggestions: list[SuggestionBilan] = []
for valeur, score in meilleurs.values():
# « 9 bilans similaires sur ce parc » : les bilans clôturés portant ce code
similaires = await cnx.fetchval(
'SELECT count(*) FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\' AND content ILIKE $1',
f"%{valeur['label']}%",
)
suggestions.append(
SuggestionBilan(
field=valeur["field"],
value_id=str(valeur["id"]),
label=valeur["label"],
confidence="FORTE" if score >= SEUIL_CONFIANCE_FORTE else "MOYENNE",
similar_reports=similaires,
score=round(score, 4),
)
)
suggestions.sort(key=lambda s: s.score, reverse=True)
return suggestions

View File

@@ -36,7 +36,10 @@ class EmbeddeurDeterministe:
class EmbeddeurLocal: class EmbeddeurLocal:
"""fastembed (ONNX, CPU) — chargé paresseusement, jamais importé en test.""" """fastembed (ONNX, CPU) — chargé paresseusement, jamais importé en test.
Sortie NORMÉE : fastembed ne garantit pas des vecteurs unitaires, or la
similarité par produit scalaire (suggestions) l'exige — pgvector, lui,
normalise dans son opérateur cosinus, ce qui masquait l'écart."""
def __init__(self) -> None: def __init__(self) -> None:
from fastembed import TextEmbedding # import différé (dépendance optionnelle) from fastembed import TextEmbedding # import différé (dépendance optionnelle)
@@ -44,7 +47,12 @@ class EmbeddeurLocal:
self._modele = TextEmbedding(model_name=MODELE_LOCAL) self._modele = TextEmbedding(model_name=MODELE_LOCAL)
def encoder(self, textes: list[str]) -> list[list[float]]: def encoder(self, textes: list[str]) -> list[list[float]]:
return [vecteur.tolist() for vecteur in self._modele.embed(textes)] vecteurs = []
for vecteur in self._modele.embed(textes):
liste = vecteur.tolist()
norme = math.sqrt(sum(v * v for v in liste)) or 1.0
vecteurs.append([v / norme for v in liste])
return vecteurs
def construire_embeddeur(mode: str) -> Embeddeur: def construire_embeddeur(mode: str) -> Embeddeur:

View File

@@ -0,0 +1,49 @@
"""Logique de l'assistant testée sans base : le seuil « sourcé ou silencieux »
et la sélection des suggestions — la partie SQL est couverte par la recette
réelle (convention R5.1 : pytest purs en CI)."""
from siop_ai.assistant import (
CHAMPS_BILAN,
SEUIL_CONFIANCE_FORTE,
SEUIL_PERTINENCE,
SEUIL_SUGGESTION,
_cosinus,
)
from siop_ai.embeddings import EmbeddeurDeterministe
def test_les_seuils_sont_ordonnes():
# pertinence < suggestion < confiance forte : un extrait tout juste
# pertinent ne devient jamais une suggestion « forte » par accident.
assert 0 < SEUIL_PERTINENCE <= SEUIL_SUGGESTION < SEUIL_CONFIANCE_FORTE < 1
def test_champs_bilan_couvrent_les_six_champs_du_contrat():
assert set(CHAMPS_BILAN) == {
"DOOR_STATE",
"CABIN_POSITION",
"ANOMALY",
"EXTERNAL_CAUSE",
"ACTION_TAKEN",
"COMPONENT_CONCERNED",
}
def test_similarite_discrimine_le_bon_code():
"""Le cœur de la suggestion : une description de panne de porte doit être
plus proche du code « portes » que d'un code sans rapport."""
embeddeur = EmbeddeurDeterministe()
description, porte, treuil = embeddeur.encoder(
[
"la porte cabine rebondit, cellule encrassée, nettoyage barrière porte",
"anomalie constatée : cellule ou barrière de porte encrassée",
"élément concerné : treuil et moteur de traction",
]
)
assert _cosinus(description, porte) > _cosinus(description, treuil)
def test_cosinus_de_vecteurs_normes():
embeddeur = EmbeddeurDeterministe()
[v] = embeddeur.encoder(["contrôle mensuel des portes palières"])
assert abs(_cosinus(v, v) - 1.0) < 1e-6

View File

@@ -2,6 +2,7 @@ import { DynamicModule, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { AnalyticsModule } from './analytics/analytics.module'; import { AnalyticsModule } from './analytics/analytics.module';
import { SearchModule } from './search/search.module'; import { SearchModule } from './search/search.module';
import { AssistantModule } from './assistant/assistant.module';
import { AssetsModule } from './assets/assets.module'; import { AssetsModule } from './assets/assets.module';
import { DocumentsModule } from './documents/documents.module'; import { DocumentsModule } from './documents/documents.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
@@ -63,6 +64,7 @@ export class AppModule {
DocumentsModule, DocumentsModule,
AnalyticsModule, AnalyticsModule,
SearchModule, SearchModule,
AssistantModule,
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404 // ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
...(demoModeEnabled() ? [DemoAuthModule] : []), ...(demoModeEnabled() ? [DemoAuthModule] : []),
], ],

View 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);
}
}

View 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 {}

View 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,
})),
};
}
}

View File

@@ -18,6 +18,9 @@ const EnvSchema = z.object({
// Vide = aucun CORS (défaut sûr) — le web de prod passe par le proxy nginx // 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. // même-origine, les apps natives n'envoient pas d'Origin.
CORS_ORIGINS: z.string().default(''), 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>; export type Env = z.infer<typeof EnvSchema>;

View 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 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('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);
});
});

View File

@@ -441,6 +441,40 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/assistant/ask": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Assistant R5 — sourcé ou silencieux : extraits cités ou refus honnête (D2) */
post: operations["askAssistant"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assistant/suggest-bilan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Suggérer des codes de bilan depuis une description libre (D1 — lhumain valide) */
post: operations["suggestBilan"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/search": { "/search": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1446,6 +1480,44 @@ export interface components {
total: number; total: number;
}[]; }[];
}; };
AssistantAnswer: {
/** @enum {string} */
mode: "EXTRACTIVE" | "GENERATED" | "REFUSAL";
answer: string | null;
excerpts: {
/** @enum {string} */
sourceType: "DOCUMENT" | "WORK_ORDER";
documentId: string | null;
workOrderId: string | null;
title: string;
locator: string;
content: string;
score: number;
}[];
corpus: {
documents: number;
reports: number;
};
};
AssistantAsk: {
question: string;
};
BilanSuggestions: {
suggestions: {
/** @enum {string} */
field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED";
/** Format: uuid */
valueId: string;
label: string;
/** @enum {string} */
confidence: "HIGH" | "MEDIUM";
similarReports: number;
score: number;
}[];
};
SuggestBilan: {
description: string;
};
SearchResponse: { SearchResponse: {
workOrders: { workOrders: {
/** Format: uuid */ /** Format: uuid */
@@ -2967,6 +3039,68 @@ export interface operations {
}; };
}; };
}; };
askAssistant: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AssistantAsk"];
};
};
responses: {
/** @description Réponse sourcée ou refus */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AssistantAnswer"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
suggestBilan: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SuggestBilan"];
};
};
responses: {
/** @description Suggestions (codes existants seulement) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BilanSuggestions"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
globalSearch: { globalSearch: {
parameters: { parameters: {
query: { query: {

View File

@@ -441,6 +441,40 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/assistant/ask": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Assistant R5 — sourcé ou silencieux : extraits cités ou refus honnête (D2) */
post: operations["askAssistant"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assistant/suggest-bilan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Suggérer des codes de bilan depuis une description libre (D1 — lhumain valide) */
post: operations["suggestBilan"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/search": { "/search": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1446,6 +1480,44 @@ export interface components {
total: number; total: number;
}[]; }[];
}; };
AssistantAnswer: {
/** @enum {string} */
mode: "EXTRACTIVE" | "GENERATED" | "REFUSAL";
answer: string | null;
excerpts: {
/** @enum {string} */
sourceType: "DOCUMENT" | "WORK_ORDER";
documentId: string | null;
workOrderId: string | null;
title: string;
locator: string;
content: string;
score: number;
}[];
corpus: {
documents: number;
reports: number;
};
};
AssistantAsk: {
question: string;
};
BilanSuggestions: {
suggestions: {
/** @enum {string} */
field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED";
/** Format: uuid */
valueId: string;
label: string;
/** @enum {string} */
confidence: "HIGH" | "MEDIUM";
similarReports: number;
score: number;
}[];
};
SuggestBilan: {
description: string;
};
SearchResponse: { SearchResponse: {
workOrders: { workOrders: {
/** Format: uuid */ /** Format: uuid */
@@ -2967,6 +3039,68 @@ export interface operations {
}; };
}; };
}; };
askAssistant: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AssistantAsk"];
};
};
responses: {
/** @description Réponse sourcée ou refus */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AssistantAnswer"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
suggestBilan: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SuggestBilan"];
};
};
responses: {
/** @description Suggestions (codes existants seulement) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BilanSuggestions"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
globalSearch: { globalSearch: {
parameters: { parameters: {
query: { query: {

View File

@@ -4,6 +4,23 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
--- ---
## 2026-07-17 — Pr. Daaif (+ Claude) — R5.2 : l'assistant au contrat + suggestion de codes de bilan
**Actions**
- **`apps/ai`** : `/internal/ask` — recherche → **seuil de pertinence** → extraits sourcés, ou **refus honnête** portant la taille du corpus cherché (« 1 document, 3 bilans » — l'écran 2 des maquettes aura ses chiffres) ; la rédaction passe par le `Generateur` (opt-in — mode extractif par défaut). `/internal/suggest` — similarité sémantique entre la description libre et les libellés **actifs** des référentiels (contextualisés « anomalie constatée : … »), un seul code par champ, au-dessus du seuil, confiance FORTE/MOYENNE + « N bilans similaires sur ce parc » (comptés dans les chunks d'historique). Sans LLM : déterministe, explicable. 23 pytest.
- **Contrat** (74 opérations) : `POST /assistant/ask``AssistantAnswer` (mode EXTRACTIVE/GENERATED/REFUSAL, extraits cités document/page ou bilan daté, corpus cherché) ; `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` à l'env, ADR-004 §4), permissions par la matrice (`ask` = WORK_ORDERS.view ; `suggest` = WORK_ORDERS.edit — qui remplit des bilans), traduction du dialecte interne vers le contrat, **503 propre** si le service IA est éteint (jamais un 500). 6 tests e2e sur un **stub HTTP** (76 tests API, 14 suites).
- **Vérifié sur la vraie chaîne** (NestJS → ai → pgvector, vrai modèle) — et elle a débusqué un bug : fastembed ne renvoie pas des vecteurs normés, la similarité des suggestions dépassait 1 (pgvector normalisait dans son opérateur, ce qui masquait l'écart). **Normalisation à l'encodage** + réindexation : bilans du parc trouvés en tête (0.41), refus honnête hors corpus, suggestions cosinus ≤ 1 à confiances étagées.
**Décisions**
- Les seuils (pertinence 0.30, suggestion 0.35, confiance forte 0.55) sont des constantes de départ — à calibrer en recette sur le corpus réel du client.
**Prochaine étape** : R5.3 — les écrans validés (Assistant sidebar, corpus dans la Bibliothèque, suggestions dans les fiches OT web et clôture mobile). Restes : recette R4 sur appareil, redéploiement Dokploy de `release/r3`.
---
## 2026-07-17 — Pr. Daaif (+ Claude) — R5.1+ : clé API de génération configurable (demande du référent) ## 2026-07-17 — Pr. Daaif (+ Claude) — R5.1+ : clé API de génération configurable (demande du référent)
**Actions** **Actions**

View File

@@ -1165,6 +1165,84 @@
} }
} }
}, },
"/assistant/ask": {
"post": {
"operationId": "askAssistant",
"summary": "Assistant R5 — sourcé ou silencieux : extraits cités ou refus honnête (D2)",
"tags": [
"assistant"
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssistantAsk"
}
}
}
},
"responses": {
"200": {
"description": "Réponse sourcée ou refus",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssistantAnswer"
}
}
}
},
"503": {
"description": "Service IA indisponible"
}
}
}
},
"/assistant/suggest-bilan": {
"post": {
"operationId": "suggestBilan",
"summary": "Suggérer des codes de bilan depuis une description libre (D1 — lhumain valide)",
"tags": [
"assistant"
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SuggestBilan"
}
}
}
},
"responses": {
"200": {
"description": "Suggestions (codes existants seulement)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BilanSuggestions"
}
}
}
},
"503": {
"description": "Service IA indisponible"
}
}
}
},
"/search": { "/search": {
"get": { "get": {
"operationId": "globalSearch", "operationId": "globalSearch",
@@ -4975,6 +5053,209 @@
], ],
"additionalProperties": false "additionalProperties": false
}, },
"AssistantAnswer": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": [
"EXTRACTIVE",
"GENERATED",
"REFUSAL"
]
},
"answer": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"excerpts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"sourceType": {
"type": "string",
"enum": [
"DOCUMENT",
"WORK_ORDER"
]
},
"documentId": {
"anyOf": [
{
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
{
"type": "null"
}
]
},
"workOrderId": {
"anyOf": [
{
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
{
"type": "null"
}
]
},
"title": {
"type": "string"
},
"locator": {
"type": "string"
},
"content": {
"type": "string"
},
"score": {
"type": "number"
}
},
"required": [
"sourceType",
"documentId",
"workOrderId",
"title",
"locator",
"content",
"score"
],
"additionalProperties": false
}
},
"corpus": {
"type": "object",
"properties": {
"documents": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"reports": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
}
},
"required": [
"documents",
"reports"
],
"additionalProperties": false
}
},
"required": [
"mode",
"answer",
"excerpts",
"corpus"
],
"additionalProperties": false
},
"AssistantAsk": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"question": {
"type": "string",
"minLength": 3,
"maxLength": 500
}
},
"required": [
"question"
],
"additionalProperties": false
},
"BilanSuggestions": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"suggestions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"field": {
"type": "string",
"enum": [
"DOOR_STATE",
"CABIN_POSITION",
"ANOMALY",
"EXTERNAL_CAUSE",
"ACTION_TAKEN",
"COMPONENT_CONCERNED"
]
},
"valueId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"label": {
"type": "string"
},
"confidence": {
"type": "string",
"enum": [
"HIGH",
"MEDIUM"
]
},
"similarReports": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"score": {
"type": "number"
}
},
"required": [
"field",
"valueId",
"label",
"confidence",
"similarReports",
"score"
],
"additionalProperties": false
}
}
},
"required": [
"suggestions"
],
"additionalProperties": false
},
"SuggestBilan": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"description": {
"type": "string",
"minLength": 10,
"maxLength": 2000
}
},
"required": [
"description"
],
"additionalProperties": false
},
"SearchResponse": { "SearchResponse": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object", "type": "object",

View File

@@ -50,6 +50,12 @@ import {
DocumentsResponseSchema, DocumentsResponseSchema,
} from './schemas/documents'; } from './schemas/documents';
import { SearchResponseSchema } from './schemas/search'; import { SearchResponseSchema } from './schemas/search';
import {
AssistantAnswerSchema,
AssistantAskSchema,
BilanSuggestionsResponseSchema,
SuggestBilanSchema,
} from './schemas/assistant';
import { import {
ConsumePartSchema, ConsumePartSchema,
LaborTimeCreateSchema, LaborTimeCreateSchema,
@@ -508,6 +514,30 @@ export const API_CONTRACT: ApiOperation[] = [
200: { description: 'Synthèse', name: 'AnalyticsSummary', schema: AnalyticsSummarySchema }, 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', operationId: 'globalSearch',
method: 'get', method: 'get',

View File

@@ -12,4 +12,5 @@ export * from './schemas/users-admin';
export * from './schemas/referentiel'; export * from './schemas/referentiel';
export * from './schemas/health'; export * from './schemas/health';
export * from './schemas/search'; export * from './schemas/search';
export * from './schemas/assistant';
export * from './contract'; 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>;