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:
@@ -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 ~15–30 с** (`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)
|
||||
- Фикс-волна (I1–I4/M1–M5/m1–m6): 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` на задачу; случайная пауза **50–70 с**, по одному действию; лимит **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) (L746–747); ошибка чтения отдельной темы → `continue`
|
||||
(L793–795); сам вызов `_read_forum_topics` дополнительно обёрнут catch-all (L743–745).
|
||||
Строки 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()` (50–70 с);
|
||||
- `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": [...]}` (8–16 строк RU+EN); ИИ недоступен/выключен → `{"keywords": [], "error": "..."}`.
|
||||
- `GET /tasks/{id}/candidates?status=`
|
||||
- `POST /candidates/{dialog_id}/join` — ручное вступление (вне квот): `tg.discovery_join` + `add_dialog_monitored` + `mark_joined(auto=False)`; 400 при ошибке.
|
||||
- `POST /candidates/{dialog_id}/reject` — `mark_rejected` (добавляет в чёрный список). Если кандидат уже `joined` — 400.
|
||||
- `GET /blacklist`, `DELETE /blacklist/{dialog_id}`
|
||||
- `GET /tasks/{id}/log`
|
||||
|
||||
Pydantic-модели: `TaskCreate` (name, description, keywords, minSubscribers, lang, threshold, sampleSize, planJoins, autoJoin), `TaskPatch` (все optional), `GenKeywordsBody` не нужен (id в пути).
|
||||
|
||||
- [ ] **Step 1: Реализовать роутер** (ValueError → HTTPException 400; KeyError → 404).
|
||||
- [ ] **Step 2: Зарегистрировать в main.py**.
|
||||
- [ ] **Step 3: Проверить API на живом контейнере**: логин, создание задачи plan=1, list, delete; `generate-keywords` вернёт error-ветку без настроенного ИИ (не падает).
|
||||
|
||||
---
|
||||
@@ -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 10–16, 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` не трогался.
|
||||
Reference in New Issue
Block a user