366 lines
16 KiB
Python
366 lines
16 KiB
Python
"""
|
||
DeepSeek API сервис для скоринга постов.
|
||
|
||
Использует DeepSeek API для семантической оценки релевантности поста.
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
from typing import List, Optional
|
||
|
||
import httpx
|
||
|
||
from helper_bot.utils.metrics import track_errors, track_time
|
||
from logs.custom_logger import logger
|
||
|
||
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,
|
||
}
|