ci / build-test (push) Canceled after 0s
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 зелёные.
10 KiB
10 KiB
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; после прогона удалён):
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")
Вывод проверок
cd /c/telbase && python -m py_compile backend/app/services/discovery.py→PY_COMPILE OK(без ошибок).- Сценарий в контейнере на временной БД (
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.
- задача
- Диагностика файла — без ошибок и предупреждений.
Concerns
- Конвенция ключей: наружные dict-ы модуля — camelCase (не snake-колонки). Task 7 (роутер) может возвращать их как есть и передавать в
create/patchmodel_dump()Pydantic-моделей; Task 6 (воркер) при работе с задачами/кандидатами должен использовать camelCase-ключи (task["planJoins"],c["fitRatio"]и т.п.). Контракт зафиксирован в докстринге модуля. set_candidate_statusограниченnew/review—joined/rejectedтолько черезmark_joined/mark_rejected(иначе разъезжаются счётчики/чёрный список/лог). Если в Task 6/7 понадобится «сырой» перевод — расширить функцию осознанно.delete_taskдополнительно чиститdisc_logзадачи (в брифе — «задачу и её кандидатов»); чёрный список общий и не трогается.add_candidateинкрементитfoundтолько при успешном добавлении (skip-источники не считаются найденными).- Для прогонов в контейнере нужен
docker compose build app(код запекается в образ) иMSYS_NO_PATHCONV=1на Git Bash (иначе аргументы/data/…и/tmp/…конвертируются в Windows-пути).