Files
Deal/docs/superpowers/plans/2026-09-04-channel-discovery.md
T
Rustam Khalimov 27c7831910
ci / build-test (push) Canceled after 0s
Deal — единая кодовая база
SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
2026-09-11 23:56:47 +03:00

342 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Поиск и подключение каналов (Discovery) — Implementation Plan
> Исторический документ (план Discovery прототипа LeadRadar, 2026-09-04). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Дать пользователю возможность создавать «задачи поиска»: система находит по описанию/ключам Telegram-каналы и группы (в которых мы не состоим), оценивает их (метаданные → язык → контент по темам), показывает «на рассмотрение», а человек вступает сам или включает авто-вступление в рамках суточных квот с анти-бан паузами.
**Architecture:** Дочерняя система Discovery поверх существующего стека (FastAPI + DuckDB + TelegramManager/Telethon + Vue 3). Отдельный сервис `discovery` (хранилище+оркестрация), новые методы Telegram-действий в `TelegramManager`, общий BanGuard для квот/пауз, отдельный фоновый воркер в `main.py`. Оценка сообщений переиспользует правила/ML/ИИ, но с профилем задачи и БЕЗ создания карточек. UI — подвкладка «Поиск» на экране «Каналы».
**Tech Stack:** Python 3.12 / FastAPI / DuckDB / Telethon / Vue 3 + Tailwind (Vite). Новых зависимостей нет.
## Global Constraints
- Правило «мы не состоим» — глобальное и безусловное: источники из `dialogs`, чёрного списка или уже в другой задаче исключаются сразу (проверка повторяется и при вступлении).
- Спека: `docs/superpowers/specs/2026-09-04-channel-discovery-design.md` (читать при каждом задании).
- Проект НЕ git-репозиторий: вместо `git commit` — проверка через `docker compose`/`npm run build`, фиксация результата в тексте шага.
- Все тексты UI — по-русски, в стиле существующего интерфейса (без канцелярита, короткие подписи).
- Все настраиваемые числа (лимиты, паузы, пороги, размеры выборок) — настройки в БД (`store.get_setting`), НЕ в коде; дефолты в `constants.DEFAULT_SETTINGS`.
- Новые таблицы добавлять только через `db.py` (`_SCHEMA`, `CREATE TABLE IF NOT EXISTS`), при необходимости — миграции в `_MIGRATIONS`.
- Запуск/проверка: контейнеры `docker compose up -d`, бэкенд на :8000, ML на :8100; пересборка `docker compose build app`.
- JSON-поля (keywords/marks/topics) хранить как VARCHAR с `json.dumps(..., ensure_ascii=False)`, читать через `json.loads` — как в остальном коде.
---
### Task 1: Схема БД и настройки по умолчанию
**Files:**
- Modify: `backend/app/db.py` (добавить 4 таблицы в `_SCHEMA`)
- Modify: `backend/app/constants.py` (`DEFAULT_SETTINGS`)
- Modify: `backend/app/routers/settings_routes.py` (`_PUBLIC_INT`)
**Interfaces:**
- Produces: таблицы `disc_tasks`, `disc_candidates`, `disc_blacklist`, `disc_log`; настройки `discJoinLimit` (50), `discJoinDelayMin` (50), `discJoinDelayMax` (70), `discEvalSample` (10), `discEvalThreshold` (40).
- [ ] **Step 1: Добавить таблицы в `_SCHEMA`** (перед таблицей `settings`)
```sql
CREATE TABLE IF NOT EXISTS disc_tasks (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
description VARCHAR NOT NULL DEFAULT '',
keywords VARCHAR NOT NULL DEFAULT '[]',
min_subscribers INTEGER NOT NULL DEFAULT 0,
lang VARCHAR NOT NULL DEFAULT 'ru',
threshold INTEGER NOT NULL DEFAULT 40,
sample_size INTEGER NOT NULL DEFAULT 10,
plan_joins INTEGER NOT NULL DEFAULT 1,
auto_join BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR NOT NULL DEFAULT 'draft', -- draft|running|paused|done|failed
search_idx INTEGER NOT NULL DEFAULT 0,
search_done BOOLEAN NOT NULL DEFAULT FALSE,
found INTEGER NOT NULL DEFAULT 0,
evaluated INTEGER NOT NULL DEFAULT 0,
joined INTEGER NOT NULL DEFAULT 0,
rejected INTEGER NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS disc_candidates (
dialog_id VARCHAR PRIMARY KEY,
task_id VARCHAR NOT NULL,
name VARCHAR NOT NULL DEFAULT '',
username VARCHAR NOT NULL DEFAULT '',
kind VARCHAR NOT NULL DEFAULT 'channel', -- channel|group|forum
hue VARCHAR NOT NULL DEFAULT '#666',
participants INTEGER,
lang_ru BOOLEAN,
marks VARCHAR NOT NULL DEFAULT '[]',
topics VARCHAR NOT NULL DEFAULT '[]',
fit_ratio DOUBLE,
status VARCHAR NOT NULL DEFAULT 'new', -- new|review|joined|rejected
auto_joined BOOLEAN NOT NULL DEFAULT FALSE,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_disc_cand_task ON disc_candidates(task_id, status);
CREATE TABLE IF NOT EXISTS disc_blacklist (
dialog_id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL DEFAULT '',
reason VARCHAR NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS disc_log (
id VARCHAR PRIMARY KEY,
task_id VARCHAR NOT NULL,
event VARCHAR NOT NULL, -- search|found|skip|eval|review|join_auto|join_manual|leave|reject|flood|error|done
text VARCHAR NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_disc_log_task ON disc_log(task_id, created_at);
```
- [ ] **Step 2: Добавить настройки в `constants.py` → `DEFAULT_SETTINGS`**
```python
# поиск каналов (Discovery)
"discJoinLimit": 50, # суточный лимит авто-вступлений (общий)
"discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями
"discJoinDelayMax": 70, # сек, верхняя граница
"discEvalSample": 10, # размер выборки сообщений при оценке
"discEvalThreshold": 40, # % подходящих сообщений
```
- [ ] **Step 3: Открыть настройки наружу в `settings_routes.py`**
В `_PUBLIC_INT` добавить `discJoinLimit, discJoinDelayMin, discJoinDelayMax, discEvalSample, discEvalThreshold`. В `patch_settings` наложить границы: limit 1..200; паузы 5..600; sample 3..30; threshold 1..100.
- [ ] **Step 4: Проверить**
```bash
docker compose build app && docker compose up -d app
```
Затем `GET /api/settings` (после логина) — в ответе присутствуют `discJoinLimit: 50` и остальные ключи. `python -m py_compile` всех изменённых файлов — без ошибок.
---
### Task 2: BanGuard (квоты, паузы, flood)
**Files:**
- Create: `backend/app/services/ban_guard.py`
**Interfaces:**
- Consumes: `store`, настройки из Task 1.
- Produces:
- `def joins_today_auto() -> int` — авто-вступления за текущие UTC-сутки (считает `disc_log` event='join_auto', `created_at >= начало суток`).
- `def can_auto_join() -> bool` — лимит не исчерпан И нет flood-блокировки на сегодня И нет глобальной паузы.
- `async def wait_join_delay() -> None``asyncio.sleep(random.uniform(min, max))`.
- `def note_flood() -> None``store.set_setting("discFloodDay", <start_of_day_ms>)`.
- `def flood_today() -> bool`
- `def global_paused() -> bool` / `def set_global_pause(v: bool) -> None` (setting `discPaused`)
- `def search_pause() -> float``random.uniform(2.0, 4.0)`.
- [ ] **Step 1: Реализовать модуль** (~60 строк; начало суток — UTC: `datetime.now(timezone.utc).replace(hour=0,minute=0,second=0,microsecond=0)` → ms).
- [ ] **Step 2: Проверить на временной БД в контейнере**
```bash
docker compose run --rm --no-deps -e PYTHONPATH=/srv -e LEADRADAR_DATA=/tmp/lr_bg --entrypoint python app -c "
from app.db import store; store.init()
from app.services import ban_guard as bg
assert bg.can_auto_join() is True
assert bg.joins_today_auto() == 0
bg.note_flood(); assert bg.flood_today() is True
bg.set_global_pause(True); assert bg.can_auto_join() is False
print('BANGUARD OK')
"
```
---
### Task 3: Хранилище Discovery (задачи/кандидаты/чёрный список/лог)
**Files:**
- Create: `backend/app/services/discovery.py`
**Interfaces:**
- Consumes: `store` (таблицы Task 1).
- Produces (все синхронные):
- `list_tasks() -> list[dict]`, `get_task(id) -> dict | None` (keywords — список)
- `create_task(payload: dict) -> dict` — валидация: name непустое; `plan_joins` 1..limit; **правило бюджета**: `sum(plan_joins задач, где status NOT IN ('done','failed')) + plan_joins <= discJoinLimit`, иначе `raise ValueError(...)`.
- `patch_task(id, patch: dict) -> dict` (name/description/keywords/min_subscribers/lang/threshold/sample_size/plan_joins/auto_join; увеличение plan_joins — с той же проверкой)
- `delete_task(id) -> None` (удалить задачу и её кандидатов)
- `start_task(id) -> dict` — требует непустой keywords; status=running; `pause_task(id) -> dict` — paused
- `list_candidates(task_id, status: str | None) -> list[dict]` (декод marks/topics)
- `add_candidate(task_id, dialog_id, name, username, kind, hue) -> dict | None``None`, если: в `dialogs`, в `disc_blacklist`, либо уже есть `disc_candidates` со статусом new/review/joined. Лог `skip` с причиной.
- `bump_counter(task_id, field: str, n: int = 1)` — found/evaluated/joined/rejected
- `set_candidate(task_id, dialog_id, patch: dict)` — обновление полей кандидата
- `set_candidate_status(dialog_id, status)` + лог
- `delete_candidate(dialog_id) -> None` — удалить кандидата (skip-ветки)
- `advance_search(task_id) -> None``search_idx += 1`; когда индекс >= len(keywords) → `search_done=True`
- `mark_joined(dialog_id, auto: bool)` — статус joined + `bump_counter('joined')` + лог `join_auto`/`join_manual`
- `mark_rejected(dialog_id, reason="")` — статус rejected + `bump_counter('rejected')` + лог `reject` + `add_blacklist`
- `add_blacklist(dialog_id, name, reason) / remove_blacklist(dialog_id) / list_blacklist() -> list[dict]`
- `add_log(task_id, event, text="")`; `task_log(task_id, limit=100) -> list[dict]`
- [ ] **Step 1: Реализовать модуль** (json-поля по конвенции проекта; все `store.execute/query` с параметрами).
- [ ] **Step 2: Проверить на временной БД** (сценарий): создать задачу plan=25 → ок; создать вторую plan=30 → `ValueError`; кандидат, совпадающий с `dialogs``add_candidate` вернул None + лог skip; `mark_rejected` → в чёрном списке; повторный `add_candidate` того же источника → None.
---
### Task 4: Telegram-действия поиска (методы TelegramManager)
**Files:**
- Modify: `backend/app/services/telegram.py` (класс `TelegramManager`)
**Interfaces:**
- Consumes: `self.client`, `ban_guard`.
- Produces (async-методы):
- `async def discovery_search(q: str, limit: int = 30) -> list[dict]``client(functions.contacts.SearchRequest(q=q, limit=limit))`; вернуть `[{id(str), name, username, kind, hue}]` (kind через `_kind_of`, hue через `dialog_hue`); между вызовами — `await asyncio.sleep(ban_guard.search_pause())`.
- `async def discovery_info(dialog_id: str) -> dict``{id, name, username, kind, hue, participants: int | None, is_forum: bool}` (participants из `full_chat` где возможно; иначе None).
- `async def discovery_read(dialog_id: str, limit: int) -> dict` — последние сообщения: `{"ok": bool, "error": str | None, "messages": [{"id", "text", "date_ms", "topic_id"}]}`; `topic_id``getattr(getattr(m,'reply_to',None),'reply_to_top_id',None)`. История недоступна → `{"ok": False, "error": "no_history", "messages": []}`.
- `async def discovery_join(username: str) -> None``client(functions.channels.JoinChannelRequest(...))`; ПЕРЕД вызовом `await ban_guard.wait_join_delay()`; `FloodWaitError``ban_guard.note_flood()` и проброс.
- `async def discovery_leave(dialog_id: str) -> None``channels.LeaveChannelRequest`.
- `def add_dialog_monitored(dialog_id, name, username, kind, hue) -> None` — INSERT/UPDATE `dialogs` с `monitor=TRUE, backfilled=FALSE` (как в `set_monitor`, но без авто-join-логики).
- [ ] **Step 1: Реализовать методы** (импорт `telethon.tl.functions`, `telethon.errors.rpcerrorlist.FloodWaitError`).
- [ ] **Step 2: Проверить компиляцию** `py_compile`. Полная проверка — после Task 6/7 на живом аккаунте (ручные шаги в Task 10).
---
### Task 5: Оценка контента (язык, темы, fit по профилю задачи)
**Files:**
- Create: `backend/app/services/discovery_eval.py`
**Interfaces:**
- Consumes: `store`, `ml_client`, `ai_service` (chat_json), `pipeline.clean_short`.
- Produces:
- `def detect_lang_ru(texts: list[str]) -> bool | None` — доля кириллических букв от всех букв в сумме: `>=0.15 → True`; `<=0.03 → False`; между порогами → `None` (неопределённо).
- `def group_by_topic(messages: list[dict]) -> list[dict]` — группировка по `topic_id` (None → "main"); возвращает `[{"topic_id", "title", "messages": [...]}]`, title = сниппет первого текста темы (≤60 симв.), сортировка по количеству сообщений (убыв.).
- `async def evaluate_message(task: dict, text: str) -> dict``{"fit": bool, "reason": str, "source": "heuristic"|"ml"|"ai"}`:
1) текст пустой/длина <10 → fit False «слишком короткое»;
2) ML: если `ml_client.is_enabled()` и прогноз `take` и `label=='spam'` → fit False «ML: спам»;
3) ИИ: если `aiEnabled` → один JSON-вызов `ai_service.chat_json(промпт, user=text)` с промптом из описания задачи и ключей (`{fit, reason}`); ошибка → шаг 4;
4) эвристика: fit = любой ключ входит в `clean_short(text)` casefold; reason «совпал ключ "…"» / «нет совпадений с ключами».
- `async def evaluate_sample(task: dict, messages: list[dict]) -> dict` — последовательно по каждому сообщению; вернуть `{"fit_count": int, "total": int, "fit_ratio": float, "per_message": [{"text": …, "fit", "reason", "topic_id"}]}`.
- `def passed(ev: dict, task: dict) -> bool``ev["total"] >= 3 and ev["fit_ratio"]*100 >= task["threshold"]`.
- [ ] **Step 1: Реализовать модуль**. Промпт ИИ (внутри модуля, константа):
`Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. Ключи: {keywords}. Верни JSON {"fit": 0|1, "reason": "краткая причина"}.`
- [ ] **Step 2: Проверить на временной БД** (без сети): `detect_lang_ru(["Ищем python разработчика"]) is True`; `detect_lang_ru(["we need a python developer"]) is False`; `group_by_topic` объединяет по topic_id и сортирует; `evaluate_message` на задаче без ИИ/ML возвращает эвристический fit по ключу.
---
### Task 6: Воркер Discovery (поиск → оценка → авто-вступление)
**Files:**
- Create: `backend/app/services/discovery_worker.py`
- Modify: `backend/app/main.py` (фоновый цикл `_discovery_loop`, каждые 5 c)
**Interfaces:**
- Consumes: `discovery` (Task 3), `tg.discovery_*` (Task 4), `discovery_eval` (Task 5), `ban_guard` (Task 2).
- Produces: `async def tick() -> dict` — выполняет ОДНО действие и возвращает `{"action": ..., "taskId": ...}` (или `{"action": "none"}`).
Логика tick (по одной задаче за вызов, начиная с самой старой running):
1. Если задача `search_done=False`: взять ключ `keywords[search_idx]`, вызвать `tg.discovery_search`; для каждого результата `discovery.add_candidate`; `discovery.advance_search(task_id)`; если `search_done` стал True — лог `search` «поиск завершён: N кандидатов». Возврат.
2. Иначе взять первого кандидата статуса `new` задачи:
- `info = tg.discovery_info`; `participants`, `kind` (forum если `is_forum`); при заданном `min_subscribers` и participants НЕ None и меньше минимума — `set_candidate_status(...)` нет: просто `discovery.delete_candidate` + лог `skip`; если participants None — метка «участники не подтверждены» (идём дальше).
- `read = tg.discovery_read(dialog_id, sample_size)`.
- Если `read.ok=False` (история недоступна без членства): kind==channel → `review` с меткой «канал: история недоступна»; группа/форум → `review` с меткой «закрытая группа (история скрыта) — вступите сами»; оценка контента не производится, неподтверждённые фильтры помечаются.
- Язык: если прочитано и `task.lang=='ru'`: `lang_ru=detect_lang_ru(...)`; False → удалить кандидата, лог `skip` «язык не русский»; None → метка «язык не подтверждён».
- Оценка: `evaluate_sample`; `passed` → метки topics/fit → `review` + лог `review`; иначе удалить кандидата, лог `skip` «мало подходящих (X из N)».
3. Авто-вступление (отдельный проход tick, приоритет ниже оценки): если у running-задачи `auto_join` и есть кандидат `review` и `ban_guard.can_auto_join()`:
- повторная проверка «мы не состоим» (`dialogs`/blacklist) → если вступили уже → `mark_rejected` с логом;
- `await ban_guard.wait_join_delay()` (рандом 50–70 с — спейсинг авто-вступлений; ручные join из API паузу не делают);
- `tg.discovery_join(username)``discovery.mark_joined(dialog_id, auto=True)``tg.add_dialog_monitored(...)`; при FloodWaitError → `ban_guard.note_flood()` + лог `flood`.
4. Если `task.joined >= task.plan_joins` → статус `done`, лог `done`.
- [ ] **Step 1: Реализовать** `discovery_worker.py` и цикл в `main.py`.
- [ ] **Step 2: Проверить компиляцию** и запуск без падений (воркер с пустыми таблицами делает `none`). Полный прогон — Task 10 вручную.
---
### Task 7: API Discovery
**Files:**
- Create: `backend/app/routers/discovery_routes.py`
- Modify: `backend/app/main.py` (регистрация роутера)
**Interfaces:**
- Prefix `/api/discovery`, auth `current_login`:
- `GET /tasks`, `POST /tasks`, `PATCH /tasks/{id}`, `DELETE /tasks/{id}`, `POST /tasks/{id}/start`, `POST /tasks/{id}/pause`
- `POST /tasks/{id}/generate-keywords` — ИИ: промпт по description → JSON `{"keywords": [...]}` (816 строк RU+EN); ИИ недоступен/выключен → `{"keywords": [], "error": "..."}`.
- `GET /tasks/{id}/candidates?status=`
- `POST /candidates/{dialog_id}/join` — ручное вступление (вне квот): `tg.discovery_join` + `add_dialog_monitored` + `mark_joined(auto=False)`; 400 при ошибке.
- `POST /candidates/{dialog_id}/reject``mark_rejected` (добавляет в чёрный список). Если кандидат уже `joined` — 400.
- `GET /blacklist`, `DELETE /blacklist/{dialog_id}`
- `GET /tasks/{id}/log`
Pydantic-модели: `TaskCreate` (name, description, keywords, minSubscribers, lang, threshold, sampleSize, planJoins, autoJoin), `TaskPatch` (все optional), `GenKeywordsBody` не нужен (id в пути).
- [ ] **Step 1: Реализовать роутер** (ValueError → HTTPException 400; KeyError → 404).
- [ ] **Step 2: Зарегистрировать в main.py**.
- [ ] **Step 3: Проверить API на живом контейнере**: логин, создание задачи plan=1, list, delete; `generate-keywords` вернёт error-ветку без настроенного ИИ (не падает).
---
### Task 8: Фронтенд — store + каркас подвкладки «Поиск»
**Files:**
- Modify: `frontend/src/store.js`
- Create: `frontend/src/views/DiscoveryView.vue`
- Modify: `frontend/src/views/ChannelsView.vue`
**Interfaces:**
- state: `channelsTab: 'list' | 'search'`, `discTasks: []`, `discCandidates: []`, `discBlacklist: []`, `discLog: []`, `discActiveTaskId: null`, `discCandidateStatus: 'review'`, `discBusy: false`.
- store-функции: `gotoChannelsTab(tab)`, `loadDiscTasks()`, `saveDiscTask(form, id=null)` (create/patch), `deleteDiscTask(id)`, `startDiscTask(id)`, `pauseDiscTask(id)`, `generateDiscKeywords(taskId)`, `loadDiscCandidates(taskId, status)`, `joinDiscCandidate(c)`, `rejectDiscCandidate(c)`, `loadDiscBlacklist()`, `removeDiscBlacklist(id)`, `loadDiscLog(taskId)`.
- [ ] **Step 1: store.js** — состояние + функции (паттерны: `api.get/post/patch/delete`, `toast`, `errMsg`).
- [ ] **Step 2: ChannelsView.vue** — в шапке сегмент: «Каналы | Поиск» (`state.channelsTab`), содержимое по табу.
- [ ] **Step 3: DiscoveryView.vue (каркас)**: левая колонка — список задач (+ «Новая задача»); правая — панель задачи: мастер (name, description, «Сгенерировать ключи ИИ», чипы ключей редактируемые, minSubscribers, lang select ru/any, threshold, sampleSize, planJoins, autoJoin toggle, кнопки «Запустить/Пауза/Удалить»).
- [ ] **Step 4: `npm run build`** — без ошибок.
---
### Task 9: Фронтенд — кандидаты, действия, чёрный список, история
**Files:**
- Modify: `frontend/src/views/DiscoveryView.vue`
**Interfaces:**
- Consumes: Task 8 (store).
- [ ] **Step 1: Табы панели задачи**: «В обработке» (`new`) / «На рассмотрении» (`review`) / «Вступили» (`joined`) / «Отклонены» (`rejected`) + «История» (лог). Бейджи счётчиков скрыты при 0.
- [ ] **Step 2: Карточка кандидата**: название, @username, kind-иконка/метка (канал/группа/форум), метки marks (чипы: закрытая, не прочитан, участники/язык не подтверждены, мало сообщений), участники, «подходит X из N», кнопки «Вступить и мониторить» / «Отклонить» (только для review). Форум → раскрывающийся список topics («тема — подходит X из N»).
- [ ] **Step 3: Чёрный список** (под списками или отдельный таб) — снять источник; «Настройки квот» — popover/inline с `discJoinLimit/discJoinDelayMin/discJoinDelayMax` + стоп-кран (PATCH /api/settings).
- [ ] **Step 4: История** — лог задачи.
- [ ] **Step 5: `npm run build`** — без ошибок; визуальная проверка основных сценариев (Task 10).
---
### Task 10: ТЗ, сборка и end-to-end проверка
**Files:**
- Modify: `ТЗ.md` (раздел «Поиск и подключение каналов»)
- [ ] **Step 1: Дополнить ТЗ** — кратко: задачи поиска, каскад фильтров, глобальное правило «мы не состоим», метки, оценка по темам, review/join/reject + чёрный список, авто-вступление и квоты (50/сутки, 50–70 с), подвкладка «Поиск» на «Каналах».
- [ ] **Step 2: Сборка и рестарт**:
```bash
docker compose build app && docker compose up -d app
cd frontend && npm run build
```
- [ ] **Step 3: E2E вручную (нужен подключённый Telegram-аккаунт)**:
1. «Каналы → Поиск» → создать задачу (описание, план 3, авто-вступление выкл) → «Сгенерировать ключи» → запустить.
2. Дождаться кандидатов; проверить, что текущие подписки и отклонённые не появляются.
3. Открыть кандидата: метки, участники, fit «X из N», темы форума.
4. «Вступить и мониторить» → источник появляется в «Каналах» (monitor on) и начинает давать карточки.
5. «Отклонить» → уходит в чёрный список; повторно не находится.
6. Включить авто-вступление: проверить паузы (≥50 с) и расход суточного лимита.
---
## Self-Review
- **Покрытие спеки:** Task 1 (хранилище+настройки), Task 2 (квоты/анти-бан), Task 3 (задачи/бюджет планов/чёрный список), Task 4 (поиск/инфо/чтение/join), Task 5 (язык/темы/fit), Task 6 (воркер+авто-join), Task 7 (API), Task 89 (UI), Task 10 (ТЗ+E2E). Правило «мы не состоим» — Task 3 `add_candidate`, Task 6 шаг 3 (повторная проверка перед join), UI Task 9. Разделы спеки §4–§12 покрыты; «вне рамок» (§13) не реализуются.
- **Плейсхолдеры:** нет; у каждого шага есть конкретный код/поведение и способ проверки.
- **Согласованность:** единые статусы задач `draft|running|paused|done|failed`, кандидатов `new|review|joined|rejected`; события лога `search|found|skip|eval|review|join_auto|join_manual|leave|reject|flood|error|done`; все имена настроек и функций совпадают между задачами.