Deal — единая кодовая база
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 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,61 @@
# Discovery — финальная волна правок: отчёт
**Статус: ГОТОВО** — все findings закрыты, проверки (компиляция, сборки, смоук) зелёные.
## Что исправлено
| Finding | Файлы | Суть |
| --- | --- | --- |
| I1 + M1 + m2 (авто-join) | `db.py`, `discovery.py`, `discovery_worker.py` | Колонка `disc_candidates.join_failures``_SCHEMA` + `_MIGRATIONS`), `joinFailures` во view кандидата. `_join_step`: после `wait_join_delay()` кандидат перечитывается (SELECT по dialog_id) и join выполняется только если запись есть, `status='review'`, задача `running`+`autoJoin`, `_we_are_in()` False — иначе выход без join. Ошибка join (не flood): `join_failures += 1`; на 3-й неудаче `delete_candidate` + лог skip «не удалось вступить (3 попытки): {err}». FloodWaitError — как было (note_flood + лог flood, кандидат остаётся review). После успеха: `mark_joined``add_dialog_monitored``await tg.backfill_dialog(dialog_id)``remove_blacklist`. |
| I2 (flood/ошибки поиска) | `discovery_worker.py` | `tg.discovery_search` обёрнут в try/except: FloodWaitError → `note_flood()` + лог flood + return; прочие Exception → лог error «поиск «{keyword}»: {err}» + return; `advance_search` только после успешного поиска. |
| I3 (backfill после join) | `discovery_worker.py`, `discovery_routes.py` | В обоих местах join: `await tg.backfill_dialog(dialog_id)` после `add_dialog_monitored` (best-effort: исключение не роняет шаг/API — `log.warning`). |
| I4 (отсев «чатов») | `discovery_worker.py` | В `_search_step` результаты с `kind='чат'` (люди/личные чаты/боты) пропускаются: `continue` + лог skip «{name}: личный чат/бот»; каналы/группы/форумы — как раньше. |
| M4/m1 (идемпотентность reject + ЧС) | `discovery.py`, `discovery_worker.py`, `discovery_routes.py` | `mark_rejected`: ранний return при `status='rejected'` (счётчик/лог/чёрный список не трогаются). После успешного join (worker и routes) — `discovery.remove_blacklist(dialog_id)`. |
| M3 (фронт-чистота) | `store.js`, `DiscoveryView.vue` | Удалён мёртвый `state.discCandidateStatus` (проверено: читается только в docs; вью использует локальный `activeTab`) вместе с записями в `loadDiscCandidates`/`resetLocal`. `resetLocal` сбрасывает `discCounts``discQuota`). Квоты переехали в store: `state.discQuota {limit,delayMin,delayMax,paused}` + `loadDiscQuota()` (GET /api/settings), `saveDiscQuota(patch)` (PATCH), `toggleDiscPaused()`; прямой `import { api, ApiError }` из вью убран. |
| M5 (инвариант пауз) | `settings_routes.py` | `patch_settings`: если пришли оба `discJoinDelayMin`/`discJoinDelayMax` — после клампов (5..600), при min>max значения меняются местами (как делает фронт). |
| m6 (потеря имени) | `discovery_worker.py` | `_eval_step`: name/username обновляются только при успешном резолве `discovery_info` (`resolved`); fallback (`name=dialog_id`) больше не затирает имя кандидата. |
| Синхронизация дизайн-дока | `docs/superpowers/specs/2026-09-04-channel-discovery-design.md` | §9: у кандидата `lang_ru` (не lang_detected), добавлен `join_failures`; topics = {topicId,title,fitCount,total,fitRatio,passed}; нет транзитного статуса `evaluated` (счётчик на задаче). |
## Проверки (команды и вывод)
1. Компиляция бэкенда:
```
$ cd /c/telbase && python -m py_compile backend/app/services/discovery_worker.py \
backend/app/services/discovery.py backend/app/routers/discovery_routes.py \
backend/app/db.py backend/app/routers/settings_routes.py
PY_COMPILE_OK
```
2. Сборка образа (обязательно после правок бэкенда/db.py):
```
$ docker compose build app
[+] build 1/1
✔ Image telbase-app Built 6.4s
```
(внутри образа повторно собран и фронтенд: `vite build` → 42 modules transformed, ok)
3. Сборка фронтенда:
```
$ cd /c/telbase/frontend && npm run build
vite v6.4.3 building for production...
✓ 42 modules transformed.
✓ built in 1.40s
```
4. Смоук на временной БД в контейнере (все Telegram-вызовы замоканы):
```
$ MSYS_NO_PATHCONV=1 docker compose run --rm --no-deps -e PYTHONPATH=/srv \
-e LEADRADAR_DATA=/tmp/lr_fix --entrypoint python app /data/lr_fix_smoke.py
A_REJECT_IDEMPOTENT_OK # mark_rejected повторно: rejected=1, 1 reject-лог, 1 запись ЧС
B_JOIN_FAILURES_OK # join_failures 1→2, на 3-й неудаче кандидат удалён + skip «3 попытки»
B_JOIN_RECHECK_OK # задача на паузе во время delay → join не вызывается (action none)
C_SEARCH_ERROR_TICK_OK # ошибка поиска: tick жив (action error), searchIdx не двигается
D_AUTOJOIN_OK # успешный авто-join: joined/auto_joined, dialogs(monitor), backfill вызван
LR_FIX_SMOKE_OK
```
Временный скрипт `data/lr_fix_smoke.py` удалён после прогона; `progress.md` не трогался.
## Concerns
1. Ветка flood-ошибки поиска и реальные сетевые ошибки join офлайн не воспроизводятся (нужен живой аккаунт); покрыты код-ревью и логикой (в `tg.discovery_join` flood фиксируется и пробрасывается, worker дублирует `note_flood` идемпотентно).
2. При отсеве «личный чат/бот» пишется skip-лог на каждого человека в выдаче поиска — история задачи может стать многословной на «широких» ключах (по ТЗ-рекомендации инструкции; при желании можно убрать лог, оставив continue).
3. `_join_step` при нарушении условий re-check после паузы выходит молча (`{"action":"none"}`) без лога — намеренно (ситуация штатная: задача поставлена на паузу/кандидат отклонён человеком). Ошибки же join всегда логируются.
4. В `db.py` и `settings_routes.py` остались предсуществующие замечания линтера, не связанные с этой волной: db.py — сортировка импортов (L7), try/except-pass и «голый» Exception в нетронутых местах Store (миграции/close/all_settings); settings_routes.py — неиспользуемые импорты `json`/`HTTPException`/`tg` и неиспользуемая `provider_id` в `ai_check`. Новые правки этих замечаний не добавляют (проверено: файлы воркера/сервиса/роута discovery чисты).
5. `join_failures` не сбрасывается при переводе кандидата обратно в review после удачной паузы — не требуется: при повторном добавлении (после rejected) запись создаётся заново с `DEFAULT 0`, а успешный join переводит кандидата в `joined`.
@@ -0,0 +1,41 @@
# Discovery — scoped-ревью фикс-волны: вердикт и доработки
**Вердикт ревьюера: Ready — With fixes** (все 8 заявленных фиксов I1–I4/M1–M5/m1–m6 подтверждены код-ревью; 2 Important + 4 Minor).
Смоук-прогон новых веток после правок — зелёный (A–E), образ пересобран, контейнер поднят, API 200.
## Findings ревьюера и что сделано
### Important
1. **Search-flood retry storm** (`discovery_worker.py`) — при `FloodWaitError` ключ не двигался, а тик каждые 5 с снова дёргал `discovery_search` весь день (флуд > 60 с Telethon не гасит сам), спамя лог flood и блокируя eval/join всех задач.
**Исправлено**: `tick()` теперь при `ban_guard.flood_today()` возвращает none (полный стоп discovery-сетевых действий до конца суток — согласуется с «стоп до конца суток» из note_flood); generic-ошибка ключа — после 3 попыток подряд ключ пропускается (`advance_search` + лог «ключ пропущен»), счётчик ошибок сбрасывается при успешном поиске.
2. **Re-check после wait_join_delay без BanGuard** (`_join_step`) — за паузу 50–70 с пользователь мог нажать стоп-кран / случиться флуд, а pending-join всё равно выполнялся.
**Исправлено**: в условие после паузы добавлено `not ban_guard.can_auto_join()` (покрывает стоп-кран, flood дня и суточный лимит).
### Minor
3. **Single-key PATCH ломает инвариант min ≤ max пауз**`PATCH {discJoinDelayMax: 5}` при сохранённом min=600 давал инверсию → `random.uniform` падал на каждом join-тике.
**Исправлено** в двух местах: `settings_routes.patch_settings` клампит одиночный конец интервала относительно сохранённого другого; `ban_guard.wait_join_delay` защитно меняет концы местами (и выходит без паузы, если обе настройки 0).
4. **UPDATE join_failures / delete на 3-й неудаче без ре-валидации status='review'** — узкая гонка: человека отклонил кандидата между re-read и падением join.
**Исправлено**: перед инкрементом счётчика статус перечитывается (`row_now`); UPDATE идёт с `AND status='review'`; при выходе из review воркер выходит молча, не трогая запись.
5. **Ручной join в API ждал inline-backfill ~1530 с** (`backfill_dialog` спит 1.5–3 с/сообщение) — кнопка «Вступить» висела, прокси с коротким таймаутом показал бы ложную ошибку.
**Исправлено**: `POST /candidates/{id}/join` запускает backfill в фоне через `_spawn(_backfill_quiet(...))` (паттерн set_monitor_all), ответ API быстрый, ошибки backfill не роняют запрос.
6. **Skip-лог на каждого человека в поиске** — известный minor из отчёта фиксера (concern #2). Оставлен как есть: по одному логу на источник информативно для истории задачи; при желании можно агрегировать («пропущено личных чатов: N») — не критично.
## Smoke новых веток (временный скрипт в data/, удалён после прогона)
```
A_FLOOD_GATE_OK # flood_today -> tick none, search не вызван
B_PAUSE_DURING_DELAY_OK # стоп-кран во сне -> join не выполнен, кандидат review
C_JOIN_FAILURES_REVIEW_GONE_OK # кандидат rejected во время падения join: счётчик 0, запись жива
D_INVERTED_DELAYS_OK # min>max: wait_join_delay не падает (swap)
E_SEARCH_KEY_SKIP_OK # 3 ошибки ключа -> ключ пропущен, searchDone, лог «ключ пропущен»
LR_REVIEW_FIX_SMOKE_OK
```
## Проверки
1. `python -m py_compile` изменённых файлов (discovery_worker.py, ban_guard.py, discovery_routes.py, settings_routes.py) — OK.
2. `docker compose build app` — Built (внутри образа повторный `vite build` — ok).
3. `docker compose up -d app` — контейнер Recreated/Started.
4. `GET /api/health` — 200 `{"ok":true,...}`; login admin — 200; `/api/settings` отдаёт disc-ключи и `discPaused: false`; `/api/discovery/tasks` и `/blacklist` — 200 `{"items":[]}`.
## Осталось
- Живой E2E с Telegram-аккаунтом (шаги в task-10-report.md §«Осталось для ручной проверки») — вместе с пользователем: поиск → кандидаты → вступить/отклонить → авто-вступление с паузами и расходом лимита. Реальные flood/форумы офлайн не воспроизводятся.
@@ -0,0 +1,28 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-04-channel-discovery.md
Адаптация процесса: проект НЕ git-репозиторий (рабочее дерево C:\telbase, деплой docker compose).
Вместо коммитов фиксируем затронутые файлы и результат проверок; вместо git-диффов ревьюер читает
файлы из brief. Рабочая папка плана: .superpowers/sdd/channel-discovery/
Pre-flight правки плана (сделаны контроллером до старта):
- Task 3: добавлены недостающие интерфейсы delete_candidate() и advance_search() (Task 6 на них ссылается).
- Task 6: унифицированы метки недоступной истории (закрытая группа/канал — история скрыта; канал — история недоступна).
- Task 5: уточнена detect_lang_ru (>=0.15 -> True, <=0.03 -> False, иначе None).
Состояние задач:
- Task 1: complete (db.py, constants.py, settings_routes.py; review clean; minor: min<=max пауз не проверяется — кандидат в UI-задачу)
- Task 2: complete (ban_guard.py; review clean; minors: wait_join_delay не покрыт живым прогоном, min<=max не enforced, защитные `or 0`)
- Task 3: complete (discovery.py; review clean; контракт: внешние dict camelCase, set_candidate_status только new/review, delete_task чистит лог; minors: идемпотентность mark_rejected, сортировка лога)
- Task 4: complete (telegram.py discovery_* + add_dialog_monitored; review clean; fix round 2: форум читает >=3 сообщ./тема; контракт: join без паузы, discovery_read c topic_id/topic_title)
- Task 5: complete (discovery_eval.py; review clean; семантика: topic "main" для None, ИИ-ветка дополнительно проверяет наличие ключа провайдера)
- Task 6: complete (discovery_worker.py + main.py loop; review clean; deferred minor: авто-join без лимита ретраев — риск застревания конвейера на битом кандидате, решить в финале/E2E)
- Task 7: complete (discovery_routes.py + main.py; review clean; deferred minors: двойной reject не идемпотентен, join ранее отклонённого оставляет запись в blacklist — решить в финальной волне)
- Task 8: complete (store.js discovery-функции, ChannelsView сегмент, DiscoveryView каркас+мастер; review clean)
- Task 9: complete (DiscoveryView табы/кандидаты/ЧС/квоты, Icon users/megaphone/list, settings discPaused в _PUBLIC_BOOL; review clean; minors: прямой api-импорт в вью, discCandidateStatus мёртвое, resetLocal не чистит discCounts)
- Task 10: complete (ТЗ 4.9 + сборка/health; E2E с живым аккаунтом — за пользователем, шаги в task-10-report.md)
- Фикс-волна (I1I4/M1M5/m1m6): complete (final-fix-report.md; smoke зелёный)
- Scoped-ревью фикс-волны: complete (final-review-report.md) — 2 Important + 4 Minor
закрыты правками в discovery_worker/ban_guard/discovery_routes/settings_routes;
повторный smoke A–E зелёный; образ пересобран, контейнер поднят, API 200.
Все 10 задач + фикс-волна выполнены, ревью чистое. Осталось: живой E2E с Telegram-аккаунтом (шаги в task-10-report.md).
@@ -0,0 +1,91 @@
### 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` всех изменённых файлов — без ошибок.
---
@@ -0,0 +1,91 @@
# Task 1 — Отчёт: Схема БД и настройки по умолчанию (Discovery)
Статус: **DONE**
## Что сделано по шагам
### Step 1: Таблицы в `_SCHEMA` (`backend/app/db.py`)
Добавлены перед таблицей `settings` (текст SQL — дословно из брифа):
- `disc_tasks` — задачи поиска каналов (статус, прогресс, план);
- `disc_candidates` — найденные кандидаты (оценки, статус);
- `disc_blacklist` — чёрный список;
- `disc_log` — лог событий по задаче;
- индексы `idx_disc_cand_task` (candidates: task_id, status) и `idx_disc_log_task` (log: task_id, created_at).
> ⚠️ ВАЖНО (поймано при проверке): `_SCHEMA` исполняется по фрагментам через `split(";")`, поэтому в `--`-комментариях над таблицами не должно быть точки с запятой. Первая версия комментария содержала «;» внутри строки комментария — приложение падало на старте (`Parser Error: syntax error at or near "столбцы"`). Исправлено переформулировкой комментария без `;`. Inline-комментарии внутри строк `CREATE TABLE` (из брифа, напр. `-- draft|running|...`) безопасны — в них `;` нет.
### Step 2: Настройки в `DEFAULT_SETTINGS` (`backend/app/constants.py`)
Добавлены в конец словаря (дословно из брифа):
```python
# поиск каналов (Discovery)
"discJoinLimit": 50, # суточный лимит авто-вступлений (общий)
"discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями
"discJoinDelayMax": 70, # сек, верхняя граница
"discEvalSample": 10, # размер выборки сообщений при оценке
"discEvalThreshold": 40, # % подходящих сообщений
```
### Step 3: Публичные ключи и клампы (`backend/app/routers/settings_routes.py`)
- В `_PUBLIC_INT` добавлены: `discJoinLimit, discJoinDelayMin, discJoinDelayMax, discEvalSample, discEvalThreshold`.
- В `patch_settings` (по образцу существующих клампов `archiveAfterDays`/`minLen`):
- `discJoinLimit` → 1..200;
- `discJoinDelayMin` / `discJoinDelayMax` → 5..600;
- `discEvalSample` → 3..30;
- `discEvalThreshold` → 1..100.
### Step 4: Проверка
Выполнена (вывод ниже).
## Изменённые файлы
- `backend/app/db.py`
- `backend/app/constants.py`
- `backend/app/routers/settings_routes.py`
## Вывод проверок
### 1. `py_compile`
```
$ cd /c/telbase && python -m py_compile backend/app/db.py backend/app/constants.py backend/app/routers/settings_routes.py
PY_COMPILE_OK # без ошибок
```
### 2. Пересборка и запуск контейнера
```
$ docker compose build app && docker compose up -d app
[+] build 1/1 ✔ Image telbase-app Built (первый запуск упал — см. Concern/Фикс)
[+] up 3/3
✔ Container leadradar-ml Running
✔ Container leadradar-minio Running
✔ Container leadradar Started
```
Логи контейнера после фикса:
```
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Ctrl+C to quit)
```
### 3. API
```
POST /api/auth/login {"login":"admin","password":"admin"} → login status: 200
GET /api/settings →
settings: {"discJoinLimit": 50, "discJoinDelayMin": 50, "discJoinDelayMax": 70,
"discEvalSample": 10, "discEvalThreshold": 40}
```
Все пять ключей присутствуют и равны значениям по умолчанию. ✔
### Дополнительно
- Попытка прямой проверки таблиц в DuckDB вторым процессом
(`duckdb.connect(..., read_only=True)`) не удалась ожидаемо: DuckDB — одна запись,
лок держит процесс приложения (`Conflicting lock is held... PID 1`). Косвенное
подтверждение: `_SCHEMA` исполняется в `store.init()` при старте и падает loudly
(первый запуск упал с Parser Error — это и выявило баг), а финальный старт чистый.
## Concerns
1. **`;` в `--`-комментариях `_SCHEMA` опасен** — пайплайн `split(";")` режет схему
посередине комментария. В добавленном блоке таких мест больше нет, но при будущих
правках схемы стоит избегать `;` в комментариях.
2. Клампы `discJoinDelayMin`/`discJoinDelayMax` независимы (оба 5..600) — нет
перекрёстной проверки `min <= max` (в брифе её не требовалось).
3. Прямой SQL-запрос к таблицам извне невозможен при запущенном приложении (лок
DuckDB); фактическое наличие таблиц подтверждено чистым стартом и прохождением
всей схемы в `store.init()`.
@@ -0,0 +1,22 @@
### 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 с) и расход суточного лимита.
---
@@ -0,0 +1,47 @@
# Task 10 — Отчёт: ТЗ, сборка и health-проверка
## Статус
Выполнено. ТЗ дополнено разделом 4.9; docker-образ собран и контейнер перезапущен; фронтенд собран; py_compile всех файлов Discovery — OK; health/settings проверены по HTTP (200). Живой E2E с кандидатами/вступлениями не выполнялся (нужен реальный Telegram-аккаунт) — ручные шаги перенесены в «Осталось для ручной проверки».
## Файлы
- Изменён: `ТЗ.md` — добавлен подраздел `### **4.9. Поиск и подключение каналов (Discovery)**` (11 пунктов) между `4.8` и «5. Спецификация пайплайна обработки данных».
## Что сделано
### Step 1: Раздел ТЗ (4.9)
Структура файла: функциональные требования — секция 4.x (4.1–4.8), поэтому Discovery добавлен подразделом **4.9** (перед «5. Спецификация пайплайна»), в стиле остальных разделов (`### **4.N. …**` + маркированный список `> *`). Покрыто по спеке:
- Подвкладка **«Поиск»** на экране «Каналы»; задача поиска: описание цели → генерация поисковых ключей ИИ (редактируются до старта) → **каскад фильтров** по нарастающей стоимости (поиск/дедупликация → «мы не состоим» → число участников → язык → оценка содержимого).
- **Глобальное правило «мы не состоим»** — безусловное, для всех задач и всех этапов (поиск → оценка → вступление), повторная проверка перед join'ом.
- **Метки**: «закрытая группа/канал», «форум», «не прочитано», «участники не подтверждены», «язык не подтверждён», «мало сообщений», «есть проходные темы»; «не подтверждено» = сигнал человеку, не пропуск.
- Оценка содержимого с **профилем задачи** (без карточек/обучения ML); каналы/открытые группы — выборка до sampleSize, доля ≥ порога; открытая группа без чтения — на рассмотрение с меткой.
- **Оценка по темам (форумы):** тема «подходит X из N», группа подходит при ≥1 проходной теме, превью тем; ≥3 содержательных сообщений для оценки, иначе метка «мало сообщений»; закрытые группы — сразу на рассмотрение с меткой.
- **Review/join/reject + чёрный список**: «Вступить и мониторить» (monitor=1 + догон ~10 сообщений), «Отклонить» → чёрный список (исключение во всех задачах, снимается вручную), закрытые группы — ссылка `t.me/<username>` + авто-замечание при синхронизации.
- **План задач и правило создания:** план 1–50; сумма планов активных задач (не `done/failed`) ≤ суточного лимита (по умолчанию 50); у активной задачи план не увеличить сверх свободного бюджета.
- **Авто-вступление и квоты:** `autoJoin` на задачу; случайная пауза **5070 с**, по одному действию; лимит **50 авто-вступлений/сутки общий на все задачи**; ручные — без квот; задача «выполнена» по плану, упор в бюджет — продолжение на следующий день.
- **Анти-бан (BanGuard):** мягкие паузы поиска/чтения, `FloodWaitError` → пауза + остановка авто-вступлений до следующего дня, общий «стоп-кран»; лимит/интервалы — настройки UI.
### Step 2: Сборка и рестарт
- `docker compose build app && docker compose up -d app` — образ `telbase-app` собран, контейнер `leadradar` пересоздан и поднят; `leadradar-ml`/`leadradar-minio` — running.
- `cd frontend && npm run build` — без ошибок.
- `python -m py_compile …` (все файлы брифa) — без ошибок.
## Вывод проверок
1. `docker compose build app && docker compose up -d app` → image built, контейнер Started; лог старта чистый (startup complete, без traceback).
2. `cd /c/telbase/frontend && npm run build``✓ built in 1.35s`, 42 modules transformed, ошибок нет (сборка в Dockerfile — та же: 42 modules).
3. `python -m py_compile backend/app/services/discovery.py discovery_eval.py discovery_worker.py ban_guard.py telegram.py backend/app/routers/discovery_routes.py settings_routes.py backend/app/main.py` → OK.
4. `GET /api/health`**200**.
5. `POST /api/auth/login` (admin/admin) → 200; `GET /api/settings`**200**, discovery-ключи присутствуют: `discJoinLimit: 50`, `discJoinDelayMin: 50`, `discJoinDelayMax: 70` (int-группа `_PUBLIC_INT`), `discPaused: false` (bool, отдаётся из `_PUBLIC_BOOL`); дополнительно `discEvalSample: 10`, `discEvalThreshold: 40`.
6. Smoke API: `GET /api/discovery/tasks` → 200 `{"items":[]}`, `GET /api/discovery/blacklist` → 200 `{"items":[]}` — роутер Discovery включён, БД-таблицы созданы.
## Осталось для ручной проверки (E2E из брифа Step 3 — нужен подключённый Telegram-аккаунт)
1. «Каналы → Поиск» → создать задачу (описание, план 3, авто-вступление выкл) → «Сгенерировать ключи» → запустить.
2. Дождаться кандидатов; убедиться, что текущие подписки и отклонённые не появляются (правило «мы не состоим» + чёрный список).
3. Открыть кандидата: метки, участники, fit «X из N», темы форума.
4. «Вступить и мониторить» → источник в «Каналах» (monitor on) и начинает давать карточки.
5. «Отклонить» → источник в чёрном списке; повторно не находится.
6. Включить авто-вступление: проверить паузы (≥50 с) и расход суточного лимита (50).
## Concerns
1. ТЗ-раздел написан кратко по спеке — детальные значения (таблицы полей задачи/БД, схемы API) сознательно не дублируются: в ТЗ это функциональный обзор, а точные контракты зафиксированы в дизайн-доке (ссылки на параметры `discJoinLimit`, `sampleSize`, `autoJoin` и т.п. даны).
2. «На рассмотрение»/вступление/чёрный список проверены только на уровне API-контракта (пустые списки 200) и логов запуска — поведение с живым Telegram-аккаунтом (поиск реально возвращает кандидатов, join проходит) остаётся за ручной проверкой пользователя.
3. `discPaused` — рантайм-настройка без дефолта в `DEFAULT_SETTINGS`; в GET /api/settings присутствует как `false` (bool), что подтверждено.
4. `progress.md` не трогался.
@@ -0,0 +1,33 @@
### 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')
"
```
---
@@ -0,0 +1,84 @@
# Task 2 — Отчёт: BanGuard (квоты, паузы, flood)
Статус: **DONE**
## Что сделано по шагам
### Step 1: Модуль `backend/app/services/ban_guard.py` (создан)
Реализован по интерфейс-спеке брифа (в брифе «дословного» кода нет — только сигнатуры
и поведение, см. Concerns #1):
- `joins_today_auto() -> int``count(*)` из `disc_log` по `event='join_auto'`
с `created_at >= начало текущих UTC-суток` (`datetime.now(timezone.utc)
.replace(hour=0,minute=0,second=0,microsecond=0)` → ms).
- `can_auto_join() -> bool``joins_today_auto() < discJoinLimit` И `not flood_today()`
И `not global_paused()`.
- `async wait_join_delay() -> None` — `asyncio.sleep(random.uniform(discJoinDelayMin,
discJoinDelayMax))` (значения из `store.get_setting`).
- `note_flood() -> None` — `store.set_setting("discFloodDay", <start_of_day_ms>)`.
- `flood_today() -> bool` — `discFloodDay == start_of_day_ms` (вчерашний флуд-день
автоматически «протухает» в полночь UTC).
- `global_paused() -> bool` / `set_global_pause(v: bool) -> None` — setting `discPaused`.
- `search_pause() -> float` — `random.uniform(2.0, 4.0)`.
Детали реализации:
- Хелпер `_start_of_day_ms()` общий для квоты, флуда и паузы.
- Ключи настроек вынесены в модульные константы (`_KEY_*`) — в коде нет «голых» строк.
- `discPaused`/`discFloodDay` не в `DEFAULT_SETTINGS` (рантайм-настройки): `get_setting`
возвращает `None`, который трактуется как «не взведено» (`False`/`0`).
- Стиль модуля — как в соседних сервисах: `from ..db import store`, русские докстринги,
`from __future__ import annotations`.
### Step 2: Проверка на временной БД в контейнере
Первая попытка упала: `ImportError: cannot import name 'ban_guard'` — код копируется в
образ при сборке (`backend/Dockerfile`: `COPY backend/app ./app`), исходники не
монтируются, а образ был собран до создания файла. Выполнена пересборка
`docker compose build app` (2.6s, слой кода — единственный не из кэша), затем команда
из брифа прошла (вывод ниже).
## Изменённые файлы
- `backend/app/services/ban_guard.py` (создан)
## Вывод проверок
### 1. `py_compile`
```
$ cd /c/telbase && python -m py_compile backend/app/services/ban_guard.py
COMPILE_OK # без ошибок
```
### 2. Временная БД в контейнере (команда из брифа Step 2)
```
$ docker compose build app # необходимо, т.к. код запекается в образ
$ 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')
"
BANGUARD OK
```
Все 4 assert'а из брифа прошли. ✔
### Дополнительно
- Статическая проверка (diagnostics Zed): ошибок и предупреждений нет.
## Concerns
1. **«Весь код — в брифе» — фактически неверно**: в `task-2-brief.md` (и в плане)
нет ни одного блока кода модуля, только интерфейс-спека (~10 сигнатур с описанием
поведения) и команда проверки. «Транскрибировать дословно» было нечего; модуль
реализован по спеке. Очевидных ошибок/противоречий в спеке не нашёл — править было
нечего. Если планировался эталонный код — его нужно добавить в бриф.
2. `wait_join_delay()` и `search_pause()` не покрыты проверкой Step 2 (оба — случайные
паузы; дефолт паузы вступления 50–70 сек, поэтому в проверку они не входили).
Логика тривиальная (`random.uniform` + `asyncio.sleep`), но «живого» прогона нет.
3. `can_auto_join()` при `discJoinLimit <= 0` всегда `False` (осторожная сторона:
лимит 0 = «не вступать»).
4. Клампы пауз (5..600) независимы — `min > max` теоретически возможно через UI
(унаследованный concern из Task 1; в `wait_join_delay` тогда диапазон
«вывернется», но не упадёт). Перекрёстную проверку бриф не требовал.
5. Для проверок последующих задач нужна пересборка образа после каждого изменения
backend-кода (образ не монтирует исходники).
@@ -0,0 +1,29 @@
### 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.
---
@@ -0,0 +1,111 @@
# Task 3 — Отчёт: Хранилище Discovery (задачи/кандидаты/чёрный список/лог)
## Статус
✅ Реализовано и проверено (py_compile + сценарий на временной БД в контейнере).
## Файлы
- Создан: `backend/app/services/discovery.py` — все функции из брифа (см. «Interfaces» брифа), синхронные, поверх `store` из `db.py`.
## Что сделано
### Step 1: Модуль `backend/app/services/discovery.py`
Полный набор интерфейсов брифа:
- **Задачи**: `list_tasks()`, `get_task(id)`, `create_task(payload)`, `patch_task(id, patch)`, `delete_task(id)` (задача + её кандидаты + лог), `start_task(id)`, `pause_task(id)`.
- **Бюджет**: `sum(plan_joins)` задач со статусом `NOT IN ('done','failed')` + новая/увеличенная `plan_joins <= discJoinLimit`; иначе `ValueError` (сообщение с занятой суммой и лимитом). Дополнительно `plan_joins` ограничен `1..discJoinLimit`. Проверка при увеличении `plan_joins` в `patch_task` — с исключением самой задачи из суммы.
- **Кандидаты**: `list_candidates(task_id, status)`, `add_candidate(...)` (None + лог `skip`, если источник в `dialogs`/`disc_blacklist`/уже есть в `new|review|joined`; успешное добавление инкрементит `found`), `set_candidate(task_id, dialog_id, patch)`, `set_candidate_status(dialog_id, status)`, `delete_candidate(dialog_id)`, `bump_counter(task_id, field, n)` (found/evaluated/joined/rejected), `advance_search(task_id)`.
- **Переходы**: `mark_joined(dialog_id, auto)``joined` + `bump_counter('joined')` + лог `join_auto`/`join_manual` (идемпотентно); `mark_rejected(dialog_id, reason="")``rejected` + счётчик + лог `reject` + `add_blacklist`; при статусе `joined``ValueError` (для 400 в Task 7).
- **Чёрный список / лог**: `add_blacklist`, `remove_blacklist`, `list_blacklist`, `add_log`, `task_log(limit=100)`.
Ключевые решения (задокументированы в докстринге модуля):
- Все обращения к БД — `store.*` с параметрами; JSON-поля `keywords/marks/topics``json.dumps(..., ensure_ascii=False)`/`json.loads`.
- Время — `time.time_ns() // 1_000_000`; id — `store.uid('dt_'/'dl_')`.
- Наружные dict-ы — **camelCase** (конвенция границы API проекта, как `projects.py`/`leads.py`): `planJoins`, `sampleSize`, `minSubscribers`, `autoJoin`, `searchIdx`, `searchDone`, `fitRatio`, `autoJoined`, `langRu`, `dialogId``create_task`/`patch_task` на входе принимают и camelCase, и snake_case (нормализация к колонкам БД), поэтому Task 7 может передавать `TaskCreate.model_dump()` напрямую.
- `set_candidate_status` разрешает `new/review` (лог `review`); `joined/rejected` — только через `mark_joined`/`mark_rejected` (там счётчики/чёрный список/лог).
- `start_task`: keywords непустые (иначе `ValueError`); из `done/failed` — сброс прогресса поиска, из `paused` — продолжение без сброса.
- `advance_search`: `search_idx += 1`; `search_idx >= len(keywords)``search_done = True`.
- `delete_candidate` — идемпотентная (skip-ветки воркера); перезапись «устаревшего» rejected-кандидата новым при `add_candidate` (после `remove_blacklist`), т.к. `dialog_id` — PK.
### Step 2: Проверка на временной БД в контейнере
Код запекается в образ, поэтому перед прогоном: `docker compose build app` (кэш — сборка ~3 c).
Команда:
```
MSYS_NO_PATHCONV=1 docker compose run --rm --no-deps -e PYTHONPATH=/srv \
-e LEADRADAR_DATA=/tmp/lr_disc --entrypoint python app /data/task3_check.py
```
Сценарий (текст; временный файл `data/task3_check.py`, смонтирован в контейнер как `/data/task3_check.py`; после прогона удалён):
```python
from app.db import store
from app.services import discovery as d
store.init()
# ── 1. Бюджет plan_joins: 25 ок; 30 поверх 25 -> ValueError (лимит 50) ──────
t1 = d.create_task({"name": "Задача A", "planJoins": 25, "keywords": ["fl", "market", "python"]})
assert t1["planJoins"] == 25 and t1["status"] == "draft" and isinstance(t1["keywords"], list)
try:
d.create_task({"name": "Задача B", "planJoins": 30})
raise AssertionError("ожидался ValueError по бюджету")
except ValueError as exc:
assert "исчерпан" in str(exc), exc
assert len(d.list_tasks()) == 1
# ── 2. add_candidate: источник уже в dialogs -> None + лог skip ─────────────
store.execute(
"INSERT INTO dialogs(id, name, handle, kind, hue, updated_at) VALUES (?, ?, '', 'чат', '#666', ?)",
["src_we_are_in", "Уже наш канал", 1],
)
assert d.add_candidate(t1["id"], "src_we_are_in", "Уже наш канал", "our_ch", "channel", "#666") is None
skip_log = [r for r in d.task_log(t1["id"]) if r["event"] == "skip"]
assert any("уже мониторится" in r["text"] for r in skip_log), d.task_log(t1["id"])
assert d.get_task(t1["id"])["found"] == 0 # skip не считается найденным
# ── 3. mark_rejected -> чёрный список; повторный add_candidate -> None ──────
cand = d.add_candidate(t1["id"], "ch_bad", "Плохой канал", "bad_ch", "channel", "#f00")
assert cand is not None and cand["status"] == "new"
assert d.get_task(t1["id"])["found"] == 1
rej = d.mark_rejected("ch_bad", "спам")
assert rej["status"] == "rejected"
assert any(b["dialogId"] == "ch_bad" for b in d.list_blacklist()), d.list_blacklist()
assert d.get_task(t1["id"])["rejected"] == 1
assert d.add_candidate(t1["id"], "ch_bad", "Плохой канал", "bad_ch", "channel", "#f00") is None
skip2 = [r for r in d.task_log(t1["id"]) if r["event"] == "skip"]
assert any("чёрном списке" in r["text"] for r in skip2), d.task_log(t1["id"])
# ── 4. advance_search до конца ключей -> searchDone=True ────────────────────
t = d.get_task(t1["id"])
assert t["searchIdx"] == 0 and t["searchDone"] is False
for _ in range(3):
d.advance_search(t1["id"])
t = d.get_task(t1["id"])
assert t["searchIdx"] == 3 and t["searchDone"] is True, t
# ── доп. проверки целостности интерфейсов ───────────────────────────────────
assert d.patch_task(t1["id"], {"minSubscribers": 500, "autoJoin": True})["minSubscribers"] == 500
assert d.list_candidates(t1["id"], status="rejected")[0]["dialogId"] == "ch_bad"
good = d.add_candidate(t1["id"], "ch_good", "Хор канал", "good_ch", "channel", "#0f0")
assert good is not None
assert d.mark_joined(good["dialogId"], auto=False)["status"] == "joined"
assert d.get_task(t1["id"])["joined"] == 1
assert [r["event"] for r in d.task_log(t1["id"])].count("join_manual") == 1
print("DISCOVERY OK")
```
## Вывод проверок
1. `cd /c/telbase && python -m py_compile backend/app/services/discovery.py``PY_COMPILE OK` (без ошибок).
2. Сценарий в контейнере на временной БД (`LEADRADAR_DATA=/tmp/lr_disc`, одноразовый контейнер `--rm --no-deps`) → `DISCOVERY OK`:
- задача `planJoins=25` создана; вторая с `planJoins=30``ValueError` («Бюджет авто-вступлений исчерпан…»);
- источник, вставленный в `dialogs`, → `add_candidate` вернул `None`, в `task_log` событие `skip` «уже мониторится», `found` не увеличен;
- `mark_rejected` → статус `rejected`, кандидат в `list_blacklist()`, счётчик `rejected=1`; повторный `add_candidate` того же источника → `None` + лог `skip` «в чёрном списке»;
- `advance_search` ×3 (3 ключа) → `searchIdx=3`, `searchDone=True`;
- доп.: `patch_task` (minSubscribers/autoJoin), `list_candidates(status=…)`, `mark_joined(auto=False)``joined=1` + лог `join_manual`.
3. Диагностика файла — без ошибок и предупреждений.
## Concerns
1. **Конвенция ключей**: наружные dict-ы модуля — camelCase (не snake-колонки). Task 7 (роутер) может возвращать их как есть и передавать в `create/patch` `model_dump()` Pydantic-моделей; Task 6 (воркер) при работе с задачами/кандидатами должен использовать camelCase-ключи (`task["planJoins"]`, `c["fitRatio"]` и т.п.). Контракт зафиксирован в докстринге модуля.
2. `set_candidate_status` ограничен `new/review``joined/rejected` только через `mark_joined`/`mark_rejected` (иначе разъезжаются счётчики/чёрный список/лог). Если в Task 6/7 понадобится «сырой» перевод — расширить функцию осознанно.
3. `delete_task` дополнительно чистит `disc_log` задачи (в брифе — «задачу и её кандидатов»); чёрный список общий и не трогается.
4. `add_candidate` инкрементит `found` только при успешном добавлении (skip-источники не считаются найденными).
5. Для прогонов в контейнере нужен `docker compose build app` (код запекается в образ) и `MSYS_NO_PATHCONV=1` на Git Bash (иначе аргументы `/data/…` и `/tmp/…` конвертируются в Windows-пути).
@@ -0,0 +1,19 @@
### 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).
---
@@ -0,0 +1,202 @@
# Task 4 — Отчёт: Telegram-действия поиска (методы TelegramManager)
Статус: **DONE**
## Что сделано по шагам
### Step 1: Методы в `TelegramManager` (`backend/app/services/telegram.py`)
Добавлены в конец класса (секция `# ── discovery ...`), рядом с `dialog_messages`:
- `discovery_search(q, limit=30) -> list[dict]``client(functions.contacts.SearchRequest(...))`;
после запроса `await asyncio.sleep(ban_guard.search_pause())` (пауза «между поисками»).
Возвращает `[{id, name, username, kind, hue}]`:
- `id`**подписанный** peer id (`utils.get_peer_id(entity)`), т.е. формат совпадает с
`str(dlg.id)` в таблице `dialogs` (каналы `-100…`, базовые группы `-id`, люди `+id`).
Это нужно для глобального правила «мы не состоим» (сравнение с `dialogs`).
- `name``utils.get_display_name(entity)` (как `Dialog.name`), `kind``_kind_of`, `hue``dialog_hue`.
- Результаты дедуплицируются по `id`.
- `discovery_info(dialog_id) -> dict``{id, name, username, kind, hue, participants, is_forum}`:
участники из `full_chat` (`GetFullChannelRequest` для каналов/супергрупп,
`GetFullChatRequest` для базовых групп); при любой ошибке/недоступности — `participants=None`,
исключение наружу не бросается.
- `discovery_read(dialog_id, limit) -> dict``{"ok", "error", "messages":[{id, text, date_ms, topic_id, topic_title}]}`;
для форумов — выборка по активным темам (`channels.getForumTopics` + чтение каждой темы),
плоский список с `topic_id`/`topic_title`; для обычных источников оба поля = `None`.
История недоступна → `{"ok": False, "error": "no_history", "messages": []}`. Сообщения без
текста (медиа/сервисные) пропускаются (как в `dialog_messages`/backfill). `limit <= 0` → пустой
`ok` без сетевых вызовов. (правка тем форума — в «Fix round 1»)
- `discovery_join(username) -> None``get_entity(username)` + `JoinChannelRequest`;
`FloodWaitError``ban_guard.note_flood()` + `raise`. Пауза/квоты НЕ внутри — ручной join из API
вне квот, `wait_join_delay()` перед авто-вступлением вызывает воркер (Task 6).
Пустой username → `ValueError`. (правка — в «Fix round 1»)
- `discovery_leave(dialog_id) -> None``LeaveChannelRequest`.
- `add_dialog_monitored(dialog_id, name, username, kind, hue) -> None` — синхронный upsert в `dialogs`
(`monitor=TRUE, backfilled=FALSE`, `ON CONFLICT DO UPDATE` — по образцу `set_monitor`/`_persist_dialogs`)
+ `_reload_monitored()`. Без авто-логики (никакого `_spawn(backfill)` — как в брифе).
Импорты: `from telethon import ..., utils`, `FloodWaitError` (`telethon.errors.rpcerrorlist`),
`functions` (`telethon.tl`), `from . import ban_guard`. `progress.md` не трогал.
### Step 2: Проверка
Выполнена (см. ниже). Живых Telegram-вызовов не делалось (E2E — Task 10).
## Изменённые файлы
- `backend/app/services/telegram.py` (импорты + 6 методов класса `TelegramManager`)
## Вывод проверок
```
$ cd /c/telbase && python -m py_compile backend/app/services/telegram.py backend/app/services/ban_guard.py
PY_COMPILE_OK # без ошибок
```
Дополнительно (без сети, на временной БД `LEADRADAR_DATA=/tmp/lr_t4*`):
- импорт `from app.services import telegram, ban_guard``IMPORT_OK`, методы присутствуют;
- офлайн-сценарии: `discovery_search` без клиента → `RuntimeError`; `discovery_info` без клиента →
словарь с `participants=None, is_forum=False`; `discovery_read``no_history``ok=True` при `limit<=0`);
`discovery_join('')``ValueError`; `discovery_leave` без клиента → `RuntimeError`;
`add_dialog_monitored` → строка `monitor=TRUE, backfilled=FALSE` в `dialogs` + `_monitored` обновлён;
`dialog_messages` (существующий метод) не сломан — `OFFLINE_OK`.
## Concerns
### Доступность полей Telethon (проверено интроспекцией установленного telethon==1.37.0)
- **participants**: авторитетный источник — `ChannelFull.participants_count` из
`channels.GetFullChannelRequest` (каналы и супергруппы; работает для публичных каналов и без
вступления). У самого `Channel` тоже есть `participants_count: Optional[int]`, но он не гарантирован,
поэтому в коде берём полный чат. Для базовой группы `ChatFull.participants` имеет тип
`ChatParticipants | ChatParticipantsForbidden` — считаем `len(participants.participants)`;
`Forbidden`/ошибка → `None`. Для «чатов»-людей участников нет → `None`. Любая ошибка (приватный
канал/группа без членства) → `None` по брифу. Такие кандидаты получат метку
«участники не подтверждены» (Task 6) вместо пропуска.
- **is_forum**: берётся с entity — `Channel.forum: Optional[bool]` (флаг конструктора `channel`).
⚠️ У `ChannelFull` поля `forum` НЕТ (есть только `view_forum_as_messages` — личная настройка
просмотра, не признак форума). Fallback: если entity пришло в min-форме без флага — `False`.
- **topic_id / темы форума**: `Message.reply_to``MessageReplyHeader.reply_to_top_id` (в 1.37 поле
есть), но для раскладки «по активным темам» (спека §6) плоской ленты недостаточно — чтение по
темам через `channels.GetForumTopicsRequest` реализовано в Fix round 1 (см. ниже).
### Прочее
1. **Кэш entity из поиска**: `discovery_search` делает `client.session.process_entities(found)`
иначе `discovery_info/read` по `dialog_id` не смогут резолвить кандидата до вступления (нет в
dialogs). Запись идёт в файл сессии Telethon, работает между вызовами и после рестарта.
2. **`discovery_join` и ручной join (Task 7)** — **закрыто в Fix round 1**: пауза убрана из
`discovery_join` (ручной join — вне квот); `wait_join_delay()` перед авто-вступлением будет
вызывать воркер (Task 6).
3. **`add_dialog_monitored` не запускает backfill**: по брифу авто-логики нет (в отличие от
`set_monitor`, где `_spawn(backfill_dialog)`). Строка остаётся `backfilled=FALSE`, и разбор
последних ~10 сообщений подхватит обычный механизм при первом подключении/перечитывании;
если нужен немедленный backfill после вступления — воркеру Task 6 стоит вызвать
`tg.backfill_dialog(dialog_id)` явно (спека §7).
4. **Ошибки поиска**: пауза стоит после успешного `contacts.search`; ошибки запроса (в т.ч.
`FloodWaitError`) пробрасываются без `note_flood` (бриф связывает flood-обработку только с
join). Воркеру Task 6 нужно ловить RPC-ошибки поиска и логировать (события `flood`/`error`).
5. **Юзеры в выдаче поиска**: `contacts.search` возвращает и людей (`_kind_of` → «чат»). По брифу
не фильтровал; такие кандидаты обычно отсеиваются на оценке (история недоступна/мало
сообщений) — при желании Task 6 может отфильтровать их раньше.
6. **Pyright-«шум»** в новых методах (`Entity | List[Entity]` в `JoinChannelRequest`, отсутствие
`process_entities` в стабах сессии и т.п.) — тот же класс предупреждений, что и в существующем
коде (`backfill_dialog`, `dialog_messages`); на рантайм не влияет, код следует стилю файла.
---
## Fix round 1
Правки по итогам ревью (только `backend/app/services/telegram.py`).
### 1) Пауза убрана из `discovery_join`
- Удалён `await ban_guard.wait_join_delay()` из метода: ручной join из API (Task 7) — вне квот/пауз.
- Внутри осталась только обработка `FloodWaitError``ban_guard.note_flood()` + `raise`.
- Паузу перед авто-вступлением теперь вызывает воркер (Task 6): `await ban_guard.wait_join_delay()`
непосредственно перед `tg.discovery_join(...)`. `ban_guard` в файле по-прежнему используется
(`search_pause` в `discovery_search`, `note_flood` в `discovery_join`).
### 2) Форумные темы в `discovery_read`
- Определение форума — `entity.forum`.
- Если forum: `functions.channels.GetForumTopicsRequest(channel=entity, offset_date=0,
offset_id=0, offset_topic=0, limit=5)` → `topics`; для каждого topic читается до
`max(1, limit // len(topics))` последних сообщений через `client.get_messages(entity, limit=n,
reply_to=topic.id)` (в 1.37 это `messages.GetRepliesRequest` — см. Concerns).
- Возвращается плоский список `{id, text, date_ms, topic_id, topic_title}` (`topic_id=topic.id`,
`topic_title=topic.title`); для non-forum оба поля `None` (topic_id из `reply_to_top_id` больше
не берётся — контракт брифа).
- Безопасность: исключения в темах не пробрасываются — тема, которая не прочиталась,
пропускается; если не собрано ни одного сообщения тем — fallback на обычное чтение ленты
(General). Полный отказ и обычного чтения → `ok=False, error="no_history"`. Формат ответа
сохранён: `{"ok", "error", "messages"}`.
- Добавлены приватные хелперы: `_read_forum_topics(...)` (чтение тем) и
`_discovery_message_item(...)` (общий фильтр непустого текста + сборка item для ленты и тем).
### Проверка Fix round 1
```
$ cd /c/telbase && python -m py_compile backend/app/services/telegram.py
PY_COMPILE_OK
```
Офлайн-смоук без сети (`LEADRADAR_DATA=/tmp/lr_t4e`): `discovery_read` без клиента → `no_history`,
`limit<=0` → пустой `ok`; `discovery_join('')` → `ValueError`; `_discovery_message_item` фильтрует
пустой текст и собирает поля `topic_id`/`topic_title` — `SMOKE_OK`. Живых Telegram-вызовов нет.
### Concerns (Fix round 1)
1. Сигнатура `GetForumTopicsRequest` в 1.37: параметр называется `channel` (не `peer`), а
параметра `offset` нет (есть `offset_date/offset_id/offset_topic`); вызываем с реальными именами.
2. Поле темы в 1.37 — `ForumTopic.title` (не `top_title`); берём `topic.title`.
3. `messages.GetHistoryRequest` в 1.37 не имеет `top_msg_id`; `client.get_messages(...,
reply_to=topic_id)` реализован через `messages.GetRepliesRequest(peer, msg_id=topic_id)` — в
Telegram сообщения темы форума являются «ответами» на её стартовое сообщение, поэтому это
корректный способ чтения темы. Ручной fallback через `GetHistoryRequest(top_msg_id=…)` в 1.37
невозможен; при ошибке чтения темы — пропуск темы + (при пустом результате) обычная лента.
4. Поведение чтения тем (полнота выборки, название темы для не-участника форума) проверить на
живом аккаунте в E2E (Task 10).
---
## Fix round 2
Правка по замечанию ревью (только `backend/app/services/telegram.py`).
### Изменение
- В `_read_forum_topics` размер выборки на тему изменён с `max(1, limit // len(topics))`
на `min(max(3, math.ceil(limit / len(topics))), 10)`:
- минимум **3** сообщения на тему — иначе типичная тема не набирает порог
«мало сообщений» (Task 5: `passed` требует ≥3 содержательных) и многотемные
форумы почти всегда отсеивались бы как «мало подходящих»;
- `ceil` вместо целочисленного деления — при `limit=10` и 5 темах теперь 3, а не 2;
- cap **10** — не выкачиваем больше десятка на тему (sample_size ограничен 3..30,
чтение по темам и так дороже плоской ленты).
- Добавлен `import math`. Поведение без тем/с ошибками не менялось: пустой список тем и
исключения по-прежнему ведут к fallback на обычное чтение ленты (General); суммарная
выборка может слегка превышать `limit` — осознанно для форумов.
Примеры расчёта: limit=10/5 тем → 3 на тему; limit=10/1 тема → 10; limit=30/5 тем → 6;
limit=30/10 тем → 3.
### Проверка
```
$ cd /c/telbase && python -m py_compile backend/app/services/telegram.py
PY_COMPILE_OK # без ошибок
```
### Re-review (scoped, по текущему коду `_read_forum_topics`)
Вердикт: **ADDRESSED** — новых Critical/Important в фиксе нет.
1. **Формула и cap корректны** (L784 `min(max(3, math.ceil(limit / len(topics))), 10)`):
- limit=10 / 5 тем: `ceil(10/5)=2` → `max(3,2)=3` → `min(3,10)=3` ✓
- limit=30 / 10 тем: `ceil(3)=3` → 3 ✓
- limit=30 / 5 тем: `ceil(6)=6` → 6 ✓
Нижняя граница (3) и cap (10) на месте; `ceil` даёт int, деления на ноль нет —
`if not topics: return out` (L780–781) стоит до расчёта.
2. **Fallback и обработка ошибок не сломаны**: пустой список тем и исключение
`getForumTopicsRequest` → `return out` → в `discovery_read` пустой результат ведёт к
обычной ленте (General) (L746747); ошибка чтения отдельной темы → `continue`
(L793795); сам вызов `_read_forum_topics` дополнительно обёрнут catch-all (L743745).
Строки fallback-путей не менялись.
3. **Новых проблем в фрагменте нет**: `import math` не конфликтует (имя `math` в модуле
ничем не перекрыто); остальные строки хелпера не изменены. Комментарий (L782–783)
соответствует поведению.
Не-блокирующие наблюдения (вне объёма фикса, поведение осознанное):
- `GetForumTopicsRequest` имеет `limit=5`, поэтому фактически `len(topics) ≤ 5` — случай
«30/10 тем» сейчас недостижим, но формула корректно его обработает, если лимит выдачи
тем вырастет.
- Пол «минимум 3» может заметно превышать маленький `limit` (напр. limit=3 при 5 темах →
до 15 сообщений вместо 3) — заявлено в комментарии как осознанное; при рабочих
`sample_size` 3..30 деградации нет.
@@ -0,0 +1,24 @@
### 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 по ключу.
---
@@ -0,0 +1,42 @@
# Task 5 — Отчёт: Оценка контента (язык, темы, fit по профилю задачи)
## Статус
✅ Реализовано и проверено (py_compile + офлайн-сценарий на временной БД в контейнере).
## Файлы
- Создан: `backend/app/services/discovery_eval.py` — чистая логика оценки (без карточек/очередей/обучения), поверх `store`, `ml_client`, `ai_service.chat_json`, `pipeline.clean_short`.
## Что сделано
### Интерфейсы брифа
- `detect_lang_ru(texts)` — суммарная доля кириллицы (блок U+0400–U+04FF) среди всех `str.isalpha()`-букв выборки: `>=0.15 → True`, `<=0.03 → False`, между порогами или 0 букв → `None`.
- `group_by_topic(messages)` — группировка по `topic_id` (`None → "main"`); `[{"topic_id", "title", "messages": [...]}]`; title — сниппет первого непустого текста темы (≤60 симв., whitespace схлопнут); группы отсортированы по числу сообщений (убыв.), порядок сообщений внутри группы — входной (хронологический из `discovery_read`).
- `evaluate_message(task, text)` async — каскад:
1. текст пустой/`len(strip) < 10``{"fit": False, "reason": "слишком короткое", "source": "heuristic"}`;
2. ML: `ml_client.is_enabled()``predict(text)`; `take` и `label=='spam'``False`, reason «ML: спам», `source: "ml"`; иначе ниже;
3. ИИ: если `aiEnabled` **и** доступен ключ/локальный провайдер (см. Concerns) — один `ai_service.chat_json(промпт, user="Сообщение:\n"+text[:4000])`; `{fit, reason}` из ответа; любая ошибка → шаг 4;
4. эвристика: любой ключ входит в `clean_short(text).casefold()`; reason `совпал ключ "…"` / `нет совпадений с ключами`, `source: "heuristic"`.
- `evaluate_sample(task, messages)` async — последовательно по сообщениям; `{"fit_count", "total", "fit_ratio", "per_message": [{"text", "fit", "reason", "topic_id"}]}`; `topic_id` в per_message нормализован `None → "main"` (стыкуется с ключами `group_by_topic`).
- `passed(ev, task)``ev["total"] >= 3` и `ev["fit_ratio"]*100 >= task["threshold"]`.
- Промпт ИИ — константа `_AI_PROMPT` точно по тексту брифа (description/keywords подставляются через `.format`).
## Вывод проверок
1. `cd /c/telbase && python -m py_compile backend/app/services/discovery_eval.py``PY_COMPILE OK` (без ошибок).
2. Сборка и офлайн-прогон на временной БД:
- `docker compose build app` → образ собран;
- `MSYS_NO_PATHCONV=1 docker compose run --rm --no-deps -e PYTHONPATH=/srv -e LEADRADAR_DATA=/tmp/lr_eval --entrypoint python app /data/task5_check.py``DISCOVERY_EVAL OK`:
- `detect_lang_ru(["Ищем python разработчика"]) is True`; `detect_lang_ru(["we need a python developer"]) is False`; смесь 1/20 кириллицы → `None`; текст без букв → `None`;
- `group_by_topic`: темы `main`/111/222, объединение 2 сообщений в 111, сортировка `[111, main, 222]`, title «Топик A первый» (≤60), входной порядок внутри группы сохранён;
- `evaluate_message` на задаче без ИИ/ML (`aiEnabled=False`, `mlEnabled=False`) → эвристика: `{"fit": True, "reason": "совпал ключ "python"", "source": "heuristic"}`; без ключа → False «нет совпадений с ключами»; «короче» → False «слишком короткое»;
- `aiEnabled=True` без ключа провайдера → ИИ-ветка не вызывается (без сети), отвечает эвристика;
- `evaluate_sample`: total=4/fit=2/ratio=0.5, per_message topic_id `["main","main",5,5]`; `passed`: threshold 40 → True, 60 → False, выборка из 2 → False, пустая → ratio 0.0/False.
- Временный файл `data/task5_check.py` удалён после прогона.
3. Диагностика файла — без ошибок и предупреждений.
## Concerns
1. **`source` для слишком коротких сообщений** — `"heuristic"`: это первая ступень каскада (до ML/ИИ), но `source` по брифу — объединение `heuristic|ml|ai`, отдельного значения нет. Воркеру (Task 6) это не мешает; при необходимости подсчёта «отсевов по длине» лучше ориентироваться на `reason`.
2. **ИИ-ветка дополнительно проверяет наличие ключа** (`ai_service.provider_status()`: `keySet` или `local`-провайдер), а не только `aiEnabled` — иначе `chat_json` при отсутствии ключа тратит ~6 c на ретраи и сыплет warning в лог на каждое сообщение. При любой ошибке/недоступности статуса — всё равно fallback на эвристику (как и требует бриф).
3. **`topic_id` в `per_message` нормализован** `None → "main"`, чтобы результат `evaluate_sample` стыковался с ключами `group_by_topic`. Task 6 при подсчёте «подходит X из N» по темам форума должен сравнивать ключ `"main"`, а не `None`.
4. **`title` темы** — сниппет первого **непустого** текста темы (первого во входном порядке), а не обязательно первого сообщения; `topic_title` из сообщений не используется (в `discovery_read` Task 4 его и нет). Если позже понадобится название темы из Telegram — добавить как fallback для пустых текстов.
5. **Лимиты на границе**: тексту в ИИ обрезается до 4000 симв. (как в `ai.filter_incoming`), причина из ИИ — до 200 симв.; эвристика и длина считаются по полному тексту.
6. Решения оценки нигде не логируются и не учитываются в счётчиках `ml_client.track_decisions` — модуль чистый; учёт/логирование при необходимости добавить в Task 6 на уровне воркера.
@@ -0,0 +1,28 @@
### 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 вручную.
---
@@ -0,0 +1,60 @@
# Task 6 — Отчёт: воркер Discovery (поиск → оценка → авто-вступление)
## Статус
✅ Реализовано и проверено (py_compile + сборка образа + офлайн-прогон на временной БД с моками Telegram/пауз).
## Файлы
- Создан: `backend/app/services/discovery_worker.py``async def tick() -> dict` + шаги.
- Изменён: `backend/app/main.py` — фоновый цикл `_discovery_loop` (каждые 5 c: `await tick()`, исключения — `log.exception`) и запуск в lifespan в списке задач рядом с `_pipeline_loop`.
## Что сделано
### Структура `tick()` (одно действие за вызов, возврат `{"action": ..., "taskId": ...}`)
Приоритеты (как в брифе + пожелание про стоп-кран):
0. `ban_guard.global_paused()` → сразу `{"action": "none"}` (стоп-кран останавливает весь tick).
1. **План выполнен** (`joined >= planJoins` у любой running-задачи) → `status='done'` + лог `done` («план выполнен: вступили X из Y»). Идёт ДО поиска/оценки/join, чтобы задачу с выполненным планом не продолжать обрабатывать (и чтобы освободился бюджет планов).
2. **Шаг поиска**: первая running-задача с `searchDone=False` → ключ `keywords[searchIdx]``tg.discovery_search` → каждый результат `discovery.add_candidate` (kind нормализуется: `канал/группа/чат → channel/group/forum`) → `advance_search` → при переходе в `searchDone` лог `search` «поиск завершён: N кандидатов» (N — счётчик `found`). Паузы между поисками — внутри `discovery_search`. Пустые/съехавшие ключи закрываются без сетевого вызова.
3. **Шаг оценки**: первый кандидат `status='new'`:
- `discovery_info``participants`, `kind` (`forum`, если `is_forum`; иначе маппинг RU-kind), обновляются name/username/hue;
- `minSubscribers`>0: participants меньше → `delete_candidate` + лог `skip` «мало участников (X < min)»; participants не получены → метка «участники не подтверждены»;
- `discovery_read`: `ok=False``review` + метка «канал: история недоступна» (channel) или «закрытая группа (история скрыта) — вступите сами» (group/forum); контент не оценивается, для ru-задач добавляется метка «язык не подтверждён»;
- язык (только ru-задачи): `False``delete_candidate` + skip «язык не русский»; `None` → метка «язык не подтверждён»;
- объём: содержательных <3 → `review` + метка «мало сообщений» (решает человек);
- контент: не-форум — `evaluate_sample` по всей выборке, `fitRatio` общий; форум — `group_by_topic`, `evaluate_sample` по каждой теме, заполняется `topics` (`{topicId, title, fitCount, total, fitRatio, passed}`), вердикт — есть ≥1 проходная тема, `fitRatio` — агрегат fit из N по всей выборке; `passed()``review` (метки/fitRatio/topics), иначе `delete_candidate` + skip «мало подходящих (X из N)»;
- `bump_counter(evaluated)` при выходе кандидата из `new` (review или delete).
4. **Авто-вступление** (отдельный проход, приоритет ниже оценки): задача `autoJoin=True` + кандидат `review` + `ban_guard.can_auto_join()` (иначе `none`):
- повторная проверка «мы не состоим» прямым SQL по `dialogs`/`disc_blacklist` (интерфейсы Task 3 не менялись) — если уже вступили/в чёрном списке → `mark_rejected` + лог `reject`, action `reject`;
- `await ban_guard.wait_join_delay()` (5070 с);
- `tg.discovery_join(username)``mark_joined(auto=True)``tg.add_dialog_monitored(dialogId, name, username, kind, hue)`;
- `FloodWaitError``ban_guard.note_flood()` (идемпотентно) + лог `flood`; прочие ошибки → лог `error` (кандидат остаётся `review` для ретрая).
Возвращаемые action: `search|review|skip|join|reject|flood|error|done|none`. Метки/topics-контракт продублирован в docstring модуля.
### Контракт меток и тем (в docstring `discovery_worker.py`)
- `marks` — список строк: «участники не подтверждены», «язык не подтверждён», «канал: история недоступна», «закрытая группа (история скрыта) — вступите сами», «мало сообщений».
- `topics` — список dict для форумов: `{"topicId": str|int, "title": str, "fitCount": int, "total": int, "fitRatio": float, "passed": bool}`. Общий вердикт форума — есть хотя бы одна проходная тема; `fitRatio` кандидата — агрегат по всей выборке; для не-форумов `topics` не заполняется.
## Вывод проверок
1. `cd /c/telbase && python -m py_compile backend/app/services/discovery_worker.py backend/app/main.py``PY_COMPILE_OK` (без ошибок).
2. `docker compose build app``Image telbase-app Built` (3 c, кэш).
3. Офлайн-сценарий в контейнере на временной БД (`LEADRADAR_DATA=/tmp/lr_w6`, `MSYS_NO_PATHCONV=1`, `--entrypoint sh`), Telegram-методы и `wait_join_delay` замоканы, оценка — реальная (эвристика: `aiEnabled/mlEnabled=False`):
```
SEARCH_OK # пустая система → none; поиск: кандидат добавлен, «уже мониторится» пропущен, «поиск завершён: 1 кандидатов»
EVAL_REVIEW_OK # оценка: review, fitRatio 0.75, langRu=True
EVAL_LANG_SKIP_OK # язык не русский → delete + skip
EVAL_FORUM_OK # форум: kind=forum, topics по темам (passed/нет), fitRatio агрегат
EVAL_FEW_OK # <3 сообщений → review + «мало сообщений»
EVAL_NO_HISTORY_OK # история недоступна → review + «канал: история недоступна» + «язык не подтверждён»
AUTOJOIN_DONE_OK # join → joined/autoJoined + dialogs(monitor) + join_auto; план → done + лог done
REJECT_RECHECK_OK # «вступили между оценкой и join» → mark_rejected + reject (без join)
TASK6_OFFLINE_OK
```
4. Диагностика `discovery_worker.py` — без ошибок и предупреждений (ruff I/SIM/default — чисто; импорты/`_we_are_in`/`suppress` приведены к правилам).
## Concerns
1. Полный прогон на живом аккаунте — Task 10 вручную. Ветки `flood` и `error` (реальные FloodWaitError/сетевые ошибки join) офлайн не воспроизводятся — только код-ревью и логика Task 4 (`discovery_join` сам фиксирует flood и пробрасывает).
2. Ветка «повторная проверка перед join» использует `mark_rejected`, который по контракту Task 3 добавляет источник в `disc_blacklist` — даже когда «уже вступили между оценкой и join». Для поиска это безвредно (источник и так отсекается по `dialogs`), но чёрный список формально пополняется. Если это нежелательно — можно ввести отдельный helper (интерфейсы Task 3 не менялись).
3. `minSubscribers`-ветка «участники не подтверждены» помечает кандидата только когда минимум задан (`minSubscribers>0`); при `min=0` отсутствие participants не метка (участники не критерий).
4. Стоп-кран `discPaused` (`ban_guard.global_paused()`) останавливает весь tick — включая поиск и оценку, не только авто-join (по требованию задания).
5. Running-задача без работы (поиск завершён, кандидатов нет, `autoJoin=False`) остаётся running и даёт `{"action":"none"}` каждые 5 c — завершение/удаление такой задачи за пользователем (по брифу).
6. `fitRatio` форума — агрегат по всей выборке (fit из N), а не максимум темы; вердикт форума — «есть ≥1 проходная тема». Формат зафиксирован в docstring и в этом отчёте (UI Task 9 показывает темы с per-topic X из N).
@@ -0,0 +1,23 @@
### 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-ветку без настроенного ИИ (не падает).
---
@@ -0,0 +1,52 @@
# Task 7 — Отчёт: API Discovery
## Статус
Выполнено. Роутер `/api/discovery` реализован, зарегистрирован в `main.py`, проверен на живом контейнере.
## Файлы
- Создан: `backend/app/routers/discovery_routes.py` (prefix `/api/discovery`, tags `discovery`, auth `current_login`).
- Изменён: `backend/app/main.py` — импорт `discovery_routes` и добавление в цикл `include_router`.
## Что сделано
### Эндпоинты
- `GET /tasks`, `POST /tasks`, `PATCH /tasks/{id}`, `DELETE /tasks/{id}`;
- `POST /tasks/{id}/start`, `POST /tasks/{id}/pause`;
- `POST /tasks/{id}/generate-keywords` — ИИ-генерация ключей по описанию задачи;
- `GET /tasks/{id}/candidates?status=new|review|joined|rejected` (необязателен; невалидный статус — 422 через `Literal`);
- `POST /candidates/{dialog_id}/join` — ручное вступление (вне квот/пауз воркера);
- `POST /candidates/{dialog_id}/reject` — отклонение с добавлением в чёрный список;
- `GET /blacklist`, `DELETE /blacklist/{dialog_id}`;
- `GET /tasks/{id}/log`.
### Модели и контракт
- `TaskCreate` / `TaskPatch` — camelCase-поля без алиасов (как `PreviewBody` в `tg_routes`), необязательные поля `None` исключаются через `model_dump(exclude_none=True)`, чтобы `create_task`/`patch_task` сами подставляли дефолты (в т.ч. `threshold`/`sampleSize` из настроек).
- `POST /tasks` отдаёт созданную задачу как есть (200); списки — `{"items": [...]}`; delete — `{"ok": true}`.
- Обработка: `ValueError` → 400, `KeyError` → 404; для отсутствующих задачи/кандидата — явные 404-хелперы (`_task_or_404`, `_candidate_or_404`, чтение кандидата через `store: SELECT * FROM disc_candidates WHERE dialog_id=?` как в брифе).
- `generate-keywords`: если `!aiEnabled` или у активного провайдера нет ключа (и не local) → `{"keywords": [], "error": "..."}` с HTTP 200; иначе `ai_service.chat_json(промпт RU+EN 1016, user=описание)``{"keywords": [...]}` (чистка: строки, без пустых/длинных/повторов, страховочный лимит 30); ошибка провайдера → `{"keywords": [], "error": str}`. Пустое описание → error-ветка.
- `join`: статус `joined` → 400; `tg.discovery_join(username)``tg.add_dialog_monitored(...)``discovery.mark_joined(auto=False)`; ошибка Telegram → 400 с текстом.
- `reject`: статус `joined` → 400 «Уже вступили — удалите источник из каналов»; иначе `mark_rejected(reason="отклонено вручную")`.
- Роутер чисто проходит `ruff check` и `py_compile`.
## Вывод проверок
1. `cd /c/telbase && python -m py_compile backend/app/routers/discovery_routes.py backend/app/main.py``PY_COMPILE_OK`; `ruff check backend/app/routers/discovery_routes.py` → clean.
2. `docker compose build app``Image telbase-app Built`; `docker compose up -d app` → контейнер пересоздан, `/api/health` → 200.
3. Живой API (логин admin/admin, куки):
- `POST /api/discovery/tasks {"name":"","planJoins":1}`**400** `{"detail":"Укажите название задачи"}`;
- `POST /api/discovery/tasks` корректная (plan=1, ключи пустые) → **200**, задача `status:"draft"`, дефолты `threshold:40/sampleSize:10` подставлены;
- `GET /api/discovery/tasks`**200** `{"items":[задача]}`;
- `GET /api/discovery/tasks/{id}/candidates?status=review`**200** `{"items":[]}`; `GET /api/discovery/tasks/{id}/log`**200** `{"items":[]}`; `GET /api/discovery/blacklist`**200** `{"items":[]}`;
- `POST /api/discovery/tasks/{id}/generate-keywords`**200** (в этом окружении ключ ИИ настроен и `aiEnabled=true`) → реальный вызов провайдера, ответ `{"keywords":[14 строк RU+EN]}` (happy path);
- error-ветка: временно `PATCH /api/settings {"aiEnabled":false}``generate-keywords`**200** `{"keywords":[],"error":"ИИ выключен в настройках (aiEnabled)"}`; настройка возвращена в `true`;
- `POST /api/discovery/tasks/{id}/start` при пустых ключах → **400** `{"detail":"Нет ключевых слов для поиска — добавьте их в задачу"}`;
- `PATCH /api/discovery/tasks/{id}` (name+keywords) → **200**, поля обновлены; `POST .../pause`**200** `status:"paused"`;
- `DELETE /api/discovery/tasks/{id}`**200** `{"ok":true}`; повторный `GET /tasks`**200** `{"items":[]}`;
- `start`/`PATCH`/`candidates` по несуществующей задаче → **404** `{"detail":"Задача не найдена"}`;
- `POST /api/discovery/candidates/{id}/join` и `/reject` по несуществующему кандидату → **404** `{"detail":"Кандидат не найден"}`;
- `GET /api/discovery/tasks/{id}/candidates?status=bogus`**422** (валидация `Literal`);
- `DELETE /api/discovery/blacklist/{id}` (нет записи) → **200** `{"ok":true}`.
## Concerns
1. Ветки `join`/`reject` с реальным кандидатом и реальным `tg.discovery_join` (в т.ч. «уже joined» → 400 и ошибка Telegram → 400) живьём не гонялись — нужен подключённый Telegram-аккаунт и настоящий кандидат; это ручная проверка уровня Task 10. Контрактные 404/422 проверены.
2. `generate-keywords` в проверке реально дёрнул настроенного провайдера (сетевой вызов). Error-ветка проверена переключением `aiEnabled`; ветка «ключ не задан» воспроизводится так же, но отдельно не гонялась, чтобы не трогать `aiConfigs`.
3. В `main.py` остались pre-existing предупреждения ruff (не связаны с задачей): неиспользуемый импорт `pathlib.Path` (F401) и серия `# noqa: BLE001` на голых `except Exception:` без `as` (RUF100 — ruff не считает BLE001 срабатывающим на таких обработчиках). Не правил: файл вне объёма, `py_compile` чист.
4. Под Windows/MSYS кириллица в `curl -d '...'` ломает тело запроса («There was an error parsing the body») — проверки с кириллицей делались через `--data @файл` (UTF-8). К продакшену отношения не имеет.
@@ -0,0 +1,17 @@
### 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`** — без ошибок.
---
@@ -0,0 +1,46 @@
# Task 8 — Отчёт: Фронтенд — store + каркас подвкладки «Поиск»
## Статус
Выполнено. `npm run build` проходит без ошибок.
## Файлы
- Изменён: `frontend/src/store.js` — состояние Discovery, сброс при разлогине, 13 функций.
- Изменён: `frontend/src/views/ChannelsView.vue` — сегмент «Каналы | Поиск каналов» и рендер по табу.
- Создан: `frontend/src/views/DiscoveryView.vue` — список задач + панель активной задачи/мастер.
## Что сделано
### store.js
- Состояние (по интерфейсу брифа): `channelsTab: 'list' | 'search'` (init `'list'`), `discTasks`, `discCandidates`, `discBlacklist`, `discLog`, `discActiveTaskId: null`, `discCandidateStatus: 'review'`, `discBusy: false`. Добавлено в `state` (блок «Каналы») и в `resetLocal()` (разлогин → чистый Discovery, `channelsTab` возвращается в `'list'`).
- Функции названы точно по брифу: `gotoChannelsTab(tab)`, `loadDiscTasks()`, `saveDiscTask(form, id=null)`, `deleteDiscTask(id)`, `startDiscTask(id)`, `pauseDiscTask(id)`, `generateDiscKeywords(taskId)`, `loadDiscCandidates(taskId, status)`, `joinDiscCandidate(c)`, `rejectDiscCandidate(c)`, `loadDiscBlacklist()`, `removeDiscBlacklist(id)`, `loadDiscLog(taskId)`.
- Контракт в camelCase как в API: `minSubscribers`, `sampleSize`, `planJoins`, `autoJoin`, `dialogId` и т.д. Тела задач собираются в camelCase (`POST/PATCH /api/discovery/tasks`), списки читаются из `{"items": [...]}`.
- Паттерны проекта: `api.get/post/patch/delete`, `toast`/`errMsg` на действиях; тихие `catch → false` на фоновых чтениях списков (как `loadPipelineQueue`/`loadRejected`).
- `saveDiscTask` — создаёт/патчит, кладёт задачу в `discTasks`, ставит `discActiveTaskId`, возвращает задачу или `null`.
- `generateDiscKeywords`**error-ветка API** (`{"keywords": [], "error": "..."}`) → `toast(error)`, возвращает `null`; успех → массив `keywords` (может быть пустым).
- `deleteDiscTask` — после удаления активной задачи выбирает первую оставшуюся.
- `loadDiscTasks` — сохраняет активную задачу, если она ещё существует, иначе выбирает первую (правая панель не пустует).
- `join/rejectDiscCandidate` — на успехе убирают кандидата из текущего списка `discCandidates` + toast; `removeDiscBlacklist` фильтрует по `dialogId`. Поля кандидата/блэклиста не используются в UI до Task 9.
### ChannelsView.vue
- В шапке — сегмент «Каналы | Поиск каналов» (мелкие кнопки в контейнере `bg-ink/60 border border-white/5`, активный — `bg-white/8 text-hi`), переключение через `gotoChannelsTab`. Подзаголовок шапки и правые действия («Перечитать», «Включить все», поиск по имени) показываются только на табе `'list'`.
- Синхронизация списка каналов (watch по `state.view` + `onMounted`) ограничена условием `channelsTab === 'list'`; добавлен отдельный watch по `channelsTab` — возврат на таб «Каналы» освежает список.
- При `channelsTab === 'search'` вместо списка каналов рендерится `<DiscoveryView/>` (импорт из `./DiscoveryView.vue`).
### DiscoveryView.vue (каркас: задачи + мастер)
- Слева: колонка `w-[300px]` со списком задач (`state.discTasks`; имя + чип статуса + ключи/найдено/вступили), кнопка «Новая задача», refresh. Пустое состояние — с подсказкой.
- Справа: панель активной задачи (`state.discActiveTaskId`). Нет выбора — приветственный экран с кнопкой «Новая задача».
- Мастер: `name`, `description`, кнопка «Сгенерировать ключи ИИ» (иконка sparkles), редактируемые чипы ключей (ввод + Enter/плюс, удаление крестиком), числовые поля `minSubscribers`/`threshold`/`sampleSize`/`planJoins` (дефолты 0/40/10/1), `lang` select ru/any, toggle `autoJoin`. Стили в духе SettingsView: `rounded-xl border border-white/8 bg-raise/40 p-4`, инпуты `h-9 bg-ink/70 border-white/10 focus:border-brand/50`, чипы как у стоп-фраз/сферы.
- Кнопки: «Запустить» (градиент; disabled пока `keywords` пустые; подпись «Продолжить» для paused, «Запустить заново» для done/failed), «Сохранить», «Удалить» (с `askConfirm`, скрыта для черновика и на паузе выполнения), «Поставить на паузу» (только running).
- Статус задачи: чип статуса (draft/running/paused/done/failed с цветами online/warn/brand/danger), при прогрессе — счётчики found/evaluated/joined/rejected, дата создания. Пока задача running — поля задизейблены (overlay) и список тихо опрашивается раз в 4.5 c (статус/счётчики живут на бэке у воркера).
- Без заглушек-обещаний: панель кандидатов/лога не рисовалась (ожидаемо — Task 9).
- UX-детали: «Запустить» и «Сгенерировать ключи ИИ» при необходимости сначала сохраняют форму (`saveDiscTask`), т.к. старт и генерация идут по id задачи на сервере; черновик «Новой задачи» не затирается ручным refresh списка.
## Вывод проверок
1. `cd /c/telbase/frontend && npm run build``✓ built in 1.32s`, `42 modules transformed`, ошибок нет.
2. `diagnostics` по `store.js`, `ChannelsView.vue`, `DiscoveryView.vue` — ошибок/предупреждений нет.
## Concerns
1. Живой API/UI не гонялся (нет запущенного бэкенда/дев-сервера в этой сессии); поведение функций опирается на контракт Task 7 (`{"items": [...]}`, camelCase, 200/400/404). Ручная проверка сценариев — за Task 10.
2. Генерация ключей ИИ по API идёт от id задачи (`POST /tasks/{id}/generate-keywords`), поэтому при генерации из нового черновика кнопка сначала создаёт задачу (нужно название — при пустом показывается toast «Укажите название задачи»). После генерации ключи кладутся в форму, в БД фиксируются кнопкой «Сохранить» (или автоматически при «Запустить»).
3. Дефолты формы (threshold 40 / sampleSize 10) повторяют дефолты сервиса; при создании бэкенд может подставить значения настроек `discEvalThreshold/discEvalSample` — после «Сохранить» форма перечитывается из ответа сервера (`pickFromTask`), так что расхождение схлопывается.
4. `channelsTab` глобальный и переживает уход/возврат на экран «Каналы» (как `settingsTab`); активная задача Discovery тоже сохраняется между визитами, при её удалении/исчезновении выбирается первая.
5. Опрос статуса running-задачи — простой `setInterval` 4.5 c на время нахождения на вкладке; при большом числе задач можно позже перевести на точечный `GET /tasks/{id}` или SSE.
@@ -0,0 +1,15 @@
### 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).
---
@@ -0,0 +1,49 @@
# Task 9 — Отчёт: Фронтенд — кандидаты, действия, чёрный список, история
## Статус
Выполнено. `cd /c/telbase/frontend && npm run build` — без ошибок (`✓ built`, 42 modules transformed).
`python -m py_compile backend/app/routers/settings_routes.py` — OK.
## Файлы
- Изменён: `frontend/src/views/DiscoveryView.vue` — табы кандидатов/истории/чёрного списка, карточки кандидатов, квоты.
- Изменён: `frontend/src/store.js``state.discCounts` + `loadDiscCounts(taskId)`; `loadDiscCandidates` обновляет счётчик текущего статуса.
- Изменён: `frontend/src/components/Icon.vue` — новые иконки `users`, `megaphone`, `list` (чипы вида источника, участники, темы).
- Изменён: `backend/app/routers/settings_routes.py``discPaused` добавлен в `_PUBLIC_BOOL` (одной строкой).
## Что сделано
### Табы панели задачи (DiscoveryView, правая колонка)
- Под «мастером» и действиями задачи — панель с табами: «В обработке» (`new`), «На рассмотрении» (`review`), «Вступили» (`joined`), «Отклонены» (`rejected`), «История» (лог), «Чёрный список».
- Бейджи-счётчики (`state.discCounts`) скрыты при 0; переключение таба вызывает `loadDiscCandidates(taskId, status)` / `loadDiscLog(taskId)` / `loadDiscBlacklist()`; смена активной задачи и повторный клик по ней — авто-загрузка панели (`loadPanel`).
- Счётчики всех четырёх статусов обновляет `loadDiscCounts` (4 параллельных GET по статусам). Защита от гонок при быстром переключении табов/задач — `panelSeq` (stale-ответ перезагружает актуальный таб).
- Пока задача `running` — список кандидатов/счётчики тихо обновляются каждый второй тик опроса (~9 c).
### Карточка кандидата
- Аватар по `hue` (как в ChannelsView), название, `@username`, чип вида (канал `megaphone`/brand, группа `users`/online, форум `list`/warn), участники (`N участник/а/ов`, «—» при None), метки `marks` чипами (важные — закрытая группа/история недоступна — подсвечиваются warn).
- Соответствие: для канала/группы — «подходит N%» (по `fitRatio`); для форума — «подходит тем: N из M» и раскрывающийся список тем «тема „{title}" — подходит {fitCount} из {total}» с passed-подсветкой (pass — online, нет — приглушённый).
- Кнопки «Вступить и мониторить» и «Отклонить» — только для `status === 'review'`; join без подтверждения, reject через `askConfirm` (уходит в чёрный список). Для `new` кнопок нет — подпись «воркер оценит источник». После join/reject счётчики перечитываются.
- Раскрытие тем форума — локальный `Set` `expanded` (chevron).
### Чёрный список
- Выбран отдельный таб «Чёрный список» в той же панели (читабельно и не спорит с макетом). Строка: иконка, имя, причина/дата, кнопка «Снять» (`removeDiscBlacklist`, guard от двойного клика `unbanId`).
### Квоты авто-вступлений
- Маленькая кнопка «Квоты» в шапке левой панели (рядом со списком задач) → inline-блок: суточный лимит `discJoinLimit`, паузы мин/макс `discJoinDelayMin/Max` (сек), стоп-кран-переключатель `discPaused`.
- Дефолты не хардкодятся: при первом открытии блока — `GET /api/settings` (локальная загрузка), сохранение чисел — `PATCH /api/settings` одним объектом (клампы 1–200 и 5–600 как на бэке, min ≤ max), стоп-кран патчится сразу при переключении.
- На бэке `discPaused` добавлен в `_PUBLIC_BOOL` — теперь принимается PATCH и отдаётся в GET.
### store.js
- `state.discCounts = { new:0, review:0, joined:0, rejected:0 }`.
- `loadDiscCandidates` дополнительно пишет `discCounts[status]`.
- `loadDiscCounts(taskId)` — Promise.all по 4 статусам, заполняет `discCounts` (не трогает `discCandidates`).
## Вывод проверок
1. `cd /c/telbase/frontend && npm run build``✓ built in 1.24s`, `42 modules transformed`, ошибок нет.
2. `diagnostics` по `DiscoveryView.vue`, `store.js`, `Icon.vue` — ошибок/предупреждений нет.
3. `python -m py_compile backend/app/routers/settings_routes.py` → OK.
## Concerns
1. Живой API/UI не гонялся (нет запущенного бэкенда/дев-сервера в этой сессии); поведение опирается на контракт Task 7. Визуальная проверка сценариев — за Task 10.
2. Кнопка «Квоты» дергает `GET/PATCH /api/settings` напрямую из вью (в store нет state-полей под диск-квоты, по брифу разрешена локальная загрузка/сохранение через `api.patch`). Это единственное место, где вью импортирует `api` напрямую — если захочется строгой «только через store», квоты стоит перевести в store-функции.
3. Счётчики табов получаются отдельными запросами по каждому статусу (у бэкенда нет эндпоинта counts); при активном воркере это ~4 лёгких GET каждые ~9 c — приемлемо для текущих объёмов, но при росте числа кандидатов можно добавить серверный `counts`.
4. «На рассмотрении» — таб по умолчанию для активной задачи (в `state.discCandidateStatus` изначально `review`); таб «История»/«Чёрный список» сохраняется при переключении задач.
5. `progress.md` не трогался.