""" Суммаризация новостей через LLM. Поддержка: Anthropic Claude, OpenAI, Ollama (локальный). """ import logging from datetime import datetime, timezone import httpx logger = logging.getLogger(__name__) SYSTEM_PROMPT = """Ты — опытный редактор новостного дайджеста. Твоя задача: 1. Прочитай все собранные сообщения из Telegram-каналов. 2. Сгруппируй новости по темам (политика, экономика, технологии, общество и т.д.). 3. Для каждой темы напиши краткое резюме ключевых событий. 4. Если несколько каналов пишут об одном событии — объедини в одну новость, не дублируй. 5. Выдели 3-5 главных новостей дня отдельным блоком в начале. 6. Для каждой новости укажи источник (название канала). Формат ответа: 📌 **ГЛАВНОЕ** • [краткая новость] — _Источник_ ... 📂 **[ТЕМА]** • [краткая новость] — _Источник_ ... Правила: - Пиши кратко и по делу, без воды - Язык: {language} - Не выдумывай информацию — только то, что есть в сообщениях - Если сообщение — реклама или спам, пропусти его - Сохраняй нейтральный тон """ def _build_messages_block(messages: list) -> str: """Форматирует сообщения в текстовый блок для LLM.""" parts = [] for i, msg in enumerate(messages, 1): date_str = msg.date.strftime("%Y-%m-%d %H:%M") part = ( f"--- Сообщение {i} ---\n" f"Канал: {msg.channel_title}\n" f"Дата: {date_str}\n" f"Просмотры: {msg.views}\n" f"Текст:\n{msg.text}\n" ) if msg.url: part += f"Ссылка: {msg.url}\n" parts.append(part) return "\n".join(parts) def _truncate_to_fit(text: str, max_chars: int = 180_000) -> str: """Обрезает текст, чтобы влезть в контекстное окно.""" if len(text) <= max_chars: return text logger.warning( f"Текст обрезан: {len(text)} → {max_chars} символов" ) return text[:max_chars] + "\n\n[...текст обрезан из-за лимита контекста]" class Summarizer: def __init__(self, config: dict): self.cfg = config["llm"] self.provider = self.cfg["provider"] self.language = self.cfg.get("language", "русский") self.max_tokens = self.cfg.get("max_tokens", 4096) async def summarize(self, messages: list) -> str: """Суммаризация списка сообщений.""" if not messages: return "📭 Нет новых сообщений за указанный период." messages_block = _build_messages_block(messages) messages_block = _truncate_to_fit(messages_block) user_prompt = ( f"Вот {len(messages)} сообщений из Telegram-каналов. " f"Составь новостной дайджест.\n\n{messages_block}" ) system = SYSTEM_PROMPT.format(language=self.language) logger.info( f"Суммаризация через {self.provider} " f"({len(messages)} сообщений, ~{len(messages_block)} символов)" ) if self.provider == "anthropic": return await self._anthropic(system, user_prompt) elif self.provider == "openai": return await self._openai(system, user_prompt) elif self.provider == "ollama": return await self._ollama(system, user_prompt) else: raise ValueError(f"Неизвестный провайдер: {self.provider}") async def _anthropic(self, system: str, user_prompt: str) -> str: import anthropic client = anthropic.AsyncAnthropic( api_key=self.cfg["anthropic_api_key"] ) response = await client.messages.create( model=self.cfg.get("anthropic_model", "claude-sonnet-4-20250514"), max_tokens=self.max_tokens, system=system, messages=[{"role": "user", "content": user_prompt}], ) return response.content[0].text async def _openai(self, system: str, user_prompt: str) -> str: from openai import AsyncOpenAI client = AsyncOpenAI(api_key=self.cfg["openai_api_key"]) response = await client.chat.completions.create( model=self.cfg.get("openai_model", "gpt-4o-mini"), max_tokens=self.max_tokens, messages=[ {"role": "system", "content": system}, {"role": "user", "content": user_prompt}, ], ) return response.choices[0].message.content async def _ollama(self, system: str, user_prompt: str) -> str: url = self.cfg.get("ollama_url", "http://localhost:11434") model = self.cfg.get("ollama_model", "llama3.1:8b") async with httpx.AsyncClient(timeout=300) as client: response = await client.post( f"{url}/api/chat", json={ "model": model, "stream": False, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user_prompt}, ], }, ) response.raise_for_status() return response.json()["message"]["content"]