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 зелёные.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
# 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-пути).
|
||||
Reference in New Issue
Block a user