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

Первый коммит: модульный монолит ядра (.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 @@
"""Сервисный слой."""
@@ -0,0 +1,357 @@
"""AI-классификатор поверх выбранного провайдера.
Провайдеров много (облако + локальные OpenAI-совместимые + Anthropic),
активен один. Всё сведено к двум задачам:
* filter_incoming(text) — этап 2 фильтра входящих (промпт aiFilterPrompt);
* classify(text, boards) — разбор лида (промпт aiPrompt + обучающие примеры).
Ответы моделей строго JSON; распознаём и обрезаем markdown-обёртки ```json.
"""
from __future__ import annotations
import json
import logging
import re
import httpx
from .. import constants as C
from ..crypto import decrypt_text
from ..db import store
from .rates import convert_amount
log = logging.getLogger("leadradar.ai")
def _cfg() -> tuple[str, dict]:
provider_id = store.get_setting("aiProvider") or "deepseek"
configs = store.get_setting("aiConfigs") or {}
meta = next((p for p in C.AI_PROVIDERS if p["id"] == provider_id), None) or C.AI_PROVIDERS[0]
raw = configs.get(provider_id, {})
cfg = dict(raw)
if cfg.get("apiKey"):
cfg["apiKey"] = decrypt_text(str(cfg["apiKey"]))
return provider_id, {"meta": meta, "cfg": cfg}
def provider_status() -> dict:
"""Статус для UI: кто активен, есть ли ключ, base, модель (ключ маскируется)."""
provider_id, data = _cfg()
meta = data["meta"]
cfg = data["cfg"]
key = str(cfg.get("apiKey") or "")
return {
"provider": provider_id,
"name": meta["name"],
"base": cfg.get("baseUrl") or meta["base"],
"model": cfg.get("model") or (meta["models"] or [""])[0],
"local": bool(meta.get("local")),
"keySet": bool(key),
"keyMasked": mask_key(key),
}
def mask_key(key: str) -> str:
if not key:
return ""
if len(key) <= 8:
return key[0] + ""
return f"{key[:4]}{key[-4:]}"
# ── «Сфера и ключи»: подстановка в промпты из настроек пользователя ────────
def fill_prompt(prompt: str) -> str:
"""Заменяет в тексте промпта {domain} и {keywords} значениями из настроек.
Настройки задаются в UI (вкладка «Сфера и ключи»), поэтому ИИ-классификатор
работает для любой сферы: разработка, дизайн, недвижимость, стройка и т.п.
"""
domain = str(store.get_setting("domainDescription") or "").strip()
if not domain:
domain = "Универсально: заявка = конкретный запрос на услугу/товар/работу или найм человека."
raw_kws = store.get_setting("domainKeywords") or []
if isinstance(raw_kws, str):
raw_kws = [raw_kws]
kws = [str(k).strip() for k in raw_kws if str(k).strip()]
kws_text = ", ".join(kws[:60]) if kws else "(не заданы — определяй по тексту)"
return (prompt or "").replace("{domain}", domain).replace("{keywords}", kws_text)
async def chat_json(system: str, user: str, max_retries: int = 2, max_tokens: int = 8000) -> dict:
"""Единая точка вызова выбранной модели. Возвращает dict (JSON-ответ).
Модель иногда отвечает HTTP 200 с пустым/не-JSON содержимым (особенно
на больших запросах или при перегрузке API) — делаем несколько попыток
с нарастающей паузой, чтобы не сыпать «ИИ недоступен» из-за разового сбоя.
"""
provider_id, data = _cfg()
meta = data["meta"]
cfg = data["cfg"]
base = str(cfg.get("baseUrl") or meta["base"]).rstrip("/")
model = cfg.get("model") or (meta["models"] or [""])[0]
api_key = str(cfg.get("apiKey") or "")
last_err: Exception | None = None
last_raw = ""
for attempt in range(max_retries + 1):
try:
if meta.get("api_style") == "anthropic":
text = await _call_anthropic(base, api_key, model, system, user, max_tokens)
else:
text = await _call_openai(base, api_key, model, system, user, max_tokens)
last_raw = text
return extract_json(text)
except Exception as exc: # noqa: BLE001 — пробуем ещё раз
last_err = exc
if attempt < max_retries:
await asyncio_sleep(0.8 + 1.2 * attempt) # 0.8с, 2с, 3.2с
log.warning(
"AI call failed (%s, попыток %d): %s | ответ: %r",
meta["name"],
max_retries + 1,
last_err,
last_raw[:200],
)
raise RuntimeError(
f"ИИ ({meta['name']}) не ответил корректно — повторите попытку через несколько секунд"
)
async def asyncio_sleep(secs: float) -> None:
import asyncio
await asyncio.sleep(secs)
async def _call_openai(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str:
url = base + "/chat/completions"
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
body = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.2,
"max_tokens": max_tokens,
}
async with httpx.AsyncClient(timeout=90) as client:
resp = await client.post(url, headers=headers, json=body)
resp.raise_for_status()
payload = resp.json()
try:
msg = payload["choices"][0]["message"]
except (KeyError, IndexError, TypeError):
raise ValueError(f"неожиданный ответ API: {str(payload)[:200]}")
content = msg.get("content")
if not content and msg.get("reasoning_content"):
# модель «подумала», но не дала ответа (переполнение/обрыв) — считаем сбоем и повторим
raise ValueError("модель вернула только reasoning без ответа")
return str(content or "")
async def _call_anthropic(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str:
url = base.rstrip("/") + "/v1/messages"
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
body = {
"model": model,
"max_tokens": max_tokens,
"system": system,
"messages": [{"role": "user", "content": user}],
}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, headers=headers, json=body)
resp.raise_for_status()
payload = resp.json()
return "".join(blk.get("text", "") for blk in payload.get("content", []))
def extract_json(text: str) -> dict:
raw = text.strip()
fence = re.search(r"```(?:json)?\s*(.*?)```", raw, re.S)
if fence:
raw = fence.group(1).strip()
start, end = raw.find("{"), raw.rfind("}")
if start >= 0 and end > start:
raw = raw[start : end + 1]
return json.loads(raw)
# ── Задачи ────────────────────────────────────────────────────────────────
async def filter_incoming(text: str) -> dict:
"""Этап 2 (ИИ-фильтр). Если выключен — считаем пропущенным."""
if not store.get_setting("aiFilterEnabled"):
return {"pass": True, "reason": None, "skipped": True}
prompt = fill_prompt(store.get_setting("aiFilterPrompt"))
out = await chat_json(prompt, f"Сообщение:\n{text[:4000]}")
return {
"pass": bool(out.get("pass", True)),
"reason": out.get("reason"),
"skipped": False,
}
def _learning_examples(limit: int = 8) -> list[dict]:
"""Примеры разметки пользователя для few-shot классификации."""
rows = store.query(
"SELECT l.source_msg AS msg, ll.to_col "
"FROM learning_log ll JOIN leads l ON l.id = ll.lead_id "
"WHERE ll.action IN ('move','restore') AND l.source_msg <> '' "
"ORDER BY ll.created_at DESC LIMIT ?",
[limit],
)
examples = []
for r in rows:
if r["to_col"] in ("trash", "archive"):
continue
examples.append({"text": (r["msg"] or "")[:500], "board": r["to_col"]})
return examples
async def classify(message_text: str) -> dict:
"""Полный разбор лида. Возвращает сырой словарь модели.
Колонка для ИИ — это набор критериев (правила), а не просто название:
в промпт уходят правила колонок, чтобы модель выбирала осмысленно.
"""
from .rules import describe as describe_rules
# в классификации участвуют только принятые колонки (не ИИ-предложения)
boards = store.query(
"SELECT id, name, description, keywords, rules FROM boards WHERE suggested = FALSE ORDER BY pos"
)
lines = []
for b in boards:
rules = json.loads(b["rules"] or "{}") if b["rules"] else {}
has_rules = any(rules.get(k) for k in ("direction", "keywords", "stack", "grade", "budget"))
if has_rules:
suffix = f" (критерии: {describe_rules(rules)})"
else:
kws = json.loads(b["keywords"] or "[]")[:8]
suffix = f" (ключевые слова: {', '.join(kws)})" if kws else ""
line = f"- {b['id']}: {b['name']}{suffix}"
if b.get("description"):
line += f"{str(b['description']).strip()[:160]}"
lines.append(line)
board_map = "\n".join(lines) or "- (колонок пока нет — верните board: null)"
examples = _learning_examples()
user = (
f"Доски: {board_map}\n\n"
+ ("Примеры разметки пользователя:\n" + "\n".join(
f"текст: {e['text']}\n→ колонка: {e['board']}" for e in examples
) + "\n\n" if examples else "")
+ f"Новое сообщение:\n{message_text[:5000]}"
)
prompt = fill_prompt(store.get_setting("aiPrompt"))
# отдельный промпт структуры карточки («О заявке») — задаёт поля контента,
# чтобы все карточки имели одинаковую структуру текста
card = fill_prompt(store.get_setting("cardPrompt") or "")
if card:
prompt = prompt + "\n\n" + card
return await chat_json(prompt, user)
def normalize_dedup(text: str) -> str:
"""Дедупликация по нормализованному тексту (регистр и спецсимволы)."""
import hashlib
import re as _re
norm = _re.sub(r"[^\wа-яё]+", "", text.casefold())
return hashlib.sha1(norm.encode()).hexdigest()
# Синонимы валют из ответов ИИ → коды хранения (согласовано с rules._CUR_*)
_CUR_ALIASES = {
"USD": "USD", "$": "USD", "US$": "USD", "ДОЛЛАР": "USD", "ДОЛЛАРОВ": "USD", "ДОЛЛ": "USD", "БАКС": "USD", "БАКСОВ": "USD",
"EUR": "EUR", "": "EUR", "ЕВРО": "EUR",
"RUB": "RUB", "RUR": "RUB", "": "RUB", "РУБ": "RUB", "РУБЛЕЙ": "RUB", "РУБЛИ": "RUB", "РУБЛЬ": "RUB", "РУБЛЯ": "RUB",
"GBP": "GBP", "£": "GBP", "CNY": "CNY", "¥": "CNY", "USDT": "USDT", "": "USDT",
}
def _norm_currency(raw) -> str | None:
"""Приводит название/символ валюты из ИИ к коду (USD/EUR/RUB/…)."""
s = str(raw or "").strip().upper()
if not s:
return None
if s in _CUR_ALIASES:
return _CUR_ALIASES[s]
letters = re.sub(r"[^A-ZА-Я]", "", s)
if letters in _CUR_ALIASES:
return _CUR_ALIASES[letters]
if len(s) == 3 and s.isalpha():
return s
return None
def _budget_num(v):
"""Число из значения бюджета ИИ: «2к»/«2К» → 2000, «2000₽»/«2000р» → 2000."""
if v is None:
return None
s = str(v).strip().casefold().replace("\u00a0", "").replace(" ", "")
if not s:
return None
mult = 1.0
if s.endswith(("к", "k")):
mult = 1000.0
s = s[:-1]
if s.endswith("руб"):
s = s[:-3]
elif s.endswith(("р", "")):
s = s[:-1]
try:
x = float(s.replace(",", ".")) * mult
except ValueError:
return None
return None if x == 0 else x
def clean_budget(budget) -> dict | None:
"""Нормализация бюджета из ИИ/локального разбора.
Соглашение хранения (и отображения в UI):
одна сумма X → {from: X, to: X}
«до X» → {from: None, to: X}
диапазон «от X до Y» → {from: X, to: Y}
from=0 трактуется как отсутствие нижней границы («от 0 до X» == «до X»).
"""
if not isinstance(budget, dict):
return None
cur = _norm_currency(budget.get("currency") or budget.get("cur"))
if not cur:
return None
def num(v):
return _budget_num(v)
f, t = num(budget.get("from")), num(budget.get("to"))
if f is None and t is None:
return None
if t is None:
t = f # одна сумма или «от X» без верхней границы
return {"from": f, "to": t, "currency": cur}
def budget_to_target(budget: dict | None) -> dict:
"""Пересчёт «один раз при поступлении» в целевую валюту по текущим курсам."""
if not budget or not budget.get("currency"):
return {"convFrom": None, "convTo": None, "convCur": ""}
cur = budget["currency"]
target = store.get_setting("targetCurrency") or "RUB"
if not store.get_setting("conversionOn"):
return {"convFrom": None, "convTo": None, "convCur": ""}
from_, to_ = budget.get("from"), budget.get("to")
c_from = convert_amount(from_, cur, target) if from_ is not None else None
c_to = None
if to_ is not None:
c_to = convert_amount(to_, cur, target)
elif from_ is not None:
c_to = c_from
return {"convFrom": c_from, "convTo": c_to, "convCur": target}
@@ -0,0 +1,80 @@
"""BanGuard: квоты, паузы и защита от flood для авто-вступлений Discovery.
Суточный лимит (discJoinLimit) считается по disc_log (event='join_auto')
за текущие UTC-сутки; между вступлениями выдерживается случайная пауза
(discJoinDelayMin/Max). При FloodWait от Telegram ставится блокировка до
конца суток (discFloodDay), плюс есть ручной стоп-кран (discPaused).
"""
from __future__ import annotations
import asyncio
import random
from datetime import datetime, timezone
from ..db import store
_KEY_JOIN_LIMIT = "discJoinLimit"
_KEY_DELAY_MIN = "discJoinDelayMin"
_KEY_DELAY_MAX = "discJoinDelayMax"
_KEY_FLOOD_DAY = "discFloodDay"
_KEY_PAUSED = "discPaused"
def _start_of_day_ms() -> int:
"""Начало текущих UTC-суток в миллисекундах."""
start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
return int(start.timestamp() * 1000)
def joins_today_auto() -> int:
"""Авто-вступления за текущие UTC-сутки (disc_log, event='join_auto')."""
row = store.scalar(
"SELECT count(*) FROM disc_log WHERE event = 'join_auto' AND created_at >= ?",
[_start_of_day_ms()],
)
return int(row or 0)
def can_auto_join() -> bool:
"""Разрешено ли авто-вступление: лимит не исчерпан, нет flood на сегодня, нет паузы."""
limit = int(store.get_setting(_KEY_JOIN_LIMIT) or 0)
return joins_today_auto() < limit and not flood_today() and not global_paused()
async def wait_join_delay() -> None:
"""Случайная пауза перед авто-вступлением (сек, из настроек).
Защита инварианта min <= max: настройки мог изменить один конец интервала
(single-key PATCH), поэтому при инверсии концы меняются местами.
"""
low = float(store.get_setting(_KEY_DELAY_MIN) or 0)
high = float(store.get_setting(_KEY_DELAY_MAX) or 0)
if low <= 0 and high <= 0:
return # обе настройки не заданы — паузы нет (крайний случай)
if low > high:
low, high = high, low
await asyncio.sleep(random.uniform(low, high))
def note_flood() -> None:
"""Зафиксировать FloodWait: блокировка авто-вступлений до конца суток."""
store.set_setting(_KEY_FLOOD_DAY, _start_of_day_ms())
def flood_today() -> bool:
"""Была ли flood-блокировка в текущие UTC-сутки."""
return int(store.get_setting(_KEY_FLOOD_DAY) or 0) == _start_of_day_ms()
def global_paused() -> bool:
"""Ручной стоп-кран авто-вступлений (setting discPaused)."""
return bool(store.get_setting(_KEY_PAUSED))
def set_global_pause(v: bool) -> None:
store.set_setting(_KEY_PAUSED, bool(v))
def search_pause() -> float:
"""Пауза между поисковыми запросами Telegram (сек)."""
return random.uniform(2.0, 4.0)
@@ -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]
@@ -0,0 +1,237 @@
"""Discovery: оценка контента — язык, темы, fit сообщений под задачу поиска.
Чистая логика оценки (без карточек, очередей и обучения):
* detect_lang_ru — доля кириллических букв среди всех букв выборки;
* group_by_topic — группировка выборки по topic_id (None -> "main")
для форумов: {topic_id, title, messages: [...]};
* evaluate_message — fit одного сообщения под задачу: каскад
короткое -> ML-спам -> ИИ -> эвристика;
* evaluate_sample — агрегат по выборке сообщений кандидата;
* passed — вердикт «источник подходит» (объём выборки + доля fit).
Каскад оценки сообщения (evaluate_message):
1) текст пустой/короче 10 символов -> False «слишком короткое»;
2) ML: ml_client.is_enabled() и прогноз take + label=='spam' -> False;
3) ИИ: если включён (aiEnabled) и доступен ключ/локальный провайдер — один
JSON-вызов ai_service.chat_json({fit, reason}); любая ошибка (нет ключа,
сеть, не-JSON) ловится и оценка продолжается эвристикой;
4) эвристика: любой ключ задачи входит в clean_short(text).casefold().
task — внешний dict в camelCase (конвенция discovery.Task 3): description,
keywords (list[str]), lang, threshold, sampleSize. Иные ключи игнорируются,
поэтому сюда можно передавать и полный view задачи из discovery.get_task.
"""
from __future__ import annotations
import logging
from ..db import store
from . import ai as ai_service
from . import ml_client
from .pipeline import clean_short
log = logging.getLogger("leadradar.discovery_eval")
# пороги доли кириллицы (задача ru-языка): >= hi -> True, <= lo -> False
_RU_RATIO_HI = 0.15
_RU_RATIO_LO = 0.03
# минимальная длина сообщения для содержательной оценки
_MIN_TEXT_LEN = 10
# ограничение текста, уходящего провайдеру (в одном сообщении больше не нужно)
_AI_TEXT_LIMIT = 4000
# потолок причины из ИИ (в UI не нужны простыни)
_AI_REASON_LIMIT = 200
# длина title темы (сниппет первого текста)
_TITLE_LIMIT = 60
# topic_id=None в выборке/группировке -> общая тема ("main")
_MAIN_TOPIC = "main"
# промпт ИИ-оценки (задача: относится ли сообщение к сфере/описанию)
_AI_PROMPT = (
"Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. "
"Ключи: {keywords}. "
"Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}."
)
def _is_cyrillic(ch: str) -> bool:
"""Кириллица ли символ (базовый блок U+0400–U+04FF, включает ё/ў и т.п.)."""
return 0x0400 <= ord(ch) <= 0x04FF
def detect_lang_ru(texts: list[str]) -> bool | None:
"""Доля кириллицы среди всех букв выборки: >=0.15 True, <=0.03 False, иначе None.
None означает «неопределённо» — воркер не отсекает кандидата, а помечает
язык как неподтверждённый. Пустая выборка без букв тоже даёт None.
"""
letters = 0
cyr = 0
for t in texts or []:
for ch in str(t or ""):
if ch.isalpha():
letters += 1
if _is_cyrillic(ch):
cyr += 1
if letters == 0:
return None
ratio = cyr / letters
if ratio >= _RU_RATIO_HI:
return True
if ratio <= _RU_RATIO_LO:
return False
return None
def _topic_title(messages: list[dict]) -> str:
"""Сниппет первого непустого текста темы (<=60 симв., whitespace схлопнут)."""
for m in messages:
text = str(m.get("text") or "").strip()
if text:
text = " ".join(text.split())
return text[:_TITLE_LIMIT]
return ""
def group_by_topic(messages: list[dict]) -> list[dict]:
"""Группирует сообщения по topic_id (None -> "main"), сортирует группы по
числу сообщений (убыв.), порядок сообщений внутри группы — входной.
Возвращает [{"topic_id", "title", "messages": [...]}], где title — сниппет
первого текста темы (первое непустое сообщение во входном порядке).
"""
groups: dict[str, list[dict]] = {}
order: list[str] = []
for m in messages or []:
tid = m.get("topic_id")
key = _MAIN_TOPIC if tid is None else tid
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(m)
out = [
{"topic_id": key, "title": _topic_title(groups[key]), "messages": groups[key]}
for key in order
]
out.sort(key=lambda g: len(g["messages"]), reverse=True)
return out
def _keywords(task: dict) -> list[str]:
kws = task.get("keywords") or []
if isinstance(kws, str):
kws = [kws]
return [str(k).strip() for k in kws if str(k).strip()]
def _ai_usable() -> bool:
"""ИИ-ветка доступна: полный выключатель включён и есть ключ/локальный сервер.
Локальные OpenAI-совместимые (Ollama/LM Studio) работают без ключа, поэтому
для них проверка ключа не требуется. Если статус провайдера прочитать нельзя
(нет БД/настроек) — считаем ИИ недоступным, чтобы не дёргать сеть впустую.
"""
if not store.get_setting("aiEnabled"):
return False
try:
status = ai_service.provider_status()
except Exception as exc: # noqa: BLE001 — отсутствие статуса = ИИ недоступен
log.debug("discovery eval: статус ИИ недоступен (%s) — эвристика", exc)
return False
return bool(status.get("local") or status.get("keySet"))
def _heuristic(task: dict, text: str) -> dict:
"""Эвристика: ключ задачи входит в очищенный текст (без учёта регистра)."""
hay = clean_short(text).casefold()
for kw in _keywords(task):
if kw and kw.casefold() in hay:
return {"fit": True, "reason": f'совпал ключ "{kw}"', "source": "heuristic"}
return {"fit": False, "reason": "нет совпадений с ключами", "source": "heuristic"}
def _ai_prompt(task: dict) -> str:
description = str(task.get("description") or "").strip()
return _AI_PROMPT.format(description=description, keywords=", ".join(_keywords(task)))
def _ai_fit(out: dict) -> bool:
"""fit из JSON-ответа ИИ: 1/true/«да»-подобные -> True, иначе False."""
v = out.get("fit")
if isinstance(v, str):
s = v.strip().casefold()
return bool(s) and s not in {"0", "false", "no", "нет", "null", "none"}
return bool(v)
def _ai_reason(out: dict) -> str:
reason = str(out.get("reason") or "").strip()
if not reason:
reason = "подходит" if _ai_fit(out) else "не подходит"
return reason[:_AI_REASON_LIMIT]
async def evaluate_message(task: dict, text: str) -> dict:
"""Оценка fit одного сообщения. Возвращает {"fit", "reason", "source"}."""
raw = str(text or "")
if len(raw.strip()) < _MIN_TEXT_LEN:
return {"fit": False, "reason": "слишком короткое", "source": "heuristic"}
# ML: уверенный спам отсекаем без обращения к ИИ (когда ML доступен)
if ml_client.is_enabled():
pred = await ml_client.predict(raw)
if pred.get("take") and pred.get("label") == "spam":
return {"fit": False, "reason": "ML: спам", "source": "ml"}
# ИИ: один JSON-вызов; любая ошибка провайдера -> шаг эвристики ниже
if _ai_usable():
try:
out = await ai_service.chat_json(_ai_prompt(task), f"Сообщение:\n{raw[:_AI_TEXT_LIMIT]}")
return {"fit": _ai_fit(out), "reason": _ai_reason(out), "source": "ai"}
except Exception as exc: # noqa: BLE001 — сбой ИИ не роняет оценку
log.debug("discovery eval: ИИ не ответил (%s) — эвристика", exc)
return _heuristic(task, raw)
async def evaluate_sample(task: dict, messages: list[dict]) -> dict:
"""Последовательная оценка всех сообщений выборки кандидата.
Возвращает {"fit_count", "total", "fit_ratio", "per_message": [...]} с
per_message [{"text", "fit", "reason", "topic_id"}] (topic_id None -> "main",
чтобы per_message стыковался с ключами group_by_topic).
"""
per_message = []
fit_count = 0
for m in messages or []:
text = str(m.get("text") or "")
res = await evaluate_message(task, text)
tid = m.get("topic_id")
per_message.append(
{
"text": text,
"fit": bool(res["fit"]),
"reason": str(res["reason"]),
"topic_id": _MAIN_TOPIC if tid is None else tid,
}
)
if res["fit"]:
fit_count += 1
total = len(per_message)
return {
"fit_count": fit_count,
"total": total,
"fit_ratio": (fit_count / total) if total else 0.0,
"per_message": per_message,
}
def passed(ev: dict, task: dict) -> bool:
"""Вердикт «источник подходит»: выборки >=3 сообщений и доля fit >= threshold (%).
Сообщения не обязаны быть «чистыми»: каналы часто разбавляют полезный
контент офтопом, поэтому достаточно доли, а не сплошного соответствия.
"""
total = int(ev.get("total") or 0)
ratio = float(ev.get("fit_ratio") or 0.0)
return total >= 3 and ratio * 100 >= int(task.get("threshold") or 0)
@@ -0,0 +1,484 @@
"""Discovery worker: поиск → оценка → авто-вступление (Task 6).
Фоновый цикл main._discovery_loop вызывает `tick()` каждые ~5 секунд; каждый
вызов выполняет ОДНО действие для самой старой running-задачи и возвращает
{"action": "search"|"review"|"skip"|"join"|"reject"|"flood"|"error"|"done"|"none",
"taskId": ...}. Если работы нет — {"action": "none"}.
Приоритеты внутри tick:
0. ban_guard.global_paused() — ручной стоп-кран: возвращаем none;
1. задача достигла плана вступлений (joined >= planJoins) → status=done
(+ лог done) — занимаемый ею бюджет планов освобождается сразу;
2. шаг поиска: search_done=False → следующий ключ keywords[search_idx],
tg.discovery_search, каждый результат — discovery.add_candidate,
discovery.advance_search; при переходе search_done=True — лог search
«поиск завершён: N кандидатов» (N = счётчик found задачи). Личные чаты/
боты (kind «чат») пропускаются (лог skip). FloodWaitError → note_flood +
лог flood; прочие ошибки поиска → лог error; индекс ключей не двигается
(тик повторит ключ позже), но после 3 ошибок подряд ключ пропускается
(advance_search + лог error «ключ пропущен») — битый ключ не должен
зацикливать поиск навсегда;
2a. флуд-стоп: пока действует flood-блокировка дня (ban_guard.flood_today())
discovery-воркер полностью стоит (никаких сетевых действий поиска/оценки/
вступлений) — это и есть «стоп до конца суток» из лога flood;
3. шаг оценки: первый кандидат status='new' → tg.discovery_info (участники,
kind; форум — если is_forum), фильтр minSubscribers (меньше — delete +
лог skip; не получено — метка), tg.discovery_read: история недоступна →
review с меткой («канал…»/«закрытая группа…», контент не оцениваем),
язык (для ru-задач), evaluate_sample (форумы — по темам через
group_by_topic) → passed → review с fitRatio/topics, иначе
delete_candidate + лог skip «мало подходящих (X из N)»;
4. авто-вступление (отдельный проход, приоритет ниже оценки): у running-
задачи с autoJoin и кандидатом status='review', если
ban_guard.can_auto_join() → повторная проверка «мы не состоим»
(dialogs/disc_blacklist — могли вступить между оценкой и join) → если
уже состоим/в чёрном списке — discovery.mark_rejected + лог;
ban_guard.wait_join_delay() (5070 с — спейсинг авто-вступлений), ПОСЛЕ
паузы кандидат перечитывается — join выполняется, только если запись
ещё есть и в review, задача ещё running с autoJoin и мы не состоим
(иначе — тик выходит без join);
tg.discovery_join(username) → discovery.mark_joined(auto=True) →
tg.add_dialog_monitored(...) → tg.backfill_dialog(dialog_id) →
discovery.remove_blacklist. FloodWaitError → ban_guard.note_flood()
+ лог flood (кандидат остаётся review); прочие ошибки join →
join_failures += 1, лог error, кандидат остаётся review для повтора
(ретраи ограничены: после 3-й неудачи кандидат удаляется, лог skip).
Метки кандидата (marks) — список строк-чипов:
«участники не подтверждены», «язык не подтверждён»,
«канал: история недоступна», «закрытая группа (история скрыта) — вступите сами»,
«мало сообщений».
Темы форума (topics) — список dict (заполняется только для kind='forum'):
{"topicId": str|int, "title": str, "fitCount": int, "total": int,
"fitRatio": float, "passed": bool}
fit каждой темы считается по своей выборке (evaluate_sample + passed);
общий вердикт форума — есть хотя бы одна проходная тема. fitRatio кандидата
— агрегат по всей выборке (fit из N). Для не-форумов topics не заполняется.
"""
from __future__ import annotations
import logging
import time
from contextlib import suppress
from telethon.errors.rpcerrorlist import FloodWaitError
from ..db import store
from . import ban_guard, discovery
from . import discovery_eval as eval_svc
from .telegram import tg
log = logging.getLogger("leadradar.discovery_worker")
_ACTION_NONE = {"action": "none"}
# минимальный объём содержательной выборки для вердикта оценки
_MIN_CONTENT = 3
# ошибки поиска одного ключа подряд, после которых ключ пропускается
_SEARCH_ERRORS_TO_SKIP = 3
# счётчик ошибок поиска по задачам (память процесса; при рестарте сбрасывается)
_search_errors: dict[str, int] = {}
# метки кандидата (marks) — чипы в UI
_MARK_PARTICIPANTS = "участники не подтверждены"
_MARK_LANG = "язык не подтверждён"
_MARK_CHANNEL_NO_HISTORY = "канал: история недоступна"
_MARK_CLOSED_GROUP = "закрытая группа (история скрыта) — вступите сами"
_MARK_FEW_MESSAGES = "мало сообщений"
# kind из Telegram (_kind_of: канал/группа/чат) → код кандидата (channel/group/forum)
_KIND_RU_TO_CODE = {"канал": "channel", "группа": "group", "чат": "group"}
def _now_ms() -> int:
return time.time_ns() // 1_000_000
def _kind_code(kind: str, is_forum: bool = False) -> str:
"""Нормализация kind источника: 'channel'|'group'|'forum' (см. _KIND_RU_TO_CODE)."""
if is_forum:
return "forum"
code = str(kind or "").strip().casefold()
if code in ("channel", "group", "forum"):
return code
return _KIND_RU_TO_CODE.get(code, "group")
def _running_tasks() -> list[dict]:
"""Running-задачи, старые первыми (list_tasks сортирует по created_at)."""
return [t for t in discovery.list_tasks() if t["status"] == "running"]
def _we_are_in(dialog_id: str) -> bool:
"""Уже состоим/отклонили: источник в dialogs или в чёрном списке.
Повторная проверка «мы не состоим» перед авто-вступлением (диалог мог
появиться между оценкой кандидата и join). dialog_id — подписанный peer id,
как в dialogs.id (конвенция discovery_search, Task 4).
"""
in_dialogs = store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id])
in_blacklist = store.scalar("SELECT 1 FROM disc_blacklist WHERE dialog_id = ? LIMIT 1", [dialog_id])
return bool(in_dialogs or in_blacklist)
def _finish_done(task: dict) -> None:
"""Задача выполнила план вступлений: status=done + лог done."""
store.execute(
"UPDATE disc_tasks SET status = 'done', updated_at = ? WHERE id = ?",
[_now_ms(), task["id"]],
)
discovery.add_log(
task["id"],
"done",
f"план выполнен: вступили {task['joined']} из {task['planJoins']}",
)
def _close_search(task_id: str) -> None:
"""Закрыть проход по ключам (пустой список ключей / индекс за границей)."""
discovery.advance_search(task_id)
task = discovery.get_task(task_id)
if task and task["searchDone"]:
discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов")
def _log_search_done(task_id: str) -> None:
"""Лог завершения поиска, если advance_search перевёл задачу в search_done."""
task = discovery.get_task(task_id)
if task and task["searchDone"]:
discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов")
def _finish_review(
task_id: str,
dialog_id: str,
*,
marks: list[str],
lang_ru: bool | None = None,
fit_ratio: float | None = None,
topics: list[dict] | None = None,
) -> None:
"""Перевести кандидата в review с метками/оценкой (статус пишет лог review)."""
patch: dict = {"marks": [m for m in marks if m]}
if lang_ru is not None:
patch["langRu"] = lang_ru
if fit_ratio is not None:
patch["fitRatio"] = fit_ratio
if topics is not None:
patch["topics"] = topics
discovery.set_candidate(task_id, dialog_id, patch)
discovery.set_candidate_status(dialog_id, "review")
# ─── шаги tick ─────────────────────────────────────────────────────────────
async def _search_step(task: dict) -> dict:
"""Шаг поиска: один ключ keywords[searchIdx] → кандидаты + advance_search."""
task_id = task["id"]
keywords = list(task.get("keywords") or [])
idx = int(task.get("searchIdx") or 0)
if not keywords or idx >= len(keywords):
# ключи закончились/пустой список: закрываем проход без сетевого вызова
_close_search(task_id)
return {"action": "search", "taskId": task_id}
keyword = keywords[idx]
try:
results = await tg.discovery_search(keyword)
except FloodWaitError:
# флуд: стоп авто-вступлений до конца суток; ключ не двигаем — повторим позже
ban_guard.note_flood()
discovery.add_log(task_id, "flood", f"поиск «{keyword}»: flood — стоп до конца суток")
return {"action": "flood", "taskId": task_id}
except Exception as exc: # noqa: BLE001 — сбой поиска не двигает индекс ключей
errors = _search_errors.get(task_id, 0) + 1
if errors >= _SEARCH_ERRORS_TO_SKIP:
# 3 ошибки подряд одного ключа: пропускаем (битый ключ не должен
# зацикливать поиск и блокировать оценку/вступления других задач)
_search_errors.pop(task_id, None)
discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc} — ключ пропущен ({errors} ошибки подряд)")
discovery.advance_search(task_id)
_log_search_done(task_id)
return {"action": "error", "taskId": task_id}
_search_errors[task_id] = errors
discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc}")
return {"action": "error", "taskId": task_id}
_search_errors.pop(task_id, None) # успешный поиск — сброс счётчика ошибок ключа
for item in results:
kind_raw = str(item.get("kind") or "").strip().casefold()
name = item.get("name") or ""
if kind_raw == "чат":
# люди/личные чаты и боты глобальным поиском не предлагаются
discovery.add_log(task_id, "skip", f"{name}: личный чат/бот")
continue
discovery.add_candidate(
task_id,
str(item.get("id") or ""),
name,
item.get("username") or "",
_kind_code(kind_raw),
item.get("hue") or "#666",
)
discovery.advance_search(task_id)
_log_search_done(task_id)
return {"action": "search", "taskId": task_id}
async def _eval_step(task: dict, cand: dict) -> dict:
"""Шаг оценки первого кандидата status='new' (все ветки — одно действие)."""
task_id = task["id"]
dialog_id = cand["dialogId"]
marks: list[str] = []
# ── инфо об источнике: kind/forum, участники, имя/username ────────────
info = await tg.discovery_info(dialog_id)
is_forum = bool(info.get("is_forum"))
resolved = info.get("kind") or is_forum
kind = _kind_code(info.get("kind", ""), is_forum) if resolved else (cand["kind"] or "channel")
cand_patch: dict = {
"kind": kind,
"hue": info.get("hue") or "",
"participants": info.get("participants"),
}
if resolved:
# имя/username обновляем только при успешном резолве: при fallback
# discovery_info возвращает name=dialog_id и не должен затирать имя
cand_patch["name"] = info.get("name") or ""
cand_patch["username"] = info.get("username") or ""
discovery.set_candidate(task_id, dialog_id, cand_patch)
participants = info.get("participants")
# ── фильтр minSubscribers ──────────────────────────────────────────────
min_sub = int(task.get("minSubscribers") or 0)
if min_sub > 0:
if participants is None:
marks.append(_MARK_PARTICIPANTS)
elif int(participants) < min_sub:
discovery.delete_candidate(dialog_id)
discovery.add_log(
task_id,
"skip",
f"{dialog_id}: мало участников ({participants} < {min_sub})",
)
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
# ── чтение истории для оценки ──────────────────────────────────────────
read = await tg.discovery_read(dialog_id, int(task.get("sampleSize") or 10))
if not read.get("ok"):
# история недоступна без членства: контент не оцениваем, фильтры помечаем
marks.append(_MARK_CHANNEL_NO_HISTORY if kind == "channel" else _MARK_CLOSED_GROUP)
if task.get("lang") == "ru":
marks.append(_MARK_LANG)
_finish_review(task_id, dialog_id, marks=marks)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
messages = read.get("messages") or []
# ── язык (только для ru-задач) ─────────────────────────────────────────
lang_ru: bool | None = None
if task.get("lang") == "ru":
lang_ru = eval_svc.detect_lang_ru([str(m.get("text") or "") for m in messages])
if lang_ru is False:
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: язык не русский")
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
if lang_ru is None:
marks.append(_MARK_LANG)
# ── объём выборки: меньше 3 содержательных — решает человек ────────────
if len(messages) < _MIN_CONTENT:
marks.append(_MARK_FEW_MESSAGES)
_finish_review(task_id, dialog_id, marks=marks, lang_ru=lang_ru)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
# ── оценка содержания (форумы — по темам) ──────────────────────────────
fit_count, total, fit_ratio, topics, ok = await _evaluate_content(task, kind, messages)
if ok:
_finish_review(
task_id,
dialog_id,
marks=marks,
lang_ru=lang_ru,
fit_ratio=fit_ratio,
topics=topics if kind == "forum" else None,
)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: мало подходящих ({fit_count} из {total})")
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
async def _evaluate_content(task: dict, kind: str, messages: list[dict]) -> tuple[int, int, float, list[dict], bool]:
"""evaluate_sample по выборке кандидата.
Не-форум: один прогон по всем сообщениям, topics пуст, вердикт — passed().
Форум: прогон по каждой теме (group_by_topic), topics заполняется
({topicId, title, fitCount, total, fitRatio, passed}), вердикт — есть хотя
бы одна проходная тема; fitRatio — агрегат fit из N по всей выборке.
"""
if kind == "forum":
topics: list[dict] = []
fit_count = 0
total = 0
any_passed = False
for group in eval_svc.group_by_topic(messages):
ev = await eval_svc.evaluate_sample(task, group["messages"])
t_ok = eval_svc.passed(ev, task)
fit_count += int(ev.get("fit_count") or 0)
total += int(ev.get("total") or 0)
topics.append(
{
"topicId": group["topic_id"],
"title": group["title"] or "",
"fitCount": int(ev.get("fit_count") or 0),
"total": int(ev.get("total") or 0),
"fitRatio": float(ev.get("fit_ratio") or 0.0),
"passed": bool(t_ok),
}
)
any_passed = any_passed or bool(t_ok)
return fit_count, total, (fit_count / total) if total else 0.0, topics, any_passed
ev = await eval_svc.evaluate_sample(task, messages)
return (
int(ev.get("fit_count") or 0),
int(ev.get("total") or 0),
float(ev.get("fit_ratio") or 0.0),
[],
eval_svc.passed(ev, task),
)
async def _join_step(task: dict, cand: dict) -> dict:
"""Шаг авто-вступления одного кандидата status='review'."""
task_id = task["id"]
dialog_id = cand["dialogId"]
# между оценкой и вступлением могли вступить/отклонить источник
if _we_are_in(dialog_id):
already = bool(store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id]))
reason = (
"уже вступили между оценкой и авто-вступлением"
if already
else "источник в чёрном списке (повторная проверка перед авто-вступлением)"
)
with suppress(KeyError, ValueError):
discovery.mark_rejected(dialog_id, reason=reason)
return {"action": "reject", "taskId": task_id}
# спейсинг авто-вступлений (сек из настроек discJoinDelayMin/Max)
await ban_guard.wait_join_delay()
# за время паузы задача/кандидат/состояние BanGuard могли измениться:
# вступаем только если кандидат всё ещё есть и в review, задача ещё running
# с autoJoin, мы не состоим и авто-вступления по-прежнему разрешены (стоп-
# кран/flood/лимит могли включиться во время паузы) — иначе выходим без join
fresh = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
task_now = discovery.get_task(task_id)
if (
fresh is None
or fresh["status"] != "review"
or task_now is None
or task_now["status"] != "running"
or not task_now["autoJoin"]
or _we_are_in(dialog_id)
or not ban_guard.can_auto_join()
):
return _ACTION_NONE
username = str(fresh.get("username") or "").strip().lstrip("@")
try:
await tg.discovery_join(username)
except FloodWaitError:
ban_guard.note_flood() # идемпотентно: discovery_join тоже фиксирует флуд
discovery.add_log(task_id, "flood", f"авто-вступление {dialog_id}: flood — стоп до конца суток")
return {"action": "flood", "taskId": task_id}
except Exception as exc: # noqa: BLE001 — ретраи ограничены счётчиком join_failures
# между паузой и неудачным join кандидата могли отклонить/удалить:
# счётчик и удаление трогаем только у живой записи в статусе review
row_now = store.query_one(
"SELECT status FROM disc_candidates WHERE dialog_id = ?", [dialog_id]
)
if not row_now or row_now["status"] != "review":
return _ACTION_NONE
failures = int(fresh.get("join_failures") or 0) + 1
store.execute(
"UPDATE disc_candidates SET join_failures = ?, updated_at = ? "
"WHERE dialog_id = ? AND status = 'review'",
[failures, _now_ms(), dialog_id],
)
if failures >= 3:
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: не удалось вступить (3 попытки): {exc}")
return {"action": "skip", "taskId": task_id}
discovery.add_log(task_id, "error", f"авто-вступление {dialog_id}: {exc}")
return {"action": "error", "taskId": task_id}
discovery.mark_joined(dialog_id, auto=True)
tg.add_dialog_monitored(
dialog_id,
fresh.get("name"),
username,
fresh.get("kind"),
fresh.get("hue"),
)
# разбор последних сообщений источника (спейсинг/read-ack внутри метода);
# вступление уже состоялось — сбой backfill не роняет шаг
try:
await tg.backfill_dialog(dialog_id)
except Exception as exc: # noqa: BLE001
log.warning("join %s: backfill не удался: %s", dialog_id, exc)
discovery.remove_blacklist(dialog_id)
return {"action": "join", "taskId": task_id}
# ─── tick ──────────────────────────────────────────────────────────────────
async def tick() -> dict:
"""Одно действие discovery-воркера (см. docstring модуля)."""
if ban_guard.global_paused():
return _ACTION_NONE
if ban_guard.flood_today():
# флуд-блокировка дня: никаких сетевых действий (поиск/оценка/join),
# пока действует discFloodDay — воркер просто стоит
return _ACTION_NONE
running = _running_tasks()
if not running:
return _ACTION_NONE
# 1. план достигнут — закрываем задачу (важно до поиска/оценки/join:
# задачу с выполненным планом нельзя продолжать обрабатывать)
for task in running:
if int(task["joined"]) >= int(task["planJoins"]):
_finish_done(task)
return {"action": "done", "taskId": task["id"]}
# 2. поиск: следующая running-задача с незавершённым проходом по ключам
for task in running:
if not task["searchDone"]:
return await _search_step(task)
# 3. оценка: первый кандидат status='new' (самая старая задача — первой)
for task in running:
new_cands = discovery.list_candidates(task["id"], status="new")
if new_cands:
return await _eval_step(task, new_cands[0])
# 4. авто-вступление: отдельный проход, приоритет ниже оценки
for task in running:
if not task["autoJoin"]:
continue
review_cands = discovery.list_candidates(task["id"], status="review")
if review_cands:
if not ban_guard.can_auto_join():
return _ACTION_NONE # суточный лимит/флуд/пауза — join никому нельзя
return await _join_step(task, review_cands[0])
return _ACTION_NONE
@@ -0,0 +1,100 @@
"""Вложения проектных карточек.
По решению: файлы хранятся в MinIO (креды в env), в БД — метаданные и ключ
объекта. Тип определяется автоматически по MIME и расширению.
"""
from __future__ import annotations
import json
import time
from ..db import store
KIND_BY_EXT = {
"image": {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "heic"},
"video": {"mp4", "mov", "avi", "mkv", "webm", "m4v"},
"audio": {"mp3", "wav", "ogg", "m4a", "flac", "aac"},
"archive": {"zip", "rar", "7z", "tar", "gz", "bz2"},
"document": {"pdf", "doc", "docx", "xls", "xlsx", "csv", "txt", "md", "rtf", "ppt", "pptx", "odt", "ods"},
}
KIND_LABELS = {
"image": "Изображение",
"video": "Видео",
"audio": "Аудио",
"archive": "Архив",
"document": "Документ",
"other": "Файл",
}
def detect(name: str, mime: str = "") -> dict:
ext = (name.split(".")[-1] if "." in name else "").lower()
kind = "other"
if mime.startswith("image/"):
kind = "image"
elif mime.startswith("video/"):
kind = "video"
elif mime.startswith("audio/"):
kind = "audio"
else:
for k, exts in KIND_BY_EXT.items():
if ext in exts:
kind = k
break
return {"kind": kind, "label": KIND_LABELS[kind]}
def _files_of(card_id: str) -> list[dict]:
from .projects import patch_card
row = store.query_one("SELECT files FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
return json.loads(row["files"] or "[]")
def add_file(card_id: str, name: str, data: bytes, mime: str = "") -> dict:
"""Сохраняет тело в MinIO и метаданные в карточку."""
from . import object_store
from .projects import patch_card
files = _files_of(card_id)
info = detect(name, mime)
object_key = object_store.put(card_id, name, data, mime)
entry = {
"id": store.uid("pf_"),
"name": name,
"size": len(data),
"kind": info["kind"],
"label": info["label"],
"objectKey": object_key,
}
files.append(entry)
patch_card(card_id, {"files": files})
return entry
def get_file_entry(card_id: str, file_id: str) -> tuple[dict, list[dict]]:
files = _files_of(card_id)
entry = next((f for f in files if f["id"] == file_id), None)
if not entry:
raise KeyError(file_id)
return entry, files
def remove_file(card_id: str, file_id: str) -> None:
from . import object_store
from .projects import patch_card
files = _files_of(card_id)
entry = next((f for f in files if f["id"] == file_id), None)
if entry and entry.get("objectKey"):
object_store.remove(entry["objectKey"])
patch_card(card_id, {"files": [f for f in files if f["id"] != file_id]})
def object_storage_available() -> bool:
from . import object_store
return object_store.configured()
@@ -0,0 +1,103 @@
"""Полнотекстовый поиск DuckDB FTS (по решению — включаем).
DuckDB FTS строит виртуальную таблицу-снимок: индекс пересоздаётся при
старте, раз в сутки по расписанию и вручную (POST /api/admin/fts/rebuild).
Чтобы свежесозданные карточки искались сразу, поиск совмещает FTS с
точечным LIKE-дополнением (см. services/leads.search).
Рабочий синтаксис DuckDB >= 1.0: только PRAGMA create_fts_index
(вариант CREATE VIRTUAL TABLE ... USING fts(...) в этой версии падает).
Поиск — через табличную функцию fts_main_<table>.match_bm25(rowid, query).
"""
from __future__ import annotations
import logging
from ..db import store
log = logging.getLogger("leadradar.fts")
OK_KEY = "ftsOk"
# Источник, колонка-rowid, текстовые колонки (должны совпадать со схемой db.py)
_FTS_TARGETS = [
("leads", ("title", "summary", "source_msg", "contact")),
("messages", ("text",)),
("rejected_msgs", ("text",)),
]
def is_ready() -> bool:
return bool(store.get_setting(OK_KEY))
def _set(ok: bool) -> None:
store.set_setting(OK_KEY, ok)
def install_extension() -> bool:
try:
store.execute("INSTALL fts")
store.execute("LOAD fts")
return True
except Exception as exc: # noqa: BLE001
log.warning("FTS extension unavailable: %s", exc)
return False
def rebuild() -> bool:
"""Пересоздаёт FTS-индексы по leads и messages (overwrite поверх старого).
PRAGMA не поддерживает prepared-параметры, поэтому имена подставляются
напрямую — это внутренние константы из _FTS_TARGETS, не пользовательский ввод.
"""
try:
for table, cols in _FTS_TARGETS:
col_list = ", ".join(f"'{c}'" for c in cols)
store.execute(
f"PRAGMA create_fts_index('{table}', 'id', {col_list}, "
"stemmer='russian', overwrite=1)"
)
_set(True)
log.info("FTS rebuild done")
return True
except Exception as exc: # noqa: BLE001
log.warning("FTS rebuild failed: %s", exc)
_set(False)
return False
def search(query: str, limit: int = 50) -> dict:
"""Возвращает {'leads': [ids], 'messages': [ids], 'rejected': [ids]} по FTS
(пусто при сбое)."""
out: dict = {"leads": [], "messages": [], "rejected": []}
if not is_ready():
return out
q = _sanitize(query)
if not q:
return out
targets = ("leads", "messages", "rejected_msgs")
keys = {"leads": "leads", "messages": "messages", "rejected_msgs": "rejected"}
for table in targets:
try:
# fts_main_<table> создаётся PRAGMA create_fts_index над таблицей
fn = f"fts_main_{table}.match_bm25(id, ?)"
rows = store.query(
f"SELECT id, {fn} AS _s FROM {table} "
f"WHERE {fn} IS NOT NULL ORDER BY _s LIMIT ?",
[q, q, limit * 2],
)
out[keys[table]] = [r["id"] for r in rows]
except Exception as exc: # noqa: BLE001
log.warning("fts %s query failed: %s", table, exc)
return out
def _sanitize(query: str) -> str:
"""Минимальная очистка запроса от служебных символов FTS."""
import re
q = query.strip().lower()
q = re.sub(r'["\'\\()!*+-]', " ", q)
q = " ".join(q.split())
return q[:80]
@@ -0,0 +1,551 @@
"""Доски, колонки и карточки дашборда (п.4.4, 4.5, 4.7 ТЗ).
Сюда же входят правила хранения (автоархив/очистка) и обучающие примеры
(действия пользователя -> learning_log).
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from .. import constants as C
from ..db import store
from ..sse import broker
from . import fts as fts_svc
from . import ml_client
from . import processing as processing_svc
from .pipeline import (
build_contacts,
clean_block,
clean_short,
compose_summary,
lead_to_dict,
normalize_stack,
primary_contact,
qualify_contact,
)
from .rules import board_accepts, extract_amounts, has_active_rules, hits_for_board
log = logging.getLogger("leadradar.leads")
KANBAN_COLS = ("inbox",) # + доски
def _now() -> int:
return time.time_ns() // 1_000_000
def _log_learning(lead_id: str, action: str, from_col: str | None, to_col: str | None) -> None:
store.execute(
"INSERT INTO learning_log(id, lead_id, action, from_col, to_col, created_at) VALUES (?, ?, ?, ?, ?, ?)",
[store.uid("lm_"), lead_id, action, from_col, to_col, _now()],
)
# ─── Доски / колонки ──────────────────────────────────────────────────────
def list_boards() -> list[dict]:
rows = store.query("SELECT * FROM boards ORDER BY suggested, pos")
return [
{
"id": r["id"],
"name": r["name"],
"description": r["description"] or "",
"color": r["color"],
"width": r["width"],
"collapsed": bool(r["collapsed"]),
"keywords": json.loads(r["keywords"] or "[]"),
"prompt": r["prompt"] or "",
"visibleFields": json.loads(r["visible_fields"] or "[]"),
"suggested": bool(r["suggested"]),
"rules": json.loads(r["rules"] or "{}"),
"note": r["note"] or "",
}
for r in rows
]
def board_by_id(board_id: str) -> dict | None:
return next((b for b in list_boards() if b["id"] == board_id), None)
def create_board(
name: str,
color: str | None = None,
keywords: list | None = None,
prompt: str = "",
description: str = "",
suggested: bool = False,
rules: dict | None = None,
note: str = "",
) -> dict:
"""Создаёт колонку. suggested=TRUE — ИИ-предложение, ждёт решения пользователя."""
board_id = store.uid("b_")
pos = int(store.scalar("SELECT COALESCE(MAX(pos), -1) + 1 FROM boards"))
store.execute(
"INSERT INTO boards(id, name, description, color, width, pos, keywords, prompt, visible_fields, collapsed, suggested, rules, note, created_at) "
"VALUES (?, ?, ?, ?, 'md', ?, ?, ?, '[\"budget\",\"stack\",\"contacts\"]', FALSE, ?, ?, ?, ?)",
[
board_id,
name.strip() or "Новая колонка",
(description or "").strip(),
color or C.PALETTE[pos % len(C.PALETTE)],
pos,
json.dumps(keywords or [], ensure_ascii=False),
prompt or "",
bool(suggested),
json.dumps(rules or {}, ensure_ascii=False),
note or "",
_now(),
],
)
return {"id": board_id}
def patch_board(board_id: str, patch: dict) -> dict:
row = store.query_one("SELECT * FROM boards WHERE id = ?", [board_id])
if not row:
raise KeyError(board_id)
allowed = {"name", "description", "color", "width", "collapsed", "prompt", "suggested", "note"}
for key in allowed:
if key in patch and patch[key] is not None:
store.execute(f"UPDATE boards SET {key} = ? WHERE id = ?", [patch[key], board_id])
if "keywords" in patch and patch["keywords"] is not None:
store.execute("UPDATE boards SET keywords = ? WHERE id = ?", [json.dumps(patch["keywords"], ensure_ascii=False), board_id])
if "visibleFields" in patch and patch["visibleFields"] is not None:
store.execute("UPDATE boards SET visible_fields = ? WHERE id = ?", [json.dumps(patch["visibleFields"], ensure_ascii=False), board_id])
if "rules" in patch and patch["rules"] is not None:
store.execute("UPDATE boards SET rules = ? WHERE id = ?", [json.dumps(patch["rules"], ensure_ascii=False), board_id])
return {"id": board_id}
def delete_board(board_id: str) -> int:
"""Карточки доски уходят в «Неразобранное» (с пометкой новых)."""
leads = store.query("SELECT id FROM leads WHERE col = ?", [board_id])
for l in leads:
store.execute("UPDATE leads SET col = 'inbox', is_new = TRUE, prev_col = 'inbox' WHERE id = ?", [l["id"]])
store.execute("DELETE FROM boards WHERE id = ?", [board_id])
return len(leads)
def reorder_boards(order: list[str]) -> None:
for i, board_id in enumerate(order):
store.execute("UPDATE boards SET pos = ? WHERE id = ?", [i, board_id])
def get_col_state() -> dict:
return store.get_setting("colState") or {}
def set_col_state(col_id: str, state: dict) -> dict:
current = store.get_setting("colState") or {}
current[col_id] = state
store.set_setting("colState", current)
return current[col_id]
# ─── Лиды ─────────────────────────────────────────────────────────────────
def list_leads(col: str | None = None) -> list[dict]:
if col:
rows = store.query("SELECT id FROM leads WHERE col = ? ORDER BY received_at DESC", [col])
else:
rows = store.query("SELECT id FROM leads WHERE col NOT IN ('taken') ORDER BY received_at DESC")
return [lead_to_dict(r["id"]) for r in rows]
def get_lead(lead_id: str) -> dict | None:
return lead_to_dict(lead_id) or None
def _move(lead_id: str, to_col: str, action: str = "move") -> None:
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
if not lead or lead["col"] == to_col:
return
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
# при переносе пересчитываем «почему карточка в колонке» (для архив/корзина — пусто)
hits = hits_for_board(to_col, text) if to_col not in ("inbox", "trash", "archive") else []
store.execute(
"UPDATE leads SET col = ?, is_new = FALSE, prev_col = ?, match_hits = ? WHERE id = ?",
[to_col, lead["col"], json.dumps(hits, ensure_ascii=False), lead_id],
)
_log_learning(lead_id, action, lead["col"], to_col)
def move_lead(lead_id: str, to_col: str, teach: bool = True) -> None:
"""Перенос между канбаном (Неразобранное и доски); архив/корзина не цели переноса.
teach=False — «тихое» перемещение без обучения (используется при ручной
разметке в ML-лаборатории, где обучение кладётся явно одним событием).
"""
if to_col not in ("inbox",) and store.query_one("SELECT 1 FROM boards WHERE id = ?", [to_col]) is None:
raise ValueError("Переносить можно только на доски или в «Неразобранное»")
lead = store.query_one("SELECT source_msg, title, col FROM leads WHERE id = ?", [lead_id])
_move(lead_id, to_col)
# ML обучается всегда: текст -> выбранная доска
if teach and lead and to_col != "inbox" and to_col != lead["col"]:
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
if text:
ml_client.push(text, to_col)
def trash_lead(lead_id: str, teach: bool = True) -> None:
lead = store.query_one("SELECT source_msg, title, col FROM leads WHERE id = ?", [lead_id])
_move(lead_id, "trash", action="trash")
# «в корзину» = спам/не то: ML запоминает (обучение всегда)
if teach and lead and lead["col"] != "trash" and lead["col"] != "archive":
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
if text:
ml_client.push(text, "spam")
def restore_lead(lead_id: str) -> str:
"""Возврат из архива/корзины — только на канбан."""
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
if not lead:
raise KeyError(lead_id)
back = lead["prev_col"] if lead["prev_col"] in ("inbox",) or store.query_one("SELECT 1 FROM boards WHERE id = ?", [lead["prev_col"]]) else "inbox"
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
hits = hits_for_board(back, text) if back not in ("inbox", "trash", "archive") else []
store.execute(
"UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox', archived_at = NULL, match_hits = ? WHERE id = ?",
[back, json.dumps(hits, ensure_ascii=False), lead_id],
)
_log_learning(lead_id, "restore", lead["col"], back)
# возврат из корзины = не спам: снимаем метку
if lead["col"] == "trash":
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
if text:
ml_client.push(text, "spam", delta=-1.0)
return back
def _hard_delete(lead_id: str) -> None:
"""Полное удаление карточки: leads + dedup (иначе «сирота» заблокирует
повторное создание той же карточки при перечитывании) + отвязка исходника."""
store.execute("DELETE FROM leads WHERE id = ?", [lead_id])
store.execute("DELETE FROM dedup WHERE lead_id = ?", [lead_id])
store.execute("UPDATE messages SET lead_id = NULL WHERE lead_id = ?", [lead_id])
def delete_forever(lead_id: str) -> None:
_hard_delete(lead_id)
def clear_col(col: str) -> int:
"""Полная ручная очистка служебной колонки (корзина/архив) — безвозвратно."""
if col not in ("trash", "archive"):
raise ValueError("Очищать можно только корзину или архив")
ids = [r["id"] for r in store.query("SELECT id FROM leads WHERE col = ?", [col])]
if not ids:
return 0
store.execute("DELETE FROM leads WHERE col = ?", [col])
for lead_id in ids:
_hard_delete(lead_id)
return len(ids)
def mark_seen(lead_id: str | None = None, col: str | None = None) -> None:
if lead_id:
store.execute("UPDATE leads SET is_new = FALSE WHERE id = ?", [lead_id])
elif col:
store.execute("UPDATE leads SET is_new = FALSE WHERE col = ?", [col])
else:
store.execute("UPDATE leads SET is_new = FALSE")
def add_comment(lead_id: str, text: str) -> list[dict]:
lead = store.query_one("SELECT comments FROM leads WHERE id = ?", [lead_id])
comments = json.loads(lead["comments"] or "[]")
comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"})
store.execute("UPDATE leads SET comments = ? WHERE id = ?", [json.dumps(comments, ensure_ascii=False), lead_id])
_log_learning(lead_id, "comment", None, None)
return comments
def counts() -> dict:
"""Счётчики по колонкам, новые + статистика ML/ИИ (локальная, без HTTP)."""
out = {"new": 0}
rows = store.query("SELECT col, count(*) AS cnt, sum(CASE WHEN is_new THEN 1 ELSE 0 END) AS fresh FROM leads GROUP BY col")
for r in rows:
out[r["col"]] = {"count": r["cnt"], "new": r["fresh"] or 0}
out["new"] = sum((v["new"] for k, v in out.items() if isinstance(v, dict)), 0)
snap = ml_client.snapshot()
out["learning"] = snap["learning"]
out["ml"] = snap["ml"]
out["ai"] = snap["ai"]
return out
# ─── Пакетная переклассификация (Inbox) ──────────────────────────────────
# Фоновая задача переклассификации (одна; повторный вызов возвращает busy)
_reclassify_task: object | None = None
def reclassify_busy() -> bool:
return bool(_reclassify_task and not _reclassify_task.done())
async def reclassify_lead(lead_id: str) -> dict | None:
"""Прогнать карточку «Неразобранного» через полный конвейер ИИ.
Этап 2 (ИИ-фильтр) + классификатор; мусор/спам/служебные сообщения
отправляются в корзину (с обучением ML). Вернувшееся None — карточка
без исходного текста или не найденная.
"""
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
if not lead or not lead["source_msg"]:
return None
from .ai import budget_to_target, classify, clean_budget, filter_incoming
text = lead["source_msg"]
try:
r2 = await filter_incoming(text)
except Exception as exc: # noqa: BLE001
log.warning("reclassify filter fail %s: %s", lead_id, exc)
r2 = {"pass": True, "reason": None, "skipped": True}
if not r2["pass"]:
trash_lead(lead_id, teach=False)
ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT)
return {"status": "trashed", "reason": str(r2.get("reason") or "не прошло ИИ-фильтр")[:120]}
raw = await classify(text)
if raw.get("is_spam"):
trash_lead(lead_id, teach=False)
ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT)
return {"status": "trashed", "reason": "ИИ: не заявка/спам"}
board_id = str(raw.get("board") or "").strip() or None
# страховка: колонку с активными правилами может назначить только текст,
# прошедший эти правила (иначе ручная переклассификация закидывает хлам)
if board_id and not board_accepts(board_id, text):
board_id = None
budget = clean_budget(raw.get("budget"))
contacts = build_contacts(raw.get("contacts"), text)
contact = primary_contact(contacts)[:200]
if not contact:
# старый контакт оставляем только если он валидный (@, телефон, почта…),
# а не мусорная фраза из старого разбора
old = str(lead["contact"] or "").strip()[:200]
contact = old if old and qualify_contact(old) else ""
stack = normalize_stack(raw.get("stack"))
new_title = clean_short(raw.get("title") or "", 140) or clean_short(lead["title"], 140)
new_summary = clean_block(compose_summary(raw, text), 2000) or clean_block(lead["summary"], 2000)
if not budget:
# ИИ не выделил бюджет полем, но сумма с валютой есть в исходнике или
# в структурированной «О заявке» — показываем её на карточке.
for src in (text, new_summary):
amts = extract_amounts(src or "")
if amts:
a = amts[0]
budget = {"from": a["from"], "to": a["to"], "currency": a["cur"]}
break
conv = budget_to_target(budget)
hits = hits_for_board(board_id, text) if board_id else []
store.execute(
"UPDATE leads SET col = ?, title = ?, summary = ?, is_vacancy = ?, is_vacancy_known = TRUE, "
"stack = ?, budget_from = ?, budget_to = ?, budget_cur = ?, "
"conv_from = ?, conv_to = ?, conv_cur = ?, contact = ?, contacts = ?, "
"match_hits = ?, is_new = TRUE "
"WHERE id = ?",
[
board_id or "inbox",
new_title,
new_summary,
bool(raw.get("is_vacancy")),
json.dumps(stack, ensure_ascii=False),
budget.get("from") if budget else None,
budget.get("to") if budget else None,
budget.get("currency", "") if budget else "",
conv["convFrom"], conv["convTo"], conv["convCur"],
contact,
json.dumps(contacts, ensure_ascii=False),
json.dumps(hits, ensure_ascii=False),
lead_id,
],
)
# ИИ-решение при переклассификации — тоже обучающий сигнал для ML.
# Учим только «свободные» колонки (без активных правил, не suggested):
# именно их ML может назначать сама в своём пути.
if board_id:
br = store.query_one("SELECT suggested, rules FROM boards WHERE id = ?", [board_id])
free = False
if br and not bool(br["suggested"]):
try:
br_rules = json.loads(br["rules"] or "{}") if br["rules"] else {}
except Exception: # noqa: BLE001
br_rules = {}
free = not has_active_rules(br_rules)
if free:
ml_client.push(text, board_id, delta=ml_client.AI_WEIGHT)
# тип известен от ИИ — учим ML определять его сам (t:hire / t:order)
if not bool(raw.get("is_spam")):
ml_client.push(
text,
"t:hire" if bool(raw.get("is_vacancy")) else "t:order",
delta=ml_client.AI_WEIGHT,
)
return {"status": "moved" if board_id else "kept"}
async def reclassify_inbox(ids: list[str] | None = None) -> dict:
"""Переклассифицировать «Неразобранное» (все карточки или выбранные)."""
rows = store.query("SELECT id FROM leads WHERE col = 'inbox'")
if ids:
wanted = set(ids)
target = [r["id"] for r in rows if r["id"] in wanted]
else:
target = [r["id"] for r in rows]
res = {"attempted": len(target), "kept": 0, "moved": 0, "trashed": 0}
for lead_id in target:
try:
out = await reclassify_lead(lead_id)
except Exception as exc: # noqa: BLE001
log.warning("reclassify %s failed: %s", lead_id, exc)
continue
if not out:
continue
status = out.get("status")
if status == "trashed":
res["trashed"] += 1
elif status == "moved":
res["moved"] += 1
else:
res["kept"] += 1
await broker.publish("leads_reclassified", res)
return res
async def start_reclassify(ids: list[str] | None = None) -> dict:
"""Запустить переклассификацию в фоне (одна задача за раз)."""
global _reclassify_task
if not store.get_setting("aiEnabled"):
return {"started": False, "busy": False, "attempted": 0, "reason": "ИИ выключен — переклассификация недоступна"}
if reclassify_busy():
return {"started": False, "busy": True}
rows = store.query("SELECT id FROM leads WHERE col = 'inbox'")
if ids:
wanted = set(ids)
target = [r["id"] for r in rows if r["id"] in wanted]
else:
target = [r["id"] for r in rows]
if not target:
return {"started": False, "busy": False, "attempted": 0}
async def _run() -> None:
try:
res = await reclassify_inbox(ids)
await broker.publish_toast(
f"Переклассификация готова: {res['trashed']} в корзину, "
f"{res['moved']} в колонки, {res['kept']} осталось в «Неразобранном»",
"sparkles",
)
except Exception as exc: # noqa: BLE001
log.warning("reclassify task error: %s", exc)
await broker.publish_toast("Переклассификация завершилась с ошибкой", "x")
_reclassify_task = asyncio.create_task(_run())
return {"started": True, "busy": False, "attempted": len(target)}
# ─── Правила хранения (тик раз в 30 секунд) ───────────────────────────────
def tick_storage() -> dict:
auto = bool(store.get_setting("autoArchive"))
after_days = int(store.get_setting("archiveAfterDays") or 14)
archive_clear = int(store.get_setting("archiveClearDays") or 90)
trash_clear = int(store.get_setting("trashClearDays") or 7)
now = _now()
archived = purged_arch = purged_trash = 0
if auto:
rows = store.query(
"SELECT id FROM leads WHERE col IN (SELECT id FROM boards UNION ALL SELECT 'inbox') "
"AND received_at < ?",
[now - after_days * C.DAY_MS],
)
for r in rows:
store.execute(
"UPDATE leads SET col = 'archive', is_new = FALSE, archived_at = ? WHERE id = ?",
[now, r["id"]],
)
archived += 1
old_arch = store.query("SELECT id FROM leads WHERE col = 'archive' AND archived_at IS NOT NULL AND archived_at < ?", [now - archive_clear * C.DAY_MS])
for r in old_arch:
_hard_delete(r["id"])
purged_arch += 1
old_trash = store.query("SELECT id FROM leads WHERE col = 'trash' AND received_at < ?", [now - trash_clear * C.DAY_MS])
for r in old_trash:
_hard_delete(r["id"])
purged_trash += 1
# отсев пайплайна живёт 3 суток, дальше удаляется автоматически
purged_rejected = processing_svc.purge_expired()
return {
"archived": archived,
"purgedArchive": purged_arch,
"purgedTrash": purged_trash,
"purgedRejected": purged_rejected,
}
async def notify_tick_stats(stats: dict) -> None:
if stats["archived"]:
await broker.publish_toast(f"Автоархив: {stats['archived']} карточек", "clock")
if stats["purgedArchive"]:
await broker.publish_toast(f"Архив очищен: {stats['purgedArchive']} (90 дн.)", "trash")
if stats["purgedTrash"]:
await broker.publish_toast(f"Корзина очищена: {stats['purgedTrash']} (7 дн.)", "trash")
if stats.get("purgedRejected"):
await broker.publish_toast(f"Отсев очищен: {stats['purgedRejected']} записей (3 дн.)", "trash")
# ─── Поиск ────────────────────────────────────────────────────────────────
def search(q: str, limit: int = 12) -> dict:
qq = q.strip().lower()
if len(qq) < 2:
return {"leads": [], "messages": []}
pattern = f"%{qq}%"
# FTS-кандидаты (снимок индекса)
fts_ids: list[str] = []
if fts_svc.is_ready():
try:
fts_ids = fts_svc.search(qq, limit=limit)["leads"]
except Exception: # noqa: BLE001
fts_ids = []
# LIKE-дополнение (свежие записи после последнего rebuild)
like_ids = [
r["id"]
for r in store.query(
"SELECT id FROM leads WHERE col != 'taken' AND ("
" lower(title) LIKE ? OR lower(summary) LIKE ? OR lower(contact) LIKE ? OR lower(source_msg) LIKE ?) "
"ORDER BY received_at DESC LIMIT ?",
[pattern, pattern, pattern, pattern, limit * 2],
)
]
merged: list[str] = []
for bid in [*fts_ids, *like_ids]:
if bid not in merged:
merged.append(bid)
leads = []
for lid in merged:
d = lead_to_dict(lid)
if d and d["col"] != "taken":
leads.append(d)
if len(leads) >= limit:
break
msgs = store.query(
"SELECT id, dialog_id, text, msg_at FROM messages WHERE lower(text) LIKE ? ORDER BY msg_at DESC LIMIT 20",
[pattern],
)
return {"leads": leads, "messages": msgs}
@@ -0,0 +1,162 @@
"""Клиент автономного ML-сервиса + локальный outbox обучения.
Основное приложение НИКОГДА не обучает ML напрямую «в своей» базе: каждое
действие пользователя синхронно пишется в таблицу ml_outbox, а фоновый
воркер (main._ml_sync_loop) отправляет накопленное в ML-сервис батчами.
Использование ML в пайплайне включается настройкой `mlEnabled`; обучение
идёт всегда.
"""
from __future__ import annotations
import logging
import time
import httpx
from .. import config
from ..db import store
log = logging.getLogger("leadradar.mlclient")
DECISIONS_ML = "mlDecisions"
DECISIONS_AI = "aiDecisions"
# Веса обучающих сигналов: действия пользователя — истина (1.0), решения ИИ —
# гипотезы (меньше), чтобы реальные действия со временем перевешивали ошибки ИИ.
USER_WEIGHT = 1.0
AI_WEIGHT = 0.4
RULE_WEIGHT = 0.6
# кэш статуса сервиса (обновляется фоновым циклом; живёт не дольше 15 c)
_cached: dict = {"at": 0, "data": None, "reachable": False}
def _now() -> int:
return time.time_ns() // 1_000_000
# ─── Обучение: всегда пишем в outbox ──────────────────────────────────────
def push(text: str, label: str, delta: float = 1.0) -> None:
"""Действие пользователя -> событие обучения (гарантированно, локально)."""
text = (text or "").strip()
label = str(label or "").strip()
if not text or not label:
return
store.execute(
"INSERT INTO ml_outbox(id, text, label, delta, created_at) VALUES (?, ?, ?, ?, ?)",
[store.uid("mle_"), text[:6000], label, delta, _now()],
)
def outbox_len() -> int:
return int(store.scalar("SELECT count(*) FROM ml_outbox") or 0)
async def flush_outbox(batch: int = 100) -> int:
"""Отправляет накопленные события в ML-сервис небольшими порциями.
Один learn-batch из сотен сообщений надолго блокирует ML-сервис
(модель пишет каждый термин отдельным INSERT) и упирается в таймаут;
порции по 20 строк проходят быстро и не роняют сервис.
"""
chunk = 10
total = 0
while total < batch:
rows = store.query(
"SELECT id, text, label, delta FROM ml_outbox ORDER BY created_at LIMIT ?",
[chunk],
)
if not rows:
break
items = [{"text": r["text"], "label": r["label"], "delta": r["delta"]} for r in rows]
try:
await _post("/learn-batch", {"items": items}, timeout=config.ML_TIMEOUT + 10)
except Exception as exc: # noqa: BLE001
log.warning("ml outbox flush failed (%d rows): %s", len(rows), exc)
break
ids = [r["id"] for r in rows]
store.execute(f"DELETE FROM ml_outbox WHERE id IN ({','.join('?' * len(ids))})", ids)
total += len(rows)
log.info("ml outbox flushed: %d", len(rows))
return total
# ─── HTTP ─────────────────────────────────────────────────────────────────
async def _post(path: str, body: dict, timeout: float | None = None) -> dict:
async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client:
resp = await client.post(config.ML_URL + path, json=body)
resp.raise_for_status()
return resp.json()
async def _get(path: str, timeout: float | None = None) -> dict:
async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client:
resp = await client.get(config.ML_URL + path)
resp.raise_for_status()
return resp.json()
async def predict(text: str) -> dict:
"""Предсказание. При сбое/недоступности сервиса — «не уверен» (решит ИИ)."""
try:
return await _post("/predict", {"text": (text or "")[:6000]})
except Exception as exc: # noqa: BLE001
log.debug("ml predict unavailable: %s", exc)
return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": False}
async def reset_model() -> dict:
"""Полный сброс ML-модели + очистка очереди обучения.
Старая модель (в т.ч. «мусорные» классы удалённых колонок) стирается,
события обучения из outbox тоже удаляются — иначе они сразу «переобучат»
модель на старых данных.
"""
try:
await _post("/reset", {}, timeout=30)
except Exception as exc: # noqa: BLE001
log.warning("ml reset failed: %s", exc)
return {"ok": False, "error": str(exc)}
store.execute("DELETE FROM ml_outbox")
await refresh_status()
return {"ok": True}
async def refresh_status() -> dict:
global _cached
try:
data = await _get("/status", timeout=3)
_cached = {"at": _now(), "data": data, "reachable": True}
except Exception as exc: # noqa: BLE001
log.debug("ml status unavailable: %s", exc)
_cached = {"at": _now(), "data": _cached["data"], "reachable": False}
return _cached["data"] or {}
def snapshot() -> dict:
"""Локальная статистика + последний известный статус ML-сервиса (без HTTP)."""
fresh = _cached["data"] or {}
return {
"ml": int(store.get_setting(DECISIONS_ML) or 0),
"ai": int(store.get_setting(DECISIONS_AI) or 0),
"learning": int(store.scalar("SELECT count(*) FROM learning_log") or 0),
"ready": bool(fresh.get("ready")),
"classes": fresh.get("classes", {}),
"learned": int(fresh.get("learned") or 0),
"reachable": _cached["reachable"],
"outbox": outbox_len(),
}
def track_decisions(ml: int = 0, ai: int = 0) -> None:
if ml:
store.set_setting(DECISIONS_ML, int(store.get_setting(DECISIONS_ML) or 0) + ml)
if ai:
store.set_setting(DECISIONS_AI, int(store.get_setting(DECISIONS_AI) or 0) + ai)
def is_enabled() -> bool:
"""Использовать ли ML в пайплайне (настройка UI). Обучение — всегда."""
return store.get_setting("mlEnabled") is not False and _cached["reachable"]
@@ -0,0 +1,108 @@
"""Хранение вложений: MinIO (S3-совместимое), креды в env.
Если MinIO не настроен (локальная разработка без docker-compose), объекты
сохраняются в локальную папку DATA_DIR/attachments — API и карточки при этом
не меняются, в БД хранится только ключ объекта.
"""
from __future__ import annotations
import io
import logging
from pathlib import Path
from .. import config
log = logging.getLogger("leadradar.objectstore")
def configured() -> bool:
return bool(config.MINIO_ENDPOINT and config.MINIO_ACCESS_KEY and config.MINIO_SECRET_KEY)
_client = None
_client_checked = False
def client():
"""MinIO-клиент (создаётся лениво). Бросает ошибку, если не настроен."""
global _client, _client_checked
if _client is not None:
return _client
if not configured():
raise RuntimeError(
"MinIO не настроен: задайте LEADRADAR_MINIO_ENDPOINT / _ACCESS_KEY / _SECRET_KEY / _BUCKET"
)
from minio import Minio
from minio.error import S3Error
_client = Minio(
config.MINIO_ENDPOINT,
access_key=config.MINIO_ACCESS_KEY,
secret_key=config.MINIO_SECRET_KEY,
secure=config.MINIO_SECURE,
)
if not _client_checked:
_client_checked = True
try:
if not _client.bucket_exists(config.MINIO_BUCKET):
_client.make_bucket(config.MINIO_BUCKET)
except S3Error as exc:
log.warning("bucket check failed: %s", exc)
return _client
def _local_path(object_key: str) -> Path:
# object_key вида projects/{card_id}/{ts}_{name}; не даём выйти за пределы FILES_DIR
safe = Path(object_key).name
parent = Path(object_key).parent
return (config.FILES_DIR / parent / safe).resolve()
def put(card_id: str, name: str, data: bytes, content_type: str) -> str:
"""Сохраняет объект (MinIO или локально), возвращает objectKey."""
import time
object_key = f"projects/{card_id}/{int(time.time() * 1000)}_{name}"
if configured():
client().put_object(
config.MINIO_BUCKET,
object_key,
io.BytesIO(data),
length=len(data),
content_type=content_type or "application/octet-stream",
)
else:
path = _local_path(object_key)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
log.info("MinIO не настроен — файл сохранён локально: %s", path)
return object_key
def get(object_key: str):
"""Возвращает поток (бинарный) для скачивания."""
if configured():
try:
return client().get_object(config.MINIO_BUCKET, object_key)
except Exception as exc: # noqa: BLE001
log.warning("minio get failed: %s", exc)
raise
path = _local_path(object_key)
if not path.is_file():
raise FileNotFoundError(object_key)
return io.BytesIO(path.read_bytes())
def remove(object_key: str) -> None:
if configured():
try:
client().remove_object(config.MINIO_BUCKET, object_key)
except Exception as exc: # noqa: BLE001
log.warning("minio remove failed: %s", exc)
return
try:
path = _local_path(object_key)
if path.is_file():
path.unlink()
except Exception as exc: # noqa: BLE001
log.warning("local remove failed: %s", exc)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,320 @@
"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев).
Очередь — сырые сообщения из каналов, ждущие разбора (pipeline_msg).
Отсев — сообщения, отброшенные на любом этапе: стоп-фразы/резюме/тип заявки/
без суммы (source='stop'), устарело ('stale'), ML ('ml'), ИИ ('ai'), повтор
('dup'). Для каждой записи храним этап, причину и конкретное слово/фразу
(kw), если отсев по стоп-списку.
Автоочистка отсева — раз в 3 суток (вызывается из leads.tick_storage),
плюс ручная очистка и удаление отдельных записей из UI.
"""
from __future__ import annotations
import logging
import time
from ..db import store
from . import fts as fts_svc
log = logging.getLogger("leadradar.processing")
# отсев живёт 3 суток, дальше удаляется автоматически
RETENTION_DAYS = 3
# человекочитаемые подписи этапов (для UI; source хранится отдельно)
_STAGE_LABELS = {
"length": "короткое сообщение",
"stop": "стоп-фраза",
"resume": "резюме соискателя",
"type": "тип заявки",
"budget": "нет суммы",
"stale": "устарело",
"spam_ml": "спам (ML)",
"spam_ai": "спам (ИИ)",
"filter_ai": "ИИ-фильтр",
"dup": "повтор",
}
# «чьё» решение: используется в UI как источник метки
_SOURCE_LABELS = {
"stop": "правила",
"ml": "ML",
"ai": "ИИ",
"stale": "система",
"dup": "система",
}
DEFAULT_LIMIT = 100
MAX_LIMIT = 500
def _now() -> int:
return time.time_ns() // 1_000_000
def stage_label(stage: str) -> str:
return _STAGE_LABELS.get(stage, stage or "отсев")
def source_label(source: str) -> str:
return _SOURCE_LABELS.get(source, source or "система")
# ─── Запись отсева ────────────────────────────────────────────────────────
def record(row: dict, source: str, stage: str, reason: str, kw: str = "") -> None:
"""Сохраняет отброшенное сообщение в таблицу отсева.
Ид записи детерминирован по (dialog_id, msg_id): повторное отбрасывание
того же сообщения (перечитывание каналов) обновляет запись, а не копит
дубликаты в списке отсева.
"""
if not row or not (row.get("text") or "").strip():
return
msg_id = row.get("msg_id")
dialog_id = row.get("dialog_id") or ""
rid = f"r_{dialog_id}_{msg_id}" if (msg_id is not None and dialog_id) else store.uid("r_")
now = _now()
store.execute(
"INSERT INTO rejected_msgs(id, dialog_id, msg_id, text, ch_name, ch_handle, ch_hue, stage, reason, kw, source, msg_at, rejected_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET "
"text = excluded.text, ch_name = excluded.ch_name, ch_handle = excluded.ch_handle, "
"ch_hue = excluded.ch_hue, stage = excluded.stage, reason = excluded.reason, kw = excluded.kw, "
"source = excluded.source, msg_at = excluded.msg_at, rejected_at = excluded.rejected_at",
[
rid,
dialog_id,
msg_id,
str(row["text"])[:6000],
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
stage,
str(reason or "")[:500],
str(kw or "")[:200],
source,
row.get("msg_at"),
now,
],
)
def purge_expired(days: int = RETENTION_DAYS) -> int:
"""Автоочистка отсева: записи старше N суток удаляются безвозвратно."""
cut = _now() - days * 24 * 3600 * 1000
rows = store.query(
"SELECT id FROM rejected_msgs WHERE rejected_at < ?", [cut]
)
if not rows:
return 0
ids = [r["id"] for r in rows]
store.execute(
"DELETE FROM rejected_msgs WHERE id IN (" + ",".join(["?"] * len(ids)) + ")",
ids,
)
return len(ids)
def clear_all() -> int:
rows = store.query("SELECT count(*) AS c FROM rejected_msgs")
total = int(rows[0]["c"]) if rows else 0
if total:
store.execute("DELETE FROM rejected_msgs")
return total
def return_to_queue(rej_id: str, reason: str = "") -> dict:
"""Вернуть отсеянное сообщение в обработку (кнопка в «Обработке»).
Строка очереди помечается force: этап 1, устарело, ML-решения и ИИ-отсев
для неё игнорируются — сообщение уходит на классификацию и создаёт карточку.
Запись в отсеве не удаляется, а помечается «возвращено» с причиной (аудит).
Если отсев был по решению «спам» (ML/ИИ) — снимаем у ML вес спама для текста.
"""
row = store.query_one("SELECT * FROM rejected_msgs WHERE id = ?", [rej_id])
if not row:
raise KeyError(rej_id)
if bool(row.get("returned")):
raise ValueError("Сообщение уже возвращено в обработку")
if str(row.get("source") or "") == "dup":
raise ValueError("Повтор: карточка с таким текстом уже есть в системе — возвращать нечего")
dialog_id = str(row.get("dialog_id") or "")
msg_id = row.get("msg_id")
text = str(row.get("text") or "").strip()
if not text:
raise ValueError("В записи нет текста сообщения")
if str(row.get("stage") or "") in ("spam_ml", "spam_ai", "filter_ai"):
from . import ml_client
# реальное действие пользователя: этот текст НЕ спам
ml_client.push(text, "spam", delta=-1.0)
now = _now()
store.execute(
"UPDATE rejected_msgs SET returned = TRUE, returned_at = ?, return_reason = ? WHERE id = ?",
[now, str(reason or "").strip()[:500], rej_id],
)
from .pipeline import enqueue # локальный импорт: pipeline импортирует processing
if dialog_id and msg_id is not None:
enqueue(
dialog_id,
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
msg_id,
text,
row.get("msg_at") or now,
force=True,
)
else:
# старые записи (до сохранения dialog_id/msg_id): текст сохранился,
# возвращаем без ссылки на исходное сообщение (force=True)
store.execute(
"INSERT INTO pipeline_msg(id, dialog_id, ch_name, ch_handle, ch_hue, text, msg_id, msg_at, status, force, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', TRUE, ?, ?)",
[
store.uid("p_"),
dialog_id,
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
text[:6000],
msg_id,
row.get("msg_at") or now,
now,
now,
],
)
return {"id": rej_id, "returned": True, "returnedAt": now}
def delete_one(rej_id: str) -> bool:
store.execute("DELETE FROM rejected_msgs WHERE id = ?", [rej_id])
return True
def rejected_count() -> int:
return int(store.scalar("SELECT count(*) FROM rejected_msgs") or 0)
# ─── Очередь (pipeline_msg) ───────────────────────────────────────────────
def queue_counts() -> dict:
out = {"new": 0, "ai": 0}
for r in store.query("SELECT status, count(*) AS c FROM pipeline_msg GROUP BY status"):
if r["status"] == "new":
out["new"] = int(r["c"])
elif r["status"] == "filtered":
out["ai"] = int(r["c"])
out["total"] = out["new"] + out["ai"]
return out
def list_queue(limit: int = DEFAULT_LIMIT) -> list[dict]:
limit = min(max(1, limit), MAX_LIMIT)
rows = store.query(
"SELECT * FROM pipeline_msg ORDER BY created_at LIMIT ?", [limit]
)
items = []
for r in rows:
items.append(
{
"id": r["id"],
"dialogId": r.get("dialog_id") or "",
"msgId": r.get("msg_id"),
"text": r["text"],
"status": r["status"], # new | filtered
"ch": {
"name": r["ch_name"],
"handle": r["ch_handle"],
"hue": r["ch_hue"],
},
"msgAt": r["msg_at"],
"queuedAt": r["created_at"],
}
)
return items
# ─── Отсев (rejected_msgs) ────────────────────────────────────────────────
def list_rejected(q: str = "", offset: int = 0, limit: int = DEFAULT_LIMIT) -> dict:
offset = max(0, offset)
limit = min(max(1, limit), MAX_LIMIT)
qq = (q or "").strip().lower()
ids: list[str] = []
if qq:
# FTS-кандидаты + LIKE-дополнение (свежие записи после последнего rebuild)
if fts_svc.is_ready():
try:
ids = fts_svc.search(qq, limit=limit)["rejected"]
except Exception: # noqa: BLE001
ids = []
pattern = f"%{qq}%"
like = store.query(
"SELECT id FROM rejected_msgs WHERE "
"lower(text) LIKE ? OR lower(reason) LIKE ? OR lower(kw) LIKE ? OR lower(ch_name) LIKE ? "
"ORDER BY rejected_at DESC LIMIT ?",
[pattern, pattern, pattern, pattern, limit * 2],
)
for r in like:
if r["id"] not in ids:
ids.append(r["id"])
total = len(ids) # итог по условию поиска (все кандидаты)
page = ids[offset : offset + limit]
else:
total = rejected_count()
page_rows = store.query(
"SELECT id FROM rejected_msgs ORDER BY rejected_at DESC LIMIT ? OFFSET ?",
[limit, offset],
)
page = [r["id"] for r in page_rows]
by_id: dict[str, dict] = {}
if page:
ph = ",".join(["?"] * len(page))
rows = store.query(
f"SELECT * FROM rejected_msgs WHERE id IN ({ph})", page
)
for r in rows:
by_id[r["id"]] = r
items = []
for rid in page:
r = by_id.get(rid)
if not r:
continue
items.append(
{
"id": r["id"],
"dialogId": r.get("dialog_id") or "",
"msgId": r.get("msg_id"),
"text": r["text"],
"stage": r["stage"],
"stageLabel": stage_label(r["stage"]),
"reason": r["reason"],
"kw": r["kw"],
"source": r["source"],
"sourceLabel": source_label(r["source"]),
"ch": {"name": r["ch_name"], "handle": r["ch_handle"], "hue": r.get("ch_hue") or "#666"},
"msgAt": r["msg_at"],
"rejectedAt": r["rejected_at"],
"returned": bool(r.get("returned")),
"returnedAt": r.get("returned_at"),
"returnReason": r.get("return_reason") or "",
}
)
return {"items": items, "total": total, "offset": offset, "limit": limit}
def stats() -> dict:
q = queue_counts()
return {
"queue": q,
"rejected": rejected_count(),
}
@@ -0,0 +1,282 @@
"""«Выбранные» — проектный канбан (п.4.8 ТЗ).
Карточка живёт только здесь: лид уходит с дашборда безвозвратно (col='taken'),
в архив/корзину проектные карточки не попадают. Есть история движения,
вложения (файлы сейчас мета-мок, MinIO позже), ссылки, ТЗ и напоминания
для стадии «Отложено».
"""
from __future__ import annotations
import json
import logging
import time
from .. import constants as C
from ..db import store
from ..sse import broker
log = logging.getLogger("leadradar.projects")
STAGES = {s["id"] for s in C.PIPELINE_STAGES}
def _now() -> int:
return time.time_ns() // 1_000_000
def _json(value: list, default: str = "[]") -> str:
return json.dumps(value or [], ensure_ascii=False) if value is not None else default
def _row_to_card(row: dict) -> dict:
history = json.loads(row["history"] or "[]")
return {
"id": row["id"],
"stage": row["stage"],
"local": bool(row["local"]),
"leadId": row["lead_id"],
"title": row["title"],
"summary": row["summary"],
"stack": json.loads(row["stack"] or "[]"),
"budget": (
{"from": row["budget_from"], "to": row["budget_to"], "cur": row["budget_cur"]}
if row["budget_cur"]
else None
),
"contact": row["contact"],
"comments": json.loads(row["comments"] or "[]"),
"links": json.loads(row["links"] or "[]"),
"files": json.loads(row["files"] or "[]"),
"tzText": row["tz_text"],
"history": history,
"reminder": {"at": row["reminder_at"]} if row["reminder_at"] else None,
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
}
def list_cards(stage: str | None = None) -> list[dict]:
if stage:
rows = store.query("SELECT * FROM projects WHERE stage = ? ORDER BY updated_at DESC", [stage])
else:
rows = store.query("SELECT * FROM projects ORDER BY updated_at DESC")
return [_row_to_card(r) for r in rows]
def get_card(card_id: str) -> dict | None:
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
return _row_to_card(row) if row else None
def _insert(row_fields: dict) -> dict:
now = _now()
card_id = row_fields["id"]
store.execute(
"INSERT INTO projects(id, stage, local, lead_id, title, summary, stack, "
"budget_from, budget_to, budget_cur, contact, comments, links, files, tz_text, "
"history, reminder_at, reminder_fired, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, FALSE, ?, ?)",
[
card_id,
row_fields.get("stage", "planned"),
bool(row_fields.get("local")),
row_fields.get("lead_id"),
row_fields.get("title", ""),
row_fields.get("summary", ""),
_json(row_fields.get("stack")),
row_fields.get("budget_from"),
row_fields.get("budget_to"),
row_fields.get("budget_cur", ""),
row_fields.get("contact", ""),
_json(row_fields.get("comments")),
_json(row_fields.get("links")),
_json(row_fields.get("files")),
row_fields.get("tz_text", ""),
_json(row_fields.get("history")),
now,
now,
],
)
return get_card(card_id)
def create_local_card(data: dict) -> dict:
"""Ручное создание карточки — «локальная» (без лида)."""
budget = data.get("budget") if isinstance(data.get("budget"), dict) else None
return _insert(
{
"id": store.uid("pr_"),
"stage": data.get("stage") if data.get("stage") in STAGES else "planned",
"local": True,
"title": str(data.get("title", "")).strip(),
"summary": str(data.get("summary", "")),
"stack": data.get("stack") or [],
"budget_from": budget.get("from") if budget else None,
"budget_to": budget.get("to") if budget else None,
"budget_cur": budget.get("cur", "") if budget else "",
"contact": str(data.get("contact", "")),
"comments": data.get("comments") or [],
"links": data.get("links") or [],
"files": data.get("files") or [],
"tz_text": str(data.get("tzText", "")),
"history": [{"id": store.uid("h_"), "at": _now(), "type": "createdLocal"}],
}
)
def take_lead_to_projects(lead_id: str) -> dict:
"""«Взять в работу»: лид уходит с дашборда, создаётся проектная карточка."""
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
if not lead:
raise KeyError(lead_id)
existing = store.query_one("SELECT id FROM projects WHERE lead_id = ?", [lead_id])
if existing:
return get_card(existing["id"])
budget = {"from": lead["budget_from"], "to": lead["budget_to"], "cur": lead["budget_cur"]} if lead["budget_cur"] else None
card = _insert(
{
"id": store.uid("pr_"),
"stage": "planned",
"local": False,
"lead_id": lead_id,
"title": lead["title"],
"summary": lead["summary"],
"stack": json.loads(lead["stack"] or "[]"),
"budget_from": budget["from"] if budget else None,
"budget_to": budget["to"] if budget else None,
"budget_cur": budget["cur"] if budget else "",
"contact": lead["contact"],
"comments": [{"id": store.uid("cm_"), "by": "Вы", "text": "Взял в работу из лида.", "time": "только что"}],
"tz_text": "",
"history": [{"id": store.uid("h_"), "at": _now(), "type": "created"}],
}
)
# эксклюзивность: на дашборде лида больше нет, возврата нет
store.execute("UPDATE leads SET col = 'taken', is_new = FALSE WHERE id = ?", [lead_id])
return card
def patch_card(card_id: str, patch: dict) -> dict:
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
simple = {
"title": "title",
"summary": "summary",
"contact": "contact",
"tz_text": "tzText",
}
for col, key in simple.items():
if key in patch:
store.execute(f"UPDATE projects SET {col} = ? WHERE id = ?", [str(patch[key]), card_id])
if "stack" in patch:
store.execute("UPDATE projects SET stack = ? WHERE id = ?", [_json(patch["stack"]), card_id])
if "budget" in patch:
b = patch["budget"] if isinstance(patch["budget"], dict) else None
store.execute(
"UPDATE projects SET budget_from = ?, budget_to = ?, budget_cur = ? WHERE id = ?",
[b.get("from") if b else None, b.get("to") if b else None, b.get("cur", "") if b else "", card_id],
)
if "comments" in patch:
store.execute("UPDATE projects SET comments = ? WHERE id = ?", [_json(patch["comments"]), card_id])
if "links" in patch:
store.execute("UPDATE projects SET links = ? WHERE id = ?", [_json(patch["links"]), card_id])
if "files" in patch:
store.execute("UPDATE projects SET files = ? WHERE id = ?", [_json(patch["files"]), card_id])
_bump(card_id)
return get_card(card_id)
def _bump(card_id: str) -> None:
store.execute("UPDATE projects SET updated_at = ? WHERE id = ?", [_now(), card_id])
def add_comment(card_id: str, text: str) -> list[dict]:
row = store.query_one("SELECT comments FROM projects WHERE id = ?", [card_id])
comments = json.loads(row["comments"] or "[]")
comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"})
patch_card(card_id, {"comments": comments})
return comments
def move_stage(card_id: str, stage: str) -> dict:
if stage not in STAGES:
raise ValueError("Неизвестная стадия")
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
history = json.loads(row["history"] or "[]")
now = _now()
store.execute(
"UPDATE projects SET stage = ?, reminder_at = NULL, reminder_fired = FALSE, updated_at = ? WHERE id = ?",
[stage, now, card_id],
)
history.append({"id": store.uid("h_"), "at": now, "stage": stage})
store.execute("UPDATE projects SET history = ? WHERE id = ?", [_json(history), card_id])
return get_card(card_id)
def remove_card(card_id: str) -> None:
store.execute("DELETE FROM projects WHERE id = ?", [card_id])
def clear_stage(stage: str) -> int:
"""Полная ручная очистка стадии «Отклонено» (терминальные не восстанавливаются)."""
if stage not in ("rejected",):
raise ValueError("Очистка разрешена только для стадии «Отклонено»")
rows = store.query("SELECT id FROM projects WHERE stage = ?", [stage])
if not rows:
return 0
store.execute("DELETE FROM projects WHERE stage = ?", [stage])
return len(rows)
# ─── Напоминания стадии «Отложено» ────────────────────────────────────────
def set_reminder(card_id: str, at: int) -> dict:
if not store.get_setting("remindersEnabled"):
raise PermissionError("Напоминания об отложенных выключены в настройках")
store.execute(
"UPDATE projects SET reminder_at = ?, reminder_fired = FALSE, updated_at = ? WHERE id = ?",
[at, _now(), card_id],
)
return get_card(card_id)
def clear_reminder(card_id: str) -> None:
store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE id = ?", [card_id])
def active_reminders() -> list[dict]:
rows = store.query(
"SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL ORDER BY reminder_at"
)
return [{"id": r["id"], "title": r["title"], "at": r["reminder_at"]} for r in rows]
def snooze(card_id: str, ms: int = C.DAY_MS) -> None:
store.execute(
"UPDATE projects SET reminder_at = ?, reminder_fired = FALSE WHERE id = ?",
[_now() + ms, card_id],
)
async def check_reminders() -> list[dict]:
"""Наступившие напоминания -> события. Если выключено — чистим протухшие."""
if not store.get_setting("remindersEnabled"):
# протухшие напоминания не храним: при включении старые не «выстрелят»
store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE reminder_at IS NOT NULL AND reminder_at <= ?", [_now()])
return []
now = _now()
rows = store.query(
"SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL "
"AND reminder_fired = FALSE AND reminder_at <= ?",
[now],
)
due = []
for r in rows:
store.execute("UPDATE projects SET reminder_fired = TRUE WHERE id = ?", [r["id"]])
due.append({"id": r["id"], "title": r["title"], "stage": r["stage"]})
for item in due:
await broker.publish("reminder_due", item)
return due
@@ -0,0 +1,130 @@
"""Курсы валют: источник ЦБ РФ, 4 запроса в сутки (каждые 6 часов).
По ТЗ до подключения сервиса используются мок-курсы; источник выбирается
в настройках (cbr | mock). Значения хранятся в БД вместе с временем
обновления и выдаются наружу для вкладки «Валюта и курсы».
"""
from __future__ import annotations
import json
import logging
import time
import httpx
from .. import constants as C
from ..db import store
log = logging.getLogger("leadradar.rates")
_FETCH_INTERVAL_MS = 6 * C.HOUR_MS # 4 раза в сутки
def get_rates() -> dict:
row = store.query_one("SELECT rates, source, updated_at FROM rates WHERE id = 1")
rates = json.loads(row["rates"]) if row else dict(C.MOCK_RATES)
return {
"base": "RUB",
"rates": rates,
"source": row["source"] if row else "mock",
"updatedAt": row["updated_at"] if row else None,
}
def save_rates(rates: dict, source: str) -> None:
store.execute(
"INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET rates = excluded.rates, source = excluded.source, "
"updated_at = excluded.updated_at",
[json.dumps(rates), source, time.time_ns() // 1_000_000],
)
async def fetch_cbr() -> dict | None:
"""Запрос к ЦБ РФ (JSON-зеркало daily_json.js). Возвращает rates к RUB."""
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(C.CBR_URL)
resp.raise_for_status()
payload = resp.json()
rates: dict = {"RUB": 1.0}
for code, item in payload.get("Valute", {}).items():
# 1 единица валюты в рублях: Nominal может быть > 1
nominal = int(item.get("Nominal", 1)) or 1
value = float(item.get("Value", 0))
rates[code] = round(value / nominal, 6)
return rates
except Exception as exc: # noqa: BLE001
log.warning("CBR fetch failed: %s", exc)
return None
async def refresh_rates(force_source: str | None = None) -> bool:
"""Ручное/фоновое обновление. Возвращает True при успехе (или при мок-режиме)."""
source = force_source or store.get_setting("rateSource") or "cbr"
if source == "mock":
save_rates(dict(C.MOCK_RATES), "mock")
recompute_conversions()
return True
rates = await fetch_cbr()
if rates is None:
return False
save_rates(rates, "cbr")
recompute_conversions()
return True
def should_fetch() -> bool:
row = store.query_one("SELECT updated_at, source FROM rates WHERE id = 1")
if not row:
return True
if row["source"] == "mock" and store.get_setting("rateSource") == "cbr":
return True
return time.time_ns() // 1_000_000 - row["updated_at"] >= _FETCH_INTERVAL_MS
def _resolve_rate(rates: dict, code: str) -> float | None:
"""USDT приравниваем к USD (у ЦБ нет тикера USDT)."""
if code == "USDT" and "USD" in rates:
return float(rates["USD"])
val = rates.get(code)
return float(val) if val is not None else None
def convert_amount(amount: float | int | None, from_cur: str, to_cur: str) -> float | None:
"""amount в from_cur -> to_cur по актуальным курсам (к рублю)."""
if amount is None:
return None
rates = json.loads(store.query_one("SELECT rates FROM rates WHERE id = 1")["rates"])
rf = _resolve_rate(rates, from_cur)
rt = _resolve_rate(rates, to_cur)
if rf is None or rt is None:
return None
return round(float(amount) * rf / rt, 2)
def recompute_conversions() -> int:
"""Пересчёт старых карточек по актуальному курсу и целевой валюте.
Пересчитываются карточки на канбане и в «Неразобранном»; архивные,
корзинные и ушедшие в «Выбранные» (taken) не трогаем.
"""
if not store.get_setting("conversionOn"):
return 0
target = store.get_setting("targetCurrency") or "RUB"
rows = store.query(
"SELECT id, budget_from, budget_to, budget_cur FROM leads "
"WHERE budget_cur <> '' AND col NOT IN ('archive','trash','taken')",
)
updated = 0
for r in rows:
cf = convert_amount(r["budget_from"], r["budget_cur"], target)
ct = convert_amount(r["budget_to"] if r["budget_to"] is not None else r["budget_from"], r["budget_cur"], target)
if cf is None:
continue
store.execute(
"UPDATE leads SET conv_from = ?, conv_to = ?, conv_cur = ? WHERE id = ?",
[cf, ct, target, r["id"]],
)
updated += 1
return updated
@@ -0,0 +1,390 @@
"""Детерминированные правила колонок: направление, стек, слова, грейд, бюджет.
Колонка — это набор опциональных фильтров, задаёт пользователь (или ИИ при
предложении). Если текст входящего сообщения соответствует правилам — карточка
уходит в эту колонку сразу, без ML/ИИ (ML/ИИ подключаются только когда правила
не сработали). Набор фильтров может меняться: пустая группа не участвует.
"""
from __future__ import annotations
import json
import re
from ..db import store
_CUR_SYMBOLS = {"$": "USD", "": "EUR", "": "RUB", "": "USDT", "£": "GBP", "¥": "CNY"}
_CUR_WORDS = {"usd": "USD", "eur": "EUR", "rub": "RUB", "usdt": "USDT", "gbp": "GBP", "cny": "CNY", "руб": "RUB", "долл": "USD", "бакс": "USD"}
# Грейд/уровень: тег из правил расширяется синонимами, чтобы «middle» находил
# и «mid», и «мидл», а «сеньор» находил senior и т.п.
_GRADE_ALIASES = {
"junior": ["junior", "джун", "джуниор"],
"middle": ["middle", "mid", "мидл"],
"senior": ["senior", "сеньор", "сеньйор"],
"lead": ["lead", "тимлид", "тиэмлид", "team lead", "teamlead", "tech lead", "техлид"],
"architect": ["architect", "архитектор"],
"intern": ["intern", "стажёр", "стажер", "trainee"],
}
def _grade_terms(tags: list[str]) -> list[str]:
out: list[str] = []
for t in tags:
key = str(t).strip().lower()
if not key:
continue
if key in _GRADE_ALIASES:
out.extend(_GRADE_ALIASES[key])
else:
out.append(key)
return out
# Перед матчингом правил вырезаем ссылки и markdown-ссылки: иначе фильтр ловит
# слова из трекерных хвостов/служебных строк URL (например, «desktop» в
# utm_medium=member_desktop) и в колонку попадает мусор, не имеющий отношения
# к содержанию сообщения.
_MD_URL_RE = re.compile(r"\[[^\]]*\]\([^)\s]+\)")
_RAW_URL_RE = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+")
def content_text(text: str) -> str:
s = str(text or "")
s = _MD_URL_RE.sub(" ", s)
return _RAW_URL_RE.sub(" ", s)
# ищем: 1) "от 1 200 до 1 500 $", 2) "1 2001 500 $ / 1 200$", 3) "$1 2001 500"
# суффикс «к/К» разрешён прямо в числе: «2к», «1.5к$» (множитель в _norm_amount)
_AMT = r"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?"
_RANGE = rf"({_AMT})\s*(?:[-–—]\s*({_AMT}))?"
def _norm_amount(raw: str) -> float | None:
s = raw.replace("\u00a0", " ").replace(" ", "").replace(",", ".")
mult = 1.0
# «2к»/«2К»/«1.5к» — тысячи; суффикс убираем до float (иначе парс падает)
if s and s[-1].lower() in ("k", "к"):
mult = 1000.0
s = s[:-1]
try:
v = float(s) * mult
except ValueError:
return None
return v
def _cur_from_tail(text: str, m_start: int) -> str | None:
tail = text[m_start : m_start + 12].lower().strip()
for sym, code in _CUR_SYMBOLS.items():
if tail.startswith(sym):
return code
# слово-валюта сразу после числа (с пробелом)
for word, code in _CUR_WORDS.items():
if tail.startswith(word):
return code
# валюта перед числом ($/€/₽), например "$1 200"
head = text[max(0, m_start - 3) : m_start].strip()
for sym, code in _CUR_SYMBOLS.items():
if head.endswith(sym):
return code
return None
def extract_amounts(text: str) -> list[dict]:
"""Парсер сумм с сохранением смысла:
- диапазон «от A до B» / «A–B» → {'from': A, 'to': B, 'cur': ...};
- «до B» (только верхняя граница) → {'from': None, 'to': B, 'cur': ...};
- одна сумма / «от A» без верхней границы → {'from': A, 'to': A, 'cur': ...}.
Валюту ищем сразу после суммы (или перед ней для «$1 200»); суммы без валюты игнорируются.
"""
out: list[dict] = []
t = text or ""
if not t.strip():
return out
occupied: list[tuple[int, int]] = []
def _free(s: int, e: int) -> bool:
return not any(s < oe and e > os for os, oe in occupied)
def _add(a, b, cur: str | None, s: int, e: int) -> None:
if cur and (a is not None or b is not None) and _free(s, e):
out.append({"from": a, "to": b, "cur": cur})
occupied.append((s, e))
# 1) словесный диапазон «от A до B» (+ валюта после B)
for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")\s+до\s+(" + _AMT + r")", t):
a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2))
cur = _cur_from_tail(t, m.end(2))
if a is not None and b is not None and cur:
_add(a, b, cur, m.start(1), m.end(2))
# 2) «до B» (без пары «от … до») — верхняя граница
for m in re.finditer(r"(?i)\bдо\s+(" + _AMT + r")", t):
b = _norm_amount(m.group(1))
cur = _cur_from_tail(t, m.end(1))
if b is not None and cur:
_add(None, b, cur, m.start(1), m.end(1))
# 3) «от A» без верхней границы — считаем одной суммой
for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")", t):
a = _norm_amount(m.group(1))
cur = _cur_from_tail(t, m.end(1))
if a is not None and cur:
_add(a, a, cur, m.start(1), m.end(1))
# 4) числовые диапазоны «A–B» / «A - B»
for m in re.finditer(r"(?i)(" + _AMT + r")\s*(?:[-–—]|\s+до\s+)\s*(" + _AMT + r")", t):
a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2))
cur = _cur_from_tail(t, m.end(2)) or _cur_from_tail(t, m.end(1))
if a is not None and b is not None and cur:
_add(a, b, cur, m.start(1), m.end(2))
# 5) одиночные суммы с валютой (не вошедшие в конструкции выше)
for m in re.finditer(_AMT, t):
a = _norm_amount(m.group(0))
cur = _cur_from_tail(t, m.end())
if a is not None and cur:
_add(a, a, cur, m.start(), m.end())
return out
def _amount_in_range(amounts: list[dict], budget: dict) -> bool:
from ..services.rates import convert_amount
try:
lo = float(budget.get("from")) if budget.get("from") is not None else None
hi = float(budget.get("to")) if budget.get("to") is not None else None
except (TypeError, ValueError):
return False
if lo is None and hi is None:
return True
target_cur = str(budget.get("cur") or "USD").upper()
for amt in amounts:
raw_val = amt["from"] if amt["from"] is not None else amt.get("to")
if raw_val is None:
continue
try:
val = raw_val if amt["cur"] == target_cur else convert_amount(raw_val, amt["cur"], target_cur)
except Exception: # noqa: BLE001
val = None
if val is None:
continue
if lo is not None and val < lo:
continue
if hi is not None and val > hi:
continue
return True
return False
def match_text(rules: dict, text: str) -> bool:
"""Соответствует ли текст правилам колонки. Возвращает bool.
Колонка — это набор опциональных фильтров: направление, стек, ключевые
слова, грейд/уровень, бюджет. Пустая группа не участвует; режим «все» —
должны совпасть все включённые группы, «любое» — хотя бы одна.
"""
rules = rules or {}
lower = content_text(text).lower()
direction = [str(x).strip().lower() for x in (rules.get("direction") or []) if str(x).strip()]
kw = [str(x).strip().lower() for x in (rules.get("keywords") or []) if str(x).strip()]
stack = [str(x).strip().lower() for x in (rules.get("stack") or []) if str(x).strip()]
grade = [str(x).strip().lower() for x in (rules.get("grade") or []) if str(x).strip()]
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
enabled = [bool(kw), bool(stack), bool(direction), bool(grade), bool(budget)]
if not any(enabled):
return False
grade_terms = _grade_terms(grade)
groups = {
"keywords": any(k in lower for k in kw) if kw else True,
"stack": any(s in lower for s in stack) if stack else True,
"direction": any(d in lower for d in direction) if direction else True,
"grade": any(g in lower for g in grade_terms) if grade else True,
"budget": (_amount_in_range(extract_amounts(text), budget) if budget else True),
}
mode = str(rules.get("mode") or "all").lower()
if mode == "any":
# «любое»: совпасть должна хотя бы одна включённая (непустая) группа.
# Пустые группы равны True и не должны участвовать — иначе колонка
# с одним фильтром (например «стек: WPF») ловит вообще всё.
return any(groups[k] for k, on in zip(groups, enabled) if on and k in groups)
# all: каждая включённая группа обязана совпасть
return all(groups[k] for k, on in zip(groups, enabled) if on and k in groups)
def score_text(rules: dict, text: str) -> int:
"""Число совпавших фильтров (для выбора лучшей ИИ-колонки)."""
if not text:
return 0
lower = content_text(text).lower()
score = 0
for group in ("direction", "keywords", "stack", "grade"):
if group == "grade":
for g in _grade_terms(rules.get(group) or []):
if g in lower:
score += 1
else:
for w in (rules.get(group) or []):
if str(w).lower() in lower:
score += 1
return score
def excluded_terms(rules: dict | None, text: str) -> list[str]:
"""Какие слова-исключения колонки есть в тексте.
Исключения — veto колонки: если в содержании сообщения (без ссылок и
служебных хвостов) встречается любое из них, карточка в колонку не
попадает, даже если все положительные условия совпали.
"""
rules = rules or {}
lower = content_text(text).lower()
out: list[str] = []
for term in rules.get("exclude") or []:
t = str(term).strip()
if t and t.lower() in lower:
out.append(t)
return out
def is_excluded(rules: dict | None, text: str) -> bool:
return bool(excluded_terms(rules, text))
def board_accepts(board_id: str, text: str) -> bool:
"""Пропускает ли колонка этот текст.
Колонка с активными правилами принимает только текст, прошедший её правила
(детерминированно); колонка без правил принимает любой текст — её наполняют
ИИ/ML/пользователь. Нужно как страховка: ИИ или ML не должны класть карточку
в «отфильтрованную» колонку, если текст под правила не подходит. Слова-
исключения работают как veto в любом случае.
"""
row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id])
if not row:
return False
rules = json.loads(row["rules"] or "{}") if row["rules"] else {}
if is_excluded(rules, text):
return False
if not has_active_rules(rules):
return True
return match_text(rules, text)
def hits(rules: dict, text: str) -> list[dict]:
"""Какие именно критерии фильтра совпали с текстом.
Возвращает список совпадений по группам фильтра колонки:
[{"label": "Стек", "term": "WPF"}, {"label": "Грейд", "term": "middle", "word": "mid"}, …].
Нужно, чтобы на карточке показывать «по каким критериям она попала в колонку»
(список совпавших условий фильтра). Ключевое слово ищется по всему тексту
сообщения — включая стек, требования и «будет плюсом», как и наоборот.
"""
rules = rules or {}
lower = content_text(text).lower()
out: list[dict] = []
for group, label in (("direction", "Направление"), ("keywords", "Слова"), ("stack", "Стек")):
for term in rules.get(group) or []:
t = str(term).strip()
if t and t.lower() in lower:
out.append({"label": label, "term": t})
for term in rules.get("grade") or []:
for alias in _grade_terms([term]):
if alias in lower:
out.append({"label": "Грейд/уровень", "term": str(term).strip(), "word": alias})
break
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
if budget and _amount_in_range(extract_amounts(text), budget):
out.append({"label": "Бюджет", "term": _budget_label(budget)})
return out
def _budget_label(budget: dict) -> str:
lo, hi = budget.get("from"), budget.get("to")
cur = str(budget.get("cur") or "").upper()
if lo is not None and hi is not None:
return f"от {lo:g} до {hi:g} {cur}".strip()
if hi is not None:
return f"до {hi:g} {cur}".strip()
if lo is not None:
return f"от {lo:g} {cur}".strip()
return "бюджет"
def hits_for_board(board_id: str, text: str) -> list[dict]:
"""Совпавшие критерии колонки с активными правилами; [] — правил нет."""
row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id])
if not row:
return []
rules = json.loads(row["rules"] or "{}") if row["rules"] else {}
if not has_active_rules(rules):
return []
return hits(rules, text)
def has_active_rules(rules: dict | None) -> bool:
"""Есть ли в правилах хотя бы одна реально работающая группа фильтров.
Нужно для ML: колонка с активными правилами раскладывается только самими
правилами (детерминированно), ML её назначать не должен — иначе в колонку
попадает то, что под правила не подходит.
"""
rules = rules or {}
for key in ("direction", "keywords", "stack", "grade"):
if any(str(x).strip() for x in (rules.get(key) or [])):
return True
budget = rules.get("budget")
if isinstance(budget, dict) and (
budget.get("from") is not None or budget.get("to") is not None
):
return True
return False
def describe(rules: dict) -> str:
"""Человекочитаемое описание правил (для обоснования и промпта)."""
rules = rules or {}
parts = []
direction = rules.get("direction") or []
stack = rules.get("stack") or []
kw = rules.get("keywords") or []
grade = rules.get("grade") or []
if direction:
parts.append("направление: " + ", ".join(str(x) for x in direction[:6]))
if stack:
parts.append("стек: " + ", ".join(str(x) for x in stack[:8]))
if kw:
parts.append("слова: " + ", ".join(str(x) for x in kw[:8]))
if grade:
parts.append("грейд: " + ", ".join(str(x) for x in grade[:8]))
exc = rules.get("exclude") or []
if exc:
parts.append("исключено: " + ", ".join(str(x) for x in exc[:8]))
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
if budget and (budget.get("from") is not None or budget.get("to") is not None):
lo = budget.get("from") if budget.get("from") is not None else ""
hi = budget.get("to") if budget.get("to") is not None else ""
parts.append(f"бюджет: {lo}{hi} {budget.get('cur') or ''}".replace(' ', '').replace(' ', ''))
if not parts:
return "без правил (решает ИИ/ML)"
mode = "все условия" if str(rules.get("mode") or "all").lower() != "any" else "любое из условий"
return mode + " · " + "; ".join(parts)
def route(text: str) -> dict | None:
"""Детерминированная маршрутизация по правилам активных колонок.
Возвращает колонку, если правила однозначно совпали (при нескольких —
ту, где больше совпадений). Предложения ИИ в маршрутизации не участвуют.
"""
rows = store.query(
"SELECT * FROM boards WHERE suggested = FALSE AND rules <> '{}' AND rules <> '' ORDER BY pos"
)
best: dict | None = None
best_score = 0
for r in rows:
rules = json.loads(r["rules"] or "{}")
if not match_text(rules, text):
continue
sc = score_text(rules, text) or 1
if sc > best_score:
best = {"id": r["id"], "name": r["name"], "color": r["color"], "rules": rules}
best_score = sc
return best
@@ -0,0 +1,247 @@
"""ИИ-предложения колонок по анализу «Неразобранного».
Колонка — не «на один скилл», а смысловая тема: направление + набор стека и
ключевых слов (+ бюджет при повторяемости). ИИ группирует карточки и создаёт
КОЛОНКИ-ПРЕДЛОЖЕНИЯ (suggested=TRUE) с обоснованием (note), по каким
критериям собрана. Пользователь открывает предложение, смотрит карточки и
решает: принять (можно переименовать/поправить правила) или удалить.
"""
from __future__ import annotations
import logging
import time
from ..db import store
from ..sse import broker
from . import ai as ai_svc
log = logging.getLogger("leadradar.suggest")
SUGGEST_PROMPT = """Ты — аналитик входящих заявок. Перед тобой пронумерованные сообщения (номера 1..N), которые не подошли ни под одну существующую колонку. Сгруппируй их в 2–4 ОСМЫСЛЕННЫЕ тематические колонки.
Сфера/что считается заявкой:
{domain}
Колонка — это не отдельный предмет и не каждое слово по отдельности, а направление с набором родственных признаков (тип работ/услуг, предметы, технологии, материалы). Пример: «Telegram-боты» — направление: чат-боты/автоматизация; предметы/технологии: Python, aiogram; слова: бот, telegram, автоответчик.
Правила:
- группируй повторяющиеся темы: в каждую колонку бери минимум 2 сообщения;
- не предлагай колонки, похожие на уже существующие;
- 2–4 колонки максимум; если ничего общего нет — верни пустой список.
Для каждой колонки верни:
- name — короткое название (2–4 слова);
- description — короткое описание колонки (1–2 предложения): что за заявки и для кого;
- direction — направление/тип задач (2–5 слов или фраз);
- stack — что фигурирует в заявках: предметы, услуги, технологии, материалы (1–6);
- grade — уровень/грейд, если ярко выражен (например: ["middle"]), иначе пустой список;
- keywords — 3–8 ключевых слов/фраз, по которым узнаётся такая заявка;
- messages — НОМЕРА сообщений из списка, которые относятся к этой колонке (минимум 2, максимум 12);
- reason — обоснование в 1 предложение: что за тема, сколько карточек и что в них общего.
Верни строго JSON:
{ "columns": [ { "name": "", "description": "", "direction": [""], "stack": [""], "grade": [""], "keywords": [""], "messages": [1, 5], "reason": "" } ] }"""
KEYWORDS_PROMPT = """Ты — аналитик входящих сообщений. Ниже — реальные сообщения, которые уже признаны заявками/лидами (и часть — обычный флуд каналов).
Сфера/что считается заявкой:
{domain}
Выдели общие слова-МАРКЕРЫ, по которым сообщение можно отнести к заявкам этой сферы (а не к флуду): типовые предметы/услуги/работы, глаголы-действия («куплю», «нужен», «сниму», «отремонтировать»), статусные слова («срочно», «под ключ», «бюджет»).
Верни строго JSON с плоским списком от 8 до 40 ключевых слов/фраз (слова в нижнем регистре, без знаков препинания):
{ "keywords": ["", ""] }"""
MIN_INBOX = 6 # минимум карточек в «Неразобранном» для анализа
MIN_INBOX_GROUP = 2 # минимум карточек в одной ИИ-колонке
MAX_TEXT = 12 # сколько сообщений берём в анализ (ИИ обрывает длинный JSON)
COOLDOWN_S = 20 * 60 # как часто автоматически переспрашиваем
KEY = "lastSuggestAt"
def _similar_exists(name: str) -> bool:
low = name.casefold()
rows = store.query("SELECT id, name FROM boards")
for r in rows:
n = str(r["name"]).casefold()
if low == n or low in n or n in low:
return True
return False
def _rules_for(item: dict) -> dict:
"""Правила колонки из ИИ-ответа: направление/слова/стек/грейд (любое из условий)."""
return {
"mode": "any",
"direction": [str(x).strip().lower() for x in (item.get("direction") or []) if str(x).strip()][:6],
"keywords": [str(x).strip().lower() for x in (item.get("keywords") or []) if str(x).strip()][:8],
"stack": [str(x).strip().lower() for x in (item.get("stack") or []) if str(x).strip()][:8],
"grade": [str(x).strip().lower() for x in (item.get("grade") or []) if str(x).strip()][:6],
}
async def suggest_from_inbox(force: bool = False) -> dict:
"""Анализ «Неразобранного» и создание колонок-предложений с обоснованием.
ИИ группирует пронумерованные сообщения и для каждой колонки возвращает
номера сообщений (messages). Карточки раскладываются именно по этим
номерам, а не строгим match_text по сгенерированным правилам — иначе
колонка-предложение часто остаётся пустой (правила ИИ приблизительные).
"""
# Предложения колонок — это вызов ИИ: при выключенном ИИ (aiEnabled=false,
# режим «только фильтр + ML») автоцикл не должен дёргать провайдера.
if not force and not store.get_setting("aiEnabled"):
return {"ok": False, "reason": "ИИ выключен — предложения колонок недоступны"}
if not force:
# не плодим предложения, пока пользователь не разобрался со старыми
pending = store.scalar("SELECT count(*) FROM boards WHERE suggested = TRUE")
if pending:
return {"ok": False, "reason": f"сначала решите судьбу {pending} предложенных колонок"}
last = int(store.get_setting(KEY) or 0)
if time.time() - last < COOLDOWN_S:
return {"ok": False, "reason": "недавно предлагали — подождите", "cooldown": True}
inbox = store.query(
"SELECT id, source_msg FROM leads WHERE col = 'inbox' AND source_msg <> '' "
"ORDER BY received_at DESC LIMIT ?",
[MAX_TEXT],
)
if len(inbox) < MIN_INBOX:
return {"ok": False, "reason": f"мало карточек в «Неразобранном» (нужно от {MIN_INBOX})"}
rows = [(str(r["id"]), str(r["source_msg"])) for r in inbox]
texts = [t[:180] for _, t in rows]
existing = [str(r["name"]) for r in store.query("SELECT name FROM boards WHERE suggested = FALSE ORDER BY pos")]
user = (
f"Существующие колонки: {', '.join(existing) if existing else 'нет'}\n\n"
"Сообщения:\n" + "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts, start=1))
)
try:
out = await ai_svc.chat_json(ai_svc.fill_prompt(SUGGEST_PROMPT), user, max_retries=2, max_tokens=16000)
except Exception as exc: # noqa: BLE001
log.warning("suggest failed: %s", exc)
return {"ok": False, "reason": f"ИИ недоступен: {exc}"}
cols = out.get("columns") if isinstance(out, dict) else None
if not isinstance(cols, list) or not cols:
log.info("suggest: ИИ не предложил колонок")
return {"ok": False, "reason": "ИИ не предложил колонок"}
# диагностика: что именно предложил ИИ (имена + число карточек)
log.info(
"suggest: ИИ предложил %d кандидатов: %s",
len(cols),
"; ".join(
f"{str(c.get('name'))[:40]}(msg={c.get('messages')})" for c in cols if isinstance(c, dict)
)[:300],
)
# номера, на которые уже «потратились» предыдущие колонки этого прогона
used_msg: set[int] = set()
created = 0
for item in cols[:4]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if len(name) < 2 or len(name) > 40:
continue
if _similar_exists(name):
continue
nums = [int(x) for x in (item.get("messages") or []) if str(x).isdigit()]
nums = [n for n in nums if 1 <= n <= len(rows) and n not in used_msg]
if len(nums) < MIN_INBOX_GROUP:
continue # колонка без явных карточек не создаётся
used_msg.update(nums)
rules = _rules_for(item)
note = _make_note(item, texts, rules, nums)
description = str(item.get("description") or "").strip()[:300]
board = _store_suggested(name, rules, note, description)
if not board:
continue
assigned = _assign_ids(rows, nums, board["id"])
if not assigned:
# ничего не удалось положить — пустое предложение не нужно
_rollback_suggested(board["id"])
continue
created += 1
if not created:
return {"ok": False, "reason": "похожие колонки уже есть или нечего сгруппировать"}
store.set_setting(KEY, int(time.time()))
await broker.publish("boards_changed", {})
await broker.publish_toast(f"ИИ предложил колонок: {created} — откройте и решите", "sparkles")
return {"ok": True, "created": created}
async def suggest_domain_keywords() -> dict:
"""«Предложить ключи ИИ»: по вашим карточкам выделяет общие слова-маркеры сферы.
Возвращает кандидатов — пользователь смотрит, правит и сохраняет в настройках
«Сфера и ключи» (общие ключи) или использует для правил колонок.
"""
rows = store.query(
"SELECT source_msg FROM leads WHERE col <> 'trash' AND col <> 'archive' AND source_msg <> '' "
"ORDER BY received_at DESC LIMIT 40"
)
texts = [str(r["source_msg"])[:350] for r in rows]
if len(texts) < 3:
return {"ok": False, "reason": "мало карточек — сначала накопите заявки (нужно хотя бы 3)"}
user = "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts))
try:
out = await ai_svc.chat_json(ai_svc.fill_prompt(KEYWORDS_PROMPT), user, max_retries=2)
except Exception as exc: # noqa: BLE001
log.warning("suggest keywords failed: %s", exc)
return {"ok": False, "reason": f"ИИ недоступен: {exc}"}
kws = out.get("keywords") if isinstance(out, dict) else None
if not isinstance(kws, list) or not kws:
return {"ok": False, "reason": "ИИ не смог выделить ключи — попробуйте ещё раз"}
clean: list[str] = []
for k in kws:
s = str(k).strip().casefold().strip(".,;:«»\"'()!#")
if s and len(s) <= 40 and s not in clean:
clean.append(s)
return {"ok": True, "keywords": clean[:60]}
def _make_note(item: dict, texts: list[str], rules: dict, nums: list[int]) -> str:
reason = str(item.get("reason") or "").strip()
direction = " · ".join(str(x) for x in (item.get("direction") or [])[:3])
base = f"Обоснование ИИ: {reason}" if reason else (
f"Обоснование ИИ: направление — {direction}" if direction else
"Обоснование ИИ: повторяющиеся заявки одной темы"
)
# сколько карточек из выборки реально попадёт в колонку (по номерам ИИ)
matched = sum(1 for n in nums if 1 <= n <= len(texts))
if matched:
base += f" · карточек в колонке: {matched}"
return base[:400]
def _store_suggested(name: str, rules: dict, note: str, description: str = "") -> dict | None:
from . import leads as leads_svc
keywords = rules.get("keywords") or []
board = leads_svc.create_board(
name, suggested=True, keywords=keywords, rules=rules, note=note, description=description
)
return leads_svc.board_by_id(board["id"])
def _assign_ids(rows: list[tuple[str, str]], nums: list[int], board_id: str) -> int:
"""Раскладывает в колонку-предложение карточки по номерам, которые назвал ИИ.
rows — список (id, source_msg) в том же порядке, в каком сообщения уходили
в промпт (1..N). Возвращает число реально перенесённых карточек.
"""
assigned = 0
for n in nums:
if not (1 <= n <= len(rows)):
continue
lead_id, _ = rows[n - 1]
still = store.scalar("SELECT 1 FROM leads WHERE id = ? AND col = 'inbox'", [lead_id])
if not still:
continue # карточка уже разобрана другим предложением/пользователем
store.execute(
"UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox' WHERE id = ? AND col = 'inbox'",
[board_id, lead_id],
)
assigned += 1
return assigned
def _rollback_suggested(board_id: str) -> None:
"""Удаляет пустую колонку-предложение (в неё ничего не попало)."""
from . import leads as leads_svc
store.execute("UPDATE leads SET col = 'inbox' WHERE col = ?", [board_id])
store.execute("DELETE FROM boards WHERE id = ?", [board_id])
@@ -0,0 +1,883 @@
"""Telegram-интеграция (п.4.2, 4.3 ТЗ).
api_id/api_hash берутся из настроек (введены в UI, НЕ из env). Сессия
Telethon сохраняется в файловой системе — повторная авторизация не нужна.
Вход: по телефону (+2FA) или по QR-ссылке. Сообщения из каналов с
включённым мониторингом кладутся в очередь pipeline, откуда их разбирает
фоновый воркер (стоп-фразы → ML → ИИ). В БД оседает только прошедшее
фильтры; исходное сообщение карточки хранит msg_id для «открыть исходник».
"""
from __future__ import annotations
import asyncio
import logging
import math
import random
import threading
import time
from telethon import TelegramClient, events, utils
from telethon.errors import SessionPasswordNeededError, PhoneCodeInvalidError, PhoneCodeExpiredError
from telethon.errors.rpcerrorlist import FloodWaitError
from telethon.tl import functions
from .. import config
from ..constants import DIALOG_HUES
from ..crypto import decrypt_text
from ..db import store
from ..sse import broker
from . import ban_guard
from . import pipeline
log = logging.getLogger("leadradar.tg")
BACKFILL_PER_MESSAGE = (1.5, 3.0) # секунды, чтобы не попасть под бан
BACKFILL_PER_DIALOG = (3.0, 6.0)
def _keys() -> dict:
raw = store.get_setting("tgKeys") or {}
api_hash = str(raw.get("apiHash", ""))
return {
"apiId": str(raw.get("apiId", "")).strip(),
"apiHash": (decrypt_text(api_hash) if api_hash.startswith("enc:") else api_hash),
}
# Главный event loop приложения: на нём живут Telethon-клиент и фоновые
# задачи. Синхронные роутеры FastAPI исполняются в threadpool (без running
# loop), поэтому корутины нужно планировать на этот loop через
# call_soon_threadsafe — Telethon не переносит клиент между циклами.
_MAIN_LOOP: asyncio.AbstractEventLoop | None = None
def register_main_loop(loop: asyncio.AbstractEventLoop) -> None:
global _MAIN_LOOP
_MAIN_LOOP = loop
def _spawn(coro) -> None:
"""Запустить корутину из любого контекста: running loop, главный loop или поток.
set_monitor/set_monitor_all вызываются из синхронных роутеров FastAPI, где
running loop отсутствует, поэтому прямой asyncio.create_task падает с
RuntimeError (отсюда Internal Server Error при включении канала).
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = _MAIN_LOOP
if loop is not None and loop.is_running():
loop.call_soon_threadsafe(loop.create_task, coro)
return
threading.Thread(
target=lambda: asyncio.new_event_loop().run_until_complete(coro),
daemon=True,
).start()
else:
loop.create_task(coro)
class TelegramManager:
def __init__(self) -> None:
self.client: TelegramClient | None = None
self.phase = "idle" # idle | phone | code | password | qr | ready
self.phone: str = ""
self._code_hash: str = ""
self._auth_lock = asyncio.Lock()
self._listener_task: asyncio.Task | None = None
self._monitored: set[str] = set()
self.error: str | None = None
self._disconnect_hook = None
# QR
self.qr_url: str = ""
self._qr_task: asyncio.Task | None = None
# сердцебиение статуса
self._last_connected: bool | None = None
# backfill каналов при первом подключении
self._backfilling: set[str] = set()
# ── состояние для UI ──────────────────────────────────────────────────
def status(self) -> dict:
row = store.query_one(
"SELECT count(*) AS d FROM dialogs WHERE monitor = TRUE"
)
acc = store.get_setting("tgAccount") or ""
connected = bool(self.client and self.client.is_connected())
listener_alive = bool(self._listener_task and not self._listener_task.done())
return {
"phase": self.phase,
"connected": connected,
"listener": listener_alive,
"account": acc,
"monitored": int(row["d"]) if row else 0,
"keysSet": bool(_keys().get("apiId") and _keys().get("apiHash")),
"error": self.error,
"qrUrl": self.qr_url if self.phase == "qr" else None,
}
def _client(self) -> TelegramClient:
keys = _keys()
api_id = str(keys.get("apiId") or "").strip()
api_hash = str(keys.get("apiHash") or "").strip()
if not api_id or not api_hash:
raise ValueError("Сначала сохраните Telegram api_id и api_hash в настройках")
if self.client is None:
session = str(config.SESSIONS_DIR / config.SESSION_PREFIX)
self.client = TelegramClient(session, int(api_id), api_hash)
return self.client
# ── веб-авторизация ───────────────────────────────────────────────────
async def start_phone(self, phone: str) -> None:
async with self._auth_lock:
self.phone = phone
self.error = None
try:
client = self._client()
await client.connect()
sent = await client.send_code_request(phone)
self._code_hash = sent.phone_code_hash
self.phase = "code"
except Exception as exc: # noqa: BLE001
self.phase = "idle"
self.error = str(exc)
raise
async def submit_code(self, code: str) -> None:
async with self._auth_lock:
self.error = None
client = self._client()
try:
await client.sign_in(self.phone, code, phone_code_hash=self._code_hash)
await self._finalize()
except SessionPasswordNeededError:
self.phase = "password"
except PhoneCodeInvalidError:
self.error = "Неверный код"
raise ValueError("Неверный код")
except PhoneCodeExpiredError:
self.error = "Код истёк — запросите новый"
raise ValueError("Код истёк — запросите новый")
except Exception as exc: # noqa: BLE001
self.error = str(exc)
raise
async def submit_password(self, password: str) -> None:
async with self._auth_lock:
self.error = None
try:
await self.client.sign_in(password=password)
await self._finalize()
except Exception as exc: # noqa: BLE001
self.error = "Неверный облачный пароль"
raise ValueError("Неверный облачный пароль") from exc
async def _finalize(self, notify: bool = True) -> None:
me = await self.client.get_me()
store.set_setting("tgAccount", f"@{me.username or 'user'}")
self.phase = "ready"
self._start_listener()
await self.refresh_dialogs()
self._schedule_first_backfill()
if notify:
await broker.publish_toast("Telegram подключён, сессия сохранена", "send")
await self._publish_status()
async def disconnect(self) -> None:
async with self._auth_lock:
if self._qr_task:
self._qr_task.cancel()
self._qr_task = None
if self._listener_task:
self._listener_task.cancel()
self._listener_task = None
if self.client:
try:
await self.client.disconnect()
except Exception: # noqa: BLE001
pass
self.phase = "idle"
self._monitored.clear()
self.qr_url = ""
store.set_setting("tgAccount", "")
await broker.publish_toast("Telegram отключён", "logout")
await self._publish_status()
async def auto_resume(self) -> None:
"""При старте сервера: если сессия сохранена — подключиться автоматически."""
try:
keys = _keys()
if not (keys.get("apiId") and keys.get("apiHash")):
return
client = self._client()
await client.connect()
if await client.is_user_authorized():
await self._finalize(notify=False)
except Exception as exc: # noqa: BLE001
log.info("auto_resume skipped: %s", exc)
self.phase = "idle"
# ── прослушивание ─────────────────────────────────────────────────────
def _start_listener(self) -> None:
if self._listener_task and not self._listener_task.done():
return
self._reload_monitored()
self.client.add_event_handler(self._on_message, events.NewMessage())
self._listener_task = asyncio.create_task(self.client.run_until_disconnected())
def _on_done(task: asyncio.Task) -> None:
if task.cancelled():
log.info("listener stopped (cancelled)")
return
exc = task.exception()
if exc:
log.error("listener crashed: %s", exc)
else:
log.info("listener stopped")
self._listener_task.add_done_callback(_on_done)
def _reload_monitored(self) -> None:
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE")
self._monitored = {r["id"] for r in rows}
def _dialog_id(self, message) -> str:
try:
chat_id = message.chat_id
except Exception: # noqa: BLE001
chat_id = message.peer_id
return str(chat_id)
async def _on_message(self, event) -> None:
"""Каждое сообщение мониторящегося диалога кладём в очередь pipeline."""
try:
msg = event.message
if msg is None or msg.text is None or not msg.text.strip():
return
dialog_id = self._dialog_id(event.message)
if dialog_id not in self._monitored:
return
chat = await event.get_chat()
ch_name = getattr(chat, "title", None) or getattr(chat, "first_name", "") or dialog_id
ch_handle = getattr(chat, "username", "") or ""
hue = dialog_hue(dialog_id, ch_name)
now = time.time_ns() // 1_000_000
ts = int(msg.date.timestamp() * 1000) if getattr(msg, "date", None) else now
store.execute(
"UPDATE dialogs SET last_text = ?, last_at = ?, updated_at = ? WHERE id = ?",
[msg.text.strip()[:200], now, now, dialog_id],
)
pipeline.enqueue(dialog_id, ch_name, ch_handle, hue, msg.id, msg.text, ts)
# помечаем сообщение прочитанным в Telegram, чтобы оно не висело
# «новым» в других клиентах/устройствах
try:
await self.client.send_read_acknowledge(chat)
except Exception: # noqa: BLE001
log.debug("read ack failed", exc_info=True)
except Exception as exc: # noqa: BLE001
log.exception("on_message failed: %s", exc)
# ── QR-вход ───────────────────────────────────────────────────────────
async def qr_start(self) -> str:
async with self._auth_lock:
self.error = None
client = self._client()
await client.connect()
if await client.is_user_authorized():
await self._finalize(notify=False)
return ""
if self._qr_task and not self._qr_task.done():
return self.qr_url
qr = await client.qr_login()
self.qr_url = qr.url
self.phase = "qr"
self._qr_task = asyncio.create_task(self._wait_qr(qr))
return self.qr_url
async def _wait_qr(self, qr) -> None:
try:
await qr.wait()
await self._finalize(notify=True)
except Exception as exc: # noqa: BLE001
log.warning("qr wait error: %s", exc)
self.error = str(exc)
self.phase = "idle"
finally:
self._qr_task = None
# ── статус (сердцебиение) ─────────────────────────────────────────────
async def _publish_status(self) -> None:
await broker.publish("system_status", self.status())
async def heartbeat(self) -> None:
"""Периодический вызов из планировщика: уведомляем, если Telegram «уснул»."""
connected = bool(self.client and self.client.is_connected())
if self.phase == "ready" and connected != self._last_connected:
self._last_connected = connected
await self._publish_status()
if not connected:
await broker.publish_toast("Telegram отключён — переподключение при следующей проверке", "bell")
if self.phase == "ready" and connected:
self._last_connected = True
# ── backfill: при первом подключении по 10 последних сообщений ────────
def _schedule_first_backfill(self) -> None:
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE AND backfilled = FALSE")
ids = [r["id"] for r in rows]
if ids:
asyncio.create_task(self._backfill_dialogs(ids))
async def _backfill_dialogs(self, dialog_ids: list[str], force: bool = False) -> None:
for dialog_id in dialog_ids:
if dialog_id in self._backfilling:
continue
try:
processed = await self.backfill_dialog(dialog_id, force=force)
if processed:
log.info("backfill %s: %d сообщений в очередь", dialog_id, processed)
except Exception as exc: # noqa: BLE001
log.warning("backfill %s failed: %s", dialog_id, exc)
await asyncio.sleep(random.uniform(*BACKFILL_PER_DIALOG))
async def backfill_dialog(self, dialog_id: str, force: bool = False) -> int:
"""Последние 10 сообщений канала: разбираем с паузами (анти-бан).
force=True — «Перечитать» по кнопке: даже если канал уже разобран.
Повторы карточек не создаются (защита dedup по нормализованному тексту).
"""
if dialog_id in self._backfilling:
return 0
client = self.client
if not client or not client.is_connected():
return 0
row = store.query_one("SELECT backfilled FROM dialogs WHERE id = ?", [dialog_id])
if not row or (not force and bool(row["backfilled"])):
return 0
self._backfilling.add(dialog_id)
processed = 0
try:
entity = await client.get_entity(int(dialog_id))
chat = await client.get_entity(int(dialog_id))
name = getattr(chat, "title", None) or getattr(chat, "first_name", "") or dialog_id
handle = getattr(chat, "username", "") or ""
hue = dialog_hue(dialog_id, name)
msgs = await client.get_messages(entity, limit=10)
for m in reversed(msgs): # от старых к новым, как реальный поток
if m.text is None or not m.text.strip():
continue
now = time.time_ns() // 1_000_000
ts = int(m.date.timestamp() * 1000) if m.date else now
# в очередь уходит всё; отсев сделает воркер, в БД осядет только
# то, что прошло фильтры
pipeline.enqueue(dialog_id, name, handle, hue, m.id, m.text, ts)
processed += 1
await asyncio.sleep(random.uniform(*BACKFILL_PER_MESSAGE))
# «Перечитать» — вручную вытащили сообщения: снимаем «новое» в Telegram
try:
await client.send_read_acknowledge(entity)
except Exception: # noqa: BLE001
log.debug("backfill read ack failed", exc_info=True)
store.execute("UPDATE dialogs SET backfilled = TRUE WHERE id = ?", [dialog_id])
finally:
self._backfilling.discard(dialog_id)
return processed
async def realtime_sweep(self) -> None:
"""Страховка realtime: если событие потеряно (рестарт/разрыв), раз в 30 с
докачиваем непрочитанные сообщения включённых каналов, кладём в очередь
и помечаем прочитанными."""
client = self.client
if not client or not client.is_connected():
return
self._reload_monitored()
if not self._monitored:
return
try:
dialogs = await client.get_dialogs(limit=500)
except Exception as exc: # noqa: BLE001
log.debug("realtime sweep: get_dialogs fail: %s", exc)
return
# синхронизация списка: новые каналы/группы появляются и включаются
# автоматически, удалённые исчезают (см. _persist_dialogs)
try:
entries = []
for dlg in dialogs:
ent = dlg.entity
name = dlg.name or ""
entries.append(
(
str(dlg.id),
name,
getattr(ent, "username", "") or "",
self._kind_of(ent),
dialog_hue(str(dlg.id), name),
)
)
if entries:
self._persist_dialogs(entries)
except Exception as exc: # noqa: BLE001
log.debug("realtime sweep: sync dialogs fail: %s", exc)
for dlg in dialogs:
did = str(dlg.id)
if did not in self._monitored:
continue
unread = int(getattr(dlg, "unread_count", 0) or 0)
if unread <= 0:
continue
try:
msgs = await client.get_messages(dlg.entity, limit=min(unread + 2, 10))
added = 0
for m in reversed(msgs): # от старых к новым
if m.text is None or not m.text.strip():
continue
if store.scalar(
"SELECT 1 FROM pipeline_msg WHERE dialog_id = ? AND msg_id = ?", [did, m.id]
):
continue
ent = dlg.entity
name = getattr(ent, "title", None) or getattr(ent, "first_name", "") or did
handle = getattr(ent, "username", "") or ""
hue = dialog_hue(did, name)
now = time.time_ns() // 1_000_000
ts = int(m.date.timestamp() * 1000) if getattr(m, "date", None) else now
pipeline.enqueue(did, name, handle, hue, m.id, m.text, ts)
added += 1
if added:
log.info("realtime sweep %s: +%d в очередь (потерянные события)", did, added)
await client.send_read_acknowledge(dlg.entity)
except Exception as exc: # noqa: BLE001
log.debug("realtime sweep dialog %s fail: %s", did, exc)
# ── диалоги/каналы ────────────────────────────────────────────────────
@staticmethod
def _kind_of(entity) -> str:
if getattr(entity, "broadcast", False):
return "канал"
if getattr(entity, "megagroup", False) or getattr(entity, "gigagroup", False) or getattr(entity, "group", False):
return "группа"
return "чат"
def _persist_dialogs(self, entries: list[tuple]) -> int:
"""Синхронизация списка диалогов с Telegram.
- новые чаты/каналы добавляются; авто-мониторинг новых управляется
настройкой autoMonitorNew (вкл — любой появившийся чат мониторится,
выкл — появляется отключённым);
- переименования/смена типа обновляются (monitor пользователя не трогаем);
- диалоги, которых больше нет в Telegram (вышел/удалил), удаляются.
"""
if not entries:
return 0
now = time.time_ns() // 1_000_000
auto_new = bool(store.get_setting("autoMonitorNew"))
seen: set[str] = set()
for dlg_id, name, handle, kind, hue in entries:
seen.add(dlg_id)
store.execute(
"INSERT INTO dialogs(id, name, handle, kind, hue, monitor, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET name = excluded.name, handle = excluded.handle, "
"kind = excluded.kind, hue = excluded.hue, updated_at = excluded.updated_at",
[dlg_id, name, handle, kind, hue, auto_new, now],
)
stale = store.query(
"SELECT id FROM dialogs WHERE id NOT IN (" + ",".join(["?"] * len(seen)) + ")",
list(seen),
)
if stale:
stale_ids = [r["id"] for r in stale]
store.execute(
"DELETE FROM dialogs WHERE id IN (" + ",".join(["?"] * len(stale_ids)) + ")",
stale_ids,
)
log.info("dialogs sync: удалено устаревших источников: %d", len(stale_ids))
self._reload_monitored()
return len(entries)
async def refresh_dialogs(self) -> int:
client = self._client()
if not client.is_connected():
await client.connect()
entries: list[tuple] = []
async for dialog in client.iter_dialogs(limit=500):
entity = dialog.entity
name = dialog.name or ""
handle = getattr(entity, "username", "") or ""
kind = self._kind_of(entity)
hue = dialog_hue(str(dialog.id), name)
entries.append((str(dialog.id), name, handle, kind, hue))
if entries:
self._persist_dialogs(entries)
return len(entries)
def list_dialogs(self) -> list[dict]:
rows = store.query("SELECT * FROM dialogs ORDER BY monitor DESC, name")
return [
{
"id": r["id"],
"name": r["name"],
"handle": r["handle"],
"type": r["kind"],
"hue": r["hue"],
"on": bool(r["monitor"]),
"last": {"text": r["last_text"], "time": r["last_at"]},
}
for r in rows
]
def set_monitor(self, dialog_id: str, enabled: bool) -> None:
store.execute(
"UPDATE dialogs SET monitor = ?, updated_at = ? WHERE id = ?",
[enabled, time.time_ns() // 1_000_000, dialog_id],
)
self._reload_monitored()
if enabled:
row = store.query_one("SELECT backfilled FROM dialogs WHERE id = ?", [dialog_id])
if row and not bool(row["backfilled"]):
# первое включение мониторинга: разбираем последние 10 сообщений с паузами
_spawn(self.backfill_dialog(dialog_id))
def set_monitor_all(self, enabled: bool) -> int:
"""Включить/выключить мониторинг сразу для всех диалогов.
Первое включение каждого канала разбирается последовательно (с паузами),
чтобы не попасть под бан Telegram.
"""
now = time.time_ns() // 1_000_000
rows = store.query("SELECT id, backfilled FROM dialogs")
to_backfill: list[str] = []
for r in rows:
store.execute(
"UPDATE dialogs SET monitor = ?, updated_at = ? WHERE id = ?",
[enabled, now, r["id"]],
)
if enabled and not bool(r["backfilled"]):
to_backfill.append(r["id"])
self._reload_monitored()
if enabled and to_backfill:
_spawn(self._backfill_dialogs(to_backfill))
return len(rows)
def backfill_monitored(self) -> int:
"""Кнопка «Перечитать»: последние 10 сообщений всех включённых каналов.
Разбор идёт в фоне последовательно с паузами (анти-бан), даже если
канал уже разобран (force). Включённые каналы продолжают ловить новые
сообщения в реальном времени — это ручная догонялка.
"""
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE")
ids = [r["id"] for r in rows]
if not ids:
return 0
_spawn(self._backfill_dialogs(ids, force=True))
return len(ids)
async def dialog_messages(self, dialog_id: str, limit: int = 24) -> list[dict]:
"""Последние сообщения диалога: свежие берём из Telegram, старые — из БД."""
out: list[dict] = []
client = self.client
try:
if client and client.is_connected():
entity = await client.get_entity(int(dialog_id))
msgs = await client.get_messages(entity, limit=min(limit, 30))
now = time.time_ns() // 1_000_000
for m in msgs:
if m.text:
row = store.query_one(
"SELECT id FROM messages WHERE id = ?",
[f"m_{dialog_id}_{m.id}"],
)
lead_id = None
if row:
lead_id = row.get("lead_id") or None
if not row:
store.execute(
"INSERT OR IGNORE INTO messages(id, dialog_id, text, msg_at) VALUES (?, ?, ?, ?)",
[f"m_{dialog_id}_{m.id}", dialog_id, m.text[:4000], now],
)
out.append({"id": m.id, "text": m.text, "time": m.date.timestamp() * 1000, "lead": bool(lead_id)})
# вручную вытащили сообщения — снимаем «новое» в Telegram
try:
await client.send_read_acknowledge(entity)
except Exception: # noqa: BLE001
log.debug("dialog_messages read ack failed", exc_info=True)
except Exception as exc: # noqa: BLE001
log.warning("dialog_messages tg fail: %s", exc)
if not out:
rows = store.query(
"SELECT * FROM messages WHERE dialog_id = ? ORDER BY msg_at DESC LIMIT ?",
[dialog_id, limit],
)
out = [{"id": r["id"], "text": r["text"], "time": r["msg_at"], "lead": bool(r["lead_id"])} for r in rows]
return out
# ── discovery: поиск каналов и действия (Task 4) ──────────────────────
async def discovery_search(self, q: str, limit: int = 30) -> list[dict]:
"""Глобальный поиск каналов/групп по ключу (contacts.search).
id возвращается подписанным (как в dialogs: каналы -100…, группы -id,
люди +id). Найденные entity кэшируются в сессию Telethon — тогда
discovery_info/read смогут получить участников/историю по id даже без
вступления (публичные источники).
"""
client = self.client
if not client or not client.is_connected():
raise RuntimeError("Telegram не подключён")
found = await client(functions.contacts.SearchRequest(q=q, limit=limit))
# пауза между поисковыми запросами (анти-бан, BanGuard)
await asyncio.sleep(ban_guard.search_pause())
try:
client.session.process_entities(found)
except Exception: # noqa: BLE001
log.debug("discovery_search: cache entities failed", exc_info=True)
out: list[dict] = []
seen: set[str] = set()
for ent in (*found.chats, *found.users):
try:
dialog_id = str(utils.get_peer_id(ent))
except (TypeError, ValueError):
continue
if dialog_id in seen:
continue
seen.add(dialog_id)
name = utils.get_display_name(ent) or dialog_id
out.append(
{
"id": dialog_id,
"name": name,
"username": getattr(ent, "username", "") or "",
"kind": self._kind_of(ent),
"hue": dialog_hue(dialog_id, name),
}
)
if len(out) >= limit:
break
return out
async def discovery_info(self, dialog_id: str) -> dict:
"""Инфо об источнике: тип, username, участники, признак форума.
participants берётся из full_chat (channels.getFullChannel для
каналов/супергрупп, messages.getFullChat для базовых групп); если
определить не удалось (нет членства/приватный/ошибка) — None,
исключение наружу не бросаем.
"""
result = {
"id": str(dialog_id),
"name": str(dialog_id),
"username": "",
"kind": "",
"hue": dialog_hue(str(dialog_id), str(dialog_id)),
"participants": None,
"is_forum": False,
}
client = self.client
if not client or not client.is_connected():
return result
try:
entity = await client.get_entity(int(dialog_id))
except Exception as exc: # noqa: BLE001
log.warning("discovery_info %s: entity not resolved: %s", dialog_id, exc)
return result
name = utils.get_display_name(entity) or str(dialog_id)
kind = self._kind_of(entity)
result.update(
name=name,
username=getattr(entity, "username", "") or "",
kind=kind,
hue=dialog_hue(str(dialog_id), name),
is_forum=bool(getattr(entity, "forum", False)),
)
try:
if kind in ("канал", "группа"):
full = await client(functions.channels.GetFullChannelRequest(entity))
full_chat = full.full_chat
participants = int(getattr(full_chat, "participants_count", 0) or 0)
result["participants"] = participants or None
elif getattr(entity, "title", None):
# базовая группа (legacy): полный чат приносит список участников
full = await client(functions.messages.GetFullChatRequest(entity.id))
members = getattr(
getattr(full.full_chat, "participants", None), "participants", None
)
if members:
result["participants"] = len(members)
except Exception as exc: # noqa: BLE001
log.debug("discovery_info %s: participants unavailable: %s", dialog_id, exc)
return result
async def discovery_read(self, dialog_id: str, limit: int) -> dict:
"""Последние сообщения источника для оценки (без создания карточек).
Форум (entity.forum): читаем выборку по активным темам
(channels.getForumTopics + по каждой теме get_messages(reply_to=topic_id))
и возвращаем плоский список с topic_id/topic_title. Для обычных
источников topic_id/topic_title = None. История недоступна
(приватный/закрытый источник без членства) — ok=False,
error="no_history". Исключения наружу не бросаем: ошибка в темах —
безопасный fallback на обычное чтение ленты.
"""
client = self.client
limit = max(int(limit or 0), 0)
if limit <= 0:
return {"ok": True, "error": None, "messages": []}
if not client or not client.is_connected():
return {"ok": False, "error": "no_history", "messages": []}
try:
entity = await client.get_entity(int(dialog_id))
except Exception as exc: # noqa: BLE001
log.warning("discovery_read %s: entity not resolved: %s", dialog_id, exc)
return {"ok": False, "error": "no_history", "messages": []}
if getattr(entity, "forum", False):
try:
topic_msgs = await self._read_forum_topics(client, entity, limit)
except Exception as exc: # noqa: BLE001
log.debug("discovery_read %s: forum topics fail, fallback to feed: %s", dialog_id, exc)
topic_msgs = []
if topic_msgs:
return {"ok": True, "error": None, "messages": topic_msgs}
# обычная лента (каналы/группы; для форума — General-тема)
try:
msgs = await client.get_messages(entity, limit=limit)
except Exception as exc: # noqa: BLE001
log.warning("discovery_read %s: history unavailable: %s", dialog_id, exc)
return {"ok": False, "error": "no_history", "messages": []}
now = time.time_ns() // 1_000_000
out: list[dict] = []
for m in msgs:
item = self._discovery_message_item(m, None, None, now)
if item:
out.append(item)
return {"ok": True, "error": None, "messages": out}
async def _read_forum_topics(self, client, entity, limit: int) -> list[dict]:
"""Выборка сообщений по активным темам форума.
Возвращает плоский список {id, text, date_ms, topic_id, topic_title}.
Исключения не бросает: темы, которые не прочитались, пропускаются,
вызывающий решает, делать ли fallback на обычную ленту.
"""
out: list[dict] = []
try:
res = await client(
functions.channels.GetForumTopicsRequest(
channel=entity, offset_date=0, offset_id=0, offset_topic=0, limit=5
)
)
topics = list(getattr(res, "topics", None) or [])
except Exception as exc: # noqa: BLE001
log.debug("discovery_read: getForumTopics fail: %s", exc)
return out
if not topics:
return out
# на тему минимум 3 сообщения (иначе тема почти всегда отсеется как
# «мало подходящих»), cap 10; суммарно выборка может слегка превысить limit
per_topic = min(max(3, math.ceil(limit / len(topics))), 10)
now = time.time_ns() // 1_000_000
for topic in topics:
topic_id = int(getattr(topic, "id", 0) or 0)
topic_title = getattr(topic, "title", "") or ""
if not topic_id:
continue
try:
msgs = await client.get_messages(entity, limit=per_topic, reply_to=topic_id)
except Exception as exc: # noqa: BLE001
log.debug("discovery_read: topic %s read fail: %s", topic_id, exc)
continue
for m in msgs:
item = self._discovery_message_item(m, topic_id, topic_title, now)
if item:
out.append(item)
return out
@staticmethod
def _discovery_message_item(m, topic_id, topic_title, now: int) -> dict | None:
"""Одно сообщение discovery_read: только непустой текст (как в
backfill/dialog_messages), поля {id, text, date_ms, topic_id, topic_title}."""
text = getattr(m, "text", None)
if not text or not text.strip():
return None
date = getattr(m, "date", None)
return {
"id": m.id,
"text": text,
"date_ms": int(date.timestamp() * 1000) if date else now,
"topic_id": topic_id,
"topic_title": topic_title,
}
async def discovery_join(self, username: str) -> None:
"""Вступить в канал/группу по @username (channels.JoinChannelRequest).
Ручной join из API — вне квот, без пауз; паузу перед авто-вступлением
делает воркер (ban_guard.wait_join_delay). FloodWaitError фиксируется
в BanGuard (стоп авто-вступлений до конца суток) и пробрасывается
вызывающему.
"""
username = (username or "").strip().lstrip("@")
if not username:
raise ValueError("Не указан username для вступления")
client = self.client
if not client or not client.is_connected():
raise RuntimeError("Telegram не подключён")
try:
entity = await client.get_entity(username)
await client(functions.channels.JoinChannelRequest(entity))
except FloodWaitError:
ban_guard.note_flood()
log.warning("discovery_join %s: flood — авто-вступления стоп до конца суток", username)
raise
log.info("discovery_join: вступили в @%s", username)
async def discovery_leave(self, dialog_id: str) -> None:
"""Выйти из канала/группы (channels.LeaveChannelRequest)."""
client = self.client
if not client or not client.is_connected():
raise RuntimeError("Telegram не подключён")
entity = await client.get_entity(int(dialog_id))
await client(functions.channels.LeaveChannelRequest(entity))
log.info("discovery_leave: вышли из %s", dialog_id)
def add_dialog_monitored(self, dialog_id, name, username, kind, hue) -> None:
"""Добавить источник в dialogs с monitor=TRUE (после вступления).
INSERT/UPDATE без авто-логики (как set_monitor, но без запуска
backfill): backfilled=FALSE — разбор последних сообщений подхватит
обычный механизм при первом подключении/перечитывании.
"""
now = time.time_ns() // 1_000_000
store.execute(
"INSERT INTO dialogs(id, name, handle, kind, hue, monitor, backfilled, updated_at) "
"VALUES (?, ?, ?, ?, ?, TRUE, FALSE, ?) "
"ON CONFLICT(id) DO UPDATE SET name = excluded.name, handle = excluded.handle, "
"kind = excluded.kind, hue = excluded.hue, monitor = TRUE, backfilled = FALSE, "
"updated_at = excluded.updated_at",
[
str(dialog_id),
name or str(dialog_id),
username or "",
kind or "",
hue or "#666",
now,
],
)
self._reload_monitored()
def dialog_hue(dialog_id: str, name: str = "") -> str:
h = 0
for ch in (dialog_id + name):
h = (h * 31 + ord(ch)) % len(DIALOG_HUES)
return DIALOG_HUES[h]
tg = TelegramManager()