IA : Claude substitut Qwen + auth rate limit prototype

- qualify_ai_service : ai_frame_async() avec Claude Haiku
  · round 1 → questions contextualisées si ANTHROPIC_API_KEY définie
  · round 2 → explication enrichie par Claude
  · fallback transparent sur ai_frame() si pas de clé (tests inchangés)
- config : ANTHROPIC_API_KEY + ANTHROPIC_MODEL (claude-haiku-4-5-20251001)
- requirements : anthropic>=0.97.0
- main : auth rate limit = RATE_LIMIT_DEFAULT partout (prototype mode)
  → supporte accès démo/test sans lockout en prod comme en dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-04-23 23:55:10 +02:00
parent 9a8f10efdf
commit 9b6322c546
5 changed files with 154 additions and 24 deletions

View File

@@ -39,6 +39,10 @@ class Settings(BaseSettings):
RATE_LIMIT_AUTH: int = 10 RATE_LIMIT_AUTH: int = 10
RATE_LIMIT_VOTE: int = 30 RATE_LIMIT_VOTE: int = 30
# AI (Claude — substitut Qwen en attendant le déploiement local)
ANTHROPIC_API_KEY: str = ""
ANTHROPIC_MODEL: str = "claude-haiku-4-5-20251001"
# Blockchain cache # Blockchain cache
BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600 BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600

View File

@@ -99,13 +99,10 @@ app = FastAPI(
app.add_middleware(SecurityHeadersMiddleware) app.add_middleware(SecurityHeadersMiddleware)
# In dev mode, use the default (higher) limit for auth to avoid login lockout # Prototype mode: use RATE_LIMIT_DEFAULT for auth so demos/testing don't hit
# during repeated disconnect/reconnect cycles. # the stricter RATE_LIMIT_AUTH (10/min). Set RATE_LIMIT_AUTH >= RATE_LIMIT_DEFAULT
_auth_rate_limit = ( # in .env only when going to real production.
settings.RATE_LIMIT_DEFAULT _auth_rate_limit = settings.RATE_LIMIT_DEFAULT
if settings.ENVIRONMENT == "development"
else settings.RATE_LIMIT_AUTH
)
app.add_middleware( app.add_middleware(
RateLimiterMiddleware, RateLimiterMiddleware,

View File

@@ -25,7 +25,7 @@ from app.services.qualify_ai_service import (
AIMessage, AIMessage,
AIQuestion, AIQuestion,
AIQualifyResult, AIQualifyResult,
ai_frame, ai_frame_async,
) )
router = APIRouter() router = APIRouter()
@@ -127,7 +127,7 @@ async def ai_chat(payload: AIChatRequest) -> AIChatResponse:
context=payload.context, context=payload.context,
messages=[AIMessage(role=m.role, content=m.content) for m in payload.messages], messages=[AIMessage(role=m.role, content=m.content) for m in payload.messages],
) )
resp = ai_frame(req) resp = await ai_frame_async(req)
return AIChatResponse( return AIChatResponse(
done=resp.done, done=resp.done,

View File

@@ -3,15 +3,23 @@
Orchestrates a 2-round conversation that clarifies reversibility and urgency Orchestrates a 2-round conversation that clarifies reversibility and urgency
before producing a final QualificationResult. before producing a final QualificationResult.
Currently a rule-based stub — will be replaced by Qwen3.6 (MacStudio) calls Two entry points:
once the local LLM endpoint is available. The interface is stable: callers ai_frame() — synchronous, rule-based only. Used by tests.
always receive AIFrameResponse; the underlying engine is swappable. ai_frame_async() — async, enriched by Claude API (or Qwen3.6 later).
Falls back to ai_frame() if ANTHROPIC_API_KEY is unset.
The interface (AIFrameRequest / AIFrameResponse) is stable; the underlying
engine is swappable (Qwen3.6 MacStudio planned to replace Claude calls).
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging
from dataclasses import dataclass from dataclasses import dataclass
logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Schemas (dataclasses — no Pydantic dependency in the engine layer) # Schemas (dataclasses — no Pydantic dependency in the engine layer)
@@ -66,8 +74,7 @@ class AIFrameResponse:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Standard clarifying questions (stub — same regardless of context) # Standard clarifying questions (fallback when no context or API unavailable)
# Real Qwen integration will generate context-aware questions
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -94,19 +101,18 @@ _CLARIFYING_QUESTIONS: list[AIQuestion] = [
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Core function # Synchronous rule-based engine (used directly by tests)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def ai_frame(request: AIFrameRequest) -> AIFrameResponse: def ai_frame(request: AIFrameRequest) -> AIFrameResponse:
"""Run one round of AI framing. """Run one round of rule-based AI framing.
Round 1 (messages=[]) → return 2 clarifying questions, done=False Round 1 (messages=[]) → return 2 clarifying questions, done=False
Round 2 (messages set) → parse answers, qualify, return result, done=True Round 2 (messages set) → parse answers, qualify, return result, done=True
""" """
messages = request.messages or [] messages = request.messages or []
# ── Round 1: no conversation yet ────────────────────────────────────────
if not messages: if not messages:
return AIFrameResponse( return AIFrameResponse(
done=False, done=False,
@@ -115,7 +121,6 @@ def ai_frame(request: AIFrameRequest) -> AIFrameResponse:
explanation=None, explanation=None,
) )
# ── Round 2: answers present → qualify ──────────────────────────────────
answers = _parse_answers(messages) answers = _parse_answers(messages)
result = _build_result(request, answers) result = _build_result(request, answers)
explanation = _build_explanation(answers) explanation = _build_explanation(answers)
@@ -129,7 +134,135 @@ def ai_frame(request: AIFrameRequest) -> AIFrameResponse:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Async Claude-enriched entry point (used by the router)
# ---------------------------------------------------------------------------
async def ai_frame_async(request: AIFrameRequest) -> AIFrameResponse:
"""Async entry point: rule engine + Claude enrichment.
Falls back to ai_frame() if ANTHROPIC_API_KEY is not configured.
Qwen3.6 (MacStudio) will replace Claude calls when available.
"""
base = ai_frame(request)
client = _get_claude_client()
if client is None:
return base
messages = request.messages or []
if not messages:
# Round 1: context-aware questions
enriched_questions = await _claude_questions(client, request.context, base.questions)
return AIFrameResponse(done=False, questions=enriched_questions, result=None, explanation=None)
else:
# Round 2: enriched explanation
enriched_explanation = await _claude_explanation(client, request, base)
return AIFrameResponse(
done=True,
questions=[],
result=base.result,
explanation=enriched_explanation or base.explanation,
)
# ---------------------------------------------------------------------------
# Claude helpers
# ---------------------------------------------------------------------------
def _get_claude_client():
"""Return an AsyncAnthropic client if ANTHROPIC_API_KEY is set, else None."""
try:
from app.config import settings
if not settings.ANTHROPIC_API_KEY:
return None
import anthropic
return anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
except Exception:
return None
async def _claude_questions(client, context: str | None, fallback: list[AIQuestion]) -> list[AIQuestion]:
"""Generate context-aware clarifying questions. Falls back to standard questions on error."""
if not context:
return fallback
from app.config import settings
prompt = f"""Tu assistes à la qualification d'une décision collective dans la communauté Duniter/G1 (monnaie libre).
Contexte de la décision : {context}
Génère 2 questions de clarification courtes et précises pour mieux qualifier cette décision.
Chaque question doit avoir exactement 3 options de réponse.
Les questions doivent porter sur la réversibilité et l'urgence, adaptées au contexte.
Réponds UNIQUEMENT en JSON valide, sans texte avant ni après :
[
{{"id": "reversibility", "text": "...", "options": ["option1", "option2", "option3"]}},
{{"id": "urgency", "text": "...", "options": ["option1", "option2", "option3"]}}
]"""
try:
message = await client.messages.create(
model=settings.ANTHROPIC_MODEL,
max_tokens=512,
messages=[{"role": "user", "content": prompt}],
)
raw = message.content[0].text.strip()
start = raw.find("[")
end = raw.rfind("]") + 1
data = json.loads(raw[start:end])
return [AIQuestion(id=q["id"], text=q["text"], options=q["options"]) for q in data]
except Exception as exc:
logger.warning("Claude question generation failed: %s — using fallback", exc)
return fallback
async def _claude_explanation(client, request: AIFrameRequest, base: AIFrameResponse) -> str | None:
"""Generate a contextual explanation for the qualification result."""
if base.result is None:
return None
from app.config import settings
answers = _parse_answers(request.messages or [])
rev = answers.get("reversibility", "non précisé")
urg = answers.get("urgency", "non précisé")
context_line = f"\nContexte : {request.context}" if request.context else ""
prompt = f"""Tu qualifies une décision collective pour la communauté Duniter/G1 (monnaie libre).{context_line}
Paramètres :
- Personnes concernées : {request.affected_count or 'non précisé'}
- Dans le cadre d'un mandat : {'oui' if request.within_mandate else 'non'}
- Décision structurante (on-chain) : {'oui' if request.is_structural else 'non'}
- Réversibilité : {rev}
- Urgence : {urg}
Résultat du moteur de qualification :
- Type : {base.result.decision_type}
- Processus recommandé : {base.result.process}
- Modalités : {', '.join(base.result.recommended_modalities) or 'aucune'}
- Gravure on-chain : {'recommandée' if base.result.recommend_onchain else 'non nécessaire'}
Rédige une explication courte (2-3 phrases) qui explique pourquoi ce processus est recommandé \
pour cette décision spécifique. Sois direct et concis. Réponds en français."""
try:
message = await client.messages.create(
model=settings.ANTHROPIC_MODEL,
max_tokens=300,
messages=[{"role": "user", "content": prompt}],
)
return message.content[0].text.strip()
except Exception as exc:
logger.warning("Claude explanation generation failed: %s — using fallback", exc)
return None
# ---------------------------------------------------------------------------
# Rule-based helpers (shared by sync and async paths)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -137,7 +270,6 @@ def _parse_answers(messages: list[AIMessage]) -> dict[str, str]:
"""Extract question answers from the last user message. """Extract question answers from the last user message.
Expected format: "reversibility:<answer>|urgency:<answer>" Expected format: "reversibility:<answer>|urgency:<answer>"
Anything not matching is treated as free text for context.
""" """
answers: dict[str, str] = {} answers: dict[str, str] = {}
for msg in reversed(messages): for msg in reversed(messages):
@@ -169,14 +301,10 @@ def _build_result(request: AIFrameRequest, answers: dict[str, str]) -> AIQualify
reasons = list(base.reasons) reasons = list(base.reasons)
# Reversibility adjustment
reversibility = answers.get("reversibility", "") reversibility = answers.get("reversibility", "")
if "irréversible" in reversibility.lower(): if "irréversible" in reversibility.lower():
reasons.append("Décision irréversible : consensus élevé recommandé.") reasons.append("Décision irréversible : consensus élevé recommandé.")
if not base.recommend_onchain and request.is_structural:
pass # already handled by engine
# Urgency note
urgency = answers.get("urgency", "") urgency = answers.get("urgency", "")
if "urgente" in urgency.lower() or "< 1" in urgency: if "urgente" in urgency.lower() or "< 1" in urgency:
reasons.append("Urgence signalée : privilégier un protocole à délai court.") reasons.append("Urgence signalée : privilégier un protocole à délai court.")

View File

@@ -12,6 +12,7 @@ substrate-interface==1.7.10
py-sr25519-bindings==0.2.1 py-sr25519-bindings==0.2.1
base58==2.1.1 base58==2.1.1
httpx==0.28.1 httpx==0.28.1
anthropic>=0.97.0
aioipfs==0.7.1 aioipfs==0.7.1
pytest==8.3.4 pytest==8.3.4
pytest-asyncio==0.24.0 pytest-asyncio==0.24.0