### 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. ---