diff --git a/backend/app/config.py b/backend/app/config.py index 9822768..e7b26d4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -39,9 +39,8 @@ 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" + # AI — Qwen3.6 (MacStudio) endpoint, branché plus tard + QWEN_API_URL: str = "" # Blockchain cache BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600 diff --git a/backend/app/routers/qualify.py b/backend/app/routers/qualify.py index aab005c..a01dca5 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_async, + ai_frame, ) 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 = await ai_frame_async(req) + resp = ai_frame(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 c86b2c8..dab7b20 100644 --- a/backend/app/services/qualify_ai_service.py +++ b/backend/app/services/qualify_ai_service.py @@ -3,23 +3,14 @@ Orchestrates a 2-round conversation that clarifies reversibility and urgency before producing a final QualificationResult. -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). +Rule-based stub — Qwen3.6 (MacStudio) calls will replace ai_frame() internals +once the local endpoint is available. The interface is stable. """ 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) @@ -74,7 +65,7 @@ class AIFrameResponse: # --------------------------------------------------------------------------- -# Standard clarifying questions (fallback when no context or API unavailable) +# Standard clarifying questions # --------------------------------------------------------------------------- @@ -101,12 +92,12 @@ _CLARIFYING_QUESTIONS: list[AIQuestion] = [ # --------------------------------------------------------------------------- -# Synchronous rule-based engine (used directly by tests) +# Core function # --------------------------------------------------------------------------- def ai_frame(request: AIFrameRequest) -> AIFrameResponse: - """Run one round of rule-based AI framing. + """Run one round of AI framing. Round 1 (messages=[]) → return 2 clarifying questions, done=False Round 2 (messages set) → parse answers, qualify, return result, done=True @@ -134,135 +125,7 @@ def ai_frame(request: AIFrameRequest) -> AIFrameResponse: # --------------------------------------------------------------------------- -# 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) +# Helpers # --------------------------------------------------------------------------- diff --git a/backend/requirements.txt b/backend/requirements.txt index 1c50e8c..8401843 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,7 +12,6 @@ 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