ci / build-test (push) Canceled after 0s
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 зелёные.
342 lines
28 KiB
Markdown
342 lines
28 KiB
Markdown
# Поиск и подключение каналов (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": [...]}` (8–16 строк 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 8–9 (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`; все имена настроек и функций совпадают между задачами.
|