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 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]}
|
||||
|
||||
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:
|
||||
"""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:
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user