Нормализовать переводы строк в LF

Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF,
CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были
LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения),
.editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize.
Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
This commit is contained in:
Rustam Khalimov
2026-09-11 19:01:42 +03:00
parent 39c9bdc1b7
commit 713d554dc2
751 changed files with 94507 additions and 94486 deletions
@@ -1 +1 @@
"""Сервисный слой."""
"""Сервисный слой."""
@@ -1,357 +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}
"""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}
@@ -1,80 +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)
"""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)
File diff suppressed because it is too large Load Diff
@@ -1,237 +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)
"""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)
@@ -1,484 +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
"""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
@@ -1,100 +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()
"""Вложения проектных карточек.
По решению: файлы хранятся в 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()
@@ -1,103 +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]
"""Полнотекстовый поиск 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]
File diff suppressed because it is too large Load Diff
@@ -1,162 +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"]
"""Клиент автономного 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"]
@@ -1,108 +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)
"""Хранение вложений: 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
@@ -1,320 +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(),
}
"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев).
Очередь — сырые сообщения из каналов, ждущие разбора (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(),
}
@@ -1,282 +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
"""«Выбранные» — проектный канбан (п.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
@@ -1,130 +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
"""Курсы валют: источник ЦБ РФ, 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
@@ -1,390 +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
"""Детерминированные правила колонок: направление, стек, слова, грейд, бюджет.
Колонка — это набор опциональных фильтров, задаёт пользователь (или ИИ при
предложении). Если текст входящего сообщения соответствует правилам — карточка
уходит в эту колонку сразу, без 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
@@ -1,247 +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])
"""ИИ-предложения колонок по анализу «Неразобранного».
Колонка — не «на один скилл», а смысловая тема: направление + набор стека и
ключевых слов (+ бюджет при повторяемости). ИИ группирует карточки и создаёт
КОЛОНКИ-ПРЕДЛОЖЕНИЯ (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])
File diff suppressed because it is too large Load Diff