mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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>
150 lines
4.9 KiB
Python
150 lines
4.9 KiB
Python
"""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
|