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