148 lines
6.0 KiB
Python
148 lines
6.0 KiB
Python
"""
|
||
Суммаризация новостей через LLM.
|
||
Поддержка: Anthropic Claude, OpenAI, Ollama (нативный на хосте).
|
||
"""
|
||
|
||
import logging
|
||
|
||
import httpx
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
SYSTEM_PROMPT = """ВАЖНО: Весь ответ ДОЛЖЕН быть написан ТОЛЬКО на {language} языке. Никакого английского.
|
||
|
||
Ты — опытный редактор новостного дайджеста. Твоя задача:
|
||
|
||
1. Прочитай все собранные сообщения из Telegram-каналов.
|
||
2. Сгруппируй новости по темам (политика, экономика, технологии, общество и т.д.).
|
||
3. Для каждой темы напиши краткое резюме ключевых событий.
|
||
4. Если несколько каналов пишут об одном событии — объедини в одну новость, не дублируй.
|
||
5. Выдели 3-5 главных новостей дня отдельным блоком в начале.
|
||
6. Для каждой новости укажи источник (название канала).
|
||
|
||
Формат ответа:
|
||
|
||
📌 **ГЛАВНОЕ**
|
||
• [краткая новость] — _Источник_
|
||
...
|
||
|
||
📂 **[ТЕМА]**
|
||
• [краткая новость] — _Источник_
|
||
...
|
||
|
||
Правила:
|
||
- Пиши кратко и по делу, без воды
|
||
- Не выдумывай информацию — только то, что есть в сообщениях
|
||
- Если сообщение — реклама или спам, пропусти его
|
||
- Сохраняй нейтральный тон
|
||
- Названия тем, заголовки и весь текст — ТОЛЬКО на {language} языке
|
||
"""
|
||
|
||
|
||
def _build_messages_block(messages: list) -> str:
|
||
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"Составь новостной дайджест на {self.language} языке. "
|
||
f"Весь текст ответа должен быть на {self.language} языке.\n\n"
|
||
f"{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://host.docker.internal:11434")
|
||
model = self.cfg.get("ollama_model", "gemma3:12b")
|
||
|
||
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"]
|