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 pydantic import BaseModel, Field
from .assistant import repondre, suggerer_bilan
from .config import Reglages, charger_reglages
from .embeddings import construire_embeddeur
from .generation import construire_generateur
@@ -69,3 +70,35 @@ async def rechercher(corps: RequeteRecherche) -> dict:
async with app.state.pool.acquire() as cnx:
extraits = await chercher(cnx, app.state.embeddeur, corps.question, corps.limite)
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:
"""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:
from fastembed import TextEmbedding # import différé (dépendance optionnelle)
@@ -44,7 +47,12 @@ class EmbeddeurLocal:
self._modele = TextEmbedding(model_name=MODELE_LOCAL)
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:

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 { 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] : []),
],

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
// 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>;

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;
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": {
parameters: {
query?: never;
@@ -1446,6 +1480,44 @@ export interface components {
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: {
workOrders: {
/** 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: {
parameters: {
query: {

View File

@@ -441,6 +441,40 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -1446,6 +1480,44 @@ export interface components {
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: {
workOrders: {
/** 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: {
parameters: {
query: {