168 lines
6.6 KiB
Python
168 lines
6.6 KiB
Python
"""
|
||
Обёртка над Яндекс Cloud API (через openai-совместимый клиент).
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
|
||
from openai import AsyncOpenAI, APIConnectionError, APIStatusError, APITimeoutError
|
||
|
||
from services.meta_prompt import (
|
||
STANDARD_SYSTEM_PROMPT,
|
||
META_SYSTEM_PROMPT,
|
||
build_meta_user_message,
|
||
)
|
||
|
||
YANDEX_BASE_URL = "https://ai.api.cloud.yandex.net/v1"
|
||
YANDEX_MODEL_NAME = "gpt-oss-120b/latest"
|
||
|
||
|
||
def _get_client() -> AsyncOpenAI:
|
||
api_key = os.getenv("YANDEX_API_KEY")
|
||
folder_id = os.getenv("YANDEX_FOLDER_ID")
|
||
if not api_key or not folder_id:
|
||
raise RuntimeError(
|
||
"Переменные окружения YANDEX_API_KEY и YANDEX_FOLDER_ID не заданы. "
|
||
"Проверь файл .env."
|
||
)
|
||
return AsyncOpenAI(
|
||
api_key=api_key,
|
||
base_url=YANDEX_BASE_URL,
|
||
default_headers={"x-folder-id": folder_id},
|
||
)
|
||
|
||
|
||
def _model_uri() -> str:
|
||
folder_id = os.getenv("YANDEX_FOLDER_ID", "")
|
||
return f"gpt://{folder_id}/{YANDEX_MODEL_NAME}"
|
||
|
||
|
||
async def _call_llm(
|
||
system_prompt: str,
|
||
user_message: str,
|
||
temperature: float = 0.4,
|
||
max_tokens: int = 2000,
|
||
) -> str:
|
||
"""Базовый вызов LLM. Возвращает текст ответа модели."""
|
||
client = _get_client()
|
||
try:
|
||
response = await client.chat.completions.create(
|
||
model=_model_uri(),
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
messages=[
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_message},
|
||
],
|
||
)
|
||
except APIConnectionError as e:
|
||
raise RuntimeError(
|
||
"Не удалось подключиться к Яндекс Cloud API. "
|
||
"Проверьте сетевое соединение."
|
||
) from e
|
||
except APITimeoutError as e:
|
||
raise RuntimeError(
|
||
"Яндекс Cloud API не ответил в отведённое время. Попробуйте позже."
|
||
) from e
|
||
except APIStatusError as e:
|
||
# Передаём статус и тело ошибки от Яндекса в понятном виде
|
||
raise RuntimeError(
|
||
f"Яндекс Cloud API вернул ошибку {e.status_code}: {e.message}"
|
||
) from e
|
||
|
||
text = response.choices[0].message.content or ""
|
||
if not text.strip():
|
||
raise RuntimeError("Модель вернула пустой ответ.")
|
||
return text
|
||
|
||
|
||
async def get_standard_answer(history: list[dict], meta_feedbacks: list[str]) -> str:
|
||
"""
|
||
Получить ответ модели с учётом истории диалога и рекомендаций мета-мониторинга.
|
||
|
||
history — список {"role": "user"/"assistant", "content": "..."} за текущую сессию.
|
||
meta_feedbacks — список рекомендаций от предыдущих мета-мониторингов в этой сессии.
|
||
"""
|
||
client = _get_client()
|
||
|
||
# Формируем системный промпт: базовый + рекомендации мета-мониторинга если есть
|
||
system_content = STANDARD_SYSTEM_PROMPT
|
||
if meta_feedbacks:
|
||
joined = "\n".join(f"- {fb}" for fb in meta_feedbacks)
|
||
system_content += (
|
||
"\n\nВ этой сессии агент мета-мониторинга уже оценивал твои предыдущие ответы "
|
||
"и выдал следующие рекомендации по улучшению. Учитывай их в своих ответах:\n"
|
||
+ joined
|
||
)
|
||
|
||
messages = [{"role": "system", "content": system_content}] + history
|
||
|
||
try:
|
||
response = await client.chat.completions.create(
|
||
model=_model_uri(),
|
||
temperature=0.4,
|
||
max_tokens=2000,
|
||
messages=messages,
|
||
)
|
||
except APIConnectionError as e:
|
||
raise RuntimeError(
|
||
"Не удалось подключиться к Яндекс Cloud API. Проверьте сетевое соединение."
|
||
) from e
|
||
except APITimeoutError as e:
|
||
raise RuntimeError(
|
||
"Яндекс Cloud API не ответил в отведённое время. Попробуйте позже."
|
||
) from e
|
||
except APIStatusError as e:
|
||
raise RuntimeError(
|
||
f"Яндекс Cloud API вернул ошибку {e.status_code}: {e.message}"
|
||
) from e
|
||
|
||
text = response.choices[0].message.content or ""
|
||
if not text.strip():
|
||
raise RuntimeError("Модель вернула пустой ответ.")
|
||
return text
|
||
|
||
|
||
async def get_meta_monitoring_result(user_request: str, model_answer: str) -> dict:
|
||
"""
|
||
Запустить мета-мониторинг ответа модели.
|
||
Возвращает распарсенный словарь с результатами оценки.
|
||
"""
|
||
user_message = build_meta_user_message(user_request, model_answer)
|
||
raw = await _call_llm(
|
||
system_prompt=META_SYSTEM_PROMPT,
|
||
user_message=user_message,
|
||
temperature=0.2, # меньше случайности — нужен стабильный JSON
|
||
max_tokens=1500,
|
||
)
|
||
|
||
# Очищаем на случай если модель всё же добавила markdown-обёртку
|
||
cleaned = raw.strip()
|
||
if cleaned.startswith("```"):
|
||
lines = cleaned.splitlines()
|
||
# Убираем первую и последнюю строки с ```
|
||
cleaned = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
|
||
|
||
try:
|
||
result = json.loads(cleaned)
|
||
except json.JSONDecodeError as e:
|
||
raise RuntimeError(
|
||
f"Агент мета-мониторинга вернул невалидный JSON. "
|
||
f"Ошибка парсинга: {e}. Сырой ответ: {raw[:300]}"
|
||
) from e
|
||
|
||
# Гарантируем обязательные поля
|
||
required = {"factual_correctness", "completeness", "logical_consistency",
|
||
"overall_score", "comment", "issues", "recommendations"}
|
||
missing = required - result.keys()
|
||
if missing:
|
||
raise RuntimeError(
|
||
f"В ответе мета-мониторинга отсутствуют поля: {', '.join(missing)}"
|
||
)
|
||
|
||
# Убеждаемся что issues — это список
|
||
if not isinstance(result.get("issues"), list):
|
||
result["issues"] = [str(result.get("issues", "Недостатки не указаны"))]
|
||
|
||
return result
|