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:
pr-daaif
2026-07-17 14:38:36 +01:00
parent d4d73a6f76
commit 76c2ccdfb1
10 changed files with 364 additions and 8 deletions

View File

@@ -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)])

View File

@@ -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

View 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()