""" 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, }