Я собрал Telegram-бота, который показывает только хорошие новости — и хостится за $5 в месяц
TL;DR — @futur_e_news_bot. Двуязычная (RU/EN) лента новостей. По умолчанию — только хорошие и нейтральные, негатив подключается в настройках на 4 уровнях. ИИ убирает дубли, одно событие = одна карточка с несколькими источниками, перевод на лету, выдача подстраивается под реакции. Внутри: aiogram, локальные эмбеддинги, sqlite-vec вместо pgvector, бесплатные LLM через OpenRouter и одна машина на Fly.io за ~$5/мес. В статье — разбор архитектуры, код, цифры и грабли.
Зачем ещё один новостной бот
Я устал от трёх вещей одновременно:
-
Дубли. Одна и та же новость прилетает из пяти каналов с разными заголовками, превратив ленту в эхо-камеру одного события.
-
Шум. 90% повестки мне не интересны, но чтобы найти свои 10%, надо пролистать всё.
-
Тяжесть. Лента новостей сегодня — это поток катастроф. Я не хочу полностью отключаться от мира, но и не хочу начинать каждое утро с трёх смертей и одной войны.
Хотелось ленту, которая а) схлопывает дубли в одну карточку с указанием всех изданий, б) сама понимает, что мне заходит (без ручной разметки тегов), в) показывает это на двух языках без копипасты в переводчик и — главное — г) по умолчанию молчит про плохое, но даёт включить тяжёлый контент тумблером, если я к этому готов. Платные агрегаторы есть, но они либо не персонализируются, либо не понимают русский, либо стоят как абонемент в спортзал. Поэтому — выходные, кофе, git init.
Спустя несколько недель в проде бот живёт на одной shared-машине Fly.io, обрабатывает ~1.5k новостей в базе, и обходится примерно в стоимость чашки кофе в месяц. Под капотом — несколько архитектурных решений, которые имеет смысл разобрать отдельно. Этим и займёмся.
Что умеет
-
🌞 Хорошие новости по умолчанию. Каждая новость на лету оценивается LLM по шкале негатива 0–3 (от «нейтрального» до «тяжёлой трагедии»). У юзера ceiling по дефолту 0 — видит только позитив и технические апдейты. В настройках можно сдвинуть на «+ лёгкий негатив», «+ заметный» или «без фильтра».
-
Персональная лента. Жмёшь 🔥 / ❤️ / 😢 — бот сдвигает твой «вектор вкуса» и в следующий раз поднимает похожее выше. Никаких ручных тегов: первичные интересы вытягиваются из TG-профиля, дальше всё уточняется по реакциям.
-
Кластеризация дублей. Если 5 изданий написали об одном событии — увидишь одну карточку с пометкой
📰 5 источникови кнопкой со списком всех ссылок. Сигнал из количества источников учитывается в ранжировании: мультиисточные события естественно поднимаются выше. -
Двуязычность. Каждая новость доступна на RU и EN, перевод делает LLM. Язык переключается в один тап.
-
Форматы доставки. Live-лента, сводка за час, сводка за день, моментальные пуши по «срочному». Всё с тумблерами в настройках, по дефолту включена только дневная сводка (чтобы не спамить).
-
Свои каналы. Можно добавить любой публичный TG-канал — он подключится через self-hosted RSSHub и попадёт в общий пайплайн обогащения и ранжирования. Есть переключатель «только мои каналы».
-
Inline-режим. Набираешь
@futur_e_news_bot AIв любом чате — и вставляешь свежую новость по теме прямо в переписку. -
Управление интересами на естественном языке. Пишешь «больше про космос, меньше про политику» — LLM разбирает и применяет.
-
Админ-фичи.
/statsсо срезами по пользователям и категориям,/broadcastс предпросмотром и подтверждением — чтобы не разослать миллиону людей опечатку.
Архитектурно: один воркер, один SQLite, две машины
┌─────────────────────────┐ ┌────────────────────┐│ Fly machine #1 (512 МБ) │ │ Fly machine #2 ││ │ │ (256 МБ, приватная)││ ┌─────────────────────┐ │ │ ││ │ aiogram long-polling│ │ │ RSSHub ││ ├─────────────────────┤ │ │ (Telegram → RSS) ││ │ APScheduler │◄┼────── 6PN ────────────┤ ││ │ • pipeline (15 мин)│ │ ainews-rsshub. │ ││ │ • deliver (20 мин) │ │ internal:1200 │ ││ │ • breaking (1 мин)│ │ │ ││ │ • daily digest │ │ │ ││ └─────────────────────┘ │ └────────────────────┘│ ┌─────────────────────┐ ││ │ SQLite + sqlite-vec │ ││ │ (на Fly-volume) │ ││ └─────────────────────┘ ││ ┌─────────────────────┐ ││ │ fastembed (ONNX) │ │ ── locally, no API│ └─────────────────────┘ │└─────────┬───────────────┘ │ │ HTTPS ▼ OpenRouter (LLM chain)
Никакого публичного HTTP, балансировщиков, отдельной БД-машины и Redis. Воркер опрашивает Telegram через long-polling, APScheduler гоняет джобы, всё состояние — в SQLite на томе. RSSHub живёт отдельным приложением и доступен только по внутренней сети Fly (*.internal:1200) — наружу не торчит.
Цена этого:
|
Компонент |
Память |
~$/мес |
|---|---|---|
|
Бот (app + swap) |
512 МБ + 512 МБ |
~$3.2 |
|
RSSHub (приватный) |
256 МБ |
~$1.9 |
|
Том SQLite |
1 ГБ |
~$0.15 |
|
OpenRouter (LLM) |
— |
$0–1 |
|
Итого |
|
~$5–6 |
При текущей нагрузке (десятки активных пользователей) машина простаивает: CPU loadavg около нуля, RAM ~167 МБ из ~459 МБ. Запас до сотен пользователей — без апгрейда.
Дальше — по техническим решениям, которые сделали это возможным.
sqlite-vec вместо pgvector
Изначально была связка Postgres + pgvector. Она прекрасно работает, но требует отдельной машины под БД (минимум +$5/мес на Fly за самый простой инстанс), отдельных секретов, бэкапов, миграций — куча инфры ради того, чтобы хранить эмбеддинги.
В какой-то момент я попробовал sqlite-vec — это нативное расширение SQLite, которое добавляет виртуальные таблицы vec0 с косинусной/L2/Hamming-метриками и KNN-поиском прямо в SQL. По сути — pgvector, только встраиваемый и без сервера.
Создаётся таблица так:
# app/db/vec.pyasync def create_table(conn) -> None: await conn.exec_driver_sql( f"CREATE VIRTUAL TABLE IF NOT EXISTS story_vec " f"USING vec0(embedding float[384] distance_metric=cosine)" )
KNN-запрос — обычный SELECT с MATCH:
async def knn(session, vector, k: int) -> list[tuple[int, float]]: rows = (await session.execute( text( "SELECT rowid, distance FROM story_vec " "WHERE embedding MATCH :v AND k = :k ORDER BY distance" ), {"v": sqlite_vec.serialize_float32(list(vector)), "k": k}, )).all() return [(r[0], r[1]) for r in rows]
Этого хватает для трёх вещей в боте:
-
Дедуп при инжесте. Для каждой свежей новости делаем KNN, если ближайший сосед ближе порога — это дубль, не сохраняем как новую историю (а прицепляем как дополнительный источник, об этом ниже).
-
«Похожее». Когда читаешь новость, есть кнопка «ещё про это» — тот же KNN, только результаты не отбрасываем как дубли, а показываем.
-
Inline-поиск. Запрос пользователя
@futur_e_news_bot AI→ эмбеддим строку → KNN по базе. Это не поиск по подстроке, это семантический поиск.
Ранжирование персональной ленты делается отдельно — там нужен не KNN, а скоринг каждого кандидата по нескольким признакам (cosine + категория + теги + freshness). Поэтому для ленты — brute-force по последним 600 кандидатам в numpy. При наших объёмах это миллисекунды.
# app/reco/engine.py_RANK_SCAN = 600async def _rank(session, user, conds): stories = (await session.execute( select(Story).where(*conds).order_by(Story.created_at.desc()).limit(_RANK_SCAN) )).scalars().all() tv = np.asarray(user.taste_vec, dtype=np.float32) tnorm = float(np.linalg.norm(tv)) or 1.0 scored = [] for st in stories: ev = np.asarray(st.embedding, dtype=np.float32) denom = (float(np.linalg.norm(ev)) * tnorm) or 1.0 dist = 1.0 - float(np.dot(ev, tv)) / denom scored.append((st, _score(st, dist, interests, tag_interests))) scored.sort(key=lambda p: p[1], reverse=True) return [s for s, _ in scored]
Итог: одна машина вместо двух, нулевая стоимость хранения векторов, нулевая инфраструктурная сложность. Минус: SQLite не умеет concurrent writers, но для одного воркера это не проблема — PRAGMA journal_mode=WAL + PRAGMA busy_timeout=5000 решают всё, что могло возникнуть.
Цепочка бесплатных LLM с платным запасным
LLM нужен для нескольких вещей: типизация (категория + теги + важность + флаг «срочное» + теперь ещё тональность), краткое содержание (1-2 предложения для карточки), перевод RU↔EN. Это всё гонится одним промптом для каждой свежей новости.
OpenRouter — это маршрутизатор API: один ключ, доступ к десяткам моделей, в т.ч. полностью бесплатным (с rate-limit). Идея: основной поток обработки делаем на бесплатных моделях с фолбэком на дешёвую платную, когда бесплатные отдают 429.
В конфиге это просто список:
openrouter_models = [ "qwen/qwen3-next-80b-a3b-instruct:free", "meta-llama/llama-3.3-70b-instruct:free", "mistralai/mistral-nemo", # paid fallback (~$0.15 / M tokens)]
OpenRouter принимает массив models в запросе и сам пробует по очереди:
payload = { "models": settings.model_chain[:3], # max 3 "messages": [...], "temperature": 0.2, "response_format": {"type": "json_object"},}
Сверху — глобальный rate-limiter (15 запросов в минуту, общий на все модели) и экспоненциальный backoff с jitter на 429/5xx. По факту бесплатные тянут 90%+ трафика, в платный fallback падает редко. Реальный счёт за месяц: $0-1 (зависит от того, сколько мусора прилетает в брейкинг-канал).
Цена качества тут невысокая: для типизации/перевода новостной заметки 70B-модели хватает с запасом, а если временами free 429 — fallback просто доделает. В UX это не видно.
Локальные эмбеддинги: fastembed на ONNX
Эмбеддинги нужны двух типов: для всех новостей (в базу) и для запросов inline-поиска. Ходить за ними во внешнее API — это (а) деньги, (б) латентность, (в) приватность.
fastembed — библиотека от Qdrant, которая запускает sentence-transformers на ONNX без PyTorch. Я взял paraphrase-multilingual-MiniLM-L12-v2 (поддерживает 50+ языков, в т.ч. русский), 384 измерения, mean-pooling. Считает на CPU, ~10-30 мс на текст, прекрасно.
from fastembed import TextEmbedding_model = TextEmbedding("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")def embed(text: str) -> list[float]: return next(_model.embed([text])).tolist()
Расход RAM на загрузке: одноразовый спайк до ~300 МБ, потом стабильно ~150-200. На 512 МБ Fly-машине именно из-за этого выделено 512 МБ swap — модель загружается один раз, кеш страниц устаканивается, swap не активируется.
Рекомендательное ядро: вектор вкуса + EWMA + анти-бабл
Это самая интересная часть. Хочется, чтобы лента училась без сбора кучи метаданных и без ручной разметки от пользователя.
У каждого юзера есть taste_vec — вектор размерности 384, тот же формат, что и эмбеддинги новостей. Сначала он NULL. При первой реакции 🔥/❤️ — копируется эмбеддинг этой новости. При следующих — обновляется по экспоненциальной скользящей:
# app/reco/engine.pyalpha = 0.6 if kind == "open" else 0.85 # open = link click, сильнее лайкаuser.taste_vec = [alpha * o + (1 - alpha) * n for o, n in zip(old, emb)]
«Открыл ссылку» (зарегистрировано на клике из карточки) — это куда более сильный сигнал, чем просто лайк, поэтому alpha ниже и сдвиг вектора больше.
Реакция 😢 двигает вектор от эмбеддинга новости (отрицательное обучение):
user.taste_vec = [o - 0.1 * (n - o) for o, n in zip(old, emb)]
Параллельно ведётся interests: dict[category, weight] и tag_interests: dict[tag, weight] — категории и теги бот определяет той же LLM-обработкой. Это даёт второй сигнал поверх вектора: можно резко поднять «Космос» из-за одного клика, даже если вектор ещё не уехал.
Скоринг — комбинация:
score = (1 − cosine_distance) * w_taste + interests[story.category] * w_cat + mean(tag_interests[t] for t in story.tags) * w_tag + recency_bonus + importance_bonus − duplicate_penalty
Веса подобраны эмпирически, но главная идея: вектор даёт «семантический вкус», а категории/теги — быстрое доменное обновление.
Анти-бабл. Если просто всегда показывать топ-N по скорингу, лента схлопывается в эхо-камеру. У меня есть простой инжект серендипности:
def _inject_discovery(ranked, n): top = ranked[:n] tail = ranked[n:] if random.random() < 0.35: # в трети случаев if random.random() < 0.25 and len(tail) > 5: pick = random.choice(tail[len(tail)//2:]) # совсем далёкое else: pick = random.choice(tail[:len(tail)//2]) # соседнее top = top[:-1] + [pick] return top
Это очень примитивно, но рабоче: ~трети итераций один слот в выдаче занят чем-то новым. В половине этих случаев — слегка соседнее (рядом с интересами), в половине — что-то совсем из тейла. По ощущениям и метрикам реакций — заметно улучшает удержание на длинной дистанции.
Кластеризация: одно событие — одна карточка с N источниками
Раньше «дубль» при инжесте просто отбрасывался: повторная новость не сохранялась, и факт того, что её осветили ещё 4 издания, терялся. Это плохо по двум причинам: пользователь не видит масштаб, и ранжирование теряет очень важный сигнал.
Сейчас у каждой Story есть таблица story_sources (1 история → N источников). При первом сохранении в неё добавляется «оригинальный» источник. Когда приходит дубль:
# app/pipeline/process.pynear = await vec.knn(session, vector, k=3)if near and near[0][1] < DEDUP_THRESHOLD: canonical_id = near[0][0] await attach_source(canonical_id, raw.source_id) # idempotent bump_importance(canonical_id, +0.05) # +вес на каждое издание return # не создаём новую story
В карточке появляется пометка 📰 5 источников (с правильной русской плюрализацией) и кнопка «Источники» — раскрывает callback со списком всех ссылок. В UX это значит «вижу, что событие важное — про него написали все».
Параллельный эффект: importance растёт у мультиисточных новостей, и они естественно поднимаются в ранжировании. Это бесплатный, ничем не подкручиваемый сигнал «реальной значимости» — изданий нельзя обмануть так же, как можно накрутить лайки.
Хорошие новости по умолчанию
Самая свежая фича, и, кажется, самая важная по UX. Идея простая: я не хочу, чтобы человек, открывший бота в первый раз, получил в лицо войну, катастрофу и три скандала. Если он сам захочет — включит в настройках. Но по умолчанию — только хорошие и нейтральные новости.
Технически это два изменения: классификатор тональности на стороне LLM и per-user ceiling на стороне фильтра выдачи.
LLM-классификатор тональности
В тот же JSON-промпт обработки новости я добавил ещё одно поле — negativity от 0 до 3 с явной рубрикой:
0 — позитивное, нейтральное, технический апдейт, бизнес-новость (запуски, открытия, достижения, рутинные обновления, спорт-победы, сделки)1 — слегка негативное (критика, регуляторное давление, лёгкий конфликт, суды без крупных потерь, падения рынков)2 — явно негативное (скандалы, увольнения, санкции, аварии без массовых жертв, геополитическая напряжённость, утечки данных)3 — тяжёлое (смерти, войны, крупные трагедии, природные катастрофы с жертвами, теракты, системные провалы)
Дополнительная инструкция: «по умолчанию ставь 0, если у новости нет явно негативного угла; 3 — только когда речь о гибели людей или катастрофе крупного масштаба». Это важно — без рубрики LLM начинает «подкручивать» оценки: «ну тут же критика, давай 1, а тут же увольнения, давай 2». Чёткий список дефолтит большинство новостей в 0, что и нужно.
На выходе клампим в int 0..3:
def _clamp_negativity(value) -> int: try: return max(0, min(3, int(value))) except (TypeError, ValueError): return 0
Per-user ceiling и фильтр выдачи
У пользователя — поле max_negativity (по дефолту 0). В рекомендательном движке появился shared-helper:
def _negativity_cond(user: User): """Hide stories whose tone exceeds the user's ceiling. Default (max_negativity=0) → only positive/neutral news.""" return Story.negativity <= (user.max_negativity or 0)
И он подмешан во все пути доставки: основная лента (next_unseen), персональный auto-broadcast (rank_for_delivery), брейкинг (pending_breaking). Особенно про брейкинг: я специально провожу фильтр и там — потому что «срочное» в реальном мире обычно негативное, и человек с ceiling=0 не должен получать пуш про катастрофу, даже если у него отдельно включены breaking-alerts. Право на «отвернуться от плохого» — оно сильнее правила «если включил срочное, получишь всё».
inline_search и «похожее» — НЕ фильтруются. Это явные действия пользователя; если человек сам набрал в inline @futur_e_news_bot disaster — логично показать.
Дайджесты: per-user filter поверх общего пула
Тут была тонкость. Дайджест считается один раз для всех (топ-N за период) — иначе на каждого юзера пришлось бы пересчитывать. Если просто отдать всем одинаковые топ-5, но затем выкинуть негативные — у строгого юзера дайджест может оказаться пустым в день, где в топе много тяжёлого.
Решение: овер-фетчить общий пул в 4 раза больше нужного, потом per-user фильтровать и брать первые limit:
pool = await reco.top_recent(hours=hours, limit=limit * 4)for user in users: ceiling = user.max_negativity or 0 stories = [st for st in pool if (st.negativity or 0) <= ceiling][:limit] if not stories: continue # nothing acceptable for this user → skip the digest entirely ...
Если даже после фильтра ноль — лучше промолчать, чем прислать огрызок. Принцип «не шлём пустоту» в боте без push-токенов часто важнее, чем «не пропустить день».
UI: 4-уровневый переключатель в настройках
В настройках появилась зелёная кнопка 🌞 Тональность: только хорошие. По тапу — подменю с 4 опциями:
-
🌞 Только хорошие
-
😐 + лёгкий негатив
-
⚠️ + заметный негатив
-
💀 Без фильтра (включая тяжёлое)
Текущий уровень подсвечен зелёным. Выбор сохраняется одним тапом и тут же применяется к следующей выдаче. Никаких подтверждений и модалок — мне это всегда казалось важным: настройка должна меняться так же легко, как мнение.
Backfill для существующих новостей
Колонка negativity появилась после того, как в базе уже лежало ~1500 историй с дефолтным 0. Если оставить как есть — пользователи с ceiling=0 будут видеть среди старых новостей и негативные. Поэтому одноразовый скрипт-классификатор:
# scripts/backfill_negativity.py — упрощённоsem = asyncio.Semaphore(4) # rate-limiter в _chat всё равно сверхуfor chunk in chunks(story_ids, 200): results = await asyncio.gather(*[_score(sem, sid) for sid in chunk]) # ... commit batch
Бежит через те же бесплатные модели OpenRouter (qwen3-next/llama-3.3) с фолбэком на mistral-nemo. ~1500 новостей × один короткий вызов = ~10 минут, ~$0. После прогона распределение в моей базе вышло примерно: 60% — 0, 22% — 1, 13% — 2, 5% — 3. То есть «по-настоящему тяжёлых» новостей всего около 5% потока, но именно они портят общее впечатление от ленты.
Деплой и миграция
Колонки добавляются в init_db идемпотентным ALTER’ом — SQLAlchemy create_all создаёт новые таблицы, но не добавляет колонки в существующие. Поэтому добавил тонкий хелпер:
async def _ensure_column(conn, table: str, column: str, decl: str) -> None: cols = await conn.exec_driver_sql(f"PRAGMA table_info({table})") if any(row[1] == column for row in cols.fetchall()): return await conn.exec_driver_sql(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
И в init_db:
await _ensure_column(conn, "stories", "negativity", "INTEGER NOT NULL DEFAULT 0")await _ensure_column(conn, "users", "max_negativity", "INTEGER NOT NULL DEFAULT 0")
Никакого Alembic ради двух колонок — пока схема меняется редко, это лишняя инфраструктура.
Деплой и грабли
Деплой укладывается в Dockerfile + один fly.toml. SQLite живёт на /data (Fly-volume), бот стартует, opens DB, поднимает планировщик, начинает поллинг. Никаких сервисов, healthchecks, очередей сообщений.
Но по дороге наловил несколько вещей, о которых стоит знать.
sqlite-vec 0.1.6 на arm64
При первом деплое получил весёлое OSError: wrong ELF class: ELFCLASS32. Оказалось, в релизе 0.1.6 для arm64 был выложен 32-битный бинарник. Лечится пином версии:
sqlite-vec==0.1.9
В 0.1.9 уже всё ок. Если попадётесь — это типично выглядит как ошибка загрузки расширения SQLite, и легко списать на свой код, а не на upstream.
vec0 не умеет INSERT OR REPLACE
Стандартный SQLite-ный апсерт INSERT OR REPLACE INTO ... валится на vec0 с UNIQUE-нарушением. У виртуальных таблиц свой движок, и REPLACE-семантика не поддерживается. Решение — DELETE, потом INSERT:
await session.execute(text("DELETE FROM story_vec WHERE rowid = :id"), {"id": story_id})await session.execute( text("INSERT INTO story_vec(rowid, embedding) VALUES (:id, :v)"), {"id": story_id, "v": sqlite_vec.serialize_float32(list(vector))},)
Не криминал, но в документации это пока не выделено явно.
Загрузка расширения через aiosqlite
В SQLAlchemy async-режиме соединение — это AsyncAdapt_aiosqlite_connection, у которого нет enable_load_extension. Чтобы загрузить sqlite-vec, нужно достучаться до «сырого» sqlite3.Connection через два уровня обёрток:
def load_extension(dbapi_conn): raw = getattr(dbapi_conn, "driver_connection", dbapi_conn) # aiosqlite.Connection raw = getattr(raw, "_conn", raw) # sqlite3.Connection raw.enable_load_extension(True) raw.load_extension(sqlite_vec.loadable_path()) raw.enable_load_extension(False) raw.execute("PRAGMA busy_timeout=5000")
И вешается это на событие connect SQLAlchemy движка, чтобы выполнялось ровно один раз на новое соединение. Если делать это в обычном async-методе после engine.begin(), не будет работать на других соединениях из пула.
fastembed 0.8 поменял пулинг
Между минорными версиями fastembed сменил дефолтный пулинг с CLS на mean. Новые эмбеддинги стали несовместимы со старыми, хранившимися в базе. KNN внезапно начал возвращать мусор.
Лечится только одним способом: переэмбедить всю базу новой версией + пересчитать taste_vec всех пользователей как среднее эмбеддингов лайкнутых ими новостей. У меня был одноразовый скрипт reembed.py, который прогнал базу за минуту. Если у вас стоит fastembed в проде — закрепляйте версию явно и проверяйте changelog между апгрейдами.
tg_id не помещается в int32
У современных Telegram-аккаунтов user_id уже превышает int32 (~2.1 млрд). У меня сначала колонка была Integer, потом упало с DataError: out of int32 range. Меняем на BigInteger и больше не вспоминаем.
Один токен бота = один поллер
Очевидно, но всё равно ловил: Telegram пускает только один getUpdates consumer. Если бот запущен локально и параллельно на Fly — оба получают 409 Conflict пачкой в логи. Перед деплоем docker compose down. Перед локальной отладкой — fly machine stop.
Fly remote builder отваливается
Иногда fly deploy падает с deadline_exceeded или handshake EOF при попытке использовать Depot. Решение: fly deploy --local-only --depot=false — собрать образ локально и запушить в Fly registry напрямую. Делает деплой более предсказуемым, особенно если у вас arm64-Mac.
Машина может просто стоять
Однажды машина бота оказалась в state: stopped — не разбилась, не упала по OOM, а просто остановилась (видимо, после ручного fly machine stop в прошлой сессии). И никаких хелсчеков нет → автоматического подъёма тоже нет. Урок: или добавлять [checks] секцию в fly.toml с HTTP/TCP-проверкой, или мониторить через внешний uptime-сервис. Я пока что мониторю через свой же бот (/stats показывает, когда был последний пайплайн).
LLM «подкручивает» оценки негатива без явной рубрики
Первая итерация классификатора тональности промптила просто "negativity": 0..3 (how negative this news is). И LLM послушно начинала растягивать шкалу: «ну тут же есть критика — давай поставлю 1». В итоге половина новостей оказывалась негативной даже визуально нейтральная. Лечится явной рубрикой с примерами и инструкцией «по умолчанию 0, 3 — только при жертвах/катастрофе». Распределение тут же выправилось.
Что дальше
Следующие фичи в очереди:
-
Share-кнопка на каждой карточке через
switch_inline_query— самый дешёвый виральный цикл. -
Deeplinks на конкретную историю:
t.me/futur_e_news_bot?start=story_<id>— чтобы расшаренная ссылка открывала именно эту новость. -
Showcase-канал с автопостингом топ-5 за день — публичная витрина и попадание в поиск Telegram.
-
TTS-аудио для дневной сводки через OpenAI/ElevenLabs — слушать в пробке.
-
Source weighting в taste_vec: если ты лайкаешь Habr и дизлайкаешь Lenta — Habr должен ранжироваться выше для тебя, не для всех.
-
Collaborative filtering: «что читают похожие на тебя пользователи» — раз в неделю, поверх taste_vec.
Если зайдёт пост — соберу отдельную статью про рекомендательное ядро (где разберу веса в формуле скоринга, какие сигналы реально влияют на retention и почему собственное velocity-управление вектором работает лучше, чем готовые библиотеки для маленьких ботов).
Попробовать
Бот живой: @futur_e_news_bot — жмёшь /start, выбираешь язык, и через пару тапов лента уже подстраивается. По дефолту включена только дневная сводка (live-лента — выключена, чтобы не спамить), а тональность стоит на «только хорошие». Всё переключается тумблерами в настройках за один тап.
Очень жду фидбэка по трём вещам: качество классификации тональности (заметна ли разница на разных уровнях, не ловит ли система «ложных негативов» — нейтральную новость как мрачную), качество кластеризации (часто ли видны несхлопнувшиеся дубли) и точность ленты на 1-2 неделе (когда taste_vec уже устаканивается).
Если интересно глубже копнуть конкретный кусок (sqlite-vec, рекомендательное ядро, классификатор тональности, OpenRouter chain) — пишите в комменты, отдельным постом разберу.
ссылка на оригинал статьи https://habr.com/ru/articles/1042690/