From ba1245a06c2b4fbd9557845a828cc1b8f0e81fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=90=D0=B1=D0=B0=D0=B9?= =?UTF-8?q?=D0=B4=D1=83=D0=BB=D0=B8=D0=BD?= Date: Sat, 7 Feb 2026 16:31:46 +0300 Subject: [PATCH] init --- .env.example | 56 +++++++++++++++++ .gitignore | 7 +++ Dockerfile | 14 +++++ README.md | 104 ++++++++++++++++++++++++++++++++ collector.py | 136 +++++++++++++++++++++++++++++++++++++++++ config.py | 62 +++++++++++++++++++ delivery.py | 109 +++++++++++++++++++++++++++++++++ docker-compose.yml | 14 +++++ main.py | 100 ++++++++++++++++++++++++++++++ requirements.txt | 5 ++ summarizer.py | 147 +++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 754 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 collector.py create mode 100644 config.py create mode 100644 delivery.py create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 summarizer.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8ca1be7 --- /dev/null +++ b/.env.example @@ -0,0 +1,56 @@ +# ============================================ +# Telegram News Digest — Конфигурация +# ============================================ + +# --- Telegram API --- +TG_API_ID=12345678 +TG_API_HASH=your_api_hash_here +TG_SESSION_NAME=digest_session + +# Список каналов через запятую +TG_CHANNELS=@rbc_news,@medabordarossa,@breakingmash,@rian_ru + +# Сколько часов назад собирать +TG_HOURS_BACK=12 + +# Максимум сообщений с одного канала +TG_MAX_MESSAGES_PER_CHANNEL=50 + +# Пауза между каналами (секунды) +TG_DELAY_BETWEEN_CHANNELS=2 + +# Минимальная длина сообщения (символов) +TG_MIN_MESSAGE_LENGTH=50 + +# --- LLM --- +# Провайдер: anthropic / openai / ollama +LLM_PROVIDER=ollama + +# Anthropic +ANTHROPIC_API_KEY= +ANTHROPIC_MODEL=claude-sonnet-4-20250514 + +# OpenAI +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o-mini + +# Ollama (host.docker.internal — доступ к хосту из Docker) +OLLAMA_URL=http://host.docker.internal:11434 +OLLAMA_MODEL=gemma3:12b + +# Максимум токенов в ответе +LLM_MAX_TOKENS=4096 + +# Язык дайджеста +LLM_LANGUAGE=русский + +# --- Доставка --- +# Метод: saved_messages / bot / file +DELIVERY_METHOD=saved_messages + +# Через бота +BOT_TOKEN= +BOT_CHAT_ID= + +# Максимальная длина сообщения Telegram +DELIVERY_MAX_MESSAGE_LENGTH=4000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f82920 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +*.session +*.session-journal +digests/ +__pycache__/ +*.pyc +digest.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b4f633e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Зависимости отдельным слоем для кэширования +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY *.py . + +# Директория для сессий и дайджестов (монтируется как volume) +RUN mkdir -p /data + +ENTRYPOINT ["python", "main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f660bf1 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# 🤖 tg-digest-docker + +Автоматический сбор новостей из Telegram-каналов с AI-суммаризацией и доставкой дайджеста. + +## Архитектура + +``` +┌─────────────────────────────────────────────┐ +│ macOS (M4 Pro) │ +│ │ +│ ┌───────────┐ ┌──────────────────────┐ │ +│ │ Ollama │◄───│ Docker: tg-digest │ │ +│ │ (native) │ │ ┌────────────────┐ │ │ +│ │ GPU ✓ │ │ │ collector.py │ │ │ +│ └───────────┘ │ │ summarizer.py │ │ │ +│ localhost:11434 │ │ delivery.py │ │ │ +│ │ └────────────────┘ │ │ +│ │ volume: /data │ │ +│ └──────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +- **Ollama** работает нативно на macOS → полный доступ к Apple GPU +- **Python-приложение** в Docker → изолированные зависимости +- Связь через `host.docker.internal:11434` + +## Быстрый старт + +### 1. Ollama (на хосте) + +```bash +# Установи Ollama: https://ollama.com +ollama pull gemma3:12b +ollama serve # если не запущен как сервис +``` + +### 2. Telegram API + +Получи `api_id` и `api_hash` на https://my.telegram.org + +### 3. Конфигурация + +```bash +cp .env.example .env +# Заполни .env: TG_API_ID, TG_API_HASH, список каналов +``` + +### 4. Сборка и авторизация + +```bash +docker compose build +docker compose run --rm app --auth +# Введи номер телефона и код из Telegram +``` + +### 5. Запуск + +```bash +# Тест (вывод в консоль, без отправки) +docker compose run --rm app --dry-run + +# Боевой запуск +docker compose run --rm app + +# Только сбор (без суммаризации) +docker compose run --rm app --collect +``` + +### Автозапуск (cron на маке) + +```bash +crontab -e +# Каждый день в 8:00 и 20:00 +0 8,20 * * * cd /path/to/tg-digest && docker compose run --rm app >> /tmp/tg-digest.log 2>&1 +``` + +## Конфигурация (.env) + +| Переменная | Описание | По умолчанию | +|---|---|---| +| `TG_API_ID` | Telegram API ID | — | +| `TG_API_HASH` | Telegram API Hash | — | +| `TG_CHANNELS` | Каналы через запятую | — | +| `TG_HOURS_BACK` | За сколько часов собирать | `12` | +| `LLM_PROVIDER` | `ollama` / `anthropic` / `openai` | `ollama` | +| `OLLAMA_MODEL` | Модель Ollama | `gemma3:12b` | +| `DELIVERY_METHOD` | `saved_messages` / `bot` / `file` | `saved_messages` | + +Полный список — в `.env.example`. + +## Структура + +``` +tg-digest/ +├── .env.example # Шаблон конфигурации +├── Dockerfile +├── docker-compose.yml +├── requirements.txt +├── config.py # Загрузка конфига из env +├── main.py # Точка входа + CLI +├── collector.py # Сбор из Telegram +├── summarizer.py # LLM-суммаризация +└── delivery.py # Отправка дайджеста +``` diff --git a/collector.py b/collector.py new file mode 100644 index 0000000..f88b578 --- /dev/null +++ b/collector.py @@ -0,0 +1,136 @@ +""" +Сбор сообщений из Telegram-каналов через Telethon. +""" + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from dataclasses import dataclass, field +from pathlib import Path + +from telethon import TelegramClient +from telethon.errors import ( + FloodWaitError, + ChannelPrivateError, + UsernameNotOccupiedError, + UsernameInvalidError, +) + +logger = logging.getLogger(__name__) + +SESSION_DIR = Path("/data") + + +@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"] + session_path = SESSION_DIR / tg["session_name"] + self.client = TelegramClient( + str(session_path), 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( + "Сессия не авторизована. Запусти:\n" + "docker compose run --rm app 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 diff --git a/config.py b/config.py new file mode 100644 index 0000000..4171b7b --- /dev/null +++ b/config.py @@ -0,0 +1,62 @@ +""" +Загрузка конфигурации из переменных окружения. +""" + +import os +from pathlib import Path + +from dotenv import load_dotenv + +# Загружаем .env если есть (в Docker пробрасывается через env_file) +env_path = Path(__file__).parent / ".env" +if env_path.exists(): + load_dotenv(env_path) + + +def get_config() -> dict: + """Собрать конфиг из переменных окружения.""" + return { + "telegram": { + "api_id": int(os.environ["TG_API_ID"]), + "api_hash": os.environ["TG_API_HASH"], + "session_name": os.environ.get("TG_SESSION_NAME", "digest_session"), + "channels": [ + ch.strip() + for ch in os.environ.get("TG_CHANNELS", "").split(",") + if ch.strip() + ], + "hours_back": int(os.environ.get("TG_HOURS_BACK", "12")), + "max_messages_per_channel": int( + os.environ.get("TG_MAX_MESSAGES_PER_CHANNEL", "50") + ), + "delay_between_channels": float( + os.environ.get("TG_DELAY_BETWEEN_CHANNELS", "2") + ), + "min_message_length": int( + os.environ.get("TG_MIN_MESSAGE_LENGTH", "50") + ), + }, + "llm": { + "provider": os.environ.get("LLM_PROVIDER", "ollama"), + "anthropic_api_key": os.environ.get("ANTHROPIC_API_KEY", ""), + "anthropic_model": os.environ.get( + "ANTHROPIC_MODEL", "claude-sonnet-4-20250514" + ), + "openai_api_key": os.environ.get("OPENAI_API_KEY", ""), + "openai_model": os.environ.get("OPENAI_MODEL", "gpt-4o-mini"), + "ollama_url": os.environ.get( + "OLLAMA_URL", "http://host.docker.internal:11434" + ), + "ollama_model": os.environ.get("OLLAMA_MODEL", "gemma3:12b"), + "max_tokens": int(os.environ.get("LLM_MAX_TOKENS", "4096")), + "language": os.environ.get("LLM_LANGUAGE", "русский"), + }, + "delivery": { + "method": os.environ.get("DELIVERY_METHOD", "saved_messages"), + "bot_token": os.environ.get("BOT_TOKEN", ""), + "chat_id": os.environ.get("BOT_CHAT_ID", ""), + "max_message_length": int( + os.environ.get("DELIVERY_MAX_MESSAGE_LENGTH", "4000") + ), + }, + } diff --git a/delivery.py b/delivery.py new file mode 100644 index 0000000..77cf2f2 --- /dev/null +++ b/delivery.py @@ -0,0 +1,109 @@ +""" +Доставка дайджеста: Telegram (Избранное / бот) или файл. +""" + +import logging +from datetime import datetime +from pathlib import Path + +import httpx +from telethon import TelegramClient + +logger = logging.getLogger(__name__) + +SESSION_DIR = Path("/data") + + +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): + session_path = SESSION_DIR / self.tg_cfg["session_name"] + client = TelegramClient( + str(session_path), + 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): + 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("/data/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}") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ff4c295 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + app: + build: . + env_file: .env + volumes: + # Persistent: сессия Telethon + сохранённые дайджесты + - tg-data:/data + # host.docker.internal доступен по умолчанию на Docker Desktop (macOS) + # Для Linux раскомментируй: + # extra_hosts: + # - "host.docker.internal:host-gateway" + +volumes: + tg-data: diff --git a/main.py b/main.py new file mode 100644 index 0000000..1deed67 --- /dev/null +++ b/main.py @@ -0,0 +1,100 @@ +""" +Telegram News Digest — точка входа. + +Использование (через Docker): + docker compose run --rm app python main.py --auth # Авторизация + docker compose run --rm app python main.py --dry-run # Тест (без отправки) + docker compose run --rm app python main.py # Боевой запуск +""" + +import argparse +import asyncio +import logging +import sys + +from config import get_config +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__) + + +async def run_auth(config: dict): + logger.info("🔑 Авторизация в Telegram...") + collector = MessageCollector(config) + await collector.auth() + logger.info("✓ Авторизация завершена. Сессия сохранена в /data/") + + +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") + parser.add_argument("--collect", action="store_true") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + try: + config = get_config() + except (KeyError, ValueError) as e: + logger.error(f"Ошибка конфигурации: {e}") + logger.error("Проверь .env файл (см. .env.example)") + sys.exit(1) + + 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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..67b07e9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +telethon>=1.36 +python-dotenv>=1.0 +httpx>=0.27 +anthropic>=0.40 +openai>=1.50 diff --git a/summarizer.py b/summarizer.py new file mode 100644 index 0000000..9b8f2a2 --- /dev/null +++ b/summarizer.py @@ -0,0 +1,147 @@ +""" +Суммаризация новостей через 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"]