Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,608 @@
"""Discovery: хранилище задач поиска каналов, кандидатов, чёрного списка и лога.
Единый слой доступа к disc_tasks / disc_candidates / disc_blacklist / disc_log
(Task 1). Потребляется и API (Task 7), и фоновым воркером (Task 6).
Соглашения модуля:
- все обращения к БД только через `store.*` с параметрами (без конкатенации SQL);
- время — миллисекунды (`time.time_ns() // 1_000_000`);
- JSON-поля (keywords/marks/topics) в БД — VARCHAR, наружу всегда список
(`json.dumps(..., ensure_ascii=False)` при записи, `json.loads` при чтении);
- наружные dict-ы — camelCase (конвенция границы API проекта), поля совпадают
с колонками БД: keywords/minSubscribers/sampleSize/planJoins/autoJoin,
searchIdx/searchDone, fitRatio/autoJoined, createdAt/updatedAt, dialogId...
- create/patch принимают ключи и в camelCase, и в snake_case (поле "min_subscribers"
и т.п.) — на входе идёт нормализация к колонкам БД;
- статусы кандидата: new -> review -> joined|rejected. Переводы в joined/rejected
делаются ТОЛЬКО через mark_joined()/mark_rejected() (счётчики, чёрный список,
лог); set_candidate_status() разрешает new/review.
Бюджет авто-вступлений: сумма plan_joins задач со статусом NOT IN ('done','failed')
плюс plan_joins новой/увеличиваемой задачи не должна превышать discJoinLimit.
"""
from __future__ import annotations
import json
import time
from ..db import store
# префиксы id (конвенция: видно, из какой таблицы запись)
_ID_TASK = "dt_"
_ID_LOG = "dl_"
# статусы кандидата, при которых повторное добавление источника запрещено
_ACTIVE_CANDIDATE = {"new", "review", "joined"}
# статусы задачи, которые НЕ занимают бюджет plan_joins
_DONE_TASK = {"done", "failed"}
# алиасы полей задачи: колонка БД -> набор имён в payload/патче
_TASK_ALIASES = {
"min_subscribers": {"minSubscribers", "min_subscribers"},
"sample_size": {"sampleSize", "sample_size"},
"plan_joins": {"planJoins", "plan_joins"},
"auto_join": {"autoJoin", "auto_join"},
# остальные поля называются одинаково: name, description, keywords, lang, threshold
}
# поля кандидата, которые можно менять через set_candidate()
_CANDIDATE_FIELDS = {
"name": "name",
"username": "username",
"kind": "kind",
"hue": "hue",
"participants": "participants",
"lang_ru": "lang_ru",
"langRu": "lang_ru",
"marks": "marks",
"topics": "topics",
"fit_ratio": "fit_ratio",
"fitRatio": "fit_ratio",
"auto_joined": "auto_joined",
"autoJoined": "auto_joined",
}
def _now() -> int:
return time.time_ns() // 1_000_000
def _json(value) -> str:
return json.dumps(value or [], ensure_ascii=False)
def _loads(raw, default: list | None = None) -> list:
try:
return json.loads(raw or "[]")
except (TypeError, ValueError):
return list(default or [])
def _plan_limit() -> int:
"""Верхняя граница plan_joins: текущий суточный лимит discJoinLimit."""
return max(1, int(store.get_setting("discJoinLimit") or 0))
def _active_plan_sum(exclude_id: str | None = None) -> int:
sql = "SELECT coalesce(sum(plan_joins), 0) FROM disc_tasks WHERE status NOT IN ('done', 'failed')"
params: list = []
if exclude_id:
sql += " AND id <> ?"
params.append(exclude_id)
return int(store.scalar(sql, params) or 0)
def _assert_plan(plan_joins: int) -> None:
"""plan_joins >= 1 и не больше суточного лимита (настраивается в UI)."""
if plan_joins < 1:
raise ValueError("plan_joins должен быть не меньше 1")
limit = _plan_limit()
if plan_joins > limit:
raise ValueError(f"plan_joins {plan_joins} больше суточного лимита авто-вступлений ({limit})")
def _assert_budget(plan_joins: int, exclude_id: str | None = None) -> None:
"""Правило бюджета: сумма планов активных задач + новая <= discJoinLimit."""
limit = _plan_limit()
used = _active_plan_sum(exclude_id)
if used + plan_joins > limit:
raise ValueError(
f"Бюджет авто-вступлений исчерпан: задачи уже занимают {used} из {limit} в сутки, "
f"ещё {plan_joins} не влезает"
)
# ─── view-слои (наружу camelCase) ──────────────────────────────────────────
def _task_view(row: dict) -> dict:
return {
"id": row["id"],
"name": row["name"],
"description": row["description"],
"keywords": _loads(row["keywords"]),
"minSubscribers": int(row["min_subscribers"]),
"lang": row["lang"],
"threshold": int(row["threshold"]),
"sampleSize": int(row["sample_size"]),
"planJoins": int(row["plan_joins"]),
"autoJoin": bool(row["auto_join"]),
"status": row["status"],
"searchIdx": int(row["search_idx"]),
"searchDone": bool(row["search_done"]),
"found": int(row["found"]),
"evaluated": int(row["evaluated"]),
"joined": int(row["joined"]),
"rejected": int(row["rejected"]),
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
}
def _candidate_view(row: dict) -> dict:
return {
"dialogId": row["dialog_id"],
"taskId": row["task_id"],
"name": row["name"],
"username": row["username"],
"kind": row["kind"],
"hue": row["hue"],
"participants": row["participants"],
"langRu": row["lang_ru"],
"marks": _loads(row["marks"]),
"topics": _loads(row["topics"]),
"fitRatio": row["fit_ratio"],
"status": row["status"],
"autoJoined": bool(row["auto_joined"]),
"joinFailures": int(row.get("join_failures") or 0),
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
}
def _blacklist_view(row: dict) -> dict:
return {
"dialogId": row["dialog_id"],
"name": row["name"],
"reason": row["reason"],
"createdAt": row["created_at"],
}
def _log_view(row: dict) -> dict:
return {
"id": row["id"],
"taskId": row["task_id"],
"event": row["event"],
"text": row["text"],
"createdAt": row["created_at"],
}
# ─── задачи ────────────────────────────────────────────────────────────────
def _task_or_raise(task_id: str) -> dict:
row = store.query_one("SELECT * FROM disc_tasks WHERE id = ?", [task_id])
if row is None:
raise KeyError(task_id)
return row
def list_tasks() -> list[dict]:
"""Все задачи, старые первыми (воркер берёт самую старую running)."""
return [_task_view(r) for r in store.query("SELECT * FROM disc_tasks ORDER BY created_at ASC")]
def get_task(task_id: str) -> dict | None:
row = store.query_one("SELECT * FROM disc_tasks WHERE id = ?", [task_id])
return _task_view(row) if row else None
def _norm_task_values(patch: dict) -> dict:
"""Нормализация payload/патча задачи (camel/snake алиасы) к колонкам БД."""
out: dict = {}
for key, value in patch.items():
if key in ("name", "description", "keywords", "lang", "threshold"):
col = key
else:
col = next((c for c, aliases in _TASK_ALIASES.items() if key in aliases), None)
if col is None:
continue # неизвестное поле игнорируем
out[col] = value
return out
def _validate_task_values(cols: dict) -> None:
"""Проверка границ значений задачи (после нормализации)."""
if "threshold" in cols:
cols["threshold"] = max(1, min(100, int(cols["threshold"])))
if "sample_size" in cols:
cols["sample_size"] = max(1, int(cols["sample_size"]))
if "min_subscribers" in cols:
cols["min_subscribers"] = max(0, int(cols["min_subscribers"]))
if "lang" in cols and cols["lang"] not in ("ru", "any"):
cols["lang"] = "ru"
if "auto_join" in cols:
cols["auto_join"] = bool(cols["auto_join"])
if "keywords" in cols:
keywords = cols["keywords"] if isinstance(cols["keywords"], list) else [cols["keywords"]]
cols["keywords"] = [str(k).strip() for k in keywords if str(k).strip()]
if "description" in cols:
cols["description"] = str(cols["description"] or "")
if "name" in cols:
cols["name"] = str(cols["name"] or "").strip()
def create_task(payload: dict) -> dict:
"""Создать задачу поиска.
Валидация: name непустое; plan_joins 1..discJoinLimit; правило бюджета
(сумма plan_joins активных задач + новая <= discJoinLimit) — иначе ValueError.
"""
cols = _norm_task_values(payload)
name = str(cols.get("name") or "").strip()
if not name:
raise ValueError("Укажите название задачи")
plan_joins = int(cols.get("plan_joins", 1))
_assert_plan(plan_joins)
_assert_budget(plan_joins)
values = {
"name": name,
"description": str(cols.get("description") or ""),
"keywords": cols.get("keywords", []),
"min_subscribers": max(0, int(cols.get("min_subscribers", 0))),
"lang": cols.get("lang", "ru"),
"threshold": max(1, min(100, int(cols.get("threshold", int(store.get_setting("discEvalThreshold") or 40))))),
"sample_size": max(1, int(cols.get("sample_size", int(store.get_setting("discEvalSample") or 10)))),
"plan_joins": plan_joins,
"auto_join": bool(cols.get("auto_join", False)),
}
_validate_task_values(values)
task_id = store.uid(_ID_TASK)
now = _now()
store.execute(
"INSERT INTO disc_tasks(id, name, description, keywords, min_subscribers, lang, threshold, "
"sample_size, plan_joins, auto_join, status, search_idx, search_done, found, evaluated, "
"joined, rejected, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 0, FALSE, 0, 0, 0, 0, ?, ?)",
[
task_id,
values["name"],
values["description"],
_json(values["keywords"]),
values["min_subscribers"],
values["lang"],
values["threshold"],
values["sample_size"],
values["plan_joins"],
values["auto_join"],
now,
now,
],
)
return get_task(task_id) # type: ignore[return-value]
def patch_task(task_id: str, patch: dict) -> dict:
"""Обновить поля задачи. Увеличение plan_joins — с проверкой бюджета."""
row = _task_or_raise(task_id)
cols = _norm_task_values(patch)
if not cols:
return get_task(task_id) # type: ignore[return-value]
old_plan = int(row["plan_joins"])
if "plan_joins" in cols:
new_plan = int(cols["plan_joins"])
_assert_plan(new_plan)
if new_plan > old_plan:
_assert_budget(new_plan, exclude_id=task_id)
cols["plan_joins"] = new_plan
_validate_task_values(cols)
sets = ["updated_at = ?"]
params: list = [_now()]
for col, value in cols.items():
sets.append(f"{col} = ?")
params.append(_json(value) if col == "keywords" else value)
params.append(task_id)
store.execute(
f"UPDATE disc_tasks SET {', '.join(sets)} WHERE id = ?",
params,
)
return get_task(task_id) # type: ignore[return-value]
def delete_task(task_id: str) -> None:
"""Удалить задачу вместе с её кандидатами и логом (чёрный список общий)."""
store.execute("DELETE FROM disc_tasks WHERE id = ?", [task_id])
store.execute("DELETE FROM disc_candidates WHERE task_id = ?", [task_id])
store.execute("DELETE FROM disc_log WHERE task_id = ?", [task_id])
def start_task(task_id: str) -> dict:
"""Запустить поиск: keywords непустые; status=running.
При повторном запуске завершённой/упавшей задачи прогресс поиска обнуляется
(свежий проход по ключам); при продолжении из paused — сохраняется.
"""
row = _task_or_raise(task_id)
keywords = _loads(row["keywords"])
if not keywords:
raise ValueError("Нет ключевых слов для поиска — добавьте их в задачу")
now = _now()
reset = row["status"] in _DONE_TASK
if reset:
# повторный прогон завершённой/упавшей задачи — свежий проход по ключам
store.execute(
"UPDATE disc_tasks SET status = 'running', search_idx = 0, search_done = FALSE, "
"found = 0, evaluated = 0, joined = 0, rejected = 0, updated_at = ? WHERE id = ?",
[now, task_id],
)
else:
# старт из draft или продолжение из paused — прогресс поиска сохраняется
store.execute(
"UPDATE disc_tasks SET status = 'running', updated_at = ? WHERE id = ?",
[now, task_id],
)
return get_task(task_id) # type: ignore[return-value]
def pause_task(task_id: str) -> dict:
"""Поставить задачу на паузу."""
_task_or_raise(task_id)
store.execute(
"UPDATE disc_tasks SET status = 'paused', updated_at = ? WHERE id = ?",
[_now(), task_id],
)
return get_task(task_id) # type: ignore[return-value]
def bump_counter(task_id: str, field: str, n: int = 1) -> None:
"""Увеличить счётчик задачи: found|evaluated|joined|rejected."""
if field not in ("found", "evaluated", "joined", "rejected"):
raise ValueError(f"Неизвестный счётчик задачи: {field}")
row = _task_or_raise(task_id)
row[field] = int(row[field]) + max(0, int(n))
store.execute(
f"UPDATE disc_tasks SET {field} = ?, updated_at = ? WHERE id = ?",
[row[field], _now(), task_id],
)
def advance_search(task_id: str) -> None:
"""Перейти к следующему ключу; когда search_idx >= len(keywords) — search_done=True."""
row = _task_or_raise(task_id)
keywords = _loads(row["keywords"])
new_idx = int(row["search_idx"]) + 1
done = new_idx >= len(keywords)
store.execute(
"UPDATE disc_tasks SET search_idx = ?, search_done = ?, updated_at = ? WHERE id = ?",
[new_idx, done, _now(), task_id],
)
# ─── кандидаты ─────────────────────────────────────────────────────────────
def list_candidates(task_id: str, status: str | None = None) -> list[dict]:
"""Кандидаты задачи (marks/topics уже списки); status — фильтр."""
if status:
rows = store.query(
"SELECT * FROM disc_candidates WHERE task_id = ? AND status = ? ORDER BY created_at ASC",
[task_id, status],
)
else:
rows = store.query(
"SELECT * FROM disc_candidates WHERE task_id = ? ORDER BY created_at ASC",
[task_id],
)
return [_candidate_view(r) for r in rows]
def _get_candidate(dialog_id: str) -> dict | None:
row = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
return row
def _skip(task_id: str, reason: str) -> None:
add_log(task_id, "skip", reason)
def add_candidate(task_id: str, dialog_id: str, name: str, username: str, kind: str, hue: str) -> dict | None:
"""Добавить найденный источник как кандидата задачи.
None (с логом skip), если источник уже мониторится (есть в dialogs), в
чёрном списке или уже добавлен в статусе new/review/joined. Прежняя запись
со статусом rejected (например, после remove_blacklist) заменяется новой.
"""
_task_or_raise(task_id)
dialog_id = str(dialog_id)
if store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id]):
_skip(task_id, f"пропущен {dialog_id}: источник уже мониторится (мы состоим)")
return None
if store.scalar("SELECT 1 FROM disc_blacklist WHERE dialog_id = ? LIMIT 1", [dialog_id]):
_skip(task_id, f"пропущен {dialog_id}: источник в чёрном списке")
return None
existing = store.query_one(
"SELECT status FROM disc_candidates WHERE dialog_id = ?",
[dialog_id],
)
if existing and existing["status"] in _ACTIVE_CANDIDATE:
_skip(task_id, f"пропущен {dialog_id}: кандидат уже есть (статус {existing['status']})")
return None
if existing:
# устаревшая rejected-запись: перезаписываем как новый кандидат
store.execute("DELETE FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
now = _now()
store.execute(
"INSERT INTO disc_candidates(dialog_id, task_id, name, username, kind, hue, participants, "
"lang_ru, marks, topics, fit_ratio, status, auto_joined, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, '[]', '[]', NULL, 'new', FALSE, ?, ?)",
[
dialog_id,
task_id,
str(name or "").strip() or dialog_id,
str(username or "").strip(),
str(kind or "channel"),
str(hue or "#666"),
now,
now,
],
)
bump_counter(task_id, "found")
row = _get_candidate(dialog_id)
return _candidate_view(row) if row else None
def set_candidate(task_id: str, dialog_id: str, patch: dict) -> dict:
"""Обновить поля кандидата задачи (например, по результатам оценки).
patch — значения в нотации кандидата (camelCase или snake_case):
participants, kind, langRu/lang_ru, marks, topics, fitRatio/fit_ratio,
autoJoined/auto_joined, name, username, hue.
"""
_task_or_raise(task_id)
row = _get_candidate(dialog_id)
if row is None or row["task_id"] != task_id:
raise KeyError(dialog_id)
cols: dict = {}
for key, value in patch.items():
col = _CANDIDATE_FIELDS.get(key)
if col is None:
continue
cols[col] = value
if not cols:
return _candidate_view(row)
if "marks" in cols:
cols["marks"] = _json([str(m) for m in cols["marks"]])
if "topics" in cols:
cols["topics"] = _json(cols["topics"])
for col in ("name", "username", "kind", "hue"):
if col in cols:
cols[col] = str(cols[col] or "").strip() or row[col]
if "participants" in cols and cols["participants"] is not None:
cols["participants"] = int(cols["participants"])
sets = ["updated_at = ?"]
params: list = [_now()]
for col, value in cols.items():
sets.append(f"{col} = ?")
params.append(value)
params.append(dialog_id)
store.execute(f"UPDATE disc_candidates SET {', '.join(sets)} WHERE dialog_id = ?", params)
updated = _get_candidate(dialog_id)
return _candidate_view(updated) if updated else _candidate_view(row)
def set_candidate_status(dialog_id: str, status: str) -> dict:
"""Перевести кандидата в new/review (+ лог review).
joined/rejected меняются только через mark_joined()/mark_rejected() —
там счётчики задачи, чёрный список и лог join/reject.
"""
if status not in ("new", "review"):
raise ValueError(f"Статус {status} выставляется через mark_joined/mark_rejected")
row = _get_candidate(dialog_id)
if row is None:
raise KeyError(dialog_id)
store.execute(
"UPDATE disc_candidates SET status = ?, updated_at = ? WHERE dialog_id = ?",
[status, _now(), dialog_id],
)
if status == "review":
add_log(row["task_id"], "review", f"кандидат {dialog_id} переведён в review")
updated = _get_candidate(dialog_id)
return _candidate_view(updated) if updated else _candidate_view(row)
def delete_candidate(dialog_id: str) -> None:
"""Удалить кандидата (skip-ветки воркера; повторный вызов безопасен)."""
store.execute("DELETE FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
def mark_joined(dialog_id: str, auto: bool) -> dict:
"""Источник вступил: status=joined, счётчик joined задачи, лог join_auto/join_manual."""
row = _get_candidate(dialog_id)
if row is None:
raise KeyError(dialog_id)
if row["status"] == "joined":
return _candidate_view(row) # идемпотентно: повторно не считаем
now = _now()
store.execute(
"UPDATE disc_candidates SET status = 'joined', auto_joined = ?, updated_at = ? WHERE dialog_id = ?",
[bool(auto), now, dialog_id],
)
bump_counter(row["task_id"], "joined")
add_log(row["task_id"], "join_auto" if auto else "join_manual", f"вступили в {dialog_id}")
updated = _get_candidate(dialog_id)
return _candidate_view(updated) if updated else _candidate_view(row)
def mark_rejected(dialog_id: str, reason: str = "") -> dict:
"""Отклонить кандидата: status=rejected, счётчик rejected, лог reject, чёрный список.
Повторный вызов для уже отклонённого — идемпотентен: счётчик/лог/чёрный
список не трогаются (кандидата мог отклонить и человек, и воркер).
"""
row = _get_candidate(dialog_id)
if row is None:
raise KeyError(dialog_id)
if row["status"] == "joined":
raise ValueError("Нельзя отклонить источник, в который уже вступили")
if row["status"] == "rejected":
return _candidate_view(row) # идемпотентно: повторно не считаем и не логируем
now = _now()
store.execute(
"UPDATE disc_candidates SET status = 'rejected', updated_at = ? WHERE dialog_id = ?",
[now, dialog_id],
)
bump_counter(row["task_id"], "rejected")
add_log(row["task_id"], "reject", reason or f"отклонён {dialog_id}")
add_blacklist(dialog_id, row["name"], reason or "")
updated = _get_candidate(dialog_id)
return _candidate_view(updated) if updated else _candidate_view(row)
# ─── чёрный список ─────────────────────────────────────────────────────────
def add_blacklist(dialog_id: str, name: str = "", reason: str = "") -> dict:
"""Пометить источник в чёрном списке (существующая запись обновляется)."""
dialog_id = str(dialog_id)
now = _now()
store.execute(
"INSERT INTO disc_blacklist(dialog_id, name, reason, created_at) VALUES (?, ?, ?, ?) "
"ON CONFLICT(dialog_id) DO UPDATE SET name = excluded.name, reason = excluded.reason",
[dialog_id, str(name or "").strip() or dialog_id, str(reason or ""), now],
)
row = store.query_one("SELECT * FROM disc_blacklist WHERE dialog_id = ?", [dialog_id])
return _blacklist_view(row) if row else {"dialogId": dialog_id, "name": name, "reason": reason, "createdAt": now}
def remove_blacklist(dialog_id: str) -> None:
store.execute("DELETE FROM disc_blacklist WHERE dialog_id = ?", [dialog_id])
def list_blacklist() -> list[dict]:
return [
_blacklist_view(r)
for r in store.query("SELECT * FROM disc_blacklist ORDER BY created_at DESC")
]
# ─── лог ───────────────────────────────────────────────────────────────────
def add_log(task_id: str, event: str, text: str = "") -> None:
store.execute(
"INSERT INTO disc_log(id, task_id, event, text, created_at) VALUES (?, ?, ?, ?, ?)",
[store.uid(_ID_LOG), task_id, str(event), str(text or ""), _now()],
)
def task_log(task_id: str, limit: int = 100) -> list[dict]:
"""Последние события задачи, новые сверху."""
limit = max(1, min(500, int(limit)))
rows = store.query(
"SELECT * FROM disc_log WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
[task_id, limit],
)
return [_log_view(r) for r in rows]