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:
@@ -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]}
|
||||||
|
|||||||
149
apps/ai/src/siop_ai/assistant.py
Normal file
149
apps/ai/src/siop_ai/assistant.py
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
49
apps/ai/tests/test_assistant.py
Normal file
49
apps/ai/tests/test_assistant.py
Normal 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
|
||||||
@@ -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] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
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
|
// 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>;
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
134
apps/mobile/src/api/schema.d.ts
vendored
134
apps/mobile/src/api/schema.d.ts
vendored
@@ -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 — l’humain 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: {
|
||||||
|
|||||||
134
apps/web/src/api/schema.d.ts
vendored
134
apps/web/src/api/schema.d.ts
vendored
@@ -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 — l’humain 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: {
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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 — l’humain 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",
|
||||||
|
|||||||
@@ -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 — l’humain 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',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
62
packages/shared/src/schemas/assistant.ts
Normal file
62
packages/shared/src/schemas/assistant.ts
Normal 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>;
|
||||||
Reference in New Issue
Block a user