"""Mentor LLM client communicating with llama.cpp server (OpenAI API compatible).""" import httpx from typing import Dict, Any, Optional from src.core.config import config from src.core.prompts import get_mentor_prompt, get_socratic_prompt def get_chat_completions_url(base_url: str) -> str: url = base_url.strip().rstrip("/") if url.endswith("/chat/completions"): return url if url.endswith("/v1"): return f"{url}/chat/completions" return f"{url}/v1/chat/completions" class MentorClient: """LLM client for software engineering tutoring via OpenAI-compatible endpoints.""" def __init__(self) -> None: self.config = config async def get_guidance_async( self, challenge: object, user_code: str = "", user_question: str = "", files: Optional[Dict[str, str]] = None, active_file: Optional[str] = None, ) -> Dict[str, Any]: """Fetch mentor guidance asynchronously from the configured LLM endpoint.""" prompt = get_mentor_prompt( challenge, user_code, user_question=user_question, files=files, active_file=active_file ) endpoint = get_chat_completions_url(self.config.llm_base_url) user_content = ( f"Question for Mentor: {user_question}\n\nPlease evaluate my code or answer my question for this challenge." if user_question.strip() else "Please review my code structure and provide guidance for my current step." ) payload = { "model": self.config.llm_model, "messages": [ {"role": "system", "content": prompt}, {"role": "user", "content": user_content}, ], "temperature": self.config.llm_temperature, "max_tokens": self.config.llm_max_tokens, } headers = {"Content-Type": "application/json"} if self.config.llm_api_key and self.config.llm_api_key != "not-needed": headers["Authorization"] = f"Bearer {self.config.llm_api_key}" try: async with httpx.AsyncClient(timeout=self.config.llm_timeout, follow_redirects=True) as client: response = await client.post(endpoint, json=payload, headers=headers) response.raise_for_status() data = response.json() msg_obj = data.get("choices", [{}])[0].get("message", {}) content = msg_obj.get("content") or "" if not content.strip() and msg_obj.get("reasoning_content"): content = msg_obj.get("reasoning_content", "") content = content.strip() if content: return { "status": "success", "mentor_response": content, "offline": False, } else: return { "status": "error", "mentor_response": "Received empty response from LLM server.", "offline": False, } except (httpx.ConnectError, httpx.TimeoutException, httpx.TransportError): return { "status": "offline", "mentor_response": ( f"⚠️ LLM Server offline or unreachable at {self.config.llm_base_url}.\n" "Start your llama.cpp server with:\n" " ./llama-server -m --port 8080\n\n" "💡 Offline Hint:\n" "How is your code structuring the initial state before entering your main logic loop?" ), "offline": True, } except Exception as e: return { "status": "error", "mentor_response": f"⚠️ Error consulting LLM mentor: {e}", "offline": False, } def get_guidance( self, challenge: object, user_code: str = "", user_question: str = "", files: Optional[Dict[str, str]] = None, active_file: Optional[str] = None, ) -> Dict[str, Any]: """Synchronous wrapper for fetching mentor guidance.""" import asyncio try: loop = asyncio.get_event_loop() if loop.is_running(): import nest_asyncio nest_asyncio.apply() return loop.run_until_complete( self.get_guidance_async( challenge, user_code, user_question=user_question, files=files, active_file=active_file ) ) else: return asyncio.run( self.get_guidance_async( challenge, user_code, user_question=user_question, files=files, active_file=active_file ) ) except Exception: return asyncio.run( self.get_guidance_async( challenge, user_code, user_question=user_question, files=files, active_file=active_file ) ) SocraticMentor = MentorClient mentor = MentorClient()