Персональная лента городских новостей в Telegram.
Собирает город из СМИ и пабликов, сливает дубликаты в события, группирует в сюжеты
и учится на 👍/👎 каждого пользователя.
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
Городская повестка живёт в 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-<день>.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-<ts>.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-<день>.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
Система разделена на две части. Тяжёлое — эмбеддинги и локальная LLM — считается на ноутбуке раз в сутки за 10–20 минут. Лёгкое — бот, ранжирование готовых сюжетов и приём реакций — крутится 24/7 на бесплатном хостинге и обходится без torch и Ollama.
Обмениваются они не копией базы, а append-only артефактами: ноутбук владеет статьями, сюжетами и моделями, сервер — пользователями, показами и фидбеком. У каждого артефакта контрольная сумма и watermark, повторное применение бандла — no-op.
Дедупликация: две стадии вместо порога по косинусу
Косинус эмбеддингов путает похожие новости с новостями об одном событии: «ДТП на Красной, двое пострадавших» и «ДТП на Ставропольской, один пострадавший» находятся в 0.93 друг от друга.
- Blocking — временное окно + top-k соседей, чтобы не сравнивать всё со всем. Recall блокинга измеряется отдельно: это потолок, выше которого не прыгнет ни один скорер.
- Парный скорер — логистическая регрессия на девяти интерпретируемых признаках: косинус, символьные n-граммы, совпадение чисел, совпадение имён собственных, разница во времени, один ли источник. Модель сама выучивает, что несовпадение цифр важнее общей похожести.
- Слияние с complete-linkage guard — union-find это single-linkage, он склеивает
цепочки A
BC даже когда 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_HASHTG_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.plistlaunchd, а не 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. Лицензия распространяется на код; тексты новостей принадлежат их авторам.
