mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +00:00
feat(r5.1): clé API de génération configurable (ADR-004 §3 en actes)
Demande du référent : la génération opt-in devient réellement configurable. AI_GENERATION=off|api, AI_API_KEY (exigée en mode api — le boot refuse sinon, jamais loguée, /healthz n'expose que le mode), AI_MODEL (défaut claude-opus-4-8). generation.py : interface Generateur — GenerateurExtractif (contrat de base sans LLM) et GenerateurAPI (SDK officiel anthropic, dépendance optionnelle --extra generation, absente des tests/CI). Consigne : citations [n] obligatoires depuis les extraits anonymisés, jamais d'invention, rappel de validation humaine. Tout échec (refus du modèle, quota, réseau) retombe silencieusement sur l'extractif. 5 tests ajoutés (19 pytest) + .env.example. Vérifié en réel : boot refusé api-sans-clé, générateur construit, healthz sans secret. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from .config import Reglages, charger_reglages
|
||||
from .embeddings import construire_embeddeur
|
||||
from .generation import construire_generateur
|
||||
from .ingestion import reindexer_tout
|
||||
from .recherche import chercher
|
||||
|
||||
@@ -21,6 +22,9 @@ async def cycle_de_vie(app: FastAPI):
|
||||
reglages = charger_reglages()
|
||||
app.state.reglages = reglages
|
||||
app.state.embeddeur = construire_embeddeur(reglages.ai_embeddings)
|
||||
app.state.generateur = construire_generateur(
|
||||
reglages.ai_generation, reglages.ai_api_key, reglages.ai_model
|
||||
)
|
||||
app.state.pool = await asyncpg.create_pool(reglages.database_url, min_size=1, max_size=5)
|
||||
yield
|
||||
await app.state.pool.close()
|
||||
@@ -39,8 +43,13 @@ def verifier_jeton(
|
||||
|
||||
@app.get("/healthz")
|
||||
async def sante() -> dict:
|
||||
"""Sonde interne (compose/Dokploy) — ne révèle rien du corpus."""
|
||||
return {"status": "ok", "service": "siop2-ai"}
|
||||
"""Sonde interne (compose/Dokploy) — ne révèle ni corpus ni secret."""
|
||||
reglages = getattr(app.state, "reglages", None) or charger_reglages()
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "siop2-ai",
|
||||
"generation": reglages.ai_generation, # « off » = extractif — jamais la clé
|
||||
}
|
||||
|
||||
|
||||
@app.post("/internal/reindex", dependencies=[Depends(verifier_jeton)])
|
||||
|
||||
@@ -18,8 +18,12 @@ class Reglages(BaseSettings):
|
||||
ai_service_token: str = "dev-only-ai-token"
|
||||
# Embeddings : « locale » (fastembed ONNX) ou « deterministe » (tests/CI)
|
||||
ai_embeddings: str = "locale"
|
||||
# Génération : « off » (mode extractif, défaut honnête) ou « api » (opt-in)
|
||||
# Génération (ADR-004 §3) : « off » = mode extractif (défaut honnête,
|
||||
# la recette passe sans clé) ; « api » = rédaction par le LLM externe,
|
||||
# sur textes DÉJÀ anonymisés (D4), citations obligatoires.
|
||||
ai_generation: str = "off"
|
||||
ai_api_key: str = "" # requise seulement si ai_generation=api — jamais loguée
|
||||
ai_model: str = "claude-opus-4-8"
|
||||
|
||||
model_config = {"env_prefix": "", "case_sensitive": False}
|
||||
|
||||
@@ -29,4 +33,9 @@ def charger_reglages() -> Reglages:
|
||||
# asyncpg ne comprend pas le paramètre ?schema= de Prisma
|
||||
if "?" in reglages.database_url:
|
||||
reglages.database_url = reglages.database_url.split("?")[0]
|
||||
# Même philosophie que l'API NestJS : une config invalide refuse de booter.
|
||||
if reglages.ai_generation not in ("off", "api"):
|
||||
raise ValueError("AI_GENERATION doit valoir « off » ou « api »")
|
||||
if reglages.ai_generation == "api" and not reglages.ai_api_key:
|
||||
raise ValueError("AI_GENERATION=api exige AI_API_KEY (voir ADR-004 §3)")
|
||||
return reglages
|
||||
|
||||
94
apps/ai/src/siop_ai/generation.py
Normal file
94
apps/ai/src/siop_ai/generation.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Génération des réponses rédigées (ADR-004 §3) — OPT-IN par configuration.
|
||||
|
||||
- « off » (défaut) : mode extractif — pas de LLM, l'assistant montrera les
|
||||
extraits sourcés tels quels. La recette R5 passe entièrement dans ce mode.
|
||||
- « api » : rédaction par Claude (SDK officiel), sur des extraits DÉJÀ
|
||||
anonymisés (D4), avec l'obligation de ne rien affirmer hors extraits (D2).
|
||||
Tout échec (refus, réseau, quota) retombe sur le mode extractif — jamais
|
||||
d'erreur utilisateur à cause du LLM.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Protocol
|
||||
|
||||
from .recherche import ExtraitTrouve
|
||||
|
||||
journal = logging.getLogger("siop_ai.generation")
|
||||
|
||||
CONSIGNE = """Tu es l'assistant SIOP d'une société de maintenance d'ascenseurs.
|
||||
Tu réponds en français, UNIQUEMENT à partir des extraits fournis (notices et
|
||||
historiques d'intervention du parc, déjà anonymisés).
|
||||
Règles absolues :
|
||||
- chaque affirmation porte sa citation [n] renvoyant à un extrait fourni ;
|
||||
- si les extraits ne portent pas la réponse, dis-le et n'invente RIEN ;
|
||||
- reste bref et opérationnel : un technicien te lit sur le terrain ;
|
||||
- termine toujours par le rappel que l'humain vérifie avant d'agir."""
|
||||
|
||||
|
||||
class Generateur(Protocol):
|
||||
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None: ...
|
||||
|
||||
|
||||
class GenerateurExtractif:
|
||||
"""Le contrat de base : pas de rédaction — l'appelant montre les extraits.
|
||||
`None` signifie « pas de texte généré », jamais une erreur."""
|
||||
|
||||
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def construire_invite(question: str, extraits: list[ExtraitTrouve]) -> str:
|
||||
"""L'invite utilisateur — fonction pure, testée : la question et les
|
||||
extraits numérotés, rien d'autre (les textes sont déjà anonymisés)."""
|
||||
blocs = [
|
||||
f"[{rang}] {e.titre} · {e.locator}\n{e.content}"
|
||||
for rang, e in enumerate(extraits, start=1)
|
||||
]
|
||||
return "Extraits du corpus :\n\n" + "\n\n".join(blocs) + f"\n\nQuestion : {question}"
|
||||
|
||||
|
||||
class GenerateurAPI:
|
||||
"""Rédaction par Claude — import différé : la dépendance `anthropic` est
|
||||
optionnelle (groupe `generation`), absente des tests/CI."""
|
||||
|
||||
def __init__(self, api_key: str, modele: str) -> None:
|
||||
from anthropic import Anthropic
|
||||
|
||||
self._client = Anthropic(api_key=api_key)
|
||||
self._modele = modele
|
||||
|
||||
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None:
|
||||
import anthropic
|
||||
|
||||
if not extraits:
|
||||
return None # sourcé ou silencieux (D2) : rien à citer = rien à rédiger
|
||||
try:
|
||||
reponse = self._client.messages.create(
|
||||
model=self._modele,
|
||||
max_tokens=2048, # réponses courtes et sourcées, par conception
|
||||
thinking={"type": "adaptive"},
|
||||
system=CONSIGNE,
|
||||
messages=[
|
||||
{"role": "user", "content": construire_invite(question, extraits)}
|
||||
],
|
||||
)
|
||||
if reponse.stop_reason == "refusal":
|
||||
journal.warning("Génération refusée par le modèle — repli extractif")
|
||||
return None
|
||||
texte = "".join(b.text for b in reponse.content if b.type == "text").strip()
|
||||
return texte or None
|
||||
except anthropic.RateLimitError:
|
||||
journal.warning("Quota API atteint — repli extractif")
|
||||
return None
|
||||
except anthropic.APIStatusError as e:
|
||||
journal.warning("API génération %s — repli extractif", e.status_code)
|
||||
return None
|
||||
except anthropic.APIConnectionError:
|
||||
journal.warning("API génération injoignable — repli extractif")
|
||||
return None
|
||||
|
||||
|
||||
def construire_generateur(mode: str, api_key: str, modele: str) -> Generateur:
|
||||
if mode == "api":
|
||||
return GenerateurAPI(api_key, modele)
|
||||
return GenerateurExtractif()
|
||||
Reference in New Issue
Block a user