mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r5.3): écrans IA — assistant web, corpus administrable, suggestions OT/mobile
- Contrat (76 opérations) : Document expose inCorpus/indexedAt/chunkCount,
PATCH /documents/{id}/corpus (ASSETS.edit), POST /assistant/reindex
(bilan chiffré) ; ci-contract vérifie désormais aussi le client mobile.
- Web : page /assistant (chat sourcé — extraits exacts cités, Ouvrir vers
PDF authentifié ou fiche OT, avertissement permanent ; refus honnête
chiffré avec action utile) ; Bibliothèque = corpus (bandeau 09-08,
statut d'indexation par document, interrupteur d'exclusion PDF,
Réindexer tout) ; fiche OT : « Décrire pour suggérer » (Appliquer =
geste humain, liseré « suggéré » retiré au choix manuel).
- Mobile : chips de suggestion dans la clôture (un appui = un champ
pré-rempli, « réseau requis » hors-ligne — la file R4 n'en dépend pas).
- apps/ai : seuils AI_SEUIL_* configurables par env (CI + calibrage).
- CI e2e : service siop2-ai (embeddeur déterministe, seuils calibrés sur
mesures : match 0,66 vs bruit 0,11) + parcours R5 Playwright (PDF généré
xref valide → réindexation → réponse sourcée → refus → suggestion).
- Vérifié : 16/16 Playwright, 78 tests API, 23 pytest, 17 jest-expo ;
chaîne réelle au vrai modèle ONNX (web 7/7, mobile Expo web 6/6).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -78,7 +78,12 @@ async def demander(corps: RequeteRecherche) -> dict:
|
||||
(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
|
||||
cnx,
|
||||
app.state.embeddeur,
|
||||
app.state.generateur,
|
||||
corps.question,
|
||||
corps.limite,
|
||||
seuil_pertinence=app.state.reglages.ai_seuil_pertinence,
|
||||
)
|
||||
return {
|
||||
"mode": reponse.mode,
|
||||
@@ -100,5 +105,11 @@ 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)
|
||||
suggestions = await suggerer_bilan(
|
||||
cnx,
|
||||
app.state.embeddeur,
|
||||
corps.description,
|
||||
seuil_suggestion=app.state.reglages.ai_seuil_suggestion,
|
||||
seuil_confiance_forte=app.state.reglages.ai_seuil_confiance_forte,
|
||||
)
|
||||
return {"suggestions": [asdict(s) for s in suggestions]}
|
||||
|
||||
@@ -29,6 +29,7 @@ CHAMPS_BILAN = {
|
||||
"COMPONENT_CONCERNED": "élément concerné",
|
||||
}
|
||||
|
||||
# Défauts — surchargés par la config (AI_SEUIL_*) : calibrage en recette.
|
||||
SEUIL_PERTINENCE = 0.30 # en dessous : le corpus ne porte pas la réponse
|
||||
SEUIL_SUGGESTION = 0.35
|
||||
SEUIL_CONFIANCE_FORTE = 0.55
|
||||
@@ -61,10 +62,11 @@ async def repondre(
|
||||
generateur: Generateur,
|
||||
question: str,
|
||||
limite: int = 5,
|
||||
seuil_pertinence: float = SEUIL_PERTINENCE,
|
||||
) -> 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]
|
||||
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.
|
||||
@@ -104,6 +106,8 @@ async def suggerer_bilan(
|
||||
cnx: asyncpg.Connection,
|
||||
embeddeur: Embeddeur,
|
||||
description: str,
|
||||
seuil_suggestion: float = SEUIL_SUGGESTION,
|
||||
seuil_confiance_forte: float = SEUIL_CONFIANCE_FORTE,
|
||||
) -> list[SuggestionBilan]:
|
||||
valeurs = await cnx.fetch(
|
||||
'SELECT id, field, label FROM "ReferenceValue" WHERE "isActive" ORDER BY field, label'
|
||||
@@ -122,7 +126,7 @@ async def suggerer_bilan(
|
||||
meilleurs: dict[str, tuple[asyncpg.Record, float]] = {}
|
||||
for valeur, vecteur in zip(valeurs, v_valeurs):
|
||||
score = _cosinus(v_description, vecteur)
|
||||
if score < SEUIL_SUGGESTION:
|
||||
if score < seuil_suggestion:
|
||||
continue
|
||||
champ = valeur["field"]
|
||||
if champ not in meilleurs or score > meilleurs[champ][1]:
|
||||
@@ -140,7 +144,7 @@ async def suggerer_bilan(
|
||||
field=valeur["field"],
|
||||
value_id=str(valeur["id"]),
|
||||
label=valeur["label"],
|
||||
confidence="FORTE" if score >= SEUIL_CONFIANCE_FORTE else "MOYENNE",
|
||||
confidence="FORTE" if score >= seuil_confiance_forte else "MOYENNE",
|
||||
similar_reports=similaires,
|
||||
score=round(score, 4),
|
||||
)
|
||||
|
||||
@@ -24,6 +24,11 @@ class Reglages(BaseSettings):
|
||||
ai_generation: str = "off"
|
||||
ai_api_key: str = "" # requise seulement si ai_generation=api — jamais loguée
|
||||
ai_model: str = "claude-opus-4-8"
|
||||
# Seuils de similarité — constantes de départ, calibrables par env
|
||||
# (recette sur corpus réel ; abaissés en CI e2e — embeddeur déterministe).
|
||||
ai_seuil_pertinence: float = 0.30
|
||||
ai_seuil_suggestion: float = 0.35
|
||||
ai_seuil_confiance_forte: float = 0.55
|
||||
|
||||
model_config = {"env_prefix": "", "case_sensitive": False}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user