166 lines
6.7 KiB
Python
166 lines
6.7 KiB
Python
# rag/functions/soma_evaluate.py
|
||
"""
|
||
SOMA-анализ (LLM-as-judge) для оценки качества ответа.
|
||
Оценивает ответ по критериям: релевантность, полнота, отсутствие галлюцинаций,
|
||
стиль, полезность. Возвращает оценки и вердикт.
|
||
|
||
Добавлено кэширование результатов (in-memory с TTL) для экономии токенов.
|
||
Ключ кэша — хеш от (query, context, answer).
|
||
TTL по умолчанию 300 секунд (5 минут), настраивается через config.soma_cache_ttl.
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import hashlib
|
||
import time
|
||
from typing import Dict, Any
|
||
|
||
from rag.services.giga_client import GigaClient
|
||
from rag.config_models import AppConfig
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# In-memory кэш для SOMA-оценок
|
||
# Структура: {cache_key: (timestamp, result_dict)}
|
||
_soma_cache: Dict[str, tuple] = {}
|
||
# TTL по умолчанию (секунды)
|
||
_DEFAULT_TTL = 300
|
||
|
||
|
||
def _get_cache_key(query: str, context: str, answer: str) -> str:
|
||
"""
|
||
Формирует ключ кэша на основе хеша от (query, context, answer).
|
||
"""
|
||
# Ограничиваем длину контекста для хеширования, чтобы не перегружать память
|
||
# Берём первые 2000 символов контекста, чтобы избежать слишком длинных строк
|
||
context_hash = hashlib.md5(context[:2000].encode('utf-8')).hexdigest()
|
||
answer_hash = hashlib.md5(answer[:2000].encode('utf-8')).hexdigest()
|
||
query_hash = hashlib.md5(query.encode('utf-8')).hexdigest()
|
||
return f"soma:{query_hash}:{context_hash}:{answer_hash}"
|
||
|
||
|
||
async def soma_evaluate(
|
||
query: str,
|
||
context: str,
|
||
answer: str,
|
||
config: AppConfig,
|
||
giga: GigaClient,
|
||
prompts: Dict[str, str],
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Оценивает ответ LLM с помощью другого вызова GigaChat.
|
||
|
||
Аргументы:
|
||
query: исходный запрос пользователя.
|
||
context: контекст из базы знаний (может быть пустым).
|
||
answer: сгенерированный ответ.
|
||
config: объект конфигурации.
|
||
giga: клиент GigaChat для вызова.
|
||
prompts: словарь с содержимым промптов (ключ 'soma_evaluate').
|
||
|
||
Возвращает:
|
||
dict с полями:
|
||
scores: dict с оценками по критериям (числа от 1 до 5).
|
||
overall_score: средняя оценка (float).
|
||
verdict: 'pass' или 'fail' (в зависимости от порога).
|
||
feedback: текстовое обоснование.
|
||
error: строка ошибки (если есть).
|
||
"""
|
||
default_result = {
|
||
"scores": {
|
||
"relevance": 3.0,
|
||
"completeness": 3.0,
|
||
"no_hallucination": 3.0,
|
||
"style": 3.0,
|
||
"usefulness": 3.0,
|
||
},
|
||
"overall_score": 3.0,
|
||
"verdict": "fail",
|
||
"feedback": "Оценка не удалась, возвращены значения по умолчанию.",
|
||
"error": None,
|
||
}
|
||
|
||
# ---- Проверка кэша ----
|
||
ttl = getattr(config, 'soma_cache_ttl', _DEFAULT_TTL)
|
||
cache_key = _get_cache_key(query, context, answer)
|
||
|
||
if cache_key in _soma_cache:
|
||
timestamp, cached_result = _soma_cache[cache_key]
|
||
if time.time() - timestamp < ttl:
|
||
logger.debug(f"SOMA-оценка взята из кэша (TTL {ttl} сек)")
|
||
return cached_result
|
||
else:
|
||
# Истекло — удаляем
|
||
del _soma_cache[cache_key]
|
||
logger.debug("SOMA-кэш истёк")
|
||
|
||
# ---- Проверка наличия промпта ----
|
||
prompt_template = prompts.get("soma_evaluate")
|
||
if not prompt_template:
|
||
logger.error("Промпт для SOMA-оценки не найден")
|
||
default_result["error"] = "Отсутствует промпт soma_evaluate"
|
||
return default_result
|
||
|
||
# ---- Формирование промпта ----
|
||
full_prompt = prompt_template.format(
|
||
query=query,
|
||
context=context[:3000] if context else "Нет контекста",
|
||
answer=answer,
|
||
)
|
||
|
||
temperature = getattr(config, 'soma_temperature', 0.1)
|
||
timeout = getattr(config, 'soma_timeout', 30)
|
||
|
||
try:
|
||
response = await giga.chat(
|
||
history=[],
|
||
query=full_prompt,
|
||
system_prompt=None,
|
||
file_id=None,
|
||
temperature=temperature,
|
||
timeout=timeout,
|
||
)
|
||
data = json.loads(response.strip())
|
||
scores = data.get("scores", {})
|
||
feedback = data.get("feedback", "")
|
||
|
||
for key in scores:
|
||
try:
|
||
scores[key] = float(scores[key])
|
||
except (ValueError, TypeError):
|
||
scores[key] = 3.0
|
||
overall = sum(scores.values()) / len(scores) if scores else 3.0
|
||
|
||
threshold = getattr(config, 'soma_threshold', 3.5)
|
||
verdict = "pass" if overall >= threshold else "fail"
|
||
|
||
result = {
|
||
"scores": scores,
|
||
"overall_score": overall,
|
||
"verdict": verdict,
|
||
"feedback": feedback,
|
||
"error": None,
|
||
}
|
||
|
||
# ---- Сохранение в кэш ----
|
||
_soma_cache[cache_key] = (time.time(), result)
|
||
logger.debug(f"SOMA-оценка сохранена в кэш (всего {len(_soma_cache)} записей)")
|
||
|
||
# Ограничим размер кэша, чтобы не переполнить память
|
||
if len(_soma_cache) > 1000:
|
||
# Удаляем самые старые записи (по времени)
|
||
sorted_keys = sorted(_soma_cache.keys(), key=lambda k: _soma_cache[k][0])
|
||
for old_key in sorted_keys[:200]: # удаляем 200 самых старых
|
||
del _soma_cache[old_key]
|
||
logger.debug("Кэш SOMA очищен (старые записи удалены)")
|
||
|
||
return result
|
||
|
||
except json.JSONDecodeError as e:
|
||
logger.error(f"Ошибка парсинга JSON от GigaChat: {e}, ответ: {response[:200]}")
|
||
default_result["error"] = f"Ошибка парсинга JSON: {e}"
|
||
return default_result
|
||
except Exception as e:
|
||
logger.exception(f"Ошибка при вызове SOMA-оценки: {e}")
|
||
default_result["error"] = str(e)
|
||
return default_result |