Files
Deal/.superpowers/sdd/channel-discovery/task-3-report.md
T
Rustam Khalimov 27c7831910
ci / build-test (push) Canceled after 0s
Deal — единая кодовая база
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 зелёные.
2026-09-11 23:56:47 +03:00

112 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-пути).