# 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-пути).