Files
TactiTerm/src/core/mentor.py
T

138 lines
5.1 KiB
Python

"""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 <your-model.gguf> --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()