|
|
|
|
@@ -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:<answer>|urgency:<answer>"
|
|
|
|
|
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.")
|
|
|
|
|
|