feat: интеграция ML-скоринга с использованием RAG и DeepSeek
- Обновлен Dockerfile для установки необходимых зависимостей. - Добавлены новые переменные окружения для настройки ML-скоринга в env.example. - Реализованы методы для получения и обновления ML-скоров в AsyncBotDB и PostRepository. - Обновлены обработчики публикации постов для интеграции ML-скоринга. - Добавлен новый обработчик для получения статистики ML-скоринга в админ-панели. - Обновлены функции для форматирования сообщений с учетом ML-скоров.
This commit is contained in:
358
helper_bot/services/scoring/deepseek_service.py
Normal file
358
helper_bot/services/scoring/deepseek_service.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""
|
||||
DeepSeek API сервис для скоринга постов.
|
||||
|
||||
Использует DeepSeek API для семантической оценки релевантности поста.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Optional, List
|
||||
|
||||
import httpx
|
||||
|
||||
from logs.custom_logger import logger
|
||||
from helper_bot.utils.metrics import track_time, track_errors
|
||||
|
||||
from .base import ScoringResult
|
||||
from .exceptions import DeepSeekAPIError, ScoringError, TextTooShortError
|
||||
|
||||
|
||||
class DeepSeekService:
|
||||
"""
|
||||
Сервис для оценки постов через DeepSeek API.
|
||||
|
||||
Отправляет текст поста в DeepSeek с промптом для оценки
|
||||
и получает числовой скор релевантности.
|
||||
|
||||
Attributes:
|
||||
api_key: API ключ DeepSeek
|
||||
api_url: URL API эндпоинта
|
||||
model: Название модели
|
||||
timeout: Таймаут запроса в секундах
|
||||
"""
|
||||
|
||||
# Промпт для оценки поста
|
||||
SCORING_PROMPT = """Роль: Ты — строгий и внимательный модератор сообщества в социальной сети, ориентированного на знакомства между людьми. Твоя задача — оценить, можно ли опубликовать пост, основываясь на четких правилах.
|
||||
|
||||
Контекст группы: Это группа для поиска и знакомства с людьми. Пользователи могут искать кого угодно: случайно увиденных на улице, в транспорте, в кафе, старых знакомых, новых друзей или пару. Это главная и единственная цель группы.
|
||||
|
||||
---
|
||||
|
||||
ПРАВИЛА ЗАПРЕТА (пост НЕ ДОЛЖЕН быть опубликован, если содержит это):
|
||||
|
||||
1. Запрещенные законом тематики: Любые призывы, обсуждение или поиск чего-либо незаконного (наркотики, оружие, мошенничество, насилие и т.д.).
|
||||
2. Поиск и утеря животных, найденные предметы: Запрещены посты про потерявшихся/найденных кошек, собак, хомяков, а также про потерянные/найденные телефоны, ключи, сумки и т.п.
|
||||
3. Конкуренция (Дайвинчик): Любое упоминание группы/проекта/чата "Дайвинчик" или любых других групп-конкурентов. Запрещены призывы переходить в другие сообщества.
|
||||
4. Сбор больших компаний и групп: Запрещены посты с целью собрать большую тусовку, компанию, группу для похода, вечеринки, игры и т.д. (например, "собираем команду для футбола", "кто хочет на квартиру?").
|
||||
5. Организация чатов и других сообществ: Запрещено создание или реклама сторонних чатов, каналов, групп в телеграме, дискорде и т.п.
|
||||
|
||||
---
|
||||
|
||||
ПРАВИЛА РАЗРЕШЕНИЯ (пост МОЖЕТ быть опубликован, если):
|
||||
|
||||
· Цель — найти конкретного человека или познакомиться с кем-то новым.
|
||||
· Формат: Описание человека, обстоятельств встречи, примет, места и времени. Или прямой призыв к знакомству.
|
||||
· Примеры ДОПУСТИМЫХ постов (ориентируйся на них):
|
||||
· "мальчики нефоры/патлатые, гоу знакомиться😻 анон"
|
||||
· "ищу девочку, ехала на 21 автобусе примерно в 15:20. села на детской поликлинике и вышла в заречье вся в черной одежде и с черным баулом"
|
||||
· "ищу мальчика ехали на 35 автобусе часов в 7 вечера я была с девочками,у нас с тобой еще куртки одинаковые ,я рядом с тобой сидела,напиши в комментарии если у тебя нету девочки. анон админу любви."
|
||||
|
||||
---
|
||||
|
||||
ИНСТРУКЦИЯ ПО ОЦЕНКЕ:
|
||||
|
||||
Проанализируй полученный пост и присвой ему итоговый Вес (Score) от 0.0 до 1.0, где:
|
||||
|
||||
· 1.0 — Пост полностью соответствует правилам. Цель — найти/познакомиться с человеком. Ничего из списка запретов не нарушено. Можно публиковать.
|
||||
· 0.0 — Пост категорически нарушает правила. Содержит явные признаки одного или нескольких пунктов из списка запрета. Публиковать НЕЛЬЗЯ.
|
||||
· 0.2 - 0.8 — Пост находится в "серой зоне". Присваивай промежуточный вес, оценивая степень риска и соответствия цели группы.
|
||||
· Ближе к 0.2: Сильно сомнительный пост, есть явные признаки запрещенной темы (например, упоминание "собраться компанией", косвенная реклама другого места).
|
||||
· 0.5: Нейтральный или неочевидный пост. Нужно проверить, нет ли скрытого смысла, нарушающего правила.
|
||||
· Ближе к 0.8: В целом допустимый пост, но с небольшими странностями или двусмысленностями, не нарушающими правила напрямую.
|
||||
---
|
||||
{text}
|
||||
---
|
||||
|
||||
Ответь ТОЛЬКО числом от 0.0 до 1.0, без дополнительных объяснений.
|
||||
Пример ответа: 0.75"""
|
||||
|
||||
DEFAULT_API_URL = "https://api.deepseek.com/v1/chat/completions"
|
||||
DEFAULT_MODEL = "deepseek-chat"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
api_url: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
timeout: int = 30,
|
||||
enabled: bool = True,
|
||||
min_text_length: int = 3,
|
||||
max_retries: int = 3,
|
||||
):
|
||||
"""
|
||||
Инициализация DeepSeek сервиса.
|
||||
|
||||
Args:
|
||||
api_key: API ключ DeepSeek
|
||||
api_url: URL API эндпоинта
|
||||
model: Название модели
|
||||
timeout: Таймаут запроса в секундах
|
||||
enabled: Включен ли сервис
|
||||
min_text_length: Минимальная длина текста для обработки
|
||||
max_retries: Максимальное количество повторных попыток
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.api_url = api_url or self.DEFAULT_API_URL
|
||||
self.model = model or self.DEFAULT_MODEL
|
||||
self.timeout = timeout
|
||||
self._enabled = enabled and bool(api_key)
|
||||
self.min_text_length = min_text_length
|
||||
self.max_retries = max_retries
|
||||
|
||||
# HTTP клиент (создается лениво)
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
if not api_key and enabled:
|
||||
logger.warning("DeepSeekService: API ключ не указан, сервис отключен")
|
||||
self._enabled = False
|
||||
|
||||
logger.info(
|
||||
f"DeepSeekService инициализирован "
|
||||
f"(model={self.model}, enabled={self._enabled})"
|
||||
)
|
||||
|
||||
@property
|
||||
def source_name(self) -> str:
|
||||
"""Имя источника для результатов."""
|
||||
return "deepseek"
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
"""Проверяет, включен ли сервис."""
|
||||
return self._enabled
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Получает или создает HTTP клиент."""
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает HTTP клиент."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
def _clean_text(self, text: str) -> str:
|
||||
"""Очищает текст от лишних символов."""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Удаляем лишние пробелы и переносы строк
|
||||
clean = " ".join(text.split())
|
||||
|
||||
# Удаляем служебные символы
|
||||
if clean == "^":
|
||||
return ""
|
||||
|
||||
return clean.strip()
|
||||
|
||||
def _parse_score_response(self, response_text: str) -> float:
|
||||
"""
|
||||
Парсит ответ от DeepSeek и извлекает скор.
|
||||
|
||||
Args:
|
||||
response_text: Текст ответа от API
|
||||
|
||||
Returns:
|
||||
Числовой скор от 0.0 до 1.0
|
||||
|
||||
Raises:
|
||||
DeepSeekAPIError: Если не удалось распарсить ответ
|
||||
"""
|
||||
try:
|
||||
# Пытаемся найти число в ответе
|
||||
text = response_text.strip()
|
||||
|
||||
# Убираем возможные обрамления
|
||||
text = text.strip('"\'`')
|
||||
|
||||
# Пробуем распарсить как число
|
||||
score = float(text)
|
||||
|
||||
# Ограничиваем диапазон
|
||||
score = max(0.0, min(1.0, score))
|
||||
|
||||
return score
|
||||
|
||||
except ValueError:
|
||||
# Пробуем найти число в тексте
|
||||
import re
|
||||
matches = re.findall(r'0\.\d+|1\.0|0|1', text)
|
||||
if matches:
|
||||
score = float(matches[0])
|
||||
return max(0.0, min(1.0, score))
|
||||
|
||||
logger.error(f"DeepSeekService: Не удалось распарсить ответ: {response_text}")
|
||||
raise DeepSeekAPIError(f"Не удалось распарсить скор из ответа: {response_text}")
|
||||
|
||||
@track_time("calculate_score", "deepseek_service")
|
||||
@track_errors("deepseek_service", "calculate_score")
|
||||
async def calculate_score(self, text: str) -> ScoringResult:
|
||||
"""
|
||||
Рассчитывает скор для текста поста через DeepSeek API.
|
||||
|
||||
Args:
|
||||
text: Текст поста для оценки
|
||||
|
||||
Returns:
|
||||
ScoringResult с оценкой
|
||||
|
||||
Raises:
|
||||
ScoringError: При ошибке расчета
|
||||
"""
|
||||
if not self._enabled:
|
||||
raise ScoringError("DeepSeek сервис отключен")
|
||||
|
||||
# Очищаем текст
|
||||
clean_text = self._clean_text(text)
|
||||
|
||||
if len(clean_text) < self.min_text_length:
|
||||
raise TextTooShortError(
|
||||
f"Текст слишком короткий (минимум {self.min_text_length} символов)"
|
||||
)
|
||||
|
||||
# Формируем промпт
|
||||
prompt = self.SCORING_PROMPT.format(text=clean_text)
|
||||
|
||||
# Выполняем запрос с повторными попытками
|
||||
last_error = None
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
score = await self._make_api_request(prompt)
|
||||
|
||||
return ScoringResult(
|
||||
score=score,
|
||||
source=self.source_name,
|
||||
model=self.model,
|
||||
metadata={
|
||||
"text_length": len(clean_text),
|
||||
"attempt": attempt + 1,
|
||||
},
|
||||
)
|
||||
|
||||
except DeepSeekAPIError as e:
|
||||
last_error = e
|
||||
logger.warning(
|
||||
f"DeepSeekService: Попытка {attempt + 1}/{self.max_retries} "
|
||||
f"не удалась: {e}"
|
||||
)
|
||||
if attempt < self.max_retries - 1:
|
||||
# Экспоненциальная задержка
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
|
||||
raise ScoringError(f"Все попытки запроса к DeepSeek API не удались: {last_error}")
|
||||
|
||||
async def _make_api_request(self, prompt: str) -> float:
|
||||
"""
|
||||
Выполняет запрос к DeepSeek API.
|
||||
|
||||
Args:
|
||||
prompt: Промпт для отправки
|
||||
|
||||
Returns:
|
||||
Числовой скор от 0.0 до 1.0
|
||||
|
||||
Raises:
|
||||
DeepSeekAPIError: При ошибке API
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
}
|
||||
],
|
||||
"temperature": 0.1, # Низкая температура для детерминированности
|
||||
"max_tokens": 10, # Ожидаем только число
|
||||
}
|
||||
|
||||
try:
|
||||
response = await client.post(self.api_url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Извлекаем ответ
|
||||
if "choices" not in data or not data["choices"]:
|
||||
raise DeepSeekAPIError("Пустой ответ от API")
|
||||
|
||||
response_text = data["choices"][0]["message"]["content"]
|
||||
|
||||
# Парсим скор
|
||||
score = self._parse_score_response(response_text)
|
||||
|
||||
logger.debug(f"DeepSeekService: Получен скор {score} для текста")
|
||||
return score
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_msg = f"HTTP ошибка {e.response.status_code}"
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
if "error" in error_data:
|
||||
error_msg = error_data["error"].get("message", error_msg)
|
||||
except Exception:
|
||||
pass
|
||||
raise DeepSeekAPIError(error_msg)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise DeepSeekAPIError(f"Таймаут запроса ({self.timeout}s)")
|
||||
|
||||
except Exception as e:
|
||||
raise DeepSeekAPIError(f"Ошибка запроса: {e}")
|
||||
|
||||
async def add_positive_example(self, text: str) -> None:
|
||||
"""
|
||||
Добавляет текст как положительный пример.
|
||||
|
||||
Для DeepSeek не требуется хранить примеры - оценка выполняется
|
||||
на основе промпта. Метод существует для совместимости с протоколом.
|
||||
|
||||
Args:
|
||||
text: Текст опубликованного поста
|
||||
"""
|
||||
# DeepSeek не использует примеры для обучения
|
||||
# Промпт уже содержит критерии оценки
|
||||
pass
|
||||
|
||||
async def add_negative_example(self, text: str) -> None:
|
||||
"""
|
||||
Добавляет текст как отрицательный пример.
|
||||
|
||||
Для DeepSeek не требуется хранить примеры - оценка выполняется
|
||||
на основе промпта. Метод существует для совместимости с протоколом.
|
||||
|
||||
Args:
|
||||
text: Текст отклоненного поста
|
||||
"""
|
||||
# DeepSeek не использует примеры для обучения
|
||||
pass
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Возвращает статистику сервиса."""
|
||||
return {
|
||||
"enabled": self._enabled,
|
||||
"model": self.model,
|
||||
"api_url": self.api_url,
|
||||
"timeout": self.timeout,
|
||||
"max_retries": self.max_retries,
|
||||
}
|
||||
Reference in New Issue
Block a user