"""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": [], }