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

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1 @@
"""LeadRadar backend (FastAPI + DuckDB)."""
@@ -0,0 +1,98 @@
"""Авторизация в дашборд: admin/admin по умолчанию, сессия 30 дней.
Хэш пароля — PBKDF2-HMAC-SHA256. Токен сессии — случайный, хранится в БД,
передаётся httpOnly-кукой. По ТЗ смена пароля — через интерфейс.
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
import time
from fastapi import Cookie, HTTPException, Response
from . import config
from .db import store
_ITERATIONS = 200_000
def _hash_password(password: str, salt: str) -> str:
return hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), _ITERATIONS).hex()
def _make_salt() -> str:
return secrets.token_hex(16)
def ensure_creds() -> None:
"""Создаёт учётные данные по умолчанию, если их нет."""
if store.scalar("SELECT count(*) FROM creds") == 0:
salt = _make_salt()
store.execute(
"INSERT INTO creds(login, password_hash, salt) VALUES (?, ?, ?)",
[config.BOOTSTRAP_LOGIN, _hash_password(config.BOOTSTRAP_PASSWORD, salt), salt],
)
def verify_password(login: str, password: str) -> bool:
row = store.query_one("SELECT password_hash, salt FROM creds WHERE login = ?", [login])
if not row:
return False
return hmac.compare_digest(row["password_hash"], _hash_password(password, row["salt"]))
def change_password(login: str, old_password: str, new_password: str) -> bool:
if not verify_password(login, old_password):
return False
if len(new_password) < 4:
raise HTTPException(400, "Пароль слишком короткий (минимум 4 символа)")
salt = _make_salt()
store.execute(
"UPDATE creds SET password_hash = ?, salt = ? WHERE login = ?",
[_hash_password(new_password, salt), salt, login],
)
# разлогиниваем все старые сессии пользователя
store.execute("DELETE FROM sessions WHERE login = ?", [login])
return True
def create_session(login: str) -> str:
token = secrets.token_urlsafe(32)
expires = time.time_ns() // 1_000_000 + config.SESSION_DAYS * 86_400_000
store.execute("INSERT INTO sessions(token, login, expires) VALUES (?, ?, ?)", [token, login, expires])
return token
def resolve_session(token: str | None) -> str | None:
if not token:
return None
row = store.query_one(
"SELECT login FROM sessions WHERE token = ? AND expires > ?",
[token, time.time_ns() // 1_000_000],
)
return row["login"] if row else None
def set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=config.COOKIE_NAME,
value=token,
max_age=config.SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
# secure включается при HTTPS-проксировании; флаг не критичен локально
)
def destroy_session(token: str | None) -> None:
if token:
store.execute("DELETE FROM sessions WHERE token = ?", [token])
def current_login(token: str | None = Cookie(default=None, alias=config.COOKIE_NAME)) -> str | None:
login = resolve_session(token)
if not login:
raise HTTPException(401, "Требуется авторизация")
return login
@@ -0,0 +1,99 @@
"""Конфигурация из переменных окружения.
По ТЗ в env выносятся ТОЛЬКО неконфиденциальные параметры:
порты, пути к БД/данным/сессиям. Секреты (Telegram api_id/hash,
ключи AI, пароль дашборда) живут в базе и задаются через UI.
"""
from __future__ import annotations
import os
from pathlib import Path
def _bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except ValueError:
return default
# Корень данных (том в docker-compose). Внутри лежат leadradar.duckdb,
# telegram-сессии, файлы вложений (позже — MinIO), секрет подписи сессий.
DATA_DIR = Path(os.getenv("LEADRADAR_DATA", str(Path(__file__).resolve().parent.parent / "data"))).resolve()
DB_PATH = DATA_DIR / os.getenv("LEADRADAR_DB_NAME", "leadradar.duckdb")
SESSIONS_DIR = DATA_DIR / "telegram_sessions"
FILES_DIR = DATA_DIR / "attachments"
SECRET_FILE = DATA_DIR / "session_secret.key"
# Хост/порт HTTP-сервера
HOST = os.getenv("LEADRADAR_HOST", "0.0.0.0")
PORT = _int("LEADRADAR_PORT", 8000)
# Через env допустимо задать стартовый секрет (например, из секрет-менеджера),
# но если он не задан — будет создан случайный и сохранён в SECRET_FILE.
SESSION_SECRET = os.getenv("LEADRADAR_SESSION_SECRET", "") or None
# Учётные данные первого входа можно выставить и в env (удобно для развёртывания),
# НО по умолчанию система стартует с admin/admin и требует смены пароля.
BOOTSTRAP_LOGIN = os.getenv("LEADRADAR_BOOTSTRAP_LOGIN", "admin")
BOOTSTRAP_PASSWORD = os.getenv("LEADRADAR_BOOTSTRAP_PASSWORD", "admin")
# Шифрование секретов в БД. По договорённости ключ живёт в env;
# при локальной разработке без env создаётся файл data/encryption.key.
ENCRYPTION_KEY = os.getenv("LEADRADAR_ENCRYPTION_KEY", "") or None
ENCRYPTION_KEY_FILE = DATA_DIR / "encryption.key"
# MinIO (вложения; креды по договорённости — в env)
MINIO_ENDPOINT = os.getenv("LEADRADAR_MINIO_ENDPOINT", "")
MINIO_ACCESS_KEY = os.getenv("LEADRADAR_MINIO_ACCESS_KEY", "")
MINIO_SECRET_KEY = os.getenv("LEADRADAR_MINIO_SECRET_KEY", "")
MINIO_BUCKET = os.getenv("LEADRADAR_MINIO_BUCKET", "leadradar")
MINIO_SECURE = _bool("LEADRADAR_MINIO_SECURE", False)
# Автономный ML-сервис (отдельный контейнер). Если недоступен — модель не
# используется в пайплайне, а события обучения копятся в outbox до его возврата.
ML_URL = os.getenv("LEADRADAR_ML_URL", "http://127.0.0.1:8100").rstrip("/")
ML_TIMEOUT = _int("LEADRADAR_ML_TIMEOUT", 30) # обучение батчами — тяжёлое, таймаут щедрый
# Куки
COOKIE_NAME = "leadradar_session"
SESSION_DAYS = 30
# Telegram-сессия (Telethon)
SESSION_PREFIX = "user"
# Путь к статике фронтенда (собранный dist). Если папки нет — API работает
# отдельно (фронт поднимается dev-сервером), статика просто не раздаётся.
FRONTEND_DIST = Path(os.getenv("LEADRADAR_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"))).resolve()
# Логи
LOG_LEVEL = os.getenv("LEADRADAR_LOG_LEVEL", "INFO")
# Демо-эндпоинты (simulate-lead, age-lead). В проде выключено;
# включается только для локальных тестов разработчика: LEADRADAR_DEMO=1
DEMO_ENABLED = _bool("LEADRADAR_DEMO", False)
def ensure_dirs() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
FILES_DIR.mkdir(parents=True, exist_ok=True)
def get_or_create_secret() -> str:
if SESSION_SECRET:
return SESSION_SECRET
if SECRET_FILE.exists():
return SECRET_FILE.read_text(encoding="utf-8").strip()
import secrets
secret = secrets.token_hex(32)
SECRET_FILE.write_text(secret, encoding="utf-8")
return secret
@@ -0,0 +1,253 @@
"""Базовые константы: палитры, стадии, промпты, валюты, провайдеры AI.
Значения согласованы с фронтенд-прототипом (frontend/src/data.js),
чтобы интеграция была безболезненной.
"""
# Палитра колонок-досок (по умолчанию)
PALETTE = ["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"]
# Колонки не создаются по умолчанию: их создаёт пользователь или предлагает
# ИИ (suggested=TRUE — ждёт решения пользователя).
# Служебные колонки дашборда
SERVICE_COLS = {"inbox", "archive", "trash", "taken"}
# Стадии канбана «Выбранных»
PIPELINE_STAGES = [
{"id": "planned", "name": "Запланировано", "color": "#818cf8", "terminal": False},
{"id": "reply", "name": "Отклик", "color": "#38bdf8", "terminal": False},
{"id": "agree", "name": "Согласование", "color": "#a78bfa", "terminal": False},
{"id": "work", "name": "В работе", "color": "#fbbf24", "terminal": False},
{"id": "review", "name": "Проверка", "color": "#f97316", "terminal": False},
{"id": "ready", "name": "Готово", "color": "#4ade80", "terminal": False},
{"id": "hold", "name": "Отложено", "color": "#94a3b8", "terminal": False},
{"id": "finished", "name": "Выполнено", "color": "#2bd576", "terminal": True},
{"id": "rejected", "name": "Отклонено", "color": "#ff6b6b", "terminal": True},
]
# Валюты и мок-курсы (до первого успешного запроса к ЦБ)
CURRENCIES = [
{"code": "RUB", "name": "Российский рубль", "symbol": ""},
{"code": "USD", "name": "Доллар США", "symbol": "$"},
{"code": "EUR", "name": "Евро", "symbol": ""},
{"code": "CNY", "name": "Китайский юань", "symbol": "¥"},
{"code": "KZT", "name": "Казахстанский тенге", "symbol": ""},
{"code": "BYN", "name": "Белорусский рубль", "symbol": "Br"},
{"code": "USDT", "name": "Tether (USDT)", "symbol": ""},
{"code": "GBP", "name": "Британский фунт", "symbol": "£"},
]
MOCK_RATES = {
"RUB": 1.0,
"USD": 92.5,
"EUR": 99.9,
"CNY": 13.1,
"KZT": 0.19,
"BYN": 28.6,
"USDT": 92.5,
"GBP": 117.4,
}
CBR_URL = "https://www.cbr-xml-daily.ru/daily_json.js"
# Стоп-фразы по умолчанию (этап 1 фильтра — без ИИ)
DEFAULT_STOP_PHRASES = ["взаимный пиар", "резюме", "ищу работу", "набор в команду"]
DEFAULT_MIN_LEN = 24
# Промпты. В тексте можно использовать плейсхолдеры, которые подставляются из
# настроек «Сферы и ключей» при каждом вызове ИИ:
# {domain} — domainDescription (что для вас заявка/лид, ваша сфера)
# {keywords} — domainKeywords (общие слова-маркеры заявок)
DEFAULT_AI_PROMPT = (
"Ты — классификатор входящих сообщений. Сообщение — ЗАЯВКА (лид), только если в нём есть конкретный"
" запрос или предложение по делу: заказ услуги/товара/работы, поиск исполнителя или найм человека."
" Направление вашей сферы:\n{domain}\n\n"
"Частые слова-маркеры заявок в вашей сфере: {keywords}\n\n"
"Определи по тексту:\n"
"1. is_spam — true, если это НЕ заявка: служебные сообщения (коды входа/подтверждения, уведомления),"
" приветствия и поздравления, флуд и обсуждения без задачи, вопросы без конкретики, реклама, скам,"
" фин. пирамиды, резюме соискателей, взаимный пиар, приглашения в чаты, рассылки."
" Если сомневаешься — ставь true (лучше пропустить сообщение, чем засорить карточками)\n"
"2. board — id подходящей доски из списка. Выбирай по КРИТЕРИЯМ колонки"
" (в скобках указаны её направление, тематика, ключевые слова, уровень, бюджет, описание), а не только по названию;"
" если ни одна колонка не подходит под текст — верни null (карточка пойдёт в «Неразобранное»)\n"
"3. is_vacancy — true, если это НАЙМ/постоянная или проектная занятость: ищут человека в команду"
" (признаки: вакансия, грейд/уровень, «в компанию», hr, зарплата за месяц);"
" false — разовая сделка: заказ/услуга/товар (фриланс, подряд, «нужно сделать/купить», цена за работу)\n"
"4. title — короткий заголовок (4–9 слов, без эмодзи, хэштегов и знаков препинания в конце)."
" Запрещено: markdown, ссылки в любом виде ([текст](url), голые url) — только чистый текст\n"
"5. company — кто разместил заявку: компания/бренд/частное лицо/заказчик. Только факт из текста;"
" не указано — верни пустую строку \"\"\n"
"6. format — формат работы/выполнения: удалённо/офис/гибрид, город, график. Коротко; нет — \"\"\n"
"7. task — 1–2 предложения: что за задача/роль и в чём суть (для кого, что нужно сделать)."
" Ёмко, без пересказа всего объявления и без служебных строк\n"
"8. requirements — JSON-массив строк ключевых требований к кандидату/исполнителю"
" (что нужно уметь/иметь, пункты «Требования/Обязанности/Нужно»). Нет — []\n"
"9. plus — JSON-массив строк «будет плюсом»/«приветствуется»/«желательно». Нет — []\n"
"10. conditions — условия одной строкой: оплата/ЗП/вилка, сроки, объём, тип занятости. Нет — \"\"\n"
"11. stack — JSON-массив строк (не строка!): технологии/предметы/услуги/материалы из заявки."
" Названия пиши слитно как в оригинале: \".NET\", \"C#\", \"Node.js\", \"ASP.NET Core\" — не разбивай на отдельные буквы (2–8)\n"
"12. budget — объект { from, to, currency }: одна сумма → from=to=X; «до X» → from=null, to=X;"
" диапазон «от X до Y» → from=X, to=Y. Если суммы нет — null\n"
"13. contacts — JSON-массив строк контактов ДЛЯ СВЯЗИ: телефон, email, @username (не бот),"
" ссылка на профиль человека (LinkedIn, t.me/…). НЕ включай ботов, каналы/группы и ссылки на"
" вакансию/пост/форму. Если контакта в тексте нет — пустой массив []\n"
"Поля 5–10 — это блок «О заявке» на карточке: заполняй их только фактами из текста, ничего не выдумывай."
" Запрещено: markdown-разметка, ссылки в любом виде, эмодзи, хэштеги, «от»/«привет», дословное копирование исходника.\n"
"Отвечай строго в формате JSON."
)
DEFAULT_AI_FILTER_PROMPT = (
"Ты — страж входящих сообщений каналов. Пропускай только реальные заявки/лиды по вашей сфере"
" (заказ, услуга, товар, найм — с конкретикой).\n"
"Ваша сфера и что считать заявкой:\n{domain}\n\n"
"Слова-маркеры заявок: {keywords}\n\n"
"НЕ пропускай:\n"
"- служебные сообщения: коды входа/подтверждения, уведомления, приветствия, поздравления\n"
"- просто сообщения без задачи и конкретики: флуд, обсуждения, вопросы «кто работал с …»\n"
"- рекламу и саморекламу\n"
"- скам, фин. пирамиды, «заработок»\n"
"- резюме, поиск работы соискателями\n"
"- взаимный пиар, приглашения в чаты\n"
"- рассылки и дайджесты без прямых заявок\n"
"При сомнении — не пропускай.\n"
'Верни строго JSON: { "pass": true|false, "reason": "причина отказа или null" }'
)
# Промпт структуры карточки (блок «О заявке»). Отдельный от классификатора:
# задаёт, какие поля и как заполнять, чтобы все карточки имели одинаковую
# структуру текста (разной длины). Добавляется к промпту классификатора
# отдельным блоком (см. services/ai.classify). Редактируется в UI.
DEFAULT_AI_CARD_PROMPT = (
"Ты также возвращаешь содержимое блока «О заявке» карточки — поля company, format,"
" task, requirements, plus, conditions. Карточка всегда собирается из одних и тех же блоков:"
" одинаковая структура у всех карточек, различается только длина.\n"
"1. company — кто ищет/разместил: компания, бренд, агентство, частное лицо, заказчик. Одно предложение, факт из текста.\n"
"2. format — формат работы: удалённо/офис/гибрид/разъездной, город/страна, график (5/2, full-time, part-time). Коротко.\n"
"3. task — 1–2 предложения по шаблону: что за задача/роль → для кого → что нужно сделать/какой результат."
" Пиши ёмко, не пересказывай объявление дословно.\n"
"4. requirements — вынеси сюда только реальные требования/обязанности из текста"
" (пункты списков «Требования», «Обязанности», «Что предстоит делать», «Нужно»):"
" каждый пункт короткой строкой в JSON-массиве. Не придумывай сверх текста.\n"
"5. plus — только то, что отмечено как «будет плюсом»/«приветствуется»/«желательно»."
" Нет таких пунктов — пустой массив [].\n"
"6. conditions — условия одной строкой: оплата/ЗП/вилка/гонорар, сроки, объём, тип занятости."
" Не дублируй budget-числа в других полях.\n"
"Для не-IT сфер «стек/требования» означают материалы, услуги, навыки, инструменты — по смыслу заявки."
" Запрещено во всех полях: markdown-разметка, ссылки в любом виде ([текст](url), голые url),"
" эмодзи, хэштеги, «от»/«привет»/служебные строки канала."
)
# Дефолтные маркеры локального разбора (настраиваются в UI «Сфера и ключи»).
DEFAULT_HIRE_MARKERS = [
"вакансия", "вакансию", "вакансии", "вакантна", "вакант", "нанимаем", "найм", "full-time",
"на постоянную", "занятость", "в офис", "официальное оформление", "пятидневка",
"грейд", "в компанию", "полная занятость", "на постоянную работу", "в команду", "нанимает",
"на постоянную основу", "в штат",
]
DEFAULT_LEVEL_TERMS = [
"junior", "джун", "джуниор", "middle", "мидл", "mid", "senior", "сеньор", "сеньйор",
"lead", "тимлид", "тиэмлид", "architect", "архитектор", "стажёр", "стажер", "intern", "trainee",
]
# Маркеры, по которым сообщение опознаётся как резюме/самопрезентация
# соискателя («ищу работу», «моё резюме»). По умолчанию такие сообщения
# отсекаются на этапе 1 (до ИИ и правил колонок), если пользователь ищет
# вакансии и заказы, а не кандидатов. Редактируется в «Сфере и ключах»;
# выключите отсев — резюме начнут собираться как обычные лиды.
# Слово «резюме» имеет контекстный guard (см. pipeline._resume_reason):
# «…вакансия…, присылайте резюме» — это объявление работодателя, его не режем.
DEFAULT_RESUME_MARKERS = [
"резюме", "#резюме", "моё резюме", "мое резюме", "ищу работу", "ищу вакансию",
"рассмотрю предложения", "готов к собеседованию", "в поиске работы",
"ищу проект", "ищу подработку",
]
# AI-провайдеры (активен один)
AI_PROVIDERS = [
{"id": "deepseek", "name": "DeepSeek", "base": "https://api.deepseek.com", "local": False,
"models": ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp"]},
{"id": "openai", "name": "OpenAI", "base": "https://api.openai.com/v1", "local": False,
"models": ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]},
{"id": "openrouter", "name": "OpenRouter", "base": "https://openrouter.ai/api/v1", "local": False,
"models": ["deepseek/deepseek-v4-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.6-luna",
"google/gemini-3.8-flash", "qwen/qwen3.8-flash"]},
{"id": "anthropic", "name": "Anthropic Claude", "base": "https://api.anthropic.com", "local": False,
"api_style": "anthropic",
"models": ["claude-sonnet-5", "claude-opus-5", "claude-fable-5-1", "claude-haiku-4-5-20251001"]},
{"id": "ollama", "name": "Ollama (локально)", "base": "http://localhost:11434/v1", "local": True,
"models": ["qwen3-coder:30b", "qwen3-coder:480b", "qwen3:32b"]},
{"id": "lmstudio", "name": "LM Studio (локально)", "base": "http://localhost:1234/v1", "local": True,
"models": ["qwen3-coder-30b", "qwen3-coder-480b", "llama-3.3-70b"]},
{"id": "custom", "name": "Другой (OpenAI-совместимый)", "base": "https://", "local": False, "models": []},
]
# Настройки по умолчанию (ключи -> значение; не секреты)
DEFAULT_SETTINGS = {
# хранилище
"autoArchive": True,
"archiveAfterDays": 14, # 1..30
"archiveClearDays": 90,
"trashClearDays": 7,
# фильтры входящих
"minLen": DEFAULT_MIN_LEN,
"stopPhrases": DEFAULT_STOP_PHRASES,
"mlEnabled": True, # локальный ML-слой (обучается на действиях пользователя)
"aiEnabled": True, # полный выключатель ИИ: false = только фильтр + ML
"aiFilterEnabled": True,
"aiFilterPrompt": DEFAULT_AI_FILTER_PROMPT,
"aiPrompt": DEFAULT_AI_PROMPT,
"cardPrompt": DEFAULT_AI_CARD_PROMPT, # структура карточки/блока «О заявке» (отдельный промпт)
# сфера и ключи (универсальный классификатор: редактируется в UI)
"domainDescription": "",
"domainKeywords": [],
"hireMarkers": DEFAULT_HIRE_MARKERS,
"levelTerms": DEFAULT_LEVEL_TERMS,
"resumeMarkers": DEFAULT_RESUME_MARKERS,
"blockResumes": True, # True = ищем вакансии/заказы, резюме соискателей отсекаем
# тип заявок, которые собираем: both | vacancy | freelance (этап 1, до ИИ)
"wantedType": "both",
# «без указания суммы карточку не создаём»: отдельно для найма и заказов
"budgetRequiredHire": False,
"budgetRequiredOrder": False,
# подписи типов на карточках (по ситуации/сфере пользователя)
"hireLabel": "вакансия",
"orderLabel": "фриланс",
# личная библиотека промптов пользователя: [{id, name, description, prompt}]
"myPrompts": [],
# напоминания
"remindersEnabled": True,
# валюта и курсы
"conversionOn": True,
"targetCurrency": "RUB",
"rateSource": "cbr", # cbr | mock
# UI-состояния колонок
"colState": {},
# AI: активный провайдер и конфиги (ключи хранятся здесь же, наружу маскируются)
"aiProvider": "deepseek",
"aiConfigs": {
p["id"]: {"apiKey": "", "baseUrl": p["base"], "model": (p["models"] or [""])[0]}
for p in AI_PROVIDERS
},
# telegram-ключи: {api_id, api_hash, account}
"tgKeys": {"apiId": "", "apiHash": ""},
# авто-мониторинг новых чатов/каналов (добавлены с другого клиента)
"autoMonitorNew": True,
# поиск каналов (Discovery)
"discJoinLimit": 50, # суточный лимит авто-вступлений (общий)
"discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями
"discJoinDelayMax": 70, # сек, верхняя граница
"discEvalSample": 10, # размер выборки сообщений при оценке
"discEvalThreshold": 40, # % подходящих сообщений
}
# Диалоговые цвета (детерминированно по имени)
DIALOG_HUES = ["#3b82f6", "#38bdf8", "#f472b6", "#f59e0b", "#a78bfa", "#34d399", "#fb7185", "#22c55e"]
# Множители единиц времени
DAY_MS = 86_400_000
HOUR_MS = 3_600_000
MIN_MS = 60_000
@@ -0,0 +1,70 @@
"""Шифрование секретов, хранимых в БД (Telegram api_hash, ключи AI).
Решение по итогам ревью: ключи шифруются симметричным ключом; ключ шифрования
пока живёт в env (LEADRADAR_ENCRYPTION_KEY). Для локальной разработки без env
ключ генерируется и кладётся в data/encryption.key (с предупреждением).
"""
from __future__ import annotations
import base64
import logging
import os
from cryptography.fernet import Fernet, InvalidToken
from . import config
log = logging.getLogger("leadradar.crypto")
_fernet: Fernet | None = None
def _get_fernet() -> Fernet:
global _fernet
if _fernet is not None:
return _fernet
key = config.ENCRYPTION_KEY
if key:
try:
_fernet = Fernet(key.encode() if not key.endswith("=") else key.encode())
return _fernet
except Exception: # noqa: BLE001
log.error("LEADRADAR_ENCRYPTION_KEY не похож на Fernet-ключ (32 байта urlsafe b64)")
raise
if config.ENCRYPTION_KEY_FILE.exists():
key = config.ENCRYPTION_KEY_FILE.read_text(encoding="utf-8").strip()
else:
config.ensure_dirs()
key = Fernet.generate_key().decode()
config.ENCRYPTION_KEY_FILE.write_text(key, encoding="utf-8")
log.warning("Ключ шифрования создан в %s (для продакшена задайте LEADRADAR_ENCRYPTION_KEY)", config.ENCRYPTION_KEY_FILE)
_fernet = Fernet(key.encode())
return _fernet
def encrypt_text(value: str) -> str:
if not value:
return ""
token = _get_fernet().encrypt(value.encode())
return "enc:" + token.decode()
def decrypt_text(value: str) -> str:
if not value:
return ""
if not value.startswith("enc:"):
return value # совместимость с незашифрованными значениями ранних версий
try:
return _get_fernet().decrypt(value[4:].encode()).decode()
except InvalidToken:
log.error("Не удалось расшифровать секрет (неверный ключ шифрования)")
return ""
def maybe_encrypt(value: str) -> str:
"""Шифруем только непустые значения."""
return encrypt_text(value) if value else ""
def random_key() -> str:
return Fernet.generate_key().decode()
+407
View File
@@ -0,0 +1,407 @@
"""DuckDB: единый доступ, схема, стартовые данные.
DuckDB — одна запись в момент времени. Внутри одного процесса мы
сериализуем все операции общим локом (чтение дёшево, объёмы личные),
что полностью соответствует ТЗ (self-hosted, один файл).
"""
from __future__ import annotations
import json
import threading
import time
import uuid
import duckdb
from . import constants as C
from . import config
_LOCK = threading.RLock()
_SCHEMA = """
CREATE TABLE IF NOT EXISTS boards (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
description VARCHAR NOT NULL DEFAULT '',
color VARCHAR NOT NULL DEFAULT '#818cf8',
width VARCHAR NOT NULL DEFAULT 'md',
pos INTEGER NOT NULL DEFAULT 0,
keywords VARCHAR NOT NULL DEFAULT '[]',
prompt VARCHAR NOT NULL DEFAULT '',
visible_fields VARCHAR NOT NULL DEFAULT '["budget","stack","contacts"]',
collapsed BOOLEAN NOT NULL DEFAULT FALSE,
suggested BOOLEAN NOT NULL DEFAULT FALSE,
rules VARCHAR NOT NULL DEFAULT '{}',
note VARCHAR NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS leads (
id VARCHAR PRIMARY KEY,
col VARCHAR NOT NULL,
is_new BOOLEAN NOT NULL DEFAULT TRUE,
is_vacancy BOOLEAN NOT NULL DEFAULT FALSE,
title VARCHAR NOT NULL,
summary VARCHAR NOT NULL DEFAULT '',
stack VARCHAR NOT NULL DEFAULT '[]',
budget_from DOUBLE,
budget_to DOUBLE,
budget_cur VARCHAR NOT NULL DEFAULT '',
conv_from DOUBLE,
conv_to DOUBLE,
conv_cur VARCHAR NOT NULL DEFAULT '',
contact VARCHAR NOT NULL DEFAULT '',
contacts VARCHAR NOT NULL DEFAULT '[]',
ch_name VARCHAR NOT NULL DEFAULT '',
ch_handle VARCHAR NOT NULL DEFAULT '',
ch_hue VARCHAR NOT NULL DEFAULT '#666',
time_label VARCHAR NOT NULL DEFAULT '',
received_at BIGINT NOT NULL,
source_msg VARCHAR NOT NULL DEFAULT '',
prev_col VARCHAR NOT NULL DEFAULT 'inbox',
comments VARCHAR NOT NULL DEFAULT '[]',
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_leads_col ON leads(col);
CREATE TABLE IF NOT EXISTS messages (
id VARCHAR PRIMARY KEY,
dialog_id VARCHAR NOT NULL,
text VARCHAR NOT NULL DEFAULT '',
msg_at BIGINT NOT NULL,
lead_id VARCHAR
);
CREATE INDEX IF NOT EXISTS idx_messages_dialog ON messages(dialog_id, msg_at);
CREATE TABLE IF NOT EXISTS dialogs (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
handle VARCHAR NOT NULL DEFAULT '',
kind VARCHAR NOT NULL DEFAULT 'чат',
hue VARCHAR NOT NULL DEFAULT '#666',
monitor BOOLEAN NOT NULL DEFAULT FALSE,
last_text VARCHAR NOT NULL DEFAULT '',
last_at BIGINT,
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS dedup (
hash VARCHAR PRIMARY KEY,
lead_id VARCHAR,
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS learning_log (
id VARCHAR PRIMARY KEY,
lead_id VARCHAR NOT NULL,
action VARCHAR NOT NULL,
from_col VARCHAR,
to_col VARCHAR,
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS projects (
id VARCHAR PRIMARY KEY,
stage VARCHAR NOT NULL DEFAULT 'planned',
local BOOLEAN NOT NULL DEFAULT FALSE,
lead_id VARCHAR,
title VARCHAR NOT NULL DEFAULT '',
summary VARCHAR NOT NULL DEFAULT '',
stack VARCHAR NOT NULL DEFAULT '[]',
budget_from DOUBLE,
budget_to DOUBLE,
budget_cur VARCHAR NOT NULL DEFAULT '',
contact VARCHAR NOT NULL DEFAULT '',
comments VARCHAR NOT NULL DEFAULT '[]',
links VARCHAR NOT NULL DEFAULT '[]',
files VARCHAR NOT NULL DEFAULT '[]',
tz_text VARCHAR NOT NULL DEFAULT '',
history VARCHAR NOT NULL DEFAULT '[]',
reminder_at BIGINT,
reminder_fired BOOLEAN NOT NULL DEFAULT FALSE,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_projects_stage ON projects(stage);
CREATE TABLE IF NOT EXISTS rates (
id INTEGER PRIMARY KEY,
rates VARCHAR NOT NULL DEFAULT '{}',
source VARCHAR NOT NULL DEFAULT 'cbr',
updated_at BIGINT NOT NULL
);
-- Discovery: задачи поиска Telegram-каналов. keywords — JSON-список, столбцы
-- search_* / found / evaluated / joined / rejected — живой прогресс по задаче.
CREATE TABLE IF NOT EXISTS disc_tasks (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
description VARCHAR NOT NULL DEFAULT '',
keywords VARCHAR NOT NULL DEFAULT '[]',
min_subscribers INTEGER NOT NULL DEFAULT 0,
lang VARCHAR NOT NULL DEFAULT 'ru',
threshold INTEGER NOT NULL DEFAULT 40,
sample_size INTEGER NOT NULL DEFAULT 10,
plan_joins INTEGER NOT NULL DEFAULT 1,
auto_join BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR NOT NULL DEFAULT 'draft', -- draft|running|paused|done|failed
search_idx INTEGER NOT NULL DEFAULT 0,
search_done BOOLEAN NOT NULL DEFAULT FALSE,
found INTEGER NOT NULL DEFAULT 0,
evaluated INTEGER NOT NULL DEFAULT 0,
joined INTEGER NOT NULL DEFAULT 0,
rejected INTEGER NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
-- Discovery: найденные кандидаты. marks/topics — JSON-списки (оценки ИИ).
CREATE TABLE IF NOT EXISTS disc_candidates (
dialog_id VARCHAR PRIMARY KEY,
task_id VARCHAR NOT NULL,
name VARCHAR NOT NULL DEFAULT '',
username VARCHAR NOT NULL DEFAULT '',
kind VARCHAR NOT NULL DEFAULT 'channel', -- channel|group|forum
hue VARCHAR NOT NULL DEFAULT '#666',
participants INTEGER,
lang_ru BOOLEAN,
marks VARCHAR NOT NULL DEFAULT '[]',
topics VARCHAR NOT NULL DEFAULT '[]',
fit_ratio DOUBLE,
status VARCHAR NOT NULL DEFAULT 'new', -- new|review|joined|rejected
auto_joined BOOLEAN NOT NULL DEFAULT FALSE,
join_failures INTEGER NOT NULL DEFAULT 0, -- неудачные авто-вступления подряд (3 → кандидат удаляется)
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_disc_cand_task ON disc_candidates(task_id, status);
-- Discovery: чёрный список (пропускать при поиске).
CREATE TABLE IF NOT EXISTS disc_blacklist (
dialog_id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL DEFAULT '',
reason VARCHAR NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
);
-- Discovery: лог событий по задаче (search|found|skip|eval|review|join_auto|
-- join_manual|leave|reject|flood|error|done).
CREATE TABLE IF NOT EXISTS disc_log (
id VARCHAR PRIMARY KEY,
task_id VARCHAR NOT NULL,
event VARCHAR NOT NULL,
text VARCHAR NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_disc_log_task ON disc_log(task_id, created_at);
CREATE TABLE IF NOT EXISTS settings (
key VARCHAR PRIMARY KEY,
value VARCHAR NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token VARCHAR PRIMARY KEY,
login VARCHAR NOT NULL,
expires BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS creds (
login VARCHAR PRIMARY KEY,
password_hash VARCHAR NOT NULL,
salt VARCHAR NOT NULL
);
-- Outbox обучения ML: действия пользователя всегда пишутся сюда (синхронно,
-- локально), а фоновый воркер отправляет их в автономный ML-сервис.
-- Так ML «обучается всегда» даже если сервис временно недоступен.
CREATE TABLE IF NOT EXISTS ml_outbox (
id VARCHAR PRIMARY KEY,
text VARCHAR NOT NULL,
label VARCHAR NOT NULL,
delta DOUBLE NOT NULL DEFAULT 1.0,
created_at BIGINT NOT NULL
);
-- Очередь входящих: все сообщения из групп попадают сюда и разбираются
-- фоновым воркером. Не прошедшие фильтры удаляются сразу (не копятся).
-- force=TRUE — сообщение возвращено из отсева пользователем: фильтры-отсев
-- для него игнорируются (сообщение уходит на ML/ИИ и создаёт карточку).
CREATE TABLE IF NOT EXISTS pipeline_msg (
id VARCHAR PRIMARY KEY,
dialog_id VARCHAR NOT NULL,
ch_name VARCHAR NOT NULL DEFAULT '',
ch_handle VARCHAR NOT NULL DEFAULT '',
ch_hue VARCHAR NOT NULL DEFAULT '#666',
text VARCHAR NOT NULL,
msg_id BIGINT,
msg_at BIGINT NOT NULL,
status VARCHAR NOT NULL DEFAULT 'new',
force BOOLEAN DEFAULT FALSE,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pipeline_status ON pipeline_msg(status, created_at);
-- Отсев пайплайна (мониторинг «Обработка»): сообщения, не прошедшие этапы
-- (стоп-лист/резюме/тип/без суммы/устарело/ML/ИИ). Хранится причина и «чьё»
-- решение. Автоочистка раз в 3 суток + ручная очистка из UI.
-- При возврате в обработку запись не удаляется: помечается returned с причиной.
CREATE TABLE IF NOT EXISTS rejected_msgs (
id VARCHAR PRIMARY KEY,
dialog_id VARCHAR NOT NULL DEFAULT '',
msg_id BIGINT,
ch_name VARCHAR NOT NULL DEFAULT '',
ch_handle VARCHAR NOT NULL DEFAULT '',
ch_hue VARCHAR NOT NULL DEFAULT '#666',
text VARCHAR NOT NULL,
stage VARCHAR NOT NULL DEFAULT '',
reason VARCHAR NOT NULL DEFAULT '',
kw VARCHAR NOT NULL DEFAULT '',
source VARCHAR NOT NULL DEFAULT 'stop',
msg_at BIGINT,
rejected_at BIGINT NOT NULL,
returned BOOLEAN DEFAULT FALSE,
returned_at BIGINT,
return_reason VARCHAR NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_rejected_rejected_at ON rejected_msgs(rejected_at);
"""
# DuckDB не поддерживает IF NOT EXISTS в ADD COLUMN на старых версиях;
# выполняем в try для совместимости (поле нужно для правила «архив очищается
# через 90 дней после помещения в архив»).
_MIGRATIONS = [
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS archived_at BIGINT",
"ALTER TABLE dialogs ADD COLUMN IF NOT EXISTS backfilled BOOLEAN",
# исходное сообщение: id в Telegram + id диалога (для «открыть исходник»)
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS source_dialog_id VARCHAR DEFAULT ''",
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS source_msg_id BIGINT",
# квалифицированные контакты: JSON-список [{type, value}] (tg/phone/email/linkedin/site)
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS contacts VARCHAR DEFAULT '[]'",
# модель ML переезжает в отдельный контейнер — старые встроенные таблицы не нужны
"DROP TABLE IF EXISTS ml_classes",
"DROP TABLE IF EXISTS ml_terms",
# колонки: ИИ-предложения и правила маршрутизации (DuckDB не умеет
# ADD COLUMN с NOT NULL — значения трактуются как FALSE/{}/'')
"ALTER TABLE boards ADD COLUMN IF NOT EXISTS suggested BOOLEAN",
"ALTER TABLE boards ADD COLUMN IF NOT EXISTS rules VARCHAR",
"ALTER TABLE boards ADD COLUMN IF NOT EXISTS note VARCHAR",
# описание колонки (для пользователя и подсказки ИИ/ML)
"ALTER TABLE boards ADD COLUMN IF NOT EXISTS description VARCHAR DEFAULT ''",
# какие критерии фильтра совпали при попадании карточки в колонку (JSON)
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS match_hits VARCHAR DEFAULT '[]'",
# тип «найм/разовое» подтверждён ИИ по контексту (маркерная эвристика — нет)
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS is_vacancy_known BOOLEAN DEFAULT FALSE",
# возврат из отсева в обработку: force на строке очереди (фильтры игнорируются)
"ALTER TABLE pipeline_msg ADD COLUMN IF NOT EXISTS force BOOLEAN DEFAULT FALSE",
# отсев: исходный диалог/сообщение (для повторного возврата в очередь) и метки возврата
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS dialog_id VARCHAR DEFAULT ''",
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS msg_id BIGINT",
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS ch_hue VARCHAR DEFAULT '#666'",
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS returned BOOLEAN DEFAULT FALSE",
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS returned_at BIGINT",
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS return_reason VARCHAR DEFAULT ''",
# discovery: счётчик неудачных авто-вступлений кандидата (лимит ретраев join)
"ALTER TABLE disc_candidates ADD COLUMN IF NOT EXISTS join_failures INTEGER DEFAULT 0",
]
class Store:
"""Синглтон-доступ к DuckDB."""
def __init__(self) -> None:
self._con: duckdb.DuckDBPyConnection | None = None
def init(self) -> None:
config.ensure_dirs()
with _LOCK:
self._con = duckdb.connect(str(config.DB_PATH))
for stmt in _SCHEMA.split(";"):
if stmt.strip():
self._con.execute(stmt)
for stmt in _MIGRATIONS:
try:
self._con.execute(stmt)
except Exception:
pass
self._seed_defaults()
def close(self) -> None:
with _LOCK:
if self._con is not None:
try:
self._con.close()
except Exception:
pass
self._con = None
# ── низкоуровневые примитивы ──────────────────────────────────────────
def execute(self, sql: str, params: dict | list | tuple | None = None) -> None:
with _LOCK:
self._con.execute(sql, params or [])
def query(self, sql: str, params: dict | list | tuple | None = None) -> list[dict]:
with _LOCK:
cur = self._con.execute(sql, params or [])
cols = [d[0] for d in cur.description]
rows = cur.fetchall()
return [dict(zip(cols, row)) for row in rows]
def query_one(self, sql: str, params: dict | list | tuple | None = None) -> dict | None:
rows = self.query(sql, params)
return rows[0] if rows else None
def scalar(self, sql: str, params: dict | list | tuple | None = None):
with _LOCK:
cur = self._con.execute(sql, params or [])
row = cur.fetchone()
return row[0] if row else None
# ── настройки ─────────────────────────────────────────────────────────
def get_setting(self, key: str):
row = self.query_one("SELECT value FROM settings WHERE key = ?", [key])
if row is None:
default = C.DEFAULT_SETTINGS.get(key, None)
return default
return json.loads(row["value"])
def all_settings(self) -> dict:
out = dict(C.DEFAULT_SETTINGS)
for row in self.query("SELECT key, value FROM settings"):
try:
out[row["key"]] = json.loads(row["value"])
except Exception:
pass
return out
def set_setting(self, key: str, value) -> None:
self.execute(
"INSERT INTO settings(key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[key, json.dumps(value, ensure_ascii=False)],
)
# ── стартовые данные ──────────────────────────────────────────────────
def _seed_defaults(self) -> None:
now = time.time_ns() // 1_000_000
# Колонки не создаются по умолчанию: их делает пользователь или
# предлагает ИИ (см. services/suggest.py).
if self.scalar("SELECT count(*) FROM rates") == 0:
self.execute(
"INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, 'mock', ?)",
[json.dumps(C.MOCK_RATES), now],
)
# начальные настройки сохраняем только при отсутствии (значения берутся из DEFAULT_SETTINGS сами)
# ── генераторы ────────────────────────────────────────────────────────
@staticmethod
def uid(prefix: str = "") -> str:
return f"{prefix}{uuid.uuid4().hex[:12]}"
store = Store()
@@ -0,0 +1,216 @@
"""Точка входа FastAPI-приложения LeadRadar.
Запуск: uvicorn app.main:app --host 0.0.0.0 --port 8000
или: python -m app.main
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from . import config
from .auth import ensure_creds
from .db import store
from .routers import (
auth_routes,
dashboard_routes,
discovery_routes,
events_routes,
ml_routes,
processing_routes,
projects_routes,
settings_routes,
tg_routes,
)
from .services import fts as fts_svc
from .services import ml_client
from .services import projects as proj_svc
from .services import rates as rates_svc
from .services.leads import notify_tick_stats, tick_storage
from .services.telegram import register_main_loop, tg
logging.basicConfig(level=config.LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("leadradar")
async def _storage_loop() -> None:
"""Каждые 30 секунд: правила хранения (архив/очистка), напоминания, сердцебиение Telegram."""
while True:
try:
stats = tick_storage()
await notify_tick_stats(stats)
await proj_svc.check_reminders()
await tg.heartbeat()
except Exception: # noqa: BLE001
log.exception("storage loop error")
await asyncio.sleep(30)
async def _fts_loop() -> None:
"""Полнотекстовые индексы пересобираем раз в сутки (FTS — снимок)."""
while True:
await asyncio.sleep(24 * 3600)
try:
fts_svc.rebuild()
except Exception: # noqa: BLE001
log.exception("fts rebuild error")
async def _rates_loop() -> None:
"""Курсы ЦБ РФ: проверяем раз в 30 минут, тянем не чаще 1 раза в 6 часов."""
while True:
try:
if rates_svc.should_fetch():
ok = await rates_svc.refresh_rates()
if not ok:
log.warning("rates fetch failed (повтор через 30 минут)")
except Exception: # noqa: BLE001
log.exception("rates loop error")
await asyncio.sleep(30 * 60)
async def _pipeline_loop() -> None:
"""Фоновый воркер очереди входящих: разбирает сообщения каждые 2 секунды."""
from .services.pipeline import pump_once
while True:
try:
await pump_once()
except Exception: # noqa: BLE001
log.exception("pipeline worker error")
await asyncio.sleep(2)
async def _discovery_loop() -> None:
"""Фоновый воркер Discovery: поиск → оценка → авто-вступление (раз в 5 c)."""
from .services.discovery_worker import tick
while True:
try:
await tick()
except Exception: # noqa: BLE001
log.exception("discovery worker error")
await asyncio.sleep(5)
async def _ml_sync_loop() -> None:
"""Отправка событий обучения в ML-сервис + кэш его статуса (каждые 10 c)."""
while True:
try:
await ml_client.flush_outbox()
await ml_client.refresh_status()
except Exception: # noqa: BLE001
log.exception("ml sync error")
await asyncio.sleep(10)
async def _suggest_loop() -> None:
"""Раз в 3 минуты пробуем предложить колонки по «Неразобранному» (кулдаун 20 мин)."""
from .services.suggest import suggest_from_inbox
while True:
try:
await suggest_from_inbox(force=False)
except Exception: # noqa: BLE001
log.exception("suggest loop error")
await asyncio.sleep(180)
async def _tg_sweep_loop() -> None:
"""Раз в 30 с: страховочная догонялка непрочитанного по включённым каналам
(событие могло потеряться при рестарте/разрыве соединения)."""
from .services.telegram import tg as tg_svc
while True:
await asyncio.sleep(30)
try:
await tg_svc.realtime_sweep()
except Exception: # noqa: BLE001
log.exception("tg realtime sweep error")
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
config.ensure_dirs()
store.init()
ensure_creds()
register_main_loop(asyncio.get_running_loop())
tasks = [
asyncio.create_task(_storage_loop()),
asyncio.create_task(_rates_loop()),
asyncio.create_task(_fts_loop()),
asyncio.create_task(_pipeline_loop()),
asyncio.create_task(_discovery_loop()),
asyncio.create_task(_ml_sync_loop()),
asyncio.create_task(_suggest_loop()),
asyncio.create_task(_tg_sweep_loop()),
asyncio.create_task(tg.auto_resume()),
]
# полнотекстовый поиск: пробуем установить расширение и собрать индекс
if fts_svc.install_extension():
fts_svc.rebuild()
# первичное обновление курсов из ЦБ (если источник cbr)
if store.get_setting("rateSource") == "cbr":
try:
await rates_svc.refresh_rates()
except Exception: # noqa: BLE001
log.warning("initial rates fetch failed; продолжаем с мок-курсами")
try:
yield
finally:
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
await tg.disconnect()
store.close()
app = FastAPI(title="LeadRadar", version="1.2.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # локальный dev-режим; в проде замените на свой origin
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API
for mod in (auth_routes, tg_routes, dashboard_routes, projects_routes, settings_routes, events_routes, discovery_routes, ml_routes, processing_routes):
app.include_router(mod.router)
@app.get("/api/health")
def health() -> dict:
return {"ok": True, "service": "leadradar"}
# Статика фронтенда (собранный dist). Если папки нет — API живёт отдельно.
_DIST = config.FRONTEND_DIST
if _DIST.is_dir():
assets = _DIST / "assets"
if assets.is_dir():
app.mount("/assets", StaticFiles(directory=str(assets)), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def spa(full_path: str):
candidate = (_DIST / full_path).resolve()
if full_path and candidate.is_file() and candidate.is_relative_to(_DIST):
return FileResponse(str(candidate))
index = _DIST / "index.html"
if index.exists():
return FileResponse(str(index))
return JSONResponse({"detail": "фронтенд не собран — используйте npm run dev"}, status_code=200)
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host=config.HOST, port=config.PORT, reload=False)
@@ -0,0 +1 @@
"""Пакет роутеров API."""
@@ -0,0 +1,65 @@
"""Вход в дашборд и сессии (п.4.1 ТЗ)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from ..auth import (
change_password,
create_session,
current_login,
destroy_session,
ensure_creds,
set_session_cookie,
verify_password,
)
from ..config import COOKIE_NAME
router = APIRouter(prefix="/api", tags=["auth"])
class LoginBody(BaseModel):
login: str
password: str
class PasswordBody(BaseModel):
oldPassword: str
newPassword: str
@router.post("/auth/login")
def login(body: LoginBody, response: Response) -> dict:
ensure_creds()
login_name = body.login.strip()
if not verify_password(login_name, body.password):
raise HTTPException(401, "Неверный логин или пароль")
token = create_session(login_name)
set_session_cookie(response, token)
return {"ok": True, "login": login_name}
@router.post("/auth/logout")
def logout(request: Request, response: Response) -> dict:
token = request.cookies.get(COOKIE_NAME)
destroy_session(token)
response.delete_cookie(COOKIE_NAME)
return {"ok": True}
@router.get("/auth/me")
def me(login: str = Depends(current_login)) -> dict:
return {"login": login, "ok": True}
@router.post("/auth/change-password")
def change(body: PasswordBody, request: Request, response: Response, login: str = Depends(current_login)) -> dict:
token = request.cookies.get(COOKIE_NAME)
ok = change_password(login, body.oldPassword, body.newPassword)
if not ok:
raise HTTPException(400, "Текущий пароль неверен")
# старые сессии удалены внутри change_password; выдаём свежую
fresh = create_session(login)
destroy_session(token)
set_session_cookie(response, fresh)
return {"ok": True}
@@ -0,0 +1,409 @@
"""Дашборд: доски, колонки, карточки, поиск, правила хранения."""
from __future__ import annotations
import secrets
import time
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from .. import config
from .. import constants as C
from ..auth import current_login
from ..db import store
from ..sse import broker
from ..services import fts as fts_svc
from ..services import leads as leads_svc
from ..services.ai import normalize_dedup
from ..services.pipeline import _store_lead, lead_to_dict, stage1_plain
from ..services.rates import refresh_rates # noqa: F401 (для админ-тика может пригодиться)
router = APIRouter(prefix="/api", tags=["dashboard"])
class BoardCreate(BaseModel):
name: str
description: str = ""
color: str | None = None
keywords: list[str] | None = None
prompt: str = ""
rules: dict | None = None
class BoardPatch(BaseModel):
name: str | None = None
description: str | None = None
color: str | None = None
width: str | None = None
collapsed: bool | None = None
prompt: str | None = None
keywords: list[str] | None = None
visibleFields: list[str] | None = None
suggested: bool | None = None
rules: dict | None = None
note: str | None = None
class OrderBody(BaseModel):
order: list[str]
class ColStateBody(BaseModel):
collapsed: bool | None = None
width: str | None = None
class MoveBody(BaseModel):
to: str
class CommentBody(BaseModel):
text: str
class ReclassifyBody(BaseModel):
ids: list[str] | None = None
class PumpGateBody(BaseModel):
limit: int | None = None # 0/None — выключить; >0 — остановить воркер после N созданных карточек
class CheckMessageBody(BaseModel):
text: str
# Демо-пул для кнопки «Симулировать лид» (без обращения к ИИ)
_DEMO_POOL = [
("Нужен Middle Python-разработчик на бота для CRM, удалённо, 1 6002 200$.",
{"title": "Middle Python-разработчик на бота для CRM", "summary": "Развитие CRM: бот для менеджеров, интеграция с amoCRM.",
"stack": ["Python", "aiogram", "amoCRM"], "budget": {"from": 1600, "to": 2200, "currency": "USD"},
"contacts": "@crm_head", "is_vacancy": True, "board": None, "is_spam": False}),
("Frontend-разработчик в продуктовую команду, Vue 3, 2 400$.",
{"title": "Frontend-разработчик в продуктовую команду", "summary": "Развитие SPA: компоненты, стейт, производительность.",
"stack": ["Vue 3", "Pinia", "Vitest"], "budget": {"from": 2400, "to": 2400, "currency": "USD"},
"contacts": "@front_hh", "is_vacancy": True, "board": None, "is_spam": False}),
("Ищу подрядчика на бота для такси: клиент заказывает, водитель принимает. Бюджет обсуждаем.",
{"title": "Бот для заказов такси", "summary": "Клиент заказывает, водитель принимает.",
"stack": [], "budget": None, "contacts": "@taxi_owner", "is_vacancy": False, "board": None, "is_spam": False}),
]
def _lead_or_404(lead_id: str) -> dict:
lead = leads_svc.get_lead(lead_id)
if not lead:
raise HTTPException(404, "Карточка не найдена")
return lead
# ─── Доски ────────────────────────────────────────────────────────────────
@router.get("/boards")
def boards(_: str = Depends(current_login)) -> list[dict]:
return leads_svc.list_boards()
@router.post("/boards")
def create_board(body: BoardCreate, _: str = Depends(current_login)) -> dict:
return leads_svc.create_board(
name=body.name,
color=body.color,
keywords=body.keywords,
prompt=body.prompt,
description=body.description,
rules=body.rules,
)
@router.patch("/boards/{board_id}")
def patch_board(board_id: str, body: BoardPatch, _: str = Depends(current_login)) -> dict:
try:
return leads_svc.patch_board(board_id, body.model_dump(exclude_none=True))
except KeyError:
raise HTTPException(404, "Доска не найдена")
@router.delete("/boards/{board_id}")
def delete_board(board_id: str, _: str = Depends(current_login)) -> dict:
moved = leads_svc.delete_board(board_id)
return {"ok": True, "movedToInbox": moved}
@router.post("/boards/reorder")
def reorder(body: OrderBody, _: str = Depends(current_login)) -> dict:
leads_svc.reorder_boards(body.order)
return {"ok": True}
# ─── Состояние колонок ────────────────────────────────────────────────────
@router.get("/columns/state")
def columns_state(_: str = Depends(current_login)) -> dict:
return leads_svc.get_col_state()
@router.patch("/columns/{col_id}/state")
def set_column_state(col_id: str, body: ColStateBody, _: str = Depends(current_login)) -> dict:
state = leads_svc.get_col_state().get(col_id, {})
state.update(body.model_dump(exclude_none=True))
return leads_svc.set_col_state(col_id, state)
# ─── Лиды ─────────────────────────────────────────────────────────────────
@router.get("/leads")
def list_leads(col: str | None = None, _: str = Depends(current_login)) -> dict:
if col and col not in ("inbox", "archive", "trash") and not any(b["id"] == col for b in leads_svc.list_boards()):
raise HTTPException(400, "Неизвестная колонка")
return {"items": leads_svc.list_leads(col)}
@router.get("/leads/counts")
def leads_counts(_: str = Depends(current_login)) -> dict:
return leads_svc.counts()
@router.get("/leads/{lead_id}")
def get_lead(lead_id: str, _: str = Depends(current_login)) -> dict:
return _lead_or_404(lead_id)
@router.post("/leads/{lead_id}/seen")
def seen(lead_id: str, _: str = Depends(current_login)) -> dict:
leads_svc.mark_seen(lead_id=lead_id)
return {"ok": True}
@router.post("/leads/mark-all-seen")
def mark_all_seen(_: str = Depends(current_login)) -> dict:
leads_svc.mark_seen()
return {"ok": True}
class MarkColBody(BaseModel):
col: str
@router.post("/leads/mark-col-seen")
def mark_col_seen(body: MarkColBody, _: str = Depends(current_login)) -> dict:
"""Снять «новое» с колонки (используется при открытии колонки из сайдбара)."""
leads_svc.mark_seen(col=body.col)
return {"ok": True}
@router.post("/leads/{lead_id}/move")
def move(lead_id: str, body: MoveBody, _: str = Depends(current_login)) -> dict:
try:
leads_svc.move_lead(lead_id, body.to)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return _lead_or_404(lead_id)
@router.post("/leads/{lead_id}/trash")
def trash(lead_id: str, _: str = Depends(current_login)) -> dict:
_lead_or_404(lead_id)
leads_svc.trash_lead(lead_id)
return {"ok": True}
@router.post("/leads/{lead_id}/restore")
def restore(lead_id: str, _: str = Depends(current_login)) -> dict:
_lead_or_404(lead_id)
back = leads_svc.restore_lead(lead_id)
return {"ok": True, "col": back}
@router.delete("/leads/{lead_id}")
def delete(lead_id: str, _: str = Depends(current_login)) -> dict:
_lead_or_404(lead_id)
leads_svc.delete_forever(lead_id)
return {"ok": True}
class ClearColBody(BaseModel):
col: str
@router.post("/leads/clear-col")
def clear_col(body: ClearColBody, _: str = Depends(current_login)) -> dict:
"""Ручная полная очистка корзины/архива (безвозвратно)."""
try:
cleared = leads_svc.clear_col(body.col)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": True, "cleared": cleared}
@router.post("/leads/{lead_id}/comments")
def comment(lead_id: str, body: CommentBody, _: str = Depends(current_login)) -> dict:
if not body.text.strip():
raise HTTPException(400, "Пустой комментарий")
return {"comments": leads_svc.add_comment(lead_id, body.text)}
@router.post("/leads/reclassify")
async def reclassify(body: ReclassifyBody | None = None, _: str = Depends(current_login)) -> dict:
"""Переклассификация «Неразобранного»: фоновая задача (одна за раз)."""
ids = body.ids if body else None
return await leads_svc.start_reclassify(ids)
# ─── Поиск ────────────────────────────────────────────────────────────────
@router.get("/search")
def search(q: str = "", _: str = Depends(current_login)) -> dict:
return leads_svc.search(q)
# ─── Админ-тик (правила хранения) ─────────────────────────────────────────
@router.post("/admin/fts/rebuild")
def fts_rebuild(_: str = Depends(current_login)) -> dict:
ok = fts_svc.rebuild()
return {"ok": ok, "ready": fts_svc.is_ready()}
@router.post("/admin/check-message")
async def check_message(body: CheckMessageBody, _: str = Depends(current_login)) -> dict:
"""Проверка фильтра входящих (этап 1 + этап 2) для тестера в настройках."""
from ..services.ai import filter_incoming
r1 = stage1_plain(body.text)
result = {"stage1": {"pass": r1["pass"], "reason": r1["reason"]}}
if not r1["pass"]:
result["stage2"] = {"pass": False, "reason": None, "skipped": True}
result["passed"] = False
return result
try:
r2 = await filter_incoming(body.text)
except Exception as exc: # noqa: BLE001
r2 = {"pass": True, "reason": None, "skipped": True}
result["stage2"] = r2
result["passed"] = bool(r2["pass"])
return result
def _demo_off() -> None:
if not config.DEMO_ENABLED:
raise HTTPException(404, "Демо-режим отключён")
@router.post("/demo/simulate-lead")
async def simulate_lead(_: str = Depends(current_login)) -> dict:
"""Служебный демо-эндпоинт (включён только при LEADRADAR_DEMO=1)."""
_demo_off()
import random
text, raw = random.choice(_DEMO_POOL)
digest = "demo_" + secrets.token_hex(8)
lead = _store_lead(
digest, "demo_channel", "Демо-канал", "demo_channel", "#8b8ff8", text, raw, time.time_ns() // 1_000_000,
)
if lead:
await broker.publish("new_lead", lead)
await broker.publish_toast("Демо: новый лид", "sparkles")
return lead or {}
@router.post("/demo/age-lead")
async def demo_age_lead(_: str = Depends(current_login)) -> dict:
"""Служебный демо-эндпоинт (включён только при LEADRADAR_DEMO=1)."""
_demo_off()
days = int(store.get_setting("archiveAfterDays") or 14)
row = store.query_one(
"SELECT id FROM leads WHERE col IN (SELECT id FROM boards) ORDER BY received_at ASC LIMIT 1"
)
if not row:
raise HTTPException(400, "Нет карточек на досках для демо")
aged = time.time_ns() // 1_000_000 - (days + 1) * C.DAY_MS
store.execute("UPDATE leads SET received_at = ? WHERE id = ?", [aged, row["id"]])
stats = leads_svc.tick_storage()
if stats["archived"]:
await broker.publish_toast(f"Демо: карточка → Архив (старше {days + 1} дн.)", "clock")
return {"ok": True, "stats": stats}
@router.post("/admin/tick")
async def admin_tick(_: str = Depends(current_login)) -> dict:
from ..services import projects as projects_svc
from ..services.pipeline import pump_once, queue_len
stats = leads_svc.tick_storage()
await leads_svc.notify_tick_stats(stats)
due = await projects_svc.check_reminders()
# разгребаем очередь входящих (этап 1 -> правила -> ML/ИИ)
pump = await pump_once()
return {"storage": stats, "reminders": due, "pipeline": pump, "queue": queue_len()}
@router.post("/admin/wipe")
async def admin_wipe(_: str = Depends(current_login)) -> dict:
"""Полный сброс под новый прогон: все карточки, обучение ML, счётчики ИИ/ML.
Удаляет карточки со всех колонок (включая архив/корзину), исходные
сообщения, журнал обучения, очередь обучения и очередь входящих; сбрасывает
модель ML и счётчики реальных действий. Колонки/доски, каналы и настройки
не трогаются.
"""
from ..services import ml_client
from ..services.ml_client import DECISIONS_AI, DECISIONS_ML
n_leads = int(store.scalar("SELECT count(*) FROM leads") or 0)
for tbl in ("leads", "dedup", "messages", "learning_log", "ml_outbox", "pipeline_msg", "rejected_msgs"):
store.execute(f"DELETE FROM {tbl}")
store.set_setting(DECISIONS_ML, 0)
store.set_setting(DECISIONS_AI, 0)
fts_svc.rebuild()
ml = await ml_client.reset_model()
return {"ok": True, "cardsRemoved": n_leads, "ml": ml}
@router.post("/admin/clear-cards")
async def admin_clear_cards(_: str = Depends(current_login)) -> dict:
"""Очистить карточки и очереди для повторного прогона, НЕ трогая ML.
Удаляет карточки (все колонки/архив/корзина), исходники, дедуп, журнал
обучения и очередь входящих. Модель ML, очередь её обучения (ml_outbox)
и счётчики ИИ/ML остаются нетронутыми — нужно для тестов «обучить ML на
прогоне с ИИ, затем прогнать те же сообщения без ИИ».
"""
n_leads = int(store.scalar("SELECT count(*) FROM leads") or 0)
for tbl in ("leads", "dedup", "messages", "learning_log", "pipeline_msg", "rejected_msgs"):
store.execute(f"DELETE FROM {tbl}")
fts_svc.rebuild()
return {"ok": True, "cardsRemoved": n_leads}
@router.post("/admin/pump-gate")
def pump_gate(body: PumpGateBody, _: str = Depends(current_login)) -> dict:
"""Шлагбаум пайплайна: остановить воркер после N созданных карточек.
limit=0 или None — снять ограничение (воркер продолжит разбирать очередь);
limit>0 — обнулить счётчик и останавливаться после N карточек (проверка
результата: «прогон с остановкой после первых N сообщений»).
"""
if body.limit is not None:
v = max(0, int(body.limit))
store.set_setting("pumpGate", v)
store.set_setting("pumpGateDone", 0)
limit = int(store.get_setting("pumpGate") or 0)
done = int(store.get_setting("pumpGateDone") or 0)
return {"ok": True, "limit": limit, "done": done}
@router.post("/ai/suggest-columns")
async def ai_suggest_columns(_: str = Depends(current_login)) -> dict:
"""Ручной запуск анализа «Неразобранного»: ИИ предлагает колонки."""
from ..services.suggest import suggest_from_inbox
result = await suggest_from_inbox(force=True)
return result
@router.post("/ai/suggest-keywords")
async def ai_suggest_keywords(_: str = Depends(current_login)) -> dict:
"""ИИ предлагает общие ключевые слова-маркеры сферы по вашим карточкам."""
from ..services.suggest import suggest_domain_keywords
return await suggest_domain_keywords()
@@ -0,0 +1,285 @@
"""API Discovery (Task 7): задачи поиска каналов, кандидаты, чёрный список, лог.
Prefix /api/discovery, авторизация — current_login (как в соседних роутерах).
Сервис discovery отдаёт наружу camelCase-словари (см. его docstring), поэтому
Pydantic-модели повторяют имена полей API без алиасов (как PreviewBody в tg_routes).
Списки наружу — {"items": [...]}, единичные объекты — как есть (конвенция проекта).
Обработка ошибок контракта: ValueError -> HTTP 400, KeyError -> HTTP 404.
"""
from __future__ import annotations
import logging
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from ..auth import current_login
from ..db import store
from ..services import ai as ai_service
from ..services import discovery
from ..services.telegram import _spawn, tg
log = logging.getLogger("leadradar.discovery_api")
router = APIRouter(prefix="/api/discovery", tags=["discovery"])
# потолок текста описания, уходящего ИИ-генератору ключей
_AI_DESCRIPTION_LIMIT = 4000
# страховочный потолок числа сгенерированных ключей (промпт просит 10–16)
_KEYWORDS_LIMIT = 30
# потолок длины одного ключа (короткие фразы для поиска Telegram)
_KEYWORD_LENGTH_LIMIT = 60
# промпт генерации ключевых слов по описанию задачи (RU+EN, глобальный поиск)
_KEYWORDS_PROMPT = (
"Ты — эксперт по поиску Telegram-каналов и групп. По описанию ниши/задачи "
"составь поисковые ключевые слова, по которым в глобальном поиске Telegram "
"находят подходящие источники. Верни строго JSON вида "
'{"keywords": ["...", "..."]}. Требования к списку:\n'
"- 1016 ключей;\n"
"- примерно поровну русских и английских (английские — популярные в нише термины);\n"
"- короткие фразы 1–4 слова;\n"
"- без #, @, кавычек и лишней пунктуации;\n"
"- конкретные для ниши, включая сленг заказчиков и подрядчиков;\n"
"- без дублей и близких по смыслу повторов."
)
class TaskCreate(BaseModel):
name: str
description: str = ""
keywords: list[str] = []
minSubscribers: int = 0
lang: str = "ru"
threshold: int | None = None
sampleSize: int | None = None
planJoins: int = 1
autoJoin: bool = False
class TaskPatch(BaseModel):
name: str | None = None
description: str | None = None
keywords: list[str] | None = None
minSubscribers: int | None = None
lang: str | None = None
threshold: int | None = None
sampleSize: int | None = None
planJoins: int | None = None
autoJoin: bool | None = None
def _payload(body: BaseModel) -> dict:
"""Поля модели -> payload сервиса, без явных None.
discovery.create_task/patch_task сами подставляют значения по умолчанию
(в т.ч. threshold/sampleSize из настроек), поэтому None-поля пропускаем.
"""
return body.model_dump(exclude_none=True)
def _task_or_404(task_id: str) -> dict:
task = discovery.get_task(task_id)
if task is None:
raise HTTPException(404, "Задача не найдена")
return task
def _candidate_or_404(dialog_id: str) -> dict:
row = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
if row is None:
raise HTTPException(404, "Кандидат не найден")
return row
def _ai_unavailable_reason() -> str | None:
"""Причина недоступности ИИ (None — можно вызывать)."""
if not store.get_setting("aiEnabled"):
return "ИИ выключен в настройках (aiEnabled)"
try:
status = ai_service.provider_status()
except Exception as exc: # noqa: BLE001 — статус не читается = ИИ недоступен
log.debug("generate-keywords: статус ИИ недоступен (%s)", exc)
return "Не удалось прочитать статус ИИ-провайдера"
if not (status.get("local") or status.get("keySet")):
return "Не задан API-ключ ИИ-провайдера"
return None
def _clean_keywords(raw) -> list[str]:
"""Ключи из ответа ИИ: строки без пустых/длинных и повторов (casefold)."""
seen: set[str] = set()
out: list[str] = []
for item in raw or []:
if not isinstance(item, str):
continue
keyword = item.strip()
if not keyword or len(keyword) > _KEYWORD_LENGTH_LIMIT:
continue
key = keyword.casefold()
if key in seen:
continue
seen.add(key)
out.append(keyword)
if len(out) >= _KEYWORDS_LIMIT:
break
return out
async def _backfill_quiet(dialog_id: str) -> None:
"""Догон последних сообщений вступившего источника (фон, best-effort)."""
try:
await tg.backfill_dialog(dialog_id)
except Exception as exc: # noqa: BLE001 — вступление уже состоялось
log.warning("join %s: backfill не удался: %s", dialog_id, exc)
# ─── задачи ────────────────────────────────────────────────────────────────
@router.get("/tasks")
def list_tasks(_: str = Depends(current_login)) -> dict:
return {"items": discovery.list_tasks()}
@router.post("/tasks")
def create_task(body: TaskCreate, _: str = Depends(current_login)) -> dict:
try:
return discovery.create_task(_payload(body))
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.patch("/tasks/{task_id}")
def patch_task(task_id: str, body: TaskPatch, _: str = Depends(current_login)) -> dict:
try:
return discovery.patch_task(task_id, _payload(body))
except KeyError as exc:
raise HTTPException(404, "Задача не найдена") from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.delete("/tasks/{task_id}")
def delete_task(task_id: str, _: str = Depends(current_login)) -> dict:
_task_or_404(task_id)
discovery.delete_task(task_id)
return {"ok": True}
@router.post("/tasks/{task_id}/start")
def start_task(task_id: str, _: str = Depends(current_login)) -> dict:
try:
return discovery.start_task(task_id)
except KeyError as exc:
raise HTTPException(404, "Задача не найдена") from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.post("/tasks/{task_id}/pause")
def pause_task(task_id: str, _: str = Depends(current_login)) -> dict:
try:
return discovery.pause_task(task_id)
except KeyError as exc:
raise HTTPException(404, "Задача не найдена") from exc
@router.post("/tasks/{task_id}/generate-keywords")
async def generate_keywords(task_id: str, _: str = Depends(current_login)) -> dict:
"""ИИ-генерация ключей по описанию задачи: RU+EN, 10–16 строк.
ИИ выключен/не настроен/ответил ошибкой — {"keywords": [], "error": "..."}
с HTTP 200, чтобы UI показал причину, а не падал.
"""
task = _task_or_404(task_id)
reason = _ai_unavailable_reason()
if reason:
return {"keywords": [], "error": reason}
description = str(task.get("description") or "").strip()
if not description:
return {"keywords": [], "error": "У задачи нет описания — по нему генерируются ключи"}
try:
out = await ai_service.chat_json(
_KEYWORDS_PROMPT,
f"Описание ниши/задачи:\n{description[:_AI_DESCRIPTION_LIMIT]}",
)
except Exception as exc: # noqa: BLE001 — сбой провайдера не роняет API
log.warning("generate-keywords задача %s: ИИ не ответил: %s", task_id, exc)
return {"keywords": [], "error": str(exc)}
return {"keywords": _clean_keywords(out.get("keywords", []) if isinstance(out, dict) else [])}
# ─── кандидаты ─────────────────────────────────────────────────────────────
@router.get("/tasks/{task_id}/candidates")
def list_candidates(
task_id: str,
status: Literal["new", "review", "joined", "rejected"] | None = None,
_: str = Depends(current_login),
) -> dict:
_task_or_404(task_id)
return {"items": discovery.list_candidates(task_id, status)}
@router.post("/candidates/{dialog_id}/join")
async def join_candidate(dialog_id: str, _: str = Depends(current_login)) -> dict:
"""Ручное вступление (вне квот и пауз воркера).
tg.discovery_join -> add_dialog_monitored -> backfill_dialog (последние
сообщения, best-effort) -> mark_joined(auto=False); источник снимается с
чёрного списка. Ошибка Telegram -> 400 с текстом причины.
"""
row = _candidate_or_404(dialog_id)
if row["status"] == "joined":
raise HTTPException(400, "Уже вступили в этот источник")
username = str(row.get("username") or "")
try:
await tg.discovery_join(username)
except Exception as exc: # текст ошибки уходит наружу
raise HTTPException(400, f"Не удалось вступить в @{username}: {exc}") from exc
tg.add_dialog_monitored(dialog_id, row.get("name"), username, row.get("kind"), row.get("hue"))
# догон последних сообщений — в фоне: join из UI не должен висеть на
# паузах backfill (10 сообщений × 1.5–3 с); источник уже в мониторинге
_spawn(_backfill_quiet(dialog_id))
discovery.remove_blacklist(dialog_id)
try:
return discovery.mark_joined(dialog_id, auto=False)
except KeyError as exc:
raise HTTPException(404, "Кандидат не найден") from exc
@router.post("/candidates/{dialog_id}/reject")
def reject_candidate(dialog_id: str, _: str = Depends(current_login)) -> dict:
"""Отклонить кандидата (в чёрный список). Уже вступившего — нельзя."""
row = _candidate_or_404(dialog_id)
if row["status"] == "joined":
raise HTTPException(400, "Уже вступили — удалите источник из каналов")
try:
return discovery.mark_rejected(dialog_id, reason="отклонено вручную")
except KeyError as exc:
raise HTTPException(404, "Кандидат не найден") from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
# ─── чёрный список ─────────────────────────────────────────────────────────
@router.get("/blacklist")
def list_blacklist(_: str = Depends(current_login)) -> dict:
return {"items": discovery.list_blacklist()}
@router.delete("/blacklist/{dialog_id}")
def remove_blacklist(dialog_id: str, _: str = Depends(current_login)) -> dict:
discovery.remove_blacklist(dialog_id)
return {"ok": True}
# ─── лог задачи ────────────────────────────────────────────────────────────
@router.get("/tasks/{task_id}/log")
def task_log(task_id: str, _: str = Depends(current_login)) -> dict:
_task_or_404(task_id)
return {"items": discovery.task_log(task_id)}
@@ -0,0 +1,38 @@
"""SSE-события (realtime) для фронтенда."""
from __future__ import annotations
import asyncio
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from ..auth import current_login
from ..sse import broker
router = APIRouter(prefix="/api", tags=["events"])
@router.get("/events")
async def events(request: Request, _: str = Depends(current_login)):
async def stream():
q = await broker.subscribe()
try:
while True:
if await request.is_disconnected():
break
try:
yield await asyncio.wait_for(q.get(), timeout=15)
except asyncio.TimeoutError:
yield ": ping\n\n"
finally:
await broker.unsubscribe(q)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -0,0 +1,171 @@
"""ML-лаборатория: статус, проверка на сообщении, обучение на канале.
Всё обучение уходит в outbox (гарантированно), фоновый цикл отправляет его
в автономный ML-сервис. Использование ML в пайплайне — только по настройке
`mlEnabled`; здесь можно проверить модель и вручную разметить сообщения.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from ..auth import current_login
from ..db import store
from ..services import leads as leads_svc
from ..services import ml_client
from ..services.telegram import tg
router = APIRouter(prefix="/api/ml", tags=["ml"])
class PredictBody(BaseModel):
text: str
class LearnBody(BaseModel):
text: str
label: str
class CandidatesBody(BaseModel):
dialogId: str
limit: int = 10
class ApplyBody(BaseModel):
dialogId: str
msgId: int
action: str # 'spam' | 'board:<id>' | 'skip'
def _msg_text(dialog_id: str, msg_id: int) -> str | None:
"""Текст исходного сообщения (из карточки или из messages)."""
row = store.query_one(
"SELECT source_msg AS text FROM leads WHERE source_dialog_id = ? AND source_msg_id = ? LIMIT 1",
[dialog_id, msg_id],
)
if row and row["text"]:
return row["text"]
m = store.query_one("SELECT text FROM messages WHERE id = ?", [f"m_{dialog_id}_{msg_id}"])
return m["text"] if m else None
def _lead_by_msg(dialog_id: str, msg_id: int) -> dict | None:
row = store.query_one(
"SELECT id FROM leads WHERE source_dialog_id = ? AND source_msg_id = ? LIMIT 1",
[dialog_id, msg_id],
)
if row:
return leads_svc.get_lead(row["id"])
link = store.query_one("SELECT lead_id FROM messages WHERE id = ?", [f"m_{dialog_id}_{msg_id}"])
if link and link["lead_id"]:
return leads_svc.get_lead(link["lead_id"])
return None
@router.get("/status")
async def ml_status(_: str = Depends(current_login)) -> dict:
"""Свежий статус ML-сервиса + локальная статистика (с принудительным refresh)."""
svc = await ml_client.refresh_status()
return {
"enabled": store.get_setting("mlEnabled") is not False,
"service": svc,
"reachable": ml_client._cached["reachable"], # noqa: SLF001
"stats": ml_client.snapshot(),
}
@router.post("/reset")
async def ml_reset(_: str = Depends(current_login)) -> dict:
"""Полный сброс ML-модели (классы/термины) + очистка очереди обучения."""
return await ml_client.reset_model()
@router.post("/predict")
async def ml_predict(body: PredictBody, _: str = Depends(current_login)) -> dict:
text = (body.text or "").strip()
if len(text) < 2:
raise HTTPException(400, "Введите текст")
result = await ml_client.predict(text)
return {"text": text[:200], **result}
@router.post("/learn")
async def ml_learn(body: LearnBody, _: str = Depends(current_login)) -> dict:
"""Ручная разметка: «это сообщение -> сюда». Пишется в outbox (всегда)."""
text = (body.text or "").strip()
label = (body.label or "").strip()
if not text or not label:
raise HTTPException(400, "text и label обязательны")
ml_client.push(text, label)
return {"ok": True, "outbox": ml_client.outbox_len()}
@router.post("/flush")
async def ml_flush(_: str = Depends(current_login)) -> dict:
"""Отправить накопленное обучение в ML-сервис немедленно (обычно — фон раз в 10 c)."""
flushed = await ml_client.flush_outbox()
svc = await ml_client.refresh_status()
return {"ok": True, "flushed": flushed, "outbox": ml_client.outbox_len(), "service": svc}
@router.post("/candidates")
async def ml_candidates(body: CandidatesBody, _: str = Depends(current_login)) -> dict:
"""Последние сообщения канала для разбора/обучения + мнение ML по каждому."""
limit = max(1, min(body.limit, 60))
items = await tg.dialog_messages(body.dialogId, limit)
out = []
for m in items:
text = (m.get("text") or "").strip()
if not text:
continue
pred = await ml_client.predict(text) if ml_client.is_enabled() else {
"take": False, "label": None, "scores": {}, "ready": False}
out.append(
{
"id": m["id"],
"dialogId": body.dialogId,
"text": text[:600],
"time": m.get("time"),
"lead": bool(m.get("lead")),
"pred": {"take": pred.get("take"), "label": pred.get("label"), "scores": pred.get("scores", {})},
}
)
return {"items": out}
@router.post("/apply")
async def ml_apply(body: ApplyBody, _: str = Depends(current_login)) -> dict:
"""Ручное решение по сообщению: учим ML и (если карточка есть) двигаем её."""
text = _msg_text(body.dialogId, body.msgId)
if not text:
raise HTTPException(404, "Исходное сообщение не найдено")
action = body.action
if action == "skip":
return {"ok": True, "learned": False, "moved": None}
lead = _lead_by_msg(body.dialogId, body.msgId)
result: dict = {"ok": True, "learned": False, "moved": None, "leadId": None}
if action == "spam":
ml_client.push(text, "spam")
result["learned"] = True
if lead:
leads_svc.trash_lead(lead["id"], teach=False)
result["moved"] = "trash"
result["leadId"] = lead["id"]
elif action.startswith("board:"):
board_id = action.split(":", 1)[1]
if board_id != "inbox" and store.query_one("SELECT 1 FROM boards WHERE id = ?", [board_id]) is None:
raise HTTPException(400, "Неизвестная доска")
ml_client.push(text, board_id)
result["learned"] = True
if lead:
# повторная разметка карточки, которая уже на доске, — просто обучение
if lead["col"] != board_id:
leads_svc.move_lead(lead["id"], board_id, teach=False)
result["moved"] = board_id
result["leadId"] = lead["id"]
else:
raise HTTPException(400, "Неизвестное действие")
return result
@@ -0,0 +1,74 @@
"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from ..auth import current_login
from ..services import processing as processing_svc
router = APIRouter(prefix="/api/pipeline", tags=["processing"])
class ReturnBody(BaseModel):
reason: str = ""
@router.get("/stats")
def stats(_: str = Depends(current_login)) -> dict:
"""Сводка: размер очереди (new/ai) и число записей в отсеве."""
return processing_svc.stats()
@router.get("/queue")
def queue(limit: int = 100, _: str = Depends(current_login)) -> dict:
"""Сырые сообщения, ожидающие обработки (этап 1 или ИИ)."""
q = processing_svc.queue_counts()
return {
"items": processing_svc.list_queue(limit),
"counts": q,
"rejected": processing_svc.rejected_count(),
}
@router.get("/rejected")
def rejected(
q: str = "",
offset: int = 0,
limit: int = 100,
_: str = Depends(current_login),
) -> dict:
"""Отсев: что и почему не прошло пайплайн (полнотекстовый поиск по q)."""
return processing_svc.list_rejected(q, offset, limit)
@router.post("/rejected/clear")
def rejected_clear(_: str = Depends(current_login)) -> dict:
"""Ручная полная очистка отсева (безвозвратно)."""
cleared = processing_svc.clear_all()
return {"ok": True, "cleared": cleared}
@router.delete("/rejected/{rej_id}")
def rejected_delete(rej_id: str, _: str = Depends(current_login)) -> dict:
processing_svc.delete_one(rej_id)
return {"ok": True}
@router.post("/rejected/{rej_id}/return")
def rejected_return(rej_id: str, body: ReturnBody, _: str = Depends(current_login)) -> dict:
"""Вернуть отсеянное сообщение в обработку.
Для возвращённого сообщения причины отсева (стоп-лист, резюме, тип,
без суммы, устарело, ML/ИИ-спам) игнорируются: оно уходит на ML/ИИ
и создаёт карточку. ML обучается на действии (снятие «спама»), а решение
ИИ по возвращённому сообщению снова учит ML. Запись в отсеве помечается
«возвращено» с указанной пользователем причиной.
"""
try:
out = processing_svc.return_to_queue(rej_id, body.reason)
except KeyError as exc:
raise HTTPException(404, "Запись не найдена") from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return out
@@ -0,0 +1,211 @@
"""«Выбранные»: проектные карточки, стадии, история, напоминания, вложения."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from ..auth import current_login
from ..services import files as files_svc
from ..services import object_store
from ..services import projects as proj_svc
router = APIRouter(prefix="/api/projects", tags=["projects"])
class TakeBody(BaseModel):
leadId: str
class CreateBody(BaseModel):
title: str = ""
summary: str = ""
stack: list[str] | None = None
budget: dict | None = None
contact: str = ""
tzText: str = ""
stage: str | None = None
class PatchBody(BaseModel):
title: str | None = None
summary: str | None = None
stack: list[str] | None = None
budget: dict | None = None
contact: str | None = None
tzText: str | None = None
class StageBody(BaseModel):
stage: str
class CommentBody(BaseModel):
text: str
class LinkBody(BaseModel):
name: str = ""
url: str
class ReminderBody(BaseModel):
at: int # epoch ms
def _card_or_404(card_id: str) -> dict:
card = proj_svc.get_card(card_id)
if not card:
raise HTTPException(404, "Карточка не найдена")
return card
@router.get("")
def list_cards(stage: str | None = None, _: str = Depends(current_login)) -> dict:
return {"items": proj_svc.list_cards(stage)}
@router.get("/reminders")
def reminders(_: str = Depends(current_login)) -> dict:
return {"items": proj_svc.active_reminders()}
@router.get("/{card_id}")
def get_card(card_id: str, _: str = Depends(current_login)) -> dict:
return _card_or_404(card_id)
@router.post("")
def create_card(body: CreateBody, _: str = Depends(current_login)) -> dict:
return proj_svc.create_local_card(body.model_dump())
@router.post("/take")
def take(body: TakeBody, _: str = Depends(current_login)) -> dict:
"""«Взять в работу» — лид безвозвратно уходит с дашборда."""
try:
return proj_svc.take_lead_to_projects(body.leadId)
except KeyError:
raise HTTPException(404, "Лид не найден")
@router.post("/clear-rejected")
def clear_rejected(_: str = Depends(current_login)) -> dict:
"""Полная очистка стадии «Отклонено» (безвозвратно)."""
try:
cleared = proj_svc.clear_stage("rejected")
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": True, "cleared": cleared}
@router.patch("/{card_id}")
def patch(card_id: str, body: PatchBody, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
return proj_svc.patch_card(card_id, body.model_dump(exclude_none=True))
@router.delete("/{card_id}")
def delete(card_id: str, _: str = Depends(current_login)) -> dict:
# По решению удаление проектных карточек не делаем — карточка живёт
# до финального статуса «Выполнено»/«Отклонено».
raise HTTPException(400, "Удаление проектных карточек отключено")
@router.post("/{card_id}/move")
def move(card_id: str, body: StageBody, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
try:
return proj_svc.move_stage(card_id, body.stage)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.post("/{card_id}/comments")
def comment(card_id: str, body: CommentBody, _: str = Depends(current_login)) -> dict:
if not body.text.strip():
raise HTTPException(400, "Пустой комментарий")
return {"comments": proj_svc.add_comment(card_id, body.text)}
# ─── Ссылки ───────────────────────────────────────────────────────────────
@router.post("/{card_id}/links")
def add_link(card_id: str, body: LinkBody, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
url = body.url.strip()
if not url:
raise HTTPException(400, "Пустая ссылка")
if not url.startswith(("http://", "https://")):
url = "https://" + url
card = _card_or_404(card_id)
links = card["links"] + [{"id": f"pl_{card_id[:6]}_{len(card['links'])}", "name": body.name.strip() or url, "url": url}]
return proj_svc.patch_card(card_id, {"links": links})
@router.delete("/{card_id}/links/{link_id}")
def remove_link(card_id: str, link_id: str, _: str = Depends(current_login)) -> dict:
card = _card_or_404(card_id)
links = [l for l in card["links"] if l["id"] != link_id]
return proj_svc.patch_card(card_id, {"links": links})
# ─── Вложения (мета; MinIO позже) ─────────────────────────────────────────
@router.post("/{card_id}/files")
async def upload_files(card_id: str, files: list[UploadFile], _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
added = []
for f in files:
content = await f.read()
added.append(files_svc.add_file(card_id, f.filename or "file", content, f.content_type or ""))
return {"items": added}
@router.get("/{card_id}/files/{file_id}/download")
def download_file(card_id: str, file_id: str, _: str = Depends(current_login)):
entry, _ = files_svc.get_file_entry(card_id, file_id)
if not entry.get("objectKey"):
raise HTTPException(410, "Файл не сохранён в объектном хранилище")
try:
stream = object_store.get(entry["objectKey"])
except Exception as exc: # noqa: BLE001
raise HTTPException(404, "Файл не найден в MinIO") from exc
safe_name = entry["name"].replace('"', "")
return StreamingResponse(
stream,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
)
@router.delete("/{card_id}/files/{file_id}")
def remove_file(card_id: str, file_id: str, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
files_svc.remove_file(card_id, file_id)
return {"ok": True}
# ─── Напоминания об «Отложено» ────────────────────────────────────────────
@router.post("/{card_id}/reminder")
def set_reminder(card_id: str, body: ReminderBody, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
try:
return proj_svc.set_reminder(card_id, body.at)
except PermissionError as exc:
raise HTTPException(400, str(exc)) from exc
@router.delete("/{card_id}/reminder")
def clear_reminder(card_id: str, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
proj_svc.clear_reminder(card_id)
return {"ok": True}
@router.post("/{card_id}/reminder/snooze")
def snooze(card_id: str, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
proj_svc.snooze(card_id)
return {"ok": True}
@@ -0,0 +1,243 @@
"""Настройки, AI-провайдеры, курсы валют (роутер /api)."""
from __future__ import annotations
import asyncio
import json
import httpx
from fastapi import APIRouter, Depends, HTTPException
from .. import constants as C
from ..auth import current_login
from ..crypto import decrypt_text, encrypt_text
from ..db import store
from ..services import ai as ai_svc
from ..services import rates as rates_svc
from ..services.telegram import tg
router = APIRouter(prefix="/api", tags=["settings"])
# Какие настройки видны наружу (без секретов). Значения секретов маскируются.
_PUBLIC_INT = {"archiveAfterDays", "archiveClearDays", "trashClearDays", "minLen", "discJoinLimit", "discJoinDelayMin", "discJoinDelayMax", "discEvalSample", "discEvalThreshold"}
_PUBLIC_BOOL = {"autoArchive", "aiFilterEnabled", "aiEnabled", "conversionOn", "remindersEnabled", "mlEnabled", "blockResumes", "budgetRequiredHire", "budgetRequiredOrder", "autoMonitorNew", "discPaused"}
_PUBLIC_STR = {"targetCurrency", "rateSource", "aiProvider", "aiPrompt", "aiFilterPrompt", "cardPrompt", "domainDescription", "wantedType", "hireLabel", "orderLabel"}
_PUBLIC_LIST = {"stopPhrases", "domainKeywords", "hireMarkers", "levelTerms", "resumeMarkers"}
_PUBLIC_DICT = {"colState"}
def mask(value: str) -> str:
if not value:
return ""
return value if len(value) <= 8 else f"{value[:4]}{value[-4:]}"
def public_settings() -> dict:
out: dict = {}
allset = store.all_settings()
for k in _PUBLIC_INT:
out[k] = int(allset.get(k, 0))
for k in _PUBLIC_BOOL:
out[k] = bool(allset.get(k))
for k in _PUBLIC_STR:
out[k] = allset.get(k, "")
for k in _PUBLIC_LIST:
out[k] = allset.get(k, [])
for k in _PUBLIC_DICT:
out[k] = allset.get(k, {})
out["myPrompts"] = allset.get("myPrompts", [])
tg_keys = store.get_setting("tgKeys") or {}
api_hash = str(tg_keys.get("apiHash", ""))
out["tgKeys"] = {
"apiId": mask(str(tg_keys.get("apiId", ""))),
"apiHashSet": bool(decrypt_text(api_hash)) if api_hash.startswith("enc:") else bool(api_hash),
}
cfg = store.get_setting("aiConfigs") or {}
out["aiConfigs"] = {}
for pid, conf in cfg.items():
raw_key = str(conf.get("apiKey", ""))
plain_key = decrypt_text(raw_key) if raw_key.startswith("enc:") else raw_key
out["aiConfigs"][pid] = {
"baseUrl": conf.get("baseUrl", ""),
"model": conf.get("model", ""),
"keySet": bool(plain_key),
"keyMasked": mask(plain_key),
}
out["providers"] = C.AI_PROVIDERS
return out
@router.get("/settings")
def get_settings(_: str = Depends(current_login)) -> dict:
return public_settings()
@router.patch("/settings")
async def patch_settings(body: dict, _: str = Depends(current_login)) -> dict:
current = store.all_settings()
# инвариант пауз авто-вступлений: если пришли оба конца интервала — клампы
# (5..600) и min <= max (если нет — меняем местами, как делает фронт); если
# пришёл один конец — клампим его относительно сохранённого другого конца
delay_min, delay_max = body.get("discJoinDelayMin"), body.get("discJoinDelayMax")
if delay_min is not None and delay_max is not None:
try:
dmin, dmax = int(delay_min), int(delay_max)
except (TypeError, ValueError):
pass
else:
dmin = max(5, min(600, dmin))
dmax = max(5, min(600, dmax))
if dmin > dmax:
dmin, dmax = dmax, dmin
body["discJoinDelayMin"] = dmin
body["discJoinDelayMax"] = dmax
elif delay_min is not None:
try:
cur_max = int(current.get("discJoinDelayMax") or 0)
value = max(5, min(600, int(delay_min)))
except (TypeError, ValueError):
pass
else:
body["discJoinDelayMin"] = min(value, cur_max) if cur_max >= 5 else value
elif delay_max is not None:
try:
cur_min = int(current.get("discJoinDelayMin") or 0)
value = max(5, min(600, int(delay_max)))
except (TypeError, ValueError):
pass
else:
body["discJoinDelayMax"] = max(value, cur_min) if cur_min <= 600 else value
for key, value in body.items():
if key in _PUBLIC_INT:
try:
value = int(value)
except (TypeError, ValueError):
continue
if key == "archiveAfterDays":
value = max(1, min(30, value))
if key == "minLen":
value = max(10, min(500, value))
if key == "discJoinLimit":
value = max(1, min(200, value))
if key in {"discJoinDelayMin", "discJoinDelayMax"}:
value = max(5, min(600, value))
if key == "discEvalSample":
value = max(3, min(30, value))
if key == "discEvalThreshold":
value = max(1, min(100, value))
store.set_setting(key, value)
elif key in _PUBLIC_BOOL:
store.set_setting(key, bool(value))
elif key in _PUBLIC_STR:
if key in {"targetCurrency"}:
value = str(value).upper()
if key == "aiProvider" and not any(p["id"] == value for p in C.AI_PROVIDERS):
continue
store.set_setting(key, str(value))
elif key in _PUBLIC_LIST:
if isinstance(value, list):
store.set_setting(key, [str(x) for x in value][:200])
elif key in _PUBLIC_DICT:
if isinstance(value, dict):
store.set_setting(key, value)
elif key == "myPrompts" and isinstance(value, list):
clean: list[dict] = []
for item in value[:100]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()[:80]
prompt = str(item.get("prompt") or "").strip()[:8000]
if not name or not prompt:
continue
clean.append(
{
"id": str(item.get("id") or "")[:40] or store.uid("pp_"),
"name": name,
"description": str(item.get("description") or "").strip()[:300],
"prompt": prompt,
}
)
store.set_setting("myPrompts", clean)
elif key == "aiConfigs" and isinstance(value, dict):
cfg = current.get("aiConfigs") or {}
for pid, conf in value.items():
if pid not in cfg:
continue
entry = dict(cfg[pid])
if isinstance(conf, dict):
for fk in ("baseUrl", "model"):
if fk in conf and conf[fk] is not None:
entry[fk] = str(conf[fk])
new_key = conf.get("apiKey")
if new_key and not str(new_key).startswith(("enc:",)) and len(str(new_key)) >= 8:
entry["apiKey"] = encrypt_text(str(new_key))
cfg[pid] = entry
store.set_setting("aiConfigs", cfg)
elif key == "tgKeys" and isinstance(value, dict):
keys = current.get("tgKeys") or {}
if value.get("apiId") is not None:
api = str(value["apiId"]).strip()
if api.isdigit() and 5 < len(api) < 10:
keys["apiId"] = api
new_hash = value.get("apiHash")
if new_hash and len(str(new_hash)) >= 16 and not str(new_hash).startswith("enc:"):
keys["apiHash"] = encrypt_text(str(new_hash).strip())
store.set_setting("tgKeys", keys)
# смена источника курсов — обновляем в фоне; смена целевой валюты/конвертации —
# пересчёт старых карточек (кроме архива/корзины)
if body.get("rateSource"):
asyncio.get_running_loop().create_task(rates_svc.refresh_rates())
if body.get("targetCurrency") is not None or body.get("conversionOn") is not None:
rates_svc.recompute_conversions()
return public_settings()
@router.post("/ai/check")
async def ai_check(_: str = Depends(current_login)) -> dict:
status = ai_svc.provider_status()
provider_id, data = ai_svc._cfg()
meta = data["meta"]
if meta.get("local"):
return {**status, "ok": True, "message": f"Локальный сервер «{meta['name']}» (ping в проде)"}
key = data["cfg"].get("apiKey")
if not key:
return {**status, "ok": False, "message": "Не задан API-ключ"}
# Лёгкая проверка: GET /models (для OpenAI-совместимых) или /v1/models (Anthropic)
base = str(data["cfg"].get("baseUrl") or meta["base"]).rstrip("/")
url = base + ("/v1/models" if meta.get("api_style") == "anthropic" else "/models")
headers = {"Authorization": f"Bearer {key}"} if meta.get("api_style") != "anthropic" else {
"x-api-key": key, "anthropic-version": "2023-06-01"}
try:
async with httpx.AsyncClient(timeout=12) as client:
resp = await client.get(url, headers=headers)
if resp.status_code < 400:
return {**status, "ok": True, "message": "Подключение успешно"}
if resp.status_code in (401, 403):
return {**status, "ok": False, "message": f"Ключ не принят (HTTP {resp.status_code}) — проверьте ключ и доступ к модели"}
return {**status, "ok": False, "message": f"HTTP {resp.status_code} — проверьте Base URL и модель"}
except Exception as exc: # noqa: BLE001
return {**status, "ok": False, "message": f"Ошибка соединения: {exc}"}
# ─── Курсы ────────────────────────────────────────────────────────────────
@router.get("/rates")
def get_rates(_: str = Depends(current_login)) -> dict:
return rates_svc.get_rates()
@router.post("/rates/refresh")
async def refresh_rates(_: str = Depends(current_login)) -> dict:
ok = await rates_svc.refresh_rates()
return {"ok": ok, "rates": rates_svc.get_rates()}
# ─── Методанные для фронта ────────────────────────────────────────────────
@router.get("/meta/constants")
def meta_constants(_: str = Depends(current_login)) -> dict:
return {
"currencies": C.CURRENCIES,
"stages": C.PIPELINE_STAGES,
"palette": C.PALETTE,
}
@@ -0,0 +1,154 @@
"""Telegram: статус, веб-авторизация, диалоги и мониторинг (п.4.2, 4.3 ТЗ)."""
from __future__ import annotations
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel
from ..auth import current_login
from ..services.telegram import tg
router = APIRouter(prefix="/api/tg", tags=["telegram"])
def _qr_svg(url: str) -> str:
"""Реальный QR-код (SVG, без внешних растровых зависимостей)."""
import io
import qrcode
import qrcode.image.svg
qr = qrcode.QRCode(version=None, box_size=12, border=1, error_correction=qrcode.constants.ERROR_CORRECT_M)
qr.add_data(url)
qr.make(fit=True)
buf = io.StringIO()
qr.make_image(image_factory=qrcode.image.svg.SvgPathImage).save(buf)
return buf.getvalue()
@router.get("/qr-image")
def qr_image(_: str = Depends(current_login)) -> Response:
"""SVG-картинка QR для сканирования (фаза входа 'qr')."""
if tg.phase != "qr" or not tg.qr_url:
raise HTTPException(404, "QR не активен — начните вход по QR")
return Response(
_qr_svg(tg.qr_url),
media_type="image/svg+xml",
headers={"Cache-Control": "no-store", "Content-Disposition": "inline"},
)
class PhoneBody(BaseModel):
phone: str
class CodeBody(BaseModel):
code: str
class PasswordBody(BaseModel):
password: str
class MonitorBody(BaseModel):
enabled: bool
class PreviewBody(BaseModel):
dialogId: str
limit: int = 24
@router.get("/status")
def status(_: str = Depends(current_login)) -> dict:
return tg.status()
@router.post("/start-phone")
async def start_phone(body: PhoneBody, _: str = Depends(current_login)) -> dict:
try:
await tg.start_phone(body.phone.strip())
except Exception as exc: # noqa: BLE001
raise HTTPException(400, tg.error or str(exc)) from exc
return {"phase": tg.phase}
@router.post("/start-qr")
async def start_qr(_: str = Depends(current_login)) -> dict:
try:
url = await tg.qr_start()
except Exception as exc: # noqa: BLE001
raise HTTPException(400, tg.error or str(exc)) from exc
return {"phase": tg.phase, "qrUrl": url}
@router.post("/send-code")
async def send_code(body: CodeBody, _: str = Depends(current_login)) -> dict:
try:
await tg.submit_code(body.code.strip())
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(400, tg.error or str(exc)) from exc
return {"phase": tg.phase}
@router.post("/send-password")
async def send_password(body: PasswordBody, _: str = Depends(current_login)) -> dict:
try:
await tg.submit_password(body.password)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"phase": tg.phase}
@router.post("/logout")
async def logout(_: str = Depends(current_login)) -> dict:
await tg.disconnect()
return {"ok": True}
@router.get("/dialogs")
def dialogs(_: str = Depends(current_login)) -> dict:
return {"items": tg.list_dialogs()}
@router.post("/dialogs/refresh")
async def dialogs_refresh(_: str = Depends(current_login)) -> dict:
if not tg.client or not tg.client.is_connected():
return {"ok": False, "reason": "not-connected", "count": 0}
count = await asyncio.wait_for(tg.refresh_dialogs(), timeout=60)
return {"ok": True, "count": count}
@router.post("/dialogs/monitor-all")
def monitor_all(body: MonitorBody, _: str = Depends(current_login)) -> dict:
"""Включить/выключить мониторинг сразу для всех каналов."""
count = tg.set_monitor_all(body.enabled)
return {"ok": True, "count": count, "enabled": body.enabled}
@router.post("/dialogs/backfill-all")
def backfill_all(_: str = Depends(current_login)) -> dict:
"""Перечитать последние 10 сообщений всех включённых каналов (кнопка)."""
count = tg.backfill_monitored()
return {"ok": True, "count": count}
@router.post("/dialogs/{dialog_id}/monitor")
def set_monitor(dialog_id: str, body: MonitorBody, _: str = Depends(current_login)) -> dict:
tg.set_monitor(dialog_id, body.enabled)
return {"ok": True, "enabled": body.enabled}
@router.post("/dialogs/{dialog_id}/backfill")
async def backfill(dialog_id: str, _: str = Depends(current_login)) -> dict:
processed = await tg.backfill_dialog(dialog_id)
return {"ok": True, "processed": processed}
@router.post("/dialogs/preview")
async def preview(body: PreviewBody, _: str = Depends(current_login)) -> dict:
items = await tg.dialog_messages(body.dialogId, min(max(body.limit, 1), 50))
return {"items": items}
@@ -0,0 +1 @@
"""Сервисный слой."""
@@ -0,0 +1,357 @@
"""AI-классификатор поверх выбранного провайдера.
Провайдеров много (облако + локальные OpenAI-совместимые + Anthropic),
активен один. Всё сведено к двум задачам:
* filter_incoming(text) — этап 2 фильтра входящих (промпт aiFilterPrompt);
* classify(text, boards) — разбор лида (промпт aiPrompt + обучающие примеры).
Ответы моделей строго JSON; распознаём и обрезаем markdown-обёртки ```json.
"""
from __future__ import annotations
import json
import logging
import re
import httpx
from .. import constants as C
from ..crypto import decrypt_text
from ..db import store
from .rates import convert_amount
log = logging.getLogger("leadradar.ai")
def _cfg() -> tuple[str, dict]:
provider_id = store.get_setting("aiProvider") or "deepseek"
configs = store.get_setting("aiConfigs") or {}
meta = next((p for p in C.AI_PROVIDERS if p["id"] == provider_id), None) or C.AI_PROVIDERS[0]
raw = configs.get(provider_id, {})
cfg = dict(raw)
if cfg.get("apiKey"):
cfg["apiKey"] = decrypt_text(str(cfg["apiKey"]))
return provider_id, {"meta": meta, "cfg": cfg}
def provider_status() -> dict:
"""Статус для UI: кто активен, есть ли ключ, base, модель (ключ маскируется)."""
provider_id, data = _cfg()
meta = data["meta"]
cfg = data["cfg"]
key = str(cfg.get("apiKey") or "")
return {
"provider": provider_id,
"name": meta["name"],
"base": cfg.get("baseUrl") or meta["base"],
"model": cfg.get("model") or (meta["models"] or [""])[0],
"local": bool(meta.get("local")),
"keySet": bool(key),
"keyMasked": mask_key(key),
}
def mask_key(key: str) -> str:
if not key:
return ""
if len(key) <= 8:
return key[0] + ""
return f"{key[:4]}{key[-4:]}"
# ── «Сфера и ключи»: подстановка в промпты из настроек пользователя ────────
def fill_prompt(prompt: str) -> str:
"""Заменяет в тексте промпта {domain} и {keywords} значениями из настроек.
Настройки задаются в UI (вкладка «Сфера и ключи»), поэтому ИИ-классификатор
работает для любой сферы: разработка, дизайн, недвижимость, стройка и т.п.
"""
domain = str(store.get_setting("domainDescription") or "").strip()
if not domain:
domain = "Универсально: заявка = конкретный запрос на услугу/товар/работу или найм человека."
raw_kws = store.get_setting("domainKeywords") or []
if isinstance(raw_kws, str):
raw_kws = [raw_kws]
kws = [str(k).strip() for k in raw_kws if str(k).strip()]
kws_text = ", ".join(kws[:60]) if kws else "(не заданы — определяй по тексту)"
return (prompt or "").replace("{domain}", domain).replace("{keywords}", kws_text)
async def chat_json(system: str, user: str, max_retries: int = 2, max_tokens: int = 8000) -> dict:
"""Единая точка вызова выбранной модели. Возвращает dict (JSON-ответ).
Модель иногда отвечает HTTP 200 с пустым/не-JSON содержимым (особенно
на больших запросах или при перегрузке API) — делаем несколько попыток
с нарастающей паузой, чтобы не сыпать «ИИ недоступен» из-за разового сбоя.
"""
provider_id, data = _cfg()
meta = data["meta"]
cfg = data["cfg"]
base = str(cfg.get("baseUrl") or meta["base"]).rstrip("/")
model = cfg.get("model") or (meta["models"] or [""])[0]
api_key = str(cfg.get("apiKey") or "")
last_err: Exception | None = None
last_raw = ""
for attempt in range(max_retries + 1):
try:
if meta.get("api_style") == "anthropic":
text = await _call_anthropic(base, api_key, model, system, user, max_tokens)
else:
text = await _call_openai(base, api_key, model, system, user, max_tokens)
last_raw = text
return extract_json(text)
except Exception as exc: # noqa: BLE001 — пробуем ещё раз
last_err = exc
if attempt < max_retries:
await asyncio_sleep(0.8 + 1.2 * attempt) # 0.8с, 2с, 3.2с
log.warning(
"AI call failed (%s, попыток %d): %s | ответ: %r",
meta["name"],
max_retries + 1,
last_err,
last_raw[:200],
)
raise RuntimeError(
f"ИИ ({meta['name']}) не ответил корректно — повторите попытку через несколько секунд"
)
async def asyncio_sleep(secs: float) -> None:
import asyncio
await asyncio.sleep(secs)
async def _call_openai(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str:
url = base + "/chat/completions"
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
body = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.2,
"max_tokens": max_tokens,
}
async with httpx.AsyncClient(timeout=90) as client:
resp = await client.post(url, headers=headers, json=body)
resp.raise_for_status()
payload = resp.json()
try:
msg = payload["choices"][0]["message"]
except (KeyError, IndexError, TypeError):
raise ValueError(f"неожиданный ответ API: {str(payload)[:200]}")
content = msg.get("content")
if not content and msg.get("reasoning_content"):
# модель «подумала», но не дала ответа (переполнение/обрыв) — считаем сбоем и повторим
raise ValueError("модель вернула только reasoning без ответа")
return str(content or "")
async def _call_anthropic(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str:
url = base.rstrip("/") + "/v1/messages"
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
body = {
"model": model,
"max_tokens": max_tokens,
"system": system,
"messages": [{"role": "user", "content": user}],
}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, headers=headers, json=body)
resp.raise_for_status()
payload = resp.json()
return "".join(blk.get("text", "") for blk in payload.get("content", []))
def extract_json(text: str) -> dict:
raw = text.strip()
fence = re.search(r"```(?:json)?\s*(.*?)```", raw, re.S)
if fence:
raw = fence.group(1).strip()
start, end = raw.find("{"), raw.rfind("}")
if start >= 0 and end > start:
raw = raw[start : end + 1]
return json.loads(raw)
# ── Задачи ────────────────────────────────────────────────────────────────
async def filter_incoming(text: str) -> dict:
"""Этап 2 (ИИ-фильтр). Если выключен — считаем пропущенным."""
if not store.get_setting("aiFilterEnabled"):
return {"pass": True, "reason": None, "skipped": True}
prompt = fill_prompt(store.get_setting("aiFilterPrompt"))
out = await chat_json(prompt, f"Сообщение:\n{text[:4000]}")
return {
"pass": bool(out.get("pass", True)),
"reason": out.get("reason"),
"skipped": False,
}
def _learning_examples(limit: int = 8) -> list[dict]:
"""Примеры разметки пользователя для few-shot классификации."""
rows = store.query(
"SELECT l.source_msg AS msg, ll.to_col "
"FROM learning_log ll JOIN leads l ON l.id = ll.lead_id "
"WHERE ll.action IN ('move','restore') AND l.source_msg <> '' "
"ORDER BY ll.created_at DESC LIMIT ?",
[limit],
)
examples = []
for r in rows:
if r["to_col"] in ("trash", "archive"):
continue
examples.append({"text": (r["msg"] or "")[:500], "board": r["to_col"]})
return examples
async def classify(message_text: str) -> dict:
"""Полный разбор лида. Возвращает сырой словарь модели.
Колонка для ИИ — это набор критериев (правила), а не просто название:
в промпт уходят правила колонок, чтобы модель выбирала осмысленно.
"""
from .rules import describe as describe_rules
# в классификации участвуют только принятые колонки (не ИИ-предложения)
boards = store.query(
"SELECT id, name, description, keywords, rules FROM boards WHERE suggested = FALSE ORDER BY pos"
)
lines = []
for b in boards:
rules = json.loads(b["rules"] or "{}") if b["rules"] else {}
has_rules = any(rules.get(k) for k in ("direction", "keywords", "stack", "grade", "budget"))
if has_rules:
suffix = f" (критерии: {describe_rules(rules)})"
else:
kws = json.loads(b["keywords"] or "[]")[:8]
suffix = f" (ключевые слова: {', '.join(kws)})" if kws else ""
line = f"- {b['id']}: {b['name']}{suffix}"
if b.get("description"):
line += f"{str(b['description']).strip()[:160]}"
lines.append(line)
board_map = "\n".join(lines) or "- (колонок пока нет — верните board: null)"
examples = _learning_examples()
user = (
f"Доски: {board_map}\n\n"
+ ("Примеры разметки пользователя:\n" + "\n".join(
f"текст: {e['text']}\n→ колонка: {e['board']}" for e in examples
) + "\n\n" if examples else "")
+ f"Новое сообщение:\n{message_text[:5000]}"
)
prompt = fill_prompt(store.get_setting("aiPrompt"))
# отдельный промпт структуры карточки («О заявке») — задаёт поля контента,
# чтобы все карточки имели одинаковую структуру текста
card = fill_prompt(store.get_setting("cardPrompt") or "")
if card:
prompt = prompt + "\n\n" + card
return await chat_json(prompt, user)
def normalize_dedup(text: str) -> str:
"""Дедупликация по нормализованному тексту (регистр и спецсимволы)."""
import hashlib
import re as _re
norm = _re.sub(r"[^\wа-яё]+", "", text.casefold())
return hashlib.sha1(norm.encode()).hexdigest()
# Синонимы валют из ответов ИИ → коды хранения (согласовано с rules._CUR_*)
_CUR_ALIASES = {
"USD": "USD", "$": "USD", "US$": "USD", "ДОЛЛАР": "USD", "ДОЛЛАРОВ": "USD", "ДОЛЛ": "USD", "БАКС": "USD", "БАКСОВ": "USD",
"EUR": "EUR", "": "EUR", "ЕВРО": "EUR",
"RUB": "RUB", "RUR": "RUB", "": "RUB", "РУБ": "RUB", "РУБЛЕЙ": "RUB", "РУБЛИ": "RUB", "РУБЛЬ": "RUB", "РУБЛЯ": "RUB",
"GBP": "GBP", "£": "GBP", "CNY": "CNY", "¥": "CNY", "USDT": "USDT", "": "USDT",
}
def _norm_currency(raw) -> str | None:
"""Приводит название/символ валюты из ИИ к коду (USD/EUR/RUB/…)."""
s = str(raw or "").strip().upper()
if not s:
return None
if s in _CUR_ALIASES:
return _CUR_ALIASES[s]
letters = re.sub(r"[^A-ZА-Я]", "", s)
if letters in _CUR_ALIASES:
return _CUR_ALIASES[letters]
if len(s) == 3 and s.isalpha():
return s
return None
def _budget_num(v):
"""Число из значения бюджета ИИ: «2к»/«2К» → 2000, «2000₽»/«2000р» → 2000."""
if v is None:
return None
s = str(v).strip().casefold().replace("\u00a0", "").replace(" ", "")
if not s:
return None
mult = 1.0
if s.endswith(("к", "k")):
mult = 1000.0
s = s[:-1]
if s.endswith("руб"):
s = s[:-3]
elif s.endswith(("р", "")):
s = s[:-1]
try:
x = float(s.replace(",", ".")) * mult
except ValueError:
return None
return None if x == 0 else x
def clean_budget(budget) -> dict | None:
"""Нормализация бюджета из ИИ/локального разбора.
Соглашение хранения (и отображения в UI):
одна сумма X → {from: X, to: X}
«до X» → {from: None, to: X}
диапазон «от X до Y» → {from: X, to: Y}
from=0 трактуется как отсутствие нижней границы («от 0 до X» == «до X»).
"""
if not isinstance(budget, dict):
return None
cur = _norm_currency(budget.get("currency") or budget.get("cur"))
if not cur:
return None
def num(v):
return _budget_num(v)
f, t = num(budget.get("from")), num(budget.get("to"))
if f is None and t is None:
return None
if t is None:
t = f # одна сумма или «от X» без верхней границы
return {"from": f, "to": t, "currency": cur}
def budget_to_target(budget: dict | None) -> dict:
"""Пересчёт «один раз при поступлении» в целевую валюту по текущим курсам."""
if not budget or not budget.get("currency"):
return {"convFrom": None, "convTo": None, "convCur": ""}
cur = budget["currency"]
target = store.get_setting("targetCurrency") or "RUB"
if not store.get_setting("conversionOn"):
return {"convFrom": None, "convTo": None, "convCur": ""}
from_, to_ = budget.get("from"), budget.get("to")
c_from = convert_amount(from_, cur, target) if from_ is not None else None
c_to = None
if to_ is not None:
c_to = convert_amount(to_, cur, target)
elif from_ is not None:
c_to = c_from
return {"convFrom": c_from, "convTo": c_to, "convCur": target}
@@ -0,0 +1,80 @@
"""BanGuard: квоты, паузы и защита от flood для авто-вступлений Discovery.
Суточный лимит (discJoinLimit) считается по disc_log (event='join_auto')
за текущие UTC-сутки; между вступлениями выдерживается случайная пауза
(discJoinDelayMin/Max). При FloodWait от Telegram ставится блокировка до
конца суток (discFloodDay), плюс есть ручной стоп-кран (discPaused).
"""
from __future__ import annotations
import asyncio
import random
from datetime import datetime, timezone
from ..db import store
_KEY_JOIN_LIMIT = "discJoinLimit"
_KEY_DELAY_MIN = "discJoinDelayMin"
_KEY_DELAY_MAX = "discJoinDelayMax"
_KEY_FLOOD_DAY = "discFloodDay"
_KEY_PAUSED = "discPaused"
def _start_of_day_ms() -> int:
"""Начало текущих UTC-суток в миллисекундах."""
start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
return int(start.timestamp() * 1000)
def joins_today_auto() -> int:
"""Авто-вступления за текущие UTC-сутки (disc_log, event='join_auto')."""
row = store.scalar(
"SELECT count(*) FROM disc_log WHERE event = 'join_auto' AND created_at >= ?",
[_start_of_day_ms()],
)
return int(row or 0)
def can_auto_join() -> bool:
"""Разрешено ли авто-вступление: лимит не исчерпан, нет flood на сегодня, нет паузы."""
limit = int(store.get_setting(_KEY_JOIN_LIMIT) or 0)
return joins_today_auto() < limit and not flood_today() and not global_paused()
async def wait_join_delay() -> None:
"""Случайная пауза перед авто-вступлением (сек, из настроек).
Защита инварианта min <= max: настройки мог изменить один конец интервала
(single-key PATCH), поэтому при инверсии концы меняются местами.
"""
low = float(store.get_setting(_KEY_DELAY_MIN) or 0)
high = float(store.get_setting(_KEY_DELAY_MAX) or 0)
if low <= 0 and high <= 0:
return # обе настройки не заданы — паузы нет (крайний случай)
if low > high:
low, high = high, low
await asyncio.sleep(random.uniform(low, high))
def note_flood() -> None:
"""Зафиксировать FloodWait: блокировка авто-вступлений до конца суток."""
store.set_setting(_KEY_FLOOD_DAY, _start_of_day_ms())
def flood_today() -> bool:
"""Была ли flood-блокировка в текущие UTC-сутки."""
return int(store.get_setting(_KEY_FLOOD_DAY) or 0) == _start_of_day_ms()
def global_paused() -> bool:
"""Ручной стоп-кран авто-вступлений (setting discPaused)."""
return bool(store.get_setting(_KEY_PAUSED))
def set_global_pause(v: bool) -> None:
store.set_setting(_KEY_PAUSED, bool(v))
def search_pause() -> float:
"""Пауза между поисковыми запросами Telegram (сек)."""
return random.uniform(2.0, 4.0)
@@ -0,0 +1,608 @@
"""Discovery: хранилище задач поиска каналов, кандидатов, чёрного списка и лога.
Единый слой доступа к disc_tasks / disc_candidates / disc_blacklist / disc_log
(Task 1). Потребляется и API (Task 7), и фоновым воркером (Task 6).
Соглашения модуля:
- все обращения к БД только через `store.*` с параметрами (без конкатенации SQL);
- время — миллисекунды (`time.time_ns() // 1_000_000`);
- JSON-поля (keywords/marks/topics) в БД — VARCHAR, наружу всегда список
(`json.dumps(..., ensure_ascii=False)` при записи, `json.loads` при чтении);
- наружные dict-ы — camelCase (конвенция границы API проекта), поля совпадают
с колонками БД: keywords/minSubscribers/sampleSize/planJoins/autoJoin,
searchIdx/searchDone, fitRatio/autoJoined, createdAt/updatedAt, dialogId...
- create/patch принимают ключи и в camelCase, и в snake_case (поле "min_subscribers"
и т.п.) — на входе идёт нормализация к колонкам БД;
- статусы кандидата: new -> review -> joined|rejected. Переводы в joined/rejected
делаются ТОЛЬКО через mark_joined()/mark_rejected() (счётчики, чёрный список,
лог); set_candidate_status() разрешает new/review.
Бюджет авто-вступлений: сумма plan_joins задач со статусом NOT IN ('done','failed')
плюс plan_joins новой/увеличиваемой задачи не должна превышать discJoinLimit.
"""
from __future__ import annotations
import json
import time
from ..db import store
# префиксы id (конвенция: видно, из какой таблицы запись)
_ID_TASK = "dt_"
_ID_LOG = "dl_"
# статусы кандидата, при которых повторное добавление источника запрещено
_ACTIVE_CANDIDATE = {"new", "review", "joined"}
# статусы задачи, которые НЕ занимают бюджет plan_joins
_DONE_TASK = {"done", "failed"}
# алиасы полей задачи: колонка БД -> набор имён в payload/патче
_TASK_ALIASES = {
"min_subscribers": {"minSubscribers", "min_subscribers"},
"sample_size": {"sampleSize", "sample_size"},
"plan_joins": {"planJoins", "plan_joins"},
"auto_join": {"autoJoin", "auto_join"},
# остальные поля называются одинаково: name, description, keywords, lang, threshold
}
# поля кандидата, которые можно менять через set_candidate()
_CANDIDATE_FIELDS = {
"name": "name",
"username": "username",
"kind": "kind",
"hue": "hue",
"participants": "participants",
"lang_ru": "lang_ru",
"langRu": "lang_ru",
"marks": "marks",
"topics": "topics",
"fit_ratio": "fit_ratio",
"fitRatio": "fit_ratio",
"auto_joined": "auto_joined",
"autoJoined": "auto_joined",
}
def _now() -> int:
return time.time_ns() // 1_000_000
def _json(value) -> str:
return json.dumps(value or [], ensure_ascii=False)
def _loads(raw, default: list | None = None) -> list:
try:
return json.loads(raw or "[]")
except (TypeError, ValueError):
return list(default or [])
def _plan_limit() -> int:
"""Верхняя граница plan_joins: текущий суточный лимит discJoinLimit."""
return max(1, int(store.get_setting("discJoinLimit") or 0))
def _active_plan_sum(exclude_id: str | None = None) -> int:
sql = "SELECT coalesce(sum(plan_joins), 0) FROM disc_tasks WHERE status NOT IN ('done', 'failed')"
params: list = []
if exclude_id:
sql += " AND id <> ?"
params.append(exclude_id)
return int(store.scalar(sql, params) or 0)
def _assert_plan(plan_joins: int) -> None:
"""plan_joins >= 1 и не больше суточного лимита (настраивается в UI)."""
if plan_joins < 1:
raise ValueError("plan_joins должен быть не меньше 1")
limit = _plan_limit()
if plan_joins > limit:
raise ValueError(f"plan_joins {plan_joins} больше суточного лимита авто-вступлений ({limit})")
def _assert_budget(plan_joins: int, exclude_id: str | None = None) -> None:
"""Правило бюджета: сумма планов активных задач + новая <= discJoinLimit."""
limit = _plan_limit()
used = _active_plan_sum(exclude_id)
if used + plan_joins > limit:
raise ValueError(
f"Бюджет авто-вступлений исчерпан: задачи уже занимают {used} из {limit} в сутки, "
f"ещё {plan_joins} не влезает"
)
# ─── view-слои (наружу camelCase) ──────────────────────────────────────────
def _task_view(row: dict) -> dict:
return {
"id": row["id"],
"name": row["name"],
"description": row["description"],
"keywords": _loads(row["keywords"]),
"minSubscribers": int(row["min_subscribers"]),
"lang": row["lang"],
"threshold": int(row["threshold"]),
"sampleSize": int(row["sample_size"]),
"planJoins": int(row["plan_joins"]),
"autoJoin": bool(row["auto_join"]),
"status": row["status"],
"searchIdx": int(row["search_idx"]),
"searchDone": bool(row["search_done"]),
"found": int(row["found"]),
"evaluated": int(row["evaluated"]),
"joined": int(row["joined"]),
"rejected": int(row["rejected"]),
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
}
def _candidate_view(row: dict) -> dict:
return {
"dialogId": row["dialog_id"],
"taskId": row["task_id"],
"name": row["name"],
"username": row["username"],
"kind": row["kind"],
"hue": row["hue"],
"participants": row["participants"],
"langRu": row["lang_ru"],
"marks": _loads(row["marks"]),
"topics": _loads(row["topics"]),
"fitRatio": row["fit_ratio"],
"status": row["status"],
"autoJoined": bool(row["auto_joined"]),
"joinFailures": int(row.get("join_failures") or 0),
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
}
def _blacklist_view(row: dict) -> dict:
return {
"dialogId": row["dialog_id"],
"name": row["name"],
"reason": row["reason"],
"createdAt": row["created_at"],
}
def _log_view(row: dict) -> dict:
return {
"id": row["id"],
"taskId": row["task_id"],
"event": row["event"],
"text": row["text"],
"createdAt": row["created_at"],
}
# ─── задачи ────────────────────────────────────────────────────────────────
def _task_or_raise(task_id: str) -> dict:
row = store.query_one("SELECT * FROM disc_tasks WHERE id = ?", [task_id])
if row is None:
raise KeyError(task_id)
return row
def list_tasks() -> list[dict]:
"""Все задачи, старые первыми (воркер берёт самую старую running)."""
return [_task_view(r) for r in store.query("SELECT * FROM disc_tasks ORDER BY created_at ASC")]
def get_task(task_id: str) -> dict | None:
row = store.query_one("SELECT * FROM disc_tasks WHERE id = ?", [task_id])
return _task_view(row) if row else None
def _norm_task_values(patch: dict) -> dict:
"""Нормализация payload/патча задачи (camel/snake алиасы) к колонкам БД."""
out: dict = {}
for key, value in patch.items():
if key in ("name", "description", "keywords", "lang", "threshold"):
col = key
else:
col = next((c for c, aliases in _TASK_ALIASES.items() if key in aliases), None)
if col is None:
continue # неизвестное поле игнорируем
out[col] = value
return out
def _validate_task_values(cols: dict) -> None:
"""Проверка границ значений задачи (после нормализации)."""
if "threshold" in cols:
cols["threshold"] = max(1, min(100, int(cols["threshold"])))
if "sample_size" in cols:
cols["sample_size"] = max(1, int(cols["sample_size"]))
if "min_subscribers" in cols:
cols["min_subscribers"] = max(0, int(cols["min_subscribers"]))
if "lang" in cols and cols["lang"] not in ("ru", "any"):
cols["lang"] = "ru"
if "auto_join" in cols:
cols["auto_join"] = bool(cols["auto_join"])
if "keywords" in cols:
keywords = cols["keywords"] if isinstance(cols["keywords"], list) else [cols["keywords"]]
cols["keywords"] = [str(k).strip() for k in keywords if str(k).strip()]
if "description" in cols:
cols["description"] = str(cols["description"] or "")
if "name" in cols:
cols["name"] = str(cols["name"] or "").strip()
def create_task(payload: dict) -> dict:
"""Создать задачу поиска.
Валидация: name непустое; plan_joins 1..discJoinLimit; правило бюджета
(сумма plan_joins активных задач + новая <= discJoinLimit) — иначе ValueError.
"""
cols = _norm_task_values(payload)
name = str(cols.get("name") or "").strip()
if not name:
raise ValueError("Укажите название задачи")
plan_joins = int(cols.get("plan_joins", 1))
_assert_plan(plan_joins)
_assert_budget(plan_joins)
values = {
"name": name,
"description": str(cols.get("description") or ""),
"keywords": cols.get("keywords", []),
"min_subscribers": max(0, int(cols.get("min_subscribers", 0))),
"lang": cols.get("lang", "ru"),
"threshold": max(1, min(100, int(cols.get("threshold", int(store.get_setting("discEvalThreshold") or 40))))),
"sample_size": max(1, int(cols.get("sample_size", int(store.get_setting("discEvalSample") or 10)))),
"plan_joins": plan_joins,
"auto_join": bool(cols.get("auto_join", False)),
}
_validate_task_values(values)
task_id = store.uid(_ID_TASK)
now = _now()
store.execute(
"INSERT INTO disc_tasks(id, name, description, keywords, min_subscribers, lang, threshold, "
"sample_size, plan_joins, auto_join, status, search_idx, search_done, found, evaluated, "
"joined, rejected, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 0, FALSE, 0, 0, 0, 0, ?, ?)",
[
task_id,
values["name"],
values["description"],
_json(values["keywords"]),
values["min_subscribers"],
values["lang"],
values["threshold"],
values["sample_size"],
values["plan_joins"],
values["auto_join"],
now,
now,
],
)
return get_task(task_id) # type: ignore[return-value]
def patch_task(task_id: str, patch: dict) -> dict:
"""Обновить поля задачи. Увеличение plan_joins — с проверкой бюджета."""
row = _task_or_raise(task_id)
cols = _norm_task_values(patch)
if not cols:
return get_task(task_id) # type: ignore[return-value]
old_plan = int(row["plan_joins"])
if "plan_joins" in cols:
new_plan = int(cols["plan_joins"])
_assert_plan(new_plan)
if new_plan > old_plan:
_assert_budget(new_plan, exclude_id=task_id)
cols["plan_joins"] = new_plan
_validate_task_values(cols)
sets = ["updated_at = ?"]
params: list = [_now()]
for col, value in cols.items():
sets.append(f"{col} = ?")
params.append(_json(value) if col == "keywords" else value)
params.append(task_id)
store.execute(
f"UPDATE disc_tasks SET {', '.join(sets)} WHERE id = ?",
params,
)
return get_task(task_id) # type: ignore[return-value]
def delete_task(task_id: str) -> None:
"""Удалить задачу вместе с её кандидатами и логом (чёрный список общий)."""
store.execute("DELETE FROM disc_tasks WHERE id = ?", [task_id])
store.execute("DELETE FROM disc_candidates WHERE task_id = ?", [task_id])
store.execute("DELETE FROM disc_log WHERE task_id = ?", [task_id])
def start_task(task_id: str) -> dict:
"""Запустить поиск: keywords непустые; status=running.
При повторном запуске завершённой/упавшей задачи прогресс поиска обнуляется
(свежий проход по ключам); при продолжении из paused — сохраняется.
"""
row = _task_or_raise(task_id)
keywords = _loads(row["keywords"])
if not keywords:
raise ValueError("Нет ключевых слов для поиска — добавьте их в задачу")
now = _now()
reset = row["status"] in _DONE_TASK
if reset:
# повторный прогон завершённой/упавшей задачи — свежий проход по ключам
store.execute(
"UPDATE disc_tasks SET status = 'running', search_idx = 0, search_done = FALSE, "
"found = 0, evaluated = 0, joined = 0, rejected = 0, updated_at = ? WHERE id = ?",
[now, task_id],
)
else:
# старт из draft или продолжение из paused — прогресс поиска сохраняется
store.execute(
"UPDATE disc_tasks SET status = 'running', updated_at = ? WHERE id = ?",
[now, task_id],
)
return get_task(task_id) # type: ignore[return-value]
def pause_task(task_id: str) -> dict:
"""Поставить задачу на паузу."""
_task_or_raise(task_id)
store.execute(
"UPDATE disc_tasks SET status = 'paused', updated_at = ? WHERE id = ?",
[_now(), task_id],
)
return get_task(task_id) # type: ignore[return-value]
def bump_counter(task_id: str, field: str, n: int = 1) -> None:
"""Увеличить счётчик задачи: found|evaluated|joined|rejected."""
if field not in ("found", "evaluated", "joined", "rejected"):
raise ValueError(f"Неизвестный счётчик задачи: {field}")
row = _task_or_raise(task_id)
row[field] = int(row[field]) + max(0, int(n))
store.execute(
f"UPDATE disc_tasks SET {field} = ?, updated_at = ? WHERE id = ?",
[row[field], _now(), task_id],
)
def advance_search(task_id: str) -> None:
"""Перейти к следующему ключу; когда search_idx >= len(keywords) — search_done=True."""
row = _task_or_raise(task_id)
keywords = _loads(row["keywords"])
new_idx = int(row["search_idx"]) + 1
done = new_idx >= len(keywords)
store.execute(
"UPDATE disc_tasks SET search_idx = ?, search_done = ?, updated_at = ? WHERE id = ?",
[new_idx, done, _now(), task_id],
)
# ─── кандидаты ─────────────────────────────────────────────────────────────
def list_candidates(task_id: str, status: str | None = None) -> list[dict]:
"""Кандидаты задачи (marks/topics уже списки); status — фильтр."""
if status:
rows = store.query(
"SELECT * FROM disc_candidates WHERE task_id = ? AND status = ? ORDER BY created_at ASC",
[task_id, status],
)
else:
rows = store.query(
"SELECT * FROM disc_candidates WHERE task_id = ? ORDER BY created_at ASC",
[task_id],
)
return [_candidate_view(r) for r in rows]
def _get_candidate(dialog_id: str) -> dict | None:
row = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
return row
def _skip(task_id: str, reason: str) -> None:
add_log(task_id, "skip", reason)
def add_candidate(task_id: str, dialog_id: str, name: str, username: str, kind: str, hue: str) -> dict | None:
"""Добавить найденный источник как кандидата задачи.
None (с логом skip), если источник уже мониторится (есть в dialogs), в
чёрном списке или уже добавлен в статусе new/review/joined. Прежняя запись
со статусом rejected (например, после remove_blacklist) заменяется новой.
"""
_task_or_raise(task_id)
dialog_id = str(dialog_id)
if store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id]):
_skip(task_id, f"пропущен {dialog_id}: источник уже мониторится (мы состоим)")
return None
if store.scalar("SELECT 1 FROM disc_blacklist WHERE dialog_id = ? LIMIT 1", [dialog_id]):
_skip(task_id, f"пропущен {dialog_id}: источник в чёрном списке")
return None
existing = store.query_one(
"SELECT status FROM disc_candidates WHERE dialog_id = ?",
[dialog_id],
)
if existing and existing["status"] in _ACTIVE_CANDIDATE:
_skip(task_id, f"пропущен {dialog_id}: кандидат уже есть (статус {existing['status']})")
return None
if existing:
# устаревшая rejected-запись: перезаписываем как новый кандидат
store.execute("DELETE FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
now = _now()
store.execute(
"INSERT INTO disc_candidates(dialog_id, task_id, name, username, kind, hue, participants, "
"lang_ru, marks, topics, fit_ratio, status, auto_joined, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, '[]', '[]', NULL, 'new', FALSE, ?, ?)",
[
dialog_id,
task_id,
str(name or "").strip() or dialog_id,
str(username or "").strip(),
str(kind or "channel"),
str(hue or "#666"),
now,
now,
],
)
bump_counter(task_id, "found")
row = _get_candidate(dialog_id)
return _candidate_view(row) if row else None
def set_candidate(task_id: str, dialog_id: str, patch: dict) -> dict:
"""Обновить поля кандидата задачи (например, по результатам оценки).
patch — значения в нотации кандидата (camelCase или snake_case):
participants, kind, langRu/lang_ru, marks, topics, fitRatio/fit_ratio,
autoJoined/auto_joined, name, username, hue.
"""
_task_or_raise(task_id)
row = _get_candidate(dialog_id)
if row is None or row["task_id"] != task_id:
raise KeyError(dialog_id)
cols: dict = {}
for key, value in patch.items():
col = _CANDIDATE_FIELDS.get(key)
if col is None:
continue
cols[col] = value
if not cols:
return _candidate_view(row)
if "marks" in cols:
cols["marks"] = _json([str(m) for m in cols["marks"]])
if "topics" in cols:
cols["topics"] = _json(cols["topics"])
for col in ("name", "username", "kind", "hue"):
if col in cols:
cols[col] = str(cols[col] or "").strip() or row[col]
if "participants" in cols and cols["participants"] is not None:
cols["participants"] = int(cols["participants"])
sets = ["updated_at = ?"]
params: list = [_now()]
for col, value in cols.items():
sets.append(f"{col} = ?")
params.append(value)
params.append(dialog_id)
store.execute(f"UPDATE disc_candidates SET {', '.join(sets)} WHERE dialog_id = ?", params)
updated = _get_candidate(dialog_id)
return _candidate_view(updated) if updated else _candidate_view(row)
def set_candidate_status(dialog_id: str, status: str) -> dict:
"""Перевести кандидата в new/review (+ лог review).
joined/rejected меняются только через mark_joined()/mark_rejected() —
там счётчики задачи, чёрный список и лог join/reject.
"""
if status not in ("new", "review"):
raise ValueError(f"Статус {status} выставляется через mark_joined/mark_rejected")
row = _get_candidate(dialog_id)
if row is None:
raise KeyError(dialog_id)
store.execute(
"UPDATE disc_candidates SET status = ?, updated_at = ? WHERE dialog_id = ?",
[status, _now(), dialog_id],
)
if status == "review":
add_log(row["task_id"], "review", f"кандидат {dialog_id} переведён в review")
updated = _get_candidate(dialog_id)
return _candidate_view(updated) if updated else _candidate_view(row)
def delete_candidate(dialog_id: str) -> None:
"""Удалить кандидата (skip-ветки воркера; повторный вызов безопасен)."""
store.execute("DELETE FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
def mark_joined(dialog_id: str, auto: bool) -> dict:
"""Источник вступил: status=joined, счётчик joined задачи, лог join_auto/join_manual."""
row = _get_candidate(dialog_id)
if row is None:
raise KeyError(dialog_id)
if row["status"] == "joined":
return _candidate_view(row) # идемпотентно: повторно не считаем
now = _now()
store.execute(
"UPDATE disc_candidates SET status = 'joined', auto_joined = ?, updated_at = ? WHERE dialog_id = ?",
[bool(auto), now, dialog_id],
)
bump_counter(row["task_id"], "joined")
add_log(row["task_id"], "join_auto" if auto else "join_manual", f"вступили в {dialog_id}")
updated = _get_candidate(dialog_id)
return _candidate_view(updated) if updated else _candidate_view(row)
def mark_rejected(dialog_id: str, reason: str = "") -> dict:
"""Отклонить кандидата: status=rejected, счётчик rejected, лог reject, чёрный список.
Повторный вызов для уже отклонённого — идемпотентен: счётчик/лог/чёрный
список не трогаются (кандидата мог отклонить и человек, и воркер).
"""
row = _get_candidate(dialog_id)
if row is None:
raise KeyError(dialog_id)
if row["status"] == "joined":
raise ValueError("Нельзя отклонить источник, в который уже вступили")
if row["status"] == "rejected":
return _candidate_view(row) # идемпотентно: повторно не считаем и не логируем
now = _now()
store.execute(
"UPDATE disc_candidates SET status = 'rejected', updated_at = ? WHERE dialog_id = ?",
[now, dialog_id],
)
bump_counter(row["task_id"], "rejected")
add_log(row["task_id"], "reject", reason or f"отклонён {dialog_id}")
add_blacklist(dialog_id, row["name"], reason or "")
updated = _get_candidate(dialog_id)
return _candidate_view(updated) if updated else _candidate_view(row)
# ─── чёрный список ─────────────────────────────────────────────────────────
def add_blacklist(dialog_id: str, name: str = "", reason: str = "") -> dict:
"""Пометить источник в чёрном списке (существующая запись обновляется)."""
dialog_id = str(dialog_id)
now = _now()
store.execute(
"INSERT INTO disc_blacklist(dialog_id, name, reason, created_at) VALUES (?, ?, ?, ?) "
"ON CONFLICT(dialog_id) DO UPDATE SET name = excluded.name, reason = excluded.reason",
[dialog_id, str(name or "").strip() or dialog_id, str(reason or ""), now],
)
row = store.query_one("SELECT * FROM disc_blacklist WHERE dialog_id = ?", [dialog_id])
return _blacklist_view(row) if row else {"dialogId": dialog_id, "name": name, "reason": reason, "createdAt": now}
def remove_blacklist(dialog_id: str) -> None:
store.execute("DELETE FROM disc_blacklist WHERE dialog_id = ?", [dialog_id])
def list_blacklist() -> list[dict]:
return [
_blacklist_view(r)
for r in store.query("SELECT * FROM disc_blacklist ORDER BY created_at DESC")
]
# ─── лог ───────────────────────────────────────────────────────────────────
def add_log(task_id: str, event: str, text: str = "") -> None:
store.execute(
"INSERT INTO disc_log(id, task_id, event, text, created_at) VALUES (?, ?, ?, ?, ?)",
[store.uid(_ID_LOG), task_id, str(event), str(text or ""), _now()],
)
def task_log(task_id: str, limit: int = 100) -> list[dict]:
"""Последние события задачи, новые сверху."""
limit = max(1, min(500, int(limit)))
rows = store.query(
"SELECT * FROM disc_log WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
[task_id, limit],
)
return [_log_view(r) for r in rows]
@@ -0,0 +1,237 @@
"""Discovery: оценка контента — язык, темы, fit сообщений под задачу поиска.
Чистая логика оценки (без карточек, очередей и обучения):
* detect_lang_ru — доля кириллических букв среди всех букв выборки;
* group_by_topic — группировка выборки по topic_id (None -> "main")
для форумов: {topic_id, title, messages: [...]};
* evaluate_message — fit одного сообщения под задачу: каскад
короткое -> ML-спам -> ИИ -> эвристика;
* evaluate_sample — агрегат по выборке сообщений кандидата;
* passed — вердикт «источник подходит» (объём выборки + доля fit).
Каскад оценки сообщения (evaluate_message):
1) текст пустой/короче 10 символов -> False «слишком короткое»;
2) ML: ml_client.is_enabled() и прогноз take + label=='spam' -> False;
3) ИИ: если включён (aiEnabled) и доступен ключ/локальный провайдер — один
JSON-вызов ai_service.chat_json({fit, reason}); любая ошибка (нет ключа,
сеть, не-JSON) ловится и оценка продолжается эвристикой;
4) эвристика: любой ключ задачи входит в clean_short(text).casefold().
task — внешний dict в camelCase (конвенция discovery.Task 3): description,
keywords (list[str]), lang, threshold, sampleSize. Иные ключи игнорируются,
поэтому сюда можно передавать и полный view задачи из discovery.get_task.
"""
from __future__ import annotations
import logging
from ..db import store
from . import ai as ai_service
from . import ml_client
from .pipeline import clean_short
log = logging.getLogger("leadradar.discovery_eval")
# пороги доли кириллицы (задача ru-языка): >= hi -> True, <= lo -> False
_RU_RATIO_HI = 0.15
_RU_RATIO_LO = 0.03
# минимальная длина сообщения для содержательной оценки
_MIN_TEXT_LEN = 10
# ограничение текста, уходящего провайдеру (в одном сообщении больше не нужно)
_AI_TEXT_LIMIT = 4000
# потолок причины из ИИ (в UI не нужны простыни)
_AI_REASON_LIMIT = 200
# длина title темы (сниппет первого текста)
_TITLE_LIMIT = 60
# topic_id=None в выборке/группировке -> общая тема ("main")
_MAIN_TOPIC = "main"
# промпт ИИ-оценки (задача: относится ли сообщение к сфере/описанию)
_AI_PROMPT = (
"Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. "
"Ключи: {keywords}. "
"Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}."
)
def _is_cyrillic(ch: str) -> bool:
"""Кириллица ли символ (базовый блок U+0400–U+04FF, включает ё/ў и т.п.)."""
return 0x0400 <= ord(ch) <= 0x04FF
def detect_lang_ru(texts: list[str]) -> bool | None:
"""Доля кириллицы среди всех букв выборки: >=0.15 True, <=0.03 False, иначе None.
None означает «неопределённо» — воркер не отсекает кандидата, а помечает
язык как неподтверждённый. Пустая выборка без букв тоже даёт None.
"""
letters = 0
cyr = 0
for t in texts or []:
for ch in str(t or ""):
if ch.isalpha():
letters += 1
if _is_cyrillic(ch):
cyr += 1
if letters == 0:
return None
ratio = cyr / letters
if ratio >= _RU_RATIO_HI:
return True
if ratio <= _RU_RATIO_LO:
return False
return None
def _topic_title(messages: list[dict]) -> str:
"""Сниппет первого непустого текста темы (<=60 симв., whitespace схлопнут)."""
for m in messages:
text = str(m.get("text") or "").strip()
if text:
text = " ".join(text.split())
return text[:_TITLE_LIMIT]
return ""
def group_by_topic(messages: list[dict]) -> list[dict]:
"""Группирует сообщения по topic_id (None -> "main"), сортирует группы по
числу сообщений (убыв.), порядок сообщений внутри группы — входной.
Возвращает [{"topic_id", "title", "messages": [...]}], где title — сниппет
первого текста темы (первое непустое сообщение во входном порядке).
"""
groups: dict[str, list[dict]] = {}
order: list[str] = []
for m in messages or []:
tid = m.get("topic_id")
key = _MAIN_TOPIC if tid is None else tid
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(m)
out = [
{"topic_id": key, "title": _topic_title(groups[key]), "messages": groups[key]}
for key in order
]
out.sort(key=lambda g: len(g["messages"]), reverse=True)
return out
def _keywords(task: dict) -> list[str]:
kws = task.get("keywords") or []
if isinstance(kws, str):
kws = [kws]
return [str(k).strip() for k in kws if str(k).strip()]
def _ai_usable() -> bool:
"""ИИ-ветка доступна: полный выключатель включён и есть ключ/локальный сервер.
Локальные OpenAI-совместимые (Ollama/LM Studio) работают без ключа, поэтому
для них проверка ключа не требуется. Если статус провайдера прочитать нельзя
(нет БД/настроек) — считаем ИИ недоступным, чтобы не дёргать сеть впустую.
"""
if not store.get_setting("aiEnabled"):
return False
try:
status = ai_service.provider_status()
except Exception as exc: # noqa: BLE001 — отсутствие статуса = ИИ недоступен
log.debug("discovery eval: статус ИИ недоступен (%s) — эвристика", exc)
return False
return bool(status.get("local") or status.get("keySet"))
def _heuristic(task: dict, text: str) -> dict:
"""Эвристика: ключ задачи входит в очищенный текст (без учёта регистра)."""
hay = clean_short(text).casefold()
for kw in _keywords(task):
if kw and kw.casefold() in hay:
return {"fit": True, "reason": f'совпал ключ "{kw}"', "source": "heuristic"}
return {"fit": False, "reason": "нет совпадений с ключами", "source": "heuristic"}
def _ai_prompt(task: dict) -> str:
description = str(task.get("description") or "").strip()
return _AI_PROMPT.format(description=description, keywords=", ".join(_keywords(task)))
def _ai_fit(out: dict) -> bool:
"""fit из JSON-ответа ИИ: 1/true/«да»-подобные -> True, иначе False."""
v = out.get("fit")
if isinstance(v, str):
s = v.strip().casefold()
return bool(s) and s not in {"0", "false", "no", "нет", "null", "none"}
return bool(v)
def _ai_reason(out: dict) -> str:
reason = str(out.get("reason") or "").strip()
if not reason:
reason = "подходит" if _ai_fit(out) else "не подходит"
return reason[:_AI_REASON_LIMIT]
async def evaluate_message(task: dict, text: str) -> dict:
"""Оценка fit одного сообщения. Возвращает {"fit", "reason", "source"}."""
raw = str(text or "")
if len(raw.strip()) < _MIN_TEXT_LEN:
return {"fit": False, "reason": "слишком короткое", "source": "heuristic"}
# ML: уверенный спам отсекаем без обращения к ИИ (когда ML доступен)
if ml_client.is_enabled():
pred = await ml_client.predict(raw)
if pred.get("take") and pred.get("label") == "spam":
return {"fit": False, "reason": "ML: спам", "source": "ml"}
# ИИ: один JSON-вызов; любая ошибка провайдера -> шаг эвристики ниже
if _ai_usable():
try:
out = await ai_service.chat_json(_ai_prompt(task), f"Сообщение:\n{raw[:_AI_TEXT_LIMIT]}")
return {"fit": _ai_fit(out), "reason": _ai_reason(out), "source": "ai"}
except Exception as exc: # noqa: BLE001 — сбой ИИ не роняет оценку
log.debug("discovery eval: ИИ не ответил (%s) — эвристика", exc)
return _heuristic(task, raw)
async def evaluate_sample(task: dict, messages: list[dict]) -> dict:
"""Последовательная оценка всех сообщений выборки кандидата.
Возвращает {"fit_count", "total", "fit_ratio", "per_message": [...]} с
per_message [{"text", "fit", "reason", "topic_id"}] (topic_id None -> "main",
чтобы per_message стыковался с ключами group_by_topic).
"""
per_message = []
fit_count = 0
for m in messages or []:
text = str(m.get("text") or "")
res = await evaluate_message(task, text)
tid = m.get("topic_id")
per_message.append(
{
"text": text,
"fit": bool(res["fit"]),
"reason": str(res["reason"]),
"topic_id": _MAIN_TOPIC if tid is None else tid,
}
)
if res["fit"]:
fit_count += 1
total = len(per_message)
return {
"fit_count": fit_count,
"total": total,
"fit_ratio": (fit_count / total) if total else 0.0,
"per_message": per_message,
}
def passed(ev: dict, task: dict) -> bool:
"""Вердикт «источник подходит»: выборки >=3 сообщений и доля fit >= threshold (%).
Сообщения не обязаны быть «чистыми»: каналы часто разбавляют полезный
контент офтопом, поэтому достаточно доли, а не сплошного соответствия.
"""
total = int(ev.get("total") or 0)
ratio = float(ev.get("fit_ratio") or 0.0)
return total >= 3 and ratio * 100 >= int(task.get("threshold") or 0)
@@ -0,0 +1,484 @@
"""Discovery worker: поиск → оценка → авто-вступление (Task 6).
Фоновый цикл main._discovery_loop вызывает `tick()` каждые ~5 секунд; каждый
вызов выполняет ОДНО действие для самой старой running-задачи и возвращает
{"action": "search"|"review"|"skip"|"join"|"reject"|"flood"|"error"|"done"|"none",
"taskId": ...}. Если работы нет — {"action": "none"}.
Приоритеты внутри tick:
0. ban_guard.global_paused() — ручной стоп-кран: возвращаем none;
1. задача достигла плана вступлений (joined >= planJoins) → status=done
(+ лог done) — занимаемый ею бюджет планов освобождается сразу;
2. шаг поиска: search_done=False → следующий ключ keywords[search_idx],
tg.discovery_search, каждый результат — discovery.add_candidate,
discovery.advance_search; при переходе search_done=True — лог search
«поиск завершён: N кандидатов» (N = счётчик found задачи). Личные чаты/
боты (kind «чат») пропускаются (лог skip). FloodWaitError → note_flood +
лог flood; прочие ошибки поиска → лог error; индекс ключей не двигается
(тик повторит ключ позже), но после 3 ошибок подряд ключ пропускается
(advance_search + лог error «ключ пропущен») — битый ключ не должен
зацикливать поиск навсегда;
2a. флуд-стоп: пока действует flood-блокировка дня (ban_guard.flood_today())
discovery-воркер полностью стоит (никаких сетевых действий поиска/оценки/
вступлений) — это и есть «стоп до конца суток» из лога flood;
3. шаг оценки: первый кандидат status='new' → tg.discovery_info (участники,
kind; форум — если is_forum), фильтр minSubscribers (меньше — delete +
лог skip; не получено — метка), tg.discovery_read: история недоступна →
review с меткой («канал…»/«закрытая группа…», контент не оцениваем),
язык (для ru-задач), evaluate_sample (форумы — по темам через
group_by_topic) → passed → review с fitRatio/topics, иначе
delete_candidate + лог skip «мало подходящих (X из N)»;
4. авто-вступление (отдельный проход, приоритет ниже оценки): у running-
задачи с autoJoin и кандидатом status='review', если
ban_guard.can_auto_join() → повторная проверка «мы не состоим»
(dialogs/disc_blacklist — могли вступить между оценкой и join) → если
уже состоим/в чёрном списке — discovery.mark_rejected + лог;
ban_guard.wait_join_delay() (5070 с — спейсинг авто-вступлений), ПОСЛЕ
паузы кандидат перечитывается — join выполняется, только если запись
ещё есть и в review, задача ещё running с autoJoin и мы не состоим
(иначе — тик выходит без join);
tg.discovery_join(username) → discovery.mark_joined(auto=True) →
tg.add_dialog_monitored(...) → tg.backfill_dialog(dialog_id) →
discovery.remove_blacklist. FloodWaitError → ban_guard.note_flood()
+ лог flood (кандидат остаётся review); прочие ошибки join →
join_failures += 1, лог error, кандидат остаётся review для повтора
(ретраи ограничены: после 3-й неудачи кандидат удаляется, лог skip).
Метки кандидата (marks) — список строк-чипов:
«участники не подтверждены», «язык не подтверждён»,
«канал: история недоступна», «закрытая группа (история скрыта) — вступите сами»,
«мало сообщений».
Темы форума (topics) — список dict (заполняется только для kind='forum'):
{"topicId": str|int, "title": str, "fitCount": int, "total": int,
"fitRatio": float, "passed": bool}
fit каждой темы считается по своей выборке (evaluate_sample + passed);
общий вердикт форума — есть хотя бы одна проходная тема. fitRatio кандидата
— агрегат по всей выборке (fit из N). Для не-форумов topics не заполняется.
"""
from __future__ import annotations
import logging
import time
from contextlib import suppress
from telethon.errors.rpcerrorlist import FloodWaitError
from ..db import store
from . import ban_guard, discovery
from . import discovery_eval as eval_svc
from .telegram import tg
log = logging.getLogger("leadradar.discovery_worker")
_ACTION_NONE = {"action": "none"}
# минимальный объём содержательной выборки для вердикта оценки
_MIN_CONTENT = 3
# ошибки поиска одного ключа подряд, после которых ключ пропускается
_SEARCH_ERRORS_TO_SKIP = 3
# счётчик ошибок поиска по задачам (память процесса; при рестарте сбрасывается)
_search_errors: dict[str, int] = {}
# метки кандидата (marks) — чипы в UI
_MARK_PARTICIPANTS = "участники не подтверждены"
_MARK_LANG = "язык не подтверждён"
_MARK_CHANNEL_NO_HISTORY = "канал: история недоступна"
_MARK_CLOSED_GROUP = "закрытая группа (история скрыта) — вступите сами"
_MARK_FEW_MESSAGES = "мало сообщений"
# kind из Telegram (_kind_of: канал/группа/чат) → код кандидата (channel/group/forum)
_KIND_RU_TO_CODE = {"канал": "channel", "группа": "group", "чат": "group"}
def _now_ms() -> int:
return time.time_ns() // 1_000_000
def _kind_code(kind: str, is_forum: bool = False) -> str:
"""Нормализация kind источника: 'channel'|'group'|'forum' (см. _KIND_RU_TO_CODE)."""
if is_forum:
return "forum"
code = str(kind or "").strip().casefold()
if code in ("channel", "group", "forum"):
return code
return _KIND_RU_TO_CODE.get(code, "group")
def _running_tasks() -> list[dict]:
"""Running-задачи, старые первыми (list_tasks сортирует по created_at)."""
return [t for t in discovery.list_tasks() if t["status"] == "running"]
def _we_are_in(dialog_id: str) -> bool:
"""Уже состоим/отклонили: источник в dialogs или в чёрном списке.
Повторная проверка «мы не состоим» перед авто-вступлением (диалог мог
появиться между оценкой кандидата и join). dialog_id — подписанный peer id,
как в dialogs.id (конвенция discovery_search, Task 4).
"""
in_dialogs = store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id])
in_blacklist = store.scalar("SELECT 1 FROM disc_blacklist WHERE dialog_id = ? LIMIT 1", [dialog_id])
return bool(in_dialogs or in_blacklist)
def _finish_done(task: dict) -> None:
"""Задача выполнила план вступлений: status=done + лог done."""
store.execute(
"UPDATE disc_tasks SET status = 'done', updated_at = ? WHERE id = ?",
[_now_ms(), task["id"]],
)
discovery.add_log(
task["id"],
"done",
f"план выполнен: вступили {task['joined']} из {task['planJoins']}",
)
def _close_search(task_id: str) -> None:
"""Закрыть проход по ключам (пустой список ключей / индекс за границей)."""
discovery.advance_search(task_id)
task = discovery.get_task(task_id)
if task and task["searchDone"]:
discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов")
def _log_search_done(task_id: str) -> None:
"""Лог завершения поиска, если advance_search перевёл задачу в search_done."""
task = discovery.get_task(task_id)
if task and task["searchDone"]:
discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов")
def _finish_review(
task_id: str,
dialog_id: str,
*,
marks: list[str],
lang_ru: bool | None = None,
fit_ratio: float | None = None,
topics: list[dict] | None = None,
) -> None:
"""Перевести кандидата в review с метками/оценкой (статус пишет лог review)."""
patch: dict = {"marks": [m for m in marks if m]}
if lang_ru is not None:
patch["langRu"] = lang_ru
if fit_ratio is not None:
patch["fitRatio"] = fit_ratio
if topics is not None:
patch["topics"] = topics
discovery.set_candidate(task_id, dialog_id, patch)
discovery.set_candidate_status(dialog_id, "review")
# ─── шаги tick ─────────────────────────────────────────────────────────────
async def _search_step(task: dict) -> dict:
"""Шаг поиска: один ключ keywords[searchIdx] → кандидаты + advance_search."""
task_id = task["id"]
keywords = list(task.get("keywords") or [])
idx = int(task.get("searchIdx") or 0)
if not keywords or idx >= len(keywords):
# ключи закончились/пустой список: закрываем проход без сетевого вызова
_close_search(task_id)
return {"action": "search", "taskId": task_id}
keyword = keywords[idx]
try:
results = await tg.discovery_search(keyword)
except FloodWaitError:
# флуд: стоп авто-вступлений до конца суток; ключ не двигаем — повторим позже
ban_guard.note_flood()
discovery.add_log(task_id, "flood", f"поиск «{keyword}»: flood — стоп до конца суток")
return {"action": "flood", "taskId": task_id}
except Exception as exc: # noqa: BLE001 — сбой поиска не двигает индекс ключей
errors = _search_errors.get(task_id, 0) + 1
if errors >= _SEARCH_ERRORS_TO_SKIP:
# 3 ошибки подряд одного ключа: пропускаем (битый ключ не должен
# зацикливать поиск и блокировать оценку/вступления других задач)
_search_errors.pop(task_id, None)
discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc} — ключ пропущен ({errors} ошибки подряд)")
discovery.advance_search(task_id)
_log_search_done(task_id)
return {"action": "error", "taskId": task_id}
_search_errors[task_id] = errors
discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc}")
return {"action": "error", "taskId": task_id}
_search_errors.pop(task_id, None) # успешный поиск — сброс счётчика ошибок ключа
for item in results:
kind_raw = str(item.get("kind") or "").strip().casefold()
name = item.get("name") or ""
if kind_raw == "чат":
# люди/личные чаты и боты глобальным поиском не предлагаются
discovery.add_log(task_id, "skip", f"{name}: личный чат/бот")
continue
discovery.add_candidate(
task_id,
str(item.get("id") or ""),
name,
item.get("username") or "",
_kind_code(kind_raw),
item.get("hue") or "#666",
)
discovery.advance_search(task_id)
_log_search_done(task_id)
return {"action": "search", "taskId": task_id}
async def _eval_step(task: dict, cand: dict) -> dict:
"""Шаг оценки первого кандидата status='new' (все ветки — одно действие)."""
task_id = task["id"]
dialog_id = cand["dialogId"]
marks: list[str] = []
# ── инфо об источнике: kind/forum, участники, имя/username ────────────
info = await tg.discovery_info(dialog_id)
is_forum = bool(info.get("is_forum"))
resolved = info.get("kind") or is_forum
kind = _kind_code(info.get("kind", ""), is_forum) if resolved else (cand["kind"] or "channel")
cand_patch: dict = {
"kind": kind,
"hue": info.get("hue") or "",
"participants": info.get("participants"),
}
if resolved:
# имя/username обновляем только при успешном резолве: при fallback
# discovery_info возвращает name=dialog_id и не должен затирать имя
cand_patch["name"] = info.get("name") or ""
cand_patch["username"] = info.get("username") or ""
discovery.set_candidate(task_id, dialog_id, cand_patch)
participants = info.get("participants")
# ── фильтр minSubscribers ──────────────────────────────────────────────
min_sub = int(task.get("minSubscribers") or 0)
if min_sub > 0:
if participants is None:
marks.append(_MARK_PARTICIPANTS)
elif int(participants) < min_sub:
discovery.delete_candidate(dialog_id)
discovery.add_log(
task_id,
"skip",
f"{dialog_id}: мало участников ({participants} < {min_sub})",
)
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
# ── чтение истории для оценки ──────────────────────────────────────────
read = await tg.discovery_read(dialog_id, int(task.get("sampleSize") or 10))
if not read.get("ok"):
# история недоступна без членства: контент не оцениваем, фильтры помечаем
marks.append(_MARK_CHANNEL_NO_HISTORY if kind == "channel" else _MARK_CLOSED_GROUP)
if task.get("lang") == "ru":
marks.append(_MARK_LANG)
_finish_review(task_id, dialog_id, marks=marks)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
messages = read.get("messages") or []
# ── язык (только для ru-задач) ─────────────────────────────────────────
lang_ru: bool | None = None
if task.get("lang") == "ru":
lang_ru = eval_svc.detect_lang_ru([str(m.get("text") or "") for m in messages])
if lang_ru is False:
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: язык не русский")
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
if lang_ru is None:
marks.append(_MARK_LANG)
# ── объём выборки: меньше 3 содержательных — решает человек ────────────
if len(messages) < _MIN_CONTENT:
marks.append(_MARK_FEW_MESSAGES)
_finish_review(task_id, dialog_id, marks=marks, lang_ru=lang_ru)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
# ── оценка содержания (форумы — по темам) ──────────────────────────────
fit_count, total, fit_ratio, topics, ok = await _evaluate_content(task, kind, messages)
if ok:
_finish_review(
task_id,
dialog_id,
marks=marks,
lang_ru=lang_ru,
fit_ratio=fit_ratio,
topics=topics if kind == "forum" else None,
)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: мало подходящих ({fit_count} из {total})")
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
async def _evaluate_content(task: dict, kind: str, messages: list[dict]) -> tuple[int, int, float, list[dict], bool]:
"""evaluate_sample по выборке кандидата.
Не-форум: один прогон по всем сообщениям, topics пуст, вердикт — passed().
Форум: прогон по каждой теме (group_by_topic), topics заполняется
({topicId, title, fitCount, total, fitRatio, passed}), вердикт — есть хотя
бы одна проходная тема; fitRatio — агрегат fit из N по всей выборке.
"""
if kind == "forum":
topics: list[dict] = []
fit_count = 0
total = 0
any_passed = False
for group in eval_svc.group_by_topic(messages):
ev = await eval_svc.evaluate_sample(task, group["messages"])
t_ok = eval_svc.passed(ev, task)
fit_count += int(ev.get("fit_count") or 0)
total += int(ev.get("total") or 0)
topics.append(
{
"topicId": group["topic_id"],
"title": group["title"] or "",
"fitCount": int(ev.get("fit_count") or 0),
"total": int(ev.get("total") or 0),
"fitRatio": float(ev.get("fit_ratio") or 0.0),
"passed": bool(t_ok),
}
)
any_passed = any_passed or bool(t_ok)
return fit_count, total, (fit_count / total) if total else 0.0, topics, any_passed
ev = await eval_svc.evaluate_sample(task, messages)
return (
int(ev.get("fit_count") or 0),
int(ev.get("total") or 0),
float(ev.get("fit_ratio") or 0.0),
[],
eval_svc.passed(ev, task),
)
async def _join_step(task: dict, cand: dict) -> dict:
"""Шаг авто-вступления одного кандидата status='review'."""
task_id = task["id"]
dialog_id = cand["dialogId"]
# между оценкой и вступлением могли вступить/отклонить источник
if _we_are_in(dialog_id):
already = bool(store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id]))
reason = (
"уже вступили между оценкой и авто-вступлением"
if already
else "источник в чёрном списке (повторная проверка перед авто-вступлением)"
)
with suppress(KeyError, ValueError):
discovery.mark_rejected(dialog_id, reason=reason)
return {"action": "reject", "taskId": task_id}
# спейсинг авто-вступлений (сек из настроек discJoinDelayMin/Max)
await ban_guard.wait_join_delay()
# за время паузы задача/кандидат/состояние BanGuard могли измениться:
# вступаем только если кандидат всё ещё есть и в review, задача ещё running
# с autoJoin, мы не состоим и авто-вступления по-прежнему разрешены (стоп-
# кран/flood/лимит могли включиться во время паузы) — иначе выходим без join
fresh = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
task_now = discovery.get_task(task_id)
if (
fresh is None
or fresh["status"] != "review"
or task_now is None
or task_now["status"] != "running"
or not task_now["autoJoin"]
or _we_are_in(dialog_id)
or not ban_guard.can_auto_join()
):
return _ACTION_NONE
username = str(fresh.get("username") or "").strip().lstrip("@")
try:
await tg.discovery_join(username)
except FloodWaitError:
ban_guard.note_flood() # идемпотентно: discovery_join тоже фиксирует флуд
discovery.add_log(task_id, "flood", f"авто-вступление {dialog_id}: flood — стоп до конца суток")
return {"action": "flood", "taskId": task_id}
except Exception as exc: # noqa: BLE001 — ретраи ограничены счётчиком join_failures
# между паузой и неудачным join кандидата могли отклонить/удалить:
# счётчик и удаление трогаем только у живой записи в статусе review
row_now = store.query_one(
"SELECT status FROM disc_candidates WHERE dialog_id = ?", [dialog_id]
)
if not row_now or row_now["status"] != "review":
return _ACTION_NONE
failures = int(fresh.get("join_failures") or 0) + 1
store.execute(
"UPDATE disc_candidates SET join_failures = ?, updated_at = ? "
"WHERE dialog_id = ? AND status = 'review'",
[failures, _now_ms(), dialog_id],
)
if failures >= 3:
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: не удалось вступить (3 попытки): {exc}")
return {"action": "skip", "taskId": task_id}
discovery.add_log(task_id, "error", f"авто-вступление {dialog_id}: {exc}")
return {"action": "error", "taskId": task_id}
discovery.mark_joined(dialog_id, auto=True)
tg.add_dialog_monitored(
dialog_id,
fresh.get("name"),
username,
fresh.get("kind"),
fresh.get("hue"),
)
# разбор последних сообщений источника (спейсинг/read-ack внутри метода);
# вступление уже состоялось — сбой backfill не роняет шаг
try:
await tg.backfill_dialog(dialog_id)
except Exception as exc: # noqa: BLE001
log.warning("join %s: backfill не удался: %s", dialog_id, exc)
discovery.remove_blacklist(dialog_id)
return {"action": "join", "taskId": task_id}
# ─── tick ──────────────────────────────────────────────────────────────────
async def tick() -> dict:
"""Одно действие discovery-воркера (см. docstring модуля)."""
if ban_guard.global_paused():
return _ACTION_NONE
if ban_guard.flood_today():
# флуд-блокировка дня: никаких сетевых действий (поиск/оценка/join),
# пока действует discFloodDay — воркер просто стоит
return _ACTION_NONE
running = _running_tasks()
if not running:
return _ACTION_NONE
# 1. план достигнут — закрываем задачу (важно до поиска/оценки/join:
# задачу с выполненным планом нельзя продолжать обрабатывать)
for task in running:
if int(task["joined"]) >= int(task["planJoins"]):
_finish_done(task)
return {"action": "done", "taskId": task["id"]}
# 2. поиск: следующая running-задача с незавершённым проходом по ключам
for task in running:
if not task["searchDone"]:
return await _search_step(task)
# 3. оценка: первый кандидат status='new' (самая старая задача — первой)
for task in running:
new_cands = discovery.list_candidates(task["id"], status="new")
if new_cands:
return await _eval_step(task, new_cands[0])
# 4. авто-вступление: отдельный проход, приоритет ниже оценки
for task in running:
if not task["autoJoin"]:
continue
review_cands = discovery.list_candidates(task["id"], status="review")
if review_cands:
if not ban_guard.can_auto_join():
return _ACTION_NONE # суточный лимит/флуд/пауза — join никому нельзя
return await _join_step(task, review_cands[0])
return _ACTION_NONE
@@ -0,0 +1,100 @@
"""Вложения проектных карточек.
По решению: файлы хранятся в MinIO (креды в env), в БД — метаданные и ключ
объекта. Тип определяется автоматически по MIME и расширению.
"""
from __future__ import annotations
import json
import time
from ..db import store
KIND_BY_EXT = {
"image": {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "heic"},
"video": {"mp4", "mov", "avi", "mkv", "webm", "m4v"},
"audio": {"mp3", "wav", "ogg", "m4a", "flac", "aac"},
"archive": {"zip", "rar", "7z", "tar", "gz", "bz2"},
"document": {"pdf", "doc", "docx", "xls", "xlsx", "csv", "txt", "md", "rtf", "ppt", "pptx", "odt", "ods"},
}
KIND_LABELS = {
"image": "Изображение",
"video": "Видео",
"audio": "Аудио",
"archive": "Архив",
"document": "Документ",
"other": "Файл",
}
def detect(name: str, mime: str = "") -> dict:
ext = (name.split(".")[-1] if "." in name else "").lower()
kind = "other"
if mime.startswith("image/"):
kind = "image"
elif mime.startswith("video/"):
kind = "video"
elif mime.startswith("audio/"):
kind = "audio"
else:
for k, exts in KIND_BY_EXT.items():
if ext in exts:
kind = k
break
return {"kind": kind, "label": KIND_LABELS[kind]}
def _files_of(card_id: str) -> list[dict]:
from .projects import patch_card
row = store.query_one("SELECT files FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
return json.loads(row["files"] or "[]")
def add_file(card_id: str, name: str, data: bytes, mime: str = "") -> dict:
"""Сохраняет тело в MinIO и метаданные в карточку."""
from . import object_store
from .projects import patch_card
files = _files_of(card_id)
info = detect(name, mime)
object_key = object_store.put(card_id, name, data, mime)
entry = {
"id": store.uid("pf_"),
"name": name,
"size": len(data),
"kind": info["kind"],
"label": info["label"],
"objectKey": object_key,
}
files.append(entry)
patch_card(card_id, {"files": files})
return entry
def get_file_entry(card_id: str, file_id: str) -> tuple[dict, list[dict]]:
files = _files_of(card_id)
entry = next((f for f in files if f["id"] == file_id), None)
if not entry:
raise KeyError(file_id)
return entry, files
def remove_file(card_id: str, file_id: str) -> None:
from . import object_store
from .projects import patch_card
files = _files_of(card_id)
entry = next((f for f in files if f["id"] == file_id), None)
if entry and entry.get("objectKey"):
object_store.remove(entry["objectKey"])
patch_card(card_id, {"files": [f for f in files if f["id"] != file_id]})
def object_storage_available() -> bool:
from . import object_store
return object_store.configured()
@@ -0,0 +1,103 @@
"""Полнотекстовый поиск DuckDB FTS (по решению — включаем).
DuckDB FTS строит виртуальную таблицу-снимок: индекс пересоздаётся при
старте, раз в сутки по расписанию и вручную (POST /api/admin/fts/rebuild).
Чтобы свежесозданные карточки искались сразу, поиск совмещает FTS с
точечным LIKE-дополнением (см. services/leads.search).
Рабочий синтаксис DuckDB >= 1.0: только PRAGMA create_fts_index
(вариант CREATE VIRTUAL TABLE ... USING fts(...) в этой версии падает).
Поиск — через табличную функцию fts_main_<table>.match_bm25(rowid, query).
"""
from __future__ import annotations
import logging
from ..db import store
log = logging.getLogger("leadradar.fts")
OK_KEY = "ftsOk"
# Источник, колонка-rowid, текстовые колонки (должны совпадать со схемой db.py)
_FTS_TARGETS = [
("leads", ("title", "summary", "source_msg", "contact")),
("messages", ("text",)),
("rejected_msgs", ("text",)),
]
def is_ready() -> bool:
return bool(store.get_setting(OK_KEY))
def _set(ok: bool) -> None:
store.set_setting(OK_KEY, ok)
def install_extension() -> bool:
try:
store.execute("INSTALL fts")
store.execute("LOAD fts")
return True
except Exception as exc: # noqa: BLE001
log.warning("FTS extension unavailable: %s", exc)
return False
def rebuild() -> bool:
"""Пересоздаёт FTS-индексы по leads и messages (overwrite поверх старого).
PRAGMA не поддерживает prepared-параметры, поэтому имена подставляются
напрямую — это внутренние константы из _FTS_TARGETS, не пользовательский ввод.
"""
try:
for table, cols in _FTS_TARGETS:
col_list = ", ".join(f"'{c}'" for c in cols)
store.execute(
f"PRAGMA create_fts_index('{table}', 'id', {col_list}, "
"stemmer='russian', overwrite=1)"
)
_set(True)
log.info("FTS rebuild done")
return True
except Exception as exc: # noqa: BLE001
log.warning("FTS rebuild failed: %s", exc)
_set(False)
return False
def search(query: str, limit: int = 50) -> dict:
"""Возвращает {'leads': [ids], 'messages': [ids], 'rejected': [ids]} по FTS
(пусто при сбое)."""
out: dict = {"leads": [], "messages": [], "rejected": []}
if not is_ready():
return out
q = _sanitize(query)
if not q:
return out
targets = ("leads", "messages", "rejected_msgs")
keys = {"leads": "leads", "messages": "messages", "rejected_msgs": "rejected"}
for table in targets:
try:
# fts_main_<table> создаётся PRAGMA create_fts_index над таблицей
fn = f"fts_main_{table}.match_bm25(id, ?)"
rows = store.query(
f"SELECT id, {fn} AS _s FROM {table} "
f"WHERE {fn} IS NOT NULL ORDER BY _s LIMIT ?",
[q, q, limit * 2],
)
out[keys[table]] = [r["id"] for r in rows]
except Exception as exc: # noqa: BLE001
log.warning("fts %s query failed: %s", table, exc)
return out
def _sanitize(query: str) -> str:
"""Минимальная очистка запроса от служебных символов FTS."""
import re
q = query.strip().lower()
q = re.sub(r'["\'\\()!*+-]', " ", q)
q = " ".join(q.split())
return q[:80]
@@ -0,0 +1,551 @@
"""Доски, колонки и карточки дашборда (п.4.4, 4.5, 4.7 ТЗ).
Сюда же входят правила хранения (автоархив/очистка) и обучающие примеры
(действия пользователя -> learning_log).
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from .. import constants as C
from ..db import store
from ..sse import broker
from . import fts as fts_svc
from . import ml_client
from . import processing as processing_svc
from .pipeline import (
build_contacts,
clean_block,
clean_short,
compose_summary,
lead_to_dict,
normalize_stack,
primary_contact,
qualify_contact,
)
from .rules import board_accepts, extract_amounts, has_active_rules, hits_for_board
log = logging.getLogger("leadradar.leads")
KANBAN_COLS = ("inbox",) # + доски
def _now() -> int:
return time.time_ns() // 1_000_000
def _log_learning(lead_id: str, action: str, from_col: str | None, to_col: str | None) -> None:
store.execute(
"INSERT INTO learning_log(id, lead_id, action, from_col, to_col, created_at) VALUES (?, ?, ?, ?, ?, ?)",
[store.uid("lm_"), lead_id, action, from_col, to_col, _now()],
)
# ─── Доски / колонки ──────────────────────────────────────────────────────
def list_boards() -> list[dict]:
rows = store.query("SELECT * FROM boards ORDER BY suggested, pos")
return [
{
"id": r["id"],
"name": r["name"],
"description": r["description"] or "",
"color": r["color"],
"width": r["width"],
"collapsed": bool(r["collapsed"]),
"keywords": json.loads(r["keywords"] or "[]"),
"prompt": r["prompt"] or "",
"visibleFields": json.loads(r["visible_fields"] or "[]"),
"suggested": bool(r["suggested"]),
"rules": json.loads(r["rules"] or "{}"),
"note": r["note"] or "",
}
for r in rows
]
def board_by_id(board_id: str) -> dict | None:
return next((b for b in list_boards() if b["id"] == board_id), None)
def create_board(
name: str,
color: str | None = None,
keywords: list | None = None,
prompt: str = "",
description: str = "",
suggested: bool = False,
rules: dict | None = None,
note: str = "",
) -> dict:
"""Создаёт колонку. suggested=TRUE — ИИ-предложение, ждёт решения пользователя."""
board_id = store.uid("b_")
pos = int(store.scalar("SELECT COALESCE(MAX(pos), -1) + 1 FROM boards"))
store.execute(
"INSERT INTO boards(id, name, description, color, width, pos, keywords, prompt, visible_fields, collapsed, suggested, rules, note, created_at) "
"VALUES (?, ?, ?, ?, 'md', ?, ?, ?, '[\"budget\",\"stack\",\"contacts\"]', FALSE, ?, ?, ?, ?)",
[
board_id,
name.strip() or "Новая колонка",
(description or "").strip(),
color or C.PALETTE[pos % len(C.PALETTE)],
pos,
json.dumps(keywords or [], ensure_ascii=False),
prompt or "",
bool(suggested),
json.dumps(rules or {}, ensure_ascii=False),
note or "",
_now(),
],
)
return {"id": board_id}
def patch_board(board_id: str, patch: dict) -> dict:
row = store.query_one("SELECT * FROM boards WHERE id = ?", [board_id])
if not row:
raise KeyError(board_id)
allowed = {"name", "description", "color", "width", "collapsed", "prompt", "suggested", "note"}
for key in allowed:
if key in patch and patch[key] is not None:
store.execute(f"UPDATE boards SET {key} = ? WHERE id = ?", [patch[key], board_id])
if "keywords" in patch and patch["keywords"] is not None:
store.execute("UPDATE boards SET keywords = ? WHERE id = ?", [json.dumps(patch["keywords"], ensure_ascii=False), board_id])
if "visibleFields" in patch and patch["visibleFields"] is not None:
store.execute("UPDATE boards SET visible_fields = ? WHERE id = ?", [json.dumps(patch["visibleFields"], ensure_ascii=False), board_id])
if "rules" in patch and patch["rules"] is not None:
store.execute("UPDATE boards SET rules = ? WHERE id = ?", [json.dumps(patch["rules"], ensure_ascii=False), board_id])
return {"id": board_id}
def delete_board(board_id: str) -> int:
"""Карточки доски уходят в «Неразобранное» (с пометкой новых)."""
leads = store.query("SELECT id FROM leads WHERE col = ?", [board_id])
for l in leads:
store.execute("UPDATE leads SET col = 'inbox', is_new = TRUE, prev_col = 'inbox' WHERE id = ?", [l["id"]])
store.execute("DELETE FROM boards WHERE id = ?", [board_id])
return len(leads)
def reorder_boards(order: list[str]) -> None:
for i, board_id in enumerate(order):
store.execute("UPDATE boards SET pos = ? WHERE id = ?", [i, board_id])
def get_col_state() -> dict:
return store.get_setting("colState") or {}
def set_col_state(col_id: str, state: dict) -> dict:
current = store.get_setting("colState") or {}
current[col_id] = state
store.set_setting("colState", current)
return current[col_id]
# ─── Лиды ─────────────────────────────────────────────────────────────────
def list_leads(col: str | None = None) -> list[dict]:
if col:
rows = store.query("SELECT id FROM leads WHERE col = ? ORDER BY received_at DESC", [col])
else:
rows = store.query("SELECT id FROM leads WHERE col NOT IN ('taken') ORDER BY received_at DESC")
return [lead_to_dict(r["id"]) for r in rows]
def get_lead(lead_id: str) -> dict | None:
return lead_to_dict(lead_id) or None
def _move(lead_id: str, to_col: str, action: str = "move") -> None:
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
if not lead or lead["col"] == to_col:
return
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
# при переносе пересчитываем «почему карточка в колонке» (для архив/корзина — пусто)
hits = hits_for_board(to_col, text) if to_col not in ("inbox", "trash", "archive") else []
store.execute(
"UPDATE leads SET col = ?, is_new = FALSE, prev_col = ?, match_hits = ? WHERE id = ?",
[to_col, lead["col"], json.dumps(hits, ensure_ascii=False), lead_id],
)
_log_learning(lead_id, action, lead["col"], to_col)
def move_lead(lead_id: str, to_col: str, teach: bool = True) -> None:
"""Перенос между канбаном (Неразобранное и доски); архив/корзина не цели переноса.
teach=False — «тихое» перемещение без обучения (используется при ручной
разметке в ML-лаборатории, где обучение кладётся явно одним событием).
"""
if to_col not in ("inbox",) and store.query_one("SELECT 1 FROM boards WHERE id = ?", [to_col]) is None:
raise ValueError("Переносить можно только на доски или в «Неразобранное»")
lead = store.query_one("SELECT source_msg, title, col FROM leads WHERE id = ?", [lead_id])
_move(lead_id, to_col)
# ML обучается всегда: текст -> выбранная доска
if teach and lead and to_col != "inbox" and to_col != lead["col"]:
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
if text:
ml_client.push(text, to_col)
def trash_lead(lead_id: str, teach: bool = True) -> None:
lead = store.query_one("SELECT source_msg, title, col FROM leads WHERE id = ?", [lead_id])
_move(lead_id, "trash", action="trash")
# «в корзину» = спам/не то: ML запоминает (обучение всегда)
if teach and lead and lead["col"] != "trash" and lead["col"] != "archive":
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
if text:
ml_client.push(text, "spam")
def restore_lead(lead_id: str) -> str:
"""Возврат из архива/корзины — только на канбан."""
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
if not lead:
raise KeyError(lead_id)
back = lead["prev_col"] if lead["prev_col"] in ("inbox",) or store.query_one("SELECT 1 FROM boards WHERE id = ?", [lead["prev_col"]]) else "inbox"
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
hits = hits_for_board(back, text) if back not in ("inbox", "trash", "archive") else []
store.execute(
"UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox', archived_at = NULL, match_hits = ? WHERE id = ?",
[back, json.dumps(hits, ensure_ascii=False), lead_id],
)
_log_learning(lead_id, "restore", lead["col"], back)
# возврат из корзины = не спам: снимаем метку
if lead["col"] == "trash":
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
if text:
ml_client.push(text, "spam", delta=-1.0)
return back
def _hard_delete(lead_id: str) -> None:
"""Полное удаление карточки: leads + dedup (иначе «сирота» заблокирует
повторное создание той же карточки при перечитывании) + отвязка исходника."""
store.execute("DELETE FROM leads WHERE id = ?", [lead_id])
store.execute("DELETE FROM dedup WHERE lead_id = ?", [lead_id])
store.execute("UPDATE messages SET lead_id = NULL WHERE lead_id = ?", [lead_id])
def delete_forever(lead_id: str) -> None:
_hard_delete(lead_id)
def clear_col(col: str) -> int:
"""Полная ручная очистка служебной колонки (корзина/архив) — безвозвратно."""
if col not in ("trash", "archive"):
raise ValueError("Очищать можно только корзину или архив")
ids = [r["id"] for r in store.query("SELECT id FROM leads WHERE col = ?", [col])]
if not ids:
return 0
store.execute("DELETE FROM leads WHERE col = ?", [col])
for lead_id in ids:
_hard_delete(lead_id)
return len(ids)
def mark_seen(lead_id: str | None = None, col: str | None = None) -> None:
if lead_id:
store.execute("UPDATE leads SET is_new = FALSE WHERE id = ?", [lead_id])
elif col:
store.execute("UPDATE leads SET is_new = FALSE WHERE col = ?", [col])
else:
store.execute("UPDATE leads SET is_new = FALSE")
def add_comment(lead_id: str, text: str) -> list[dict]:
lead = store.query_one("SELECT comments FROM leads WHERE id = ?", [lead_id])
comments = json.loads(lead["comments"] or "[]")
comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"})
store.execute("UPDATE leads SET comments = ? WHERE id = ?", [json.dumps(comments, ensure_ascii=False), lead_id])
_log_learning(lead_id, "comment", None, None)
return comments
def counts() -> dict:
"""Счётчики по колонкам, новые + статистика ML/ИИ (локальная, без HTTP)."""
out = {"new": 0}
rows = store.query("SELECT col, count(*) AS cnt, sum(CASE WHEN is_new THEN 1 ELSE 0 END) AS fresh FROM leads GROUP BY col")
for r in rows:
out[r["col"]] = {"count": r["cnt"], "new": r["fresh"] or 0}
out["new"] = sum((v["new"] for k, v in out.items() if isinstance(v, dict)), 0)
snap = ml_client.snapshot()
out["learning"] = snap["learning"]
out["ml"] = snap["ml"]
out["ai"] = snap["ai"]
return out
# ─── Пакетная переклассификация (Inbox) ──────────────────────────────────
# Фоновая задача переклассификации (одна; повторный вызов возвращает busy)
_reclassify_task: object | None = None
def reclassify_busy() -> bool:
return bool(_reclassify_task and not _reclassify_task.done())
async def reclassify_lead(lead_id: str) -> dict | None:
"""Прогнать карточку «Неразобранного» через полный конвейер ИИ.
Этап 2 (ИИ-фильтр) + классификатор; мусор/спам/служебные сообщения
отправляются в корзину (с обучением ML). Вернувшееся None — карточка
без исходного текста или не найденная.
"""
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
if not lead or not lead["source_msg"]:
return None
from .ai import budget_to_target, classify, clean_budget, filter_incoming
text = lead["source_msg"]
try:
r2 = await filter_incoming(text)
except Exception as exc: # noqa: BLE001
log.warning("reclassify filter fail %s: %s", lead_id, exc)
r2 = {"pass": True, "reason": None, "skipped": True}
if not r2["pass"]:
trash_lead(lead_id, teach=False)
ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT)
return {"status": "trashed", "reason": str(r2.get("reason") or "не прошло ИИ-фильтр")[:120]}
raw = await classify(text)
if raw.get("is_spam"):
trash_lead(lead_id, teach=False)
ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT)
return {"status": "trashed", "reason": "ИИ: не заявка/спам"}
board_id = str(raw.get("board") or "").strip() or None
# страховка: колонку с активными правилами может назначить только текст,
# прошедший эти правила (иначе ручная переклассификация закидывает хлам)
if board_id and not board_accepts(board_id, text):
board_id = None
budget = clean_budget(raw.get("budget"))
contacts = build_contacts(raw.get("contacts"), text)
contact = primary_contact(contacts)[:200]
if not contact:
# старый контакт оставляем только если он валидный (@, телефон, почта…),
# а не мусорная фраза из старого разбора
old = str(lead["contact"] or "").strip()[:200]
contact = old if old and qualify_contact(old) else ""
stack = normalize_stack(raw.get("stack"))
new_title = clean_short(raw.get("title") or "", 140) or clean_short(lead["title"], 140)
new_summary = clean_block(compose_summary(raw, text), 2000) or clean_block(lead["summary"], 2000)
if not budget:
# ИИ не выделил бюджет полем, но сумма с валютой есть в исходнике или
# в структурированной «О заявке» — показываем её на карточке.
for src in (text, new_summary):
amts = extract_amounts(src or "")
if amts:
a = amts[0]
budget = {"from": a["from"], "to": a["to"], "currency": a["cur"]}
break
conv = budget_to_target(budget)
hits = hits_for_board(board_id, text) if board_id else []
store.execute(
"UPDATE leads SET col = ?, title = ?, summary = ?, is_vacancy = ?, is_vacancy_known = TRUE, "
"stack = ?, budget_from = ?, budget_to = ?, budget_cur = ?, "
"conv_from = ?, conv_to = ?, conv_cur = ?, contact = ?, contacts = ?, "
"match_hits = ?, is_new = TRUE "
"WHERE id = ?",
[
board_id or "inbox",
new_title,
new_summary,
bool(raw.get("is_vacancy")),
json.dumps(stack, ensure_ascii=False),
budget.get("from") if budget else None,
budget.get("to") if budget else None,
budget.get("currency", "") if budget else "",
conv["convFrom"], conv["convTo"], conv["convCur"],
contact,
json.dumps(contacts, ensure_ascii=False),
json.dumps(hits, ensure_ascii=False),
lead_id,
],
)
# ИИ-решение при переклассификации — тоже обучающий сигнал для ML.
# Учим только «свободные» колонки (без активных правил, не suggested):
# именно их ML может назначать сама в своём пути.
if board_id:
br = store.query_one("SELECT suggested, rules FROM boards WHERE id = ?", [board_id])
free = False
if br and not bool(br["suggested"]):
try:
br_rules = json.loads(br["rules"] or "{}") if br["rules"] else {}
except Exception: # noqa: BLE001
br_rules = {}
free = not has_active_rules(br_rules)
if free:
ml_client.push(text, board_id, delta=ml_client.AI_WEIGHT)
# тип известен от ИИ — учим ML определять его сам (t:hire / t:order)
if not bool(raw.get("is_spam")):
ml_client.push(
text,
"t:hire" if bool(raw.get("is_vacancy")) else "t:order",
delta=ml_client.AI_WEIGHT,
)
return {"status": "moved" if board_id else "kept"}
async def reclassify_inbox(ids: list[str] | None = None) -> dict:
"""Переклассифицировать «Неразобранное» (все карточки или выбранные)."""
rows = store.query("SELECT id FROM leads WHERE col = 'inbox'")
if ids:
wanted = set(ids)
target = [r["id"] for r in rows if r["id"] in wanted]
else:
target = [r["id"] for r in rows]
res = {"attempted": len(target), "kept": 0, "moved": 0, "trashed": 0}
for lead_id in target:
try:
out = await reclassify_lead(lead_id)
except Exception as exc: # noqa: BLE001
log.warning("reclassify %s failed: %s", lead_id, exc)
continue
if not out:
continue
status = out.get("status")
if status == "trashed":
res["trashed"] += 1
elif status == "moved":
res["moved"] += 1
else:
res["kept"] += 1
await broker.publish("leads_reclassified", res)
return res
async def start_reclassify(ids: list[str] | None = None) -> dict:
"""Запустить переклассификацию в фоне (одна задача за раз)."""
global _reclassify_task
if not store.get_setting("aiEnabled"):
return {"started": False, "busy": False, "attempted": 0, "reason": "ИИ выключен — переклассификация недоступна"}
if reclassify_busy():
return {"started": False, "busy": True}
rows = store.query("SELECT id FROM leads WHERE col = 'inbox'")
if ids:
wanted = set(ids)
target = [r["id"] for r in rows if r["id"] in wanted]
else:
target = [r["id"] for r in rows]
if not target:
return {"started": False, "busy": False, "attempted": 0}
async def _run() -> None:
try:
res = await reclassify_inbox(ids)
await broker.publish_toast(
f"Переклассификация готова: {res['trashed']} в корзину, "
f"{res['moved']} в колонки, {res['kept']} осталось в «Неразобранном»",
"sparkles",
)
except Exception as exc: # noqa: BLE001
log.warning("reclassify task error: %s", exc)
await broker.publish_toast("Переклассификация завершилась с ошибкой", "x")
_reclassify_task = asyncio.create_task(_run())
return {"started": True, "busy": False, "attempted": len(target)}
# ─── Правила хранения (тик раз в 30 секунд) ───────────────────────────────
def tick_storage() -> dict:
auto = bool(store.get_setting("autoArchive"))
after_days = int(store.get_setting("archiveAfterDays") or 14)
archive_clear = int(store.get_setting("archiveClearDays") or 90)
trash_clear = int(store.get_setting("trashClearDays") or 7)
now = _now()
archived = purged_arch = purged_trash = 0
if auto:
rows = store.query(
"SELECT id FROM leads WHERE col IN (SELECT id FROM boards UNION ALL SELECT 'inbox') "
"AND received_at < ?",
[now - after_days * C.DAY_MS],
)
for r in rows:
store.execute(
"UPDATE leads SET col = 'archive', is_new = FALSE, archived_at = ? WHERE id = ?",
[now, r["id"]],
)
archived += 1
old_arch = store.query("SELECT id FROM leads WHERE col = 'archive' AND archived_at IS NOT NULL AND archived_at < ?", [now - archive_clear * C.DAY_MS])
for r in old_arch:
_hard_delete(r["id"])
purged_arch += 1
old_trash = store.query("SELECT id FROM leads WHERE col = 'trash' AND received_at < ?", [now - trash_clear * C.DAY_MS])
for r in old_trash:
_hard_delete(r["id"])
purged_trash += 1
# отсев пайплайна живёт 3 суток, дальше удаляется автоматически
purged_rejected = processing_svc.purge_expired()
return {
"archived": archived,
"purgedArchive": purged_arch,
"purgedTrash": purged_trash,
"purgedRejected": purged_rejected,
}
async def notify_tick_stats(stats: dict) -> None:
if stats["archived"]:
await broker.publish_toast(f"Автоархив: {stats['archived']} карточек", "clock")
if stats["purgedArchive"]:
await broker.publish_toast(f"Архив очищен: {stats['purgedArchive']} (90 дн.)", "trash")
if stats["purgedTrash"]:
await broker.publish_toast(f"Корзина очищена: {stats['purgedTrash']} (7 дн.)", "trash")
if stats.get("purgedRejected"):
await broker.publish_toast(f"Отсев очищен: {stats['purgedRejected']} записей (3 дн.)", "trash")
# ─── Поиск ────────────────────────────────────────────────────────────────
def search(q: str, limit: int = 12) -> dict:
qq = q.strip().lower()
if len(qq) < 2:
return {"leads": [], "messages": []}
pattern = f"%{qq}%"
# FTS-кандидаты (снимок индекса)
fts_ids: list[str] = []
if fts_svc.is_ready():
try:
fts_ids = fts_svc.search(qq, limit=limit)["leads"]
except Exception: # noqa: BLE001
fts_ids = []
# LIKE-дополнение (свежие записи после последнего rebuild)
like_ids = [
r["id"]
for r in store.query(
"SELECT id FROM leads WHERE col != 'taken' AND ("
" lower(title) LIKE ? OR lower(summary) LIKE ? OR lower(contact) LIKE ? OR lower(source_msg) LIKE ?) "
"ORDER BY received_at DESC LIMIT ?",
[pattern, pattern, pattern, pattern, limit * 2],
)
]
merged: list[str] = []
for bid in [*fts_ids, *like_ids]:
if bid not in merged:
merged.append(bid)
leads = []
for lid in merged:
d = lead_to_dict(lid)
if d and d["col"] != "taken":
leads.append(d)
if len(leads) >= limit:
break
msgs = store.query(
"SELECT id, dialog_id, text, msg_at FROM messages WHERE lower(text) LIKE ? ORDER BY msg_at DESC LIMIT 20",
[pattern],
)
return {"leads": leads, "messages": msgs}
@@ -0,0 +1,162 @@
"""Клиент автономного ML-сервиса + локальный outbox обучения.
Основное приложение НИКОГДА не обучает ML напрямую «в своей» базе: каждое
действие пользователя синхронно пишется в таблицу ml_outbox, а фоновый
воркер (main._ml_sync_loop) отправляет накопленное в ML-сервис батчами.
Использование ML в пайплайне включается настройкой `mlEnabled`; обучение
идёт всегда.
"""
from __future__ import annotations
import logging
import time
import httpx
from .. import config
from ..db import store
log = logging.getLogger("leadradar.mlclient")
DECISIONS_ML = "mlDecisions"
DECISIONS_AI = "aiDecisions"
# Веса обучающих сигналов: действия пользователя — истина (1.0), решения ИИ —
# гипотезы (меньше), чтобы реальные действия со временем перевешивали ошибки ИИ.
USER_WEIGHT = 1.0
AI_WEIGHT = 0.4
RULE_WEIGHT = 0.6
# кэш статуса сервиса (обновляется фоновым циклом; живёт не дольше 15 c)
_cached: dict = {"at": 0, "data": None, "reachable": False}
def _now() -> int:
return time.time_ns() // 1_000_000
# ─── Обучение: всегда пишем в outbox ──────────────────────────────────────
def push(text: str, label: str, delta: float = 1.0) -> None:
"""Действие пользователя -> событие обучения (гарантированно, локально)."""
text = (text or "").strip()
label = str(label or "").strip()
if not text or not label:
return
store.execute(
"INSERT INTO ml_outbox(id, text, label, delta, created_at) VALUES (?, ?, ?, ?, ?)",
[store.uid("mle_"), text[:6000], label, delta, _now()],
)
def outbox_len() -> int:
return int(store.scalar("SELECT count(*) FROM ml_outbox") or 0)
async def flush_outbox(batch: int = 100) -> int:
"""Отправляет накопленные события в ML-сервис небольшими порциями.
Один learn-batch из сотен сообщений надолго блокирует ML-сервис
(модель пишет каждый термин отдельным INSERT) и упирается в таймаут;
порции по 20 строк проходят быстро и не роняют сервис.
"""
chunk = 10
total = 0
while total < batch:
rows = store.query(
"SELECT id, text, label, delta FROM ml_outbox ORDER BY created_at LIMIT ?",
[chunk],
)
if not rows:
break
items = [{"text": r["text"], "label": r["label"], "delta": r["delta"]} for r in rows]
try:
await _post("/learn-batch", {"items": items}, timeout=config.ML_TIMEOUT + 10)
except Exception as exc: # noqa: BLE001
log.warning("ml outbox flush failed (%d rows): %s", len(rows), exc)
break
ids = [r["id"] for r in rows]
store.execute(f"DELETE FROM ml_outbox WHERE id IN ({','.join('?' * len(ids))})", ids)
total += len(rows)
log.info("ml outbox flushed: %d", len(rows))
return total
# ─── HTTP ─────────────────────────────────────────────────────────────────
async def _post(path: str, body: dict, timeout: float | None = None) -> dict:
async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client:
resp = await client.post(config.ML_URL + path, json=body)
resp.raise_for_status()
return resp.json()
async def _get(path: str, timeout: float | None = None) -> dict:
async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client:
resp = await client.get(config.ML_URL + path)
resp.raise_for_status()
return resp.json()
async def predict(text: str) -> dict:
"""Предсказание. При сбое/недоступности сервиса — «не уверен» (решит ИИ)."""
try:
return await _post("/predict", {"text": (text or "")[:6000]})
except Exception as exc: # noqa: BLE001
log.debug("ml predict unavailable: %s", exc)
return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": False}
async def reset_model() -> dict:
"""Полный сброс ML-модели + очистка очереди обучения.
Старая модель (в т.ч. «мусорные» классы удалённых колонок) стирается,
события обучения из outbox тоже удаляются — иначе они сразу «переобучат»
модель на старых данных.
"""
try:
await _post("/reset", {}, timeout=30)
except Exception as exc: # noqa: BLE001
log.warning("ml reset failed: %s", exc)
return {"ok": False, "error": str(exc)}
store.execute("DELETE FROM ml_outbox")
await refresh_status()
return {"ok": True}
async def refresh_status() -> dict:
global _cached
try:
data = await _get("/status", timeout=3)
_cached = {"at": _now(), "data": data, "reachable": True}
except Exception as exc: # noqa: BLE001
log.debug("ml status unavailable: %s", exc)
_cached = {"at": _now(), "data": _cached["data"], "reachable": False}
return _cached["data"] or {}
def snapshot() -> dict:
"""Локальная статистика + последний известный статус ML-сервиса (без HTTP)."""
fresh = _cached["data"] or {}
return {
"ml": int(store.get_setting(DECISIONS_ML) or 0),
"ai": int(store.get_setting(DECISIONS_AI) or 0),
"learning": int(store.scalar("SELECT count(*) FROM learning_log") or 0),
"ready": bool(fresh.get("ready")),
"classes": fresh.get("classes", {}),
"learned": int(fresh.get("learned") or 0),
"reachable": _cached["reachable"],
"outbox": outbox_len(),
}
def track_decisions(ml: int = 0, ai: int = 0) -> None:
if ml:
store.set_setting(DECISIONS_ML, int(store.get_setting(DECISIONS_ML) or 0) + ml)
if ai:
store.set_setting(DECISIONS_AI, int(store.get_setting(DECISIONS_AI) or 0) + ai)
def is_enabled() -> bool:
"""Использовать ли ML в пайплайне (настройка UI). Обучение — всегда."""
return store.get_setting("mlEnabled") is not False and _cached["reachable"]
@@ -0,0 +1,108 @@
"""Хранение вложений: MinIO (S3-совместимое), креды в env.
Если MinIO не настроен (локальная разработка без docker-compose), объекты
сохраняются в локальную папку DATA_DIR/attachments — API и карточки при этом
не меняются, в БД хранится только ключ объекта.
"""
from __future__ import annotations
import io
import logging
from pathlib import Path
from .. import config
log = logging.getLogger("leadradar.objectstore")
def configured() -> bool:
return bool(config.MINIO_ENDPOINT and config.MINIO_ACCESS_KEY and config.MINIO_SECRET_KEY)
_client = None
_client_checked = False
def client():
"""MinIO-клиент (создаётся лениво). Бросает ошибку, если не настроен."""
global _client, _client_checked
if _client is not None:
return _client
if not configured():
raise RuntimeError(
"MinIO не настроен: задайте LEADRADAR_MINIO_ENDPOINT / _ACCESS_KEY / _SECRET_KEY / _BUCKET"
)
from minio import Minio
from minio.error import S3Error
_client = Minio(
config.MINIO_ENDPOINT,
access_key=config.MINIO_ACCESS_KEY,
secret_key=config.MINIO_SECRET_KEY,
secure=config.MINIO_SECURE,
)
if not _client_checked:
_client_checked = True
try:
if not _client.bucket_exists(config.MINIO_BUCKET):
_client.make_bucket(config.MINIO_BUCKET)
except S3Error as exc:
log.warning("bucket check failed: %s", exc)
return _client
def _local_path(object_key: str) -> Path:
# object_key вида projects/{card_id}/{ts}_{name}; не даём выйти за пределы FILES_DIR
safe = Path(object_key).name
parent = Path(object_key).parent
return (config.FILES_DIR / parent / safe).resolve()
def put(card_id: str, name: str, data: bytes, content_type: str) -> str:
"""Сохраняет объект (MinIO или локально), возвращает objectKey."""
import time
object_key = f"projects/{card_id}/{int(time.time() * 1000)}_{name}"
if configured():
client().put_object(
config.MINIO_BUCKET,
object_key,
io.BytesIO(data),
length=len(data),
content_type=content_type or "application/octet-stream",
)
else:
path = _local_path(object_key)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
log.info("MinIO не настроен — файл сохранён локально: %s", path)
return object_key
def get(object_key: str):
"""Возвращает поток (бинарный) для скачивания."""
if configured():
try:
return client().get_object(config.MINIO_BUCKET, object_key)
except Exception as exc: # noqa: BLE001
log.warning("minio get failed: %s", exc)
raise
path = _local_path(object_key)
if not path.is_file():
raise FileNotFoundError(object_key)
return io.BytesIO(path.read_bytes())
def remove(object_key: str) -> None:
if configured():
try:
client().remove_object(config.MINIO_BUCKET, object_key)
except Exception as exc: # noqa: BLE001
log.warning("minio remove failed: %s", exc)
return
try:
path = _local_path(object_key)
if path.is_file():
path.unlink()
except Exception as exc: # noqa: BLE001
log.warning("local remove failed: %s", exc)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,320 @@
"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев).
Очередь — сырые сообщения из каналов, ждущие разбора (pipeline_msg).
Отсев — сообщения, отброшенные на любом этапе: стоп-фразы/резюме/тип заявки/
без суммы (source='stop'), устарело ('stale'), ML ('ml'), ИИ ('ai'), повтор
('dup'). Для каждой записи храним этап, причину и конкретное слово/фразу
(kw), если отсев по стоп-списку.
Автоочистка отсева — раз в 3 суток (вызывается из leads.tick_storage),
плюс ручная очистка и удаление отдельных записей из UI.
"""
from __future__ import annotations
import logging
import time
from ..db import store
from . import fts as fts_svc
log = logging.getLogger("leadradar.processing")
# отсев живёт 3 суток, дальше удаляется автоматически
RETENTION_DAYS = 3
# человекочитаемые подписи этапов (для UI; source хранится отдельно)
_STAGE_LABELS = {
"length": "короткое сообщение",
"stop": "стоп-фраза",
"resume": "резюме соискателя",
"type": "тип заявки",
"budget": "нет суммы",
"stale": "устарело",
"spam_ml": "спам (ML)",
"spam_ai": "спам (ИИ)",
"filter_ai": "ИИ-фильтр",
"dup": "повтор",
}
# «чьё» решение: используется в UI как источник метки
_SOURCE_LABELS = {
"stop": "правила",
"ml": "ML",
"ai": "ИИ",
"stale": "система",
"dup": "система",
}
DEFAULT_LIMIT = 100
MAX_LIMIT = 500
def _now() -> int:
return time.time_ns() // 1_000_000
def stage_label(stage: str) -> str:
return _STAGE_LABELS.get(stage, stage or "отсев")
def source_label(source: str) -> str:
return _SOURCE_LABELS.get(source, source or "система")
# ─── Запись отсева ────────────────────────────────────────────────────────
def record(row: dict, source: str, stage: str, reason: str, kw: str = "") -> None:
"""Сохраняет отброшенное сообщение в таблицу отсева.
Ид записи детерминирован по (dialog_id, msg_id): повторное отбрасывание
того же сообщения (перечитывание каналов) обновляет запись, а не копит
дубликаты в списке отсева.
"""
if not row or not (row.get("text") or "").strip():
return
msg_id = row.get("msg_id")
dialog_id = row.get("dialog_id") or ""
rid = f"r_{dialog_id}_{msg_id}" if (msg_id is not None and dialog_id) else store.uid("r_")
now = _now()
store.execute(
"INSERT INTO rejected_msgs(id, dialog_id, msg_id, text, ch_name, ch_handle, ch_hue, stage, reason, kw, source, msg_at, rejected_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET "
"text = excluded.text, ch_name = excluded.ch_name, ch_handle = excluded.ch_handle, "
"ch_hue = excluded.ch_hue, stage = excluded.stage, reason = excluded.reason, kw = excluded.kw, "
"source = excluded.source, msg_at = excluded.msg_at, rejected_at = excluded.rejected_at",
[
rid,
dialog_id,
msg_id,
str(row["text"])[:6000],
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
stage,
str(reason or "")[:500],
str(kw or "")[:200],
source,
row.get("msg_at"),
now,
],
)
def purge_expired(days: int = RETENTION_DAYS) -> int:
"""Автоочистка отсева: записи старше N суток удаляются безвозвратно."""
cut = _now() - days * 24 * 3600 * 1000
rows = store.query(
"SELECT id FROM rejected_msgs WHERE rejected_at < ?", [cut]
)
if not rows:
return 0
ids = [r["id"] for r in rows]
store.execute(
"DELETE FROM rejected_msgs WHERE id IN (" + ",".join(["?"] * len(ids)) + ")",
ids,
)
return len(ids)
def clear_all() -> int:
rows = store.query("SELECT count(*) AS c FROM rejected_msgs")
total = int(rows[0]["c"]) if rows else 0
if total:
store.execute("DELETE FROM rejected_msgs")
return total
def return_to_queue(rej_id: str, reason: str = "") -> dict:
"""Вернуть отсеянное сообщение в обработку (кнопка в «Обработке»).
Строка очереди помечается force: этап 1, устарело, ML-решения и ИИ-отсев
для неё игнорируются — сообщение уходит на классификацию и создаёт карточку.
Запись в отсеве не удаляется, а помечается «возвращено» с причиной (аудит).
Если отсев был по решению «спам» (ML/ИИ) — снимаем у ML вес спама для текста.
"""
row = store.query_one("SELECT * FROM rejected_msgs WHERE id = ?", [rej_id])
if not row:
raise KeyError(rej_id)
if bool(row.get("returned")):
raise ValueError("Сообщение уже возвращено в обработку")
if str(row.get("source") or "") == "dup":
raise ValueError("Повтор: карточка с таким текстом уже есть в системе — возвращать нечего")
dialog_id = str(row.get("dialog_id") or "")
msg_id = row.get("msg_id")
text = str(row.get("text") or "").strip()
if not text:
raise ValueError("В записи нет текста сообщения")
if str(row.get("stage") or "") in ("spam_ml", "spam_ai", "filter_ai"):
from . import ml_client
# реальное действие пользователя: этот текст НЕ спам
ml_client.push(text, "spam", delta=-1.0)
now = _now()
store.execute(
"UPDATE rejected_msgs SET returned = TRUE, returned_at = ?, return_reason = ? WHERE id = ?",
[now, str(reason or "").strip()[:500], rej_id],
)
from .pipeline import enqueue # локальный импорт: pipeline импортирует processing
if dialog_id and msg_id is not None:
enqueue(
dialog_id,
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
msg_id,
text,
row.get("msg_at") or now,
force=True,
)
else:
# старые записи (до сохранения dialog_id/msg_id): текст сохранился,
# возвращаем без ссылки на исходное сообщение (force=True)
store.execute(
"INSERT INTO pipeline_msg(id, dialog_id, ch_name, ch_handle, ch_hue, text, msg_id, msg_at, status, force, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', TRUE, ?, ?)",
[
store.uid("p_"),
dialog_id,
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
text[:6000],
msg_id,
row.get("msg_at") or now,
now,
now,
],
)
return {"id": rej_id, "returned": True, "returnedAt": now}
def delete_one(rej_id: str) -> bool:
store.execute("DELETE FROM rejected_msgs WHERE id = ?", [rej_id])
return True
def rejected_count() -> int:
return int(store.scalar("SELECT count(*) FROM rejected_msgs") or 0)
# ─── Очередь (pipeline_msg) ───────────────────────────────────────────────
def queue_counts() -> dict:
out = {"new": 0, "ai": 0}
for r in store.query("SELECT status, count(*) AS c FROM pipeline_msg GROUP BY status"):
if r["status"] == "new":
out["new"] = int(r["c"])
elif r["status"] == "filtered":
out["ai"] = int(r["c"])
out["total"] = out["new"] + out["ai"]
return out
def list_queue(limit: int = DEFAULT_LIMIT) -> list[dict]:
limit = min(max(1, limit), MAX_LIMIT)
rows = store.query(
"SELECT * FROM pipeline_msg ORDER BY created_at LIMIT ?", [limit]
)
items = []
for r in rows:
items.append(
{
"id": r["id"],
"dialogId": r.get("dialog_id") or "",
"msgId": r.get("msg_id"),
"text": r["text"],
"status": r["status"], # new | filtered
"ch": {
"name": r["ch_name"],
"handle": r["ch_handle"],
"hue": r["ch_hue"],
},
"msgAt": r["msg_at"],
"queuedAt": r["created_at"],
}
)
return items
# ─── Отсев (rejected_msgs) ────────────────────────────────────────────────
def list_rejected(q: str = "", offset: int = 0, limit: int = DEFAULT_LIMIT) -> dict:
offset = max(0, offset)
limit = min(max(1, limit), MAX_LIMIT)
qq = (q or "").strip().lower()
ids: list[str] = []
if qq:
# FTS-кандидаты + LIKE-дополнение (свежие записи после последнего rebuild)
if fts_svc.is_ready():
try:
ids = fts_svc.search(qq, limit=limit)["rejected"]
except Exception: # noqa: BLE001
ids = []
pattern = f"%{qq}%"
like = store.query(
"SELECT id FROM rejected_msgs WHERE "
"lower(text) LIKE ? OR lower(reason) LIKE ? OR lower(kw) LIKE ? OR lower(ch_name) LIKE ? "
"ORDER BY rejected_at DESC LIMIT ?",
[pattern, pattern, pattern, pattern, limit * 2],
)
for r in like:
if r["id"] not in ids:
ids.append(r["id"])
total = len(ids) # итог по условию поиска (все кандидаты)
page = ids[offset : offset + limit]
else:
total = rejected_count()
page_rows = store.query(
"SELECT id FROM rejected_msgs ORDER BY rejected_at DESC LIMIT ? OFFSET ?",
[limit, offset],
)
page = [r["id"] for r in page_rows]
by_id: dict[str, dict] = {}
if page:
ph = ",".join(["?"] * len(page))
rows = store.query(
f"SELECT * FROM rejected_msgs WHERE id IN ({ph})", page
)
for r in rows:
by_id[r["id"]] = r
items = []
for rid in page:
r = by_id.get(rid)
if not r:
continue
items.append(
{
"id": r["id"],
"dialogId": r.get("dialog_id") or "",
"msgId": r.get("msg_id"),
"text": r["text"],
"stage": r["stage"],
"stageLabel": stage_label(r["stage"]),
"reason": r["reason"],
"kw": r["kw"],
"source": r["source"],
"sourceLabel": source_label(r["source"]),
"ch": {"name": r["ch_name"], "handle": r["ch_handle"], "hue": r.get("ch_hue") or "#666"},
"msgAt": r["msg_at"],
"rejectedAt": r["rejected_at"],
"returned": bool(r.get("returned")),
"returnedAt": r.get("returned_at"),
"returnReason": r.get("return_reason") or "",
}
)
return {"items": items, "total": total, "offset": offset, "limit": limit}
def stats() -> dict:
q = queue_counts()
return {
"queue": q,
"rejected": rejected_count(),
}
@@ -0,0 +1,282 @@
"""«Выбранные» — проектный канбан (п.4.8 ТЗ).
Карточка живёт только здесь: лид уходит с дашборда безвозвратно (col='taken'),
в архив/корзину проектные карточки не попадают. Есть история движения,
вложения (файлы сейчас мета-мок, MinIO позже), ссылки, ТЗ и напоминания
для стадии «Отложено».
"""
from __future__ import annotations
import json
import logging
import time
from .. import constants as C
from ..db import store
from ..sse import broker
log = logging.getLogger("leadradar.projects")
STAGES = {s["id"] for s in C.PIPELINE_STAGES}
def _now() -> int:
return time.time_ns() // 1_000_000
def _json(value: list, default: str = "[]") -> str:
return json.dumps(value or [], ensure_ascii=False) if value is not None else default
def _row_to_card(row: dict) -> dict:
history = json.loads(row["history"] or "[]")
return {
"id": row["id"],
"stage": row["stage"],
"local": bool(row["local"]),
"leadId": row["lead_id"],
"title": row["title"],
"summary": row["summary"],
"stack": json.loads(row["stack"] or "[]"),
"budget": (
{"from": row["budget_from"], "to": row["budget_to"], "cur": row["budget_cur"]}
if row["budget_cur"]
else None
),
"contact": row["contact"],
"comments": json.loads(row["comments"] or "[]"),
"links": json.loads(row["links"] or "[]"),
"files": json.loads(row["files"] or "[]"),
"tzText": row["tz_text"],
"history": history,
"reminder": {"at": row["reminder_at"]} if row["reminder_at"] else None,
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
}
def list_cards(stage: str | None = None) -> list[dict]:
if stage:
rows = store.query("SELECT * FROM projects WHERE stage = ? ORDER BY updated_at DESC", [stage])
else:
rows = store.query("SELECT * FROM projects ORDER BY updated_at DESC")
return [_row_to_card(r) for r in rows]
def get_card(card_id: str) -> dict | None:
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
return _row_to_card(row) if row else None
def _insert(row_fields: dict) -> dict:
now = _now()
card_id = row_fields["id"]
store.execute(
"INSERT INTO projects(id, stage, local, lead_id, title, summary, stack, "
"budget_from, budget_to, budget_cur, contact, comments, links, files, tz_text, "
"history, reminder_at, reminder_fired, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, FALSE, ?, ?)",
[
card_id,
row_fields.get("stage", "planned"),
bool(row_fields.get("local")),
row_fields.get("lead_id"),
row_fields.get("title", ""),
row_fields.get("summary", ""),
_json(row_fields.get("stack")),
row_fields.get("budget_from"),
row_fields.get("budget_to"),
row_fields.get("budget_cur", ""),
row_fields.get("contact", ""),
_json(row_fields.get("comments")),
_json(row_fields.get("links")),
_json(row_fields.get("files")),
row_fields.get("tz_text", ""),
_json(row_fields.get("history")),
now,
now,
],
)
return get_card(card_id)
def create_local_card(data: dict) -> dict:
"""Ручное создание карточки — «локальная» (без лида)."""
budget = data.get("budget") if isinstance(data.get("budget"), dict) else None
return _insert(
{
"id": store.uid("pr_"),
"stage": data.get("stage") if data.get("stage") in STAGES else "planned",
"local": True,
"title": str(data.get("title", "")).strip(),
"summary": str(data.get("summary", "")),
"stack": data.get("stack") or [],
"budget_from": budget.get("from") if budget else None,
"budget_to": budget.get("to") if budget else None,
"budget_cur": budget.get("cur", "") if budget else "",
"contact": str(data.get("contact", "")),
"comments": data.get("comments") or [],
"links": data.get("links") or [],
"files": data.get("files") or [],
"tz_text": str(data.get("tzText", "")),
"history": [{"id": store.uid("h_"), "at": _now(), "type": "createdLocal"}],
}
)
def take_lead_to_projects(lead_id: str) -> dict:
"""«Взять в работу»: лид уходит с дашборда, создаётся проектная карточка."""
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
if not lead:
raise KeyError(lead_id)
existing = store.query_one("SELECT id FROM projects WHERE lead_id = ?", [lead_id])
if existing:
return get_card(existing["id"])
budget = {"from": lead["budget_from"], "to": lead["budget_to"], "cur": lead["budget_cur"]} if lead["budget_cur"] else None
card = _insert(
{
"id": store.uid("pr_"),
"stage": "planned",
"local": False,
"lead_id": lead_id,
"title": lead["title"],
"summary": lead["summary"],
"stack": json.loads(lead["stack"] or "[]"),
"budget_from": budget["from"] if budget else None,
"budget_to": budget["to"] if budget else None,
"budget_cur": budget["cur"] if budget else "",
"contact": lead["contact"],
"comments": [{"id": store.uid("cm_"), "by": "Вы", "text": "Взял в работу из лида.", "time": "только что"}],
"tz_text": "",
"history": [{"id": store.uid("h_"), "at": _now(), "type": "created"}],
}
)
# эксклюзивность: на дашборде лида больше нет, возврата нет
store.execute("UPDATE leads SET col = 'taken', is_new = FALSE WHERE id = ?", [lead_id])
return card
def patch_card(card_id: str, patch: dict) -> dict:
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
simple = {
"title": "title",
"summary": "summary",
"contact": "contact",
"tz_text": "tzText",
}
for col, key in simple.items():
if key in patch:
store.execute(f"UPDATE projects SET {col} = ? WHERE id = ?", [str(patch[key]), card_id])
if "stack" in patch:
store.execute("UPDATE projects SET stack = ? WHERE id = ?", [_json(patch["stack"]), card_id])
if "budget" in patch:
b = patch["budget"] if isinstance(patch["budget"], dict) else None
store.execute(
"UPDATE projects SET budget_from = ?, budget_to = ?, budget_cur = ? WHERE id = ?",
[b.get("from") if b else None, b.get("to") if b else None, b.get("cur", "") if b else "", card_id],
)
if "comments" in patch:
store.execute("UPDATE projects SET comments = ? WHERE id = ?", [_json(patch["comments"]), card_id])
if "links" in patch:
store.execute("UPDATE projects SET links = ? WHERE id = ?", [_json(patch["links"]), card_id])
if "files" in patch:
store.execute("UPDATE projects SET files = ? WHERE id = ?", [_json(patch["files"]), card_id])
_bump(card_id)
return get_card(card_id)
def _bump(card_id: str) -> None:
store.execute("UPDATE projects SET updated_at = ? WHERE id = ?", [_now(), card_id])
def add_comment(card_id: str, text: str) -> list[dict]:
row = store.query_one("SELECT comments FROM projects WHERE id = ?", [card_id])
comments = json.loads(row["comments"] or "[]")
comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"})
patch_card(card_id, {"comments": comments})
return comments
def move_stage(card_id: str, stage: str) -> dict:
if stage not in STAGES:
raise ValueError("Неизвестная стадия")
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
history = json.loads(row["history"] or "[]")
now = _now()
store.execute(
"UPDATE projects SET stage = ?, reminder_at = NULL, reminder_fired = FALSE, updated_at = ? WHERE id = ?",
[stage, now, card_id],
)
history.append({"id": store.uid("h_"), "at": now, "stage": stage})
store.execute("UPDATE projects SET history = ? WHERE id = ?", [_json(history), card_id])
return get_card(card_id)
def remove_card(card_id: str) -> None:
store.execute("DELETE FROM projects WHERE id = ?", [card_id])
def clear_stage(stage: str) -> int:
"""Полная ручная очистка стадии «Отклонено» (терминальные не восстанавливаются)."""
if stage not in ("rejected",):
raise ValueError("Очистка разрешена только для стадии «Отклонено»")
rows = store.query("SELECT id FROM projects WHERE stage = ?", [stage])
if not rows:
return 0
store.execute("DELETE FROM projects WHERE stage = ?", [stage])
return len(rows)
# ─── Напоминания стадии «Отложено» ────────────────────────────────────────
def set_reminder(card_id: str, at: int) -> dict:
if not store.get_setting("remindersEnabled"):
raise PermissionError("Напоминания об отложенных выключены в настройках")
store.execute(
"UPDATE projects SET reminder_at = ?, reminder_fired = FALSE, updated_at = ? WHERE id = ?",
[at, _now(), card_id],
)
return get_card(card_id)
def clear_reminder(card_id: str) -> None:
store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE id = ?", [card_id])
def active_reminders() -> list[dict]:
rows = store.query(
"SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL ORDER BY reminder_at"
)
return [{"id": r["id"], "title": r["title"], "at": r["reminder_at"]} for r in rows]
def snooze(card_id: str, ms: int = C.DAY_MS) -> None:
store.execute(
"UPDATE projects SET reminder_at = ?, reminder_fired = FALSE WHERE id = ?",
[_now() + ms, card_id],
)
async def check_reminders() -> list[dict]:
"""Наступившие напоминания -> события. Если выключено — чистим протухшие."""
if not store.get_setting("remindersEnabled"):
# протухшие напоминания не храним: при включении старые не «выстрелят»
store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE reminder_at IS NOT NULL AND reminder_at <= ?", [_now()])
return []
now = _now()
rows = store.query(
"SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL "
"AND reminder_fired = FALSE AND reminder_at <= ?",
[now],
)
due = []
for r in rows:
store.execute("UPDATE projects SET reminder_fired = TRUE WHERE id = ?", [r["id"]])
due.append({"id": r["id"], "title": r["title"], "stage": r["stage"]})
for item in due:
await broker.publish("reminder_due", item)
return due
@@ -0,0 +1,130 @@
"""Курсы валют: источник ЦБ РФ, 4 запроса в сутки (каждые 6 часов).
По ТЗ до подключения сервиса используются мок-курсы; источник выбирается
в настройках (cbr | mock). Значения хранятся в БД вместе с временем
обновления и выдаются наружу для вкладки «Валюта и курсы».
"""
from __future__ import annotations
import json
import logging
import time
import httpx
from .. import constants as C
from ..db import store
log = logging.getLogger("leadradar.rates")
_FETCH_INTERVAL_MS = 6 * C.HOUR_MS # 4 раза в сутки
def get_rates() -> dict:
row = store.query_one("SELECT rates, source, updated_at FROM rates WHERE id = 1")
rates = json.loads(row["rates"]) if row else dict(C.MOCK_RATES)
return {
"base": "RUB",
"rates": rates,
"source": row["source"] if row else "mock",
"updatedAt": row["updated_at"] if row else None,
}
def save_rates(rates: dict, source: str) -> None:
store.execute(
"INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET rates = excluded.rates, source = excluded.source, "
"updated_at = excluded.updated_at",
[json.dumps(rates), source, time.time_ns() // 1_000_000],
)
async def fetch_cbr() -> dict | None:
"""Запрос к ЦБ РФ (JSON-зеркало daily_json.js). Возвращает rates к RUB."""
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(C.CBR_URL)
resp.raise_for_status()
payload = resp.json()
rates: dict = {"RUB": 1.0}
for code, item in payload.get("Valute", {}).items():
# 1 единица валюты в рублях: Nominal может быть > 1
nominal = int(item.get("Nominal", 1)) or 1
value = float(item.get("Value", 0))
rates[code] = round(value / nominal, 6)
return rates
except Exception as exc: # noqa: BLE001
log.warning("CBR fetch failed: %s", exc)
return None
async def refresh_rates(force_source: str | None = None) -> bool:
"""Ручное/фоновое обновление. Возвращает True при успехе (или при мок-режиме)."""
source = force_source or store.get_setting("rateSource") or "cbr"
if source == "mock":
save_rates(dict(C.MOCK_RATES), "mock")
recompute_conversions()
return True
rates = await fetch_cbr()
if rates is None:
return False
save_rates(rates, "cbr")
recompute_conversions()
return True
def should_fetch() -> bool:
row = store.query_one("SELECT updated_at, source FROM rates WHERE id = 1")
if not row:
return True
if row["source"] == "mock" and store.get_setting("rateSource") == "cbr":
return True
return time.time_ns() // 1_000_000 - row["updated_at"] >= _FETCH_INTERVAL_MS
def _resolve_rate(rates: dict, code: str) -> float | None:
"""USDT приравниваем к USD (у ЦБ нет тикера USDT)."""
if code == "USDT" and "USD" in rates:
return float(rates["USD"])
val = rates.get(code)
return float(val) if val is not None else None
def convert_amount(amount: float | int | None, from_cur: str, to_cur: str) -> float | None:
"""amount в from_cur -> to_cur по актуальным курсам (к рублю)."""
if amount is None:
return None
rates = json.loads(store.query_one("SELECT rates FROM rates WHERE id = 1")["rates"])
rf = _resolve_rate(rates, from_cur)
rt = _resolve_rate(rates, to_cur)
if rf is None or rt is None:
return None
return round(float(amount) * rf / rt, 2)
def recompute_conversions() -> int:
"""Пересчёт старых карточек по актуальному курсу и целевой валюте.
Пересчитываются карточки на канбане и в «Неразобранном»; архивные,
корзинные и ушедшие в «Выбранные» (taken) не трогаем.
"""
if not store.get_setting("conversionOn"):
return 0
target = store.get_setting("targetCurrency") or "RUB"
rows = store.query(
"SELECT id, budget_from, budget_to, budget_cur FROM leads "
"WHERE budget_cur <> '' AND col NOT IN ('archive','trash','taken')",
)
updated = 0
for r in rows:
cf = convert_amount(r["budget_from"], r["budget_cur"], target)
ct = convert_amount(r["budget_to"] if r["budget_to"] is not None else r["budget_from"], r["budget_cur"], target)
if cf is None:
continue
store.execute(
"UPDATE leads SET conv_from = ?, conv_to = ?, conv_cur = ? WHERE id = ?",
[cf, ct, target, r["id"]],
)
updated += 1
return updated
@@ -0,0 +1,390 @@
"""Детерминированные правила колонок: направление, стек, слова, грейд, бюджет.
Колонка — это набор опциональных фильтров, задаёт пользователь (или ИИ при
предложении). Если текст входящего сообщения соответствует правилам — карточка
уходит в эту колонку сразу, без ML/ИИ (ML/ИИ подключаются только когда правила
не сработали). Набор фильтров может меняться: пустая группа не участвует.
"""
from __future__ import annotations
import json
import re
from ..db import store
_CUR_SYMBOLS = {"$": "USD", "": "EUR", "": "RUB", "": "USDT", "£": "GBP", "¥": "CNY"}
_CUR_WORDS = {"usd": "USD", "eur": "EUR", "rub": "RUB", "usdt": "USDT", "gbp": "GBP", "cny": "CNY", "руб": "RUB", "долл": "USD", "бакс": "USD"}
# Грейд/уровень: тег из правил расширяется синонимами, чтобы «middle» находил
# и «mid», и «мидл», а «сеньор» находил senior и т.п.
_GRADE_ALIASES = {
"junior": ["junior", "джун", "джуниор"],
"middle": ["middle", "mid", "мидл"],
"senior": ["senior", "сеньор", "сеньйор"],
"lead": ["lead", "тимлид", "тиэмлид", "team lead", "teamlead", "tech lead", "техлид"],
"architect": ["architect", "архитектор"],
"intern": ["intern", "стажёр", "стажер", "trainee"],
}
def _grade_terms(tags: list[str]) -> list[str]:
out: list[str] = []
for t in tags:
key = str(t).strip().lower()
if not key:
continue
if key in _GRADE_ALIASES:
out.extend(_GRADE_ALIASES[key])
else:
out.append(key)
return out
# Перед матчингом правил вырезаем ссылки и markdown-ссылки: иначе фильтр ловит
# слова из трекерных хвостов/служебных строк URL (например, «desktop» в
# utm_medium=member_desktop) и в колонку попадает мусор, не имеющий отношения
# к содержанию сообщения.
_MD_URL_RE = re.compile(r"\[[^\]]*\]\([^)\s]+\)")
_RAW_URL_RE = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+")
def content_text(text: str) -> str:
s = str(text or "")
s = _MD_URL_RE.sub(" ", s)
return _RAW_URL_RE.sub(" ", s)
# ищем: 1) "от 1 200 до 1 500 $", 2) "1 2001 500 $ / 1 200$", 3) "$1 2001 500"
# суффикс «к/К» разрешён прямо в числе: «2к», «1.5к$» (множитель в _norm_amount)
_AMT = r"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?"
_RANGE = rf"({_AMT})\s*(?:[-–—]\s*({_AMT}))?"
def _norm_amount(raw: str) -> float | None:
s = raw.replace("\u00a0", " ").replace(" ", "").replace(",", ".")
mult = 1.0
# «2к»/«2К»/«1.5к» — тысячи; суффикс убираем до float (иначе парс падает)
if s and s[-1].lower() in ("k", "к"):
mult = 1000.0
s = s[:-1]
try:
v = float(s) * mult
except ValueError:
return None
return v
def _cur_from_tail(text: str, m_start: int) -> str | None:
tail = text[m_start : m_start + 12].lower().strip()
for sym, code in _CUR_SYMBOLS.items():
if tail.startswith(sym):
return code
# слово-валюта сразу после числа (с пробелом)
for word, code in _CUR_WORDS.items():
if tail.startswith(word):
return code
# валюта перед числом ($/€/₽), например "$1 200"
head = text[max(0, m_start - 3) : m_start].strip()
for sym, code in _CUR_SYMBOLS.items():
if head.endswith(sym):
return code
return None
def extract_amounts(text: str) -> list[dict]:
"""Парсер сумм с сохранением смысла:
- диапазон «от A до B» / «A–B» → {'from': A, 'to': B, 'cur': ...};
- «до B» (только верхняя граница) → {'from': None, 'to': B, 'cur': ...};
- одна сумма / «от A» без верхней границы → {'from': A, 'to': A, 'cur': ...}.
Валюту ищем сразу после суммы (или перед ней для «$1 200»); суммы без валюты игнорируются.
"""
out: list[dict] = []
t = text or ""
if not t.strip():
return out
occupied: list[tuple[int, int]] = []
def _free(s: int, e: int) -> bool:
return not any(s < oe and e > os for os, oe in occupied)
def _add(a, b, cur: str | None, s: int, e: int) -> None:
if cur and (a is not None or b is not None) and _free(s, e):
out.append({"from": a, "to": b, "cur": cur})
occupied.append((s, e))
# 1) словесный диапазон «от A до B» (+ валюта после B)
for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")\s+до\s+(" + _AMT + r")", t):
a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2))
cur = _cur_from_tail(t, m.end(2))
if a is not None and b is not None and cur:
_add(a, b, cur, m.start(1), m.end(2))
# 2) «до B» (без пары «от … до») — верхняя граница
for m in re.finditer(r"(?i)\bдо\s+(" + _AMT + r")", t):
b = _norm_amount(m.group(1))
cur = _cur_from_tail(t, m.end(1))
if b is not None and cur:
_add(None, b, cur, m.start(1), m.end(1))
# 3) «от A» без верхней границы — считаем одной суммой
for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")", t):
a = _norm_amount(m.group(1))
cur = _cur_from_tail(t, m.end(1))
if a is not None and cur:
_add(a, a, cur, m.start(1), m.end(1))
# 4) числовые диапазоны «A–B» / «A - B»
for m in re.finditer(r"(?i)(" + _AMT + r")\s*(?:[-–—]|\s+до\s+)\s*(" + _AMT + r")", t):
a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2))
cur = _cur_from_tail(t, m.end(2)) or _cur_from_tail(t, m.end(1))
if a is not None and b is not None and cur:
_add(a, b, cur, m.start(1), m.end(2))
# 5) одиночные суммы с валютой (не вошедшие в конструкции выше)
for m in re.finditer(_AMT, t):
a = _norm_amount(m.group(0))
cur = _cur_from_tail(t, m.end())
if a is not None and cur:
_add(a, a, cur, m.start(), m.end())
return out
def _amount_in_range(amounts: list[dict], budget: dict) -> bool:
from ..services.rates import convert_amount
try:
lo = float(budget.get("from")) if budget.get("from") is not None else None
hi = float(budget.get("to")) if budget.get("to") is not None else None
except (TypeError, ValueError):
return False
if lo is None and hi is None:
return True
target_cur = str(budget.get("cur") or "USD").upper()
for amt in amounts:
raw_val = amt["from"] if amt["from"] is not None else amt.get("to")
if raw_val is None:
continue
try:
val = raw_val if amt["cur"] == target_cur else convert_amount(raw_val, amt["cur"], target_cur)
except Exception: # noqa: BLE001
val = None
if val is None:
continue
if lo is not None and val < lo:
continue
if hi is not None and val > hi:
continue
return True
return False
def match_text(rules: dict, text: str) -> bool:
"""Соответствует ли текст правилам колонки. Возвращает bool.
Колонка — это набор опциональных фильтров: направление, стек, ключевые
слова, грейд/уровень, бюджет. Пустая группа не участвует; режим «все» —
должны совпасть все включённые группы, «любое» — хотя бы одна.
"""
rules = rules or {}
lower = content_text(text).lower()
direction = [str(x).strip().lower() for x in (rules.get("direction") or []) if str(x).strip()]
kw = [str(x).strip().lower() for x in (rules.get("keywords") or []) if str(x).strip()]
stack = [str(x).strip().lower() for x in (rules.get("stack") or []) if str(x).strip()]
grade = [str(x).strip().lower() for x in (rules.get("grade") or []) if str(x).strip()]
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
enabled = [bool(kw), bool(stack), bool(direction), bool(grade), bool(budget)]
if not any(enabled):
return False
grade_terms = _grade_terms(grade)
groups = {
"keywords": any(k in lower for k in kw) if kw else True,
"stack": any(s in lower for s in stack) if stack else True,
"direction": any(d in lower for d in direction) if direction else True,
"grade": any(g in lower for g in grade_terms) if grade else True,
"budget": (_amount_in_range(extract_amounts(text), budget) if budget else True),
}
mode = str(rules.get("mode") or "all").lower()
if mode == "any":
# «любое»: совпасть должна хотя бы одна включённая (непустая) группа.
# Пустые группы равны True и не должны участвовать — иначе колонка
# с одним фильтром (например «стек: WPF») ловит вообще всё.
return any(groups[k] for k, on in zip(groups, enabled) if on and k in groups)
# all: каждая включённая группа обязана совпасть
return all(groups[k] for k, on in zip(groups, enabled) if on and k in groups)
def score_text(rules: dict, text: str) -> int:
"""Число совпавших фильтров (для выбора лучшей ИИ-колонки)."""
if not text:
return 0
lower = content_text(text).lower()
score = 0
for group in ("direction", "keywords", "stack", "grade"):
if group == "grade":
for g in _grade_terms(rules.get(group) or []):
if g in lower:
score += 1
else:
for w in (rules.get(group) or []):
if str(w).lower() in lower:
score += 1
return score
def excluded_terms(rules: dict | None, text: str) -> list[str]:
"""Какие слова-исключения колонки есть в тексте.
Исключения — veto колонки: если в содержании сообщения (без ссылок и
служебных хвостов) встречается любое из них, карточка в колонку не
попадает, даже если все положительные условия совпали.
"""
rules = rules or {}
lower = content_text(text).lower()
out: list[str] = []
for term in rules.get("exclude") or []:
t = str(term).strip()
if t and t.lower() in lower:
out.append(t)
return out
def is_excluded(rules: dict | None, text: str) -> bool:
return bool(excluded_terms(rules, text))
def board_accepts(board_id: str, text: str) -> bool:
"""Пропускает ли колонка этот текст.
Колонка с активными правилами принимает только текст, прошедший её правила
(детерминированно); колонка без правил принимает любой текст — её наполняют
ИИ/ML/пользователь. Нужно как страховка: ИИ или ML не должны класть карточку
в «отфильтрованную» колонку, если текст под правила не подходит. Слова-
исключения работают как veto в любом случае.
"""
row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id])
if not row:
return False
rules = json.loads(row["rules"] or "{}") if row["rules"] else {}
if is_excluded(rules, text):
return False
if not has_active_rules(rules):
return True
return match_text(rules, text)
def hits(rules: dict, text: str) -> list[dict]:
"""Какие именно критерии фильтра совпали с текстом.
Возвращает список совпадений по группам фильтра колонки:
[{"label": "Стек", "term": "WPF"}, {"label": "Грейд", "term": "middle", "word": "mid"}, …].
Нужно, чтобы на карточке показывать «по каким критериям она попала в колонку»
(список совпавших условий фильтра). Ключевое слово ищется по всему тексту
сообщения — включая стек, требования и «будет плюсом», как и наоборот.
"""
rules = rules or {}
lower = content_text(text).lower()
out: list[dict] = []
for group, label in (("direction", "Направление"), ("keywords", "Слова"), ("stack", "Стек")):
for term in rules.get(group) or []:
t = str(term).strip()
if t and t.lower() in lower:
out.append({"label": label, "term": t})
for term in rules.get("grade") or []:
for alias in _grade_terms([term]):
if alias in lower:
out.append({"label": "Грейд/уровень", "term": str(term).strip(), "word": alias})
break
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
if budget and _amount_in_range(extract_amounts(text), budget):
out.append({"label": "Бюджет", "term": _budget_label(budget)})
return out
def _budget_label(budget: dict) -> str:
lo, hi = budget.get("from"), budget.get("to")
cur = str(budget.get("cur") or "").upper()
if lo is not None and hi is not None:
return f"от {lo:g} до {hi:g} {cur}".strip()
if hi is not None:
return f"до {hi:g} {cur}".strip()
if lo is not None:
return f"от {lo:g} {cur}".strip()
return "бюджет"
def hits_for_board(board_id: str, text: str) -> list[dict]:
"""Совпавшие критерии колонки с активными правилами; [] — правил нет."""
row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id])
if not row:
return []
rules = json.loads(row["rules"] or "{}") if row["rules"] else {}
if not has_active_rules(rules):
return []
return hits(rules, text)
def has_active_rules(rules: dict | None) -> bool:
"""Есть ли в правилах хотя бы одна реально работающая группа фильтров.
Нужно для ML: колонка с активными правилами раскладывается только самими
правилами (детерминированно), ML её назначать не должен — иначе в колонку
попадает то, что под правила не подходит.
"""
rules = rules or {}
for key in ("direction", "keywords", "stack", "grade"):
if any(str(x).strip() for x in (rules.get(key) or [])):
return True
budget = rules.get("budget")
if isinstance(budget, dict) and (
budget.get("from") is not None or budget.get("to") is not None
):
return True
return False
def describe(rules: dict) -> str:
"""Человекочитаемое описание правил (для обоснования и промпта)."""
rules = rules or {}
parts = []
direction = rules.get("direction") or []
stack = rules.get("stack") or []
kw = rules.get("keywords") or []
grade = rules.get("grade") or []
if direction:
parts.append("направление: " + ", ".join(str(x) for x in direction[:6]))
if stack:
parts.append("стек: " + ", ".join(str(x) for x in stack[:8]))
if kw:
parts.append("слова: " + ", ".join(str(x) for x in kw[:8]))
if grade:
parts.append("грейд: " + ", ".join(str(x) for x in grade[:8]))
exc = rules.get("exclude") or []
if exc:
parts.append("исключено: " + ", ".join(str(x) for x in exc[:8]))
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
if budget and (budget.get("from") is not None or budget.get("to") is not None):
lo = budget.get("from") if budget.get("from") is not None else ""
hi = budget.get("to") if budget.get("to") is not None else ""
parts.append(f"бюджет: {lo}{hi} {budget.get('cur') or ''}".replace(' ', '').replace(' ', ''))
if not parts:
return "без правил (решает ИИ/ML)"
mode = "все условия" if str(rules.get("mode") or "all").lower() != "any" else "любое из условий"
return mode + " · " + "; ".join(parts)
def route(text: str) -> dict | None:
"""Детерминированная маршрутизация по правилам активных колонок.
Возвращает колонку, если правила однозначно совпали (при нескольких —
ту, где больше совпадений). Предложения ИИ в маршрутизации не участвуют.
"""
rows = store.query(
"SELECT * FROM boards WHERE suggested = FALSE AND rules <> '{}' AND rules <> '' ORDER BY pos"
)
best: dict | None = None
best_score = 0
for r in rows:
rules = json.loads(r["rules"] or "{}")
if not match_text(rules, text):
continue
sc = score_text(rules, text) or 1
if sc > best_score:
best = {"id": r["id"], "name": r["name"], "color": r["color"], "rules": rules}
best_score = sc
return best
@@ -0,0 +1,247 @@
"""ИИ-предложения колонок по анализу «Неразобранного».
Колонка — не «на один скилл», а смысловая тема: направление + набор стека и
ключевых слов (+ бюджет при повторяемости). ИИ группирует карточки и создаёт
КОЛОНКИ-ПРЕДЛОЖЕНИЯ (suggested=TRUE) с обоснованием (note), по каким
критериям собрана. Пользователь открывает предложение, смотрит карточки и
решает: принять (можно переименовать/поправить правила) или удалить.
"""
from __future__ import annotations
import logging
import time
from ..db import store
from ..sse import broker
from . import ai as ai_svc
log = logging.getLogger("leadradar.suggest")
SUGGEST_PROMPT = """Ты — аналитик входящих заявок. Перед тобой пронумерованные сообщения (номера 1..N), которые не подошли ни под одну существующую колонку. Сгруппируй их в 2–4 ОСМЫСЛЕННЫЕ тематические колонки.
Сфера/что считается заявкой:
{domain}
Колонка — это не отдельный предмет и не каждое слово по отдельности, а направление с набором родственных признаков (тип работ/услуг, предметы, технологии, материалы). Пример: «Telegram-боты» — направление: чат-боты/автоматизация; предметы/технологии: Python, aiogram; слова: бот, telegram, автоответчик.
Правила:
- группируй повторяющиеся темы: в каждую колонку бери минимум 2 сообщения;
- не предлагай колонки, похожие на уже существующие;
- 2–4 колонки максимум; если ничего общего нет — верни пустой список.
Для каждой колонки верни:
- name — короткое название (2–4 слова);
- description — короткое описание колонки (1–2 предложения): что за заявки и для кого;
- direction — направление/тип задач (2–5 слов или фраз);
- stack — что фигурирует в заявках: предметы, услуги, технологии, материалы (1–6);
- grade — уровень/грейд, если ярко выражен (например: ["middle"]), иначе пустой список;
- keywords — 3–8 ключевых слов/фраз, по которым узнаётся такая заявка;
- messages — НОМЕРА сообщений из списка, которые относятся к этой колонке (минимум 2, максимум 12);
- reason — обоснование в 1 предложение: что за тема, сколько карточек и что в них общего.
Верни строго JSON:
{ "columns": [ { "name": "", "description": "", "direction": [""], "stack": [""], "grade": [""], "keywords": [""], "messages": [1, 5], "reason": "" } ] }"""
KEYWORDS_PROMPT = """Ты — аналитик входящих сообщений. Ниже — реальные сообщения, которые уже признаны заявками/лидами (и часть — обычный флуд каналов).
Сфера/что считается заявкой:
{domain}
Выдели общие слова-МАРКЕРЫ, по которым сообщение можно отнести к заявкам этой сферы (а не к флуду): типовые предметы/услуги/работы, глаголы-действия («куплю», «нужен», «сниму», «отремонтировать»), статусные слова («срочно», «под ключ», «бюджет»).
Верни строго JSON с плоским списком от 8 до 40 ключевых слов/фраз (слова в нижнем регистре, без знаков препинания):
{ "keywords": ["", ""] }"""
MIN_INBOX = 6 # минимум карточек в «Неразобранном» для анализа
MIN_INBOX_GROUP = 2 # минимум карточек в одной ИИ-колонке
MAX_TEXT = 12 # сколько сообщений берём в анализ (ИИ обрывает длинный JSON)
COOLDOWN_S = 20 * 60 # как часто автоматически переспрашиваем
KEY = "lastSuggestAt"
def _similar_exists(name: str) -> bool:
low = name.casefold()
rows = store.query("SELECT id, name FROM boards")
for r in rows:
n = str(r["name"]).casefold()
if low == n or low in n or n in low:
return True
return False
def _rules_for(item: dict) -> dict:
"""Правила колонки из ИИ-ответа: направление/слова/стек/грейд (любое из условий)."""
return {
"mode": "any",
"direction": [str(x).strip().lower() for x in (item.get("direction") or []) if str(x).strip()][:6],
"keywords": [str(x).strip().lower() for x in (item.get("keywords") or []) if str(x).strip()][:8],
"stack": [str(x).strip().lower() for x in (item.get("stack") or []) if str(x).strip()][:8],
"grade": [str(x).strip().lower() for x in (item.get("grade") or []) if str(x).strip()][:6],
}
async def suggest_from_inbox(force: bool = False) -> dict:
"""Анализ «Неразобранного» и создание колонок-предложений с обоснованием.
ИИ группирует пронумерованные сообщения и для каждой колонки возвращает
номера сообщений (messages). Карточки раскладываются именно по этим
номерам, а не строгим match_text по сгенерированным правилам — иначе
колонка-предложение часто остаётся пустой (правила ИИ приблизительные).
"""
# Предложения колонок — это вызов ИИ: при выключенном ИИ (aiEnabled=false,
# режим «только фильтр + ML») автоцикл не должен дёргать провайдера.
if not force and not store.get_setting("aiEnabled"):
return {"ok": False, "reason": "ИИ выключен — предложения колонок недоступны"}
if not force:
# не плодим предложения, пока пользователь не разобрался со старыми
pending = store.scalar("SELECT count(*) FROM boards WHERE suggested = TRUE")
if pending:
return {"ok": False, "reason": f"сначала решите судьбу {pending} предложенных колонок"}
last = int(store.get_setting(KEY) or 0)
if time.time() - last < COOLDOWN_S:
return {"ok": False, "reason": "недавно предлагали — подождите", "cooldown": True}
inbox = store.query(
"SELECT id, source_msg FROM leads WHERE col = 'inbox' AND source_msg <> '' "
"ORDER BY received_at DESC LIMIT ?",
[MAX_TEXT],
)
if len(inbox) < MIN_INBOX:
return {"ok": False, "reason": f"мало карточек в «Неразобранном» (нужно от {MIN_INBOX})"}
rows = [(str(r["id"]), str(r["source_msg"])) for r in inbox]
texts = [t[:180] for _, t in rows]
existing = [str(r["name"]) for r in store.query("SELECT name FROM boards WHERE suggested = FALSE ORDER BY pos")]
user = (
f"Существующие колонки: {', '.join(existing) if existing else 'нет'}\n\n"
"Сообщения:\n" + "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts, start=1))
)
try:
out = await ai_svc.chat_json(ai_svc.fill_prompt(SUGGEST_PROMPT), user, max_retries=2, max_tokens=16000)
except Exception as exc: # noqa: BLE001
log.warning("suggest failed: %s", exc)
return {"ok": False, "reason": f"ИИ недоступен: {exc}"}
cols = out.get("columns") if isinstance(out, dict) else None
if not isinstance(cols, list) or not cols:
log.info("suggest: ИИ не предложил колонок")
return {"ok": False, "reason": "ИИ не предложил колонок"}
# диагностика: что именно предложил ИИ (имена + число карточек)
log.info(
"suggest: ИИ предложил %d кандидатов: %s",
len(cols),
"; ".join(
f"{str(c.get('name'))[:40]}(msg={c.get('messages')})" for c in cols if isinstance(c, dict)
)[:300],
)
# номера, на которые уже «потратились» предыдущие колонки этого прогона
used_msg: set[int] = set()
created = 0
for item in cols[:4]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if len(name) < 2 or len(name) > 40:
continue
if _similar_exists(name):
continue
nums = [int(x) for x in (item.get("messages") or []) if str(x).isdigit()]
nums = [n for n in nums if 1 <= n <= len(rows) and n not in used_msg]
if len(nums) < MIN_INBOX_GROUP:
continue # колонка без явных карточек не создаётся
used_msg.update(nums)
rules = _rules_for(item)
note = _make_note(item, texts, rules, nums)
description = str(item.get("description") or "").strip()[:300]
board = _store_suggested(name, rules, note, description)
if not board:
continue
assigned = _assign_ids(rows, nums, board["id"])
if not assigned:
# ничего не удалось положить — пустое предложение не нужно
_rollback_suggested(board["id"])
continue
created += 1
if not created:
return {"ok": False, "reason": "похожие колонки уже есть или нечего сгруппировать"}
store.set_setting(KEY, int(time.time()))
await broker.publish("boards_changed", {})
await broker.publish_toast(f"ИИ предложил колонок: {created} — откройте и решите", "sparkles")
return {"ok": True, "created": created}
async def suggest_domain_keywords() -> dict:
"""«Предложить ключи ИИ»: по вашим карточкам выделяет общие слова-маркеры сферы.
Возвращает кандидатов — пользователь смотрит, правит и сохраняет в настройках
«Сфера и ключи» (общие ключи) или использует для правил колонок.
"""
rows = store.query(
"SELECT source_msg FROM leads WHERE col <> 'trash' AND col <> 'archive' AND source_msg <> '' "
"ORDER BY received_at DESC LIMIT 40"
)
texts = [str(r["source_msg"])[:350] for r in rows]
if len(texts) < 3:
return {"ok": False, "reason": "мало карточек — сначала накопите заявки (нужно хотя бы 3)"}
user = "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts))
try:
out = await ai_svc.chat_json(ai_svc.fill_prompt(KEYWORDS_PROMPT), user, max_retries=2)
except Exception as exc: # noqa: BLE001
log.warning("suggest keywords failed: %s", exc)
return {"ok": False, "reason": f"ИИ недоступен: {exc}"}
kws = out.get("keywords") if isinstance(out, dict) else None
if not isinstance(kws, list) or not kws:
return {"ok": False, "reason": "ИИ не смог выделить ключи — попробуйте ещё раз"}
clean: list[str] = []
for k in kws:
s = str(k).strip().casefold().strip(".,;:«»\"'()!#")
if s and len(s) <= 40 and s not in clean:
clean.append(s)
return {"ok": True, "keywords": clean[:60]}
def _make_note(item: dict, texts: list[str], rules: dict, nums: list[int]) -> str:
reason = str(item.get("reason") or "").strip()
direction = " · ".join(str(x) for x in (item.get("direction") or [])[:3])
base = f"Обоснование ИИ: {reason}" if reason else (
f"Обоснование ИИ: направление — {direction}" if direction else
"Обоснование ИИ: повторяющиеся заявки одной темы"
)
# сколько карточек из выборки реально попадёт в колонку (по номерам ИИ)
matched = sum(1 for n in nums if 1 <= n <= len(texts))
if matched:
base += f" · карточек в колонке: {matched}"
return base[:400]
def _store_suggested(name: str, rules: dict, note: str, description: str = "") -> dict | None:
from . import leads as leads_svc
keywords = rules.get("keywords") or []
board = leads_svc.create_board(
name, suggested=True, keywords=keywords, rules=rules, note=note, description=description
)
return leads_svc.board_by_id(board["id"])
def _assign_ids(rows: list[tuple[str, str]], nums: list[int], board_id: str) -> int:
"""Раскладывает в колонку-предложение карточки по номерам, которые назвал ИИ.
rows — список (id, source_msg) в том же порядке, в каком сообщения уходили
в промпт (1..N). Возвращает число реально перенесённых карточек.
"""
assigned = 0
for n in nums:
if not (1 <= n <= len(rows)):
continue
lead_id, _ = rows[n - 1]
still = store.scalar("SELECT 1 FROM leads WHERE id = ? AND col = 'inbox'", [lead_id])
if not still:
continue # карточка уже разобрана другим предложением/пользователем
store.execute(
"UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox' WHERE id = ? AND col = 'inbox'",
[board_id, lead_id],
)
assigned += 1
return assigned
def _rollback_suggested(board_id: str) -> None:
"""Удаляет пустую колонку-предложение (в неё ничего не попало)."""
from . import leads as leads_svc
store.execute("UPDATE leads SET col = 'inbox' WHERE col = ?", [board_id])
store.execute("DELETE FROM boards WHERE id = ?", [board_id])
@@ -0,0 +1,883 @@
"""Telegram-интеграция (п.4.2, 4.3 ТЗ).
api_id/api_hash берутся из настроек (введены в UI, НЕ из env). Сессия
Telethon сохраняется в файловой системе — повторная авторизация не нужна.
Вход: по телефону (+2FA) или по QR-ссылке. Сообщения из каналов с
включённым мониторингом кладутся в очередь pipeline, откуда их разбирает
фоновый воркер (стоп-фразы → ML → ИИ). В БД оседает только прошедшее
фильтры; исходное сообщение карточки хранит msg_id для «открыть исходник».
"""
from __future__ import annotations
import asyncio
import logging
import math
import random
import threading
import time
from telethon import TelegramClient, events, utils
from telethon.errors import SessionPasswordNeededError, PhoneCodeInvalidError, PhoneCodeExpiredError
from telethon.errors.rpcerrorlist import FloodWaitError
from telethon.tl import functions
from .. import config
from ..constants import DIALOG_HUES
from ..crypto import decrypt_text
from ..db import store
from ..sse import broker
from . import ban_guard
from . import pipeline
log = logging.getLogger("leadradar.tg")
BACKFILL_PER_MESSAGE = (1.5, 3.0) # секунды, чтобы не попасть под бан
BACKFILL_PER_DIALOG = (3.0, 6.0)
def _keys() -> dict:
raw = store.get_setting("tgKeys") or {}
api_hash = str(raw.get("apiHash", ""))
return {
"apiId": str(raw.get("apiId", "")).strip(),
"apiHash": (decrypt_text(api_hash) if api_hash.startswith("enc:") else api_hash),
}
# Главный event loop приложения: на нём живут Telethon-клиент и фоновые
# задачи. Синхронные роутеры FastAPI исполняются в threadpool (без running
# loop), поэтому корутины нужно планировать на этот loop через
# call_soon_threadsafe — Telethon не переносит клиент между циклами.
_MAIN_LOOP: asyncio.AbstractEventLoop | None = None
def register_main_loop(loop: asyncio.AbstractEventLoop) -> None:
global _MAIN_LOOP
_MAIN_LOOP = loop
def _spawn(coro) -> None:
"""Запустить корутину из любого контекста: running loop, главный loop или поток.
set_monitor/set_monitor_all вызываются из синхронных роутеров FastAPI, где
running loop отсутствует, поэтому прямой asyncio.create_task падает с
RuntimeError (отсюда Internal Server Error при включении канала).
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = _MAIN_LOOP
if loop is not None and loop.is_running():
loop.call_soon_threadsafe(loop.create_task, coro)
return
threading.Thread(
target=lambda: asyncio.new_event_loop().run_until_complete(coro),
daemon=True,
).start()
else:
loop.create_task(coro)
class TelegramManager:
def __init__(self) -> None:
self.client: TelegramClient | None = None
self.phase = "idle" # idle | phone | code | password | qr | ready
self.phone: str = ""
self._code_hash: str = ""
self._auth_lock = asyncio.Lock()
self._listener_task: asyncio.Task | None = None
self._monitored: set[str] = set()
self.error: str | None = None
self._disconnect_hook = None
# QR
self.qr_url: str = ""
self._qr_task: asyncio.Task | None = None
# сердцебиение статуса
self._last_connected: bool | None = None
# backfill каналов при первом подключении
self._backfilling: set[str] = set()
# ── состояние для UI ──────────────────────────────────────────────────
def status(self) -> dict:
row = store.query_one(
"SELECT count(*) AS d FROM dialogs WHERE monitor = TRUE"
)
acc = store.get_setting("tgAccount") or ""
connected = bool(self.client and self.client.is_connected())
listener_alive = bool(self._listener_task and not self._listener_task.done())
return {
"phase": self.phase,
"connected": connected,
"listener": listener_alive,
"account": acc,
"monitored": int(row["d"]) if row else 0,
"keysSet": bool(_keys().get("apiId") and _keys().get("apiHash")),
"error": self.error,
"qrUrl": self.qr_url if self.phase == "qr" else None,
}
def _client(self) -> TelegramClient:
keys = _keys()
api_id = str(keys.get("apiId") or "").strip()
api_hash = str(keys.get("apiHash") or "").strip()
if not api_id or not api_hash:
raise ValueError("Сначала сохраните Telegram api_id и api_hash в настройках")
if self.client is None:
session = str(config.SESSIONS_DIR / config.SESSION_PREFIX)
self.client = TelegramClient(session, int(api_id), api_hash)
return self.client
# ── веб-авторизация ───────────────────────────────────────────────────
async def start_phone(self, phone: str) -> None:
async with self._auth_lock:
self.phone = phone
self.error = None
try:
client = self._client()
await client.connect()
sent = await client.send_code_request(phone)
self._code_hash = sent.phone_code_hash
self.phase = "code"
except Exception as exc: # noqa: BLE001
self.phase = "idle"
self.error = str(exc)
raise
async def submit_code(self, code: str) -> None:
async with self._auth_lock:
self.error = None
client = self._client()
try:
await client.sign_in(self.phone, code, phone_code_hash=self._code_hash)
await self._finalize()
except SessionPasswordNeededError:
self.phase = "password"
except PhoneCodeInvalidError:
self.error = "Неверный код"
raise ValueError("Неверный код")
except PhoneCodeExpiredError:
self.error = "Код истёк — запросите новый"
raise ValueError("Код истёк — запросите новый")
except Exception as exc: # noqa: BLE001
self.error = str(exc)
raise
async def submit_password(self, password: str) -> None:
async with self._auth_lock:
self.error = None
try:
await self.client.sign_in(password=password)
await self._finalize()
except Exception as exc: # noqa: BLE001
self.error = "Неверный облачный пароль"
raise ValueError("Неверный облачный пароль") from exc
async def _finalize(self, notify: bool = True) -> None:
me = await self.client.get_me()
store.set_setting("tgAccount", f"@{me.username or 'user'}")
self.phase = "ready"
self._start_listener()
await self.refresh_dialogs()
self._schedule_first_backfill()
if notify:
await broker.publish_toast("Telegram подключён, сессия сохранена", "send")
await self._publish_status()
async def disconnect(self) -> None:
async with self._auth_lock:
if self._qr_task:
self._qr_task.cancel()
self._qr_task = None
if self._listener_task:
self._listener_task.cancel()
self._listener_task = None
if self.client:
try:
await self.client.disconnect()
except Exception: # noqa: BLE001
pass
self.phase = "idle"
self._monitored.clear()
self.qr_url = ""
store.set_setting("tgAccount", "")
await broker.publish_toast("Telegram отключён", "logout")
await self._publish_status()
async def auto_resume(self) -> None:
"""При старте сервера: если сессия сохранена — подключиться автоматически."""
try:
keys = _keys()
if not (keys.get("apiId") and keys.get("apiHash")):
return
client = self._client()
await client.connect()
if await client.is_user_authorized():
await self._finalize(notify=False)
except Exception as exc: # noqa: BLE001
log.info("auto_resume skipped: %s", exc)
self.phase = "idle"
# ── прослушивание ─────────────────────────────────────────────────────
def _start_listener(self) -> None:
if self._listener_task and not self._listener_task.done():
return
self._reload_monitored()
self.client.add_event_handler(self._on_message, events.NewMessage())
self._listener_task = asyncio.create_task(self.client.run_until_disconnected())
def _on_done(task: asyncio.Task) -> None:
if task.cancelled():
log.info("listener stopped (cancelled)")
return
exc = task.exception()
if exc:
log.error("listener crashed: %s", exc)
else:
log.info("listener stopped")
self._listener_task.add_done_callback(_on_done)
def _reload_monitored(self) -> None:
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE")
self._monitored = {r["id"] for r in rows}
def _dialog_id(self, message) -> str:
try:
chat_id = message.chat_id
except Exception: # noqa: BLE001
chat_id = message.peer_id
return str(chat_id)
async def _on_message(self, event) -> None:
"""Каждое сообщение мониторящегося диалога кладём в очередь pipeline."""
try:
msg = event.message
if msg is None or msg.text is None or not msg.text.strip():
return
dialog_id = self._dialog_id(event.message)
if dialog_id not in self._monitored:
return
chat = await event.get_chat()
ch_name = getattr(chat, "title", None) or getattr(chat, "first_name", "") or dialog_id
ch_handle = getattr(chat, "username", "") or ""
hue = dialog_hue(dialog_id, ch_name)
now = time.time_ns() // 1_000_000
ts = int(msg.date.timestamp() * 1000) if getattr(msg, "date", None) else now
store.execute(
"UPDATE dialogs SET last_text = ?, last_at = ?, updated_at = ? WHERE id = ?",
[msg.text.strip()[:200], now, now, dialog_id],
)
pipeline.enqueue(dialog_id, ch_name, ch_handle, hue, msg.id, msg.text, ts)
# помечаем сообщение прочитанным в Telegram, чтобы оно не висело
# «новым» в других клиентах/устройствах
try:
await self.client.send_read_acknowledge(chat)
except Exception: # noqa: BLE001
log.debug("read ack failed", exc_info=True)
except Exception as exc: # noqa: BLE001
log.exception("on_message failed: %s", exc)
# ── QR-вход ───────────────────────────────────────────────────────────
async def qr_start(self) -> str:
async with self._auth_lock:
self.error = None
client = self._client()
await client.connect()
if await client.is_user_authorized():
await self._finalize(notify=False)
return ""
if self._qr_task and not self._qr_task.done():
return self.qr_url
qr = await client.qr_login()
self.qr_url = qr.url
self.phase = "qr"
self._qr_task = asyncio.create_task(self._wait_qr(qr))
return self.qr_url
async def _wait_qr(self, qr) -> None:
try:
await qr.wait()
await self._finalize(notify=True)
except Exception as exc: # noqa: BLE001
log.warning("qr wait error: %s", exc)
self.error = str(exc)
self.phase = "idle"
finally:
self._qr_task = None
# ── статус (сердцебиение) ─────────────────────────────────────────────
async def _publish_status(self) -> None:
await broker.publish("system_status", self.status())
async def heartbeat(self) -> None:
"""Периодический вызов из планировщика: уведомляем, если Telegram «уснул»."""
connected = bool(self.client and self.client.is_connected())
if self.phase == "ready" and connected != self._last_connected:
self._last_connected = connected
await self._publish_status()
if not connected:
await broker.publish_toast("Telegram отключён — переподключение при следующей проверке", "bell")
if self.phase == "ready" and connected:
self._last_connected = True
# ── backfill: при первом подключении по 10 последних сообщений ────────
def _schedule_first_backfill(self) -> None:
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE AND backfilled = FALSE")
ids = [r["id"] for r in rows]
if ids:
asyncio.create_task(self._backfill_dialogs(ids))
async def _backfill_dialogs(self, dialog_ids: list[str], force: bool = False) -> None:
for dialog_id in dialog_ids:
if dialog_id in self._backfilling:
continue
try:
processed = await self.backfill_dialog(dialog_id, force=force)
if processed:
log.info("backfill %s: %d сообщений в очередь", dialog_id, processed)
except Exception as exc: # noqa: BLE001
log.warning("backfill %s failed: %s", dialog_id, exc)
await asyncio.sleep(random.uniform(*BACKFILL_PER_DIALOG))
async def backfill_dialog(self, dialog_id: str, force: bool = False) -> int:
"""Последние 10 сообщений канала: разбираем с паузами (анти-бан).
force=True — «Перечитать» по кнопке: даже если канал уже разобран.
Повторы карточек не создаются (защита dedup по нормализованному тексту).
"""
if dialog_id in self._backfilling:
return 0
client = self.client
if not client or not client.is_connected():
return 0
row = store.query_one("SELECT backfilled FROM dialogs WHERE id = ?", [dialog_id])
if not row or (not force and bool(row["backfilled"])):
return 0
self._backfilling.add(dialog_id)
processed = 0
try:
entity = await client.get_entity(int(dialog_id))
chat = await client.get_entity(int(dialog_id))
name = getattr(chat, "title", None) or getattr(chat, "first_name", "") or dialog_id
handle = getattr(chat, "username", "") or ""
hue = dialog_hue(dialog_id, name)
msgs = await client.get_messages(entity, limit=10)
for m in reversed(msgs): # от старых к новым, как реальный поток
if m.text is None or not m.text.strip():
continue
now = time.time_ns() // 1_000_000
ts = int(m.date.timestamp() * 1000) if m.date else now
# в очередь уходит всё; отсев сделает воркер, в БД осядет только
# то, что прошло фильтры
pipeline.enqueue(dialog_id, name, handle, hue, m.id, m.text, ts)
processed += 1
await asyncio.sleep(random.uniform(*BACKFILL_PER_MESSAGE))
# «Перечитать» — вручную вытащили сообщения: снимаем «новое» в Telegram
try:
await client.send_read_acknowledge(entity)
except Exception: # noqa: BLE001
log.debug("backfill read ack failed", exc_info=True)
store.execute("UPDATE dialogs SET backfilled = TRUE WHERE id = ?", [dialog_id])
finally:
self._backfilling.discard(dialog_id)
return processed
async def realtime_sweep(self) -> None:
"""Страховка realtime: если событие потеряно (рестарт/разрыв), раз в 30 с
докачиваем непрочитанные сообщения включённых каналов, кладём в очередь
и помечаем прочитанными."""
client = self.client
if not client or not client.is_connected():
return
self._reload_monitored()
if not self._monitored:
return
try:
dialogs = await client.get_dialogs(limit=500)
except Exception as exc: # noqa: BLE001
log.debug("realtime sweep: get_dialogs fail: %s", exc)
return
# синхронизация списка: новые каналы/группы появляются и включаются
# автоматически, удалённые исчезают (см. _persist_dialogs)
try:
entries = []
for dlg in dialogs:
ent = dlg.entity
name = dlg.name or ""
entries.append(
(
str(dlg.id),
name,
getattr(ent, "username", "") or "",
self._kind_of(ent),
dialog_hue(str(dlg.id), name),
)
)
if entries:
self._persist_dialogs(entries)
except Exception as exc: # noqa: BLE001
log.debug("realtime sweep: sync dialogs fail: %s", exc)
for dlg in dialogs:
did = str(dlg.id)
if did not in self._monitored:
continue
unread = int(getattr(dlg, "unread_count", 0) or 0)
if unread <= 0:
continue
try:
msgs = await client.get_messages(dlg.entity, limit=min(unread + 2, 10))
added = 0
for m in reversed(msgs): # от старых к новым
if m.text is None or not m.text.strip():
continue
if store.scalar(
"SELECT 1 FROM pipeline_msg WHERE dialog_id = ? AND msg_id = ?", [did, m.id]
):
continue
ent = dlg.entity
name = getattr(ent, "title", None) or getattr(ent, "first_name", "") or did
handle = getattr(ent, "username", "") or ""
hue = dialog_hue(did, name)
now = time.time_ns() // 1_000_000
ts = int(m.date.timestamp() * 1000) if getattr(m, "date", None) else now
pipeline.enqueue(did, name, handle, hue, m.id, m.text, ts)
added += 1
if added:
log.info("realtime sweep %s: +%d в очередь (потерянные события)", did, added)
await client.send_read_acknowledge(dlg.entity)
except Exception as exc: # noqa: BLE001
log.debug("realtime sweep dialog %s fail: %s", did, exc)
# ── диалоги/каналы ────────────────────────────────────────────────────
@staticmethod
def _kind_of(entity) -> str:
if getattr(entity, "broadcast", False):
return "канал"
if getattr(entity, "megagroup", False) or getattr(entity, "gigagroup", False) or getattr(entity, "group", False):
return "группа"
return "чат"
def _persist_dialogs(self, entries: list[tuple]) -> int:
"""Синхронизация списка диалогов с Telegram.
- новые чаты/каналы добавляются; авто-мониторинг новых управляется
настройкой autoMonitorNew (вкл — любой появившийся чат мониторится,
выкл — появляется отключённым);
- переименования/смена типа обновляются (monitor пользователя не трогаем);
- диалоги, которых больше нет в Telegram (вышел/удалил), удаляются.
"""
if not entries:
return 0
now = time.time_ns() // 1_000_000
auto_new = bool(store.get_setting("autoMonitorNew"))
seen: set[str] = set()
for dlg_id, name, handle, kind, hue in entries:
seen.add(dlg_id)
store.execute(
"INSERT INTO dialogs(id, name, handle, kind, hue, monitor, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET name = excluded.name, handle = excluded.handle, "
"kind = excluded.kind, hue = excluded.hue, updated_at = excluded.updated_at",
[dlg_id, name, handle, kind, hue, auto_new, now],
)
stale = store.query(
"SELECT id FROM dialogs WHERE id NOT IN (" + ",".join(["?"] * len(seen)) + ")",
list(seen),
)
if stale:
stale_ids = [r["id"] for r in stale]
store.execute(
"DELETE FROM dialogs WHERE id IN (" + ",".join(["?"] * len(stale_ids)) + ")",
stale_ids,
)
log.info("dialogs sync: удалено устаревших источников: %d", len(stale_ids))
self._reload_monitored()
return len(entries)
async def refresh_dialogs(self) -> int:
client = self._client()
if not client.is_connected():
await client.connect()
entries: list[tuple] = []
async for dialog in client.iter_dialogs(limit=500):
entity = dialog.entity
name = dialog.name or ""
handle = getattr(entity, "username", "") or ""
kind = self._kind_of(entity)
hue = dialog_hue(str(dialog.id), name)
entries.append((str(dialog.id), name, handle, kind, hue))
if entries:
self._persist_dialogs(entries)
return len(entries)
def list_dialogs(self) -> list[dict]:
rows = store.query("SELECT * FROM dialogs ORDER BY monitor DESC, name")
return [
{
"id": r["id"],
"name": r["name"],
"handle": r["handle"],
"type": r["kind"],
"hue": r["hue"],
"on": bool(r["monitor"]),
"last": {"text": r["last_text"], "time": r["last_at"]},
}
for r in rows
]
def set_monitor(self, dialog_id: str, enabled: bool) -> None:
store.execute(
"UPDATE dialogs SET monitor = ?, updated_at = ? WHERE id = ?",
[enabled, time.time_ns() // 1_000_000, dialog_id],
)
self._reload_monitored()
if enabled:
row = store.query_one("SELECT backfilled FROM dialogs WHERE id = ?", [dialog_id])
if row and not bool(row["backfilled"]):
# первое включение мониторинга: разбираем последние 10 сообщений с паузами
_spawn(self.backfill_dialog(dialog_id))
def set_monitor_all(self, enabled: bool) -> int:
"""Включить/выключить мониторинг сразу для всех диалогов.
Первое включение каждого канала разбирается последовательно (с паузами),
чтобы не попасть под бан Telegram.
"""
now = time.time_ns() // 1_000_000
rows = store.query("SELECT id, backfilled FROM dialogs")
to_backfill: list[str] = []
for r in rows:
store.execute(
"UPDATE dialogs SET monitor = ?, updated_at = ? WHERE id = ?",
[enabled, now, r["id"]],
)
if enabled and not bool(r["backfilled"]):
to_backfill.append(r["id"])
self._reload_monitored()
if enabled and to_backfill:
_spawn(self._backfill_dialogs(to_backfill))
return len(rows)
def backfill_monitored(self) -> int:
"""Кнопка «Перечитать»: последние 10 сообщений всех включённых каналов.
Разбор идёт в фоне последовательно с паузами (анти-бан), даже если
канал уже разобран (force). Включённые каналы продолжают ловить новые
сообщения в реальном времени — это ручная догонялка.
"""
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE")
ids = [r["id"] for r in rows]
if not ids:
return 0
_spawn(self._backfill_dialogs(ids, force=True))
return len(ids)
async def dialog_messages(self, dialog_id: str, limit: int = 24) -> list[dict]:
"""Последние сообщения диалога: свежие берём из Telegram, старые — из БД."""
out: list[dict] = []
client = self.client
try:
if client and client.is_connected():
entity = await client.get_entity(int(dialog_id))
msgs = await client.get_messages(entity, limit=min(limit, 30))
now = time.time_ns() // 1_000_000
for m in msgs:
if m.text:
row = store.query_one(
"SELECT id FROM messages WHERE id = ?",
[f"m_{dialog_id}_{m.id}"],
)
lead_id = None
if row:
lead_id = row.get("lead_id") or None
if not row:
store.execute(
"INSERT OR IGNORE INTO messages(id, dialog_id, text, msg_at) VALUES (?, ?, ?, ?)",
[f"m_{dialog_id}_{m.id}", dialog_id, m.text[:4000], now],
)
out.append({"id": m.id, "text": m.text, "time": m.date.timestamp() * 1000, "lead": bool(lead_id)})
# вручную вытащили сообщения — снимаем «новое» в Telegram
try:
await client.send_read_acknowledge(entity)
except Exception: # noqa: BLE001
log.debug("dialog_messages read ack failed", exc_info=True)
except Exception as exc: # noqa: BLE001
log.warning("dialog_messages tg fail: %s", exc)
if not out:
rows = store.query(
"SELECT * FROM messages WHERE dialog_id = ? ORDER BY msg_at DESC LIMIT ?",
[dialog_id, limit],
)
out = [{"id": r["id"], "text": r["text"], "time": r["msg_at"], "lead": bool(r["lead_id"])} for r in rows]
return out
# ── discovery: поиск каналов и действия (Task 4) ──────────────────────
async def discovery_search(self, q: str, limit: int = 30) -> list[dict]:
"""Глобальный поиск каналов/групп по ключу (contacts.search).
id возвращается подписанным (как в dialogs: каналы -100…, группы -id,
люди +id). Найденные entity кэшируются в сессию Telethon — тогда
discovery_info/read смогут получить участников/историю по id даже без
вступления (публичные источники).
"""
client = self.client
if not client or not client.is_connected():
raise RuntimeError("Telegram не подключён")
found = await client(functions.contacts.SearchRequest(q=q, limit=limit))
# пауза между поисковыми запросами (анти-бан, BanGuard)
await asyncio.sleep(ban_guard.search_pause())
try:
client.session.process_entities(found)
except Exception: # noqa: BLE001
log.debug("discovery_search: cache entities failed", exc_info=True)
out: list[dict] = []
seen: set[str] = set()
for ent in (*found.chats, *found.users):
try:
dialog_id = str(utils.get_peer_id(ent))
except (TypeError, ValueError):
continue
if dialog_id in seen:
continue
seen.add(dialog_id)
name = utils.get_display_name(ent) or dialog_id
out.append(
{
"id": dialog_id,
"name": name,
"username": getattr(ent, "username", "") or "",
"kind": self._kind_of(ent),
"hue": dialog_hue(dialog_id, name),
}
)
if len(out) >= limit:
break
return out
async def discovery_info(self, dialog_id: str) -> dict:
"""Инфо об источнике: тип, username, участники, признак форума.
participants берётся из full_chat (channels.getFullChannel для
каналов/супергрупп, messages.getFullChat для базовых групп); если
определить не удалось (нет членства/приватный/ошибка) — None,
исключение наружу не бросаем.
"""
result = {
"id": str(dialog_id),
"name": str(dialog_id),
"username": "",
"kind": "",
"hue": dialog_hue(str(dialog_id), str(dialog_id)),
"participants": None,
"is_forum": False,
}
client = self.client
if not client or not client.is_connected():
return result
try:
entity = await client.get_entity(int(dialog_id))
except Exception as exc: # noqa: BLE001
log.warning("discovery_info %s: entity not resolved: %s", dialog_id, exc)
return result
name = utils.get_display_name(entity) or str(dialog_id)
kind = self._kind_of(entity)
result.update(
name=name,
username=getattr(entity, "username", "") or "",
kind=kind,
hue=dialog_hue(str(dialog_id), name),
is_forum=bool(getattr(entity, "forum", False)),
)
try:
if kind in ("канал", "группа"):
full = await client(functions.channels.GetFullChannelRequest(entity))
full_chat = full.full_chat
participants = int(getattr(full_chat, "participants_count", 0) or 0)
result["participants"] = participants or None
elif getattr(entity, "title", None):
# базовая группа (legacy): полный чат приносит список участников
full = await client(functions.messages.GetFullChatRequest(entity.id))
members = getattr(
getattr(full.full_chat, "participants", None), "participants", None
)
if members:
result["participants"] = len(members)
except Exception as exc: # noqa: BLE001
log.debug("discovery_info %s: participants unavailable: %s", dialog_id, exc)
return result
async def discovery_read(self, dialog_id: str, limit: int) -> dict:
"""Последние сообщения источника для оценки (без создания карточек).
Форум (entity.forum): читаем выборку по активным темам
(channels.getForumTopics + по каждой теме get_messages(reply_to=topic_id))
и возвращаем плоский список с topic_id/topic_title. Для обычных
источников topic_id/topic_title = None. История недоступна
(приватный/закрытый источник без членства) — ok=False,
error="no_history". Исключения наружу не бросаем: ошибка в темах —
безопасный fallback на обычное чтение ленты.
"""
client = self.client
limit = max(int(limit or 0), 0)
if limit <= 0:
return {"ok": True, "error": None, "messages": []}
if not client or not client.is_connected():
return {"ok": False, "error": "no_history", "messages": []}
try:
entity = await client.get_entity(int(dialog_id))
except Exception as exc: # noqa: BLE001
log.warning("discovery_read %s: entity not resolved: %s", dialog_id, exc)
return {"ok": False, "error": "no_history", "messages": []}
if getattr(entity, "forum", False):
try:
topic_msgs = await self._read_forum_topics(client, entity, limit)
except Exception as exc: # noqa: BLE001
log.debug("discovery_read %s: forum topics fail, fallback to feed: %s", dialog_id, exc)
topic_msgs = []
if topic_msgs:
return {"ok": True, "error": None, "messages": topic_msgs}
# обычная лента (каналы/группы; для форума — General-тема)
try:
msgs = await client.get_messages(entity, limit=limit)
except Exception as exc: # noqa: BLE001
log.warning("discovery_read %s: history unavailable: %s", dialog_id, exc)
return {"ok": False, "error": "no_history", "messages": []}
now = time.time_ns() // 1_000_000
out: list[dict] = []
for m in msgs:
item = self._discovery_message_item(m, None, None, now)
if item:
out.append(item)
return {"ok": True, "error": None, "messages": out}
async def _read_forum_topics(self, client, entity, limit: int) -> list[dict]:
"""Выборка сообщений по активным темам форума.
Возвращает плоский список {id, text, date_ms, topic_id, topic_title}.
Исключения не бросает: темы, которые не прочитались, пропускаются,
вызывающий решает, делать ли fallback на обычную ленту.
"""
out: list[dict] = []
try:
res = await client(
functions.channels.GetForumTopicsRequest(
channel=entity, offset_date=0, offset_id=0, offset_topic=0, limit=5
)
)
topics = list(getattr(res, "topics", None) or [])
except Exception as exc: # noqa: BLE001
log.debug("discovery_read: getForumTopics fail: %s", exc)
return out
if not topics:
return out
# на тему минимум 3 сообщения (иначе тема почти всегда отсеется как
# «мало подходящих»), cap 10; суммарно выборка может слегка превысить limit
per_topic = min(max(3, math.ceil(limit / len(topics))), 10)
now = time.time_ns() // 1_000_000
for topic in topics:
topic_id = int(getattr(topic, "id", 0) or 0)
topic_title = getattr(topic, "title", "") or ""
if not topic_id:
continue
try:
msgs = await client.get_messages(entity, limit=per_topic, reply_to=topic_id)
except Exception as exc: # noqa: BLE001
log.debug("discovery_read: topic %s read fail: %s", topic_id, exc)
continue
for m in msgs:
item = self._discovery_message_item(m, topic_id, topic_title, now)
if item:
out.append(item)
return out
@staticmethod
def _discovery_message_item(m, topic_id, topic_title, now: int) -> dict | None:
"""Одно сообщение discovery_read: только непустой текст (как в
backfill/dialog_messages), поля {id, text, date_ms, topic_id, topic_title}."""
text = getattr(m, "text", None)
if not text or not text.strip():
return None
date = getattr(m, "date", None)
return {
"id": m.id,
"text": text,
"date_ms": int(date.timestamp() * 1000) if date else now,
"topic_id": topic_id,
"topic_title": topic_title,
}
async def discovery_join(self, username: str) -> None:
"""Вступить в канал/группу по @username (channels.JoinChannelRequest).
Ручной join из API — вне квот, без пауз; паузу перед авто-вступлением
делает воркер (ban_guard.wait_join_delay). FloodWaitError фиксируется
в BanGuard (стоп авто-вступлений до конца суток) и пробрасывается
вызывающему.
"""
username = (username or "").strip().lstrip("@")
if not username:
raise ValueError("Не указан username для вступления")
client = self.client
if not client or not client.is_connected():
raise RuntimeError("Telegram не подключён")
try:
entity = await client.get_entity(username)
await client(functions.channels.JoinChannelRequest(entity))
except FloodWaitError:
ban_guard.note_flood()
log.warning("discovery_join %s: flood — авто-вступления стоп до конца суток", username)
raise
log.info("discovery_join: вступили в @%s", username)
async def discovery_leave(self, dialog_id: str) -> None:
"""Выйти из канала/группы (channels.LeaveChannelRequest)."""
client = self.client
if not client or not client.is_connected():
raise RuntimeError("Telegram не подключён")
entity = await client.get_entity(int(dialog_id))
await client(functions.channels.LeaveChannelRequest(entity))
log.info("discovery_leave: вышли из %s", dialog_id)
def add_dialog_monitored(self, dialog_id, name, username, kind, hue) -> None:
"""Добавить источник в dialogs с monitor=TRUE (после вступления).
INSERT/UPDATE без авто-логики (как set_monitor, но без запуска
backfill): backfilled=FALSE — разбор последних сообщений подхватит
обычный механизм при первом подключении/перечитывании.
"""
now = time.time_ns() // 1_000_000
store.execute(
"INSERT INTO dialogs(id, name, handle, kind, hue, monitor, backfilled, updated_at) "
"VALUES (?, ?, ?, ?, ?, TRUE, FALSE, ?) "
"ON CONFLICT(id) DO UPDATE SET name = excluded.name, handle = excluded.handle, "
"kind = excluded.kind, hue = excluded.hue, monitor = TRUE, backfilled = FALSE, "
"updated_at = excluded.updated_at",
[
str(dialog_id),
name or str(dialog_id),
username or "",
kind or "",
hue or "#666",
now,
],
)
self._reload_monitored()
def dialog_hue(dialog_id: str, name: str = "") -> str:
h = 0
for ch in (dialog_id + name):
h = (h * 31 + ord(ch)) % len(DIALOG_HUES)
return DIALOG_HUES[h]
tg = TelegramManager()
@@ -0,0 +1,51 @@
"""SSE-брокер событий.
Сервер рассылает события подключённым браузерам:
new_lead, lead_updated, toast, reminder_due, project_updated, system_status.
Поток однонаправленный, по ТЗ — Server-Sent Events с автопереподключением.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
class Broker:
def __init__(self) -> None:
self._subscribers: set[asyncio.Queue] = set()
self._lock = asyncio.Lock()
async def subscribe(self) -> asyncio.Queue:
q: asyncio.Queue = asyncio.Queue(maxsize=200)
async with self._lock:
self._subscribers.add(q)
return q
async def unsubscribe(self, q: asyncio.Queue) -> None:
async with self._lock:
self._subscribers.discard(q)
async def publish(self, event_type: str, data: Any) -> None:
payload = f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async with self._lock:
subs = list(self._subscribers)
for q in subs:
try:
q.put_nowait(payload)
except asyncio.QueueFull:
# при переполнении сбрасываем очередь подписчика — браузер переподключится
try:
q.get_nowait()
except Exception:
pass
try:
q.put_nowait(payload)
except Exception:
pass
async def publish_toast(self, text: str, icon: str = "check") -> None:
await self.publish("toast", {"text": text, "icon": icon})
broker = Broker()