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

Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF,
CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были
LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения),
.editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize.
Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
This commit is contained in:
Rustam Khalimov
2026-09-11 19:01:42 +03:00
parent 39c9bdc1b7
commit 713d554dc2
751 changed files with 94507 additions and 94486 deletions
+32 -32
View File
@@ -1,32 +1,32 @@
# ─── Неконфиденциальные параметры (по ТЗ секреты в env не передаются) ───
# Секреты (Telegram api_id/hash, ключи AI, пароль дашборда) задаются в UI
# и хранятся в БД.
LEADRADAR_HOST=0.0.0.0
LEADRADAR_PORT=8000
# Пути (внутри контейнера монтируются в том ./data)
LEADRADAR_DATA=/data
LEADRADAR_DB_NAME=leadradar.duckdb
# Опционально: стабильный секрет подписи сессий (иначе создаётся сам
# и сохраняется в data/session_secret.key). Для многоузлового деплоя задайте.
# LEADRADAR_SESSION_SECRET=
# Учётные данные первого входа (если не заданы — admin/admin)
LEADRADAR_BOOTSTRAP_LOGIN=admin
LEADRADAR_BOOTSTRAP_PASSWORD=admin
LEADRADAR_LOG_LEVEL=INFO
# ─── Ключ шифрования секретов БД (Telegram api_hash, ключи AI) ─────────
# Обязателен для продакшена; для локальной разработки без него создаётся
# файл data/encryption.key (см. backend/app/crypto.py).
LEADRADAR_ENCRYPTION_KEY=
# ─── MinIO (вложения). Креды — в env (по договорённости) ─────────────────
LEADRADAR_MINIO_ENDPOINT=minio:9000
LEADRADAR_MINIO_ACCESS_KEY=leadradar
LEADRADAR_MINIO_SECRET_KEY=leadradar-secret
LEADRADAR_MINIO_BUCKET=leadradar
LEADRADAR_MINIO_SECURE=false
# ─── Неконфиденциальные параметры (по ТЗ секреты в env не передаются) ───
# Секреты (Telegram api_id/hash, ключи AI, пароль дашборда) задаются в UI
# и хранятся в БД.
LEADRADAR_HOST=0.0.0.0
LEADRADAR_PORT=8000
# Пути (внутри контейнера монтируются в том ./data)
LEADRADAR_DATA=/data
LEADRADAR_DB_NAME=leadradar.duckdb
# Опционально: стабильный секрет подписи сессий (иначе создаётся сам
# и сохраняется в data/session_secret.key). Для многоузлового деплоя задайте.
# LEADRADAR_SESSION_SECRET=
# Учётные данные первого входа (если не заданы — admin/admin)
LEADRADAR_BOOTSTRAP_LOGIN=admin
LEADRADAR_BOOTSTRAP_PASSWORD=admin
LEADRADAR_LOG_LEVEL=INFO
# ─── Ключ шифрования секретов БД (Telegram api_hash, ключи AI) ─────────
# Обязателен для продакшена; для локальной разработки без него создаётся
# файл data/encryption.key (см. backend/app/crypto.py).
LEADRADAR_ENCRYPTION_KEY=
# ─── MinIO (вложения). Креды — в env (по договорённости) ─────────────────
LEADRADAR_MINIO_ENDPOINT=minio:9000
LEADRADAR_MINIO_ACCESS_KEY=leadradar
LEADRADAR_MINIO_SECRET_KEY=leadradar-secret
LEADRADAR_MINIO_BUCKET=leadradar
LEADRADAR_MINIO_SECURE=false
+24 -24
View File
@@ -1,24 +1,24 @@
# Архив: legacy LeadRadar
Здесь лежит прототип **LeadRadar** (Python), который предшествовал проекту **«Дейл»**.
Он больше не используется и не собирается; оставлен только как историческая справка.
Перемещено 2026-09-10 (из корня репозитория), чтобы не путаться с актуальным стеком:
- `backend/` — прежний Python-бэкенд (FastAPI) прототипа.
- `mlservice/` — прежний Python-сервис ML прототипа.
- `docker-compose.yml` — прежний compose прототипа (DuckDB/MinIO/Python), ссылается на `backend/`/`mlservice/`.
- `.ruff_cache/` — кэш линтера Python прототипа.
- `data/` — рабочие данные прототипа (DuckDB-дампы, сессии, логи, `encryption.key`).
- `.env`, `.env.example` — env прототипа (`LEADRADAR_*`).
- `ТЗ-LeadRadar-v1.2.md` — исходное ТЗ прототипа LeadRadar V1.2 (DuckDB/FastAPI).
**Актуальное ТЗ «Дейла»**`docs/spec/ТЗ-дейл-новая-архитектура.md` (единственный канонический ТЗ).
**Актуальный стек «Дейл»:**
- Код — `src/{core,frontend,ai-service,ml-service,telegram-service,grpc-hosting,contracts}`.
- Запуск — `deploy/compose.dev.yml` (dev) / `deploy/compose.prod.yml` (prod).
- Документация — `docs/` (ТЗ, инструкция, техдок, api-map), планы/ledgers — `docs/superpowers/`, `.superpowers/sdd/`.
Удалять этот архив без необходимости не нужно; он не влияет на сборку и запуск.
# Архив: legacy LeadRadar
Здесь лежит прототип **LeadRadar** (Python), который предшествовал проекту **«Дейл»**.
Он больше не используется и не собирается; оставлен только как историческая справка.
Перемещено 2026-09-10 (из корня репозитория), чтобы не путаться с актуальным стеком:
- `backend/` — прежний Python-бэкенд (FastAPI) прототипа.
- `mlservice/` — прежний Python-сервис ML прототипа.
- `docker-compose.yml` — прежний compose прототипа (DuckDB/MinIO/Python), ссылается на `backend/`/`mlservice/`.
- `.ruff_cache/` — кэш линтера Python прототипа.
- `data/` — рабочие данные прототипа (DuckDB-дампы, сессии, логи, `encryption.key`).
- `.env`, `.env.example` — env прототипа (`LEADRADAR_*`).
- `ТЗ-LeadRadar-v1.2.md` — исходное ТЗ прототипа LeadRadar V1.2 (DuckDB/FastAPI).
**Актуальное ТЗ «Дейла»**`docs/spec/ТЗ-дейл-новая-архитектура.md` (единственный канонический ТЗ).
**Актуальный стек «Дейл»:**
- Код — `src/{core,frontend,ai-service,ml-service,telegram-service,grpc-hosting,contracts}`.
- Запуск — `deploy/compose.dev.yml` (dev) / `deploy/compose.prod.yml` (prod).
- Документация — `docs/` (ТЗ, инструкция, техдок, api-map), планы/ledgers — `docs/superpowers/`, `.superpowers/sdd/`.
Удалять этот архив без необходимости не нужно; он не влияет на сборку и запуск.
+5 -5
View File
@@ -1,5 +1,5 @@
__pycache__/
*.pyc
data/
*.duckdb
.env
__pycache__/
*.pyc
data/
*.duckdb
.env
+24 -24
View File
@@ -1,24 +1,24 @@
# ─── Этап 1: сборка фронтенда ─────────────────────────────────────────────
FROM node:22-alpine AS frontend
WORKDIR /fe
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
# ─── Этап 2: бэкенд + статика фронта ──────────────────────────────────────
FROM python:3.12-slim
WORKDIR /srv
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/app ./app
COPY --from=frontend /fe/dist ./frontend_dist
ENV LEADRADAR_FRONTEND_DIST=/srv/frontend_dist \
LEADRADAR_DATA=/data \
PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# ─── Этап 1: сборка фронтенда ─────────────────────────────────────────────
FROM node:22-alpine AS frontend
WORKDIR /fe
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
# ─── Этап 2: бэкенд + статика фронта ──────────────────────────────────────
FROM python:3.12-slim
WORKDIR /srv
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/app ./app
COPY --from=frontend /fe/dist ./frontend_dist
ENV LEADRADAR_FRONTEND_DIST=/srv/frontend_dist \
LEADRADAR_DATA=/data \
PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -1 +1 @@
"""LeadRadar backend (FastAPI + DuckDB)."""
"""LeadRadar backend (FastAPI + DuckDB)."""
+98 -98
View File
@@ -1,98 +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
"""Авторизация в дашборд: 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
+99 -99
View File
@@ -1,99 +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
"""Конфигурация из переменных окружения.
По ТЗ в 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
+253 -253
View File
@@ -1,253 +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
"""Базовые константы: палитры, стадии, промпты, валюты, провайдеры 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
+70 -70
View File
@@ -1,70 +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()
"""Шифрование секретов, хранимых в БД (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 -407
View File
@@ -1,407 +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()
"""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()
+216 -216
View File
@@ -1,216 +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)
"""Точка входа 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)
@@ -1 +1 @@
"""Пакет роутеров API."""
"""Пакет роутеров API."""
@@ -1,65 +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}
"""Вход в дашборд и сессии (п.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}
@@ -1,409 +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()
"""Дашборд: доски, колонки, карточки, поиск, правила хранения."""
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()
@@ -1,285 +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)}
"""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)}
@@ -1,38 +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",
},
)
"""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",
},
)
@@ -1,171 +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
"""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
@@ -1,74 +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
"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев)."""
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
@@ -1,211 +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}
"""«Выбранные»: проектные карточки, стадии, история, напоминания, вложения."""
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}
@@ -1,243 +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,
}
"""Настройки, 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,
}
@@ -1,154 +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}
"""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}
@@ -1 +1 @@
"""Сервисный слой."""
"""Сервисный слой."""
@@ -1,357 +1,357 @@
"""AI-классификатор поверх выбранного провайдера.
Провайдеров много (облако + локальные OpenAI-совместимые + Anthropic),
активен один. Всё сведено к двум задачам:
* filter_incoming(text) — этап 2 фильтра входящих (промпт aiFilterPrompt);
* classify(text, boards) — разбор лида (промпт aiPrompt + обучающие примеры).
Ответы моделей строго JSON; распознаём и обрезаем markdown-обёртки ```json.
"""
from __future__ import annotations
import json
import logging
import re
import httpx
from .. import constants as C
from ..crypto import decrypt_text
from ..db import store
from .rates import convert_amount
log = logging.getLogger("leadradar.ai")
def _cfg() -> tuple[str, dict]:
provider_id = store.get_setting("aiProvider") or "deepseek"
configs = store.get_setting("aiConfigs") or {}
meta = next((p for p in C.AI_PROVIDERS if p["id"] == provider_id), None) or C.AI_PROVIDERS[0]
raw = configs.get(provider_id, {})
cfg = dict(raw)
if cfg.get("apiKey"):
cfg["apiKey"] = decrypt_text(str(cfg["apiKey"]))
return provider_id, {"meta": meta, "cfg": cfg}
def provider_status() -> dict:
"""Статус для UI: кто активен, есть ли ключ, base, модель (ключ маскируется)."""
provider_id, data = _cfg()
meta = data["meta"]
cfg = data["cfg"]
key = str(cfg.get("apiKey") or "")
return {
"provider": provider_id,
"name": meta["name"],
"base": cfg.get("baseUrl") or meta["base"],
"model": cfg.get("model") or (meta["models"] or [""])[0],
"local": bool(meta.get("local")),
"keySet": bool(key),
"keyMasked": mask_key(key),
}
def mask_key(key: str) -> str:
if not key:
return ""
if len(key) <= 8:
return key[0] + ""
return f"{key[:4]}{key[-4:]}"
# ── «Сфера и ключи»: подстановка в промпты из настроек пользователя ────────
def fill_prompt(prompt: str) -> str:
"""Заменяет в тексте промпта {domain} и {keywords} значениями из настроек.
Настройки задаются в UI (вкладка «Сфера и ключи»), поэтому ИИ-классификатор
работает для любой сферы: разработка, дизайн, недвижимость, стройка и т.п.
"""
domain = str(store.get_setting("domainDescription") or "").strip()
if not domain:
domain = "Универсально: заявка = конкретный запрос на услугу/товар/работу или найм человека."
raw_kws = store.get_setting("domainKeywords") or []
if isinstance(raw_kws, str):
raw_kws = [raw_kws]
kws = [str(k).strip() for k in raw_kws if str(k).strip()]
kws_text = ", ".join(kws[:60]) if kws else "(не заданы — определяй по тексту)"
return (prompt or "").replace("{domain}", domain).replace("{keywords}", kws_text)
async def chat_json(system: str, user: str, max_retries: int = 2, max_tokens: int = 8000) -> dict:
"""Единая точка вызова выбранной модели. Возвращает dict (JSON-ответ).
Модель иногда отвечает HTTP 200 с пустым/не-JSON содержимым (особенно
на больших запросах или при перегрузке API) — делаем несколько попыток
с нарастающей паузой, чтобы не сыпать «ИИ недоступен» из-за разового сбоя.
"""
provider_id, data = _cfg()
meta = data["meta"]
cfg = data["cfg"]
base = str(cfg.get("baseUrl") or meta["base"]).rstrip("/")
model = cfg.get("model") or (meta["models"] or [""])[0]
api_key = str(cfg.get("apiKey") or "")
last_err: Exception | None = None
last_raw = ""
for attempt in range(max_retries + 1):
try:
if meta.get("api_style") == "anthropic":
text = await _call_anthropic(base, api_key, model, system, user, max_tokens)
else:
text = await _call_openai(base, api_key, model, system, user, max_tokens)
last_raw = text
return extract_json(text)
except Exception as exc: # noqa: BLE001 — пробуем ещё раз
last_err = exc
if attempt < max_retries:
await asyncio_sleep(0.8 + 1.2 * attempt) # 0.8с, 2с, 3.2с
log.warning(
"AI call failed (%s, попыток %d): %s | ответ: %r",
meta["name"],
max_retries + 1,
last_err,
last_raw[:200],
)
raise RuntimeError(
f"ИИ ({meta['name']}) не ответил корректно — повторите попытку через несколько секунд"
)
async def asyncio_sleep(secs: float) -> None:
import asyncio
await asyncio.sleep(secs)
async def _call_openai(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str:
url = base + "/chat/completions"
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
body = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.2,
"max_tokens": max_tokens,
}
async with httpx.AsyncClient(timeout=90) as client:
resp = await client.post(url, headers=headers, json=body)
resp.raise_for_status()
payload = resp.json()
try:
msg = payload["choices"][0]["message"]
except (KeyError, IndexError, TypeError):
raise ValueError(f"неожиданный ответ API: {str(payload)[:200]}")
content = msg.get("content")
if not content and msg.get("reasoning_content"):
# модель «подумала», но не дала ответа (переполнение/обрыв) — считаем сбоем и повторим
raise ValueError("модель вернула только reasoning без ответа")
return str(content or "")
async def _call_anthropic(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str:
url = base.rstrip("/") + "/v1/messages"
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
body = {
"model": model,
"max_tokens": max_tokens,
"system": system,
"messages": [{"role": "user", "content": user}],
}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, headers=headers, json=body)
resp.raise_for_status()
payload = resp.json()
return "".join(blk.get("text", "") for blk in payload.get("content", []))
def extract_json(text: str) -> dict:
raw = text.strip()
fence = re.search(r"```(?:json)?\s*(.*?)```", raw, re.S)
if fence:
raw = fence.group(1).strip()
start, end = raw.find("{"), raw.rfind("}")
if start >= 0 and end > start:
raw = raw[start : end + 1]
return json.loads(raw)
# ── Задачи ────────────────────────────────────────────────────────────────
async def filter_incoming(text: str) -> dict:
"""Этап 2 (ИИ-фильтр). Если выключен — считаем пропущенным."""
if not store.get_setting("aiFilterEnabled"):
return {"pass": True, "reason": None, "skipped": True}
prompt = fill_prompt(store.get_setting("aiFilterPrompt"))
out = await chat_json(prompt, f"Сообщение:\n{text[:4000]}")
return {
"pass": bool(out.get("pass", True)),
"reason": out.get("reason"),
"skipped": False,
}
def _learning_examples(limit: int = 8) -> list[dict]:
"""Примеры разметки пользователя для few-shot классификации."""
rows = store.query(
"SELECT l.source_msg AS msg, ll.to_col "
"FROM learning_log ll JOIN leads l ON l.id = ll.lead_id "
"WHERE ll.action IN ('move','restore') AND l.source_msg <> '' "
"ORDER BY ll.created_at DESC LIMIT ?",
[limit],
)
examples = []
for r in rows:
if r["to_col"] in ("trash", "archive"):
continue
examples.append({"text": (r["msg"] or "")[:500], "board": r["to_col"]})
return examples
async def classify(message_text: str) -> dict:
"""Полный разбор лида. Возвращает сырой словарь модели.
Колонка для ИИ — это набор критериев (правила), а не просто название:
в промпт уходят правила колонок, чтобы модель выбирала осмысленно.
"""
from .rules import describe as describe_rules
# в классификации участвуют только принятые колонки (не ИИ-предложения)
boards = store.query(
"SELECT id, name, description, keywords, rules FROM boards WHERE suggested = FALSE ORDER BY pos"
)
lines = []
for b in boards:
rules = json.loads(b["rules"] or "{}") if b["rules"] else {}
has_rules = any(rules.get(k) for k in ("direction", "keywords", "stack", "grade", "budget"))
if has_rules:
suffix = f" (критерии: {describe_rules(rules)})"
else:
kws = json.loads(b["keywords"] or "[]")[:8]
suffix = f" (ключевые слова: {', '.join(kws)})" if kws else ""
line = f"- {b['id']}: {b['name']}{suffix}"
if b.get("description"):
line += f"{str(b['description']).strip()[:160]}"
lines.append(line)
board_map = "\n".join(lines) or "- (колонок пока нет — верните board: null)"
examples = _learning_examples()
user = (
f"Доски: {board_map}\n\n"
+ ("Примеры разметки пользователя:\n" + "\n".join(
f"текст: {e['text']}\n→ колонка: {e['board']}" for e in examples
) + "\n\n" if examples else "")
+ f"Новое сообщение:\n{message_text[:5000]}"
)
prompt = fill_prompt(store.get_setting("aiPrompt"))
# отдельный промпт структуры карточки («О заявке») — задаёт поля контента,
# чтобы все карточки имели одинаковую структуру текста
card = fill_prompt(store.get_setting("cardPrompt") or "")
if card:
prompt = prompt + "\n\n" + card
return await chat_json(prompt, user)
def normalize_dedup(text: str) -> str:
"""Дедупликация по нормализованному тексту (регистр и спецсимволы)."""
import hashlib
import re as _re
norm = _re.sub(r"[^\wа-яё]+", "", text.casefold())
return hashlib.sha1(norm.encode()).hexdigest()
# Синонимы валют из ответов ИИ → коды хранения (согласовано с rules._CUR_*)
_CUR_ALIASES = {
"USD": "USD", "$": "USD", "US$": "USD", "ДОЛЛАР": "USD", "ДОЛЛАРОВ": "USD", "ДОЛЛ": "USD", "БАКС": "USD", "БАКСОВ": "USD",
"EUR": "EUR", "": "EUR", "ЕВРО": "EUR",
"RUB": "RUB", "RUR": "RUB", "": "RUB", "РУБ": "RUB", "РУБЛЕЙ": "RUB", "РУБЛИ": "RUB", "РУБЛЬ": "RUB", "РУБЛЯ": "RUB",
"GBP": "GBP", "£": "GBP", "CNY": "CNY", "¥": "CNY", "USDT": "USDT", "": "USDT",
}
def _norm_currency(raw) -> str | None:
"""Приводит название/символ валюты из ИИ к коду (USD/EUR/RUB/…)."""
s = str(raw or "").strip().upper()
if not s:
return None
if s in _CUR_ALIASES:
return _CUR_ALIASES[s]
letters = re.sub(r"[^A-ZА-Я]", "", s)
if letters in _CUR_ALIASES:
return _CUR_ALIASES[letters]
if len(s) == 3 and s.isalpha():
return s
return None
def _budget_num(v):
"""Число из значения бюджета ИИ: «2к»/«2К» → 2000, «2000₽»/«2000р» → 2000."""
if v is None:
return None
s = str(v).strip().casefold().replace("\u00a0", "").replace(" ", "")
if not s:
return None
mult = 1.0
if s.endswith(("к", "k")):
mult = 1000.0
s = s[:-1]
if s.endswith("руб"):
s = s[:-3]
elif s.endswith(("р", "")):
s = s[:-1]
try:
x = float(s.replace(",", ".")) * mult
except ValueError:
return None
return None if x == 0 else x
def clean_budget(budget) -> dict | None:
"""Нормализация бюджета из ИИ/локального разбора.
Соглашение хранения (и отображения в UI):
одна сумма X → {from: X, to: X}
«до X» → {from: None, to: X}
диапазон «от X до Y» → {from: X, to: Y}
from=0 трактуется как отсутствие нижней границы («от 0 до X» == «до X»).
"""
if not isinstance(budget, dict):
return None
cur = _norm_currency(budget.get("currency") or budget.get("cur"))
if not cur:
return None
def num(v):
return _budget_num(v)
f, t = num(budget.get("from")), num(budget.get("to"))
if f is None and t is None:
return None
if t is None:
t = f # одна сумма или «от X» без верхней границы
return {"from": f, "to": t, "currency": cur}
def budget_to_target(budget: dict | None) -> dict:
"""Пересчёт «один раз при поступлении» в целевую валюту по текущим курсам."""
if not budget or not budget.get("currency"):
return {"convFrom": None, "convTo": None, "convCur": ""}
cur = budget["currency"]
target = store.get_setting("targetCurrency") or "RUB"
if not store.get_setting("conversionOn"):
return {"convFrom": None, "convTo": None, "convCur": ""}
from_, to_ = budget.get("from"), budget.get("to")
c_from = convert_amount(from_, cur, target) if from_ is not None else None
c_to = None
if to_ is not None:
c_to = convert_amount(to_, cur, target)
elif from_ is not None:
c_to = c_from
return {"convFrom": c_from, "convTo": c_to, "convCur": target}
"""AI-классификатор поверх выбранного провайдера.
Провайдеров много (облако + локальные OpenAI-совместимые + Anthropic),
активен один. Всё сведено к двум задачам:
* filter_incoming(text) — этап 2 фильтра входящих (промпт aiFilterPrompt);
* classify(text, boards) — разбор лида (промпт aiPrompt + обучающие примеры).
Ответы моделей строго JSON; распознаём и обрезаем markdown-обёртки ```json.
"""
from __future__ import annotations
import json
import logging
import re
import httpx
from .. import constants as C
from ..crypto import decrypt_text
from ..db import store
from .rates import convert_amount
log = logging.getLogger("leadradar.ai")
def _cfg() -> tuple[str, dict]:
provider_id = store.get_setting("aiProvider") or "deepseek"
configs = store.get_setting("aiConfigs") or {}
meta = next((p for p in C.AI_PROVIDERS if p["id"] == provider_id), None) or C.AI_PROVIDERS[0]
raw = configs.get(provider_id, {})
cfg = dict(raw)
if cfg.get("apiKey"):
cfg["apiKey"] = decrypt_text(str(cfg["apiKey"]))
return provider_id, {"meta": meta, "cfg": cfg}
def provider_status() -> dict:
"""Статус для UI: кто активен, есть ли ключ, base, модель (ключ маскируется)."""
provider_id, data = _cfg()
meta = data["meta"]
cfg = data["cfg"]
key = str(cfg.get("apiKey") or "")
return {
"provider": provider_id,
"name": meta["name"],
"base": cfg.get("baseUrl") or meta["base"],
"model": cfg.get("model") or (meta["models"] or [""])[0],
"local": bool(meta.get("local")),
"keySet": bool(key),
"keyMasked": mask_key(key),
}
def mask_key(key: str) -> str:
if not key:
return ""
if len(key) <= 8:
return key[0] + ""
return f"{key[:4]}{key[-4:]}"
# ── «Сфера и ключи»: подстановка в промпты из настроек пользователя ────────
def fill_prompt(prompt: str) -> str:
"""Заменяет в тексте промпта {domain} и {keywords} значениями из настроек.
Настройки задаются в UI (вкладка «Сфера и ключи»), поэтому ИИ-классификатор
работает для любой сферы: разработка, дизайн, недвижимость, стройка и т.п.
"""
domain = str(store.get_setting("domainDescription") or "").strip()
if not domain:
domain = "Универсально: заявка = конкретный запрос на услугу/товар/работу или найм человека."
raw_kws = store.get_setting("domainKeywords") or []
if isinstance(raw_kws, str):
raw_kws = [raw_kws]
kws = [str(k).strip() for k in raw_kws if str(k).strip()]
kws_text = ", ".join(kws[:60]) if kws else "(не заданы — определяй по тексту)"
return (prompt or "").replace("{domain}", domain).replace("{keywords}", kws_text)
async def chat_json(system: str, user: str, max_retries: int = 2, max_tokens: int = 8000) -> dict:
"""Единая точка вызова выбранной модели. Возвращает dict (JSON-ответ).
Модель иногда отвечает HTTP 200 с пустым/не-JSON содержимым (особенно
на больших запросах или при перегрузке API) — делаем несколько попыток
с нарастающей паузой, чтобы не сыпать «ИИ недоступен» из-за разового сбоя.
"""
provider_id, data = _cfg()
meta = data["meta"]
cfg = data["cfg"]
base = str(cfg.get("baseUrl") or meta["base"]).rstrip("/")
model = cfg.get("model") or (meta["models"] or [""])[0]
api_key = str(cfg.get("apiKey") or "")
last_err: Exception | None = None
last_raw = ""
for attempt in range(max_retries + 1):
try:
if meta.get("api_style") == "anthropic":
text = await _call_anthropic(base, api_key, model, system, user, max_tokens)
else:
text = await _call_openai(base, api_key, model, system, user, max_tokens)
last_raw = text
return extract_json(text)
except Exception as exc: # noqa: BLE001 — пробуем ещё раз
last_err = exc
if attempt < max_retries:
await asyncio_sleep(0.8 + 1.2 * attempt) # 0.8с, 2с, 3.2с
log.warning(
"AI call failed (%s, попыток %d): %s | ответ: %r",
meta["name"],
max_retries + 1,
last_err,
last_raw[:200],
)
raise RuntimeError(
f"ИИ ({meta['name']}) не ответил корректно — повторите попытку через несколько секунд"
)
async def asyncio_sleep(secs: float) -> None:
import asyncio
await asyncio.sleep(secs)
async def _call_openai(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str:
url = base + "/chat/completions"
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
body = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.2,
"max_tokens": max_tokens,
}
async with httpx.AsyncClient(timeout=90) as client:
resp = await client.post(url, headers=headers, json=body)
resp.raise_for_status()
payload = resp.json()
try:
msg = payload["choices"][0]["message"]
except (KeyError, IndexError, TypeError):
raise ValueError(f"неожиданный ответ API: {str(payload)[:200]}")
content = msg.get("content")
if not content and msg.get("reasoning_content"):
# модель «подумала», но не дала ответа (переполнение/обрыв) — считаем сбоем и повторим
raise ValueError("модель вернула только reasoning без ответа")
return str(content or "")
async def _call_anthropic(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str:
url = base.rstrip("/") + "/v1/messages"
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
body = {
"model": model,
"max_tokens": max_tokens,
"system": system,
"messages": [{"role": "user", "content": user}],
}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, headers=headers, json=body)
resp.raise_for_status()
payload = resp.json()
return "".join(blk.get("text", "") for blk in payload.get("content", []))
def extract_json(text: str) -> dict:
raw = text.strip()
fence = re.search(r"```(?:json)?\s*(.*?)```", raw, re.S)
if fence:
raw = fence.group(1).strip()
start, end = raw.find("{"), raw.rfind("}")
if start >= 0 and end > start:
raw = raw[start : end + 1]
return json.loads(raw)
# ── Задачи ────────────────────────────────────────────────────────────────
async def filter_incoming(text: str) -> dict:
"""Этап 2 (ИИ-фильтр). Если выключен — считаем пропущенным."""
if not store.get_setting("aiFilterEnabled"):
return {"pass": True, "reason": None, "skipped": True}
prompt = fill_prompt(store.get_setting("aiFilterPrompt"))
out = await chat_json(prompt, f"Сообщение:\n{text[:4000]}")
return {
"pass": bool(out.get("pass", True)),
"reason": out.get("reason"),
"skipped": False,
}
def _learning_examples(limit: int = 8) -> list[dict]:
"""Примеры разметки пользователя для few-shot классификации."""
rows = store.query(
"SELECT l.source_msg AS msg, ll.to_col "
"FROM learning_log ll JOIN leads l ON l.id = ll.lead_id "
"WHERE ll.action IN ('move','restore') AND l.source_msg <> '' "
"ORDER BY ll.created_at DESC LIMIT ?",
[limit],
)
examples = []
for r in rows:
if r["to_col"] in ("trash", "archive"):
continue
examples.append({"text": (r["msg"] or "")[:500], "board": r["to_col"]})
return examples
async def classify(message_text: str) -> dict:
"""Полный разбор лида. Возвращает сырой словарь модели.
Колонка для ИИ — это набор критериев (правила), а не просто название:
в промпт уходят правила колонок, чтобы модель выбирала осмысленно.
"""
from .rules import describe as describe_rules
# в классификации участвуют только принятые колонки (не ИИ-предложения)
boards = store.query(
"SELECT id, name, description, keywords, rules FROM boards WHERE suggested = FALSE ORDER BY pos"
)
lines = []
for b in boards:
rules = json.loads(b["rules"] or "{}") if b["rules"] else {}
has_rules = any(rules.get(k) for k in ("direction", "keywords", "stack", "grade", "budget"))
if has_rules:
suffix = f" (критерии: {describe_rules(rules)})"
else:
kws = json.loads(b["keywords"] or "[]")[:8]
suffix = f" (ключевые слова: {', '.join(kws)})" if kws else ""
line = f"- {b['id']}: {b['name']}{suffix}"
if b.get("description"):
line += f"{str(b['description']).strip()[:160]}"
lines.append(line)
board_map = "\n".join(lines) or "- (колонок пока нет — верните board: null)"
examples = _learning_examples()
user = (
f"Доски: {board_map}\n\n"
+ ("Примеры разметки пользователя:\n" + "\n".join(
f"текст: {e['text']}\n→ колонка: {e['board']}" for e in examples
) + "\n\n" if examples else "")
+ f"Новое сообщение:\n{message_text[:5000]}"
)
prompt = fill_prompt(store.get_setting("aiPrompt"))
# отдельный промпт структуры карточки («О заявке») — задаёт поля контента,
# чтобы все карточки имели одинаковую структуру текста
card = fill_prompt(store.get_setting("cardPrompt") or "")
if card:
prompt = prompt + "\n\n" + card
return await chat_json(prompt, user)
def normalize_dedup(text: str) -> str:
"""Дедупликация по нормализованному тексту (регистр и спецсимволы)."""
import hashlib
import re as _re
norm = _re.sub(r"[^\wа-яё]+", "", text.casefold())
return hashlib.sha1(norm.encode()).hexdigest()
# Синонимы валют из ответов ИИ → коды хранения (согласовано с rules._CUR_*)
_CUR_ALIASES = {
"USD": "USD", "$": "USD", "US$": "USD", "ДОЛЛАР": "USD", "ДОЛЛАРОВ": "USD", "ДОЛЛ": "USD", "БАКС": "USD", "БАКСОВ": "USD",
"EUR": "EUR", "": "EUR", "ЕВРО": "EUR",
"RUB": "RUB", "RUR": "RUB", "": "RUB", "РУБ": "RUB", "РУБЛЕЙ": "RUB", "РУБЛИ": "RUB", "РУБЛЬ": "RUB", "РУБЛЯ": "RUB",
"GBP": "GBP", "£": "GBP", "CNY": "CNY", "¥": "CNY", "USDT": "USDT", "": "USDT",
}
def _norm_currency(raw) -> str | None:
"""Приводит название/символ валюты из ИИ к коду (USD/EUR/RUB/…)."""
s = str(raw or "").strip().upper()
if not s:
return None
if s in _CUR_ALIASES:
return _CUR_ALIASES[s]
letters = re.sub(r"[^A-ZА-Я]", "", s)
if letters in _CUR_ALIASES:
return _CUR_ALIASES[letters]
if len(s) == 3 and s.isalpha():
return s
return None
def _budget_num(v):
"""Число из значения бюджета ИИ: «2к»/«2К» → 2000, «2000₽»/«2000р» → 2000."""
if v is None:
return None
s = str(v).strip().casefold().replace("\u00a0", "").replace(" ", "")
if not s:
return None
mult = 1.0
if s.endswith(("к", "k")):
mult = 1000.0
s = s[:-1]
if s.endswith("руб"):
s = s[:-3]
elif s.endswith(("р", "")):
s = s[:-1]
try:
x = float(s.replace(",", ".")) * mult
except ValueError:
return None
return None if x == 0 else x
def clean_budget(budget) -> dict | None:
"""Нормализация бюджета из ИИ/локального разбора.
Соглашение хранения (и отображения в UI):
одна сумма X → {from: X, to: X}
«до X» → {from: None, to: X}
диапазон «от X до Y» → {from: X, to: Y}
from=0 трактуется как отсутствие нижней границы («от 0 до X» == «до X»).
"""
if not isinstance(budget, dict):
return None
cur = _norm_currency(budget.get("currency") or budget.get("cur"))
if not cur:
return None
def num(v):
return _budget_num(v)
f, t = num(budget.get("from")), num(budget.get("to"))
if f is None and t is None:
return None
if t is None:
t = f # одна сумма или «от X» без верхней границы
return {"from": f, "to": t, "currency": cur}
def budget_to_target(budget: dict | None) -> dict:
"""Пересчёт «один раз при поступлении» в целевую валюту по текущим курсам."""
if not budget or not budget.get("currency"):
return {"convFrom": None, "convTo": None, "convCur": ""}
cur = budget["currency"]
target = store.get_setting("targetCurrency") or "RUB"
if not store.get_setting("conversionOn"):
return {"convFrom": None, "convTo": None, "convCur": ""}
from_, to_ = budget.get("from"), budget.get("to")
c_from = convert_amount(from_, cur, target) if from_ is not None else None
c_to = None
if to_ is not None:
c_to = convert_amount(to_, cur, target)
elif from_ is not None:
c_to = c_from
return {"convFrom": c_from, "convTo": c_to, "convCur": target}
@@ -1,80 +1,80 @@
"""BanGuard: квоты, паузы и защита от flood для авто-вступлений Discovery.
Суточный лимит (discJoinLimit) считается по disc_log (event='join_auto')
за текущие UTC-сутки; между вступлениями выдерживается случайная пауза
(discJoinDelayMin/Max). При FloodWait от Telegram ставится блокировка до
конца суток (discFloodDay), плюс есть ручной стоп-кран (discPaused).
"""
from __future__ import annotations
import asyncio
import random
from datetime import datetime, timezone
from ..db import store
_KEY_JOIN_LIMIT = "discJoinLimit"
_KEY_DELAY_MIN = "discJoinDelayMin"
_KEY_DELAY_MAX = "discJoinDelayMax"
_KEY_FLOOD_DAY = "discFloodDay"
_KEY_PAUSED = "discPaused"
def _start_of_day_ms() -> int:
"""Начало текущих UTC-суток в миллисекундах."""
start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
return int(start.timestamp() * 1000)
def joins_today_auto() -> int:
"""Авто-вступления за текущие UTC-сутки (disc_log, event='join_auto')."""
row = store.scalar(
"SELECT count(*) FROM disc_log WHERE event = 'join_auto' AND created_at >= ?",
[_start_of_day_ms()],
)
return int(row or 0)
def can_auto_join() -> bool:
"""Разрешено ли авто-вступление: лимит не исчерпан, нет flood на сегодня, нет паузы."""
limit = int(store.get_setting(_KEY_JOIN_LIMIT) or 0)
return joins_today_auto() < limit and not flood_today() and not global_paused()
async def wait_join_delay() -> None:
"""Случайная пауза перед авто-вступлением (сек, из настроек).
Защита инварианта min <= max: настройки мог изменить один конец интервала
(single-key PATCH), поэтому при инверсии концы меняются местами.
"""
low = float(store.get_setting(_KEY_DELAY_MIN) or 0)
high = float(store.get_setting(_KEY_DELAY_MAX) or 0)
if low <= 0 and high <= 0:
return # обе настройки не заданы — паузы нет (крайний случай)
if low > high:
low, high = high, low
await asyncio.sleep(random.uniform(low, high))
def note_flood() -> None:
"""Зафиксировать FloodWait: блокировка авто-вступлений до конца суток."""
store.set_setting(_KEY_FLOOD_DAY, _start_of_day_ms())
def flood_today() -> bool:
"""Была ли flood-блокировка в текущие UTC-сутки."""
return int(store.get_setting(_KEY_FLOOD_DAY) or 0) == _start_of_day_ms()
def global_paused() -> bool:
"""Ручной стоп-кран авто-вступлений (setting discPaused)."""
return bool(store.get_setting(_KEY_PAUSED))
def set_global_pause(v: bool) -> None:
store.set_setting(_KEY_PAUSED, bool(v))
def search_pause() -> float:
"""Пауза между поисковыми запросами Telegram (сек)."""
return random.uniform(2.0, 4.0)
"""BanGuard: квоты, паузы и защита от flood для авто-вступлений Discovery.
Суточный лимит (discJoinLimit) считается по disc_log (event='join_auto')
за текущие UTC-сутки; между вступлениями выдерживается случайная пауза
(discJoinDelayMin/Max). При FloodWait от Telegram ставится блокировка до
конца суток (discFloodDay), плюс есть ручной стоп-кран (discPaused).
"""
from __future__ import annotations
import asyncio
import random
from datetime import datetime, timezone
from ..db import store
_KEY_JOIN_LIMIT = "discJoinLimit"
_KEY_DELAY_MIN = "discJoinDelayMin"
_KEY_DELAY_MAX = "discJoinDelayMax"
_KEY_FLOOD_DAY = "discFloodDay"
_KEY_PAUSED = "discPaused"
def _start_of_day_ms() -> int:
"""Начало текущих UTC-суток в миллисекундах."""
start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
return int(start.timestamp() * 1000)
def joins_today_auto() -> int:
"""Авто-вступления за текущие UTC-сутки (disc_log, event='join_auto')."""
row = store.scalar(
"SELECT count(*) FROM disc_log WHERE event = 'join_auto' AND created_at >= ?",
[_start_of_day_ms()],
)
return int(row or 0)
def can_auto_join() -> bool:
"""Разрешено ли авто-вступление: лимит не исчерпан, нет flood на сегодня, нет паузы."""
limit = int(store.get_setting(_KEY_JOIN_LIMIT) or 0)
return joins_today_auto() < limit and not flood_today() and not global_paused()
async def wait_join_delay() -> None:
"""Случайная пауза перед авто-вступлением (сек, из настроек).
Защита инварианта min <= max: настройки мог изменить один конец интервала
(single-key PATCH), поэтому при инверсии концы меняются местами.
"""
low = float(store.get_setting(_KEY_DELAY_MIN) or 0)
high = float(store.get_setting(_KEY_DELAY_MAX) or 0)
if low <= 0 and high <= 0:
return # обе настройки не заданы — паузы нет (крайний случай)
if low > high:
low, high = high, low
await asyncio.sleep(random.uniform(low, high))
def note_flood() -> None:
"""Зафиксировать FloodWait: блокировка авто-вступлений до конца суток."""
store.set_setting(_KEY_FLOOD_DAY, _start_of_day_ms())
def flood_today() -> bool:
"""Была ли flood-блокировка в текущие UTC-сутки."""
return int(store.get_setting(_KEY_FLOOD_DAY) or 0) == _start_of_day_ms()
def global_paused() -> bool:
"""Ручной стоп-кран авто-вступлений (setting discPaused)."""
return bool(store.get_setting(_KEY_PAUSED))
def set_global_pause(v: bool) -> None:
store.set_setting(_KEY_PAUSED, bool(v))
def search_pause() -> float:
"""Пауза между поисковыми запросами Telegram (сек)."""
return random.uniform(2.0, 4.0)
File diff suppressed because it is too large Load Diff
@@ -1,237 +1,237 @@
"""Discovery: оценка контента — язык, темы, fit сообщений под задачу поиска.
Чистая логика оценки (без карточек, очередей и обучения):
* detect_lang_ru — доля кириллических букв среди всех букв выборки;
* group_by_topic — группировка выборки по topic_id (None -> "main")
для форумов: {topic_id, title, messages: [...]};
* evaluate_message — fit одного сообщения под задачу: каскад
короткое -> ML-спам -> ИИ -> эвристика;
* evaluate_sample — агрегат по выборке сообщений кандидата;
* passed — вердикт «источник подходит» (объём выборки + доля fit).
Каскад оценки сообщения (evaluate_message):
1) текст пустой/короче 10 символов -> False «слишком короткое»;
2) ML: ml_client.is_enabled() и прогноз take + label=='spam' -> False;
3) ИИ: если включён (aiEnabled) и доступен ключ/локальный провайдер — один
JSON-вызов ai_service.chat_json({fit, reason}); любая ошибка (нет ключа,
сеть, не-JSON) ловится и оценка продолжается эвристикой;
4) эвристика: любой ключ задачи входит в clean_short(text).casefold().
task — внешний dict в camelCase (конвенция discovery.Task 3): description,
keywords (list[str]), lang, threshold, sampleSize. Иные ключи игнорируются,
поэтому сюда можно передавать и полный view задачи из discovery.get_task.
"""
from __future__ import annotations
import logging
from ..db import store
from . import ai as ai_service
from . import ml_client
from .pipeline import clean_short
log = logging.getLogger("leadradar.discovery_eval")
# пороги доли кириллицы (задача ru-языка): >= hi -> True, <= lo -> False
_RU_RATIO_HI = 0.15
_RU_RATIO_LO = 0.03
# минимальная длина сообщения для содержательной оценки
_MIN_TEXT_LEN = 10
# ограничение текста, уходящего провайдеру (в одном сообщении больше не нужно)
_AI_TEXT_LIMIT = 4000
# потолок причины из ИИ (в UI не нужны простыни)
_AI_REASON_LIMIT = 200
# длина title темы (сниппет первого текста)
_TITLE_LIMIT = 60
# topic_id=None в выборке/группировке -> общая тема ("main")
_MAIN_TOPIC = "main"
# промпт ИИ-оценки (задача: относится ли сообщение к сфере/описанию)
_AI_PROMPT = (
"Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. "
"Ключи: {keywords}. "
"Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}."
)
def _is_cyrillic(ch: str) -> bool:
"""Кириллица ли символ (базовый блок U+0400–U+04FF, включает ё/ў и т.п.)."""
return 0x0400 <= ord(ch) <= 0x04FF
def detect_lang_ru(texts: list[str]) -> bool | None:
"""Доля кириллицы среди всех букв выборки: >=0.15 True, <=0.03 False, иначе None.
None означает «неопределённо» — воркер не отсекает кандидата, а помечает
язык как неподтверждённый. Пустая выборка без букв тоже даёт None.
"""
letters = 0
cyr = 0
for t in texts or []:
for ch in str(t or ""):
if ch.isalpha():
letters += 1
if _is_cyrillic(ch):
cyr += 1
if letters == 0:
return None
ratio = cyr / letters
if ratio >= _RU_RATIO_HI:
return True
if ratio <= _RU_RATIO_LO:
return False
return None
def _topic_title(messages: list[dict]) -> str:
"""Сниппет первого непустого текста темы (<=60 симв., whitespace схлопнут)."""
for m in messages:
text = str(m.get("text") or "").strip()
if text:
text = " ".join(text.split())
return text[:_TITLE_LIMIT]
return ""
def group_by_topic(messages: list[dict]) -> list[dict]:
"""Группирует сообщения по topic_id (None -> "main"), сортирует группы по
числу сообщений (убыв.), порядок сообщений внутри группы — входной.
Возвращает [{"topic_id", "title", "messages": [...]}], где title — сниппет
первого текста темы (первое непустое сообщение во входном порядке).
"""
groups: dict[str, list[dict]] = {}
order: list[str] = []
for m in messages or []:
tid = m.get("topic_id")
key = _MAIN_TOPIC if tid is None else tid
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(m)
out = [
{"topic_id": key, "title": _topic_title(groups[key]), "messages": groups[key]}
for key in order
]
out.sort(key=lambda g: len(g["messages"]), reverse=True)
return out
def _keywords(task: dict) -> list[str]:
kws = task.get("keywords") or []
if isinstance(kws, str):
kws = [kws]
return [str(k).strip() for k in kws if str(k).strip()]
def _ai_usable() -> bool:
"""ИИ-ветка доступна: полный выключатель включён и есть ключ/локальный сервер.
Локальные OpenAI-совместимые (Ollama/LM Studio) работают без ключа, поэтому
для них проверка ключа не требуется. Если статус провайдера прочитать нельзя
(нет БД/настроек) — считаем ИИ недоступным, чтобы не дёргать сеть впустую.
"""
if not store.get_setting("aiEnabled"):
return False
try:
status = ai_service.provider_status()
except Exception as exc: # noqa: BLE001 — отсутствие статуса = ИИ недоступен
log.debug("discovery eval: статус ИИ недоступен (%s) — эвристика", exc)
return False
return bool(status.get("local") or status.get("keySet"))
def _heuristic(task: dict, text: str) -> dict:
"""Эвристика: ключ задачи входит в очищенный текст (без учёта регистра)."""
hay = clean_short(text).casefold()
for kw in _keywords(task):
if kw and kw.casefold() in hay:
return {"fit": True, "reason": f'совпал ключ "{kw}"', "source": "heuristic"}
return {"fit": False, "reason": "нет совпадений с ключами", "source": "heuristic"}
def _ai_prompt(task: dict) -> str:
description = str(task.get("description") or "").strip()
return _AI_PROMPT.format(description=description, keywords=", ".join(_keywords(task)))
def _ai_fit(out: dict) -> bool:
"""fit из JSON-ответа ИИ: 1/true/«да»-подобные -> True, иначе False."""
v = out.get("fit")
if isinstance(v, str):
s = v.strip().casefold()
return bool(s) and s not in {"0", "false", "no", "нет", "null", "none"}
return bool(v)
def _ai_reason(out: dict) -> str:
reason = str(out.get("reason") or "").strip()
if not reason:
reason = "подходит" if _ai_fit(out) else "не подходит"
return reason[:_AI_REASON_LIMIT]
async def evaluate_message(task: dict, text: str) -> dict:
"""Оценка fit одного сообщения. Возвращает {"fit", "reason", "source"}."""
raw = str(text or "")
if len(raw.strip()) < _MIN_TEXT_LEN:
return {"fit": False, "reason": "слишком короткое", "source": "heuristic"}
# ML: уверенный спам отсекаем без обращения к ИИ (когда ML доступен)
if ml_client.is_enabled():
pred = await ml_client.predict(raw)
if pred.get("take") and pred.get("label") == "spam":
return {"fit": False, "reason": "ML: спам", "source": "ml"}
# ИИ: один JSON-вызов; любая ошибка провайдера -> шаг эвристики ниже
if _ai_usable():
try:
out = await ai_service.chat_json(_ai_prompt(task), f"Сообщение:\n{raw[:_AI_TEXT_LIMIT]}")
return {"fit": _ai_fit(out), "reason": _ai_reason(out), "source": "ai"}
except Exception as exc: # noqa: BLE001 — сбой ИИ не роняет оценку
log.debug("discovery eval: ИИ не ответил (%s) — эвристика", exc)
return _heuristic(task, raw)
async def evaluate_sample(task: dict, messages: list[dict]) -> dict:
"""Последовательная оценка всех сообщений выборки кандидата.
Возвращает {"fit_count", "total", "fit_ratio", "per_message": [...]} с
per_message [{"text", "fit", "reason", "topic_id"}] (topic_id None -> "main",
чтобы per_message стыковался с ключами group_by_topic).
"""
per_message = []
fit_count = 0
for m in messages or []:
text = str(m.get("text") or "")
res = await evaluate_message(task, text)
tid = m.get("topic_id")
per_message.append(
{
"text": text,
"fit": bool(res["fit"]),
"reason": str(res["reason"]),
"topic_id": _MAIN_TOPIC if tid is None else tid,
}
)
if res["fit"]:
fit_count += 1
total = len(per_message)
return {
"fit_count": fit_count,
"total": total,
"fit_ratio": (fit_count / total) if total else 0.0,
"per_message": per_message,
}
def passed(ev: dict, task: dict) -> bool:
"""Вердикт «источник подходит»: выборки >=3 сообщений и доля fit >= threshold (%).
Сообщения не обязаны быть «чистыми»: каналы часто разбавляют полезный
контент офтопом, поэтому достаточно доли, а не сплошного соответствия.
"""
total = int(ev.get("total") or 0)
ratio = float(ev.get("fit_ratio") or 0.0)
return total >= 3 and ratio * 100 >= int(task.get("threshold") or 0)
"""Discovery: оценка контента — язык, темы, fit сообщений под задачу поиска.
Чистая логика оценки (без карточек, очередей и обучения):
* detect_lang_ru — доля кириллических букв среди всех букв выборки;
* group_by_topic — группировка выборки по topic_id (None -> "main")
для форумов: {topic_id, title, messages: [...]};
* evaluate_message — fit одного сообщения под задачу: каскад
короткое -> ML-спам -> ИИ -> эвристика;
* evaluate_sample — агрегат по выборке сообщений кандидата;
* passed — вердикт «источник подходит» (объём выборки + доля fit).
Каскад оценки сообщения (evaluate_message):
1) текст пустой/короче 10 символов -> False «слишком короткое»;
2) ML: ml_client.is_enabled() и прогноз take + label=='spam' -> False;
3) ИИ: если включён (aiEnabled) и доступен ключ/локальный провайдер — один
JSON-вызов ai_service.chat_json({fit, reason}); любая ошибка (нет ключа,
сеть, не-JSON) ловится и оценка продолжается эвристикой;
4) эвристика: любой ключ задачи входит в clean_short(text).casefold().
task — внешний dict в camelCase (конвенция discovery.Task 3): description,
keywords (list[str]), lang, threshold, sampleSize. Иные ключи игнорируются,
поэтому сюда можно передавать и полный view задачи из discovery.get_task.
"""
from __future__ import annotations
import logging
from ..db import store
from . import ai as ai_service
from . import ml_client
from .pipeline import clean_short
log = logging.getLogger("leadradar.discovery_eval")
# пороги доли кириллицы (задача ru-языка): >= hi -> True, <= lo -> False
_RU_RATIO_HI = 0.15
_RU_RATIO_LO = 0.03
# минимальная длина сообщения для содержательной оценки
_MIN_TEXT_LEN = 10
# ограничение текста, уходящего провайдеру (в одном сообщении больше не нужно)
_AI_TEXT_LIMIT = 4000
# потолок причины из ИИ (в UI не нужны простыни)
_AI_REASON_LIMIT = 200
# длина title темы (сниппет первого текста)
_TITLE_LIMIT = 60
# topic_id=None в выборке/группировке -> общая тема ("main")
_MAIN_TOPIC = "main"
# промпт ИИ-оценки (задача: относится ли сообщение к сфере/описанию)
_AI_PROMPT = (
"Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. "
"Ключи: {keywords}. "
"Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}."
)
def _is_cyrillic(ch: str) -> bool:
"""Кириллица ли символ (базовый блок U+0400–U+04FF, включает ё/ў и т.п.)."""
return 0x0400 <= ord(ch) <= 0x04FF
def detect_lang_ru(texts: list[str]) -> bool | None:
"""Доля кириллицы среди всех букв выборки: >=0.15 True, <=0.03 False, иначе None.
None означает «неопределённо» — воркер не отсекает кандидата, а помечает
язык как неподтверждённый. Пустая выборка без букв тоже даёт None.
"""
letters = 0
cyr = 0
for t in texts or []:
for ch in str(t or ""):
if ch.isalpha():
letters += 1
if _is_cyrillic(ch):
cyr += 1
if letters == 0:
return None
ratio = cyr / letters
if ratio >= _RU_RATIO_HI:
return True
if ratio <= _RU_RATIO_LO:
return False
return None
def _topic_title(messages: list[dict]) -> str:
"""Сниппет первого непустого текста темы (<=60 симв., whitespace схлопнут)."""
for m in messages:
text = str(m.get("text") or "").strip()
if text:
text = " ".join(text.split())
return text[:_TITLE_LIMIT]
return ""
def group_by_topic(messages: list[dict]) -> list[dict]:
"""Группирует сообщения по topic_id (None -> "main"), сортирует группы по
числу сообщений (убыв.), порядок сообщений внутри группы — входной.
Возвращает [{"topic_id", "title", "messages": [...]}], где title — сниппет
первого текста темы (первое непустое сообщение во входном порядке).
"""
groups: dict[str, list[dict]] = {}
order: list[str] = []
for m in messages or []:
tid = m.get("topic_id")
key = _MAIN_TOPIC if tid is None else tid
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(m)
out = [
{"topic_id": key, "title": _topic_title(groups[key]), "messages": groups[key]}
for key in order
]
out.sort(key=lambda g: len(g["messages"]), reverse=True)
return out
def _keywords(task: dict) -> list[str]:
kws = task.get("keywords") or []
if isinstance(kws, str):
kws = [kws]
return [str(k).strip() for k in kws if str(k).strip()]
def _ai_usable() -> bool:
"""ИИ-ветка доступна: полный выключатель включён и есть ключ/локальный сервер.
Локальные OpenAI-совместимые (Ollama/LM Studio) работают без ключа, поэтому
для них проверка ключа не требуется. Если статус провайдера прочитать нельзя
(нет БД/настроек) — считаем ИИ недоступным, чтобы не дёргать сеть впустую.
"""
if not store.get_setting("aiEnabled"):
return False
try:
status = ai_service.provider_status()
except Exception as exc: # noqa: BLE001 — отсутствие статуса = ИИ недоступен
log.debug("discovery eval: статус ИИ недоступен (%s) — эвристика", exc)
return False
return bool(status.get("local") or status.get("keySet"))
def _heuristic(task: dict, text: str) -> dict:
"""Эвристика: ключ задачи входит в очищенный текст (без учёта регистра)."""
hay = clean_short(text).casefold()
for kw in _keywords(task):
if kw and kw.casefold() in hay:
return {"fit": True, "reason": f'совпал ключ "{kw}"', "source": "heuristic"}
return {"fit": False, "reason": "нет совпадений с ключами", "source": "heuristic"}
def _ai_prompt(task: dict) -> str:
description = str(task.get("description") or "").strip()
return _AI_PROMPT.format(description=description, keywords=", ".join(_keywords(task)))
def _ai_fit(out: dict) -> bool:
"""fit из JSON-ответа ИИ: 1/true/«да»-подобные -> True, иначе False."""
v = out.get("fit")
if isinstance(v, str):
s = v.strip().casefold()
return bool(s) and s not in {"0", "false", "no", "нет", "null", "none"}
return bool(v)
def _ai_reason(out: dict) -> str:
reason = str(out.get("reason") or "").strip()
if not reason:
reason = "подходит" if _ai_fit(out) else "не подходит"
return reason[:_AI_REASON_LIMIT]
async def evaluate_message(task: dict, text: str) -> dict:
"""Оценка fit одного сообщения. Возвращает {"fit", "reason", "source"}."""
raw = str(text or "")
if len(raw.strip()) < _MIN_TEXT_LEN:
return {"fit": False, "reason": "слишком короткое", "source": "heuristic"}
# ML: уверенный спам отсекаем без обращения к ИИ (когда ML доступен)
if ml_client.is_enabled():
pred = await ml_client.predict(raw)
if pred.get("take") and pred.get("label") == "spam":
return {"fit": False, "reason": "ML: спам", "source": "ml"}
# ИИ: один JSON-вызов; любая ошибка провайдера -> шаг эвристики ниже
if _ai_usable():
try:
out = await ai_service.chat_json(_ai_prompt(task), f"Сообщение:\n{raw[:_AI_TEXT_LIMIT]}")
return {"fit": _ai_fit(out), "reason": _ai_reason(out), "source": "ai"}
except Exception as exc: # noqa: BLE001 — сбой ИИ не роняет оценку
log.debug("discovery eval: ИИ не ответил (%s) — эвристика", exc)
return _heuristic(task, raw)
async def evaluate_sample(task: dict, messages: list[dict]) -> dict:
"""Последовательная оценка всех сообщений выборки кандидата.
Возвращает {"fit_count", "total", "fit_ratio", "per_message": [...]} с
per_message [{"text", "fit", "reason", "topic_id"}] (topic_id None -> "main",
чтобы per_message стыковался с ключами group_by_topic).
"""
per_message = []
fit_count = 0
for m in messages or []:
text = str(m.get("text") or "")
res = await evaluate_message(task, text)
tid = m.get("topic_id")
per_message.append(
{
"text": text,
"fit": bool(res["fit"]),
"reason": str(res["reason"]),
"topic_id": _MAIN_TOPIC if tid is None else tid,
}
)
if res["fit"]:
fit_count += 1
total = len(per_message)
return {
"fit_count": fit_count,
"total": total,
"fit_ratio": (fit_count / total) if total else 0.0,
"per_message": per_message,
}
def passed(ev: dict, task: dict) -> bool:
"""Вердикт «источник подходит»: выборки >=3 сообщений и доля fit >= threshold (%).
Сообщения не обязаны быть «чистыми»: каналы часто разбавляют полезный
контент офтопом, поэтому достаточно доли, а не сплошного соответствия.
"""
total = int(ev.get("total") or 0)
ratio = float(ev.get("fit_ratio") or 0.0)
return total >= 3 and ratio * 100 >= int(task.get("threshold") or 0)
@@ -1,484 +1,484 @@
"""Discovery worker: поиск → оценка → авто-вступление (Task 6).
Фоновый цикл main._discovery_loop вызывает `tick()` каждые ~5 секунд; каждый
вызов выполняет ОДНО действие для самой старой running-задачи и возвращает
{"action": "search"|"review"|"skip"|"join"|"reject"|"flood"|"error"|"done"|"none",
"taskId": ...}. Если работы нет — {"action": "none"}.
Приоритеты внутри tick:
0. ban_guard.global_paused() — ручной стоп-кран: возвращаем none;
1. задача достигла плана вступлений (joined >= planJoins) → status=done
(+ лог done) — занимаемый ею бюджет планов освобождается сразу;
2. шаг поиска: search_done=False → следующий ключ keywords[search_idx],
tg.discovery_search, каждый результат — discovery.add_candidate,
discovery.advance_search; при переходе search_done=True — лог search
«поиск завершён: N кандидатов» (N = счётчик found задачи). Личные чаты/
боты (kind «чат») пропускаются (лог skip). FloodWaitError → note_flood +
лог flood; прочие ошибки поиска → лог error; индекс ключей не двигается
(тик повторит ключ позже), но после 3 ошибок подряд ключ пропускается
(advance_search + лог error «ключ пропущен») — битый ключ не должен
зацикливать поиск навсегда;
2a. флуд-стоп: пока действует flood-блокировка дня (ban_guard.flood_today())
discovery-воркер полностью стоит (никаких сетевых действий поиска/оценки/
вступлений) — это и есть «стоп до конца суток» из лога flood;
3. шаг оценки: первый кандидат status='new' → tg.discovery_info (участники,
kind; форум — если is_forum), фильтр minSubscribers (меньше — delete +
лог skip; не получено — метка), tg.discovery_read: история недоступна →
review с меткой («канал…»/«закрытая группа…», контент не оцениваем),
язык (для ru-задач), evaluate_sample (форумы — по темам через
group_by_topic) → passed → review с fitRatio/topics, иначе
delete_candidate + лог skip «мало подходящих (X из N)»;
4. авто-вступление (отдельный проход, приоритет ниже оценки): у running-
задачи с autoJoin и кандидатом status='review', если
ban_guard.can_auto_join() → повторная проверка «мы не состоим»
(dialogs/disc_blacklist — могли вступить между оценкой и join) → если
уже состоим/в чёрном списке — discovery.mark_rejected + лог;
ban_guard.wait_join_delay() (5070 с — спейсинг авто-вступлений), ПОСЛЕ
паузы кандидат перечитывается — join выполняется, только если запись
ещё есть и в review, задача ещё running с autoJoin и мы не состоим
(иначе — тик выходит без join);
tg.discovery_join(username) → discovery.mark_joined(auto=True) →
tg.add_dialog_monitored(...) → tg.backfill_dialog(dialog_id) →
discovery.remove_blacklist. FloodWaitError → ban_guard.note_flood()
+ лог flood (кандидат остаётся review); прочие ошибки join →
join_failures += 1, лог error, кандидат остаётся review для повтора
(ретраи ограничены: после 3-й неудачи кандидат удаляется, лог skip).
Метки кандидата (marks) — список строк-чипов:
«участники не подтверждены», «язык не подтверждён»,
«канал: история недоступна», «закрытая группа (история скрыта) — вступите сами»,
«мало сообщений».
Темы форума (topics) — список dict (заполняется только для kind='forum'):
{"topicId": str|int, "title": str, "fitCount": int, "total": int,
"fitRatio": float, "passed": bool}
fit каждой темы считается по своей выборке (evaluate_sample + passed);
общий вердикт форума — есть хотя бы одна проходная тема. fitRatio кандидата
— агрегат по всей выборке (fit из N). Для не-форумов topics не заполняется.
"""
from __future__ import annotations
import logging
import time
from contextlib import suppress
from telethon.errors.rpcerrorlist import FloodWaitError
from ..db import store
from . import ban_guard, discovery
from . import discovery_eval as eval_svc
from .telegram import tg
log = logging.getLogger("leadradar.discovery_worker")
_ACTION_NONE = {"action": "none"}
# минимальный объём содержательной выборки для вердикта оценки
_MIN_CONTENT = 3
# ошибки поиска одного ключа подряд, после которых ключ пропускается
_SEARCH_ERRORS_TO_SKIP = 3
# счётчик ошибок поиска по задачам (память процесса; при рестарте сбрасывается)
_search_errors: dict[str, int] = {}
# метки кандидата (marks) — чипы в UI
_MARK_PARTICIPANTS = "участники не подтверждены"
_MARK_LANG = "язык не подтверждён"
_MARK_CHANNEL_NO_HISTORY = "канал: история недоступна"
_MARK_CLOSED_GROUP = "закрытая группа (история скрыта) — вступите сами"
_MARK_FEW_MESSAGES = "мало сообщений"
# kind из Telegram (_kind_of: канал/группа/чат) → код кандидата (channel/group/forum)
_KIND_RU_TO_CODE = {"канал": "channel", "группа": "group", "чат": "group"}
def _now_ms() -> int:
return time.time_ns() // 1_000_000
def _kind_code(kind: str, is_forum: bool = False) -> str:
"""Нормализация kind источника: 'channel'|'group'|'forum' (см. _KIND_RU_TO_CODE)."""
if is_forum:
return "forum"
code = str(kind or "").strip().casefold()
if code in ("channel", "group", "forum"):
return code
return _KIND_RU_TO_CODE.get(code, "group")
def _running_tasks() -> list[dict]:
"""Running-задачи, старые первыми (list_tasks сортирует по created_at)."""
return [t for t in discovery.list_tasks() if t["status"] == "running"]
def _we_are_in(dialog_id: str) -> bool:
"""Уже состоим/отклонили: источник в dialogs или в чёрном списке.
Повторная проверка «мы не состоим» перед авто-вступлением (диалог мог
появиться между оценкой кандидата и join). dialog_id — подписанный peer id,
как в dialogs.id (конвенция discovery_search, Task 4).
"""
in_dialogs = store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id])
in_blacklist = store.scalar("SELECT 1 FROM disc_blacklist WHERE dialog_id = ? LIMIT 1", [dialog_id])
return bool(in_dialogs or in_blacklist)
def _finish_done(task: dict) -> None:
"""Задача выполнила план вступлений: status=done + лог done."""
store.execute(
"UPDATE disc_tasks SET status = 'done', updated_at = ? WHERE id = ?",
[_now_ms(), task["id"]],
)
discovery.add_log(
task["id"],
"done",
f"план выполнен: вступили {task['joined']} из {task['planJoins']}",
)
def _close_search(task_id: str) -> None:
"""Закрыть проход по ключам (пустой список ключей / индекс за границей)."""
discovery.advance_search(task_id)
task = discovery.get_task(task_id)
if task and task["searchDone"]:
discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов")
def _log_search_done(task_id: str) -> None:
"""Лог завершения поиска, если advance_search перевёл задачу в search_done."""
task = discovery.get_task(task_id)
if task and task["searchDone"]:
discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов")
def _finish_review(
task_id: str,
dialog_id: str,
*,
marks: list[str],
lang_ru: bool | None = None,
fit_ratio: float | None = None,
topics: list[dict] | None = None,
) -> None:
"""Перевести кандидата в review с метками/оценкой (статус пишет лог review)."""
patch: dict = {"marks": [m for m in marks if m]}
if lang_ru is not None:
patch["langRu"] = lang_ru
if fit_ratio is not None:
patch["fitRatio"] = fit_ratio
if topics is not None:
patch["topics"] = topics
discovery.set_candidate(task_id, dialog_id, patch)
discovery.set_candidate_status(dialog_id, "review")
# ─── шаги tick ─────────────────────────────────────────────────────────────
async def _search_step(task: dict) -> dict:
"""Шаг поиска: один ключ keywords[searchIdx] → кандидаты + advance_search."""
task_id = task["id"]
keywords = list(task.get("keywords") or [])
idx = int(task.get("searchIdx") or 0)
if not keywords or idx >= len(keywords):
# ключи закончились/пустой список: закрываем проход без сетевого вызова
_close_search(task_id)
return {"action": "search", "taskId": task_id}
keyword = keywords[idx]
try:
results = await tg.discovery_search(keyword)
except FloodWaitError:
# флуд: стоп авто-вступлений до конца суток; ключ не двигаем — повторим позже
ban_guard.note_flood()
discovery.add_log(task_id, "flood", f"поиск «{keyword}»: flood — стоп до конца суток")
return {"action": "flood", "taskId": task_id}
except Exception as exc: # noqa: BLE001 — сбой поиска не двигает индекс ключей
errors = _search_errors.get(task_id, 0) + 1
if errors >= _SEARCH_ERRORS_TO_SKIP:
# 3 ошибки подряд одного ключа: пропускаем (битый ключ не должен
# зацикливать поиск и блокировать оценку/вступления других задач)
_search_errors.pop(task_id, None)
discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc} — ключ пропущен ({errors} ошибки подряд)")
discovery.advance_search(task_id)
_log_search_done(task_id)
return {"action": "error", "taskId": task_id}
_search_errors[task_id] = errors
discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc}")
return {"action": "error", "taskId": task_id}
_search_errors.pop(task_id, None) # успешный поиск — сброс счётчика ошибок ключа
for item in results:
kind_raw = str(item.get("kind") or "").strip().casefold()
name = item.get("name") or ""
if kind_raw == "чат":
# люди/личные чаты и боты глобальным поиском не предлагаются
discovery.add_log(task_id, "skip", f"{name}: личный чат/бот")
continue
discovery.add_candidate(
task_id,
str(item.get("id") or ""),
name,
item.get("username") or "",
_kind_code(kind_raw),
item.get("hue") or "#666",
)
discovery.advance_search(task_id)
_log_search_done(task_id)
return {"action": "search", "taskId": task_id}
async def _eval_step(task: dict, cand: dict) -> dict:
"""Шаг оценки первого кандидата status='new' (все ветки — одно действие)."""
task_id = task["id"]
dialog_id = cand["dialogId"]
marks: list[str] = []
# ── инфо об источнике: kind/forum, участники, имя/username ────────────
info = await tg.discovery_info(dialog_id)
is_forum = bool(info.get("is_forum"))
resolved = info.get("kind") or is_forum
kind = _kind_code(info.get("kind", ""), is_forum) if resolved else (cand["kind"] or "channel")
cand_patch: dict = {
"kind": kind,
"hue": info.get("hue") or "",
"participants": info.get("participants"),
}
if resolved:
# имя/username обновляем только при успешном резолве: при fallback
# discovery_info возвращает name=dialog_id и не должен затирать имя
cand_patch["name"] = info.get("name") or ""
cand_patch["username"] = info.get("username") or ""
discovery.set_candidate(task_id, dialog_id, cand_patch)
participants = info.get("participants")
# ── фильтр minSubscribers ──────────────────────────────────────────────
min_sub = int(task.get("minSubscribers") or 0)
if min_sub > 0:
if participants is None:
marks.append(_MARK_PARTICIPANTS)
elif int(participants) < min_sub:
discovery.delete_candidate(dialog_id)
discovery.add_log(
task_id,
"skip",
f"{dialog_id}: мало участников ({participants} < {min_sub})",
)
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
# ── чтение истории для оценки ──────────────────────────────────────────
read = await tg.discovery_read(dialog_id, int(task.get("sampleSize") or 10))
if not read.get("ok"):
# история недоступна без членства: контент не оцениваем, фильтры помечаем
marks.append(_MARK_CHANNEL_NO_HISTORY if kind == "channel" else _MARK_CLOSED_GROUP)
if task.get("lang") == "ru":
marks.append(_MARK_LANG)
_finish_review(task_id, dialog_id, marks=marks)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
messages = read.get("messages") or []
# ── язык (только для ru-задач) ─────────────────────────────────────────
lang_ru: bool | None = None
if task.get("lang") == "ru":
lang_ru = eval_svc.detect_lang_ru([str(m.get("text") or "") for m in messages])
if lang_ru is False:
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: язык не русский")
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
if lang_ru is None:
marks.append(_MARK_LANG)
# ── объём выборки: меньше 3 содержательных — решает человек ────────────
if len(messages) < _MIN_CONTENT:
marks.append(_MARK_FEW_MESSAGES)
_finish_review(task_id, dialog_id, marks=marks, lang_ru=lang_ru)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
# ── оценка содержания (форумы — по темам) ──────────────────────────────
fit_count, total, fit_ratio, topics, ok = await _evaluate_content(task, kind, messages)
if ok:
_finish_review(
task_id,
dialog_id,
marks=marks,
lang_ru=lang_ru,
fit_ratio=fit_ratio,
topics=topics if kind == "forum" else None,
)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: мало подходящих ({fit_count} из {total})")
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
async def _evaluate_content(task: dict, kind: str, messages: list[dict]) -> tuple[int, int, float, list[dict], bool]:
"""evaluate_sample по выборке кандидата.
Не-форум: один прогон по всем сообщениям, topics пуст, вердикт — passed().
Форум: прогон по каждой теме (group_by_topic), topics заполняется
({topicId, title, fitCount, total, fitRatio, passed}), вердикт — есть хотя
бы одна проходная тема; fitRatio — агрегат fit из N по всей выборке.
"""
if kind == "forum":
topics: list[dict] = []
fit_count = 0
total = 0
any_passed = False
for group in eval_svc.group_by_topic(messages):
ev = await eval_svc.evaluate_sample(task, group["messages"])
t_ok = eval_svc.passed(ev, task)
fit_count += int(ev.get("fit_count") or 0)
total += int(ev.get("total") or 0)
topics.append(
{
"topicId": group["topic_id"],
"title": group["title"] or "",
"fitCount": int(ev.get("fit_count") or 0),
"total": int(ev.get("total") or 0),
"fitRatio": float(ev.get("fit_ratio") or 0.0),
"passed": bool(t_ok),
}
)
any_passed = any_passed or bool(t_ok)
return fit_count, total, (fit_count / total) if total else 0.0, topics, any_passed
ev = await eval_svc.evaluate_sample(task, messages)
return (
int(ev.get("fit_count") or 0),
int(ev.get("total") or 0),
float(ev.get("fit_ratio") or 0.0),
[],
eval_svc.passed(ev, task),
)
async def _join_step(task: dict, cand: dict) -> dict:
"""Шаг авто-вступления одного кандидата status='review'."""
task_id = task["id"]
dialog_id = cand["dialogId"]
# между оценкой и вступлением могли вступить/отклонить источник
if _we_are_in(dialog_id):
already = bool(store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id]))
reason = (
"уже вступили между оценкой и авто-вступлением"
if already
else "источник в чёрном списке (повторная проверка перед авто-вступлением)"
)
with suppress(KeyError, ValueError):
discovery.mark_rejected(dialog_id, reason=reason)
return {"action": "reject", "taskId": task_id}
# спейсинг авто-вступлений (сек из настроек discJoinDelayMin/Max)
await ban_guard.wait_join_delay()
# за время паузы задача/кандидат/состояние BanGuard могли измениться:
# вступаем только если кандидат всё ещё есть и в review, задача ещё running
# с autoJoin, мы не состоим и авто-вступления по-прежнему разрешены (стоп-
# кран/flood/лимит могли включиться во время паузы) — иначе выходим без join
fresh = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
task_now = discovery.get_task(task_id)
if (
fresh is None
or fresh["status"] != "review"
or task_now is None
or task_now["status"] != "running"
or not task_now["autoJoin"]
or _we_are_in(dialog_id)
or not ban_guard.can_auto_join()
):
return _ACTION_NONE
username = str(fresh.get("username") or "").strip().lstrip("@")
try:
await tg.discovery_join(username)
except FloodWaitError:
ban_guard.note_flood() # идемпотентно: discovery_join тоже фиксирует флуд
discovery.add_log(task_id, "flood", f"авто-вступление {dialog_id}: flood — стоп до конца суток")
return {"action": "flood", "taskId": task_id}
except Exception as exc: # noqa: BLE001 — ретраи ограничены счётчиком join_failures
# между паузой и неудачным join кандидата могли отклонить/удалить:
# счётчик и удаление трогаем только у живой записи в статусе review
row_now = store.query_one(
"SELECT status FROM disc_candidates WHERE dialog_id = ?", [dialog_id]
)
if not row_now or row_now["status"] != "review":
return _ACTION_NONE
failures = int(fresh.get("join_failures") or 0) + 1
store.execute(
"UPDATE disc_candidates SET join_failures = ?, updated_at = ? "
"WHERE dialog_id = ? AND status = 'review'",
[failures, _now_ms(), dialog_id],
)
if failures >= 3:
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: не удалось вступить (3 попытки): {exc}")
return {"action": "skip", "taskId": task_id}
discovery.add_log(task_id, "error", f"авто-вступление {dialog_id}: {exc}")
return {"action": "error", "taskId": task_id}
discovery.mark_joined(dialog_id, auto=True)
tg.add_dialog_monitored(
dialog_id,
fresh.get("name"),
username,
fresh.get("kind"),
fresh.get("hue"),
)
# разбор последних сообщений источника (спейсинг/read-ack внутри метода);
# вступление уже состоялось — сбой backfill не роняет шаг
try:
await tg.backfill_dialog(dialog_id)
except Exception as exc: # noqa: BLE001
log.warning("join %s: backfill не удался: %s", dialog_id, exc)
discovery.remove_blacklist(dialog_id)
return {"action": "join", "taskId": task_id}
# ─── tick ──────────────────────────────────────────────────────────────────
async def tick() -> dict:
"""Одно действие discovery-воркера (см. docstring модуля)."""
if ban_guard.global_paused():
return _ACTION_NONE
if ban_guard.flood_today():
# флуд-блокировка дня: никаких сетевых действий (поиск/оценка/join),
# пока действует discFloodDay — воркер просто стоит
return _ACTION_NONE
running = _running_tasks()
if not running:
return _ACTION_NONE
# 1. план достигнут — закрываем задачу (важно до поиска/оценки/join:
# задачу с выполненным планом нельзя продолжать обрабатывать)
for task in running:
if int(task["joined"]) >= int(task["planJoins"]):
_finish_done(task)
return {"action": "done", "taskId": task["id"]}
# 2. поиск: следующая running-задача с незавершённым проходом по ключам
for task in running:
if not task["searchDone"]:
return await _search_step(task)
# 3. оценка: первый кандидат status='new' (самая старая задача — первой)
for task in running:
new_cands = discovery.list_candidates(task["id"], status="new")
if new_cands:
return await _eval_step(task, new_cands[0])
# 4. авто-вступление: отдельный проход, приоритет ниже оценки
for task in running:
if not task["autoJoin"]:
continue
review_cands = discovery.list_candidates(task["id"], status="review")
if review_cands:
if not ban_guard.can_auto_join():
return _ACTION_NONE # суточный лимит/флуд/пауза — join никому нельзя
return await _join_step(task, review_cands[0])
return _ACTION_NONE
"""Discovery worker: поиск → оценка → авто-вступление (Task 6).
Фоновый цикл main._discovery_loop вызывает `tick()` каждые ~5 секунд; каждый
вызов выполняет ОДНО действие для самой старой running-задачи и возвращает
{"action": "search"|"review"|"skip"|"join"|"reject"|"flood"|"error"|"done"|"none",
"taskId": ...}. Если работы нет — {"action": "none"}.
Приоритеты внутри tick:
0. ban_guard.global_paused() — ручной стоп-кран: возвращаем none;
1. задача достигла плана вступлений (joined >= planJoins) → status=done
(+ лог done) — занимаемый ею бюджет планов освобождается сразу;
2. шаг поиска: search_done=False → следующий ключ keywords[search_idx],
tg.discovery_search, каждый результат — discovery.add_candidate,
discovery.advance_search; при переходе search_done=True — лог search
«поиск завершён: N кандидатов» (N = счётчик found задачи). Личные чаты/
боты (kind «чат») пропускаются (лог skip). FloodWaitError → note_flood +
лог flood; прочие ошибки поиска → лог error; индекс ключей не двигается
(тик повторит ключ позже), но после 3 ошибок подряд ключ пропускается
(advance_search + лог error «ключ пропущен») — битый ключ не должен
зацикливать поиск навсегда;
2a. флуд-стоп: пока действует flood-блокировка дня (ban_guard.flood_today())
discovery-воркер полностью стоит (никаких сетевых действий поиска/оценки/
вступлений) — это и есть «стоп до конца суток» из лога flood;
3. шаг оценки: первый кандидат status='new' → tg.discovery_info (участники,
kind; форум — если is_forum), фильтр minSubscribers (меньше — delete +
лог skip; не получено — метка), tg.discovery_read: история недоступна →
review с меткой («канал…»/«закрытая группа…», контент не оцениваем),
язык (для ru-задач), evaluate_sample (форумы — по темам через
group_by_topic) → passed → review с fitRatio/topics, иначе
delete_candidate + лог skip «мало подходящих (X из N)»;
4. авто-вступление (отдельный проход, приоритет ниже оценки): у running-
задачи с autoJoin и кандидатом status='review', если
ban_guard.can_auto_join() → повторная проверка «мы не состоим»
(dialogs/disc_blacklist — могли вступить между оценкой и join) → если
уже состоим/в чёрном списке — discovery.mark_rejected + лог;
ban_guard.wait_join_delay() (5070 с — спейсинг авто-вступлений), ПОСЛЕ
паузы кандидат перечитывается — join выполняется, только если запись
ещё есть и в review, задача ещё running с autoJoin и мы не состоим
(иначе — тик выходит без join);
tg.discovery_join(username) → discovery.mark_joined(auto=True) →
tg.add_dialog_monitored(...) → tg.backfill_dialog(dialog_id) →
discovery.remove_blacklist. FloodWaitError → ban_guard.note_flood()
+ лог flood (кандидат остаётся review); прочие ошибки join →
join_failures += 1, лог error, кандидат остаётся review для повтора
(ретраи ограничены: после 3-й неудачи кандидат удаляется, лог skip).
Метки кандидата (marks) — список строк-чипов:
«участники не подтверждены», «язык не подтверждён»,
«канал: история недоступна», «закрытая группа (история скрыта) — вступите сами»,
«мало сообщений».
Темы форума (topics) — список dict (заполняется только для kind='forum'):
{"topicId": str|int, "title": str, "fitCount": int, "total": int,
"fitRatio": float, "passed": bool}
fit каждой темы считается по своей выборке (evaluate_sample + passed);
общий вердикт форума — есть хотя бы одна проходная тема. fitRatio кандидата
— агрегат по всей выборке (fit из N). Для не-форумов topics не заполняется.
"""
from __future__ import annotations
import logging
import time
from contextlib import suppress
from telethon.errors.rpcerrorlist import FloodWaitError
from ..db import store
from . import ban_guard, discovery
from . import discovery_eval as eval_svc
from .telegram import tg
log = logging.getLogger("leadradar.discovery_worker")
_ACTION_NONE = {"action": "none"}
# минимальный объём содержательной выборки для вердикта оценки
_MIN_CONTENT = 3
# ошибки поиска одного ключа подряд, после которых ключ пропускается
_SEARCH_ERRORS_TO_SKIP = 3
# счётчик ошибок поиска по задачам (память процесса; при рестарте сбрасывается)
_search_errors: dict[str, int] = {}
# метки кандидата (marks) — чипы в UI
_MARK_PARTICIPANTS = "участники не подтверждены"
_MARK_LANG = "язык не подтверждён"
_MARK_CHANNEL_NO_HISTORY = "канал: история недоступна"
_MARK_CLOSED_GROUP = "закрытая группа (история скрыта) — вступите сами"
_MARK_FEW_MESSAGES = "мало сообщений"
# kind из Telegram (_kind_of: канал/группа/чат) → код кандидата (channel/group/forum)
_KIND_RU_TO_CODE = {"канал": "channel", "группа": "group", "чат": "group"}
def _now_ms() -> int:
return time.time_ns() // 1_000_000
def _kind_code(kind: str, is_forum: bool = False) -> str:
"""Нормализация kind источника: 'channel'|'group'|'forum' (см. _KIND_RU_TO_CODE)."""
if is_forum:
return "forum"
code = str(kind or "").strip().casefold()
if code in ("channel", "group", "forum"):
return code
return _KIND_RU_TO_CODE.get(code, "group")
def _running_tasks() -> list[dict]:
"""Running-задачи, старые первыми (list_tasks сортирует по created_at)."""
return [t for t in discovery.list_tasks() if t["status"] == "running"]
def _we_are_in(dialog_id: str) -> bool:
"""Уже состоим/отклонили: источник в dialogs или в чёрном списке.
Повторная проверка «мы не состоим» перед авто-вступлением (диалог мог
появиться между оценкой кандидата и join). dialog_id — подписанный peer id,
как в dialogs.id (конвенция discovery_search, Task 4).
"""
in_dialogs = store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id])
in_blacklist = store.scalar("SELECT 1 FROM disc_blacklist WHERE dialog_id = ? LIMIT 1", [dialog_id])
return bool(in_dialogs or in_blacklist)
def _finish_done(task: dict) -> None:
"""Задача выполнила план вступлений: status=done + лог done."""
store.execute(
"UPDATE disc_tasks SET status = 'done', updated_at = ? WHERE id = ?",
[_now_ms(), task["id"]],
)
discovery.add_log(
task["id"],
"done",
f"план выполнен: вступили {task['joined']} из {task['planJoins']}",
)
def _close_search(task_id: str) -> None:
"""Закрыть проход по ключам (пустой список ключей / индекс за границей)."""
discovery.advance_search(task_id)
task = discovery.get_task(task_id)
if task and task["searchDone"]:
discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов")
def _log_search_done(task_id: str) -> None:
"""Лог завершения поиска, если advance_search перевёл задачу в search_done."""
task = discovery.get_task(task_id)
if task and task["searchDone"]:
discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов")
def _finish_review(
task_id: str,
dialog_id: str,
*,
marks: list[str],
lang_ru: bool | None = None,
fit_ratio: float | None = None,
topics: list[dict] | None = None,
) -> None:
"""Перевести кандидата в review с метками/оценкой (статус пишет лог review)."""
patch: dict = {"marks": [m for m in marks if m]}
if lang_ru is not None:
patch["langRu"] = lang_ru
if fit_ratio is not None:
patch["fitRatio"] = fit_ratio
if topics is not None:
patch["topics"] = topics
discovery.set_candidate(task_id, dialog_id, patch)
discovery.set_candidate_status(dialog_id, "review")
# ─── шаги tick ─────────────────────────────────────────────────────────────
async def _search_step(task: dict) -> dict:
"""Шаг поиска: один ключ keywords[searchIdx] → кандидаты + advance_search."""
task_id = task["id"]
keywords = list(task.get("keywords") or [])
idx = int(task.get("searchIdx") or 0)
if not keywords or idx >= len(keywords):
# ключи закончились/пустой список: закрываем проход без сетевого вызова
_close_search(task_id)
return {"action": "search", "taskId": task_id}
keyword = keywords[idx]
try:
results = await tg.discovery_search(keyword)
except FloodWaitError:
# флуд: стоп авто-вступлений до конца суток; ключ не двигаем — повторим позже
ban_guard.note_flood()
discovery.add_log(task_id, "flood", f"поиск «{keyword}»: flood — стоп до конца суток")
return {"action": "flood", "taskId": task_id}
except Exception as exc: # noqa: BLE001 — сбой поиска не двигает индекс ключей
errors = _search_errors.get(task_id, 0) + 1
if errors >= _SEARCH_ERRORS_TO_SKIP:
# 3 ошибки подряд одного ключа: пропускаем (битый ключ не должен
# зацикливать поиск и блокировать оценку/вступления других задач)
_search_errors.pop(task_id, None)
discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc} — ключ пропущен ({errors} ошибки подряд)")
discovery.advance_search(task_id)
_log_search_done(task_id)
return {"action": "error", "taskId": task_id}
_search_errors[task_id] = errors
discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc}")
return {"action": "error", "taskId": task_id}
_search_errors.pop(task_id, None) # успешный поиск — сброс счётчика ошибок ключа
for item in results:
kind_raw = str(item.get("kind") or "").strip().casefold()
name = item.get("name") or ""
if kind_raw == "чат":
# люди/личные чаты и боты глобальным поиском не предлагаются
discovery.add_log(task_id, "skip", f"{name}: личный чат/бот")
continue
discovery.add_candidate(
task_id,
str(item.get("id") or ""),
name,
item.get("username") or "",
_kind_code(kind_raw),
item.get("hue") or "#666",
)
discovery.advance_search(task_id)
_log_search_done(task_id)
return {"action": "search", "taskId": task_id}
async def _eval_step(task: dict, cand: dict) -> dict:
"""Шаг оценки первого кандидата status='new' (все ветки — одно действие)."""
task_id = task["id"]
dialog_id = cand["dialogId"]
marks: list[str] = []
# ── инфо об источнике: kind/forum, участники, имя/username ────────────
info = await tg.discovery_info(dialog_id)
is_forum = bool(info.get("is_forum"))
resolved = info.get("kind") or is_forum
kind = _kind_code(info.get("kind", ""), is_forum) if resolved else (cand["kind"] or "channel")
cand_patch: dict = {
"kind": kind,
"hue": info.get("hue") or "",
"participants": info.get("participants"),
}
if resolved:
# имя/username обновляем только при успешном резолве: при fallback
# discovery_info возвращает name=dialog_id и не должен затирать имя
cand_patch["name"] = info.get("name") or ""
cand_patch["username"] = info.get("username") or ""
discovery.set_candidate(task_id, dialog_id, cand_patch)
participants = info.get("participants")
# ── фильтр minSubscribers ──────────────────────────────────────────────
min_sub = int(task.get("minSubscribers") or 0)
if min_sub > 0:
if participants is None:
marks.append(_MARK_PARTICIPANTS)
elif int(participants) < min_sub:
discovery.delete_candidate(dialog_id)
discovery.add_log(
task_id,
"skip",
f"{dialog_id}: мало участников ({participants} < {min_sub})",
)
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
# ── чтение истории для оценки ──────────────────────────────────────────
read = await tg.discovery_read(dialog_id, int(task.get("sampleSize") or 10))
if not read.get("ok"):
# история недоступна без членства: контент не оцениваем, фильтры помечаем
marks.append(_MARK_CHANNEL_NO_HISTORY if kind == "channel" else _MARK_CLOSED_GROUP)
if task.get("lang") == "ru":
marks.append(_MARK_LANG)
_finish_review(task_id, dialog_id, marks=marks)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
messages = read.get("messages") or []
# ── язык (только для ru-задач) ─────────────────────────────────────────
lang_ru: bool | None = None
if task.get("lang") == "ru":
lang_ru = eval_svc.detect_lang_ru([str(m.get("text") or "") for m in messages])
if lang_ru is False:
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: язык не русский")
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
if lang_ru is None:
marks.append(_MARK_LANG)
# ── объём выборки: меньше 3 содержательных — решает человек ────────────
if len(messages) < _MIN_CONTENT:
marks.append(_MARK_FEW_MESSAGES)
_finish_review(task_id, dialog_id, marks=marks, lang_ru=lang_ru)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
# ── оценка содержания (форумы — по темам) ──────────────────────────────
fit_count, total, fit_ratio, topics, ok = await _evaluate_content(task, kind, messages)
if ok:
_finish_review(
task_id,
dialog_id,
marks=marks,
lang_ru=lang_ru,
fit_ratio=fit_ratio,
topics=topics if kind == "forum" else None,
)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: мало подходящих ({fit_count} из {total})")
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
async def _evaluate_content(task: dict, kind: str, messages: list[dict]) -> tuple[int, int, float, list[dict], bool]:
"""evaluate_sample по выборке кандидата.
Не-форум: один прогон по всем сообщениям, topics пуст, вердикт — passed().
Форум: прогон по каждой теме (group_by_topic), topics заполняется
({topicId, title, fitCount, total, fitRatio, passed}), вердикт — есть хотя
бы одна проходная тема; fitRatio — агрегат fit из N по всей выборке.
"""
if kind == "forum":
topics: list[dict] = []
fit_count = 0
total = 0
any_passed = False
for group in eval_svc.group_by_topic(messages):
ev = await eval_svc.evaluate_sample(task, group["messages"])
t_ok = eval_svc.passed(ev, task)
fit_count += int(ev.get("fit_count") or 0)
total += int(ev.get("total") or 0)
topics.append(
{
"topicId": group["topic_id"],
"title": group["title"] or "",
"fitCount": int(ev.get("fit_count") or 0),
"total": int(ev.get("total") or 0),
"fitRatio": float(ev.get("fit_ratio") or 0.0),
"passed": bool(t_ok),
}
)
any_passed = any_passed or bool(t_ok)
return fit_count, total, (fit_count / total) if total else 0.0, topics, any_passed
ev = await eval_svc.evaluate_sample(task, messages)
return (
int(ev.get("fit_count") or 0),
int(ev.get("total") or 0),
float(ev.get("fit_ratio") or 0.0),
[],
eval_svc.passed(ev, task),
)
async def _join_step(task: dict, cand: dict) -> dict:
"""Шаг авто-вступления одного кандидата status='review'."""
task_id = task["id"]
dialog_id = cand["dialogId"]
# между оценкой и вступлением могли вступить/отклонить источник
if _we_are_in(dialog_id):
already = bool(store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id]))
reason = (
"уже вступили между оценкой и авто-вступлением"
if already
else "источник в чёрном списке (повторная проверка перед авто-вступлением)"
)
with suppress(KeyError, ValueError):
discovery.mark_rejected(dialog_id, reason=reason)
return {"action": "reject", "taskId": task_id}
# спейсинг авто-вступлений (сек из настроек discJoinDelayMin/Max)
await ban_guard.wait_join_delay()
# за время паузы задача/кандидат/состояние BanGuard могли измениться:
# вступаем только если кандидат всё ещё есть и в review, задача ещё running
# с autoJoin, мы не состоим и авто-вступления по-прежнему разрешены (стоп-
# кран/flood/лимит могли включиться во время паузы) — иначе выходим без join
fresh = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
task_now = discovery.get_task(task_id)
if (
fresh is None
or fresh["status"] != "review"
or task_now is None
or task_now["status"] != "running"
or not task_now["autoJoin"]
or _we_are_in(dialog_id)
or not ban_guard.can_auto_join()
):
return _ACTION_NONE
username = str(fresh.get("username") or "").strip().lstrip("@")
try:
await tg.discovery_join(username)
except FloodWaitError:
ban_guard.note_flood() # идемпотентно: discovery_join тоже фиксирует флуд
discovery.add_log(task_id, "flood", f"авто-вступление {dialog_id}: flood — стоп до конца суток")
return {"action": "flood", "taskId": task_id}
except Exception as exc: # noqa: BLE001 — ретраи ограничены счётчиком join_failures
# между паузой и неудачным join кандидата могли отклонить/удалить:
# счётчик и удаление трогаем только у живой записи в статусе review
row_now = store.query_one(
"SELECT status FROM disc_candidates WHERE dialog_id = ?", [dialog_id]
)
if not row_now or row_now["status"] != "review":
return _ACTION_NONE
failures = int(fresh.get("join_failures") or 0) + 1
store.execute(
"UPDATE disc_candidates SET join_failures = ?, updated_at = ? "
"WHERE dialog_id = ? AND status = 'review'",
[failures, _now_ms(), dialog_id],
)
if failures >= 3:
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: не удалось вступить (3 попытки): {exc}")
return {"action": "skip", "taskId": task_id}
discovery.add_log(task_id, "error", f"авто-вступление {dialog_id}: {exc}")
return {"action": "error", "taskId": task_id}
discovery.mark_joined(dialog_id, auto=True)
tg.add_dialog_monitored(
dialog_id,
fresh.get("name"),
username,
fresh.get("kind"),
fresh.get("hue"),
)
# разбор последних сообщений источника (спейсинг/read-ack внутри метода);
# вступление уже состоялось — сбой backfill не роняет шаг
try:
await tg.backfill_dialog(dialog_id)
except Exception as exc: # noqa: BLE001
log.warning("join %s: backfill не удался: %s", dialog_id, exc)
discovery.remove_blacklist(dialog_id)
return {"action": "join", "taskId": task_id}
# ─── tick ──────────────────────────────────────────────────────────────────
async def tick() -> dict:
"""Одно действие discovery-воркера (см. docstring модуля)."""
if ban_guard.global_paused():
return _ACTION_NONE
if ban_guard.flood_today():
# флуд-блокировка дня: никаких сетевых действий (поиск/оценка/join),
# пока действует discFloodDay — воркер просто стоит
return _ACTION_NONE
running = _running_tasks()
if not running:
return _ACTION_NONE
# 1. план достигнут — закрываем задачу (важно до поиска/оценки/join:
# задачу с выполненным планом нельзя продолжать обрабатывать)
for task in running:
if int(task["joined"]) >= int(task["planJoins"]):
_finish_done(task)
return {"action": "done", "taskId": task["id"]}
# 2. поиск: следующая running-задача с незавершённым проходом по ключам
for task in running:
if not task["searchDone"]:
return await _search_step(task)
# 3. оценка: первый кандидат status='new' (самая старая задача — первой)
for task in running:
new_cands = discovery.list_candidates(task["id"], status="new")
if new_cands:
return await _eval_step(task, new_cands[0])
# 4. авто-вступление: отдельный проход, приоритет ниже оценки
for task in running:
if not task["autoJoin"]:
continue
review_cands = discovery.list_candidates(task["id"], status="review")
if review_cands:
if not ban_guard.can_auto_join():
return _ACTION_NONE # суточный лимит/флуд/пауза — join никому нельзя
return await _join_step(task, review_cands[0])
return _ACTION_NONE
@@ -1,100 +1,100 @@
"""Вложения проектных карточек.
По решению: файлы хранятся в MinIO (креды в env), в БД — метаданные и ключ
объекта. Тип определяется автоматически по MIME и расширению.
"""
from __future__ import annotations
import json
import time
from ..db import store
KIND_BY_EXT = {
"image": {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "heic"},
"video": {"mp4", "mov", "avi", "mkv", "webm", "m4v"},
"audio": {"mp3", "wav", "ogg", "m4a", "flac", "aac"},
"archive": {"zip", "rar", "7z", "tar", "gz", "bz2"},
"document": {"pdf", "doc", "docx", "xls", "xlsx", "csv", "txt", "md", "rtf", "ppt", "pptx", "odt", "ods"},
}
KIND_LABELS = {
"image": "Изображение",
"video": "Видео",
"audio": "Аудио",
"archive": "Архив",
"document": "Документ",
"other": "Файл",
}
def detect(name: str, mime: str = "") -> dict:
ext = (name.split(".")[-1] if "." in name else "").lower()
kind = "other"
if mime.startswith("image/"):
kind = "image"
elif mime.startswith("video/"):
kind = "video"
elif mime.startswith("audio/"):
kind = "audio"
else:
for k, exts in KIND_BY_EXT.items():
if ext in exts:
kind = k
break
return {"kind": kind, "label": KIND_LABELS[kind]}
def _files_of(card_id: str) -> list[dict]:
from .projects import patch_card
row = store.query_one("SELECT files FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
return json.loads(row["files"] or "[]")
def add_file(card_id: str, name: str, data: bytes, mime: str = "") -> dict:
"""Сохраняет тело в MinIO и метаданные в карточку."""
from . import object_store
from .projects import patch_card
files = _files_of(card_id)
info = detect(name, mime)
object_key = object_store.put(card_id, name, data, mime)
entry = {
"id": store.uid("pf_"),
"name": name,
"size": len(data),
"kind": info["kind"],
"label": info["label"],
"objectKey": object_key,
}
files.append(entry)
patch_card(card_id, {"files": files})
return entry
def get_file_entry(card_id: str, file_id: str) -> tuple[dict, list[dict]]:
files = _files_of(card_id)
entry = next((f for f in files if f["id"] == file_id), None)
if not entry:
raise KeyError(file_id)
return entry, files
def remove_file(card_id: str, file_id: str) -> None:
from . import object_store
from .projects import patch_card
files = _files_of(card_id)
entry = next((f for f in files if f["id"] == file_id), None)
if entry and entry.get("objectKey"):
object_store.remove(entry["objectKey"])
patch_card(card_id, {"files": [f for f in files if f["id"] != file_id]})
def object_storage_available() -> bool:
from . import object_store
return object_store.configured()
"""Вложения проектных карточек.
По решению: файлы хранятся в MinIO (креды в env), в БД — метаданные и ключ
объекта. Тип определяется автоматически по MIME и расширению.
"""
from __future__ import annotations
import json
import time
from ..db import store
KIND_BY_EXT = {
"image": {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "heic"},
"video": {"mp4", "mov", "avi", "mkv", "webm", "m4v"},
"audio": {"mp3", "wav", "ogg", "m4a", "flac", "aac"},
"archive": {"zip", "rar", "7z", "tar", "gz", "bz2"},
"document": {"pdf", "doc", "docx", "xls", "xlsx", "csv", "txt", "md", "rtf", "ppt", "pptx", "odt", "ods"},
}
KIND_LABELS = {
"image": "Изображение",
"video": "Видео",
"audio": "Аудио",
"archive": "Архив",
"document": "Документ",
"other": "Файл",
}
def detect(name: str, mime: str = "") -> dict:
ext = (name.split(".")[-1] if "." in name else "").lower()
kind = "other"
if mime.startswith("image/"):
kind = "image"
elif mime.startswith("video/"):
kind = "video"
elif mime.startswith("audio/"):
kind = "audio"
else:
for k, exts in KIND_BY_EXT.items():
if ext in exts:
kind = k
break
return {"kind": kind, "label": KIND_LABELS[kind]}
def _files_of(card_id: str) -> list[dict]:
from .projects import patch_card
row = store.query_one("SELECT files FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
return json.loads(row["files"] or "[]")
def add_file(card_id: str, name: str, data: bytes, mime: str = "") -> dict:
"""Сохраняет тело в MinIO и метаданные в карточку."""
from . import object_store
from .projects import patch_card
files = _files_of(card_id)
info = detect(name, mime)
object_key = object_store.put(card_id, name, data, mime)
entry = {
"id": store.uid("pf_"),
"name": name,
"size": len(data),
"kind": info["kind"],
"label": info["label"],
"objectKey": object_key,
}
files.append(entry)
patch_card(card_id, {"files": files})
return entry
def get_file_entry(card_id: str, file_id: str) -> tuple[dict, list[dict]]:
files = _files_of(card_id)
entry = next((f for f in files if f["id"] == file_id), None)
if not entry:
raise KeyError(file_id)
return entry, files
def remove_file(card_id: str, file_id: str) -> None:
from . import object_store
from .projects import patch_card
files = _files_of(card_id)
entry = next((f for f in files if f["id"] == file_id), None)
if entry and entry.get("objectKey"):
object_store.remove(entry["objectKey"])
patch_card(card_id, {"files": [f for f in files if f["id"] != file_id]})
def object_storage_available() -> bool:
from . import object_store
return object_store.configured()
@@ -1,103 +1,103 @@
"""Полнотекстовый поиск DuckDB FTS (по решению — включаем).
DuckDB FTS строит виртуальную таблицу-снимок: индекс пересоздаётся при
старте, раз в сутки по расписанию и вручную (POST /api/admin/fts/rebuild).
Чтобы свежесозданные карточки искались сразу, поиск совмещает FTS с
точечным LIKE-дополнением (см. services/leads.search).
Рабочий синтаксис DuckDB >= 1.0: только PRAGMA create_fts_index
(вариант CREATE VIRTUAL TABLE ... USING fts(...) в этой версии падает).
Поиск — через табличную функцию fts_main_<table>.match_bm25(rowid, query).
"""
from __future__ import annotations
import logging
from ..db import store
log = logging.getLogger("leadradar.fts")
OK_KEY = "ftsOk"
# Источник, колонка-rowid, текстовые колонки (должны совпадать со схемой db.py)
_FTS_TARGETS = [
("leads", ("title", "summary", "source_msg", "contact")),
("messages", ("text",)),
("rejected_msgs", ("text",)),
]
def is_ready() -> bool:
return bool(store.get_setting(OK_KEY))
def _set(ok: bool) -> None:
store.set_setting(OK_KEY, ok)
def install_extension() -> bool:
try:
store.execute("INSTALL fts")
store.execute("LOAD fts")
return True
except Exception as exc: # noqa: BLE001
log.warning("FTS extension unavailable: %s", exc)
return False
def rebuild() -> bool:
"""Пересоздаёт FTS-индексы по leads и messages (overwrite поверх старого).
PRAGMA не поддерживает prepared-параметры, поэтому имена подставляются
напрямую — это внутренние константы из _FTS_TARGETS, не пользовательский ввод.
"""
try:
for table, cols in _FTS_TARGETS:
col_list = ", ".join(f"'{c}'" for c in cols)
store.execute(
f"PRAGMA create_fts_index('{table}', 'id', {col_list}, "
"stemmer='russian', overwrite=1)"
)
_set(True)
log.info("FTS rebuild done")
return True
except Exception as exc: # noqa: BLE001
log.warning("FTS rebuild failed: %s", exc)
_set(False)
return False
def search(query: str, limit: int = 50) -> dict:
"""Возвращает {'leads': [ids], 'messages': [ids], 'rejected': [ids]} по FTS
(пусто при сбое)."""
out: dict = {"leads": [], "messages": [], "rejected": []}
if not is_ready():
return out
q = _sanitize(query)
if not q:
return out
targets = ("leads", "messages", "rejected_msgs")
keys = {"leads": "leads", "messages": "messages", "rejected_msgs": "rejected"}
for table in targets:
try:
# fts_main_<table> создаётся PRAGMA create_fts_index над таблицей
fn = f"fts_main_{table}.match_bm25(id, ?)"
rows = store.query(
f"SELECT id, {fn} AS _s FROM {table} "
f"WHERE {fn} IS NOT NULL ORDER BY _s LIMIT ?",
[q, q, limit * 2],
)
out[keys[table]] = [r["id"] for r in rows]
except Exception as exc: # noqa: BLE001
log.warning("fts %s query failed: %s", table, exc)
return out
def _sanitize(query: str) -> str:
"""Минимальная очистка запроса от служебных символов FTS."""
import re
q = query.strip().lower()
q = re.sub(r'["\'\\()!*+-]', " ", q)
q = " ".join(q.split())
return q[:80]
"""Полнотекстовый поиск DuckDB FTS (по решению — включаем).
DuckDB FTS строит виртуальную таблицу-снимок: индекс пересоздаётся при
старте, раз в сутки по расписанию и вручную (POST /api/admin/fts/rebuild).
Чтобы свежесозданные карточки искались сразу, поиск совмещает FTS с
точечным LIKE-дополнением (см. services/leads.search).
Рабочий синтаксис DuckDB >= 1.0: только PRAGMA create_fts_index
(вариант CREATE VIRTUAL TABLE ... USING fts(...) в этой версии падает).
Поиск — через табличную функцию fts_main_<table>.match_bm25(rowid, query).
"""
from __future__ import annotations
import logging
from ..db import store
log = logging.getLogger("leadradar.fts")
OK_KEY = "ftsOk"
# Источник, колонка-rowid, текстовые колонки (должны совпадать со схемой db.py)
_FTS_TARGETS = [
("leads", ("title", "summary", "source_msg", "contact")),
("messages", ("text",)),
("rejected_msgs", ("text",)),
]
def is_ready() -> bool:
return bool(store.get_setting(OK_KEY))
def _set(ok: bool) -> None:
store.set_setting(OK_KEY, ok)
def install_extension() -> bool:
try:
store.execute("INSTALL fts")
store.execute("LOAD fts")
return True
except Exception as exc: # noqa: BLE001
log.warning("FTS extension unavailable: %s", exc)
return False
def rebuild() -> bool:
"""Пересоздаёт FTS-индексы по leads и messages (overwrite поверх старого).
PRAGMA не поддерживает prepared-параметры, поэтому имена подставляются
напрямую — это внутренние константы из _FTS_TARGETS, не пользовательский ввод.
"""
try:
for table, cols in _FTS_TARGETS:
col_list = ", ".join(f"'{c}'" for c in cols)
store.execute(
f"PRAGMA create_fts_index('{table}', 'id', {col_list}, "
"stemmer='russian', overwrite=1)"
)
_set(True)
log.info("FTS rebuild done")
return True
except Exception as exc: # noqa: BLE001
log.warning("FTS rebuild failed: %s", exc)
_set(False)
return False
def search(query: str, limit: int = 50) -> dict:
"""Возвращает {'leads': [ids], 'messages': [ids], 'rejected': [ids]} по FTS
(пусто при сбое)."""
out: dict = {"leads": [], "messages": [], "rejected": []}
if not is_ready():
return out
q = _sanitize(query)
if not q:
return out
targets = ("leads", "messages", "rejected_msgs")
keys = {"leads": "leads", "messages": "messages", "rejected_msgs": "rejected"}
for table in targets:
try:
# fts_main_<table> создаётся PRAGMA create_fts_index над таблицей
fn = f"fts_main_{table}.match_bm25(id, ?)"
rows = store.query(
f"SELECT id, {fn} AS _s FROM {table} "
f"WHERE {fn} IS NOT NULL ORDER BY _s LIMIT ?",
[q, q, limit * 2],
)
out[keys[table]] = [r["id"] for r in rows]
except Exception as exc: # noqa: BLE001
log.warning("fts %s query failed: %s", table, exc)
return out
def _sanitize(query: str) -> str:
"""Минимальная очистка запроса от служебных символов FTS."""
import re
q = query.strip().lower()
q = re.sub(r'["\'\\()!*+-]', " ", q)
q = " ".join(q.split())
return q[:80]
File diff suppressed because it is too large Load Diff
@@ -1,162 +1,162 @@
"""Клиент автономного ML-сервиса + локальный outbox обучения.
Основное приложение НИКОГДА не обучает ML напрямую «в своей» базе: каждое
действие пользователя синхронно пишется в таблицу ml_outbox, а фоновый
воркер (main._ml_sync_loop) отправляет накопленное в ML-сервис батчами.
Использование ML в пайплайне включается настройкой `mlEnabled`; обучение
идёт всегда.
"""
from __future__ import annotations
import logging
import time
import httpx
from .. import config
from ..db import store
log = logging.getLogger("leadradar.mlclient")
DECISIONS_ML = "mlDecisions"
DECISIONS_AI = "aiDecisions"
# Веса обучающих сигналов: действия пользователя — истина (1.0), решения ИИ —
# гипотезы (меньше), чтобы реальные действия со временем перевешивали ошибки ИИ.
USER_WEIGHT = 1.0
AI_WEIGHT = 0.4
RULE_WEIGHT = 0.6
# кэш статуса сервиса (обновляется фоновым циклом; живёт не дольше 15 c)
_cached: dict = {"at": 0, "data": None, "reachable": False}
def _now() -> int:
return time.time_ns() // 1_000_000
# ─── Обучение: всегда пишем в outbox ──────────────────────────────────────
def push(text: str, label: str, delta: float = 1.0) -> None:
"""Действие пользователя -> событие обучения (гарантированно, локально)."""
text = (text or "").strip()
label = str(label or "").strip()
if not text or not label:
return
store.execute(
"INSERT INTO ml_outbox(id, text, label, delta, created_at) VALUES (?, ?, ?, ?, ?)",
[store.uid("mle_"), text[:6000], label, delta, _now()],
)
def outbox_len() -> int:
return int(store.scalar("SELECT count(*) FROM ml_outbox") or 0)
async def flush_outbox(batch: int = 100) -> int:
"""Отправляет накопленные события в ML-сервис небольшими порциями.
Один learn-batch из сотен сообщений надолго блокирует ML-сервис
(модель пишет каждый термин отдельным INSERT) и упирается в таймаут;
порции по 20 строк проходят быстро и не роняют сервис.
"""
chunk = 10
total = 0
while total < batch:
rows = store.query(
"SELECT id, text, label, delta FROM ml_outbox ORDER BY created_at LIMIT ?",
[chunk],
)
if not rows:
break
items = [{"text": r["text"], "label": r["label"], "delta": r["delta"]} for r in rows]
try:
await _post("/learn-batch", {"items": items}, timeout=config.ML_TIMEOUT + 10)
except Exception as exc: # noqa: BLE001
log.warning("ml outbox flush failed (%d rows): %s", len(rows), exc)
break
ids = [r["id"] for r in rows]
store.execute(f"DELETE FROM ml_outbox WHERE id IN ({','.join('?' * len(ids))})", ids)
total += len(rows)
log.info("ml outbox flushed: %d", len(rows))
return total
# ─── HTTP ─────────────────────────────────────────────────────────────────
async def _post(path: str, body: dict, timeout: float | None = None) -> dict:
async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client:
resp = await client.post(config.ML_URL + path, json=body)
resp.raise_for_status()
return resp.json()
async def _get(path: str, timeout: float | None = None) -> dict:
async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client:
resp = await client.get(config.ML_URL + path)
resp.raise_for_status()
return resp.json()
async def predict(text: str) -> dict:
"""Предсказание. При сбое/недоступности сервиса — «не уверен» (решит ИИ)."""
try:
return await _post("/predict", {"text": (text or "")[:6000]})
except Exception as exc: # noqa: BLE001
log.debug("ml predict unavailable: %s", exc)
return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": False}
async def reset_model() -> dict:
"""Полный сброс ML-модели + очистка очереди обучения.
Старая модель (в т.ч. «мусорные» классы удалённых колонок) стирается,
события обучения из outbox тоже удаляются — иначе они сразу «переобучат»
модель на старых данных.
"""
try:
await _post("/reset", {}, timeout=30)
except Exception as exc: # noqa: BLE001
log.warning("ml reset failed: %s", exc)
return {"ok": False, "error": str(exc)}
store.execute("DELETE FROM ml_outbox")
await refresh_status()
return {"ok": True}
async def refresh_status() -> dict:
global _cached
try:
data = await _get("/status", timeout=3)
_cached = {"at": _now(), "data": data, "reachable": True}
except Exception as exc: # noqa: BLE001
log.debug("ml status unavailable: %s", exc)
_cached = {"at": _now(), "data": _cached["data"], "reachable": False}
return _cached["data"] or {}
def snapshot() -> dict:
"""Локальная статистика + последний известный статус ML-сервиса (без HTTP)."""
fresh = _cached["data"] or {}
return {
"ml": int(store.get_setting(DECISIONS_ML) or 0),
"ai": int(store.get_setting(DECISIONS_AI) or 0),
"learning": int(store.scalar("SELECT count(*) FROM learning_log") or 0),
"ready": bool(fresh.get("ready")),
"classes": fresh.get("classes", {}),
"learned": int(fresh.get("learned") or 0),
"reachable": _cached["reachable"],
"outbox": outbox_len(),
}
def track_decisions(ml: int = 0, ai: int = 0) -> None:
if ml:
store.set_setting(DECISIONS_ML, int(store.get_setting(DECISIONS_ML) or 0) + ml)
if ai:
store.set_setting(DECISIONS_AI, int(store.get_setting(DECISIONS_AI) or 0) + ai)
def is_enabled() -> bool:
"""Использовать ли ML в пайплайне (настройка UI). Обучение — всегда."""
return store.get_setting("mlEnabled") is not False and _cached["reachable"]
"""Клиент автономного ML-сервиса + локальный outbox обучения.
Основное приложение НИКОГДА не обучает ML напрямую «в своей» базе: каждое
действие пользователя синхронно пишется в таблицу ml_outbox, а фоновый
воркер (main._ml_sync_loop) отправляет накопленное в ML-сервис батчами.
Использование ML в пайплайне включается настройкой `mlEnabled`; обучение
идёт всегда.
"""
from __future__ import annotations
import logging
import time
import httpx
from .. import config
from ..db import store
log = logging.getLogger("leadradar.mlclient")
DECISIONS_ML = "mlDecisions"
DECISIONS_AI = "aiDecisions"
# Веса обучающих сигналов: действия пользователя — истина (1.0), решения ИИ —
# гипотезы (меньше), чтобы реальные действия со временем перевешивали ошибки ИИ.
USER_WEIGHT = 1.0
AI_WEIGHT = 0.4
RULE_WEIGHT = 0.6
# кэш статуса сервиса (обновляется фоновым циклом; живёт не дольше 15 c)
_cached: dict = {"at": 0, "data": None, "reachable": False}
def _now() -> int:
return time.time_ns() // 1_000_000
# ─── Обучение: всегда пишем в outbox ──────────────────────────────────────
def push(text: str, label: str, delta: float = 1.0) -> None:
"""Действие пользователя -> событие обучения (гарантированно, локально)."""
text = (text or "").strip()
label = str(label or "").strip()
if not text or not label:
return
store.execute(
"INSERT INTO ml_outbox(id, text, label, delta, created_at) VALUES (?, ?, ?, ?, ?)",
[store.uid("mle_"), text[:6000], label, delta, _now()],
)
def outbox_len() -> int:
return int(store.scalar("SELECT count(*) FROM ml_outbox") or 0)
async def flush_outbox(batch: int = 100) -> int:
"""Отправляет накопленные события в ML-сервис небольшими порциями.
Один learn-batch из сотен сообщений надолго блокирует ML-сервис
(модель пишет каждый термин отдельным INSERT) и упирается в таймаут;
порции по 20 строк проходят быстро и не роняют сервис.
"""
chunk = 10
total = 0
while total < batch:
rows = store.query(
"SELECT id, text, label, delta FROM ml_outbox ORDER BY created_at LIMIT ?",
[chunk],
)
if not rows:
break
items = [{"text": r["text"], "label": r["label"], "delta": r["delta"]} for r in rows]
try:
await _post("/learn-batch", {"items": items}, timeout=config.ML_TIMEOUT + 10)
except Exception as exc: # noqa: BLE001
log.warning("ml outbox flush failed (%d rows): %s", len(rows), exc)
break
ids = [r["id"] for r in rows]
store.execute(f"DELETE FROM ml_outbox WHERE id IN ({','.join('?' * len(ids))})", ids)
total += len(rows)
log.info("ml outbox flushed: %d", len(rows))
return total
# ─── HTTP ─────────────────────────────────────────────────────────────────
async def _post(path: str, body: dict, timeout: float | None = None) -> dict:
async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client:
resp = await client.post(config.ML_URL + path, json=body)
resp.raise_for_status()
return resp.json()
async def _get(path: str, timeout: float | None = None) -> dict:
async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client:
resp = await client.get(config.ML_URL + path)
resp.raise_for_status()
return resp.json()
async def predict(text: str) -> dict:
"""Предсказание. При сбое/недоступности сервиса — «не уверен» (решит ИИ)."""
try:
return await _post("/predict", {"text": (text or "")[:6000]})
except Exception as exc: # noqa: BLE001
log.debug("ml predict unavailable: %s", exc)
return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": False}
async def reset_model() -> dict:
"""Полный сброс ML-модели + очистка очереди обучения.
Старая модель (в т.ч. «мусорные» классы удалённых колонок) стирается,
события обучения из outbox тоже удаляются — иначе они сразу «переобучат»
модель на старых данных.
"""
try:
await _post("/reset", {}, timeout=30)
except Exception as exc: # noqa: BLE001
log.warning("ml reset failed: %s", exc)
return {"ok": False, "error": str(exc)}
store.execute("DELETE FROM ml_outbox")
await refresh_status()
return {"ok": True}
async def refresh_status() -> dict:
global _cached
try:
data = await _get("/status", timeout=3)
_cached = {"at": _now(), "data": data, "reachable": True}
except Exception as exc: # noqa: BLE001
log.debug("ml status unavailable: %s", exc)
_cached = {"at": _now(), "data": _cached["data"], "reachable": False}
return _cached["data"] or {}
def snapshot() -> dict:
"""Локальная статистика + последний известный статус ML-сервиса (без HTTP)."""
fresh = _cached["data"] or {}
return {
"ml": int(store.get_setting(DECISIONS_ML) or 0),
"ai": int(store.get_setting(DECISIONS_AI) or 0),
"learning": int(store.scalar("SELECT count(*) FROM learning_log") or 0),
"ready": bool(fresh.get("ready")),
"classes": fresh.get("classes", {}),
"learned": int(fresh.get("learned") or 0),
"reachable": _cached["reachable"],
"outbox": outbox_len(),
}
def track_decisions(ml: int = 0, ai: int = 0) -> None:
if ml:
store.set_setting(DECISIONS_ML, int(store.get_setting(DECISIONS_ML) or 0) + ml)
if ai:
store.set_setting(DECISIONS_AI, int(store.get_setting(DECISIONS_AI) or 0) + ai)
def is_enabled() -> bool:
"""Использовать ли ML в пайплайне (настройка UI). Обучение — всегда."""
return store.get_setting("mlEnabled") is not False and _cached["reachable"]
@@ -1,108 +1,108 @@
"""Хранение вложений: MinIO (S3-совместимое), креды в env.
Если MinIO не настроен (локальная разработка без docker-compose), объекты
сохраняются в локальную папку DATA_DIR/attachments — API и карточки при этом
не меняются, в БД хранится только ключ объекта.
"""
from __future__ import annotations
import io
import logging
from pathlib import Path
from .. import config
log = logging.getLogger("leadradar.objectstore")
def configured() -> bool:
return bool(config.MINIO_ENDPOINT and config.MINIO_ACCESS_KEY and config.MINIO_SECRET_KEY)
_client = None
_client_checked = False
def client():
"""MinIO-клиент (создаётся лениво). Бросает ошибку, если не настроен."""
global _client, _client_checked
if _client is not None:
return _client
if not configured():
raise RuntimeError(
"MinIO не настроен: задайте LEADRADAR_MINIO_ENDPOINT / _ACCESS_KEY / _SECRET_KEY / _BUCKET"
)
from minio import Minio
from minio.error import S3Error
_client = Minio(
config.MINIO_ENDPOINT,
access_key=config.MINIO_ACCESS_KEY,
secret_key=config.MINIO_SECRET_KEY,
secure=config.MINIO_SECURE,
)
if not _client_checked:
_client_checked = True
try:
if not _client.bucket_exists(config.MINIO_BUCKET):
_client.make_bucket(config.MINIO_BUCKET)
except S3Error as exc:
log.warning("bucket check failed: %s", exc)
return _client
def _local_path(object_key: str) -> Path:
# object_key вида projects/{card_id}/{ts}_{name}; не даём выйти за пределы FILES_DIR
safe = Path(object_key).name
parent = Path(object_key).parent
return (config.FILES_DIR / parent / safe).resolve()
def put(card_id: str, name: str, data: bytes, content_type: str) -> str:
"""Сохраняет объект (MinIO или локально), возвращает objectKey."""
import time
object_key = f"projects/{card_id}/{int(time.time() * 1000)}_{name}"
if configured():
client().put_object(
config.MINIO_BUCKET,
object_key,
io.BytesIO(data),
length=len(data),
content_type=content_type or "application/octet-stream",
)
else:
path = _local_path(object_key)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
log.info("MinIO не настроен — файл сохранён локально: %s", path)
return object_key
def get(object_key: str):
"""Возвращает поток (бинарный) для скачивания."""
if configured():
try:
return client().get_object(config.MINIO_BUCKET, object_key)
except Exception as exc: # noqa: BLE001
log.warning("minio get failed: %s", exc)
raise
path = _local_path(object_key)
if not path.is_file():
raise FileNotFoundError(object_key)
return io.BytesIO(path.read_bytes())
def remove(object_key: str) -> None:
if configured():
try:
client().remove_object(config.MINIO_BUCKET, object_key)
except Exception as exc: # noqa: BLE001
log.warning("minio remove failed: %s", exc)
return
try:
path = _local_path(object_key)
if path.is_file():
path.unlink()
except Exception as exc: # noqa: BLE001
log.warning("local remove failed: %s", exc)
"""Хранение вложений: MinIO (S3-совместимое), креды в env.
Если MinIO не настроен (локальная разработка без docker-compose), объекты
сохраняются в локальную папку DATA_DIR/attachments — API и карточки при этом
не меняются, в БД хранится только ключ объекта.
"""
from __future__ import annotations
import io
import logging
from pathlib import Path
from .. import config
log = logging.getLogger("leadradar.objectstore")
def configured() -> bool:
return bool(config.MINIO_ENDPOINT and config.MINIO_ACCESS_KEY and config.MINIO_SECRET_KEY)
_client = None
_client_checked = False
def client():
"""MinIO-клиент (создаётся лениво). Бросает ошибку, если не настроен."""
global _client, _client_checked
if _client is not None:
return _client
if not configured():
raise RuntimeError(
"MinIO не настроен: задайте LEADRADAR_MINIO_ENDPOINT / _ACCESS_KEY / _SECRET_KEY / _BUCKET"
)
from minio import Minio
from minio.error import S3Error
_client = Minio(
config.MINIO_ENDPOINT,
access_key=config.MINIO_ACCESS_KEY,
secret_key=config.MINIO_SECRET_KEY,
secure=config.MINIO_SECURE,
)
if not _client_checked:
_client_checked = True
try:
if not _client.bucket_exists(config.MINIO_BUCKET):
_client.make_bucket(config.MINIO_BUCKET)
except S3Error as exc:
log.warning("bucket check failed: %s", exc)
return _client
def _local_path(object_key: str) -> Path:
# object_key вида projects/{card_id}/{ts}_{name}; не даём выйти за пределы FILES_DIR
safe = Path(object_key).name
parent = Path(object_key).parent
return (config.FILES_DIR / parent / safe).resolve()
def put(card_id: str, name: str, data: bytes, content_type: str) -> str:
"""Сохраняет объект (MinIO или локально), возвращает objectKey."""
import time
object_key = f"projects/{card_id}/{int(time.time() * 1000)}_{name}"
if configured():
client().put_object(
config.MINIO_BUCKET,
object_key,
io.BytesIO(data),
length=len(data),
content_type=content_type or "application/octet-stream",
)
else:
path = _local_path(object_key)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
log.info("MinIO не настроен — файл сохранён локально: %s", path)
return object_key
def get(object_key: str):
"""Возвращает поток (бинарный) для скачивания."""
if configured():
try:
return client().get_object(config.MINIO_BUCKET, object_key)
except Exception as exc: # noqa: BLE001
log.warning("minio get failed: %s", exc)
raise
path = _local_path(object_key)
if not path.is_file():
raise FileNotFoundError(object_key)
return io.BytesIO(path.read_bytes())
def remove(object_key: str) -> None:
if configured():
try:
client().remove_object(config.MINIO_BUCKET, object_key)
except Exception as exc: # noqa: BLE001
log.warning("minio remove failed: %s", exc)
return
try:
path = _local_path(object_key)
if path.is_file():
path.unlink()
except Exception as exc: # noqa: BLE001
log.warning("local remove failed: %s", exc)
File diff suppressed because it is too large Load Diff
@@ -1,320 +1,320 @@
"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев).
Очередь — сырые сообщения из каналов, ждущие разбора (pipeline_msg).
Отсев — сообщения, отброшенные на любом этапе: стоп-фразы/резюме/тип заявки/
без суммы (source='stop'), устарело ('stale'), ML ('ml'), ИИ ('ai'), повтор
('dup'). Для каждой записи храним этап, причину и конкретное слово/фразу
(kw), если отсев по стоп-списку.
Автоочистка отсева — раз в 3 суток (вызывается из leads.tick_storage),
плюс ручная очистка и удаление отдельных записей из UI.
"""
from __future__ import annotations
import logging
import time
from ..db import store
from . import fts as fts_svc
log = logging.getLogger("leadradar.processing")
# отсев живёт 3 суток, дальше удаляется автоматически
RETENTION_DAYS = 3
# человекочитаемые подписи этапов (для UI; source хранится отдельно)
_STAGE_LABELS = {
"length": "короткое сообщение",
"stop": "стоп-фраза",
"resume": "резюме соискателя",
"type": "тип заявки",
"budget": "нет суммы",
"stale": "устарело",
"spam_ml": "спам (ML)",
"spam_ai": "спам (ИИ)",
"filter_ai": "ИИ-фильтр",
"dup": "повтор",
}
# «чьё» решение: используется в UI как источник метки
_SOURCE_LABELS = {
"stop": "правила",
"ml": "ML",
"ai": "ИИ",
"stale": "система",
"dup": "система",
}
DEFAULT_LIMIT = 100
MAX_LIMIT = 500
def _now() -> int:
return time.time_ns() // 1_000_000
def stage_label(stage: str) -> str:
return _STAGE_LABELS.get(stage, stage or "отсев")
def source_label(source: str) -> str:
return _SOURCE_LABELS.get(source, source or "система")
# ─── Запись отсева ────────────────────────────────────────────────────────
def record(row: dict, source: str, stage: str, reason: str, kw: str = "") -> None:
"""Сохраняет отброшенное сообщение в таблицу отсева.
Ид записи детерминирован по (dialog_id, msg_id): повторное отбрасывание
того же сообщения (перечитывание каналов) обновляет запись, а не копит
дубликаты в списке отсева.
"""
if not row or not (row.get("text") or "").strip():
return
msg_id = row.get("msg_id")
dialog_id = row.get("dialog_id") or ""
rid = f"r_{dialog_id}_{msg_id}" if (msg_id is not None and dialog_id) else store.uid("r_")
now = _now()
store.execute(
"INSERT INTO rejected_msgs(id, dialog_id, msg_id, text, ch_name, ch_handle, ch_hue, stage, reason, kw, source, msg_at, rejected_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET "
"text = excluded.text, ch_name = excluded.ch_name, ch_handle = excluded.ch_handle, "
"ch_hue = excluded.ch_hue, stage = excluded.stage, reason = excluded.reason, kw = excluded.kw, "
"source = excluded.source, msg_at = excluded.msg_at, rejected_at = excluded.rejected_at",
[
rid,
dialog_id,
msg_id,
str(row["text"])[:6000],
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
stage,
str(reason or "")[:500],
str(kw or "")[:200],
source,
row.get("msg_at"),
now,
],
)
def purge_expired(days: int = RETENTION_DAYS) -> int:
"""Автоочистка отсева: записи старше N суток удаляются безвозвратно."""
cut = _now() - days * 24 * 3600 * 1000
rows = store.query(
"SELECT id FROM rejected_msgs WHERE rejected_at < ?", [cut]
)
if not rows:
return 0
ids = [r["id"] for r in rows]
store.execute(
"DELETE FROM rejected_msgs WHERE id IN (" + ",".join(["?"] * len(ids)) + ")",
ids,
)
return len(ids)
def clear_all() -> int:
rows = store.query("SELECT count(*) AS c FROM rejected_msgs")
total = int(rows[0]["c"]) if rows else 0
if total:
store.execute("DELETE FROM rejected_msgs")
return total
def return_to_queue(rej_id: str, reason: str = "") -> dict:
"""Вернуть отсеянное сообщение в обработку (кнопка в «Обработке»).
Строка очереди помечается force: этап 1, устарело, ML-решения и ИИ-отсев
для неё игнорируются — сообщение уходит на классификацию и создаёт карточку.
Запись в отсеве не удаляется, а помечается «возвращено» с причиной (аудит).
Если отсев был по решению «спам» (ML/ИИ) — снимаем у ML вес спама для текста.
"""
row = store.query_one("SELECT * FROM rejected_msgs WHERE id = ?", [rej_id])
if not row:
raise KeyError(rej_id)
if bool(row.get("returned")):
raise ValueError("Сообщение уже возвращено в обработку")
if str(row.get("source") or "") == "dup":
raise ValueError("Повтор: карточка с таким текстом уже есть в системе — возвращать нечего")
dialog_id = str(row.get("dialog_id") or "")
msg_id = row.get("msg_id")
text = str(row.get("text") or "").strip()
if not text:
raise ValueError("В записи нет текста сообщения")
if str(row.get("stage") or "") in ("spam_ml", "spam_ai", "filter_ai"):
from . import ml_client
# реальное действие пользователя: этот текст НЕ спам
ml_client.push(text, "spam", delta=-1.0)
now = _now()
store.execute(
"UPDATE rejected_msgs SET returned = TRUE, returned_at = ?, return_reason = ? WHERE id = ?",
[now, str(reason or "").strip()[:500], rej_id],
)
from .pipeline import enqueue # локальный импорт: pipeline импортирует processing
if dialog_id and msg_id is not None:
enqueue(
dialog_id,
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
msg_id,
text,
row.get("msg_at") or now,
force=True,
)
else:
# старые записи (до сохранения dialog_id/msg_id): текст сохранился,
# возвращаем без ссылки на исходное сообщение (force=True)
store.execute(
"INSERT INTO pipeline_msg(id, dialog_id, ch_name, ch_handle, ch_hue, text, msg_id, msg_at, status, force, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', TRUE, ?, ?)",
[
store.uid("p_"),
dialog_id,
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
text[:6000],
msg_id,
row.get("msg_at") or now,
now,
now,
],
)
return {"id": rej_id, "returned": True, "returnedAt": now}
def delete_one(rej_id: str) -> bool:
store.execute("DELETE FROM rejected_msgs WHERE id = ?", [rej_id])
return True
def rejected_count() -> int:
return int(store.scalar("SELECT count(*) FROM rejected_msgs") or 0)
# ─── Очередь (pipeline_msg) ───────────────────────────────────────────────
def queue_counts() -> dict:
out = {"new": 0, "ai": 0}
for r in store.query("SELECT status, count(*) AS c FROM pipeline_msg GROUP BY status"):
if r["status"] == "new":
out["new"] = int(r["c"])
elif r["status"] == "filtered":
out["ai"] = int(r["c"])
out["total"] = out["new"] + out["ai"]
return out
def list_queue(limit: int = DEFAULT_LIMIT) -> list[dict]:
limit = min(max(1, limit), MAX_LIMIT)
rows = store.query(
"SELECT * FROM pipeline_msg ORDER BY created_at LIMIT ?", [limit]
)
items = []
for r in rows:
items.append(
{
"id": r["id"],
"dialogId": r.get("dialog_id") or "",
"msgId": r.get("msg_id"),
"text": r["text"],
"status": r["status"], # new | filtered
"ch": {
"name": r["ch_name"],
"handle": r["ch_handle"],
"hue": r["ch_hue"],
},
"msgAt": r["msg_at"],
"queuedAt": r["created_at"],
}
)
return items
# ─── Отсев (rejected_msgs) ────────────────────────────────────────────────
def list_rejected(q: str = "", offset: int = 0, limit: int = DEFAULT_LIMIT) -> dict:
offset = max(0, offset)
limit = min(max(1, limit), MAX_LIMIT)
qq = (q or "").strip().lower()
ids: list[str] = []
if qq:
# FTS-кандидаты + LIKE-дополнение (свежие записи после последнего rebuild)
if fts_svc.is_ready():
try:
ids = fts_svc.search(qq, limit=limit)["rejected"]
except Exception: # noqa: BLE001
ids = []
pattern = f"%{qq}%"
like = store.query(
"SELECT id FROM rejected_msgs WHERE "
"lower(text) LIKE ? OR lower(reason) LIKE ? OR lower(kw) LIKE ? OR lower(ch_name) LIKE ? "
"ORDER BY rejected_at DESC LIMIT ?",
[pattern, pattern, pattern, pattern, limit * 2],
)
for r in like:
if r["id"] not in ids:
ids.append(r["id"])
total = len(ids) # итог по условию поиска (все кандидаты)
page = ids[offset : offset + limit]
else:
total = rejected_count()
page_rows = store.query(
"SELECT id FROM rejected_msgs ORDER BY rejected_at DESC LIMIT ? OFFSET ?",
[limit, offset],
)
page = [r["id"] for r in page_rows]
by_id: dict[str, dict] = {}
if page:
ph = ",".join(["?"] * len(page))
rows = store.query(
f"SELECT * FROM rejected_msgs WHERE id IN ({ph})", page
)
for r in rows:
by_id[r["id"]] = r
items = []
for rid in page:
r = by_id.get(rid)
if not r:
continue
items.append(
{
"id": r["id"],
"dialogId": r.get("dialog_id") or "",
"msgId": r.get("msg_id"),
"text": r["text"],
"stage": r["stage"],
"stageLabel": stage_label(r["stage"]),
"reason": r["reason"],
"kw": r["kw"],
"source": r["source"],
"sourceLabel": source_label(r["source"]),
"ch": {"name": r["ch_name"], "handle": r["ch_handle"], "hue": r.get("ch_hue") or "#666"},
"msgAt": r["msg_at"],
"rejectedAt": r["rejected_at"],
"returned": bool(r.get("returned")),
"returnedAt": r.get("returned_at"),
"returnReason": r.get("return_reason") or "",
}
)
return {"items": items, "total": total, "offset": offset, "limit": limit}
def stats() -> dict:
q = queue_counts()
return {
"queue": q,
"rejected": rejected_count(),
}
"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев).
Очередь — сырые сообщения из каналов, ждущие разбора (pipeline_msg).
Отсев — сообщения, отброшенные на любом этапе: стоп-фразы/резюме/тип заявки/
без суммы (source='stop'), устарело ('stale'), ML ('ml'), ИИ ('ai'), повтор
('dup'). Для каждой записи храним этап, причину и конкретное слово/фразу
(kw), если отсев по стоп-списку.
Автоочистка отсева — раз в 3 суток (вызывается из leads.tick_storage),
плюс ручная очистка и удаление отдельных записей из UI.
"""
from __future__ import annotations
import logging
import time
from ..db import store
from . import fts as fts_svc
log = logging.getLogger("leadradar.processing")
# отсев живёт 3 суток, дальше удаляется автоматически
RETENTION_DAYS = 3
# человекочитаемые подписи этапов (для UI; source хранится отдельно)
_STAGE_LABELS = {
"length": "короткое сообщение",
"stop": "стоп-фраза",
"resume": "резюме соискателя",
"type": "тип заявки",
"budget": "нет суммы",
"stale": "устарело",
"spam_ml": "спам (ML)",
"spam_ai": "спам (ИИ)",
"filter_ai": "ИИ-фильтр",
"dup": "повтор",
}
# «чьё» решение: используется в UI как источник метки
_SOURCE_LABELS = {
"stop": "правила",
"ml": "ML",
"ai": "ИИ",
"stale": "система",
"dup": "система",
}
DEFAULT_LIMIT = 100
MAX_LIMIT = 500
def _now() -> int:
return time.time_ns() // 1_000_000
def stage_label(stage: str) -> str:
return _STAGE_LABELS.get(stage, stage or "отсев")
def source_label(source: str) -> str:
return _SOURCE_LABELS.get(source, source or "система")
# ─── Запись отсева ────────────────────────────────────────────────────────
def record(row: dict, source: str, stage: str, reason: str, kw: str = "") -> None:
"""Сохраняет отброшенное сообщение в таблицу отсева.
Ид записи детерминирован по (dialog_id, msg_id): повторное отбрасывание
того же сообщения (перечитывание каналов) обновляет запись, а не копит
дубликаты в списке отсева.
"""
if not row or not (row.get("text") or "").strip():
return
msg_id = row.get("msg_id")
dialog_id = row.get("dialog_id") or ""
rid = f"r_{dialog_id}_{msg_id}" if (msg_id is not None and dialog_id) else store.uid("r_")
now = _now()
store.execute(
"INSERT INTO rejected_msgs(id, dialog_id, msg_id, text, ch_name, ch_handle, ch_hue, stage, reason, kw, source, msg_at, rejected_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET "
"text = excluded.text, ch_name = excluded.ch_name, ch_handle = excluded.ch_handle, "
"ch_hue = excluded.ch_hue, stage = excluded.stage, reason = excluded.reason, kw = excluded.kw, "
"source = excluded.source, msg_at = excluded.msg_at, rejected_at = excluded.rejected_at",
[
rid,
dialog_id,
msg_id,
str(row["text"])[:6000],
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
stage,
str(reason or "")[:500],
str(kw or "")[:200],
source,
row.get("msg_at"),
now,
],
)
def purge_expired(days: int = RETENTION_DAYS) -> int:
"""Автоочистка отсева: записи старше N суток удаляются безвозвратно."""
cut = _now() - days * 24 * 3600 * 1000
rows = store.query(
"SELECT id FROM rejected_msgs WHERE rejected_at < ?", [cut]
)
if not rows:
return 0
ids = [r["id"] for r in rows]
store.execute(
"DELETE FROM rejected_msgs WHERE id IN (" + ",".join(["?"] * len(ids)) + ")",
ids,
)
return len(ids)
def clear_all() -> int:
rows = store.query("SELECT count(*) AS c FROM rejected_msgs")
total = int(rows[0]["c"]) if rows else 0
if total:
store.execute("DELETE FROM rejected_msgs")
return total
def return_to_queue(rej_id: str, reason: str = "") -> dict:
"""Вернуть отсеянное сообщение в обработку (кнопка в «Обработке»).
Строка очереди помечается force: этап 1, устарело, ML-решения и ИИ-отсев
для неё игнорируются — сообщение уходит на классификацию и создаёт карточку.
Запись в отсеве не удаляется, а помечается «возвращено» с причиной (аудит).
Если отсев был по решению «спам» (ML/ИИ) — снимаем у ML вес спама для текста.
"""
row = store.query_one("SELECT * FROM rejected_msgs WHERE id = ?", [rej_id])
if not row:
raise KeyError(rej_id)
if bool(row.get("returned")):
raise ValueError("Сообщение уже возвращено в обработку")
if str(row.get("source") or "") == "dup":
raise ValueError("Повтор: карточка с таким текстом уже есть в системе — возвращать нечего")
dialog_id = str(row.get("dialog_id") or "")
msg_id = row.get("msg_id")
text = str(row.get("text") or "").strip()
if not text:
raise ValueError("В записи нет текста сообщения")
if str(row.get("stage") or "") in ("spam_ml", "spam_ai", "filter_ai"):
from . import ml_client
# реальное действие пользователя: этот текст НЕ спам
ml_client.push(text, "spam", delta=-1.0)
now = _now()
store.execute(
"UPDATE rejected_msgs SET returned = TRUE, returned_at = ?, return_reason = ? WHERE id = ?",
[now, str(reason or "").strip()[:500], rej_id],
)
from .pipeline import enqueue # локальный импорт: pipeline импортирует processing
if dialog_id and msg_id is not None:
enqueue(
dialog_id,
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
msg_id,
text,
row.get("msg_at") or now,
force=True,
)
else:
# старые записи (до сохранения dialog_id/msg_id): текст сохранился,
# возвращаем без ссылки на исходное сообщение (force=True)
store.execute(
"INSERT INTO pipeline_msg(id, dialog_id, ch_name, ch_handle, ch_hue, text, msg_id, msg_at, status, force, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', TRUE, ?, ?)",
[
store.uid("p_"),
dialog_id,
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
text[:6000],
msg_id,
row.get("msg_at") or now,
now,
now,
],
)
return {"id": rej_id, "returned": True, "returnedAt": now}
def delete_one(rej_id: str) -> bool:
store.execute("DELETE FROM rejected_msgs WHERE id = ?", [rej_id])
return True
def rejected_count() -> int:
return int(store.scalar("SELECT count(*) FROM rejected_msgs") or 0)
# ─── Очередь (pipeline_msg) ───────────────────────────────────────────────
def queue_counts() -> dict:
out = {"new": 0, "ai": 0}
for r in store.query("SELECT status, count(*) AS c FROM pipeline_msg GROUP BY status"):
if r["status"] == "new":
out["new"] = int(r["c"])
elif r["status"] == "filtered":
out["ai"] = int(r["c"])
out["total"] = out["new"] + out["ai"]
return out
def list_queue(limit: int = DEFAULT_LIMIT) -> list[dict]:
limit = min(max(1, limit), MAX_LIMIT)
rows = store.query(
"SELECT * FROM pipeline_msg ORDER BY created_at LIMIT ?", [limit]
)
items = []
for r in rows:
items.append(
{
"id": r["id"],
"dialogId": r.get("dialog_id") or "",
"msgId": r.get("msg_id"),
"text": r["text"],
"status": r["status"], # new | filtered
"ch": {
"name": r["ch_name"],
"handle": r["ch_handle"],
"hue": r["ch_hue"],
},
"msgAt": r["msg_at"],
"queuedAt": r["created_at"],
}
)
return items
# ─── Отсев (rejected_msgs) ────────────────────────────────────────────────
def list_rejected(q: str = "", offset: int = 0, limit: int = DEFAULT_LIMIT) -> dict:
offset = max(0, offset)
limit = min(max(1, limit), MAX_LIMIT)
qq = (q or "").strip().lower()
ids: list[str] = []
if qq:
# FTS-кандидаты + LIKE-дополнение (свежие записи после последнего rebuild)
if fts_svc.is_ready():
try:
ids = fts_svc.search(qq, limit=limit)["rejected"]
except Exception: # noqa: BLE001
ids = []
pattern = f"%{qq}%"
like = store.query(
"SELECT id FROM rejected_msgs WHERE "
"lower(text) LIKE ? OR lower(reason) LIKE ? OR lower(kw) LIKE ? OR lower(ch_name) LIKE ? "
"ORDER BY rejected_at DESC LIMIT ?",
[pattern, pattern, pattern, pattern, limit * 2],
)
for r in like:
if r["id"] not in ids:
ids.append(r["id"])
total = len(ids) # итог по условию поиска (все кандидаты)
page = ids[offset : offset + limit]
else:
total = rejected_count()
page_rows = store.query(
"SELECT id FROM rejected_msgs ORDER BY rejected_at DESC LIMIT ? OFFSET ?",
[limit, offset],
)
page = [r["id"] for r in page_rows]
by_id: dict[str, dict] = {}
if page:
ph = ",".join(["?"] * len(page))
rows = store.query(
f"SELECT * FROM rejected_msgs WHERE id IN ({ph})", page
)
for r in rows:
by_id[r["id"]] = r
items = []
for rid in page:
r = by_id.get(rid)
if not r:
continue
items.append(
{
"id": r["id"],
"dialogId": r.get("dialog_id") or "",
"msgId": r.get("msg_id"),
"text": r["text"],
"stage": r["stage"],
"stageLabel": stage_label(r["stage"]),
"reason": r["reason"],
"kw": r["kw"],
"source": r["source"],
"sourceLabel": source_label(r["source"]),
"ch": {"name": r["ch_name"], "handle": r["ch_handle"], "hue": r.get("ch_hue") or "#666"},
"msgAt": r["msg_at"],
"rejectedAt": r["rejected_at"],
"returned": bool(r.get("returned")),
"returnedAt": r.get("returned_at"),
"returnReason": r.get("return_reason") or "",
}
)
return {"items": items, "total": total, "offset": offset, "limit": limit}
def stats() -> dict:
q = queue_counts()
return {
"queue": q,
"rejected": rejected_count(),
}
@@ -1,282 +1,282 @@
"""«Выбранные» — проектный канбан (п.4.8 ТЗ).
Карточка живёт только здесь: лид уходит с дашборда безвозвратно (col='taken'),
в архив/корзину проектные карточки не попадают. Есть история движения,
вложения (файлы сейчас мета-мок, MinIO позже), ссылки, ТЗ и напоминания
для стадии «Отложено».
"""
from __future__ import annotations
import json
import logging
import time
from .. import constants as C
from ..db import store
from ..sse import broker
log = logging.getLogger("leadradar.projects")
STAGES = {s["id"] for s in C.PIPELINE_STAGES}
def _now() -> int:
return time.time_ns() // 1_000_000
def _json(value: list, default: str = "[]") -> str:
return json.dumps(value or [], ensure_ascii=False) if value is not None else default
def _row_to_card(row: dict) -> dict:
history = json.loads(row["history"] or "[]")
return {
"id": row["id"],
"stage": row["stage"],
"local": bool(row["local"]),
"leadId": row["lead_id"],
"title": row["title"],
"summary": row["summary"],
"stack": json.loads(row["stack"] or "[]"),
"budget": (
{"from": row["budget_from"], "to": row["budget_to"], "cur": row["budget_cur"]}
if row["budget_cur"]
else None
),
"contact": row["contact"],
"comments": json.loads(row["comments"] or "[]"),
"links": json.loads(row["links"] or "[]"),
"files": json.loads(row["files"] or "[]"),
"tzText": row["tz_text"],
"history": history,
"reminder": {"at": row["reminder_at"]} if row["reminder_at"] else None,
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
}
def list_cards(stage: str | None = None) -> list[dict]:
if stage:
rows = store.query("SELECT * FROM projects WHERE stage = ? ORDER BY updated_at DESC", [stage])
else:
rows = store.query("SELECT * FROM projects ORDER BY updated_at DESC")
return [_row_to_card(r) for r in rows]
def get_card(card_id: str) -> dict | None:
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
return _row_to_card(row) if row else None
def _insert(row_fields: dict) -> dict:
now = _now()
card_id = row_fields["id"]
store.execute(
"INSERT INTO projects(id, stage, local, lead_id, title, summary, stack, "
"budget_from, budget_to, budget_cur, contact, comments, links, files, tz_text, "
"history, reminder_at, reminder_fired, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, FALSE, ?, ?)",
[
card_id,
row_fields.get("stage", "planned"),
bool(row_fields.get("local")),
row_fields.get("lead_id"),
row_fields.get("title", ""),
row_fields.get("summary", ""),
_json(row_fields.get("stack")),
row_fields.get("budget_from"),
row_fields.get("budget_to"),
row_fields.get("budget_cur", ""),
row_fields.get("contact", ""),
_json(row_fields.get("comments")),
_json(row_fields.get("links")),
_json(row_fields.get("files")),
row_fields.get("tz_text", ""),
_json(row_fields.get("history")),
now,
now,
],
)
return get_card(card_id)
def create_local_card(data: dict) -> dict:
"""Ручное создание карточки — «локальная» (без лида)."""
budget = data.get("budget") if isinstance(data.get("budget"), dict) else None
return _insert(
{
"id": store.uid("pr_"),
"stage": data.get("stage") if data.get("stage") in STAGES else "planned",
"local": True,
"title": str(data.get("title", "")).strip(),
"summary": str(data.get("summary", "")),
"stack": data.get("stack") or [],
"budget_from": budget.get("from") if budget else None,
"budget_to": budget.get("to") if budget else None,
"budget_cur": budget.get("cur", "") if budget else "",
"contact": str(data.get("contact", "")),
"comments": data.get("comments") or [],
"links": data.get("links") or [],
"files": data.get("files") or [],
"tz_text": str(data.get("tzText", "")),
"history": [{"id": store.uid("h_"), "at": _now(), "type": "createdLocal"}],
}
)
def take_lead_to_projects(lead_id: str) -> dict:
"""«Взять в работу»: лид уходит с дашборда, создаётся проектная карточка."""
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
if not lead:
raise KeyError(lead_id)
existing = store.query_one("SELECT id FROM projects WHERE lead_id = ?", [lead_id])
if existing:
return get_card(existing["id"])
budget = {"from": lead["budget_from"], "to": lead["budget_to"], "cur": lead["budget_cur"]} if lead["budget_cur"] else None
card = _insert(
{
"id": store.uid("pr_"),
"stage": "planned",
"local": False,
"lead_id": lead_id,
"title": lead["title"],
"summary": lead["summary"],
"stack": json.loads(lead["stack"] or "[]"),
"budget_from": budget["from"] if budget else None,
"budget_to": budget["to"] if budget else None,
"budget_cur": budget["cur"] if budget else "",
"contact": lead["contact"],
"comments": [{"id": store.uid("cm_"), "by": "Вы", "text": "Взял в работу из лида.", "time": "только что"}],
"tz_text": "",
"history": [{"id": store.uid("h_"), "at": _now(), "type": "created"}],
}
)
# эксклюзивность: на дашборде лида больше нет, возврата нет
store.execute("UPDATE leads SET col = 'taken', is_new = FALSE WHERE id = ?", [lead_id])
return card
def patch_card(card_id: str, patch: dict) -> dict:
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
simple = {
"title": "title",
"summary": "summary",
"contact": "contact",
"tz_text": "tzText",
}
for col, key in simple.items():
if key in patch:
store.execute(f"UPDATE projects SET {col} = ? WHERE id = ?", [str(patch[key]), card_id])
if "stack" in patch:
store.execute("UPDATE projects SET stack = ? WHERE id = ?", [_json(patch["stack"]), card_id])
if "budget" in patch:
b = patch["budget"] if isinstance(patch["budget"], dict) else None
store.execute(
"UPDATE projects SET budget_from = ?, budget_to = ?, budget_cur = ? WHERE id = ?",
[b.get("from") if b else None, b.get("to") if b else None, b.get("cur", "") if b else "", card_id],
)
if "comments" in patch:
store.execute("UPDATE projects SET comments = ? WHERE id = ?", [_json(patch["comments"]), card_id])
if "links" in patch:
store.execute("UPDATE projects SET links = ? WHERE id = ?", [_json(patch["links"]), card_id])
if "files" in patch:
store.execute("UPDATE projects SET files = ? WHERE id = ?", [_json(patch["files"]), card_id])
_bump(card_id)
return get_card(card_id)
def _bump(card_id: str) -> None:
store.execute("UPDATE projects SET updated_at = ? WHERE id = ?", [_now(), card_id])
def add_comment(card_id: str, text: str) -> list[dict]:
row = store.query_one("SELECT comments FROM projects WHERE id = ?", [card_id])
comments = json.loads(row["comments"] or "[]")
comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"})
patch_card(card_id, {"comments": comments})
return comments
def move_stage(card_id: str, stage: str) -> dict:
if stage not in STAGES:
raise ValueError("Неизвестная стадия")
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
history = json.loads(row["history"] or "[]")
now = _now()
store.execute(
"UPDATE projects SET stage = ?, reminder_at = NULL, reminder_fired = FALSE, updated_at = ? WHERE id = ?",
[stage, now, card_id],
)
history.append({"id": store.uid("h_"), "at": now, "stage": stage})
store.execute("UPDATE projects SET history = ? WHERE id = ?", [_json(history), card_id])
return get_card(card_id)
def remove_card(card_id: str) -> None:
store.execute("DELETE FROM projects WHERE id = ?", [card_id])
def clear_stage(stage: str) -> int:
"""Полная ручная очистка стадии «Отклонено» (терминальные не восстанавливаются)."""
if stage not in ("rejected",):
raise ValueError("Очистка разрешена только для стадии «Отклонено»")
rows = store.query("SELECT id FROM projects WHERE stage = ?", [stage])
if not rows:
return 0
store.execute("DELETE FROM projects WHERE stage = ?", [stage])
return len(rows)
# ─── Напоминания стадии «Отложено» ────────────────────────────────────────
def set_reminder(card_id: str, at: int) -> dict:
if not store.get_setting("remindersEnabled"):
raise PermissionError("Напоминания об отложенных выключены в настройках")
store.execute(
"UPDATE projects SET reminder_at = ?, reminder_fired = FALSE, updated_at = ? WHERE id = ?",
[at, _now(), card_id],
)
return get_card(card_id)
def clear_reminder(card_id: str) -> None:
store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE id = ?", [card_id])
def active_reminders() -> list[dict]:
rows = store.query(
"SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL ORDER BY reminder_at"
)
return [{"id": r["id"], "title": r["title"], "at": r["reminder_at"]} for r in rows]
def snooze(card_id: str, ms: int = C.DAY_MS) -> None:
store.execute(
"UPDATE projects SET reminder_at = ?, reminder_fired = FALSE WHERE id = ?",
[_now() + ms, card_id],
)
async def check_reminders() -> list[dict]:
"""Наступившие напоминания -> события. Если выключено — чистим протухшие."""
if not store.get_setting("remindersEnabled"):
# протухшие напоминания не храним: при включении старые не «выстрелят»
store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE reminder_at IS NOT NULL AND reminder_at <= ?", [_now()])
return []
now = _now()
rows = store.query(
"SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL "
"AND reminder_fired = FALSE AND reminder_at <= ?",
[now],
)
due = []
for r in rows:
store.execute("UPDATE projects SET reminder_fired = TRUE WHERE id = ?", [r["id"]])
due.append({"id": r["id"], "title": r["title"], "stage": r["stage"]})
for item in due:
await broker.publish("reminder_due", item)
return due
"""«Выбранные» — проектный канбан (п.4.8 ТЗ).
Карточка живёт только здесь: лид уходит с дашборда безвозвратно (col='taken'),
в архив/корзину проектные карточки не попадают. Есть история движения,
вложения (файлы сейчас мета-мок, MinIO позже), ссылки, ТЗ и напоминания
для стадии «Отложено».
"""
from __future__ import annotations
import json
import logging
import time
from .. import constants as C
from ..db import store
from ..sse import broker
log = logging.getLogger("leadradar.projects")
STAGES = {s["id"] for s in C.PIPELINE_STAGES}
def _now() -> int:
return time.time_ns() // 1_000_000
def _json(value: list, default: str = "[]") -> str:
return json.dumps(value or [], ensure_ascii=False) if value is not None else default
def _row_to_card(row: dict) -> dict:
history = json.loads(row["history"] or "[]")
return {
"id": row["id"],
"stage": row["stage"],
"local": bool(row["local"]),
"leadId": row["lead_id"],
"title": row["title"],
"summary": row["summary"],
"stack": json.loads(row["stack"] or "[]"),
"budget": (
{"from": row["budget_from"], "to": row["budget_to"], "cur": row["budget_cur"]}
if row["budget_cur"]
else None
),
"contact": row["contact"],
"comments": json.loads(row["comments"] or "[]"),
"links": json.loads(row["links"] or "[]"),
"files": json.loads(row["files"] or "[]"),
"tzText": row["tz_text"],
"history": history,
"reminder": {"at": row["reminder_at"]} if row["reminder_at"] else None,
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
}
def list_cards(stage: str | None = None) -> list[dict]:
if stage:
rows = store.query("SELECT * FROM projects WHERE stage = ? ORDER BY updated_at DESC", [stage])
else:
rows = store.query("SELECT * FROM projects ORDER BY updated_at DESC")
return [_row_to_card(r) for r in rows]
def get_card(card_id: str) -> dict | None:
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
return _row_to_card(row) if row else None
def _insert(row_fields: dict) -> dict:
now = _now()
card_id = row_fields["id"]
store.execute(
"INSERT INTO projects(id, stage, local, lead_id, title, summary, stack, "
"budget_from, budget_to, budget_cur, contact, comments, links, files, tz_text, "
"history, reminder_at, reminder_fired, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, FALSE, ?, ?)",
[
card_id,
row_fields.get("stage", "planned"),
bool(row_fields.get("local")),
row_fields.get("lead_id"),
row_fields.get("title", ""),
row_fields.get("summary", ""),
_json(row_fields.get("stack")),
row_fields.get("budget_from"),
row_fields.get("budget_to"),
row_fields.get("budget_cur", ""),
row_fields.get("contact", ""),
_json(row_fields.get("comments")),
_json(row_fields.get("links")),
_json(row_fields.get("files")),
row_fields.get("tz_text", ""),
_json(row_fields.get("history")),
now,
now,
],
)
return get_card(card_id)
def create_local_card(data: dict) -> dict:
"""Ручное создание карточки — «локальная» (без лида)."""
budget = data.get("budget") if isinstance(data.get("budget"), dict) else None
return _insert(
{
"id": store.uid("pr_"),
"stage": data.get("stage") if data.get("stage") in STAGES else "planned",
"local": True,
"title": str(data.get("title", "")).strip(),
"summary": str(data.get("summary", "")),
"stack": data.get("stack") or [],
"budget_from": budget.get("from") if budget else None,
"budget_to": budget.get("to") if budget else None,
"budget_cur": budget.get("cur", "") if budget else "",
"contact": str(data.get("contact", "")),
"comments": data.get("comments") or [],
"links": data.get("links") or [],
"files": data.get("files") or [],
"tz_text": str(data.get("tzText", "")),
"history": [{"id": store.uid("h_"), "at": _now(), "type": "createdLocal"}],
}
)
def take_lead_to_projects(lead_id: str) -> dict:
"""«Взять в работу»: лид уходит с дашборда, создаётся проектная карточка."""
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
if not lead:
raise KeyError(lead_id)
existing = store.query_one("SELECT id FROM projects WHERE lead_id = ?", [lead_id])
if existing:
return get_card(existing["id"])
budget = {"from": lead["budget_from"], "to": lead["budget_to"], "cur": lead["budget_cur"]} if lead["budget_cur"] else None
card = _insert(
{
"id": store.uid("pr_"),
"stage": "planned",
"local": False,
"lead_id": lead_id,
"title": lead["title"],
"summary": lead["summary"],
"stack": json.loads(lead["stack"] or "[]"),
"budget_from": budget["from"] if budget else None,
"budget_to": budget["to"] if budget else None,
"budget_cur": budget["cur"] if budget else "",
"contact": lead["contact"],
"comments": [{"id": store.uid("cm_"), "by": "Вы", "text": "Взял в работу из лида.", "time": "только что"}],
"tz_text": "",
"history": [{"id": store.uid("h_"), "at": _now(), "type": "created"}],
}
)
# эксклюзивность: на дашборде лида больше нет, возврата нет
store.execute("UPDATE leads SET col = 'taken', is_new = FALSE WHERE id = ?", [lead_id])
return card
def patch_card(card_id: str, patch: dict) -> dict:
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
simple = {
"title": "title",
"summary": "summary",
"contact": "contact",
"tz_text": "tzText",
}
for col, key in simple.items():
if key in patch:
store.execute(f"UPDATE projects SET {col} = ? WHERE id = ?", [str(patch[key]), card_id])
if "stack" in patch:
store.execute("UPDATE projects SET stack = ? WHERE id = ?", [_json(patch["stack"]), card_id])
if "budget" in patch:
b = patch["budget"] if isinstance(patch["budget"], dict) else None
store.execute(
"UPDATE projects SET budget_from = ?, budget_to = ?, budget_cur = ? WHERE id = ?",
[b.get("from") if b else None, b.get("to") if b else None, b.get("cur", "") if b else "", card_id],
)
if "comments" in patch:
store.execute("UPDATE projects SET comments = ? WHERE id = ?", [_json(patch["comments"]), card_id])
if "links" in patch:
store.execute("UPDATE projects SET links = ? WHERE id = ?", [_json(patch["links"]), card_id])
if "files" in patch:
store.execute("UPDATE projects SET files = ? WHERE id = ?", [_json(patch["files"]), card_id])
_bump(card_id)
return get_card(card_id)
def _bump(card_id: str) -> None:
store.execute("UPDATE projects SET updated_at = ? WHERE id = ?", [_now(), card_id])
def add_comment(card_id: str, text: str) -> list[dict]:
row = store.query_one("SELECT comments FROM projects WHERE id = ?", [card_id])
comments = json.loads(row["comments"] or "[]")
comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"})
patch_card(card_id, {"comments": comments})
return comments
def move_stage(card_id: str, stage: str) -> dict:
if stage not in STAGES:
raise ValueError("Неизвестная стадия")
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
history = json.loads(row["history"] or "[]")
now = _now()
store.execute(
"UPDATE projects SET stage = ?, reminder_at = NULL, reminder_fired = FALSE, updated_at = ? WHERE id = ?",
[stage, now, card_id],
)
history.append({"id": store.uid("h_"), "at": now, "stage": stage})
store.execute("UPDATE projects SET history = ? WHERE id = ?", [_json(history), card_id])
return get_card(card_id)
def remove_card(card_id: str) -> None:
store.execute("DELETE FROM projects WHERE id = ?", [card_id])
def clear_stage(stage: str) -> int:
"""Полная ручная очистка стадии «Отклонено» (терминальные не восстанавливаются)."""
if stage not in ("rejected",):
raise ValueError("Очистка разрешена только для стадии «Отклонено»")
rows = store.query("SELECT id FROM projects WHERE stage = ?", [stage])
if not rows:
return 0
store.execute("DELETE FROM projects WHERE stage = ?", [stage])
return len(rows)
# ─── Напоминания стадии «Отложено» ────────────────────────────────────────
def set_reminder(card_id: str, at: int) -> dict:
if not store.get_setting("remindersEnabled"):
raise PermissionError("Напоминания об отложенных выключены в настройках")
store.execute(
"UPDATE projects SET reminder_at = ?, reminder_fired = FALSE, updated_at = ? WHERE id = ?",
[at, _now(), card_id],
)
return get_card(card_id)
def clear_reminder(card_id: str) -> None:
store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE id = ?", [card_id])
def active_reminders() -> list[dict]:
rows = store.query(
"SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL ORDER BY reminder_at"
)
return [{"id": r["id"], "title": r["title"], "at": r["reminder_at"]} for r in rows]
def snooze(card_id: str, ms: int = C.DAY_MS) -> None:
store.execute(
"UPDATE projects SET reminder_at = ?, reminder_fired = FALSE WHERE id = ?",
[_now() + ms, card_id],
)
async def check_reminders() -> list[dict]:
"""Наступившие напоминания -> события. Если выключено — чистим протухшие."""
if not store.get_setting("remindersEnabled"):
# протухшие напоминания не храним: при включении старые не «выстрелят»
store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE reminder_at IS NOT NULL AND reminder_at <= ?", [_now()])
return []
now = _now()
rows = store.query(
"SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL "
"AND reminder_fired = FALSE AND reminder_at <= ?",
[now],
)
due = []
for r in rows:
store.execute("UPDATE projects SET reminder_fired = TRUE WHERE id = ?", [r["id"]])
due.append({"id": r["id"], "title": r["title"], "stage": r["stage"]})
for item in due:
await broker.publish("reminder_due", item)
return due
@@ -1,130 +1,130 @@
"""Курсы валют: источник ЦБ РФ, 4 запроса в сутки (каждые 6 часов).
По ТЗ до подключения сервиса используются мок-курсы; источник выбирается
в настройках (cbr | mock). Значения хранятся в БД вместе с временем
обновления и выдаются наружу для вкладки «Валюта и курсы».
"""
from __future__ import annotations
import json
import logging
import time
import httpx
from .. import constants as C
from ..db import store
log = logging.getLogger("leadradar.rates")
_FETCH_INTERVAL_MS = 6 * C.HOUR_MS # 4 раза в сутки
def get_rates() -> dict:
row = store.query_one("SELECT rates, source, updated_at FROM rates WHERE id = 1")
rates = json.loads(row["rates"]) if row else dict(C.MOCK_RATES)
return {
"base": "RUB",
"rates": rates,
"source": row["source"] if row else "mock",
"updatedAt": row["updated_at"] if row else None,
}
def save_rates(rates: dict, source: str) -> None:
store.execute(
"INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET rates = excluded.rates, source = excluded.source, "
"updated_at = excluded.updated_at",
[json.dumps(rates), source, time.time_ns() // 1_000_000],
)
async def fetch_cbr() -> dict | None:
"""Запрос к ЦБ РФ (JSON-зеркало daily_json.js). Возвращает rates к RUB."""
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(C.CBR_URL)
resp.raise_for_status()
payload = resp.json()
rates: dict = {"RUB": 1.0}
for code, item in payload.get("Valute", {}).items():
# 1 единица валюты в рублях: Nominal может быть > 1
nominal = int(item.get("Nominal", 1)) or 1
value = float(item.get("Value", 0))
rates[code] = round(value / nominal, 6)
return rates
except Exception as exc: # noqa: BLE001
log.warning("CBR fetch failed: %s", exc)
return None
async def refresh_rates(force_source: str | None = None) -> bool:
"""Ручное/фоновое обновление. Возвращает True при успехе (или при мок-режиме)."""
source = force_source or store.get_setting("rateSource") or "cbr"
if source == "mock":
save_rates(dict(C.MOCK_RATES), "mock")
recompute_conversions()
return True
rates = await fetch_cbr()
if rates is None:
return False
save_rates(rates, "cbr")
recompute_conversions()
return True
def should_fetch() -> bool:
row = store.query_one("SELECT updated_at, source FROM rates WHERE id = 1")
if not row:
return True
if row["source"] == "mock" and store.get_setting("rateSource") == "cbr":
return True
return time.time_ns() // 1_000_000 - row["updated_at"] >= _FETCH_INTERVAL_MS
def _resolve_rate(rates: dict, code: str) -> float | None:
"""USDT приравниваем к USD (у ЦБ нет тикера USDT)."""
if code == "USDT" and "USD" in rates:
return float(rates["USD"])
val = rates.get(code)
return float(val) if val is not None else None
def convert_amount(amount: float | int | None, from_cur: str, to_cur: str) -> float | None:
"""amount в from_cur -> to_cur по актуальным курсам (к рублю)."""
if amount is None:
return None
rates = json.loads(store.query_one("SELECT rates FROM rates WHERE id = 1")["rates"])
rf = _resolve_rate(rates, from_cur)
rt = _resolve_rate(rates, to_cur)
if rf is None or rt is None:
return None
return round(float(amount) * rf / rt, 2)
def recompute_conversions() -> int:
"""Пересчёт старых карточек по актуальному курсу и целевой валюте.
Пересчитываются карточки на канбане и в «Неразобранном»; архивные,
корзинные и ушедшие в «Выбранные» (taken) не трогаем.
"""
if not store.get_setting("conversionOn"):
return 0
target = store.get_setting("targetCurrency") or "RUB"
rows = store.query(
"SELECT id, budget_from, budget_to, budget_cur FROM leads "
"WHERE budget_cur <> '' AND col NOT IN ('archive','trash','taken')",
)
updated = 0
for r in rows:
cf = convert_amount(r["budget_from"], r["budget_cur"], target)
ct = convert_amount(r["budget_to"] if r["budget_to"] is not None else r["budget_from"], r["budget_cur"], target)
if cf is None:
continue
store.execute(
"UPDATE leads SET conv_from = ?, conv_to = ?, conv_cur = ? WHERE id = ?",
[cf, ct, target, r["id"]],
)
updated += 1
return updated
"""Курсы валют: источник ЦБ РФ, 4 запроса в сутки (каждые 6 часов).
По ТЗ до подключения сервиса используются мок-курсы; источник выбирается
в настройках (cbr | mock). Значения хранятся в БД вместе с временем
обновления и выдаются наружу для вкладки «Валюта и курсы».
"""
from __future__ import annotations
import json
import logging
import time
import httpx
from .. import constants as C
from ..db import store
log = logging.getLogger("leadradar.rates")
_FETCH_INTERVAL_MS = 6 * C.HOUR_MS # 4 раза в сутки
def get_rates() -> dict:
row = store.query_one("SELECT rates, source, updated_at FROM rates WHERE id = 1")
rates = json.loads(row["rates"]) if row else dict(C.MOCK_RATES)
return {
"base": "RUB",
"rates": rates,
"source": row["source"] if row else "mock",
"updatedAt": row["updated_at"] if row else None,
}
def save_rates(rates: dict, source: str) -> None:
store.execute(
"INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET rates = excluded.rates, source = excluded.source, "
"updated_at = excluded.updated_at",
[json.dumps(rates), source, time.time_ns() // 1_000_000],
)
async def fetch_cbr() -> dict | None:
"""Запрос к ЦБ РФ (JSON-зеркало daily_json.js). Возвращает rates к RUB."""
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(C.CBR_URL)
resp.raise_for_status()
payload = resp.json()
rates: dict = {"RUB": 1.0}
for code, item in payload.get("Valute", {}).items():
# 1 единица валюты в рублях: Nominal может быть > 1
nominal = int(item.get("Nominal", 1)) or 1
value = float(item.get("Value", 0))
rates[code] = round(value / nominal, 6)
return rates
except Exception as exc: # noqa: BLE001
log.warning("CBR fetch failed: %s", exc)
return None
async def refresh_rates(force_source: str | None = None) -> bool:
"""Ручное/фоновое обновление. Возвращает True при успехе (или при мок-режиме)."""
source = force_source or store.get_setting("rateSource") or "cbr"
if source == "mock":
save_rates(dict(C.MOCK_RATES), "mock")
recompute_conversions()
return True
rates = await fetch_cbr()
if rates is None:
return False
save_rates(rates, "cbr")
recompute_conversions()
return True
def should_fetch() -> bool:
row = store.query_one("SELECT updated_at, source FROM rates WHERE id = 1")
if not row:
return True
if row["source"] == "mock" and store.get_setting("rateSource") == "cbr":
return True
return time.time_ns() // 1_000_000 - row["updated_at"] >= _FETCH_INTERVAL_MS
def _resolve_rate(rates: dict, code: str) -> float | None:
"""USDT приравниваем к USD (у ЦБ нет тикера USDT)."""
if code == "USDT" and "USD" in rates:
return float(rates["USD"])
val = rates.get(code)
return float(val) if val is not None else None
def convert_amount(amount: float | int | None, from_cur: str, to_cur: str) -> float | None:
"""amount в from_cur -> to_cur по актуальным курсам (к рублю)."""
if amount is None:
return None
rates = json.loads(store.query_one("SELECT rates FROM rates WHERE id = 1")["rates"])
rf = _resolve_rate(rates, from_cur)
rt = _resolve_rate(rates, to_cur)
if rf is None or rt is None:
return None
return round(float(amount) * rf / rt, 2)
def recompute_conversions() -> int:
"""Пересчёт старых карточек по актуальному курсу и целевой валюте.
Пересчитываются карточки на канбане и в «Неразобранном»; архивные,
корзинные и ушедшие в «Выбранные» (taken) не трогаем.
"""
if not store.get_setting("conversionOn"):
return 0
target = store.get_setting("targetCurrency") or "RUB"
rows = store.query(
"SELECT id, budget_from, budget_to, budget_cur FROM leads "
"WHERE budget_cur <> '' AND col NOT IN ('archive','trash','taken')",
)
updated = 0
for r in rows:
cf = convert_amount(r["budget_from"], r["budget_cur"], target)
ct = convert_amount(r["budget_to"] if r["budget_to"] is not None else r["budget_from"], r["budget_cur"], target)
if cf is None:
continue
store.execute(
"UPDATE leads SET conv_from = ?, conv_to = ?, conv_cur = ? WHERE id = ?",
[cf, ct, target, r["id"]],
)
updated += 1
return updated
@@ -1,390 +1,390 @@
"""Детерминированные правила колонок: направление, стек, слова, грейд, бюджет.
Колонка — это набор опциональных фильтров, задаёт пользователь (или ИИ при
предложении). Если текст входящего сообщения соответствует правилам — карточка
уходит в эту колонку сразу, без ML/ИИ (ML/ИИ подключаются только когда правила
не сработали). Набор фильтров может меняться: пустая группа не участвует.
"""
from __future__ import annotations
import json
import re
from ..db import store
_CUR_SYMBOLS = {"$": "USD", "": "EUR", "": "RUB", "": "USDT", "£": "GBP", "¥": "CNY"}
_CUR_WORDS = {"usd": "USD", "eur": "EUR", "rub": "RUB", "usdt": "USDT", "gbp": "GBP", "cny": "CNY", "руб": "RUB", "долл": "USD", "бакс": "USD"}
# Грейд/уровень: тег из правил расширяется синонимами, чтобы «middle» находил
# и «mid», и «мидл», а «сеньор» находил senior и т.п.
_GRADE_ALIASES = {
"junior": ["junior", "джун", "джуниор"],
"middle": ["middle", "mid", "мидл"],
"senior": ["senior", "сеньор", "сеньйор"],
"lead": ["lead", "тимлид", "тиэмлид", "team lead", "teamlead", "tech lead", "техлид"],
"architect": ["architect", "архитектор"],
"intern": ["intern", "стажёр", "стажер", "trainee"],
}
def _grade_terms(tags: list[str]) -> list[str]:
out: list[str] = []
for t in tags:
key = str(t).strip().lower()
if not key:
continue
if key in _GRADE_ALIASES:
out.extend(_GRADE_ALIASES[key])
else:
out.append(key)
return out
# Перед матчингом правил вырезаем ссылки и markdown-ссылки: иначе фильтр ловит
# слова из трекерных хвостов/служебных строк URL (например, «desktop» в
# utm_medium=member_desktop) и в колонку попадает мусор, не имеющий отношения
# к содержанию сообщения.
_MD_URL_RE = re.compile(r"\[[^\]]*\]\([^)\s]+\)")
_RAW_URL_RE = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+")
def content_text(text: str) -> str:
s = str(text or "")
s = _MD_URL_RE.sub(" ", s)
return _RAW_URL_RE.sub(" ", s)
# ищем: 1) "от 1 200 до 1 500 $", 2) "1 2001 500 $ / 1 200$", 3) "$1 2001 500"
# суффикс «к/К» разрешён прямо в числе: «2к», «1.5к$» (множитель в _norm_amount)
_AMT = r"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?"
_RANGE = rf"({_AMT})\s*(?:[-–—]\s*({_AMT}))?"
def _norm_amount(raw: str) -> float | None:
s = raw.replace("\u00a0", " ").replace(" ", "").replace(",", ".")
mult = 1.0
# «2к»/«2К»/«1.5к» — тысячи; суффикс убираем до float (иначе парс падает)
if s and s[-1].lower() in ("k", "к"):
mult = 1000.0
s = s[:-1]
try:
v = float(s) * mult
except ValueError:
return None
return v
def _cur_from_tail(text: str, m_start: int) -> str | None:
tail = text[m_start : m_start + 12].lower().strip()
for sym, code in _CUR_SYMBOLS.items():
if tail.startswith(sym):
return code
# слово-валюта сразу после числа (с пробелом)
for word, code in _CUR_WORDS.items():
if tail.startswith(word):
return code
# валюта перед числом ($/€/₽), например "$1 200"
head = text[max(0, m_start - 3) : m_start].strip()
for sym, code in _CUR_SYMBOLS.items():
if head.endswith(sym):
return code
return None
def extract_amounts(text: str) -> list[dict]:
"""Парсер сумм с сохранением смысла:
- диапазон «от A до B» / «A–B» → {'from': A, 'to': B, 'cur': ...};
- «до B» (только верхняя граница) → {'from': None, 'to': B, 'cur': ...};
- одна сумма / «от A» без верхней границы → {'from': A, 'to': A, 'cur': ...}.
Валюту ищем сразу после суммы (или перед ней для «$1 200»); суммы без валюты игнорируются.
"""
out: list[dict] = []
t = text or ""
if not t.strip():
return out
occupied: list[tuple[int, int]] = []
def _free(s: int, e: int) -> bool:
return not any(s < oe and e > os for os, oe in occupied)
def _add(a, b, cur: str | None, s: int, e: int) -> None:
if cur and (a is not None or b is not None) and _free(s, e):
out.append({"from": a, "to": b, "cur": cur})
occupied.append((s, e))
# 1) словесный диапазон «от A до B» (+ валюта после B)
for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")\s+до\s+(" + _AMT + r")", t):
a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2))
cur = _cur_from_tail(t, m.end(2))
if a is not None and b is not None and cur:
_add(a, b, cur, m.start(1), m.end(2))
# 2) «до B» (без пары «от … до») — верхняя граница
for m in re.finditer(r"(?i)\bдо\s+(" + _AMT + r")", t):
b = _norm_amount(m.group(1))
cur = _cur_from_tail(t, m.end(1))
if b is not None and cur:
_add(None, b, cur, m.start(1), m.end(1))
# 3) «от A» без верхней границы — считаем одной суммой
for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")", t):
a = _norm_amount(m.group(1))
cur = _cur_from_tail(t, m.end(1))
if a is not None and cur:
_add(a, a, cur, m.start(1), m.end(1))
# 4) числовые диапазоны «A–B» / «A - B»
for m in re.finditer(r"(?i)(" + _AMT + r")\s*(?:[-–—]|\s+до\s+)\s*(" + _AMT + r")", t):
a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2))
cur = _cur_from_tail(t, m.end(2)) or _cur_from_tail(t, m.end(1))
if a is not None and b is not None and cur:
_add(a, b, cur, m.start(1), m.end(2))
# 5) одиночные суммы с валютой (не вошедшие в конструкции выше)
for m in re.finditer(_AMT, t):
a = _norm_amount(m.group(0))
cur = _cur_from_tail(t, m.end())
if a is not None and cur:
_add(a, a, cur, m.start(), m.end())
return out
def _amount_in_range(amounts: list[dict], budget: dict) -> bool:
from ..services.rates import convert_amount
try:
lo = float(budget.get("from")) if budget.get("from") is not None else None
hi = float(budget.get("to")) if budget.get("to") is not None else None
except (TypeError, ValueError):
return False
if lo is None and hi is None:
return True
target_cur = str(budget.get("cur") or "USD").upper()
for amt in amounts:
raw_val = amt["from"] if amt["from"] is not None else amt.get("to")
if raw_val is None:
continue
try:
val = raw_val if amt["cur"] == target_cur else convert_amount(raw_val, amt["cur"], target_cur)
except Exception: # noqa: BLE001
val = None
if val is None:
continue
if lo is not None and val < lo:
continue
if hi is not None and val > hi:
continue
return True
return False
def match_text(rules: dict, text: str) -> bool:
"""Соответствует ли текст правилам колонки. Возвращает bool.
Колонка — это набор опциональных фильтров: направление, стек, ключевые
слова, грейд/уровень, бюджет. Пустая группа не участвует; режим «все» —
должны совпасть все включённые группы, «любое» — хотя бы одна.
"""
rules = rules or {}
lower = content_text(text).lower()
direction = [str(x).strip().lower() for x in (rules.get("direction") or []) if str(x).strip()]
kw = [str(x).strip().lower() for x in (rules.get("keywords") or []) if str(x).strip()]
stack = [str(x).strip().lower() for x in (rules.get("stack") or []) if str(x).strip()]
grade = [str(x).strip().lower() for x in (rules.get("grade") or []) if str(x).strip()]
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
enabled = [bool(kw), bool(stack), bool(direction), bool(grade), bool(budget)]
if not any(enabled):
return False
grade_terms = _grade_terms(grade)
groups = {
"keywords": any(k in lower for k in kw) if kw else True,
"stack": any(s in lower for s in stack) if stack else True,
"direction": any(d in lower for d in direction) if direction else True,
"grade": any(g in lower for g in grade_terms) if grade else True,
"budget": (_amount_in_range(extract_amounts(text), budget) if budget else True),
}
mode = str(rules.get("mode") or "all").lower()
if mode == "any":
# «любое»: совпасть должна хотя бы одна включённая (непустая) группа.
# Пустые группы равны True и не должны участвовать — иначе колонка
# с одним фильтром (например «стек: WPF») ловит вообще всё.
return any(groups[k] for k, on in zip(groups, enabled) if on and k in groups)
# all: каждая включённая группа обязана совпасть
return all(groups[k] for k, on in zip(groups, enabled) if on and k in groups)
def score_text(rules: dict, text: str) -> int:
"""Число совпавших фильтров (для выбора лучшей ИИ-колонки)."""
if not text:
return 0
lower = content_text(text).lower()
score = 0
for group in ("direction", "keywords", "stack", "grade"):
if group == "grade":
for g in _grade_terms(rules.get(group) or []):
if g in lower:
score += 1
else:
for w in (rules.get(group) or []):
if str(w).lower() in lower:
score += 1
return score
def excluded_terms(rules: dict | None, text: str) -> list[str]:
"""Какие слова-исключения колонки есть в тексте.
Исключения — veto колонки: если в содержании сообщения (без ссылок и
служебных хвостов) встречается любое из них, карточка в колонку не
попадает, даже если все положительные условия совпали.
"""
rules = rules or {}
lower = content_text(text).lower()
out: list[str] = []
for term in rules.get("exclude") or []:
t = str(term).strip()
if t and t.lower() in lower:
out.append(t)
return out
def is_excluded(rules: dict | None, text: str) -> bool:
return bool(excluded_terms(rules, text))
def board_accepts(board_id: str, text: str) -> bool:
"""Пропускает ли колонка этот текст.
Колонка с активными правилами принимает только текст, прошедший её правила
(детерминированно); колонка без правил принимает любой текст — её наполняют
ИИ/ML/пользователь. Нужно как страховка: ИИ или ML не должны класть карточку
в «отфильтрованную» колонку, если текст под правила не подходит. Слова-
исключения работают как veto в любом случае.
"""
row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id])
if not row:
return False
rules = json.loads(row["rules"] or "{}") if row["rules"] else {}
if is_excluded(rules, text):
return False
if not has_active_rules(rules):
return True
return match_text(rules, text)
def hits(rules: dict, text: str) -> list[dict]:
"""Какие именно критерии фильтра совпали с текстом.
Возвращает список совпадений по группам фильтра колонки:
[{"label": "Стек", "term": "WPF"}, {"label": "Грейд", "term": "middle", "word": "mid"}, …].
Нужно, чтобы на карточке показывать «по каким критериям она попала в колонку»
(список совпавших условий фильтра). Ключевое слово ищется по всему тексту
сообщения — включая стек, требования и «будет плюсом», как и наоборот.
"""
rules = rules or {}
lower = content_text(text).lower()
out: list[dict] = []
for group, label in (("direction", "Направление"), ("keywords", "Слова"), ("stack", "Стек")):
for term in rules.get(group) or []:
t = str(term).strip()
if t and t.lower() in lower:
out.append({"label": label, "term": t})
for term in rules.get("grade") or []:
for alias in _grade_terms([term]):
if alias in lower:
out.append({"label": "Грейд/уровень", "term": str(term).strip(), "word": alias})
break
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
if budget and _amount_in_range(extract_amounts(text), budget):
out.append({"label": "Бюджет", "term": _budget_label(budget)})
return out
def _budget_label(budget: dict) -> str:
lo, hi = budget.get("from"), budget.get("to")
cur = str(budget.get("cur") or "").upper()
if lo is not None and hi is not None:
return f"от {lo:g} до {hi:g} {cur}".strip()
if hi is not None:
return f"до {hi:g} {cur}".strip()
if lo is not None:
return f"от {lo:g} {cur}".strip()
return "бюджет"
def hits_for_board(board_id: str, text: str) -> list[dict]:
"""Совпавшие критерии колонки с активными правилами; [] — правил нет."""
row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id])
if not row:
return []
rules = json.loads(row["rules"] or "{}") if row["rules"] else {}
if not has_active_rules(rules):
return []
return hits(rules, text)
def has_active_rules(rules: dict | None) -> bool:
"""Есть ли в правилах хотя бы одна реально работающая группа фильтров.
Нужно для ML: колонка с активными правилами раскладывается только самими
правилами (детерминированно), ML её назначать не должен — иначе в колонку
попадает то, что под правила не подходит.
"""
rules = rules or {}
for key in ("direction", "keywords", "stack", "grade"):
if any(str(x).strip() for x in (rules.get(key) or [])):
return True
budget = rules.get("budget")
if isinstance(budget, dict) and (
budget.get("from") is not None or budget.get("to") is not None
):
return True
return False
def describe(rules: dict) -> str:
"""Человекочитаемое описание правил (для обоснования и промпта)."""
rules = rules or {}
parts = []
direction = rules.get("direction") or []
stack = rules.get("stack") or []
kw = rules.get("keywords") or []
grade = rules.get("grade") or []
if direction:
parts.append("направление: " + ", ".join(str(x) for x in direction[:6]))
if stack:
parts.append("стек: " + ", ".join(str(x) for x in stack[:8]))
if kw:
parts.append("слова: " + ", ".join(str(x) for x in kw[:8]))
if grade:
parts.append("грейд: " + ", ".join(str(x) for x in grade[:8]))
exc = rules.get("exclude") or []
if exc:
parts.append("исключено: " + ", ".join(str(x) for x in exc[:8]))
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
if budget and (budget.get("from") is not None or budget.get("to") is not None):
lo = budget.get("from") if budget.get("from") is not None else ""
hi = budget.get("to") if budget.get("to") is not None else ""
parts.append(f"бюджет: {lo}{hi} {budget.get('cur') or ''}".replace(' ', '').replace(' ', ''))
if not parts:
return "без правил (решает ИИ/ML)"
mode = "все условия" if str(rules.get("mode") or "all").lower() != "any" else "любое из условий"
return mode + " · " + "; ".join(parts)
def route(text: str) -> dict | None:
"""Детерминированная маршрутизация по правилам активных колонок.
Возвращает колонку, если правила однозначно совпали (при нескольких —
ту, где больше совпадений). Предложения ИИ в маршрутизации не участвуют.
"""
rows = store.query(
"SELECT * FROM boards WHERE suggested = FALSE AND rules <> '{}' AND rules <> '' ORDER BY pos"
)
best: dict | None = None
best_score = 0
for r in rows:
rules = json.loads(r["rules"] or "{}")
if not match_text(rules, text):
continue
sc = score_text(rules, text) or 1
if sc > best_score:
best = {"id": r["id"], "name": r["name"], "color": r["color"], "rules": rules}
best_score = sc
return best
"""Детерминированные правила колонок: направление, стек, слова, грейд, бюджет.
Колонка — это набор опциональных фильтров, задаёт пользователь (или ИИ при
предложении). Если текст входящего сообщения соответствует правилам — карточка
уходит в эту колонку сразу, без ML/ИИ (ML/ИИ подключаются только когда правила
не сработали). Набор фильтров может меняться: пустая группа не участвует.
"""
from __future__ import annotations
import json
import re
from ..db import store
_CUR_SYMBOLS = {"$": "USD", "": "EUR", "": "RUB", "": "USDT", "£": "GBP", "¥": "CNY"}
_CUR_WORDS = {"usd": "USD", "eur": "EUR", "rub": "RUB", "usdt": "USDT", "gbp": "GBP", "cny": "CNY", "руб": "RUB", "долл": "USD", "бакс": "USD"}
# Грейд/уровень: тег из правил расширяется синонимами, чтобы «middle» находил
# и «mid», и «мидл», а «сеньор» находил senior и т.п.
_GRADE_ALIASES = {
"junior": ["junior", "джун", "джуниор"],
"middle": ["middle", "mid", "мидл"],
"senior": ["senior", "сеньор", "сеньйор"],
"lead": ["lead", "тимлид", "тиэмлид", "team lead", "teamlead", "tech lead", "техлид"],
"architect": ["architect", "архитектор"],
"intern": ["intern", "стажёр", "стажер", "trainee"],
}
def _grade_terms(tags: list[str]) -> list[str]:
out: list[str] = []
for t in tags:
key = str(t).strip().lower()
if not key:
continue
if key in _GRADE_ALIASES:
out.extend(_GRADE_ALIASES[key])
else:
out.append(key)
return out
# Перед матчингом правил вырезаем ссылки и markdown-ссылки: иначе фильтр ловит
# слова из трекерных хвостов/служебных строк URL (например, «desktop» в
# utm_medium=member_desktop) и в колонку попадает мусор, не имеющий отношения
# к содержанию сообщения.
_MD_URL_RE = re.compile(r"\[[^\]]*\]\([^)\s]+\)")
_RAW_URL_RE = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+")
def content_text(text: str) -> str:
s = str(text or "")
s = _MD_URL_RE.sub(" ", s)
return _RAW_URL_RE.sub(" ", s)
# ищем: 1) "от 1 200 до 1 500 $", 2) "1 2001 500 $ / 1 200$", 3) "$1 2001 500"
# суффикс «к/К» разрешён прямо в числе: «2к», «1.5к$» (множитель в _norm_amount)
_AMT = r"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?"
_RANGE = rf"({_AMT})\s*(?:[-–—]\s*({_AMT}))?"
def _norm_amount(raw: str) -> float | None:
s = raw.replace("\u00a0", " ").replace(" ", "").replace(",", ".")
mult = 1.0
# «2к»/«2К»/«1.5к» — тысячи; суффикс убираем до float (иначе парс падает)
if s and s[-1].lower() in ("k", "к"):
mult = 1000.0
s = s[:-1]
try:
v = float(s) * mult
except ValueError:
return None
return v
def _cur_from_tail(text: str, m_start: int) -> str | None:
tail = text[m_start : m_start + 12].lower().strip()
for sym, code in _CUR_SYMBOLS.items():
if tail.startswith(sym):
return code
# слово-валюта сразу после числа (с пробелом)
for word, code in _CUR_WORDS.items():
if tail.startswith(word):
return code
# валюта перед числом ($/€/₽), например "$1 200"
head = text[max(0, m_start - 3) : m_start].strip()
for sym, code in _CUR_SYMBOLS.items():
if head.endswith(sym):
return code
return None
def extract_amounts(text: str) -> list[dict]:
"""Парсер сумм с сохранением смысла:
- диапазон «от A до B» / «A–B» → {'from': A, 'to': B, 'cur': ...};
- «до B» (только верхняя граница) → {'from': None, 'to': B, 'cur': ...};
- одна сумма / «от A» без верхней границы → {'from': A, 'to': A, 'cur': ...}.
Валюту ищем сразу после суммы (или перед ней для «$1 200»); суммы без валюты игнорируются.
"""
out: list[dict] = []
t = text or ""
if not t.strip():
return out
occupied: list[tuple[int, int]] = []
def _free(s: int, e: int) -> bool:
return not any(s < oe and e > os for os, oe in occupied)
def _add(a, b, cur: str | None, s: int, e: int) -> None:
if cur and (a is not None or b is not None) and _free(s, e):
out.append({"from": a, "to": b, "cur": cur})
occupied.append((s, e))
# 1) словесный диапазон «от A до B» (+ валюта после B)
for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")\s+до\s+(" + _AMT + r")", t):
a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2))
cur = _cur_from_tail(t, m.end(2))
if a is not None and b is not None and cur:
_add(a, b, cur, m.start(1), m.end(2))
# 2) «до B» (без пары «от … до») — верхняя граница
for m in re.finditer(r"(?i)\bдо\s+(" + _AMT + r")", t):
b = _norm_amount(m.group(1))
cur = _cur_from_tail(t, m.end(1))
if b is not None and cur:
_add(None, b, cur, m.start(1), m.end(1))
# 3) «от A» без верхней границы — считаем одной суммой
for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")", t):
a = _norm_amount(m.group(1))
cur = _cur_from_tail(t, m.end(1))
if a is not None and cur:
_add(a, a, cur, m.start(1), m.end(1))
# 4) числовые диапазоны «A–B» / «A - B»
for m in re.finditer(r"(?i)(" + _AMT + r")\s*(?:[-–—]|\s+до\s+)\s*(" + _AMT + r")", t):
a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2))
cur = _cur_from_tail(t, m.end(2)) or _cur_from_tail(t, m.end(1))
if a is not None and b is not None and cur:
_add(a, b, cur, m.start(1), m.end(2))
# 5) одиночные суммы с валютой (не вошедшие в конструкции выше)
for m in re.finditer(_AMT, t):
a = _norm_amount(m.group(0))
cur = _cur_from_tail(t, m.end())
if a is not None and cur:
_add(a, a, cur, m.start(), m.end())
return out
def _amount_in_range(amounts: list[dict], budget: dict) -> bool:
from ..services.rates import convert_amount
try:
lo = float(budget.get("from")) if budget.get("from") is not None else None
hi = float(budget.get("to")) if budget.get("to") is not None else None
except (TypeError, ValueError):
return False
if lo is None and hi is None:
return True
target_cur = str(budget.get("cur") or "USD").upper()
for amt in amounts:
raw_val = amt["from"] if amt["from"] is not None else amt.get("to")
if raw_val is None:
continue
try:
val = raw_val if amt["cur"] == target_cur else convert_amount(raw_val, amt["cur"], target_cur)
except Exception: # noqa: BLE001
val = None
if val is None:
continue
if lo is not None and val < lo:
continue
if hi is not None and val > hi:
continue
return True
return False
def match_text(rules: dict, text: str) -> bool:
"""Соответствует ли текст правилам колонки. Возвращает bool.
Колонка — это набор опциональных фильтров: направление, стек, ключевые
слова, грейд/уровень, бюджет. Пустая группа не участвует; режим «все» —
должны совпасть все включённые группы, «любое» — хотя бы одна.
"""
rules = rules or {}
lower = content_text(text).lower()
direction = [str(x).strip().lower() for x in (rules.get("direction") or []) if str(x).strip()]
kw = [str(x).strip().lower() for x in (rules.get("keywords") or []) if str(x).strip()]
stack = [str(x).strip().lower() for x in (rules.get("stack") or []) if str(x).strip()]
grade = [str(x).strip().lower() for x in (rules.get("grade") or []) if str(x).strip()]
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
enabled = [bool(kw), bool(stack), bool(direction), bool(grade), bool(budget)]
if not any(enabled):
return False
grade_terms = _grade_terms(grade)
groups = {
"keywords": any(k in lower for k in kw) if kw else True,
"stack": any(s in lower for s in stack) if stack else True,
"direction": any(d in lower for d in direction) if direction else True,
"grade": any(g in lower for g in grade_terms) if grade else True,
"budget": (_amount_in_range(extract_amounts(text), budget) if budget else True),
}
mode = str(rules.get("mode") or "all").lower()
if mode == "any":
# «любое»: совпасть должна хотя бы одна включённая (непустая) группа.
# Пустые группы равны True и не должны участвовать — иначе колонка
# с одним фильтром (например «стек: WPF») ловит вообще всё.
return any(groups[k] for k, on in zip(groups, enabled) if on and k in groups)
# all: каждая включённая группа обязана совпасть
return all(groups[k] for k, on in zip(groups, enabled) if on and k in groups)
def score_text(rules: dict, text: str) -> int:
"""Число совпавших фильтров (для выбора лучшей ИИ-колонки)."""
if not text:
return 0
lower = content_text(text).lower()
score = 0
for group in ("direction", "keywords", "stack", "grade"):
if group == "grade":
for g in _grade_terms(rules.get(group) or []):
if g in lower:
score += 1
else:
for w in (rules.get(group) or []):
if str(w).lower() in lower:
score += 1
return score
def excluded_terms(rules: dict | None, text: str) -> list[str]:
"""Какие слова-исключения колонки есть в тексте.
Исключения — veto колонки: если в содержании сообщения (без ссылок и
служебных хвостов) встречается любое из них, карточка в колонку не
попадает, даже если все положительные условия совпали.
"""
rules = rules or {}
lower = content_text(text).lower()
out: list[str] = []
for term in rules.get("exclude") or []:
t = str(term).strip()
if t and t.lower() in lower:
out.append(t)
return out
def is_excluded(rules: dict | None, text: str) -> bool:
return bool(excluded_terms(rules, text))
def board_accepts(board_id: str, text: str) -> bool:
"""Пропускает ли колонка этот текст.
Колонка с активными правилами принимает только текст, прошедший её правила
(детерминированно); колонка без правил принимает любой текст — её наполняют
ИИ/ML/пользователь. Нужно как страховка: ИИ или ML не должны класть карточку
в «отфильтрованную» колонку, если текст под правила не подходит. Слова-
исключения работают как veto в любом случае.
"""
row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id])
if not row:
return False
rules = json.loads(row["rules"] or "{}") if row["rules"] else {}
if is_excluded(rules, text):
return False
if not has_active_rules(rules):
return True
return match_text(rules, text)
def hits(rules: dict, text: str) -> list[dict]:
"""Какие именно критерии фильтра совпали с текстом.
Возвращает список совпадений по группам фильтра колонки:
[{"label": "Стек", "term": "WPF"}, {"label": "Грейд", "term": "middle", "word": "mid"}, …].
Нужно, чтобы на карточке показывать «по каким критериям она попала в колонку»
(список совпавших условий фильтра). Ключевое слово ищется по всему тексту
сообщения — включая стек, требования и «будет плюсом», как и наоборот.
"""
rules = rules or {}
lower = content_text(text).lower()
out: list[dict] = []
for group, label in (("direction", "Направление"), ("keywords", "Слова"), ("stack", "Стек")):
for term in rules.get(group) or []:
t = str(term).strip()
if t and t.lower() in lower:
out.append({"label": label, "term": t})
for term in rules.get("grade") or []:
for alias in _grade_terms([term]):
if alias in lower:
out.append({"label": "Грейд/уровень", "term": str(term).strip(), "word": alias})
break
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
if budget and _amount_in_range(extract_amounts(text), budget):
out.append({"label": "Бюджет", "term": _budget_label(budget)})
return out
def _budget_label(budget: dict) -> str:
lo, hi = budget.get("from"), budget.get("to")
cur = str(budget.get("cur") or "").upper()
if lo is not None and hi is not None:
return f"от {lo:g} до {hi:g} {cur}".strip()
if hi is not None:
return f"до {hi:g} {cur}".strip()
if lo is not None:
return f"от {lo:g} {cur}".strip()
return "бюджет"
def hits_for_board(board_id: str, text: str) -> list[dict]:
"""Совпавшие критерии колонки с активными правилами; [] — правил нет."""
row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id])
if not row:
return []
rules = json.loads(row["rules"] or "{}") if row["rules"] else {}
if not has_active_rules(rules):
return []
return hits(rules, text)
def has_active_rules(rules: dict | None) -> bool:
"""Есть ли в правилах хотя бы одна реально работающая группа фильтров.
Нужно для ML: колонка с активными правилами раскладывается только самими
правилами (детерминированно), ML её назначать не должен — иначе в колонку
попадает то, что под правила не подходит.
"""
rules = rules or {}
for key in ("direction", "keywords", "stack", "grade"):
if any(str(x).strip() for x in (rules.get(key) or [])):
return True
budget = rules.get("budget")
if isinstance(budget, dict) and (
budget.get("from") is not None or budget.get("to") is not None
):
return True
return False
def describe(rules: dict) -> str:
"""Человекочитаемое описание правил (для обоснования и промпта)."""
rules = rules or {}
parts = []
direction = rules.get("direction") or []
stack = rules.get("stack") or []
kw = rules.get("keywords") or []
grade = rules.get("grade") or []
if direction:
parts.append("направление: " + ", ".join(str(x) for x in direction[:6]))
if stack:
parts.append("стек: " + ", ".join(str(x) for x in stack[:8]))
if kw:
parts.append("слова: " + ", ".join(str(x) for x in kw[:8]))
if grade:
parts.append("грейд: " + ", ".join(str(x) for x in grade[:8]))
exc = rules.get("exclude") or []
if exc:
parts.append("исключено: " + ", ".join(str(x) for x in exc[:8]))
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
if budget and (budget.get("from") is not None or budget.get("to") is not None):
lo = budget.get("from") if budget.get("from") is not None else ""
hi = budget.get("to") if budget.get("to") is not None else ""
parts.append(f"бюджет: {lo}{hi} {budget.get('cur') or ''}".replace(' ', '').replace(' ', ''))
if not parts:
return "без правил (решает ИИ/ML)"
mode = "все условия" if str(rules.get("mode") or "all").lower() != "any" else "любое из условий"
return mode + " · " + "; ".join(parts)
def route(text: str) -> dict | None:
"""Детерминированная маршрутизация по правилам активных колонок.
Возвращает колонку, если правила однозначно совпали (при нескольких —
ту, где больше совпадений). Предложения ИИ в маршрутизации не участвуют.
"""
rows = store.query(
"SELECT * FROM boards WHERE suggested = FALSE AND rules <> '{}' AND rules <> '' ORDER BY pos"
)
best: dict | None = None
best_score = 0
for r in rows:
rules = json.loads(r["rules"] or "{}")
if not match_text(rules, text):
continue
sc = score_text(rules, text) or 1
if sc > best_score:
best = {"id": r["id"], "name": r["name"], "color": r["color"], "rules": rules}
best_score = sc
return best
@@ -1,247 +1,247 @@
"""ИИ-предложения колонок по анализу «Неразобранного».
Колонка — не «на один скилл», а смысловая тема: направление + набор стека и
ключевых слов (+ бюджет при повторяемости). ИИ группирует карточки и создаёт
КОЛОНКИ-ПРЕДЛОЖЕНИЯ (suggested=TRUE) с обоснованием (note), по каким
критериям собрана. Пользователь открывает предложение, смотрит карточки и
решает: принять (можно переименовать/поправить правила) или удалить.
"""
from __future__ import annotations
import logging
import time
from ..db import store
from ..sse import broker
from . import ai as ai_svc
log = logging.getLogger("leadradar.suggest")
SUGGEST_PROMPT = """Ты — аналитик входящих заявок. Перед тобой пронумерованные сообщения (номера 1..N), которые не подошли ни под одну существующую колонку. Сгруппируй их в 2–4 ОСМЫСЛЕННЫЕ тематические колонки.
Сфера/что считается заявкой:
{domain}
Колонка — это не отдельный предмет и не каждое слово по отдельности, а направление с набором родственных признаков (тип работ/услуг, предметы, технологии, материалы). Пример: «Telegram-боты» — направление: чат-боты/автоматизация; предметы/технологии: Python, aiogram; слова: бот, telegram, автоответчик.
Правила:
- группируй повторяющиеся темы: в каждую колонку бери минимум 2 сообщения;
- не предлагай колонки, похожие на уже существующие;
- 2–4 колонки максимум; если ничего общего нет — верни пустой список.
Для каждой колонки верни:
- name — короткое название (2–4 слова);
- description — короткое описание колонки (1–2 предложения): что за заявки и для кого;
- direction — направление/тип задач (2–5 слов или фраз);
- stack — что фигурирует в заявках: предметы, услуги, технологии, материалы (1–6);
- grade — уровень/грейд, если ярко выражен (например: ["middle"]), иначе пустой список;
- keywords — 3–8 ключевых слов/фраз, по которым узнаётся такая заявка;
- messages — НОМЕРА сообщений из списка, которые относятся к этой колонке (минимум 2, максимум 12);
- reason — обоснование в 1 предложение: что за тема, сколько карточек и что в них общего.
Верни строго JSON:
{ "columns": [ { "name": "", "description": "", "direction": [""], "stack": [""], "grade": [""], "keywords": [""], "messages": [1, 5], "reason": "" } ] }"""
KEYWORDS_PROMPT = """Ты — аналитик входящих сообщений. Ниже — реальные сообщения, которые уже признаны заявками/лидами (и часть — обычный флуд каналов).
Сфера/что считается заявкой:
{domain}
Выдели общие слова-МАРКЕРЫ, по которым сообщение можно отнести к заявкам этой сферы (а не к флуду): типовые предметы/услуги/работы, глаголы-действия («куплю», «нужен», «сниму», «отремонтировать»), статусные слова («срочно», «под ключ», «бюджет»).
Верни строго JSON с плоским списком от 8 до 40 ключевых слов/фраз (слова в нижнем регистре, без знаков препинания):
{ "keywords": ["", ""] }"""
MIN_INBOX = 6 # минимум карточек в «Неразобранном» для анализа
MIN_INBOX_GROUP = 2 # минимум карточек в одной ИИ-колонке
MAX_TEXT = 12 # сколько сообщений берём в анализ (ИИ обрывает длинный JSON)
COOLDOWN_S = 20 * 60 # как часто автоматически переспрашиваем
KEY = "lastSuggestAt"
def _similar_exists(name: str) -> bool:
low = name.casefold()
rows = store.query("SELECT id, name FROM boards")
for r in rows:
n = str(r["name"]).casefold()
if low == n or low in n or n in low:
return True
return False
def _rules_for(item: dict) -> dict:
"""Правила колонки из ИИ-ответа: направление/слова/стек/грейд (любое из условий)."""
return {
"mode": "any",
"direction": [str(x).strip().lower() for x in (item.get("direction") or []) if str(x).strip()][:6],
"keywords": [str(x).strip().lower() for x in (item.get("keywords") or []) if str(x).strip()][:8],
"stack": [str(x).strip().lower() for x in (item.get("stack") or []) if str(x).strip()][:8],
"grade": [str(x).strip().lower() for x in (item.get("grade") or []) if str(x).strip()][:6],
}
async def suggest_from_inbox(force: bool = False) -> dict:
"""Анализ «Неразобранного» и создание колонок-предложений с обоснованием.
ИИ группирует пронумерованные сообщения и для каждой колонки возвращает
номера сообщений (messages). Карточки раскладываются именно по этим
номерам, а не строгим match_text по сгенерированным правилам — иначе
колонка-предложение часто остаётся пустой (правила ИИ приблизительные).
"""
# Предложения колонок — это вызов ИИ: при выключенном ИИ (aiEnabled=false,
# режим «только фильтр + ML») автоцикл не должен дёргать провайдера.
if not force and not store.get_setting("aiEnabled"):
return {"ok": False, "reason": "ИИ выключен — предложения колонок недоступны"}
if not force:
# не плодим предложения, пока пользователь не разобрался со старыми
pending = store.scalar("SELECT count(*) FROM boards WHERE suggested = TRUE")
if pending:
return {"ok": False, "reason": f"сначала решите судьбу {pending} предложенных колонок"}
last = int(store.get_setting(KEY) or 0)
if time.time() - last < COOLDOWN_S:
return {"ok": False, "reason": "недавно предлагали — подождите", "cooldown": True}
inbox = store.query(
"SELECT id, source_msg FROM leads WHERE col = 'inbox' AND source_msg <> '' "
"ORDER BY received_at DESC LIMIT ?",
[MAX_TEXT],
)
if len(inbox) < MIN_INBOX:
return {"ok": False, "reason": f"мало карточек в «Неразобранном» (нужно от {MIN_INBOX})"}
rows = [(str(r["id"]), str(r["source_msg"])) for r in inbox]
texts = [t[:180] for _, t in rows]
existing = [str(r["name"]) for r in store.query("SELECT name FROM boards WHERE suggested = FALSE ORDER BY pos")]
user = (
f"Существующие колонки: {', '.join(existing) if existing else 'нет'}\n\n"
"Сообщения:\n" + "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts, start=1))
)
try:
out = await ai_svc.chat_json(ai_svc.fill_prompt(SUGGEST_PROMPT), user, max_retries=2, max_tokens=16000)
except Exception as exc: # noqa: BLE001
log.warning("suggest failed: %s", exc)
return {"ok": False, "reason": f"ИИ недоступен: {exc}"}
cols = out.get("columns") if isinstance(out, dict) else None
if not isinstance(cols, list) or not cols:
log.info("suggest: ИИ не предложил колонок")
return {"ok": False, "reason": "ИИ не предложил колонок"}
# диагностика: что именно предложил ИИ (имена + число карточек)
log.info(
"suggest: ИИ предложил %d кандидатов: %s",
len(cols),
"; ".join(
f"{str(c.get('name'))[:40]}(msg={c.get('messages')})" for c in cols if isinstance(c, dict)
)[:300],
)
# номера, на которые уже «потратились» предыдущие колонки этого прогона
used_msg: set[int] = set()
created = 0
for item in cols[:4]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if len(name) < 2 or len(name) > 40:
continue
if _similar_exists(name):
continue
nums = [int(x) for x in (item.get("messages") or []) if str(x).isdigit()]
nums = [n for n in nums if 1 <= n <= len(rows) and n not in used_msg]
if len(nums) < MIN_INBOX_GROUP:
continue # колонка без явных карточек не создаётся
used_msg.update(nums)
rules = _rules_for(item)
note = _make_note(item, texts, rules, nums)
description = str(item.get("description") or "").strip()[:300]
board = _store_suggested(name, rules, note, description)
if not board:
continue
assigned = _assign_ids(rows, nums, board["id"])
if not assigned:
# ничего не удалось положить — пустое предложение не нужно
_rollback_suggested(board["id"])
continue
created += 1
if not created:
return {"ok": False, "reason": "похожие колонки уже есть или нечего сгруппировать"}
store.set_setting(KEY, int(time.time()))
await broker.publish("boards_changed", {})
await broker.publish_toast(f"ИИ предложил колонок: {created} — откройте и решите", "sparkles")
return {"ok": True, "created": created}
async def suggest_domain_keywords() -> dict:
"""«Предложить ключи ИИ»: по вашим карточкам выделяет общие слова-маркеры сферы.
Возвращает кандидатов — пользователь смотрит, правит и сохраняет в настройках
«Сфера и ключи» (общие ключи) или использует для правил колонок.
"""
rows = store.query(
"SELECT source_msg FROM leads WHERE col <> 'trash' AND col <> 'archive' AND source_msg <> '' "
"ORDER BY received_at DESC LIMIT 40"
)
texts = [str(r["source_msg"])[:350] for r in rows]
if len(texts) < 3:
return {"ok": False, "reason": "мало карточек — сначала накопите заявки (нужно хотя бы 3)"}
user = "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts))
try:
out = await ai_svc.chat_json(ai_svc.fill_prompt(KEYWORDS_PROMPT), user, max_retries=2)
except Exception as exc: # noqa: BLE001
log.warning("suggest keywords failed: %s", exc)
return {"ok": False, "reason": f"ИИ недоступен: {exc}"}
kws = out.get("keywords") if isinstance(out, dict) else None
if not isinstance(kws, list) or not kws:
return {"ok": False, "reason": "ИИ не смог выделить ключи — попробуйте ещё раз"}
clean: list[str] = []
for k in kws:
s = str(k).strip().casefold().strip(".,;:«»\"'()!#")
if s and len(s) <= 40 and s not in clean:
clean.append(s)
return {"ok": True, "keywords": clean[:60]}
def _make_note(item: dict, texts: list[str], rules: dict, nums: list[int]) -> str:
reason = str(item.get("reason") or "").strip()
direction = " · ".join(str(x) for x in (item.get("direction") or [])[:3])
base = f"Обоснование ИИ: {reason}" if reason else (
f"Обоснование ИИ: направление — {direction}" if direction else
"Обоснование ИИ: повторяющиеся заявки одной темы"
)
# сколько карточек из выборки реально попадёт в колонку (по номерам ИИ)
matched = sum(1 for n in nums if 1 <= n <= len(texts))
if matched:
base += f" · карточек в колонке: {matched}"
return base[:400]
def _store_suggested(name: str, rules: dict, note: str, description: str = "") -> dict | None:
from . import leads as leads_svc
keywords = rules.get("keywords") or []
board = leads_svc.create_board(
name, suggested=True, keywords=keywords, rules=rules, note=note, description=description
)
return leads_svc.board_by_id(board["id"])
def _assign_ids(rows: list[tuple[str, str]], nums: list[int], board_id: str) -> int:
"""Раскладывает в колонку-предложение карточки по номерам, которые назвал ИИ.
rows — список (id, source_msg) в том же порядке, в каком сообщения уходили
в промпт (1..N). Возвращает число реально перенесённых карточек.
"""
assigned = 0
for n in nums:
if not (1 <= n <= len(rows)):
continue
lead_id, _ = rows[n - 1]
still = store.scalar("SELECT 1 FROM leads WHERE id = ? AND col = 'inbox'", [lead_id])
if not still:
continue # карточка уже разобрана другим предложением/пользователем
store.execute(
"UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox' WHERE id = ? AND col = 'inbox'",
[board_id, lead_id],
)
assigned += 1
return assigned
def _rollback_suggested(board_id: str) -> None:
"""Удаляет пустую колонку-предложение (в неё ничего не попало)."""
from . import leads as leads_svc
store.execute("UPDATE leads SET col = 'inbox' WHERE col = ?", [board_id])
store.execute("DELETE FROM boards WHERE id = ?", [board_id])
"""ИИ-предложения колонок по анализу «Неразобранного».
Колонка — не «на один скилл», а смысловая тема: направление + набор стека и
ключевых слов (+ бюджет при повторяемости). ИИ группирует карточки и создаёт
КОЛОНКИ-ПРЕДЛОЖЕНИЯ (suggested=TRUE) с обоснованием (note), по каким
критериям собрана. Пользователь открывает предложение, смотрит карточки и
решает: принять (можно переименовать/поправить правила) или удалить.
"""
from __future__ import annotations
import logging
import time
from ..db import store
from ..sse import broker
from . import ai as ai_svc
log = logging.getLogger("leadradar.suggest")
SUGGEST_PROMPT = """Ты — аналитик входящих заявок. Перед тобой пронумерованные сообщения (номера 1..N), которые не подошли ни под одну существующую колонку. Сгруппируй их в 2–4 ОСМЫСЛЕННЫЕ тематические колонки.
Сфера/что считается заявкой:
{domain}
Колонка — это не отдельный предмет и не каждое слово по отдельности, а направление с набором родственных признаков (тип работ/услуг, предметы, технологии, материалы). Пример: «Telegram-боты» — направление: чат-боты/автоматизация; предметы/технологии: Python, aiogram; слова: бот, telegram, автоответчик.
Правила:
- группируй повторяющиеся темы: в каждую колонку бери минимум 2 сообщения;
- не предлагай колонки, похожие на уже существующие;
- 2–4 колонки максимум; если ничего общего нет — верни пустой список.
Для каждой колонки верни:
- name — короткое название (2–4 слова);
- description — короткое описание колонки (1–2 предложения): что за заявки и для кого;
- direction — направление/тип задач (2–5 слов или фраз);
- stack — что фигурирует в заявках: предметы, услуги, технологии, материалы (1–6);
- grade — уровень/грейд, если ярко выражен (например: ["middle"]), иначе пустой список;
- keywords — 3–8 ключевых слов/фраз, по которым узнаётся такая заявка;
- messages — НОМЕРА сообщений из списка, которые относятся к этой колонке (минимум 2, максимум 12);
- reason — обоснование в 1 предложение: что за тема, сколько карточек и что в них общего.
Верни строго JSON:
{ "columns": [ { "name": "", "description": "", "direction": [""], "stack": [""], "grade": [""], "keywords": [""], "messages": [1, 5], "reason": "" } ] }"""
KEYWORDS_PROMPT = """Ты — аналитик входящих сообщений. Ниже — реальные сообщения, которые уже признаны заявками/лидами (и часть — обычный флуд каналов).
Сфера/что считается заявкой:
{domain}
Выдели общие слова-МАРКЕРЫ, по которым сообщение можно отнести к заявкам этой сферы (а не к флуду): типовые предметы/услуги/работы, глаголы-действия («куплю», «нужен», «сниму», «отремонтировать»), статусные слова («срочно», «под ключ», «бюджет»).
Верни строго JSON с плоским списком от 8 до 40 ключевых слов/фраз (слова в нижнем регистре, без знаков препинания):
{ "keywords": ["", ""] }"""
MIN_INBOX = 6 # минимум карточек в «Неразобранном» для анализа
MIN_INBOX_GROUP = 2 # минимум карточек в одной ИИ-колонке
MAX_TEXT = 12 # сколько сообщений берём в анализ (ИИ обрывает длинный JSON)
COOLDOWN_S = 20 * 60 # как часто автоматически переспрашиваем
KEY = "lastSuggestAt"
def _similar_exists(name: str) -> bool:
low = name.casefold()
rows = store.query("SELECT id, name FROM boards")
for r in rows:
n = str(r["name"]).casefold()
if low == n or low in n or n in low:
return True
return False
def _rules_for(item: dict) -> dict:
"""Правила колонки из ИИ-ответа: направление/слова/стек/грейд (любое из условий)."""
return {
"mode": "any",
"direction": [str(x).strip().lower() for x in (item.get("direction") or []) if str(x).strip()][:6],
"keywords": [str(x).strip().lower() for x in (item.get("keywords") or []) if str(x).strip()][:8],
"stack": [str(x).strip().lower() for x in (item.get("stack") or []) if str(x).strip()][:8],
"grade": [str(x).strip().lower() for x in (item.get("grade") or []) if str(x).strip()][:6],
}
async def suggest_from_inbox(force: bool = False) -> dict:
"""Анализ «Неразобранного» и создание колонок-предложений с обоснованием.
ИИ группирует пронумерованные сообщения и для каждой колонки возвращает
номера сообщений (messages). Карточки раскладываются именно по этим
номерам, а не строгим match_text по сгенерированным правилам — иначе
колонка-предложение часто остаётся пустой (правила ИИ приблизительные).
"""
# Предложения колонок — это вызов ИИ: при выключенном ИИ (aiEnabled=false,
# режим «только фильтр + ML») автоцикл не должен дёргать провайдера.
if not force and not store.get_setting("aiEnabled"):
return {"ok": False, "reason": "ИИ выключен — предложения колонок недоступны"}
if not force:
# не плодим предложения, пока пользователь не разобрался со старыми
pending = store.scalar("SELECT count(*) FROM boards WHERE suggested = TRUE")
if pending:
return {"ok": False, "reason": f"сначала решите судьбу {pending} предложенных колонок"}
last = int(store.get_setting(KEY) or 0)
if time.time() - last < COOLDOWN_S:
return {"ok": False, "reason": "недавно предлагали — подождите", "cooldown": True}
inbox = store.query(
"SELECT id, source_msg FROM leads WHERE col = 'inbox' AND source_msg <> '' "
"ORDER BY received_at DESC LIMIT ?",
[MAX_TEXT],
)
if len(inbox) < MIN_INBOX:
return {"ok": False, "reason": f"мало карточек в «Неразобранном» (нужно от {MIN_INBOX})"}
rows = [(str(r["id"]), str(r["source_msg"])) for r in inbox]
texts = [t[:180] for _, t in rows]
existing = [str(r["name"]) for r in store.query("SELECT name FROM boards WHERE suggested = FALSE ORDER BY pos")]
user = (
f"Существующие колонки: {', '.join(existing) if existing else 'нет'}\n\n"
"Сообщения:\n" + "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts, start=1))
)
try:
out = await ai_svc.chat_json(ai_svc.fill_prompt(SUGGEST_PROMPT), user, max_retries=2, max_tokens=16000)
except Exception as exc: # noqa: BLE001
log.warning("suggest failed: %s", exc)
return {"ok": False, "reason": f"ИИ недоступен: {exc}"}
cols = out.get("columns") if isinstance(out, dict) else None
if not isinstance(cols, list) or not cols:
log.info("suggest: ИИ не предложил колонок")
return {"ok": False, "reason": "ИИ не предложил колонок"}
# диагностика: что именно предложил ИИ (имена + число карточек)
log.info(
"suggest: ИИ предложил %d кандидатов: %s",
len(cols),
"; ".join(
f"{str(c.get('name'))[:40]}(msg={c.get('messages')})" for c in cols if isinstance(c, dict)
)[:300],
)
# номера, на которые уже «потратились» предыдущие колонки этого прогона
used_msg: set[int] = set()
created = 0
for item in cols[:4]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if len(name) < 2 or len(name) > 40:
continue
if _similar_exists(name):
continue
nums = [int(x) for x in (item.get("messages") or []) if str(x).isdigit()]
nums = [n for n in nums if 1 <= n <= len(rows) and n not in used_msg]
if len(nums) < MIN_INBOX_GROUP:
continue # колонка без явных карточек не создаётся
used_msg.update(nums)
rules = _rules_for(item)
note = _make_note(item, texts, rules, nums)
description = str(item.get("description") or "").strip()[:300]
board = _store_suggested(name, rules, note, description)
if not board:
continue
assigned = _assign_ids(rows, nums, board["id"])
if not assigned:
# ничего не удалось положить — пустое предложение не нужно
_rollback_suggested(board["id"])
continue
created += 1
if not created:
return {"ok": False, "reason": "похожие колонки уже есть или нечего сгруппировать"}
store.set_setting(KEY, int(time.time()))
await broker.publish("boards_changed", {})
await broker.publish_toast(f"ИИ предложил колонок: {created} — откройте и решите", "sparkles")
return {"ok": True, "created": created}
async def suggest_domain_keywords() -> dict:
"""«Предложить ключи ИИ»: по вашим карточкам выделяет общие слова-маркеры сферы.
Возвращает кандидатов — пользователь смотрит, правит и сохраняет в настройках
«Сфера и ключи» (общие ключи) или использует для правил колонок.
"""
rows = store.query(
"SELECT source_msg FROM leads WHERE col <> 'trash' AND col <> 'archive' AND source_msg <> '' "
"ORDER BY received_at DESC LIMIT 40"
)
texts = [str(r["source_msg"])[:350] for r in rows]
if len(texts) < 3:
return {"ok": False, "reason": "мало карточек — сначала накопите заявки (нужно хотя бы 3)"}
user = "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts))
try:
out = await ai_svc.chat_json(ai_svc.fill_prompt(KEYWORDS_PROMPT), user, max_retries=2)
except Exception as exc: # noqa: BLE001
log.warning("suggest keywords failed: %s", exc)
return {"ok": False, "reason": f"ИИ недоступен: {exc}"}
kws = out.get("keywords") if isinstance(out, dict) else None
if not isinstance(kws, list) or not kws:
return {"ok": False, "reason": "ИИ не смог выделить ключи — попробуйте ещё раз"}
clean: list[str] = []
for k in kws:
s = str(k).strip().casefold().strip(".,;:«»\"'()!#")
if s and len(s) <= 40 and s not in clean:
clean.append(s)
return {"ok": True, "keywords": clean[:60]}
def _make_note(item: dict, texts: list[str], rules: dict, nums: list[int]) -> str:
reason = str(item.get("reason") or "").strip()
direction = " · ".join(str(x) for x in (item.get("direction") or [])[:3])
base = f"Обоснование ИИ: {reason}" if reason else (
f"Обоснование ИИ: направление — {direction}" if direction else
"Обоснование ИИ: повторяющиеся заявки одной темы"
)
# сколько карточек из выборки реально попадёт в колонку (по номерам ИИ)
matched = sum(1 for n in nums if 1 <= n <= len(texts))
if matched:
base += f" · карточек в колонке: {matched}"
return base[:400]
def _store_suggested(name: str, rules: dict, note: str, description: str = "") -> dict | None:
from . import leads as leads_svc
keywords = rules.get("keywords") or []
board = leads_svc.create_board(
name, suggested=True, keywords=keywords, rules=rules, note=note, description=description
)
return leads_svc.board_by_id(board["id"])
def _assign_ids(rows: list[tuple[str, str]], nums: list[int], board_id: str) -> int:
"""Раскладывает в колонку-предложение карточки по номерам, которые назвал ИИ.
rows — список (id, source_msg) в том же порядке, в каком сообщения уходили
в промпт (1..N). Возвращает число реально перенесённых карточек.
"""
assigned = 0
for n in nums:
if not (1 <= n <= len(rows)):
continue
lead_id, _ = rows[n - 1]
still = store.scalar("SELECT 1 FROM leads WHERE id = ? AND col = 'inbox'", [lead_id])
if not still:
continue # карточка уже разобрана другим предложением/пользователем
store.execute(
"UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox' WHERE id = ? AND col = 'inbox'",
[board_id, lead_id],
)
assigned += 1
return assigned
def _rollback_suggested(board_id: str) -> None:
"""Удаляет пустую колонку-предложение (в неё ничего не попало)."""
from . import leads as leads_svc
store.execute("UPDATE leads SET col = 'inbox' WHERE col = ?", [board_id])
store.execute("DELETE FROM boards WHERE id = ?", [board_id])
File diff suppressed because it is too large Load Diff
+51 -51
View File
@@ -1,51 +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()
"""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()
@@ -1,57 +1,57 @@
"""Временный boot-тест №2: шифрование, FTS, demo, check-message, recompute."""
import os
import sys
import tempfile
# devtests/ лежит внутри backend/ — кладём в path сам backend
tmp = tempfile.mkdtemp(prefix="leadradar_boot2_")
os.environ["LEADRADAR_DATA"] = tmp
os.environ["LEADRADAR_DEMO"] = "1"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
from app.services import rates as R # noqa: E402
def seed_lead(client):
# ручной лид через demo-эндпоинт (не требует ИИ/телеграма)
r = client.post("/api/demo/simulate-lead")
assert r.status_code == 200, r.text
return r.json()
with TestClient(app) as client:
assert client.get("/api/health").status_code == 200
assert client.post("/api/auth/login", json={"login": "admin", "password": "admin"}).status_code == 200
# шифрование настроек aiConfigs (класс провайдера)
s = client.patch("/api/settings", json={"aiConfigs": {"deepseek": {"apiKey": "sk-abcdefgh1234"}}})
assert s.status_code == 200, s.text
pub = client.get("/api/settings").json()
assert pub["aiConfigs"]["deepseek"]["keySet"] is True, pub["aiConfigs"]
# FTS rebuild
f = client.post("/api/admin/fts/rebuild")
assert f.status_code == 200 and f.json()["ready"] is True, f.text
# демо-лид + поиск
lead = seed_lead(client)
assert lead.get("id"), lead
q = client.get("/api/search", params={"q": "Python"}).json()
assert isinstance(q["leads"], list)
# check-message (этап 1: стоп-фразы без ИИ)
cm = client.post("/api/admin/check-message", json={"text": "Ищу работу на неделю, вот моё резюме и портфолио"})
assert cm.status_code == 200 and cm.json()["passed"] is False and cm.json()["stage1"]["pass"] is False, cm.text
# пересчёт конверсий при смене валюты (архивные не трогаем)
R.save_rates({"RUB": 1.0, "USD": 100.0}, "mock")
client.patch("/api/settings", json={"targetCurrency": "RUB", "conversionOn": True})
refreshed = client.get("/api/settings").json()
assert refreshed["targetCurrency"] == "RUB"
tick = client.post("/api/admin/tick")
assert tick.status_code == 200
print("BOOT2 OK")
"""Временный boot-тест №2: шифрование, FTS, demo, check-message, recompute."""
import os
import sys
import tempfile
# devtests/ лежит внутри backend/ — кладём в path сам backend
tmp = tempfile.mkdtemp(prefix="leadradar_boot2_")
os.environ["LEADRADAR_DATA"] = tmp
os.environ["LEADRADAR_DEMO"] = "1"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
from app.services import rates as R # noqa: E402
def seed_lead(client):
# ручной лид через demo-эндпоинт (не требует ИИ/телеграма)
r = client.post("/api/demo/simulate-lead")
assert r.status_code == 200, r.text
return r.json()
with TestClient(app) as client:
assert client.get("/api/health").status_code == 200
assert client.post("/api/auth/login", json={"login": "admin", "password": "admin"}).status_code == 200
# шифрование настроек aiConfigs (класс провайдера)
s = client.patch("/api/settings", json={"aiConfigs": {"deepseek": {"apiKey": "sk-abcdefgh1234"}}})
assert s.status_code == 200, s.text
pub = client.get("/api/settings").json()
assert pub["aiConfigs"]["deepseek"]["keySet"] is True, pub["aiConfigs"]
# FTS rebuild
f = client.post("/api/admin/fts/rebuild")
assert f.status_code == 200 and f.json()["ready"] is True, f.text
# демо-лид + поиск
lead = seed_lead(client)
assert lead.get("id"), lead
q = client.get("/api/search", params={"q": "Python"}).json()
assert isinstance(q["leads"], list)
# check-message (этап 1: стоп-фразы без ИИ)
cm = client.post("/api/admin/check-message", json={"text": "Ищу работу на неделю, вот моё резюме и портфолио"})
assert cm.status_code == 200 and cm.json()["passed"] is False and cm.json()["stage1"]["pass"] is False, cm.text
# пересчёт конверсий при смене валюты (архивные не трогаем)
R.save_rates({"RUB": 1.0, "USD": 100.0}, "mock")
client.patch("/api/settings", json={"targetCurrency": "RUB", "conversionOn": True})
refreshed = client.get("/api/settings").json()
assert refreshed["targetCurrency"] == "RUB"
tick = client.post("/api/admin/tick")
assert tick.status_code == 200
print("BOOT2 OK")
@@ -1,73 +1,73 @@
"""Read-only смоук docker-инсталляции: ничего не создаёт в живой базе.
Проверяет: health, SPA, вход, настройки/доски, поиск (ответ), admin-тик,
FTS, счётчики и связку с автономным ML-сервисом. Файловые проверки MinIO
в opt-in режиме SMOKE_MUTATE=1 (создают проект, который надо удалять вручную).
"""
import os
import sys
import httpx
BASE = "http://localhost:8000"
MUTATE = os.getenv("SMOKE_MUTATE", "") == "1"
ok = True
def check(name, cond, extra=""):
global ok
if not cond:
ok = False
print("FAIL:", name, extra)
else:
print("ok:", name)
with httpx.Client(base_url=BASE, timeout=30) as c:
check("health", c.get("/api/health").status_code == 200)
page = c.get("/")
check("spa", page.status_code == 200 and 'id="app"' in page.text)
r = c.post("/api/auth/login", json={"login": "admin", "password": "admin"})
check("login", r.status_code == 200 and c.cookies.get("leadradar_session"))
boards = c.get("/api/boards").json()
check("boards api", isinstance(boards, list))
settings = c.get("/api/settings").json()
check("settings", settings.get("mlEnabled") is True and settings.get("targetCurrency") == "RUB")
# демо-механика в проде выключена
demo = c.post("/api/demo/simulate-lead")
check("demo disabled", demo.status_code == 404)
# поиск отвечает (лиды создаются только из Telegram)
q = c.get("/api/search", params={"q": "Python"}).json()
check("search", isinstance(q.get("leads"), list))
# тик (правила хранения + очередь), FTS, счётчики
tick = c.post("/api/admin/tick").json()
check("tick", "pipeline" in tick and "queue" in tick)
check("fts rebuild", c.post("/api/admin/fts/rebuild").json().get("ready") is True)
counts = c.get("/api/leads/counts").json()
check("counts", "ml" in counts and "ai" in counts and "learning" in counts)
# автономный ML-сервис (отдельный контейнер) через API основного приложения
ml = c.get("/api/ml/status").json()
check("ml reachable", ml.get("reachable") is True, ml)
pred = c.post("/api/ml/predict", json={"text": "python backend fastapi бот телеграм"}).json()
check("ml predict", isinstance(pred.get("scores"), dict))
if MUTATE:
# opt-in: проект + файл через MinIO (после проверки проект надо удалить)
card = c.post("/api/projects", json={"title": "SMOKE-TMP-удалить"}).json()
check("project", bool(card.get("id")))
r = c.post(f"/api/projects/{card['id']}/files", files=[("files", ("smoke.txt", b"docker smoke", "text/plain"))])
check("upload to MinIO", r.status_code == 200 and len(r.json().get("items", [])) == 1, r.text)
fid = r.json()["items"][0]["id"]
dl = c.get(f"/api/projects/{card['id']}/files/{fid}/download")
check("download from MinIO", dl.status_code == 200 and dl.content == b"docker smoke", dl.text[:100])
check("unauth me", c.get("/api/auth/me").status_code == 200) # кука жива
print("DOCKER SMOKE OK" if ok else "DOCKER SMOKE FAILED")
sys.exit(0 if ok else 1)
"""Read-only смоук docker-инсталляции: ничего не создаёт в живой базе.
Проверяет: health, SPA, вход, настройки/доски, поиск (ответ), admin-тик,
FTS, счётчики и связку с автономным ML-сервисом. Файловые проверки MinIO
в opt-in режиме SMOKE_MUTATE=1 (создают проект, который надо удалять вручную).
"""
import os
import sys
import httpx
BASE = "http://localhost:8000"
MUTATE = os.getenv("SMOKE_MUTATE", "") == "1"
ok = True
def check(name, cond, extra=""):
global ok
if not cond:
ok = False
print("FAIL:", name, extra)
else:
print("ok:", name)
with httpx.Client(base_url=BASE, timeout=30) as c:
check("health", c.get("/api/health").status_code == 200)
page = c.get("/")
check("spa", page.status_code == 200 and 'id="app"' in page.text)
r = c.post("/api/auth/login", json={"login": "admin", "password": "admin"})
check("login", r.status_code == 200 and c.cookies.get("leadradar_session"))
boards = c.get("/api/boards").json()
check("boards api", isinstance(boards, list))
settings = c.get("/api/settings").json()
check("settings", settings.get("mlEnabled") is True and settings.get("targetCurrency") == "RUB")
# демо-механика в проде выключена
demo = c.post("/api/demo/simulate-lead")
check("demo disabled", demo.status_code == 404)
# поиск отвечает (лиды создаются только из Telegram)
q = c.get("/api/search", params={"q": "Python"}).json()
check("search", isinstance(q.get("leads"), list))
# тик (правила хранения + очередь), FTS, счётчики
tick = c.post("/api/admin/tick").json()
check("tick", "pipeline" in tick and "queue" in tick)
check("fts rebuild", c.post("/api/admin/fts/rebuild").json().get("ready") is True)
counts = c.get("/api/leads/counts").json()
check("counts", "ml" in counts and "ai" in counts and "learning" in counts)
# автономный ML-сервис (отдельный контейнер) через API основного приложения
ml = c.get("/api/ml/status").json()
check("ml reachable", ml.get("reachable") is True, ml)
pred = c.post("/api/ml/predict", json={"text": "python backend fastapi бот телеграм"}).json()
check("ml predict", isinstance(pred.get("scores"), dict))
if MUTATE:
# opt-in: проект + файл через MinIO (после проверки проект надо удалить)
card = c.post("/api/projects", json={"title": "SMOKE-TMP-удалить"}).json()
check("project", bool(card.get("id")))
r = c.post(f"/api/projects/{card['id']}/files", files=[("files", ("smoke.txt", b"docker smoke", "text/plain"))])
check("upload to MinIO", r.status_code == 200 and len(r.json().get("items", [])) == 1, r.text)
fid = r.json()["items"][0]["id"]
dl = c.get(f"/api/projects/{card['id']}/files/{fid}/download")
check("download from MinIO", dl.status_code == 200 and dl.content == b"docker smoke", dl.text[:100])
check("unauth me", c.get("/api/auth/me").status_code == 200) # кука жива
print("DOCKER SMOKE OK" if ok else "DOCKER SMOKE FAILED")
sys.exit(0 if ok else 1)
@@ -1,175 +1,175 @@
"""Временный e2e-тест по HTTP: реальный uvicorn + фронтовые API-вызовы."""
import os
import subprocess
import sys
import tempfile
import time
import httpx
# devtests/ лежит внутри backend/ — backend нужен как cwd для uvicorn
tmp = tempfile.mkdtemp(prefix="leadradar_e2e_")
env = dict(os.environ)
env["LEADRADAR_DATA"] = tmp
env["LEADRADAR_DEMO"] = "1"
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
server = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app", "--port", "8077", "--log-level", "warning"],
cwd=BACKEND_DIR,
env=env,
)
BASE = "http://127.0.0.1:8077"
ok = True
def check(name, cond, extra=""):
global ok
if not cond:
ok = False
print("FAIL:", name, extra)
else:
print("ok:", name)
try:
for _ in range(40):
try:
r = httpx.get(BASE + "/api/health", timeout=1)
if r.status_code == 200:
break
except Exception:
time.sleep(0.5)
else:
raise SystemExit("server did not start")
with httpx.Client(base_url=BASE, timeout=20) as c:
check("health", c.get("/api/health").status_code == 200)
# вход
r = c.post("/api/auth/login", json={"login": "admin", "password": "admin"})
check("login", r.status_code == 200)
check("cookie set", bool(c.cookies.get("leadradar_session")))
me = c.get("/api/auth/me")
check("me", me.status_code == 200 and me.json().get("login") == "admin")
# стартовые данные: колонок нет по умолчанию — создаём одну через API
boards = c.get("/api/boards").json()
check("no default boards", boards == [])
bid = c.post("/api/boards", json={"name": "Python"}).json()["id"]
check("board created", bool(bid))
settings = c.get("/api/settings").json()
check("settings public", settings.get("targetCurrency") == "RUB" and settings.get("aiProvider") == "deepseek")
# демо-лид -> поиск
lead = c.post("/api/demo/simulate-lead").json()
check("demo lead", bool(lead.get("id")), str(lead)[:120])
lid = lead["id"]
q = c.get("/api/search", params={"q": "Python"}).json()
check("search", isinstance(q.get("leads"), list))
# перенос на доску
r = c.post(f"/api/leads/{lid}/move", json={"to": bid})
check("move to board", r.status_code == 200 and r.json().get("col") == bid, r.text)
r = c.post(f"/api/leads/{lid}/comments", json={"text": "Комментарий из e2e"})
check("comment", r.status_code == 200 and len(r.json().get("comments", [])) == 1)
# демо-старение -> архив
r = c.post("/api/demo/age-lead")
check("age-lead", r.status_code == 200, r.text)
leads = c.get("/api/leads").json()["items"]
arch = c.get("/api/leads", params={"col": "archive"}).json()["items"]
check("lead archived", any(l["id"] == lid for l in arch), f"leads={len(leads)}")
# восстановление
r = c.post(f"/api/leads/{lid}/restore")
check("restore", r.status_code == 200 and r.json().get("col") in ("inbox", bid), r.text)
# проекты: взять в работу
card = c.post("/api/projects/take", json={"leadId": lid}).json()
check("take to projects", bool(card.get("id")), str(card)[:120])
cid = card["id"]
leads_after = c.get("/api/leads").json()["items"]
check("lead gone from board", all(l["id"] != lid for l in leads_after))
# стадия + комментарий + ссылка + ТЗ
r = c.post(f"/api/projects/{cid}/move", json={"stage": "reply"})
check("stage reply", r.status_code == 200 and r.json().get("stage") == "reply")
r = c.post(f"/api/projects/{cid}/comments", json={"text": "Откликнулся"})
check("proj comment", r.status_code == 200)
r = c.post(f"/api/projects/{cid}/links", json={"name": "Макет", "url": "figma.com/x"})
check("proj link", r.status_code == 200 and r.json().get("links", [])[0]["url"].startswith("https://"))
r = c.patch(f"/api/projects/{cid}", json={"tzText": "ТЗ: интеграция с amoCRM"})
check("proj tz", r.status_code == 200 and r.json().get("tzText") == "ТЗ: интеграция с amoCRM")
# файл (локальный fallback без MinIO) + скачивание
r = c.post(f"/api/projects/{cid}/files", files=[("files", ("tz.pdf", b"%PDF-1.4 test", "application/pdf"))])
check("file upload", r.status_code == 200 and len(r.json().get("items", [])) == 1, r.text)
file_id = r.json()["items"][0]["id"]
dl = c.get(f"/api/projects/{cid}/files/{file_id}/download")
check("file download", dl.status_code == 200 and dl.content == b"%PDF-1.4 test", dl.text[:80])
r = c.delete(f"/api/projects/{cid}/files/{file_id}")
check("file remove", r.status_code == 200)
# локальная карточка
loc = c.post("/api/projects", json={"title": "", "stack": ["Go"]}).json()
check("local card", loc.get("local") is True and loc.get("title") == "")
# напоминание (hold)
r = c.post(f"/api/projects/{cid}/move", json={"stage": "hold"})
check("stage hold", r.status_code == 200)
at = int(time.time() * 1000) + 60000
r = c.post(f"/api/projects/{cid}/reminder", json={"at": at})
check("set reminder", r.status_code == 200 and r.json().get("reminder", {}).get("at") == at, r.text)
rem = c.get("/api/projects/reminders").json()["items"]
check("active reminders", any(x["id"] == cid for x in rem))
# настройки валюты и пересчёт
r = c.patch("/api/settings", json={"targetCurrency": "RUB", "conversionOn": True})
check("settings patch", r.status_code == 200)
rates = c.get("/api/rates").json()
check("rates have USD", "USD" in rates.get("rates", {}))
# mark-col-seen (новый эндпоинт)
r = c.post("/api/leads/mark-col-seen", json={"col": "inbox"})
check("mark col seen", r.status_code == 200)
# ручная полная очистка «Отклонено» (проектные карточки)
r = c.post(f"/api/projects/{cid}/move", json={"stage": "rejected"})
check("stage rejected", r.status_code == 200)
r = c.post("/api/projects/clear-rejected")
check("clear rejected", r.status_code == 200 and r.json().get("cleared", 0) >= 1, r.text)
gone = c.get("/api/projects").json()["items"]
check("rejected gone", all(x["id"] != cid for x in gone))
# ручная полная очистка корзины (лиды дашборда)
d2 = c.post("/api/demo/simulate-lead").json()
c.post(f"/api/leads/{d2['id']}/trash")
trash_items = c.get("/api/leads", params={"col": "trash"}).json()["items"]
check("trash has lead", any(x["id"] == d2["id"] for x in trash_items))
r = c.post("/api/leads/clear-col", json={"col": "trash"})
check("clear trash", r.status_code == 200 and r.json().get("cleared", 0) >= 1, r.text)
trash_items = c.get("/api/leads", params={"col": "trash"}).json()["items"]
check("trash empty", len(trash_items) == 0)
# архив: тот же эндпоинт (сейчас пуст — просто валидируем)
r = c.post("/api/leads/clear-col", json={"col": "archive"})
check("clear archive", r.status_code == 200)
# FTS rebuild
r = c.post("/api/admin/fts/rebuild")
check("fts rebuild", r.status_code == 200 and r.json().get("ready") is True, r.text)
# статика фронтенда из dist
page = c.get("/")
check("spa served", page.status_code == 200 and "<div id=\"app\">" in page.text)
print("E2E OK" if ok else "E2E FAILED")
finally:
server.terminate()
try:
server.wait(timeout=10)
except Exception:
server.kill()
"""Временный e2e-тест по HTTP: реальный uvicorn + фронтовые API-вызовы."""
import os
import subprocess
import sys
import tempfile
import time
import httpx
# devtests/ лежит внутри backend/ — backend нужен как cwd для uvicorn
tmp = tempfile.mkdtemp(prefix="leadradar_e2e_")
env = dict(os.environ)
env["LEADRADAR_DATA"] = tmp
env["LEADRADAR_DEMO"] = "1"
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
server = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app", "--port", "8077", "--log-level", "warning"],
cwd=BACKEND_DIR,
env=env,
)
BASE = "http://127.0.0.1:8077"
ok = True
def check(name, cond, extra=""):
global ok
if not cond:
ok = False
print("FAIL:", name, extra)
else:
print("ok:", name)
try:
for _ in range(40):
try:
r = httpx.get(BASE + "/api/health", timeout=1)
if r.status_code == 200:
break
except Exception:
time.sleep(0.5)
else:
raise SystemExit("server did not start")
with httpx.Client(base_url=BASE, timeout=20) as c:
check("health", c.get("/api/health").status_code == 200)
# вход
r = c.post("/api/auth/login", json={"login": "admin", "password": "admin"})
check("login", r.status_code == 200)
check("cookie set", bool(c.cookies.get("leadradar_session")))
me = c.get("/api/auth/me")
check("me", me.status_code == 200 and me.json().get("login") == "admin")
# стартовые данные: колонок нет по умолчанию — создаём одну через API
boards = c.get("/api/boards").json()
check("no default boards", boards == [])
bid = c.post("/api/boards", json={"name": "Python"}).json()["id"]
check("board created", bool(bid))
settings = c.get("/api/settings").json()
check("settings public", settings.get("targetCurrency") == "RUB" and settings.get("aiProvider") == "deepseek")
# демо-лид -> поиск
lead = c.post("/api/demo/simulate-lead").json()
check("demo lead", bool(lead.get("id")), str(lead)[:120])
lid = lead["id"]
q = c.get("/api/search", params={"q": "Python"}).json()
check("search", isinstance(q.get("leads"), list))
# перенос на доску
r = c.post(f"/api/leads/{lid}/move", json={"to": bid})
check("move to board", r.status_code == 200 and r.json().get("col") == bid, r.text)
r = c.post(f"/api/leads/{lid}/comments", json={"text": "Комментарий из e2e"})
check("comment", r.status_code == 200 and len(r.json().get("comments", [])) == 1)
# демо-старение -> архив
r = c.post("/api/demo/age-lead")
check("age-lead", r.status_code == 200, r.text)
leads = c.get("/api/leads").json()["items"]
arch = c.get("/api/leads", params={"col": "archive"}).json()["items"]
check("lead archived", any(l["id"] == lid for l in arch), f"leads={len(leads)}")
# восстановление
r = c.post(f"/api/leads/{lid}/restore")
check("restore", r.status_code == 200 and r.json().get("col") in ("inbox", bid), r.text)
# проекты: взять в работу
card = c.post("/api/projects/take", json={"leadId": lid}).json()
check("take to projects", bool(card.get("id")), str(card)[:120])
cid = card["id"]
leads_after = c.get("/api/leads").json()["items"]
check("lead gone from board", all(l["id"] != lid for l in leads_after))
# стадия + комментарий + ссылка + ТЗ
r = c.post(f"/api/projects/{cid}/move", json={"stage": "reply"})
check("stage reply", r.status_code == 200 and r.json().get("stage") == "reply")
r = c.post(f"/api/projects/{cid}/comments", json={"text": "Откликнулся"})
check("proj comment", r.status_code == 200)
r = c.post(f"/api/projects/{cid}/links", json={"name": "Макет", "url": "figma.com/x"})
check("proj link", r.status_code == 200 and r.json().get("links", [])[0]["url"].startswith("https://"))
r = c.patch(f"/api/projects/{cid}", json={"tzText": "ТЗ: интеграция с amoCRM"})
check("proj tz", r.status_code == 200 and r.json().get("tzText") == "ТЗ: интеграция с amoCRM")
# файл (локальный fallback без MinIO) + скачивание
r = c.post(f"/api/projects/{cid}/files", files=[("files", ("tz.pdf", b"%PDF-1.4 test", "application/pdf"))])
check("file upload", r.status_code == 200 and len(r.json().get("items", [])) == 1, r.text)
file_id = r.json()["items"][0]["id"]
dl = c.get(f"/api/projects/{cid}/files/{file_id}/download")
check("file download", dl.status_code == 200 and dl.content == b"%PDF-1.4 test", dl.text[:80])
r = c.delete(f"/api/projects/{cid}/files/{file_id}")
check("file remove", r.status_code == 200)
# локальная карточка
loc = c.post("/api/projects", json={"title": "", "stack": ["Go"]}).json()
check("local card", loc.get("local") is True and loc.get("title") == "")
# напоминание (hold)
r = c.post(f"/api/projects/{cid}/move", json={"stage": "hold"})
check("stage hold", r.status_code == 200)
at = int(time.time() * 1000) + 60000
r = c.post(f"/api/projects/{cid}/reminder", json={"at": at})
check("set reminder", r.status_code == 200 and r.json().get("reminder", {}).get("at") == at, r.text)
rem = c.get("/api/projects/reminders").json()["items"]
check("active reminders", any(x["id"] == cid for x in rem))
# настройки валюты и пересчёт
r = c.patch("/api/settings", json={"targetCurrency": "RUB", "conversionOn": True})
check("settings patch", r.status_code == 200)
rates = c.get("/api/rates").json()
check("rates have USD", "USD" in rates.get("rates", {}))
# mark-col-seen (новый эндпоинт)
r = c.post("/api/leads/mark-col-seen", json={"col": "inbox"})
check("mark col seen", r.status_code == 200)
# ручная полная очистка «Отклонено» (проектные карточки)
r = c.post(f"/api/projects/{cid}/move", json={"stage": "rejected"})
check("stage rejected", r.status_code == 200)
r = c.post("/api/projects/clear-rejected")
check("clear rejected", r.status_code == 200 and r.json().get("cleared", 0) >= 1, r.text)
gone = c.get("/api/projects").json()["items"]
check("rejected gone", all(x["id"] != cid for x in gone))
# ручная полная очистка корзины (лиды дашборда)
d2 = c.post("/api/demo/simulate-lead").json()
c.post(f"/api/leads/{d2['id']}/trash")
trash_items = c.get("/api/leads", params={"col": "trash"}).json()["items"]
check("trash has lead", any(x["id"] == d2["id"] for x in trash_items))
r = c.post("/api/leads/clear-col", json={"col": "trash"})
check("clear trash", r.status_code == 200 and r.json().get("cleared", 0) >= 1, r.text)
trash_items = c.get("/api/leads", params={"col": "trash"}).json()["items"]
check("trash empty", len(trash_items) == 0)
# архив: тот же эндпоинт (сейчас пуст — просто валидируем)
r = c.post("/api/leads/clear-col", json={"col": "archive"})
check("clear archive", r.status_code == 200)
# FTS rebuild
r = c.post("/api/admin/fts/rebuild")
check("fts rebuild", r.status_code == 200 and r.json().get("ready") is True, r.text)
# статика фронтенда из dist
page = c.get("/")
check("spa served", page.status_code == 200 and "<div id=\"app\">" in page.text)
print("E2E OK" if ok else "E2E FAILED")
finally:
server.terminate()
try:
server.wait(timeout=10)
except Exception:
server.kill()
@@ -1,164 +1,164 @@
"""Тест: автономный ML-сервис + очередь входящих (этап1 -> ML -> ИИ) + outbox.
Запускает локальный ML-сервис (mlservice/server.py) на порту 8121, учит его
напрямую по HTTP и проверяет весь контур основного приложения:
* обучение всегда идёт через outbox (даже при выключенном ML в пайплайне);
* /api/ml/predict, /api/ml/candidates, /api/ml/apply;
* очередь: stop-фраза -> удаляется; ML уверен -> карточка сразу на доску.
"""
import os
import subprocess
import sys
import tempfile
import time
import httpx
TMP = tempfile.mkdtemp(prefix="leadradar_pl_")
os.environ["LEADRADAR_DATA"] = os.path.join(TMP, "app")
os.environ["LEADRADAR_ML_URL"] = "http://127.0.0.1:8121"
os.environ["LEADRADAR_DEMO"] = "1"
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ML_DIR = os.path.join(os.path.dirname(BACKEND_DIR), "mlservice")
# ── локальный ML-сервис ───────────────────────────────────────────────────
ml_env = dict(os.environ)
ml_env["ML_DATA"] = os.path.join(TMP, "ml.duckdb")
ml_server = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server:app", "--port", "8121", "--log-level", "warning"],
cwd=ML_DIR,
env=ml_env,
)
try:
for _ in range(50):
try:
if httpx.get("http://127.0.0.1:8121/health", timeout=1).status_code == 200:
break
except Exception:
time.sleep(0.3)
else:
raise SystemExit("ml service did not start")
sys.path.insert(0, BACKEND_DIR)
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
from app.services import pipeline as pl # noqa: E402
ML = "http://127.0.0.1:8121"
SPAM_TEXTS = [
"Заработок на крипте 300 процентов в месяц гарантировано подпишись на канал",
"Инвестируй в наш фонд и получай пассивный доход каждый день без риска",
"Трейдинг бот приносит 1000 долларов в день забери свою прибыль сейчас",
"Бесплатный курс по заработку на бирже забери по ссылке внизу поста",
"Приглашаю в закрытый чат заработка пассивно без вложений начни сегодня",
"Схема быстрого заработка на арбитраже крипты без риска все проверено",
"Купи сигналы на форекс и зарабатывай миллионы пока все спят от нас",
"Пирамида дохода открыла набор новых участников успей вложиться",
]
PY_TEXTS = [
"Нужен Python разработчик для телеграм бота парсера маркетплейсов удаленно",
"Ищем middle python бекенд разработчика на fastapi для стартапа удаленка",
"Задача для python джуна написать скрипт парсинга авито с антидетектом",
"Python разработчик на django проект CRM интеграция с телеграм ботом",
"Нужен python программист для автоматизации отчетов и бота в телеграм",
"Срочно python разработчик aiogram телеграм бот для интернет магазина",
"Python backend для API на fastapi микросервисы postgres kafka",
"Разработчик python на скрапинг каталогов маркетплейсов выгрузка в excel",
"Ищем python специалиста для интеграции с мессенджерами и crm",
"Python разработчик на парсер и бота оплата достойная сразу в лс",
]
FRONT_TEXTS = [
"Frontend разработчик vuejs для корпоративного портала удаленная работа",
"Нужен верстальщик реакт для интернет магазина срочно до конца недели",
"Ищем frontend специалиста vue3 typescript компоненты дизайн система",
"Задача для фронтендера сверстать адаптивный лендинг на nuxtjs",
"Frontend разработчик react nextjs для панели администратора стартапа",
"Верстка писем и лендингов html css для маркетинговых рассылок заказ",
"Нужен vue разработчик доработка фронта для телеграм мини апп",
"Frontend инженер angular для банковского приложения гибрид офис",
]
# учим ML-сервис напрямую (как если бы фоновый воркер отправил outbox)
# — перенесено внутрь with: метки = id колонок, которые создаём через API
with TestClient(app) as client:
assert client.post("/api/auth/login", json={"login": "admin", "password": "admin"}).status_code == 200
client.patch("/api/settings", json={"aiProvider": "ollama", "mlEnabled": True})
# колонок по умолчанию нет — создаём Python и Frontend
py = client.post("/api/boards", json={"name": "Python"}).json()["id"]
fr = client.post("/api/boards", json={"name": "Frontend"}).json()["id"]
for t in PY_TEXTS + FRONT_TEXTS:
httpx.post(ML + "/learn", json={"label": py if t in PY_TEXTS else fr, "text": t})
for t in SPAM_TEXTS:
httpx.post(ML + "/learn", json={"label": "spam", "text": t})
def wait_until(pred, seconds=8):
"""Фоновый воркер разбирает очередь сам — опрашиваем до наступления условия."""
deadline = time.time() + seconds
last = None
while time.time() < deadline:
last = client.post("/api/admin/tick").json()
if pred():
return last
time.sleep(0.5)
raise AssertionError("условие не наступило: %s" % pred())
def leads():
return client.get("/api/leads").json()["items"]
# статус/предсказание через основное приложение
st = client.get("/api/ml/status").json()
assert st["reachable"] and st["service"]["ready"], st
p = client.post("/api/ml/predict", json={"text": "Python backend на fastapi парсер телеграм удаленно"}).json()
assert p["take"] and p["label"] == py, p
print("ml status/predict ok")
# стоп-фраза -> удаляется из очереди, ничего не оседает
pl.enqueue("d1", "Канал А", "", "#333", 1, "Ищу работу на неделю, вот моё резюме и портфолио для отклика", 1_788_000_000_000)
assert pl.queue_len() == 1
wait_until(lambda: pl.queue_len() == 0)
assert len(leads()) == 0
print("stop-phrase drop ok")
# ML уверен -> карточка сразу на доску (без ИИ), спам -> удаление
pl.enqueue("d1", "Канал А", "", "#333", 3, "Python backend на fastapi для стартапа, бот в телеграм, удалённо", 1_788_000_200_000)
pl.enqueue("d1", "Канал А", "", "#333", 4, "Заработок на крипте инвестируй в наш фонд пассивный доход каждый день", 1_788_000_300_000)
wait_until(lambda: pl.queue_len() == 0 and any(l["col"] == py for l in leads()))
card = [l for l in leads() if l["col"] == py and l["sourceMsgId"] == 3]
assert len(card) == 1, leads()
print("ml fast-path ok")
# ручная разметка apply: spam -> карточка в корзину + обучение (outbox)
r = client.post("/api/ml/apply", json={"dialogId": "d1", "msgId": 3, "action": "spam"}).json()
assert r["learned"] is True and r["moved"] == "trash", r
assert client.get("/api/ml/status").json()["stats"]["outbox"] >= 1
# flush -> обучение уехало в ML-сервис
f = client.post("/api/ml/flush").json()
assert f["outbox"] == 0 and f["flushed"] >= 1, f
print("apply + outbox ok")
# обучение идёт всегда, даже если ML выключен в пайплайне
pl.enqueue("d1", "Канал А", "", "#333", 7, "Frontend vue разработка интерфейса компоненты верстка реакт удаленно", 1_788_000_500_000)
wait_until(lambda: any(l["col"] == fr for l in leads()))
lead = [l for l in leads() if l["col"] == fr][0]
boards = client.get("/api/boards").json()
client.patch("/api/settings", json={"mlEnabled": False})
client.post(f"/api/leads/{lead['id']}/move", json={"to": py})
assert client.get("/api/ml/status").json()["stats"]["outbox"] == 1
print("learn-always (ml off) ok")
# кандидаты канала: последние сообщения (после демо-лида их нет в TG -> fallback)
cand = client.post("/api/ml/candidates", json={"dialogId": "d1", "limit": 5}).json()
assert isinstance(cand.get("items"), list)
print("candidates ok")
print("PIPELINE TEST OK")
finally:
ml_server.terminate()
try:
ml_server.wait(timeout=10)
except Exception:
ml_server.kill()
"""Тест: автономный ML-сервис + очередь входящих (этап1 -> ML -> ИИ) + outbox.
Запускает локальный ML-сервис (mlservice/server.py) на порту 8121, учит его
напрямую по HTTP и проверяет весь контур основного приложения:
* обучение всегда идёт через outbox (даже при выключенном ML в пайплайне);
* /api/ml/predict, /api/ml/candidates, /api/ml/apply;
* очередь: stop-фраза -> удаляется; ML уверен -> карточка сразу на доску.
"""
import os
import subprocess
import sys
import tempfile
import time
import httpx
TMP = tempfile.mkdtemp(prefix="leadradar_pl_")
os.environ["LEADRADAR_DATA"] = os.path.join(TMP, "app")
os.environ["LEADRADAR_ML_URL"] = "http://127.0.0.1:8121"
os.environ["LEADRADAR_DEMO"] = "1"
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ML_DIR = os.path.join(os.path.dirname(BACKEND_DIR), "mlservice")
# ── локальный ML-сервис ───────────────────────────────────────────────────
ml_env = dict(os.environ)
ml_env["ML_DATA"] = os.path.join(TMP, "ml.duckdb")
ml_server = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server:app", "--port", "8121", "--log-level", "warning"],
cwd=ML_DIR,
env=ml_env,
)
try:
for _ in range(50):
try:
if httpx.get("http://127.0.0.1:8121/health", timeout=1).status_code == 200:
break
except Exception:
time.sleep(0.3)
else:
raise SystemExit("ml service did not start")
sys.path.insert(0, BACKEND_DIR)
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
from app.services import pipeline as pl # noqa: E402
ML = "http://127.0.0.1:8121"
SPAM_TEXTS = [
"Заработок на крипте 300 процентов в месяц гарантировано подпишись на канал",
"Инвестируй в наш фонд и получай пассивный доход каждый день без риска",
"Трейдинг бот приносит 1000 долларов в день забери свою прибыль сейчас",
"Бесплатный курс по заработку на бирже забери по ссылке внизу поста",
"Приглашаю в закрытый чат заработка пассивно без вложений начни сегодня",
"Схема быстрого заработка на арбитраже крипты без риска все проверено",
"Купи сигналы на форекс и зарабатывай миллионы пока все спят от нас",
"Пирамида дохода открыла набор новых участников успей вложиться",
]
PY_TEXTS = [
"Нужен Python разработчик для телеграм бота парсера маркетплейсов удаленно",
"Ищем middle python бекенд разработчика на fastapi для стартапа удаленка",
"Задача для python джуна написать скрипт парсинга авито с антидетектом",
"Python разработчик на django проект CRM интеграция с телеграм ботом",
"Нужен python программист для автоматизации отчетов и бота в телеграм",
"Срочно python разработчик aiogram телеграм бот для интернет магазина",
"Python backend для API на fastapi микросервисы postgres kafka",
"Разработчик python на скрапинг каталогов маркетплейсов выгрузка в excel",
"Ищем python специалиста для интеграции с мессенджерами и crm",
"Python разработчик на парсер и бота оплата достойная сразу в лс",
]
FRONT_TEXTS = [
"Frontend разработчик vuejs для корпоративного портала удаленная работа",
"Нужен верстальщик реакт для интернет магазина срочно до конца недели",
"Ищем frontend специалиста vue3 typescript компоненты дизайн система",
"Задача для фронтендера сверстать адаптивный лендинг на nuxtjs",
"Frontend разработчик react nextjs для панели администратора стартапа",
"Верстка писем и лендингов html css для маркетинговых рассылок заказ",
"Нужен vue разработчик доработка фронта для телеграм мини апп",
"Frontend инженер angular для банковского приложения гибрид офис",
]
# учим ML-сервис напрямую (как если бы фоновый воркер отправил outbox)
# — перенесено внутрь with: метки = id колонок, которые создаём через API
with TestClient(app) as client:
assert client.post("/api/auth/login", json={"login": "admin", "password": "admin"}).status_code == 200
client.patch("/api/settings", json={"aiProvider": "ollama", "mlEnabled": True})
# колонок по умолчанию нет — создаём Python и Frontend
py = client.post("/api/boards", json={"name": "Python"}).json()["id"]
fr = client.post("/api/boards", json={"name": "Frontend"}).json()["id"]
for t in PY_TEXTS + FRONT_TEXTS:
httpx.post(ML + "/learn", json={"label": py if t in PY_TEXTS else fr, "text": t})
for t in SPAM_TEXTS:
httpx.post(ML + "/learn", json={"label": "spam", "text": t})
def wait_until(pred, seconds=8):
"""Фоновый воркер разбирает очередь сам — опрашиваем до наступления условия."""
deadline = time.time() + seconds
last = None
while time.time() < deadline:
last = client.post("/api/admin/tick").json()
if pred():
return last
time.sleep(0.5)
raise AssertionError("условие не наступило: %s" % pred())
def leads():
return client.get("/api/leads").json()["items"]
# статус/предсказание через основное приложение
st = client.get("/api/ml/status").json()
assert st["reachable"] and st["service"]["ready"], st
p = client.post("/api/ml/predict", json={"text": "Python backend на fastapi парсер телеграм удаленно"}).json()
assert p["take"] and p["label"] == py, p
print("ml status/predict ok")
# стоп-фраза -> удаляется из очереди, ничего не оседает
pl.enqueue("d1", "Канал А", "", "#333", 1, "Ищу работу на неделю, вот моё резюме и портфолио для отклика", 1_788_000_000_000)
assert pl.queue_len() == 1
wait_until(lambda: pl.queue_len() == 0)
assert len(leads()) == 0
print("stop-phrase drop ok")
# ML уверен -> карточка сразу на доску (без ИИ), спам -> удаление
pl.enqueue("d1", "Канал А", "", "#333", 3, "Python backend на fastapi для стартапа, бот в телеграм, удалённо", 1_788_000_200_000)
pl.enqueue("d1", "Канал А", "", "#333", 4, "Заработок на крипте инвестируй в наш фонд пассивный доход каждый день", 1_788_000_300_000)
wait_until(lambda: pl.queue_len() == 0 and any(l["col"] == py for l in leads()))
card = [l for l in leads() if l["col"] == py and l["sourceMsgId"] == 3]
assert len(card) == 1, leads()
print("ml fast-path ok")
# ручная разметка apply: spam -> карточка в корзину + обучение (outbox)
r = client.post("/api/ml/apply", json={"dialogId": "d1", "msgId": 3, "action": "spam"}).json()
assert r["learned"] is True and r["moved"] == "trash", r
assert client.get("/api/ml/status").json()["stats"]["outbox"] >= 1
# flush -> обучение уехало в ML-сервис
f = client.post("/api/ml/flush").json()
assert f["outbox"] == 0 and f["flushed"] >= 1, f
print("apply + outbox ok")
# обучение идёт всегда, даже если ML выключен в пайплайне
pl.enqueue("d1", "Канал А", "", "#333", 7, "Frontend vue разработка интерфейса компоненты верстка реакт удаленно", 1_788_000_500_000)
wait_until(lambda: any(l["col"] == fr for l in leads()))
lead = [l for l in leads() if l["col"] == fr][0]
boards = client.get("/api/boards").json()
client.patch("/api/settings", json={"mlEnabled": False})
client.post(f"/api/leads/{lead['id']}/move", json={"to": py})
assert client.get("/api/ml/status").json()["stats"]["outbox"] == 1
print("learn-always (ml off) ok")
# кандидаты канала: последние сообщения (после демо-лида их нет в TG -> fallback)
cand = client.post("/api/ml/candidates", json={"dialogId": "d1", "limit": 5}).json()
assert isinstance(cand.get("items"), list)
print("candidates ok")
print("PIPELINE TEST OK")
finally:
ml_server.terminate()
try:
ml_server.wait(timeout=10)
except Exception:
ml_server.kill()
@@ -1,9 +1,9 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
duckdb==1.2.1
telethon==1.37.0
httpx==0.28.1
python-multipart==0.0.20
minio==7.2.15
cryptography==44.0.3
qrcode==7.4.2
fastapi==0.115.12
uvicorn[standard]==0.34.2
duckdb==1.2.1
telethon==1.37.0
httpx==0.28.1
python-multipart==0.0.20
minio==7.2.15
cryptography==44.0.3
qrcode==7.4.2
+47 -47
View File
@@ -1,47 +1,47 @@
services:
app:
build:
context: .
dockerfile: backend/Dockerfile
container_name: leadradar
restart: unless-stopped
env_file:
- .env
environment:
# По ТЗ в env — несекретные параметры + MinIO/ключ шифрования (см. договорённости)
LEADRADAR_DATA: /data
LEADRADAR_PORT: "8000"
LEADRADAR_MINIO_ENDPOINT: "minio:9000"
LEADRADAR_ML_URL: "http://ml:8100"
volumes:
- ./data:/data # DuckDB + telegram-сессии + вложения
depends_on:
- minio
- ml
ports:
- "${LEADRADAR_PORT:-8000}:8000"
ml:
build: ./mlservice
container_name: leadradar-ml
restart: unless-stopped
environment:
ML_DATA: /data/ml.duckdb # собственная модель (volume), наружу порт не публикуется
volumes:
- ./data/ml:/data
minio:
image: minio/minio:latest
container_name: leadradar-minio
command: server /data --console-address ":9001"
restart: unless-stopped
env_file:
- .env
environment:
MINIO_ROOT_USER: ${LEADRADAR_MINIO_ACCESS_KEY:-leadradar}
MINIO_ROOT_PASSWORD: ${LEADRADAR_MINIO_SECRET_KEY:-leadradar-secret}
volumes:
- ./data/minio:/data
ports:
- "9000:9000"
- "9001:9001"
services:
app:
build:
context: .
dockerfile: backend/Dockerfile
container_name: leadradar
restart: unless-stopped
env_file:
- .env
environment:
# По ТЗ в env — несекретные параметры + MinIO/ключ шифрования (см. договорённости)
LEADRADAR_DATA: /data
LEADRADAR_PORT: "8000"
LEADRADAR_MINIO_ENDPOINT: "minio:9000"
LEADRADAR_ML_URL: "http://ml:8100"
volumes:
- ./data:/data # DuckDB + telegram-сессии + вложения
depends_on:
- minio
- ml
ports:
- "${LEADRADAR_PORT:-8000}:8000"
ml:
build: ./mlservice
container_name: leadradar-ml
restart: unless-stopped
environment:
ML_DATA: /data/ml.duckdb # собственная модель (volume), наружу порт не публикуется
volumes:
- ./data/ml:/data
minio:
image: minio/minio:latest
container_name: leadradar-minio
command: server /data --console-address ":9001"
restart: unless-stopped
env_file:
- .env
environment:
MINIO_ROOT_USER: ${LEADRADAR_MINIO_ACCESS_KEY:-leadradar}
MINIO_ROOT_PASSWORD: ${LEADRADAR_MINIO_SECRET_KEY:-leadradar-secret}
volumes:
- ./data/minio:/data
ports:
- "9000:9000"
- "9001:9001"
@@ -1,3 +1,3 @@
__pycache__
*.pyc
data
__pycache__
*.pyc
data
+13 -13
View File
@@ -1,13 +1,13 @@
FROM python:3.12-slim
WORKDIR /ml
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY model.py server.py ./
ENV ML_DATA=/data \
PYTHONUNBUFFERED=1
EXPOSE 8100
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8100"]
FROM python:3.12-slim
WORKDIR /ml
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY model.py server.py ./
ENV ML_DATA=/data \
PYTHONUNBUFFERED=1
EXPOSE 8100
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8100"]
+353 -353
View File
@@ -1,353 +1,353 @@
"""Автономная ML-модель (наивный Байес по словам) для LeadRadar.
Хранилище собственный DuckDB-файл (volume). Модель живёт в отдельном
контейнере и общается с основным приложением по HTTP:
* /learn, /learn-batch обучение (всегда, независимо от настроек UI);
* /predict предсказание (используется, только если mlEnabled);
* /status готовность и статистика.
"""
from __future__ import annotations
import os
import re
import threading
import time
import duckdb
# Пороги уверенности: консервативные на старте, смягчаются по мере накопления
# опыта (см. _adaptive_margin) — ML постепенно берёт на себя больше работы.
MIN_TOTAL = 20 # суммарно примеров по всем классам, чтобы модель «включилась»
MIN_WINNER = 6 # минимум примеров у класса-победителя
MIN_WINNER_SPAM = 4
MIN_HITS = 2 # минимум различных терминов, встреченных у победителя
MARGIN = 0.9 # ln-отрыв от второго класса на старте
# Самооценка «справляется ли ML»: каждый реальный (пользовательский) обучающий
# сигнал сверяется с текущим предсказанием модели. В /status отдаётся окно
# последних решений — по нему UI подсказывает, что ИИ можно отключить.
EVAL_WINDOW = 50 # сколько последних решений показываем
EVAL_KEEP = 200 # сколько храним в БД модели
TOKEN_RE = re.compile(r"[a-zа-яё0-9@+.#]+", re.I)
# Ссылки и markdown-ссылки не должны влиять ни на обучение, ни на предсказание:
# иначе модель учит мусор из URL (utm, source, campaign…) и режет по нему заявки.
_LINK_RE = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+|\[[^\]]*\]\([^)\s]+\)")
DATA_PATH = os.getenv("ML_DATA", "./data/ml.duckdb")
_lock = threading.RLock()
_con: duckdb.DuckDBPyConnection | None = None
def _adaptive_margin(total: float) -> float:
"""Отрыв от второго класса, требуемый для самостоятельного решения.
Чем больше примеров ML уже видела, тем ниже порог модель набирается
опыта и постепенно заменяет ИИ на типовых сообщениях. На старте порог
консервативный (0.9), после ~400 примеров 0.35.
"""
if total >= 400:
return 0.35
if total >= 150:
return 0.5
if total >= 60:
return 0.7
return MARGIN
def _db() -> duckdb.DuckDBPyConnection:
global _con
with _lock:
if _con is None:
os.makedirs(os.path.dirname(DATA_PATH) or ".", exist_ok=True)
_con = duckdb.connect(DATA_PATH)
_con.execute(
"CREATE TABLE IF NOT EXISTS classes (label VARCHAR PRIMARY KEY, n DOUBLE NOT NULL DEFAULT 0, updated_at BIGINT)"
)
_con.execute(
"CREATE TABLE IF NOT EXISTS terms (label VARCHAR NOT NULL, term VARCHAR NOT NULL, count DOUBLE NOT NULL DEFAULT 0, "
"PRIMARY KEY (label, term))"
)
_con.execute(
"CREATE TABLE IF NOT EXISTS eval_log (created_at BIGINT NOT NULL, "
"expected VARCHAR NOT NULL, predicted VARCHAR NOT NULL DEFAULT '', correct BOOLEAN NOT NULL)"
)
return _con
def tokenize(text: str) -> list[str]:
text = _LINK_RE.sub(" ", str(text or ""))
words = [w.lower() for w in TOKEN_RE.findall(text)]
out: list[str] = []
for w in words:
if len(w) >= 3:
out.append(w)
if len(w) >= 6:
out.append("~" + w[:4])
return out
def totals() -> dict[str, float]:
with _lock:
rows = _db().execute("SELECT label, n FROM classes WHERE n > 0").fetchall()
return {r[0]: float(r[1]) for r in rows}
def ready() -> bool:
t = totals()
total = sum(t.values())
if total < MIN_TOTAL:
return False
non_spam = {k: v for k, v in t.items() if k != "spam"}
return t.get("spam", 0) >= MIN_WINNER_SPAM and sum(non_spam.values()) >= MIN_WINNER
def _upsert_one(db, label: str, text: str, delta: float) -> None:
"""Обновление одного обучающего примера (вызывается внутри транзакции).
Термины пишутся пакетно (executemany), а не по одному INSERT на большой
модели построчная вставка занимает десятки секунд и блокирует /status и
/predict, из-за чего сервис «выглядит недоступным».
"""
label = str(label)
if not text or not label:
return
now_ms = int(time.time() * 1000)
db.execute(
"INSERT INTO classes(label, n, updated_at) VALUES (?, ?, ?) "
"ON CONFLICT(label) DO UPDATE SET n = classes.n + ?, updated_at = ?",
[label, delta, now_ms, delta, now_ms],
)
terms = tokenize(text)
if terms:
db.executemany(
"INSERT INTO terms(label, term, count) VALUES (?, ?, ?) "
"ON CONFLICT(label, term) DO UPDATE SET count = terms.count + ?",
[(label, t, delta, delta) for t in terms],
)
if delta < 0:
db.execute("DELETE FROM terms WHERE label = ? AND count <= 0", [label])
db.execute("DELETE FROM classes WHERE n <= 0", [])
def learn(label: str, text: str, delta: float = 1.0) -> None:
"""Увеличить вес класса/терминов (delta>0) или «разучить» (delta<0)."""
_maybe_eval(label, text, delta)
with _lock:
db = _db()
db.execute("BEGIN")
try:
_upsert_one(db, label, text, delta)
db.execute("COMMIT")
except Exception:
db.execute("ROLLBACK")
raise
def learn_batch(items: list[dict]) -> int:
"""Пакетное обучение: одна транзакция + пакетные вставки терминов."""
if not items:
return 0
# самооценка по реальным действиям пользователя — до применения примеров
for it in items:
_maybe_eval(
str(it.get("label") or ""),
str(it.get("text") or ""),
float(it.get("delta", 1.0)),
)
with _lock:
db = _db()
db.execute("BEGIN")
try:
for it in items:
_upsert_one(
db,
str(it.get("label") or ""),
str(it.get("text") or ""),
float(it.get("delta", 1.0)),
)
db.execute("COMMIT")
except Exception:
db.execute("ROLLBACK")
raise
return len(items)
# Классы типа заявки (ML учит их по ИИ-решениям/действиям, чтобы со временем
# сам определять «занятость vs разовая сделка» без вызова ИИ)
TYPE_HIRE = "t:hire"
TYPE_ORDER = "t:order"
# минимум примеров типа, чтобы ML начал выдавать тип
MIN_TYPE_WINNER = 4
def predict(text: str) -> dict:
tokens = tokenize(text)
t = totals()
if not t or not tokens:
return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": ready(), "type": None}
# Модель «включается» только с опытом: пока примеров мало (ready=False),
# она ничего не решает и не может ошибочно удалить заявку как спам.
if not ready():
return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": False, "type": None}
total = sum(t.values())
type_classes = {k: v for k, v in t.items() if k in (TYPE_HIRE, TYPE_ORDER)}
with _lock:
db = _db()
scores: dict[str, float] = {}
hits: dict[str, int] = {}
for label, n in t.items():
rows = db.execute("SELECT term, count FROM terms WHERE label = ? AND count > 0", [label]).fetchall()
weights = {r[0]: float(r[1]) for r in rows}
score = 0.0
hit = 0
for term in set(tokens):
w = weights.get(term)
if w:
score += 1.0 if w < 1 else (1.0 + (w - 1.0) / (w + 1.0))
hit += 1
if hit:
scores[label] = score
hits[label] = hit
margin = _adaptive_margin(total)
prior = {label: n / total for label, n in t.items()}
# ── тип заявки: только t:hire / t:order ───────────────────────────────
type_decision = None
if len(type_classes) >= 2:
ranked_t = sorted(
((k, scores.get(k, 0.0)) for k in type_classes),
key=lambda kv: -kv[1],
)
bt_label, bt_score = ranked_t[0]
st = ranked_t[1] if len(ranked_t) > 1 else None
bt_total = bt_score + 3.0 * prior.get(bt_label, 0.0)
st_total = (st[1] + 3.0 * prior.get(st[0], 0.0)) if st else 0.0
if (
bt_score > 0
and t.get(bt_label, 0) >= MIN_TYPE_WINNER
and (bt_total - st_total) >= margin
):
type_decision = {
"take": True,
"label": "hire" if bt_label == TYPE_HIRE else "order",
"value": bt_label,
"margin": round(margin, 2),
}
# ── колонка/спам: без t:* классов ─────────────────────────────────────
regular = {k: v for k, v in t.items() if not k.startswith("t:")}
if not regular or not scores:
return {
"take": False, "label": None, "scores": {}, "hits": 0, "ready": ready(),
"type": type_decision,
}
ranked = sorted(((k, scores[k]) for k in regular if k in scores), key=lambda kv: -kv[1])
if not ranked:
return {
"take": False, "label": None, "scores": {}, "hits": 0, "ready": ready(),
"type": type_decision,
}
best_label, best_score = ranked[0]
second = ranked[1] if len(ranked) > 1 else None
best_total = best_score + 3.0 * prior.get(best_label, 0.0)
second_total = (second[1] + 3.0 * prior.get(second[0], 0.0)) if second else 0.0
is_spam = best_label == "spam"
min_winner = MIN_WINNER_SPAM if is_spam else MIN_WINNER
take = (
t.get(best_label, 0) >= min_winner
and hits[best_label] >= MIN_HITS
and (best_total - second_total) >= margin
)
# термины, которые модель «узнала» в тексте у класса-победителя:
# подсказка для структурирования карточки (стек/услуги/материалы) без ИИ
matched_terms: list[str] = []
if take and best_label != "spam":
with _lock:
db = _db()
rows = db.execute(
"SELECT term, count FROM terms WHERE label = ? AND count > 0", [best_label]
).fetchall()
weights = {r[0]: float(r[1]) for r in rows}
seen = set()
for term in tokens:
if term.startswith("~"):
continue # хвостовой токен (~pyth) — не нужен как подсказка стека
if term in weights and term not in seen and term not in best_label:
seen.add(term)
matched_terms.append(term)
matched_terms = sorted(seen, key=lambda x: -weights.get(x, 0))[:8]
out_scores = {label: round(s, 3) for label, s in sorted(scores.items(), key=lambda kv: -kv[1])[:5]}
return {
"take": bool(take),
"label": best_label if take else None,
"scores": out_scores,
"hits": hits[best_label],
"ready": ready(),
"margin": round(margin, 2),
"terms": matched_terms,
"type": type_decision,
}
def _maybe_eval(label: str, text: str, delta: float) -> None:
"""Самооценка перед обучением на реальном действии пользователя.
delta=1.0 пользовательское действие (перенос на доску, корзина, возврат,
ручная разметка): это «правильный ответ по карточке». Если модель уже
включена (ready) и уверенно взяла решение сверяем его с действием:
совпало в копилку верных, нет в копилку ошибок. Гипотезы ИИ
(delta<1), типы t:* и «разучивание» (delta<0) не оцениваются.
"""
if delta != 1.0 or not (text or "").strip() or str(label or "").startswith("t:"):
return
if not ready():
return
pr = predict(text)
if not pr.get("take") or not pr.get("label"):
return # модель не уверена — такое сообщение ушло бы ИИ, не считаем ошибкой
correct = bool(pr["label"] == label)
with _lock:
db = _db()
db.execute(
"INSERT INTO eval_log(created_at, expected, predicted, correct) VALUES (?, ?, ?, ?)",
[int(time.time() * 1000), str(label), str(pr["label"]), correct],
)
db.execute(
f"DELETE FROM eval_log WHERE created_at < "
f"(SELECT created_at FROM eval_log ORDER BY created_at DESC LIMIT 1 OFFSET {EVAL_KEEP})"
)
def status() -> dict:
t = totals()
# окно самооценки: последние решения модели, подтверждённые действиями
# пользователя (перенос на доску, корзина, возврат, ручная разметка)
ev = {"count": 0, "correct": 0, "accuracy": 0.0}
with _lock:
rows = _db().execute(
"SELECT count(*) AS c, coalesce(sum(CASE WHEN correct THEN 1 ELSE 0 END), 0) AS ok "
"FROM (SELECT correct FROM eval_log ORDER BY created_at DESC LIMIT ?)",
[EVAL_WINDOW],
).fetchone()
if rows and rows[0]:
ev["count"] = int(rows[0])
ev["correct"] = int(rows[1])
ev["accuracy"] = round(ev["correct"] / ev["count"], 3) if ev["count"] else 0.0
return {
"ready": ready(),
"classes": {label: round(n, 2) for label, n in sorted(t.items(), key=lambda kv: -kv[1])},
"learned": int(sum(t.values())),
"eval": ev,
}
def reset() -> None:
with _lock:
db = _db()
db.execute("DELETE FROM terms", [])
db.execute("DELETE FROM classes", [])
db.execute("DELETE FROM eval_log", [])
"""Автономная ML-модель (наивный Байес по словам) для LeadRadar.
Хранилище собственный DuckDB-файл (volume). Модель живёт в отдельном
контейнере и общается с основным приложением по HTTP:
* /learn, /learn-batch обучение (всегда, независимо от настроек UI);
* /predict предсказание (используется, только если mlEnabled);
* /status готовность и статистика.
"""
from __future__ import annotations
import os
import re
import threading
import time
import duckdb
# Пороги уверенности: консервативные на старте, смягчаются по мере накопления
# опыта (см. _adaptive_margin) — ML постепенно берёт на себя больше работы.
MIN_TOTAL = 20 # суммарно примеров по всем классам, чтобы модель «включилась»
MIN_WINNER = 6 # минимум примеров у класса-победителя
MIN_WINNER_SPAM = 4
MIN_HITS = 2 # минимум различных терминов, встреченных у победителя
MARGIN = 0.9 # ln-отрыв от второго класса на старте
# Самооценка «справляется ли ML»: каждый реальный (пользовательский) обучающий
# сигнал сверяется с текущим предсказанием модели. В /status отдаётся окно
# последних решений — по нему UI подсказывает, что ИИ можно отключить.
EVAL_WINDOW = 50 # сколько последних решений показываем
EVAL_KEEP = 200 # сколько храним в БД модели
TOKEN_RE = re.compile(r"[a-zа-яё0-9@+.#]+", re.I)
# Ссылки и markdown-ссылки не должны влиять ни на обучение, ни на предсказание:
# иначе модель учит мусор из URL (utm, source, campaign…) и режет по нему заявки.
_LINK_RE = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+|\[[^\]]*\]\([^)\s]+\)")
DATA_PATH = os.getenv("ML_DATA", "./data/ml.duckdb")
_lock = threading.RLock()
_con: duckdb.DuckDBPyConnection | None = None
def _adaptive_margin(total: float) -> float:
"""Отрыв от второго класса, требуемый для самостоятельного решения.
Чем больше примеров ML уже видела, тем ниже порог модель набирается
опыта и постепенно заменяет ИИ на типовых сообщениях. На старте порог
консервативный (0.9), после ~400 примеров 0.35.
"""
if total >= 400:
return 0.35
if total >= 150:
return 0.5
if total >= 60:
return 0.7
return MARGIN
def _db() -> duckdb.DuckDBPyConnection:
global _con
with _lock:
if _con is None:
os.makedirs(os.path.dirname(DATA_PATH) or ".", exist_ok=True)
_con = duckdb.connect(DATA_PATH)
_con.execute(
"CREATE TABLE IF NOT EXISTS classes (label VARCHAR PRIMARY KEY, n DOUBLE NOT NULL DEFAULT 0, updated_at BIGINT)"
)
_con.execute(
"CREATE TABLE IF NOT EXISTS terms (label VARCHAR NOT NULL, term VARCHAR NOT NULL, count DOUBLE NOT NULL DEFAULT 0, "
"PRIMARY KEY (label, term))"
)
_con.execute(
"CREATE TABLE IF NOT EXISTS eval_log (created_at BIGINT NOT NULL, "
"expected VARCHAR NOT NULL, predicted VARCHAR NOT NULL DEFAULT '', correct BOOLEAN NOT NULL)"
)
return _con
def tokenize(text: str) -> list[str]:
text = _LINK_RE.sub(" ", str(text or ""))
words = [w.lower() for w in TOKEN_RE.findall(text)]
out: list[str] = []
for w in words:
if len(w) >= 3:
out.append(w)
if len(w) >= 6:
out.append("~" + w[:4])
return out
def totals() -> dict[str, float]:
with _lock:
rows = _db().execute("SELECT label, n FROM classes WHERE n > 0").fetchall()
return {r[0]: float(r[1]) for r in rows}
def ready() -> bool:
t = totals()
total = sum(t.values())
if total < MIN_TOTAL:
return False
non_spam = {k: v for k, v in t.items() if k != "spam"}
return t.get("spam", 0) >= MIN_WINNER_SPAM and sum(non_spam.values()) >= MIN_WINNER
def _upsert_one(db, label: str, text: str, delta: float) -> None:
"""Обновление одного обучающего примера (вызывается внутри транзакции).
Термины пишутся пакетно (executemany), а не по одному INSERT на большой
модели построчная вставка занимает десятки секунд и блокирует /status и
/predict, из-за чего сервис «выглядит недоступным».
"""
label = str(label)
if not text or not label:
return
now_ms = int(time.time() * 1000)
db.execute(
"INSERT INTO classes(label, n, updated_at) VALUES (?, ?, ?) "
"ON CONFLICT(label) DO UPDATE SET n = classes.n + ?, updated_at = ?",
[label, delta, now_ms, delta, now_ms],
)
terms = tokenize(text)
if terms:
db.executemany(
"INSERT INTO terms(label, term, count) VALUES (?, ?, ?) "
"ON CONFLICT(label, term) DO UPDATE SET count = terms.count + ?",
[(label, t, delta, delta) for t in terms],
)
if delta < 0:
db.execute("DELETE FROM terms WHERE label = ? AND count <= 0", [label])
db.execute("DELETE FROM classes WHERE n <= 0", [])
def learn(label: str, text: str, delta: float = 1.0) -> None:
"""Увеличить вес класса/терминов (delta>0) или «разучить» (delta<0)."""
_maybe_eval(label, text, delta)
with _lock:
db = _db()
db.execute("BEGIN")
try:
_upsert_one(db, label, text, delta)
db.execute("COMMIT")
except Exception:
db.execute("ROLLBACK")
raise
def learn_batch(items: list[dict]) -> int:
"""Пакетное обучение: одна транзакция + пакетные вставки терминов."""
if not items:
return 0
# самооценка по реальным действиям пользователя — до применения примеров
for it in items:
_maybe_eval(
str(it.get("label") or ""),
str(it.get("text") or ""),
float(it.get("delta", 1.0)),
)
with _lock:
db = _db()
db.execute("BEGIN")
try:
for it in items:
_upsert_one(
db,
str(it.get("label") or ""),
str(it.get("text") or ""),
float(it.get("delta", 1.0)),
)
db.execute("COMMIT")
except Exception:
db.execute("ROLLBACK")
raise
return len(items)
# Классы типа заявки (ML учит их по ИИ-решениям/действиям, чтобы со временем
# сам определять «занятость vs разовая сделка» без вызова ИИ)
TYPE_HIRE = "t:hire"
TYPE_ORDER = "t:order"
# минимум примеров типа, чтобы ML начал выдавать тип
MIN_TYPE_WINNER = 4
def predict(text: str) -> dict:
tokens = tokenize(text)
t = totals()
if not t or not tokens:
return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": ready(), "type": None}
# Модель «включается» только с опытом: пока примеров мало (ready=False),
# она ничего не решает и не может ошибочно удалить заявку как спам.
if not ready():
return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": False, "type": None}
total = sum(t.values())
type_classes = {k: v for k, v in t.items() if k in (TYPE_HIRE, TYPE_ORDER)}
with _lock:
db = _db()
scores: dict[str, float] = {}
hits: dict[str, int] = {}
for label, n in t.items():
rows = db.execute("SELECT term, count FROM terms WHERE label = ? AND count > 0", [label]).fetchall()
weights = {r[0]: float(r[1]) for r in rows}
score = 0.0
hit = 0
for term in set(tokens):
w = weights.get(term)
if w:
score += 1.0 if w < 1 else (1.0 + (w - 1.0) / (w + 1.0))
hit += 1
if hit:
scores[label] = score
hits[label] = hit
margin = _adaptive_margin(total)
prior = {label: n / total for label, n in t.items()}
# ── тип заявки: только t:hire / t:order ───────────────────────────────
type_decision = None
if len(type_classes) >= 2:
ranked_t = sorted(
((k, scores.get(k, 0.0)) for k in type_classes),
key=lambda kv: -kv[1],
)
bt_label, bt_score = ranked_t[0]
st = ranked_t[1] if len(ranked_t) > 1 else None
bt_total = bt_score + 3.0 * prior.get(bt_label, 0.0)
st_total = (st[1] + 3.0 * prior.get(st[0], 0.0)) if st else 0.0
if (
bt_score > 0
and t.get(bt_label, 0) >= MIN_TYPE_WINNER
and (bt_total - st_total) >= margin
):
type_decision = {
"take": True,
"label": "hire" if bt_label == TYPE_HIRE else "order",
"value": bt_label,
"margin": round(margin, 2),
}
# ── колонка/спам: без t:* классов ─────────────────────────────────────
regular = {k: v for k, v in t.items() if not k.startswith("t:")}
if not regular or not scores:
return {
"take": False, "label": None, "scores": {}, "hits": 0, "ready": ready(),
"type": type_decision,
}
ranked = sorted(((k, scores[k]) for k in regular if k in scores), key=lambda kv: -kv[1])
if not ranked:
return {
"take": False, "label": None, "scores": {}, "hits": 0, "ready": ready(),
"type": type_decision,
}
best_label, best_score = ranked[0]
second = ranked[1] if len(ranked) > 1 else None
best_total = best_score + 3.0 * prior.get(best_label, 0.0)
second_total = (second[1] + 3.0 * prior.get(second[0], 0.0)) if second else 0.0
is_spam = best_label == "spam"
min_winner = MIN_WINNER_SPAM if is_spam else MIN_WINNER
take = (
t.get(best_label, 0) >= min_winner
and hits[best_label] >= MIN_HITS
and (best_total - second_total) >= margin
)
# термины, которые модель «узнала» в тексте у класса-победителя:
# подсказка для структурирования карточки (стек/услуги/материалы) без ИИ
matched_terms: list[str] = []
if take and best_label != "spam":
with _lock:
db = _db()
rows = db.execute(
"SELECT term, count FROM terms WHERE label = ? AND count > 0", [best_label]
).fetchall()
weights = {r[0]: float(r[1]) for r in rows}
seen = set()
for term in tokens:
if term.startswith("~"):
continue # хвостовой токен (~pyth) — не нужен как подсказка стека
if term in weights and term not in seen and term not in best_label:
seen.add(term)
matched_terms.append(term)
matched_terms = sorted(seen, key=lambda x: -weights.get(x, 0))[:8]
out_scores = {label: round(s, 3) for label, s in sorted(scores.items(), key=lambda kv: -kv[1])[:5]}
return {
"take": bool(take),
"label": best_label if take else None,
"scores": out_scores,
"hits": hits[best_label],
"ready": ready(),
"margin": round(margin, 2),
"terms": matched_terms,
"type": type_decision,
}
def _maybe_eval(label: str, text: str, delta: float) -> None:
"""Самооценка перед обучением на реальном действии пользователя.
delta=1.0 пользовательское действие (перенос на доску, корзина, возврат,
ручная разметка): это «правильный ответ по карточке». Если модель уже
включена (ready) и уверенно взяла решение сверяем его с действием:
совпало в копилку верных, нет в копилку ошибок. Гипотезы ИИ
(delta<1), типы t:* и «разучивание» (delta<0) не оцениваются.
"""
if delta != 1.0 or not (text or "").strip() or str(label or "").startswith("t:"):
return
if not ready():
return
pr = predict(text)
if not pr.get("take") or not pr.get("label"):
return # модель не уверена — такое сообщение ушло бы ИИ, не считаем ошибкой
correct = bool(pr["label"] == label)
with _lock:
db = _db()
db.execute(
"INSERT INTO eval_log(created_at, expected, predicted, correct) VALUES (?, ?, ?, ?)",
[int(time.time() * 1000), str(label), str(pr["label"]), correct],
)
db.execute(
f"DELETE FROM eval_log WHERE created_at < "
f"(SELECT created_at FROM eval_log ORDER BY created_at DESC LIMIT 1 OFFSET {EVAL_KEEP})"
)
def status() -> dict:
t = totals()
# окно самооценки: последние решения модели, подтверждённые действиями
# пользователя (перенос на доску, корзина, возврат, ручная разметка)
ev = {"count": 0, "correct": 0, "accuracy": 0.0}
with _lock:
rows = _db().execute(
"SELECT count(*) AS c, coalesce(sum(CASE WHEN correct THEN 1 ELSE 0 END), 0) AS ok "
"FROM (SELECT correct FROM eval_log ORDER BY created_at DESC LIMIT ?)",
[EVAL_WINDOW],
).fetchone()
if rows and rows[0]:
ev["count"] = int(rows[0])
ev["correct"] = int(rows[1])
ev["accuracy"] = round(ev["correct"] / ev["count"], 3) if ev["count"] else 0.0
return {
"ready": ready(),
"classes": {label: round(n, 2) for label, n in sorted(t.items(), key=lambda kv: -kv[1])},
"learned": int(sum(t.values())),
"eval": ev,
}
def reset() -> None:
with _lock:
db = _db()
db.execute("DELETE FROM terms", [])
db.execute("DELETE FROM classes", [])
db.execute("DELETE FROM eval_log", [])
@@ -1,3 +1,3 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
duckdb==1.2.1
fastapi==0.115.12
uvicorn[standard]==0.34.2
duckdb==1.2.1
+70 -70
View File
@@ -1,70 +1,70 @@
"""HTTP-API автономного ML-сервиса LeadRadar.
Запуск: uvicorn server:app --host 0.0.0.0 --port 8100
(в compose сервис `ml`, наружу порт не публикуется).
"""
from __future__ import annotations
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import model as m
app = FastAPI(title="LeadRadar ML", version="1.0.0")
class LearnItem(BaseModel):
text: str
label: str
delta: float = 1.0
class PredictBody(BaseModel):
text: str
class BatchBody(BaseModel):
items: list[LearnItem]
@app.get("/health")
def health() -> dict:
return {"ok": True, "service": "leadradar-ml"}
@app.get("/status")
def status() -> dict:
return m.status()
@app.post("/learn")
def learn(body: LearnItem) -> dict:
if not body.text.strip() or not body.label.strip():
raise HTTPException(400, "text и label обязательны")
m.learn(body.label, body.text, body.delta)
return {"ok": True}
@app.post("/learn-batch")
def learn_batch(body: BatchBody) -> dict:
m.learn_batch([it.model_dump() for it in body.items])
return {"ok": True, "learned": len(body.items)}
@app.post("/predict")
def predict(body: PredictBody) -> dict:
if not body.text.strip():
raise HTTPException(400, "text обязателен")
return m.predict(body.text)
@app.post("/reset")
def reset() -> dict:
m.reset()
return {"ok": True}
@app.on_event("startup")
def _startup() -> None:
# прогреваем соединение с БД модели
m.status()
"""HTTP-API автономного ML-сервиса LeadRadar.
Запуск: uvicorn server:app --host 0.0.0.0 --port 8100
(в compose сервис `ml`, наружу порт не публикуется).
"""
from __future__ import annotations
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import model as m
app = FastAPI(title="LeadRadar ML", version="1.0.0")
class LearnItem(BaseModel):
text: str
label: str
delta: float = 1.0
class PredictBody(BaseModel):
text: str
class BatchBody(BaseModel):
items: list[LearnItem]
@app.get("/health")
def health() -> dict:
return {"ok": True, "service": "leadradar-ml"}
@app.get("/status")
def status() -> dict:
return m.status()
@app.post("/learn")
def learn(body: LearnItem) -> dict:
if not body.text.strip() or not body.label.strip():
raise HTTPException(400, "text и label обязательны")
m.learn(body.label, body.text, body.delta)
return {"ok": True}
@app.post("/learn-batch")
def learn_batch(body: BatchBody) -> dict:
m.learn_batch([it.model_dump() for it in body.items])
return {"ok": True, "learned": len(body.items)}
@app.post("/predict")
def predict(body: PredictBody) -> dict:
if not body.text.strip():
raise HTTPException(400, "text обязателен")
return m.predict(body.text)
@app.post("/reset")
def reset() -> dict:
m.reset()
return {"ok": True}
@app.on_event("startup")
def _startup() -> None:
# прогреваем соединение с БД модели
m.status()