"""Unified LLM service for VoIdeaAI. Supports OpenAI-compatible APIs (OpenAI, YandexGPT via API). """ import json import logging from datetime import datetime, timezone from typing import Any import httpx from app.core.config import get_settings from app.core.database import async_session_maker from app.models.log import LogEntry from app.services.debug_service import DebugService logger = logging.getLogger("voidea.llm") OPENAI_URL = "https://api.openai.com/v1/chat/completions" async def _log_llm_call(messages: list[dict], response: str | None, model: str, duration_ms: int): """Log LLM call details if debug mode is active.""" try: async with async_session_maker() as db: svc = DebugService(db) if await svc.is_debug_mode(): log = LogEntry( level="DEBUG", source="llm", message=f"LLM call: model={model} duration={duration_ms}ms success={response is not None}", details=json.dumps({ "messages": messages, "response": response, "model": model, }, ensure_ascii=False), created_at=datetime.now(timezone.utc), ) db.add(log) await db.commit() except Exception: pass async def chat_completion( messages: list[dict[str, str]], model: str = "gpt-4o-mini", temperature: float = 0.7, max_tokens: int = 1024, ) -> str | None: """Call an LLM with OpenAI-compatible chat completions format. Args: messages: List of {"role": "system"|"user"|"assistant", "content": "..."} model: Model name temperature: Response creativity max_tokens: Max tokens in response Returns: Response text or None on failure """ import time start = time.time() settings = get_settings() api_key = settings.openai_api_key or settings.ai_yandex_key or "" if not api_key: await _log_llm_call(messages, None, model, 0) return None url = settings.ai_yandex_url.rstrip("/") + "/chat/completions" if settings.ai_yandex_key else OPENAI_URL async with httpx.AsyncClient(timeout=30.0) as client: try: resp = await client.post( url, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, json={ "model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, }, ) elapsed = int((time.time() - start) * 1000) if resp.status_code != 200: await _log_llm_call(messages, None, model, elapsed) return None data = resp.json() content = data["choices"][0]["message"]["content"] await _log_llm_call(messages, content, model, elapsed) return content except Exception as e: elapsed = int((time.time() - start) * 1000) await _log_llm_call(messages, None, model, elapsed) return None async def classify_intent(text: str, agents: list[dict[str, Any]]) -> str | None: """Use LLM to classify user intent and select the best agent. Args: text: User input text agents: List of agent dicts with name and description Returns: Selected agent name or None """ agent_list = "\n".join(f"- {a['name']}: {a['description']}" for a in agents) prompt = f"""Ты — дирижёр умных ассистентов. Определи, какой агент лучше всего подходит для ответа пользователю. Доступные агенты: {agent_list} Ответь ТОЛЬКО именем агента, без пояснений.""" messages = [ {"role": "system", "content": prompt}, {"role": "user", "content": text}, ] result = await chat_completion(messages, temperature=0.3, max_tokens=64) if not result: return None result = result.strip().strip('"').strip("'") agent_names = {a["name"] for a in agents} return result if result in agent_names else None