mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
openspec: архив 14 завершённых change-ов (веб-фиксы, crawler-queue, own-content-hub, publisher-service); спеки влиты в openspec/specs
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# classifier Specification
|
||||
|
||||
## Purpose
|
||||
Классификация постов по направлениям локальным LLM + словарный фильтр. Дополняется
|
||||
приоритетом «своего контента» и поддержкой мультинаправлений для fan-out.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Приоритет своего контента
|
||||
Классификатор MUST обрабатывать посты с `is_own=1` как «сильные кандидаты»: при наличии
|
||||
направления по словарю — relevance=critical, classified=True — даже если LLM недоступна
|
||||
(фолбэк без LLM, пост не теряется). Для внешних постов поведение без изменений.
|
||||
|
||||
#### Scenario: Свой пост, LLM недоступна
|
||||
- **WHEN** пост is_own=1, словарь дал направление, но Ollama недоступна
|
||||
- **THEN** пост получает direction (словарь), relevance=critical, classified=True,
|
||||
method='dict-own' (не требует LLM)
|
||||
|
||||
#### Scenario: Свой пост без направления по словарю
|
||||
- **WHEN** пост is_own=1, словарь не дал направление
|
||||
- **THEN** классификации нет (classified=False) до LLM; пост не теряется (остаётся в очереди)
|
||||
|
||||
### Requirement: Мультинаправления
|
||||
Классификатор MUST уметь возвращать несколько направлений для поста (для маппинга fan-out);
|
||||
основное направление хранится в posts.direction, дополнительные — в classifications
|
||||
(таблица уже позволяет несколько классификаций на пост).
|
||||
|
||||
#### Scenario: Пост про Linux + AI
|
||||
- **WHEN** пост упоминает и линукс, и нейросети
|
||||
- **THEN** в classifications может быть несколько записей (linux, ai); fan-out использует
|
||||
оба при подтверждении
|
||||
@@ -0,0 +1,66 @@
|
||||
# crawler-queue Specification
|
||||
|
||||
## Purpose
|
||||
Двухфазный пайплайн сбора и обработки новостей: краулеры (фаза 1) собирают
|
||||
кандидатов в очередь (`posts` со `status='new'` и неклассифицированные), воркеры
|
||||
(фаза 3) разбирают её параллельно (ThreadPoolExecutor). Отдельная страница
|
||||
`/crawlers` показывает статус источников и размер очереди.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Параллельная фаза 3 (воркеры)
|
||||
Система MUST предоставлять `crawler/worker.py` с пулом `ThreadPoolExecutor(max_workers=N)`,
|
||||
где каждый воркер атомарно забирает пост из очереди (`UPDATE posts SET classified=-1
|
||||
WHERE id=? AND classified=0`), классифицирует его (`classify_text`: keywords → Ollama,
|
||||
trafilatura для summary-only) и пишет результат (direction/relevance/interest/summary,
|
||||
classified=1). При исключении посте MUST возвращаться в очередь (classified=0).
|
||||
|
||||
#### Scenario: Параллельная обработка очереди
|
||||
- **GIVEN** 100 постов со `classified=0`
|
||||
- **WHEN** `python -m crawler.worker --workers 8 --limit 200`
|
||||
- **THEN** все 100 постов обработаны (classified=1), дублей нет (каждый обработан ровно 1 раз)
|
||||
|
||||
#### Scenario: Сбой воркера
|
||||
- **GIVEN** пост, у которого `classify_text` бросает исключение (Ollama недоступна)
|
||||
- **WHEN** воркер обрабатывает пост
|
||||
- **THEN** пост возвращается в очередь (classified=0), воркер продолжает работу, запуск не падает
|
||||
|
||||
### Requirement: Очередь на основе posts (без новой таблицы)
|
||||
«Размер очереди» MUST вычисляться из существующей таблицы `posts`:
|
||||
`SELECT COUNT(*) FROM posts WHERE classified IS NULL OR classified=0` (pending),
|
||||
`classified=-1` (processing), `classified=1 AND fetched_at >= date('now')` (done today).
|
||||
Новая таблица для очереди НЕ создаётся — она дублировала бы `posts`.
|
||||
|
||||
#### Scenario: Размер очереди
|
||||
- **GIVEN** в posts 10 новых (classified=0) и 2 в обработке (classified=-1)
|
||||
- **WHEN** страница /crawlers запрашивает размер очереди
|
||||
- **THEN** pending=10, processing=2, done today=0
|
||||
|
||||
### Requirement: Страница /crawlers
|
||||
Веб MUST предоставлять `GET /crawlers` (за аутентификацией): таблица источников
|
||||
(slug, name, crawler, status, last_fetch, last_error за 200 симв., error_count,
|
||||
priority; для rss — etag/modified/last_build_date из rss_state) и карточки очереди
|
||||
(pending/processing/done). Кнопка «Сбросить dead» (POST /crawlers/reset) MUST
|
||||
устанавливать sources.status='alive', error_count=0, last_error=NULL для всех
|
||||
источников со status='dead'.
|
||||
|
||||
#### Scenario: Просмотр статуса
|
||||
- **GIVEN** источник lwn (rss, alive) и 15 новых постов в очереди
|
||||
- **WHEN** GET /crawlers
|
||||
- **THEN** страница показывает lwn с статусом alive, размер очереди 15
|
||||
|
||||
#### Scenario: Сброс dead-источников
|
||||
- **GIVEN** источник со status='dead', error_count=7
|
||||
- **WHEN** POST /crawlers/reset
|
||||
- **THEN** источник становится alive, error_count=0, last_error=NULL
|
||||
|
||||
### Requirement: Фаза 1 (сбор) отдельно от фазы 3 (обработка)
|
||||
Система MUST предоставлять `crawler/crawl_sources.py` — запуск сбора всех
|
||||
включённых источников (telegram_crawler + rss_crawler) без классификации;
|
||||
новые посты попадают в очередь (posts, classified=0). Обработка (фаза 3)
|
||||
запускается отдельно (`crawler/worker.py`).
|
||||
|
||||
#### Scenario: Сбор без классификации
|
||||
- **GIVEN** включённые источники telegram и rss
|
||||
- **WHEN** `python -m crawler.crawl_sources --all`
|
||||
- **THEN** новые посты добавлены в posts с classified=0, классификация НЕ запущена
|
||||
@@ -0,0 +1,22 @@
|
||||
# news-store Specification
|
||||
|
||||
## Purpose
|
||||
Банк статей: markdown-бандлы с frontmatter + медиа. Дополняется пометкой происхождения
|
||||
своего контента и ссылкой на оригинал.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Атрибуция в бандле
|
||||
Бандл поста с is_own=1 MUST содержать в frontmatter `origin: own`, ссылку на оригинал
|
||||
(`source_url` = t.me/dedinit/<id>) и имя автора («Дед в АйТи»). Бандл внешнего поста —
|
||||
как раньше (origin: external, source_url=url источника).
|
||||
|
||||
#### Scenario: Бандл своего поста
|
||||
- **WHEN** create_bundle вызывается для поста is_own=1
|
||||
- **THEN** frontmatter содержит origin: own, source: dedinit, source_url:
|
||||
https://t.me/dedinit/<tg_post_id>, author: Дед в АйТи
|
||||
|
||||
#### Scenario: Бандл внешнего поста в нескольких направлениях
|
||||
- **WHEN** пост (свой или внешний) опубликован в несколько направлений
|
||||
- **THEN** бандл создаётся по каждому направлению (bundles/<dir>/<YYYY-MM>/<slug>.md),
|
||||
обе записи ссылаются на один и тот же original post_id
|
||||
@@ -0,0 +1,92 @@
|
||||
# sources-admin Specification
|
||||
|
||||
## Purpose
|
||||
Страница управления источниками в веб-интерфейсе VESTI: добавление, изменение,
|
||||
удаление, пауза и смена приоритета источников без ручной правки YAML.
|
||||
`sources.yaml` остаётся источником истины, веб пишет в него и синкает БД.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Просмотр списка источников
|
||||
Страница /sources показывает таблицу всех источников с их полями и статусом.
|
||||
|
||||
#### Scenario: просмотр списка источников
|
||||
- **Given** веб запущен, пользователь авторизован
|
||||
- **When** он открывает `/sources`
|
||||
- **Then** страница показывает таблицу всех источников: slug, name, crawler,
|
||||
direction, lang, priority, enabled (пауза), url/feed_url, статус (last_fetch/error)
|
||||
|
||||
### Requirement: Добавление нового источника
|
||||
Форма «Добавить» создаёт запись в sources.yaml и БД.
|
||||
|
||||
#### Scenario: добавление нового источника
|
||||
- **Given** страница /sources
|
||||
- **When** форма «Добавить» заполнена (slug, name, crawler=rss, feed_url, direction=tech, priority=P1)
|
||||
- **And** отправлена
|
||||
- **Then** запись появляется в `sources/sources.yaml` и таблице `sources` БД
|
||||
- **And** страница показывает новый источник в таблице
|
||||
|
||||
#### Scenario: валидация добавления
|
||||
- **Given** форма добавления с невалидным slug (`my source!`)
|
||||
- **When** отправлена
|
||||
- **Then** добавление отклонено, показана ошибка (flash), ничего не записано
|
||||
- **And** slug принимается только `[a-z0-9_-]+`
|
||||
|
||||
### Requirement: Изменение полей источника
|
||||
Форма «Изменить» обновляет name/priority/направление и пр. в yaml и БД.
|
||||
|
||||
#### Scenario: переименование / изменение полей
|
||||
- **Given** существующий источник `lwn`
|
||||
- **When** форма «Изменить» меняет `name` на «LWN Tech» и `priority` на P2
|
||||
- **Then** изменения применяются в yaml и БД, таблица обновляется
|
||||
|
||||
### Requirement: Пауза и снятие с паузы
|
||||
Переключатель enabled=false останавливает сбор источника, enabled=true возобновляет.
|
||||
|
||||
#### Scenario: пауза и снятие с паузы
|
||||
- **Given** источник `lwn` (enabled=true)
|
||||
- **When** пользователь нажимает «Пауза»
|
||||
- **Then** `enabled=false` в yaml и БД
|
||||
- **And** краулеры больше не собирают этот источник (`get_enabled_sources` исключает)
|
||||
- **When** пользователь нажимает «Снять с паузы»
|
||||
- **Then** `enabled=true`, сбор возобновляется
|
||||
|
||||
### Requirement: Удаление источника
|
||||
Удаление (с подтверждением) убирает источник из yaml и БД, сохраняя его посты.
|
||||
|
||||
#### Scenario: удаление источника
|
||||
- **Given** источник `opennet` с постами в БД
|
||||
- **When** пользователь подтверждает удаление (confirm)
|
||||
- **Then** запись удалена из yaml и sources БД
|
||||
- **And** его посты НЕ удалены: `source_id=NULL` (история сохраняется)
|
||||
- **And** rss_state для него удалён
|
||||
|
||||
#### Scenario: удаление без подтверждения
|
||||
- **Given** форма удаления
|
||||
- **When** confirm отклонён (Cancel)
|
||||
- **Then** ничего не удалено, данные не меняются
|
||||
|
||||
### Requirement: Доступ без авторизации
|
||||
Страница /sources защищена паролем, как остальные админ-страницы.
|
||||
|
||||
#### Scenario: доступ без авторизации
|
||||
- **Given** пользователь не авторизован
|
||||
- **When** он открывает `/sources`
|
||||
- **Then** происходит редирект на /login (303)
|
||||
|
||||
### Requirement: Приоритет источника
|
||||
Приоритет P0–P3 влияет на порядок обработки источников.
|
||||
|
||||
#### Scenario: приоритет
|
||||
- **Given** источники с priority P0–P3
|
||||
- **When** страница /sources загружена
|
||||
- **Then** приоритеты отображаются и доступны для изменения (P0–P3)
|
||||
- **And** /crawlers сортирует по (enabled, priority, slug) с учётом нового приоритета
|
||||
|
||||
### Requirement: Навигация
|
||||
Ссылка «Источники» присутствует в навбаре.
|
||||
|
||||
#### Scenario: навигация
|
||||
- **Given** любая страница веб (base.html)
|
||||
- **When** открыт навбар
|
||||
- **Then** есть ссылка «Источники» на `/sources`
|
||||
@@ -0,0 +1,33 @@
|
||||
# tg-crawler Specification
|
||||
|
||||
## Purpose
|
||||
Чтение публичных Telegram-каналов через Telethon (MTProto) для сбора новостей с метриками
|
||||
популярности. Дополняется поддержкой «своих» источников (own: true) — контент пользователя.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Источники своего контента
|
||||
Краулер MUST распознавать источники с `own: true` в sources.yaml и для их постов
|
||||
проставлять `is_own=1`, `is_own_canonical=1`. Для таких источников медиа MUST
|
||||
скачиваться (контент принадлежит пользователю).
|
||||
|
||||
#### Scenario: Краулинг своего канала
|
||||
- **WHEN** источник slug=dedinit имеет own: true
|
||||
- **THEN** посты сохраняются с is_own=1 и is_own_canonical=1; медиа скачивается;
|
||||
инкрементальный обход и дедуп работают как обычно
|
||||
|
||||
#### Scenario: Чужой форвард в своём канале
|
||||
- **WHEN** пост в своём канале — форвард из чужого канала
|
||||
- **THEN** пост сохраняется с is_own=1 (это пост пользователя, он его переслал) и с
|
||||
fwd_from_channel_id/fwd_from_post_id; медиа не скачивается (содержимое чужое),
|
||||
атрибуция оригинала сохраняется в fwd-полях
|
||||
|
||||
### Requirement: Канонический экземпляр своего контента
|
||||
Если пост пользователя (is_own=1) позже встречается во внешнем канале (тот же sha256/url),
|
||||
внешний экземпляр MUST сохраняться как «упоминание» с is_own=0; каноническим остаётся
|
||||
первичный (is_own_canonical=1).
|
||||
|
||||
#### Scenario: Свой пост запостили в чужой канал
|
||||
- **WHEN** краулер находит в чужом канале пост с текстом, совпадающим с is_own-постом
|
||||
- **THEN** создаётся запись is_own=0 (упоминание) без дублирования контента; веб видит
|
||||
оба экземпляра, но кандидатом на публикацию считается канонический (is_own_canonical)
|
||||
@@ -0,0 +1,65 @@
|
||||
# tg-publisher-service Specification
|
||||
|
||||
## Purpose
|
||||
Изолированный FastAPI-сервис публикации карточек в Telegram-каналы через Bot API.
|
||||
Единая точка вызова для всех компонентов (веб, cron, будущие боты). Один бот-контроллер
|
||||
публикует во все каналы, в которые добавлен администратором. Telegram доступен только
|
||||
через SOCKS5-прокси (127.0.0.1:1080).
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Изолированный сервис публикации
|
||||
Сервис MUST быть отдельным FastAPI-приложением (контейнер), НЕ импортируемым модулем веба.
|
||||
Он MUST предоставлять `GET /healthz` (статус, бот, прокси, каналы) и
|
||||
`POST /api/v1/publish` (публикация карточки).
|
||||
|
||||
#### Scenario: Healthcheck
|
||||
- **GIVEN** сервис запущен
|
||||
- **THEN** `GET /healthz` возвращает 200 с `{"status":"ok","bot":...,"channels":[...]}`
|
||||
и healthcheck в docker-compose (`curl -f`) проходит
|
||||
|
||||
#### Scenario: Публикация карточки
|
||||
- **GIVEN** POST /api/v1/publish c `card: {text, direction, lang}` и `channels: ["@dedinit_vesti"]`
|
||||
- **THEN** сервис отправляет сообщение через Bot API в каждый канал и возвращает
|
||||
`{"ok":true,"results":{"@dedinit_vesti":{"message_id":<int>,"views":0}}}`
|
||||
|
||||
### Requirement: Один бот, несколько каналов (конфиг)
|
||||
Сервис MUST поддерживать список каналов из конфигурации (`VESTI_BOT_CHANNELS`, через запятую).
|
||||
`channels` в запросе MAY переопределять список. Если бот добавлен в канал администратором —
|
||||
публикация MUST работать; иначе сервис MUST вернуть понятную ошибку (403).
|
||||
|
||||
#### Scenario: Несколько каналов
|
||||
- **GIVEN** `VESTI_BOT_CHANNELS=@dedinit_vesti,@other_channel` (оба добавлены боту)
|
||||
- **WHEN** POST /api/v1/publish без поля channels
|
||||
- **THEN** карточка публикуется в оба канала; results содержит оба message_id
|
||||
|
||||
#### Scenario: Бот не админ канала
|
||||
- **GIVEN** канал, в котором бот не администратор
|
||||
- **WHEN** публикация в него
|
||||
- **THEN** сервис возвращает 403 с текстом ошибки Bot API (sendMessage → Forbidden)
|
||||
|
||||
### Requirement: Прокси SOCKS5 для Bot API
|
||||
Все запросы к api.telegram.org MUST идти через прокси из `TG_PROXY=socks5://127.0.0.1:1080`.
|
||||
Если прокси недоступен — сервис MUST вернуть 502 (не падать).
|
||||
|
||||
#### Scenario: Прокси недоступен
|
||||
- **GIVEN** прокси 127.0.0.1:1080 выключен
|
||||
- **WHEN** POST /api/v1/publish
|
||||
- **THEN** сервис возвращает 502 с ошибкой подключения (не 500, не краш)
|
||||
|
||||
### Requirement: Сбор views
|
||||
Сервис MUST предоставлять способ получения просмотров для опубликованных сообщений
|
||||
(через Bot API getMessage), чтобы веб показывал метрики.
|
||||
|
||||
#### Scenario: Views после публикации
|
||||
- **GIVEN** сообщение опубликовано (message_id получен)
|
||||
- **WHEN** запрос views для этого message_id
|
||||
- **THEN** возвращается число просмотров (0 если ещё нет)
|
||||
|
||||
### Requirement: Режим подтверждения
|
||||
Сервис MUST НЕ публиковать автоматически: вызывается только по HTTP-запросу (из веба
|
||||
после клика «Опубликовать»). Автопостинга по таймеру внутри сервиса НЕТ.
|
||||
|
||||
#### Scenario: Нет автопостинга
|
||||
- **GIVEN** сервис запущен без входящих запросов
|
||||
- **THEN** ничего не публикуется (процесс только слушает HTTP)
|
||||
@@ -0,0 +1,44 @@
|
||||
# tg-publisher Specification
|
||||
|
||||
## Purpose
|
||||
Публикация отобранных новостей через Bot API в тематические Telegram-каналы. Дополняется
|
||||
автораспространением своего контента (fan-out) по нескольким направлениям.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Автораспространение (fan-out)
|
||||
Подтверждение СВОЕГО поста (is_own=1) MUST публиковать карточку во ВСЕ тематические
|
||||
каналы @dedinit_vesti_<direction>_<lang>_bot, соответствующие выбранным направлениям
|
||||
(по умолчанию — все направления классификации поста). Каждая карточка MUST содержать
|
||||
атрибуцию «Дед в АйТи» (@dedinit) и ссылку на оригинал t.me/dedinit/<post_id>.
|
||||
|
||||
#### Scenario: Мульти-публикация
|
||||
- **WHEN** подтверждается свой пост с направлениями [linux, ai]
|
||||
- **THEN** карточка отправляется в @dedinit_vesti_linux_ru_bot и @dedinit_vesti_ai_ru_bot;
|
||||
обе содержат ссылку на оригинал
|
||||
|
||||
#### Scenario: Чужой пост — без fan-out
|
||||
- **WHEN** подтверждается внешний пост (is_own=0)
|
||||
- **THEN** публикуется только в канал своего направления, без атрибуции автора
|
||||
|
||||
### Requirement: Публикация по списку направлений
|
||||
Публикатор MUST поддерживать `publish_multi(directions)` — публикацию карточки по списку
|
||||
направлений, возвращающую map {direction: message_id}. При пустом списке направлений
|
||||
MUST публиковать в направление по умолчанию (direction поста).
|
||||
|
||||
#### Scenario: Список направлений рассылки
|
||||
- **WHEN** publish_multi вызывается с directions=[linux, ai]
|
||||
- **THEN** возвращается {linux: message_id1, ai: message_id2}; каждая публикация
|
||||
записывается в published с distributed_dirs
|
||||
|
||||
#### Scenario: Пустой список направлений
|
||||
- **WHEN** publish_multi вызывается без directions
|
||||
- **THEN** публикация идёт в канал направления поста (direction по умолчанию), без fan-out
|
||||
|
||||
### Requirement: Метрики по каждому направлению
|
||||
Для опубликованных карточек MUST собираться views через Bot API по каждому направлению
|
||||
(каждому message_id), чтобы веб показывал эффективность рассылки по лентам.
|
||||
|
||||
#### Scenario: Метрики fan-out
|
||||
- **WHEN** пост разослан в [linux, ai] и каналы набирают просмотры
|
||||
- **THEN** get_views вызывается для каждого message_id; views хранятся по направлению
|
||||
@@ -0,0 +1,29 @@
|
||||
# vesti-web Specification
|
||||
|
||||
## Purpose
|
||||
Веб-интерфейс управления VESTI. Дополняется фильтром «Свои», бейджем и выбором
|
||||
направлений рассылки для своего контента.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Фильтр «Свои»
|
||||
Веб MUST давать фильтр постов по `is_own` (все/только свои/только внешние) и показывать
|
||||
бейдж «СВОЙ» у постов is_own=1. Для своего поста при подтверждении MUST отображаться
|
||||
выбор направлений рассылки (по умолчанию — все направления классификации поста).
|
||||
|
||||
#### Scenario: Фильтр своих постов
|
||||
- **WHEN** админ выбирает фильтр «Свои»
|
||||
- **THEN** показываются только посты is_own=1 с бейджем «СВОЙ» и чекбоксами направлений
|
||||
|
||||
#### Scenario: Подтверждение своего поста
|
||||
- **WHEN** админ подтверждает свой пост с выбранными направлениями [linux, ai]
|
||||
- **THEN** публикация идёт в оба канала (fan-out), результат виден в опубликованных с
|
||||
distributed_dirs
|
||||
|
||||
### Requirement: Список распространения
|
||||
Веб MUST показывать для опубликованного поста, в какие направления/каналы он был
|
||||
разослан (distributed_dirs) и метрики (views) по каждому каналу.
|
||||
|
||||
#### Scenario: Просмотр распространения
|
||||
- **WHEN** админ открывает опубликованный пост (свой)
|
||||
- **THEN** видит список @dedinit_vesti_<dir>_<lang>_bot с views по каждому
|
||||
Reference in New Issue
Block a user