Files
Deal/docs/superpowers/plans/2026-09-04-channel-discovery.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

28 KiB
Raw Blame History

Поиск и подключение каналов (Discovery) — Implementation Plan

Исторический документ (план Discovery прототипа LeadRadar, 2026-09-04). Актуальное состояние — docs/superpowers/STATUS.md и docs/technical/Техническая-документация-Дейл.md.

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Дать пользователю возможность создавать «задачи поиска»: система находит по описанию/ключам Telegram-каналы и группы (в которых мы не состоим), оценивает их (метаданные → язык → контент по темам), показывает «на рассмотрение», а человек вступает сам или включает авто-вступление в рамках суточных квот с анти-бан паузами.

Architecture: Дочерняя система Discovery поверх существующего стека (FastAPI + DuckDB + TelegramManager/Telethon + Vue 3). Отдельный сервис discovery (хранилище+оркестрация), новые методы Telegram-действий в TelegramManager, общий BanGuard для квот/пауз, отдельный фоновый воркер в main.py. Оценка сообщений переиспользует правила/ML/ИИ, но с профилем задачи и БЕЗ создания карточек. UI — подвкладка «Поиск» на экране «Каналы».

Tech Stack: Python 3.12 / FastAPI / DuckDB / Telethon / Vue 3 + Tailwind (Vite). Новых зависимостей нет.

Global Constraints

  • Правило «мы не состоим» — глобальное и безусловное: источники из dialogs, чёрного списка или уже в другой задаче исключаются сразу (проверка повторяется и при вступлении).
  • Спека: docs/superpowers/specs/2026-09-04-channel-discovery-design.md (читать при каждом задании).
  • Проект НЕ git-репозиторий: вместо git commit — проверка через docker compose/npm run build, фиксация результата в тексте шага.
  • Все тексты UI — по-русски, в стиле существующего интерфейса (без канцелярита, короткие подписи).
  • Все настраиваемые числа (лимиты, паузы, пороги, размеры выборок) — настройки в БД (store.get_setting), НЕ в коде; дефолты в constants.DEFAULT_SETTINGS.
  • Новые таблицы добавлять только через db.py (_SCHEMA, CREATE TABLE IF NOT EXISTS), при необходимости — миграции в _MIGRATIONS.
  • Запуск/проверка: контейнеры docker compose up -d, бэкенд на :8000, ML на :8100; пересборка docker compose build app.
  • JSON-поля (keywords/marks/topics) хранить как VARCHAR с json.dumps(..., ensure_ascii=False), читать через json.loads — как в остальном коде.

Task 1: Схема БД и настройки по умолчанию

Files:

  • Modify: backend/app/db.py (добавить 4 таблицы в _SCHEMA)
  • Modify: backend/app/constants.py (DEFAULT_SETTINGS)
  • Modify: backend/app/routers/settings_routes.py (_PUBLIC_INT)

Interfaces:

  • Produces: таблицы disc_tasks, disc_candidates, disc_blacklist, disc_log; настройки discJoinLimit (50), discJoinDelayMin (50), discJoinDelayMax (70), discEvalSample (10), discEvalThreshold (40).

  • Step 1: Добавить таблицы в _SCHEMA (перед таблицей settings)

CREATE TABLE IF NOT EXISTS disc_tasks (
    id VARCHAR PRIMARY KEY,
    name VARCHAR NOT NULL,
    description VARCHAR NOT NULL DEFAULT '',
    keywords VARCHAR NOT NULL DEFAULT '[]',
    min_subscribers INTEGER NOT NULL DEFAULT 0,
    lang VARCHAR NOT NULL DEFAULT 'ru',
    threshold INTEGER NOT NULL DEFAULT 40,
    sample_size INTEGER NOT NULL DEFAULT 10,
    plan_joins INTEGER NOT NULL DEFAULT 1,
    auto_join BOOLEAN NOT NULL DEFAULT FALSE,
    status VARCHAR NOT NULL DEFAULT 'draft',   -- draft|running|paused|done|failed
    search_idx INTEGER NOT NULL DEFAULT 0,
    search_done BOOLEAN NOT NULL DEFAULT FALSE,
    found INTEGER NOT NULL DEFAULT 0,
    evaluated INTEGER NOT NULL DEFAULT 0,
    joined INTEGER NOT NULL DEFAULT 0,
    rejected INTEGER NOT NULL DEFAULT 0,
    created_at BIGINT NOT NULL,
    updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS disc_candidates (
    dialog_id VARCHAR PRIMARY KEY,
    task_id VARCHAR NOT NULL,
    name VARCHAR NOT NULL DEFAULT '',
    username VARCHAR NOT NULL DEFAULT '',
    kind VARCHAR NOT NULL DEFAULT 'channel',  -- channel|group|forum
    hue VARCHAR NOT NULL DEFAULT '#666',
    participants INTEGER,
    lang_ru BOOLEAN,
    marks VARCHAR NOT NULL DEFAULT '[]',
    topics VARCHAR NOT NULL DEFAULT '[]',
    fit_ratio DOUBLE,
    status VARCHAR NOT NULL DEFAULT 'new',   -- new|review|joined|rejected
    auto_joined BOOLEAN NOT NULL DEFAULT FALSE,
    created_at BIGINT NOT NULL,
    updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_disc_cand_task ON disc_candidates(task_id, status);
CREATE TABLE IF NOT EXISTS disc_blacklist (
    dialog_id VARCHAR PRIMARY KEY,
    name VARCHAR NOT NULL DEFAULT '',
    reason VARCHAR NOT NULL DEFAULT '',
    created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS disc_log (
    id VARCHAR PRIMARY KEY,
    task_id VARCHAR NOT NULL,
    event VARCHAR NOT NULL,     -- search|found|skip|eval|review|join_auto|join_manual|leave|reject|flood|error|done
    text VARCHAR NOT NULL DEFAULT '',
    created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_disc_log_task ON disc_log(task_id, created_at);
  • Step 2: Добавить настройки в constants.pyDEFAULT_SETTINGS
# поиск каналов (Discovery)
"discJoinLimit": 50,        # суточный лимит авто-вступлений (общий)
"discJoinDelayMin": 50,     # сек, нижняя граница паузы между авто-вступлениями
"discJoinDelayMax": 70,     # сек, верхняя граница
"discEvalSample": 10,       # размер выборки сообщений при оценке
"discEvalThreshold": 40,    # % подходящих сообщений
  • Step 3: Открыть настройки наружу в settings_routes.py

В _PUBLIC_INT добавить discJoinLimit, discJoinDelayMin, discJoinDelayMax, discEvalSample, discEvalThreshold. В patch_settings наложить границы: limit 1..200; паузы 5..600; sample 3..30; threshold 1..100.

  • Step 4: Проверить
docker compose build app && docker compose up -d app

Затем GET /api/settings (после логина) — в ответе присутствуют discJoinLimit: 50 и остальные ключи. python -m py_compile всех изменённых файлов — без ошибок.


Task 2: BanGuard (квоты, паузы, flood)

Files:

  • Create: backend/app/services/ban_guard.py

Interfaces:

  • Consumes: store, настройки из Task 1.

  • Produces:

    • def joins_today_auto() -> int — авто-вступления за текущие UTC-сутки (считает disc_log event='join_auto', created_at >= начало суток).
    • def can_auto_join() -> bool — лимит не исчерпан И нет flood-блокировки на сегодня И нет глобальной паузы.
    • async def wait_join_delay() -> Noneasyncio.sleep(random.uniform(min, max)).
    • def note_flood() -> Nonestore.set_setting("discFloodDay", <start_of_day_ms>).
    • def flood_today() -> bool
    • def global_paused() -> bool / def set_global_pause(v: bool) -> None (setting discPaused)
    • def search_pause() -> floatrandom.uniform(2.0, 4.0).
  • Step 1: Реализовать модуль (~60 строк; начало суток — UTC: datetime.now(timezone.utc).replace(hour=0,minute=0,second=0,microsecond=0) → ms).

  • Step 2: Проверить на временной БД в контейнере

docker compose run --rm --no-deps -e PYTHONPATH=/srv -e LEADRADAR_DATA=/tmp/lr_bg --entrypoint python app -c "
from app.db import store; store.init()
from app.services import ban_guard as bg
assert bg.can_auto_join() is True
assert bg.joins_today_auto() == 0
bg.note_flood(); assert bg.flood_today() is True
bg.set_global_pause(True); assert bg.can_auto_join() is False
print('BANGUARD OK')
"

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 | NoneNone, если: в 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) -> Nonesearch_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; кандидат, совпадающий с dialogsadd_candidate вернул None + лог skip; mark_rejected → в чёрном списке; повторный add_candidate того же источника → None.


Task 4: Telegram-действия поиска (методы TelegramManager)

Files:

  • Modify: backend/app/services/telegram.py (класс TelegramManager)

Interfaces:

  • Consumes: self.client, ban_guard.

  • Produces (async-методы):

    • async def discovery_search(q: str, limit: int = 30) -> list[dict]client(functions.contacts.SearchRequest(q=q, limit=limit)); вернуть [{id(str), name, username, kind, hue}] (kind через _kind_of, hue через dialog_hue); между вызовами — await asyncio.sleep(ban_guard.search_pause()).
    • async def discovery_info(dialog_id: str) -> dict{id, name, username, kind, hue, participants: int | None, is_forum: bool} (participants из full_chat где возможно; иначе None).
    • async def discovery_read(dialog_id: str, limit: int) -> dict — последние сообщения: {"ok": bool, "error": str | None, "messages": [{"id", "text", "date_ms", "topic_id"}]}; topic_idgetattr(getattr(m,'reply_to',None),'reply_to_top_id',None). История недоступна → {"ok": False, "error": "no_history", "messages": []}.
    • async def discovery_join(username: str) -> Noneclient(functions.channels.JoinChannelRequest(...)); ПЕРЕД вызовом await ban_guard.wait_join_delay(); FloodWaitErrorban_guard.note_flood() и проброс.
    • async def discovery_leave(dialog_id: str) -> Nonechannels.LeaveChannelRequest.
    • def add_dialog_monitored(dialog_id, name, username, kind, hue) -> None — INSERT/UPDATE dialogs с monitor=TRUE, backfilled=FALSE (как в set_monitor, но без авто-join-логики).
  • Step 1: Реализовать методы (импорт telethon.tl.functions, telethon.errors.rpcerrorlist.FloodWaitError).

  • Step 2: Проверить компиляцию py_compile. Полная проверка — после Task 6/7 на живом аккаунте (ручные шаги в Task 10).


Task 5: Оценка контента (язык, темы, fit по профилю задачи)

Files:

  • Create: backend/app/services/discovery_eval.py

Interfaces:

  • Consumes: store, ml_client, ai_service (chat_json), pipeline.clean_short.

  • Produces:

    • def detect_lang_ru(texts: list[str]) -> bool | None — доля кириллических букв от всех букв в сумме: >=0.15 → True; <=0.03 → False; между порогами → None (неопределённо).
    • def group_by_topic(messages: list[dict]) -> list[dict] — группировка по topic_id (None → "main"); возвращает [{"topic_id", "title", "messages": [...]}], title = сниппет первого текста темы (≤60 симв.), сортировка по количеству сообщений (убыв.).
    • async def evaluate_message(task: dict, text: str) -> dict{"fit": bool, "reason": str, "source": "heuristic"|"ml"|"ai"}:
      1. текст пустой/длина <10 → fit False «слишком короткое»;
      2. ML: если ml_client.is_enabled() и прогноз take и label=='spam' → fit False «ML: спам»;
      3. ИИ: если aiEnabled → один JSON-вызов ai_service.chat_json(промпт, user=text) с промптом из описания задачи и ключей ({fit, reason}); ошибка → шаг 4;
      4. эвристика: fit = любой ключ входит в clean_short(text) casefold; reason «совпал ключ "…"» / «нет совпадений с ключами».
    • async def evaluate_sample(task: dict, messages: list[dict]) -> dict — последовательно по каждому сообщению; вернуть {"fit_count": int, "total": int, "fit_ratio": float, "per_message": [{"text": …, "fit", "reason", "topic_id"}]}.
    • def passed(ev: dict, task: dict) -> boolev["total"] >= 3 and ev["fit_ratio"]*100 >= task["threshold"].
  • Step 1: Реализовать модуль. Промпт ИИ (внутри модуля, константа): Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. Ключи: {keywords}. Верни JSON {"fit": 0|1, "reason": "краткая причина"}.

  • Step 2: Проверить на временной БД (без сети): detect_lang_ru(["Ищем python разработчика"]) is True; detect_lang_ru(["we need a python developer"]) is False; group_by_topic объединяет по topic_id и сортирует; evaluate_message на задаче без ИИ/ML возвращает эвристический fit по ключу.


Task 6: Воркер Discovery (поиск → оценка → авто-вступление)

Files:

  • Create: backend/app/services/discovery_worker.py
  • Modify: backend/app/main.py (фоновый цикл _discovery_loop, каждые 5 c)

Interfaces:

  • Consumes: discovery (Task 3), tg.discovery_* (Task 4), discovery_eval (Task 5), ban_guard (Task 2).
  • Produces: async def tick() -> dict — выполняет ОДНО действие и возвращает {"action": ..., "taskId": ...} (или {"action": "none"}).

Логика tick (по одной задаче за вызов, начиная с самой старой running):

  1. Если задача search_done=False: взять ключ keywords[search_idx], вызвать tg.discovery_search; для каждого результата discovery.add_candidate; discovery.advance_search(task_id); если search_done стал True — лог search «поиск завершён: N кандидатов». Возврат.
  2. Иначе взять первого кандидата статуса new задачи:
    • info = tg.discovery_info; participants, kind (forum если is_forum); при заданном min_subscribers и participants НЕ None и меньше минимума — set_candidate_status(...) нет: просто discovery.delete_candidate + лог skip; если participants None — метка «участники не подтверждены» (идём дальше).
    • read = tg.discovery_read(dialog_id, sample_size).
      • Если read.ok=False (история недоступна без членства): kind==channel → review с меткой «канал: история недоступна»; группа/форум → review с меткой «закрытая группа (история скрыта) — вступите сами»; оценка контента не производится, неподтверждённые фильтры помечаются.
    • Язык: если прочитано и task.lang=='ru': lang_ru=detect_lang_ru(...); False → удалить кандидата, лог skip «язык не русский»; None → метка «язык не подтверждён».
    • Оценка: evaluate_sample; passed → метки topics/fit → review + лог review; иначе удалить кандидата, лог skip «мало подходящих (X из N)».
  3. Авто-вступление (отдельный проход tick, приоритет ниже оценки): если у running-задачи auto_join и есть кандидат review и ban_guard.can_auto_join():
    • повторная проверка «мы не состоим» (dialogs/blacklist) → если вступили уже → mark_rejected с логом;
    • await ban_guard.wait_join_delay() (рандом 50–70 с — спейсинг авто-вступлений; ручные join из API паузу не делают);
    • tg.discovery_join(username)discovery.mark_joined(dialog_id, auto=True)tg.add_dialog_monitored(...); при FloodWaitError → ban_guard.note_flood() + лог flood.
  4. Если task.joined >= task.plan_joins → статус done, лог done.
  • Step 1: Реализовать discovery_worker.py и цикл в main.py.
  • Step 2: Проверить компиляцию и запуск без падений (воркер с пустыми таблицами делает none). Полный прогон — Task 10 вручную.

Task 7: API Discovery

Files:

  • Create: backend/app/routers/discovery_routes.py
  • Modify: backend/app/main.py (регистрация роутера)

Interfaces:

  • Prefix /api/discovery, auth current_login:
    • GET /tasks, POST /tasks, PATCH /tasks/{id}, DELETE /tasks/{id}, POST /tasks/{id}/start, POST /tasks/{id}/pause
    • POST /tasks/{id}/generate-keywords — ИИ: промпт по description → JSON {"keywords": [...]} (816 строк RU+EN); ИИ недоступен/выключен → {"keywords": [], "error": "..."}.
    • GET /tasks/{id}/candidates?status=
    • POST /candidates/{dialog_id}/join — ручное вступление (вне квот): tg.discovery_join + add_dialog_monitored + mark_joined(auto=False); 400 при ошибке.
    • POST /candidates/{dialog_id}/rejectmark_rejected (добавляет в чёрный список). Если кандидат уже joined — 400.
    • GET /blacklist, DELETE /blacklist/{dialog_id}
    • GET /tasks/{id}/log

Pydantic-модели: TaskCreate (name, description, keywords, minSubscribers, lang, threshold, sampleSize, planJoins, autoJoin), TaskPatch (все optional), GenKeywordsBody не нужен (id в пути).

  • Step 1: Реализовать роутер (ValueError → HTTPException 400; KeyError → 404).
  • Step 2: Зарегистрировать в main.py.
  • Step 3: Проверить API на живом контейнере: логин, создание задачи plan=1, list, delete; generate-keywords вернёт error-ветку без настроенного ИИ (не падает).

Task 8: Фронтенд — store + каркас подвкладки «Поиск»

Files:

  • Modify: frontend/src/store.js
  • Create: frontend/src/views/DiscoveryView.vue
  • Modify: frontend/src/views/ChannelsView.vue

Interfaces:

  • state: channelsTab: 'list' | 'search', discTasks: [], discCandidates: [], discBlacklist: [], discLog: [], discActiveTaskId: null, discCandidateStatus: 'review', discBusy: false.

  • store-функции: gotoChannelsTab(tab), loadDiscTasks(), saveDiscTask(form, id=null) (create/patch), deleteDiscTask(id), startDiscTask(id), pauseDiscTask(id), generateDiscKeywords(taskId), loadDiscCandidates(taskId, status), joinDiscCandidate(c), rejectDiscCandidate(c), loadDiscBlacklist(), removeDiscBlacklist(id), loadDiscLog(taskId).

  • Step 1: store.js — состояние + функции (паттерны: api.get/post/patch/delete, toast, errMsg).

  • Step 2: ChannelsView.vue — в шапке сегмент: «Каналы | Поиск» (state.channelsTab), содержимое по табу.

  • Step 3: DiscoveryView.vue (каркас): левая колонка — список задач (+ «Новая задача»); правая — панель задачи: мастер (name, description, «Сгенерировать ключи ИИ», чипы ключей редактируемые, minSubscribers, lang select ru/any, threshold, sampleSize, planJoins, autoJoin toggle, кнопки «Запустить/Пауза/Удалить»).

  • Step 4: npm run build — без ошибок.


Task 9: Фронтенд — кандидаты, действия, чёрный список, история

Files:

  • Modify: frontend/src/views/DiscoveryView.vue

Interfaces:

  • Consumes: Task 8 (store).

  • Step 1: Табы панели задачи: «В обработке» (new) / «На рассмотрении» (review) / «Вступили» (joined) / «Отклонены» (rejected) + «История» (лог). Бейджи счётчиков скрыты при 0.

  • Step 2: Карточка кандидата: название, @username, kind-иконка/метка (канал/группа/форум), метки marks (чипы: закрытая, не прочитан, участники/язык не подтверждены, мало сообщений), участники, «подходит X из N», кнопки «Вступить и мониторить» / «Отклонить» (только для review). Форум → раскрывающийся список topics («тема — подходит X из N»).

  • Step 3: Чёрный список (под списками или отдельный таб) — снять источник; «Настройки квот» — popover/inline с discJoinLimit/discJoinDelayMin/discJoinDelayMax + стоп-кран (PATCH /api/settings).

  • Step 4: История — лог задачи.

  • Step 5: npm run build — без ошибок; визуальная проверка основных сценариев (Task 10).


Task 10: ТЗ, сборка и end-to-end проверка

Files:

  • Modify: ТЗ.md (раздел «Поиск и подключение каналов»)

  • Step 1: Дополнить ТЗ — кратко: задачи поиска, каскад фильтров, глобальное правило «мы не состоим», метки, оценка по темам, review/join/reject + чёрный список, авто-вступление и квоты (50/сутки, 50–70 с), подвкладка «Поиск» на «Каналах».

  • Step 2: Сборка и рестарт:

docker compose build app && docker compose up -d app
cd frontend && npm run build
  • Step 3: E2E вручную (нужен подключённый Telegram-аккаунт):
    1. «Каналы → Поиск» → создать задачу (описание, план 3, авто-вступление выкл) → «Сгенерировать ключи» → запустить.
    2. Дождаться кандидатов; проверить, что текущие подписки и отклонённые не появляются.
    3. Открыть кандидата: метки, участники, fit «X из N», темы форума.
    4. «Вступить и мониторить» → источник появляется в «Каналах» (monitor on) и начинает давать карточки.
    5. «Отклонить» → уходит в чёрный список; повторно не находится.
    6. Включить авто-вступление: проверить паузы (≥50 с) и расход суточного лимита.

Self-Review

  • Покрытие спеки: Task 1 (хранилище+настройки), Task 2 (квоты/анти-бан), Task 3 (задачи/бюджет планов/чёрный список), Task 4 (поиск/инфо/чтение/join), Task 5 (язык/темы/fit), Task 6 (воркер+авто-join), Task 7 (API), Task 89 (UI), Task 10 (ТЗ+E2E). Правило «мы не состоим» — Task 3 add_candidate, Task 6 шаг 3 (повторная проверка перед join), UI Task 9. Разделы спеки §4–§12 покрыты; «вне рамок» (§13) не реализуются.
  • Плейсхолдеры: нет; у каждого шага есть конкретный код/поведение и способ проверки.
  • Согласованность: единые статусы задач draft|running|paused|done|failed, кандидатов new|review|joined|rejected; события лога search|found|skip|eval|review|join_auto|join_manual|leave|reject|flood|error|done; все имена настроек и функций совпадают между задачами.