Skip to content
Neuronavt
Go back

News Radar — AI-система мониторинга крипто-новостей на локальной LLM

Edit page

TL;DR: Телеграм-userbot читает 57+ каналов, LLM анализирует каждое сообщение, HDBSCAN кластеризует тренды, а дайджест сам пишется и отправляется дважды в день. Всё работает локально на Qwen3.6 35B. Под катом — архитектура, нетривиальные решения и грабли, на которые я наступил.


С чего всё началось

Любое информационное поле — это хаос. Куча каналов, сайтов, множество сообщений в день. За этим не уследить. При этом 80% постов — либо реклама, либо перепосты одного и того же.

Идея простая: пусть машина читает всё, а человеку достаётся только суть. И реализация оказалась интереснее, комплекснее, чем изначально задумывалась.

В итоге вышел проект, который я назвал News Radar — пилотный, но рабочий. И именно о нём эта статья.

Я опишу в целом как работает проект и с чем столкнулся, это не подробный гайд по запуску.

News Radar


Архитектура: что с чем разговаривает

Прежде чем лезть в детали, важно понять общую картину.

Telegram Channels (57+)
        │
        ▼
   COLLECTOR (Telethon userbot)
   читает каналы, сохраняет в SQLite
        │
        ▼
   DATABASE (SQLite WAL)
   sources, messages, analysis, trends, digests
        │
   ┌────┴────┐
   ▼         ▼
ANALYZER    API (FastAPI :8000)
LLM + BGE-m3    /feed /search /trends
ChromaDB        /digest /settings
TrendTracker         │
                     ▼
               Telegram Bot
               (команды + auto-digest)

Пять Docker-контейнеров: collector, analyzer, api, bot, chromadb. Все общаются через SQLite и HTTP. Никакого Kafka, никакого Redis — специально: хочется понять систему целиком, не прятать сложность за абстракциями.

Docker-контейнеры

Collector — это Telethon userbot, который сидит в Telegram под реальным аккаунтом и читает все каналы из заданной папки. Он не анализирует и не думает — просто собирает сырые данные и складывает в базу. Views, реакции, пересылки, метаданные канала — всё идёт в SQLite.

SQLite — общая шина данных между всеми сервисами. Не Kafka, не Redis, просто файл с WAL-режимом. Collector пишет, Analyzer читает и пишет, API читает. Всё крутится вокруг одной базы, и это осознанный выбор: меньше движущихся частей, проще отлаживать.

Analyzer — сердце системы. Он забирает необработанные сообщения, прогоняет через эвристический фильтр рекламы, проверяет в ChromaDB нет ли уже похожего текста, и если нет — отправляет на LLM. Модель возвращает JSON: температуру новости от 1 до 10, тему, саммари, сентимент. Параллельно работает TrendTracker — он берёт эмбеддинги из ChromaDB, кластеризует через HDBSCAN и находит темы, о которых одновременно пишут сразу много каналов.

ChromaDB — отдельный контейнер с векторным хранилищем. Туда уходят BGE-m3 эмбеддинги каждого проанализированного сообщения. Используется для двух вещей: семантическая дедупликация перед LLM и поиск по смыслу через /search.

API + Bot — FastAPI отдаёт данные наружу, Telegram-бот их потребляет. Бот умеет присылать последний дайджест, показывать горячие тренды, принимать подписки на темы и генерировать дайджест по команде. Два раза в день — в 12:00 и 20:00 по Москве — дайджест уходит автоматически.

Поток данных линейный:

каналы → collector → SQLite → analyzer → ChromaDB + SQLite → API → бот → пользователь

Никаких обратных связей, никаких очередей. Если что-то упало — данные лежат в базе и будут обработаны при следующем запуске.


Collector: userbot

Здесь первая нетривиальная вещь. Обычный Telegram-бот не может читать чужие каналы — он отвечает только на команды. Нужен userbot на реальном аккаунте через Telethon.

Принцип работы: нужен клиент через который при первом запуске вводишь SMS-код, сессия сохраняется в файл. Дальше userbot читает все каналы, в которых состоит аккаунт. Можно создать свое приложение и использовать его Telegram API hash (my.telegram.org) Либо можно обще доступный взять, но лучше свой, безопасней.

Фильтрация происходит на уровне Telegram-папок. Создал папку Ton/DeFi в Telegram, добавил туда нужные каналы — collector автоматически подхватывает их список через GetDialogFiltersRequest. Хочу добавить канал — просто кидаю его в папку, перезапускать ничего не нужно.

Catchup + real-time параллельно

При старте нужно решить задачу: догнать пропущенные сообщения и одновременно слушать новые. Если делать последовательно — пока догоняешь (это может занять минуты), новые события теряются.

await asyncio.gather(
    listen_loop(),       # Real-time сразу при подключении
    catchup_then_done(), # Догоняем пропущенное параллельно
    cfg.watch(),         # Следим за изменениями конфига
)

INSERT OR IGNORE в базе защищает от дублей, если одно и то же сообщение придёт из обоих потоков.

Forward tracking

Многие каналы — агрегаторы: они пересылают чужие посты. Важно понимать, кто первым опубликовал новость. Для этого сохраняем forward_from_channel и forward_from_msg_id из метаданных пересылки. Позже это используется в подсчёте уникальных источников тренда.


База данных: SQLite

Я выбрал SQLite с WAL-режимом. Схема написана так, что при необходимости легко мигрировать на PostgreSQL — структура идентична. Но для одной машины SQLite справляется отлично.

Ключевые решения:

conn = sqlite3.connect(path, check_same_thread=False, timeout=30)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")  # 30 сек retry при lock

WAL позволяет читателям не блокировать писателей. busy_timeout решает проблему конкурентного доступа collector + analyzer: вместо мгновенной ошибки SQLite ждёт освобождения до 30 секунд.

Миграции — пара (name, sql), выполняются ровно один раз. Если колонка уже есть (duplicate column) — миграция помечается как применённая без ошибки. Безопасно при перезапусках.

SQLite схема


Analyzer: сердце системы

Здесь происходит всё самое интересное. Цикл запускается каждые 30 минут или при накоплении достаточного количества необработанных сообщений.

Приоритизация

Не все сообщения одинаково важны. Порядок обработки:

ORDER BY COALESCE(m.views, 0) DESC, length(m.text) DESC, m.collected_at DESC

Посты с высоким engagement обрабатываются первыми. Если пришло 200 новых сообщений, а лимит батча 50 — LLM увидит самые горячие.

Два слоя фильтрации рекламы

Перед тем как тратить токены на LLM, проверяем текст эвристически:

# Layer 1: ключевые слова (мгновенно)
if any(kw in text.lower() for kw in ["#реклама", "на правах рекламы", "реферальная ссылка"]):
    mark_as_ad()
    return

# Layer 2: LLM (для неявной рекламы)
# "is_ad": true в JSON-ответе

Эвристика ловит явные случаи и экономит 30-40% LLM-запросов. LLM ловит тонкие: “этот токен изменит твою жизнь” — реклама, хотя слова “реклама” нет.

Семантическая дедупликация

Перед LLM-анализом проверяем: не обрабатывали ли мы уже похожее сообщение?

encode(text) → embedding (BGE-m3)
        │
ChromaDB.search(embedding, limit=1)
        │
similarity > 0.90? → клонируем AI-ответ из найденного
similarity < 0.90? → LLM analysis

На практике один и тот же релиз появляется в 10-15 каналах. Первый идёт на LLM, остальные получают клонированный результат. Экономия ~85-90% токенов на дублирующихся новостях.

LLM prompt

"""You are a crypto/financial news analyst.
CHANNEL: {source_name}
MESSAGE: {text}

Return ONLY valid JSON:
{
  "temperature": <1-10: 1-3 routine, 4-6 interesting, 7-8 hot, 9-10 BREAKING>,
  "topic": "bitcoin | ethereum | defi | hack/scam | ...",
  "summary": "<...>",
  "keywords": ["..."],
  "sentiment": "positive | negative | neutral",
  "is_ad": false
}

CRITICAL: If the post contains strong subjective opinions or sarcasm,
capture the author's main point and attitude. Do not reduce editorial
posts to dry facts only."""

Последний абзац — результат итерации. Без него LLM превращал мнения и сарказм в сухие факты, теряя голос автора канала. Для крипто-контента, где часто важна именно позиция, это критично.

Параллельные запросы к LLM

concurrency = cfg.get("llm_concurrency", 3)
sem = asyncio.Semaphore(concurrency)

# 3 параллельных LLM-запроса одновременно
tasks = [process_row(r) for r in batch]
results = await asyncio.gather(*tasks)

Qwen3.6 35B на 4090 держит 3 параллельных запроса без деградации. Throughput ~140 токен/с в llama.cpp (против ~85 в Oobabooga). Режим thinking включён — это +15-20 секунд на сообщение, но качество температурных оценок значительно лучше.

Параллельные запросы к LLM

Параллельные запросы: батч сортируется по views (горячее — первым), затем Semaphore(3) пускает одновременно не более трёх воркеров. Каждый воркер проходит три этапа последовательно — эвристика → ChromaDB → LLM. Если эвристика поймала рекламу — дальше не идёт. Если ChromaDB нашла похожее (similarity ≥ 0.90) — клонирует результат и пропускает LLM. На LLM попадает только то, что прошло оба фильтра.


Embeddings и ChromaDB: векторный слой

Для семантики используется BGE-m3 — мультиязычная модель от BAAI. Поддерживает русский и английский (основные языки крипто-каналов), 1024 измерения, работает локально (~570 MB).

from sentence_transformers import SentenceTransformer

class Embedder:
    def encode(self, text: str) -> list[float]:
        vector = self._model.encode(text, normalize_embeddings=True)
        return vector.tolist()

normalize_embeddings=True — после нормализации косинусное сходство равно скалярному произведению. Это то, что использует ChromaDB.

ChromaDB работает в отдельном контейнере. Каждый документ — одно проанализированное сообщение, в метаданных — source, timestamp, temperature, topic.

Важный момент: ChromaDB может упасть (OOM killer в Docker). При этом коллекция исчезает. Решение — reconnect + retry:

def _reconnect_if_collection_lost(self, e: Exception):
    if "does not exist" in str(e) or "404" in str(e):
        self._connect(force=True)
        return True

Ошибка не фатальна — анализ продолжается, ChromaDB восстанавливается при следующем вызове.


TrendTracker: кластеризация трендов

Идея простая и мощная:

Когда множество разных каналов независимо пишут об одном и том же — это горячая новость.

Не нужен ML с разметкой. Нужна кластеризация.

HDBSCAN вместо BERTopic

Почему не BERTopic? Он заново кодирует тексты. У нас уже есть эмбеддинги в ChromaDB. Берём их напрямую:

arr = np.array(embeddings, dtype=np.float32)
clusterer = HDBSCAN(
    min_cluster_size=2,
    metric="euclidean",             # для нормализованных = cosine-equivalent
    cluster_selection_epsilon=0.25,   # tunable через конфиг
    core_dist_n_jobs=1,
)
labels = clusterer.fit_predict(arr)

Значение epsilon подбирал вручную: 0.35 — слишком мало кластеров, несвязанные новости сливались. 0.15 — слишком много, один тренд разбивался на части. Остановился на 0.25 с возможностью менять через конфиг без пересборки.

TrendScore

score = (
    unique_sources          # главный множитель
    * avg_temperature
    * math.exp(-0.3 * hours_since_first)  # decay: через 2.3ч = 0.5
    * (1.0 + math.log1p(avg_views))
)

unique_sources — самый важный фактор. 10 разных каналов о событии важнее, чем один канал с temperature=10.

Жизненный цикл тренда

emerging → hot → cooling → dead

При достижении hot и ≥5 уникальных источников — алерт уходит в Telegram. Один раз, не при каждом цикле: проверяется alerted_at IS NULL.

TrendTracker


Digest: дайджест на автопилоте

Дайджест генерируется дважды в день (12:00 и 20:00 МСК) или по команде /digest new.

Четыре уровня приоритета

# 1. ALERTS: hack/scam или temperature >= 9 — всегда первыми
# 2. TRENDS: сообщения из горячих трендов
# 3. HIGH: temperature >= базовый + 2
# 4. FILL: по одному лучшему от каждого оставшегося топика

max_per_topic = 1 — одна тема не может заполнить весь дайджест.

Три слоя дедупликации

  1. Semantic (ChromaDB, similarity > 0.85) — убирает почти одинаковые сообщения
  2. Cross-digest (против предыдущих 2 дайджестов, порог 0.75) — если тема была вчера, она идёт в секцию “Продолжение”, не как новая
  3. Topic cap — не более 1 элемента на топик

Эмоциональный баланс

Если предыдущий дайджест начинался с негатива (hack, crash, liquidation) — следующий не должен. Пользователь (Я) не хочет видеть два подряд депрессивных дайджеста:

if prev_was_negative:
    selected = non_negative[:2] + negative_items + non_negative[2:]

Два шаблона: Classic и Spoiler

Classic: LLM генерирует готовый Markdown-текст для Telegram.

Spoiler: LLM возвращает JSON со структурой, Python рендерит HTML с <blockquote expandable> — сводка скрыта под катом, разворачивается по нажатию. Решает проблему длинных дайджестов.

lines.append(f"🔹 <b>{title}</b>")
lines.append(f"<blockquote expandable>{summary}</blockquote>")
lines.append(f'<a href="{source_url}">источник</a>')

Digest шаблоны


Config Hot-Reload: без перезапуска контейнеров

Раньше чтобы изменить breaking_alert_min_temp — нужен был рестарт. В Docker это downtime.

Решение: watchdog + callbacks.

class ConfigWatcher:
    async def watch(self):
        observer = PollingObserver(timeout=3)  # не inotify!
        # ...

    def on_change(self, key: str, callback: Callable):
        self._callbacks.setdefault(key, []).append(callback)

Почему PollingObserver, а не inotify? Потому что inotify не работает в Docker Desktop на Windows — файловые изменения с хоста невидимы для Linux inotify. Polling работает везде, latency 3 секунды — для конфига приемлемо.

# Пример: смена папки Telegram без рестарта
cfg.on_change("telegram_folder", lambda folder: collector._sync_dialogs(folder))

Agent Mode: OpenClaw

Самая экспериментальная часть. Есть два режима:

Legacy: Analyzer → LLM → Digest → Telegram

Agent:  Analyzer → OpenClaw → Narrative Digest → API → Telegram

OpenClaw — внешний AI-агент, который получает текстовые события и сам решает что делать: написать narrative-дайджест, отправить алерт, ответить на вопрос пользователя из /ask.

Честно скажу про ограничение: на локальной машине с 4090 Qwen3.6 35B держит только ~24K токенов контекста. Для полноценного агентного режима с длинной историей диалога этого не хватает. На моём сценарии (короткие events, небольшой контекст) — работает. Но для тяжёлых агентных задач нужно либо больше VRAM, либо меньшая модель.

В основном делал для баловства и теста библиотеки и практики.

Межпроцессный lock

Проблема: analyzer генерирует дайджест → одновременно запускается цикл анализа → LLM перегружается.

Решение — файловый lock на shared volume:

LLM_LOCK_FILE = "/app/data/llm.lock"

# В generate_digest():
with LLMLock():
    result = await llm.complete_json(...)

# В analyze_pending():
if is_llm_locked():
    return 0  # пропускаем цикл

Работает между Docker-контейнерами через общий volume data/.


Проблемы, на которые наступил

Ни один проект не обходится без граблей. Самые показательные:

Stale alerts на старте. После перезапуска или запуска ибо ночью я компьютер то вырубаю чтобы спать в тишине. collector догонял тысячи пропущенных сообщений. TrendTracker сразу кластеризовал их и слал алерты для трендов, которые были горячими неделю назад. Решение: is_fresh = (now - last_seen) < 2 hours — алерт только для свежих трендов.

LLM hallucinating URLs. LLM генерировал ссылки на источники прямо в тексте и мог придумать несуществующие или порядок изменить. Теперь URL строится только из данных БД через source_map: LLM получает [1], [2], Python подставляет реальные ссылки.

Private channels. Telegram использует разные форматы URL для публичных и приватных каналов: t.me/channel/123 vs t.me/c/123456789/123. Числовой ID нужно обрабатывать отдельно, убирая -100 префикс.

Двойные кавычки в HTML. Telegram HTML-парсер отвергает одинарные кавычки в атрибутах href. Казалось бы, мелочь, но дайджест просто не отправлялся.

Author voice в LLM. Модель превращала саркастичные или эмоциональные посты в скучные факты. Добавил в prompt явное требование сохранять субъективную позицию автора — качество саммари сразу выросло.


Итог: что получилось

News Radar — это не продукт, это учебный проект с реальным результатом. Я пользуюсь им каждый день: два дайджеста, алерты на breaking news, /hot когда хочу быстро глянуть тренды.

Что получилось понять и пощупать руками:

Стек: Python, Telethon, FastAPI, SQLite, ChromaDB, BGE-m3, Qwen3.6 35B (llama.cpp), HDBSCAN, python-telegram-bot, Docker.

От этого проекта можно идти в разные стороны: добавить Twitter/Discord как источники, сделать персонализацию под каждого пользователя, улучшить агентный режим когда появится больше VRAM, построить RAG поверх накопленной базы.

Хотелось бы написать о другом проекте — Polymarket-trading UI, там уже другой масштаб и другие интересные приколюхи. Но сначала напишу отдельно про RAG систему, что новоял из новостей.


Ссылки


Edit page
Share this post:

Previous Post
Домен и его секреты — DNS, SSL, Nginx и защита от ботов
Next Post
Как я подключил AI-агента за $30 вместо $250 — честный разбор вариантов