Files
vesti/openspec/changes/own-content-hub/design.md
T

106 lines
6.3 KiB
Markdown

# Design: own-content-hub
## Approach
Канал @dedinit — обычный источник в реестре, но с флагом `own: true`. Вся общая логика
(краулер, дедуп, классификатор, банк) работает как для любого TG-канала; различие —
семантика: посты своего канала считаются СВОИМ контентом (is_own=1) и становятся
«сильными кандидатами» на автораспространение по всем тематическим лентам.
Ключевая идея — **fan-out вместо single-out**: один пост пользователя при подтверждении
уходит сразу во все тематические каналы @dedinit_vesti_<direction>_<lang>_bot, под
которые он подходит (направления из классификации). Так контент «инъецируется» в
новостные ленты и собирает аудиторию на всех площадках, а сам канал-источник остаётся
первоисточником (атрибуция везде).
Поток данных:
```
sources.yaml: @dedinit (own: true)
│
▼
telegram_crawler.py → posts.is_own=1 (дедуп как обычно; медиа скачивается — свой контент)
▼
classifier.py → направление(я) + relevance; is_own + критичность → «сильный кандидат»
▼
vesti-web (фильтр «Свои», бейдж; подтверждение с выбором направлений рассылки)
▼
tg-publisher.py → fan-out: карточка в каждый @dedinit_vesti_<dir>_<lang>_bot
▼
news-store → бандл bundles/<dir>/<YYYY-MM>/<slug>.md (origin=own, ссылка на оригинал)
```
## Files
```bash
# Изменяемые файлы
sources/sources.yaml # + источник dedinit (own: true)
db/schema.sql # + posts.is_own, posts.is_own_canonical, sources.own,
# published.distributed_dirs (миграция ALTER TABLE)
crawler/telegram_crawler.py # + определение own-источника, проставление is_own,
# is_own_canonical (первый экземпляр = канал), медиа скачивается
classifier/classify.py # + is_own → «сильный кандидат» (relevance critical, classified=True)
publisher/bot.py # + fan-out publish_multi(directions)
publisher/card.py # + атрибуция «Дед в АйТи» + ссылка на оригинал
web/app.py # + фильтр is_own, бейдж, выбор направлений рассылки при approve
web/templates/candidates.html # + бейдж СВОЙ, чекбоксы направлений
web/store.py # + frontmatter origin: own + ссылка на оригинал
```
## Commands
```bash
# 1) Миграция схемы (идиемпотентно)
cd /opt/vesti && .venv/bin/python - <<'PY'
import sqlite3
c = sqlite3.connect('db/vesti.db')
for ddl in [
"ALTER TABLE posts ADD COLUMN is_own INTEGER DEFAULT 0",
"ALTER TABLE posts ADD COLUMN is_own_canonical INTEGER DEFAULT 0",
"ALTER TABLE sources ADD COLUMN own INTEGER DEFAULT 0",
"ALTER TABLE published ADD COLUMN distributed_dirs TEXT",
]:
try: c.execute(ddl)
except sqlite3.OperationalError: pass # уже есть
c.commit(); c.close()
PY
# 2) Добавить источник в sources.yaml (own: true), синк
.venv/bin/python -c "from sources.sources import sync_sources_to_db, load_sources_yaml; sync_sources_to_db(load_sources_yaml())"
# 3) Краулер по своему каналу (бэкфилл ~1039 постов; медиа скачивается)
.venv/bin/python -m crawler.telegram_crawler --source dedinit
# 4) Классификатор по своему каналу
CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --direction linux # + нужные направления
# 5) Веб
VESTI_WEB_PASSWORD=<пароль> .venv/bin/uvicorn web.app:app --host 127.0.0.1 --port 8400
# 6) Проверка
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8400/login # 200
sqlite3 db/vesti.db "SELECT COUNT(*) FROM posts WHERE is_own=1" # >0
sqlite3 db/vesti.db "SELECT COUNT(*) FROM posts WHERE is_own=1 AND is_own_canonical=1"
```
## Rollback
```bash
# Отключить источник (не удалять данные)
# sources.yaml: dedinit → enabled: false, own: false
# Перезапустить синк; посты остаются в БД, новые не приходят.
# Поля is_own в данных можно оставить (безвредно); удаление данных — по согласованию.
```
Существующие внешние источники и их посты не затрагиваются: is_own=0 по умолчанию.
## Risks
- **Бэкфилл 1039 постов** — первый прогон долгий (медиа ~623M). Ограничить
MAX_POSTS_PER_CHANNEL или сначала только текст, медиа докачать позже (бэкфилл-флаг).
- **Дубликаты с внешними источниками**: пост пользователя, который запостили в чужой
канал, попадёт и как is_own (свой), и как внешний. Для своих постов `is_own_canonical=1`
(первичный экземпляр), внешние остаются как «упоминания» (is_own=0).
- **Fan-out = спам**: всегда режим подтверждения; веб показывает направления заранее;
лимит 4096 символов сохраняется; атрибуция не даёт путаницы с чужим контентом.
- **qwen3:8b think:false** — уже учтено в classify.py.
- **Право на медиа**: медиа своего канала скачивается (контент пользователя) — ок.