Skip to content

Repository files navigation

CityFeed

Персональная лента городских новостей в Telegram.
Собирает город из СМИ и пабликов, сливает дубликаты в события, группирует в сюжеты и учится на 👍/👎 каждого пользователя.

CI Python License Style


Пайплайн в двух словах

flowchart LR
    subgraph L["Ноутбук — тяжёлое, раз в сутки (launchd → scripts/daily.sh)"]
        direction LR
        A["ingest: RSS + Telegram-паблики"] --> B["embed → dedup → cluster → summarize (Ollama)"]
        B --> C["retrain global/personal ранкеров"]
    end

    subgraph S["Сервер — docker-бот, лёгкое, 24/7 (без torch и Ollama)"]
        direction LR
        D["apply bundle → reload моделей"] --> E["ранжирование + MMR + exploration"]
        E --> F["дайджест в Telegram, 👍/👎"]
    end

    C -- "bundle-<день>.json по scp: сюжеты, статьи-стабы (только заголовок+URL), модели" --> D
    F -- "feedback-<ts>.json: пользователи, показы, реакции" --> A
Loading

О проекте

Городская повестка живёт в 10–20 источниках одновременно, и они пересказывают друг друга. Читать всё — час в день. Читать один канал — пропускать половину. Обычный агрегатор выдаёт всем одинаковую ленту, а сортировка «по популярности» превращает её в криминальную хронику.

CityFeed делает три вещи, которых нет у RSS-читалки:

Событие, а не пересказы Пять заметок про один пожар → один пункт с пометкой «подтверждено 4 источниками» и ссылками на все оригиналы
Сюжет, а не лента События одной истории связываются между собой и между днями: «сюжет развивается 3-й день»
Личная лента Реакции обучают персональную модель ранжирования — не «настройки уведомлений», а обучаемый ранкер

Проект писался как end-to-end ML-система, поэтому у каждого компонента — дедупликации, кластеризации, ранжирования, суммаризации — есть измеримое качество, воспроизводимый отчёт и разбор собственных ошибок.

Как это выглядит

Краснодар — главное за сутки (11.08)
5 сюжетов, персональная подборка.
👍/👎 под каждым сюжетом настраивают ленту под вас.

🚨 1. Пожар на складе в Прикубанском округе
Загорелся склад площадью 200 кв. м, тушили четыре расчёта. Пострадавших нет.
подтверждено 4 источниками; 2 события в сюжете
Источники: 93ru · kuban24 · chp_krd · krddtp1                      [👍] [👎]

🔧 2. Отключение воды в Карасунском округе продлили
Работы на сетях затянулись, без холодной воды 12 домов до вечера четверга.
подтверждено 2 источниками; сюжет развивается 3-й день
Источники: krd-official · 93ru                                     [👍] [👎]

🎭 3. Книжный маркет в парке Галицкого
🎲 в подборку добавлено для разнообразия
один источник — проверьте по оригиналу
Источники: krasnodar_afisha                                        [👍] [👎]

Быстрый старт

Нужен только Python 3.10+. Ни ключей Telegram, ни интернета, ни GPU.

git clone https://github.com/Ilyat9/cityfeed && cd cityfeed
make install    # numpy + scikit-learn + pydantic, ~15 секунд
make demo       # симуляция 42 дней → метрики → пример дайджеста

make demo разворачивает синтетический город — латентные события, девять источников-перефразов, двенадцать пользователей с разными вкусами — и прогоняет их через настоящий пайплайн: эмбеддинги, дедупликацию, кластеризацию, суммаризацию, обучение ранкеров, отбор дайджеста. Результат — отчёт в reports/metrics.md.

Отдельные команды
cityfeed doctor                 # что установлено, что настроено, чего не хватает
cityfeed simulate --days 42     # синтетическая история для офлайн-демо
cityfeed eval                   # все метрики и ablation → reports/
cityfeed preview --limit 2      # как выглядит дайджест в консоли
cityfeed pipeline               # реальный дневной прогон
cityfeed train                  # переобучить ранкеры
cityfeed bundle build|apply     # синхронизация ноутбук ↔ сервер
cityfeed bot                    # запустить Telegram-бота
Тяжёлые зависимости опциональны

sentence-transformers, hdbscan, lightgbm, telethon, python-telegram-bot ставятся отдельными extras (make install-full). Без них ядро работает на документированных фолбэках: детерминированный hashing-эмбеддер, агломеративная кластеризация, HistGradientBoosting, экстрактивные пересказы.

Это не заглушки — именно они гоняются в CI на каждом коммите. Какой бэкенд отработал, записывается в отчёт и в таблицу pipeline_runs.


Архитектура

flowchart TB
    RSS["RSS-фиды: 93.ru, Югополис, Краснодарские известия…"]
    TGP["Telegram-паблики через Telethon —<br/>пользовательский аккаунт, публичные каналы<br/>(TG_API_ID/TG_API_HASH)"]

    subgraph LAPTOP["Ноутбук — тяжёлая часть: дневной прогон по launchd (scripts/daily.sh)"]
        direction TB
        PULL["шаг 0 · pull: scp feedback-*.json с сервера →<br/>cityfeed bundle apply-feedback → файл *.done<br/>CITYFEED_REMOTE не задан → локальный режим без scp"]
        ING["шаг 1 · ingest_all: watermark на источник, окно ≤3 дня,<br/>3 ретрая с backoff, upsert_articles → SQLite"]
        EMB{"embedding.backend = auto"}
        SBERT["sbert: multilingual-e5-base<br/>(sentence-transformers)"]
        HASH["фолбэк: детерминированный HashingSVD, dim=256 —<br/>громко логируется и штампуется в pipeline_runs"]
        CACHE["шаг 2 · embed: read-through кэш в SQLite,<br/>ключ (content_hash, model_id), окно 30 ч"]
        DEDUP["шаг 3 · dedup: blocking (окно 36 ч, top-25 соседей)<br/>→ парный скорер на 9 признаках → union-find<br/>+ complete-linkage guard (порог − 0.08)"]
        DSC{"dedup.scorer"}
        LEARNED["learned: joblib-модель, порог из<br/>калибровки в БД — ключ по id эмбеддера"]
        COSINE["фолбэк: ThresholdScorer —<br/>калиброванный косинус (0.86)"]
        CLUST["шаг 4 · cluster: центроиды событий,<br/>скользящее окно 3 дня + сцепка с сюжетами за 7 дней"]
        CBC{"cluster.backend"}
        HDB["hdbscan: euclidean ≡ cosine<br/>на L2-нормированных векторах"]
        AGG["фолбэк: AgglomerativeClustering,<br/>cosine, threshold 0.45"]
        SUMM["шаг 5 · summarize: ≤6 статей — по одной с источника →<br/>промпт → verify evidence → repair ≤2 попыток<br/>кэш пересказов в kv по хешу статей"]
        SBC{"summarize.backend"}
        OLM["Ollama qwen2.5:7b-instruct<br/>localhost:11434"]
        EXTR["фолбэк: экстрактивный пересказ"]
        PERSIST["шаг 6 · persist: upsert_story → SQLite"]
        RETRAIN["шаг 7 · retrain: global и personal ранкеры<br/>(logreg | LightGBM; lightgbm нет → HistGradientBoosting)<br/>на фидбеке, импортированном на шаге 0"]
        BUNDLE["шаг 8 · bundle build: bundle-&lt;день&gt;.json —<br/>статьи-стабы (только заголовок+URL), сюжеты, модели;<br/>schema_version=2, sha256, импорт идемпотентен"]
        PUSH["шаг 9 · scp bundle в inbox сервера<br/>sync/transport: ScpTransport через ~/.ssh/config;<br/>LocalTransport — одна машина и тесты"]
    end

    subgraph SERVER["Сервер — docker compose: контейнер cityfeed-bot, лимит 512 МБ, без torch и Ollama"]
        direction TB
        INBOX["data/inbox: bundle-*.json<br/>(volume ../data:/app/data)"]
        APPLY["job каждые 10 мин: apply_bundle —<br/>проверка checksum → INSERT OR REPLACE / IGNORE →<br/>rename .done / .failed → пересоздать RankingService"]
        BOTDB["SQLite сервера: users, digests,<br/>digest_items, impressions, feedback"]
        RANK["RankingService: BackoffRanker =<br/>global + personal, α = n/(n+k) по числу реакций<br/>schema модели устарел → HeuristicRanker"]
        SEL["отбор: MMR (λ=0.72) против фильтр-пузыря<br/>+ exploration-слот 20% (никогда не позиция 0)"]
        SEND["broadcast каждые 5 мин: локальное время = slot;<br/>пейсинг ~16 msg/s, RetryAfter, Forbidden → deactivate;<br/>идемпотент (user_id, day); сюжетам старше 2 дней — skip"]
        FEED["колбэки 👍/👎 → feedback"]
        EXPF["export-feedback: feedback-&lt;ts&gt;.json —<br/>users + impressions + feedback;<br/>watermark двигается после успешной записи"]
    end

    TG["Telegram Bot API (long polling)"]

    RSS --> ING
    TGP --> ING
    PULL --> ING

    EMB -->|"sbert импортируется"| SBERT
    EMB -->|"нет / ошибка"| HASH
    SBERT --> CACHE
    HASH --> CACHE

    CACHE --> DEDUP
    DSC -->|"модель обучена"| LEARNED
    DSC -->|"файла нет / битый"| COSINE
    LEARNED --> CLUST
    COSINE --> CLUST

    CLUST --> CBC
    CBC -->|"hdbscan установлен"| HDB
    CBC -->|"нет / упал"| AGG
    HDB --> SUMM
    AGG --> SUMM

    SUMM --> SBC
    SBC -->|"auto: ollama отвечает"| OLM
    SBC -->|"нет"| EXTR
    OLM -->|"verify ok"| PERSIST
    OLM -->|"rejected после repair"| EXTR
    EXTR --> PERSIST

    PERSIST --> RETRAIN
    RETRAIN --> BUNDLE
    BUNDLE --> PUSH

    PUSH -- "scp bundle-&lt;день&gt;.json" --> INBOX
    INBOX --> APPLY
    APPLY -- "статьи-стабы, сюжеты, модели (joblib-блобы)" --> BOTDB
    APPLY --> RANK
    BOTDB --> RANK
    RANK --> SEL
    SEL --> SEND
    SEND -- "send_message HTML + клавиатура 👍/👎" --> TG
    TG -- "CallbackQuery" --> FEED
    FEED --> BOTDB
    BOTDB --> EXPF
    EXPF -- "scp-пулл шагом 0 → фидбек в обучение" --> PULL
Loading

Система разделена на две части. Тяжёлое — эмбеддинги и локальная LLM — считается на ноутбуке раз в сутки за 10–20 минут. Лёгкое — бот, ранжирование готовых сюжетов и приём реакций — крутится 24/7 на бесплатном хостинге и обходится без torch и Ollama.

Обмениваются они не копией базы, а append-only артефактами: ноутбук владеет статьями, сюжетами и моделями, сервер — пользователями, показами и фидбеком. У каждого артефакта контрольная сумма и watermark, повторное применение бандла — no-op.


Как это устроено внутри

Дедупликация: две стадии вместо порога по косинусу

Косинус эмбеддингов путает похожие новости с новостями об одном событии: «ДТП на Красной, двое пострадавших» и «ДТП на Ставропольской, один пострадавший» находятся в 0.93 друг от друга.

  1. Blocking — временное окно + top-k соседей, чтобы не сравнивать всё со всем. Recall блокинга измеряется отдельно: это потолок, выше которого не прыгнет ни один скорер.
  2. Парный скорер — логистическая регрессия на девяти интерпретируемых признаках: косинус, символьные n-граммы, совпадение чисел, совпадение имён собственных, разница во времени, один ли источник. Модель сама выучивает, что несовпадение цифр важнее общей похожести.
  3. Слияние с complete-linkage guard — union-find это single-linkage, он склеивает цепочки ABC даже когда A и C не похожи. Две группы объединяются, только если минимальный попарный косинус между ними выше порога.

Порог калибруется на валидации и хранится в БД с ключом по id эмбеддера: сменить модель эмбеддингов и молча унаследовать старый порог невозможно.

Кластеризация: сюжеты, живущие дольше одного дня

HDBSCAN поверх центроидов событий в скользящем окне (по умолчанию 3 дня) — городской сюжет редко укладывается в сутки. Вектора нормированы, поэтому евклидова метрика эквивалентна косинусной и используется хорошо оттестированный код-путь.

Сюжеты сохраняют идентичность между днями: сегодняшние кластеры сопоставляются с недавними по центроиду, при совпадении переиспользуется slug. Так пользователь видит «сюжет развивается 3-й день», а ранкер получает признак day_count и может выучить, что человек устал от этой истории.

Шум не выбрасывается: каждая шумовая точка становится одно-событийным сюжетом. Иначе из ленты пропали бы ровно те разовые объявления — отключение воды, перекрытие улицы, — ради которых на городской бот и подписываются.

Ранкер: бэкофф global → personal без утечек

Признаки фиксируются в момент показа. То, с чем сюжет попал в дайджест, сохраняется в digest_items, и обучение идёт по этому снимку, а не по пересчёту задним числом. Офлайн и онлайн видят ровно одно и то же. Схема признаков версионируется: модель со старой схемой не применяется, а не выдаёт мусор.

Метки честнее, чем «👍=+1, 👎=−1, тишина=0». Непоказанный сюжет — вообще не наблюдение. «Нет реакции» — мягкая метка с малым весом, разложенная в пару взвешенных строк. Позиционное смещение корректируется в обе стороны: лайк на пятой позиции информативнее лайка на первой, а молчание на пятой — менее информативно.

Бэкофф — сжатие к среднему, не переключатель. α = n/(n+k) растёт плавно, иначе лента дёргалась бы в день перехода. Персональная модель получает global_score как признак (стекинг): при малых данных она может просто доверять глобальной и отклоняться только там, где её собственные данные это подтверждают. Скоры z-нормализуются внутри кандидатного набора перед смешиванием — иначе более «уверенная» модель молча забирает всё.

Персональная модель не видит сырых эмбеддингов. 768 измерений против сорока меток — это заучивание шума. Эмбеддинг входит через два персональных скаляра: близость к центроиду лайков и к центроиду дизлайков.

Суммаризация: LLM проверяется, а не уговаривается

Локальная модель через Ollama возвращает JSON по схеме и обязана сослаться на конкретные статьи кластера по id. Дальше три программные проверки:

  • цитируемые id существуют в этом кластере;
  • summary лексически пересекается с цитируемой статьёй;
  • каждое число из summary встречается хотя бы в одном исходнике.

Провал → повторный запрос с описанием проблемы → при повторном провале деградация до экстрактивного пересказа, который не может галлюцинировать по построению. Описания генерируются один раз на сюжет и кэшируются по хешу содержимого; персонализация живёт только в ранжировании.

Отбор дайджеста: разнообразие и исследование

Ранжирование даёт порядок, но не набор. Топ-5 по скору в день большого пожара — это пять углов зрения на один пожар, поэтому кандидаты штрафуются за близость к уже выбранным (MMR).

В части дайджестов один слот занимает случайный сюжет из пула. Он помечен флагом в БД и подписан в интерфейсе. Это одновременно мера против фильтр-пузыря и единственный несмещённый срез данных для оценки.

Эксплуатация

Идемпотентная рассылка (UNIQUE(user_id, day) + sent_at после отправки), Forbidden → пользователь деактивируется, RetryAfter соблюдается, callback-данные адресуют теги по индексу (64 байта — это байты, а кириллица занимает два), владелец callback проверяется на сервере, устаревший бандл не рассылается как свежий.

У каждого прогона есть run_id, попадающий и в логи, и в таблицу pipeline_runs вместе со статистикой каждой стадии и отпечатком конфига.


Метрики

Полный отчёт: reports/metrics.md — генерируется командой cityfeed eval, руками не правится. Воспроизвести ровно эти цифры:

cityfeed simulate --days 42 --events-per-day 20 --end 2026-07-24 && cityfeed eval

Данные синтетические. Разметка 300 пар и шесть недель личного фидбека — это месяц календарного времени, а убедиться, что контур измерений корректен, нужно раньше. Симулятор даёт известную ground truth для дедупликации, кластеризации и ранжирования (модель кликов с позиционным смещением). Тот же код без изменений считает метрики на реальном корпусе — см. Реальные данные. Эмбеддер в этом прогоне — fallback hashing-rp:d256.

Дедупликация

650 размеченных пар, сплит по событиям (одна статья не может попасть и в train, и в test), порог подбирается на valid, отчёт — на test.

Вариант F1 Precision Recall ROC-AUC
TF-IDF (char 3–5) + cosine — baseline 0.967 0.937 1.000 0.961
Эмбеддинги + cosine 0.921 0.866 0.983 0.956
Заголовки: char-4 Jaccard 0.949 0.949 0.949 0.926
Обучаемый скорер (9 признаков, LogReg) 1.000 1.000 1.000 1.000

Recall блокинга — 0.884. Это потолок: 11.6% истинных дубликатов не попадают даже в кандидаты, и показывать F1 скорера как F1 системы было бы неправдой.

Что выучил скорер (веса на стандартизованных признаках):

log_hours_apart   -3.64   «через двое суток это уже другое событие»
same_source       -1.94   один источник дважды — репост, а не подтверждение
number_jaccard    +0.83   совпадение цифр важнее общей похожести
shared_rare_token +0.68
emb_cos           +0.50

Строка number_jaccard > emb_cos — ответ на «два разных ДТП на одной улице».

Слияние событий:

Вариант Групп Макс. размер Pair precision
union-find (single-linkage) 1364 5 0.969
+ complete-linkage guard 1559 3 0.983

Кластеризация

Оценка на скользящих окнах по 3 дня — так же, как в проде. Оценивать месяц одной матрицей значило бы мерить задачу, которую система никогда не решает. В среднем 94 события и 51 истинный сюжет на окно, 10 окон.

Вариант ARI V-measure Доля шума
agglomerative, min_cluster_size=2 0.373 0.867 0.146
KMeans (k задан по разметке — фора) 0.373 0.891 0.000
agglomerative, min_cluster_size=3 0.341 0.872 0.373
agglomerative, min_cluster_size=5 0.220 0.890 0.753

Плотностная кластеризация без подсказки k идёт вровень с KMeans, которому истинное число сюжетов сообщили. Доля шума стоит рядом с ARI намеренно: без неё min_cluster_size=5 выглядит приличным вариантом, хотя выбрасывает 75% дня.

Ранжирование

Обучение строго на днях до сплита, тест — на последующих, профили пересобираются point-in-time. 84 списка показов в тесте, в скобках — bootstrap-CI 95%.

Вариант NDCG@5 95% CI P@5 Средняя позиция лайка
Случайный порядок 0.390 [0.315; 0.462] 0.200 4.63
Эвристика (K источников + свежесть + темы) 0.521 [0.446; 0.594] 0.244 3.76
Global GBDT 0.562 [0.485; 0.637] 0.256 3.63
Global LogReg 0.604 [0.528; 0.677] 0.263 3.46
Backoff (global + personal) 0.611 [0.526; 0.695] 0.253 3.39
Только personal 0.590 [0.508; 0.672] 0.263 3.60
Как было показано (online) 0.670 [0.597; 0.743] 0.275 3.13

Обученная модель уверенно бьёт эвристику (+0.083) и случайный порядок (+0.221) — это за пределами шума. А вот выигрыш бэкоффа над чистым global (+0.008) внутри доверительного интервала: на таком объёме данных утверждать, что персонализация уже что-то даёт, нельзя. Контур замкнут и работает, размер эффекта нужно мерить на живых пользователях.

Последняя строка любопытна: «как было показано» выше всех офлайн-вариантов, потому что онлайн-скоры считались моделью, дообучавшейся каждый день, а офлайн-варианты обучены один раз. Ежедневное дообучение оказалось ценнее выбора алгоритма.

Динамика по неделям

Качество ленты по неделям

Неделя Эвристика Global Backoff Персональных моделей Средняя α
1 0.515 0.487 0.487 0 0.00
2 0.395 0.556 0.556 0 0.00
3 0.519 0.561 0.563 9 0.36
4 0.487 0.595 0.618 12 0.54
5 0.503 0.537 0.510 12 0.60

Каждая неделя оценивается моделью, обученной только на предыдущих днях. Видно, как α растёт с накоплением реакций и как персональные модели включаются одна за другой. Кривая не монотонна — на 84 списках в неделю иначе и не бывает.

Цена разнообразия

Средняя близость внутри дайджеста Разных тегов
MMR выключен 0.197 2.75
MMR включён 0.170 3.08

Like rate на exploration-слотах — 0.062 против 0.160 на обычных. Разнообразие оплачивается качеством примерно в 2.5 раза; это осознанный компромисс, а не бесплатный обед.

Латентность

Корпус 1616 статей, 9 источников, ноутбучное железо: dedup-eval 0.75 с, cluster-eval 0.68 с, rank-eval 6.2 с. Дневной пайплайн без LLM — секунды; с qwen2.5:7b основное время уходит на генерацию (2–4 с на новый сюжет, старые берутся из кэша).


Реальные данные

1. Источники

configs/sources.yaml — стартовый набор по Краснодару: 93.ru, Югополис, Краснодарские известия (RSS), @news_93_ru, @kuban24, @chp_krd, @krddtp1, @krasnodar_afisha (Telegram), плюс выключенные заготовки под RSSHub (РБК Кубань, krd.ru).

Добавить источник — одна запись в yaml. Теги (происшествия, транспорт, жкх, официальное, афиша, культура, экономика, город) — не украшение: это одновременно признаки ранкера и варианты онбординга, поэтому словарь стоит держать маленьким и стабильным.

Как искать новые: пробуете /rss, /feed, /rss.xml и смотрите content-type; нет RSS → RSSHub или RSS-Bridge; Telegram-каналы — через каталог TGStat.

2. Ключи и локальная LLM
cp .env.example .env      # TELEGRAM_BOT_TOKEN, TG_API_ID, TG_API_HASH

TG_API_ID/HASH берутся с my.telegram.org и нужны только ноутбуку: бот не может читать чужие каналы, пользовательский аккаунт может. Файл сессии Telethon — такой же секрет, как токен, он в .gitignore.

brew install ollama
ollama pull qwen2.5:7b-instruct-q4_K_M     # ~4.7 ГБ, комфортно на M2/16GB
ollama serve

Модель грузится только на время запроса и выгружается через ~5 минут простоя. Без Ollama пайплайн не падает — переходит на экстрактивные пересказы и сообщает об этом.

3. Прогон и метрики на своих данных
cityfeed pipeline                                   # реальный день
python scripts/label_dedup.py --limit 60            # разметка пар
cityfeed eval --pairs data/labels/dedup_pairs.jsonl

Скрипт разметки сэмплирует пары рядом с границей решения, а не случайные: час работы покупает максимум информации. Сплит детерминированный, разметка дописывается в JSONL и переживает несколько подходов.

После этого таблицы в reports/metrics.md пересчитаются на реальных данных, а data_source в шапке сменится с synthetic на manual labels.


Деплой

Ноутбук — тяжёлая часть, 10–20 минут в сутки:

sed -i '' "s|__PROJECT__|$PWD|g" scripts/com.cityfeed.daily.plist
cp scripts/com.cityfeed.daily.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.cityfeed.daily.plist

launchd, а не cron: cron не выполнит задачу, если в 06:30 ноутбук спал — а он спит. scripts/daily.sh забирает фидбек, считает день и отправляет бандл; падение любого шага не роняет остальные, сервер продолжает работать на вчерашних сюжетах.

Сервер — лёгкая часть, 24/7:

docker compose -f docker/docker-compose.yml up -d

Образ ~250 МБ, лимит памяти 512 МБ, torch и Ollama не нужны. Подходит Oracle Cloud Always Free (полноценная VM бесплатно навсегда; при регистрации спрашивают карту, из РФ может понадобиться VPN), HF Spaces или Render — оба засыпают при простое, что решаемо для webhook и хуже для polling. GitHub Actions для бота не подходит: это задачи по расписанию, а не постоянно слушающий процесс.


Структура

cityfeed/
├── src/cityfeed/
│   ├── config.py          типизированный конфиг, секреты только из env
│   ├── models.py          доменные объекты
│   ├── storage/           SQLite, миграции, репозитории
│   ├── ingest/            RSS + Telethon + нормализация
│   ├── embed/             bi-encoder, кэш, офлайн-фолбэк
│   ├── dedup/             blocking, признаки пар, скореры, слияние
│   ├── cluster/           HDBSCAN/agglomerative + непрерывность сюжетов
│   ├── summarize/         Ollama, промпты, верификация evidence, экстрактив
│   ├── rank/              признаки, метки, global/personal, бэкофф, обучение
│   ├── digest/            MMR, exploration, рендер
│   ├── bot/               команды, клавиатуры, рассылка
│   ├── sync/              бандлы и транспорт ноутбук ↔ сервер
│   ├── simulate/          синтетический город + модель кликов
│   ├── eval/              метрики, ablation, генерация отчёта
│   ├── pipeline/          дневная оркестрация
│   └── cli.py
├── configs/               city.yaml, sources.yaml, model.yaml
├── tests/                 62 теста, ~8 секунд
├── scripts/               daily.sh, launchd plist, разметка пар
├── docker/                образ бота + compose
├── reports/               metrics.md, график (генерируются)
└── docs/                  failure_analysis.md

Стек: Python 3.10+, scikit-learn, sentence-transformers, HDBSCAN, LightGBM, Ollama, Telethon, python-telegram-bot, SQLite, pydantic, pytest, ruff, Docker.


Разбор ошибок

docs/failure_analysis.md — восемь кейсов, у каждого пример, причина, что сделано и что осталось: ложное слияние двух ДТП на одной улице, пропуск дубликата в коротком репосте, транзитивное склеивание, развал кластера в шум, зацикливание ранкера на одной теме, неточные evidence у LLM, повтор заголовка в экстрактивном фолбэке — и отдельно история о том, как обучаемый ранкер сначала проиграл эвристике и что оказалось причиной.


Ограничения и этика

Фильтр-пузырь. Персональная лента по построению сужает повестку. Смягчения — epsilon-exploration, MMR, сглаживание тег-аффинити; цена измерена и приведена выше. Полностью проблема не решается: она встроена в постановку задачи.

Авторские права и ToS. Дайджест показывает краткое описание и ссылается на оригиналы; полные тексты не перепечатываются и не уезжают на сервер — в бандл едут только заголовок и ссылка. Telegram-паблики читаются пользовательским аккаунтом: только публичные каналы, на которые он подписан, с уважением к rate limit'ам.

Приватность. Хранится: user_id, username, выбранные темы, настройки, история показов и реакции. Не хранится: переписка, контакты, телефон, геолокация. /privacy показывает это пользователю, /delete удаляет всё одной транзакцией, включая персональные модели.

LLM ошибается. Проверки лексические, а не смысловые. Это дайджест со ссылками, а не источник истины: сюжет из одного источника помечается «проверьте по оригиналу».

Границы измерений. Цифры получены на синтетике и fallback-эмбеддере — они показывают, что контур измерений корректен, а не что система хороша на реальном Краснодаре. Метрика ранжирования — это переранжирование показанного: сюжет, которого никто не видел, не имеет метки. Несмещённый срез даёт только exploration.

Осознанно не сделано. Несколько городов, веб-интерфейс, обучение эмбеддингов с нуля, collaborative filtering, realtime — всё это расширило бы проект вширь за счёт глубины оценки.


Дальше

  • Перенести метрики на реальный корпус Краснодара и sbert-эмбеддинги
  • Нормализация числительных из текста и лёгкий NER вместо эвристики по заглавным
  • Второй проход блокинга по редким токенам — поднять recall выше 0.88
  • A/B прослойка: сравнивать варианты ранкера на живых пользователях, а не офлайн
  • Клики по ссылкам как дополнительный неявный сигнал

Лицензия

MIT — см. LICENSE. Лицензия распространяется на код; тексты новостей принадлежат их авторам.

About

End-to-end ML system for a personalized city news feed: dedup, clustering, learning-to-rank, LLM summarization — each component has a baseline, reproducible eval, error analysis

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages