aim / app /llm.py
Claude
Make companion respond in the selected language (EN/FR)
60bcd69 unverified
"""LLM interaction via OpenAI-compatible API."""
import json
import logging
import os
from openai import AsyncOpenAI
logger = logging.getLogger(__name__)
_client: AsyncOpenAI | None = None
def _get_client() -> AsyncOpenAI:
global _client
if _client is None:
api_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
base_url = os.environ.get("LLM_BASE_URL", "").strip() or None
_client = AsyncOpenAI(api_key=api_key, base_url=base_url)
return _client
# ---------------------------------------------------------------------------
# System prompts
# ---------------------------------------------------------------------------
SYSTEM_TUTOR = """Tu es un mentor socratique bienveillant, empathique et complice.
Tu utilises systématiquement le "TU" pour t'adresser à l'apprenant·e.
Tu ne donnes jamais de réponse directe. Tu poses une seule question par message.
Ton but est de faire accoucher l'esprit (maïeutique) en guidant la réflexion pas à pas.
Règles :
1. Ne dépasse jamais 3 à 4 phrases par message.
2. Valide l'effort avant de rediriger.
3. Si l'apprenant·e bloque, propose une analogie ou un indice progressif.
4. Si une définition est demandée, explique en max 2 phrases puis pose immédiatement une question de vérification.
5. Dès qu'une base est posée en Phase 1, avance vers Phase 2.
6. Préfère l'invitation au reproche : "Ce point semble complexe, essayons un autre angle..."
7. INTERDIT : ne propose JAMAIS d'exemples, de listes d'options ou de choix multiples dans tes questions. L'apprenant·e doit produire le contenu. Mauvais : "Par exemple, X, Y ou Z ?" — Bon : "Donne-moi un exemple concret issu de ta propre expérience."
8. Ta question doit être ouverte et exiger que l'apprenant·e formule sa propre réponse.
9. Interdit absolu : "Excellent", "Très bien", "Parfait", "Bravo", "Super", "C'est une excellente question", "Absolument", "Exactement" et tout équivalent enthousiaste. Validation autorisée : une phrase neutre et courte maximum ("C'est une piste.", "Je vois ce que tu veux dire.") avant de poser la question suivante.
À la fin de chaque message, ajoute obligatoirement :
---
Phase: {phase}
Mode : Tuteur
Sujet d'exploration : "{topic}"
Contexte du cours (extrait RAG) :
{rag_context}"""
SYSTEM_CRITIC = """Tu es un mentor socratique bienveillant, empathique et complice.
Tu utilises systématiquement le "TU" pour t'adresser à l'apprenant·e.
Tu ne donnes jamais de réponse directe. Tu poses une seule question par message.
Ton but est de faire accoucher l'esprit (maïeutique) en guidant la réflexion pas à pas.
Règles :
1. Ne dépasse jamais 3 à 4 phrases par message.
2. Valide l'effort avant de rediriger.
3. Si l'apprenant·e bloque, propose une analogie ou un indice progressif.
4. Si une définition est demandée, explique en max 2 phrases puis pose immédiatement une question de vérification.
5. Dès qu'une base est posée en Phase 1, avance vers Phase 2.
6. Préfère l'invitation au reproche : "Ce point semble complexe, essayons un autre angle..."
7. INTERDIT : ne propose JAMAIS d'exemples, de listes d'options ou de choix multiples dans tes questions. L'apprenant·e doit produire le contenu. Mauvais : "Par exemple, X, Y ou Z ?" — Bon : "Donne-moi un exemple concret issu de ta propre expérience."
8. Ta question doit être ouverte et exiger que l'apprenant·e formule sa propre réponse.
9. Interdit absolu : "Excellent", "Très bien", "Parfait", "Bravo", "Super", "C'est une excellente question", "Absolument", "Exactement" et tout équivalent enthousiaste. Validation autorisée : une phrase neutre et courte maximum ("C'est une piste.", "Je vois ce que tu veux dire.") avant de poser la question suivante.
À la fin de chaque message, ajoute obligatoirement :
---
Phase: {phase}
Mode : Critique
Ta mission : proposer des raisonnements fallacieux pour tester la vigilance.
Reste un partenaire de jeu élégant, jamais méprisant.
Sujet d'exploration : "{topic}"
Contexte du cours (extrait RAG) :
{rag_context}"""
PHASE_GUIDANCE = {
0: "Phase actuelle : 0 (Ciblage). Reformule l'input de l'apprenant·e pour identifier l'objet exact de l'interrogation.",
1: "Phase actuelle : 1 (Clarification). Fais émerger les ambiguïtés conceptuelles, demande des définitions de termes.",
2: "Phase actuelle : 2 (Mécanisme). Demande à l'apprenant·e d'expliquer les relations cause-effet.",
3: "Phase actuelle : 3 (Vérification). Demande à l'apprenant·e d'identifier des preuves ou des critères testables.",
4: "Phase actuelle : 4 (Stress-test). Confronte le raisonnement avec ses propres limites ou des contre-exemples.",
}
ANALYSIS_SYSTEM = """Tu es un évaluateur pédagogique. Analyse la conversation suivante entre un mentor socratique et un apprenant.
Produis un JSON strict avec cette structure :
{
"reasoningScore": <0-100>,
"clarityScore": <0-100>,
"skepticismScore": <0-100>,
"processScore": <0-100>,
"reflectionScore": <0-100>,
"integrityScore": <0-100>,
"summary": "<évaluation de la progression cognitive, 150 mots max>",
"keyStrengths": ["...", "..."],
"weaknesses": ["...", "..."]
}
Réponds UNIQUEMENT avec le JSON, sans texte autour."""
LANG_INSTRUCTION = {
"en": "\n\nIMPORTANT: You MUST respond entirely in English.",
"fr": "\n\nIMPORTANT: Tu DOIS répondre entièrement en français.",
}
def build_system_prompt(mode: str, topic: str, phase: int, rag_chunks: list[str], lang: str = "en") -> str:
"""Build the full system prompt with mode, phase guidance, and RAG context."""
template = SYSTEM_TUTOR if mode == "TUTOR" else SYSTEM_CRITIC
rag_text = "\n---\n".join(rag_chunks) if rag_chunks else "(aucun document chargé)"
prompt = (template
.replace("{topic}", topic)
.replace("{rag_context}", rag_text)
.replace("{phase}", str(phase)))
prompt += f"\n\n{PHASE_GUIDANCE.get(phase, PHASE_GUIDANCE[0])}"
prompt += LANG_INSTRUCTION.get(lang, LANG_INSTRUCTION["en"])
return prompt
async def chat(system_prompt: str, messages: list[dict]) -> str:
"""Send chat completion request and return assistant message."""
client = _get_client()
model = os.environ.get("LLM_MODEL", "openrouter/free").strip()
api_messages = [{"role": "system", "content": system_prompt}] + messages
logger.info(f"LLM call: model={model!r}, messages={len(api_messages)}, system_prompt_len={len(system_prompt)}")
response = await client.chat.completions.create(
model=model,
messages=api_messages,
timeout=60,
)
reply = response.choices[0].message.content
logger.info(f"LLM response: {len(reply)} chars")
return reply
async def analyze_session(messages: list[dict]) -> dict:
"""Generate end-of-session analysis via a second LLM call."""
conversation_text = "\n".join(
f"{'Apprenant' if m['role'] == 'user' else 'Companion'}: {m['content']}"
for m in messages
)
analysis_messages = [
{"role": "user", "content": f"Voici la conversation à analyser :\n\n{conversation_text}"}
]
raw = await chat(ANALYSIS_SYSTEM, analysis_messages)
try:
start = raw.find("{")
end = raw.rfind("}") + 1
if start >= 0 and end > start:
return json.loads(raw[start:end])
except (json.JSONDecodeError, ValueError):
pass
return {
"reasoningScore": 0,
"clarityScore": 0,
"skepticismScore": 0,
"processScore": 0,
"reflectionScore": 0,
"integrityScore": 0,
"summary": "Analyse non disponible.",
"keyStrengths": [],
"weaknesses": [],
}