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

10 KiB
Raw Blame History

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; при статусе joinedValueError (для 400 в Task 7).
  • Чёрный список / лог: add_blacklist, remove_blacklist, list_blacklist, add_log, task_log(limit=100).

Ключевые решения (задокументированы в докстринге модуля):

  • Все обращения к БД — store.* с параметрами; JSON-поля keywords/marks/topicsjson.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, dialogIdcreate_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; после прогона удалён):

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.pyPY_COMPILE OK (без ошибок).
  2. Сценарий в контейнере на временной БД (LEADRADAR_DATA=/tmp/lr_disc, одноразовый контейнер --rm --no-deps) → DISCOVERY OK:
    • задача planJoins=25 создана; вторая с planJoins=30ValueError («Бюджет авто-вступлений исчерпан…»);
    • источник, вставленный в 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/reviewjoined/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-пути).