Я собрал Telegram-бота, который показывает только хорошие новости — и хостится за $5 в месяц

TL;DR — @futur_e_news_bot. Двуязычная (RU/EN) лента новостей. По умолчанию — только хорошие и нейтральные, негатив подключается в настройках на 4 уровнях. ИИ убирает дубли, одно событие = одна карточка с несколькими источниками, перевод на лету, выдача подстраивается под реакции. Внутри: aiogram, локальные эмбеддинги, sqlite-vec вместо pgvector, бесплатные LLM через OpenRouter и одна машина на Fly.io за ~$5/мес. В статье — разбор архитектуры, код, цифры и грабли.


Зачем ещё один новостной бот

Я устал от трёх вещей одновременно:

  1. Дубли. Одна и та же новость прилетает из пяти каналов с разными заголовками, превратив ленту в эхо-камеру одного события.

  2. Шум. 90% повестки мне не интересны, но чтобы найти свои 10%, надо пролистать всё.

  3. Тяжесть. Лента новостей сегодня — это поток катастроф. Я не хочу полностью отключаться от мира, но и не хочу начинать каждое утро с трёх смертей и одной войны.

Хотелось ленту, которая а) схлопывает дубли в одну карточку с указанием всех изданий, б) сама понимает, что мне заходит (без ручной разметки тегов), в) показывает это на двух языках без копипасты в переводчик и — главное — г) по умолчанию молчит про плохое, но даёт включить тяжёлый контент тумблером, если я к этому готов. Платные агрегаторы есть, но они либо не персонализируются, либо не понимают русский, либо стоят как абонемент в спортзал. Поэтому — выходные, кофе, 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.py
async 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]

Этого хватает для трёх вещей в боте:

  1. Дедуп при инжесте. Для каждой свежей новости делаем KNN, если ближайший сосед ближе порога — это дубль, не сохраняем как новую историю (а прицепляем как дополнительный источник, об этом ниже).

  2. «Похожее». Когда читаешь новость, есть кнопка «ещё про это» — тот же KNN, только результаты не отбрасываем как дубли, а показываем.

  3. Inline-поиск. Запрос пользователя @futur_e_news_bot AI → эмбеддим строку → KNN по базе. Это не поиск по подстроке, это семантический поиск.

Ранжирование персональной ленты делается отдельно — там нужен не KNN, а скоринг каждого кандидата по нескольким признакам (cosine + категория + теги + freshness). Поэтому для ленты — brute-force по последним 600 кандидатам в numpy. При наших объёмах это миллисекунды.

# app/reco/engine.py
_RANK_SCAN = 600

async 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.py
alpha = 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.py
near = 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) — пишите в комменты, отдельным постом разберу.

Комментарии (0)