diff --git a/backend/app/config.py b/backend/app/config.py index 86e868c..9822768 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -39,6 +39,10 @@ class Settings(BaseSettings): RATE_LIMIT_AUTH: int = 10 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_TTL_SECONDS: int = 3600 diff --git a/backend/app/main.py b/backend/app/main.py index 9c5cf4a..97467dd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -99,13 +99,10 @@ app = FastAPI( app.add_middleware(SecurityHeadersMiddleware) -# In dev mode, use the default (higher) limit for auth to avoid login lockout -# during repeated disconnect/reconnect cycles. -_auth_rate_limit = ( - settings.RATE_LIMIT_DEFAULT - if settings.ENVIRONMENT == "development" - else settings.RATE_LIMIT_AUTH -) +# Prototype mode: use RATE_LIMIT_DEFAULT for auth so demos/testing don't hit +# the stricter RATE_LIMIT_AUTH (10/min). Set RATE_LIMIT_AUTH >= RATE_LIMIT_DEFAULT +# in .env only when going to real production. +_auth_rate_limit = settings.RATE_LIMIT_DEFAULT app.add_middleware( RateLimiterMiddleware, diff --git a/backend/app/routers/qualify.py b/backend/app/routers/qualify.py index a01dca5..aab005c 100644 --- a/backend/app/routers/qualify.py +++ b/backend/app/routers/qualify.py @@ -25,7 +25,7 @@ from app.services.qualify_ai_service import ( AIMessage, AIQuestion, AIQualifyResult, - ai_frame, + ai_frame_async, ) router = APIRouter() @@ -127,7 +127,7 @@ async def ai_chat(payload: AIChatRequest) -> AIChatResponse: context=payload.context, 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( done=resp.done, diff --git a/backend/app/services/qualify_ai_service.py b/backend/app/services/qualify_ai_service.py index d6d2cf9..c86b2c8 100644 --- a/backend/app/services/qualify_ai_service.py +++ b/backend/app/services/qualify_ai_service.py @@ -3,15 +3,23 @@ Orchestrates a 2-round conversation that clarifies reversibility and urgency before producing a final QualificationResult. -Currently a rule-based stub — will be replaced by Qwen3.6 (MacStudio) calls -once the local LLM endpoint is available. The interface is stable: callers -always receive AIFrameResponse; the underlying engine is swappable. +Two entry points: + ai_frame() — synchronous, rule-based only. Used by tests. + 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 +import json +import logging from dataclasses import dataclass +logger = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Schemas (dataclasses — no Pydantic dependency in the engine layer) @@ -66,8 +74,7 @@ class AIFrameResponse: # --------------------------------------------------------------------------- -# Standard clarifying questions (stub — same regardless of context) -# Real Qwen integration will generate context-aware questions +# Standard clarifying questions (fallback when no context or API unavailable) # --------------------------------------------------------------------------- @@ -94,19 +101,18 @@ _CLARIFYING_QUESTIONS: list[AIQuestion] = [ # --------------------------------------------------------------------------- -# Core function +# Synchronous rule-based engine (used directly by tests) # --------------------------------------------------------------------------- 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 2 (messages set) → parse answers, qualify, return result, done=True """ messages = request.messages or [] - # ── Round 1: no conversation yet ──────────────────────────────────────── if not messages: return AIFrameResponse( done=False, @@ -115,7 +121,6 @@ def ai_frame(request: AIFrameRequest) -> AIFrameResponse: explanation=None, ) - # ── Round 2: answers present → qualify ────────────────────────────────── answers = _parse_answers(messages) result = _build_result(request, 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. Expected format: "reversibility:|urgency:" - Anything not matching is treated as free text for context. """ answers: dict[str, str] = {} for msg in reversed(messages): @@ -169,14 +301,10 @@ def _build_result(request: AIFrameRequest, answers: dict[str, str]) -> AIQualify reasons = list(base.reasons) - # Reversibility adjustment reversibility = answers.get("reversibility", "") if "irréversible" in reversibility.lower(): 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", "") if "urgente" in urgency.lower() or "< 1" in urgency: reasons.append("Urgence signalée : privilégier un protocole à délai court.") diff --git a/backend/requirements.txt b/backend/requirements.txt index 8401843..1c50e8c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,6 +12,7 @@ substrate-interface==1.7.10 py-sr25519-bindings==0.2.1 base58==2.1.1 httpx==0.28.1 +anthropic>=0.97.0 aioipfs==0.7.1 pytest==8.3.4 pytest-asyncio==0.24.0