init
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
config.yaml
|
||||
*.session
|
||||
*.session-journal
|
||||
digests/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
digest.log
|
||||
75
README.md
Normal file
75
README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# 📰 Telegram News Digest
|
||||
|
||||
Автоматический сбор новостей из Telegram-каналов с AI-суммаризацией и доставкой дайджеста.
|
||||
|
||||
## Возможности
|
||||
|
||||
- Сбор сообщений из списка Telegram-каналов (публичных и приватных)
|
||||
- Фильтрация по времени (за последние N часов)
|
||||
- Суммаризация через LLM (Anthropic Claude API / OpenAI / Ollama)
|
||||
- Группировка новостей по темам
|
||||
- Отправка дайджеста в Telegram (Избранное или бот)
|
||||
- Готов к запуску через cron / Airflow
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Получи Telegram API credentials
|
||||
|
||||
- Зайди на https://my.telegram.org
|
||||
- Создай приложение → получи `api_id` и `api_hash`
|
||||
|
||||
### 2. (Опционально) Создай Telegram-бота для доставки
|
||||
|
||||
- Напиши @BotFather → `/newbot`
|
||||
- Получи токен бота
|
||||
- Напиши боту любое сообщение, затем узнай свой `chat_id`:
|
||||
```
|
||||
curl https://api.telegram.org/bot<TOKEN>/getUpdates
|
||||
```
|
||||
|
||||
### 3. Установи зависимости
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Настрой конфиг
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
# Отредактируй config.yaml — впиши свои ключи и список каналов
|
||||
```
|
||||
|
||||
### 5. Первый запуск (авторизация)
|
||||
|
||||
```bash
|
||||
python main.py --auth
|
||||
```
|
||||
Введи номер телефона и код из Telegram. Сессия сохранится в файл.
|
||||
|
||||
### 6. Запуск сбора дайджеста
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Автоматический запуск (cron)
|
||||
|
||||
```bash
|
||||
# Каждый день в 8:00 и 20:00
|
||||
0 8,20 * * * cd /path/to/tg-digest && python main.py >> digest.log 2>&1
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
tg-digest/
|
||||
├── config.example.yaml # Пример конфигурации
|
||||
├── config.yaml # Твой конфиг (не коммитить!)
|
||||
├── main.py # Точка входа
|
||||
├── collector.py # Сбор сообщений из каналов
|
||||
├── summarizer.py # Суммаризация через LLM
|
||||
├── delivery.py # Отправка дайджеста
|
||||
├── requirements.txt # Зависимости
|
||||
└── README.md
|
||||
```
|
||||
143
collector.py
Normal file
143
collector.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Сбор сообщений из Telegram-каналов через Telethon.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from telethon import TelegramClient
|
||||
from telethon.errors import (
|
||||
FloodWaitError,
|
||||
ChannelPrivateError,
|
||||
UsernameNotOccupiedError,
|
||||
UsernameInvalidError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
"""Одно сообщение из канала."""
|
||||
channel: str
|
||||
channel_title: str
|
||||
text: str
|
||||
date: datetime
|
||||
views: int = 0
|
||||
url: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CollectorResult:
|
||||
"""Результат сбора."""
|
||||
messages: list[Message] = field(default_factory=list)
|
||||
errors: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
class MessageCollector:
|
||||
def __init__(self, config: dict):
|
||||
tg = config["telegram"]
|
||||
self.client = TelegramClient(
|
||||
tg["session_name"],
|
||||
tg["api_id"],
|
||||
tg["api_hash"],
|
||||
)
|
||||
self.channels: list[str] = tg["channels"]
|
||||
self.hours_back: int = tg.get("hours_back", 12)
|
||||
self.max_per_channel: int = tg.get("max_messages_per_channel", 50)
|
||||
self.delay: float = tg.get("delay_between_channels", 2)
|
||||
self.min_length: int = tg.get("min_message_length", 50)
|
||||
|
||||
async def auth(self):
|
||||
"""Интерактивная авторизация (первый запуск)."""
|
||||
await self.client.start()
|
||||
me = await self.client.get_me()
|
||||
logger.info(f"Авторизован как: {me.first_name} ({me.phone})")
|
||||
await self.client.disconnect()
|
||||
|
||||
async def collect(self) -> CollectorResult:
|
||||
"""Собрать сообщения из всех каналов."""
|
||||
result = CollectorResult()
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=self.hours_back)
|
||||
|
||||
await self.client.connect()
|
||||
if not await self.client.is_user_authorized():
|
||||
raise RuntimeError(
|
||||
"Сессия не авторизована. Запусти: python main.py --auth"
|
||||
)
|
||||
|
||||
for channel in self.channels:
|
||||
try:
|
||||
messages = await self._collect_channel(channel, cutoff)
|
||||
result.messages.extend(messages)
|
||||
logger.info(f" ✓ {channel}: {len(messages)} сообщений")
|
||||
except ChannelPrivateError:
|
||||
err = "Канал приватный или вы не подписаны"
|
||||
result.errors[channel] = err
|
||||
logger.warning(f" ✗ {channel}: {err}")
|
||||
except (UsernameNotOccupiedError, UsernameInvalidError):
|
||||
err = "Канал не найден"
|
||||
result.errors[channel] = err
|
||||
logger.warning(f" ✗ {channel}: {err}")
|
||||
except FloodWaitError as e:
|
||||
logger.warning(f" ⏳ FloodWait: ждём {e.seconds}с")
|
||||
await asyncio.sleep(e.seconds)
|
||||
# Повторяем попытку
|
||||
try:
|
||||
messages = await self._collect_channel(channel, cutoff)
|
||||
result.messages.extend(messages)
|
||||
except Exception as retry_err:
|
||||
result.errors[channel] = str(retry_err)
|
||||
except Exception as e:
|
||||
result.errors[channel] = str(e)
|
||||
logger.warning(f" ✗ {channel}: {e}")
|
||||
|
||||
await asyncio.sleep(self.delay)
|
||||
|
||||
await self.client.disconnect()
|
||||
|
||||
# Сортировка по дате (новые первые)
|
||||
result.messages.sort(key=lambda m: m.date, reverse=True)
|
||||
logger.info(
|
||||
f"Итого: {len(result.messages)} сообщений из "
|
||||
f"{len(self.channels) - len(result.errors)} каналов"
|
||||
)
|
||||
return result
|
||||
|
||||
async def _collect_channel(
|
||||
self, channel: str, cutoff: datetime
|
||||
) -> list[Message]:
|
||||
"""Собрать сообщения из одного канала."""
|
||||
entity = await self.client.get_entity(channel)
|
||||
channel_title = getattr(entity, "title", channel)
|
||||
messages = []
|
||||
|
||||
async for msg in self.client.iter_messages(
|
||||
entity, limit=self.max_per_channel
|
||||
):
|
||||
# Пропускаем старые
|
||||
if msg.date < cutoff:
|
||||
break
|
||||
|
||||
# Пропускаем без текста и короткие
|
||||
if not msg.text or len(msg.text.strip()) < self.min_length:
|
||||
continue
|
||||
|
||||
# Формируем ссылку
|
||||
username = getattr(entity, "username", None)
|
||||
url = f"https://t.me/{username}/{msg.id}" if username else ""
|
||||
|
||||
messages.append(
|
||||
Message(
|
||||
channel=channel,
|
||||
channel_title=channel_title,
|
||||
text=msg.text.strip(),
|
||||
date=msg.date,
|
||||
views=msg.views or 0,
|
||||
url=url,
|
||||
)
|
||||
)
|
||||
|
||||
return messages
|
||||
75
config.example.yaml
Normal file
75
config.example.yaml
Normal file
@@ -0,0 +1,75 @@
|
||||
# ============================================
|
||||
# Telegram News Digest — Конфигурация
|
||||
# ============================================
|
||||
|
||||
telegram:
|
||||
api_id: 12345678 # Получить на https://my.telegram.org
|
||||
api_hash: "your_api_hash_here"
|
||||
session_name: "digest_session" # Имя файла сессии
|
||||
|
||||
# Список каналов для парсинга
|
||||
# Можно указывать: @username, username, или числовой ID канала
|
||||
channels:
|
||||
- "@rbc_news"
|
||||
- "@medabordarossa"
|
||||
- "@breakingmash"
|
||||
- "@rian_ru"
|
||||
# Добавь свои каналы...
|
||||
|
||||
# Сколько часов назад собирать сообщения
|
||||
hours_back: 12
|
||||
|
||||
# Максимум сообщений с одного канала
|
||||
max_messages_per_channel: 50
|
||||
|
||||
# Пауза между каналами (секунды) — защита от FloodWait
|
||||
delay_between_channels: 2
|
||||
|
||||
# Минимальная длина сообщения (символов) — фильтрует мусор
|
||||
min_message_length: 50
|
||||
|
||||
# ============================================
|
||||
# LLM для суммаризации
|
||||
# ============================================
|
||||
|
||||
llm:
|
||||
# Провайдер: "anthropic", "openai", "ollama"
|
||||
provider: "anthropic"
|
||||
|
||||
# --- Anthropic ---
|
||||
anthropic_api_key: "sk-ant-..."
|
||||
anthropic_model: "claude-sonnet-4-20250514"
|
||||
|
||||
# --- OpenAI ---
|
||||
# openai_api_key: "sk-..."
|
||||
# openai_model: "gpt-4o-mini"
|
||||
|
||||
# --- Ollama (локальный) ---
|
||||
# ollama_url: "http://localhost:11434"
|
||||
# ollama_model: "llama3.1:8b"
|
||||
|
||||
# Максимум токенов в ответе
|
||||
max_tokens: 4096
|
||||
|
||||
# Язык дайджеста
|
||||
language: "русский"
|
||||
|
||||
# ============================================
|
||||
# Доставка дайджеста
|
||||
# ============================================
|
||||
|
||||
delivery:
|
||||
# Метод: "saved_messages", "bot", "file"
|
||||
method: "saved_messages" # Отправка в «Избранное»
|
||||
|
||||
# --- Через бота ---
|
||||
# method: "bot"
|
||||
# bot_token: "123456:ABC-DEF..."
|
||||
# chat_id: 123456789
|
||||
|
||||
# --- В файл ---
|
||||
# method: "file"
|
||||
# output_dir: "./digests"
|
||||
|
||||
# Максимальная длина одного сообщения Telegram (символов)
|
||||
max_message_length: 4000
|
||||
118
delivery.py
Normal file
118
delivery.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Доставка дайджеста: Telegram (Избранное / бот) или файл.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from telethon import TelegramClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _split_message(text: str, max_length: int = 4000) -> list[str]:
|
||||
"""Разбивает длинное сообщение на части по границам строк."""
|
||||
if len(text) <= max_length:
|
||||
return [text]
|
||||
|
||||
parts = []
|
||||
current = ""
|
||||
for line in text.split("\n"):
|
||||
if len(current) + len(line) + 1 > max_length:
|
||||
if current:
|
||||
parts.append(current.strip())
|
||||
current = line + "\n"
|
||||
else:
|
||||
current += line + "\n"
|
||||
if current.strip():
|
||||
parts.append(current.strip())
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
class DeliveryManager:
|
||||
def __init__(self, config: dict):
|
||||
self.cfg = config["delivery"]
|
||||
self.tg_cfg = config["telegram"]
|
||||
self.method = self.cfg["method"]
|
||||
self.max_length = self.cfg.get("max_message_length", 4000)
|
||||
|
||||
async def deliver(self, digest: str) -> None:
|
||||
"""Отправить дайджест выбранным способом."""
|
||||
now = datetime.now().strftime("%d.%m.%Y %H:%M")
|
||||
header = f"📰 **Новостной дайджест** — {now}\n\n"
|
||||
full_text = header + digest
|
||||
|
||||
if self.method == "saved_messages":
|
||||
await self._send_saved_messages(full_text)
|
||||
elif self.method == "bot":
|
||||
await self._send_via_bot(full_text)
|
||||
elif self.method == "file":
|
||||
self._save_to_file(full_text)
|
||||
else:
|
||||
raise ValueError(f"Неизвестный метод доставки: {self.method}")
|
||||
|
||||
async def _send_saved_messages(self, text: str):
|
||||
"""Отправить в «Избранное» через Telethon."""
|
||||
client = TelegramClient(
|
||||
self.tg_cfg["session_name"],
|
||||
self.tg_cfg["api_id"],
|
||||
self.tg_cfg["api_hash"],
|
||||
)
|
||||
await client.connect()
|
||||
|
||||
if not await client.is_user_authorized():
|
||||
raise RuntimeError("Сессия не авторизована")
|
||||
|
||||
parts = _split_message(text, self.max_length)
|
||||
me = await client.get_me()
|
||||
for i, part in enumerate(parts, 1):
|
||||
await client.send_message(me, part, parse_mode="md")
|
||||
logger.info(f"Отправлена часть {i}/{len(parts)} в Избранное")
|
||||
|
||||
await client.disconnect()
|
||||
logger.info("✓ Дайджест отправлен в Избранное")
|
||||
|
||||
async def _send_via_bot(self, text: str):
|
||||
"""Отправить через Telegram Bot API."""
|
||||
token = self.cfg["bot_token"]
|
||||
chat_id = self.cfg["chat_id"]
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
|
||||
parts = _split_message(text, self.max_length)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
for i, part in enumerate(parts, 1):
|
||||
resp = await client.post(
|
||||
url,
|
||||
json={
|
||||
"chat_id": chat_id,
|
||||
"text": part,
|
||||
"parse_mode": "Markdown",
|
||||
"disable_web_page_preview": True,
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.error(
|
||||
f"Ошибка Bot API: {resp.status_code} {resp.text}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Отправлена часть {i}/{len(parts)} через бота"
|
||||
)
|
||||
|
||||
logger.info("✓ Дайджест отправлен через бота")
|
||||
|
||||
def _save_to_file(self, text: str):
|
||||
"""Сохранить дайджест в файл."""
|
||||
output_dir = Path(self.cfg.get("output_dir", "./digests"))
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = datetime.now().strftime("digest_%Y%m%d_%H%M.md")
|
||||
filepath = output_dir / filename
|
||||
|
||||
filepath.write_text(text, encoding="utf-8")
|
||||
logger.info(f"✓ Дайджест сохранён: {filepath}")
|
||||
124
main.py
Normal file
124
main.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Telegram News Digest — точка входа.
|
||||
|
||||
Использование:
|
||||
python main.py # Собрать и отправить дайджест
|
||||
python main.py --auth # Авторизация (первый запуск)
|
||||
python main.py --collect # Только собрать (без суммаризации)
|
||||
python main.py --dry-run # Собрать + суммаризировать, но не отправлять
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from collector import MessageCollector
|
||||
from summarizer import Summarizer
|
||||
from delivery import DeliveryManager
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_config(path: str = "config.yaml") -> dict:
|
||||
"""Загрузить конфигурацию."""
|
||||
config_path = Path(path)
|
||||
if not config_path.exists():
|
||||
logger.error(
|
||||
f"Конфиг не найден: {path}\n"
|
||||
f"Скопируй config.example.yaml → config.yaml и заполни."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
async def run_auth(config: dict):
|
||||
"""Авторизация в Telegram."""
|
||||
logger.info("🔑 Авторизация в Telegram...")
|
||||
collector = MessageCollector(config)
|
||||
await collector.auth()
|
||||
logger.info("✓ Авторизация завершена. Сессия сохранена.")
|
||||
|
||||
|
||||
async def run_digest(config: dict, collect_only: bool = False, dry_run: bool = False):
|
||||
"""Основной пайплайн: сбор → суммаризация → доставка."""
|
||||
|
||||
# 1. Сбор сообщений
|
||||
logger.info("📥 Сбор сообщений из каналов...")
|
||||
collector = MessageCollector(config)
|
||||
result = await collector.collect()
|
||||
|
||||
if result.errors:
|
||||
logger.warning(f"Ошибки в {len(result.errors)} каналах:")
|
||||
for ch, err in result.errors.items():
|
||||
logger.warning(f" {ch}: {err}")
|
||||
|
||||
if not result.messages:
|
||||
logger.info("📭 Нет новых сообщений. Выход.")
|
||||
return
|
||||
|
||||
if collect_only:
|
||||
# Вывести сырые сообщения
|
||||
for msg in result.messages[:20]:
|
||||
print(f"\n[{msg.channel_title}] {msg.date.strftime('%H:%M')}")
|
||||
print(msg.text[:200] + ("..." if len(msg.text) > 200 else ""))
|
||||
return
|
||||
|
||||
# 2. Суммаризация
|
||||
logger.info("🤖 Суммаризация через LLM...")
|
||||
summarizer = Summarizer(config)
|
||||
digest = await summarizer.summarize(result.messages)
|
||||
|
||||
if dry_run:
|
||||
print("\n" + "=" * 60)
|
||||
print(digest)
|
||||
print("=" * 60)
|
||||
logger.info("(dry-run: дайджест не отправлен)")
|
||||
return
|
||||
|
||||
# 3. Доставка
|
||||
logger.info("📤 Отправка дайджеста...")
|
||||
delivery = DeliveryManager(config)
|
||||
await delivery.deliver(digest)
|
||||
|
||||
logger.info("✅ Готово!")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Telegram News Digest")
|
||||
parser.add_argument(
|
||||
"--auth", action="store_true", help="Авторизация в Telegram"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--collect", action="store_true", help="Только сбор (без суммаризации)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="Без отправки (вывод в консоль)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config", default="config.yaml", help="Путь к конфигу"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_config(args.config)
|
||||
|
||||
if args.auth:
|
||||
asyncio.run(run_auth(config))
|
||||
else:
|
||||
asyncio.run(
|
||||
run_digest(config, collect_only=args.collect, dry_run=args.dry_run)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
telethon>=1.36
|
||||
pyyaml>=6.0
|
||||
anthropic>=0.40
|
||||
openai>=1.50
|
||||
httpx>=0.27
|
||||
150
summarizer.py
Normal file
150
summarizer.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Суммаризация новостей через 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"]
|
||||
Reference in New Issue
Block a user