Compare commits
42
Commits
main
..
b745c324e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b745c324e3 | ||
|
|
93e9100ae4 | ||
|
|
206d61068a | ||
|
|
355723f4ad | ||
|
|
fd64c11441 | ||
|
|
a7e38846d7 | ||
|
|
713d554dc2 | ||
|
|
39c9bdc1b7 | ||
|
|
dde86fd63c | ||
|
|
f4737b9797 | ||
|
|
e62dbfafba | ||
|
|
ea21dc54c6 | ||
|
|
0f79bd4e70 | ||
|
|
e15be9de2e | ||
|
|
be457d7286 | ||
|
|
1d7c9980ba | ||
|
|
38561528ab | ||
|
|
d2b716bbf4 | ||
|
|
000d433438 | ||
|
|
db4455a554 | ||
|
|
c06ee1cf79 | ||
|
|
2b25915790 | ||
|
|
6d074834a7 | ||
|
|
3327bf48b0 | ||
|
|
ebc761d40e | ||
|
|
770dba7257 | ||
|
|
e1aebd1d78 | ||
|
|
bf4c021ca9 | ||
|
|
79c931d88e | ||
|
|
b053d58335 | ||
|
|
5f5538d33b | ||
|
|
1e118e9bba | ||
|
|
e8b9fab860 | ||
|
|
5bfa92a4ac | ||
|
|
e3a2692507 | ||
|
|
492950bdd0 | ||
|
|
410194b0cb | ||
|
|
cd0b3b606b | ||
|
|
31c434ed93 | ||
|
|
8a7d7f2a7a | ||
|
|
413eaac48c | ||
|
|
9e07568ddd |
@@ -1,79 +0,0 @@
|
|||||||
name: ci
|
|
||||||
|
|
||||||
# Прогоняем CI в двух случаях:
|
|
||||||
# 1) МР: создан, переоткрыт или в ветку МР запушены новые коммиты (synchronize).
|
|
||||||
# 2) Влитие в main (пуш в main — в т.ч. мерж МР).
|
|
||||||
# Обычные коммиты в ветки без открытого МР CI не запускают.
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Новый прогон того же рефа (та же ветка МР / тот же main) отменяет незавершённый предыдущий:
|
|
||||||
# коммит-А стартовал, через пару минут коммит-Б — прогон А отменяется, если ещё не закончил.
|
|
||||||
# Завершённые (успех/фейл) прогоны не трогаются.
|
|
||||||
concurrency:
|
|
||||||
group: ci-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: .NET 10
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: '10.0.x'
|
|
||||||
|
|
||||||
- name: Node 20
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: src/frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Сборка 5 решений
|
|
||||||
run: |
|
|
||||||
dotnet build src/core/Deal.sln --nologo -v q
|
|
||||||
dotnet build src/telegram-service/Deal.Telegram.sln --nologo -v q
|
|
||||||
dotnet build src/ai-service/Deal.Ai.sln --nologo -v q
|
|
||||||
dotnet build src/ml-service/Deal.Ml.sln --nologo -v q
|
|
||||||
dotnet build src/storage-service/Deal.Storage.sln --nologo -v q
|
|
||||||
|
|
||||||
- name: Тесты
|
|
||||||
run: |
|
|
||||||
dotnet test src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj --nologo
|
|
||||||
dotnet test src/telegram-service/Deal.Telegram.Tests/Deal.Telegram.Tests.csproj --nologo --no-build
|
|
||||||
dotnet test src/ai-service/Deal.Ai.Tests/Deal.Ai.Tests.csproj --nologo --no-build
|
|
||||||
dotnet test src/ml-service/Deal.Ml.Tests/Deal.Ml.Tests.csproj --nologo --no-build
|
|
||||||
dotnet test src/storage-service/Deal.Storage.Tests/Deal.Storage.Tests.csproj --nologo --no-build
|
|
||||||
|
|
||||||
- name: Скан уязвимых NuGet-зависимостей
|
|
||||||
run: |
|
|
||||||
fail=0
|
|
||||||
for sln in src/core/Deal.sln src/telegram-service/Deal.Telegram.sln src/ai-service/Deal.Ai.sln src/ml-service/Deal.Ml.sln src/storage-service/Deal.Storage.sln; do
|
|
||||||
echo "-- $sln"
|
|
||||||
OUTPUT=$(dotnet list "$sln" package --vulnerable --include-transitive 2>&1 || true)
|
|
||||||
if printf '%s' "$OUTPUT" | grep -qi "has the following vulnerable"; then
|
|
||||||
printf '%s\n' "$OUTPUT"
|
|
||||||
echo "ОШИБКА: уязвимые зависимости в $sln"
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
exit $fail
|
|
||||||
|
|
||||||
- name: Фронтенд (сборка + линтер i18n)
|
|
||||||
run: |
|
|
||||||
cd src/frontend
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
npm run lint:i18n
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: .NET 10
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
|
- name: Node 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: src/frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: CI (сборка, тесты, скан уязвимостей, фронт)
|
||||||
|
run: sh scripts/ci.sh
|
||||||
-11
@@ -34,14 +34,3 @@ src/core/Deal.Api/data/
|
|||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
# Gitea runner: секреты и локальное состояние
|
|
||||||
!deploy/gitea-runner/.env.example
|
|
||||||
deploy/gitea-runner/.env
|
|
||||||
deploy/gitea-runner/data/
|
|
||||||
|
|
||||||
# Служебные скрипты не входят в репозиторий (правило: в репе только код) — живут только локально
|
|
||||||
scripts/
|
|
||||||
|
|
||||||
# Локальный клон вики
|
|
||||||
.wiki-clone/
|
|
||||||
|
|||||||
@@ -2,22 +2,19 @@
|
|||||||
|
|
||||||
SaaS-мониторинг Telegram: реальные заказы и клиенты вместо рекламы и дубликатов.
|
SaaS-мониторинг Telegram: реальные заказы и клиенты вместо рекламы и дубликатов.
|
||||||
|
|
||||||
## Документация
|
## Документация (актуальное)
|
||||||
|
|
||||||
Актуальные версии для людей — в [вики проекта](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Home);
|
- **ТЗ**: `docs/spec/ТЗ-дейл-новая-архитектура.md`
|
||||||
в `docs/` — зеркала для работы агентов (дублирование разрешено и нужно).
|
- **Инструкция пользователя**: `docs/user-guide/Инструкция-пользователя-Дейл.md`
|
||||||
|
- **Техническая документация** (стек, развёртывание, эксплуатация): `docs/technical/Техническая-документация-Дейл.md`
|
||||||
- **ТЗ**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/ТЗ](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/ТЗ)
|
- **Карта API**: `docs/api/api-map.md`
|
||||||
- **Инструкция пользователя**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Инструкция-пользователя](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Инструкция-пользователя)
|
- **Статус и борд состояния**: `docs/superpowers/STATUS.md`
|
||||||
- **Техническая документация** (стек, развёртывание, эксплуатация): [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Техническая-документация](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Техническая-документация)
|
- **Бэклог (техдолг и отложенное)**: `backlog.md`
|
||||||
- **Карта API**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/API-карта](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/API-карта)
|
- **Код-стайл (полный свод правил)**: `docs/spec/Код-стайл-Дейл.md`
|
||||||
- **Статус и борд состояния**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Статус-разработки](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Статус-разработки)
|
|
||||||
- **Бэклог (техдолг и отложенное)**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Бэклог](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Бэклог)
|
|
||||||
- **Код-стайл (полный свод правил)**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Код-стайл](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Код-стайл)
|
|
||||||
|
|
||||||
Исходники: `src/` (`core`, `frontend`, `ai-service`, `ml-service`, `telegram-service`, `contracts`, `grpc-hosting`).
|
Исходники: `src/` (`core`, `frontend`, `ai-service`, `ml-service`, `telegram-service`, `contracts`, `grpc-hosting`).
|
||||||
Планы и ledgers этапов — в вики (раздел «Историческое» в Sidebar).
|
Планы и ledgers этапов: `docs/superpowers/plans/`, `.superpowers/sdd/`.
|
||||||
Легаси-прототип LeadRadar и служебные скрипты в репозиторий не входят — живут локально, вне кода.
|
Архив: `archive/leadradar-legacy/` (прототип), `archive/style-guide-original/` (исходный `Стиль_кода.docx`).
|
||||||
|
|
||||||
## Запуск dev-окружения
|
## Запуск dev-окружения
|
||||||
|
|
||||||
@@ -34,8 +31,5 @@ cd src/frontend && npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
Вход: `admin` / `admin`. Оператор-консоль: `http://localhost:5173/#/operator` (`operator` / `operator`).
|
Вход: `admin` / `admin`. Оператор-консоль: `http://localhost:5173/#/operator` (`operator` / `operator`).
|
||||||
|
Сквозная проверка стека: `sh scripts/dev-smoke.sh`. Сборка: `sh scripts/build.sh`; тесты: `sh scripts/test.sh`;
|
||||||
Сборка: `dotnet build src/core/Deal.sln` (и аналогично для sln в `src/telegram-service`, `src/ai-service`,
|
полный CI-прогон (сборка + тесты + скан уязвимостей + фронт): `sh scripts/ci.sh`.
|
||||||
`src/ml-service`, `src/storage-service`). Тесты: `dotnet test src/core/tests/Deal.Tests.Unit`.
|
|
||||||
Полный CI-прогон (сборка 5 решений + тесты + скан уязвимостей + фронт) — `.gitea/workflows/ci.yml`.
|
|
||||||
Служебные скрипты в репозиторий не входят — живут только локально, вне кода.
|
|
||||||
|
|||||||
@@ -0,0 +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
|
||||||
@@ -0,0 +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/`.
|
||||||
|
|
||||||
|
Удалять этот архив без необходимости не нужно; он не влияет на сборку и запуск.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
data/
|
||||||
|
*.duckdb
|
||||||
|
.env
|
||||||
@@ -0,0 +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"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""LeadRadar backend (FastAPI + DuckDB)."""
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Авторизация в дашборд: admin/admin по умолчанию, сессия 30 дней.
|
||||||
|
|
||||||
|
Хэш пароля — PBKDF2-HMAC-SHA256. Токен сессии — случайный, хранится в БД,
|
||||||
|
передаётся httpOnly-кукой. По ТЗ смена пароля — через интерфейс.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import Cookie, HTTPException, Response
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
from .db import store
|
||||||
|
|
||||||
|
_ITERATIONS = 200_000
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_password(password: str, salt: str) -> str:
|
||||||
|
return hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), _ITERATIONS).hex()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_salt() -> str:
|
||||||
|
return secrets.token_hex(16)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_creds() -> None:
|
||||||
|
"""Создаёт учётные данные по умолчанию, если их нет."""
|
||||||
|
if store.scalar("SELECT count(*) FROM creds") == 0:
|
||||||
|
salt = _make_salt()
|
||||||
|
store.execute(
|
||||||
|
"INSERT INTO creds(login, password_hash, salt) VALUES (?, ?, ?)",
|
||||||
|
[config.BOOTSTRAP_LOGIN, _hash_password(config.BOOTSTRAP_PASSWORD, salt), salt],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(login: str, password: str) -> bool:
|
||||||
|
row = store.query_one("SELECT password_hash, salt FROM creds WHERE login = ?", [login])
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(row["password_hash"], _hash_password(password, row["salt"]))
|
||||||
|
|
||||||
|
|
||||||
|
def change_password(login: str, old_password: str, new_password: str) -> bool:
|
||||||
|
if not verify_password(login, old_password):
|
||||||
|
return False
|
||||||
|
if len(new_password) < 4:
|
||||||
|
raise HTTPException(400, "Пароль слишком короткий (минимум 4 символа)")
|
||||||
|
salt = _make_salt()
|
||||||
|
store.execute(
|
||||||
|
"UPDATE creds SET password_hash = ?, salt = ? WHERE login = ?",
|
||||||
|
[_hash_password(new_password, salt), salt, login],
|
||||||
|
)
|
||||||
|
# разлогиниваем все старые сессии пользователя
|
||||||
|
store.execute("DELETE FROM sessions WHERE login = ?", [login])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def create_session(login: str) -> str:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
expires = time.time_ns() // 1_000_000 + config.SESSION_DAYS * 86_400_000
|
||||||
|
store.execute("INSERT INTO sessions(token, login, expires) VALUES (?, ?, ?)", [token, login, expires])
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_session(token: str | None) -> str | None:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
row = store.query_one(
|
||||||
|
"SELECT login FROM sessions WHERE token = ? AND expires > ?",
|
||||||
|
[token, time.time_ns() // 1_000_000],
|
||||||
|
)
|
||||||
|
return row["login"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_session_cookie(response: Response, token: str) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
key=config.COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
max_age=config.SESSION_DAYS * 86400,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
# secure включается при HTTPS-проксировании; флаг не критичен локально
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def destroy_session(token: str | None) -> None:
|
||||||
|
if token:
|
||||||
|
store.execute("DELETE FROM sessions WHERE token = ?", [token])
|
||||||
|
|
||||||
|
|
||||||
|
def current_login(token: str | None = Cookie(default=None, alias=config.COOKIE_NAME)) -> str | None:
|
||||||
|
login = resolve_session(token)
|
||||||
|
if not login:
|
||||||
|
raise HTTPException(401, "Требуется авторизация")
|
||||||
|
return login
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Конфигурация из переменных окружения.
|
||||||
|
|
||||||
|
По ТЗ в env выносятся ТОЛЬКО неконфиденциальные параметры:
|
||||||
|
порты, пути к БД/данным/сессиям. Секреты (Telegram api_id/hash,
|
||||||
|
ключи AI, пароль дашборда) живут в базе и задаются через UI.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(name: str, default: bool = False) -> bool:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _int(name: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(os.getenv(name, str(default)))
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
# Корень данных (том в docker-compose). Внутри лежат leadradar.duckdb,
|
||||||
|
# telegram-сессии, файлы вложений (позже — MinIO), секрет подписи сессий.
|
||||||
|
DATA_DIR = Path(os.getenv("LEADRADAR_DATA", str(Path(__file__).resolve().parent.parent / "data"))).resolve()
|
||||||
|
DB_PATH = DATA_DIR / os.getenv("LEADRADAR_DB_NAME", "leadradar.duckdb")
|
||||||
|
SESSIONS_DIR = DATA_DIR / "telegram_sessions"
|
||||||
|
FILES_DIR = DATA_DIR / "attachments"
|
||||||
|
SECRET_FILE = DATA_DIR / "session_secret.key"
|
||||||
|
|
||||||
|
# Хост/порт HTTP-сервера
|
||||||
|
HOST = os.getenv("LEADRADAR_HOST", "0.0.0.0")
|
||||||
|
PORT = _int("LEADRADAR_PORT", 8000)
|
||||||
|
|
||||||
|
# Через env допустимо задать стартовый секрет (например, из секрет-менеджера),
|
||||||
|
# но если он не задан — будет создан случайный и сохранён в SECRET_FILE.
|
||||||
|
SESSION_SECRET = os.getenv("LEADRADAR_SESSION_SECRET", "") or None
|
||||||
|
|
||||||
|
# Учётные данные первого входа можно выставить и в env (удобно для развёртывания),
|
||||||
|
# НО по умолчанию система стартует с admin/admin и требует смены пароля.
|
||||||
|
BOOTSTRAP_LOGIN = os.getenv("LEADRADAR_BOOTSTRAP_LOGIN", "admin")
|
||||||
|
BOOTSTRAP_PASSWORD = os.getenv("LEADRADAR_BOOTSTRAP_PASSWORD", "admin")
|
||||||
|
|
||||||
|
# Шифрование секретов в БД. По договорённости ключ живёт в env;
|
||||||
|
# при локальной разработке без env создаётся файл data/encryption.key.
|
||||||
|
ENCRYPTION_KEY = os.getenv("LEADRADAR_ENCRYPTION_KEY", "") or None
|
||||||
|
ENCRYPTION_KEY_FILE = DATA_DIR / "encryption.key"
|
||||||
|
|
||||||
|
# MinIO (вложения; креды по договорённости — в env)
|
||||||
|
MINIO_ENDPOINT = os.getenv("LEADRADAR_MINIO_ENDPOINT", "")
|
||||||
|
MINIO_ACCESS_KEY = os.getenv("LEADRADAR_MINIO_ACCESS_KEY", "")
|
||||||
|
MINIO_SECRET_KEY = os.getenv("LEADRADAR_MINIO_SECRET_KEY", "")
|
||||||
|
MINIO_BUCKET = os.getenv("LEADRADAR_MINIO_BUCKET", "leadradar")
|
||||||
|
MINIO_SECURE = _bool("LEADRADAR_MINIO_SECURE", False)
|
||||||
|
|
||||||
|
# Автономный ML-сервис (отдельный контейнер). Если недоступен — модель не
|
||||||
|
# используется в пайплайне, а события обучения копятся в outbox до его возврата.
|
||||||
|
ML_URL = os.getenv("LEADRADAR_ML_URL", "http://127.0.0.1:8100").rstrip("/")
|
||||||
|
ML_TIMEOUT = _int("LEADRADAR_ML_TIMEOUT", 30) # обучение батчами — тяжёлое, таймаут щедрый
|
||||||
|
|
||||||
|
# Куки
|
||||||
|
COOKIE_NAME = "leadradar_session"
|
||||||
|
SESSION_DAYS = 30
|
||||||
|
|
||||||
|
# Telegram-сессия (Telethon)
|
||||||
|
SESSION_PREFIX = "user"
|
||||||
|
|
||||||
|
# Путь к статике фронтенда (собранный dist). Если папки нет — API работает
|
||||||
|
# отдельно (фронт поднимается dev-сервером), статика просто не раздаётся.
|
||||||
|
FRONTEND_DIST = Path(os.getenv("LEADRADAR_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"))).resolve()
|
||||||
|
|
||||||
|
# Логи
|
||||||
|
LOG_LEVEL = os.getenv("LEADRADAR_LOG_LEVEL", "INFO")
|
||||||
|
|
||||||
|
# Демо-эндпоинты (simulate-lead, age-lead). В проде выключено;
|
||||||
|
# включается только для локальных тестов разработчика: LEADRADAR_DEMO=1
|
||||||
|
DEMO_ENABLED = _bool("LEADRADAR_DEMO", False)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs() -> None:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
FILES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_secret() -> str:
|
||||||
|
if SESSION_SECRET:
|
||||||
|
return SESSION_SECRET
|
||||||
|
if SECRET_FILE.exists():
|
||||||
|
return SECRET_FILE.read_text(encoding="utf-8").strip()
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
secret = secrets.token_hex(32)
|
||||||
|
SECRET_FILE.write_text(secret, encoding="utf-8")
|
||||||
|
return secret
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""Базовые константы: палитры, стадии, промпты, валюты, провайдеры AI.
|
||||||
|
|
||||||
|
Значения согласованы с фронтенд-прототипом (frontend/src/data.js),
|
||||||
|
чтобы интеграция была безболезненной.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Палитра колонок-досок (по умолчанию)
|
||||||
|
PALETTE = ["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"]
|
||||||
|
|
||||||
|
# Колонки не создаются по умолчанию: их создаёт пользователь или предлагает
|
||||||
|
# ИИ (suggested=TRUE — ждёт решения пользователя).
|
||||||
|
|
||||||
|
# Служебные колонки дашборда
|
||||||
|
SERVICE_COLS = {"inbox", "archive", "trash", "taken"}
|
||||||
|
|
||||||
|
# Стадии канбана «Выбранных»
|
||||||
|
PIPELINE_STAGES = [
|
||||||
|
{"id": "planned", "name": "Запланировано", "color": "#818cf8", "terminal": False},
|
||||||
|
{"id": "reply", "name": "Отклик", "color": "#38bdf8", "terminal": False},
|
||||||
|
{"id": "agree", "name": "Согласование", "color": "#a78bfa", "terminal": False},
|
||||||
|
{"id": "work", "name": "В работе", "color": "#fbbf24", "terminal": False},
|
||||||
|
{"id": "review", "name": "Проверка", "color": "#f97316", "terminal": False},
|
||||||
|
{"id": "ready", "name": "Готово", "color": "#4ade80", "terminal": False},
|
||||||
|
{"id": "hold", "name": "Отложено", "color": "#94a3b8", "terminal": False},
|
||||||
|
{"id": "finished", "name": "Выполнено", "color": "#2bd576", "terminal": True},
|
||||||
|
{"id": "rejected", "name": "Отклонено", "color": "#ff6b6b", "terminal": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Валюты и мок-курсы (до первого успешного запроса к ЦБ)
|
||||||
|
CURRENCIES = [
|
||||||
|
{"code": "RUB", "name": "Российский рубль", "symbol": "₽"},
|
||||||
|
{"code": "USD", "name": "Доллар США", "symbol": "$"},
|
||||||
|
{"code": "EUR", "name": "Евро", "symbol": "€"},
|
||||||
|
{"code": "CNY", "name": "Китайский юань", "symbol": "¥"},
|
||||||
|
{"code": "KZT", "name": "Казахстанский тенге", "symbol": "₸"},
|
||||||
|
{"code": "BYN", "name": "Белорусский рубль", "symbol": "Br"},
|
||||||
|
{"code": "USDT", "name": "Tether (USDT)", "symbol": "₮"},
|
||||||
|
{"code": "GBP", "name": "Британский фунт", "symbol": "£"},
|
||||||
|
]
|
||||||
|
|
||||||
|
MOCK_RATES = {
|
||||||
|
"RUB": 1.0,
|
||||||
|
"USD": 92.5,
|
||||||
|
"EUR": 99.9,
|
||||||
|
"CNY": 13.1,
|
||||||
|
"KZT": 0.19,
|
||||||
|
"BYN": 28.6,
|
||||||
|
"USDT": 92.5,
|
||||||
|
"GBP": 117.4,
|
||||||
|
}
|
||||||
|
|
||||||
|
CBR_URL = "https://www.cbr-xml-daily.ru/daily_json.js"
|
||||||
|
|
||||||
|
# Стоп-фразы по умолчанию (этап 1 фильтра — без ИИ)
|
||||||
|
DEFAULT_STOP_PHRASES = ["взаимный пиар", "резюме", "ищу работу", "набор в команду"]
|
||||||
|
|
||||||
|
DEFAULT_MIN_LEN = 24
|
||||||
|
|
||||||
|
# Промпты. В тексте можно использовать плейсхолдеры, которые подставляются из
|
||||||
|
# настроек «Сферы и ключей» при каждом вызове ИИ:
|
||||||
|
# {domain} — domainDescription (что для вас заявка/лид, ваша сфера)
|
||||||
|
# {keywords} — domainKeywords (общие слова-маркеры заявок)
|
||||||
|
DEFAULT_AI_PROMPT = (
|
||||||
|
"Ты — классификатор входящих сообщений. Сообщение — ЗАЯВКА (лид), только если в нём есть конкретный"
|
||||||
|
" запрос или предложение по делу: заказ услуги/товара/работы, поиск исполнителя или найм человека."
|
||||||
|
" Направление вашей сферы:\n{domain}\n\n"
|
||||||
|
"Частые слова-маркеры заявок в вашей сфере: {keywords}\n\n"
|
||||||
|
"Определи по тексту:\n"
|
||||||
|
"1. is_spam — true, если это НЕ заявка: служебные сообщения (коды входа/подтверждения, уведомления),"
|
||||||
|
" приветствия и поздравления, флуд и обсуждения без задачи, вопросы без конкретики, реклама, скам,"
|
||||||
|
" фин. пирамиды, резюме соискателей, взаимный пиар, приглашения в чаты, рассылки."
|
||||||
|
" Если сомневаешься — ставь true (лучше пропустить сообщение, чем засорить карточками)\n"
|
||||||
|
"2. board — id подходящей доски из списка. Выбирай по КРИТЕРИЯМ колонки"
|
||||||
|
" (в скобках указаны её направление, тематика, ключевые слова, уровень, бюджет, описание), а не только по названию;"
|
||||||
|
" если ни одна колонка не подходит под текст — верни null (карточка пойдёт в «Неразобранное»)\n"
|
||||||
|
"3. is_vacancy — true, если это НАЙМ/постоянная или проектная занятость: ищут человека в команду"
|
||||||
|
" (признаки: вакансия, грейд/уровень, «в компанию», hr, зарплата за месяц);"
|
||||||
|
" false — разовая сделка: заказ/услуга/товар (фриланс, подряд, «нужно сделать/купить», цена за работу)\n"
|
||||||
|
"4. title — короткий заголовок (4–9 слов, без эмодзи, хэштегов и знаков препинания в конце)."
|
||||||
|
" Запрещено: markdown, ссылки в любом виде ([текст](url), голые url) — только чистый текст\n"
|
||||||
|
"5. company — кто разместил заявку: компания/бренд/частное лицо/заказчик. Только факт из текста;"
|
||||||
|
" не указано — верни пустую строку \"\"\n"
|
||||||
|
"6. format — формат работы/выполнения: удалённо/офис/гибрид, город, график. Коротко; нет — \"\"\n"
|
||||||
|
"7. task — 1–2 предложения: что за задача/роль и в чём суть (для кого, что нужно сделать)."
|
||||||
|
" Ёмко, без пересказа всего объявления и без служебных строк\n"
|
||||||
|
"8. requirements — JSON-массив строк ключевых требований к кандидату/исполнителю"
|
||||||
|
" (что нужно уметь/иметь, пункты «Требования/Обязанности/Нужно»). Нет — []\n"
|
||||||
|
"9. plus — JSON-массив строк «будет плюсом»/«приветствуется»/«желательно». Нет — []\n"
|
||||||
|
"10. conditions — условия одной строкой: оплата/ЗП/вилка, сроки, объём, тип занятости. Нет — \"\"\n"
|
||||||
|
"11. stack — JSON-массив строк (не строка!): технологии/предметы/услуги/материалы из заявки."
|
||||||
|
" Названия пиши слитно как в оригинале: \".NET\", \"C#\", \"Node.js\", \"ASP.NET Core\" — не разбивай на отдельные буквы (2–8)\n"
|
||||||
|
"12. budget — объект { from, to, currency }: одна сумма → from=to=X; «до X» → from=null, to=X;"
|
||||||
|
" диапазон «от X до Y» → from=X, to=Y. Если суммы нет — null\n"
|
||||||
|
"13. contacts — JSON-массив строк контактов ДЛЯ СВЯЗИ: телефон, email, @username (не бот),"
|
||||||
|
" ссылка на профиль человека (LinkedIn, t.me/…). НЕ включай ботов, каналы/группы и ссылки на"
|
||||||
|
" вакансию/пост/форму. Если контакта в тексте нет — пустой массив []\n"
|
||||||
|
"Поля 5–10 — это блок «О заявке» на карточке: заполняй их только фактами из текста, ничего не выдумывай."
|
||||||
|
" Запрещено: markdown-разметка, ссылки в любом виде, эмодзи, хэштеги, «от»/«привет», дословное копирование исходника.\n"
|
||||||
|
"Отвечай строго в формате JSON."
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_AI_FILTER_PROMPT = (
|
||||||
|
"Ты — страж входящих сообщений каналов. Пропускай только реальные заявки/лиды по вашей сфере"
|
||||||
|
" (заказ, услуга, товар, найм — с конкретикой).\n"
|
||||||
|
"Ваша сфера и что считать заявкой:\n{domain}\n\n"
|
||||||
|
"Слова-маркеры заявок: {keywords}\n\n"
|
||||||
|
"НЕ пропускай:\n"
|
||||||
|
"- служебные сообщения: коды входа/подтверждения, уведомления, приветствия, поздравления\n"
|
||||||
|
"- просто сообщения без задачи и конкретики: флуд, обсуждения, вопросы «кто работал с …»\n"
|
||||||
|
"- рекламу и саморекламу\n"
|
||||||
|
"- скам, фин. пирамиды, «заработок»\n"
|
||||||
|
"- резюме, поиск работы соискателями\n"
|
||||||
|
"- взаимный пиар, приглашения в чаты\n"
|
||||||
|
"- рассылки и дайджесты без прямых заявок\n"
|
||||||
|
"При сомнении — не пропускай.\n"
|
||||||
|
'Верни строго JSON: { "pass": true|false, "reason": "причина отказа или null" }'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Промпт структуры карточки (блок «О заявке»). Отдельный от классификатора:
|
||||||
|
# задаёт, какие поля и как заполнять, чтобы все карточки имели одинаковую
|
||||||
|
# структуру текста (разной длины). Добавляется к промпту классификатора
|
||||||
|
# отдельным блоком (см. services/ai.classify). Редактируется в UI.
|
||||||
|
DEFAULT_AI_CARD_PROMPT = (
|
||||||
|
"Ты также возвращаешь содержимое блока «О заявке» карточки — поля company, format,"
|
||||||
|
" task, requirements, plus, conditions. Карточка всегда собирается из одних и тех же блоков:"
|
||||||
|
" одинаковая структура у всех карточек, различается только длина.\n"
|
||||||
|
"1. company — кто ищет/разместил: компания, бренд, агентство, частное лицо, заказчик. Одно предложение, факт из текста.\n"
|
||||||
|
"2. format — формат работы: удалённо/офис/гибрид/разъездной, город/страна, график (5/2, full-time, part-time). Коротко.\n"
|
||||||
|
"3. task — 1–2 предложения по шаблону: что за задача/роль → для кого → что нужно сделать/какой результат."
|
||||||
|
" Пиши ёмко, не пересказывай объявление дословно.\n"
|
||||||
|
"4. requirements — вынеси сюда только реальные требования/обязанности из текста"
|
||||||
|
" (пункты списков «Требования», «Обязанности», «Что предстоит делать», «Нужно»):"
|
||||||
|
" каждый пункт короткой строкой в JSON-массиве. Не придумывай сверх текста.\n"
|
||||||
|
"5. plus — только то, что отмечено как «будет плюсом»/«приветствуется»/«желательно»."
|
||||||
|
" Нет таких пунктов — пустой массив [].\n"
|
||||||
|
"6. conditions — условия одной строкой: оплата/ЗП/вилка/гонорар, сроки, объём, тип занятости."
|
||||||
|
" Не дублируй budget-числа в других полях.\n"
|
||||||
|
"Для не-IT сфер «стек/требования» означают материалы, услуги, навыки, инструменты — по смыслу заявки."
|
||||||
|
" Запрещено во всех полях: markdown-разметка, ссылки в любом виде ([текст](url), голые url),"
|
||||||
|
" эмодзи, хэштеги, «от»/«привет»/служебные строки канала."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дефолтные маркеры локального разбора (настраиваются в UI «Сфера и ключи»).
|
||||||
|
DEFAULT_HIRE_MARKERS = [
|
||||||
|
"вакансия", "вакансию", "вакансии", "вакантна", "вакант", "нанимаем", "найм", "full-time",
|
||||||
|
"на постоянную", "занятость", "в офис", "официальное оформление", "пятидневка",
|
||||||
|
"грейд", "в компанию", "полная занятость", "на постоянную работу", "в команду", "нанимает",
|
||||||
|
"на постоянную основу", "в штат",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_LEVEL_TERMS = [
|
||||||
|
"junior", "джун", "джуниор", "middle", "мидл", "mid", "senior", "сеньор", "сеньйор",
|
||||||
|
"lead", "тимлид", "тиэмлид", "architect", "архитектор", "стажёр", "стажер", "intern", "trainee",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Маркеры, по которым сообщение опознаётся как резюме/самопрезентация
|
||||||
|
# соискателя («ищу работу», «моё резюме»). По умолчанию такие сообщения
|
||||||
|
# отсекаются на этапе 1 (до ИИ и правил колонок), если пользователь ищет
|
||||||
|
# вакансии и заказы, а не кандидатов. Редактируется в «Сфере и ключах»;
|
||||||
|
# выключите отсев — резюме начнут собираться как обычные лиды.
|
||||||
|
# Слово «резюме» имеет контекстный guard (см. pipeline._resume_reason):
|
||||||
|
# «…вакансия…, присылайте резюме» — это объявление работодателя, его не режем.
|
||||||
|
DEFAULT_RESUME_MARKERS = [
|
||||||
|
"резюме", "#резюме", "моё резюме", "мое резюме", "ищу работу", "ищу вакансию",
|
||||||
|
"рассмотрю предложения", "готов к собеседованию", "в поиске работы",
|
||||||
|
"ищу проект", "ищу подработку",
|
||||||
|
]
|
||||||
|
|
||||||
|
# AI-провайдеры (активен один)
|
||||||
|
AI_PROVIDERS = [
|
||||||
|
{"id": "deepseek", "name": "DeepSeek", "base": "https://api.deepseek.com", "local": False,
|
||||||
|
"models": ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp"]},
|
||||||
|
{"id": "openai", "name": "OpenAI", "base": "https://api.openai.com/v1", "local": False,
|
||||||
|
"models": ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]},
|
||||||
|
{"id": "openrouter", "name": "OpenRouter", "base": "https://openrouter.ai/api/v1", "local": False,
|
||||||
|
"models": ["deepseek/deepseek-v4-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.6-luna",
|
||||||
|
"google/gemini-3.8-flash", "qwen/qwen3.8-flash"]},
|
||||||
|
{"id": "anthropic", "name": "Anthropic Claude", "base": "https://api.anthropic.com", "local": False,
|
||||||
|
"api_style": "anthropic",
|
||||||
|
"models": ["claude-sonnet-5", "claude-opus-5", "claude-fable-5-1", "claude-haiku-4-5-20251001"]},
|
||||||
|
{"id": "ollama", "name": "Ollama (локально)", "base": "http://localhost:11434/v1", "local": True,
|
||||||
|
"models": ["qwen3-coder:30b", "qwen3-coder:480b", "qwen3:32b"]},
|
||||||
|
{"id": "lmstudio", "name": "LM Studio (локально)", "base": "http://localhost:1234/v1", "local": True,
|
||||||
|
"models": ["qwen3-coder-30b", "qwen3-coder-480b", "llama-3.3-70b"]},
|
||||||
|
{"id": "custom", "name": "Другой (OpenAI-совместимый)", "base": "https://", "local": False, "models": []},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Настройки по умолчанию (ключи -> значение; не секреты)
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
# хранилище
|
||||||
|
"autoArchive": True,
|
||||||
|
"archiveAfterDays": 14, # 1..30
|
||||||
|
"archiveClearDays": 90,
|
||||||
|
"trashClearDays": 7,
|
||||||
|
# фильтры входящих
|
||||||
|
"minLen": DEFAULT_MIN_LEN,
|
||||||
|
"stopPhrases": DEFAULT_STOP_PHRASES,
|
||||||
|
"mlEnabled": True, # локальный ML-слой (обучается на действиях пользователя)
|
||||||
|
"aiEnabled": True, # полный выключатель ИИ: false = только фильтр + ML
|
||||||
|
"aiFilterEnabled": True,
|
||||||
|
"aiFilterPrompt": DEFAULT_AI_FILTER_PROMPT,
|
||||||
|
"aiPrompt": DEFAULT_AI_PROMPT,
|
||||||
|
"cardPrompt": DEFAULT_AI_CARD_PROMPT, # структура карточки/блока «О заявке» (отдельный промпт)
|
||||||
|
# сфера и ключи (универсальный классификатор: редактируется в UI)
|
||||||
|
"domainDescription": "",
|
||||||
|
"domainKeywords": [],
|
||||||
|
"hireMarkers": DEFAULT_HIRE_MARKERS,
|
||||||
|
"levelTerms": DEFAULT_LEVEL_TERMS,
|
||||||
|
"resumeMarkers": DEFAULT_RESUME_MARKERS,
|
||||||
|
"blockResumes": True, # True = ищем вакансии/заказы, резюме соискателей отсекаем
|
||||||
|
# тип заявок, которые собираем: both | vacancy | freelance (этап 1, до ИИ)
|
||||||
|
"wantedType": "both",
|
||||||
|
# «без указания суммы карточку не создаём»: отдельно для найма и заказов
|
||||||
|
"budgetRequiredHire": False,
|
||||||
|
"budgetRequiredOrder": False,
|
||||||
|
# подписи типов на карточках (по ситуации/сфере пользователя)
|
||||||
|
"hireLabel": "вакансия",
|
||||||
|
"orderLabel": "фриланс",
|
||||||
|
# личная библиотека промптов пользователя: [{id, name, description, prompt}]
|
||||||
|
"myPrompts": [],
|
||||||
|
# напоминания
|
||||||
|
"remindersEnabled": True,
|
||||||
|
# валюта и курсы
|
||||||
|
"conversionOn": True,
|
||||||
|
"targetCurrency": "RUB",
|
||||||
|
"rateSource": "cbr", # cbr | mock
|
||||||
|
# UI-состояния колонок
|
||||||
|
"colState": {},
|
||||||
|
# AI: активный провайдер и конфиги (ключи хранятся здесь же, наружу маскируются)
|
||||||
|
"aiProvider": "deepseek",
|
||||||
|
"aiConfigs": {
|
||||||
|
p["id"]: {"apiKey": "", "baseUrl": p["base"], "model": (p["models"] or [""])[0]}
|
||||||
|
for p in AI_PROVIDERS
|
||||||
|
},
|
||||||
|
# telegram-ключи: {api_id, api_hash, account}
|
||||||
|
"tgKeys": {"apiId": "", "apiHash": ""},
|
||||||
|
# авто-мониторинг новых чатов/каналов (добавлены с другого клиента)
|
||||||
|
"autoMonitorNew": True,
|
||||||
|
# поиск каналов (Discovery)
|
||||||
|
"discJoinLimit": 50, # суточный лимит авто-вступлений (общий)
|
||||||
|
"discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями
|
||||||
|
"discJoinDelayMax": 70, # сек, верхняя граница
|
||||||
|
"discEvalSample": 10, # размер выборки сообщений при оценке
|
||||||
|
"discEvalThreshold": 40, # % подходящих сообщений
|
||||||
|
}
|
||||||
|
|
||||||
|
# Диалоговые цвета (детерминированно по имени)
|
||||||
|
DIALOG_HUES = ["#3b82f6", "#38bdf8", "#f472b6", "#f59e0b", "#a78bfa", "#34d399", "#fb7185", "#22c55e"]
|
||||||
|
|
||||||
|
# Множители единиц времени
|
||||||
|
DAY_MS = 86_400_000
|
||||||
|
HOUR_MS = 3_600_000
|
||||||
|
MIN_MS = 60_000
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Шифрование секретов, хранимых в БД (Telegram api_hash, ключи AI).
|
||||||
|
|
||||||
|
Решение по итогам ревью: ключи шифруются симметричным ключом; ключ шифрования
|
||||||
|
пока живёт в env (LEADRADAR_ENCRYPTION_KEY). Для локальной разработки без env
|
||||||
|
ключ генерируется и кладётся в data/encryption.key (с предупреждением).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
log = logging.getLogger("leadradar.crypto")
|
||||||
|
|
||||||
|
_fernet: Fernet | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet() -> Fernet:
|
||||||
|
global _fernet
|
||||||
|
if _fernet is not None:
|
||||||
|
return _fernet
|
||||||
|
key = config.ENCRYPTION_KEY
|
||||||
|
if key:
|
||||||
|
try:
|
||||||
|
_fernet = Fernet(key.encode() if not key.endswith("=") else key.encode())
|
||||||
|
return _fernet
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.error("LEADRADAR_ENCRYPTION_KEY не похож на Fernet-ключ (32 байта urlsafe b64)")
|
||||||
|
raise
|
||||||
|
if config.ENCRYPTION_KEY_FILE.exists():
|
||||||
|
key = config.ENCRYPTION_KEY_FILE.read_text(encoding="utf-8").strip()
|
||||||
|
else:
|
||||||
|
config.ensure_dirs()
|
||||||
|
key = Fernet.generate_key().decode()
|
||||||
|
config.ENCRYPTION_KEY_FILE.write_text(key, encoding="utf-8")
|
||||||
|
log.warning("Ключ шифрования создан в %s (для продакшена задайте LEADRADAR_ENCRYPTION_KEY)", config.ENCRYPTION_KEY_FILE)
|
||||||
|
_fernet = Fernet(key.encode())
|
||||||
|
return _fernet
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_text(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
token = _get_fernet().encrypt(value.encode())
|
||||||
|
return "enc:" + token.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_text(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if not value.startswith("enc:"):
|
||||||
|
return value # совместимость с незашифрованными значениями ранних версий
|
||||||
|
try:
|
||||||
|
return _get_fernet().decrypt(value[4:].encode()).decode()
|
||||||
|
except InvalidToken:
|
||||||
|
log.error("Не удалось расшифровать секрет (неверный ключ шифрования)")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_encrypt(value: str) -> str:
|
||||||
|
"""Шифруем только непустые значения."""
|
||||||
|
return encrypt_text(value) if value else ""
|
||||||
|
|
||||||
|
|
||||||
|
def random_key() -> str:
|
||||||
|
return Fernet.generate_key().decode()
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
"""DuckDB: единый доступ, схема, стартовые данные.
|
||||||
|
|
||||||
|
DuckDB — одна запись в момент времени. Внутри одного процесса мы
|
||||||
|
сериализуем все операции общим локом (чтение дёшево, объёмы личные),
|
||||||
|
что полностью соответствует ТЗ (self-hosted, один файл).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
|
||||||
|
from . import constants as C
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
_LOCK = threading.RLock()
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS boards (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
description VARCHAR NOT NULL DEFAULT '',
|
||||||
|
color VARCHAR NOT NULL DEFAULT '#818cf8',
|
||||||
|
width VARCHAR NOT NULL DEFAULT 'md',
|
||||||
|
pos INTEGER NOT NULL DEFAULT 0,
|
||||||
|
keywords VARCHAR NOT NULL DEFAULT '[]',
|
||||||
|
prompt VARCHAR NOT NULL DEFAULT '',
|
||||||
|
visible_fields VARCHAR NOT NULL DEFAULT '["budget","stack","contacts"]',
|
||||||
|
collapsed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
suggested BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
rules VARCHAR NOT NULL DEFAULT '{}',
|
||||||
|
note VARCHAR NOT NULL DEFAULT '',
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS leads (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
col VARCHAR NOT NULL,
|
||||||
|
is_new BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
is_vacancy BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
title VARCHAR NOT NULL,
|
||||||
|
summary VARCHAR NOT NULL DEFAULT '',
|
||||||
|
stack VARCHAR NOT NULL DEFAULT '[]',
|
||||||
|
budget_from DOUBLE,
|
||||||
|
budget_to DOUBLE,
|
||||||
|
budget_cur VARCHAR NOT NULL DEFAULT '',
|
||||||
|
conv_from DOUBLE,
|
||||||
|
conv_to DOUBLE,
|
||||||
|
conv_cur VARCHAR NOT NULL DEFAULT '',
|
||||||
|
contact VARCHAR NOT NULL DEFAULT '',
|
||||||
|
contacts VARCHAR NOT NULL DEFAULT '[]',
|
||||||
|
ch_name VARCHAR NOT NULL DEFAULT '',
|
||||||
|
ch_handle VARCHAR NOT NULL DEFAULT '',
|
||||||
|
ch_hue VARCHAR NOT NULL DEFAULT '#666',
|
||||||
|
time_label VARCHAR NOT NULL DEFAULT '',
|
||||||
|
received_at BIGINT NOT NULL,
|
||||||
|
source_msg VARCHAR NOT NULL DEFAULT '',
|
||||||
|
prev_col VARCHAR NOT NULL DEFAULT 'inbox',
|
||||||
|
comments VARCHAR NOT NULL DEFAULT '[]',
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_leads_col ON leads(col);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
dialog_id VARCHAR NOT NULL,
|
||||||
|
text VARCHAR NOT NULL DEFAULT '',
|
||||||
|
msg_at BIGINT NOT NULL,
|
||||||
|
lead_id VARCHAR
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_dialog ON messages(dialog_id, msg_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dialogs (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
handle VARCHAR NOT NULL DEFAULT '',
|
||||||
|
kind VARCHAR NOT NULL DEFAULT 'чат',
|
||||||
|
hue VARCHAR NOT NULL DEFAULT '#666',
|
||||||
|
monitor BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
last_text VARCHAR NOT NULL DEFAULT '',
|
||||||
|
last_at BIGINT,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dedup (
|
||||||
|
hash VARCHAR PRIMARY KEY,
|
||||||
|
lead_id VARCHAR,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS learning_log (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
lead_id VARCHAR NOT NULL,
|
||||||
|
action VARCHAR NOT NULL,
|
||||||
|
from_col VARCHAR,
|
||||||
|
to_col VARCHAR,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
stage VARCHAR NOT NULL DEFAULT 'planned',
|
||||||
|
local BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
lead_id VARCHAR,
|
||||||
|
title VARCHAR NOT NULL DEFAULT '',
|
||||||
|
summary VARCHAR NOT NULL DEFAULT '',
|
||||||
|
stack VARCHAR NOT NULL DEFAULT '[]',
|
||||||
|
budget_from DOUBLE,
|
||||||
|
budget_to DOUBLE,
|
||||||
|
budget_cur VARCHAR NOT NULL DEFAULT '',
|
||||||
|
contact VARCHAR NOT NULL DEFAULT '',
|
||||||
|
comments VARCHAR NOT NULL DEFAULT '[]',
|
||||||
|
links VARCHAR NOT NULL DEFAULT '[]',
|
||||||
|
files VARCHAR NOT NULL DEFAULT '[]',
|
||||||
|
tz_text VARCHAR NOT NULL DEFAULT '',
|
||||||
|
history VARCHAR NOT NULL DEFAULT '[]',
|
||||||
|
reminder_at BIGINT,
|
||||||
|
reminder_fired BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_projects_stage ON projects(stage);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rates (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
rates VARCHAR NOT NULL DEFAULT '{}',
|
||||||
|
source VARCHAR NOT NULL DEFAULT 'cbr',
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Discovery: задачи поиска Telegram-каналов. keywords — JSON-список, столбцы
|
||||||
|
-- search_* / found / evaluated / joined / rejected — живой прогресс по задаче.
|
||||||
|
CREATE TABLE IF NOT EXISTS disc_tasks (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
description VARCHAR NOT NULL DEFAULT '',
|
||||||
|
keywords VARCHAR NOT NULL DEFAULT '[]',
|
||||||
|
min_subscribers INTEGER NOT NULL DEFAULT 0,
|
||||||
|
lang VARCHAR NOT NULL DEFAULT 'ru',
|
||||||
|
threshold INTEGER NOT NULL DEFAULT 40,
|
||||||
|
sample_size INTEGER NOT NULL DEFAULT 10,
|
||||||
|
plan_joins INTEGER NOT NULL DEFAULT 1,
|
||||||
|
auto_join BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
status VARCHAR NOT NULL DEFAULT 'draft', -- draft|running|paused|done|failed
|
||||||
|
search_idx INTEGER NOT NULL DEFAULT 0,
|
||||||
|
search_done BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
found INTEGER NOT NULL DEFAULT 0,
|
||||||
|
evaluated INTEGER NOT NULL DEFAULT 0,
|
||||||
|
joined INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rejected INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Discovery: найденные кандидаты. marks/topics — JSON-списки (оценки ИИ).
|
||||||
|
CREATE TABLE IF NOT EXISTS disc_candidates (
|
||||||
|
dialog_id VARCHAR PRIMARY KEY,
|
||||||
|
task_id VARCHAR NOT NULL,
|
||||||
|
name VARCHAR NOT NULL DEFAULT '',
|
||||||
|
username VARCHAR NOT NULL DEFAULT '',
|
||||||
|
kind VARCHAR NOT NULL DEFAULT 'channel', -- channel|group|forum
|
||||||
|
hue VARCHAR NOT NULL DEFAULT '#666',
|
||||||
|
participants INTEGER,
|
||||||
|
lang_ru BOOLEAN,
|
||||||
|
marks VARCHAR NOT NULL DEFAULT '[]',
|
||||||
|
topics VARCHAR NOT NULL DEFAULT '[]',
|
||||||
|
fit_ratio DOUBLE,
|
||||||
|
status VARCHAR NOT NULL DEFAULT 'new', -- new|review|joined|rejected
|
||||||
|
auto_joined BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
join_failures INTEGER NOT NULL DEFAULT 0, -- неудачные авто-вступления подряд (3 → кандидат удаляется)
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_disc_cand_task ON disc_candidates(task_id, status);
|
||||||
|
|
||||||
|
-- Discovery: чёрный список (пропускать при поиске).
|
||||||
|
CREATE TABLE IF NOT EXISTS disc_blacklist (
|
||||||
|
dialog_id VARCHAR PRIMARY KEY,
|
||||||
|
name VARCHAR NOT NULL DEFAULT '',
|
||||||
|
reason VARCHAR NOT NULL DEFAULT '',
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Discovery: лог событий по задаче (search|found|skip|eval|review|join_auto|
|
||||||
|
-- join_manual|leave|reject|flood|error|done).
|
||||||
|
CREATE TABLE IF NOT EXISTS disc_log (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
task_id VARCHAR NOT NULL,
|
||||||
|
event VARCHAR NOT NULL,
|
||||||
|
text VARCHAR NOT NULL DEFAULT '',
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_disc_log_task ON disc_log(task_id, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key VARCHAR PRIMARY KEY,
|
||||||
|
value VARCHAR NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token VARCHAR PRIMARY KEY,
|
||||||
|
login VARCHAR NOT NULL,
|
||||||
|
expires BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS creds (
|
||||||
|
login VARCHAR PRIMARY KEY,
|
||||||
|
password_hash VARCHAR NOT NULL,
|
||||||
|
salt VARCHAR NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Outbox обучения ML: действия пользователя всегда пишутся сюда (синхронно,
|
||||||
|
-- локально), а фоновый воркер отправляет их в автономный ML-сервис.
|
||||||
|
-- Так ML «обучается всегда» даже если сервис временно недоступен.
|
||||||
|
CREATE TABLE IF NOT EXISTS ml_outbox (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
text VARCHAR NOT NULL,
|
||||||
|
label VARCHAR NOT NULL,
|
||||||
|
delta DOUBLE NOT NULL DEFAULT 1.0,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Очередь входящих: все сообщения из групп попадают сюда и разбираются
|
||||||
|
-- фоновым воркером. Не прошедшие фильтры удаляются сразу (не копятся).
|
||||||
|
-- force=TRUE — сообщение возвращено из отсева пользователем: фильтры-отсев
|
||||||
|
-- для него игнорируются (сообщение уходит на ML/ИИ и создаёт карточку).
|
||||||
|
CREATE TABLE IF NOT EXISTS pipeline_msg (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
dialog_id VARCHAR NOT NULL,
|
||||||
|
ch_name VARCHAR NOT NULL DEFAULT '',
|
||||||
|
ch_handle VARCHAR NOT NULL DEFAULT '',
|
||||||
|
ch_hue VARCHAR NOT NULL DEFAULT '#666',
|
||||||
|
text VARCHAR NOT NULL,
|
||||||
|
msg_id BIGINT,
|
||||||
|
msg_at BIGINT NOT NULL,
|
||||||
|
status VARCHAR NOT NULL DEFAULT 'new',
|
||||||
|
force BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pipeline_status ON pipeline_msg(status, created_at);
|
||||||
|
|
||||||
|
-- Отсев пайплайна (мониторинг «Обработка»): сообщения, не прошедшие этапы
|
||||||
|
-- (стоп-лист/резюме/тип/без суммы/устарело/ML/ИИ). Хранится причина и «чьё»
|
||||||
|
-- решение. Автоочистка раз в 3 суток + ручная очистка из UI.
|
||||||
|
-- При возврате в обработку запись не удаляется: помечается returned с причиной.
|
||||||
|
CREATE TABLE IF NOT EXISTS rejected_msgs (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
dialog_id VARCHAR NOT NULL DEFAULT '',
|
||||||
|
msg_id BIGINT,
|
||||||
|
ch_name VARCHAR NOT NULL DEFAULT '',
|
||||||
|
ch_handle VARCHAR NOT NULL DEFAULT '',
|
||||||
|
ch_hue VARCHAR NOT NULL DEFAULT '#666',
|
||||||
|
text VARCHAR NOT NULL,
|
||||||
|
stage VARCHAR NOT NULL DEFAULT '',
|
||||||
|
reason VARCHAR NOT NULL DEFAULT '',
|
||||||
|
kw VARCHAR NOT NULL DEFAULT '',
|
||||||
|
source VARCHAR NOT NULL DEFAULT 'stop',
|
||||||
|
msg_at BIGINT,
|
||||||
|
rejected_at BIGINT NOT NULL,
|
||||||
|
returned BOOLEAN DEFAULT FALSE,
|
||||||
|
returned_at BIGINT,
|
||||||
|
return_reason VARCHAR NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rejected_rejected_at ON rejected_msgs(rejected_at);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# DuckDB не поддерживает IF NOT EXISTS в ADD COLUMN на старых версиях;
|
||||||
|
# выполняем в try для совместимости (поле нужно для правила «архив очищается
|
||||||
|
# через 90 дней после помещения в архив»).
|
||||||
|
_MIGRATIONS = [
|
||||||
|
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS archived_at BIGINT",
|
||||||
|
"ALTER TABLE dialogs ADD COLUMN IF NOT EXISTS backfilled BOOLEAN",
|
||||||
|
# исходное сообщение: id в Telegram + id диалога (для «открыть исходник»)
|
||||||
|
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS source_dialog_id VARCHAR DEFAULT ''",
|
||||||
|
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS source_msg_id BIGINT",
|
||||||
|
# квалифицированные контакты: JSON-список [{type, value}] (tg/phone/email/linkedin/site)
|
||||||
|
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS contacts VARCHAR DEFAULT '[]'",
|
||||||
|
# модель ML переезжает в отдельный контейнер — старые встроенные таблицы не нужны
|
||||||
|
"DROP TABLE IF EXISTS ml_classes",
|
||||||
|
"DROP TABLE IF EXISTS ml_terms",
|
||||||
|
# колонки: ИИ-предложения и правила маршрутизации (DuckDB не умеет
|
||||||
|
# ADD COLUMN с NOT NULL — значения трактуются как FALSE/{}/'')
|
||||||
|
"ALTER TABLE boards ADD COLUMN IF NOT EXISTS suggested BOOLEAN",
|
||||||
|
"ALTER TABLE boards ADD COLUMN IF NOT EXISTS rules VARCHAR",
|
||||||
|
"ALTER TABLE boards ADD COLUMN IF NOT EXISTS note VARCHAR",
|
||||||
|
# описание колонки (для пользователя и подсказки ИИ/ML)
|
||||||
|
"ALTER TABLE boards ADD COLUMN IF NOT EXISTS description VARCHAR DEFAULT ''",
|
||||||
|
# какие критерии фильтра совпали при попадании карточки в колонку (JSON)
|
||||||
|
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS match_hits VARCHAR DEFAULT '[]'",
|
||||||
|
# тип «найм/разовое» подтверждён ИИ по контексту (маркерная эвристика — нет)
|
||||||
|
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS is_vacancy_known BOOLEAN DEFAULT FALSE",
|
||||||
|
# возврат из отсева в обработку: force на строке очереди (фильтры игнорируются)
|
||||||
|
"ALTER TABLE pipeline_msg ADD COLUMN IF NOT EXISTS force BOOLEAN DEFAULT FALSE",
|
||||||
|
# отсев: исходный диалог/сообщение (для повторного возврата в очередь) и метки возврата
|
||||||
|
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS dialog_id VARCHAR DEFAULT ''",
|
||||||
|
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS msg_id BIGINT",
|
||||||
|
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS ch_hue VARCHAR DEFAULT '#666'",
|
||||||
|
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS returned BOOLEAN DEFAULT FALSE",
|
||||||
|
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS returned_at BIGINT",
|
||||||
|
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS return_reason VARCHAR DEFAULT ''",
|
||||||
|
# discovery: счётчик неудачных авто-вступлений кандидата (лимит ретраев join)
|
||||||
|
"ALTER TABLE disc_candidates ADD COLUMN IF NOT EXISTS join_failures INTEGER DEFAULT 0",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
"""Синглтон-доступ к DuckDB."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._con: duckdb.DuckDBPyConnection | None = None
|
||||||
|
|
||||||
|
def init(self) -> None:
|
||||||
|
config.ensure_dirs()
|
||||||
|
with _LOCK:
|
||||||
|
self._con = duckdb.connect(str(config.DB_PATH))
|
||||||
|
for stmt in _SCHEMA.split(";"):
|
||||||
|
if stmt.strip():
|
||||||
|
self._con.execute(stmt)
|
||||||
|
for stmt in _MIGRATIONS:
|
||||||
|
try:
|
||||||
|
self._con.execute(stmt)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._seed_defaults()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with _LOCK:
|
||||||
|
if self._con is not None:
|
||||||
|
try:
|
||||||
|
self._con.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._con = None
|
||||||
|
|
||||||
|
# ── низкоуровневые примитивы ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def execute(self, sql: str, params: dict | list | tuple | None = None) -> None:
|
||||||
|
with _LOCK:
|
||||||
|
self._con.execute(sql, params or [])
|
||||||
|
|
||||||
|
def query(self, sql: str, params: dict | list | tuple | None = None) -> list[dict]:
|
||||||
|
with _LOCK:
|
||||||
|
cur = self._con.execute(sql, params or [])
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [dict(zip(cols, row)) for row in rows]
|
||||||
|
|
||||||
|
def query_one(self, sql: str, params: dict | list | tuple | None = None) -> dict | None:
|
||||||
|
rows = self.query(sql, params)
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
def scalar(self, sql: str, params: dict | list | tuple | None = None):
|
||||||
|
with _LOCK:
|
||||||
|
cur = self._con.execute(sql, params or [])
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
# ── настройки ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_setting(self, key: str):
|
||||||
|
row = self.query_one("SELECT value FROM settings WHERE key = ?", [key])
|
||||||
|
if row is None:
|
||||||
|
default = C.DEFAULT_SETTINGS.get(key, None)
|
||||||
|
return default
|
||||||
|
return json.loads(row["value"])
|
||||||
|
|
||||||
|
def all_settings(self) -> dict:
|
||||||
|
out = dict(C.DEFAULT_SETTINGS)
|
||||||
|
for row in self.query("SELECT key, value FROM settings"):
|
||||||
|
try:
|
||||||
|
out[row["key"]] = json.loads(row["value"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
def set_setting(self, key: str, value) -> None:
|
||||||
|
self.execute(
|
||||||
|
"INSERT INTO settings(key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
[key, json.dumps(value, ensure_ascii=False)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── стартовые данные ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _seed_defaults(self) -> None:
|
||||||
|
now = time.time_ns() // 1_000_000
|
||||||
|
# Колонки не создаются по умолчанию: их делает пользователь или
|
||||||
|
# предлагает ИИ (см. services/suggest.py).
|
||||||
|
if self.scalar("SELECT count(*) FROM rates") == 0:
|
||||||
|
self.execute(
|
||||||
|
"INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, 'mock', ?)",
|
||||||
|
[json.dumps(C.MOCK_RATES), now],
|
||||||
|
)
|
||||||
|
# начальные настройки сохраняем только при отсутствии (значения берутся из DEFAULT_SETTINGS сами)
|
||||||
|
|
||||||
|
# ── генераторы ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def uid(prefix: str = "") -> str:
|
||||||
|
return f"{prefix}{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
|
||||||
|
store = Store()
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Точка входа FastAPI-приложения LeadRadar.
|
||||||
|
|
||||||
|
Запуск: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
или: python -m app.main
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
from .auth import ensure_creds
|
||||||
|
from .db import store
|
||||||
|
from .routers import (
|
||||||
|
auth_routes,
|
||||||
|
dashboard_routes,
|
||||||
|
discovery_routes,
|
||||||
|
events_routes,
|
||||||
|
ml_routes,
|
||||||
|
processing_routes,
|
||||||
|
projects_routes,
|
||||||
|
settings_routes,
|
||||||
|
tg_routes,
|
||||||
|
)
|
||||||
|
from .services import fts as fts_svc
|
||||||
|
from .services import ml_client
|
||||||
|
from .services import projects as proj_svc
|
||||||
|
from .services import rates as rates_svc
|
||||||
|
from .services.leads import notify_tick_stats, tick_storage
|
||||||
|
from .services.telegram import register_main_loop, tg
|
||||||
|
|
||||||
|
logging.basicConfig(level=config.LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
|
log = logging.getLogger("leadradar")
|
||||||
|
|
||||||
|
|
||||||
|
async def _storage_loop() -> None:
|
||||||
|
"""Каждые 30 секунд: правила хранения (архив/очистка), напоминания, сердцебиение Telegram."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
stats = tick_storage()
|
||||||
|
await notify_tick_stats(stats)
|
||||||
|
await proj_svc.check_reminders()
|
||||||
|
await tg.heartbeat()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("storage loop error")
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fts_loop() -> None:
|
||||||
|
"""Полнотекстовые индексы пересобираем раз в сутки (FTS — снимок)."""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(24 * 3600)
|
||||||
|
try:
|
||||||
|
fts_svc.rebuild()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("fts rebuild error")
|
||||||
|
|
||||||
|
|
||||||
|
async def _rates_loop() -> None:
|
||||||
|
"""Курсы ЦБ РФ: проверяем раз в 30 минут, тянем не чаще 1 раза в 6 часов."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if rates_svc.should_fetch():
|
||||||
|
ok = await rates_svc.refresh_rates()
|
||||||
|
if not ok:
|
||||||
|
log.warning("rates fetch failed (повтор через 30 минут)")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("rates loop error")
|
||||||
|
await asyncio.sleep(30 * 60)
|
||||||
|
|
||||||
|
|
||||||
|
async def _pipeline_loop() -> None:
|
||||||
|
"""Фоновый воркер очереди входящих: разбирает сообщения каждые 2 секунды."""
|
||||||
|
from .services.pipeline import pump_once
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await pump_once()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("pipeline worker error")
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
|
||||||
|
async def _discovery_loop() -> None:
|
||||||
|
"""Фоновый воркер Discovery: поиск → оценка → авто-вступление (раз в 5 c)."""
|
||||||
|
from .services.discovery_worker import tick
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await tick()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("discovery worker error")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ml_sync_loop() -> None:
|
||||||
|
"""Отправка событий обучения в ML-сервис + кэш его статуса (каждые 10 c)."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await ml_client.flush_outbox()
|
||||||
|
await ml_client.refresh_status()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("ml sync error")
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
|
||||||
|
async def _suggest_loop() -> None:
|
||||||
|
"""Раз в 3 минуты пробуем предложить колонки по «Неразобранному» (кулдаун 20 мин)."""
|
||||||
|
from .services.suggest import suggest_from_inbox
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await suggest_from_inbox(force=False)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("suggest loop error")
|
||||||
|
await asyncio.sleep(180)
|
||||||
|
|
||||||
|
|
||||||
|
async def _tg_sweep_loop() -> None:
|
||||||
|
"""Раз в 30 с: страховочная догонялка непрочитанного по включённым каналам
|
||||||
|
(событие могло потеряться при рестарте/разрыве соединения)."""
|
||||||
|
from .services.telegram import tg as tg_svc
|
||||||
|
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
try:
|
||||||
|
await tg_svc.realtime_sweep()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("tg realtime sweep error")
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
config.ensure_dirs()
|
||||||
|
store.init()
|
||||||
|
ensure_creds()
|
||||||
|
register_main_loop(asyncio.get_running_loop())
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(_storage_loop()),
|
||||||
|
asyncio.create_task(_rates_loop()),
|
||||||
|
asyncio.create_task(_fts_loop()),
|
||||||
|
asyncio.create_task(_pipeline_loop()),
|
||||||
|
asyncio.create_task(_discovery_loop()),
|
||||||
|
asyncio.create_task(_ml_sync_loop()),
|
||||||
|
asyncio.create_task(_suggest_loop()),
|
||||||
|
asyncio.create_task(_tg_sweep_loop()),
|
||||||
|
asyncio.create_task(tg.auto_resume()),
|
||||||
|
]
|
||||||
|
# полнотекстовый поиск: пробуем установить расширение и собрать индекс
|
||||||
|
if fts_svc.install_extension():
|
||||||
|
fts_svc.rebuild()
|
||||||
|
# первичное обновление курсов из ЦБ (если источник cbr)
|
||||||
|
if store.get_setting("rateSource") == "cbr":
|
||||||
|
try:
|
||||||
|
await rates_svc.refresh_rates()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.warning("initial rates fetch failed; продолжаем с мок-курсами")
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
for t in tasks:
|
||||||
|
t.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
await tg.disconnect()
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="LeadRadar", version="1.2.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # локальный dev-режим; в проде замените на свой origin
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# API
|
||||||
|
for mod in (auth_routes, tg_routes, dashboard_routes, projects_routes, settings_routes, events_routes, discovery_routes, ml_routes, processing_routes):
|
||||||
|
app.include_router(mod.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {"ok": True, "service": "leadradar"}
|
||||||
|
|
||||||
|
|
||||||
|
# Статика фронтенда (собранный dist). Если папки нет — API живёт отдельно.
|
||||||
|
_DIST = config.FRONTEND_DIST
|
||||||
|
if _DIST.is_dir():
|
||||||
|
assets = _DIST / "assets"
|
||||||
|
if assets.is_dir():
|
||||||
|
app.mount("/assets", StaticFiles(directory=str(assets)), name="assets")
|
||||||
|
|
||||||
|
@app.get("/{full_path:path}", include_in_schema=False)
|
||||||
|
async def spa(full_path: str):
|
||||||
|
candidate = (_DIST / full_path).resolve()
|
||||||
|
if full_path and candidate.is_file() and candidate.is_relative_to(_DIST):
|
||||||
|
return FileResponse(str(candidate))
|
||||||
|
index = _DIST / "index.html"
|
||||||
|
if index.exists():
|
||||||
|
return FileResponse(str(index))
|
||||||
|
return JSONResponse({"detail": "фронтенд не собран — используйте npm run dev"}, status_code=200)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run("app.main:app", host=config.HOST, port=config.PORT, reload=False)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Пакет роутеров API."""
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Вход в дашборд и сессии (п.4.1 ТЗ)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..auth import (
|
||||||
|
change_password,
|
||||||
|
create_session,
|
||||||
|
current_login,
|
||||||
|
destroy_session,
|
||||||
|
ensure_creds,
|
||||||
|
set_session_cookie,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from ..config import COOKIE_NAME
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginBody(BaseModel):
|
||||||
|
login: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordBody(BaseModel):
|
||||||
|
oldPassword: str
|
||||||
|
newPassword: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/login")
|
||||||
|
def login(body: LoginBody, response: Response) -> dict:
|
||||||
|
ensure_creds()
|
||||||
|
login_name = body.login.strip()
|
||||||
|
if not verify_password(login_name, body.password):
|
||||||
|
raise HTTPException(401, "Неверный логин или пароль")
|
||||||
|
token = create_session(login_name)
|
||||||
|
set_session_cookie(response, token)
|
||||||
|
return {"ok": True, "login": login_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/logout")
|
||||||
|
def logout(request: Request, response: Response) -> dict:
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
destroy_session(token)
|
||||||
|
response.delete_cookie(COOKIE_NAME)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/me")
|
||||||
|
def me(login: str = Depends(current_login)) -> dict:
|
||||||
|
return {"login": login, "ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/change-password")
|
||||||
|
def change(body: PasswordBody, request: Request, response: Response, login: str = Depends(current_login)) -> dict:
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
ok = change_password(login, body.oldPassword, body.newPassword)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(400, "Текущий пароль неверен")
|
||||||
|
# старые сессии удалены внутри change_password; выдаём свежую
|
||||||
|
fresh = create_session(login)
|
||||||
|
destroy_session(token)
|
||||||
|
set_session_cookie(response, fresh)
|
||||||
|
return {"ok": True}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
"""Дашборд: доски, колонки, карточки, поиск, правила хранения."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .. import config
|
||||||
|
from .. import constants as C
|
||||||
|
from ..auth import current_login
|
||||||
|
from ..db import store
|
||||||
|
from ..sse import broker
|
||||||
|
from ..services import fts as fts_svc
|
||||||
|
from ..services import leads as leads_svc
|
||||||
|
from ..services.ai import normalize_dedup
|
||||||
|
from ..services.pipeline import _store_lead, lead_to_dict, stage1_plain
|
||||||
|
from ..services.rates import refresh_rates # noqa: F401 (для админ-тика может пригодиться)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
class BoardCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
color: str | None = None
|
||||||
|
keywords: list[str] | None = None
|
||||||
|
prompt: str = ""
|
||||||
|
rules: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BoardPatch(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
color: str | None = None
|
||||||
|
width: str | None = None
|
||||||
|
collapsed: bool | None = None
|
||||||
|
prompt: str | None = None
|
||||||
|
keywords: list[str] | None = None
|
||||||
|
visibleFields: list[str] | None = None
|
||||||
|
suggested: bool | None = None
|
||||||
|
rules: dict | None = None
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderBody(BaseModel):
|
||||||
|
order: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ColStateBody(BaseModel):
|
||||||
|
collapsed: bool | None = None
|
||||||
|
width: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MoveBody(BaseModel):
|
||||||
|
to: str
|
||||||
|
|
||||||
|
|
||||||
|
class CommentBody(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReclassifyBody(BaseModel):
|
||||||
|
ids: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PumpGateBody(BaseModel):
|
||||||
|
limit: int | None = None # 0/None — выключить; >0 — остановить воркер после N созданных карточек
|
||||||
|
|
||||||
|
|
||||||
|
class CheckMessageBody(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
# Демо-пул для кнопки «Симулировать лид» (без обращения к ИИ)
|
||||||
|
_DEMO_POOL = [
|
||||||
|
("Нужен Middle Python-разработчик на бота для CRM, удалённо, 1 600–2 200$.",
|
||||||
|
{"title": "Middle Python-разработчик на бота для CRM", "summary": "Развитие CRM: бот для менеджеров, интеграция с amoCRM.",
|
||||||
|
"stack": ["Python", "aiogram", "amoCRM"], "budget": {"from": 1600, "to": 2200, "currency": "USD"},
|
||||||
|
"contacts": "@crm_head", "is_vacancy": True, "board": None, "is_spam": False}),
|
||||||
|
("Frontend-разработчик в продуктовую команду, Vue 3, 2 400$.",
|
||||||
|
{"title": "Frontend-разработчик в продуктовую команду", "summary": "Развитие SPA: компоненты, стейт, производительность.",
|
||||||
|
"stack": ["Vue 3", "Pinia", "Vitest"], "budget": {"from": 2400, "to": 2400, "currency": "USD"},
|
||||||
|
"contacts": "@front_hh", "is_vacancy": True, "board": None, "is_spam": False}),
|
||||||
|
("Ищу подрядчика на бота для такси: клиент заказывает, водитель принимает. Бюджет обсуждаем.",
|
||||||
|
{"title": "Бот для заказов такси", "summary": "Клиент заказывает, водитель принимает.",
|
||||||
|
"stack": [], "budget": None, "contacts": "@taxi_owner", "is_vacancy": False, "board": None, "is_spam": False}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _lead_or_404(lead_id: str) -> dict:
|
||||||
|
lead = leads_svc.get_lead(lead_id)
|
||||||
|
if not lead:
|
||||||
|
raise HTTPException(404, "Карточка не найдена")
|
||||||
|
return lead
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Доски ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/boards")
|
||||||
|
def boards(_: str = Depends(current_login)) -> list[dict]:
|
||||||
|
return leads_svc.list_boards()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/boards")
|
||||||
|
def create_board(body: BoardCreate, _: str = Depends(current_login)) -> dict:
|
||||||
|
return leads_svc.create_board(
|
||||||
|
name=body.name,
|
||||||
|
color=body.color,
|
||||||
|
keywords=body.keywords,
|
||||||
|
prompt=body.prompt,
|
||||||
|
description=body.description,
|
||||||
|
rules=body.rules,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/boards/{board_id}")
|
||||||
|
def patch_board(board_id: str, body: BoardPatch, _: str = Depends(current_login)) -> dict:
|
||||||
|
try:
|
||||||
|
return leads_svc.patch_board(board_id, body.model_dump(exclude_none=True))
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "Доска не найдена")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/boards/{board_id}")
|
||||||
|
def delete_board(board_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
moved = leads_svc.delete_board(board_id)
|
||||||
|
return {"ok": True, "movedToInbox": moved}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/boards/reorder")
|
||||||
|
def reorder(body: OrderBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
leads_svc.reorder_boards(body.order)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Состояние колонок ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/columns/state")
|
||||||
|
def columns_state(_: str = Depends(current_login)) -> dict:
|
||||||
|
return leads_svc.get_col_state()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/columns/{col_id}/state")
|
||||||
|
def set_column_state(col_id: str, body: ColStateBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
state = leads_svc.get_col_state().get(col_id, {})
|
||||||
|
state.update(body.model_dump(exclude_none=True))
|
||||||
|
return leads_svc.set_col_state(col_id, state)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Лиды ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/leads")
|
||||||
|
def list_leads(col: str | None = None, _: str = Depends(current_login)) -> dict:
|
||||||
|
if col and col not in ("inbox", "archive", "trash") and not any(b["id"] == col for b in leads_svc.list_boards()):
|
||||||
|
raise HTTPException(400, "Неизвестная колонка")
|
||||||
|
return {"items": leads_svc.list_leads(col)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/leads/counts")
|
||||||
|
def leads_counts(_: str = Depends(current_login)) -> dict:
|
||||||
|
return leads_svc.counts()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/leads/{lead_id}")
|
||||||
|
def get_lead(lead_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
return _lead_or_404(lead_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/leads/{lead_id}/seen")
|
||||||
|
def seen(lead_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
leads_svc.mark_seen(lead_id=lead_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/leads/mark-all-seen")
|
||||||
|
def mark_all_seen(_: str = Depends(current_login)) -> dict:
|
||||||
|
leads_svc.mark_seen()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class MarkColBody(BaseModel):
|
||||||
|
col: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/leads/mark-col-seen")
|
||||||
|
def mark_col_seen(body: MarkColBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""Снять «новое» с колонки (используется при открытии колонки из сайдбара)."""
|
||||||
|
leads_svc.mark_seen(col=body.col)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/leads/{lead_id}/move")
|
||||||
|
def move(lead_id: str, body: MoveBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
try:
|
||||||
|
leads_svc.move_lead(lead_id, body.to)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return _lead_or_404(lead_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/leads/{lead_id}/trash")
|
||||||
|
def trash(lead_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
_lead_or_404(lead_id)
|
||||||
|
leads_svc.trash_lead(lead_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/leads/{lead_id}/restore")
|
||||||
|
def restore(lead_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
_lead_or_404(lead_id)
|
||||||
|
back = leads_svc.restore_lead(lead_id)
|
||||||
|
return {"ok": True, "col": back}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/leads/{lead_id}")
|
||||||
|
def delete(lead_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
_lead_or_404(lead_id)
|
||||||
|
leads_svc.delete_forever(lead_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ClearColBody(BaseModel):
|
||||||
|
col: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/leads/clear-col")
|
||||||
|
def clear_col(body: ClearColBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""Ручная полная очистка корзины/архива (безвозвратно)."""
|
||||||
|
try:
|
||||||
|
cleared = leads_svc.clear_col(body.col)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return {"ok": True, "cleared": cleared}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/leads/{lead_id}/comments")
|
||||||
|
def comment(lead_id: str, body: CommentBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
if not body.text.strip():
|
||||||
|
raise HTTPException(400, "Пустой комментарий")
|
||||||
|
return {"comments": leads_svc.add_comment(lead_id, body.text)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/leads/reclassify")
|
||||||
|
async def reclassify(body: ReclassifyBody | None = None, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""Переклассификация «Неразобранного»: фоновая задача (одна за раз)."""
|
||||||
|
ids = body.ids if body else None
|
||||||
|
return await leads_svc.start_reclassify(ids)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Поиск ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/search")
|
||||||
|
def search(q: str = "", _: str = Depends(current_login)) -> dict:
|
||||||
|
return leads_svc.search(q)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Админ-тик (правила хранения) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/admin/fts/rebuild")
|
||||||
|
def fts_rebuild(_: str = Depends(current_login)) -> dict:
|
||||||
|
ok = fts_svc.rebuild()
|
||||||
|
return {"ok": ok, "ready": fts_svc.is_ready()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/check-message")
|
||||||
|
async def check_message(body: CheckMessageBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""Проверка фильтра входящих (этап 1 + этап 2) для тестера в настройках."""
|
||||||
|
from ..services.ai import filter_incoming
|
||||||
|
|
||||||
|
r1 = stage1_plain(body.text)
|
||||||
|
result = {"stage1": {"pass": r1["pass"], "reason": r1["reason"]}}
|
||||||
|
if not r1["pass"]:
|
||||||
|
result["stage2"] = {"pass": False, "reason": None, "skipped": True}
|
||||||
|
result["passed"] = False
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
r2 = await filter_incoming(body.text)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
r2 = {"pass": True, "reason": None, "skipped": True}
|
||||||
|
result["stage2"] = r2
|
||||||
|
result["passed"] = bool(r2["pass"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _demo_off() -> None:
|
||||||
|
if not config.DEMO_ENABLED:
|
||||||
|
raise HTTPException(404, "Демо-режим отключён")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/demo/simulate-lead")
|
||||||
|
async def simulate_lead(_: str = Depends(current_login)) -> dict:
|
||||||
|
"""Служебный демо-эндпоинт (включён только при LEADRADAR_DEMO=1)."""
|
||||||
|
_demo_off()
|
||||||
|
import random
|
||||||
|
|
||||||
|
text, raw = random.choice(_DEMO_POOL)
|
||||||
|
digest = "demo_" + secrets.token_hex(8)
|
||||||
|
lead = _store_lead(
|
||||||
|
digest, "demo_channel", "Демо-канал", "demo_channel", "#8b8ff8", text, raw, time.time_ns() // 1_000_000,
|
||||||
|
)
|
||||||
|
if lead:
|
||||||
|
await broker.publish("new_lead", lead)
|
||||||
|
await broker.publish_toast("Демо: новый лид", "sparkles")
|
||||||
|
return lead or {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/demo/age-lead")
|
||||||
|
async def demo_age_lead(_: str = Depends(current_login)) -> dict:
|
||||||
|
"""Служебный демо-эндпоинт (включён только при LEADRADAR_DEMO=1)."""
|
||||||
|
_demo_off()
|
||||||
|
days = int(store.get_setting("archiveAfterDays") or 14)
|
||||||
|
row = store.query_one(
|
||||||
|
"SELECT id FROM leads WHERE col IN (SELECT id FROM boards) ORDER BY received_at ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(400, "Нет карточек на досках для демо")
|
||||||
|
aged = time.time_ns() // 1_000_000 - (days + 1) * C.DAY_MS
|
||||||
|
store.execute("UPDATE leads SET received_at = ? WHERE id = ?", [aged, row["id"]])
|
||||||
|
stats = leads_svc.tick_storage()
|
||||||
|
if stats["archived"]:
|
||||||
|
await broker.publish_toast(f"Демо: карточка → Архив (старше {days + 1} дн.)", "clock")
|
||||||
|
return {"ok": True, "stats": stats}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/tick")
|
||||||
|
async def admin_tick(_: str = Depends(current_login)) -> dict:
|
||||||
|
from ..services import projects as projects_svc
|
||||||
|
from ..services.pipeline import pump_once, queue_len
|
||||||
|
|
||||||
|
stats = leads_svc.tick_storage()
|
||||||
|
await leads_svc.notify_tick_stats(stats)
|
||||||
|
due = await projects_svc.check_reminders()
|
||||||
|
# разгребаем очередь входящих (этап 1 -> правила -> ML/ИИ)
|
||||||
|
pump = await pump_once()
|
||||||
|
return {"storage": stats, "reminders": due, "pipeline": pump, "queue": queue_len()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/wipe")
|
||||||
|
async def admin_wipe(_: str = Depends(current_login)) -> dict:
|
||||||
|
"""Полный сброс под новый прогон: все карточки, обучение ML, счётчики ИИ/ML.
|
||||||
|
|
||||||
|
Удаляет карточки со всех колонок (включая архив/корзину), исходные
|
||||||
|
сообщения, журнал обучения, очередь обучения и очередь входящих; сбрасывает
|
||||||
|
модель ML и счётчики реальных действий. Колонки/доски, каналы и настройки
|
||||||
|
не трогаются.
|
||||||
|
"""
|
||||||
|
from ..services import ml_client
|
||||||
|
from ..services.ml_client import DECISIONS_AI, DECISIONS_ML
|
||||||
|
|
||||||
|
n_leads = int(store.scalar("SELECT count(*) FROM leads") or 0)
|
||||||
|
for tbl in ("leads", "dedup", "messages", "learning_log", "ml_outbox", "pipeline_msg", "rejected_msgs"):
|
||||||
|
store.execute(f"DELETE FROM {tbl}")
|
||||||
|
store.set_setting(DECISIONS_ML, 0)
|
||||||
|
store.set_setting(DECISIONS_AI, 0)
|
||||||
|
fts_svc.rebuild()
|
||||||
|
ml = await ml_client.reset_model()
|
||||||
|
return {"ok": True, "cardsRemoved": n_leads, "ml": ml}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/clear-cards")
|
||||||
|
async def admin_clear_cards(_: str = Depends(current_login)) -> dict:
|
||||||
|
"""Очистить карточки и очереди для повторного прогона, НЕ трогая ML.
|
||||||
|
|
||||||
|
Удаляет карточки (все колонки/архив/корзина), исходники, дедуп, журнал
|
||||||
|
обучения и очередь входящих. Модель ML, очередь её обучения (ml_outbox)
|
||||||
|
и счётчики ИИ/ML остаются нетронутыми — нужно для тестов «обучить ML на
|
||||||
|
прогоне с ИИ, затем прогнать те же сообщения без ИИ».
|
||||||
|
"""
|
||||||
|
n_leads = int(store.scalar("SELECT count(*) FROM leads") or 0)
|
||||||
|
for tbl in ("leads", "dedup", "messages", "learning_log", "pipeline_msg", "rejected_msgs"):
|
||||||
|
store.execute(f"DELETE FROM {tbl}")
|
||||||
|
fts_svc.rebuild()
|
||||||
|
return {"ok": True, "cardsRemoved": n_leads}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/pump-gate")
|
||||||
|
def pump_gate(body: PumpGateBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""Шлагбаум пайплайна: остановить воркер после N созданных карточек.
|
||||||
|
|
||||||
|
limit=0 или None — снять ограничение (воркер продолжит разбирать очередь);
|
||||||
|
limit>0 — обнулить счётчик и останавливаться после N карточек (проверка
|
||||||
|
результата: «прогон с остановкой после первых N сообщений»).
|
||||||
|
"""
|
||||||
|
if body.limit is not None:
|
||||||
|
v = max(0, int(body.limit))
|
||||||
|
store.set_setting("pumpGate", v)
|
||||||
|
store.set_setting("pumpGateDone", 0)
|
||||||
|
limit = int(store.get_setting("pumpGate") or 0)
|
||||||
|
done = int(store.get_setting("pumpGateDone") or 0)
|
||||||
|
return {"ok": True, "limit": limit, "done": done}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ai/suggest-columns")
|
||||||
|
async def ai_suggest_columns(_: str = Depends(current_login)) -> dict:
|
||||||
|
"""Ручной запуск анализа «Неразобранного»: ИИ предлагает колонки."""
|
||||||
|
from ..services.suggest import suggest_from_inbox
|
||||||
|
|
||||||
|
result = await suggest_from_inbox(force=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ai/suggest-keywords")
|
||||||
|
async def ai_suggest_keywords(_: str = Depends(current_login)) -> dict:
|
||||||
|
"""ИИ предлагает общие ключевые слова-маркеры сферы по вашим карточкам."""
|
||||||
|
from ..services.suggest import suggest_domain_keywords
|
||||||
|
|
||||||
|
return await suggest_domain_keywords()
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""API Discovery (Task 7): задачи поиска каналов, кандидаты, чёрный список, лог.
|
||||||
|
|
||||||
|
Prefix /api/discovery, авторизация — current_login (как в соседних роутерах).
|
||||||
|
Сервис discovery отдаёт наружу camelCase-словари (см. его docstring), поэтому
|
||||||
|
Pydantic-модели повторяют имена полей API без алиасов (как PreviewBody в tg_routes).
|
||||||
|
Списки наружу — {"items": [...]}, единичные объекты — как есть (конвенция проекта).
|
||||||
|
|
||||||
|
Обработка ошибок контракта: ValueError -> HTTP 400, KeyError -> HTTP 404.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..auth import current_login
|
||||||
|
from ..db import store
|
||||||
|
from ..services import ai as ai_service
|
||||||
|
from ..services import discovery
|
||||||
|
from ..services.telegram import _spawn, tg
|
||||||
|
|
||||||
|
log = logging.getLogger("leadradar.discovery_api")
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/discovery", tags=["discovery"])
|
||||||
|
|
||||||
|
# потолок текста описания, уходящего ИИ-генератору ключей
|
||||||
|
_AI_DESCRIPTION_LIMIT = 4000
|
||||||
|
# страховочный потолок числа сгенерированных ключей (промпт просит 10–16)
|
||||||
|
_KEYWORDS_LIMIT = 30
|
||||||
|
# потолок длины одного ключа (короткие фразы для поиска Telegram)
|
||||||
|
_KEYWORD_LENGTH_LIMIT = 60
|
||||||
|
|
||||||
|
# промпт генерации ключевых слов по описанию задачи (RU+EN, глобальный поиск)
|
||||||
|
_KEYWORDS_PROMPT = (
|
||||||
|
"Ты — эксперт по поиску Telegram-каналов и групп. По описанию ниши/задачи "
|
||||||
|
"составь поисковые ключевые слова, по которым в глобальном поиске Telegram "
|
||||||
|
"находят подходящие источники. Верни строго JSON вида "
|
||||||
|
'{"keywords": ["...", "..."]}. Требования к списку:\n'
|
||||||
|
"- 10–16 ключей;\n"
|
||||||
|
"- примерно поровну русских и английских (английские — популярные в нише термины);\n"
|
||||||
|
"- короткие фразы 1–4 слова;\n"
|
||||||
|
"- без #, @, кавычек и лишней пунктуации;\n"
|
||||||
|
"- конкретные для ниши, включая сленг заказчиков и подрядчиков;\n"
|
||||||
|
"- без дублей и близких по смыслу повторов."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
keywords: list[str] = []
|
||||||
|
minSubscribers: int = 0
|
||||||
|
lang: str = "ru"
|
||||||
|
threshold: int | None = None
|
||||||
|
sampleSize: int | None = None
|
||||||
|
planJoins: int = 1
|
||||||
|
autoJoin: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPatch(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
keywords: list[str] | None = None
|
||||||
|
minSubscribers: int | None = None
|
||||||
|
lang: str | None = None
|
||||||
|
threshold: int | None = None
|
||||||
|
sampleSize: int | None = None
|
||||||
|
planJoins: int | None = None
|
||||||
|
autoJoin: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(body: BaseModel) -> dict:
|
||||||
|
"""Поля модели -> payload сервиса, без явных None.
|
||||||
|
|
||||||
|
discovery.create_task/patch_task сами подставляют значения по умолчанию
|
||||||
|
(в т.ч. threshold/sampleSize из настроек), поэтому None-поля пропускаем.
|
||||||
|
"""
|
||||||
|
return body.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_or_404(task_id: str) -> dict:
|
||||||
|
task = discovery.get_task(task_id)
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(404, "Задача не найдена")
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_or_404(dialog_id: str) -> dict:
|
||||||
|
row = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, "Кандидат не найден")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_unavailable_reason() -> str | None:
|
||||||
|
"""Причина недоступности ИИ (None — можно вызывать)."""
|
||||||
|
if not store.get_setting("aiEnabled"):
|
||||||
|
return "ИИ выключен в настройках (aiEnabled)"
|
||||||
|
try:
|
||||||
|
status = ai_service.provider_status()
|
||||||
|
except Exception as exc: # noqa: BLE001 — статус не читается = ИИ недоступен
|
||||||
|
log.debug("generate-keywords: статус ИИ недоступен (%s)", exc)
|
||||||
|
return "Не удалось прочитать статус ИИ-провайдера"
|
||||||
|
if not (status.get("local") or status.get("keySet")):
|
||||||
|
return "Не задан API-ключ ИИ-провайдера"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_keywords(raw) -> list[str]:
|
||||||
|
"""Ключи из ответа ИИ: строки без пустых/длинных и повторов (casefold)."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for item in raw or []:
|
||||||
|
if not isinstance(item, str):
|
||||||
|
continue
|
||||||
|
keyword = item.strip()
|
||||||
|
if not keyword or len(keyword) > _KEYWORD_LENGTH_LIMIT:
|
||||||
|
continue
|
||||||
|
key = keyword.casefold()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(keyword)
|
||||||
|
if len(out) >= _KEYWORDS_LIMIT:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _backfill_quiet(dialog_id: str) -> None:
|
||||||
|
"""Догон последних сообщений вступившего источника (фон, best-effort)."""
|
||||||
|
try:
|
||||||
|
await tg.backfill_dialog(dialog_id)
|
||||||
|
except Exception as exc: # noqa: BLE001 — вступление уже состоялось
|
||||||
|
log.warning("join %s: backfill не удался: %s", dialog_id, exc)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── задачи ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/tasks")
|
||||||
|
def list_tasks(_: str = Depends(current_login)) -> dict:
|
||||||
|
return {"items": discovery.list_tasks()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks")
|
||||||
|
def create_task(body: TaskCreate, _: str = Depends(current_login)) -> dict:
|
||||||
|
try:
|
||||||
|
return discovery.create_task(_payload(body))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/tasks/{task_id}")
|
||||||
|
def patch_task(task_id: str, body: TaskPatch, _: str = Depends(current_login)) -> dict:
|
||||||
|
try:
|
||||||
|
return discovery.patch_task(task_id, _payload(body))
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(404, "Задача не найдена") from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tasks/{task_id}")
|
||||||
|
def delete_task(task_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
_task_or_404(task_id)
|
||||||
|
discovery.delete_task(task_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/start")
|
||||||
|
def start_task(task_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
try:
|
||||||
|
return discovery.start_task(task_id)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(404, "Задача не найдена") from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/pause")
|
||||||
|
def pause_task(task_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
try:
|
||||||
|
return discovery.pause_task(task_id)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(404, "Задача не найдена") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/generate-keywords")
|
||||||
|
async def generate_keywords(task_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""ИИ-генерация ключей по описанию задачи: RU+EN, 10–16 строк.
|
||||||
|
|
||||||
|
ИИ выключен/не настроен/ответил ошибкой — {"keywords": [], "error": "..."}
|
||||||
|
с HTTP 200, чтобы UI показал причину, а не падал.
|
||||||
|
"""
|
||||||
|
task = _task_or_404(task_id)
|
||||||
|
reason = _ai_unavailable_reason()
|
||||||
|
if reason:
|
||||||
|
return {"keywords": [], "error": reason}
|
||||||
|
description = str(task.get("description") or "").strip()
|
||||||
|
if not description:
|
||||||
|
return {"keywords": [], "error": "У задачи нет описания — по нему генерируются ключи"}
|
||||||
|
try:
|
||||||
|
out = await ai_service.chat_json(
|
||||||
|
_KEYWORDS_PROMPT,
|
||||||
|
f"Описание ниши/задачи:\n{description[:_AI_DESCRIPTION_LIMIT]}",
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — сбой провайдера не роняет API
|
||||||
|
log.warning("generate-keywords задача %s: ИИ не ответил: %s", task_id, exc)
|
||||||
|
return {"keywords": [], "error": str(exc)}
|
||||||
|
return {"keywords": _clean_keywords(out.get("keywords", []) if isinstance(out, dict) else [])}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── кандидаты ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/candidates")
|
||||||
|
def list_candidates(
|
||||||
|
task_id: str,
|
||||||
|
status: Literal["new", "review", "joined", "rejected"] | None = None,
|
||||||
|
_: str = Depends(current_login),
|
||||||
|
) -> dict:
|
||||||
|
_task_or_404(task_id)
|
||||||
|
return {"items": discovery.list_candidates(task_id, status)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/candidates/{dialog_id}/join")
|
||||||
|
async def join_candidate(dialog_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""Ручное вступление (вне квот и пауз воркера).
|
||||||
|
|
||||||
|
tg.discovery_join -> add_dialog_monitored -> backfill_dialog (последние
|
||||||
|
сообщения, best-effort) -> mark_joined(auto=False); источник снимается с
|
||||||
|
чёрного списка. Ошибка Telegram -> 400 с текстом причины.
|
||||||
|
"""
|
||||||
|
row = _candidate_or_404(dialog_id)
|
||||||
|
if row["status"] == "joined":
|
||||||
|
raise HTTPException(400, "Уже вступили в этот источник")
|
||||||
|
username = str(row.get("username") or "")
|
||||||
|
try:
|
||||||
|
await tg.discovery_join(username)
|
||||||
|
except Exception as exc: # текст ошибки уходит наружу
|
||||||
|
raise HTTPException(400, f"Не удалось вступить в @{username}: {exc}") from exc
|
||||||
|
tg.add_dialog_monitored(dialog_id, row.get("name"), username, row.get("kind"), row.get("hue"))
|
||||||
|
# догон последних сообщений — в фоне: join из UI не должен висеть на
|
||||||
|
# паузах backfill (10 сообщений × 1.5–3 с); источник уже в мониторинге
|
||||||
|
_spawn(_backfill_quiet(dialog_id))
|
||||||
|
discovery.remove_blacklist(dialog_id)
|
||||||
|
try:
|
||||||
|
return discovery.mark_joined(dialog_id, auto=False)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(404, "Кандидат не найден") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/candidates/{dialog_id}/reject")
|
||||||
|
def reject_candidate(dialog_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""Отклонить кандидата (в чёрный список). Уже вступившего — нельзя."""
|
||||||
|
row = _candidate_or_404(dialog_id)
|
||||||
|
if row["status"] == "joined":
|
||||||
|
raise HTTPException(400, "Уже вступили — удалите источник из каналов")
|
||||||
|
try:
|
||||||
|
return discovery.mark_rejected(dialog_id, reason="отклонено вручную")
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(404, "Кандидат не найден") from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ─── чёрный список ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/blacklist")
|
||||||
|
def list_blacklist(_: str = Depends(current_login)) -> dict:
|
||||||
|
return {"items": discovery.list_blacklist()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/blacklist/{dialog_id}")
|
||||||
|
def remove_blacklist(dialog_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
discovery.remove_blacklist(dialog_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── лог задачи ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/log")
|
||||||
|
def task_log(task_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
_task_or_404(task_id)
|
||||||
|
return {"items": discovery.task_log(task_id)}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""SSE-события (realtime) для фронтенда."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from ..auth import current_login
|
||||||
|
from ..sse import broker
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["events"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
async def events(request: Request, _: str = Depends(current_login)):
|
||||||
|
async def stream():
|
||||||
|
q = await broker.subscribe()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
yield await asyncio.wait_for(q.get(), timeout=15)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
yield ": ping\n\n"
|
||||||
|
finally:
|
||||||
|
await broker.unsubscribe(q)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""ML-лаборатория: статус, проверка на сообщении, обучение на канале.
|
||||||
|
|
||||||
|
Всё обучение уходит в outbox (гарантированно), фоновый цикл отправляет его
|
||||||
|
в автономный ML-сервис. Использование ML в пайплайне — только по настройке
|
||||||
|
`mlEnabled`; здесь можно проверить модель и вручную разметить сообщения.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..auth import current_login
|
||||||
|
from ..db import store
|
||||||
|
from ..services import leads as leads_svc
|
||||||
|
from ..services import ml_client
|
||||||
|
from ..services.telegram import tg
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/ml", tags=["ml"])
|
||||||
|
|
||||||
|
|
||||||
|
class PredictBody(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class LearnBody(BaseModel):
|
||||||
|
text: str
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
class CandidatesBody(BaseModel):
|
||||||
|
dialogId: str
|
||||||
|
limit: int = 10
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyBody(BaseModel):
|
||||||
|
dialogId: str
|
||||||
|
msgId: int
|
||||||
|
action: str # 'spam' | 'board:<id>' | 'skip'
|
||||||
|
|
||||||
|
|
||||||
|
def _msg_text(dialog_id: str, msg_id: int) -> str | None:
|
||||||
|
"""Текст исходного сообщения (из карточки или из messages)."""
|
||||||
|
row = store.query_one(
|
||||||
|
"SELECT source_msg AS text FROM leads WHERE source_dialog_id = ? AND source_msg_id = ? LIMIT 1",
|
||||||
|
[dialog_id, msg_id],
|
||||||
|
)
|
||||||
|
if row and row["text"]:
|
||||||
|
return row["text"]
|
||||||
|
m = store.query_one("SELECT text FROM messages WHERE id = ?", [f"m_{dialog_id}_{msg_id}"])
|
||||||
|
return m["text"] if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _lead_by_msg(dialog_id: str, msg_id: int) -> dict | None:
|
||||||
|
row = store.query_one(
|
||||||
|
"SELECT id FROM leads WHERE source_dialog_id = ? AND source_msg_id = ? LIMIT 1",
|
||||||
|
[dialog_id, msg_id],
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
return leads_svc.get_lead(row["id"])
|
||||||
|
link = store.query_one("SELECT lead_id FROM messages WHERE id = ?", [f"m_{dialog_id}_{msg_id}"])
|
||||||
|
if link and link["lead_id"]:
|
||||||
|
return leads_svc.get_lead(link["lead_id"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def ml_status(_: str = Depends(current_login)) -> dict:
|
||||||
|
"""Свежий статус ML-сервиса + локальная статистика (с принудительным refresh)."""
|
||||||
|
svc = await ml_client.refresh_status()
|
||||||
|
return {
|
||||||
|
"enabled": store.get_setting("mlEnabled") is not False,
|
||||||
|
"service": svc,
|
||||||
|
"reachable": ml_client._cached["reachable"], # noqa: SLF001
|
||||||
|
"stats": ml_client.snapshot(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset")
|
||||||
|
async def ml_reset(_: str = Depends(current_login)) -> dict:
|
||||||
|
"""Полный сброс ML-модели (классы/термины) + очистка очереди обучения."""
|
||||||
|
return await ml_client.reset_model()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/predict")
|
||||||
|
async def ml_predict(body: PredictBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
text = (body.text or "").strip()
|
||||||
|
if len(text) < 2:
|
||||||
|
raise HTTPException(400, "Введите текст")
|
||||||
|
result = await ml_client.predict(text)
|
||||||
|
return {"text": text[:200], **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/learn")
|
||||||
|
async def ml_learn(body: LearnBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""Ручная разметка: «это сообщение -> сюда». Пишется в outbox (всегда)."""
|
||||||
|
text = (body.text or "").strip()
|
||||||
|
label = (body.label or "").strip()
|
||||||
|
if not text or not label:
|
||||||
|
raise HTTPException(400, "text и label обязательны")
|
||||||
|
ml_client.push(text, label)
|
||||||
|
return {"ok": True, "outbox": ml_client.outbox_len()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/flush")
|
||||||
|
async def ml_flush(_: str = Depends(current_login)) -> dict:
|
||||||
|
"""Отправить накопленное обучение в ML-сервис немедленно (обычно — фон раз в 10 c)."""
|
||||||
|
flushed = await ml_client.flush_outbox()
|
||||||
|
svc = await ml_client.refresh_status()
|
||||||
|
return {"ok": True, "flushed": flushed, "outbox": ml_client.outbox_len(), "service": svc}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/candidates")
|
||||||
|
async def ml_candidates(body: CandidatesBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""Последние сообщения канала для разбора/обучения + мнение ML по каждому."""
|
||||||
|
limit = max(1, min(body.limit, 60))
|
||||||
|
items = await tg.dialog_messages(body.dialogId, limit)
|
||||||
|
out = []
|
||||||
|
for m in items:
|
||||||
|
text = (m.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
pred = await ml_client.predict(text) if ml_client.is_enabled() else {
|
||||||
|
"take": False, "label": None, "scores": {}, "ready": False}
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": m["id"],
|
||||||
|
"dialogId": body.dialogId,
|
||||||
|
"text": text[:600],
|
||||||
|
"time": m.get("time"),
|
||||||
|
"lead": bool(m.get("lead")),
|
||||||
|
"pred": {"take": pred.get("take"), "label": pred.get("label"), "scores": pred.get("scores", {})},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"items": out}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/apply")
|
||||||
|
async def ml_apply(body: ApplyBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""Ручное решение по сообщению: учим ML и (если карточка есть) двигаем её."""
|
||||||
|
text = _msg_text(body.dialogId, body.msgId)
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(404, "Исходное сообщение не найдено")
|
||||||
|
action = body.action
|
||||||
|
if action == "skip":
|
||||||
|
return {"ok": True, "learned": False, "moved": None}
|
||||||
|
|
||||||
|
lead = _lead_by_msg(body.dialogId, body.msgId)
|
||||||
|
result: dict = {"ok": True, "learned": False, "moved": None, "leadId": None}
|
||||||
|
|
||||||
|
if action == "spam":
|
||||||
|
ml_client.push(text, "spam")
|
||||||
|
result["learned"] = True
|
||||||
|
if lead:
|
||||||
|
leads_svc.trash_lead(lead["id"], teach=False)
|
||||||
|
result["moved"] = "trash"
|
||||||
|
result["leadId"] = lead["id"]
|
||||||
|
elif action.startswith("board:"):
|
||||||
|
board_id = action.split(":", 1)[1]
|
||||||
|
if board_id != "inbox" and store.query_one("SELECT 1 FROM boards WHERE id = ?", [board_id]) is None:
|
||||||
|
raise HTTPException(400, "Неизвестная доска")
|
||||||
|
ml_client.push(text, board_id)
|
||||||
|
result["learned"] = True
|
||||||
|
if lead:
|
||||||
|
# повторная разметка карточки, которая уже на доске, — просто обучение
|
||||||
|
if lead["col"] != board_id:
|
||||||
|
leads_svc.move_lead(lead["id"], board_id, teach=False)
|
||||||
|
result["moved"] = board_id
|
||||||
|
result["leadId"] = lead["id"]
|
||||||
|
else:
|
||||||
|
raise HTTPException(400, "Неизвестное действие")
|
||||||
|
return result
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..auth import current_login
|
||||||
|
from ..services import processing as processing_svc
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/pipeline", tags=["processing"])
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnBody(BaseModel):
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def stats(_: str = Depends(current_login)) -> dict:
|
||||||
|
"""Сводка: размер очереди (new/ai) и число записей в отсеве."""
|
||||||
|
return processing_svc.stats()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/queue")
|
||||||
|
def queue(limit: int = 100, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""Сырые сообщения, ожидающие обработки (этап 1 или ИИ)."""
|
||||||
|
q = processing_svc.queue_counts()
|
||||||
|
return {
|
||||||
|
"items": processing_svc.list_queue(limit),
|
||||||
|
"counts": q,
|
||||||
|
"rejected": processing_svc.rejected_count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rejected")
|
||||||
|
def rejected(
|
||||||
|
q: str = "",
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
_: str = Depends(current_login),
|
||||||
|
) -> dict:
|
||||||
|
"""Отсев: что и почему не прошло пайплайн (полнотекстовый поиск по q)."""
|
||||||
|
return processing_svc.list_rejected(q, offset, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rejected/clear")
|
||||||
|
def rejected_clear(_: str = Depends(current_login)) -> dict:
|
||||||
|
"""Ручная полная очистка отсева (безвозвратно)."""
|
||||||
|
cleared = processing_svc.clear_all()
|
||||||
|
return {"ok": True, "cleared": cleared}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/rejected/{rej_id}")
|
||||||
|
def rejected_delete(rej_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
processing_svc.delete_one(rej_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rejected/{rej_id}/return")
|
||||||
|
def rejected_return(rej_id: str, body: ReturnBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""Вернуть отсеянное сообщение в обработку.
|
||||||
|
|
||||||
|
Для возвращённого сообщения причины отсева (стоп-лист, резюме, тип,
|
||||||
|
без суммы, устарело, ML/ИИ-спам) игнорируются: оно уходит на ML/ИИ
|
||||||
|
и создаёт карточку. ML обучается на действии (снятие «спама»), а решение
|
||||||
|
ИИ по возвращённому сообщению снова учит ML. Запись в отсеве помечается
|
||||||
|
«возвращено» с указанной пользователем причиной.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
out = processing_svc.return_to_queue(rej_id, body.reason)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(404, "Запись не найдена") from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return out
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""«Выбранные»: проектные карточки, стадии, история, напоминания, вложения."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..auth import current_login
|
||||||
|
from ..services import files as files_svc
|
||||||
|
from ..services import object_store
|
||||||
|
from ..services import projects as proj_svc
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/projects", tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
|
class TakeBody(BaseModel):
|
||||||
|
leadId: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateBody(BaseModel):
|
||||||
|
title: str = ""
|
||||||
|
summary: str = ""
|
||||||
|
stack: list[str] | None = None
|
||||||
|
budget: dict | None = None
|
||||||
|
contact: str = ""
|
||||||
|
tzText: str = ""
|
||||||
|
stage: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PatchBody(BaseModel):
|
||||||
|
title: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
stack: list[str] | None = None
|
||||||
|
budget: dict | None = None
|
||||||
|
contact: str | None = None
|
||||||
|
tzText: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StageBody(BaseModel):
|
||||||
|
stage: str
|
||||||
|
|
||||||
|
|
||||||
|
class CommentBody(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class LinkBody(BaseModel):
|
||||||
|
name: str = ""
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderBody(BaseModel):
|
||||||
|
at: int # epoch ms
|
||||||
|
|
||||||
|
|
||||||
|
def _card_or_404(card_id: str) -> dict:
|
||||||
|
card = proj_svc.get_card(card_id)
|
||||||
|
if not card:
|
||||||
|
raise HTTPException(404, "Карточка не найдена")
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_cards(stage: str | None = None, _: str = Depends(current_login)) -> dict:
|
||||||
|
return {"items": proj_svc.list_cards(stage)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reminders")
|
||||||
|
def reminders(_: str = Depends(current_login)) -> dict:
|
||||||
|
return {"items": proj_svc.active_reminders()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{card_id}")
|
||||||
|
def get_card(card_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
return _card_or_404(card_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_card(body: CreateBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
return proj_svc.create_local_card(body.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/take")
|
||||||
|
def take(body: TakeBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""«Взять в работу» — лид безвозвратно уходит с дашборда."""
|
||||||
|
try:
|
||||||
|
return proj_svc.take_lead_to_projects(body.leadId)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "Лид не найден")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clear-rejected")
|
||||||
|
def clear_rejected(_: str = Depends(current_login)) -> dict:
|
||||||
|
"""Полная очистка стадии «Отклонено» (безвозвратно)."""
|
||||||
|
try:
|
||||||
|
cleared = proj_svc.clear_stage("rejected")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return {"ok": True, "cleared": cleared}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{card_id}")
|
||||||
|
def patch(card_id: str, body: PatchBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
_card_or_404(card_id)
|
||||||
|
return proj_svc.patch_card(card_id, body.model_dump(exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{card_id}")
|
||||||
|
def delete(card_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
# По решению удаление проектных карточек не делаем — карточка живёт
|
||||||
|
# до финального статуса «Выполнено»/«Отклонено».
|
||||||
|
raise HTTPException(400, "Удаление проектных карточек отключено")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{card_id}/move")
|
||||||
|
def move(card_id: str, body: StageBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
_card_or_404(card_id)
|
||||||
|
try:
|
||||||
|
return proj_svc.move_stage(card_id, body.stage)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{card_id}/comments")
|
||||||
|
def comment(card_id: str, body: CommentBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
if not body.text.strip():
|
||||||
|
raise HTTPException(400, "Пустой комментарий")
|
||||||
|
return {"comments": proj_svc.add_comment(card_id, body.text)}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Ссылки ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{card_id}/links")
|
||||||
|
def add_link(card_id: str, body: LinkBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
_card_or_404(card_id)
|
||||||
|
url = body.url.strip()
|
||||||
|
if not url:
|
||||||
|
raise HTTPException(400, "Пустая ссылка")
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
url = "https://" + url
|
||||||
|
card = _card_or_404(card_id)
|
||||||
|
links = card["links"] + [{"id": f"pl_{card_id[:6]}_{len(card['links'])}", "name": body.name.strip() or url, "url": url}]
|
||||||
|
return proj_svc.patch_card(card_id, {"links": links})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{card_id}/links/{link_id}")
|
||||||
|
def remove_link(card_id: str, link_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
card = _card_or_404(card_id)
|
||||||
|
links = [l for l in card["links"] if l["id"] != link_id]
|
||||||
|
return proj_svc.patch_card(card_id, {"links": links})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Вложения (мета; MinIO позже) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{card_id}/files")
|
||||||
|
async def upload_files(card_id: str, files: list[UploadFile], _: str = Depends(current_login)) -> dict:
|
||||||
|
_card_or_404(card_id)
|
||||||
|
added = []
|
||||||
|
for f in files:
|
||||||
|
content = await f.read()
|
||||||
|
added.append(files_svc.add_file(card_id, f.filename or "file", content, f.content_type or ""))
|
||||||
|
return {"items": added}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{card_id}/files/{file_id}/download")
|
||||||
|
def download_file(card_id: str, file_id: str, _: str = Depends(current_login)):
|
||||||
|
entry, _ = files_svc.get_file_entry(card_id, file_id)
|
||||||
|
if not entry.get("objectKey"):
|
||||||
|
raise HTTPException(410, "Файл не сохранён в объектном хранилище")
|
||||||
|
try:
|
||||||
|
stream = object_store.get(entry["objectKey"])
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(404, "Файл не найден в MinIO") from exc
|
||||||
|
safe_name = entry["name"].replace('"', "")
|
||||||
|
return StreamingResponse(
|
||||||
|
stream,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{card_id}/files/{file_id}")
|
||||||
|
def remove_file(card_id: str, file_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
_card_or_404(card_id)
|
||||||
|
files_svc.remove_file(card_id, file_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Напоминания об «Отложено» ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{card_id}/reminder")
|
||||||
|
def set_reminder(card_id: str, body: ReminderBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
_card_or_404(card_id)
|
||||||
|
try:
|
||||||
|
return proj_svc.set_reminder(card_id, body.at)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{card_id}/reminder")
|
||||||
|
def clear_reminder(card_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
_card_or_404(card_id)
|
||||||
|
proj_svc.clear_reminder(card_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{card_id}/reminder/snooze")
|
||||||
|
def snooze(card_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
_card_or_404(card_id)
|
||||||
|
proj_svc.snooze(card_id)
|
||||||
|
return {"ok": True}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""Настройки, AI-провайдеры, курсы валют (роутер /api)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from .. import constants as C
|
||||||
|
from ..auth import current_login
|
||||||
|
from ..crypto import decrypt_text, encrypt_text
|
||||||
|
from ..db import store
|
||||||
|
from ..services import ai as ai_svc
|
||||||
|
from ..services import rates as rates_svc
|
||||||
|
from ..services.telegram import tg
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["settings"])
|
||||||
|
|
||||||
|
# Какие настройки видны наружу (без секретов). Значения секретов маскируются.
|
||||||
|
_PUBLIC_INT = {"archiveAfterDays", "archiveClearDays", "trashClearDays", "minLen", "discJoinLimit", "discJoinDelayMin", "discJoinDelayMax", "discEvalSample", "discEvalThreshold"}
|
||||||
|
_PUBLIC_BOOL = {"autoArchive", "aiFilterEnabled", "aiEnabled", "conversionOn", "remindersEnabled", "mlEnabled", "blockResumes", "budgetRequiredHire", "budgetRequiredOrder", "autoMonitorNew", "discPaused"}
|
||||||
|
_PUBLIC_STR = {"targetCurrency", "rateSource", "aiProvider", "aiPrompt", "aiFilterPrompt", "cardPrompt", "domainDescription", "wantedType", "hireLabel", "orderLabel"}
|
||||||
|
_PUBLIC_LIST = {"stopPhrases", "domainKeywords", "hireMarkers", "levelTerms", "resumeMarkers"}
|
||||||
|
_PUBLIC_DICT = {"colState"}
|
||||||
|
|
||||||
|
|
||||||
|
def mask(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
return value if len(value) <= 8 else f"{value[:4]}…{value[-4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def public_settings() -> dict:
|
||||||
|
out: dict = {}
|
||||||
|
allset = store.all_settings()
|
||||||
|
for k in _PUBLIC_INT:
|
||||||
|
out[k] = int(allset.get(k, 0))
|
||||||
|
for k in _PUBLIC_BOOL:
|
||||||
|
out[k] = bool(allset.get(k))
|
||||||
|
for k in _PUBLIC_STR:
|
||||||
|
out[k] = allset.get(k, "")
|
||||||
|
for k in _PUBLIC_LIST:
|
||||||
|
out[k] = allset.get(k, [])
|
||||||
|
for k in _PUBLIC_DICT:
|
||||||
|
out[k] = allset.get(k, {})
|
||||||
|
out["myPrompts"] = allset.get("myPrompts", [])
|
||||||
|
|
||||||
|
tg_keys = store.get_setting("tgKeys") or {}
|
||||||
|
api_hash = str(tg_keys.get("apiHash", ""))
|
||||||
|
out["tgKeys"] = {
|
||||||
|
"apiId": mask(str(tg_keys.get("apiId", ""))),
|
||||||
|
"apiHashSet": bool(decrypt_text(api_hash)) if api_hash.startswith("enc:") else bool(api_hash),
|
||||||
|
}
|
||||||
|
cfg = store.get_setting("aiConfigs") or {}
|
||||||
|
out["aiConfigs"] = {}
|
||||||
|
for pid, conf in cfg.items():
|
||||||
|
raw_key = str(conf.get("apiKey", ""))
|
||||||
|
plain_key = decrypt_text(raw_key) if raw_key.startswith("enc:") else raw_key
|
||||||
|
out["aiConfigs"][pid] = {
|
||||||
|
"baseUrl": conf.get("baseUrl", ""),
|
||||||
|
"model": conf.get("model", ""),
|
||||||
|
"keySet": bool(plain_key),
|
||||||
|
"keyMasked": mask(plain_key),
|
||||||
|
}
|
||||||
|
out["providers"] = C.AI_PROVIDERS
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings")
|
||||||
|
def get_settings(_: str = Depends(current_login)) -> dict:
|
||||||
|
return public_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/settings")
|
||||||
|
async def patch_settings(body: dict, _: str = Depends(current_login)) -> dict:
|
||||||
|
current = store.all_settings()
|
||||||
|
# инвариант пауз авто-вступлений: если пришли оба конца интервала — клампы
|
||||||
|
# (5..600) и min <= max (если нет — меняем местами, как делает фронт); если
|
||||||
|
# пришёл один конец — клампим его относительно сохранённого другого конца
|
||||||
|
delay_min, delay_max = body.get("discJoinDelayMin"), body.get("discJoinDelayMax")
|
||||||
|
if delay_min is not None and delay_max is not None:
|
||||||
|
try:
|
||||||
|
dmin, dmax = int(delay_min), int(delay_max)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
dmin = max(5, min(600, dmin))
|
||||||
|
dmax = max(5, min(600, dmax))
|
||||||
|
if dmin > dmax:
|
||||||
|
dmin, dmax = dmax, dmin
|
||||||
|
body["discJoinDelayMin"] = dmin
|
||||||
|
body["discJoinDelayMax"] = dmax
|
||||||
|
elif delay_min is not None:
|
||||||
|
try:
|
||||||
|
cur_max = int(current.get("discJoinDelayMax") or 0)
|
||||||
|
value = max(5, min(600, int(delay_min)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
body["discJoinDelayMin"] = min(value, cur_max) if cur_max >= 5 else value
|
||||||
|
elif delay_max is not None:
|
||||||
|
try:
|
||||||
|
cur_min = int(current.get("discJoinDelayMin") or 0)
|
||||||
|
value = max(5, min(600, int(delay_max)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
body["discJoinDelayMax"] = max(value, cur_min) if cur_min <= 600 else value
|
||||||
|
for key, value in body.items():
|
||||||
|
if key in _PUBLIC_INT:
|
||||||
|
try:
|
||||||
|
value = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if key == "archiveAfterDays":
|
||||||
|
value = max(1, min(30, value))
|
||||||
|
if key == "minLen":
|
||||||
|
value = max(10, min(500, value))
|
||||||
|
if key == "discJoinLimit":
|
||||||
|
value = max(1, min(200, value))
|
||||||
|
if key in {"discJoinDelayMin", "discJoinDelayMax"}:
|
||||||
|
value = max(5, min(600, value))
|
||||||
|
if key == "discEvalSample":
|
||||||
|
value = max(3, min(30, value))
|
||||||
|
if key == "discEvalThreshold":
|
||||||
|
value = max(1, min(100, value))
|
||||||
|
store.set_setting(key, value)
|
||||||
|
elif key in _PUBLIC_BOOL:
|
||||||
|
store.set_setting(key, bool(value))
|
||||||
|
elif key in _PUBLIC_STR:
|
||||||
|
if key in {"targetCurrency"}:
|
||||||
|
value = str(value).upper()
|
||||||
|
if key == "aiProvider" and not any(p["id"] == value for p in C.AI_PROVIDERS):
|
||||||
|
continue
|
||||||
|
store.set_setting(key, str(value))
|
||||||
|
elif key in _PUBLIC_LIST:
|
||||||
|
if isinstance(value, list):
|
||||||
|
store.set_setting(key, [str(x) for x in value][:200])
|
||||||
|
elif key in _PUBLIC_DICT:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
store.set_setting(key, value)
|
||||||
|
elif key == "myPrompts" and isinstance(value, list):
|
||||||
|
clean: list[dict] = []
|
||||||
|
for item in value[:100]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = str(item.get("name") or "").strip()[:80]
|
||||||
|
prompt = str(item.get("prompt") or "").strip()[:8000]
|
||||||
|
if not name or not prompt:
|
||||||
|
continue
|
||||||
|
clean.append(
|
||||||
|
{
|
||||||
|
"id": str(item.get("id") or "")[:40] or store.uid("pp_"),
|
||||||
|
"name": name,
|
||||||
|
"description": str(item.get("description") or "").strip()[:300],
|
||||||
|
"prompt": prompt,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
store.set_setting("myPrompts", clean)
|
||||||
|
elif key == "aiConfigs" and isinstance(value, dict):
|
||||||
|
cfg = current.get("aiConfigs") or {}
|
||||||
|
for pid, conf in value.items():
|
||||||
|
if pid not in cfg:
|
||||||
|
continue
|
||||||
|
entry = dict(cfg[pid])
|
||||||
|
if isinstance(conf, dict):
|
||||||
|
for fk in ("baseUrl", "model"):
|
||||||
|
if fk in conf and conf[fk] is not None:
|
||||||
|
entry[fk] = str(conf[fk])
|
||||||
|
new_key = conf.get("apiKey")
|
||||||
|
if new_key and not str(new_key).startswith(("enc:",)) and len(str(new_key)) >= 8:
|
||||||
|
entry["apiKey"] = encrypt_text(str(new_key))
|
||||||
|
cfg[pid] = entry
|
||||||
|
store.set_setting("aiConfigs", cfg)
|
||||||
|
elif key == "tgKeys" and isinstance(value, dict):
|
||||||
|
keys = current.get("tgKeys") or {}
|
||||||
|
if value.get("apiId") is not None:
|
||||||
|
api = str(value["apiId"]).strip()
|
||||||
|
if api.isdigit() and 5 < len(api) < 10:
|
||||||
|
keys["apiId"] = api
|
||||||
|
new_hash = value.get("apiHash")
|
||||||
|
if new_hash and len(str(new_hash)) >= 16 and not str(new_hash).startswith("enc:"):
|
||||||
|
keys["apiHash"] = encrypt_text(str(new_hash).strip())
|
||||||
|
store.set_setting("tgKeys", keys)
|
||||||
|
# смена источника курсов — обновляем в фоне; смена целевой валюты/конвертации —
|
||||||
|
# пересчёт старых карточек (кроме архива/корзины)
|
||||||
|
if body.get("rateSource"):
|
||||||
|
asyncio.get_running_loop().create_task(rates_svc.refresh_rates())
|
||||||
|
if body.get("targetCurrency") is not None or body.get("conversionOn") is not None:
|
||||||
|
rates_svc.recompute_conversions()
|
||||||
|
return public_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ai/check")
|
||||||
|
async def ai_check(_: str = Depends(current_login)) -> dict:
|
||||||
|
status = ai_svc.provider_status()
|
||||||
|
provider_id, data = ai_svc._cfg()
|
||||||
|
meta = data["meta"]
|
||||||
|
if meta.get("local"):
|
||||||
|
return {**status, "ok": True, "message": f"Локальный сервер «{meta['name']}» (ping в проде)"}
|
||||||
|
key = data["cfg"].get("apiKey")
|
||||||
|
if not key:
|
||||||
|
return {**status, "ok": False, "message": "Не задан API-ключ"}
|
||||||
|
# Лёгкая проверка: GET /models (для OpenAI-совместимых) или /v1/models (Anthropic)
|
||||||
|
base = str(data["cfg"].get("baseUrl") or meta["base"]).rstrip("/")
|
||||||
|
url = base + ("/v1/models" if meta.get("api_style") == "anthropic" else "/models")
|
||||||
|
headers = {"Authorization": f"Bearer {key}"} if meta.get("api_style") != "anthropic" else {
|
||||||
|
"x-api-key": key, "anthropic-version": "2023-06-01"}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=12) as client:
|
||||||
|
resp = await client.get(url, headers=headers)
|
||||||
|
if resp.status_code < 400:
|
||||||
|
return {**status, "ok": True, "message": "Подключение успешно"}
|
||||||
|
if resp.status_code in (401, 403):
|
||||||
|
return {**status, "ok": False, "message": f"Ключ не принят (HTTP {resp.status_code}) — проверьте ключ и доступ к модели"}
|
||||||
|
return {**status, "ok": False, "message": f"HTTP {resp.status_code} — проверьте Base URL и модель"}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {**status, "ok": False, "message": f"Ошибка соединения: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Курсы ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/rates")
|
||||||
|
def get_rates(_: str = Depends(current_login)) -> dict:
|
||||||
|
return rates_svc.get_rates()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rates/refresh")
|
||||||
|
async def refresh_rates(_: str = Depends(current_login)) -> dict:
|
||||||
|
ok = await rates_svc.refresh_rates()
|
||||||
|
return {"ok": ok, "rates": rates_svc.get_rates()}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Методанные для фронта ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/meta/constants")
|
||||||
|
def meta_constants(_: str = Depends(current_login)) -> dict:
|
||||||
|
return {
|
||||||
|
"currencies": C.CURRENCIES,
|
||||||
|
"stages": C.PIPELINE_STAGES,
|
||||||
|
"palette": C.PALETTE,
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Telegram: статус, веб-авторизация, диалоги и мониторинг (п.4.2, 4.3 ТЗ)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..auth import current_login
|
||||||
|
from ..services.telegram import tg
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/tg", tags=["telegram"])
|
||||||
|
|
||||||
|
|
||||||
|
def _qr_svg(url: str) -> str:
|
||||||
|
"""Реальный QR-код (SVG, без внешних растровых зависимостей)."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
import qrcode
|
||||||
|
import qrcode.image.svg
|
||||||
|
|
||||||
|
qr = qrcode.QRCode(version=None, box_size=12, border=1, error_correction=qrcode.constants.ERROR_CORRECT_M)
|
||||||
|
qr.add_data(url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
buf = io.StringIO()
|
||||||
|
qr.make_image(image_factory=qrcode.image.svg.SvgPathImage).save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/qr-image")
|
||||||
|
def qr_image(_: str = Depends(current_login)) -> Response:
|
||||||
|
"""SVG-картинка QR для сканирования (фаза входа 'qr')."""
|
||||||
|
if tg.phase != "qr" or not tg.qr_url:
|
||||||
|
raise HTTPException(404, "QR не активен — начните вход по QR")
|
||||||
|
return Response(
|
||||||
|
_qr_svg(tg.qr_url),
|
||||||
|
media_type="image/svg+xml",
|
||||||
|
headers={"Cache-Control": "no-store", "Content-Disposition": "inline"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneBody(BaseModel):
|
||||||
|
phone: str
|
||||||
|
|
||||||
|
|
||||||
|
class CodeBody(BaseModel):
|
||||||
|
code: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordBody(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorBody(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewBody(BaseModel):
|
||||||
|
dialogId: str
|
||||||
|
limit: int = 24
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def status(_: str = Depends(current_login)) -> dict:
|
||||||
|
return tg.status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start-phone")
|
||||||
|
async def start_phone(body: PhoneBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
try:
|
||||||
|
await tg.start_phone(body.phone.strip())
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(400, tg.error or str(exc)) from exc
|
||||||
|
return {"phase": tg.phase}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start-qr")
|
||||||
|
async def start_qr(_: str = Depends(current_login)) -> dict:
|
||||||
|
try:
|
||||||
|
url = await tg.qr_start()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(400, tg.error or str(exc)) from exc
|
||||||
|
return {"phase": tg.phase, "qrUrl": url}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/send-code")
|
||||||
|
async def send_code(body: CodeBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
try:
|
||||||
|
await tg.submit_code(body.code.strip())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(400, tg.error or str(exc)) from exc
|
||||||
|
return {"phase": tg.phase}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/send-password")
|
||||||
|
async def send_password(body: PasswordBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
try:
|
||||||
|
await tg.submit_password(body.password)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return {"phase": tg.phase}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(_: str = Depends(current_login)) -> dict:
|
||||||
|
await tg.disconnect()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dialogs")
|
||||||
|
def dialogs(_: str = Depends(current_login)) -> dict:
|
||||||
|
return {"items": tg.list_dialogs()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dialogs/refresh")
|
||||||
|
async def dialogs_refresh(_: str = Depends(current_login)) -> dict:
|
||||||
|
if not tg.client or not tg.client.is_connected():
|
||||||
|
return {"ok": False, "reason": "not-connected", "count": 0}
|
||||||
|
count = await asyncio.wait_for(tg.refresh_dialogs(), timeout=60)
|
||||||
|
return {"ok": True, "count": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dialogs/monitor-all")
|
||||||
|
def monitor_all(body: MonitorBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
"""Включить/выключить мониторинг сразу для всех каналов."""
|
||||||
|
count = tg.set_monitor_all(body.enabled)
|
||||||
|
return {"ok": True, "count": count, "enabled": body.enabled}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dialogs/backfill-all")
|
||||||
|
def backfill_all(_: str = Depends(current_login)) -> dict:
|
||||||
|
"""Перечитать последние 10 сообщений всех включённых каналов (кнопка)."""
|
||||||
|
count = tg.backfill_monitored()
|
||||||
|
return {"ok": True, "count": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dialogs/{dialog_id}/monitor")
|
||||||
|
def set_monitor(dialog_id: str, body: MonitorBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
tg.set_monitor(dialog_id, body.enabled)
|
||||||
|
return {"ok": True, "enabled": body.enabled}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dialogs/{dialog_id}/backfill")
|
||||||
|
async def backfill(dialog_id: str, _: str = Depends(current_login)) -> dict:
|
||||||
|
processed = await tg.backfill_dialog(dialog_id)
|
||||||
|
return {"ok": True, "processed": processed}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dialogs/preview")
|
||||||
|
async def preview(body: PreviewBody, _: str = Depends(current_login)) -> dict:
|
||||||
|
items = await tg.dialog_messages(body.dialogId, min(max(body.limit, 1), 50))
|
||||||
|
return {"items": items}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Сервисный слой."""
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
"""AI-классификатор поверх выбранного провайдера.
|
||||||
|
|
||||||
|
Провайдеров много (облако + локальные OpenAI-совместимые + Anthropic),
|
||||||
|
активен один. Всё сведено к двум задачам:
|
||||||
|
* filter_incoming(text) — этап 2 фильтра входящих (промпт aiFilterPrompt);
|
||||||
|
* classify(text, boards) — разбор лида (промпт aiPrompt + обучающие примеры).
|
||||||
|
Ответы моделей строго JSON; распознаём и обрезаем markdown-обёртки ```json.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .. import constants as C
|
||||||
|
from ..crypto import decrypt_text
|
||||||
|
from ..db import store
|
||||||
|
from .rates import convert_amount
|
||||||
|
|
||||||
|
log = logging.getLogger("leadradar.ai")
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg() -> tuple[str, dict]:
|
||||||
|
provider_id = store.get_setting("aiProvider") or "deepseek"
|
||||||
|
configs = store.get_setting("aiConfigs") or {}
|
||||||
|
meta = next((p for p in C.AI_PROVIDERS if p["id"] == provider_id), None) or C.AI_PROVIDERS[0]
|
||||||
|
raw = configs.get(provider_id, {})
|
||||||
|
cfg = dict(raw)
|
||||||
|
if cfg.get("apiKey"):
|
||||||
|
cfg["apiKey"] = decrypt_text(str(cfg["apiKey"]))
|
||||||
|
return provider_id, {"meta": meta, "cfg": cfg}
|
||||||
|
|
||||||
|
|
||||||
|
def provider_status() -> dict:
|
||||||
|
"""Статус для UI: кто активен, есть ли ключ, base, модель (ключ маскируется)."""
|
||||||
|
provider_id, data = _cfg()
|
||||||
|
meta = data["meta"]
|
||||||
|
cfg = data["cfg"]
|
||||||
|
key = str(cfg.get("apiKey") or "")
|
||||||
|
return {
|
||||||
|
"provider": provider_id,
|
||||||
|
"name": meta["name"],
|
||||||
|
"base": cfg.get("baseUrl") or meta["base"],
|
||||||
|
"model": cfg.get("model") or (meta["models"] or [""])[0],
|
||||||
|
"local": bool(meta.get("local")),
|
||||||
|
"keySet": bool(key),
|
||||||
|
"keyMasked": mask_key(key),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def mask_key(key: str) -> str:
|
||||||
|
if not key:
|
||||||
|
return ""
|
||||||
|
if len(key) <= 8:
|
||||||
|
return key[0] + "…"
|
||||||
|
return f"{key[:4]}…{key[-4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── «Сфера и ключи»: подстановка в промпты из настроек пользователя ────────
|
||||||
|
|
||||||
|
def fill_prompt(prompt: str) -> str:
|
||||||
|
"""Заменяет в тексте промпта {domain} и {keywords} значениями из настроек.
|
||||||
|
|
||||||
|
Настройки задаются в UI (вкладка «Сфера и ключи»), поэтому ИИ-классификатор
|
||||||
|
работает для любой сферы: разработка, дизайн, недвижимость, стройка и т.п.
|
||||||
|
"""
|
||||||
|
domain = str(store.get_setting("domainDescription") or "").strip()
|
||||||
|
if not domain:
|
||||||
|
domain = "Универсально: заявка = конкретный запрос на услугу/товар/работу или найм человека."
|
||||||
|
raw_kws = store.get_setting("domainKeywords") or []
|
||||||
|
if isinstance(raw_kws, str):
|
||||||
|
raw_kws = [raw_kws]
|
||||||
|
kws = [str(k).strip() for k in raw_kws if str(k).strip()]
|
||||||
|
kws_text = ", ".join(kws[:60]) if kws else "(не заданы — определяй по тексту)"
|
||||||
|
return (prompt or "").replace("{domain}", domain).replace("{keywords}", kws_text)
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_json(system: str, user: str, max_retries: int = 2, max_tokens: int = 8000) -> dict:
|
||||||
|
"""Единая точка вызова выбранной модели. Возвращает dict (JSON-ответ).
|
||||||
|
|
||||||
|
Модель иногда отвечает HTTP 200 с пустым/не-JSON содержимым (особенно
|
||||||
|
на больших запросах или при перегрузке API) — делаем несколько попыток
|
||||||
|
с нарастающей паузой, чтобы не сыпать «ИИ недоступен» из-за разового сбоя.
|
||||||
|
"""
|
||||||
|
provider_id, data = _cfg()
|
||||||
|
meta = data["meta"]
|
||||||
|
cfg = data["cfg"]
|
||||||
|
base = str(cfg.get("baseUrl") or meta["base"]).rstrip("/")
|
||||||
|
model = cfg.get("model") or (meta["models"] or [""])[0]
|
||||||
|
api_key = str(cfg.get("apiKey") or "")
|
||||||
|
|
||||||
|
last_err: Exception | None = None
|
||||||
|
last_raw = ""
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
if meta.get("api_style") == "anthropic":
|
||||||
|
text = await _call_anthropic(base, api_key, model, system, user, max_tokens)
|
||||||
|
else:
|
||||||
|
text = await _call_openai(base, api_key, model, system, user, max_tokens)
|
||||||
|
last_raw = text
|
||||||
|
return extract_json(text)
|
||||||
|
except Exception as exc: # noqa: BLE001 — пробуем ещё раз
|
||||||
|
last_err = exc
|
||||||
|
if attempt < max_retries:
|
||||||
|
await asyncio_sleep(0.8 + 1.2 * attempt) # 0.8с, 2с, 3.2с
|
||||||
|
log.warning(
|
||||||
|
"AI call failed (%s, попыток %d): %s | ответ: %r",
|
||||||
|
meta["name"],
|
||||||
|
max_retries + 1,
|
||||||
|
last_err,
|
||||||
|
last_raw[:200],
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ИИ ({meta['name']}) не ответил корректно — повторите попытку через несколько секунд"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def asyncio_sleep(secs: float) -> None:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
await asyncio.sleep(secs)
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_openai(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str:
|
||||||
|
url = base + "/chat/completions"
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
body = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": user},
|
||||||
|
],
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=90) as client:
|
||||||
|
resp = await client.post(url, headers=headers, json=body)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
try:
|
||||||
|
msg = payload["choices"][0]["message"]
|
||||||
|
except (KeyError, IndexError, TypeError):
|
||||||
|
raise ValueError(f"неожиданный ответ API: {str(payload)[:200]}")
|
||||||
|
content = msg.get("content")
|
||||||
|
if not content and msg.get("reasoning_content"):
|
||||||
|
# модель «подумала», но не дала ответа (переполнение/обрыв) — считаем сбоем и повторим
|
||||||
|
raise ValueError("модель вернула только reasoning без ответа")
|
||||||
|
return str(content or "")
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_anthropic(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str:
|
||||||
|
url = base.rstrip("/") + "/v1/messages"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
}
|
||||||
|
body = {
|
||||||
|
"model": model,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"system": system,
|
||||||
|
"messages": [{"role": "user", "content": user}],
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
resp = await client.post(url, headers=headers, json=body)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
return "".join(blk.get("text", "") for blk in payload.get("content", []))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_json(text: str) -> dict:
|
||||||
|
raw = text.strip()
|
||||||
|
fence = re.search(r"```(?:json)?\s*(.*?)```", raw, re.S)
|
||||||
|
if fence:
|
||||||
|
raw = fence.group(1).strip()
|
||||||
|
start, end = raw.find("{"), raw.rfind("}")
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
raw = raw[start : end + 1]
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Задачи ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def filter_incoming(text: str) -> dict:
|
||||||
|
"""Этап 2 (ИИ-фильтр). Если выключен — считаем пропущенным."""
|
||||||
|
if not store.get_setting("aiFilterEnabled"):
|
||||||
|
return {"pass": True, "reason": None, "skipped": True}
|
||||||
|
prompt = fill_prompt(store.get_setting("aiFilterPrompt"))
|
||||||
|
out = await chat_json(prompt, f"Сообщение:\n{text[:4000]}")
|
||||||
|
return {
|
||||||
|
"pass": bool(out.get("pass", True)),
|
||||||
|
"reason": out.get("reason"),
|
||||||
|
"skipped": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _learning_examples(limit: int = 8) -> list[dict]:
|
||||||
|
"""Примеры разметки пользователя для few-shot классификации."""
|
||||||
|
rows = store.query(
|
||||||
|
"SELECT l.source_msg AS msg, ll.to_col "
|
||||||
|
"FROM learning_log ll JOIN leads l ON l.id = ll.lead_id "
|
||||||
|
"WHERE ll.action IN ('move','restore') AND l.source_msg <> '' "
|
||||||
|
"ORDER BY ll.created_at DESC LIMIT ?",
|
||||||
|
[limit],
|
||||||
|
)
|
||||||
|
examples = []
|
||||||
|
for r in rows:
|
||||||
|
if r["to_col"] in ("trash", "archive"):
|
||||||
|
continue
|
||||||
|
examples.append({"text": (r["msg"] or "")[:500], "board": r["to_col"]})
|
||||||
|
return examples
|
||||||
|
|
||||||
|
|
||||||
|
async def classify(message_text: str) -> dict:
|
||||||
|
"""Полный разбор лида. Возвращает сырой словарь модели.
|
||||||
|
|
||||||
|
Колонка для ИИ — это набор критериев (правила), а не просто название:
|
||||||
|
в промпт уходят правила колонок, чтобы модель выбирала осмысленно.
|
||||||
|
"""
|
||||||
|
from .rules import describe as describe_rules
|
||||||
|
|
||||||
|
# в классификации участвуют только принятые колонки (не ИИ-предложения)
|
||||||
|
boards = store.query(
|
||||||
|
"SELECT id, name, description, keywords, rules FROM boards WHERE suggested = FALSE ORDER BY pos"
|
||||||
|
)
|
||||||
|
lines = []
|
||||||
|
for b in boards:
|
||||||
|
rules = json.loads(b["rules"] or "{}") if b["rules"] else {}
|
||||||
|
has_rules = any(rules.get(k) for k in ("direction", "keywords", "stack", "grade", "budget"))
|
||||||
|
if has_rules:
|
||||||
|
suffix = f" (критерии: {describe_rules(rules)})"
|
||||||
|
else:
|
||||||
|
kws = json.loads(b["keywords"] or "[]")[:8]
|
||||||
|
suffix = f" (ключевые слова: {', '.join(kws)})" if kws else ""
|
||||||
|
line = f"- {b['id']}: {b['name']}{suffix}"
|
||||||
|
if b.get("description"):
|
||||||
|
line += f" — {str(b['description']).strip()[:160]}"
|
||||||
|
lines.append(line)
|
||||||
|
board_map = "\n".join(lines) or "- (колонок пока нет — верните board: null)"
|
||||||
|
examples = _learning_examples()
|
||||||
|
user = (
|
||||||
|
f"Доски: {board_map}\n\n"
|
||||||
|
+ ("Примеры разметки пользователя:\n" + "\n".join(
|
||||||
|
f"текст: {e['text']}\n→ колонка: {e['board']}" for e in examples
|
||||||
|
) + "\n\n" if examples else "")
|
||||||
|
+ f"Новое сообщение:\n{message_text[:5000]}"
|
||||||
|
)
|
||||||
|
prompt = fill_prompt(store.get_setting("aiPrompt"))
|
||||||
|
# отдельный промпт структуры карточки («О заявке») — задаёт поля контента,
|
||||||
|
# чтобы все карточки имели одинаковую структуру текста
|
||||||
|
card = fill_prompt(store.get_setting("cardPrompt") or "")
|
||||||
|
if card:
|
||||||
|
prompt = prompt + "\n\n" + card
|
||||||
|
return await chat_json(prompt, user)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_dedup(text: str) -> str:
|
||||||
|
"""Дедупликация по нормализованному тексту (регистр и спецсимволы)."""
|
||||||
|
import hashlib
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
norm = _re.sub(r"[^\wа-яё]+", "", text.casefold())
|
||||||
|
return hashlib.sha1(norm.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# Синонимы валют из ответов ИИ → коды хранения (согласовано с rules._CUR_*)
|
||||||
|
_CUR_ALIASES = {
|
||||||
|
"USD": "USD", "$": "USD", "US$": "USD", "ДОЛЛАР": "USD", "ДОЛЛАРОВ": "USD", "ДОЛЛ": "USD", "БАКС": "USD", "БАКСОВ": "USD",
|
||||||
|
"EUR": "EUR", "€": "EUR", "ЕВРО": "EUR",
|
||||||
|
"RUB": "RUB", "RUR": "RUB", "₽": "RUB", "РУБ": "RUB", "РУБЛЕЙ": "RUB", "РУБЛИ": "RUB", "РУБЛЬ": "RUB", "РУБЛЯ": "RUB",
|
||||||
|
"GBP": "GBP", "£": "GBP", "CNY": "CNY", "¥": "CNY", "USDT": "USDT", "₮": "USDT",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_currency(raw) -> str | None:
|
||||||
|
"""Приводит название/символ валюты из ИИ к коду (USD/EUR/RUB/…)."""
|
||||||
|
s = str(raw or "").strip().upper()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if s in _CUR_ALIASES:
|
||||||
|
return _CUR_ALIASES[s]
|
||||||
|
letters = re.sub(r"[^A-ZА-Я]", "", s)
|
||||||
|
if letters in _CUR_ALIASES:
|
||||||
|
return _CUR_ALIASES[letters]
|
||||||
|
if len(s) == 3 and s.isalpha():
|
||||||
|
return s
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _budget_num(v):
|
||||||
|
"""Число из значения бюджета ИИ: «2к»/«2К» → 2000, «2000₽»/«2000р» → 2000."""
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
s = str(v).strip().casefold().replace("\u00a0", "").replace(" ", "")
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
mult = 1.0
|
||||||
|
if s.endswith(("к", "k")):
|
||||||
|
mult = 1000.0
|
||||||
|
s = s[:-1]
|
||||||
|
if s.endswith("руб"):
|
||||||
|
s = s[:-3]
|
||||||
|
elif s.endswith(("р", "₽")):
|
||||||
|
s = s[:-1]
|
||||||
|
try:
|
||||||
|
x = float(s.replace(",", ".")) * mult
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None if x == 0 else x
|
||||||
|
|
||||||
|
|
||||||
|
def clean_budget(budget) -> dict | None:
|
||||||
|
"""Нормализация бюджета из ИИ/локального разбора.
|
||||||
|
|
||||||
|
Соглашение хранения (и отображения в UI):
|
||||||
|
одна сумма X → {from: X, to: X}
|
||||||
|
«до X» → {from: None, to: X}
|
||||||
|
диапазон «от X до Y» → {from: X, to: Y}
|
||||||
|
from=0 трактуется как отсутствие нижней границы («от 0 до X» == «до X»).
|
||||||
|
"""
|
||||||
|
if not isinstance(budget, dict):
|
||||||
|
return None
|
||||||
|
cur = _norm_currency(budget.get("currency") or budget.get("cur"))
|
||||||
|
if not cur:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def num(v):
|
||||||
|
return _budget_num(v)
|
||||||
|
|
||||||
|
f, t = num(budget.get("from")), num(budget.get("to"))
|
||||||
|
if f is None and t is None:
|
||||||
|
return None
|
||||||
|
if t is None:
|
||||||
|
t = f # одна сумма или «от X» без верхней границы
|
||||||
|
return {"from": f, "to": t, "currency": cur}
|
||||||
|
|
||||||
|
|
||||||
|
def budget_to_target(budget: dict | None) -> dict:
|
||||||
|
"""Пересчёт «один раз при поступлении» в целевую валюту по текущим курсам."""
|
||||||
|
if not budget or not budget.get("currency"):
|
||||||
|
return {"convFrom": None, "convTo": None, "convCur": ""}
|
||||||
|
cur = budget["currency"]
|
||||||
|
target = store.get_setting("targetCurrency") or "RUB"
|
||||||
|
if not store.get_setting("conversionOn"):
|
||||||
|
return {"convFrom": None, "convTo": None, "convCur": ""}
|
||||||
|
from_, to_ = budget.get("from"), budget.get("to")
|
||||||
|
c_from = convert_amount(from_, cur, target) if from_ is not None else None
|
||||||
|
c_to = None
|
||||||
|
if to_ is not None:
|
||||||
|
c_to = convert_amount(to_, cur, target)
|
||||||
|
elif from_ is not None:
|
||||||
|
c_to = c_from
|
||||||
|
return {"convFrom": c_from, "convTo": c_to, "convCur": target}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""BanGuard: квоты, паузы и защита от flood для авто-вступлений Discovery.
|
||||||
|
|
||||||
|
Суточный лимит (discJoinLimit) считается по disc_log (event='join_auto')
|
||||||
|
за текущие UTC-сутки; между вступлениями выдерживается случайная пауза
|
||||||
|
(discJoinDelayMin/Max). При FloodWait от Telegram ставится блокировка до
|
||||||
|
конца суток (discFloodDay), плюс есть ручной стоп-кран (discPaused).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from ..db import store
|
||||||
|
|
||||||
|
_KEY_JOIN_LIMIT = "discJoinLimit"
|
||||||
|
_KEY_DELAY_MIN = "discJoinDelayMin"
|
||||||
|
_KEY_DELAY_MAX = "discJoinDelayMax"
|
||||||
|
_KEY_FLOOD_DAY = "discFloodDay"
|
||||||
|
_KEY_PAUSED = "discPaused"
|
||||||
|
|
||||||
|
|
||||||
|
def _start_of_day_ms() -> int:
|
||||||
|
"""Начало текущих UTC-суток в миллисекундах."""
|
||||||
|
start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
return int(start.timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def joins_today_auto() -> int:
|
||||||
|
"""Авто-вступления за текущие UTC-сутки (disc_log, event='join_auto')."""
|
||||||
|
row = store.scalar(
|
||||||
|
"SELECT count(*) FROM disc_log WHERE event = 'join_auto' AND created_at >= ?",
|
||||||
|
[_start_of_day_ms()],
|
||||||
|
)
|
||||||
|
return int(row or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def can_auto_join() -> bool:
|
||||||
|
"""Разрешено ли авто-вступление: лимит не исчерпан, нет flood на сегодня, нет паузы."""
|
||||||
|
limit = int(store.get_setting(_KEY_JOIN_LIMIT) or 0)
|
||||||
|
return joins_today_auto() < limit and not flood_today() and not global_paused()
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_join_delay() -> None:
|
||||||
|
"""Случайная пауза перед авто-вступлением (сек, из настроек).
|
||||||
|
|
||||||
|
Защита инварианта min <= max: настройки мог изменить один конец интервала
|
||||||
|
(single-key PATCH), поэтому при инверсии концы меняются местами.
|
||||||
|
"""
|
||||||
|
low = float(store.get_setting(_KEY_DELAY_MIN) or 0)
|
||||||
|
high = float(store.get_setting(_KEY_DELAY_MAX) or 0)
|
||||||
|
if low <= 0 and high <= 0:
|
||||||
|
return # обе настройки не заданы — паузы нет (крайний случай)
|
||||||
|
if low > high:
|
||||||
|
low, high = high, low
|
||||||
|
await asyncio.sleep(random.uniform(low, high))
|
||||||
|
|
||||||
|
|
||||||
|
def note_flood() -> None:
|
||||||
|
"""Зафиксировать FloodWait: блокировка авто-вступлений до конца суток."""
|
||||||
|
store.set_setting(_KEY_FLOOD_DAY, _start_of_day_ms())
|
||||||
|
|
||||||
|
|
||||||
|
def flood_today() -> bool:
|
||||||
|
"""Была ли flood-блокировка в текущие UTC-сутки."""
|
||||||
|
return int(store.get_setting(_KEY_FLOOD_DAY) or 0) == _start_of_day_ms()
|
||||||
|
|
||||||
|
|
||||||
|
def global_paused() -> bool:
|
||||||
|
"""Ручной стоп-кран авто-вступлений (setting discPaused)."""
|
||||||
|
return bool(store.get_setting(_KEY_PAUSED))
|
||||||
|
|
||||||
|
|
||||||
|
def set_global_pause(v: bool) -> None:
|
||||||
|
store.set_setting(_KEY_PAUSED, bool(v))
|
||||||
|
|
||||||
|
|
||||||
|
def search_pause() -> float:
|
||||||
|
"""Пауза между поисковыми запросами Telegram (сек)."""
|
||||||
|
return random.uniform(2.0, 4.0)
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
"""Discovery: хранилище задач поиска каналов, кандидатов, чёрного списка и лога.
|
||||||
|
|
||||||
|
Единый слой доступа к disc_tasks / disc_candidates / disc_blacklist / disc_log
|
||||||
|
(Task 1). Потребляется и API (Task 7), и фоновым воркером (Task 6).
|
||||||
|
|
||||||
|
Соглашения модуля:
|
||||||
|
- все обращения к БД только через `store.*` с параметрами (без конкатенации SQL);
|
||||||
|
- время — миллисекунды (`time.time_ns() // 1_000_000`);
|
||||||
|
- JSON-поля (keywords/marks/topics) в БД — VARCHAR, наружу всегда список
|
||||||
|
(`json.dumps(..., ensure_ascii=False)` при записи, `json.loads` при чтении);
|
||||||
|
- наружные dict-ы — camelCase (конвенция границы API проекта), поля совпадают
|
||||||
|
с колонками БД: keywords/minSubscribers/sampleSize/planJoins/autoJoin,
|
||||||
|
searchIdx/searchDone, fitRatio/autoJoined, createdAt/updatedAt, dialogId...
|
||||||
|
- create/patch принимают ключи и в camelCase, и в snake_case (поле "min_subscribers"
|
||||||
|
и т.п.) — на входе идёт нормализация к колонкам БД;
|
||||||
|
- статусы кандидата: new -> review -> joined|rejected. Переводы в joined/rejected
|
||||||
|
делаются ТОЛЬКО через mark_joined()/mark_rejected() (счётчики, чёрный список,
|
||||||
|
лог); set_candidate_status() разрешает new/review.
|
||||||
|
|
||||||
|
Бюджет авто-вступлений: сумма plan_joins задач со статусом NOT IN ('done','failed')
|
||||||
|
плюс plan_joins новой/увеличиваемой задачи не должна превышать discJoinLimit.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..db import store
|
||||||
|
|
||||||
|
# префиксы id (конвенция: видно, из какой таблицы запись)
|
||||||
|
_ID_TASK = "dt_"
|
||||||
|
_ID_LOG = "dl_"
|
||||||
|
|
||||||
|
# статусы кандидата, при которых повторное добавление источника запрещено
|
||||||
|
_ACTIVE_CANDIDATE = {"new", "review", "joined"}
|
||||||
|
# статусы задачи, которые НЕ занимают бюджет plan_joins
|
||||||
|
_DONE_TASK = {"done", "failed"}
|
||||||
|
|
||||||
|
# алиасы полей задачи: колонка БД -> набор имён в payload/патче
|
||||||
|
_TASK_ALIASES = {
|
||||||
|
"min_subscribers": {"minSubscribers", "min_subscribers"},
|
||||||
|
"sample_size": {"sampleSize", "sample_size"},
|
||||||
|
"plan_joins": {"planJoins", "plan_joins"},
|
||||||
|
"auto_join": {"autoJoin", "auto_join"},
|
||||||
|
# остальные поля называются одинаково: name, description, keywords, lang, threshold
|
||||||
|
}
|
||||||
|
# поля кандидата, которые можно менять через set_candidate()
|
||||||
|
_CANDIDATE_FIELDS = {
|
||||||
|
"name": "name",
|
||||||
|
"username": "username",
|
||||||
|
"kind": "kind",
|
||||||
|
"hue": "hue",
|
||||||
|
"participants": "participants",
|
||||||
|
"lang_ru": "lang_ru",
|
||||||
|
"langRu": "lang_ru",
|
||||||
|
"marks": "marks",
|
||||||
|
"topics": "topics",
|
||||||
|
"fit_ratio": "fit_ratio",
|
||||||
|
"fitRatio": "fit_ratio",
|
||||||
|
"auto_joined": "auto_joined",
|
||||||
|
"autoJoined": "auto_joined",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> int:
|
||||||
|
return time.time_ns() // 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _json(value) -> str:
|
||||||
|
return json.dumps(value or [], ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _loads(raw, default: list | None = None) -> list:
|
||||||
|
try:
|
||||||
|
return json.loads(raw or "[]")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return list(default or [])
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_limit() -> int:
|
||||||
|
"""Верхняя граница plan_joins: текущий суточный лимит discJoinLimit."""
|
||||||
|
return max(1, int(store.get_setting("discJoinLimit") or 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _active_plan_sum(exclude_id: str | None = None) -> int:
|
||||||
|
sql = "SELECT coalesce(sum(plan_joins), 0) FROM disc_tasks WHERE status NOT IN ('done', 'failed')"
|
||||||
|
params: list = []
|
||||||
|
if exclude_id:
|
||||||
|
sql += " AND id <> ?"
|
||||||
|
params.append(exclude_id)
|
||||||
|
return int(store.scalar(sql, params) or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_plan(plan_joins: int) -> None:
|
||||||
|
"""plan_joins >= 1 и не больше суточного лимита (настраивается в UI)."""
|
||||||
|
if plan_joins < 1:
|
||||||
|
raise ValueError("plan_joins должен быть не меньше 1")
|
||||||
|
limit = _plan_limit()
|
||||||
|
if plan_joins > limit:
|
||||||
|
raise ValueError(f"plan_joins {plan_joins} больше суточного лимита авто-вступлений ({limit})")
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_budget(plan_joins: int, exclude_id: str | None = None) -> None:
|
||||||
|
"""Правило бюджета: сумма планов активных задач + новая <= discJoinLimit."""
|
||||||
|
limit = _plan_limit()
|
||||||
|
used = _active_plan_sum(exclude_id)
|
||||||
|
if used + plan_joins > limit:
|
||||||
|
raise ValueError(
|
||||||
|
f"Бюджет авто-вступлений исчерпан: задачи уже занимают {used} из {limit} в сутки, "
|
||||||
|
f"ещё {plan_joins} не влезает"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── view-слои (наружу camelCase) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _task_view(row: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"description": row["description"],
|
||||||
|
"keywords": _loads(row["keywords"]),
|
||||||
|
"minSubscribers": int(row["min_subscribers"]),
|
||||||
|
"lang": row["lang"],
|
||||||
|
"threshold": int(row["threshold"]),
|
||||||
|
"sampleSize": int(row["sample_size"]),
|
||||||
|
"planJoins": int(row["plan_joins"]),
|
||||||
|
"autoJoin": bool(row["auto_join"]),
|
||||||
|
"status": row["status"],
|
||||||
|
"searchIdx": int(row["search_idx"]),
|
||||||
|
"searchDone": bool(row["search_done"]),
|
||||||
|
"found": int(row["found"]),
|
||||||
|
"evaluated": int(row["evaluated"]),
|
||||||
|
"joined": int(row["joined"]),
|
||||||
|
"rejected": int(row["rejected"]),
|
||||||
|
"createdAt": row["created_at"],
|
||||||
|
"updatedAt": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_view(row: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"dialogId": row["dialog_id"],
|
||||||
|
"taskId": row["task_id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"username": row["username"],
|
||||||
|
"kind": row["kind"],
|
||||||
|
"hue": row["hue"],
|
||||||
|
"participants": row["participants"],
|
||||||
|
"langRu": row["lang_ru"],
|
||||||
|
"marks": _loads(row["marks"]),
|
||||||
|
"topics": _loads(row["topics"]),
|
||||||
|
"fitRatio": row["fit_ratio"],
|
||||||
|
"status": row["status"],
|
||||||
|
"autoJoined": bool(row["auto_joined"]),
|
||||||
|
"joinFailures": int(row.get("join_failures") or 0),
|
||||||
|
"createdAt": row["created_at"],
|
||||||
|
"updatedAt": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _blacklist_view(row: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"dialogId": row["dialog_id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"reason": row["reason"],
|
||||||
|
"createdAt": row["created_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _log_view(row: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"taskId": row["task_id"],
|
||||||
|
"event": row["event"],
|
||||||
|
"text": row["text"],
|
||||||
|
"createdAt": row["created_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── задачи ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _task_or_raise(task_id: str) -> dict:
|
||||||
|
row = store.query_one("SELECT * FROM disc_tasks WHERE id = ?", [task_id])
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(task_id)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def list_tasks() -> list[dict]:
|
||||||
|
"""Все задачи, старые первыми (воркер берёт самую старую running)."""
|
||||||
|
return [_task_view(r) for r in store.query("SELECT * FROM disc_tasks ORDER BY created_at ASC")]
|
||||||
|
|
||||||
|
|
||||||
|
def get_task(task_id: str) -> dict | None:
|
||||||
|
row = store.query_one("SELECT * FROM disc_tasks WHERE id = ?", [task_id])
|
||||||
|
return _task_view(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_task_values(patch: dict) -> dict:
|
||||||
|
"""Нормализация payload/патча задачи (camel/snake алиасы) к колонкам БД."""
|
||||||
|
out: dict = {}
|
||||||
|
for key, value in patch.items():
|
||||||
|
if key in ("name", "description", "keywords", "lang", "threshold"):
|
||||||
|
col = key
|
||||||
|
else:
|
||||||
|
col = next((c for c, aliases in _TASK_ALIASES.items() if key in aliases), None)
|
||||||
|
if col is None:
|
||||||
|
continue # неизвестное поле игнорируем
|
||||||
|
out[col] = value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_task_values(cols: dict) -> None:
|
||||||
|
"""Проверка границ значений задачи (после нормализации)."""
|
||||||
|
if "threshold" in cols:
|
||||||
|
cols["threshold"] = max(1, min(100, int(cols["threshold"])))
|
||||||
|
if "sample_size" in cols:
|
||||||
|
cols["sample_size"] = max(1, int(cols["sample_size"]))
|
||||||
|
if "min_subscribers" in cols:
|
||||||
|
cols["min_subscribers"] = max(0, int(cols["min_subscribers"]))
|
||||||
|
if "lang" in cols and cols["lang"] not in ("ru", "any"):
|
||||||
|
cols["lang"] = "ru"
|
||||||
|
if "auto_join" in cols:
|
||||||
|
cols["auto_join"] = bool(cols["auto_join"])
|
||||||
|
if "keywords" in cols:
|
||||||
|
keywords = cols["keywords"] if isinstance(cols["keywords"], list) else [cols["keywords"]]
|
||||||
|
cols["keywords"] = [str(k).strip() for k in keywords if str(k).strip()]
|
||||||
|
if "description" in cols:
|
||||||
|
cols["description"] = str(cols["description"] or "")
|
||||||
|
if "name" in cols:
|
||||||
|
cols["name"] = str(cols["name"] or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def create_task(payload: dict) -> dict:
|
||||||
|
"""Создать задачу поиска.
|
||||||
|
|
||||||
|
Валидация: name непустое; plan_joins 1..discJoinLimit; правило бюджета
|
||||||
|
(сумма plan_joins активных задач + новая <= discJoinLimit) — иначе ValueError.
|
||||||
|
"""
|
||||||
|
cols = _norm_task_values(payload)
|
||||||
|
name = str(cols.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("Укажите название задачи")
|
||||||
|
plan_joins = int(cols.get("plan_joins", 1))
|
||||||
|
_assert_plan(plan_joins)
|
||||||
|
_assert_budget(plan_joins)
|
||||||
|
|
||||||
|
values = {
|
||||||
|
"name": name,
|
||||||
|
"description": str(cols.get("description") or ""),
|
||||||
|
"keywords": cols.get("keywords", []),
|
||||||
|
"min_subscribers": max(0, int(cols.get("min_subscribers", 0))),
|
||||||
|
"lang": cols.get("lang", "ru"),
|
||||||
|
"threshold": max(1, min(100, int(cols.get("threshold", int(store.get_setting("discEvalThreshold") or 40))))),
|
||||||
|
"sample_size": max(1, int(cols.get("sample_size", int(store.get_setting("discEvalSample") or 10)))),
|
||||||
|
"plan_joins": plan_joins,
|
||||||
|
"auto_join": bool(cols.get("auto_join", False)),
|
||||||
|
}
|
||||||
|
_validate_task_values(values)
|
||||||
|
task_id = store.uid(_ID_TASK)
|
||||||
|
now = _now()
|
||||||
|
store.execute(
|
||||||
|
"INSERT INTO disc_tasks(id, name, description, keywords, min_subscribers, lang, threshold, "
|
||||||
|
"sample_size, plan_joins, auto_join, status, search_idx, search_done, found, evaluated, "
|
||||||
|
"joined, rejected, created_at, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 0, FALSE, 0, 0, 0, 0, ?, ?)",
|
||||||
|
[
|
||||||
|
task_id,
|
||||||
|
values["name"],
|
||||||
|
values["description"],
|
||||||
|
_json(values["keywords"]),
|
||||||
|
values["min_subscribers"],
|
||||||
|
values["lang"],
|
||||||
|
values["threshold"],
|
||||||
|
values["sample_size"],
|
||||||
|
values["plan_joins"],
|
||||||
|
values["auto_join"],
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return get_task(task_id) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def patch_task(task_id: str, patch: dict) -> dict:
|
||||||
|
"""Обновить поля задачи. Увеличение plan_joins — с проверкой бюджета."""
|
||||||
|
row = _task_or_raise(task_id)
|
||||||
|
cols = _norm_task_values(patch)
|
||||||
|
if not cols:
|
||||||
|
return get_task(task_id) # type: ignore[return-value]
|
||||||
|
|
||||||
|
old_plan = int(row["plan_joins"])
|
||||||
|
if "plan_joins" in cols:
|
||||||
|
new_plan = int(cols["plan_joins"])
|
||||||
|
_assert_plan(new_plan)
|
||||||
|
if new_plan > old_plan:
|
||||||
|
_assert_budget(new_plan, exclude_id=task_id)
|
||||||
|
cols["plan_joins"] = new_plan
|
||||||
|
_validate_task_values(cols)
|
||||||
|
|
||||||
|
sets = ["updated_at = ?"]
|
||||||
|
params: list = [_now()]
|
||||||
|
for col, value in cols.items():
|
||||||
|
sets.append(f"{col} = ?")
|
||||||
|
params.append(_json(value) if col == "keywords" else value)
|
||||||
|
params.append(task_id)
|
||||||
|
store.execute(
|
||||||
|
f"UPDATE disc_tasks SET {', '.join(sets)} WHERE id = ?",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return get_task(task_id) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_task(task_id: str) -> None:
|
||||||
|
"""Удалить задачу вместе с её кандидатами и логом (чёрный список общий)."""
|
||||||
|
store.execute("DELETE FROM disc_tasks WHERE id = ?", [task_id])
|
||||||
|
store.execute("DELETE FROM disc_candidates WHERE task_id = ?", [task_id])
|
||||||
|
store.execute("DELETE FROM disc_log WHERE task_id = ?", [task_id])
|
||||||
|
|
||||||
|
|
||||||
|
def start_task(task_id: str) -> dict:
|
||||||
|
"""Запустить поиск: keywords непустые; status=running.
|
||||||
|
|
||||||
|
При повторном запуске завершённой/упавшей задачи прогресс поиска обнуляется
|
||||||
|
(свежий проход по ключам); при продолжении из paused — сохраняется.
|
||||||
|
"""
|
||||||
|
row = _task_or_raise(task_id)
|
||||||
|
keywords = _loads(row["keywords"])
|
||||||
|
if not keywords:
|
||||||
|
raise ValueError("Нет ключевых слов для поиска — добавьте их в задачу")
|
||||||
|
now = _now()
|
||||||
|
reset = row["status"] in _DONE_TASK
|
||||||
|
if reset:
|
||||||
|
# повторный прогон завершённой/упавшей задачи — свежий проход по ключам
|
||||||
|
store.execute(
|
||||||
|
"UPDATE disc_tasks SET status = 'running', search_idx = 0, search_done = FALSE, "
|
||||||
|
"found = 0, evaluated = 0, joined = 0, rejected = 0, updated_at = ? WHERE id = ?",
|
||||||
|
[now, task_id],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# старт из draft или продолжение из paused — прогресс поиска сохраняется
|
||||||
|
store.execute(
|
||||||
|
"UPDATE disc_tasks SET status = 'running', updated_at = ? WHERE id = ?",
|
||||||
|
[now, task_id],
|
||||||
|
)
|
||||||
|
return get_task(task_id) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def pause_task(task_id: str) -> dict:
|
||||||
|
"""Поставить задачу на паузу."""
|
||||||
|
_task_or_raise(task_id)
|
||||||
|
store.execute(
|
||||||
|
"UPDATE disc_tasks SET status = 'paused', updated_at = ? WHERE id = ?",
|
||||||
|
[_now(), task_id],
|
||||||
|
)
|
||||||
|
return get_task(task_id) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def bump_counter(task_id: str, field: str, n: int = 1) -> None:
|
||||||
|
"""Увеличить счётчик задачи: found|evaluated|joined|rejected."""
|
||||||
|
if field not in ("found", "evaluated", "joined", "rejected"):
|
||||||
|
raise ValueError(f"Неизвестный счётчик задачи: {field}")
|
||||||
|
row = _task_or_raise(task_id)
|
||||||
|
row[field] = int(row[field]) + max(0, int(n))
|
||||||
|
store.execute(
|
||||||
|
f"UPDATE disc_tasks SET {field} = ?, updated_at = ? WHERE id = ?",
|
||||||
|
[row[field], _now(), task_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def advance_search(task_id: str) -> None:
|
||||||
|
"""Перейти к следующему ключу; когда search_idx >= len(keywords) — search_done=True."""
|
||||||
|
row = _task_or_raise(task_id)
|
||||||
|
keywords = _loads(row["keywords"])
|
||||||
|
new_idx = int(row["search_idx"]) + 1
|
||||||
|
done = new_idx >= len(keywords)
|
||||||
|
store.execute(
|
||||||
|
"UPDATE disc_tasks SET search_idx = ?, search_done = ?, updated_at = ? WHERE id = ?",
|
||||||
|
[new_idx, done, _now(), task_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── кандидаты ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def list_candidates(task_id: str, status: str | None = None) -> list[dict]:
|
||||||
|
"""Кандидаты задачи (marks/topics уже списки); status — фильтр."""
|
||||||
|
if status:
|
||||||
|
rows = store.query(
|
||||||
|
"SELECT * FROM disc_candidates WHERE task_id = ? AND status = ? ORDER BY created_at ASC",
|
||||||
|
[task_id, status],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = store.query(
|
||||||
|
"SELECT * FROM disc_candidates WHERE task_id = ? ORDER BY created_at ASC",
|
||||||
|
[task_id],
|
||||||
|
)
|
||||||
|
return [_candidate_view(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_candidate(dialog_id: str) -> dict | None:
|
||||||
|
row = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _skip(task_id: str, reason: str) -> None:
|
||||||
|
add_log(task_id, "skip", reason)
|
||||||
|
|
||||||
|
|
||||||
|
def add_candidate(task_id: str, dialog_id: str, name: str, username: str, kind: str, hue: str) -> dict | None:
|
||||||
|
"""Добавить найденный источник как кандидата задачи.
|
||||||
|
|
||||||
|
None (с логом skip), если источник уже мониторится (есть в dialogs), в
|
||||||
|
чёрном списке или уже добавлен в статусе new/review/joined. Прежняя запись
|
||||||
|
со статусом rejected (например, после remove_blacklist) заменяется новой.
|
||||||
|
"""
|
||||||
|
_task_or_raise(task_id)
|
||||||
|
dialog_id = str(dialog_id)
|
||||||
|
if store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id]):
|
||||||
|
_skip(task_id, f"пропущен {dialog_id}: источник уже мониторится (мы состоим)")
|
||||||
|
return None
|
||||||
|
if store.scalar("SELECT 1 FROM disc_blacklist WHERE dialog_id = ? LIMIT 1", [dialog_id]):
|
||||||
|
_skip(task_id, f"пропущен {dialog_id}: источник в чёрном списке")
|
||||||
|
return None
|
||||||
|
existing = store.query_one(
|
||||||
|
"SELECT status FROM disc_candidates WHERE dialog_id = ?",
|
||||||
|
[dialog_id],
|
||||||
|
)
|
||||||
|
if existing and existing["status"] in _ACTIVE_CANDIDATE:
|
||||||
|
_skip(task_id, f"пропущен {dialog_id}: кандидат уже есть (статус {existing['status']})")
|
||||||
|
return None
|
||||||
|
if existing:
|
||||||
|
# устаревшая rejected-запись: перезаписываем как новый кандидат
|
||||||
|
store.execute("DELETE FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
store.execute(
|
||||||
|
"INSERT INTO disc_candidates(dialog_id, task_id, name, username, kind, hue, participants, "
|
||||||
|
"lang_ru, marks, topics, fit_ratio, status, auto_joined, created_at, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, '[]', '[]', NULL, 'new', FALSE, ?, ?)",
|
||||||
|
[
|
||||||
|
dialog_id,
|
||||||
|
task_id,
|
||||||
|
str(name or "").strip() or dialog_id,
|
||||||
|
str(username or "").strip(),
|
||||||
|
str(kind or "channel"),
|
||||||
|
str(hue or "#666"),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
bump_counter(task_id, "found")
|
||||||
|
row = _get_candidate(dialog_id)
|
||||||
|
return _candidate_view(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_candidate(task_id: str, dialog_id: str, patch: dict) -> dict:
|
||||||
|
"""Обновить поля кандидата задачи (например, по результатам оценки).
|
||||||
|
|
||||||
|
patch — значения в нотации кандидата (camelCase или snake_case):
|
||||||
|
participants, kind, langRu/lang_ru, marks, topics, fitRatio/fit_ratio,
|
||||||
|
autoJoined/auto_joined, name, username, hue.
|
||||||
|
"""
|
||||||
|
_task_or_raise(task_id)
|
||||||
|
row = _get_candidate(dialog_id)
|
||||||
|
if row is None or row["task_id"] != task_id:
|
||||||
|
raise KeyError(dialog_id)
|
||||||
|
|
||||||
|
cols: dict = {}
|
||||||
|
for key, value in patch.items():
|
||||||
|
col = _CANDIDATE_FIELDS.get(key)
|
||||||
|
if col is None:
|
||||||
|
continue
|
||||||
|
cols[col] = value
|
||||||
|
if not cols:
|
||||||
|
return _candidate_view(row)
|
||||||
|
if "marks" in cols:
|
||||||
|
cols["marks"] = _json([str(m) for m in cols["marks"]])
|
||||||
|
if "topics" in cols:
|
||||||
|
cols["topics"] = _json(cols["topics"])
|
||||||
|
for col in ("name", "username", "kind", "hue"):
|
||||||
|
if col in cols:
|
||||||
|
cols[col] = str(cols[col] or "").strip() or row[col]
|
||||||
|
if "participants" in cols and cols["participants"] is not None:
|
||||||
|
cols["participants"] = int(cols["participants"])
|
||||||
|
|
||||||
|
sets = ["updated_at = ?"]
|
||||||
|
params: list = [_now()]
|
||||||
|
for col, value in cols.items():
|
||||||
|
sets.append(f"{col} = ?")
|
||||||
|
params.append(value)
|
||||||
|
params.append(dialog_id)
|
||||||
|
store.execute(f"UPDATE disc_candidates SET {', '.join(sets)} WHERE dialog_id = ?", params)
|
||||||
|
updated = _get_candidate(dialog_id)
|
||||||
|
return _candidate_view(updated) if updated else _candidate_view(row)
|
||||||
|
|
||||||
|
|
||||||
|
def set_candidate_status(dialog_id: str, status: str) -> dict:
|
||||||
|
"""Перевести кандидата в new/review (+ лог review).
|
||||||
|
|
||||||
|
joined/rejected меняются только через mark_joined()/mark_rejected() —
|
||||||
|
там счётчики задачи, чёрный список и лог join/reject.
|
||||||
|
"""
|
||||||
|
if status not in ("new", "review"):
|
||||||
|
raise ValueError(f"Статус {status} выставляется через mark_joined/mark_rejected")
|
||||||
|
row = _get_candidate(dialog_id)
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(dialog_id)
|
||||||
|
store.execute(
|
||||||
|
"UPDATE disc_candidates SET status = ?, updated_at = ? WHERE dialog_id = ?",
|
||||||
|
[status, _now(), dialog_id],
|
||||||
|
)
|
||||||
|
if status == "review":
|
||||||
|
add_log(row["task_id"], "review", f"кандидат {dialog_id} переведён в review")
|
||||||
|
updated = _get_candidate(dialog_id)
|
||||||
|
return _candidate_view(updated) if updated else _candidate_view(row)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_candidate(dialog_id: str) -> None:
|
||||||
|
"""Удалить кандидата (skip-ветки воркера; повторный вызов безопасен)."""
|
||||||
|
store.execute("DELETE FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
|
||||||
|
|
||||||
|
|
||||||
|
def mark_joined(dialog_id: str, auto: bool) -> dict:
|
||||||
|
"""Источник вступил: status=joined, счётчик joined задачи, лог join_auto/join_manual."""
|
||||||
|
row = _get_candidate(dialog_id)
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(dialog_id)
|
||||||
|
if row["status"] == "joined":
|
||||||
|
return _candidate_view(row) # идемпотентно: повторно не считаем
|
||||||
|
now = _now()
|
||||||
|
store.execute(
|
||||||
|
"UPDATE disc_candidates SET status = 'joined', auto_joined = ?, updated_at = ? WHERE dialog_id = ?",
|
||||||
|
[bool(auto), now, dialog_id],
|
||||||
|
)
|
||||||
|
bump_counter(row["task_id"], "joined")
|
||||||
|
add_log(row["task_id"], "join_auto" if auto else "join_manual", f"вступили в {dialog_id}")
|
||||||
|
updated = _get_candidate(dialog_id)
|
||||||
|
return _candidate_view(updated) if updated else _candidate_view(row)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_rejected(dialog_id: str, reason: str = "") -> dict:
|
||||||
|
"""Отклонить кандидата: status=rejected, счётчик rejected, лог reject, чёрный список.
|
||||||
|
|
||||||
|
Повторный вызов для уже отклонённого — идемпотентен: счётчик/лог/чёрный
|
||||||
|
список не трогаются (кандидата мог отклонить и человек, и воркер).
|
||||||
|
"""
|
||||||
|
row = _get_candidate(dialog_id)
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(dialog_id)
|
||||||
|
if row["status"] == "joined":
|
||||||
|
raise ValueError("Нельзя отклонить источник, в который уже вступили")
|
||||||
|
if row["status"] == "rejected":
|
||||||
|
return _candidate_view(row) # идемпотентно: повторно не считаем и не логируем
|
||||||
|
now = _now()
|
||||||
|
store.execute(
|
||||||
|
"UPDATE disc_candidates SET status = 'rejected', updated_at = ? WHERE dialog_id = ?",
|
||||||
|
[now, dialog_id],
|
||||||
|
)
|
||||||
|
bump_counter(row["task_id"], "rejected")
|
||||||
|
add_log(row["task_id"], "reject", reason or f"отклонён {dialog_id}")
|
||||||
|
add_blacklist(dialog_id, row["name"], reason or "")
|
||||||
|
updated = _get_candidate(dialog_id)
|
||||||
|
return _candidate_view(updated) if updated else _candidate_view(row)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── чёрный список ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_blacklist(dialog_id: str, name: str = "", reason: str = "") -> dict:
|
||||||
|
"""Пометить источник в чёрном списке (существующая запись обновляется)."""
|
||||||
|
dialog_id = str(dialog_id)
|
||||||
|
now = _now()
|
||||||
|
store.execute(
|
||||||
|
"INSERT INTO disc_blacklist(dialog_id, name, reason, created_at) VALUES (?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(dialog_id) DO UPDATE SET name = excluded.name, reason = excluded.reason",
|
||||||
|
[dialog_id, str(name or "").strip() or dialog_id, str(reason or ""), now],
|
||||||
|
)
|
||||||
|
row = store.query_one("SELECT * FROM disc_blacklist WHERE dialog_id = ?", [dialog_id])
|
||||||
|
return _blacklist_view(row) if row else {"dialogId": dialog_id, "name": name, "reason": reason, "createdAt": now}
|
||||||
|
|
||||||
|
|
||||||
|
def remove_blacklist(dialog_id: str) -> None:
|
||||||
|
store.execute("DELETE FROM disc_blacklist WHERE dialog_id = ?", [dialog_id])
|
||||||
|
|
||||||
|
|
||||||
|
def list_blacklist() -> list[dict]:
|
||||||
|
return [
|
||||||
|
_blacklist_view(r)
|
||||||
|
for r in store.query("SELECT * FROM disc_blacklist ORDER BY created_at DESC")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── лог ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_log(task_id: str, event: str, text: str = "") -> None:
|
||||||
|
store.execute(
|
||||||
|
"INSERT INTO disc_log(id, task_id, event, text, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[store.uid(_ID_LOG), task_id, str(event), str(text or ""), _now()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def task_log(task_id: str, limit: int = 100) -> list[dict]:
|
||||||
|
"""Последние события задачи, новые сверху."""
|
||||||
|
limit = max(1, min(500, int(limit)))
|
||||||
|
rows = store.query(
|
||||||
|
"SELECT * FROM disc_log WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||||
|
[task_id, limit],
|
||||||
|
)
|
||||||
|
return [_log_view(r) for r in rows]
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""Discovery: оценка контента — язык, темы, fit сообщений под задачу поиска.
|
||||||
|
|
||||||
|
Чистая логика оценки (без карточек, очередей и обучения):
|
||||||
|
* detect_lang_ru — доля кириллических букв среди всех букв выборки;
|
||||||
|
* group_by_topic — группировка выборки по topic_id (None -> "main")
|
||||||
|
для форумов: {topic_id, title, messages: [...]};
|
||||||
|
* evaluate_message — fit одного сообщения под задачу: каскад
|
||||||
|
короткое -> ML-спам -> ИИ -> эвристика;
|
||||||
|
* evaluate_sample — агрегат по выборке сообщений кандидата;
|
||||||
|
* passed — вердикт «источник подходит» (объём выборки + доля fit).
|
||||||
|
|
||||||
|
Каскад оценки сообщения (evaluate_message):
|
||||||
|
1) текст пустой/короче 10 символов -> False «слишком короткое»;
|
||||||
|
2) ML: ml_client.is_enabled() и прогноз take + label=='spam' -> False;
|
||||||
|
3) ИИ: если включён (aiEnabled) и доступен ключ/локальный провайдер — один
|
||||||
|
JSON-вызов ai_service.chat_json({fit, reason}); любая ошибка (нет ключа,
|
||||||
|
сеть, не-JSON) ловится и оценка продолжается эвристикой;
|
||||||
|
4) эвристика: любой ключ задачи входит в clean_short(text).casefold().
|
||||||
|
|
||||||
|
task — внешний dict в camelCase (конвенция discovery.Task 3): description,
|
||||||
|
keywords (list[str]), lang, threshold, sampleSize. Иные ключи игнорируются,
|
||||||
|
поэтому сюда можно передавать и полный view задачи из discovery.get_task.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..db import store
|
||||||
|
from . import ai as ai_service
|
||||||
|
from . import ml_client
|
||||||
|
from .pipeline import clean_short
|
||||||
|
|
||||||
|
log = logging.getLogger("leadradar.discovery_eval")
|
||||||
|
|
||||||
|
# пороги доли кириллицы (задача ru-языка): >= hi -> True, <= lo -> False
|
||||||
|
_RU_RATIO_HI = 0.15
|
||||||
|
_RU_RATIO_LO = 0.03
|
||||||
|
# минимальная длина сообщения для содержательной оценки
|
||||||
|
_MIN_TEXT_LEN = 10
|
||||||
|
# ограничение текста, уходящего провайдеру (в одном сообщении больше не нужно)
|
||||||
|
_AI_TEXT_LIMIT = 4000
|
||||||
|
# потолок причины из ИИ (в UI не нужны простыни)
|
||||||
|
_AI_REASON_LIMIT = 200
|
||||||
|
# длина title темы (сниппет первого текста)
|
||||||
|
_TITLE_LIMIT = 60
|
||||||
|
# topic_id=None в выборке/группировке -> общая тема ("main")
|
||||||
|
_MAIN_TOPIC = "main"
|
||||||
|
|
||||||
|
# промпт ИИ-оценки (задача: относится ли сообщение к сфере/описанию)
|
||||||
|
_AI_PROMPT = (
|
||||||
|
"Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. "
|
||||||
|
"Ключи: {keywords}. "
|
||||||
|
"Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cyrillic(ch: str) -> bool:
|
||||||
|
"""Кириллица ли символ (базовый блок U+0400–U+04FF, включает ё/ў и т.п.)."""
|
||||||
|
return 0x0400 <= ord(ch) <= 0x04FF
|
||||||
|
|
||||||
|
|
||||||
|
def detect_lang_ru(texts: list[str]) -> bool | None:
|
||||||
|
"""Доля кириллицы среди всех букв выборки: >=0.15 True, <=0.03 False, иначе None.
|
||||||
|
|
||||||
|
None означает «неопределённо» — воркер не отсекает кандидата, а помечает
|
||||||
|
язык как неподтверждённый. Пустая выборка без букв тоже даёт None.
|
||||||
|
"""
|
||||||
|
letters = 0
|
||||||
|
cyr = 0
|
||||||
|
for t in texts or []:
|
||||||
|
for ch in str(t or ""):
|
||||||
|
if ch.isalpha():
|
||||||
|
letters += 1
|
||||||
|
if _is_cyrillic(ch):
|
||||||
|
cyr += 1
|
||||||
|
if letters == 0:
|
||||||
|
return None
|
||||||
|
ratio = cyr / letters
|
||||||
|
if ratio >= _RU_RATIO_HI:
|
||||||
|
return True
|
||||||
|
if ratio <= _RU_RATIO_LO:
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _topic_title(messages: list[dict]) -> str:
|
||||||
|
"""Сниппет первого непустого текста темы (<=60 симв., whitespace схлопнут)."""
|
||||||
|
for m in messages:
|
||||||
|
text = str(m.get("text") or "").strip()
|
||||||
|
if text:
|
||||||
|
text = " ".join(text.split())
|
||||||
|
return text[:_TITLE_LIMIT]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def group_by_topic(messages: list[dict]) -> list[dict]:
|
||||||
|
"""Группирует сообщения по topic_id (None -> "main"), сортирует группы по
|
||||||
|
числу сообщений (убыв.), порядок сообщений внутри группы — входной.
|
||||||
|
|
||||||
|
Возвращает [{"topic_id", "title", "messages": [...]}], где title — сниппет
|
||||||
|
первого текста темы (первое непустое сообщение во входном порядке).
|
||||||
|
"""
|
||||||
|
groups: dict[str, list[dict]] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
for m in messages or []:
|
||||||
|
tid = m.get("topic_id")
|
||||||
|
key = _MAIN_TOPIC if tid is None else tid
|
||||||
|
if key not in groups:
|
||||||
|
groups[key] = []
|
||||||
|
order.append(key)
|
||||||
|
groups[key].append(m)
|
||||||
|
out = [
|
||||||
|
{"topic_id": key, "title": _topic_title(groups[key]), "messages": groups[key]}
|
||||||
|
for key in order
|
||||||
|
]
|
||||||
|
out.sort(key=lambda g: len(g["messages"]), reverse=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _keywords(task: dict) -> list[str]:
|
||||||
|
kws = task.get("keywords") or []
|
||||||
|
if isinstance(kws, str):
|
||||||
|
kws = [kws]
|
||||||
|
return [str(k).strip() for k in kws if str(k).strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_usable() -> bool:
|
||||||
|
"""ИИ-ветка доступна: полный выключатель включён и есть ключ/локальный сервер.
|
||||||
|
|
||||||
|
Локальные OpenAI-совместимые (Ollama/LM Studio) работают без ключа, поэтому
|
||||||
|
для них проверка ключа не требуется. Если статус провайдера прочитать нельзя
|
||||||
|
(нет БД/настроек) — считаем ИИ недоступным, чтобы не дёргать сеть впустую.
|
||||||
|
"""
|
||||||
|
if not store.get_setting("aiEnabled"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
status = ai_service.provider_status()
|
||||||
|
except Exception as exc: # noqa: BLE001 — отсутствие статуса = ИИ недоступен
|
||||||
|
log.debug("discovery eval: статус ИИ недоступен (%s) — эвристика", exc)
|
||||||
|
return False
|
||||||
|
return bool(status.get("local") or status.get("keySet"))
|
||||||
|
|
||||||
|
|
||||||
|
def _heuristic(task: dict, text: str) -> dict:
|
||||||
|
"""Эвристика: ключ задачи входит в очищенный текст (без учёта регистра)."""
|
||||||
|
hay = clean_short(text).casefold()
|
||||||
|
for kw in _keywords(task):
|
||||||
|
if kw and kw.casefold() in hay:
|
||||||
|
return {"fit": True, "reason": f'совпал ключ "{kw}"', "source": "heuristic"}
|
||||||
|
return {"fit": False, "reason": "нет совпадений с ключами", "source": "heuristic"}
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_prompt(task: dict) -> str:
|
||||||
|
description = str(task.get("description") or "").strip()
|
||||||
|
return _AI_PROMPT.format(description=description, keywords=", ".join(_keywords(task)))
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_fit(out: dict) -> bool:
|
||||||
|
"""fit из JSON-ответа ИИ: 1/true/«да»-подобные -> True, иначе False."""
|
||||||
|
v = out.get("fit")
|
||||||
|
if isinstance(v, str):
|
||||||
|
s = v.strip().casefold()
|
||||||
|
return bool(s) and s not in {"0", "false", "no", "нет", "null", "none"}
|
||||||
|
return bool(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_reason(out: dict) -> str:
|
||||||
|
reason = str(out.get("reason") or "").strip()
|
||||||
|
if not reason:
|
||||||
|
reason = "подходит" if _ai_fit(out) else "не подходит"
|
||||||
|
return reason[:_AI_REASON_LIMIT]
|
||||||
|
|
||||||
|
|
||||||
|
async def evaluate_message(task: dict, text: str) -> dict:
|
||||||
|
"""Оценка fit одного сообщения. Возвращает {"fit", "reason", "source"}."""
|
||||||
|
raw = str(text or "")
|
||||||
|
if len(raw.strip()) < _MIN_TEXT_LEN:
|
||||||
|
return {"fit": False, "reason": "слишком короткое", "source": "heuristic"}
|
||||||
|
|
||||||
|
# ML: уверенный спам отсекаем без обращения к ИИ (когда ML доступен)
|
||||||
|
if ml_client.is_enabled():
|
||||||
|
pred = await ml_client.predict(raw)
|
||||||
|
if pred.get("take") and pred.get("label") == "spam":
|
||||||
|
return {"fit": False, "reason": "ML: спам", "source": "ml"}
|
||||||
|
|
||||||
|
# ИИ: один JSON-вызов; любая ошибка провайдера -> шаг эвристики ниже
|
||||||
|
if _ai_usable():
|
||||||
|
try:
|
||||||
|
out = await ai_service.chat_json(_ai_prompt(task), f"Сообщение:\n{raw[:_AI_TEXT_LIMIT]}")
|
||||||
|
return {"fit": _ai_fit(out), "reason": _ai_reason(out), "source": "ai"}
|
||||||
|
except Exception as exc: # noqa: BLE001 — сбой ИИ не роняет оценку
|
||||||
|
log.debug("discovery eval: ИИ не ответил (%s) — эвристика", exc)
|
||||||
|
|
||||||
|
return _heuristic(task, raw)
|
||||||
|
|
||||||
|
|
||||||
|
async def evaluate_sample(task: dict, messages: list[dict]) -> dict:
|
||||||
|
"""Последовательная оценка всех сообщений выборки кандидата.
|
||||||
|
|
||||||
|
Возвращает {"fit_count", "total", "fit_ratio", "per_message": [...]} с
|
||||||
|
per_message [{"text", "fit", "reason", "topic_id"}] (topic_id None -> "main",
|
||||||
|
чтобы per_message стыковался с ключами group_by_topic).
|
||||||
|
"""
|
||||||
|
per_message = []
|
||||||
|
fit_count = 0
|
||||||
|
for m in messages or []:
|
||||||
|
text = str(m.get("text") or "")
|
||||||
|
res = await evaluate_message(task, text)
|
||||||
|
tid = m.get("topic_id")
|
||||||
|
per_message.append(
|
||||||
|
{
|
||||||
|
"text": text,
|
||||||
|
"fit": bool(res["fit"]),
|
||||||
|
"reason": str(res["reason"]),
|
||||||
|
"topic_id": _MAIN_TOPIC if tid is None else tid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if res["fit"]:
|
||||||
|
fit_count += 1
|
||||||
|
total = len(per_message)
|
||||||
|
return {
|
||||||
|
"fit_count": fit_count,
|
||||||
|
"total": total,
|
||||||
|
"fit_ratio": (fit_count / total) if total else 0.0,
|
||||||
|
"per_message": per_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def passed(ev: dict, task: dict) -> bool:
|
||||||
|
"""Вердикт «источник подходит»: выборки >=3 сообщений и доля fit >= threshold (%).
|
||||||
|
|
||||||
|
Сообщения не обязаны быть «чистыми»: каналы часто разбавляют полезный
|
||||||
|
контент офтопом, поэтому достаточно доли, а не сплошного соответствия.
|
||||||
|
"""
|
||||||
|
total = int(ev.get("total") or 0)
|
||||||
|
ratio = float(ev.get("fit_ratio") or 0.0)
|
||||||
|
return total >= 3 and ratio * 100 >= int(task.get("threshold") or 0)
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
"""Discovery worker: поиск → оценка → авто-вступление (Task 6).
|
||||||
|
|
||||||
|
Фоновый цикл main._discovery_loop вызывает `tick()` каждые ~5 секунд; каждый
|
||||||
|
вызов выполняет ОДНО действие для самой старой running-задачи и возвращает
|
||||||
|
{"action": "search"|"review"|"skip"|"join"|"reject"|"flood"|"error"|"done"|"none",
|
||||||
|
"taskId": ...}. Если работы нет — {"action": "none"}.
|
||||||
|
|
||||||
|
Приоритеты внутри tick:
|
||||||
|
0. ban_guard.global_paused() — ручной стоп-кран: возвращаем none;
|
||||||
|
1. задача достигла плана вступлений (joined >= planJoins) → status=done
|
||||||
|
(+ лог done) — занимаемый ею бюджет планов освобождается сразу;
|
||||||
|
2. шаг поиска: search_done=False → следующий ключ keywords[search_idx],
|
||||||
|
tg.discovery_search, каждый результат — discovery.add_candidate,
|
||||||
|
discovery.advance_search; при переходе search_done=True — лог search
|
||||||
|
«поиск завершён: N кандидатов» (N = счётчик found задачи). Личные чаты/
|
||||||
|
боты (kind «чат») пропускаются (лог skip). FloodWaitError → note_flood +
|
||||||
|
лог flood; прочие ошибки поиска → лог error; индекс ключей не двигается
|
||||||
|
(тик повторит ключ позже), но после 3 ошибок подряд ключ пропускается
|
||||||
|
(advance_search + лог error «ключ пропущен») — битый ключ не должен
|
||||||
|
зацикливать поиск навсегда;
|
||||||
|
2a. флуд-стоп: пока действует flood-блокировка дня (ban_guard.flood_today())
|
||||||
|
discovery-воркер полностью стоит (никаких сетевых действий поиска/оценки/
|
||||||
|
вступлений) — это и есть «стоп до конца суток» из лога flood;
|
||||||
|
3. шаг оценки: первый кандидат status='new' → tg.discovery_info (участники,
|
||||||
|
kind; форум — если is_forum), фильтр minSubscribers (меньше — delete +
|
||||||
|
лог skip; не получено — метка), tg.discovery_read: история недоступна →
|
||||||
|
review с меткой («канал…»/«закрытая группа…», контент не оцениваем),
|
||||||
|
язык (для ru-задач), evaluate_sample (форумы — по темам через
|
||||||
|
group_by_topic) → passed → review с fitRatio/topics, иначе
|
||||||
|
delete_candidate + лог skip «мало подходящих (X из N)»;
|
||||||
|
4. авто-вступление (отдельный проход, приоритет ниже оценки): у running-
|
||||||
|
задачи с autoJoin и кандидатом status='review', если
|
||||||
|
ban_guard.can_auto_join() → повторная проверка «мы не состоим»
|
||||||
|
(dialogs/disc_blacklist — могли вступить между оценкой и join) → если
|
||||||
|
уже состоим/в чёрном списке — discovery.mark_rejected + лог;
|
||||||
|
ban_guard.wait_join_delay() (50–70 с — спейсинг авто-вступлений), ПОСЛЕ
|
||||||
|
паузы кандидат перечитывается — join выполняется, только если запись
|
||||||
|
ещё есть и в review, задача ещё running с autoJoin и мы не состоим
|
||||||
|
(иначе — тик выходит без join);
|
||||||
|
tg.discovery_join(username) → discovery.mark_joined(auto=True) →
|
||||||
|
tg.add_dialog_monitored(...) → tg.backfill_dialog(dialog_id) →
|
||||||
|
discovery.remove_blacklist. FloodWaitError → ban_guard.note_flood()
|
||||||
|
+ лог flood (кандидат остаётся review); прочие ошибки join →
|
||||||
|
join_failures += 1, лог error, кандидат остаётся review для повтора
|
||||||
|
(ретраи ограничены: после 3-й неудачи кандидат удаляется, лог skip).
|
||||||
|
|
||||||
|
Метки кандидата (marks) — список строк-чипов:
|
||||||
|
«участники не подтверждены», «язык не подтверждён»,
|
||||||
|
«канал: история недоступна», «закрытая группа (история скрыта) — вступите сами»,
|
||||||
|
«мало сообщений».
|
||||||
|
|
||||||
|
Темы форума (topics) — список dict (заполняется только для kind='forum'):
|
||||||
|
{"topicId": str|int, "title": str, "fitCount": int, "total": int,
|
||||||
|
"fitRatio": float, "passed": bool}
|
||||||
|
fit каждой темы считается по своей выборке (evaluate_sample + passed);
|
||||||
|
общий вердикт форума — есть хотя бы одна проходная тема. fitRatio кандидата
|
||||||
|
— агрегат по всей выборке (fit из N). Для не-форумов topics не заполняется.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
from telethon.errors.rpcerrorlist import FloodWaitError
|
||||||
|
|
||||||
|
from ..db import store
|
||||||
|
from . import ban_guard, discovery
|
||||||
|
from . import discovery_eval as eval_svc
|
||||||
|
from .telegram import tg
|
||||||
|
|
||||||
|
log = logging.getLogger("leadradar.discovery_worker")
|
||||||
|
|
||||||
|
_ACTION_NONE = {"action": "none"}
|
||||||
|
|
||||||
|
# минимальный объём содержательной выборки для вердикта оценки
|
||||||
|
_MIN_CONTENT = 3
|
||||||
|
|
||||||
|
# ошибки поиска одного ключа подряд, после которых ключ пропускается
|
||||||
|
_SEARCH_ERRORS_TO_SKIP = 3
|
||||||
|
# счётчик ошибок поиска по задачам (память процесса; при рестарте сбрасывается)
|
||||||
|
_search_errors: dict[str, int] = {}
|
||||||
|
|
||||||
|
# метки кандидата (marks) — чипы в UI
|
||||||
|
_MARK_PARTICIPANTS = "участники не подтверждены"
|
||||||
|
_MARK_LANG = "язык не подтверждён"
|
||||||
|
_MARK_CHANNEL_NO_HISTORY = "канал: история недоступна"
|
||||||
|
_MARK_CLOSED_GROUP = "закрытая группа (история скрыта) — вступите сами"
|
||||||
|
_MARK_FEW_MESSAGES = "мало сообщений"
|
||||||
|
|
||||||
|
# kind из Telegram (_kind_of: канал/группа/чат) → код кандидата (channel/group/forum)
|
||||||
|
_KIND_RU_TO_CODE = {"канал": "channel", "группа": "group", "чат": "group"}
|
||||||
|
|
||||||
|
|
||||||
|
def _now_ms() -> int:
|
||||||
|
return time.time_ns() // 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _kind_code(kind: str, is_forum: bool = False) -> str:
|
||||||
|
"""Нормализация kind источника: 'channel'|'group'|'forum' (см. _KIND_RU_TO_CODE)."""
|
||||||
|
if is_forum:
|
||||||
|
return "forum"
|
||||||
|
code = str(kind or "").strip().casefold()
|
||||||
|
if code in ("channel", "group", "forum"):
|
||||||
|
return code
|
||||||
|
return _KIND_RU_TO_CODE.get(code, "group")
|
||||||
|
|
||||||
|
|
||||||
|
def _running_tasks() -> list[dict]:
|
||||||
|
"""Running-задачи, старые первыми (list_tasks сортирует по created_at)."""
|
||||||
|
return [t for t in discovery.list_tasks() if t["status"] == "running"]
|
||||||
|
|
||||||
|
|
||||||
|
def _we_are_in(dialog_id: str) -> bool:
|
||||||
|
"""Уже состоим/отклонили: источник в dialogs или в чёрном списке.
|
||||||
|
|
||||||
|
Повторная проверка «мы не состоим» перед авто-вступлением (диалог мог
|
||||||
|
появиться между оценкой кандидата и join). dialog_id — подписанный peer id,
|
||||||
|
как в dialogs.id (конвенция discovery_search, Task 4).
|
||||||
|
"""
|
||||||
|
in_dialogs = store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id])
|
||||||
|
in_blacklist = store.scalar("SELECT 1 FROM disc_blacklist WHERE dialog_id = ? LIMIT 1", [dialog_id])
|
||||||
|
return bool(in_dialogs or in_blacklist)
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_done(task: dict) -> None:
|
||||||
|
"""Задача выполнила план вступлений: status=done + лог done."""
|
||||||
|
store.execute(
|
||||||
|
"UPDATE disc_tasks SET status = 'done', updated_at = ? WHERE id = ?",
|
||||||
|
[_now_ms(), task["id"]],
|
||||||
|
)
|
||||||
|
discovery.add_log(
|
||||||
|
task["id"],
|
||||||
|
"done",
|
||||||
|
f"план выполнен: вступили {task['joined']} из {task['planJoins']}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _close_search(task_id: str) -> None:
|
||||||
|
"""Закрыть проход по ключам (пустой список ключей / индекс за границей)."""
|
||||||
|
discovery.advance_search(task_id)
|
||||||
|
task = discovery.get_task(task_id)
|
||||||
|
if task and task["searchDone"]:
|
||||||
|
discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов")
|
||||||
|
|
||||||
|
|
||||||
|
def _log_search_done(task_id: str) -> None:
|
||||||
|
"""Лог завершения поиска, если advance_search перевёл задачу в search_done."""
|
||||||
|
task = discovery.get_task(task_id)
|
||||||
|
if task and task["searchDone"]:
|
||||||
|
discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов")
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_review(
|
||||||
|
task_id: str,
|
||||||
|
dialog_id: str,
|
||||||
|
*,
|
||||||
|
marks: list[str],
|
||||||
|
lang_ru: bool | None = None,
|
||||||
|
fit_ratio: float | None = None,
|
||||||
|
topics: list[dict] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Перевести кандидата в review с метками/оценкой (статус пишет лог review)."""
|
||||||
|
patch: dict = {"marks": [m for m in marks if m]}
|
||||||
|
if lang_ru is not None:
|
||||||
|
patch["langRu"] = lang_ru
|
||||||
|
if fit_ratio is not None:
|
||||||
|
patch["fitRatio"] = fit_ratio
|
||||||
|
if topics is not None:
|
||||||
|
patch["topics"] = topics
|
||||||
|
discovery.set_candidate(task_id, dialog_id, patch)
|
||||||
|
discovery.set_candidate_status(dialog_id, "review")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── шаги tick ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _search_step(task: dict) -> dict:
|
||||||
|
"""Шаг поиска: один ключ keywords[searchIdx] → кандидаты + advance_search."""
|
||||||
|
task_id = task["id"]
|
||||||
|
keywords = list(task.get("keywords") or [])
|
||||||
|
idx = int(task.get("searchIdx") or 0)
|
||||||
|
if not keywords or idx >= len(keywords):
|
||||||
|
# ключи закончились/пустой список: закрываем проход без сетевого вызова
|
||||||
|
_close_search(task_id)
|
||||||
|
return {"action": "search", "taskId": task_id}
|
||||||
|
keyword = keywords[idx]
|
||||||
|
try:
|
||||||
|
results = await tg.discovery_search(keyword)
|
||||||
|
except FloodWaitError:
|
||||||
|
# флуд: стоп авто-вступлений до конца суток; ключ не двигаем — повторим позже
|
||||||
|
ban_guard.note_flood()
|
||||||
|
discovery.add_log(task_id, "flood", f"поиск «{keyword}»: flood — стоп до конца суток")
|
||||||
|
return {"action": "flood", "taskId": task_id}
|
||||||
|
except Exception as exc: # noqa: BLE001 — сбой поиска не двигает индекс ключей
|
||||||
|
errors = _search_errors.get(task_id, 0) + 1
|
||||||
|
if errors >= _SEARCH_ERRORS_TO_SKIP:
|
||||||
|
# 3 ошибки подряд одного ключа: пропускаем (битый ключ не должен
|
||||||
|
# зацикливать поиск и блокировать оценку/вступления других задач)
|
||||||
|
_search_errors.pop(task_id, None)
|
||||||
|
discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc} — ключ пропущен ({errors} ошибки подряд)")
|
||||||
|
discovery.advance_search(task_id)
|
||||||
|
_log_search_done(task_id)
|
||||||
|
return {"action": "error", "taskId": task_id}
|
||||||
|
_search_errors[task_id] = errors
|
||||||
|
discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc}")
|
||||||
|
return {"action": "error", "taskId": task_id}
|
||||||
|
_search_errors.pop(task_id, None) # успешный поиск — сброс счётчика ошибок ключа
|
||||||
|
for item in results:
|
||||||
|
kind_raw = str(item.get("kind") or "").strip().casefold()
|
||||||
|
name = item.get("name") or ""
|
||||||
|
if kind_raw == "чат":
|
||||||
|
# люди/личные чаты и боты глобальным поиском не предлагаются
|
||||||
|
discovery.add_log(task_id, "skip", f"{name}: личный чат/бот")
|
||||||
|
continue
|
||||||
|
discovery.add_candidate(
|
||||||
|
task_id,
|
||||||
|
str(item.get("id") or ""),
|
||||||
|
name,
|
||||||
|
item.get("username") or "",
|
||||||
|
_kind_code(kind_raw),
|
||||||
|
item.get("hue") or "#666",
|
||||||
|
)
|
||||||
|
discovery.advance_search(task_id)
|
||||||
|
_log_search_done(task_id)
|
||||||
|
return {"action": "search", "taskId": task_id}
|
||||||
|
|
||||||
|
|
||||||
|
async def _eval_step(task: dict, cand: dict) -> dict:
|
||||||
|
"""Шаг оценки первого кандидата status='new' (все ветки — одно действие)."""
|
||||||
|
task_id = task["id"]
|
||||||
|
dialog_id = cand["dialogId"]
|
||||||
|
marks: list[str] = []
|
||||||
|
|
||||||
|
# ── инфо об источнике: kind/forum, участники, имя/username ────────────
|
||||||
|
info = await tg.discovery_info(dialog_id)
|
||||||
|
is_forum = bool(info.get("is_forum"))
|
||||||
|
resolved = info.get("kind") or is_forum
|
||||||
|
kind = _kind_code(info.get("kind", ""), is_forum) if resolved else (cand["kind"] or "channel")
|
||||||
|
cand_patch: dict = {
|
||||||
|
"kind": kind,
|
||||||
|
"hue": info.get("hue") or "",
|
||||||
|
"participants": info.get("participants"),
|
||||||
|
}
|
||||||
|
if resolved:
|
||||||
|
# имя/username обновляем только при успешном резолве: при fallback
|
||||||
|
# discovery_info возвращает name=dialog_id и не должен затирать имя
|
||||||
|
cand_patch["name"] = info.get("name") or ""
|
||||||
|
cand_patch["username"] = info.get("username") or ""
|
||||||
|
discovery.set_candidate(task_id, dialog_id, cand_patch)
|
||||||
|
participants = info.get("participants")
|
||||||
|
|
||||||
|
# ── фильтр minSubscribers ──────────────────────────────────────────────
|
||||||
|
min_sub = int(task.get("minSubscribers") or 0)
|
||||||
|
if min_sub > 0:
|
||||||
|
if participants is None:
|
||||||
|
marks.append(_MARK_PARTICIPANTS)
|
||||||
|
elif int(participants) < min_sub:
|
||||||
|
discovery.delete_candidate(dialog_id)
|
||||||
|
discovery.add_log(
|
||||||
|
task_id,
|
||||||
|
"skip",
|
||||||
|
f"{dialog_id}: мало участников ({participants} < {min_sub})",
|
||||||
|
)
|
||||||
|
discovery.bump_counter(task_id, "evaluated")
|
||||||
|
return {"action": "skip", "taskId": task_id}
|
||||||
|
|
||||||
|
# ── чтение истории для оценки ──────────────────────────────────────────
|
||||||
|
read = await tg.discovery_read(dialog_id, int(task.get("sampleSize") or 10))
|
||||||
|
if not read.get("ok"):
|
||||||
|
# история недоступна без членства: контент не оцениваем, фильтры помечаем
|
||||||
|
marks.append(_MARK_CHANNEL_NO_HISTORY if kind == "channel" else _MARK_CLOSED_GROUP)
|
||||||
|
if task.get("lang") == "ru":
|
||||||
|
marks.append(_MARK_LANG)
|
||||||
|
_finish_review(task_id, dialog_id, marks=marks)
|
||||||
|
discovery.bump_counter(task_id, "evaluated")
|
||||||
|
return {"action": "review", "taskId": task_id}
|
||||||
|
messages = read.get("messages") or []
|
||||||
|
|
||||||
|
# ── язык (только для ru-задач) ─────────────────────────────────────────
|
||||||
|
lang_ru: bool | None = None
|
||||||
|
if task.get("lang") == "ru":
|
||||||
|
lang_ru = eval_svc.detect_lang_ru([str(m.get("text") or "") for m in messages])
|
||||||
|
if lang_ru is False:
|
||||||
|
discovery.delete_candidate(dialog_id)
|
||||||
|
discovery.add_log(task_id, "skip", f"{dialog_id}: язык не русский")
|
||||||
|
discovery.bump_counter(task_id, "evaluated")
|
||||||
|
return {"action": "skip", "taskId": task_id}
|
||||||
|
if lang_ru is None:
|
||||||
|
marks.append(_MARK_LANG)
|
||||||
|
|
||||||
|
# ── объём выборки: меньше 3 содержательных — решает человек ────────────
|
||||||
|
if len(messages) < _MIN_CONTENT:
|
||||||
|
marks.append(_MARK_FEW_MESSAGES)
|
||||||
|
_finish_review(task_id, dialog_id, marks=marks, lang_ru=lang_ru)
|
||||||
|
discovery.bump_counter(task_id, "evaluated")
|
||||||
|
return {"action": "review", "taskId": task_id}
|
||||||
|
|
||||||
|
# ── оценка содержания (форумы — по темам) ──────────────────────────────
|
||||||
|
fit_count, total, fit_ratio, topics, ok = await _evaluate_content(task, kind, messages)
|
||||||
|
if ok:
|
||||||
|
_finish_review(
|
||||||
|
task_id,
|
||||||
|
dialog_id,
|
||||||
|
marks=marks,
|
||||||
|
lang_ru=lang_ru,
|
||||||
|
fit_ratio=fit_ratio,
|
||||||
|
topics=topics if kind == "forum" else None,
|
||||||
|
)
|
||||||
|
discovery.bump_counter(task_id, "evaluated")
|
||||||
|
return {"action": "review", "taskId": task_id}
|
||||||
|
|
||||||
|
discovery.delete_candidate(dialog_id)
|
||||||
|
discovery.add_log(task_id, "skip", f"{dialog_id}: мало подходящих ({fit_count} из {total})")
|
||||||
|
discovery.bump_counter(task_id, "evaluated")
|
||||||
|
return {"action": "skip", "taskId": task_id}
|
||||||
|
|
||||||
|
|
||||||
|
async def _evaluate_content(task: dict, kind: str, messages: list[dict]) -> tuple[int, int, float, list[dict], bool]:
|
||||||
|
"""evaluate_sample по выборке кандидата.
|
||||||
|
|
||||||
|
Не-форум: один прогон по всем сообщениям, topics пуст, вердикт — passed().
|
||||||
|
Форум: прогон по каждой теме (group_by_topic), topics заполняется
|
||||||
|
({topicId, title, fitCount, total, fitRatio, passed}), вердикт — есть хотя
|
||||||
|
бы одна проходная тема; fitRatio — агрегат fit из N по всей выборке.
|
||||||
|
"""
|
||||||
|
if kind == "forum":
|
||||||
|
topics: list[dict] = []
|
||||||
|
fit_count = 0
|
||||||
|
total = 0
|
||||||
|
any_passed = False
|
||||||
|
for group in eval_svc.group_by_topic(messages):
|
||||||
|
ev = await eval_svc.evaluate_sample(task, group["messages"])
|
||||||
|
t_ok = eval_svc.passed(ev, task)
|
||||||
|
fit_count += int(ev.get("fit_count") or 0)
|
||||||
|
total += int(ev.get("total") or 0)
|
||||||
|
topics.append(
|
||||||
|
{
|
||||||
|
"topicId": group["topic_id"],
|
||||||
|
"title": group["title"] or "",
|
||||||
|
"fitCount": int(ev.get("fit_count") or 0),
|
||||||
|
"total": int(ev.get("total") or 0),
|
||||||
|
"fitRatio": float(ev.get("fit_ratio") or 0.0),
|
||||||
|
"passed": bool(t_ok),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
any_passed = any_passed or bool(t_ok)
|
||||||
|
return fit_count, total, (fit_count / total) if total else 0.0, topics, any_passed
|
||||||
|
|
||||||
|
ev = await eval_svc.evaluate_sample(task, messages)
|
||||||
|
return (
|
||||||
|
int(ev.get("fit_count") or 0),
|
||||||
|
int(ev.get("total") or 0),
|
||||||
|
float(ev.get("fit_ratio") or 0.0),
|
||||||
|
[],
|
||||||
|
eval_svc.passed(ev, task),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _join_step(task: dict, cand: dict) -> dict:
|
||||||
|
"""Шаг авто-вступления одного кандидата status='review'."""
|
||||||
|
task_id = task["id"]
|
||||||
|
dialog_id = cand["dialogId"]
|
||||||
|
|
||||||
|
# между оценкой и вступлением могли вступить/отклонить источник
|
||||||
|
if _we_are_in(dialog_id):
|
||||||
|
already = bool(store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id]))
|
||||||
|
reason = (
|
||||||
|
"уже вступили между оценкой и авто-вступлением"
|
||||||
|
if already
|
||||||
|
else "источник в чёрном списке (повторная проверка перед авто-вступлением)"
|
||||||
|
)
|
||||||
|
with suppress(KeyError, ValueError):
|
||||||
|
discovery.mark_rejected(dialog_id, reason=reason)
|
||||||
|
return {"action": "reject", "taskId": task_id}
|
||||||
|
|
||||||
|
# спейсинг авто-вступлений (сек из настроек discJoinDelayMin/Max)
|
||||||
|
await ban_guard.wait_join_delay()
|
||||||
|
|
||||||
|
# за время паузы задача/кандидат/состояние BanGuard могли измениться:
|
||||||
|
# вступаем только если кандидат всё ещё есть и в review, задача ещё running
|
||||||
|
# с autoJoin, мы не состоим и авто-вступления по-прежнему разрешены (стоп-
|
||||||
|
# кран/flood/лимит могли включиться во время паузы) — иначе выходим без join
|
||||||
|
fresh = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
|
||||||
|
task_now = discovery.get_task(task_id)
|
||||||
|
if (
|
||||||
|
fresh is None
|
||||||
|
or fresh["status"] != "review"
|
||||||
|
or task_now is None
|
||||||
|
or task_now["status"] != "running"
|
||||||
|
or not task_now["autoJoin"]
|
||||||
|
or _we_are_in(dialog_id)
|
||||||
|
or not ban_guard.can_auto_join()
|
||||||
|
):
|
||||||
|
return _ACTION_NONE
|
||||||
|
|
||||||
|
username = str(fresh.get("username") or "").strip().lstrip("@")
|
||||||
|
try:
|
||||||
|
await tg.discovery_join(username)
|
||||||
|
except FloodWaitError:
|
||||||
|
ban_guard.note_flood() # идемпотентно: discovery_join тоже фиксирует флуд
|
||||||
|
discovery.add_log(task_id, "flood", f"авто-вступление {dialog_id}: flood — стоп до конца суток")
|
||||||
|
return {"action": "flood", "taskId": task_id}
|
||||||
|
except Exception as exc: # noqa: BLE001 — ретраи ограничены счётчиком join_failures
|
||||||
|
# между паузой и неудачным join кандидата могли отклонить/удалить:
|
||||||
|
# счётчик и удаление трогаем только у живой записи в статусе review
|
||||||
|
row_now = store.query_one(
|
||||||
|
"SELECT status FROM disc_candidates WHERE dialog_id = ?", [dialog_id]
|
||||||
|
)
|
||||||
|
if not row_now or row_now["status"] != "review":
|
||||||
|
return _ACTION_NONE
|
||||||
|
failures = int(fresh.get("join_failures") or 0) + 1
|
||||||
|
store.execute(
|
||||||
|
"UPDATE disc_candidates SET join_failures = ?, updated_at = ? "
|
||||||
|
"WHERE dialog_id = ? AND status = 'review'",
|
||||||
|
[failures, _now_ms(), dialog_id],
|
||||||
|
)
|
||||||
|
if failures >= 3:
|
||||||
|
discovery.delete_candidate(dialog_id)
|
||||||
|
discovery.add_log(task_id, "skip", f"{dialog_id}: не удалось вступить (3 попытки): {exc}")
|
||||||
|
return {"action": "skip", "taskId": task_id}
|
||||||
|
discovery.add_log(task_id, "error", f"авто-вступление {dialog_id}: {exc}")
|
||||||
|
return {"action": "error", "taskId": task_id}
|
||||||
|
|
||||||
|
discovery.mark_joined(dialog_id, auto=True)
|
||||||
|
tg.add_dialog_monitored(
|
||||||
|
dialog_id,
|
||||||
|
fresh.get("name"),
|
||||||
|
username,
|
||||||
|
fresh.get("kind"),
|
||||||
|
fresh.get("hue"),
|
||||||
|
)
|
||||||
|
# разбор последних сообщений источника (спейсинг/read-ack внутри метода);
|
||||||
|
# вступление уже состоялось — сбой backfill не роняет шаг
|
||||||
|
try:
|
||||||
|
await tg.backfill_dialog(dialog_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("join %s: backfill не удался: %s", dialog_id, exc)
|
||||||
|
discovery.remove_blacklist(dialog_id)
|
||||||
|
return {"action": "join", "taskId": task_id}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── tick ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def tick() -> dict:
|
||||||
|
"""Одно действие discovery-воркера (см. docstring модуля)."""
|
||||||
|
if ban_guard.global_paused():
|
||||||
|
return _ACTION_NONE
|
||||||
|
if ban_guard.flood_today():
|
||||||
|
# флуд-блокировка дня: никаких сетевых действий (поиск/оценка/join),
|
||||||
|
# пока действует discFloodDay — воркер просто стоит
|
||||||
|
return _ACTION_NONE
|
||||||
|
running = _running_tasks()
|
||||||
|
if not running:
|
||||||
|
return _ACTION_NONE
|
||||||
|
|
||||||
|
# 1. план достигнут — закрываем задачу (важно до поиска/оценки/join:
|
||||||
|
# задачу с выполненным планом нельзя продолжать обрабатывать)
|
||||||
|
for task in running:
|
||||||
|
if int(task["joined"]) >= int(task["planJoins"]):
|
||||||
|
_finish_done(task)
|
||||||
|
return {"action": "done", "taskId": task["id"]}
|
||||||
|
|
||||||
|
# 2. поиск: следующая running-задача с незавершённым проходом по ключам
|
||||||
|
for task in running:
|
||||||
|
if not task["searchDone"]:
|
||||||
|
return await _search_step(task)
|
||||||
|
|
||||||
|
# 3. оценка: первый кандидат status='new' (самая старая задача — первой)
|
||||||
|
for task in running:
|
||||||
|
new_cands = discovery.list_candidates(task["id"], status="new")
|
||||||
|
if new_cands:
|
||||||
|
return await _eval_step(task, new_cands[0])
|
||||||
|
|
||||||
|
# 4. авто-вступление: отдельный проход, приоритет ниже оценки
|
||||||
|
for task in running:
|
||||||
|
if not task["autoJoin"]:
|
||||||
|
continue
|
||||||
|
review_cands = discovery.list_candidates(task["id"], status="review")
|
||||||
|
if review_cands:
|
||||||
|
if not ban_guard.can_auto_join():
|
||||||
|
return _ACTION_NONE # суточный лимит/флуд/пауза — join никому нельзя
|
||||||
|
return await _join_step(task, review_cands[0])
|
||||||
|
|
||||||
|
return _ACTION_NONE
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Вложения проектных карточек.
|
||||||
|
|
||||||
|
По решению: файлы хранятся в MinIO (креды в env), в БД — метаданные и ключ
|
||||||
|
объекта. Тип определяется автоматически по MIME и расширению.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..db import store
|
||||||
|
|
||||||
|
KIND_BY_EXT = {
|
||||||
|
"image": {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "heic"},
|
||||||
|
"video": {"mp4", "mov", "avi", "mkv", "webm", "m4v"},
|
||||||
|
"audio": {"mp3", "wav", "ogg", "m4a", "flac", "aac"},
|
||||||
|
"archive": {"zip", "rar", "7z", "tar", "gz", "bz2"},
|
||||||
|
"document": {"pdf", "doc", "docx", "xls", "xlsx", "csv", "txt", "md", "rtf", "ppt", "pptx", "odt", "ods"},
|
||||||
|
}
|
||||||
|
|
||||||
|
KIND_LABELS = {
|
||||||
|
"image": "Изображение",
|
||||||
|
"video": "Видео",
|
||||||
|
"audio": "Аудио",
|
||||||
|
"archive": "Архив",
|
||||||
|
"document": "Документ",
|
||||||
|
"other": "Файл",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detect(name: str, mime: str = "") -> dict:
|
||||||
|
ext = (name.split(".")[-1] if "." in name else "").lower()
|
||||||
|
kind = "other"
|
||||||
|
if mime.startswith("image/"):
|
||||||
|
kind = "image"
|
||||||
|
elif mime.startswith("video/"):
|
||||||
|
kind = "video"
|
||||||
|
elif mime.startswith("audio/"):
|
||||||
|
kind = "audio"
|
||||||
|
else:
|
||||||
|
for k, exts in KIND_BY_EXT.items():
|
||||||
|
if ext in exts:
|
||||||
|
kind = k
|
||||||
|
break
|
||||||
|
return {"kind": kind, "label": KIND_LABELS[kind]}
|
||||||
|
|
||||||
|
|
||||||
|
def _files_of(card_id: str) -> list[dict]:
|
||||||
|
from .projects import patch_card
|
||||||
|
|
||||||
|
row = store.query_one("SELECT files FROM projects WHERE id = ?", [card_id])
|
||||||
|
if not row:
|
||||||
|
raise KeyError(card_id)
|
||||||
|
return json.loads(row["files"] or "[]")
|
||||||
|
|
||||||
|
|
||||||
|
def add_file(card_id: str, name: str, data: bytes, mime: str = "") -> dict:
|
||||||
|
"""Сохраняет тело в MinIO и метаданные в карточку."""
|
||||||
|
from . import object_store
|
||||||
|
from .projects import patch_card
|
||||||
|
|
||||||
|
files = _files_of(card_id)
|
||||||
|
info = detect(name, mime)
|
||||||
|
object_key = object_store.put(card_id, name, data, mime)
|
||||||
|
entry = {
|
||||||
|
"id": store.uid("pf_"),
|
||||||
|
"name": name,
|
||||||
|
"size": len(data),
|
||||||
|
"kind": info["kind"],
|
||||||
|
"label": info["label"],
|
||||||
|
"objectKey": object_key,
|
||||||
|
}
|
||||||
|
files.append(entry)
|
||||||
|
patch_card(card_id, {"files": files})
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_entry(card_id: str, file_id: str) -> tuple[dict, list[dict]]:
|
||||||
|
files = _files_of(card_id)
|
||||||
|
entry = next((f for f in files if f["id"] == file_id), None)
|
||||||
|
if not entry:
|
||||||
|
raise KeyError(file_id)
|
||||||
|
return entry, files
|
||||||
|
|
||||||
|
|
||||||
|
def remove_file(card_id: str, file_id: str) -> None:
|
||||||
|
from . import object_store
|
||||||
|
from .projects import patch_card
|
||||||
|
|
||||||
|
files = _files_of(card_id)
|
||||||
|
entry = next((f for f in files if f["id"] == file_id), None)
|
||||||
|
if entry and entry.get("objectKey"):
|
||||||
|
object_store.remove(entry["objectKey"])
|
||||||
|
patch_card(card_id, {"files": [f for f in files if f["id"] != file_id]})
|
||||||
|
|
||||||
|
|
||||||
|
def object_storage_available() -> bool:
|
||||||
|
from . import object_store
|
||||||
|
|
||||||
|
return object_store.configured()
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Полнотекстовый поиск DuckDB FTS (по решению — включаем).
|
||||||
|
|
||||||
|
DuckDB FTS строит виртуальную таблицу-снимок: индекс пересоздаётся при
|
||||||
|
старте, раз в сутки по расписанию и вручную (POST /api/admin/fts/rebuild).
|
||||||
|
Чтобы свежесозданные карточки искались сразу, поиск совмещает FTS с
|
||||||
|
точечным LIKE-дополнением (см. services/leads.search).
|
||||||
|
|
||||||
|
Рабочий синтаксис DuckDB >= 1.0: только PRAGMA create_fts_index
|
||||||
|
(вариант CREATE VIRTUAL TABLE ... USING fts(...) в этой версии падает).
|
||||||
|
Поиск — через табличную функцию fts_main_<table>.match_bm25(rowid, query).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..db import store
|
||||||
|
|
||||||
|
log = logging.getLogger("leadradar.fts")
|
||||||
|
|
||||||
|
OK_KEY = "ftsOk"
|
||||||
|
|
||||||
|
# Источник, колонка-rowid, текстовые колонки (должны совпадать со схемой db.py)
|
||||||
|
_FTS_TARGETS = [
|
||||||
|
("leads", ("title", "summary", "source_msg", "contact")),
|
||||||
|
("messages", ("text",)),
|
||||||
|
("rejected_msgs", ("text",)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_ready() -> bool:
|
||||||
|
return bool(store.get_setting(OK_KEY))
|
||||||
|
|
||||||
|
|
||||||
|
def _set(ok: bool) -> None:
|
||||||
|
store.set_setting(OK_KEY, ok)
|
||||||
|
|
||||||
|
|
||||||
|
def install_extension() -> bool:
|
||||||
|
try:
|
||||||
|
store.execute("INSTALL fts")
|
||||||
|
store.execute("LOAD fts")
|
||||||
|
return True
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("FTS extension unavailable: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild() -> bool:
|
||||||
|
"""Пересоздаёт FTS-индексы по leads и messages (overwrite поверх старого).
|
||||||
|
|
||||||
|
PRAGMA не поддерживает prepared-параметры, поэтому имена подставляются
|
||||||
|
напрямую — это внутренние константы из _FTS_TARGETS, не пользовательский ввод.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
for table, cols in _FTS_TARGETS:
|
||||||
|
col_list = ", ".join(f"'{c}'" for c in cols)
|
||||||
|
store.execute(
|
||||||
|
f"PRAGMA create_fts_index('{table}', 'id', {col_list}, "
|
||||||
|
"stemmer='russian', overwrite=1)"
|
||||||
|
)
|
||||||
|
_set(True)
|
||||||
|
log.info("FTS rebuild done")
|
||||||
|
return True
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("FTS rebuild failed: %s", exc)
|
||||||
|
_set(False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def search(query: str, limit: int = 50) -> dict:
|
||||||
|
"""Возвращает {'leads': [ids], 'messages': [ids], 'rejected': [ids]} по FTS
|
||||||
|
(пусто при сбое)."""
|
||||||
|
out: dict = {"leads": [], "messages": [], "rejected": []}
|
||||||
|
if not is_ready():
|
||||||
|
return out
|
||||||
|
q = _sanitize(query)
|
||||||
|
if not q:
|
||||||
|
return out
|
||||||
|
targets = ("leads", "messages", "rejected_msgs")
|
||||||
|
keys = {"leads": "leads", "messages": "messages", "rejected_msgs": "rejected"}
|
||||||
|
for table in targets:
|
||||||
|
try:
|
||||||
|
# fts_main_<table> создаётся PRAGMA create_fts_index над таблицей
|
||||||
|
fn = f"fts_main_{table}.match_bm25(id, ?)"
|
||||||
|
rows = store.query(
|
||||||
|
f"SELECT id, {fn} AS _s FROM {table} "
|
||||||
|
f"WHERE {fn} IS NOT NULL ORDER BY _s LIMIT ?",
|
||||||
|
[q, q, limit * 2],
|
||||||
|
)
|
||||||
|
out[keys[table]] = [r["id"] for r in rows]
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("fts %s query failed: %s", table, exc)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize(query: str) -> str:
|
||||||
|
"""Минимальная очистка запроса от служебных символов FTS."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
q = query.strip().lower()
|
||||||
|
q = re.sub(r'["\'\\()!*+-]', " ", q)
|
||||||
|
q = " ".join(q.split())
|
||||||
|
return q[:80]
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
"""Доски, колонки и карточки дашборда (п.4.4, 4.5, 4.7 ТЗ).
|
||||||
|
|
||||||
|
Сюда же входят правила хранения (автоархив/очистка) и обучающие примеры
|
||||||
|
(действия пользователя -> learning_log).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .. import constants as C
|
||||||
|
from ..db import store
|
||||||
|
from ..sse import broker
|
||||||
|
from . import fts as fts_svc
|
||||||
|
from . import ml_client
|
||||||
|
from . import processing as processing_svc
|
||||||
|
from .pipeline import (
|
||||||
|
build_contacts,
|
||||||
|
clean_block,
|
||||||
|
clean_short,
|
||||||
|
compose_summary,
|
||||||
|
lead_to_dict,
|
||||||
|
normalize_stack,
|
||||||
|
primary_contact,
|
||||||
|
qualify_contact,
|
||||||
|
)
|
||||||
|
from .rules import board_accepts, extract_amounts, has_active_rules, hits_for_board
|
||||||
|
|
||||||
|
log = logging.getLogger("leadradar.leads")
|
||||||
|
|
||||||
|
KANBAN_COLS = ("inbox",) # + доски
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> int:
|
||||||
|
return time.time_ns() // 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _log_learning(lead_id: str, action: str, from_col: str | None, to_col: str | None) -> None:
|
||||||
|
store.execute(
|
||||||
|
"INSERT INTO learning_log(id, lead_id, action, from_col, to_col, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
[store.uid("lm_"), lead_id, action, from_col, to_col, _now()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Доски / колонки ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def list_boards() -> list[dict]:
|
||||||
|
rows = store.query("SELECT * FROM boards ORDER BY suggested, pos")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"name": r["name"],
|
||||||
|
"description": r["description"] or "",
|
||||||
|
"color": r["color"],
|
||||||
|
"width": r["width"],
|
||||||
|
"collapsed": bool(r["collapsed"]),
|
||||||
|
"keywords": json.loads(r["keywords"] or "[]"),
|
||||||
|
"prompt": r["prompt"] or "",
|
||||||
|
"visibleFields": json.loads(r["visible_fields"] or "[]"),
|
||||||
|
"suggested": bool(r["suggested"]),
|
||||||
|
"rules": json.loads(r["rules"] or "{}"),
|
||||||
|
"note": r["note"] or "",
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def board_by_id(board_id: str) -> dict | None:
|
||||||
|
return next((b for b in list_boards() if b["id"] == board_id), None)
|
||||||
|
|
||||||
|
|
||||||
|
def create_board(
|
||||||
|
name: str,
|
||||||
|
color: str | None = None,
|
||||||
|
keywords: list | None = None,
|
||||||
|
prompt: str = "",
|
||||||
|
description: str = "",
|
||||||
|
suggested: bool = False,
|
||||||
|
rules: dict | None = None,
|
||||||
|
note: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Создаёт колонку. suggested=TRUE — ИИ-предложение, ждёт решения пользователя."""
|
||||||
|
board_id = store.uid("b_")
|
||||||
|
pos = int(store.scalar("SELECT COALESCE(MAX(pos), -1) + 1 FROM boards"))
|
||||||
|
store.execute(
|
||||||
|
"INSERT INTO boards(id, name, description, color, width, pos, keywords, prompt, visible_fields, collapsed, suggested, rules, note, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, 'md', ?, ?, ?, '[\"budget\",\"stack\",\"contacts\"]', FALSE, ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
board_id,
|
||||||
|
name.strip() or "Новая колонка",
|
||||||
|
(description or "").strip(),
|
||||||
|
color or C.PALETTE[pos % len(C.PALETTE)],
|
||||||
|
pos,
|
||||||
|
json.dumps(keywords or [], ensure_ascii=False),
|
||||||
|
prompt or "",
|
||||||
|
bool(suggested),
|
||||||
|
json.dumps(rules or {}, ensure_ascii=False),
|
||||||
|
note or "",
|
||||||
|
_now(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return {"id": board_id}
|
||||||
|
|
||||||
|
|
||||||
|
def patch_board(board_id: str, patch: dict) -> dict:
|
||||||
|
row = store.query_one("SELECT * FROM boards WHERE id = ?", [board_id])
|
||||||
|
if not row:
|
||||||
|
raise KeyError(board_id)
|
||||||
|
allowed = {"name", "description", "color", "width", "collapsed", "prompt", "suggested", "note"}
|
||||||
|
for key in allowed:
|
||||||
|
if key in patch and patch[key] is not None:
|
||||||
|
store.execute(f"UPDATE boards SET {key} = ? WHERE id = ?", [patch[key], board_id])
|
||||||
|
if "keywords" in patch and patch["keywords"] is not None:
|
||||||
|
store.execute("UPDATE boards SET keywords = ? WHERE id = ?", [json.dumps(patch["keywords"], ensure_ascii=False), board_id])
|
||||||
|
if "visibleFields" in patch and patch["visibleFields"] is not None:
|
||||||
|
store.execute("UPDATE boards SET visible_fields = ? WHERE id = ?", [json.dumps(patch["visibleFields"], ensure_ascii=False), board_id])
|
||||||
|
if "rules" in patch and patch["rules"] is not None:
|
||||||
|
store.execute("UPDATE boards SET rules = ? WHERE id = ?", [json.dumps(patch["rules"], ensure_ascii=False), board_id])
|
||||||
|
return {"id": board_id}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_board(board_id: str) -> int:
|
||||||
|
"""Карточки доски уходят в «Неразобранное» (с пометкой новых)."""
|
||||||
|
leads = store.query("SELECT id FROM leads WHERE col = ?", [board_id])
|
||||||
|
for l in leads:
|
||||||
|
store.execute("UPDATE leads SET col = 'inbox', is_new = TRUE, prev_col = 'inbox' WHERE id = ?", [l["id"]])
|
||||||
|
store.execute("DELETE FROM boards WHERE id = ?", [board_id])
|
||||||
|
return len(leads)
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_boards(order: list[str]) -> None:
|
||||||
|
for i, board_id in enumerate(order):
|
||||||
|
store.execute("UPDATE boards SET pos = ? WHERE id = ?", [i, board_id])
|
||||||
|
|
||||||
|
|
||||||
|
def get_col_state() -> dict:
|
||||||
|
return store.get_setting("colState") or {}
|
||||||
|
|
||||||
|
|
||||||
|
def set_col_state(col_id: str, state: dict) -> dict:
|
||||||
|
current = store.get_setting("colState") or {}
|
||||||
|
current[col_id] = state
|
||||||
|
store.set_setting("colState", current)
|
||||||
|
return current[col_id]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Лиды ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def list_leads(col: str | None = None) -> list[dict]:
|
||||||
|
if col:
|
||||||
|
rows = store.query("SELECT id FROM leads WHERE col = ? ORDER BY received_at DESC", [col])
|
||||||
|
else:
|
||||||
|
rows = store.query("SELECT id FROM leads WHERE col NOT IN ('taken') ORDER BY received_at DESC")
|
||||||
|
return [lead_to_dict(r["id"]) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_lead(lead_id: str) -> dict | None:
|
||||||
|
return lead_to_dict(lead_id) or None
|
||||||
|
|
||||||
|
|
||||||
|
def _move(lead_id: str, to_col: str, action: str = "move") -> None:
|
||||||
|
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
|
||||||
|
if not lead or lead["col"] == to_col:
|
||||||
|
return
|
||||||
|
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
|
||||||
|
# при переносе пересчитываем «почему карточка в колонке» (для архив/корзина — пусто)
|
||||||
|
hits = hits_for_board(to_col, text) if to_col not in ("inbox", "trash", "archive") else []
|
||||||
|
store.execute(
|
||||||
|
"UPDATE leads SET col = ?, is_new = FALSE, prev_col = ?, match_hits = ? WHERE id = ?",
|
||||||
|
[to_col, lead["col"], json.dumps(hits, ensure_ascii=False), lead_id],
|
||||||
|
)
|
||||||
|
_log_learning(lead_id, action, lead["col"], to_col)
|
||||||
|
|
||||||
|
|
||||||
|
def move_lead(lead_id: str, to_col: str, teach: bool = True) -> None:
|
||||||
|
"""Перенос между канбаном (Неразобранное и доски); архив/корзина не цели переноса.
|
||||||
|
|
||||||
|
teach=False — «тихое» перемещение без обучения (используется при ручной
|
||||||
|
разметке в ML-лаборатории, где обучение кладётся явно одним событием).
|
||||||
|
"""
|
||||||
|
if to_col not in ("inbox",) and store.query_one("SELECT 1 FROM boards WHERE id = ?", [to_col]) is None:
|
||||||
|
raise ValueError("Переносить можно только на доски или в «Неразобранное»")
|
||||||
|
lead = store.query_one("SELECT source_msg, title, col FROM leads WHERE id = ?", [lead_id])
|
||||||
|
_move(lead_id, to_col)
|
||||||
|
# ML обучается всегда: текст -> выбранная доска
|
||||||
|
if teach and lead and to_col != "inbox" and to_col != lead["col"]:
|
||||||
|
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
|
||||||
|
if text:
|
||||||
|
ml_client.push(text, to_col)
|
||||||
|
|
||||||
|
|
||||||
|
def trash_lead(lead_id: str, teach: bool = True) -> None:
|
||||||
|
lead = store.query_one("SELECT source_msg, title, col FROM leads WHERE id = ?", [lead_id])
|
||||||
|
_move(lead_id, "trash", action="trash")
|
||||||
|
# «в корзину» = спам/не то: ML запоминает (обучение всегда)
|
||||||
|
if teach and lead and lead["col"] != "trash" and lead["col"] != "archive":
|
||||||
|
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
|
||||||
|
if text:
|
||||||
|
ml_client.push(text, "spam")
|
||||||
|
|
||||||
|
|
||||||
|
def restore_lead(lead_id: str) -> str:
|
||||||
|
"""Возврат из архива/корзины — только на канбан."""
|
||||||
|
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
|
||||||
|
if not lead:
|
||||||
|
raise KeyError(lead_id)
|
||||||
|
back = lead["prev_col"] if lead["prev_col"] in ("inbox",) or store.query_one("SELECT 1 FROM boards WHERE id = ?", [lead["prev_col"]]) else "inbox"
|
||||||
|
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
|
||||||
|
hits = hits_for_board(back, text) if back not in ("inbox", "trash", "archive") else []
|
||||||
|
store.execute(
|
||||||
|
"UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox', archived_at = NULL, match_hits = ? WHERE id = ?",
|
||||||
|
[back, json.dumps(hits, ensure_ascii=False), lead_id],
|
||||||
|
)
|
||||||
|
_log_learning(lead_id, "restore", lead["col"], back)
|
||||||
|
# возврат из корзины = не спам: снимаем метку
|
||||||
|
if lead["col"] == "trash":
|
||||||
|
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
|
||||||
|
if text:
|
||||||
|
ml_client.push(text, "spam", delta=-1.0)
|
||||||
|
return back
|
||||||
|
|
||||||
|
|
||||||
|
def _hard_delete(lead_id: str) -> None:
|
||||||
|
"""Полное удаление карточки: leads + dedup (иначе «сирота» заблокирует
|
||||||
|
повторное создание той же карточки при перечитывании) + отвязка исходника."""
|
||||||
|
store.execute("DELETE FROM leads WHERE id = ?", [lead_id])
|
||||||
|
store.execute("DELETE FROM dedup WHERE lead_id = ?", [lead_id])
|
||||||
|
store.execute("UPDATE messages SET lead_id = NULL WHERE lead_id = ?", [lead_id])
|
||||||
|
|
||||||
|
|
||||||
|
def delete_forever(lead_id: str) -> None:
|
||||||
|
_hard_delete(lead_id)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_col(col: str) -> int:
|
||||||
|
"""Полная ручная очистка служебной колонки (корзина/архив) — безвозвратно."""
|
||||||
|
if col not in ("trash", "archive"):
|
||||||
|
raise ValueError("Очищать можно только корзину или архив")
|
||||||
|
ids = [r["id"] for r in store.query("SELECT id FROM leads WHERE col = ?", [col])]
|
||||||
|
if not ids:
|
||||||
|
return 0
|
||||||
|
store.execute("DELETE FROM leads WHERE col = ?", [col])
|
||||||
|
for lead_id in ids:
|
||||||
|
_hard_delete(lead_id)
|
||||||
|
return len(ids)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_seen(lead_id: str | None = None, col: str | None = None) -> None:
|
||||||
|
if lead_id:
|
||||||
|
store.execute("UPDATE leads SET is_new = FALSE WHERE id = ?", [lead_id])
|
||||||
|
elif col:
|
||||||
|
store.execute("UPDATE leads SET is_new = FALSE WHERE col = ?", [col])
|
||||||
|
else:
|
||||||
|
store.execute("UPDATE leads SET is_new = FALSE")
|
||||||
|
|
||||||
|
|
||||||
|
def add_comment(lead_id: str, text: str) -> list[dict]:
|
||||||
|
lead = store.query_one("SELECT comments FROM leads WHERE id = ?", [lead_id])
|
||||||
|
comments = json.loads(lead["comments"] or "[]")
|
||||||
|
comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"})
|
||||||
|
store.execute("UPDATE leads SET comments = ? WHERE id = ?", [json.dumps(comments, ensure_ascii=False), lead_id])
|
||||||
|
_log_learning(lead_id, "comment", None, None)
|
||||||
|
return comments
|
||||||
|
|
||||||
|
|
||||||
|
def counts() -> dict:
|
||||||
|
"""Счётчики по колонкам, новые + статистика ML/ИИ (локальная, без HTTP)."""
|
||||||
|
out = {"new": 0}
|
||||||
|
rows = store.query("SELECT col, count(*) AS cnt, sum(CASE WHEN is_new THEN 1 ELSE 0 END) AS fresh FROM leads GROUP BY col")
|
||||||
|
for r in rows:
|
||||||
|
out[r["col"]] = {"count": r["cnt"], "new": r["fresh"] or 0}
|
||||||
|
out["new"] = sum((v["new"] for k, v in out.items() if isinstance(v, dict)), 0)
|
||||||
|
snap = ml_client.snapshot()
|
||||||
|
out["learning"] = snap["learning"]
|
||||||
|
out["ml"] = snap["ml"]
|
||||||
|
out["ai"] = snap["ai"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Пакетная переклассификация (Inbox) ──────────────────────────────────
|
||||||
|
|
||||||
|
# Фоновая задача переклассификации (одна; повторный вызов возвращает busy)
|
||||||
|
_reclassify_task: object | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def reclassify_busy() -> bool:
|
||||||
|
return bool(_reclassify_task and not _reclassify_task.done())
|
||||||
|
|
||||||
|
|
||||||
|
async def reclassify_lead(lead_id: str) -> dict | None:
|
||||||
|
"""Прогнать карточку «Неразобранного» через полный конвейер ИИ.
|
||||||
|
|
||||||
|
Этап 2 (ИИ-фильтр) + классификатор; мусор/спам/служебные сообщения
|
||||||
|
отправляются в корзину (с обучением ML). Вернувшееся None — карточка
|
||||||
|
без исходного текста или не найденная.
|
||||||
|
"""
|
||||||
|
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
|
||||||
|
if not lead or not lead["source_msg"]:
|
||||||
|
return None
|
||||||
|
from .ai import budget_to_target, classify, clean_budget, filter_incoming
|
||||||
|
|
||||||
|
text = lead["source_msg"]
|
||||||
|
try:
|
||||||
|
r2 = await filter_incoming(text)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("reclassify filter fail %s: %s", lead_id, exc)
|
||||||
|
r2 = {"pass": True, "reason": None, "skipped": True}
|
||||||
|
if not r2["pass"]:
|
||||||
|
trash_lead(lead_id, teach=False)
|
||||||
|
ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT)
|
||||||
|
return {"status": "trashed", "reason": str(r2.get("reason") or "не прошло ИИ-фильтр")[:120]}
|
||||||
|
raw = await classify(text)
|
||||||
|
if raw.get("is_spam"):
|
||||||
|
trash_lead(lead_id, teach=False)
|
||||||
|
ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT)
|
||||||
|
return {"status": "trashed", "reason": "ИИ: не заявка/спам"}
|
||||||
|
board_id = str(raw.get("board") or "").strip() or None
|
||||||
|
# страховка: колонку с активными правилами может назначить только текст,
|
||||||
|
# прошедший эти правила (иначе ручная переклассификация закидывает хлам)
|
||||||
|
if board_id and not board_accepts(board_id, text):
|
||||||
|
board_id = None
|
||||||
|
budget = clean_budget(raw.get("budget"))
|
||||||
|
contacts = build_contacts(raw.get("contacts"), text)
|
||||||
|
contact = primary_contact(contacts)[:200]
|
||||||
|
if not contact:
|
||||||
|
# старый контакт оставляем только если он валидный (@, телефон, почта…),
|
||||||
|
# а не мусорная фраза из старого разбора
|
||||||
|
old = str(lead["contact"] or "").strip()[:200]
|
||||||
|
contact = old if old and qualify_contact(old) else ""
|
||||||
|
stack = normalize_stack(raw.get("stack"))
|
||||||
|
new_title = clean_short(raw.get("title") or "", 140) or clean_short(lead["title"], 140)
|
||||||
|
new_summary = clean_block(compose_summary(raw, text), 2000) or clean_block(lead["summary"], 2000)
|
||||||
|
if not budget:
|
||||||
|
# ИИ не выделил бюджет полем, но сумма с валютой есть в исходнике или
|
||||||
|
# в структурированной «О заявке» — показываем её на карточке.
|
||||||
|
for src in (text, new_summary):
|
||||||
|
amts = extract_amounts(src or "")
|
||||||
|
if amts:
|
||||||
|
a = amts[0]
|
||||||
|
budget = {"from": a["from"], "to": a["to"], "currency": a["cur"]}
|
||||||
|
break
|
||||||
|
conv = budget_to_target(budget)
|
||||||
|
hits = hits_for_board(board_id, text) if board_id else []
|
||||||
|
store.execute(
|
||||||
|
"UPDATE leads SET col = ?, title = ?, summary = ?, is_vacancy = ?, is_vacancy_known = TRUE, "
|
||||||
|
"stack = ?, budget_from = ?, budget_to = ?, budget_cur = ?, "
|
||||||
|
"conv_from = ?, conv_to = ?, conv_cur = ?, contact = ?, contacts = ?, "
|
||||||
|
"match_hits = ?, is_new = TRUE "
|
||||||
|
"WHERE id = ?",
|
||||||
|
[
|
||||||
|
board_id or "inbox",
|
||||||
|
new_title,
|
||||||
|
new_summary,
|
||||||
|
bool(raw.get("is_vacancy")),
|
||||||
|
json.dumps(stack, ensure_ascii=False),
|
||||||
|
budget.get("from") if budget else None,
|
||||||
|
budget.get("to") if budget else None,
|
||||||
|
budget.get("currency", "") if budget else "",
|
||||||
|
conv["convFrom"], conv["convTo"], conv["convCur"],
|
||||||
|
contact,
|
||||||
|
json.dumps(contacts, ensure_ascii=False),
|
||||||
|
json.dumps(hits, ensure_ascii=False),
|
||||||
|
lead_id,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# ИИ-решение при переклассификации — тоже обучающий сигнал для ML.
|
||||||
|
# Учим только «свободные» колонки (без активных правил, не suggested):
|
||||||
|
# именно их ML может назначать сама в своём пути.
|
||||||
|
if board_id:
|
||||||
|
br = store.query_one("SELECT suggested, rules FROM boards WHERE id = ?", [board_id])
|
||||||
|
free = False
|
||||||
|
if br and not bool(br["suggested"]):
|
||||||
|
try:
|
||||||
|
br_rules = json.loads(br["rules"] or "{}") if br["rules"] else {}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
br_rules = {}
|
||||||
|
free = not has_active_rules(br_rules)
|
||||||
|
if free:
|
||||||
|
ml_client.push(text, board_id, delta=ml_client.AI_WEIGHT)
|
||||||
|
# тип известен от ИИ — учим ML определять его сам (t:hire / t:order)
|
||||||
|
if not bool(raw.get("is_spam")):
|
||||||
|
ml_client.push(
|
||||||
|
text,
|
||||||
|
"t:hire" if bool(raw.get("is_vacancy")) else "t:order",
|
||||||
|
delta=ml_client.AI_WEIGHT,
|
||||||
|
)
|
||||||
|
return {"status": "moved" if board_id else "kept"}
|
||||||
|
|
||||||
|
|
||||||
|
async def reclassify_inbox(ids: list[str] | None = None) -> dict:
|
||||||
|
"""Переклассифицировать «Неразобранное» (все карточки или выбранные)."""
|
||||||
|
rows = store.query("SELECT id FROM leads WHERE col = 'inbox'")
|
||||||
|
if ids:
|
||||||
|
wanted = set(ids)
|
||||||
|
target = [r["id"] for r in rows if r["id"] in wanted]
|
||||||
|
else:
|
||||||
|
target = [r["id"] for r in rows]
|
||||||
|
res = {"attempted": len(target), "kept": 0, "moved": 0, "trashed": 0}
|
||||||
|
for lead_id in target:
|
||||||
|
try:
|
||||||
|
out = await reclassify_lead(lead_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("reclassify %s failed: %s", lead_id, exc)
|
||||||
|
continue
|
||||||
|
if not out:
|
||||||
|
continue
|
||||||
|
status = out.get("status")
|
||||||
|
if status == "trashed":
|
||||||
|
res["trashed"] += 1
|
||||||
|
elif status == "moved":
|
||||||
|
res["moved"] += 1
|
||||||
|
else:
|
||||||
|
res["kept"] += 1
|
||||||
|
await broker.publish("leads_reclassified", res)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
async def start_reclassify(ids: list[str] | None = None) -> dict:
|
||||||
|
"""Запустить переклассификацию в фоне (одна задача за раз)."""
|
||||||
|
global _reclassify_task
|
||||||
|
if not store.get_setting("aiEnabled"):
|
||||||
|
return {"started": False, "busy": False, "attempted": 0, "reason": "ИИ выключен — переклассификация недоступна"}
|
||||||
|
if reclassify_busy():
|
||||||
|
return {"started": False, "busy": True}
|
||||||
|
rows = store.query("SELECT id FROM leads WHERE col = 'inbox'")
|
||||||
|
if ids:
|
||||||
|
wanted = set(ids)
|
||||||
|
target = [r["id"] for r in rows if r["id"] in wanted]
|
||||||
|
else:
|
||||||
|
target = [r["id"] for r in rows]
|
||||||
|
if not target:
|
||||||
|
return {"started": False, "busy": False, "attempted": 0}
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
try:
|
||||||
|
res = await reclassify_inbox(ids)
|
||||||
|
await broker.publish_toast(
|
||||||
|
f"Переклассификация готова: {res['trashed']} в корзину, "
|
||||||
|
f"{res['moved']} в колонки, {res['kept']} осталось в «Неразобранном»",
|
||||||
|
"sparkles",
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("reclassify task error: %s", exc)
|
||||||
|
await broker.publish_toast("Переклассификация завершилась с ошибкой", "x")
|
||||||
|
|
||||||
|
_reclassify_task = asyncio.create_task(_run())
|
||||||
|
return {"started": True, "busy": False, "attempted": len(target)}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Правила хранения (тик раз в 30 секунд) ───────────────────────────────
|
||||||
|
|
||||||
|
def tick_storage() -> dict:
|
||||||
|
auto = bool(store.get_setting("autoArchive"))
|
||||||
|
after_days = int(store.get_setting("archiveAfterDays") or 14)
|
||||||
|
archive_clear = int(store.get_setting("archiveClearDays") or 90)
|
||||||
|
trash_clear = int(store.get_setting("trashClearDays") or 7)
|
||||||
|
now = _now()
|
||||||
|
archived = purged_arch = purged_trash = 0
|
||||||
|
|
||||||
|
if auto:
|
||||||
|
rows = store.query(
|
||||||
|
"SELECT id FROM leads WHERE col IN (SELECT id FROM boards UNION ALL SELECT 'inbox') "
|
||||||
|
"AND received_at < ?",
|
||||||
|
[now - after_days * C.DAY_MS],
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
store.execute(
|
||||||
|
"UPDATE leads SET col = 'archive', is_new = FALSE, archived_at = ? WHERE id = ?",
|
||||||
|
[now, r["id"]],
|
||||||
|
)
|
||||||
|
archived += 1
|
||||||
|
|
||||||
|
old_arch = store.query("SELECT id FROM leads WHERE col = 'archive' AND archived_at IS NOT NULL AND archived_at < ?", [now - archive_clear * C.DAY_MS])
|
||||||
|
for r in old_arch:
|
||||||
|
_hard_delete(r["id"])
|
||||||
|
purged_arch += 1
|
||||||
|
|
||||||
|
old_trash = store.query("SELECT id FROM leads WHERE col = 'trash' AND received_at < ?", [now - trash_clear * C.DAY_MS])
|
||||||
|
for r in old_trash:
|
||||||
|
_hard_delete(r["id"])
|
||||||
|
purged_trash += 1
|
||||||
|
|
||||||
|
# отсев пайплайна живёт 3 суток, дальше удаляется автоматически
|
||||||
|
purged_rejected = processing_svc.purge_expired()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"archived": archived,
|
||||||
|
"purgedArchive": purged_arch,
|
||||||
|
"purgedTrash": purged_trash,
|
||||||
|
"purgedRejected": purged_rejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_tick_stats(stats: dict) -> None:
|
||||||
|
if stats["archived"]:
|
||||||
|
await broker.publish_toast(f"Автоархив: {stats['archived']} карточек", "clock")
|
||||||
|
if stats["purgedArchive"]:
|
||||||
|
await broker.publish_toast(f"Архив очищен: {stats['purgedArchive']} (90 дн.)", "trash")
|
||||||
|
if stats["purgedTrash"]:
|
||||||
|
await broker.publish_toast(f"Корзина очищена: {stats['purgedTrash']} (7 дн.)", "trash")
|
||||||
|
if stats.get("purgedRejected"):
|
||||||
|
await broker.publish_toast(f"Отсев очищен: {stats['purgedRejected']} записей (3 дн.)", "trash")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Поиск ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def search(q: str, limit: int = 12) -> dict:
|
||||||
|
qq = q.strip().lower()
|
||||||
|
if len(qq) < 2:
|
||||||
|
return {"leads": [], "messages": []}
|
||||||
|
pattern = f"%{qq}%"
|
||||||
|
|
||||||
|
# FTS-кандидаты (снимок индекса)
|
||||||
|
fts_ids: list[str] = []
|
||||||
|
if fts_svc.is_ready():
|
||||||
|
try:
|
||||||
|
fts_ids = fts_svc.search(qq, limit=limit)["leads"]
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
fts_ids = []
|
||||||
|
|
||||||
|
# LIKE-дополнение (свежие записи после последнего rebuild)
|
||||||
|
like_ids = [
|
||||||
|
r["id"]
|
||||||
|
for r in store.query(
|
||||||
|
"SELECT id FROM leads WHERE col != 'taken' AND ("
|
||||||
|
" lower(title) LIKE ? OR lower(summary) LIKE ? OR lower(contact) LIKE ? OR lower(source_msg) LIKE ?) "
|
||||||
|
"ORDER BY received_at DESC LIMIT ?",
|
||||||
|
[pattern, pattern, pattern, pattern, limit * 2],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
merged: list[str] = []
|
||||||
|
for bid in [*fts_ids, *like_ids]:
|
||||||
|
if bid not in merged:
|
||||||
|
merged.append(bid)
|
||||||
|
|
||||||
|
leads = []
|
||||||
|
for lid in merged:
|
||||||
|
d = lead_to_dict(lid)
|
||||||
|
if d and d["col"] != "taken":
|
||||||
|
leads.append(d)
|
||||||
|
if len(leads) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
msgs = store.query(
|
||||||
|
"SELECT id, dialog_id, text, msg_at FROM messages WHERE lower(text) LIKE ? ORDER BY msg_at DESC LIMIT 20",
|
||||||
|
[pattern],
|
||||||
|
)
|
||||||
|
return {"leads": leads, "messages": msgs}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Клиент автономного ML-сервиса + локальный outbox обучения.
|
||||||
|
|
||||||
|
Основное приложение НИКОГДА не обучает ML напрямую «в своей» базе: каждое
|
||||||
|
действие пользователя синхронно пишется в таблицу ml_outbox, а фоновый
|
||||||
|
воркер (main._ml_sync_loop) отправляет накопленное в ML-сервис батчами.
|
||||||
|
Использование ML в пайплайне включается настройкой `mlEnabled`; обучение
|
||||||
|
идёт всегда.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .. import config
|
||||||
|
from ..db import store
|
||||||
|
|
||||||
|
log = logging.getLogger("leadradar.mlclient")
|
||||||
|
|
||||||
|
DECISIONS_ML = "mlDecisions"
|
||||||
|
DECISIONS_AI = "aiDecisions"
|
||||||
|
|
||||||
|
# Веса обучающих сигналов: действия пользователя — истина (1.0), решения ИИ —
|
||||||
|
# гипотезы (меньше), чтобы реальные действия со временем перевешивали ошибки ИИ.
|
||||||
|
USER_WEIGHT = 1.0
|
||||||
|
AI_WEIGHT = 0.4
|
||||||
|
RULE_WEIGHT = 0.6
|
||||||
|
|
||||||
|
# кэш статуса сервиса (обновляется фоновым циклом; живёт не дольше 15 c)
|
||||||
|
_cached: dict = {"at": 0, "data": None, "reachable": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> int:
|
||||||
|
return time.time_ns() // 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Обучение: всегда пишем в outbox ──────────────────────────────────────
|
||||||
|
|
||||||
|
def push(text: str, label: str, delta: float = 1.0) -> None:
|
||||||
|
"""Действие пользователя -> событие обучения (гарантированно, локально)."""
|
||||||
|
text = (text or "").strip()
|
||||||
|
label = str(label or "").strip()
|
||||||
|
if not text or not label:
|
||||||
|
return
|
||||||
|
store.execute(
|
||||||
|
"INSERT INTO ml_outbox(id, text, label, delta, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[store.uid("mle_"), text[:6000], label, delta, _now()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def outbox_len() -> int:
|
||||||
|
return int(store.scalar("SELECT count(*) FROM ml_outbox") or 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def flush_outbox(batch: int = 100) -> int:
|
||||||
|
"""Отправляет накопленные события в ML-сервис небольшими порциями.
|
||||||
|
|
||||||
|
Один learn-batch из сотен сообщений надолго блокирует ML-сервис
|
||||||
|
(модель пишет каждый термин отдельным INSERT) и упирается в таймаут;
|
||||||
|
порции по 20 строк проходят быстро и не роняют сервис.
|
||||||
|
"""
|
||||||
|
chunk = 10
|
||||||
|
total = 0
|
||||||
|
while total < batch:
|
||||||
|
rows = store.query(
|
||||||
|
"SELECT id, text, label, delta FROM ml_outbox ORDER BY created_at LIMIT ?",
|
||||||
|
[chunk],
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
items = [{"text": r["text"], "label": r["label"], "delta": r["delta"]} for r in rows]
|
||||||
|
try:
|
||||||
|
await _post("/learn-batch", {"items": items}, timeout=config.ML_TIMEOUT + 10)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("ml outbox flush failed (%d rows): %s", len(rows), exc)
|
||||||
|
break
|
||||||
|
ids = [r["id"] for r in rows]
|
||||||
|
store.execute(f"DELETE FROM ml_outbox WHERE id IN ({','.join('?' * len(ids))})", ids)
|
||||||
|
total += len(rows)
|
||||||
|
log.info("ml outbox flushed: %d", len(rows))
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
# ─── HTTP ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _post(path: str, body: dict, timeout: float | None = None) -> dict:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client:
|
||||||
|
resp = await client.post(config.ML_URL + path, json=body)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get(path: str, timeout: float | None = None) -> dict:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client:
|
||||||
|
resp = await client.get(config.ML_URL + path)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def predict(text: str) -> dict:
|
||||||
|
"""Предсказание. При сбое/недоступности сервиса — «не уверен» (решит ИИ)."""
|
||||||
|
try:
|
||||||
|
return await _post("/predict", {"text": (text or "")[:6000]})
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("ml predict unavailable: %s", exc)
|
||||||
|
return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": False}
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_model() -> dict:
|
||||||
|
"""Полный сброс ML-модели + очистка очереди обучения.
|
||||||
|
|
||||||
|
Старая модель (в т.ч. «мусорные» классы удалённых колонок) стирается,
|
||||||
|
события обучения из outbox тоже удаляются — иначе они сразу «переобучат»
|
||||||
|
модель на старых данных.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _post("/reset", {}, timeout=30)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("ml reset failed: %s", exc)
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
store.execute("DELETE FROM ml_outbox")
|
||||||
|
await refresh_status()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_status() -> dict:
|
||||||
|
global _cached
|
||||||
|
try:
|
||||||
|
data = await _get("/status", timeout=3)
|
||||||
|
_cached = {"at": _now(), "data": data, "reachable": True}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("ml status unavailable: %s", exc)
|
||||||
|
_cached = {"at": _now(), "data": _cached["data"], "reachable": False}
|
||||||
|
return _cached["data"] or {}
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot() -> dict:
|
||||||
|
"""Локальная статистика + последний известный статус ML-сервиса (без HTTP)."""
|
||||||
|
fresh = _cached["data"] or {}
|
||||||
|
return {
|
||||||
|
"ml": int(store.get_setting(DECISIONS_ML) or 0),
|
||||||
|
"ai": int(store.get_setting(DECISIONS_AI) or 0),
|
||||||
|
"learning": int(store.scalar("SELECT count(*) FROM learning_log") or 0),
|
||||||
|
"ready": bool(fresh.get("ready")),
|
||||||
|
"classes": fresh.get("classes", {}),
|
||||||
|
"learned": int(fresh.get("learned") or 0),
|
||||||
|
"reachable": _cached["reachable"],
|
||||||
|
"outbox": outbox_len(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def track_decisions(ml: int = 0, ai: int = 0) -> None:
|
||||||
|
if ml:
|
||||||
|
store.set_setting(DECISIONS_ML, int(store.get_setting(DECISIONS_ML) or 0) + ml)
|
||||||
|
if ai:
|
||||||
|
store.set_setting(DECISIONS_AI, int(store.get_setting(DECISIONS_AI) or 0) + ai)
|
||||||
|
|
||||||
|
|
||||||
|
def is_enabled() -> bool:
|
||||||
|
"""Использовать ли ML в пайплайне (настройка UI). Обучение — всегда."""
|
||||||
|
return store.get_setting("mlEnabled") is not False and _cached["reachable"]
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Хранение вложений: MinIO (S3-совместимое), креды в env.
|
||||||
|
|
||||||
|
Если MinIO не настроен (локальная разработка без docker-compose), объекты
|
||||||
|
сохраняются в локальную папку DATA_DIR/attachments — API и карточки при этом
|
||||||
|
не меняются, в БД хранится только ключ объекта.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .. import config
|
||||||
|
|
||||||
|
log = logging.getLogger("leadradar.objectstore")
|
||||||
|
|
||||||
|
|
||||||
|
def configured() -> bool:
|
||||||
|
return bool(config.MINIO_ENDPOINT and config.MINIO_ACCESS_KEY and config.MINIO_SECRET_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
_client = None
|
||||||
|
_client_checked = False
|
||||||
|
|
||||||
|
|
||||||
|
def client():
|
||||||
|
"""MinIO-клиент (создаётся лениво). Бросает ошибку, если не настроен."""
|
||||||
|
global _client, _client_checked
|
||||||
|
if _client is not None:
|
||||||
|
return _client
|
||||||
|
if not configured():
|
||||||
|
raise RuntimeError(
|
||||||
|
"MinIO не настроен: задайте LEADRADAR_MINIO_ENDPOINT / _ACCESS_KEY / _SECRET_KEY / _BUCKET"
|
||||||
|
)
|
||||||
|
from minio import Minio
|
||||||
|
from minio.error import S3Error
|
||||||
|
|
||||||
|
_client = Minio(
|
||||||
|
config.MINIO_ENDPOINT,
|
||||||
|
access_key=config.MINIO_ACCESS_KEY,
|
||||||
|
secret_key=config.MINIO_SECRET_KEY,
|
||||||
|
secure=config.MINIO_SECURE,
|
||||||
|
)
|
||||||
|
if not _client_checked:
|
||||||
|
_client_checked = True
|
||||||
|
try:
|
||||||
|
if not _client.bucket_exists(config.MINIO_BUCKET):
|
||||||
|
_client.make_bucket(config.MINIO_BUCKET)
|
||||||
|
except S3Error as exc:
|
||||||
|
log.warning("bucket check failed: %s", exc)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def _local_path(object_key: str) -> Path:
|
||||||
|
# object_key вида projects/{card_id}/{ts}_{name}; не даём выйти за пределы FILES_DIR
|
||||||
|
safe = Path(object_key).name
|
||||||
|
parent = Path(object_key).parent
|
||||||
|
return (config.FILES_DIR / parent / safe).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def put(card_id: str, name: str, data: bytes, content_type: str) -> str:
|
||||||
|
"""Сохраняет объект (MinIO или локально), возвращает objectKey."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
object_key = f"projects/{card_id}/{int(time.time() * 1000)}_{name}"
|
||||||
|
if configured():
|
||||||
|
client().put_object(
|
||||||
|
config.MINIO_BUCKET,
|
||||||
|
object_key,
|
||||||
|
io.BytesIO(data),
|
||||||
|
length=len(data),
|
||||||
|
content_type=content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
path = _local_path(object_key)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(data)
|
||||||
|
log.info("MinIO не настроен — файл сохранён локально: %s", path)
|
||||||
|
return object_key
|
||||||
|
|
||||||
|
|
||||||
|
def get(object_key: str):
|
||||||
|
"""Возвращает поток (бинарный) для скачивания."""
|
||||||
|
if configured():
|
||||||
|
try:
|
||||||
|
return client().get_object(config.MINIO_BUCKET, object_key)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("minio get failed: %s", exc)
|
||||||
|
raise
|
||||||
|
path = _local_path(object_key)
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileNotFoundError(object_key)
|
||||||
|
return io.BytesIO(path.read_bytes())
|
||||||
|
|
||||||
|
|
||||||
|
def remove(object_key: str) -> None:
|
||||||
|
if configured():
|
||||||
|
try:
|
||||||
|
client().remove_object(config.MINIO_BUCKET, object_key)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("minio remove failed: %s", exc)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
path = _local_path(object_key)
|
||||||
|
if path.is_file():
|
||||||
|
path.unlink()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("local remove failed: %s", exc)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,320 @@
|
|||||||
|
"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев).
|
||||||
|
|
||||||
|
Очередь — сырые сообщения из каналов, ждущие разбора (pipeline_msg).
|
||||||
|
Отсев — сообщения, отброшенные на любом этапе: стоп-фразы/резюме/тип заявки/
|
||||||
|
без суммы (source='stop'), устарело ('stale'), ML ('ml'), ИИ ('ai'), повтор
|
||||||
|
('dup'). Для каждой записи храним этап, причину и конкретное слово/фразу
|
||||||
|
(kw), если отсев по стоп-списку.
|
||||||
|
|
||||||
|
Автоочистка отсева — раз в 3 суток (вызывается из leads.tick_storage),
|
||||||
|
плюс ручная очистка и удаление отдельных записей из UI.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..db import store
|
||||||
|
from . import fts as fts_svc
|
||||||
|
|
||||||
|
log = logging.getLogger("leadradar.processing")
|
||||||
|
|
||||||
|
# отсев живёт 3 суток, дальше удаляется автоматически
|
||||||
|
RETENTION_DAYS = 3
|
||||||
|
|
||||||
|
# человекочитаемые подписи этапов (для UI; source хранится отдельно)
|
||||||
|
_STAGE_LABELS = {
|
||||||
|
"length": "короткое сообщение",
|
||||||
|
"stop": "стоп-фраза",
|
||||||
|
"resume": "резюме соискателя",
|
||||||
|
"type": "тип заявки",
|
||||||
|
"budget": "нет суммы",
|
||||||
|
"stale": "устарело",
|
||||||
|
"spam_ml": "спам (ML)",
|
||||||
|
"spam_ai": "спам (ИИ)",
|
||||||
|
"filter_ai": "ИИ-фильтр",
|
||||||
|
"dup": "повтор",
|
||||||
|
}
|
||||||
|
|
||||||
|
# «чьё» решение: используется в UI как источник метки
|
||||||
|
_SOURCE_LABELS = {
|
||||||
|
"stop": "правила",
|
||||||
|
"ml": "ML",
|
||||||
|
"ai": "ИИ",
|
||||||
|
"stale": "система",
|
||||||
|
"dup": "система",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_LIMIT = 100
|
||||||
|
MAX_LIMIT = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> int:
|
||||||
|
return time.time_ns() // 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def stage_label(stage: str) -> str:
|
||||||
|
return _STAGE_LABELS.get(stage, stage or "отсев")
|
||||||
|
|
||||||
|
|
||||||
|
def source_label(source: str) -> str:
|
||||||
|
return _SOURCE_LABELS.get(source, source or "система")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Запись отсева ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def record(row: dict, source: str, stage: str, reason: str, kw: str = "") -> None:
|
||||||
|
"""Сохраняет отброшенное сообщение в таблицу отсева.
|
||||||
|
|
||||||
|
Ид записи детерминирован по (dialog_id, msg_id): повторное отбрасывание
|
||||||
|
того же сообщения (перечитывание каналов) обновляет запись, а не копит
|
||||||
|
дубликаты в списке отсева.
|
||||||
|
"""
|
||||||
|
if not row or not (row.get("text") or "").strip():
|
||||||
|
return
|
||||||
|
msg_id = row.get("msg_id")
|
||||||
|
dialog_id = row.get("dialog_id") or ""
|
||||||
|
rid = f"r_{dialog_id}_{msg_id}" if (msg_id is not None and dialog_id) else store.uid("r_")
|
||||||
|
now = _now()
|
||||||
|
store.execute(
|
||||||
|
"INSERT INTO rejected_msgs(id, dialog_id, msg_id, text, ch_name, ch_handle, ch_hue, stage, reason, kw, source, msg_at, rejected_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(id) DO UPDATE SET "
|
||||||
|
"text = excluded.text, ch_name = excluded.ch_name, ch_handle = excluded.ch_handle, "
|
||||||
|
"ch_hue = excluded.ch_hue, stage = excluded.stage, reason = excluded.reason, kw = excluded.kw, "
|
||||||
|
"source = excluded.source, msg_at = excluded.msg_at, rejected_at = excluded.rejected_at",
|
||||||
|
[
|
||||||
|
rid,
|
||||||
|
dialog_id,
|
||||||
|
msg_id,
|
||||||
|
str(row["text"])[:6000],
|
||||||
|
str(row.get("ch_name") or ""),
|
||||||
|
str(row.get("ch_handle") or ""),
|
||||||
|
str(row.get("ch_hue") or "#666"),
|
||||||
|
stage,
|
||||||
|
str(reason or "")[:500],
|
||||||
|
str(kw or "")[:200],
|
||||||
|
source,
|
||||||
|
row.get("msg_at"),
|
||||||
|
now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def purge_expired(days: int = RETENTION_DAYS) -> int:
|
||||||
|
"""Автоочистка отсева: записи старше N суток удаляются безвозвратно."""
|
||||||
|
cut = _now() - days * 24 * 3600 * 1000
|
||||||
|
rows = store.query(
|
||||||
|
"SELECT id FROM rejected_msgs WHERE rejected_at < ?", [cut]
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
ids = [r["id"] for r in rows]
|
||||||
|
store.execute(
|
||||||
|
"DELETE FROM rejected_msgs WHERE id IN (" + ",".join(["?"] * len(ids)) + ")",
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
return len(ids)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_all() -> int:
|
||||||
|
rows = store.query("SELECT count(*) AS c FROM rejected_msgs")
|
||||||
|
total = int(rows[0]["c"]) if rows else 0
|
||||||
|
if total:
|
||||||
|
store.execute("DELETE FROM rejected_msgs")
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def return_to_queue(rej_id: str, reason: str = "") -> dict:
|
||||||
|
"""Вернуть отсеянное сообщение в обработку (кнопка в «Обработке»).
|
||||||
|
|
||||||
|
Строка очереди помечается force: этап 1, устарело, ML-решения и ИИ-отсев
|
||||||
|
для неё игнорируются — сообщение уходит на классификацию и создаёт карточку.
|
||||||
|
Запись в отсеве не удаляется, а помечается «возвращено» с причиной (аудит).
|
||||||
|
Если отсев был по решению «спам» (ML/ИИ) — снимаем у ML вес спама для текста.
|
||||||
|
"""
|
||||||
|
row = store.query_one("SELECT * FROM rejected_msgs WHERE id = ?", [rej_id])
|
||||||
|
if not row:
|
||||||
|
raise KeyError(rej_id)
|
||||||
|
if bool(row.get("returned")):
|
||||||
|
raise ValueError("Сообщение уже возвращено в обработку")
|
||||||
|
if str(row.get("source") or "") == "dup":
|
||||||
|
raise ValueError("Повтор: карточка с таким текстом уже есть в системе — возвращать нечего")
|
||||||
|
dialog_id = str(row.get("dialog_id") or "")
|
||||||
|
msg_id = row.get("msg_id")
|
||||||
|
text = str(row.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("В записи нет текста сообщения")
|
||||||
|
|
||||||
|
if str(row.get("stage") or "") in ("spam_ml", "spam_ai", "filter_ai"):
|
||||||
|
from . import ml_client
|
||||||
|
|
||||||
|
# реальное действие пользователя: этот текст НЕ спам
|
||||||
|
ml_client.push(text, "spam", delta=-1.0)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
store.execute(
|
||||||
|
"UPDATE rejected_msgs SET returned = TRUE, returned_at = ?, return_reason = ? WHERE id = ?",
|
||||||
|
[now, str(reason or "").strip()[:500], rej_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
from .pipeline import enqueue # локальный импорт: pipeline импортирует processing
|
||||||
|
|
||||||
|
if dialog_id and msg_id is not None:
|
||||||
|
enqueue(
|
||||||
|
dialog_id,
|
||||||
|
str(row.get("ch_name") or ""),
|
||||||
|
str(row.get("ch_handle") or ""),
|
||||||
|
str(row.get("ch_hue") or "#666"),
|
||||||
|
msg_id,
|
||||||
|
text,
|
||||||
|
row.get("msg_at") or now,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# старые записи (до сохранения dialog_id/msg_id): текст сохранился,
|
||||||
|
# возвращаем без ссылки на исходное сообщение (force=True)
|
||||||
|
store.execute(
|
||||||
|
"INSERT INTO pipeline_msg(id, dialog_id, ch_name, ch_handle, ch_hue, text, msg_id, msg_at, status, force, created_at, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', TRUE, ?, ?)",
|
||||||
|
[
|
||||||
|
store.uid("p_"),
|
||||||
|
dialog_id,
|
||||||
|
str(row.get("ch_name") or ""),
|
||||||
|
str(row.get("ch_handle") or ""),
|
||||||
|
str(row.get("ch_hue") or "#666"),
|
||||||
|
text[:6000],
|
||||||
|
msg_id,
|
||||||
|
row.get("msg_at") or now,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return {"id": rej_id, "returned": True, "returnedAt": now}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_one(rej_id: str) -> bool:
|
||||||
|
store.execute("DELETE FROM rejected_msgs WHERE id = ?", [rej_id])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def rejected_count() -> int:
|
||||||
|
return int(store.scalar("SELECT count(*) FROM rejected_msgs") or 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Очередь (pipeline_msg) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def queue_counts() -> dict:
|
||||||
|
out = {"new": 0, "ai": 0}
|
||||||
|
for r in store.query("SELECT status, count(*) AS c FROM pipeline_msg GROUP BY status"):
|
||||||
|
if r["status"] == "new":
|
||||||
|
out["new"] = int(r["c"])
|
||||||
|
elif r["status"] == "filtered":
|
||||||
|
out["ai"] = int(r["c"])
|
||||||
|
out["total"] = out["new"] + out["ai"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def list_queue(limit: int = DEFAULT_LIMIT) -> list[dict]:
|
||||||
|
limit = min(max(1, limit), MAX_LIMIT)
|
||||||
|
rows = store.query(
|
||||||
|
"SELECT * FROM pipeline_msg ORDER BY created_at LIMIT ?", [limit]
|
||||||
|
)
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"dialogId": r.get("dialog_id") or "",
|
||||||
|
"msgId": r.get("msg_id"),
|
||||||
|
"text": r["text"],
|
||||||
|
"status": r["status"], # new | filtered
|
||||||
|
"ch": {
|
||||||
|
"name": r["ch_name"],
|
||||||
|
"handle": r["ch_handle"],
|
||||||
|
"hue": r["ch_hue"],
|
||||||
|
},
|
||||||
|
"msgAt": r["msg_at"],
|
||||||
|
"queuedAt": r["created_at"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Отсев (rejected_msgs) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def list_rejected(q: str = "", offset: int = 0, limit: int = DEFAULT_LIMIT) -> dict:
|
||||||
|
offset = max(0, offset)
|
||||||
|
limit = min(max(1, limit), MAX_LIMIT)
|
||||||
|
qq = (q or "").strip().lower()
|
||||||
|
ids: list[str] = []
|
||||||
|
|
||||||
|
if qq:
|
||||||
|
# FTS-кандидаты + LIKE-дополнение (свежие записи после последнего rebuild)
|
||||||
|
if fts_svc.is_ready():
|
||||||
|
try:
|
||||||
|
ids = fts_svc.search(qq, limit=limit)["rejected"]
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
ids = []
|
||||||
|
pattern = f"%{qq}%"
|
||||||
|
like = store.query(
|
||||||
|
"SELECT id FROM rejected_msgs WHERE "
|
||||||
|
"lower(text) LIKE ? OR lower(reason) LIKE ? OR lower(kw) LIKE ? OR lower(ch_name) LIKE ? "
|
||||||
|
"ORDER BY rejected_at DESC LIMIT ?",
|
||||||
|
[pattern, pattern, pattern, pattern, limit * 2],
|
||||||
|
)
|
||||||
|
for r in like:
|
||||||
|
if r["id"] not in ids:
|
||||||
|
ids.append(r["id"])
|
||||||
|
total = len(ids) # итог по условию поиска (все кандидаты)
|
||||||
|
page = ids[offset : offset + limit]
|
||||||
|
else:
|
||||||
|
total = rejected_count()
|
||||||
|
page_rows = store.query(
|
||||||
|
"SELECT id FROM rejected_msgs ORDER BY rejected_at DESC LIMIT ? OFFSET ?",
|
||||||
|
[limit, offset],
|
||||||
|
)
|
||||||
|
page = [r["id"] for r in page_rows]
|
||||||
|
|
||||||
|
by_id: dict[str, dict] = {}
|
||||||
|
if page:
|
||||||
|
ph = ",".join(["?"] * len(page))
|
||||||
|
rows = store.query(
|
||||||
|
f"SELECT * FROM rejected_msgs WHERE id IN ({ph})", page
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
by_id[r["id"]] = r
|
||||||
|
items = []
|
||||||
|
for rid in page:
|
||||||
|
r = by_id.get(rid)
|
||||||
|
if not r:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"dialogId": r.get("dialog_id") or "",
|
||||||
|
"msgId": r.get("msg_id"),
|
||||||
|
"text": r["text"],
|
||||||
|
"stage": r["stage"],
|
||||||
|
"stageLabel": stage_label(r["stage"]),
|
||||||
|
"reason": r["reason"],
|
||||||
|
"kw": r["kw"],
|
||||||
|
"source": r["source"],
|
||||||
|
"sourceLabel": source_label(r["source"]),
|
||||||
|
"ch": {"name": r["ch_name"], "handle": r["ch_handle"], "hue": r.get("ch_hue") or "#666"},
|
||||||
|
"msgAt": r["msg_at"],
|
||||||
|
"rejectedAt": r["rejected_at"],
|
||||||
|
"returned": bool(r.get("returned")),
|
||||||
|
"returnedAt": r.get("returned_at"),
|
||||||
|
"returnReason": r.get("return_reason") or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"items": items, "total": total, "offset": offset, "limit": limit}
|
||||||
|
|
||||||
|
|
||||||
|
def stats() -> dict:
|
||||||
|
q = queue_counts()
|
||||||
|
return {
|
||||||
|
"queue": q,
|
||||||
|
"rejected": rejected_count(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"""«Выбранные» — проектный канбан (п.4.8 ТЗ).
|
||||||
|
|
||||||
|
Карточка живёт только здесь: лид уходит с дашборда безвозвратно (col='taken'),
|
||||||
|
в архив/корзину проектные карточки не попадают. Есть история движения,
|
||||||
|
вложения (файлы сейчас мета-мок, MinIO позже), ссылки, ТЗ и напоминания
|
||||||
|
для стадии «Отложено».
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .. import constants as C
|
||||||
|
from ..db import store
|
||||||
|
from ..sse import broker
|
||||||
|
|
||||||
|
log = logging.getLogger("leadradar.projects")
|
||||||
|
|
||||||
|
STAGES = {s["id"] for s in C.PIPELINE_STAGES}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> int:
|
||||||
|
return time.time_ns() // 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _json(value: list, default: str = "[]") -> str:
|
||||||
|
return json.dumps(value or [], ensure_ascii=False) if value is not None else default
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_card(row: dict) -> dict:
|
||||||
|
history = json.loads(row["history"] or "[]")
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"stage": row["stage"],
|
||||||
|
"local": bool(row["local"]),
|
||||||
|
"leadId": row["lead_id"],
|
||||||
|
"title": row["title"],
|
||||||
|
"summary": row["summary"],
|
||||||
|
"stack": json.loads(row["stack"] or "[]"),
|
||||||
|
"budget": (
|
||||||
|
{"from": row["budget_from"], "to": row["budget_to"], "cur": row["budget_cur"]}
|
||||||
|
if row["budget_cur"]
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"contact": row["contact"],
|
||||||
|
"comments": json.loads(row["comments"] or "[]"),
|
||||||
|
"links": json.loads(row["links"] or "[]"),
|
||||||
|
"files": json.loads(row["files"] or "[]"),
|
||||||
|
"tzText": row["tz_text"],
|
||||||
|
"history": history,
|
||||||
|
"reminder": {"at": row["reminder_at"]} if row["reminder_at"] else None,
|
||||||
|
"createdAt": row["created_at"],
|
||||||
|
"updatedAt": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_cards(stage: str | None = None) -> list[dict]:
|
||||||
|
if stage:
|
||||||
|
rows = store.query("SELECT * FROM projects WHERE stage = ? ORDER BY updated_at DESC", [stage])
|
||||||
|
else:
|
||||||
|
rows = store.query("SELECT * FROM projects ORDER BY updated_at DESC")
|
||||||
|
return [_row_to_card(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_card(card_id: str) -> dict | None:
|
||||||
|
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
|
||||||
|
return _row_to_card(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _insert(row_fields: dict) -> dict:
|
||||||
|
now = _now()
|
||||||
|
card_id = row_fields["id"]
|
||||||
|
store.execute(
|
||||||
|
"INSERT INTO projects(id, stage, local, lead_id, title, summary, stack, "
|
||||||
|
"budget_from, budget_to, budget_cur, contact, comments, links, files, tz_text, "
|
||||||
|
"history, reminder_at, reminder_fired, created_at, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, FALSE, ?, ?)",
|
||||||
|
[
|
||||||
|
card_id,
|
||||||
|
row_fields.get("stage", "planned"),
|
||||||
|
bool(row_fields.get("local")),
|
||||||
|
row_fields.get("lead_id"),
|
||||||
|
row_fields.get("title", ""),
|
||||||
|
row_fields.get("summary", ""),
|
||||||
|
_json(row_fields.get("stack")),
|
||||||
|
row_fields.get("budget_from"),
|
||||||
|
row_fields.get("budget_to"),
|
||||||
|
row_fields.get("budget_cur", ""),
|
||||||
|
row_fields.get("contact", ""),
|
||||||
|
_json(row_fields.get("comments")),
|
||||||
|
_json(row_fields.get("links")),
|
||||||
|
_json(row_fields.get("files")),
|
||||||
|
row_fields.get("tz_text", ""),
|
||||||
|
_json(row_fields.get("history")),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return get_card(card_id)
|
||||||
|
|
||||||
|
|
||||||
|
def create_local_card(data: dict) -> dict:
|
||||||
|
"""Ручное создание карточки — «локальная» (без лида)."""
|
||||||
|
budget = data.get("budget") if isinstance(data.get("budget"), dict) else None
|
||||||
|
return _insert(
|
||||||
|
{
|
||||||
|
"id": store.uid("pr_"),
|
||||||
|
"stage": data.get("stage") if data.get("stage") in STAGES else "planned",
|
||||||
|
"local": True,
|
||||||
|
"title": str(data.get("title", "")).strip(),
|
||||||
|
"summary": str(data.get("summary", "")),
|
||||||
|
"stack": data.get("stack") or [],
|
||||||
|
"budget_from": budget.get("from") if budget else None,
|
||||||
|
"budget_to": budget.get("to") if budget else None,
|
||||||
|
"budget_cur": budget.get("cur", "") if budget else "",
|
||||||
|
"contact": str(data.get("contact", "")),
|
||||||
|
"comments": data.get("comments") or [],
|
||||||
|
"links": data.get("links") or [],
|
||||||
|
"files": data.get("files") or [],
|
||||||
|
"tz_text": str(data.get("tzText", "")),
|
||||||
|
"history": [{"id": store.uid("h_"), "at": _now(), "type": "createdLocal"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def take_lead_to_projects(lead_id: str) -> dict:
|
||||||
|
"""«Взять в работу»: лид уходит с дашборда, создаётся проектная карточка."""
|
||||||
|
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
|
||||||
|
if not lead:
|
||||||
|
raise KeyError(lead_id)
|
||||||
|
existing = store.query_one("SELECT id FROM projects WHERE lead_id = ?", [lead_id])
|
||||||
|
if existing:
|
||||||
|
return get_card(existing["id"])
|
||||||
|
budget = {"from": lead["budget_from"], "to": lead["budget_to"], "cur": lead["budget_cur"]} if lead["budget_cur"] else None
|
||||||
|
card = _insert(
|
||||||
|
{
|
||||||
|
"id": store.uid("pr_"),
|
||||||
|
"stage": "planned",
|
||||||
|
"local": False,
|
||||||
|
"lead_id": lead_id,
|
||||||
|
"title": lead["title"],
|
||||||
|
"summary": lead["summary"],
|
||||||
|
"stack": json.loads(lead["stack"] or "[]"),
|
||||||
|
"budget_from": budget["from"] if budget else None,
|
||||||
|
"budget_to": budget["to"] if budget else None,
|
||||||
|
"budget_cur": budget["cur"] if budget else "",
|
||||||
|
"contact": lead["contact"],
|
||||||
|
"comments": [{"id": store.uid("cm_"), "by": "Вы", "text": "Взял в работу из лида.", "time": "только что"}],
|
||||||
|
"tz_text": "",
|
||||||
|
"history": [{"id": store.uid("h_"), "at": _now(), "type": "created"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# эксклюзивность: на дашборде лида больше нет, возврата нет
|
||||||
|
store.execute("UPDATE leads SET col = 'taken', is_new = FALSE WHERE id = ?", [lead_id])
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
def patch_card(card_id: str, patch: dict) -> dict:
|
||||||
|
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
|
||||||
|
if not row:
|
||||||
|
raise KeyError(card_id)
|
||||||
|
simple = {
|
||||||
|
"title": "title",
|
||||||
|
"summary": "summary",
|
||||||
|
"contact": "contact",
|
||||||
|
"tz_text": "tzText",
|
||||||
|
}
|
||||||
|
for col, key in simple.items():
|
||||||
|
if key in patch:
|
||||||
|
store.execute(f"UPDATE projects SET {col} = ? WHERE id = ?", [str(patch[key]), card_id])
|
||||||
|
if "stack" in patch:
|
||||||
|
store.execute("UPDATE projects SET stack = ? WHERE id = ?", [_json(patch["stack"]), card_id])
|
||||||
|
if "budget" in patch:
|
||||||
|
b = patch["budget"] if isinstance(patch["budget"], dict) else None
|
||||||
|
store.execute(
|
||||||
|
"UPDATE projects SET budget_from = ?, budget_to = ?, budget_cur = ? WHERE id = ?",
|
||||||
|
[b.get("from") if b else None, b.get("to") if b else None, b.get("cur", "") if b else "", card_id],
|
||||||
|
)
|
||||||
|
if "comments" in patch:
|
||||||
|
store.execute("UPDATE projects SET comments = ? WHERE id = ?", [_json(patch["comments"]), card_id])
|
||||||
|
if "links" in patch:
|
||||||
|
store.execute("UPDATE projects SET links = ? WHERE id = ?", [_json(patch["links"]), card_id])
|
||||||
|
if "files" in patch:
|
||||||
|
store.execute("UPDATE projects SET files = ? WHERE id = ?", [_json(patch["files"]), card_id])
|
||||||
|
_bump(card_id)
|
||||||
|
return get_card(card_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _bump(card_id: str) -> None:
|
||||||
|
store.execute("UPDATE projects SET updated_at = ? WHERE id = ?", [_now(), card_id])
|
||||||
|
|
||||||
|
|
||||||
|
def add_comment(card_id: str, text: str) -> list[dict]:
|
||||||
|
row = store.query_one("SELECT comments FROM projects WHERE id = ?", [card_id])
|
||||||
|
comments = json.loads(row["comments"] or "[]")
|
||||||
|
comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"})
|
||||||
|
patch_card(card_id, {"comments": comments})
|
||||||
|
return comments
|
||||||
|
|
||||||
|
|
||||||
|
def move_stage(card_id: str, stage: str) -> dict:
|
||||||
|
if stage not in STAGES:
|
||||||
|
raise ValueError("Неизвестная стадия")
|
||||||
|
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
|
||||||
|
if not row:
|
||||||
|
raise KeyError(card_id)
|
||||||
|
history = json.loads(row["history"] or "[]")
|
||||||
|
now = _now()
|
||||||
|
store.execute(
|
||||||
|
"UPDATE projects SET stage = ?, reminder_at = NULL, reminder_fired = FALSE, updated_at = ? WHERE id = ?",
|
||||||
|
[stage, now, card_id],
|
||||||
|
)
|
||||||
|
history.append({"id": store.uid("h_"), "at": now, "stage": stage})
|
||||||
|
store.execute("UPDATE projects SET history = ? WHERE id = ?", [_json(history), card_id])
|
||||||
|
return get_card(card_id)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_card(card_id: str) -> None:
|
||||||
|
store.execute("DELETE FROM projects WHERE id = ?", [card_id])
|
||||||
|
|
||||||
|
|
||||||
|
def clear_stage(stage: str) -> int:
|
||||||
|
"""Полная ручная очистка стадии «Отклонено» (терминальные не восстанавливаются)."""
|
||||||
|
if stage not in ("rejected",):
|
||||||
|
raise ValueError("Очистка разрешена только для стадии «Отклонено»")
|
||||||
|
rows = store.query("SELECT id FROM projects WHERE stage = ?", [stage])
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
store.execute("DELETE FROM projects WHERE stage = ?", [stage])
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Напоминания стадии «Отложено» ────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_reminder(card_id: str, at: int) -> dict:
|
||||||
|
if not store.get_setting("remindersEnabled"):
|
||||||
|
raise PermissionError("Напоминания об отложенных выключены в настройках")
|
||||||
|
store.execute(
|
||||||
|
"UPDATE projects SET reminder_at = ?, reminder_fired = FALSE, updated_at = ? WHERE id = ?",
|
||||||
|
[at, _now(), card_id],
|
||||||
|
)
|
||||||
|
return get_card(card_id)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_reminder(card_id: str) -> None:
|
||||||
|
store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE id = ?", [card_id])
|
||||||
|
|
||||||
|
|
||||||
|
def active_reminders() -> list[dict]:
|
||||||
|
rows = store.query(
|
||||||
|
"SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL ORDER BY reminder_at"
|
||||||
|
)
|
||||||
|
return [{"id": r["id"], "title": r["title"], "at": r["reminder_at"]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def snooze(card_id: str, ms: int = C.DAY_MS) -> None:
|
||||||
|
store.execute(
|
||||||
|
"UPDATE projects SET reminder_at = ?, reminder_fired = FALSE WHERE id = ?",
|
||||||
|
[_now() + ms, card_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_reminders() -> list[dict]:
|
||||||
|
"""Наступившие напоминания -> события. Если выключено — чистим протухшие."""
|
||||||
|
if not store.get_setting("remindersEnabled"):
|
||||||
|
# протухшие напоминания не храним: при включении старые не «выстрелят»
|
||||||
|
store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE reminder_at IS NOT NULL AND reminder_at <= ?", [_now()])
|
||||||
|
return []
|
||||||
|
now = _now()
|
||||||
|
rows = store.query(
|
||||||
|
"SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL "
|
||||||
|
"AND reminder_fired = FALSE AND reminder_at <= ?",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
due = []
|
||||||
|
for r in rows:
|
||||||
|
store.execute("UPDATE projects SET reminder_fired = TRUE WHERE id = ?", [r["id"]])
|
||||||
|
due.append({"id": r["id"], "title": r["title"], "stage": r["stage"]})
|
||||||
|
for item in due:
|
||||||
|
await broker.publish("reminder_due", item)
|
||||||
|
return due
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""Курсы валют: источник ЦБ РФ, 4 запроса в сутки (каждые 6 часов).
|
||||||
|
|
||||||
|
По ТЗ до подключения сервиса используются мок-курсы; источник выбирается
|
||||||
|
в настройках (cbr | mock). Значения хранятся в БД вместе с временем
|
||||||
|
обновления и выдаются наружу для вкладки «Валюта и курсы».
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .. import constants as C
|
||||||
|
from ..db import store
|
||||||
|
|
||||||
|
log = logging.getLogger("leadradar.rates")
|
||||||
|
|
||||||
|
_FETCH_INTERVAL_MS = 6 * C.HOUR_MS # 4 раза в сутки
|
||||||
|
|
||||||
|
|
||||||
|
def get_rates() -> dict:
|
||||||
|
row = store.query_one("SELECT rates, source, updated_at FROM rates WHERE id = 1")
|
||||||
|
rates = json.loads(row["rates"]) if row else dict(C.MOCK_RATES)
|
||||||
|
return {
|
||||||
|
"base": "RUB",
|
||||||
|
"rates": rates,
|
||||||
|
"source": row["source"] if row else "mock",
|
||||||
|
"updatedAt": row["updated_at"] if row else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_rates(rates: dict, source: str) -> None:
|
||||||
|
store.execute(
|
||||||
|
"INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(id) DO UPDATE SET rates = excluded.rates, source = excluded.source, "
|
||||||
|
"updated_at = excluded.updated_at",
|
||||||
|
[json.dumps(rates), source, time.time_ns() // 1_000_000],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_cbr() -> dict | None:
|
||||||
|
"""Запрос к ЦБ РФ (JSON-зеркало daily_json.js). Возвращает rates к RUB."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(C.CBR_URL)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
rates: dict = {"RUB": 1.0}
|
||||||
|
for code, item in payload.get("Valute", {}).items():
|
||||||
|
# 1 единица валюты в рублях: Nominal может быть > 1
|
||||||
|
nominal = int(item.get("Nominal", 1)) or 1
|
||||||
|
value = float(item.get("Value", 0))
|
||||||
|
rates[code] = round(value / nominal, 6)
|
||||||
|
return rates
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("CBR fetch failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_rates(force_source: str | None = None) -> bool:
|
||||||
|
"""Ручное/фоновое обновление. Возвращает True при успехе (или при мок-режиме)."""
|
||||||
|
source = force_source or store.get_setting("rateSource") or "cbr"
|
||||||
|
if source == "mock":
|
||||||
|
save_rates(dict(C.MOCK_RATES), "mock")
|
||||||
|
recompute_conversions()
|
||||||
|
return True
|
||||||
|
rates = await fetch_cbr()
|
||||||
|
if rates is None:
|
||||||
|
return False
|
||||||
|
save_rates(rates, "cbr")
|
||||||
|
recompute_conversions()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def should_fetch() -> bool:
|
||||||
|
row = store.query_one("SELECT updated_at, source FROM rates WHERE id = 1")
|
||||||
|
if not row:
|
||||||
|
return True
|
||||||
|
if row["source"] == "mock" and store.get_setting("rateSource") == "cbr":
|
||||||
|
return True
|
||||||
|
return time.time_ns() // 1_000_000 - row["updated_at"] >= _FETCH_INTERVAL_MS
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_rate(rates: dict, code: str) -> float | None:
|
||||||
|
"""USDT приравниваем к USD (у ЦБ нет тикера USDT)."""
|
||||||
|
if code == "USDT" and "USD" in rates:
|
||||||
|
return float(rates["USD"])
|
||||||
|
val = rates.get(code)
|
||||||
|
return float(val) if val is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def convert_amount(amount: float | int | None, from_cur: str, to_cur: str) -> float | None:
|
||||||
|
"""amount в from_cur -> to_cur по актуальным курсам (к рублю)."""
|
||||||
|
if amount is None:
|
||||||
|
return None
|
||||||
|
rates = json.loads(store.query_one("SELECT rates FROM rates WHERE id = 1")["rates"])
|
||||||
|
rf = _resolve_rate(rates, from_cur)
|
||||||
|
rt = _resolve_rate(rates, to_cur)
|
||||||
|
if rf is None or rt is None:
|
||||||
|
return None
|
||||||
|
return round(float(amount) * rf / rt, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def recompute_conversions() -> int:
|
||||||
|
"""Пересчёт старых карточек по актуальному курсу и целевой валюте.
|
||||||
|
|
||||||
|
Пересчитываются карточки на канбане и в «Неразобранном»; архивные,
|
||||||
|
корзинные и ушедшие в «Выбранные» (taken) не трогаем.
|
||||||
|
"""
|
||||||
|
if not store.get_setting("conversionOn"):
|
||||||
|
return 0
|
||||||
|
target = store.get_setting("targetCurrency") or "RUB"
|
||||||
|
rows = store.query(
|
||||||
|
"SELECT id, budget_from, budget_to, budget_cur FROM leads "
|
||||||
|
"WHERE budget_cur <> '' AND col NOT IN ('archive','trash','taken')",
|
||||||
|
)
|
||||||
|
updated = 0
|
||||||
|
for r in rows:
|
||||||
|
cf = convert_amount(r["budget_from"], r["budget_cur"], target)
|
||||||
|
ct = convert_amount(r["budget_to"] if r["budget_to"] is not None else r["budget_from"], r["budget_cur"], target)
|
||||||
|
if cf is None:
|
||||||
|
continue
|
||||||
|
store.execute(
|
||||||
|
"UPDATE leads SET conv_from = ?, conv_to = ?, conv_cur = ? WHERE id = ?",
|
||||||
|
[cf, ct, target, r["id"]],
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
return updated
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"""Детерминированные правила колонок: направление, стек, слова, грейд, бюджет.
|
||||||
|
|
||||||
|
Колонка — это набор опциональных фильтров, задаёт пользователь (или ИИ при
|
||||||
|
предложении). Если текст входящего сообщения соответствует правилам — карточка
|
||||||
|
уходит в эту колонку сразу, без ML/ИИ (ML/ИИ подключаются только когда правила
|
||||||
|
не сработали). Набор фильтров может меняться: пустая группа не участвует.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ..db import store
|
||||||
|
|
||||||
|
_CUR_SYMBOLS = {"$": "USD", "€": "EUR", "₽": "RUB", "₮": "USDT", "£": "GBP", "¥": "CNY"}
|
||||||
|
_CUR_WORDS = {"usd": "USD", "eur": "EUR", "rub": "RUB", "usdt": "USDT", "gbp": "GBP", "cny": "CNY", "руб": "RUB", "долл": "USD", "бакс": "USD"}
|
||||||
|
|
||||||
|
# Грейд/уровень: тег из правил расширяется синонимами, чтобы «middle» находил
|
||||||
|
# и «mid», и «мидл», а «сеньор» находил senior и т.п.
|
||||||
|
_GRADE_ALIASES = {
|
||||||
|
"junior": ["junior", "джун", "джуниор"],
|
||||||
|
"middle": ["middle", "mid", "мидл"],
|
||||||
|
"senior": ["senior", "сеньор", "сеньйор"],
|
||||||
|
"lead": ["lead", "тимлид", "тиэмлид", "team lead", "teamlead", "tech lead", "техлид"],
|
||||||
|
"architect": ["architect", "архитектор"],
|
||||||
|
"intern": ["intern", "стажёр", "стажер", "trainee"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _grade_terms(tags: list[str]) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
for t in tags:
|
||||||
|
key = str(t).strip().lower()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
if key in _GRADE_ALIASES:
|
||||||
|
out.extend(_GRADE_ALIASES[key])
|
||||||
|
else:
|
||||||
|
out.append(key)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Перед матчингом правил вырезаем ссылки и markdown-ссылки: иначе фильтр ловит
|
||||||
|
# слова из трекерных хвостов/служебных строк URL (например, «desktop» в
|
||||||
|
# utm_medium=member_desktop) и в колонку попадает мусор, не имеющий отношения
|
||||||
|
# к содержанию сообщения.
|
||||||
|
_MD_URL_RE = re.compile(r"\[[^\]]*\]\([^)\s]+\)")
|
||||||
|
_RAW_URL_RE = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+")
|
||||||
|
|
||||||
|
|
||||||
|
def content_text(text: str) -> str:
|
||||||
|
s = str(text or "")
|
||||||
|
s = _MD_URL_RE.sub(" ", s)
|
||||||
|
return _RAW_URL_RE.sub(" ", s)
|
||||||
|
|
||||||
|
# ищем: 1) "от 1 200 до 1 500 $", 2) "1 200–1 500 $ / 1 200$", 3) "$1 200–1 500"
|
||||||
|
# суффикс «к/К» разрешён прямо в числе: «2к», «1.5к$» (множитель в _norm_amount)
|
||||||
|
_AMT = r"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?"
|
||||||
|
_RANGE = rf"({_AMT})\s*(?:[-–—]\s*({_AMT}))?"
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_amount(raw: str) -> float | None:
|
||||||
|
s = raw.replace("\u00a0", " ").replace(" ", "").replace(",", ".")
|
||||||
|
mult = 1.0
|
||||||
|
# «2к»/«2К»/«1.5к» — тысячи; суффикс убираем до float (иначе парс падает)
|
||||||
|
if s and s[-1].lower() in ("k", "к"):
|
||||||
|
mult = 1000.0
|
||||||
|
s = s[:-1]
|
||||||
|
try:
|
||||||
|
v = float(s) * mult
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _cur_from_tail(text: str, m_start: int) -> str | None:
|
||||||
|
tail = text[m_start : m_start + 12].lower().strip()
|
||||||
|
for sym, code in _CUR_SYMBOLS.items():
|
||||||
|
if tail.startswith(sym):
|
||||||
|
return code
|
||||||
|
# слово-валюта сразу после числа (с пробелом)
|
||||||
|
for word, code in _CUR_WORDS.items():
|
||||||
|
if tail.startswith(word):
|
||||||
|
return code
|
||||||
|
# валюта перед числом ($/€/₽), например "$1 200"
|
||||||
|
head = text[max(0, m_start - 3) : m_start].strip()
|
||||||
|
for sym, code in _CUR_SYMBOLS.items():
|
||||||
|
if head.endswith(sym):
|
||||||
|
return code
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_amounts(text: str) -> list[dict]:
|
||||||
|
"""Парсер сумм с сохранением смысла:
|
||||||
|
- диапазон «от A до B» / «A–B» → {'from': A, 'to': B, 'cur': ...};
|
||||||
|
- «до B» (только верхняя граница) → {'from': None, 'to': B, 'cur': ...};
|
||||||
|
- одна сумма / «от A» без верхней границы → {'from': A, 'to': A, 'cur': ...}.
|
||||||
|
Валюту ищем сразу после суммы (или перед ней для «$1 200»); суммы без валюты игнорируются.
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
t = text or ""
|
||||||
|
if not t.strip():
|
||||||
|
return out
|
||||||
|
occupied: list[tuple[int, int]] = []
|
||||||
|
|
||||||
|
def _free(s: int, e: int) -> bool:
|
||||||
|
return not any(s < oe and e > os for os, oe in occupied)
|
||||||
|
|
||||||
|
def _add(a, b, cur: str | None, s: int, e: int) -> None:
|
||||||
|
if cur and (a is not None or b is not None) and _free(s, e):
|
||||||
|
out.append({"from": a, "to": b, "cur": cur})
|
||||||
|
occupied.append((s, e))
|
||||||
|
|
||||||
|
# 1) словесный диапазон «от A до B» (+ валюта после B)
|
||||||
|
for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")\s+до\s+(" + _AMT + r")", t):
|
||||||
|
a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2))
|
||||||
|
cur = _cur_from_tail(t, m.end(2))
|
||||||
|
if a is not None and b is not None and cur:
|
||||||
|
_add(a, b, cur, m.start(1), m.end(2))
|
||||||
|
# 2) «до B» (без пары «от … до») — верхняя граница
|
||||||
|
for m in re.finditer(r"(?i)\bдо\s+(" + _AMT + r")", t):
|
||||||
|
b = _norm_amount(m.group(1))
|
||||||
|
cur = _cur_from_tail(t, m.end(1))
|
||||||
|
if b is not None and cur:
|
||||||
|
_add(None, b, cur, m.start(1), m.end(1))
|
||||||
|
# 3) «от A» без верхней границы — считаем одной суммой
|
||||||
|
for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")", t):
|
||||||
|
a = _norm_amount(m.group(1))
|
||||||
|
cur = _cur_from_tail(t, m.end(1))
|
||||||
|
if a is not None and cur:
|
||||||
|
_add(a, a, cur, m.start(1), m.end(1))
|
||||||
|
# 4) числовые диапазоны «A–B» / «A - B»
|
||||||
|
for m in re.finditer(r"(?i)(" + _AMT + r")\s*(?:[-–—]|\s+до\s+)\s*(" + _AMT + r")", t):
|
||||||
|
a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2))
|
||||||
|
cur = _cur_from_tail(t, m.end(2)) or _cur_from_tail(t, m.end(1))
|
||||||
|
if a is not None and b is not None and cur:
|
||||||
|
_add(a, b, cur, m.start(1), m.end(2))
|
||||||
|
# 5) одиночные суммы с валютой (не вошедшие в конструкции выше)
|
||||||
|
for m in re.finditer(_AMT, t):
|
||||||
|
a = _norm_amount(m.group(0))
|
||||||
|
cur = _cur_from_tail(t, m.end())
|
||||||
|
if a is not None and cur:
|
||||||
|
_add(a, a, cur, m.start(), m.end())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _amount_in_range(amounts: list[dict], budget: dict) -> bool:
|
||||||
|
from ..services.rates import convert_amount
|
||||||
|
|
||||||
|
try:
|
||||||
|
lo = float(budget.get("from")) if budget.get("from") is not None else None
|
||||||
|
hi = float(budget.get("to")) if budget.get("to") is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
if lo is None and hi is None:
|
||||||
|
return True
|
||||||
|
target_cur = str(budget.get("cur") or "USD").upper()
|
||||||
|
for amt in amounts:
|
||||||
|
raw_val = amt["from"] if amt["from"] is not None else amt.get("to")
|
||||||
|
if raw_val is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
val = raw_val if amt["cur"] == target_cur else convert_amount(raw_val, amt["cur"], target_cur)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
val = None
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
if lo is not None and val < lo:
|
||||||
|
continue
|
||||||
|
if hi is not None and val > hi:
|
||||||
|
continue
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def match_text(rules: dict, text: str) -> bool:
|
||||||
|
"""Соответствует ли текст правилам колонки. Возвращает bool.
|
||||||
|
|
||||||
|
Колонка — это набор опциональных фильтров: направление, стек, ключевые
|
||||||
|
слова, грейд/уровень, бюджет. Пустая группа не участвует; режим «все» —
|
||||||
|
должны совпасть все включённые группы, «любое» — хотя бы одна.
|
||||||
|
"""
|
||||||
|
rules = rules or {}
|
||||||
|
lower = content_text(text).lower()
|
||||||
|
direction = [str(x).strip().lower() for x in (rules.get("direction") or []) if str(x).strip()]
|
||||||
|
kw = [str(x).strip().lower() for x in (rules.get("keywords") or []) if str(x).strip()]
|
||||||
|
stack = [str(x).strip().lower() for x in (rules.get("stack") or []) if str(x).strip()]
|
||||||
|
grade = [str(x).strip().lower() for x in (rules.get("grade") or []) if str(x).strip()]
|
||||||
|
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
|
||||||
|
enabled = [bool(kw), bool(stack), bool(direction), bool(grade), bool(budget)]
|
||||||
|
|
||||||
|
if not any(enabled):
|
||||||
|
return False
|
||||||
|
grade_terms = _grade_terms(grade)
|
||||||
|
groups = {
|
||||||
|
"keywords": any(k in lower for k in kw) if kw else True,
|
||||||
|
"stack": any(s in lower for s in stack) if stack else True,
|
||||||
|
"direction": any(d in lower for d in direction) if direction else True,
|
||||||
|
"grade": any(g in lower for g in grade_terms) if grade else True,
|
||||||
|
"budget": (_amount_in_range(extract_amounts(text), budget) if budget else True),
|
||||||
|
}
|
||||||
|
mode = str(rules.get("mode") or "all").lower()
|
||||||
|
if mode == "any":
|
||||||
|
# «любое»: совпасть должна хотя бы одна включённая (непустая) группа.
|
||||||
|
# Пустые группы равны True и не должны участвовать — иначе колонка
|
||||||
|
# с одним фильтром (например «стек: WPF») ловит вообще всё.
|
||||||
|
return any(groups[k] for k, on in zip(groups, enabled) if on and k in groups)
|
||||||
|
# all: каждая включённая группа обязана совпасть
|
||||||
|
return all(groups[k] for k, on in zip(groups, enabled) if on and k in groups)
|
||||||
|
|
||||||
|
|
||||||
|
def score_text(rules: dict, text: str) -> int:
|
||||||
|
"""Число совпавших фильтров (для выбора лучшей ИИ-колонки)."""
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
lower = content_text(text).lower()
|
||||||
|
score = 0
|
||||||
|
for group in ("direction", "keywords", "stack", "grade"):
|
||||||
|
if group == "grade":
|
||||||
|
for g in _grade_terms(rules.get(group) or []):
|
||||||
|
if g in lower:
|
||||||
|
score += 1
|
||||||
|
else:
|
||||||
|
for w in (rules.get(group) or []):
|
||||||
|
if str(w).lower() in lower:
|
||||||
|
score += 1
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def excluded_terms(rules: dict | None, text: str) -> list[str]:
|
||||||
|
"""Какие слова-исключения колонки есть в тексте.
|
||||||
|
|
||||||
|
Исключения — veto колонки: если в содержании сообщения (без ссылок и
|
||||||
|
служебных хвостов) встречается любое из них, карточка в колонку не
|
||||||
|
попадает, даже если все положительные условия совпали.
|
||||||
|
"""
|
||||||
|
rules = rules or {}
|
||||||
|
lower = content_text(text).lower()
|
||||||
|
out: list[str] = []
|
||||||
|
for term in rules.get("exclude") or []:
|
||||||
|
t = str(term).strip()
|
||||||
|
if t and t.lower() in lower:
|
||||||
|
out.append(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def is_excluded(rules: dict | None, text: str) -> bool:
|
||||||
|
return bool(excluded_terms(rules, text))
|
||||||
|
|
||||||
|
|
||||||
|
def board_accepts(board_id: str, text: str) -> bool:
|
||||||
|
"""Пропускает ли колонка этот текст.
|
||||||
|
|
||||||
|
Колонка с активными правилами принимает только текст, прошедший её правила
|
||||||
|
(детерминированно); колонка без правил принимает любой текст — её наполняют
|
||||||
|
ИИ/ML/пользователь. Нужно как страховка: ИИ или ML не должны класть карточку
|
||||||
|
в «отфильтрованную» колонку, если текст под правила не подходит. Слова-
|
||||||
|
исключения работают как veto в любом случае.
|
||||||
|
"""
|
||||||
|
row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id])
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
rules = json.loads(row["rules"] or "{}") if row["rules"] else {}
|
||||||
|
if is_excluded(rules, text):
|
||||||
|
return False
|
||||||
|
if not has_active_rules(rules):
|
||||||
|
return True
|
||||||
|
return match_text(rules, text)
|
||||||
|
|
||||||
|
|
||||||
|
def hits(rules: dict, text: str) -> list[dict]:
|
||||||
|
"""Какие именно критерии фильтра совпали с текстом.
|
||||||
|
|
||||||
|
Возвращает список совпадений по группам фильтра колонки:
|
||||||
|
[{"label": "Стек", "term": "WPF"}, {"label": "Грейд", "term": "middle", "word": "mid"}, …].
|
||||||
|
Нужно, чтобы на карточке показывать «по каким критериям она попала в колонку»
|
||||||
|
(список совпавших условий фильтра). Ключевое слово ищется по всему тексту
|
||||||
|
сообщения — включая стек, требования и «будет плюсом», как и наоборот.
|
||||||
|
"""
|
||||||
|
rules = rules or {}
|
||||||
|
lower = content_text(text).lower()
|
||||||
|
out: list[dict] = []
|
||||||
|
for group, label in (("direction", "Направление"), ("keywords", "Слова"), ("stack", "Стек")):
|
||||||
|
for term in rules.get(group) or []:
|
||||||
|
t = str(term).strip()
|
||||||
|
if t and t.lower() in lower:
|
||||||
|
out.append({"label": label, "term": t})
|
||||||
|
for term in rules.get("grade") or []:
|
||||||
|
for alias in _grade_terms([term]):
|
||||||
|
if alias in lower:
|
||||||
|
out.append({"label": "Грейд/уровень", "term": str(term).strip(), "word": alias})
|
||||||
|
break
|
||||||
|
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
|
||||||
|
if budget and _amount_in_range(extract_amounts(text), budget):
|
||||||
|
out.append({"label": "Бюджет", "term": _budget_label(budget)})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _budget_label(budget: dict) -> str:
|
||||||
|
lo, hi = budget.get("from"), budget.get("to")
|
||||||
|
cur = str(budget.get("cur") or "").upper()
|
||||||
|
if lo is not None and hi is not None:
|
||||||
|
return f"от {lo:g} до {hi:g} {cur}".strip()
|
||||||
|
if hi is not None:
|
||||||
|
return f"до {hi:g} {cur}".strip()
|
||||||
|
if lo is not None:
|
||||||
|
return f"от {lo:g} {cur}".strip()
|
||||||
|
return "бюджет"
|
||||||
|
|
||||||
|
|
||||||
|
def hits_for_board(board_id: str, text: str) -> list[dict]:
|
||||||
|
"""Совпавшие критерии колонки с активными правилами; [] — правил нет."""
|
||||||
|
row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id])
|
||||||
|
if not row:
|
||||||
|
return []
|
||||||
|
rules = json.loads(row["rules"] or "{}") if row["rules"] else {}
|
||||||
|
if not has_active_rules(rules):
|
||||||
|
return []
|
||||||
|
return hits(rules, text)
|
||||||
|
|
||||||
|
|
||||||
|
def has_active_rules(rules: dict | None) -> bool:
|
||||||
|
"""Есть ли в правилах хотя бы одна реально работающая группа фильтров.
|
||||||
|
|
||||||
|
Нужно для ML: колонка с активными правилами раскладывается только самими
|
||||||
|
правилами (детерминированно), ML её назначать не должен — иначе в колонку
|
||||||
|
попадает то, что под правила не подходит.
|
||||||
|
"""
|
||||||
|
rules = rules or {}
|
||||||
|
for key in ("direction", "keywords", "stack", "grade"):
|
||||||
|
if any(str(x).strip() for x in (rules.get(key) or [])):
|
||||||
|
return True
|
||||||
|
budget = rules.get("budget")
|
||||||
|
if isinstance(budget, dict) and (
|
||||||
|
budget.get("from") is not None or budget.get("to") is not None
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def describe(rules: dict) -> str:
|
||||||
|
"""Человекочитаемое описание правил (для обоснования и промпта)."""
|
||||||
|
rules = rules or {}
|
||||||
|
parts = []
|
||||||
|
direction = rules.get("direction") or []
|
||||||
|
stack = rules.get("stack") or []
|
||||||
|
kw = rules.get("keywords") or []
|
||||||
|
grade = rules.get("grade") or []
|
||||||
|
if direction:
|
||||||
|
parts.append("направление: " + ", ".join(str(x) for x in direction[:6]))
|
||||||
|
if stack:
|
||||||
|
parts.append("стек: " + ", ".join(str(x) for x in stack[:8]))
|
||||||
|
if kw:
|
||||||
|
parts.append("слова: " + ", ".join(str(x) for x in kw[:8]))
|
||||||
|
if grade:
|
||||||
|
parts.append("грейд: " + ", ".join(str(x) for x in grade[:8]))
|
||||||
|
exc = rules.get("exclude") or []
|
||||||
|
if exc:
|
||||||
|
parts.append("исключено: " + ", ".join(str(x) for x in exc[:8]))
|
||||||
|
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
|
||||||
|
if budget and (budget.get("from") is not None or budget.get("to") is not None):
|
||||||
|
lo = budget.get("from") if budget.get("from") is not None else ""
|
||||||
|
hi = budget.get("to") if budget.get("to") is not None else ""
|
||||||
|
parts.append(f"бюджет: {lo}–{hi} {budget.get('cur') or ''}".replace('– ', '–').replace(' –', '–'))
|
||||||
|
if not parts:
|
||||||
|
return "без правил (решает ИИ/ML)"
|
||||||
|
mode = "все условия" if str(rules.get("mode") or "all").lower() != "any" else "любое из условий"
|
||||||
|
return mode + " · " + "; ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def route(text: str) -> dict | None:
|
||||||
|
"""Детерминированная маршрутизация по правилам активных колонок.
|
||||||
|
|
||||||
|
Возвращает колонку, если правила однозначно совпали (при нескольких —
|
||||||
|
ту, где больше совпадений). Предложения ИИ в маршрутизации не участвуют.
|
||||||
|
"""
|
||||||
|
rows = store.query(
|
||||||
|
"SELECT * FROM boards WHERE suggested = FALSE AND rules <> '{}' AND rules <> '' ORDER BY pos"
|
||||||
|
)
|
||||||
|
best: dict | None = None
|
||||||
|
best_score = 0
|
||||||
|
for r in rows:
|
||||||
|
rules = json.loads(r["rules"] or "{}")
|
||||||
|
if not match_text(rules, text):
|
||||||
|
continue
|
||||||
|
sc = score_text(rules, text) or 1
|
||||||
|
if sc > best_score:
|
||||||
|
best = {"id": r["id"], "name": r["name"], "color": r["color"], "rules": rules}
|
||||||
|
best_score = sc
|
||||||
|
return best
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""ИИ-предложения колонок по анализу «Неразобранного».
|
||||||
|
|
||||||
|
Колонка — не «на один скилл», а смысловая тема: направление + набор стека и
|
||||||
|
ключевых слов (+ бюджет при повторяемости). ИИ группирует карточки и создаёт
|
||||||
|
КОЛОНКИ-ПРЕДЛОЖЕНИЯ (suggested=TRUE) с обоснованием (note), по каким
|
||||||
|
критериям собрана. Пользователь открывает предложение, смотрит карточки и
|
||||||
|
решает: принять (можно переименовать/поправить правила) или удалить.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..db import store
|
||||||
|
from ..sse import broker
|
||||||
|
from . import ai as ai_svc
|
||||||
|
|
||||||
|
log = logging.getLogger("leadradar.suggest")
|
||||||
|
|
||||||
|
SUGGEST_PROMPT = """Ты — аналитик входящих заявок. Перед тобой пронумерованные сообщения (номера 1..N), которые не подошли ни под одну существующую колонку. Сгруппируй их в 2–4 ОСМЫСЛЕННЫЕ тематические колонки.
|
||||||
|
Сфера/что считается заявкой:
|
||||||
|
{domain}
|
||||||
|
Колонка — это не отдельный предмет и не каждое слово по отдельности, а направление с набором родственных признаков (тип работ/услуг, предметы, технологии, материалы). Пример: «Telegram-боты» — направление: чат-боты/автоматизация; предметы/технологии: Python, aiogram; слова: бот, telegram, автоответчик.
|
||||||
|
Правила:
|
||||||
|
- группируй повторяющиеся темы: в каждую колонку бери минимум 2 сообщения;
|
||||||
|
- не предлагай колонки, похожие на уже существующие;
|
||||||
|
- 2–4 колонки максимум; если ничего общего нет — верни пустой список.
|
||||||
|
Для каждой колонки верни:
|
||||||
|
- name — короткое название (2–4 слова);
|
||||||
|
- description — короткое описание колонки (1–2 предложения): что за заявки и для кого;
|
||||||
|
- direction — направление/тип задач (2–5 слов или фраз);
|
||||||
|
- stack — что фигурирует в заявках: предметы, услуги, технологии, материалы (1–6);
|
||||||
|
- grade — уровень/грейд, если ярко выражен (например: ["middle"]), иначе пустой список;
|
||||||
|
- keywords — 3–8 ключевых слов/фраз, по которым узнаётся такая заявка;
|
||||||
|
- messages — НОМЕРА сообщений из списка, которые относятся к этой колонке (минимум 2, максимум 12);
|
||||||
|
- reason — обоснование в 1 предложение: что за тема, сколько карточек и что в них общего.
|
||||||
|
Верни строго JSON:
|
||||||
|
{ "columns": [ { "name": "…", "description": "…", "direction": ["…"], "stack": ["…"], "grade": ["…"], "keywords": ["…"], "messages": [1, 5], "reason": "…" } ] }"""
|
||||||
|
|
||||||
|
KEYWORDS_PROMPT = """Ты — аналитик входящих сообщений. Ниже — реальные сообщения, которые уже признаны заявками/лидами (и часть — обычный флуд каналов).
|
||||||
|
Сфера/что считается заявкой:
|
||||||
|
{domain}
|
||||||
|
|
||||||
|
Выдели общие слова-МАРКЕРЫ, по которым сообщение можно отнести к заявкам этой сферы (а не к флуду): типовые предметы/услуги/работы, глаголы-действия («куплю», «нужен», «сниму», «отремонтировать»), статусные слова («срочно», «под ключ», «бюджет»).
|
||||||
|
Верни строго JSON с плоским списком от 8 до 40 ключевых слов/фраз (слова в нижнем регистре, без знаков препинания):
|
||||||
|
{ "keywords": ["…", "…"] }"""
|
||||||
|
|
||||||
|
MIN_INBOX = 6 # минимум карточек в «Неразобранном» для анализа
|
||||||
|
MIN_INBOX_GROUP = 2 # минимум карточек в одной ИИ-колонке
|
||||||
|
MAX_TEXT = 12 # сколько сообщений берём в анализ (ИИ обрывает длинный JSON)
|
||||||
|
COOLDOWN_S = 20 * 60 # как часто автоматически переспрашиваем
|
||||||
|
KEY = "lastSuggestAt"
|
||||||
|
|
||||||
|
|
||||||
|
def _similar_exists(name: str) -> bool:
|
||||||
|
low = name.casefold()
|
||||||
|
rows = store.query("SELECT id, name FROM boards")
|
||||||
|
for r in rows:
|
||||||
|
n = str(r["name"]).casefold()
|
||||||
|
if low == n or low in n or n in low:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _rules_for(item: dict) -> dict:
|
||||||
|
"""Правила колонки из ИИ-ответа: направление/слова/стек/грейд (любое из условий)."""
|
||||||
|
return {
|
||||||
|
"mode": "any",
|
||||||
|
"direction": [str(x).strip().lower() for x in (item.get("direction") or []) if str(x).strip()][:6],
|
||||||
|
"keywords": [str(x).strip().lower() for x in (item.get("keywords") or []) if str(x).strip()][:8],
|
||||||
|
"stack": [str(x).strip().lower() for x in (item.get("stack") or []) if str(x).strip()][:8],
|
||||||
|
"grade": [str(x).strip().lower() for x in (item.get("grade") or []) if str(x).strip()][:6],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def suggest_from_inbox(force: bool = False) -> dict:
|
||||||
|
"""Анализ «Неразобранного» и создание колонок-предложений с обоснованием.
|
||||||
|
|
||||||
|
ИИ группирует пронумерованные сообщения и для каждой колонки возвращает
|
||||||
|
номера сообщений (messages). Карточки раскладываются именно по этим
|
||||||
|
номерам, а не строгим match_text по сгенерированным правилам — иначе
|
||||||
|
колонка-предложение часто остаётся пустой (правила ИИ приблизительные).
|
||||||
|
"""
|
||||||
|
# Предложения колонок — это вызов ИИ: при выключенном ИИ (aiEnabled=false,
|
||||||
|
# режим «только фильтр + ML») автоцикл не должен дёргать провайдера.
|
||||||
|
if not force and not store.get_setting("aiEnabled"):
|
||||||
|
return {"ok": False, "reason": "ИИ выключен — предложения колонок недоступны"}
|
||||||
|
if not force:
|
||||||
|
# не плодим предложения, пока пользователь не разобрался со старыми
|
||||||
|
pending = store.scalar("SELECT count(*) FROM boards WHERE suggested = TRUE")
|
||||||
|
if pending:
|
||||||
|
return {"ok": False, "reason": f"сначала решите судьбу {pending} предложенных колонок"}
|
||||||
|
last = int(store.get_setting(KEY) or 0)
|
||||||
|
if time.time() - last < COOLDOWN_S:
|
||||||
|
return {"ok": False, "reason": "недавно предлагали — подождите", "cooldown": True}
|
||||||
|
inbox = store.query(
|
||||||
|
"SELECT id, source_msg FROM leads WHERE col = 'inbox' AND source_msg <> '' "
|
||||||
|
"ORDER BY received_at DESC LIMIT ?",
|
||||||
|
[MAX_TEXT],
|
||||||
|
)
|
||||||
|
if len(inbox) < MIN_INBOX:
|
||||||
|
return {"ok": False, "reason": f"мало карточек в «Неразобранном» (нужно от {MIN_INBOX})"}
|
||||||
|
rows = [(str(r["id"]), str(r["source_msg"])) for r in inbox]
|
||||||
|
texts = [t[:180] for _, t in rows]
|
||||||
|
existing = [str(r["name"]) for r in store.query("SELECT name FROM boards WHERE suggested = FALSE ORDER BY pos")]
|
||||||
|
user = (
|
||||||
|
f"Существующие колонки: {', '.join(existing) if existing else 'нет'}\n\n"
|
||||||
|
"Сообщения:\n" + "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts, start=1))
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
out = await ai_svc.chat_json(ai_svc.fill_prompt(SUGGEST_PROMPT), user, max_retries=2, max_tokens=16000)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("suggest failed: %s", exc)
|
||||||
|
return {"ok": False, "reason": f"ИИ недоступен: {exc}"}
|
||||||
|
cols = out.get("columns") if isinstance(out, dict) else None
|
||||||
|
if not isinstance(cols, list) or not cols:
|
||||||
|
log.info("suggest: ИИ не предложил колонок")
|
||||||
|
return {"ok": False, "reason": "ИИ не предложил колонок"}
|
||||||
|
|
||||||
|
# диагностика: что именно предложил ИИ (имена + число карточек)
|
||||||
|
log.info(
|
||||||
|
"suggest: ИИ предложил %d кандидатов: %s",
|
||||||
|
len(cols),
|
||||||
|
"; ".join(
|
||||||
|
f"{str(c.get('name'))[:40]}(msg={c.get('messages')})" for c in cols if isinstance(c, dict)
|
||||||
|
)[:300],
|
||||||
|
)
|
||||||
|
|
||||||
|
# номера, на которые уже «потратились» предыдущие колонки этого прогона
|
||||||
|
used_msg: set[int] = set()
|
||||||
|
created = 0
|
||||||
|
for item in cols[:4]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = str(item.get("name") or "").strip()
|
||||||
|
if len(name) < 2 or len(name) > 40:
|
||||||
|
continue
|
||||||
|
if _similar_exists(name):
|
||||||
|
continue
|
||||||
|
nums = [int(x) for x in (item.get("messages") or []) if str(x).isdigit()]
|
||||||
|
nums = [n for n in nums if 1 <= n <= len(rows) and n not in used_msg]
|
||||||
|
if len(nums) < MIN_INBOX_GROUP:
|
||||||
|
continue # колонка без явных карточек не создаётся
|
||||||
|
used_msg.update(nums)
|
||||||
|
rules = _rules_for(item)
|
||||||
|
note = _make_note(item, texts, rules, nums)
|
||||||
|
description = str(item.get("description") or "").strip()[:300]
|
||||||
|
board = _store_suggested(name, rules, note, description)
|
||||||
|
if not board:
|
||||||
|
continue
|
||||||
|
assigned = _assign_ids(rows, nums, board["id"])
|
||||||
|
if not assigned:
|
||||||
|
# ничего не удалось положить — пустое предложение не нужно
|
||||||
|
_rollback_suggested(board["id"])
|
||||||
|
continue
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
return {"ok": False, "reason": "похожие колонки уже есть или нечего сгруппировать"}
|
||||||
|
store.set_setting(KEY, int(time.time()))
|
||||||
|
await broker.publish("boards_changed", {})
|
||||||
|
await broker.publish_toast(f"ИИ предложил колонок: {created} — откройте и решите", "sparkles")
|
||||||
|
return {"ok": True, "created": created}
|
||||||
|
|
||||||
|
|
||||||
|
async def suggest_domain_keywords() -> dict:
|
||||||
|
"""«Предложить ключи ИИ»: по вашим карточкам выделяет общие слова-маркеры сферы.
|
||||||
|
|
||||||
|
Возвращает кандидатов — пользователь смотрит, правит и сохраняет в настройках
|
||||||
|
«Сфера и ключи» (общие ключи) или использует для правил колонок.
|
||||||
|
"""
|
||||||
|
rows = store.query(
|
||||||
|
"SELECT source_msg FROM leads WHERE col <> 'trash' AND col <> 'archive' AND source_msg <> '' "
|
||||||
|
"ORDER BY received_at DESC LIMIT 40"
|
||||||
|
)
|
||||||
|
texts = [str(r["source_msg"])[:350] for r in rows]
|
||||||
|
if len(texts) < 3:
|
||||||
|
return {"ok": False, "reason": "мало карточек — сначала накопите заявки (нужно хотя бы 3)"}
|
||||||
|
user = "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts))
|
||||||
|
try:
|
||||||
|
out = await ai_svc.chat_json(ai_svc.fill_prompt(KEYWORDS_PROMPT), user, max_retries=2)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("suggest keywords failed: %s", exc)
|
||||||
|
return {"ok": False, "reason": f"ИИ недоступен: {exc}"}
|
||||||
|
kws = out.get("keywords") if isinstance(out, dict) else None
|
||||||
|
if not isinstance(kws, list) or not kws:
|
||||||
|
return {"ok": False, "reason": "ИИ не смог выделить ключи — попробуйте ещё раз"}
|
||||||
|
clean: list[str] = []
|
||||||
|
for k in kws:
|
||||||
|
s = str(k).strip().casefold().strip(".,;:«»\"'()!#")
|
||||||
|
if s and len(s) <= 40 and s not in clean:
|
||||||
|
clean.append(s)
|
||||||
|
return {"ok": True, "keywords": clean[:60]}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_note(item: dict, texts: list[str], rules: dict, nums: list[int]) -> str:
|
||||||
|
reason = str(item.get("reason") or "").strip()
|
||||||
|
direction = " · ".join(str(x) for x in (item.get("direction") or [])[:3])
|
||||||
|
base = f"Обоснование ИИ: {reason}" if reason else (
|
||||||
|
f"Обоснование ИИ: направление — {direction}" if direction else
|
||||||
|
"Обоснование ИИ: повторяющиеся заявки одной темы"
|
||||||
|
)
|
||||||
|
# сколько карточек из выборки реально попадёт в колонку (по номерам ИИ)
|
||||||
|
matched = sum(1 for n in nums if 1 <= n <= len(texts))
|
||||||
|
if matched:
|
||||||
|
base += f" · карточек в колонке: {matched}"
|
||||||
|
return base[:400]
|
||||||
|
|
||||||
|
|
||||||
|
def _store_suggested(name: str, rules: dict, note: str, description: str = "") -> dict | None:
|
||||||
|
from . import leads as leads_svc
|
||||||
|
|
||||||
|
keywords = rules.get("keywords") or []
|
||||||
|
board = leads_svc.create_board(
|
||||||
|
name, suggested=True, keywords=keywords, rules=rules, note=note, description=description
|
||||||
|
)
|
||||||
|
return leads_svc.board_by_id(board["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_ids(rows: list[tuple[str, str]], nums: list[int], board_id: str) -> int:
|
||||||
|
"""Раскладывает в колонку-предложение карточки по номерам, которые назвал ИИ.
|
||||||
|
|
||||||
|
rows — список (id, source_msg) в том же порядке, в каком сообщения уходили
|
||||||
|
в промпт (1..N). Возвращает число реально перенесённых карточек.
|
||||||
|
"""
|
||||||
|
assigned = 0
|
||||||
|
for n in nums:
|
||||||
|
if not (1 <= n <= len(rows)):
|
||||||
|
continue
|
||||||
|
lead_id, _ = rows[n - 1]
|
||||||
|
still = store.scalar("SELECT 1 FROM leads WHERE id = ? AND col = 'inbox'", [lead_id])
|
||||||
|
if not still:
|
||||||
|
continue # карточка уже разобрана другим предложением/пользователем
|
||||||
|
store.execute(
|
||||||
|
"UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox' WHERE id = ? AND col = 'inbox'",
|
||||||
|
[board_id, lead_id],
|
||||||
|
)
|
||||||
|
assigned += 1
|
||||||
|
return assigned
|
||||||
|
|
||||||
|
|
||||||
|
def _rollback_suggested(board_id: str) -> None:
|
||||||
|
"""Удаляет пустую колонку-предложение (в неё ничего не попало)."""
|
||||||
|
from . import leads as leads_svc
|
||||||
|
|
||||||
|
store.execute("UPDATE leads SET col = 'inbox' WHERE col = ?", [board_id])
|
||||||
|
store.execute("DELETE FROM boards WHERE id = ?", [board_id])
|
||||||
@@ -0,0 +1,883 @@
|
|||||||
|
"""Telegram-интеграция (п.4.2, 4.3 ТЗ).
|
||||||
|
|
||||||
|
api_id/api_hash берутся из настроек (введены в UI, НЕ из env). Сессия
|
||||||
|
Telethon сохраняется в файловой системе — повторная авторизация не нужна.
|
||||||
|
Вход: по телефону (+2FA) или по QR-ссылке. Сообщения из каналов с
|
||||||
|
включённым мониторингом кладутся в очередь pipeline, откуда их разбирает
|
||||||
|
фоновый воркер (стоп-фразы → ML → ИИ). В БД оседает только прошедшее
|
||||||
|
фильтры; исходное сообщение карточки хранит msg_id для «открыть исходник».
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from telethon import TelegramClient, events, utils
|
||||||
|
from telethon.errors import SessionPasswordNeededError, PhoneCodeInvalidError, PhoneCodeExpiredError
|
||||||
|
from telethon.errors.rpcerrorlist import FloodWaitError
|
||||||
|
from telethon.tl import functions
|
||||||
|
|
||||||
|
from .. import config
|
||||||
|
from ..constants import DIALOG_HUES
|
||||||
|
from ..crypto import decrypt_text
|
||||||
|
from ..db import store
|
||||||
|
from ..sse import broker
|
||||||
|
from . import ban_guard
|
||||||
|
from . import pipeline
|
||||||
|
|
||||||
|
log = logging.getLogger("leadradar.tg")
|
||||||
|
|
||||||
|
|
||||||
|
BACKFILL_PER_MESSAGE = (1.5, 3.0) # секунды, чтобы не попасть под бан
|
||||||
|
BACKFILL_PER_DIALOG = (3.0, 6.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _keys() -> dict:
|
||||||
|
raw = store.get_setting("tgKeys") or {}
|
||||||
|
api_hash = str(raw.get("apiHash", ""))
|
||||||
|
return {
|
||||||
|
"apiId": str(raw.get("apiId", "")).strip(),
|
||||||
|
"apiHash": (decrypt_text(api_hash) if api_hash.startswith("enc:") else api_hash),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Главный event loop приложения: на нём живут Telethon-клиент и фоновые
|
||||||
|
# задачи. Синхронные роутеры FastAPI исполняются в threadpool (без running
|
||||||
|
# loop), поэтому корутины нужно планировать на этот loop через
|
||||||
|
# call_soon_threadsafe — Telethon не переносит клиент между циклами.
|
||||||
|
_MAIN_LOOP: asyncio.AbstractEventLoop | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def register_main_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
global _MAIN_LOOP
|
||||||
|
_MAIN_LOOP = loop
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn(coro) -> None:
|
||||||
|
"""Запустить корутину из любого контекста: running loop, главный loop или поток.
|
||||||
|
|
||||||
|
set_monitor/set_monitor_all вызываются из синхронных роутеров FastAPI, где
|
||||||
|
running loop отсутствует, поэтому прямой asyncio.create_task падает с
|
||||||
|
RuntimeError (отсюда Internal Server Error при включении канала).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
loop = _MAIN_LOOP
|
||||||
|
if loop is not None and loop.is_running():
|
||||||
|
loop.call_soon_threadsafe(loop.create_task, coro)
|
||||||
|
return
|
||||||
|
threading.Thread(
|
||||||
|
target=lambda: asyncio.new_event_loop().run_until_complete(coro),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
else:
|
||||||
|
loop.create_task(coro)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramManager:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.client: TelegramClient | None = None
|
||||||
|
self.phase = "idle" # idle | phone | code | password | qr | ready
|
||||||
|
self.phone: str = ""
|
||||||
|
self._code_hash: str = ""
|
||||||
|
self._auth_lock = asyncio.Lock()
|
||||||
|
self._listener_task: asyncio.Task | None = None
|
||||||
|
self._monitored: set[str] = set()
|
||||||
|
self.error: str | None = None
|
||||||
|
self._disconnect_hook = None
|
||||||
|
# QR
|
||||||
|
self.qr_url: str = ""
|
||||||
|
self._qr_task: asyncio.Task | None = None
|
||||||
|
# сердцебиение статуса
|
||||||
|
self._last_connected: bool | None = None
|
||||||
|
# backfill каналов при первом подключении
|
||||||
|
self._backfilling: set[str] = set()
|
||||||
|
|
||||||
|
# ── состояние для UI ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
row = store.query_one(
|
||||||
|
"SELECT count(*) AS d FROM dialogs WHERE monitor = TRUE"
|
||||||
|
)
|
||||||
|
acc = store.get_setting("tgAccount") or ""
|
||||||
|
connected = bool(self.client and self.client.is_connected())
|
||||||
|
listener_alive = bool(self._listener_task and not self._listener_task.done())
|
||||||
|
return {
|
||||||
|
"phase": self.phase,
|
||||||
|
"connected": connected,
|
||||||
|
"listener": listener_alive,
|
||||||
|
"account": acc,
|
||||||
|
"monitored": int(row["d"]) if row else 0,
|
||||||
|
"keysSet": bool(_keys().get("apiId") and _keys().get("apiHash")),
|
||||||
|
"error": self.error,
|
||||||
|
"qrUrl": self.qr_url if self.phase == "qr" else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _client(self) -> TelegramClient:
|
||||||
|
keys = _keys()
|
||||||
|
api_id = str(keys.get("apiId") or "").strip()
|
||||||
|
api_hash = str(keys.get("apiHash") or "").strip()
|
||||||
|
if not api_id or not api_hash:
|
||||||
|
raise ValueError("Сначала сохраните Telegram api_id и api_hash в настройках")
|
||||||
|
if self.client is None:
|
||||||
|
session = str(config.SESSIONS_DIR / config.SESSION_PREFIX)
|
||||||
|
self.client = TelegramClient(session, int(api_id), api_hash)
|
||||||
|
return self.client
|
||||||
|
|
||||||
|
# ── веб-авторизация ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def start_phone(self, phone: str) -> None:
|
||||||
|
async with self._auth_lock:
|
||||||
|
self.phone = phone
|
||||||
|
self.error = None
|
||||||
|
try:
|
||||||
|
client = self._client()
|
||||||
|
await client.connect()
|
||||||
|
sent = await client.send_code_request(phone)
|
||||||
|
self._code_hash = sent.phone_code_hash
|
||||||
|
self.phase = "code"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.phase = "idle"
|
||||||
|
self.error = str(exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def submit_code(self, code: str) -> None:
|
||||||
|
async with self._auth_lock:
|
||||||
|
self.error = None
|
||||||
|
client = self._client()
|
||||||
|
try:
|
||||||
|
await client.sign_in(self.phone, code, phone_code_hash=self._code_hash)
|
||||||
|
await self._finalize()
|
||||||
|
except SessionPasswordNeededError:
|
||||||
|
self.phase = "password"
|
||||||
|
except PhoneCodeInvalidError:
|
||||||
|
self.error = "Неверный код"
|
||||||
|
raise ValueError("Неверный код")
|
||||||
|
except PhoneCodeExpiredError:
|
||||||
|
self.error = "Код истёк — запросите новый"
|
||||||
|
raise ValueError("Код истёк — запросите новый")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.error = str(exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def submit_password(self, password: str) -> None:
|
||||||
|
async with self._auth_lock:
|
||||||
|
self.error = None
|
||||||
|
try:
|
||||||
|
await self.client.sign_in(password=password)
|
||||||
|
await self._finalize()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.error = "Неверный облачный пароль"
|
||||||
|
raise ValueError("Неверный облачный пароль") from exc
|
||||||
|
|
||||||
|
async def _finalize(self, notify: bool = True) -> None:
|
||||||
|
me = await self.client.get_me()
|
||||||
|
store.set_setting("tgAccount", f"@{me.username or 'user'}")
|
||||||
|
self.phase = "ready"
|
||||||
|
self._start_listener()
|
||||||
|
await self.refresh_dialogs()
|
||||||
|
self._schedule_first_backfill()
|
||||||
|
if notify:
|
||||||
|
await broker.publish_toast("Telegram подключён, сессия сохранена", "send")
|
||||||
|
await self._publish_status()
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
async with self._auth_lock:
|
||||||
|
if self._qr_task:
|
||||||
|
self._qr_task.cancel()
|
||||||
|
self._qr_task = None
|
||||||
|
if self._listener_task:
|
||||||
|
self._listener_task.cancel()
|
||||||
|
self._listener_task = None
|
||||||
|
if self.client:
|
||||||
|
try:
|
||||||
|
await self.client.disconnect()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
self.phase = "idle"
|
||||||
|
self._monitored.clear()
|
||||||
|
self.qr_url = ""
|
||||||
|
store.set_setting("tgAccount", "")
|
||||||
|
await broker.publish_toast("Telegram отключён", "logout")
|
||||||
|
await self._publish_status()
|
||||||
|
|
||||||
|
async def auto_resume(self) -> None:
|
||||||
|
"""При старте сервера: если сессия сохранена — подключиться автоматически."""
|
||||||
|
try:
|
||||||
|
keys = _keys()
|
||||||
|
if not (keys.get("apiId") and keys.get("apiHash")):
|
||||||
|
return
|
||||||
|
client = self._client()
|
||||||
|
await client.connect()
|
||||||
|
if await client.is_user_authorized():
|
||||||
|
await self._finalize(notify=False)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.info("auto_resume skipped: %s", exc)
|
||||||
|
self.phase = "idle"
|
||||||
|
|
||||||
|
# ── прослушивание ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _start_listener(self) -> None:
|
||||||
|
if self._listener_task and not self._listener_task.done():
|
||||||
|
return
|
||||||
|
self._reload_monitored()
|
||||||
|
self.client.add_event_handler(self._on_message, events.NewMessage())
|
||||||
|
self._listener_task = asyncio.create_task(self.client.run_until_disconnected())
|
||||||
|
|
||||||
|
def _on_done(task: asyncio.Task) -> None:
|
||||||
|
if task.cancelled():
|
||||||
|
log.info("listener stopped (cancelled)")
|
||||||
|
return
|
||||||
|
exc = task.exception()
|
||||||
|
if exc:
|
||||||
|
log.error("listener crashed: %s", exc)
|
||||||
|
else:
|
||||||
|
log.info("listener stopped")
|
||||||
|
|
||||||
|
self._listener_task.add_done_callback(_on_done)
|
||||||
|
|
||||||
|
def _reload_monitored(self) -> None:
|
||||||
|
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE")
|
||||||
|
self._monitored = {r["id"] for r in rows}
|
||||||
|
|
||||||
|
def _dialog_id(self, message) -> str:
|
||||||
|
try:
|
||||||
|
chat_id = message.chat_id
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
chat_id = message.peer_id
|
||||||
|
return str(chat_id)
|
||||||
|
|
||||||
|
async def _on_message(self, event) -> None:
|
||||||
|
"""Каждое сообщение мониторящегося диалога кладём в очередь pipeline."""
|
||||||
|
try:
|
||||||
|
msg = event.message
|
||||||
|
if msg is None or msg.text is None or not msg.text.strip():
|
||||||
|
return
|
||||||
|
dialog_id = self._dialog_id(event.message)
|
||||||
|
if dialog_id not in self._monitored:
|
||||||
|
return
|
||||||
|
chat = await event.get_chat()
|
||||||
|
ch_name = getattr(chat, "title", None) or getattr(chat, "first_name", "") or dialog_id
|
||||||
|
ch_handle = getattr(chat, "username", "") or ""
|
||||||
|
hue = dialog_hue(dialog_id, ch_name)
|
||||||
|
now = time.time_ns() // 1_000_000
|
||||||
|
ts = int(msg.date.timestamp() * 1000) if getattr(msg, "date", None) else now
|
||||||
|
store.execute(
|
||||||
|
"UPDATE dialogs SET last_text = ?, last_at = ?, updated_at = ? WHERE id = ?",
|
||||||
|
[msg.text.strip()[:200], now, now, dialog_id],
|
||||||
|
)
|
||||||
|
pipeline.enqueue(dialog_id, ch_name, ch_handle, hue, msg.id, msg.text, ts)
|
||||||
|
# помечаем сообщение прочитанным в Telegram, чтобы оно не висело
|
||||||
|
# «новым» в других клиентах/устройствах
|
||||||
|
try:
|
||||||
|
await self.client.send_read_acknowledge(chat)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.debug("read ack failed", exc_info=True)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.exception("on_message failed: %s", exc)
|
||||||
|
|
||||||
|
# ── QR-вход ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def qr_start(self) -> str:
|
||||||
|
async with self._auth_lock:
|
||||||
|
self.error = None
|
||||||
|
client = self._client()
|
||||||
|
await client.connect()
|
||||||
|
if await client.is_user_authorized():
|
||||||
|
await self._finalize(notify=False)
|
||||||
|
return ""
|
||||||
|
if self._qr_task and not self._qr_task.done():
|
||||||
|
return self.qr_url
|
||||||
|
qr = await client.qr_login()
|
||||||
|
self.qr_url = qr.url
|
||||||
|
self.phase = "qr"
|
||||||
|
self._qr_task = asyncio.create_task(self._wait_qr(qr))
|
||||||
|
return self.qr_url
|
||||||
|
|
||||||
|
async def _wait_qr(self, qr) -> None:
|
||||||
|
try:
|
||||||
|
await qr.wait()
|
||||||
|
await self._finalize(notify=True)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("qr wait error: %s", exc)
|
||||||
|
self.error = str(exc)
|
||||||
|
self.phase = "idle"
|
||||||
|
finally:
|
||||||
|
self._qr_task = None
|
||||||
|
|
||||||
|
# ── статус (сердцебиение) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _publish_status(self) -> None:
|
||||||
|
await broker.publish("system_status", self.status())
|
||||||
|
|
||||||
|
async def heartbeat(self) -> None:
|
||||||
|
"""Периодический вызов из планировщика: уведомляем, если Telegram «уснул»."""
|
||||||
|
connected = bool(self.client and self.client.is_connected())
|
||||||
|
if self.phase == "ready" and connected != self._last_connected:
|
||||||
|
self._last_connected = connected
|
||||||
|
await self._publish_status()
|
||||||
|
if not connected:
|
||||||
|
await broker.publish_toast("Telegram отключён — переподключение при следующей проверке", "bell")
|
||||||
|
if self.phase == "ready" and connected:
|
||||||
|
self._last_connected = True
|
||||||
|
|
||||||
|
# ── backfill: при первом подключении по 10 последних сообщений ────────
|
||||||
|
|
||||||
|
def _schedule_first_backfill(self) -> None:
|
||||||
|
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE AND backfilled = FALSE")
|
||||||
|
ids = [r["id"] for r in rows]
|
||||||
|
if ids:
|
||||||
|
asyncio.create_task(self._backfill_dialogs(ids))
|
||||||
|
|
||||||
|
async def _backfill_dialogs(self, dialog_ids: list[str], force: bool = False) -> None:
|
||||||
|
for dialog_id in dialog_ids:
|
||||||
|
if dialog_id in self._backfilling:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
processed = await self.backfill_dialog(dialog_id, force=force)
|
||||||
|
if processed:
|
||||||
|
log.info("backfill %s: %d сообщений в очередь", dialog_id, processed)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("backfill %s failed: %s", dialog_id, exc)
|
||||||
|
await asyncio.sleep(random.uniform(*BACKFILL_PER_DIALOG))
|
||||||
|
|
||||||
|
async def backfill_dialog(self, dialog_id: str, force: bool = False) -> int:
|
||||||
|
"""Последние 10 сообщений канала: разбираем с паузами (анти-бан).
|
||||||
|
|
||||||
|
force=True — «Перечитать» по кнопке: даже если канал уже разобран.
|
||||||
|
Повторы карточек не создаются (защита dedup по нормализованному тексту).
|
||||||
|
"""
|
||||||
|
if dialog_id in self._backfilling:
|
||||||
|
return 0
|
||||||
|
client = self.client
|
||||||
|
if not client or not client.is_connected():
|
||||||
|
return 0
|
||||||
|
row = store.query_one("SELECT backfilled FROM dialogs WHERE id = ?", [dialog_id])
|
||||||
|
if not row or (not force and bool(row["backfilled"])):
|
||||||
|
return 0
|
||||||
|
self._backfilling.add(dialog_id)
|
||||||
|
processed = 0
|
||||||
|
try:
|
||||||
|
entity = await client.get_entity(int(dialog_id))
|
||||||
|
chat = await client.get_entity(int(dialog_id))
|
||||||
|
name = getattr(chat, "title", None) or getattr(chat, "first_name", "") or dialog_id
|
||||||
|
handle = getattr(chat, "username", "") or ""
|
||||||
|
hue = dialog_hue(dialog_id, name)
|
||||||
|
msgs = await client.get_messages(entity, limit=10)
|
||||||
|
for m in reversed(msgs): # от старых к новым, как реальный поток
|
||||||
|
if m.text is None or not m.text.strip():
|
||||||
|
continue
|
||||||
|
now = time.time_ns() // 1_000_000
|
||||||
|
ts = int(m.date.timestamp() * 1000) if m.date else now
|
||||||
|
# в очередь уходит всё; отсев сделает воркер, в БД осядет только
|
||||||
|
# то, что прошло фильтры
|
||||||
|
pipeline.enqueue(dialog_id, name, handle, hue, m.id, m.text, ts)
|
||||||
|
processed += 1
|
||||||
|
await asyncio.sleep(random.uniform(*BACKFILL_PER_MESSAGE))
|
||||||
|
# «Перечитать» — вручную вытащили сообщения: снимаем «новое» в Telegram
|
||||||
|
try:
|
||||||
|
await client.send_read_acknowledge(entity)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.debug("backfill read ack failed", exc_info=True)
|
||||||
|
store.execute("UPDATE dialogs SET backfilled = TRUE WHERE id = ?", [dialog_id])
|
||||||
|
finally:
|
||||||
|
self._backfilling.discard(dialog_id)
|
||||||
|
return processed
|
||||||
|
|
||||||
|
async def realtime_sweep(self) -> None:
|
||||||
|
"""Страховка realtime: если событие потеряно (рестарт/разрыв), раз в 30 с
|
||||||
|
докачиваем непрочитанные сообщения включённых каналов, кладём в очередь
|
||||||
|
и помечаем прочитанными."""
|
||||||
|
client = self.client
|
||||||
|
if not client or not client.is_connected():
|
||||||
|
return
|
||||||
|
self._reload_monitored()
|
||||||
|
if not self._monitored:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
dialogs = await client.get_dialogs(limit=500)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("realtime sweep: get_dialogs fail: %s", exc)
|
||||||
|
return
|
||||||
|
# синхронизация списка: новые каналы/группы появляются и включаются
|
||||||
|
# автоматически, удалённые исчезают (см. _persist_dialogs)
|
||||||
|
try:
|
||||||
|
entries = []
|
||||||
|
for dlg in dialogs:
|
||||||
|
ent = dlg.entity
|
||||||
|
name = dlg.name or ""
|
||||||
|
entries.append(
|
||||||
|
(
|
||||||
|
str(dlg.id),
|
||||||
|
name,
|
||||||
|
getattr(ent, "username", "") or "",
|
||||||
|
self._kind_of(ent),
|
||||||
|
dialog_hue(str(dlg.id), name),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if entries:
|
||||||
|
self._persist_dialogs(entries)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("realtime sweep: sync dialogs fail: %s", exc)
|
||||||
|
for dlg in dialogs:
|
||||||
|
did = str(dlg.id)
|
||||||
|
if did not in self._monitored:
|
||||||
|
continue
|
||||||
|
unread = int(getattr(dlg, "unread_count", 0) or 0)
|
||||||
|
if unread <= 0:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
msgs = await client.get_messages(dlg.entity, limit=min(unread + 2, 10))
|
||||||
|
added = 0
|
||||||
|
for m in reversed(msgs): # от старых к новым
|
||||||
|
if m.text is None or not m.text.strip():
|
||||||
|
continue
|
||||||
|
if store.scalar(
|
||||||
|
"SELECT 1 FROM pipeline_msg WHERE dialog_id = ? AND msg_id = ?", [did, m.id]
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
ent = dlg.entity
|
||||||
|
name = getattr(ent, "title", None) or getattr(ent, "first_name", "") or did
|
||||||
|
handle = getattr(ent, "username", "") or ""
|
||||||
|
hue = dialog_hue(did, name)
|
||||||
|
now = time.time_ns() // 1_000_000
|
||||||
|
ts = int(m.date.timestamp() * 1000) if getattr(m, "date", None) else now
|
||||||
|
pipeline.enqueue(did, name, handle, hue, m.id, m.text, ts)
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
log.info("realtime sweep %s: +%d в очередь (потерянные события)", did, added)
|
||||||
|
await client.send_read_acknowledge(dlg.entity)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("realtime sweep dialog %s fail: %s", did, exc)
|
||||||
|
|
||||||
|
# ── диалоги/каналы ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _kind_of(entity) -> str:
|
||||||
|
if getattr(entity, "broadcast", False):
|
||||||
|
return "канал"
|
||||||
|
if getattr(entity, "megagroup", False) or getattr(entity, "gigagroup", False) or getattr(entity, "group", False):
|
||||||
|
return "группа"
|
||||||
|
return "чат"
|
||||||
|
|
||||||
|
def _persist_dialogs(self, entries: list[tuple]) -> int:
|
||||||
|
"""Синхронизация списка диалогов с Telegram.
|
||||||
|
|
||||||
|
- новые чаты/каналы добавляются; авто-мониторинг новых управляется
|
||||||
|
настройкой autoMonitorNew (вкл — любой появившийся чат мониторится,
|
||||||
|
выкл — появляется отключённым);
|
||||||
|
- переименования/смена типа обновляются (monitor пользователя не трогаем);
|
||||||
|
- диалоги, которых больше нет в Telegram (вышел/удалил), удаляются.
|
||||||
|
"""
|
||||||
|
if not entries:
|
||||||
|
return 0
|
||||||
|
now = time.time_ns() // 1_000_000
|
||||||
|
auto_new = bool(store.get_setting("autoMonitorNew"))
|
||||||
|
seen: set[str] = set()
|
||||||
|
for dlg_id, name, handle, kind, hue in entries:
|
||||||
|
seen.add(dlg_id)
|
||||||
|
store.execute(
|
||||||
|
"INSERT INTO dialogs(id, name, handle, kind, hue, monitor, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(id) DO UPDATE SET name = excluded.name, handle = excluded.handle, "
|
||||||
|
"kind = excluded.kind, hue = excluded.hue, updated_at = excluded.updated_at",
|
||||||
|
[dlg_id, name, handle, kind, hue, auto_new, now],
|
||||||
|
)
|
||||||
|
stale = store.query(
|
||||||
|
"SELECT id FROM dialogs WHERE id NOT IN (" + ",".join(["?"] * len(seen)) + ")",
|
||||||
|
list(seen),
|
||||||
|
)
|
||||||
|
if stale:
|
||||||
|
stale_ids = [r["id"] for r in stale]
|
||||||
|
store.execute(
|
||||||
|
"DELETE FROM dialogs WHERE id IN (" + ",".join(["?"] * len(stale_ids)) + ")",
|
||||||
|
stale_ids,
|
||||||
|
)
|
||||||
|
log.info("dialogs sync: удалено устаревших источников: %d", len(stale_ids))
|
||||||
|
self._reload_monitored()
|
||||||
|
return len(entries)
|
||||||
|
|
||||||
|
async def refresh_dialogs(self) -> int:
|
||||||
|
client = self._client()
|
||||||
|
if not client.is_connected():
|
||||||
|
await client.connect()
|
||||||
|
entries: list[tuple] = []
|
||||||
|
async for dialog in client.iter_dialogs(limit=500):
|
||||||
|
entity = dialog.entity
|
||||||
|
name = dialog.name or ""
|
||||||
|
handle = getattr(entity, "username", "") or ""
|
||||||
|
kind = self._kind_of(entity)
|
||||||
|
hue = dialog_hue(str(dialog.id), name)
|
||||||
|
entries.append((str(dialog.id), name, handle, kind, hue))
|
||||||
|
if entries:
|
||||||
|
self._persist_dialogs(entries)
|
||||||
|
return len(entries)
|
||||||
|
|
||||||
|
def list_dialogs(self) -> list[dict]:
|
||||||
|
rows = store.query("SELECT * FROM dialogs ORDER BY monitor DESC, name")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"name": r["name"],
|
||||||
|
"handle": r["handle"],
|
||||||
|
"type": r["kind"],
|
||||||
|
"hue": r["hue"],
|
||||||
|
"on": bool(r["monitor"]),
|
||||||
|
"last": {"text": r["last_text"], "time": r["last_at"]},
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def set_monitor(self, dialog_id: str, enabled: bool) -> None:
|
||||||
|
store.execute(
|
||||||
|
"UPDATE dialogs SET monitor = ?, updated_at = ? WHERE id = ?",
|
||||||
|
[enabled, time.time_ns() // 1_000_000, dialog_id],
|
||||||
|
)
|
||||||
|
self._reload_monitored()
|
||||||
|
if enabled:
|
||||||
|
row = store.query_one("SELECT backfilled FROM dialogs WHERE id = ?", [dialog_id])
|
||||||
|
if row and not bool(row["backfilled"]):
|
||||||
|
# первое включение мониторинга: разбираем последние 10 сообщений с паузами
|
||||||
|
_spawn(self.backfill_dialog(dialog_id))
|
||||||
|
|
||||||
|
def set_monitor_all(self, enabled: bool) -> int:
|
||||||
|
"""Включить/выключить мониторинг сразу для всех диалогов.
|
||||||
|
|
||||||
|
Первое включение каждого канала разбирается последовательно (с паузами),
|
||||||
|
чтобы не попасть под бан Telegram.
|
||||||
|
"""
|
||||||
|
now = time.time_ns() // 1_000_000
|
||||||
|
rows = store.query("SELECT id, backfilled FROM dialogs")
|
||||||
|
to_backfill: list[str] = []
|
||||||
|
for r in rows:
|
||||||
|
store.execute(
|
||||||
|
"UPDATE dialogs SET monitor = ?, updated_at = ? WHERE id = ?",
|
||||||
|
[enabled, now, r["id"]],
|
||||||
|
)
|
||||||
|
if enabled and not bool(r["backfilled"]):
|
||||||
|
to_backfill.append(r["id"])
|
||||||
|
self._reload_monitored()
|
||||||
|
if enabled and to_backfill:
|
||||||
|
_spawn(self._backfill_dialogs(to_backfill))
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
def backfill_monitored(self) -> int:
|
||||||
|
"""Кнопка «Перечитать»: последние 10 сообщений всех включённых каналов.
|
||||||
|
|
||||||
|
Разбор идёт в фоне последовательно с паузами (анти-бан), даже если
|
||||||
|
канал уже разобран (force). Включённые каналы продолжают ловить новые
|
||||||
|
сообщения в реальном времени — это ручная догонялка.
|
||||||
|
"""
|
||||||
|
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE")
|
||||||
|
ids = [r["id"] for r in rows]
|
||||||
|
if not ids:
|
||||||
|
return 0
|
||||||
|
_spawn(self._backfill_dialogs(ids, force=True))
|
||||||
|
return len(ids)
|
||||||
|
|
||||||
|
async def dialog_messages(self, dialog_id: str, limit: int = 24) -> list[dict]:
|
||||||
|
"""Последние сообщения диалога: свежие берём из Telegram, старые — из БД."""
|
||||||
|
out: list[dict] = []
|
||||||
|
client = self.client
|
||||||
|
try:
|
||||||
|
if client and client.is_connected():
|
||||||
|
entity = await client.get_entity(int(dialog_id))
|
||||||
|
msgs = await client.get_messages(entity, limit=min(limit, 30))
|
||||||
|
now = time.time_ns() // 1_000_000
|
||||||
|
for m in msgs:
|
||||||
|
if m.text:
|
||||||
|
row = store.query_one(
|
||||||
|
"SELECT id FROM messages WHERE id = ?",
|
||||||
|
[f"m_{dialog_id}_{m.id}"],
|
||||||
|
)
|
||||||
|
lead_id = None
|
||||||
|
if row:
|
||||||
|
lead_id = row.get("lead_id") or None
|
||||||
|
if not row:
|
||||||
|
store.execute(
|
||||||
|
"INSERT OR IGNORE INTO messages(id, dialog_id, text, msg_at) VALUES (?, ?, ?, ?)",
|
||||||
|
[f"m_{dialog_id}_{m.id}", dialog_id, m.text[:4000], now],
|
||||||
|
)
|
||||||
|
out.append({"id": m.id, "text": m.text, "time": m.date.timestamp() * 1000, "lead": bool(lead_id)})
|
||||||
|
# вручную вытащили сообщения — снимаем «новое» в Telegram
|
||||||
|
try:
|
||||||
|
await client.send_read_acknowledge(entity)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.debug("dialog_messages read ack failed", exc_info=True)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("dialog_messages tg fail: %s", exc)
|
||||||
|
if not out:
|
||||||
|
rows = store.query(
|
||||||
|
"SELECT * FROM messages WHERE dialog_id = ? ORDER BY msg_at DESC LIMIT ?",
|
||||||
|
[dialog_id, limit],
|
||||||
|
)
|
||||||
|
out = [{"id": r["id"], "text": r["text"], "time": r["msg_at"], "lead": bool(r["lead_id"])} for r in rows]
|
||||||
|
return out
|
||||||
|
|
||||||
|
# ── discovery: поиск каналов и действия (Task 4) ──────────────────────
|
||||||
|
|
||||||
|
async def discovery_search(self, q: str, limit: int = 30) -> list[dict]:
|
||||||
|
"""Глобальный поиск каналов/групп по ключу (contacts.search).
|
||||||
|
|
||||||
|
id возвращается подписанным (как в dialogs: каналы -100…, группы -id,
|
||||||
|
люди +id). Найденные entity кэшируются в сессию Telethon — тогда
|
||||||
|
discovery_info/read смогут получить участников/историю по id даже без
|
||||||
|
вступления (публичные источники).
|
||||||
|
"""
|
||||||
|
client = self.client
|
||||||
|
if not client or not client.is_connected():
|
||||||
|
raise RuntimeError("Telegram не подключён")
|
||||||
|
found = await client(functions.contacts.SearchRequest(q=q, limit=limit))
|
||||||
|
# пауза между поисковыми запросами (анти-бан, BanGuard)
|
||||||
|
await asyncio.sleep(ban_guard.search_pause())
|
||||||
|
try:
|
||||||
|
client.session.process_entities(found)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.debug("discovery_search: cache entities failed", exc_info=True)
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for ent in (*found.chats, *found.users):
|
||||||
|
try:
|
||||||
|
dialog_id = str(utils.get_peer_id(ent))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if dialog_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(dialog_id)
|
||||||
|
name = utils.get_display_name(ent) or dialog_id
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": dialog_id,
|
||||||
|
"name": name,
|
||||||
|
"username": getattr(ent, "username", "") or "",
|
||||||
|
"kind": self._kind_of(ent),
|
||||||
|
"hue": dialog_hue(dialog_id, name),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(out) >= limit:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def discovery_info(self, dialog_id: str) -> dict:
|
||||||
|
"""Инфо об источнике: тип, username, участники, признак форума.
|
||||||
|
|
||||||
|
participants берётся из full_chat (channels.getFullChannel для
|
||||||
|
каналов/супергрупп, messages.getFullChat для базовых групп); если
|
||||||
|
определить не удалось (нет членства/приватный/ошибка) — None,
|
||||||
|
исключение наружу не бросаем.
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"id": str(dialog_id),
|
||||||
|
"name": str(dialog_id),
|
||||||
|
"username": "",
|
||||||
|
"kind": "",
|
||||||
|
"hue": dialog_hue(str(dialog_id), str(dialog_id)),
|
||||||
|
"participants": None,
|
||||||
|
"is_forum": False,
|
||||||
|
}
|
||||||
|
client = self.client
|
||||||
|
if not client or not client.is_connected():
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
entity = await client.get_entity(int(dialog_id))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("discovery_info %s: entity not resolved: %s", dialog_id, exc)
|
||||||
|
return result
|
||||||
|
name = utils.get_display_name(entity) or str(dialog_id)
|
||||||
|
kind = self._kind_of(entity)
|
||||||
|
result.update(
|
||||||
|
name=name,
|
||||||
|
username=getattr(entity, "username", "") or "",
|
||||||
|
kind=kind,
|
||||||
|
hue=dialog_hue(str(dialog_id), name),
|
||||||
|
is_forum=bool(getattr(entity, "forum", False)),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if kind in ("канал", "группа"):
|
||||||
|
full = await client(functions.channels.GetFullChannelRequest(entity))
|
||||||
|
full_chat = full.full_chat
|
||||||
|
participants = int(getattr(full_chat, "participants_count", 0) or 0)
|
||||||
|
result["participants"] = participants or None
|
||||||
|
elif getattr(entity, "title", None):
|
||||||
|
# базовая группа (legacy): полный чат приносит список участников
|
||||||
|
full = await client(functions.messages.GetFullChatRequest(entity.id))
|
||||||
|
members = getattr(
|
||||||
|
getattr(full.full_chat, "participants", None), "participants", None
|
||||||
|
)
|
||||||
|
if members:
|
||||||
|
result["participants"] = len(members)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("discovery_info %s: participants unavailable: %s", dialog_id, exc)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def discovery_read(self, dialog_id: str, limit: int) -> dict:
|
||||||
|
"""Последние сообщения источника для оценки (без создания карточек).
|
||||||
|
|
||||||
|
Форум (entity.forum): читаем выборку по активным темам
|
||||||
|
(channels.getForumTopics + по каждой теме get_messages(reply_to=topic_id))
|
||||||
|
и возвращаем плоский список с topic_id/topic_title. Для обычных
|
||||||
|
источников topic_id/topic_title = None. История недоступна
|
||||||
|
(приватный/закрытый источник без членства) — ok=False,
|
||||||
|
error="no_history". Исключения наружу не бросаем: ошибка в темах —
|
||||||
|
безопасный fallback на обычное чтение ленты.
|
||||||
|
"""
|
||||||
|
client = self.client
|
||||||
|
limit = max(int(limit or 0), 0)
|
||||||
|
if limit <= 0:
|
||||||
|
return {"ok": True, "error": None, "messages": []}
|
||||||
|
if not client or not client.is_connected():
|
||||||
|
return {"ok": False, "error": "no_history", "messages": []}
|
||||||
|
try:
|
||||||
|
entity = await client.get_entity(int(dialog_id))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("discovery_read %s: entity not resolved: %s", dialog_id, exc)
|
||||||
|
return {"ok": False, "error": "no_history", "messages": []}
|
||||||
|
if getattr(entity, "forum", False):
|
||||||
|
try:
|
||||||
|
topic_msgs = await self._read_forum_topics(client, entity, limit)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("discovery_read %s: forum topics fail, fallback to feed: %s", dialog_id, exc)
|
||||||
|
topic_msgs = []
|
||||||
|
if topic_msgs:
|
||||||
|
return {"ok": True, "error": None, "messages": topic_msgs}
|
||||||
|
# обычная лента (каналы/группы; для форума — General-тема)
|
||||||
|
try:
|
||||||
|
msgs = await client.get_messages(entity, limit=limit)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("discovery_read %s: history unavailable: %s", dialog_id, exc)
|
||||||
|
return {"ok": False, "error": "no_history", "messages": []}
|
||||||
|
now = time.time_ns() // 1_000_000
|
||||||
|
out: list[dict] = []
|
||||||
|
for m in msgs:
|
||||||
|
item = self._discovery_message_item(m, None, None, now)
|
||||||
|
if item:
|
||||||
|
out.append(item)
|
||||||
|
return {"ok": True, "error": None, "messages": out}
|
||||||
|
|
||||||
|
async def _read_forum_topics(self, client, entity, limit: int) -> list[dict]:
|
||||||
|
"""Выборка сообщений по активным темам форума.
|
||||||
|
|
||||||
|
Возвращает плоский список {id, text, date_ms, topic_id, topic_title}.
|
||||||
|
Исключения не бросает: темы, которые не прочитались, пропускаются,
|
||||||
|
вызывающий решает, делать ли fallback на обычную ленту.
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
try:
|
||||||
|
res = await client(
|
||||||
|
functions.channels.GetForumTopicsRequest(
|
||||||
|
channel=entity, offset_date=0, offset_id=0, offset_topic=0, limit=5
|
||||||
|
)
|
||||||
|
)
|
||||||
|
topics = list(getattr(res, "topics", None) or [])
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("discovery_read: getForumTopics fail: %s", exc)
|
||||||
|
return out
|
||||||
|
if not topics:
|
||||||
|
return out
|
||||||
|
# на тему минимум 3 сообщения (иначе тема почти всегда отсеется как
|
||||||
|
# «мало подходящих»), cap 10; суммарно выборка может слегка превысить limit
|
||||||
|
per_topic = min(max(3, math.ceil(limit / len(topics))), 10)
|
||||||
|
now = time.time_ns() // 1_000_000
|
||||||
|
for topic in topics:
|
||||||
|
topic_id = int(getattr(topic, "id", 0) or 0)
|
||||||
|
topic_title = getattr(topic, "title", "") or ""
|
||||||
|
if not topic_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
msgs = await client.get_messages(entity, limit=per_topic, reply_to=topic_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("discovery_read: topic %s read fail: %s", topic_id, exc)
|
||||||
|
continue
|
||||||
|
for m in msgs:
|
||||||
|
item = self._discovery_message_item(m, topic_id, topic_title, now)
|
||||||
|
if item:
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _discovery_message_item(m, topic_id, topic_title, now: int) -> dict | None:
|
||||||
|
"""Одно сообщение discovery_read: только непустой текст (как в
|
||||||
|
backfill/dialog_messages), поля {id, text, date_ms, topic_id, topic_title}."""
|
||||||
|
text = getattr(m, "text", None)
|
||||||
|
if not text or not text.strip():
|
||||||
|
return None
|
||||||
|
date = getattr(m, "date", None)
|
||||||
|
return {
|
||||||
|
"id": m.id,
|
||||||
|
"text": text,
|
||||||
|
"date_ms": int(date.timestamp() * 1000) if date else now,
|
||||||
|
"topic_id": topic_id,
|
||||||
|
"topic_title": topic_title,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def discovery_join(self, username: str) -> None:
|
||||||
|
"""Вступить в канал/группу по @username (channels.JoinChannelRequest).
|
||||||
|
|
||||||
|
Ручной join из API — вне квот, без пауз; паузу перед авто-вступлением
|
||||||
|
делает воркер (ban_guard.wait_join_delay). FloodWaitError фиксируется
|
||||||
|
в BanGuard (стоп авто-вступлений до конца суток) и пробрасывается
|
||||||
|
вызывающему.
|
||||||
|
"""
|
||||||
|
username = (username or "").strip().lstrip("@")
|
||||||
|
if not username:
|
||||||
|
raise ValueError("Не указан username для вступления")
|
||||||
|
client = self.client
|
||||||
|
if not client or not client.is_connected():
|
||||||
|
raise RuntimeError("Telegram не подключён")
|
||||||
|
try:
|
||||||
|
entity = await client.get_entity(username)
|
||||||
|
await client(functions.channels.JoinChannelRequest(entity))
|
||||||
|
except FloodWaitError:
|
||||||
|
ban_guard.note_flood()
|
||||||
|
log.warning("discovery_join %s: flood — авто-вступления стоп до конца суток", username)
|
||||||
|
raise
|
||||||
|
log.info("discovery_join: вступили в @%s", username)
|
||||||
|
|
||||||
|
async def discovery_leave(self, dialog_id: str) -> None:
|
||||||
|
"""Выйти из канала/группы (channels.LeaveChannelRequest)."""
|
||||||
|
client = self.client
|
||||||
|
if not client or not client.is_connected():
|
||||||
|
raise RuntimeError("Telegram не подключён")
|
||||||
|
entity = await client.get_entity(int(dialog_id))
|
||||||
|
await client(functions.channels.LeaveChannelRequest(entity))
|
||||||
|
log.info("discovery_leave: вышли из %s", dialog_id)
|
||||||
|
|
||||||
|
def add_dialog_monitored(self, dialog_id, name, username, kind, hue) -> None:
|
||||||
|
"""Добавить источник в dialogs с monitor=TRUE (после вступления).
|
||||||
|
|
||||||
|
INSERT/UPDATE без авто-логики (как set_monitor, но без запуска
|
||||||
|
backfill): backfilled=FALSE — разбор последних сообщений подхватит
|
||||||
|
обычный механизм при первом подключении/перечитывании.
|
||||||
|
"""
|
||||||
|
now = time.time_ns() // 1_000_000
|
||||||
|
store.execute(
|
||||||
|
"INSERT INTO dialogs(id, name, handle, kind, hue, monitor, backfilled, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, TRUE, FALSE, ?) "
|
||||||
|
"ON CONFLICT(id) DO UPDATE SET name = excluded.name, handle = excluded.handle, "
|
||||||
|
"kind = excluded.kind, hue = excluded.hue, monitor = TRUE, backfilled = FALSE, "
|
||||||
|
"updated_at = excluded.updated_at",
|
||||||
|
[
|
||||||
|
str(dialog_id),
|
||||||
|
name or str(dialog_id),
|
||||||
|
username or "",
|
||||||
|
kind or "",
|
||||||
|
hue or "#666",
|
||||||
|
now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self._reload_monitored()
|
||||||
|
|
||||||
|
|
||||||
|
def dialog_hue(dialog_id: str, name: str = "") -> str:
|
||||||
|
h = 0
|
||||||
|
for ch in (dialog_id + name):
|
||||||
|
h = (h * 31 + ord(ch)) % len(DIALOG_HUES)
|
||||||
|
return DIALOG_HUES[h]
|
||||||
|
|
||||||
|
|
||||||
|
tg = TelegramManager()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""SSE-брокер событий.
|
||||||
|
|
||||||
|
Сервер рассылает события подключённым браузерам:
|
||||||
|
new_lead, lead_updated, toast, reminder_due, project_updated, system_status.
|
||||||
|
Поток однонаправленный, по ТЗ — Server-Sent Events с автопереподключением.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class Broker:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._subscribers: set[asyncio.Queue] = set()
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def subscribe(self) -> asyncio.Queue:
|
||||||
|
q: asyncio.Queue = asyncio.Queue(maxsize=200)
|
||||||
|
async with self._lock:
|
||||||
|
self._subscribers.add(q)
|
||||||
|
return q
|
||||||
|
|
||||||
|
async def unsubscribe(self, q: asyncio.Queue) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
self._subscribers.discard(q)
|
||||||
|
|
||||||
|
async def publish(self, event_type: str, data: Any) -> None:
|
||||||
|
payload = f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
async with self._lock:
|
||||||
|
subs = list(self._subscribers)
|
||||||
|
for q in subs:
|
||||||
|
try:
|
||||||
|
q.put_nowait(payload)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
# при переполнении сбрасываем очередь подписчика — браузер переподключится
|
||||||
|
try:
|
||||||
|
q.get_nowait()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
q.put_nowait(payload)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def publish_toast(self, text: str, icon: str = "check") -> None:
|
||||||
|
await self.publish("toast", {"text": text, "icon": icon})
|
||||||
|
|
||||||
|
|
||||||
|
broker = Broker()
|
||||||
@@ -0,0 +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")
|
||||||
@@ -0,0 +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)
|
||||||
@@ -0,0 +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()
|
||||||
@@ -0,0 +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()
|
||||||
@@ -0,0 +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
|
||||||
@@ -0,0 +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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
data
|
||||||
@@ -0,0 +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"]
|
||||||
@@ -0,0 +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", [])
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi==0.115.12
|
||||||
|
uvicorn[standard]==0.34.2
|
||||||
|
duckdb==1.2.1
|
||||||
@@ -0,0 +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()
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
#
|
||||||
|
|
||||||
|
ТЕХНИЧЕСКОЕ ЗАДАНИЕ
|
||||||
|
|
||||||
|
## **Система мониторинга, AI-классификации и управления IT-лидами (LeadRadar) | Спецификация V1.2 (Production)**
|
||||||
|
|
||||||
|
**Архитектура:** Self-Hosted / High-Perf
|
||||||
|
**База данных:** DuckDB (Embedded OLAP)
|
||||||
|
**AI Engine:** DeepSeek v4 Flash
|
||||||
|
**Интерфейс:** SPA / Custom Dashboard
|
||||||
|
|
||||||
|
## **1\. Введение и назначение системы**
|
||||||
|
|
||||||
|
LeadRadar — это автономная программная платформа для автоматического перехвата, аналитической классификации, дедупликации и трекинга заявок/лидов **в любой сфере** (IT и не-IT: вакансии и найм, разовые заказы и услуги, товары, недвижимость и т.д.). Система разворачивается на выделенном сервере и полностью управляется через отзывчивый веб\-интерфейс, исключая необходимость взаимодействия через командную строку.
|
||||||
|
|
||||||
|
## **2\. Анализ применимости базы данных**
|
||||||
|
|
||||||
|
Выбранная СУБД (DuckDB) идеально подходит для поставленной задачи, сочетая преимущества встраиваемой архитектуры (отсутствие внешних зависимостей, работа в одном файле) и колоночной аналитики:
|
||||||
|
|
||||||
|
> * **Скорость аналитики:** мгновенная фильтрация по десяткам тысяч записей, стеку технологий, временным диапазонам и доскам с векторизованным выполнением запросов.
|
||||||
|
> * **Нативная поддержка структурированных данных:** списки технологий и параметров хранятся без деградации скорости доступа.
|
||||||
|
> * **Полнотекстовый поиск (FTS):** встроенное расширение позволяет мгновенно искать по ключевым словам и фразам во всей истории сообщений без использования сторонних поисковых движков.
|
||||||
|
> * **Легковесность:** потребляет минимум системных ресурсов и не требует администрирования отдельного сервиса СУБД.
|
||||||
|
|
||||||
|
## **3\. Выбор оптимального технологического стека**
|
||||||
|
|
||||||
|
| Уровень | Технология | Обоснование |
|
||||||
|
| :---- | :---- | :---- |
|
||||||
|
| Backend Runtime | Python 3.12+ / FastAPI | Асинхронное ядро, минимальные накладные расходы, нативная поддержка реалтайм-событий. |
|
||||||
|
| Database Core | DuckDB | Встраиваемая колоночная СУБД, быстрые агрегации, векторные выборки, работа в одном файле. |
|
||||||
|
| Telegram Engine | Telethon (MTProto API) | Поддержка Client API, веб\-авторизация, полный доступ к диалогам и истории. |
|
||||||
|
| AI Classifier | DeepSeek v4 Flash | Высокая точность в IT-терминологии, оптимальная себестоимость анализа, строгая структуризация ответов. |
|
||||||
|
| Frontend Stack | Vue 3 \+ Tailwind CSS | Максимальная кастомизация, высокая скорость рендеринга, реактивность интерфейса. |
|
||||||
|
| Realtime Transport | Server-Sent Events (SSE) | Однонаправленный поток событий, мгновенные пуш-уведомления, автоматическое переподключение. |
|
||||||
|
|
||||||
|
## **4\. Архитектура и функциональные требования**
|
||||||
|
|
||||||
|
### **4.1. Авторизация в дашборд**
|
||||||
|
|
||||||
|
> * Вход в веб-интерфейс по логину и паролю; учетные данные по умолчанию: **admin / admin**.
|
||||||
|
> * Смена пароля — через интерфейс настроек.
|
||||||
|
> * Серверная сессия действительна **30 дней** (месяц): повторная авторизация в течение этого срока не требуется.
|
||||||
|
|
||||||
|
### **4.2. Авторизация Telegram через Web-интерфейс**
|
||||||
|
|
||||||
|
> * Ключи Telegram API (**api_id / api_hash**) вводятся один раз в интерфейсе настроек, а не через переменные окружения.
|
||||||
|
> * Ввод номера телефона в модальном окне интерфейса.
|
||||||
|
> * Поддержка ввода кода верификации и облачного пароля (2FA).
|
||||||
|
> * Опциональная генерация QR-кода для быстрой авторизации камерой смартфона.
|
||||||
|
> * Надежное сохранение сессии в файловой системе (без необходимости повторных входов).
|
||||||
|
> * Индикация статуса подключения и возможных ошибок.
|
||||||
|
|
||||||
|
### **4.3. Менеджер каналов и диалогов**
|
||||||
|
|
||||||
|
> * Отдельный экран или боковая панель со списком всех каналов и групп.
|
||||||
|
> * Отображение метаданных: название, аватар, системное имя, тип (чат/канал).
|
||||||
|
> * Быстрый предпросмотр последних сообщений в один клик без перехода в приложение мессенджера.
|
||||||
|
> * Индивидуальный переключатель для каждого источника: режим активного мониторинга или игнорирования.
|
||||||
|
> * **Кнопка «Включить все» / «Выключить все»** в шапке списка каналов — массовое переключение мониторинга для всех источников разом. При первом включении канала (и при массовом включении) выполняется последовательный разбор последних 10 сообщений с паузами (анти-бан), фоново, без блокировки UI.
|
||||||
|
> * **Кнопка «Перечитать»** в шапке списка каналов — ручная догонялка: перечитывает последние ~10 сообщений **всех включённых** каналов в фоне (с паузами анти-бан), даже если канал уже разобран ранее. Повторные карточки не создаются (защита дедупликации по тексту). После этого система реагирует только на новые сообщения в реальном времени; страховочный цикл (~30 с) догоняет потерянные события (рестарт/разрыв соединения).
|
||||||
|
|
||||||
|
> * **Автосинхронизация списка:** при входе на вкладку и фоновым циклом актуальный список чатов/каналов сверяется с аккаунтом Telegram — новые появляются, переименованные обновляются, покинутые/удалённые исчезают (мониторинг по ним прекращается).
|
||||||
|
> * **Новые чаты:** при включённой настройке «новые чаты — сразу в мониторинг» (`autoMonitorNew`) любой появившийся чат включается в мониторинг автоматически; при выключенной — появляется отключённым, пользователь включает вручную.
|
||||||
|
> * **Прочитанность:** полученные/перечитанные сообщения сразу помечаются прочитанными в Telegram (read-ack в realtime, при «Перечитать» и ручном предпросмотре) — в других клиентах они не висят «новыми».
|
||||||
|
|
||||||
|
> * В пункте меню «Каналы» выводится счётчик числа каналов, находящихся в мониторинге.
|
||||||
|
|
||||||
|
### **4.4. Кастомные колонки и стилизация**
|
||||||
|
|
||||||
|
> * **По умолчанию колонок в системе нет.** Колонки создаются пользователем (кнопка «Новая колонка») либо предлагаются ИИ по результатам анализа «Неразобранного».
|
||||||
|
> * **Создание и настройка — единый диалог «Новая колонка / Настройки колонки»**: название, **описание колонки** (для пользователя и подсказки ИИ/ML), цветовой акцент и набор фильтров. Диалог открывается сразу при создании и в любой момент из меню колонки (⋮).
|
||||||
|
> * **Колонка — это не отдельный навык, а смысловой набор фильтров** (каждый опционален, набор можно менять в любой момент): направление/тема задач, ключевые технологии и стек, ключевые слова, **грейд/уровень** (junior/middle/senior/lead/…, с распознаванием синонимов: джун/мидл/сеньор/mid и т.п.), бюджетный диапазон с валютой (от–до). Пустые группы не участвуют. Режим комбинирования — «все условия» или «любое из условий» («любое» = хотя бы одна **заданная** (непустая) группа совпала; пустые группы результат не искажают). Сами правила **не раскладывают входящие «словарно» до ИИ** (словесный матч не понимает смысл и ловит ложные совпадения из дайджестов и футеров): они служат (1) критериями для ИИ-классификатора, (2) **проверкой-страховкой на бэкенде** и (3) обоснованием «почему карточка в колонке» (см. п. 5.4). Колонка, у которой заданы активные фильтры, **не принимает карточки, не прошедшие её правила, ни от ИИ, ни от ML** (страховка на бэкенде) — такая карточка остаётся в «Неразобранном».
|
||||||
|
> * **ИИ-предложения** (статус `suggested`) появляются в списке колонок с пометкой «ИИ» и **обоснованием**: по какому направлению, стеку, грейду и ключевым словам собрана, сколько похожих карточек в выборке. Пользователь открывает колонку, просматривает карточки и решает: **принять** (колонка становится обычной — можно переименовать, дополнить описание и поправить фильтры) или **отклонить** (карточки возвращаются в «Неразобранное»).
|
||||||
|
> * **Причина попадания в колонку:** при совпадении с фильтром у карточки фиксируется, **какие именно критерии совпали** — группа фильтра (направление/слова/стек/грейд/бюджет) и сами совпавшие термины. Совпадение ищется по всему тексту сообщения, включая списки стека, требований и «будет плюсом» (и наоборот: термин карточки ищется по всем группам фильтра). В подробном виде карточки есть блок **«Попала в колонку по фильтру — совпало»** с перечнем совпавших критериев. При ручном переносе карточки совпадения пересчитываются для новой колонки.
|
||||||
|
> * Для каждой колонки настраиваются: название, описание, цветовой акцент, ширина, сворачивание в виджет, **набор фильтров попадания карточек**, индивидуальный системный промпт, видимость полей (бюджет, стек, контакты, источник). Описание и набор фильтров колонки передаются в промпт ИИ-классификатора при выборе колонки.
|
||||||
|
> * **Любую колонку можно удалить** — находящиеся в ней карточки возвращаются в «Неразобранное» (с пометкой новых).
|
||||||
|
|
||||||
|
### **4.5. Буфер «Неклассифицированное» (Inbox)**
|
||||||
|
|
||||||
|
> * Изолированный раздел для входящих заявок, не подошедших под критерии активных досок.
|
||||||
|
> * Счетчик непрочитанных элементов с визуальным уведомлением.
|
||||||
|
> * Удобный ручной перенос лида на нужную доску в один клик.
|
||||||
|
> * Функция пакетной повторной классификации через нейросеть.
|
||||||
|
|
||||||
|
### **4.6. Настройки ключей и интеграций**
|
||||||
|
|
||||||
|
> * Все API-ключи (**Telegram api_id/api_hash**, **ключи AI-провайдеров**) задаются и заменяются через веб-интерфейс.
|
||||||
|
> * Секреты не хранятся в переменных окружения и конфигурационных файлах (по договорённости в env исключения — ключ шифрования БД и креды MinIO, см. п. 8).
|
||||||
|
> * Сохраненные ключи не отображаются в открытом виде: доступны только факт наличия и замена значения.
|
||||||
|
> * Настройка целевой валюты отображения и источника курсов (вкладка «Валюта и курсы»).
|
||||||
|
> * Источник курсов — **ЦБ РФ (cbr.ru)**: официальные курсы к рублю, запрос выполняется 4 раза в сутки (каждые 6 часов); до подключения сервиса используются мок-курсы.
|
||||||
|
> * AI-классификатор возвращает бюджет как число или диапазон с указанием валюты (USD/EUR/RUB/USDT и др.).
|
||||||
|
> * AI-классификатор работает через **выбираемого провайдера — активен только один**: DeepSeek, OpenAI, OpenRouter, Anthropic Claude или локальный OpenAI-совместимый сервер (Ollama, LM Studio, vLLM и т.п.). Для каждого провайдера настраиваются base URL, модель и API-ключ (локальным ключ не нужен); системный промпт общий.
|
||||||
|
|
||||||
|
### **4.7. Дашборд: рабочие колонки и карточки**
|
||||||
|
|
||||||
|
> * Основной рабочий экран — **дашборд** из колонок (как пользовательских, так и ИИ-предложений, см. п. 4.4); каждая колонка имеет свой цвет-акцент. По умолчанию колонок нет — создаются пользователем или предлагаются ИИ.
|
||||||
|
> * Служебные колонки: **«Неразобранное»** (буфер Inbox, см. п. 4.5) и **«Корзина»** (отложенное удаление карточек).
|
||||||
|
> * Колонки прокручиваются по вертикали независимо; при нехватке ширины область дашборда прокручивается горизонтально.
|
||||||
|
> * Пользователь настраивает рабочее пространство: **количество колонок, их ширину и порядок** (перетаскиванием), отображение на **пол-экрана или весь экран**.
|
||||||
|
> * Любую колонку можно свернуть в **виджет-счетчик** (компактная плашка с числом ожидающих карточек) и развернуть обратно в один клик.
|
||||||
|
> * **Карточки интерактивные**: быстрые действия без открытия — копирование контакта, перенос в другую колонку, добавление комментария, перемещение в корзину; подробное описание карточки открывается по центру экрана в модальной панели.
|
||||||
|
> * На карточке показывается **структурированная суть «О заявке»** — не голый текст исходника (он виден только в подробном виде под спойлером). «О заявке» у всех карточек собирается из **одинаковых логических блоков единой структуры** (Компания → Формат → О задаче → Требования → Будет плюсом → Условия), длина блоков разная; недостающие блоки пропускаются. Как заполнять поля блока задаёт отдельный **«Промпт структуры карточки»** (см. п. 5.6), а единый вид текста гарантирует серверная сборка из структурированных полей.
|
||||||
|
> * **Заголовок карточки** — короткий (4–9 слов), без эмодзи/хэштегов и **без ссылок** (markdown-ссылки и URL вычищаются и при сохранении, и при отображении), визуально обрезается до двух строк.
|
||||||
|
> * **Текст в карточках переносится по словам** (длинные ссылки/токены не выходят за границы карточки): включён перенос строк и сохранение структуры (список параметров от ИИ не схлопывается).
|
||||||
|
> * На карточке источник (название канала) не показывается — он виден только в подробном описании лида.
|
||||||
|
> * Бюджет может быть числом или диапазоном «от–до» в любой валюте.
|
||||||
|
> * Тип заявки (найм/занятость или разовая сделка/заказ) определяется **по контексту ИИ** (ML учится этому же); маркерная эвристика без ИИ не является решающей. Подписи типов настраиваются в «Сфере и ключах» (по умолчанию — **«вакансия»** и **«фриланс»**); бейдж типа выводится, когда тип подтверждён по контексту.
|
||||||
|
> * Пересчёт сумм в целевую валюту (по умолчанию — рубли) выполняется один раз — в момент поступления заявки, по курсу на тот день; исходная сумма в первоначальной валюте всегда сохраняется и отображается первой (в карточке и в подробном описании).
|
||||||
|
> * Служебная колонка **«Архив»**: карточки, находящиеся в системе дольше настраиваемого срока (интервал настройки 1–30 дней), автоматически переносятся в архив.
|
||||||
|
> * **Отсев устаревших на входе:** сообщение, опубликованное раньше срока до архива (например, канал молчал, и «Перечитать» подтянуло старые посты), в систему **не попадает вообще** — ни карточкой, ни в архив, ни в корзину. Работает, когда автоархив включён: возраст сообщения считается от даты публикации в канале до текущего момента.
|
||||||
|
> * **Архив** очищается автоматически через 90 дней после помещения, **корзина** — каждые 7 дней. Возврат карточек из архива и корзины возможен только на канбан (доски / «Неразобранное»), пока карточка не очищена автоматически. Помимо автоправил, **корзину и архив можно очистить вручную полностью** (безвозвратно, с подтверждением).
|
||||||
|
> * Карточки с основного канбана можно «взять в работу» — они попадают в отдельный дашборд «Выбранные» (см. п. 4.8).
|
||||||
|
> * К карточке можно добавить **комментарий** (внутренняя заметка), он сохраняется вместе с лидом.
|
||||||
|
> * Действия пользователя (ручной перенос, корзина, возврат, перенос в «Выбранные») фиксируются как **обучающие примеры и всегда передаются в ML-модель** (обучение идёт постоянно, независимо от того, используется ли ML в пайплайне). Дополнительно ML учится на каждом попадании карточки в колонку по правилам. ИИ-предложения колонок анализируются и учитываются пользователем до превращения в постоянные.
|
||||||
|
|
||||||
|
### **4.8. «Выбранные» — второй дашборд (проектный канбан)**
|
||||||
|
|
||||||
|
> * Отдельный экран для отобранных лидов: свой канбан с перетаскиванием карточек по стадиям.
|
||||||
|
> * Стадии по умолчанию: **Запланировано → Отклик → Согласование → В работе → Проверка → Готово**, плюс **Отложено** (пауза) и финальные статусы **«Выполнено»** и **«Отклонено»**.
|
||||||
|
> * Карточка попадает сюда кнопкой «Взять в работу» из лида на канбане, либо **создается вручную** кнопкой «Новая карточка» с тем же набором полей — такие карточки помечаются как **«локальные»** (созданы вручную, без лида-источника).
|
||||||
|
> * Карточка не может находиться одновременно на дашборде и в «Выбранных»: при взятии в работу лид **уходит с дашборда безвозвратно** (в архиве, корзине и поиске не участвует) — обратно на дашборд он не возвращается.
|
||||||
|
> * У проектной карточки ведется **история движения**: с момента добавления в «Выбранные» фиксируется каждая смена стадии (статус, дата и время); для локальных карточек первая запись — «Создана локально». История показывается в подробном виде под спойлером «История движения».
|
||||||
|
> * У проектной карточки редактируются: **сумма (число или диапазон), валюта, стек, контакты, комментарии**; можно **прикреплять ссылки и ТЗ**.
|
||||||
|
> * К карточке прикрепляются **файлы — медиа и документы**; система автоматически определяет тип файла (по MIME и расширению). На самой карточке значками показывается количество прикрепленных **файлов** и **ссылок**.
|
||||||
|
> * Файлы хранятся в объектном хранилище **MinIO (S3-совместимое, креды в env)**: в БД — метаданные и ключ объекта. Если MinIO не настроен (локальный запуск без docker-compose) — те же ключи сохраняются в локальную папку `data/attachments`, API и карточки не меняются. Тип файла определяется автоматически (MIME + расширение): изображение/видео/аудио/архив/документ.
|
||||||
|
> * Карточки «Выбранных» **не попадают в архив и корзину** — у дашборда собственные финальные сущности «Выполнено» и «Отклонено». Стадию «Отклонено» можно **очистить полностью вручную** (безвозвратно, с подтверждением).
|
||||||
|
> * Стадия «Отложено» поддерживает **напоминания**: при переносе карточки открывается окно настройки — напомнить **через N дней (1–30)** или **в конкретную дату (календарь)** и в какое время; по наступлению срока всплывает оповещение с действиями «Открыть карточку / Позже / Снять».
|
||||||
|
> * Общий переключатель напоминаний вынесен в **настройки уведомлений**: если он выключен, окно настройки при переносе в «Отложено» не показывается и уже установленные напоминания не срабатывают. Список активных напоминаний виден там же.
|
||||||
|
> * Напоминание автоматически снимается, когда карточка покидает стадию «Отложено».
|
||||||
|
|
||||||
|
### **4.9. Поиск и подключение каналов (Discovery)**
|
||||||
|
|
||||||
|
> * Подвкладка **«Поиск»** на экране «Каналы» — поиск и подключение новых источников (каналы, группы, форумы), в которых мы ещё **не состоим**. Система ищет кандидатов, оценивает их (метаданные, язык, содержимое) и показывает человеку список «на рассмотрение».
|
||||||
|
> * **Задача поиска** — конфиг с описанием цели (что ищем): пользователь описывает цель → система генерирует **поисковые ключи ИИ** (редактируются перед стартом; запуск возможен только после их подтверждения) → по ключам выполняется **каскад фильтров** по нарастающей стоимости (при первом «нет» источник пропускается): поиск и дедупликация кандидатов → глобальный фильтр «мы не состоим» → число участников (минимум; 0 = не важно) → язык (`ru`/`any`) → оценка содержимого выборки сообщений. Задач может быть несколько.
|
||||||
|
> * **Глобальное правило «мы не состоим» — безусловное, для всех задач:** источник, в котором мы уже состоим (вступили/мониторим), находящийся в чёрном списке или уже обрабатываемый/вступивший в другой задаче, отбрасывается сразу и на любом этапе (поиск → оценка → вступление) — независимо от запроса, ключей и настроек задачи. Проверка повторяется непосредственно перед вступлением (между оценкой и join'ом источник мог быть добавлен вручную).
|
||||||
|
> * **Метки кандидатов** — человекочитаемые пометки: «закрытая группа/канал», «форум», «не прочитано», «участники не подтверждены», «язык не подтверждён», «мало сообщений», «есть проходные темы». Метки «не подтверждено» — не ошибка и не пропуск, а сигнал человеку на экране рассмотрения.
|
||||||
|
> * **Оценка содержимого:** сообщение проходит те же правила, что в основном пайплайне (этап 1 → ML → ИИ), но с **профилем задачи** (описание + ключи задачи), без создания карточек/очереди/обучения ML; при выключенном ИИ — локальный разбор/ML. Каналы и открытые группы: читается выборка до `sampleSize` последних сообщений (по умолчанию 10), доля подходящих ≥ порога (по умолчанию 40%) → «на рассмотрение»; открытая группа без чтения → «на рассмотрение» с меткой.
|
||||||
|
> * **Оценка по темам (форумы):** группа раскладывается по темам (`reply_to_top_id`), выборка читается по активным темам и оценивается **по темам** («тема: подходит X из N»); группа подходит при ≥1 проходной теме, в превью — список тем с пометками проходная/нет (имена тем подставляются сниппетом первого сообщения, если API не отдаёт их без членства). Для оценки нужно ≥3 содержательных сообщений в выборке; если их меньше — кандидат идёт «на рассмотрение» с меткой «мало сообщений». Закрытые группы (история скрыта) — сразу «на рассмотрение» с меткой «закрытая группа/канал».
|
||||||
|
> * **«На рассмотрение» и действия человека:** у кандидата показываются тип, число участников, метки, соответствие «подходит X из N», **почему подошло** (перечень подходящих сообщений/тем, как блок «попала по фильтру») и превью. Действия: **«Вступить и мониторить»** — вступление, добавление в список каналов с `monitor=1` и догон последних ~10 сообщений; **«Отклонить»** — источник уходит в **чёрный список** (исключается из поиска всех задач; снимается вручную); для закрытых групп вместо авто-вступления — кнопка-ссылка `t.me/<username>`, факт вступления система замечает при синхронизации диалогов и предлагает добавить источник в мониторинг.
|
||||||
|
> * **План задач и правило создания:** у задачи задаётся план вступлений 1–50; **сумма планов всех активных задач (статус не `done/failed`) ≤ суточного лимита вступлений** (по умолчанию 50) — задача с планом 50 не даёт создать другую, план 25 оставляет не более 25. У активной задачи план нельзя увеличить сверх свободного бюджета.
|
||||||
|
> * **Авто-вступление и квоты:** авто-режим включается на задачу (`autoJoin`) — подходящие кандидаты вступают сами, по одному действию, со **случайной паузой 50–70 с**. Суточный лимит — **50 авто-вступлений, общий на все задачи** (считаются только автоматические); **ручные вступления — без квот и ограничений**. Задача «выполнена» при достижении плана; при упоре в общий суточный бюджет авто-режим продолжает на следующий день (новый суточный бюджет).
|
||||||
|
> * **Анти-бан (BanGuard):** единый менеджер квот и пауз для всех действий поиска и для всех задач; поиск/чтение — мягкие паузы (единицы секунд + джиттер). При `FloodWaitError` — пауза по секундам из ответа + запас, авто-вступления останавливаются до следующего дня; есть общий **«стоп-кран»** — ручная пауза всего discovery. Лимит, интервалы и размеры выборки — настройки в UI.
|
||||||
|
|
||||||
|
## **5. Спецификация пайплайна обработки данных**
|
||||||
|
|
||||||
|
> 1. **Перехват события и очередь:** Система фиксирует новые сообщения мониторящихся каналов в реальном времени и кладёт их в **очередь обработки** (на диске); очередь разбирается фоновым воркером. В момент получения сообщению присваивается строгая метка локального серверного времени и сохраняется идентификатор исходного сообщения (для «открыть исходник»).
|
||||||
|
> 2. **Этап 1 — без ИИ:** Отбрасываются короткие неинформативные тексты, сообщения со стоп-фразами и (настраиваемо) **резюме соискателей**: если включён тумблер «Отсев резюме» (`blockResumes`), текст с любым маркером резюме/соискателя (`resumeMarkers`, список редактируется в «Сфере и ключах») удаляется сразу — **до правил колонок и ИИ** (раньше резюме могло залететь в колонку по стеку, минуя ИИ-фильтр). Слово «резюме» имеет контекстный guard: если перед ним в тексте есть маркер найма (например, «…вакансия…, присылайте резюме») — это объявление работодателя, оно не блокируется. Тумблер выключен — резюме собираются как обычные лиды (полезно, когда система настроена на поиск сотрудников). Дополнительно фильтр «собирать только найм / только разовые заказы» (`wantedType`) и опциональные фильтры «не создавать карточку без суммы» (отдельно для найма и разовых заказов: `budgetRequiredHire`/`budgetRequiredOrder`) также отрабатывают на этапе 1/без ИИ. **Отсев устаревших:** если автоархив включён и сообщение опубликовано раньше, чем за `archiveAfterDays` дней до текущего момента, оно удаляется сразу (в БД не попадает — ни карточкой, ни в архив/корзину). Не прошедшее карточкой не становится: отброс фиксируется в мониторинге «Отсев» с причиной и конкретным словом/фразой (см. п. 5.12) и живёт там до автоочистки (3 суток).
|
||||||
|
> 3. **Дедупликация:** По нормализованному тексту (регистр, спецсимволы) вычисляется идентификатор; повторное сообщение игнорируется.
|
||||||
|
> 4. **Правила колонок — не «словарный» роутер до ИИ:** смысловые колонки НЕ назначаются словарным матчем до ML/ИИ — он не понимает смысл и ловит ложные совпадения (дайджест из нескольких ролей, «desktop» в URL/футере и т.п.). Правила (направление, стек, слова, грейд, бюджет; режим «все/любое», исключения) используются как: (1) критерии, передаваемые ИИ-классификатору при выборе колонки; (2) страховка-проверка после выбора ИИ/ML (карточка попадает в колонку с активными правилами только если текст прошёл их); (3) обоснование «почему карточка здесь» (блок «Попала по фильтру — совпало»). Во всех «быстрых» путях (ML без ИИ / ИИ выключен) карточка **структурируется локальным разбором без ИИ**: заголовок (первая строка без markdown-мусора), стек/грейд/контакты/бюджет по меткам вида «Стек: …» и регулярным выражениям (суммы и валюты), признак вакансии.
|
||||||
|
> 5. **ML-слой (обучаемый, отдельный сервис):** Между стоп-листом и ИИ работает локальная ML-модель, обучаемая **на реальных действиях пользователя** (перенос на доску, корзина, возврат, возврат из отсева) и на решениях ИИ. Если ML уверен — решает сам (спам уходит в отсев, колонка назначается) без обращения к ИИ. **ML не назначает колонки, у которых заданы активные правила, и ИИ-предложения** — такие колонки наполняются ИИ (с проверкой правил) или ручным выбором пользователя, чтобы нерелевантное не попадало в «отфильтрованные» колонки. Использование ML в пайплайне включается настройкой; **обучение идёт всегда**.
|
||||||
|
> 5.1 **Полный выключатель ИИ** (`aiEnabled`, вкладка настроек «AI-классификатор»): при выключенном ИИ карточки собирает локальный разбор без провайдера, смысловую раскладку по колонкам берёт на себя ML (когда готова). ML в этом режиме обучается только вручную — действиями пользователя: переносы карточек, корзина, возвраты, возврат ошибочного отсева, ручная разметка в «ML-лаборатории». Кнопки «Предложить колонки» и «Переклассифицировать» при выключенном ИИ недоступны (с понятным сообщением).
|
||||||
|
> 5.2 **Самооценка ML (индикатор на вкладке ИИ):** каждое реальное действие пользователя (delta=1.0: перенос на доску, корзина, возврат, ручная разметка) сверяется с текущим предсказанием модели до обучения на нём; если модель уверена — фиксируется «верно/ошибка». В статусе ML отдаётся окно последних 50 подтверждённых решений (верно/всего/точность). На вкладке «AI-классификатор» показывается прогресс проверки, а когда накоплено ≥ 50 решений с точностью ≥ 90% — зелёная подсказка **«ML справляется — ИИ можно отключить»** с кнопкой выключения ИИ.
|
||||||
|
> 6. **ИИ-этап (если ML не уверен или выключен):** Сообщение проходит ИИ-фильтр с отдельным промптом (не пропускать простые сообщения, рекламу, скам и прочее; этап отключаемый), затем — ИИ-классификацию. Классификатору передаётся карта **только принятых колонок вместе с их критериями** (направление, стек, ключевые слова, грейд, бюджет, описание); колонка выбирается по совпадению критериев, а не по названию; если ни одна не подходит — `null` (в «Неразобранное»). Даже если ИИ/ML вернули колонку с активными правилами, бэкенд **проверяет текст правилами колонки** и при несоответствии оставляет карточку в «Неразобранном» (страховка от засорения отфильтрованных колонок). Классификатор возвращает структурированную карточку (заголовок, поля блока «О заявке» — company/format/task/requirements/plus/conditions, стек, бюджет с валютой, контакты, признак вакансии); при сбое ИИ карточка структурируется локальным разбором и не теряется. **Ручная переклассификация «Неразобранного» повторяет конвейер ИИ** с теми же страховками правил и обучающими сигналами для ML.
|
||||||
|
> 6.1 **Промпт структуры карточки** (`cardPrompt`): отдельный настраиваемый промпт (вкладка AI-классификатора), который задаёт, какие поля блока «О заявке» и как заполнять (компания, формат, задача, требования, «будет плюсом», условия; для не-IT сфер «стек/требования» = материалы, услуги, навыки, инструменты). Добавляется к промпту классификатора отдельным блоком; текст «О заявке» сервер собирает из этих полей сам — поэтому у всех карточек одинаковая структура и разная длина. Поля заполняются только фактами из сообщения.
|
||||||
|
> 6.2 **Бюджет карточки (fallback и распознавание):** если ИИ не выделил бюджет отдельным полем, но сумма с валютой есть в исходнике или в структурированной «О заявке» (часто уходит в «Условия») — она добирается автоматически (тот же источник, что и фильтр «не создавать без суммы»). Распознаются «2к»/«1.5к»/«$2к», «2000р/₽/руб», диапазоны «от X до Y»/«X–Y», «до X»; названия валют нормализуются (руб/рублей/₽, долларов/$/бакс, евро и т.п. → коды USD/EUR/RUB/…). Если ИИ вернул бюджет строкой с суффиксом («2к») или «р/₽» — значение тоже нормализуется.
|
||||||
|
> * **Стек — всегда список строк**, а не строка: ответы ИИ нормализуются (строка «Java, Kotlin» превращается в массив), названия сохраняются слитно (`.NET`, `C#`, `Node.js`), одиночные символы отбрасываются.
|
||||||
|
> * **Контакты** собираются списком и квалифицируются (см. п. 6 UI): телефон, email, @username, LinkedIn, WhatsApp, сайт; боты, каналы/группы и ссылки на посты/вакансии отбрасываются; при отсутствии в ответе ИИ контакты добываются из текста сообщения.
|
||||||
|
> * У каждой карточки из сообщения есть **переход к исходному сообщению**: в подробном виде — явная кнопка «Открыть исходник» (ссылка на сообщение в Telegram по сохранённым id диалога и сообщения), текст исходника — под спойлером.
|
||||||
|
> 7. **ИИ-предложения колонок:** По накопленным карточкам «Неразобранного» (от ~6) ИИ периодически (кулдаун ~20 мин) или по кнопке предлагает 2–4 смысловые колонки с обоснованием и критериями; карточки раскладываются по предложениям, пользователь принимает решение (см. п. 4.4).
|
||||||
|
> 8. **Запись и триггер уведомлений:** Сохранение результата в базу (в БД оседает только прошедшее фильтры; исходные сообщения карточек хранятся с идентификатором для «исходника»). Сервер инициирует событие, фронтенд плавно добавляет карточку в верх соответствующей колонки (либо в «Неразобранное»), с визуальной и звуковой индикацией.
|
||||||
|
> 9. **Обучение на действиях пользователя:** Действия с карточкой (ручной перенос, корзина, возврат, взятие в работу) всегда записываются в очередь обучения ML и учитываются при последующих обработках — система со временем точнее раскладывает похожие заявки или оставляет их в «Неразобранном».
|
||||||
|
> 10. **Универсальность (не только IT) и настройка «Сфера и ключи»:** Система работает для любой сферы — разработка, дизайн, недвижимость, стройка/кровля, услуги и т.п. Распознающие паттерны **не зашиты в код**: в настройках (вкладка «Сфера и ключи») задаются:
|
||||||
|
> * **Описание сферы** (`domainDescription`) — что для пользователя является заявкой/лидом;
|
||||||
|
> * **Общие ключевые слова-маркеры** (`domainKeywords`) — слова/фразы, по которым сообщение опознаётся как заявка; кнопка **«Предложить ИИ»** анализирует накопленные карточки и предлагает список ключей (пользователь правит и сохраняет);
|
||||||
|
> * Маркеры «найма» (`hireMarkers`), уровней (`levelTerms`) и резюме/соискателей (`resumeMarkers`, тумблер `blockResumes`) — используются этапом 1 и локальным разбором без ИИ; дефолты покрывают IT-найм, но редактируются под любую сферу и язык.
|
||||||
|
> Промпты ИИ (классификатор, фильтр, предложение колонок/ключей) содержат плейсхолдеры `{domain}` и `{keywords}`, которые подставляются из этих настроек при каждом вызове — поэтому фильтрация спама и смысловые поля (заголовок, «о чём заявка», цена, контакты: телефон/@username/email) настраиваются под конкретный бизнес без правки кода.
|
||||||
|
> 11. **Библиотека промптов и «Мои промпты»:** В настройках AI есть **библиотека готовых промптов** (на русском) для разных специальностей, разбитая на категории (IT/разработка, дизайн, недвижимость, стройка/ремонт, бытовые услуги, красота/здоровье, обучение и базовые) с **поиском по списку**. В «Базовых» есть универсальные **варианты поведения**: нейтральный, строгий (только явные заявки с деталями), гибкий (ничего не упускать), **«только найм (вакансии)»** и **«только заказы (услуги)»**. Шаблон можно посмотреть (превью текста), изменить под себя и **применить в редактор** (активным становится только после кнопки «Сохранить промпт» — текущий сохранённый промпт не меняется автоматически). Любой текст можно **сохранить в личный раздел «Мои промпты»** с собственным названием и описанием; оттуда промпт можно снова применить или удалить. Кнопка «Базовый промпт» возвращает универсальный дефолт.
|
||||||
|
> 12. **Мониторинг обработки — вкладка «Обработка» (отдельный экран).** Прозрачность пайплайна: что сейчас в очереди, что и почему отсеяно.
|
||||||
|
> * **Очередь** — сырые сообщения из каналов, ожидающие разбора: этап 1 (стоп-лист/длина, без ИИ) и ожидающие ИИ; список живой (обновляется по SSE + поллингом), у каждого сообщения — канал, статус этапа и время в очереди. Экран показывает, что воркер делает прямо сейчас (полезно при «Перечитать»/первичном разборе).
|
||||||
|
> * **Отсев** — сообщения, не прошедшие любой этап, с пометкой **почему**: этап и причина (стоп-фраза, резюме соискателя, тип заявки, нет суммы, устарело, ML, ИИ-фильтр/спам, повтор), **конкретное слово/фраза** стоп-списка (если отсев по нему) и **чьё решение** — правила (без ИИ) / ML / ИИ / система. Повторное отбрасывание того же сообщения (перечитывание каналов) обновляет запись, а не плодит дубликаты.
|
||||||
|
> * **Полнотекстовый поиск по отсеву** (FTS + LIKE по тексту, причине, слову и каналу), пагинация «показать ещё».
|
||||||
|
> * **Очистка отсева:** вручную (кнопка «Очистить», с подтверждением; можно удалить отдельную запись) и автоматически — **раз в 3 суток** записи старше 3 дней удаляются безвозвратно.
|
||||||
|
> * В боковом меню у пункта «Обработка» показывается **только счётчик сообщений в очереди**; отсев виден внутри вкладки.
|
||||||
|
> * **Возврат из отсева в обработку:** у записи отсева есть действие «Вернуть в обработку» (кроме «повторов» — карточка уже существует). Открывается окно с полем «Почему обработано некорректно? (необязательно)». Возвращённое сообщение снова кладётся в очередь с пометкой force: **причины отсева для него игнорируются** (стоп-лист/резюме/тип/без суммы/устарело, ML и ИИ-отсев «спам») — оно проходит ML/ИИ и создаёт карточку; если ИИ снова скажет «спам», вердикт отменяется (карточка создаётся). ML обучается на действии (снятие веса «спама» за текст), а решение ИИ по возвращённому сообщению снова учит ML. Запись в отсеве не удаляется, а помечается «возвращено» с причиной (аудит); автоочистка через 3 суток действует как обычно.
|
||||||
|
> * Сброс карточек/прогона (admin wipe / clear-cards) очищает и отсев, чтобы повторные тестовые прогоны не смешивались со старой историей.
|
||||||
|
|
||||||
|
## **6\. Требования к UI/UX и дизайну**
|
||||||
|
|
||||||
|
> * **Цветовая палитра:** Глубокие темные оттенки фона с яркими акцентами через пользовательские цвета досок.
|
||||||
|
> * **Звуковая обратная связь:** Деликатный синтезированный сигнал при поступлении приоритетного лида.
|
||||||
|
> * **Анимации:** Плавное появление новой карточки, микро-индикаторы пульсации онлайн-статуса системы.
|
||||||
|
> * **Удобство отклика:** Контакты заказчика выводятся крупно в отдельном блоке с кнопкой быстрого копирования и прямой ссылкой на диалог. Контакты **квалифицируются по типу** (Telegram/@username, телефон, почта, LinkedIn, WhatsApp, сайт): в подробном виде каждый контакт показан с меткой типа, кнопкой «открыть» (t.me/tel/mailto/ссылка) и копированием. Боты (@…bot), каналы/группы и «постовые» ссылки (teletype, формы, job-агрегаторы) контактами не считаются.
|
||||||
|
> * **Навигация:** Боковое меню сворачивается в узкую панель иконок и разворачивается обратно.
|
||||||
|
> * **Время:** Текущее время в 24-часовом формате (часы:минуты, без секунд) отображается в шапке дашборда рядом с днем недели и числом.
|
||||||
|
> * **Сортировка:** В пределах колонки — безусловная хронологическая иерархия по времени получения (самые свежие заявки всегда сверху).
|
||||||
|
> * **Счётчики:** бейджи и счётчики (Выбранные, Обработка, Каналы, Архив, Корзина, колонки-доски) отображаются только при значении > 0 — нулевые показатели нигде не выводятся.
|
||||||
|
> * **Подтверждения и уведомления — только встроенные окна в стиле приложения:** единый модальный диалог подтверждения (очистка корзины/архива/«Отклонено»/отсева, удаление или отклонение колонки, сброс ML) и окна с полями (причина при возврате из отсева). Системные окна браузера (confirm/alert/prompt) не используются.
|
||||||
|
> * **Отображение суммы на карточке:** одна сумма → «2 500 ₽»; только верхняя граница → «до 3 000 ₽»; диапазон → «2 500–3 000 ₽». «От 0» не выводится: «от 0 до X» отображается как «до X».
|
||||||
|
|
||||||
|
## **7\. План этапов разработки (Roadmap)**
|
||||||
|
|
||||||
|
| Этап | Модуль | Ключевой результат |
|
||||||
|
| :---- | :---- | :---- |
|
||||||
|
| Спринт 1 | Ядро, авторизация дашборда и Telegram | Инициализация базы, авторизация в дашборд (admin/admin, сессия 30 дней), ввод Telegram api_id/api_hash, модуль веб-авторизации Telegram, веб\-интерфейс каналов с предпросмотром сообщений. |
|
||||||
|
| Спринт 2 | AI & Маршрутизация | Пайплайн дедупликации, AI-классификатор, распределение на доски и в буфер неклассифицированного. |
|
||||||
|
| Спринт 3 | UI/UX, Кастомизация | Дашборд-колонки с интерактивными карточками, выбор цветов, настройка полей, комментарии и корзина, звуковые уведомления, виджеты-счетчики, обучение на действиях пользователя. |
|
||||||
|
| Спринт 4 | Деплой & Полировка | Настройка процессов развертывания системы, стресс-тесты под нагрузкой, финальная оптимизация. |
|
||||||
|
|
||||||
|
## **8\. Развертывание (Deployment)**
|
||||||
|
|
||||||
|
> * Запуск системы — через **Docker Compose** (в том числе локально).
|
||||||
|
> * В переменные окружения выносятся неконфиденциальные параметры: порты, пути к файлу БД и данным, пути хранения сессий. По договорённости в env также лежат **ключ шифрования секретов БД** (`LEADRADAR_ENCRYPTION_KEY`; при локальной разработке без env — файл `data/encryption.key`) и **креды MinIO** (`LEADRADAR_MINIO_ENDPOINT/_ACCESS_KEY/_SECRET_KEY/_BUCKET`).
|
||||||
|
> * Секреты (Telegram **api_id/api_hash**, **ключи AI-провайдеров**, пароль дашборда) в env не передаются — задаются через веб-интерфейс (см. п. 4.1 и 4.6) и хранятся в БД в зашифрованном виде.
|
||||||
Binary file not shown.
@@ -81,7 +81,7 @@
|
|||||||
| ID | Пункт | Решение |
|
| ID | Пункт | Решение |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| DEC-DEMO | Демо-эндпоинты (`DEAL_DEMO`, `simulate-lead`) | Удалены (этап 9/12) |
|
| DEC-DEMO | Демо-эндпоинты (`DEAL_DEMO`, `simulate-lead`) | Удалены (этап 9/12) |
|
||||||
| DEC-LEGACY | Легаси-прототип LeadRadar (`backend/`, `mlservice/`, корневой compose) | Перенесён в `archive/leadradar-legacy/` (2026-09-10); 2026-09-11 убран и из репозитория — лежит только локально |
|
| DEC-LEGACY | Легаси-прототип LeadRadar (`backend/`, `mlservice/`, корневой compose) | Перенесён в `archive/leadradar-legacy/` (2026-09-10) |
|
||||||
| DEC-ML-EXP | Экспорт/импорт ML | Отложено владельцем (перенесено в `BL-ML-EXP`) |
|
| DEC-ML-EXP | Экспорт/импорт ML | Отложено владельцем (перенесено в `BL-ML-EXP`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -90,6 +90,4 @@
|
|||||||
- Для нового захода: выбрать пункты по приоритету/теме, оформить SDD-план в `docs/superpowers/plans/`
|
- Для нового захода: выбрать пункты по приоритету/теме, оформить SDD-план в `docs/superpowers/plans/`
|
||||||
и ledger `.superpowers/sdd/<этап>/`, после приёмки — перенести факт в `docs/superpowers/STATUS.md`,
|
и ledger `.superpowers/sdd/<этап>/`, после приёмки — перенести факт в `docs/superpowers/STATUS.md`,
|
||||||
а пункт здесь пометить выполненным/удалить.
|
а пункт здесь пометить выполненным/удалить.
|
||||||
- Открытые пункты (BACKLOG/MANUAL) зеркалятся задачами в Gitea (`rust/Deal`, метки P1/P2/P3/manual/techdebt);
|
|
||||||
при закрытии пункта закрывать задачу и наоборот.
|
|
||||||
- Пункты `MANUAL` не блокируют разработку; выполняются, когда владелец даёт креды/хост.
|
- Пункты `MANUAL` не блокируют разработку; выполняются, когда владелец даёт креды/хост.
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Dev-edge «Дейла» (compose.dev.yml, сервис frontend): SPA + /api на core.
|
|
||||||
# Отличие от prod-Caddyfile: HTTP без TLS и без плейсхолдер-домена — для локального просмотра UI.
|
|
||||||
# Статика — собранный SPA (Vite) в /srv, неизвестные пути отдают index.html (история браузера).
|
|
||||||
|
|
||||||
:80 {
|
|
||||||
# API core: /api/* уходит на core:5080 без перезаписи (контракт /api неизменен).
|
|
||||||
# SSE (/api/events), файлы и QR-SVG проходят reverse_proxy потоково.
|
|
||||||
handle /api/* {
|
|
||||||
reverse_proxy core:5080
|
|
||||||
}
|
|
||||||
|
|
||||||
handle {
|
|
||||||
# SPA/ассеты в dev не кэшируем: пересборка фронта должна подхватываться по F5.
|
|
||||||
header Cache-Control "no-cache"
|
|
||||||
root * /srv
|
|
||||||
try_files {path} /index.html
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -108,9 +108,6 @@ services:
|
|||||||
Storage__Minio__SecretKey: deal_minio_secret
|
Storage__Minio__SecretKey: deal_minio_secret
|
||||||
Storage__Minio__Bucket: deal-files
|
Storage__Minio__Bucket: deal-files
|
||||||
Storage__Minio__Secure: "false"
|
Storage__Minio__Secure: "false"
|
||||||
# Трейсинг OTel → коллектор профиля observability (без коллектора трейсы не экспортируются).
|
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
|
||||||
OTEL_SERVICE_NAME: core
|
|
||||||
ports:
|
ports:
|
||||||
- "5080:5080"
|
- "5080:5080"
|
||||||
- "5082:5082" # gRPC-ингресс telegram-service (сервисы ходят на http://core:5082 внутри сети)
|
- "5082:5082" # gRPC-ингресс telegram-service (сервисы ходят на http://core:5082 внутри сети)
|
||||||
@@ -149,8 +146,6 @@ services:
|
|||||||
DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:-ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=}
|
DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:-ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=}
|
||||||
DEAL_TELEGRAM_SESSION_DIR: /data/sessions
|
DEAL_TELEGRAM_SESSION_DIR: /data/sessions
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
|
||||||
OTEL_SERVICE_NAME: telegram-service
|
|
||||||
SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082}
|
SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082}
|
||||||
ports:
|
ports:
|
||||||
- "5101:5101"
|
- "5101:5101"
|
||||||
@@ -181,8 +176,6 @@ services:
|
|||||||
GRPC_PORT: "5102"
|
GRPC_PORT: "5102"
|
||||||
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
|
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
|
||||||
OTEL_SERVICE_NAME: ai-service
|
|
||||||
ports:
|
ports:
|
||||||
- "5102:5102"
|
- "5102:5102"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -210,8 +203,6 @@ services:
|
|||||||
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
|
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
|
||||||
DEAL_ML_DATA_DIR: /data/ml # файлы моделей data/ml/<tenantId>.sqlite на volume deal_ml_data (Ruling 4/12)
|
DEAL_ML_DATA_DIR: /data/ml # файлы моделей data/ml/<tenantId>.sqlite на volume deal_ml_data (Ruling 4/12)
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
|
||||||
OTEL_SERVICE_NAME: ml-service
|
|
||||||
ports:
|
ports:
|
||||||
- "5103:5103"
|
- "5103:5103"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -242,8 +233,6 @@ services:
|
|||||||
DEAL_STORAGE_BUCKET: deal-attachments
|
DEAL_STORAGE_BUCKET: deal-attachments
|
||||||
DEAL_STORAGE_SECURE: "false"
|
DEAL_STORAGE_SECURE: "false"
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
|
||||||
OTEL_SERVICE_NAME: storage-service
|
|
||||||
ports:
|
ports:
|
||||||
- "5104:5104"
|
- "5104:5104"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -254,18 +243,6 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
# Фронтенд (SPA) — сборка образа (Vite) и отдача через Caddy; /api → core:5080. UI — http://localhost:8080.
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: src/frontend/Dockerfile
|
|
||||||
container_name: deal-frontend
|
|
||||||
ports:
|
|
||||||
- "8080:80"
|
|
||||||
depends_on:
|
|
||||||
core:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
# Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464)
|
# Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464)
|
||||||
# внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d.
|
# внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d.
|
||||||
# Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090.
|
# Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090.
|
||||||
@@ -284,116 +261,6 @@ services:
|
|||||||
- ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro
|
- ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro
|
||||||
- deal_prometheus_data:/prometheus
|
- deal_prometheus_data:/prometheus
|
||||||
|
|
||||||
# OpenTelemetry Collector — приём трейсов Deal-процессов (OTLP) → Tempo (профиль observability).
|
|
||||||
otel-collector:
|
|
||||||
image: otel/opentelemetry-collector-contrib:0.160.0
|
|
||||||
container_name: deal-otel-collector
|
|
||||||
profiles: ["observability"]
|
|
||||||
command: ["--config=/etc/otelcol-contrib/config.yaml"]
|
|
||||||
ports:
|
|
||||||
- "4317:4317"
|
|
||||||
- "4318:4318"
|
|
||||||
volumes:
|
|
||||||
- ./observability/otel-collector.yml:/etc/otelcol-contrib/config.yaml:ro
|
|
||||||
depends_on:
|
|
||||||
tempo:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
# Tempo — хранилище трейсов (OTLP от коллектора), UI/API — :3200 (профиль observability).
|
|
||||||
tempo:
|
|
||||||
image: grafana/tempo:2.8.1
|
|
||||||
container_name: deal-tempo
|
|
||||||
profiles: ["observability"]
|
|
||||||
command: ["-config.file=/etc/tempo.yml"]
|
|
||||||
ports:
|
|
||||||
- "3200:3200"
|
|
||||||
volumes:
|
|
||||||
- ./observability/tempo.yml:/etc/tempo.yml:ro
|
|
||||||
- deal_tempo_data:/var/tempo
|
|
||||||
|
|
||||||
# cAdvisor — ресурсы контейнеров (CPU/RAM/сеть/диск); scrape — job cadvisor.
|
|
||||||
cadvisor:
|
|
||||||
image: gcr.io/cadvisor/cadvisor:v0.52.1
|
|
||||||
container_name: deal-cadvisor
|
|
||||||
profiles: ["observability"]
|
|
||||||
privileged: true
|
|
||||||
devices:
|
|
||||||
- /dev/kmsg:/dev/kmsg
|
|
||||||
volumes:
|
|
||||||
- /:/rootfs:ro
|
|
||||||
- /var/run:/var/run:ro
|
|
||||||
- /sys:/sys:ro
|
|
||||||
- /var/lib/docker/:/var/lib/docker:ro
|
|
||||||
- /dev/disk/:/dev/disk:ro
|
|
||||||
|
|
||||||
# node-exporter — ресурсы хоста (CPU/RAM/диск/сеть); scrape — job node-exporter.
|
|
||||||
node-exporter:
|
|
||||||
image: prom/node-exporter:v1.9.1
|
|
||||||
container_name: deal-node-exporter
|
|
||||||
profiles: ["observability"]
|
|
||||||
command:
|
|
||||||
- --path.procfs=/host/proc
|
|
||||||
- --path.sysfs=/host/sys
|
|
||||||
- --path.rootfs=/host/root
|
|
||||||
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/)
|
|
||||||
ports:
|
|
||||||
- "9100:9100"
|
|
||||||
volumes:
|
|
||||||
- /proc:/host/proc:ro
|
|
||||||
- /sys:/host/sys:ro
|
|
||||||
- /:/host/root:ro
|
|
||||||
pid: host
|
|
||||||
|
|
||||||
# Loki — хранилище логов (профиль observability), UI/API — :3100.
|
|
||||||
loki:
|
|
||||||
image: grafana/loki:3.4.2
|
|
||||||
container_name: deal-loki
|
|
||||||
profiles: ["observability"]
|
|
||||||
command: -config.file=/etc/loki/loki.yml
|
|
||||||
ports:
|
|
||||||
- "3100:3100"
|
|
||||||
volumes:
|
|
||||||
- ./observability/loki.yml:/etc/loki/loki.yml:ro
|
|
||||||
- deal_loki_data:/loki
|
|
||||||
|
|
||||||
# Promtail — сбор docker-логов deal-процессов в Loki (docker.sock, профиль observability).
|
|
||||||
promtail:
|
|
||||||
image: grafana/promtail:3.4.2
|
|
||||||
container_name: deal-promtail
|
|
||||||
profiles: ["observability"]
|
|
||||||
command: -config.file=/etc/promtail/promtail.yml
|
|
||||||
volumes:
|
|
||||||
- ./observability/promtail.yml:/etc/promtail/promtail.yml:ro
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
- deal_promtail_data:/var/lib/promtail
|
|
||||||
depends_on:
|
|
||||||
loki:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
# Grafana — UI логов/метрик/трейсов (профиль observability), локальный вход admin/admin.
|
|
||||||
grafana:
|
|
||||||
image: grafana/grafana:11.5.2
|
|
||||||
container_name: deal-grafana
|
|
||||||
profiles: ["observability"]
|
|
||||||
environment:
|
|
||||||
GF_SECURITY_ADMIN_USER: admin
|
|
||||||
GF_SECURITY_ADMIN_PASSWORD: admin
|
|
||||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
|
||||||
GF_AUTH_ANONYMOUS_ENABLED: "false"
|
|
||||||
ports:
|
|
||||||
- "3001:3000"
|
|
||||||
volumes:
|
|
||||||
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
|
|
||||||
- ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
|
||||||
- deal_grafana_data:/var/lib/grafana
|
|
||||||
depends_on:
|
|
||||||
loki:
|
|
||||||
condition: service_started
|
|
||||||
prometheus:
|
|
||||||
condition: service_started
|
|
||||||
tempo:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
deal_pgdata:
|
deal_pgdata:
|
||||||
deal_minio_data:
|
deal_minio_data:
|
||||||
@@ -401,7 +268,3 @@ volumes:
|
|||||||
deal_ml_data:
|
deal_ml_data:
|
||||||
deal_api_data:
|
deal_api_data:
|
||||||
deal_prometheus_data:
|
deal_prometheus_data:
|
||||||
deal_tempo_data:
|
|
||||||
deal_loki_data:
|
|
||||||
deal_promtail_data:
|
|
||||||
deal_grafana_data:
|
|
||||||
|
|||||||
+5
-111
@@ -6,12 +6,12 @@
|
|||||||
# Состав (всё в одной внутренней сети compose, наружу — ТОЛЬКО caddy :80/:443):
|
# Состав (всё в одной внутренней сети compose, наружу — ТОЛЬКО caddy :80/:443):
|
||||||
# postgres, minio — хранилища БЕЗ host-портов (volume'ы);
|
# postgres, minio — хранилища БЕЗ host-портов (volume'ы);
|
||||||
# core (:5080 HTTP + :5082 gRPC-ингресс), telegram-service (:5101), ai-service (:5102),
|
# core (:5080 HTTP + :5082 gRPC-ингресс), telegram-service (:5101), ai-service (:5102),
|
||||||
# ml-service (:5103), storage-service (:5104) — процессы «Дейла»; mTLS-транспорт — по env Ruling 6 (см. ниже);
|
# ml-service (:5103) — процессы «Дейла»; mTLS-транспорт — по env Ruling 6 (см. ниже);
|
||||||
# caddy — edge: TLS-терминация, статика фронта, reverse_proxy /api → core.
|
# caddy — edge: TLS-терминация, статика фронта, reverse_proxy /api → core.
|
||||||
# observability (ПРОФИЛЬ `observability`) — современный стек: otel-collector (приём трейсов OTLP),
|
# loki/promtail/grafana/prometheus — observability (Ruling 7; метрики — этап 12, пакет A): ПРОФИЛЬ
|
||||||
# tempo (хранилище трейсов), loki/promtail (логи), prometheus (метрики),
|
# `observability` — поднимается только: docker compose --profile observability up -d
|
||||||
# cadvisor/node-exporter (потребление ресурсов контейнеров/хоста), grafana (UI).
|
# (или ... up -d --profile observability). Prometheus scrape'ит /metrics
|
||||||
# Подъём: docker compose --profile observability up -d.
|
# (порт 9464) всех 4 процессов; Grafana — логи (Loki) и метрики (Prometheus).
|
||||||
#
|
#
|
||||||
# Секреты — ТОЛЬКО из env: шаблон deploy/.env.prod.example → скопируйте в deploy/.env.prod,
|
# Секреты — ТОЛЬКО из env: шаблон deploy/.env.prod.example → скопируйте в deploy/.env.prod,
|
||||||
# заполните значения и запускайте с --env-file:
|
# заполните значения и запускайте с --env-file:
|
||||||
@@ -125,7 +125,6 @@ services:
|
|||||||
Services__Ai__Endpoint: ${DEAL_AI_ENDPOINT:-http://ai-service:5102}
|
Services__Ai__Endpoint: ${DEAL_AI_ENDPOINT:-http://ai-service:5102}
|
||||||
Services__Telegram__UseLocal: "false"
|
Services__Telegram__UseLocal: "false"
|
||||||
Services__Telegram__Endpoint: ${DEAL_TELEGRAM_ENDPOINT:-http://telegram-service:5101}
|
Services__Telegram__Endpoint: ${DEAL_TELEGRAM_ENDPOINT:-http://telegram-service:5101}
|
||||||
Services__Storage__Endpoint: ${DEAL_STORAGE_SERVICE_ENDPOINT:-http://storage-service:5104}
|
|
||||||
# Файлы — MinIO (внутренний http; TLS minio — вне этапа, при желании Storage__Minio__Secure=true
|
# Файлы — MinIO (внутренний http; TLS minio — вне этапа, при желании Storage__Minio__Secure=true
|
||||||
# + endpoint https и сертификаты).
|
# + endpoint https и сертификаты).
|
||||||
Storage__Minio__Endpoint: minio:9000
|
Storage__Minio__Endpoint: minio:9000
|
||||||
@@ -133,9 +132,6 @@ services:
|
|||||||
Storage__Minio__SecretKey: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан}
|
Storage__Minio__SecretKey: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан}
|
||||||
Storage__Minio__Bucket: deal-files
|
Storage__Minio__Bucket: deal-files
|
||||||
Storage__Minio__Secure: "false"
|
Storage__Minio__Secure: "false"
|
||||||
# Трейсинг OTel → коллектор профиля observability; без коллектора трейсы не экспортируются.
|
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
|
||||||
OTEL_SERVICE_NAME: core
|
|
||||||
# mTLS внутреннего gRPC (Ruling 6; пути — /etc/deal/certs, см. volume ниже).
|
# mTLS внутреннего gRPC (Ruling 6; пути — /etc/deal/certs, см. volume ниже).
|
||||||
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
||||||
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
||||||
@@ -176,8 +172,6 @@ services:
|
|||||||
DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:?DEAL_TELEGRAM_SESSION_KEY не задан (ключ AES-GCM сессий)}
|
DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:?DEAL_TELEGRAM_SESSION_KEY не задан (ключ AES-GCM сессий)}
|
||||||
DEAL_TELEGRAM_SESSION_DIR: /data/sessions
|
DEAL_TELEGRAM_SESSION_DIR: /data/sessions
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
|
||||||
OTEL_SERVICE_NAME: telegram-service
|
|
||||||
SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082}
|
SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082}
|
||||||
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
||||||
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
||||||
@@ -207,8 +201,6 @@ services:
|
|||||||
GRPC_PORT: "5102"
|
GRPC_PORT: "5102"
|
||||||
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
|
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
|
||||||
OTEL_SERVICE_NAME: ai-service
|
|
||||||
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
||||||
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
||||||
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ai-service-server.pfx
|
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ai-service-server.pfx
|
||||||
@@ -237,8 +229,6 @@ services:
|
|||||||
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
|
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
|
||||||
DEAL_ML_DATA_DIR: /data/ml
|
DEAL_ML_DATA_DIR: /data/ml
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
|
||||||
OTEL_SERVICE_NAME: ml-service
|
|
||||||
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
||||||
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
||||||
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ml-service-server.pfx
|
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ml-service-server.pfx
|
||||||
@@ -255,44 +245,6 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# storage-service — общий gRPC-сервис данных (вложения источников), :5104. Бэкенд — MinIO.
|
|
||||||
# Без host-портов; защита — общий service-token (+ mTLS при DEAL_MTLS_ENABLED=1).
|
|
||||||
storage-service:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: src/storage-service/Deal.Storage/Dockerfile
|
|
||||||
<<: *service-hardening
|
|
||||||
mem_limit: 512m
|
|
||||||
cpus: 1.0
|
|
||||||
environment:
|
|
||||||
GRPC_PORT: "5104"
|
|
||||||
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
|
|
||||||
DEAL_STORAGE_ENDPOINT: minio:9000
|
|
||||||
DEAL_STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER:?MINIO_ROOT_USER не задан}
|
|
||||||
DEAL_STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан}
|
|
||||||
DEAL_STORAGE_BUCKET: deal-attachments
|
|
||||||
DEAL_STORAGE_SECURE: "false"
|
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
|
||||||
OTEL_SERVICE_NAME: storage-service
|
|
||||||
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
|
||||||
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
|
||||||
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/storage-service-server.pfx
|
|
||||||
DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
|
|
||||||
DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx
|
|
||||||
DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
|
|
||||||
volumes:
|
|
||||||
- ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro
|
|
||||||
depends_on:
|
|
||||||
minio:
|
|
||||||
condition: service_started
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5104 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5104; fi"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 10
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# caddy — edge: наружу только :80/:443. TLS — плейсхолдер tls internal (см. Caddyfile: домен,
|
# caddy — edge: наружу только :80/:443. TLS — плейсхолдер tls internal (см. Caddyfile: домен,
|
||||||
# реальный сертификат/Cloudflare, CSP/HSTS). Статика — ../src/frontend/dist (СОБРАТЬ ДО up).
|
# реальный сертификат/Cloudflare, CSP/HSTS). Статика — ../src/frontend/dist (СОБРАТЬ ДО up).
|
||||||
caddy:
|
caddy:
|
||||||
@@ -382,63 +334,6 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
prometheus:
|
prometheus:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
tempo:
|
|
||||||
condition: service_started
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# ── Трейсы и ресурсы (observability-стек) ─────────────────────────────────
|
|
||||||
# OpenTelemetry Collector — приёмник трейсов Deal-процессов (OTLP gRPC :4317 / HTTP :4318),
|
|
||||||
# батчит и перекладывает в Tempo. Наружу порты не публикуются (внутри compose-сети).
|
|
||||||
otel-collector:
|
|
||||||
image: otel/opentelemetry-collector-contrib:0.160.0
|
|
||||||
profiles: ["observability"]
|
|
||||||
command: ["--config=/etc/otelcol-contrib/config.yaml"]
|
|
||||||
volumes:
|
|
||||||
- ./observability/otel-collector.yml:/etc/otelcol-contrib/config.yaml:ro
|
|
||||||
depends_on:
|
|
||||||
tempo:
|
|
||||||
condition: service_started
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# Tempo — хранилище трейсов (OTLP от коллектора). Retention блоков — 7 суток (см. tempo.yml).
|
|
||||||
tempo:
|
|
||||||
image: grafana/tempo:2.8.1
|
|
||||||
profiles: ["observability"]
|
|
||||||
command: ["-config.file=/etc/tempo.yml"]
|
|
||||||
volumes:
|
|
||||||
- ./observability/tempo.yml:/etc/tempo.yml:ro
|
|
||||||
- deal_tempo_data:/var/tempo
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# cAdvisor — ресурсы контейнеров (CPU/RAM/сеть/диск); scrape — job cadvisor в prometheus.yml.
|
|
||||||
cadvisor:
|
|
||||||
image: gcr.io/cadvisor/cadvisor:v0.52.1
|
|
||||||
profiles: ["observability"]
|
|
||||||
privileged: true
|
|
||||||
devices:
|
|
||||||
- /dev/kmsg:/dev/kmsg
|
|
||||||
volumes:
|
|
||||||
- /:/rootfs:ro
|
|
||||||
- /var/run:/var/run:ro
|
|
||||||
- /sys:/sys:ro
|
|
||||||
- /var/lib/docker/:/var/lib/docker:ro
|
|
||||||
- /dev/disk/:/dev/disk:ro
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# node-exporter — ресурсы хоста (CPU/RAM/диск/сеть); scrape — job node-exporter в prometheus.yml.
|
|
||||||
node-exporter:
|
|
||||||
image: prom/node-exporter:v1.9.1
|
|
||||||
profiles: ["observability"]
|
|
||||||
command:
|
|
||||||
- --path.procfs=/host/proc
|
|
||||||
- --path.sysfs=/host/sys
|
|
||||||
- --path.rootfs=/host/root
|
|
||||||
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/)
|
|
||||||
volumes:
|
|
||||||
- /proc:/host/proc:ro
|
|
||||||
- /sys:/host/sys:ro
|
|
||||||
- /:/host/root:ro
|
|
||||||
pid: host
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@@ -448,7 +343,6 @@ volumes:
|
|||||||
deal_ml_data:
|
deal_ml_data:
|
||||||
deal_api_data:
|
deal_api_data:
|
||||||
deal_caddy_data:
|
deal_caddy_data:
|
||||||
deal_tempo_data:
|
|
||||||
deal_caddy_config:
|
deal_caddy_config:
|
||||||
deal_loki_data:
|
deal_loki_data:
|
||||||
deal_promtail_data:
|
deal_promtail_data:
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# Адрес инстанса Gitea
|
|
||||||
GITEA_INSTANCE_URL=https://gitea.khomegeneric.keenetic.pro
|
|
||||||
|
|
||||||
# Токен регистрации раннера: Gitea -> Site Administration (или репозиторий) -> Actions -> Runners
|
|
||||||
# -> Create new runner; либо API:
|
|
||||||
# curl -sk -u rust:ПАРОЛЬ -X GET \
|
|
||||||
# https://gitea.khomegeneric.keenetic.pro/api/v1/repos/rust/Deal/actions/runners/registration-token
|
|
||||||
RUNNER_TOKEN=
|
|
||||||
|
|
||||||
# Имя раннера в интерфейсе Gitea
|
|
||||||
RUNNER_NAME=deal-runner
|
|
||||||
|
|
||||||
# Метки для runs-on (по умолчанию хватает ubuntu-latest)
|
|
||||||
# RUNNER_LABELS=ubuntu-latest:docker://gitea/runner-images:ubuntu-latest
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Gitea Actions Runner (Deal CI)
|
|
||||||
|
|
||||||
Раннер для `.gitea/workflows/ci.yml`. Workflow требует метку `ubuntu-latest`;
|
|
||||||
job-контейнер — `gitea/runner-images:ubuntu-latest`, .NET 10 и Node 20 ставятся
|
|
||||||
шагами `setup-dotnet`/`setup-node` внутри job'а.
|
|
||||||
|
|
||||||
## Поднять на любой машине с Docker (в той же сети, что Gitea)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cp .env.example .env # вписать RUNNER_TOKEN
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Токен: Gitea → **Site Administration → Actions → Runners → Create new runner**
|
|
||||||
или API (см. `.env.example`).
|
|
||||||
|
|
||||||
Проверка: Gitea → репозиторий → **Actions → Runners** — раннер `deal-runner`
|
|
||||||
должен быть Idle. Далее любой `git push` в `main` запускает workflow
|
|
||||||
(`.gitea/workflows/ci.yml`: `sh scripts/ci.sh` — сборка 5 решений, тесты,
|
|
||||||
скан уязвимостей, сборка и линтер фронта).
|
|
||||||
|
|
||||||
## Перенос на сервер Gitea
|
|
||||||
|
|
||||||
1. Скопировать каталог на сервер, заполнить `.env`, `docker compose up -d`.
|
|
||||||
2. Локальный раннер погасить: `docker compose down` (каталог `data/` содержит
|
|
||||||
регистрацию — при переносе можно удалить и перерегистрировать).
|
|
||||||
|
|
||||||
## Заметки
|
|
||||||
|
|
||||||
- Первая строка pulls ~1.5 GB (`gitea/runner-images:ubuntu-latest` + SDK ~200 MB
|
|
||||||
при первом прогоне); последующие прогоны — инкрементальные.
|
|
||||||
- `restart: unless-stopped` — раннер переживает перезагрузку хоста.
|
|
||||||
- Токен регистрации используется один раз при `register`; хранить его в
|
|
||||||
репозитории нельзя — только в `.env` (в `.gitignore`).
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
services:
|
|
||||||
runner:
|
|
||||||
image: gitea/act_runner:latest
|
|
||||||
container_name: deal-act-runner
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on: []
|
|
||||||
environment:
|
|
||||||
# Адрес инстанса Gitea и токен регистрации — в .env рядом с этим файлом
|
|
||||||
GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:?заполните .env}
|
|
||||||
GITEA_RUNNER_REGISTRATION_TOKEN: ${RUNNER_TOKEN:?заполните .env}
|
|
||||||
GITEA_RUNNER_NAME: ${RUNNER_NAME:-deal-runner}
|
|
||||||
# Метки, по которым workflow находит раннер: runs-on: ubuntu-latest
|
|
||||||
GITEA_RUNNER_LABELS: ${RUNNER_LABELS:-ubuntu-latest:docker://gitea/runner-images:ubuntu-latest,ubuntu-22.04:docker://gitea/runner-images:ubuntu-22.04}
|
|
||||||
volumes:
|
|
||||||
# Раннер запускает job-контейнеры через docker хоста
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- ./data:/data
|
|
||||||
privileged: false
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# Observability-стек «Дейла»
|
|
||||||
|
|
||||||
Современный (vendor-neutral) стек мониторинга. Поднимается **профилем `observability`** (в prod —
|
|
||||||
`deploy/compose.prod.yml`, в dev — `deploy/compose.dev.yml`); наружу порты не публикуются (prod —
|
|
||||||
доступ оператору по SSH-туннелю).
|
|
||||||
|
|
||||||
## Состав и поток данных
|
|
||||||
|
|
||||||
| Слой | Сервис | Конфиг | Поток |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Трейсы (приём) | `otel-collector` | `otel-collector.yml` | OTLP от сервисов (`:4317`) → Tempo |
|
|
||||||
| Трейсы (хранение) | `tempo` | `tempo.yml` | OTLP от коллектора, retention 7 сут. |
|
|
||||||
| Логи | `loki` + `promtail` | `loki.yml`, `promtail.yml` | docker-логи → Loki |
|
|
||||||
| Метрики | `prometheus` | `prometheus.yml`, `prometheus-rules.yml` | scrape `/metrics` процессов и `cadvisor`/`node-exporter` |
|
|
||||||
| Ресурсы контейнеров | `cadvisor` | — | Prometheus |
|
|
||||||
| Ресурсы хоста | `node-exporter` | — | Prometheus |
|
|
||||||
| Визуализация | `grafana` | `grafana/provisioning/**` | Loki + Prometheus + Tempo |
|
|
||||||
|
|
||||||
## Подъём
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# prod (нужен deploy/.env.prod с DEAL_GRAFANA_ADMIN_PASSWORD)
|
|
||||||
docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml --profile observability up -d
|
|
||||||
|
|
||||||
# dev
|
|
||||||
docker compose -f deploy/compose.dev.yml --profile observability up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверка: Prometheus `/targets` (job `deal` — 4 процесса UP, `cadvisor`, `node-exporter`, `tempo`) →
|
|
||||||
Grafana → папка «Дейл» → `Deal-Metrics-Overview` / `Deal-Logs` / `Deal-Traces` / `Deal-Resources`.
|
|
||||||
|
|
||||||
## Подключение сервисов (код)
|
|
||||||
|
|
||||||
Общая настройка — `Deal.Grpc.Hosting` (сервисы) и `Deal.Api/Observability` (ядро):
|
|
||||||
|
|
||||||
- **метрики**: `DealMetricsHosting` — OTel → Prometheus, отдельный HTTP/1.1-эндпоинт `/metrics:9464`
|
|
||||||
(env `METRICS_PORT`);
|
|
||||||
- **трейсы**: `DealTracingHosting` — OTel → OTLP, **опт-ин** через env `OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
||||||
(адрес коллектора; без него трейсинг выключен), имя сервиса — `OTEL_SERVICE_NAME`;
|
|
||||||
- **логи**: Serilog JSON обогащается `TraceId`/`SpanId` (`TraceContextEnricher`) для связи с трейсами.
|
|
||||||
|
|
||||||
## Дашборды и алерты
|
|
||||||
|
|
||||||
Дашборды — как код: `grafana/dashboards/*.json` (правки только в репозитории, UI не сохраняет).
|
|
||||||
Алерты — `prometheus-rules.yml` (доступность, ошибки/5xx, очереди). Алерты по **ресурсам** вынесены в
|
|
||||||
`prometheus-resource-rules.yml` и **отключены по умолчанию**; включаются добавлением файла в `rule_files`,
|
|
||||||
пороги — через env `DEAL_ALERT_*` (см. шапку файла).
|
|
||||||
|
|
||||||
## Обновление версий образов
|
|
||||||
|
|
||||||
Версии зафиксированы в compose-файлах. При обновлении — свежие стабильные теги:
|
|
||||||
`otel/opentelemetry-collector-contrib`, `grafana/tempo`, `prom/node-exporter`,
|
|
||||||
`gcr.io/cadvisor/cadvisor`, `grafana/loki`, `grafana/promtail`, `prom/prometheus`, `grafana/grafana`.
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
{
|
|
||||||
"annotations": {
|
|
||||||
"list": []
|
|
||||||
},
|
|
||||||
"editable": true,
|
|
||||||
"graphTooltip": 0,
|
|
||||||
"id": null,
|
|
||||||
"links": [],
|
|
||||||
"panels": [
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "prometheus",
|
|
||||||
"uid": "prometheus"
|
|
||||||
},
|
|
||||||
"description": "Потребление CPU контейнерами Deal (cAdvisor). Значение — ядра, занятые контейнером за 5 минут.",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"custom": {
|
|
||||||
"drawStyle": "line",
|
|
||||||
"fillOpacity": 15,
|
|
||||||
"lineWidth": 1,
|
|
||||||
"showPoints": "never"
|
|
||||||
},
|
|
||||||
"unit": "short"
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
},
|
|
||||||
"gridPos": {
|
|
||||||
"h": 8,
|
|
||||||
"w": 12,
|
|
||||||
"x": 0,
|
|
||||||
"y": 0
|
|
||||||
},
|
|
||||||
"id": 1,
|
|
||||||
"options": {
|
|
||||||
"legend": {
|
|
||||||
"calcs": [],
|
|
||||||
"displayMode": "list",
|
|
||||||
"placement": "bottom",
|
|
||||||
"showLegend": true
|
|
||||||
},
|
|
||||||
"tooltip": {
|
|
||||||
"mode": "multi",
|
|
||||||
"sort": "desc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "prometheus",
|
|
||||||
"uid": "prometheus"
|
|
||||||
},
|
|
||||||
"expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name=~\".*deal.*|.*core.*|.*service.*\"}[5m]))",
|
|
||||||
"legendFormat": "{{name}}",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "CPU контейнеров (cAdvisor)",
|
|
||||||
"type": "timeseries"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "prometheus",
|
|
||||||
"uid": "prometheus"
|
|
||||||
},
|
|
||||||
"description": "Рабочий набор памяти контейнеров Deal (cAdvisor). Лимиты заданы mem_limit в compose.prod.yml.",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"custom": {
|
|
||||||
"drawStyle": "line",
|
|
||||||
"fillOpacity": 15,
|
|
||||||
"lineWidth": 1,
|
|
||||||
"showPoints": "never"
|
|
||||||
},
|
|
||||||
"unit": "bytes"
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
},
|
|
||||||
"gridPos": {
|
|
||||||
"h": 8,
|
|
||||||
"w": 12,
|
|
||||||
"x": 12,
|
|
||||||
"y": 0
|
|
||||||
},
|
|
||||||
"id": 2,
|
|
||||||
"options": {
|
|
||||||
"legend": {
|
|
||||||
"calcs": [],
|
|
||||||
"displayMode": "list",
|
|
||||||
"placement": "bottom",
|
|
||||||
"showLegend": true
|
|
||||||
},
|
|
||||||
"tooltip": {
|
|
||||||
"mode": "multi",
|
|
||||||
"sort": "desc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "prometheus",
|
|
||||||
"uid": "prometheus"
|
|
||||||
},
|
|
||||||
"expr": "sum by (name) (container_memory_working_set_bytes{name=~\".*deal.*|.*core.*|.*service.*\"})",
|
|
||||||
"legendFormat": "{{name}}",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Память контейнеров (cAdvisor)",
|
|
||||||
"type": "timeseries"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "prometheus",
|
|
||||||
"uid": "prometheus"
|
|
||||||
},
|
|
||||||
"description": "Загрузка CPU хоста (node-exporter), 0–100%.",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"custom": {
|
|
||||||
"drawStyle": "line",
|
|
||||||
"fillOpacity": 15,
|
|
||||||
"lineWidth": 1,
|
|
||||||
"showPoints": "never"
|
|
||||||
},
|
|
||||||
"max": 100,
|
|
||||||
"min": 0,
|
|
||||||
"unit": "percent"
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
},
|
|
||||||
"gridPos": {
|
|
||||||
"h": 8,
|
|
||||||
"w": 12,
|
|
||||||
"x": 0,
|
|
||||||
"y": 8
|
|
||||||
},
|
|
||||||
"id": 3,
|
|
||||||
"options": {
|
|
||||||
"legend": {
|
|
||||||
"calcs": [],
|
|
||||||
"displayMode": "list",
|
|
||||||
"placement": "bottom",
|
|
||||||
"showLegend": true
|
|
||||||
},
|
|
||||||
"tooltip": {
|
|
||||||
"mode": "multi",
|
|
||||||
"sort": "desc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "prometheus",
|
|
||||||
"uid": "prometheus"
|
|
||||||
},
|
|
||||||
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
|
||||||
"legendFormat": "CPU хоста",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "CPU хоста (node-exporter)",
|
|
||||||
"type": "timeseries"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "prometheus",
|
|
||||||
"uid": "prometheus"
|
|
||||||
},
|
|
||||||
"description": "Занятая память хоста (node-exporter), проценты от общего объёма.",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"custom": {
|
|
||||||
"drawStyle": "line",
|
|
||||||
"fillOpacity": 15,
|
|
||||||
"lineWidth": 1,
|
|
||||||
"showPoints": "never"
|
|
||||||
},
|
|
||||||
"max": 100,
|
|
||||||
"min": 0,
|
|
||||||
"unit": "percent"
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
},
|
|
||||||
"gridPos": {
|
|
||||||
"h": 8,
|
|
||||||
"w": 12,
|
|
||||||
"x": 12,
|
|
||||||
"y": 8
|
|
||||||
},
|
|
||||||
"id": 4,
|
|
||||||
"options": {
|
|
||||||
"legend": {
|
|
||||||
"calcs": [],
|
|
||||||
"displayMode": "list",
|
|
||||||
"placement": "bottom",
|
|
||||||
"showLegend": true
|
|
||||||
},
|
|
||||||
"tooltip": {
|
|
||||||
"mode": "multi",
|
|
||||||
"sort": "desc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "prometheus",
|
|
||||||
"uid": "prometheus"
|
|
||||||
},
|
|
||||||
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
|
|
||||||
"legendFormat": "Память хоста",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Память хоста (node-exporter)",
|
|
||||||
"type": "timeseries"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "prometheus",
|
|
||||||
"uid": "prometheus"
|
|
||||||
},
|
|
||||||
"description": "Свободное место на разделах хоста (node-exporter), проценты.",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"custom": {
|
|
||||||
"drawStyle": "line",
|
|
||||||
"fillOpacity": 15,
|
|
||||||
"lineWidth": 1,
|
|
||||||
"showPoints": "never"
|
|
||||||
},
|
|
||||||
"max": 100,
|
|
||||||
"min": 0,
|
|
||||||
"unit": "percent"
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
},
|
|
||||||
"gridPos": {
|
|
||||||
"h": 8,
|
|
||||||
"w": 24,
|
|
||||||
"x": 0,
|
|
||||||
"y": 16
|
|
||||||
},
|
|
||||||
"id": 5,
|
|
||||||
"options": {
|
|
||||||
"legend": {
|
|
||||||
"calcs": [],
|
|
||||||
"displayMode": "list",
|
|
||||||
"placement": "bottom",
|
|
||||||
"showLegend": true
|
|
||||||
},
|
|
||||||
"tooltip": {
|
|
||||||
"mode": "multi",
|
|
||||||
"sort": "desc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "prometheus",
|
|
||||||
"uid": "prometheus"
|
|
||||||
},
|
|
||||||
"expr": "min by (mountpoint) (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay\"}) * 100",
|
|
||||||
"legendFormat": "{{mountpoint}}",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Свободное место на диске (node-exporter)",
|
|
||||||
"type": "timeseries"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"refresh": "30s",
|
|
||||||
"schemaVersion": 39,
|
|
||||||
"tags": [
|
|
||||||
"deal",
|
|
||||||
"resources"
|
|
||||||
],
|
|
||||||
"templating": {
|
|
||||||
"list": []
|
|
||||||
},
|
|
||||||
"time": {
|
|
||||||
"from": "now-6h",
|
|
||||||
"to": "now"
|
|
||||||
},
|
|
||||||
"timezone": "browser",
|
|
||||||
"title": "Deal — Ресурсы",
|
|
||||||
"uid": "deal-resources",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"annotations": {
|
|
||||||
"list": []
|
|
||||||
},
|
|
||||||
"editable": true,
|
|
||||||
"graphTooltip": 0,
|
|
||||||
"id": null,
|
|
||||||
"links": [],
|
|
||||||
"panels": [
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "tempo",
|
|
||||||
"uid": "tempo"
|
|
||||||
},
|
|
||||||
"description": "Поиск трейсов Deal (Tempo, TraceQL). Источник — OTLP от сервисов через otel-collector. Клик по трейсу — спаны по сервисам; из спана можно перейти к логам (Loki) по traceId.",
|
|
||||||
"gridPos": {
|
|
||||||
"h": 22,
|
|
||||||
"w": 24,
|
|
||||||
"x": 0,
|
|
||||||
"y": 0
|
|
||||||
},
|
|
||||||
"id": 1,
|
|
||||||
"options": {},
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "tempo",
|
|
||||||
"uid": "tempo"
|
|
||||||
},
|
|
||||||
"query": "{}",
|
|
||||||
"queryType": "traceql",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Трейсы (Tempo)",
|
|
||||||
"type": "traces"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"refresh": "30s",
|
|
||||||
"schemaVersion": 39,
|
|
||||||
"tags": [
|
|
||||||
"deal",
|
|
||||||
"traces"
|
|
||||||
],
|
|
||||||
"templating": {
|
|
||||||
"list": []
|
|
||||||
},
|
|
||||||
"time": {
|
|
||||||
"from": "now-1h",
|
|
||||||
"to": "now"
|
|
||||||
},
|
|
||||||
"timezone": "browser",
|
|
||||||
"title": "Deal — Трейсы",
|
|
||||||
"uid": "deal-traces",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
@@ -13,12 +13,6 @@ datasources:
|
|||||||
isDefault: true
|
isDefault: true
|
||||||
jsonData:
|
jsonData:
|
||||||
maxLines: 1000
|
maxLines: 1000
|
||||||
# Клик по TraceId в логе открывает трейс в Tempo (обогащение логов TraceContextEnricher).
|
|
||||||
derivedFields:
|
|
||||||
- name: TraceID
|
|
||||||
matcherRegex: '"TraceId":"([0-9a-f]+)"'
|
|
||||||
datasourceUid: tempo
|
|
||||||
url: "$${__value.raw}"
|
|
||||||
|
|
||||||
- name: Prometheus
|
- name: Prometheus
|
||||||
# UID фиксирован: на него ссылаются панели дашборда Deal-Metrics-Overview (datasource uid: prometheus).
|
# UID фиксирован: на него ссылаются панели дашборда Deal-Metrics-Overview (datasource uid: prometheus).
|
||||||
@@ -32,24 +26,3 @@ datasources:
|
|||||||
# Prometheus хранит OTel-гистограммы в нативных bucket'ах — используем нативные histogram_quantile.
|
# Prometheus хранит OTel-гистограммы в нативных bucket'ах — используем нативные histogram_quantile.
|
||||||
httpMethod: POST
|
httpMethod: POST
|
||||||
timeInterval: 15s
|
timeInterval: 15s
|
||||||
|
|
||||||
- name: Tempo
|
|
||||||
# UID фиксирован: на него ссылаются панели Deal-Traces и derivedFields логов (datasource uid: tempo).
|
|
||||||
uid: tempo
|
|
||||||
type: tempo
|
|
||||||
access: proxy
|
|
||||||
url: http://tempo:3200
|
|
||||||
isDefault: false
|
|
||||||
jsonData:
|
|
||||||
# Из спана трейса — к логам того же сервиса и traceId (обратная корреляция Loki ↔ Tempo).
|
|
||||||
tracesToLogsV2:
|
|
||||||
datasourceUid: loki
|
|
||||||
filterByTraceID: true
|
|
||||||
filterBySpanID: false
|
|
||||||
spanStartTimeShift: -1m
|
|
||||||
spanEndTimeShift: 1m
|
|
||||||
# Карта сервисов и граф узлов — из метрик Prometheus.
|
|
||||||
serviceMap:
|
|
||||||
datasourceUid: prometheus
|
|
||||||
nodeGraph:
|
|
||||||
enabled: true
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# OpenTelemetry Collector — приёмник трейсов Deal-процессов (профиль observability).
|
|
||||||
#
|
|
||||||
# Процессы шлют OTLP (gRPC :4317 / HTTP :4318) на этот сервис; коллектор батчит и перекладывает
|
|
||||||
# трейсы в Tempo (OTLP). Запускается только профилем observability (deploy/compose.prod.yml);
|
|
||||||
# наружу порты не публикуются (внутри compose-сети).
|
|
||||||
#
|
|
||||||
# Конфиг монтируется в /etc/otelcol-contrib/config.yaml. Health-эндпоинт :13133 — для healthcheck.
|
|
||||||
|
|
||||||
receivers:
|
|
||||||
otlp:
|
|
||||||
protocols:
|
|
||||||
grpc:
|
|
||||||
endpoint: 0.0.0.0:4317
|
|
||||||
http:
|
|
||||||
endpoint: 0.0.0.0:4318
|
|
||||||
|
|
||||||
processors:
|
|
||||||
# Батчинг снижает число сетевых вызовов к Tempo (стандартный приём OTel).
|
|
||||||
batch: {}
|
|
||||||
|
|
||||||
exporters:
|
|
||||||
otlp/tempo:
|
|
||||||
endpoint: tempo:4317
|
|
||||||
tls:
|
|
||||||
insecure: true
|
|
||||||
|
|
||||||
extensions:
|
|
||||||
health_check:
|
|
||||||
endpoint: 0.0.0.0:13133
|
|
||||||
|
|
||||||
service:
|
|
||||||
extensions: [health_check]
|
|
||||||
telemetry:
|
|
||||||
logs:
|
|
||||||
level: warn
|
|
||||||
pipelines:
|
|
||||||
traces:
|
|
||||||
receivers: [otlp]
|
|
||||||
processors: [batch]
|
|
||||||
exporters: [otlp/tempo]
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# Правила алертов Prometheus по потреблению ресурсов — ОТКЛЮЧЕНЫ ПО УМОЛЧАНИЮ.
|
|
||||||
#
|
|
||||||
# Заготовка: включить, добавив эту строку в `rule_files` файла prometheus.yml:
|
|
||||||
# rule_files:
|
|
||||||
# - prometheus-rules.yml
|
|
||||||
# - prometheus-resource-rules.yml
|
|
||||||
#
|
|
||||||
# Пороги выносятся в env (DEAL_ALERT_*); Prometheus не раскрывает значения env в конфиге, поэтому при
|
|
||||||
# включении файл рендерится из шаблона (подстановка порогов) или пороги проставляются вручную:
|
|
||||||
# DEAL_ALERT_HOST_CPU_PERCENT — загрузка CPU хоста, % (дефолт 90)
|
|
||||||
# DEAL_ALERT_HOST_MEMORY_PERCENT — занятая память хоста, % (дефолт 90)
|
|
||||||
# DEAL_ALERT_HOST_DISK_PERCENT — минимум свободного места, % (дефолт 15)
|
|
||||||
# DEAL_ALERT_CONTAINER_CPU — CPU контейнера (ядра) (дефолт 1.5)
|
|
||||||
# DEAL_ALERT_CONTAINER_MEMORY_PERCENT — память контейнера к лимиту, %(дефолт 90)
|
|
||||||
#
|
|
||||||
# Источники: node-exporter (хост) и cAdvisor (контейнеры), scrape — jobs node-exporter/cadvisor.
|
|
||||||
|
|
||||||
groups:
|
|
||||||
- name: deal-resources
|
|
||||||
rules:
|
|
||||||
# Хост: загрузка CPU. Дефолт порога — 90%.
|
|
||||||
- alert: DealHostHighCpu
|
|
||||||
expr: 100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90
|
|
||||||
for: 10m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "Высокая загрузка CPU хоста"
|
|
||||||
description: "Средняя загрузка CPU хоста выше 90% за 5 минут более 10 минут."
|
|
||||||
|
|
||||||
# Хост: занятая память. Дефолт порога — 90%.
|
|
||||||
- alert: DealHostHighMemory
|
|
||||||
expr: (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.9
|
|
||||||
for: 10m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "Высокое потребление памяти хостом"
|
|
||||||
description: "Свободной памяти меньше 10% более 10 минут."
|
|
||||||
|
|
||||||
# Хост: свободное место на разделе. Дефолт порога — 15%.
|
|
||||||
- alert: DealHostDiskLow
|
|
||||||
expr: |
|
|
||||||
min by (mountpoint) (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) < 0.15
|
|
||||||
for: 15m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "Мало места на диске ({{ $labels.mountpoint }})"
|
|
||||||
description: "Свободно менее 15% на разделе {{ $labels.mountpoint }} более 15 минут."
|
|
||||||
|
|
||||||
# Контейнеры Deal: CPU (ядра). Дефолт порога — 1.5.
|
|
||||||
- alert: DealContainerHighCpu
|
|
||||||
expr: |
|
|
||||||
sum by (name) (rate(container_cpu_usage_seconds_total{name=~".*deal.*|.*core.*|.*service.*"}[5m])) > 1.5
|
|
||||||
for: 10m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "Высокий CPU контейнера {{ $labels.name }}"
|
|
||||||
description: "Контейнер {{ $labels.name }} держит > 1.5 CPU за 5 минут более 10 минут."
|
|
||||||
|
|
||||||
# Контейнеры Deal: память к лимиту. Дефолт порога — 90%.
|
|
||||||
- alert: DealContainerHighMemory
|
|
||||||
expr: |
|
|
||||||
(container_memory_working_set_bytes{name=~".*deal.*|.*core.*|.*service.*"}
|
|
||||||
/ clamp_min(container_spec_memory_limit_bytes{name=~".*deal.*|.*core.*|.*service.*"}, 1)) > 0.9
|
|
||||||
for: 10m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "Контейнер {{ $labels.name }} близок к лимиту памяти"
|
|
||||||
description: "Контейнер {{ $labels.name }} использует более 90% лимита памяти более 10 минут."
|
|
||||||
@@ -79,4 +79,3 @@ groups:
|
|||||||
annotations:
|
annotations:
|
||||||
summary: "Растёт очередь обучения ML (outbox)"
|
summary: "Растёт очередь обучения ML (outbox)"
|
||||||
description: "Суммарная глубина MlOutbox держится выше 100 более 15 минут ({{ $value }})."
|
description: "Суммарная глубина MlOutbox держится выше 100 более 15 минут ({{ $value }})."
|
||||||
|
|
||||||
|
|||||||
@@ -46,18 +46,3 @@ scrape_configs:
|
|||||||
- targets: ["ml-service:9464"]
|
- targets: ["ml-service:9464"]
|
||||||
labels:
|
labels:
|
||||||
service: ml-service
|
service: ml-service
|
||||||
|
|
||||||
# Ресурсы контейнеров (cAdvisor) и хоста (node-exporter) — этап «observability-стек».
|
|
||||||
# cAdvisor отдаёт метрики контейнеров (CPU/RAM/сеть/диск), node-exporter — хоста.
|
|
||||||
- job_name: cadvisor
|
|
||||||
static_configs:
|
|
||||||
- targets: ["cadvisor:8080"]
|
|
||||||
|
|
||||||
- job_name: node-exporter
|
|
||||||
static_configs:
|
|
||||||
- targets: ["node-exporter:9100"]
|
|
||||||
|
|
||||||
# Темпо (хранилище трейсов) — само-мониторинг: метрики приёма/отдачи трейсов.
|
|
||||||
- job_name: tempo
|
|
||||||
static_configs:
|
|
||||||
- targets: ["tempo:3200"]
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
# Grafana Tempo — хранилище трейсов Deal (профиль observability).
|
|
||||||
#
|
|
||||||
# Принимает OTLP-трейсы от OpenTelemetry Collector (grpc :4317), хранит локально (volume
|
|
||||||
# deal_tempo_data), отдаёт запросы Grafana на :3200 (внутри compose-сети). Block retention — 7 суток
|
|
||||||
# (трейсы объёмны; логи/метрики живут дольше).
|
|
||||||
|
|
||||||
server:
|
|
||||||
http_listen_port: 3200
|
|
||||||
log_level: warn
|
|
||||||
|
|
||||||
distributor:
|
|
||||||
receivers:
|
|
||||||
otlp:
|
|
||||||
protocols:
|
|
||||||
grpc:
|
|
||||||
endpoint: 0.0.0.0:4317
|
|
||||||
|
|
||||||
storage:
|
|
||||||
trace:
|
|
||||||
backend: local
|
|
||||||
local:
|
|
||||||
path: /var/tempo/traces
|
|
||||||
wal:
|
|
||||||
path: /var/tempo/wal
|
|
||||||
|
|
||||||
compactor:
|
|
||||||
compaction:
|
|
||||||
block_retention: 168h
|
|
||||||
@@ -149,14 +149,8 @@
|
|||||||
"actorType": "tenant",
|
"actorType": "tenant",
|
||||||
"actorId": "1f2e3d4c-5b6a-7980-1234-56789abcdef0",
|
"actorId": "1f2e3d4c-5b6a-7980-1234-56789abcdef0",
|
||||||
"tenantId": "aabbccdd-eeff-0011-2233-445566778899",
|
"tenantId": "aabbccdd-eeff-0011-2233-445566778899",
|
||||||
"userName": "owner@example.com",
|
|
||||||
"tenantName": "ООО «Ромашка»",
|
|
||||||
"ip": "203.0.113.7",
|
"ip": "203.0.113.7",
|
||||||
"changes": [
|
"detailJson": "{\"cardId\":\"c_1a2b3c4d5e6f\",\"to\":\"planned\"}",
|
||||||
{ "field": "cardId", "to": "c_1a2b3c4d5e6f" },
|
|
||||||
{ "field": "to", "to": "planned" }
|
|
||||||
],
|
|
||||||
"detailJson": "{\"changes\":[{\"field\":\"cardId\",\"to\":\"c_1a2b3c4d5e6f\"},{\"field\":\"to\",\"to\":\"planned\"}]}",
|
|
||||||
"at": "2026-09-10T15:22:46.123Z",
|
"at": "2026-09-10T15:22:46.123Z",
|
||||||
"id": 1042
|
"id": 1042
|
||||||
}
|
}
|
||||||
@@ -168,19 +162,7 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `items` — новые сверху (`at` DESC). `total` — полное число по фильтру (без `limit`/`offset`).
|
- `items` — новые сверху (`at` DESC). `total` — полное число по фильтру (без `limit`/`offset`).
|
||||||
- `userName` — логин реального пользователя (владелец пространства либо логин попытки); `null`, если не разрешён.
|
- `detailJson` — **строка** JSON деталей события (без секретов), может быть `null`.
|
||||||
- `tenantName` — имя пространства события (join с реестром); `null`, если не разрешено.
|
|
||||||
- `changes` — человекочитаемые изменения параметров события (см. «Детали события»); `[]`, если деталей нет.
|
|
||||||
- `detailJson` — **строка** сырого JSON деталей события (без секретов) для спойлера; может быть `null`.
|
|
||||||
|
|
||||||
### Детали события (`changes`)
|
|
||||||
|
|
||||||
Детали события хранятся как JSON вида `{ "changes": [ { "field": "<код>", "from": "<было>", "to": "<стало>" } ] }`,
|
|
||||||
где `field` — стабильный код параметра, `from` — предыдущее значение (`null` — параметр задан впервые),
|
|
||||||
`to` — новое. Сериализация деталей — `AuditService.ToDetailJson`, форма изменения — `AuditDetails.Set`/
|
|
||||||
`AuditDetails.Change`. Ядро отдаёт `changes` как есть; человекочитаемые названия событий, акторов, параметров
|
|
||||||
и значений — в ресурсах интерфейса (`ru.js`: `operator.event`/`operator.actor`/`operator.field`/`operator.value`).
|
|
||||||
Прежние записи плоского формата (`{ "login": "..." }`, пары `old*`/`new*`) читаются обратно совместимо.
|
|
||||||
|
|
||||||
**Коды**: `200`, `401`.
|
**Коды**: `200`, `401`.
|
||||||
|
|
||||||
|
|||||||
+11
-47
@@ -3,7 +3,7 @@
|
|||||||
> Единый свод правил стиля кода для всего репозитория (core, telegram/ai/ml-сервисы, тесты).
|
> Единый свод правил стиля кода для всего репозитория (core, telegram/ai/ml-сервисы, тесты).
|
||||||
> Составлен на основе исходного `Стиль_кода.docx` (перенесён в `archive/style-guide-original/`),
|
> Составлен на основе исходного `Стиль_кода.docx` (перенесён в `archive/style-guide-original/`),
|
||||||
> дополнен действующими правилами проекта и `.editorconfig`. Правила обязательны для нового кода;
|
> дополнен действующими правилами проекта и `.editorconfig`. Правила обязательны для нового кода;
|
||||||
> приведение существующего — в `docs/superpowers/backlog.md` (`TD-COMMENTS-IFACE`, `TD-PROTO-COMMENTS`).
|
> приведение существующего — в `backlog.md` (`TD-COMMENTS-IFACE`, `TD-PROTO-COMMENTS`).
|
||||||
|
|
||||||
Пометки:
|
Пометки:
|
||||||
- **[изм.]** — правило дополнено/уточнено относительно исходного документа.
|
- **[изм.]** — правило дополнено/уточнено относительно исходного документа.
|
||||||
@@ -103,25 +103,6 @@
|
|||||||
- Асинхронность: суффикс `Async`, `CancellationToken` пробрасывать до конца; `.Result` / `.Wait()`
|
- Асинхронность: суффикс `Async`, `CancellationToken` пробрасывать до конца; `.Result` / `.Wait()`
|
||||||
запрещены — только `await`.
|
запрещены — только `await`.
|
||||||
|
|
||||||
### 4.1. Фабрики и билдеры
|
|
||||||
|
|
||||||
- **Нетривиальные объекты с интерфейсом создаются только фабриками.** Реализация сервиса/адаптера,
|
|
||||||
у которого есть порт-интерфейс, не создаётся прямым `new` в прикладном коде или композиционном корне —
|
|
||||||
только внутри фабрики. Форма пары: `IXxxFactory` (порт фабрики) + `XxxFactory` (реализация), метод
|
|
||||||
`Create(...)` возвращает **интерфейс** готового объекта (`ISecretCipher`, `IFileStorage`, …).
|
|
||||||
- Фабрика сама регистрируется в DI (`AddScoped`/`AddSingleton<IXxxFactory, XxxFactory>()`) — контейнер
|
|
||||||
конструирует её без `new`; зависимости фабрики — тоже DI.
|
|
||||||
- **Билдер** (`IXxxBuilder`/`XxxBuilder`) добавляется, когда объект собирается итеративно из многих частей
|
|
||||||
или опций; фабрика делегирует сборку билдеру, а не повторяет её.
|
|
||||||
- Исключения из правила (прямой `new` допустим):
|
|
||||||
- DTO, рекорды, value-объекты, `Options`/`Settings`-снимки;
|
|
||||||
- исключения (`*Exception`) и примитивы/BCL-типы (`StringBuilder`, `NpgsqlConnection`, `MinioClient`, …);
|
|
||||||
- статические классы и хэлперы без состояния (фабрику для них не заводим);
|
|
||||||
- EF-конфигурации (`IEntityTypeConfiguration`) — это метаданные модели, а не прикладные объекты;
|
|
||||||
- обёртки ресурсов без порт-интерфейса (gRPC-соединения с `IDisposable`);
|
|
||||||
- объекты без порт-интерфейса, создаваемые контейнером (`AddScoped<Concrete>()`).
|
|
||||||
- Тесты могут конструировать проверяемый тип прямым `new` — это часть самого теста, а не прикладного кода.
|
|
||||||
|
|
||||||
## 5. Комментирование кода
|
## 5. Комментирование кода
|
||||||
|
|
||||||
Все комментарии — на русском языке.
|
Все комментарии — на русском языке.
|
||||||
@@ -142,8 +123,6 @@
|
|||||||
- **`<param>`/`<returns>`** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру.
|
- **`<param>`/`<returns>`** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру.
|
||||||
- **`<summary>` — только блочный.** Открывающий `<summary>` и закрывающий `</summary>` — **каждый на
|
- **`<summary>` — только блочный.** Открывающий `<summary>` и закрывающий `</summary>` — **каждый на
|
||||||
своей строке**; запись в одну строку (`/// <summary>текст</summary>`) **не допускается**. **[изм.]**
|
своей строке**; запись в одну строку (`/// <summary>текст</summary>`) **не допускается**. **[изм.]**
|
||||||
- **Конструкторы не документируем** — `<summary>`/`<param>` на них не нужны: назначение очевидно из
|
|
||||||
типа и сигнатуры. В частности, не документируем конструкторы классов, реализующих интерфейс. **[изм.]**
|
|
||||||
|
|
||||||
Правильно:
|
Правильно:
|
||||||
```csharp
|
```csharp
|
||||||
@@ -242,37 +221,21 @@
|
|||||||
|
|
||||||
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
|
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
|
||||||
- При пробрасывании выше — `throw;`, а **не** `throw ex;`.
|
- При пробрасывании выше — `throw;`, а **не** `throw ex;`.
|
||||||
- **Свои доменные исключения наследовать от `DealException`** (`Deal.SharedKernel.Errors`) — базовый тип
|
- Свои исключения наследовать от `Exception`.
|
||||||
хранит код ошибки (`ErrorCode`) и умеет брать текст из ресурсов. Состав: `NotFoundException`,
|
|
||||||
`ValidationException`, `ConflictException`, `ServiceUnavailableException`; новые — по тому же образцу.
|
|
||||||
- **Не возвращать `null` как штатный результат «не найдено»/ошибки.** Доменный сервис, у которого объект
|
|
||||||
не найден, бросает `NotFoundException` (эндпоинт отдаёт 404 через общий обработчик, а не проверкой
|
|
||||||
`is null` в каждом хендлере). `null` допустим только для **опциональных значений** — парсеры/извлечение
|
|
||||||
полей, выборки-запросы («нет строки» — нормальный результат), `Try*`-паттерн; такие методы должны быть
|
|
||||||
nullable-аннотированы и явно описаны в XML-doc.
|
|
||||||
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
|
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
|
||||||
БД, неизвестные идентификаторы и т.п.).
|
БД, неизвестные идентификаторы и т.п.).
|
||||||
- Все исключения должны быть залогированы или показаны пользователю; **пустые `catch` запрещены**.
|
- Все исключения должны быть залогированы или показаны пользователю; пустые `catch` запрещены.
|
||||||
- **Единый формат лога ошибки:** понятный русский текст + структурированный контекст (операция, `tenantId`,
|
- В лог об ошибке, как правило, писать `StackTrace`.
|
||||||
id сущности, `traceId`). Стектрейс пишется **только в лог**; в ответ/сообщение клиенту он не попадает —
|
|
||||||
наружу отдаётся обобщённый текст и код (обработчики на границах: `DealExceptionHandler`, gRPC-интерцептор).
|
|
||||||
- **Тексты исключений/ошибок не хардкодить** — держать в ресурсах (`ErrorMessages.resx`, доступ через
|
|
||||||
`ErrorResources.Format(ErrorResourceKeys.*)` и шаблоны `DealException`), чтобы переводы добавлялись
|
|
||||||
отдельной культурой (`.resx`-спутник) без правок кода.
|
|
||||||
|
|
||||||
## 11. Интерфейсы
|
## 11. Интерфейсы
|
||||||
|
|
||||||
- **В реализациях интерфейсов XML-doc не пишем вообще.** Если тип или член объявлен в интерфейсе,
|
- **Не дублировать `<summary>` интерфейса в реализации.** Если член объявлен в интерфейсе с XML-doc,
|
||||||
класс-реализация не документируется: ни `<summary>`, ни `<inheritdoc/>` (и ни `<param>` на
|
в классе-реализации достаточно `/// <inheritdoc/>` (или вообще ничего, если doc наследуется настройкой).
|
||||||
конструкторе). Описание живёт **один раз** — в интерфейсе; реализации вызываются только через порт.
|
Текст описания пишется **один раз** — у интерфейса.
|
||||||
Под этот запрет попадает и сам класс-реализация (его `<summary>` тоже лишний — есть у интерфейса).
|
|
||||||
- **XML-doc уместен только там, где нет интерфейса:** public-типы/члены без порта (статика, константы,
|
|
||||||
extension-классы), `protected`-члены и DTO/модели. **[изм. 2026-09-13, решение владельца]**
|
|
||||||
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
|
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
|
||||||
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
|
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
|
||||||
(напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные
|
(напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные
|
||||||
реализации (codemod'ы `scripts/make_explicit.py` и `scripts/strip_implementation_docs.py` — идемпотентны,
|
реализации (codemod `scripts/make_explicit.py`, идемпотентный).
|
||||||
`--apply` применяет правки, без флага — dry-run-отчёт).
|
|
||||||
- Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа.
|
- Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа.
|
||||||
- **Маркерные классы не используются** — если нужен маркер, это маркерный интерфейс
|
- **Маркерные классы не используются** — если нужен маркер, это маркерный интерфейс
|
||||||
(`IKanbanModule`, `ISharedKernel` и т.п.). **[изм. 2026-09-11]**
|
(`IKanbanModule`, `ISharedKernel` и т.п.). **[изм. 2026-09-11]**
|
||||||
@@ -305,8 +268,9 @@
|
|||||||
|
|
||||||
## 13. Автоматизация
|
## 13. Автоматизация
|
||||||
|
|
||||||
- **Служебные скрипты (codemod'ы, скрипты сборки/тестов/бэкапов) в репозиторий не входят** —
|
- **Исправление существующего кода** (идемпотентные скрипты в `scripts/`):
|
||||||
правило владельца: в репе только код. Актуальные копии живут локально, вне кода.
|
- `fix_summary_blocks.py --check | --apply` — приводит `<summary>` к блочному виду (§5).
|
||||||
|
- `fix_private_docs.py --check | --preview | --apply` — понижает XML-док с private/internal до `//` (§5).
|
||||||
- **Проверка на новом коде**: правила `<summary>`-блока и «комментарии только на public» проверяемы
|
- **Проверка на новом коде**: правила `<summary>`-блока и «комментарии только на public» проверяемы
|
||||||
статически; задел — линтер (по аналогии с `scripts/i18n-lint.mjs`) и/или анализаторы Roslyn/StyleCop в
|
статически; задел — линтер (по аналогии с `scripts/i18n-lint.mjs`) и/или анализаторы Roslyn/StyleCop в
|
||||||
`Directory.Build.props`.
|
`Directory.Build.props`.
|
||||||
|
|||||||
@@ -32,14 +32,6 @@
|
|||||||
> Сборка 5 sln 0/0; тесты: core **1340/1340**, telegram **130/130**, ai **52/52**, ml **38/38**, storage **9/9** — зелёные.
|
> Сборка 5 sln 0/0; тесты: core **1340/1340**, telegram **130/130**, ai **52/52**, ml **38/38**, storage **9/9** — зелёные.
|
||||||
> Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md`, §2.
|
> Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md`, §2.
|
||||||
>
|
>
|
||||||
> **2026-09-11 (ночь) — Gitea-контур.** Репозиторий запушен (`gitea.khomegeneric.keenetic.pro/rust/Deal`),
|
|
||||||
> ~4000 спам-пользователей вычищено, регистрация закрыта владельцем. CI перенесён в
|
|
||||||
> `.gitea/workflows/ci.yml`; первый прогон поймал и закрыл кроссплатформенный баг
|
|
||||||
> (`ModelPool` валидировал tenant-id через `GetInvalidFileNameChars` — на Linux пропускал `\`);
|
|
||||||
> валидация заменена на явный белый список. **`scripts/` исключён из репозитория** (правило владельца:
|
|
||||||
> в репе только код) — скрипты сборки/тестов/бэкапов/кодмоды живут только локально; CI-workflow
|
|
||||||
> переписан inline. Раннер для CI — на сервере рядом с Gitea (пакет `deploy/gitea-runner/`).
|
|
||||||
>
|
|
||||||
> **2026-09-11 (поздний вечер) — явные реализации интерфейсов (вариант A, решение владельца).** Правило
|
> **2026-09-11 (поздний вечер) — явные реализации интерфейсов (вариант A, решение владельца).** Правило
|
||||||
> владельца: классы напрямую не вызываем (исключения — DTO, хелперы, экстеншены), тесты — через
|
> владельца: классы напрямую не вызываем (исключения — DTO, хелперы, экстеншены), тесты — через
|
||||||
> интерфейсы, моки — NSubstitute, маркерные классы не используем. Сделано: 161 член в 30 прод-файлах
|
> интерфейсы, моки — NSubstitute, маркерные классы не используем. Сделано: 161 член в 30 прод-файлах
|
||||||
@@ -164,7 +156,7 @@ telegram **125/125**; фронт `npm run build` зелёный (main-чанк 3
|
|||||||
|
|
||||||
## Заделы (этап 13+; подробно — техдок §11 и roadmap)
|
## Заделы (этап 13+; подробно — техдок §11 и roadmap)
|
||||||
|
|
||||||
> **Единый источник отложенного и техдолга — `docs/superpowers/backlog.md`.** Ниже — краткая выжимка.
|
> **Единый источник отложенного и техдолга — `backlog.md` в корне.** Ниже — краткая выжимка.
|
||||||
|
|
||||||
- **Этап 11 — Локализация интерфейса (i18n)** — **выполнен** (2026-09-10, урезанный объём): все
|
- **Этап 11 — Локализация интерфейса (i18n)** — **выполнен** (2026-09-10, урезанный объём): все
|
||||||
пользовательские строки фронта в ресурсах (`src/frontend/src/i18n/`, 1039 ключей в 13 областях),
|
пользовательские строки фронта в ресурсах (`src/frontend/src/i18n/`, 1039 ключей в 13 областях),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
| Файлы | MinIO (S3-совместимое хранилище) |
|
| Файлы | MinIO (S3-совместимое хранилище) |
|
||||||
| Фронтенд | Vue 3 + Vite + Tailwind |
|
| Фронтенд | Vue 3 + Vite + Tailwind |
|
||||||
| Межсервисно | gRPC + Protobuf (mTLS — за флагом `DEAL_MTLS_*`, §10/§13.8) |
|
| Межсервисно | gRPC + Protobuf (mTLS — за флагом `DEAL_MTLS_*`, §10/§13.8) |
|
||||||
| Наблюдаемость | Serilog (JSON: консоль + rolling-файл) → Promtail → Loki → Grafana; метрики OTel → Prometheus; трейсы OTel → Collector → Tempo; ресурсы cAdvisor/node-exporter → Prometheus; единый UI — Grafana |
|
| Наблюдаемость | Serilog (JSON: консоль + rolling-файл) → Promtail → Loki → Grafana; метрики OTel → Prometheus → Grafana |
|
||||||
| Прокси/edge | Caddy (TLS, security-заголовки); Cloudflare/k8s — вне этапа (§10/§11) |
|
| Прокси/edge | Caddy (TLS, security-заголовки); Cloudflare/k8s — вне этапа (§10/§11) |
|
||||||
| Контейнеры | Docker / docker compose (VPS); k8s — позже |
|
| Контейнеры | Docker / docker compose (VPS); k8s — позже |
|
||||||
| Бэкапы | Ежедневные: pg_dump + MinIO + сессии |
|
| Бэкапы | Ежедневные: pg_dump + MinIO + сессии |
|
||||||
@@ -236,9 +236,7 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
|
|||||||
(нужен `DEAL_GRAFANA_ADMIN_PASSWORD` в `.env.prod`; порты Grafana/Prometheus — только loopback). Остановка —
|
(нужен `DEAL_GRAFANA_ADMIN_PASSWORD` в `.env.prod`; порты Grafana/Prometheus — только loopback). Остановка —
|
||||||
`docker compose -f deploy/compose.prod.yml --profile observability down`.
|
`docker compose -f deploy/compose.prod.yml --profile observability down`.
|
||||||
- **Провижининг Grafana — как код** (`deploy/observability/grafana/provisioning`, монтируется в
|
- **Провижининг Grafana — как код** (`deploy/observability/grafana/provisioning`, монтируется в
|
||||||
контейнер): `datasources/datasources.yml` — датасорсы Loki (uid `loki`, default), Prometheus
|
контейнер): `datasources/datasources.yml` — датасорс Loki (uid `loki`, URL `http://loki:3100`);
|
||||||
(uid `prometheus`) и Tempo (uid `tempo`); у Loki — `derivedFields` TraceID → Tempo (клик по traceId
|
|
||||||
в логе открывает трейс), у Tempo — `tracesToLogsV2` → Loki и `serviceMap`/`nodeGraph` по метрикам;
|
|
||||||
`dashboards/dashboards.yml` — папка `Дейл` из `/var/lib/grafana/dashboards`. Дашборды — файлы
|
`dashboards/dashboards.yml` — папка `Дейл` из `/var/lib/grafana/dashboards`. Дашборды — файлы
|
||||||
`deploy/observability/grafana/dashboards/*.json`: правки только в репозитории, UI-изменения не
|
`deploy/observability/grafana/dashboards/*.json`: правки только в репозитории, UI-изменения не
|
||||||
сохраняются (`allowUiUpdates: false`).
|
сохраняются (`allowUiUpdates: false`).
|
||||||
@@ -253,10 +251,7 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
|
|||||||
access-лога core) и активация инвайтов (`/api/join`);
|
access-лога core) и активация инвайтов (`/api/join`);
|
||||||
- `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента;
|
- `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента;
|
||||||
- `Deal-Rps` — нагрузка HTTP+gRPC (RPS), top-путей/методов и p50/p95 длительности запроса;
|
- `Deal-Rps` — нагрузка HTTP+gRPC (RPS), top-путей/методов и p50/p95 длительности запроса;
|
||||||
- `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram);
|
- `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram).
|
||||||
- `Deal-Traces` — поиск трейсов (Tempo, TraceQL), спаны по сервисам, переход к логам по traceId;
|
|
||||||
- `Deal-Resources` — потребление ресурсов контейнерами (cAdvisor) и хостом (node-exporter): CPU/RAM,
|
|
||||||
свободное место на дисках.
|
|
||||||
**Актор в логах:** с BL-LOG-ACTOR access-лог включает `actor` (login пользователя/оператора) и
|
**Актор в логах:** с BL-LOG-ACTOR access-лог включает `actor` (login пользователя/оператора) и
|
||||||
`tenant`; полная лента действий с деталями — `public.audit_log` (append-only) через
|
`tenant`; полная лента действий с деталями — `public.audit_log` (append-only) через
|
||||||
`GET /api/operator/audit` / экран «Аудит» оператор-консоли.
|
`GET /api/operator/audit` / экран «Аудит» оператор-консоли.
|
||||||
@@ -300,37 +295,9 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
|
|||||||
`deal_ml_outbox_depth`), пропажа метрик ядра (`absent(deal_sessions_active)`). Замечание: правила
|
`deal_ml_outbox_depth`), пропажа метрик ядра (`absent(deal_sessions_active)`). Замечание: правила
|
||||||
бюджета токенов нет — метрика бюджета в Prometheus отсутствует (см. §6/§10), поэтому алерт не вводится.
|
бюджета токенов нет — метрика бюджета в Prometheus отсутствует (см. §6/§10), поэтому алерт не вводится.
|
||||||
- Как поднять/проверить: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml
|
- Как поднять/проверить: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml
|
||||||
--profile observability up -d` → Prometheus `/targets` (все UP) → Grafana → папка «Дейл» →
|
--profile observability up -d` → Prometheus `/targets` (все 4 UP) → Grafana → папка «Дейл» →
|
||||||
`Deal-Metrics-Overview`/`Deal-Resources`/`Deal-Traces`. Быстрая проверка экспортёра без Grafana:
|
`Deal-Metrics-Overview`. Быстрая проверка экспортёра без Grafana: `curl http://<процесс>:9464/metrics`
|
||||||
`curl http://<процесс>:9464/metrics` изнутри сети.
|
изнутри сети.
|
||||||
|
|
||||||
### Трейсы (OpenTelemetry Collector + Tempo)
|
|
||||||
|
|
||||||
- **Экспорт из 5 процессов**: OpenTelemetry SDK → OTLP → **OpenTelemetry Collector** (`otel-collector:
|
|
||||||
4317`) → **Tempo** (`tempo:4317`, хранилище трейсов, retention 7 суток). Настройка — общая в
|
|
||||||
`Deal.Grpc.Hosting` (`DealTracingHosting`) для telegram/ai/ml/storage и `Deal.Api/Observability/
|
|
||||||
DealTracingHosting.cs` для ядра. Инструментируется входящий HTTP/gRPC (AspNetCore), исходящие
|
|
||||||
HTTP-клиенты и gRPC-клиенты (GrpcNetClient) — трейсы сквозные от входа до БД/внешних сервисов.
|
|
||||||
- **Включение — опт-ин через env** `OTEL_EXPORTER_OTLP_ENDPOINT` (адрес коллектора, напр.
|
|
||||||
`http://otel-collector:4317`); без него трейсинг выключен. В compose env задан пустым
|
|
||||||
(`${DEAL_OTEL_ENDPOINT:-}`) — чтобы включить, задайте `DEAL_OTEL_ENDPOINT` в `.env`. Имя сервиса в
|
|
||||||
трейсах — `OTEL_SERVICE_NAME` (дефолт по процессу: `core`, `telegram-service`, `ai-service`,
|
|
||||||
`ml-service`, `storage-service`).
|
|
||||||
- **Корреляция с логами**: Serilog обогащается `TraceId`/`SpanId` из `Activity.Current`
|
|
||||||
(`TraceContextEnricher`) — в Loki-логе есть `TraceId`, а датасорс Loki `derivedFields` даёт переход
|
|
||||||
из лога в трейс Tempo (и обратно — `tracesToLogsV2`).
|
|
||||||
- Сервисы профиля: `otel-collector` (`otel/opentelemetry-collector-contrib:0.160.0`, конфиг
|
|
||||||
`deploy/observability/otel-collector.yml`) и `tempo` (`grafana/tempo:2.8.1`, конфиг
|
|
||||||
`deploy/observability/tempo.yml`, volume `deal_tempo_data`). Наружу порты не публикуются (dev — для отладки).
|
|
||||||
|
|
||||||
### Ресурсы (cAdvisor + node-exporter)
|
|
||||||
|
|
||||||
- **cAdvisor** (`gcr.io/cadvisor/cadvisor:v0.52.1`) — потребление ресурсов **контейнерами**
|
|
||||||
(CPU/RAM/сеть/диск); **node-exporter** (`prom/node-exporter:v1.9.1`) — ресурсы **хоста** (CPU/RAM/
|
|
||||||
диски/сеть). Оба scrape'ит Prometheus (jobs `cadvisor`, `node-exporter` в `prometheus.yml`).
|
|
||||||
- Дашборд `Deal-Resources` (uid `deal-resources`): CPU/RAM контейнеров, CPU/RAM хоста, свободное место
|
|
||||||
на дисках. Правила алертов по ресурсам — в отдельном файле `prometheus-resource-rules.yml`,
|
|
||||||
**отключены по умолчанию** (не входят в `rule_files`); пороги — через env `DEAL_ALERT_*` при включении.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -363,11 +330,10 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
|
|||||||
- Одна внутренняя сеть; наружу — только **caddy** (80/443): TLS (шапка `deploy/caddy/Caddyfile` —
|
- Одна внутренняя сеть; наружу — только **caddy** (80/443): TLS (шапка `deploy/caddy/Caddyfile` —
|
||||||
`tls internal` для dev/интранет, для реального домена заменить на Cloudflare-origin/сертификаты),
|
`tls internal` для dev/интранет, для реального домена заменить на Cloudflare-origin/сертификаты),
|
||||||
статика `src/frontend/dist`, `reverse_proxy /api → core:5080`, security-заголовки (CSP/HSTS — здесь).
|
статика `src/frontend/dist`, `reverse_proxy /api → core:5080`, security-заголовки (CSP/HSTS — здесь).
|
||||||
- `core` (:5080 http + :5082 gRPC-ингресс), `telegram/ai/ml/storage-service` (mTLS-env, Ruling 6),
|
- `core` (:5080 http + :5082 gRPC-ингресс), `telegram/ai/ml-service` (mTLS-env, Ruling 6),
|
||||||
`postgres`/`minio` **без host-портов**; healthcheck'и — `grpc_health_probe` (при mTLS — TLS-проба с
|
`postgres`/`minio` **без host-портов**; healthcheck'и — `grpc_health_probe` (при mTLS — TLS-проба с
|
||||||
PEM `deal-client.crt/.key`)/`pg_isready`.
|
PEM `deal-client.crt/.key`)/`pg_isready`.
|
||||||
- Профиль `observability`: `otel-collector`/`tempo` (трейсы), `loki`/`promtail` (логи),
|
- Профиль `observability`: `loki`/`promtail`/`grafana` + `prometheus` (метрики — этап 12, пакет A; см. §7).
|
||||||
`prometheus`/`cadvisor`/`node-exporter` (метрики и ресурсы), `grafana` (UI); см. §7.
|
|
||||||
Секреты — только из `.env.prod`
|
Секреты — только из `.env.prod`
|
||||||
(шаблон `deploy/.env.prod.example`, без дефолтных паролей; отсутствие → fail-fast `:?`).
|
(шаблон `deploy/.env.prod.example`, без дефолтных паролей; отсутствие → fail-fast `:?`).
|
||||||
Rate limiting включён (`RateLimit__Enabled: true`), CORS — явный `Security__AllowedOrigins`
|
Rate limiting включён (`RateLimit__Enabled: true`), CORS — явный `Security__AllowedOrigins`
|
||||||
@@ -1186,12 +1152,11 @@ docker compose -f deploy/compose.dev.yml down # погасить ст
|
|||||||
(RpcCallLoggingInterceptor; gRPC-health не логируется). **Метрики** — OTel → Prometheus: `/metrics`
|
(RpcCallLoggingInterceptor; gRPC-health не логируется). **Метрики** — OTel → Prometheus: `/metrics`
|
||||||
(HTTP/1.1 :9464) + прикладные `deal.*` (токены/вызовы AI/ML, аудит, глубины очередей, сессии) — см. §7.
|
(HTTP/1.1 :9464) + прикладные `deal.*` (токены/вызовы AI/ML, аудит, глубины очередей, сессии) — см. §7.
|
||||||
PROD-стек: docker-логи → Promtail → Loki (retention 7 сут.) → Grafana (`127.0.0.1:3001`, SSH-туннель),
|
PROD-стек: docker-логи → Promtail → Loki (retention 7 сут.) → Grafana (`127.0.0.1:3001`, SSH-туннель),
|
||||||
метрики → Prometheus (`127.0.0.1:9090`) → Grafana, трейсы OTel → otel-collector → Tempo, ресурсы
|
метрики → Prometheus (`127.0.0.1:9090`) → Grafana; профиль `observability` compose.prod.
|
||||||
cAdvisor/node-exporter → Prometheus; профиль `observability` compose.prod.
|
|
||||||
- **compose.prod** (Ruling 9): `deploy/compose.prod.yml` — postgres/minio (без host-портов), core + telegram/ai/ml
|
- **compose.prod** (Ruling 9): `deploy/compose.prod.yml` — postgres/minio (без host-портов), core + telegram/ai/ml
|
||||||
(mTLS env; healthcheck — `grpc_health_probe`, при mTLS — TLS-проба с PEM), `caddy` (80/443: статика
|
(mTLS env; healthcheck — `grpc_health_probe`, при mTLS — TLS-проба с PEM), `caddy` (80/443: статика
|
||||||
`src/frontend/dist` + `reverse_proxy /api → core:5080`, security-заголовки; домен/TLS/Cloudflare — шапка
|
`src/frontend/dist` + `reverse_proxy /api → core:5080`, security-заголовки; домен/TLS/Cloudflare — шапка
|
||||||
`deploy/caddy/Caddyfile`), профиль `observability` (otel-collector/tempo/loki/promtail/prometheus/cadvisor/node-exporter/grafana). Секреты — только из `.env.prod`
|
`deploy/caddy/Caddyfile`), профиль `observability` (loki/promtail/grafana/prometheus). Секреты — только из `.env.prod`
|
||||||
(шаблон `deploy/.env.prod.example`, без дефолтных паролей, fail-fast `:?`). Запуск:
|
(шаблон `deploy/.env.prod.example`, без дефолтных паролей, fail-fast `:?`). Запуск:
|
||||||
`docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build` (+ `--profile observability`);
|
`docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build` (+ `--profile observability`);
|
||||||
авто-проверка — `... config` rc=0.
|
авто-проверка — `... config` rc=0.
|
||||||
@@ -1340,8 +1305,7 @@ docker compose -f deploy/compose.dev.yml start core telegram-service ml-service
|
|||||||
(`container_created`, `container_updated`, `container_deleted`), `settings_updated`, `channel_enabled`,
|
(`container_created`, `container_updated`, `container_deleted`), `settings_updated`, `channel_enabled`,
|
||||||
`telegram_linked` (таблица — в контракте; `channel_created` зарезервирован, но не эмитится).
|
`telegram_linked` (таблица — в контракте; `channel_created` зарезервирован, но не эмитится).
|
||||||
- **Наблюдаемость** (Grafana provisioning + promtail-лейблы, дашборды `Deal-Auth/Errors/Rps/Logs`; с этапа 12 —
|
- **Наблюдаемость** (Grafana provisioning + promtail-лейблы, дашборды `Deal-Auth/Errors/Rps/Logs`; с этапа 12 —
|
||||||
метрики OTel → Prometheus и дашборд `Deal-Metrics-Overview`; трейсы OTel → Collector → Tempo (`Deal-Traces`)
|
метрики OTel → Prometheus и дашборд `Deal-Metrics-Overview`, см. §7).
|
||||||
и ресурсы cAdvisor/node-exporter (`Deal-Resources`), см. §7).
|
|
||||||
- **Как открыть (dev):** `docker compose -f deploy/compose.dev.yml up -d --build` (или core на `:5080`
|
- **Как открыть (dev):** `docker compose -f deploy/compose.dev.yml up -d --build` (или core на `:5080`
|
||||||
с Postgres `:5433`, AI в Local-режиме) → фронт `cd src/frontend && npm run dev` (`:5173`, прокси `/api`)
|
с Postgres `:5433`, AI в Local-режиме) → фронт `cd src/frontend && npm run dev` (`:5173`, прокси `/api`)
|
||||||
→ **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production —
|
→ **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production —
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# backup.sh — ежедневный бэкап «Дейла» (этап 7, Task 15; Ruling 8).
|
||||||
|
#
|
||||||
|
# Покрывает 4 источника данных Ruling 8:
|
||||||
|
# 1) Postgres (БД deal: схемы public + tenant_*) — pg_dump -Fc (custom, сжатие) →
|
||||||
|
# $BACKUP_DIR/pg/backup-YYYYMMDD-HHMMSS.dump. По умолчанию дамп снимается внутри контейнера
|
||||||
|
# (docker exec deal-postgres, локальный socket — пароль не нужен); при заданном DEAL_PG_HOST —
|
||||||
|
# прямое pg_dump с хоста (DEAL_PG_PORT / DEAL_PG_USER / DEAL_PG_PASSWORD).
|
||||||
|
# 2) MinIO (бакет deal-files — вложения карточек) — mc mirror бакета →
|
||||||
|
# $BACKUP_DIR/minio/backup-YYYYMMDD-HHMMSS/ (бэкап = выгрузка ИЗ MinIO в BACKUP_DIR).
|
||||||
|
# Исполнение mc: клиент с хоста, если есть в PATH; иначе — разовый контейнер minio/mc
|
||||||
|
# (DEAL_MC_IMAGE) в docker-сети контейнера MinIO. Секреты передаются env-алиасом MC_HOST_deal,
|
||||||
|
# mc-конфиг на диск не пишется. При Local-хранилище файлов (MinIO не поднят) — DEAL_MINIO_SKIP=1.
|
||||||
|
# 3) файловые данные на хосте — tar относительных каталогов DEAL_TAR_DIRS внутри DEAL_DATA_DIR
|
||||||
|
# (по умолчанию: attachments, telegram_sessions, ml) →
|
||||||
|
# $BACKUP_DIR/data/backup-YYYYMMDD-HHMMSS.tar.gz. Для развёртывания на docker-томах задайте
|
||||||
|
# DEAL_TAR_VOLUMES (список имён volume'ов) — тар выполнит busybox-контейнер (Ruling 8).
|
||||||
|
# 4) retention — удаление снапшотов старше RETENTION_DAYS дней (дата YYYYMMDD из имени файла/каталога,
|
||||||
|
# по умолчанию 14; при ежедневном запуске хранится ~15 копий — эквивалент find -mtime +N).
|
||||||
|
#
|
||||||
|
# Планировщик — ВНЕ контейнера (Ruling 8): скрипт сам ничего не ставит. Запуск — от пользователя
|
||||||
|
# с доступом к docker. Примеры:
|
||||||
|
# cron (ежедневно в 02:00; «0 2 * * *»):
|
||||||
|
# 0 2 * * * /opt/deal/scripts/backup.sh >> /opt/deal/data/backups/cron.log 2>&1
|
||||||
|
# systemd (аналог):
|
||||||
|
# [Unit] Description=Deal daily backup
|
||||||
|
# [Timer] OnCalendar=*-*-* 02:00:00
|
||||||
|
# Persistent=true
|
||||||
|
# [Install] WantedBy=timers.target
|
||||||
|
# [Service] (unit backup.service) Type=oneshot; ExecStart=/opt/deal/scripts/backup.sh
|
||||||
|
# prod (VPS): секреты из deploy/.env.prod (MINIO_ROOT_USER/MINIO_ROOT_PASSWORD/DEAL_PG_PASSWORD
|
||||||
|
# читаются скриптом); BACKUP_DIR вынести из data/. compose.prod НЕ публикует порты MinIO —
|
||||||
|
# хостовый mc не достанет MinIO: mc исполняется docker-контейнером (дефолт), endpoint по умолчанию
|
||||||
|
# http://minio:9000 (алиас compose-сервиса; deal-minio в prod-сети нет). Нестандартная схема —
|
||||||
|
# DEAL_MINIO_ENDPOINT:
|
||||||
|
# 0 2 * * * cd /opt/deal && BACKUP_DIR=/var/backups/deal \
|
||||||
|
# bash scripts/backup.sh >> /var/backups/deal/cron.log 2>&1
|
||||||
|
# dev: хостовый mc + опубликованный порт 9000 → http://localhost:9000 (дефолт host-режима);
|
||||||
|
# docker-режим в dev — тоже http://minio:9000 (алиас сервиса compose.dev).
|
||||||
|
#
|
||||||
|
# Код возврата: 0 — все шаги ok (MinIO — осознанно пропущен при DEAL_MINIO_SKIP=1); 1 — сбой любого шага.
|
||||||
|
# Лог — консоль и $BACKUP_DIR/logs/backup-YYYYMM.log. Секреты в лог не попадают.
|
||||||
|
# Требования: bash, GNU date (coreutils), docker (шаги БД/томов/MinIO-docker), mc (MinIO с хоста).
|
||||||
|
# Полный список env — scripts/deal-backup-lib.sh и техдок §13.9. Запускать НЕ параллельно.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Общие env-дефолты и хелперы (BACKUP_DIR, RETENTION_DAYS, resolve_pg_container, select_mc_mode, mc_cmd…)
|
||||||
|
# shellcheck source=deal-backup-lib.sh
|
||||||
|
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/deal-backup-lib.sh"
|
||||||
|
|
||||||
|
# --- Валидация и подготовка ----------------------------------------------------------
|
||||||
|
case "$RETENTION_DAYS" in
|
||||||
|
'' | *[!0-9]*)
|
||||||
|
die "RETENTION_DAYS должно быть целым числом дней (сейчас: '$RETENTION_DAYS')"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
TS=$(date +%Y%m%d-%H%M%S)
|
||||||
|
mkdir -p "$BACKUP_DIR" "$BACKUP_DIR/logs" "$BACKUP_DIR/pg" "$BACKUP_DIR/minio" "$BACKUP_DIR/data"
|
||||||
|
|
||||||
|
# trap-очистка: незавершённые артефакты ТОЛЬКО текущего прогона (имя с суффиксом .part)
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$BACKUP_DIR/pg/.backup-$TS.dump.part" \
|
||||||
|
"$BACKUP_DIR/minio/.backup-$TS.part" \
|
||||||
|
"$BACKUP_DIR/data/backup-$TS"*.part 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap 'exit 130' INT TERM
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# --- Шаги ----------------------------------------------------------------------------
|
||||||
|
backup_pg() { # Postgres: pg_dump -Fc (public + tenant_*)
|
||||||
|
local part final
|
||||||
|
part="$BACKUP_DIR/pg/.backup-$TS.dump.part"
|
||||||
|
final="$BACKUP_DIR/pg/backup-$TS.dump"
|
||||||
|
if [ -n "$DEAL_PG_HOST" ]; then
|
||||||
|
have pg_dump || die "задан DEAL_PG_HOST='$DEAL_PG_HOST', но pg_dump не найден в PATH"
|
||||||
|
log "Postgres: прямое pg_dump $DEAL_PG_HOST:$DEAL_PG_PORT/$DEAL_PG_DB (user: $DEAL_PG_USER)"
|
||||||
|
PGPASSWORD="$DEAL_PG_PASSWORD" pg_dump -h "$DEAL_PG_HOST" -p "$DEAL_PG_PORT" \
|
||||||
|
-U "$DEAL_PG_USER" -d "$DEAL_PG_DB" -Fc -f "$part" || die "pg_dump завершился с ошибкой"
|
||||||
|
else
|
||||||
|
docker_ok || die "Docker недоступен и DEAL_PG_HOST не задан: укажите DEAL_PG_HOST/DEAL_PG_PORT/DEAL_PG_USER (и DEAL_PG_PASSWORD) для прямого pg_dump"
|
||||||
|
resolve_pg_container \
|
||||||
|
|| die "контейнер Postgres не найден (искал deal-postgres и compose-сервис 'postgres'); задайте DEAL_PG_CONTAINER или DEAL_PG_HOST"
|
||||||
|
log "Postgres: docker exec $PG_CONTAINER pg_dump -Fc (БД $DEAL_PG_DB)"
|
||||||
|
docker exec "$PG_CONTAINER" pg_dump -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" -Fc >"$part" \
|
||||||
|
|| die "pg_dump (docker exec) завершился с ошибкой"
|
||||||
|
fi
|
||||||
|
[ -s "$part" ] || die "pg_dump не дал данных (файл пуст) — бэкап БД прерван"
|
||||||
|
mv -f "$part" "$final" || die "не удалось переместить $part"
|
||||||
|
log "Postgres: OK $final ($(du -h "$final" | cut -f1))"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_minio() { # MinIO: mc mirror бакета deal-files → BACKUP_DIR/minio/backup-<TS>/
|
||||||
|
local part final
|
||||||
|
select_mc_mode
|
||||||
|
[ "$MC_MODE" = "skip" ] && return 0
|
||||||
|
part="$BACKUP_DIR/minio/.backup-$TS.part"
|
||||||
|
final="$BACKUP_DIR/minio/backup-$TS"
|
||||||
|
rm -rf "$part"
|
||||||
|
# Пустой бакет: mc mirror целевую директорию не создаёт (копировать нечего) —
|
||||||
|
# готовим $part заранее, чтобы снапшот-каталог существовал и mv ниже не упал.
|
||||||
|
mkdir -p "$part"
|
||||||
|
log "MinIO: mc mirror $DEAL_MINIO_BUCKET → $final (режим mc: $MC_MODE)"
|
||||||
|
mc_cmd mirror --overwrite "deal/$DEAL_MINIO_BUCKET" "$part" \
|
||||||
|
|| die "mc mirror завершился с ошибкой"
|
||||||
|
mv -f "$part" "$final" || die "не удалось переместить $part"
|
||||||
|
log "MinIO: OK $final ($(du -sh "$final" | cut -f1))"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_data() { # файловые данные: docker-volume'ы (DEAL_TAR_VOLUMES) или host-каталоги DEAL_TAR_DIRS
|
||||||
|
local vol part final d list missing
|
||||||
|
if [ -n "$DEAL_TAR_VOLUMES" ]; then
|
||||||
|
docker_ok || die "задан DEAL_TAR_VOLUMES, но Docker недоступен"
|
||||||
|
for vol in $DEAL_TAR_VOLUMES; do
|
||||||
|
docker volume inspect "$vol" >/dev/null 2>&1 \
|
||||||
|
|| die "docker volume '$vol' не существует (docker volume ls)"
|
||||||
|
part="$BACKUP_DIR/data/backup-$TS.$vol.tar.gz.part"
|
||||||
|
final="$BACKUP_DIR/data/backup-$TS.$vol.tar.gz"
|
||||||
|
log "Data: tar docker volume $vol (busybox) → $final"
|
||||||
|
docker run --rm \
|
||||||
|
-v "$vol:/data:ro" -v "$(host_docker_path "$BACKUP_DIR/data"):/out" \
|
||||||
|
busybox tar -czf "/out/backup-$TS.$vol.tar.gz.part" -C /data . \
|
||||||
|
|| die "tar docker volume '$vol' завершился с ошибкой"
|
||||||
|
mv -f "$part" "$final" || die "не удалось переместить $part"
|
||||||
|
log "Data: OK $final ($(du -h "$final" | cut -f1))"
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -d "$DEAL_DATA_DIR" ] || die "DEAL_DATA_DIR='$DEAL_DATA_DIR' не существует"
|
||||||
|
list=""
|
||||||
|
missing=""
|
||||||
|
for d in $DEAL_TAR_DIRS; do
|
||||||
|
if [ -d "$DEAL_DATA_DIR/$d" ]; then
|
||||||
|
list="$list $d"
|
||||||
|
else
|
||||||
|
missing="$missing $d"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
log "Data: WARNING — каталоги не найдены и пропущены:$missing (создайте их или уберите из DEAL_TAR_DIRS)"
|
||||||
|
fi
|
||||||
|
if [ -z "$list" ]; then
|
||||||
|
log "Data: тарировать нечего (все DEAL_TAR_DIRS отсутствуют) — архив не создаю"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
part="$BACKUP_DIR/data/backup-$TS.tar.gz.part"
|
||||||
|
final="$BACKUP_DIR/data/backup-$TS.tar.gz"
|
||||||
|
# $list без кавычек — намеренно: список относительных путей через пробел
|
||||||
|
tar -czf "$part" -C "$DEAL_DATA_DIR" $list || die "tar каталогов данных завершился с ошибкой"
|
||||||
|
mv -f "$part" "$final" || die "не удалось переместить $part"
|
||||||
|
log "Data: OK $final ($(du -h "$final" | cut -f1)), содержимое:$list"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_retention() { # удаление снапшотов старше RETENTION_DAYS (дата YYYYMMDD из имени)
|
||||||
|
local cutoff kept deleted kind f base ts ymd
|
||||||
|
cutoff=$(date -d "$RETENTION_DAYS days ago" +%Y%m%d 2>/dev/null) || {
|
||||||
|
log "Retention: WARNING — GNU date ('date -d') недоступен, retention пропущен (удалите старые снапшоты вручную)"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
kept=0
|
||||||
|
deleted=0
|
||||||
|
log "Retention: удаляю снапшоты старше $RETENTION_DAYS дней (дата имени < $cutoff)"
|
||||||
|
for kind in pg minio data; do
|
||||||
|
[ -d "$BACKUP_DIR/$kind" ] || continue
|
||||||
|
for f in "$BACKUP_DIR/$kind"/backup-*; do
|
||||||
|
[ -e "$f" ] || continue
|
||||||
|
base=${f##*/}
|
||||||
|
ts=${base#backup-}
|
||||||
|
ymd=$(printf '%s' "$ts" | cut -c1-8)
|
||||||
|
if [ "$ymd" \< "$cutoff" ]; then
|
||||||
|
rm -rf "$f"
|
||||||
|
log "Retention: удалён $kind/$base"
|
||||||
|
deleted=$((deleted + 1))
|
||||||
|
else
|
||||||
|
kept=$((kept + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
log "Retention: удалено $deleted, оставлено $kept снапшотов"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Основной поток -------------------------------------------------------------------
|
||||||
|
main() {
|
||||||
|
log "=== Бэкап «Дейла»: старт (BACKUP_DIR=$BACKUP_DIR, retention=$RETENTION_DAYS дн.) ==="
|
||||||
|
backup_pg || die "шаг Postgres завершился с ошибкой"
|
||||||
|
backup_minio || die "шаг MinIO завершился с ошибкой"
|
||||||
|
backup_data || die "шаг Data завершился с ошибкой"
|
||||||
|
run_retention
|
||||||
|
log "=== Бэкап завершён успешно: $BACKUP_DIR ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_FILE="$BACKUP_DIR/logs/backup-$(date +%Y%m).log"
|
||||||
|
main "$@" 2>&1 | tee -a "$LOG_FILE"
|
||||||
|
status=$?
|
||||||
|
exit "$status"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# build.sh — сборка всех решений Дейла (core + сервисы). CI-шаг «build».
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REPO_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
|
||||||
|
for SLN in \
|
||||||
|
src/core/Deal.sln \
|
||||||
|
src/telegram-service/Deal.Telegram.sln \
|
||||||
|
src/ai-service/Deal.Ai.sln \
|
||||||
|
src/ml-service/Deal.Ml.sln \
|
||||||
|
src/storage-service/Deal.Storage.sln; do
|
||||||
|
echo "== build: $SLN =="
|
||||||
|
(cd "$REPO_DIR" && dotnet build "$SLN" -v q --nologo)
|
||||||
|
done
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# ci.sh — полный прогон Дейла (BL-CI): сборка всех решений, тесты, скан уязвимых
|
||||||
|
# NuGet-зависимостей, сборка и линтер фронта. Для локального запуска и CI-пайплайна.
|
||||||
|
# sh scripts/ci.sh
|
||||||
|
# Секреты и внешние сервисы не требуются: тесты — юнит (InMemory/fakes), БД/сеть не нужны.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REPO_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
|
||||||
|
echo "== CI: сборка =="
|
||||||
|
sh "$REPO_DIR/scripts/build.sh"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== CI: тесты =="
|
||||||
|
sh "$REPO_DIR/scripts/test.sh"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== CI: скан уязвимых NuGet-зависимостей =="
|
||||||
|
for SLN in \
|
||||||
|
src/core/Deal.sln \
|
||||||
|
src/telegram-service/Deal.Telegram.sln \
|
||||||
|
src/ai-service/Deal.Ai.sln \
|
||||||
|
src/ml-service/Deal.Ml.sln \
|
||||||
|
src/storage-service/Deal.Storage.sln; do
|
||||||
|
echo "-- $SLN"
|
||||||
|
OUTPUT=$(cd "$REPO_DIR" && dotnet list "$SLN" package --vulnerable --include-transitive 2>&1 || true)
|
||||||
|
if printf '%s' "$OUTPUT" | grep -qi "has the following vulnerable"; then
|
||||||
|
printf '%s\n' "$OUTPUT"
|
||||||
|
echo "ОШИБКА: уязвимые зависимости в $SLN"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== CI: сборка фронта =="
|
||||||
|
if command -v npm >/dev/null 2>&1; then
|
||||||
|
(cd "$REPO_DIR/src/frontend" && {
|
||||||
|
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
|
} && npm run build)
|
||||||
|
else
|
||||||
|
echo " пропуск: npm не установлен (сборка фронта не запущена)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== CI ОК =="
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# deal-backup-lib.sh — общие для scripts/backup.sh и scripts/restore.sh env-дефолты и хелперы
|
||||||
|
# (этап 7 Task 15, Ruling 8). Скрипт НЕ исполняется напрямую: подключается из backup.sh/restore.sh
|
||||||
|
# . "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/deal-backup-lib.sh"
|
||||||
|
#
|
||||||
|
# Параметры — окружение (секреты скрипты НЕ логируют; в лог — только имена файлов и размеры):
|
||||||
|
#
|
||||||
|
# BACKUP_DIR / DEAL_BACKUP_DIR корень бэкапов (по умолчанию <репозиторий>/data/backups)
|
||||||
|
# RETENTION_DAYS / DEAL_RETENTION_DAYS
|
||||||
|
# сколько дней хранить снапшоты (удаление по дате YYYYMMDD в имени; 14)
|
||||||
|
# DEAL_DATA_DIR корень host-каталогов данных (по умолчанию <репозиторий>/data)
|
||||||
|
# DEAL_TAR_DIRS относительные к DEAL_DATA_DIR каталоги для тара (по умолчанию
|
||||||
|
# "attachments ml telegram_sessions"; контейнерный путь каталога
|
||||||
|
# сессий telegram-service — /data/sessions)
|
||||||
|
# DEAL_TAR_VOLUMES если задан (список docker volume'ов через пробел) — файловые данные
|
||||||
|
# берутся из томов busybox-контейнером (Ruling 8), DEAL_TAR_DIRS
|
||||||
|
# игнорируется
|
||||||
|
#
|
||||||
|
# Postgres (docker-режим по умолчанию — exec в контейнер; прямой pg_dump — при заданном DEAL_PG_HOST):
|
||||||
|
# DEAL_PG_HOST / DEAL_PG_PORT (5432) / DEAL_PG_USER (deal) / DEAL_PG_PASSWORD / DEAL_PG_DB (deal)
|
||||||
|
# DEAL_PG_CONTAINER имя контейнера (авто: deal-postgres → compose-сервис 'postgres')
|
||||||
|
#
|
||||||
|
# MinIO / mc (бэкап = выгрузка ИЗ MinIO в BACKUP_DIR; restore — обратно):
|
||||||
|
# DEAL_MINIO_ENDPOINT пусто → host-mc: http://localhost:9000 (dev: опубликованный порт);
|
||||||
|
# docker-mc: http://minio:9000 (алиас compose-сервиса — работает и в
|
||||||
|
# compose.dev, и в compose.prod). prod (compose.prod) порты MinIO НЕ
|
||||||
|
# публикует — хостовый mc его не достанет: нужен docker-режим (дефолт,
|
||||||
|
# endpoint minio:9000) либо достижимый DEAL_MINIO_ENDPOINT.
|
||||||
|
# DEAL_MINIO_ACCESS_KEY (fallback: MINIO_ROOT_USER; иначе deal_minio)
|
||||||
|
# DEAL_MINIO_SECRET_KEY (fallback: MINIO_ROOT_PASSWORD; иначе deal_minio_secret)
|
||||||
|
# DEAL_MINIO_BUCKET (deal-files)
|
||||||
|
# DEAL_MINIO_SKIP 1 → шаг MinIO пропускается (Local-хранилище без MinIO)
|
||||||
|
# DEAL_MINIO_MIRROR_REMOVE restore: 1 → mc mirror --remove (бакет = снимок 1:1)
|
||||||
|
# DEAL_MC_IMAGE docker-режим mc (minio/mc; в проде зафиксируйте тег, напр.
|
||||||
|
# minio/mc:RELEASE.2025-04-16T15-23-31Z)
|
||||||
|
# DEAL_MC_CONTAINER контейнер MinIO для сети (авто: deal-minio → compose-сервис 'minio')
|
||||||
|
set -u
|
||||||
|
|
||||||
|
# --- Корень репозитория и каталоги -----------------------------------------------
|
||||||
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
REPO_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
||||||
|
|
||||||
|
BACKUP_DIR=${BACKUP_DIR:-${DEAL_BACKUP_DIR:-"$REPO_DIR/data/backups"}}
|
||||||
|
RETENTION_DAYS=${RETENTION_DAYS:-${DEAL_RETENTION_DAYS:-14}}
|
||||||
|
DEAL_DATA_DIR=${DEAL_DATA_DIR:-"$REPO_DIR/data"}
|
||||||
|
DEAL_TAR_DIRS=${DEAL_TAR_DIRS:-"attachments ml telegram_sessions"}
|
||||||
|
DEAL_TAR_VOLUMES=${DEAL_TAR_VOLUMES:-""}
|
||||||
|
|
||||||
|
# --- Postgres ----------------------------------------------------------------------
|
||||||
|
DEAL_PG_DB=${DEAL_PG_DB:-deal}
|
||||||
|
DEAL_PG_USER=${DEAL_PG_USER:-deal}
|
||||||
|
DEAL_PG_HOST=${DEAL_PG_HOST:-""}
|
||||||
|
DEAL_PG_PORT=${DEAL_PG_PORT:-5432}
|
||||||
|
DEAL_PG_PASSWORD=${DEAL_PG_PASSWORD:-""}
|
||||||
|
DEAL_PG_CONTAINER=${DEAL_PG_CONTAINER:-""}
|
||||||
|
|
||||||
|
# --- MinIO / mc --------------------------------------------------------------------
|
||||||
|
DEAL_MINIO_BUCKET=${DEAL_MINIO_BUCKET:-deal-files}
|
||||||
|
DEAL_MINIO_ENDPOINT=${DEAL_MINIO_ENDPOINT:-""}
|
||||||
|
DEAL_MINIO_ACCESS_KEY=${DEAL_MINIO_ACCESS_KEY:-${MINIO_ROOT_USER:-deal_minio}}
|
||||||
|
DEAL_MINIO_SECRET_KEY=${DEAL_MINIO_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-deal_minio_secret}}
|
||||||
|
DEAL_MINIO_SKIP=${DEAL_MINIO_SKIP:-0}
|
||||||
|
DEAL_MINIO_MIRROR_REMOVE=${DEAL_MINIO_MIRROR_REMOVE:-0}
|
||||||
|
DEAL_MC_IMAGE=${DEAL_MC_IMAGE:-minio/mc}
|
||||||
|
DEAL_MC_CONTAINER=${DEAL_MC_CONTAINER:-""}
|
||||||
|
|
||||||
|
# Найденные рантайм-значения (заполняют функции ниже)
|
||||||
|
PG_CONTAINER=""
|
||||||
|
MINIO_CONTAINER=""
|
||||||
|
MC_MODE="" # skip | host (клиент mc) | docker (разовый контейнер minio/mc)
|
||||||
|
MC_ENDPOINT="$DEAL_MINIO_ENDPOINT"
|
||||||
|
|
||||||
|
# --- Утилиты -----------------------------------------------------------------------
|
||||||
|
say() { printf '%s\n' "$*"; }
|
||||||
|
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; }
|
||||||
|
die() { log "ОШИБКА: $*"; exit 1; }
|
||||||
|
have() { command -v "$1" >/dev/null 2>&1; }
|
||||||
|
docker_ok() { docker info >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
# --- Windows (Git Bash/MSYS): docker не понимает /c/... пути и ломает контейнерные
|
||||||
|
# /out, /in (MSYS конвертирует их в C:/Program Files/Git/...). Отключаем конвертацию
|
||||||
|
# аргументов и переводим host-пути в Windows-вид (C:/...) для -v/-cp. На Linux хелпер
|
||||||
|
# возвращает путь как есть — поведение не меняется.
|
||||||
|
if uname -s 2>/dev/null | grep -qiE 'mingw|msys|cygwin'; then
|
||||||
|
export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
|
||||||
|
host_docker_path() { cygpath -m "$1"; }
|
||||||
|
else
|
||||||
|
host_docker_path() { printf '%s' "$1"; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
container_running() { # имя контейнера → 0, если запущен
|
||||||
|
[ -n "${1:-}" ] && [ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null)" = "true" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_container() { # метка com.docker.compose.service → имя running-контейнера (или пусто)
|
||||||
|
docker ps --filter "label=com.docker.compose.service=$1" --format '{{.Names}}' 2>/dev/null | head -n 1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Поиск контейнеров Postgres / MinIO --------------------------------------------
|
||||||
|
resolve_pg_container() {
|
||||||
|
local c
|
||||||
|
if [ -n "$DEAL_PG_CONTAINER" ]; then
|
||||||
|
container_running "$DEAL_PG_CONTAINER" \
|
||||||
|
|| die "контейнер DEAL_PG_CONTAINER='$DEAL_PG_CONTAINER' не найден/не запущен"
|
||||||
|
PG_CONTAINER="$DEAL_PG_CONTAINER"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if container_running deal-postgres; then PG_CONTAINER=deal-postgres; return 0; fi
|
||||||
|
c=$(compose_container postgres)
|
||||||
|
if [ -n "$c" ] && container_running "$c"; then PG_CONTAINER="$c"; return 0; fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_minio_container() {
|
||||||
|
local c
|
||||||
|
if [ -n "$DEAL_MC_CONTAINER" ]; then
|
||||||
|
container_running "$DEAL_MC_CONTAINER" \
|
||||||
|
|| die "контейнер DEAL_MC_CONTAINER='$DEAL_MC_CONTAINER' не найден/не запущен"
|
||||||
|
MINIO_CONTAINER="$DEAL_MC_CONTAINER"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if container_running deal-minio; then MINIO_CONTAINER=deal-minio; return 0; fi
|
||||||
|
c=$(compose_container minio)
|
||||||
|
if [ -n "$c" ] && container_running "$c"; then MINIO_CONTAINER="$c"; return 0; fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
minio_network() { # первая docker-сеть контейнера MinIO (для разового контейнера mc)
|
||||||
|
docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' \
|
||||||
|
"$MINIO_CONTAINER" 2>/dev/null | awk '{ print $1 }'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- mc: выбор режима и запуск -------------------------------------------------------
|
||||||
|
select_mc_mode() { # заполняет MC_MODE / MC_ENDPOINT
|
||||||
|
if [ "$DEAL_MINIO_SKIP" = "1" ] || [ "$DEAL_MINIO_SKIP" = "true" ]; then
|
||||||
|
MC_MODE=skip
|
||||||
|
elif have mc; then
|
||||||
|
MC_MODE=host
|
||||||
|
[ -n "$MC_ENDPOINT" ] || MC_ENDPOINT=http://localhost:9000
|
||||||
|
elif docker_ok; then
|
||||||
|
MC_MODE=docker
|
||||||
|
# minio — алиас compose-сервиса: резолвится и в compose.dev (container_name deal-minio), и в
|
||||||
|
# compose.prod (container_name нет, сервис 'minio'); deal-minio:9000 в prod-сети не существует.
|
||||||
|
[ -n "$MC_ENDPOINT" ] || MC_ENDPOINT=http://minio:9000
|
||||||
|
else
|
||||||
|
die "MinIO: клиент mc не найден и docker недоступен — установите mc или задайте DEAL_MINIO_SKIP=1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
mc_cmd() { # выполняет «mc <аргументы…>» в выбранном режиме; alias 'deal' — только env MC_HOST_deal
|
||||||
|
# (секрет в конфиг mc на диск НЕ пишется; значение env нигде не логируется)
|
||||||
|
# MC_HOST_deal — единый URL http(s)://user:pass@host:port: схему берём из MC_ENDPOINT (если задана),
|
||||||
|
# иначе http. Убираем схему из host:port — иначе URL получает двойную схему и mc его отвергает.
|
||||||
|
local host_value endpoint scheme
|
||||||
|
endpoint="$MC_ENDPOINT"
|
||||||
|
scheme=http
|
||||||
|
case "$endpoint" in
|
||||||
|
https://*) scheme=https; endpoint=${endpoint#https://} ;;
|
||||||
|
http://*) endpoint=${endpoint#http://} ;;
|
||||||
|
esac
|
||||||
|
host_value="$scheme://$DEAL_MINIO_ACCESS_KEY:$DEAL_MINIO_SECRET_KEY@$endpoint"
|
||||||
|
case "$MC_MODE" in
|
||||||
|
skip)
|
||||||
|
log "MinIO: шаг пропущен (DEAL_MINIO_SKIP=1)"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
host)
|
||||||
|
MC_HOST_deal="$host_value" mc "$@"
|
||||||
|
;;
|
||||||
|
docker)
|
||||||
|
resolve_minio_container \
|
||||||
|
|| die "MinIO: контейнер не найден (искал deal-minio и compose-сервис 'minio') — поднимите стек, задайте DEAL_MINIO_ENDPOINT или DEAL_MINIO_SKIP=1"
|
||||||
|
log "MinIO: разовый контейнер $DEAL_MC_IMAGE (сеть: $(minio_network), бакет $DEAL_MINIO_BUCKET)"
|
||||||
|
docker run --rm -i --network "$(minio_network)" \
|
||||||
|
-e "MC_HOST_deal=$host_value" \
|
||||||
|
-v "$(host_docker_path "$BACKUP_DIR"):$BACKUP_DIR" \
|
||||||
|
"$DEAL_MC_IMAGE" "$@"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "MinIO: режим mc не выбран (MC_MODE='$MC_MODE') — вызовите select_mc_mode"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Заменить дублирующий <summary> реализации на <inheritdoc/> (код-стайл Дейла).
|
||||||
|
|
||||||
|
Консервативно:
|
||||||
|
* собираются тексты <summary> членов интерфейсов (блоки, состоящие только из <summary>);
|
||||||
|
* в типах с интерфейсной базой такой же по тексту блок-док заменяется на `/// <inheritdoc />`;
|
||||||
|
* блоки с <param>/<returns>/<remarks>/... и summary самих типов не трогаются.
|
||||||
|
|
||||||
|
Запуск: python scripts/dedup_summary_inheritdoc.py [--apply]
|
||||||
|
Без --apply — dry-run со счётчиками.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
ROOTS = [
|
||||||
|
REPO / "src" / "core",
|
||||||
|
REPO / "src" / "telegram-service",
|
||||||
|
REPO / "src" / "ai-service",
|
||||||
|
REPO / "src" / "ml-service",
|
||||||
|
REPO / "src" / "storage-service",
|
||||||
|
REPO / "src" / "grpc-hosting",
|
||||||
|
]
|
||||||
|
|
||||||
|
INTERFACE_RE = re.compile(
|
||||||
|
r"^\s*(?:public|internal|protected|private)?\s*"
|
||||||
|
r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*interface\s+\w+"
|
||||||
|
)
|
||||||
|
TYPE_BASE_RE = re.compile(
|
||||||
|
r"^\s*(?:public|internal|protected|private)?\s*"
|
||||||
|
r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*"
|
||||||
|
r"(?:class|struct|record)\s+\w+[^{;]*:\s*(.+?)(?:\{|$)"
|
||||||
|
)
|
||||||
|
IFACE_NAME_RE = re.compile(r"\bI[A-Z]\w*")
|
||||||
|
EXTRA_TAG_RE = re.compile(r"<(?:param|typeparam|returns|remarks|exception|example|value)\b", re.IGNORECASE)
|
||||||
|
SUMMARY_OPEN_RE = re.compile(r"<summary>", re.IGNORECASE)
|
||||||
|
SUMMARY_CLOSE_RE = re.compile(r"</summary>", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_files() -> list[Path]:
|
||||||
|
files: list[Path] = []
|
||||||
|
for root in ROOTS:
|
||||||
|
if not root.exists():
|
||||||
|
continue
|
||||||
|
for path in root.rglob("*.cs"):
|
||||||
|
parts = set(path.parts)
|
||||||
|
if "obj" in parts or "bin" in parts:
|
||||||
|
continue
|
||||||
|
if "tests" in path.as_posix().lower():
|
||||||
|
continue
|
||||||
|
files.append(path)
|
||||||
|
return sorted(files)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(block: list[str]) -> str:
|
||||||
|
text = " ".join(line.strip().lstrip("/").strip() for line in block)
|
||||||
|
text = SUMMARY_CLOSE_RE.sub("", SUMMARY_OPEN_RE.sub("", text))
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_summary_only(block: list[str]) -> bool:
|
||||||
|
joined = "\n".join(block)
|
||||||
|
return (
|
||||||
|
bool(SUMMARY_OPEN_RE.search(joined))
|
||||||
|
and bool(SUMMARY_CLOSE_RE.search(joined))
|
||||||
|
and not EXTRA_TAG_RE.search(joined)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def depth_snapshot(lines: list[str]) -> list[int]:
|
||||||
|
"""Глубина фигурных скобок перед каждой строкой (приблизительно, без строк/комментариев)."""
|
||||||
|
depths: list[int] = []
|
||||||
|
depth = 0
|
||||||
|
for line in lines:
|
||||||
|
depths.append(depth)
|
||||||
|
depth += line.count("{") - line.count("}")
|
||||||
|
return depths
|
||||||
|
|
||||||
|
|
||||||
|
def type_scopes(lines: list[str]):
|
||||||
|
"""Список (start_line, end_line, kind) для интерфейсов и типов с интерфейсной базой.
|
||||||
|
|
||||||
|
kind: "interface" | "type". Границы — по фигурным скобкам объявления.
|
||||||
|
"""
|
||||||
|
depths = depth_snapshot(lines)
|
||||||
|
scopes: list[tuple[int, int, str]] = []
|
||||||
|
stack: list[tuple[int, str, int]] = [] # (depth, kind, start_line)
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
|
depth_before = depths[idx]
|
||||||
|
if INTERFACE_RE.match(line):
|
||||||
|
stack.append((depth_before, "interface", idx))
|
||||||
|
else:
|
||||||
|
m = TYPE_BASE_RE.match(line)
|
||||||
|
if m and IFACE_NAME_RE.search(m.group(1)):
|
||||||
|
stack.append((depth_before, "type", idx))
|
||||||
|
# закрыть блоки на строке закрывающей скобки (глубина после неё возвращается к уровню объявления)
|
||||||
|
while (
|
||||||
|
stack
|
||||||
|
and depths[idx] <= stack[-1][0]
|
||||||
|
and lines[idx].strip().startswith("}")
|
||||||
|
and idx > stack[-1][2]
|
||||||
|
):
|
||||||
|
depth, kind, start = stack.pop()
|
||||||
|
scopes.append((start, idx, kind))
|
||||||
|
for depth, kind, start in stack:
|
||||||
|
scopes.append((start, len(lines) - 1, kind))
|
||||||
|
return scopes
|
||||||
|
|
||||||
|
|
||||||
|
def containing_kinds(scope_kinds: list[tuple[int, int, str]], line_idx: int, depth: int) -> set[str]:
|
||||||
|
kinds: set[str] = set()
|
||||||
|
for start, end, kind in scope_kinds:
|
||||||
|
if start <= line_idx <= end and start != line_idx:
|
||||||
|
kinds.add(kind)
|
||||||
|
return kinds
|
||||||
|
|
||||||
|
|
||||||
|
def collect_interface_summaries(files: list[Path]) -> set[str]:
|
||||||
|
summaries: set[str] = set()
|
||||||
|
for path in files:
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
depths = depth_snapshot(lines)
|
||||||
|
scopes = type_scopes(lines)
|
||||||
|
idx = 0
|
||||||
|
while idx < len(lines):
|
||||||
|
if not lines[idx].lstrip().startswith("///"):
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
start = idx
|
||||||
|
while idx < len(lines) and lines[idx].lstrip().startswith("///"):
|
||||||
|
idx += 1
|
||||||
|
block = lines[start:idx]
|
||||||
|
kinds = containing_kinds(scopes, start, depths[start])
|
||||||
|
if "interface" in kinds and is_summary_only(block):
|
||||||
|
summaries.add(normalize(block))
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
|
def process_file(path: Path, iface_summaries: set[str]) -> tuple[int, list[str]]:
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
depths = depth_snapshot(lines)
|
||||||
|
scopes = type_scopes(lines)
|
||||||
|
result: list[str] = []
|
||||||
|
replaced = 0
|
||||||
|
idx = 0
|
||||||
|
while idx < len(lines):
|
||||||
|
if not lines[idx].lstrip().startswith("///"):
|
||||||
|
result.append(lines[idx])
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
start = idx
|
||||||
|
while idx < len(lines) and lines[idx].lstrip().startswith("///"):
|
||||||
|
idx += 1
|
||||||
|
block = lines[start:idx]
|
||||||
|
kinds = containing_kinds(scopes, start, depths[start])
|
||||||
|
if "type" in kinds and is_summary_only(block) and normalize(block) in iface_summaries:
|
||||||
|
indent = block[0][: len(block[0]) - len(block[0].lstrip())]
|
||||||
|
result.append(f"{indent}/// <inheritdoc />")
|
||||||
|
replaced += 1
|
||||||
|
else:
|
||||||
|
result.extend(block)
|
||||||
|
return replaced, result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
apply = "--apply" in sys.argv
|
||||||
|
files = iter_files()
|
||||||
|
iface_summaries = collect_interface_summaries(files)
|
||||||
|
total = 0
|
||||||
|
changed = 0
|
||||||
|
for path in files:
|
||||||
|
replaced, new_lines = process_file(path, iface_summaries)
|
||||||
|
if not replaced:
|
||||||
|
continue
|
||||||
|
total += replaced
|
||||||
|
changed += 1
|
||||||
|
if apply:
|
||||||
|
original = path.read_text(encoding="utf-8")
|
||||||
|
text = "\n".join(new_lines) + ("\n" if original.endswith("\n") else "")
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
mode = "применено" if apply else "dry-run"
|
||||||
|
print(f"{mode}: замен {total} в {changed} файлах (интерфейсных summary: {len(iface_summaries)})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# dev-smoke.sh — сквозной smoke полного dev-стека Дейла (план Task 20, Ruling 12).
|
||||||
|
#
|
||||||
|
# Поднимает ВЕСЬ стек deploy/compose.dev.yml (postgres+minio+ml/ai/telegram+core) в сквозном
|
||||||
|
# gRPC-режиме (Services__*__UseLocal=false заданы в compose) и проверяет:
|
||||||
|
# 1) health ВСЕХ контейнеров (docker healthchecks: postgres/core/ml/ai/telegram, minio — running);
|
||||||
|
# 2) auth: login admin/admin (дефолт TenantBootstrapService; при переопределённом пароле — правка);
|
||||||
|
# 3) /api/tg/status — живой статус через GrpcTelegramClient (idle-форма: telegram-service без сессии);
|
||||||
|
# 4) POST /api/cards → локальная карточка (планово, c_…);
|
||||||
|
# 5) POST /api/cards/{id}/trash — обучающий сигнал spam 1.0 → строка MlOutbox;
|
||||||
|
# 6) флашер MlOutboxFlushScheduler (10 с) → TrainBatch в ml-service: /api/ml/status показывает
|
||||||
|
# reachable:true, stats.outbox:0 и класс "spam" в модели сервиса (learned ≥ 1).
|
||||||
|
#
|
||||||
|
# Живые проверки Telegram-входа (api_id/api_hash/QR) и реальных LLM-вызовов — НЕ выполняются
|
||||||
|
# (нужны креды; см. техдок §13, «ручные проверки»). Скрипт сам гасит стек: trap → docker compose down
|
||||||
|
# (volumes БЕЗ -v — данные dev сохраняются между прогонами). Запуск: scripts/dev-smoke.sh
|
||||||
|
set -u
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
COMPOSE_FILE="deploy/compose.dev.yml"
|
||||||
|
BASE_URL="http://localhost:5080"
|
||||||
|
|
||||||
|
CONTAINERS="deal-postgres deal-minio deal-telegram-service deal-ai-service deal-ml-service deal-core"
|
||||||
|
UP_TIMEOUT_TICKS=120 # 120 × 5 с = 10 мин максимум на здоровье всех контейнеров
|
||||||
|
TICK_SECONDS=5
|
||||||
|
ML_POLL_TICKS=60 # 60 × 5 с = 5 мин на флаш outbox + refresh статуса сервиса
|
||||||
|
|
||||||
|
WORK=$(mktemp -d)
|
||||||
|
JAR="$WORK/cookies.txt"
|
||||||
|
OUT="$WORK/out.txt"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
FAILED_NAMES=""
|
||||||
|
|
||||||
|
check() { # имя, ожидание HTTP-кода, [фрагменты...]
|
||||||
|
local name="$1" code="$2"
|
||||||
|
shift 2
|
||||||
|
if grep -q "\[HTTP:$code\]" "$OUT"; then
|
||||||
|
for frag in "$@"; do
|
||||||
|
if ! grep -qF "$frag" "$OUT"; then
|
||||||
|
echo " [FAIL] $name (нет фрагмента: $frag)"
|
||||||
|
cat "$OUT"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
FAILED_NAMES="$FAILED_NAMES|$name"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo " [PASS] $name"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo " [FAIL] $name (ожидался HTTP $code)"
|
||||||
|
cat "$OUT"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
FAILED_NAMES="$FAILED_NAMES|$name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
container_ok() { # имя → 0: running и (если есть healthcheck) healthy; 1: плох; 2: нет контейнера
|
||||||
|
local name="$1" state health
|
||||||
|
state=$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null) || return 2
|
||||||
|
[ "$state" = "running" ] || return 1
|
||||||
|
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$name" 2>/dev/null)
|
||||||
|
[ "$health" = "none" ] || [ "$health" = "healthy" ] || return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo
|
||||||
|
echo "== очистка: docker compose down (volumes сохраняются) =="
|
||||||
|
(cd "$REPO_DIR" && docker compose -f "$COMPOSE_FILE" down) 2>/dev/null || true
|
||||||
|
rm -rf "$WORK"
|
||||||
|
echo "== очистка завершена =="
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
echo "Docker engine недоступен (Docker Desktop выключен?) — запустите Docker и повторите smoke."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$REPO_DIR" || exit 1
|
||||||
|
|
||||||
|
echo "== 0. compose config валиден =="
|
||||||
|
if docker compose -f "$COMPOSE_FILE" config --quiet; then
|
||||||
|
echo " [PASS] config"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo " [FAIL] docker compose config (файл невалиден)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== 1. подъём стека (первый прогон собирает 4 docker-образа — долго) =="
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --build || {
|
||||||
|
echo " [FAIL] docker compose up (см. вывод выше)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo " [ok] docker compose up -d --build завершён"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== 2. ожидание health всех контейнеров =="
|
||||||
|
ALL_OK=""
|
||||||
|
i=0
|
||||||
|
while [ $i -lt $UP_TIMEOUT_TICKS ]; do
|
||||||
|
ALL_OK=1
|
||||||
|
for c in $CONTAINERS; do
|
||||||
|
if ! container_ok "$c"; then ALL_OK=""; break; fi
|
||||||
|
done
|
||||||
|
[ -n "$ALL_OK" ] && break
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep $TICK_SECONDS
|
||||||
|
done
|
||||||
|
|
||||||
|
for c in $CONTAINERS; do
|
||||||
|
if container_ok "$c"; then
|
||||||
|
echo " [PASS] $c готов"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo " [FAIL] $c не поднялся/не healthy"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
FAILED_NAMES="$FAILED_NAMES|$c"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$ALL_OK" ]; then
|
||||||
|
echo " подробности:"
|
||||||
|
docker compose -f "$COMPOSE_FILE" ps
|
||||||
|
docker compose -f "$COMPOSE_FILE" logs --tail=40 core
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== 3. HTTP-проверки core :5080 =="
|
||||||
|
echo
|
||||||
|
echo "-- 3.1 login admin/admin --"
|
||||||
|
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
|
||||||
|
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
|
||||||
|
check "login → 200 ok:true" 200 '"ok":true'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "-- 3.2 GET /api/tg/status — живой статус через GrpcTelegramClient (telegram-service без сессии) --"
|
||||||
|
curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/tg/status" > "$OUT"
|
||||||
|
# Структурная проверка полей §4.9 (точные значения phase/monitored/keysSet зависят от состояния
|
||||||
|
# volume БД: fresh-стек ожидает idle/monitored:0/keysSet:false; после реального Telegram-входа может
|
||||||
|
# быть ready и ненулевой monitored). Живость gRPC-стека доказывают health контейнеров и флашер ML.
|
||||||
|
check "status — idle-форма/поля §4.9 (200)" 200 \
|
||||||
|
'"phase":"' '"connected":' '"monitored":' '"keysSet":'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "-- 3.3 GET /api/containers?space=dashboard → реестр колонок --"
|
||||||
|
curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/containers?space=dashboard" > "$OUT"
|
||||||
|
check "containers dashboard → 200, есть inbox" 200 '"id":"inbox"'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "-- 3.4 POST /api/cards → локальная карточка (c_…, planned) --"
|
||||||
|
curl -s -m 20 -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/cards" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"SMOKE card","containerId":"planned"}' > "$OUT"
|
||||||
|
check "create card → 200, карточка c_… в planned" 200 '"containerId":"planned"'
|
||||||
|
CARD_ID=$(sed -n 's/.*"id":"\(c_[a-zA-Z0-9_]*\)".*/\1/p' "$OUT" | head -1)
|
||||||
|
if [ -z "$CARD_ID" ]; then
|
||||||
|
echo " [FAIL] id карточки не извлечён из ответа create card"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
FAILED_NAMES="$FAILED_NAMES|create card id"
|
||||||
|
else
|
||||||
|
echo " карточка: $CARD_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "-- 3.4a GET /api/cards?containerId=planned → карточка в стадии --"
|
||||||
|
if [ -n "$CARD_ID" ]; then
|
||||||
|
curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/cards?containerId=planned" > "$OUT"
|
||||||
|
check "list planned → 200, карточка найдена" 200 "\"id\":\"$CARD_ID\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "-- 3.5 POST /api/cards/$CARD_ID/trash → обучающий сигнал spam (MlOutbox) --"
|
||||||
|
if [ -n "$CARD_ID" ]; then
|
||||||
|
curl -s -m 20 -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/cards/$CARD_ID/trash" > "$OUT"
|
||||||
|
check "trash → 200 ok:true" 200 '"ok":true'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "-- 3.6 флашер MlOutbox: ждём TrainBatch → ml-service (outbox:0 + класс spam в модели) --"
|
||||||
|
FLUSHED=""
|
||||||
|
i=0
|
||||||
|
while [ $i -lt $ML_POLL_TICKS ]; do
|
||||||
|
curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT"
|
||||||
|
# reachable — живой Status RPC ml-service; outbox — локальная очередь (убывает после успешного
|
||||||
|
# TrainBatch); "spam" — класс в модели сервиса (появляется после рефреша кэша статуса, 15 с).
|
||||||
|
if grep -q '"reachable":true' "$OUT" && grep -q '"outbox":0' "$OUT" && grep -qF '"spam":' "$OUT"; then
|
||||||
|
FLUSHED=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep $TICK_SECONDS
|
||||||
|
done
|
||||||
|
if [ -n "$FLUSHED" ]; then
|
||||||
|
echo " [PASS] ML-флашер: outbox выгружен в ml-service (класс spam обучен)"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
cat "$OUT"
|
||||||
|
else
|
||||||
|
echo " [FAIL] ML-флашер: outbox не выгружен / ml-service недоступен за отведённое время"
|
||||||
|
cat "$OUT"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
FAILED_NAMES="$FAILED_NAMES|ml flusher"
|
||||||
|
docker compose -f "$COMPOSE_FILE" logs --tail=40 core | grep -i "флашер\|outbox\|TrainBatch" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== ИТОГ: PASS=$PASS FAIL=$FAIL =="
|
||||||
|
if [ "$FAIL" = "0" ]; then
|
||||||
|
echo "SMOKE ПРОЙДЕН — полный dev-стек работает по gRPC"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Провалы:$FAILED_NAMES"
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Понижение XML-док комментариев с private/internal членов до обычных `//` (код-стайл «Дейл» §5).
|
||||||
|
|
||||||
|
Правило: XML-doc — только на public/protected. Приватные детали реализации — при необходимости короткий
|
||||||
|
обычный комментарий. Скрипт сохраняет текст (теги <param>/<returns> и т.п. разворачиваются в читаемый вид).
|
||||||
|
|
||||||
|
Режимы:
|
||||||
|
python scripts/fix_private_docs.py --preview [N] # показать N примеров (по умолчанию 10)
|
||||||
|
python scripts/fix_private_docs.py --apply # применить
|
||||||
|
python scripts/fix_private_docs.py --check # сколько блоков будет изменено
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = r"C:\telbase\src"
|
||||||
|
EXC = ("bin", "obj", "node_modules", ".git")
|
||||||
|
DOC_RE = re.compile(r"^(\s*)///[ \t]?(.*)$")
|
||||||
|
ACC_RE = re.compile(r"\b(public|protected|private|internal)\b")
|
||||||
|
|
||||||
|
|
||||||
|
def demote(block_content):
|
||||||
|
"""block_content: список строк после '///'. Возвращает список строк текста обычного комментария."""
|
||||||
|
t = "\n".join(block_content)
|
||||||
|
t = re.sub(r"<summary>\s*(.*?)\s*</summary>", r"\1", t, flags=re.S)
|
||||||
|
t = re.sub(r"<remarks>\s*(.*?)\s*</remarks>", r"\1", t, flags=re.S)
|
||||||
|
t = re.sub(r"<typeparamref\s+name=\"([^\"]+)\"\s*/?>", r"\1", t)
|
||||||
|
t = re.sub(r"<paramref\s+name=\"([^\"]+)\"\s*/?>", r"\1", t)
|
||||||
|
t = re.sub(r"<typeparam\s+name=\"([^\"]+)\"\s*>\s*(.*?)\s*</typeparam>", r"\1: \2", t, flags=re.S)
|
||||||
|
t = re.sub(r"<param\s+name=\"([^\"]+)\"\s*>\s*(.*?)\s*</param>", r"\1: \2", t, flags=re.S)
|
||||||
|
t = re.sub(r"<returns>\s*(.*?)\s*</returns>", r"Возвращает: \1", t, flags=re.S)
|
||||||
|
t = re.sub(
|
||||||
|
r"<exception\s+cref=\"([^\"]+)\"\s*>\s*(.*?)\s*</exception>",
|
||||||
|
r"Исключение \1: \2",
|
||||||
|
t,
|
||||||
|
flags=re.S,
|
||||||
|
)
|
||||||
|
t = re.sub(r"<see\s+cref=\"([^\"]+)\"\s*/?>", r"\1", t)
|
||||||
|
t = re.sub(r"<seealso\s+cref=\"[^\"]+\"\s*/?>", "", t)
|
||||||
|
t = re.sub(r"<inheritdoc\s*/?>", "", t)
|
||||||
|
t = re.sub(r"</?c>", "", t)
|
||||||
|
t = re.sub(r"<[^>]+>", "", t)
|
||||||
|
out = []
|
||||||
|
for line in t.split("\n"):
|
||||||
|
s = line.strip()
|
||||||
|
if s:
|
||||||
|
out.append(s)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def iter_cs():
|
||||||
|
for dp, dn, fn in os.walk(ROOT):
|
||||||
|
dn[:] = [d for d in dn if d not in EXC]
|
||||||
|
for f in fn:
|
||||||
|
if f.endswith(".cs"):
|
||||||
|
yield os.path.join(dp, f)
|
||||||
|
|
||||||
|
|
||||||
|
def find_blocks(lines):
|
||||||
|
"""Возвращает список (start, end, block_content, decl_index, decl)."""
|
||||||
|
res = []
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
m = DOC_RE.match(lines[i].rstrip("\r"))
|
||||||
|
if not m:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
start = i
|
||||||
|
block = []
|
||||||
|
while i < len(lines):
|
||||||
|
m2 = DOC_RE.match(lines[i].rstrip("\r"))
|
||||||
|
if not m2:
|
||||||
|
break
|
||||||
|
block.append(m2.group(2))
|
||||||
|
i += 1
|
||||||
|
end = i # exclusive
|
||||||
|
j = i
|
||||||
|
decl = ""
|
||||||
|
while j < len(lines):
|
||||||
|
s = lines[j].strip()
|
||||||
|
if s == "" or s.startswith("["):
|
||||||
|
j += 1
|
||||||
|
continue
|
||||||
|
decl = s
|
||||||
|
break
|
||||||
|
if not decl:
|
||||||
|
continue
|
||||||
|
if not any("<summary>" in b or "</summary>" in b for b in block):
|
||||||
|
continue
|
||||||
|
am = ACC_RE.search(decl)
|
||||||
|
if am and am.group(1) in ("private", "internal"):
|
||||||
|
res.append((start, end, block, decl))
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def process_file(path, apply):
|
||||||
|
raw = open(path, "rb").read().decode("utf-8")
|
||||||
|
lines = raw.splitlines(keepends=True)
|
||||||
|
plain = [l.rstrip("\r\n") for l in lines]
|
||||||
|
endings = [l[len(l.rstrip("\r\n")):] for l in lines]
|
||||||
|
blocks = find_blocks(plain)
|
||||||
|
if not blocks:
|
||||||
|
return 0, []
|
||||||
|
changes = []
|
||||||
|
for start, end, block, decl in blocks:
|
||||||
|
indent = DOC_RE.match(plain[start]).group(1)
|
||||||
|
comments = demote(block)
|
||||||
|
new_lines = [indent + "//" + (" " + c if c else "") for c in comments]
|
||||||
|
changes.append((start, end, new_lines, decl))
|
||||||
|
# применяем с конца
|
||||||
|
for start, end, new_lines, decl in reversed(changes):
|
||||||
|
ending = endings[start] if endings[start] else (endings[end - 1] if end > start else "\n")
|
||||||
|
replacement = [nl + ending for nl in new_lines]
|
||||||
|
lines[start:end] = replacement
|
||||||
|
if apply:
|
||||||
|
open(path, "w", encoding="utf-8", newline="").write("".join(lines))
|
||||||
|
return len(changes), [(b[0], b[3], demote(b[2])) for b in [(s, e, bl, d) for s, e, bl, d in blocks]]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if "--preview" in sys.argv:
|
||||||
|
n = 10
|
||||||
|
idx = sys.argv.index("--preview")
|
||||||
|
if idx + 1 < len(sys.argv) and sys.argv[idx + 1].isdigit():
|
||||||
|
n = int(sys.argv[idx + 1])
|
||||||
|
shown = 0
|
||||||
|
for path in iter_cs():
|
||||||
|
raw = open(path, "rb").read().decode("utf-8")
|
||||||
|
plain = [l.rstrip("\r\n") for l in raw.split("\n")]
|
||||||
|
for start, end, block, decl in find_blocks(plain):
|
||||||
|
print("FILE:", os.path.relpath(path, r"C:\telbase"), f"line {start + 1}")
|
||||||
|
print(" DECL:", decl[:100])
|
||||||
|
print(" BEFORE:")
|
||||||
|
for b in plain[start:end]:
|
||||||
|
print(" ", b)
|
||||||
|
print(" AFTER:")
|
||||||
|
for c in demote(block):
|
||||||
|
print(" // " + c)
|
||||||
|
print()
|
||||||
|
shown += 1
|
||||||
|
if shown >= n:
|
||||||
|
return
|
||||||
|
return
|
||||||
|
apply = "--apply" in sys.argv
|
||||||
|
total = 0
|
||||||
|
files = 0
|
||||||
|
for path in iter_cs():
|
||||||
|
cnt, _ = process_file(path, apply)
|
||||||
|
if cnt:
|
||||||
|
files += 1
|
||||||
|
total += cnt
|
||||||
|
print(("APPLIED" if apply else "DRY-RUN"), "blocks:", total, "files:", files)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Приведение XML-док <summary> к блочному виду (действующий код-стайл «Дейл»).
|
||||||
|
|
||||||
|
Правило: открывающий <summary> и закрывающий </summary> — каждый на своей строке.
|
||||||
|
Однострочная запись `/// <summary>текст</summary>` не допускается.
|
||||||
|
|
||||||
|
Режимы:
|
||||||
|
python scripts/fix_summary_blocks.py --check # только посчитать, что изменится
|
||||||
|
python scripts/fix_summary_blocks.py --apply # применить
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = r"C:\telbase\src"
|
||||||
|
EXC = ("bin", "obj", "node_modules", ".git")
|
||||||
|
LINE_RE = re.compile(r"^(\s*)(///)([ \t]?)(.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
def transform(body: str):
|
||||||
|
m = LINE_RE.match(body)
|
||||||
|
if not m:
|
||||||
|
return [body]
|
||||||
|
indent, _, _, content = m.groups()
|
||||||
|
if "<summary>" not in content and "</summary>" not in content:
|
||||||
|
return [body]
|
||||||
|
base = indent + "///"
|
||||||
|
|
||||||
|
def tag(text=""):
|
||||||
|
return base + (" " + text if text else "")
|
||||||
|
|
||||||
|
if "<summary>" in content and "</summary>" in content:
|
||||||
|
pre, rest = content.split("<summary>", 1)
|
||||||
|
between, post = rest.split("</summary>", 1)
|
||||||
|
res = []
|
||||||
|
if pre.strip():
|
||||||
|
res.append(tag(pre.strip()))
|
||||||
|
res.append(tag("<summary>"))
|
||||||
|
if between.strip():
|
||||||
|
res.append(tag(between.strip()))
|
||||||
|
res.append(tag("</summary>"))
|
||||||
|
if post.strip():
|
||||||
|
res.append(tag(post.strip()))
|
||||||
|
return res
|
||||||
|
|
||||||
|
if "<summary>" in content:
|
||||||
|
pre, after = content.split("<summary>", 1)
|
||||||
|
res = []
|
||||||
|
if pre.strip():
|
||||||
|
res.append(tag(pre.strip()))
|
||||||
|
res.append(tag("<summary>"))
|
||||||
|
if after.strip():
|
||||||
|
res.append(tag(after.strip()))
|
||||||
|
return res
|
||||||
|
|
||||||
|
if "</summary>" in content:
|
||||||
|
pre, after = content.split("</summary>", 1)
|
||||||
|
res = []
|
||||||
|
if pre.strip():
|
||||||
|
res.append(tag(pre.strip()))
|
||||||
|
res.append(tag("</summary>"))
|
||||||
|
if after.strip():
|
||||||
|
res.append(tag(after.strip()))
|
||||||
|
return res
|
||||||
|
|
||||||
|
return [body]
|
||||||
|
|
||||||
|
|
||||||
|
def iter_cs():
|
||||||
|
for dp, dn, fn in os.walk(ROOT):
|
||||||
|
dn[:] = [d for d in dn if d not in EXC]
|
||||||
|
for f in fn:
|
||||||
|
if f.endswith(".cs"):
|
||||||
|
yield os.path.join(dp, f)
|
||||||
|
|
||||||
|
|
||||||
|
def process(path, apply):
|
||||||
|
raw = open(path, "rb").read().decode("utf-8")
|
||||||
|
lines = raw.splitlines(keepends=True)
|
||||||
|
out = []
|
||||||
|
changed = False
|
||||||
|
for raw_line in lines:
|
||||||
|
if raw_line.endswith("\r\n"):
|
||||||
|
body, ending = raw_line[:-2], "\r\n"
|
||||||
|
elif raw_line.endswith("\n"):
|
||||||
|
body, ending = raw_line[:-1], "\n"
|
||||||
|
else:
|
||||||
|
body, ending = raw_line, ""
|
||||||
|
new_bodies = transform(body)
|
||||||
|
if new_bodies != [body]:
|
||||||
|
changed = True
|
||||||
|
out.extend(b + ending for b in new_bodies)
|
||||||
|
if changed and apply:
|
||||||
|
open(path, "w", encoding="utf-8", newline="").write("".join(out))
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
apply = "--apply" in sys.argv
|
||||||
|
total = 0
|
||||||
|
files = 0
|
||||||
|
for path in iter_cs():
|
||||||
|
if process(path, apply):
|
||||||
|
files += 1
|
||||||
|
print(("APPLIED" if apply else "DRY-RUN"), "files changed:", files)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Нагрузочный смоук-тест API Deal (`scripts/loadtest/`)
|
||||||
|
|
||||||
|
Минимальный прогон «есть ли жизнь и как быстро отвечает» по читающим ручкам API
|
||||||
|
(этап 12, пакет C). Внешних сервисов и записывающих операций нет — только логин
|
||||||
|
и чтение, БД не мутируется.
|
||||||
|
|
||||||
|
## Сценарий
|
||||||
|
|
||||||
|
1. `POST /api/auth/login` `{login, password}` — сессия (`deal_session`).
|
||||||
|
2. В цикле: `GET /api/containers`, затем `GET /api/cards`.
|
||||||
|
3. Метрики: число запросов, ошибки (не-200), RPS, средняя/p95/max латентность.
|
||||||
|
|
||||||
|
Учётные данные по умолчанию — `admin` / `admin` (dev-дефолт bootstrap тенанта).
|
||||||
|
|
||||||
|
## Вариант A — bash + curl (зависимостей нет, кроме curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/loadtest/api-loadtest.sh
|
||||||
|
|
||||||
|
# пример с параметрами
|
||||||
|
BASE_URL=http://localhost:5080 LOGIN=admin PASSWORD=admin \
|
||||||
|
DURATION_SECONDS=20 PAUSE_SECONDS=0.1 \
|
||||||
|
bash scripts/loadtest/api-loadtest.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Переменные окружения: `BASE_URL` (по умолчанию `http://localhost:5080`),
|
||||||
|
`LOGIN`/`PASSWORD`, `DURATION_SECONDS` (15), `PAUSE_SECONDS` (0.2), `CURL_TIMEOUT` (5).
|
||||||
|
Код возврата: `0` — без ошибок, `1` — ошибка логина/конфигурации, `2` — были не-200.
|
||||||
|
|
||||||
|
## Вариант B — k6 (если установлен)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
k6 run scripts/loadtest/deal-loadtest.js
|
||||||
|
BASE_URL=http://localhost:5080 VUS=10 DURATION=30s \
|
||||||
|
k6 run scripts/loadtest/deal-loadtest.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт логинится один раз в `setup()` и раздаёт куку виртуальным пользователям;
|
||||||
|
пороги проваливают прогон при доле ошибок > 1% или p95 > 500 мс.
|
||||||
|
|
||||||
|
## Как поднять API для прогона
|
||||||
|
|
||||||
|
- **dev (полный стек)**: `docker compose -f deploy/compose.dev.yml up -d --build`,
|
||||||
|
затем прогон против `http://localhost:5080`; после — `docker compose -f deploy/compose.dev.yml down`.
|
||||||
|
- **dev (только core + Postgres)**: см. техдок §13 «Быстрый старт» (Postgres :5433, API :5080).
|
||||||
|
- Прогон короткий (по умолчанию 15–20 с) — это смоук, а не полноценный бенчмарк.
|
||||||
|
|
||||||
|
## Замечания
|
||||||
|
|
||||||
|
- В PROD включён rate-limit (`api` — 600/мин на тенанта, `auth` — 10/мин на IP),
|
||||||
|
поэтому длительные прогоны делайте на dev-стенде или поднимите лимит.
|
||||||
|
- Метрики перфа сервисов дополнительно смотрите в Prometheus/Grafana (профиль
|
||||||
|
`observability`): `http_server_request_duration_seconds_*`, RPS/p95 на дашборде
|
||||||
|
`Deal-Metrics-Overview` (см. техдок §7).
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Простой нагрузочный смоук-тест API Deal (этап 12, пакет C).
|
||||||
|
#
|
||||||
|
# Что делает: логинится (по умолчанию admin/admin), затем в цикле дёргает два
|
||||||
|
# читающих эндпоинта — GET /api/containers и GET /api/cards — до истечения
|
||||||
|
# DURATION_SECONDS, замеряя HTTP-код и время ответа. В конце печатает сводку:
|
||||||
|
# число запросов, ошибки (не 200), RPS, средняя/p95-латентность (сек).
|
||||||
|
#
|
||||||
|
# Зависимости: только bash и curl. Ничего не пишет в БД; читающие ручки.
|
||||||
|
#
|
||||||
|
# Запуск (из корня репозитория, dev-API на :5080):
|
||||||
|
# bash scripts/loadtest/api-loadtest.sh
|
||||||
|
# BASE_URL=http://localhost:5080 LOGIN=admin PASSWORD=admin \
|
||||||
|
# DURATION_SECONDS=20 PAUSE_SECONDS=0.1 bash scripts/loadtest/api-loadtest.sh
|
||||||
|
#
|
||||||
|
# Переменные окружения:
|
||||||
|
# BASE_URL базовый URL API (по умолчанию http://localhost:5080)
|
||||||
|
# LOGIN/PASSWORD учётные данные тенанта (по умолчанию admin/admin — dev-дефолт bootstrap)
|
||||||
|
# DURATION_SECONDS длительность прогона, с (по умолчанию 15)
|
||||||
|
# PAUSE_SECONDS пауза между итерациями, с (по умолчанию 0.2)
|
||||||
|
# CURL_TIMEOUT таймаут запроса, с (по умолчанию 5)
|
||||||
|
#
|
||||||
|
# Внимание: в PROD включён rate-limit (api-политика — 600/мин на тенанта). Для
|
||||||
|
# нагрузочного прогона используйте dev-стенд или поднимите лимит.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${BASE_URL:-http://localhost:5080}"
|
||||||
|
LOGIN="${LOGIN:-admin}"
|
||||||
|
PASSWORD="${PASSWORD:-admin}"
|
||||||
|
DURATION_SECONDS="${DURATION_SECONDS:-15}"
|
||||||
|
PAUSE_SECONDS="${PAUSE_SECONDS:-0.2}"
|
||||||
|
CURL_TIMEOUT="${CURL_TIMEOUT:-5}"
|
||||||
|
|
||||||
|
command -v curl >/dev/null 2>&1 || { echo "Ошибка: нужен curl" >&2; exit 127; }
|
||||||
|
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
cookie_jar="${tmp_dir}/cookies.txt"
|
||||||
|
lat_file="${tmp_dir}/latencies.txt"
|
||||||
|
trap 'rm -rf "${tmp_dir}"' EXIT
|
||||||
|
|
||||||
|
login_code="$(curl -sS -o "${tmp_dir}/login.json" -w '%{http_code}' \
|
||||||
|
-c "${cookie_jar}" -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"login\":\"${LOGIN}\",\"password\":\"${PASSWORD}\"}" \
|
||||||
|
--max-time "${CURL_TIMEOUT}" "${BASE_URL}/api/auth/login" || true)"
|
||||||
|
[ -n "${login_code}" ] || login_code="000"
|
||||||
|
|
||||||
|
if [ "${login_code}" != "200" ]; then
|
||||||
|
echo "LOGIN FAILED (HTTP ${login_code}): $(cat "${tmp_dir}/login.json" 2>/dev/null)" >&2
|
||||||
|
echo "Проверьте BASE_URL=${BASE_URL} и что API поднят." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "login OK (${LOGIN}) → прогон ${DURATION_SECONDS} с, пауза ${PAUSE_SECONDS} с"
|
||||||
|
|
||||||
|
started_at="$(date +%s)"
|
||||||
|
end_at=$((started_at + DURATION_SECONDS))
|
||||||
|
total=0
|
||||||
|
errors=0
|
||||||
|
|
||||||
|
while [ "$(date +%s)" -lt "${end_at}" ]; do
|
||||||
|
for path in /api/containers /api/cards; do
|
||||||
|
result="$(curl -sS -o /dev/null -w '%{http_code} %{time_total}' \
|
||||||
|
-b "${cookie_jar}" --max-time "${CURL_TIMEOUT}" "${BASE_URL}${path}" || true)"
|
||||||
|
[ -n "${result}" ] || result="000 0"
|
||||||
|
code="${result%% *}"
|
||||||
|
elapsed="${result##* }"
|
||||||
|
total=$((total + 1))
|
||||||
|
if [ "${code}" != "200" ]; then
|
||||||
|
errors=$((errors + 1))
|
||||||
|
echo " ! ${path} → HTTP ${code}" >&2
|
||||||
|
fi
|
||||||
|
echo "${elapsed}" >> "${lat_file}"
|
||||||
|
done
|
||||||
|
sleep "${PAUSE_SECONDS}"
|
||||||
|
done
|
||||||
|
|
||||||
|
elapsed_total=$(( $(date +%s) - started_at ))
|
||||||
|
[ "${elapsed_total}" -lt 1 ] && elapsed_total=1
|
||||||
|
|
||||||
|
if [ "${total}" -eq 0 ]; then
|
||||||
|
echo "Запросов не сделано (DURATION_SECONDS слишком мал?)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
avg="$(awk '{sum += $1} END {printf "%.4f", sum / NR}' "${lat_file}")"
|
||||||
|
p95_index=$(( (total * 95 + 99) / 100 ))
|
||||||
|
p95="$(sort -n "${lat_file}" | sed -n "${p95_index}p")"
|
||||||
|
max="$(sort -n "${lat_file}" | tail -n 1)"
|
||||||
|
rps="$(awk -v n="${total}" -v d="${elapsed_total}" 'BEGIN {printf "%.2f", n / d}')"
|
||||||
|
|
||||||
|
echo "---------------------------------------------"
|
||||||
|
echo "запросов: ${total}"
|
||||||
|
echo "ошибок: ${errors}"
|
||||||
|
echo "длительность: ${elapsed_total} с"
|
||||||
|
echo "RPS: ${rps}"
|
||||||
|
echo "латентность: avg=${avg}s p95=${p95}s max=${max}s"
|
||||||
|
echo "---------------------------------------------"
|
||||||
|
|
||||||
|
[ "${errors}" -eq 0 ] || exit 2
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Нагрузочный смоук-тест API Deal на k6 (этап 12, пакет C) — альтернатива
|
||||||
|
// scripts/loadtest/api-loadtest.sh, если в системе есть k6.
|
||||||
|
//
|
||||||
|
// Логин — в setup() (один раз), затем виртуальные пользователи в цикле читают
|
||||||
|
// GET /api/containers и GET /api/cards. Пороги (thresholds) проваливают прогон
|
||||||
|
// при доле ошибок > 1% или p95 > 500 мс.
|
||||||
|
//
|
||||||
|
// Запуск (API dev на :5080):
|
||||||
|
// k6 run scripts/loadtest/deal-loadtest.js
|
||||||
|
// BASE_URL=http://localhost:5080 VUS=10 DURATION=30s k6 run scripts/loadtest/deal-loadtest.js
|
||||||
|
//
|
||||||
|
// Переменные окружения: BASE_URL, LOGIN, PASSWORD, VUS, DURATION.
|
||||||
|
// Внимание: в PROD включён rate-limit (api — 600/мин на тенанта) — гоняйте на dev.
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep } from 'k6';
|
||||||
|
|
||||||
|
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5080';
|
||||||
|
const LOGIN = __ENV.LOGIN || 'admin';
|
||||||
|
const PASSWORD = __ENV.PASSWORD || 'admin';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
vus: __ENV.VUS ? Number(__ENV.VUS) : 5,
|
||||||
|
duration: __ENV.DURATION || '20s',
|
||||||
|
thresholds: {
|
||||||
|
http_req_failed: ['rate<0.01'],
|
||||||
|
http_req_duration: ['p(95)<500'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setup() {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/auth/login`,
|
||||||
|
JSON.stringify({ login: LOGIN, password: PASSWORD }),
|
||||||
|
{ headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error(`login failed: HTTP ${res.status} — ${res.body}`);
|
||||||
|
}
|
||||||
|
const cookies = res.cookies['deal_session'];
|
||||||
|
const session = cookies && cookies.length ? cookies[0].value : '';
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('login succeeded but deal_session cookie is missing');
|
||||||
|
}
|
||||||
|
return { session };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (data) {
|
||||||
|
const params = { headers: { Cookie: `deal_session=${data.session}` } };
|
||||||
|
const paths = ['/api/containers', '/api/cards'];
|
||||||
|
for (const path of paths) {
|
||||||
|
const res = http.get(`${BASE_URL}${path}`, params);
|
||||||
|
check(res, { [`${path} → 200`]: (r) => r.status === 200 });
|
||||||
|
}
|
||||||
|
sleep(0.2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Codemod: неявные реализации интерфейсов -> явные (§11, вариант A — прод).
|
||||||
|
|
||||||
|
Только production-код (/tests/ не трогает). Card (DTO) пропускается.
|
||||||
|
Логика: таблицы членов интерфейсов -> маппинг класс -> интерфейсы (включая partial) ->
|
||||||
|
конвертация public-члена с совпавшим именем и сигнатурой в `Тип IFoo.Член`.
|
||||||
|
Ошибочный конверсионный файл откатывается компиляторным циклом (git checkout).
|
||||||
|
|
||||||
|
Запуск: python scripts/make_explicit.py [--apply] [--file <путь>]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
ROOTS = [REPO / "src" / "core", REPO / "src" / "telegram-service", REPO / "src" / "ai-service",
|
||||||
|
REPO / "src" / "ml-service", REPO / "src" / "storage-service", REPO / "src" / "grpc-hosting"]
|
||||||
|
# файлы-исключения: DTO/модели, члены которых — собственный публичный API
|
||||||
|
EXCLUDE_FILES = {"src/core/Deal.Modules.Cards/Application/Models/Card.cs"}
|
||||||
|
|
||||||
|
IFACE_DECL = re.compile(
|
||||||
|
r"^\s*(?:public\s+|internal\s+)?(?:partial\s+|sealed\s+|static\s+|unsafe\s+)*interface\s+(\w+)", re.M)
|
||||||
|
CLASS_DECL = re.compile(
|
||||||
|
r"^\s*(?:public\s+|internal\s+)?(?:sealed\s+|abstract\s+|static\s+|partial\s+|unsafe\s+)*"
|
||||||
|
r"class\s+(\w+)(?:<[^>]*>)?[^{;]*:\s*([^{;]+)", re.M)
|
||||||
|
PARTIAL_CLASS = re.compile(
|
||||||
|
r"^\s*(?:public\s+|internal\s+)?(?:sealed\s+|abstract\s+|static\s+|partial\s+|unsafe\s+)*"
|
||||||
|
r"class\s+(\w+)(?:<[^>]*>)?\s*(?::[^{;]+)?\{", re.M)
|
||||||
|
IFNAME = re.compile(r"\bI[A-Z]\w*")
|
||||||
|
METHOD_DECL = re.compile(
|
||||||
|
r"^(?P<indent>\s*)(?P<mods>(?:public\s+|internal\s+|protected\s+|private\s+|static\s+|"
|
||||||
|
r"virtual\s+|override\s+|sealed\s+|async\s+|new\s+)*)"
|
||||||
|
r"(?P<ret>[\w<>\[\],\s.?]+?)\s(?P<name>\w+)\s*(?:<[^>]*>)?\s*\(")
|
||||||
|
PROP_DECL = re.compile(
|
||||||
|
r"^(?P<indent>\s*)(?P<mods>(?:public\s+|internal\s+|protected\s+|private\s+|static\s+|"
|
||||||
|
r"virtual\s+|override\s+|sealed\s+|new\s+)*)"
|
||||||
|
r"(?P<type>[\w<>\[\],\s.?]+?)\s(?P<name>\w+)\s*\{\s*(?P<accessors>(?:public\s+)?(?:get|set|init)[^}]*)\}")
|
||||||
|
|
||||||
|
|
||||||
|
def src_files() -> list[Path]:
|
||||||
|
out = []
|
||||||
|
for root in ROOTS:
|
||||||
|
if not root.exists():
|
||||||
|
continue
|
||||||
|
for p in root.rglob("*.cs"):
|
||||||
|
s = p.as_posix().lower()
|
||||||
|
if "/bin/" in s or "/obj/" in s or "/tests/" in s or ".tests/" in s:
|
||||||
|
continue
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def interface_body(text: str, start: int) -> str:
|
||||||
|
b = text.find("{", start)
|
||||||
|
if b == -1:
|
||||||
|
return ""
|
||||||
|
depth, end = 0, b
|
||||||
|
for i in range(b, len(text)):
|
||||||
|
if text[i] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif text[i] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
return text[b:end]
|
||||||
|
|
||||||
|
|
||||||
|
def norm_types(params: str) -> tuple[str, ...]:
|
||||||
|
if not params.strip():
|
||||||
|
return ()
|
||||||
|
out, depth, cur = [], 0, ""
|
||||||
|
for ch in params:
|
||||||
|
if ch in "<([":
|
||||||
|
depth += 1
|
||||||
|
elif ch in ">)]":
|
||||||
|
depth -= 1
|
||||||
|
if ch == "," and depth == 0:
|
||||||
|
out.append(cur)
|
||||||
|
cur = ""
|
||||||
|
else:
|
||||||
|
cur += ch
|
||||||
|
out.append(cur)
|
||||||
|
res = []
|
||||||
|
for raw in out:
|
||||||
|
t = re.sub(r"\s+", " ", raw.strip())
|
||||||
|
parts = t.rsplit(" ", 1)
|
||||||
|
if len(parts) == 2 and not re.search(r"[<>\[\](),]", parts[1]) and parts[1] not in ("*", "&"):
|
||||||
|
t = parts[0]
|
||||||
|
res.append(t.replace("?", "").replace(" ", ""))
|
||||||
|
return tuple(res)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_interfaces(files: list[Path]) -> dict[str, dict[str, dict]]:
|
||||||
|
tables: dict[str, dict[str, dict]] = {}
|
||||||
|
for p in files:
|
||||||
|
text = p.read_text(encoding="utf-8")
|
||||||
|
for m in IFACE_DECL.finditer(text):
|
||||||
|
name = m.group(1)
|
||||||
|
body = interface_body(text, m.start())
|
||||||
|
lines = body.splitlines()
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
mm = METHOD_DECL.match(line)
|
||||||
|
if mm and "(" in line:
|
||||||
|
# сигнатура может быть многострочной (§7: параметры на отдельных строках)
|
||||||
|
sig_text = line
|
||||||
|
while sig_text.count("(") > sig_text.count(")") and i + 1 < len(lines):
|
||||||
|
i += 1
|
||||||
|
sig_text += " " + lines[i].strip()
|
||||||
|
if sig_text.count("(") > sig_text.count(")"):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
params = sig_text.split("(", 1)[1].rsplit(")", 1)[0]
|
||||||
|
tables.setdefault(name, {})[mm.group("name")] = {"kind": "method", "sig": norm_types(params)}
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
pm = PROP_DECL.match(line)
|
||||||
|
if pm:
|
||||||
|
tables.setdefault(name, {})[pm.group("name")] = {
|
||||||
|
"kind": "prop", "sig": pm.group("type").replace("?", "").replace(" ", "")}
|
||||||
|
i += 1
|
||||||
|
return tables
|
||||||
|
|
||||||
|
|
||||||
|
def collect_class_ifaces(files: list[Path]) -> dict[str, set[str]]:
|
||||||
|
result: dict[str, set[str]] = {}
|
||||||
|
for p in files:
|
||||||
|
rel = p.relative_to(REPO).as_posix()
|
||||||
|
if rel in EXCLUDE_FILES:
|
||||||
|
continue
|
||||||
|
text = p.read_text(encoding="utf-8")
|
||||||
|
for m in CLASS_DECL.finditer(text):
|
||||||
|
ifaces = set(IFNAME.findall(m.group(2)))
|
||||||
|
if ifaces:
|
||||||
|
result.setdefault(m.group(1), set()).update(ifaces)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def convert_file(path: Path, cls_ifaces: dict[str, set[str]], itables: dict[str, dict[str, dict]]):
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
local: set[str] = set()
|
||||||
|
for m in PARTIAL_CLASS.finditer(text):
|
||||||
|
if m.group(1) in cls_ifaces:
|
||||||
|
local.add(m.group(1))
|
||||||
|
if not local:
|
||||||
|
return 0, None, []
|
||||||
|
allowed = {i for cls in local for i in cls_ifaces[cls] if i in itables}
|
||||||
|
|
||||||
|
changed, notes = 0, []
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
stripped = line.lstrip()
|
||||||
|
if stripped.startswith("//") or stripped.startswith("#"):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
mm = METHOD_DECL.match(line.rstrip("\r\n"))
|
||||||
|
pm = None if mm else PROP_DECL.match(line.rstrip("\r\n"))
|
||||||
|
if not mm and not pm:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
kind = "method" if mm else "prop"
|
||||||
|
decl = mm or pm
|
||||||
|
mods = decl.group("mods")
|
||||||
|
if "static" in mods or "public" not in mods:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
name = decl.group("name")
|
||||||
|
if kind == "method":
|
||||||
|
sig_text, j = line, i
|
||||||
|
while sig_text.count("(") > sig_text.count(")") and j + 1 < len(lines):
|
||||||
|
j += 1
|
||||||
|
sig_text += lines[j]
|
||||||
|
if "(" not in sig_text:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
sig = norm_types(sig_text.split("(", 1)[1].rsplit(")", 1)[0])
|
||||||
|
else:
|
||||||
|
sig = decl.group("type").replace("?", "").replace(" ", "")
|
||||||
|
j = i
|
||||||
|
cands = [ifc for ifc in allowed
|
||||||
|
if itables[ifc].get(name, {}).get("kind") == kind and itables[ifc][name]["sig"] == sig]
|
||||||
|
if not cands:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
ifc = cands[0]
|
||||||
|
head = line.rstrip("\r\n")
|
||||||
|
eol = line[len(head):]
|
||||||
|
# убрать модификаторы (оставить отступ), затем квалифицировать имя
|
||||||
|
new_head = re.sub(r"^(\s*)(?:public\s+|internal\s+|protected\s+|private\s+|virtual\s+|"
|
||||||
|
r"override\s+|sealed\s+|new\s+)+", r"\1", head)
|
||||||
|
m2 = re.search(r"\s" + re.escape(name) + r"(?![\w])", new_head)
|
||||||
|
if not m2:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
new_head = new_head[:m2.start()] + " " + ifc + "." + name + new_head[m2.end():]
|
||||||
|
lines[i] = new_head + eol
|
||||||
|
changed += 1
|
||||||
|
notes.append(f"{path.relative_to(REPO).as_posix()}:{i + 1} -> {ifc}.{name}")
|
||||||
|
i = j + 1
|
||||||
|
return changed, ("".join(lines) if changed else None), notes
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
apply = "--apply" in sys.argv
|
||||||
|
only = sys.argv[sys.argv.index("--file") + 1] if "--file" in sys.argv else None
|
||||||
|
files = src_files()
|
||||||
|
itables = collect_interfaces(files)
|
||||||
|
cls_ifaces = collect_class_ifaces(files)
|
||||||
|
total, per_file = 0, 0
|
||||||
|
for p in files:
|
||||||
|
if only and p.as_posix() != only:
|
||||||
|
continue
|
||||||
|
changed, new_text, notes = convert_file(p, cls_ifaces, itables)
|
||||||
|
if not changed:
|
||||||
|
continue
|
||||||
|
total += changed
|
||||||
|
per_file += 1
|
||||||
|
if apply and new_text is not None:
|
||||||
|
p.write_bytes(new_text.replace("\r\n", "\n").encode("utf-8"))
|
||||||
|
if not apply:
|
||||||
|
for n in notes[:3]:
|
||||||
|
print(" ", n)
|
||||||
|
print(f"{'apply' if apply else 'dry-run'}: конверсий {total} в {per_file} файлах")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
#
|
||||||
|
# mtls-certs.sh — генерация dev-CA и сертификатов mTLS внутреннего gRPC «Дейла» (этап 7, Ruling 6, Task 13).
|
||||||
|
#
|
||||||
|
# Требуется openssl. Вывод — deploy/certs/:
|
||||||
|
# ca.pem / ca.key — dev-CA (ca.pem — runtime всех процессов, ca.key — только подпись новых
|
||||||
|
# сертификатов; хранится в deploy/certs, в репозиторий/образ не попадает,
|
||||||
|
# см. .dockerignore; права 600);
|
||||||
|
# <service>-server.pfx — серверный сертификат процесса: core, telegram-service, ai-service,
|
||||||
|
# ml-service (SAN: localhost + имя compose-сервиса + host.docker.internal);
|
||||||
|
# deal-client.pfx — общий клиентский сертификат исходящих каналов (core → сервисы,
|
||||||
|
# telegram-service → core-ингресс);
|
||||||
|
# deal-client.crt/.key — тот же клиентский сертификат в PEM (без пароля): нужен docker
|
||||||
|
# healthcheck'ам compose.prod при mTLS — grpc_health_probe принимает
|
||||||
|
# только PEM (флаги -tls-client-cert/-tls-client-key; см. Task 14).
|
||||||
|
#
|
||||||
|
# Сертификаты dev (срок 825 дней — как Let's Encrypt; CA — 10 лет). Пароль PFX — env DEAL_MTLS_CERT_PASSWORD
|
||||||
|
# (по умолчанию deal_mtls_dev_password — dev); в PROD задайте свой и пропишите его же в
|
||||||
|
# DEAL_MTLS_SERVER_CERT_PASSWORD / DEAL_MTLS_CLIENT_CERT_PASSWORD процессов (compose-prod — Task 14).
|
||||||
|
# Повторный запуск без -f отказывается перезаписывать существующую CA (смена CA ломает доверие всех
|
||||||
|
# сертификатов — перегенерация нужна осознанная). Subject-имена задаются config-файлами openssl, а не
|
||||||
|
# -subj, — скрипт работает и в Git Bash (MSYS не «съедает» аргументы вида /CN=...).
|
||||||
|
#
|
||||||
|
# Использование:
|
||||||
|
# scripts/mtls-certs.sh [-f] [DEAL_MTLS_CERT_PASSWORD=...]
|
||||||
|
#
|
||||||
|
# После генерации процессы поднимаются с env (пути — как смонтировано в compose-prod, Task 14):
|
||||||
|
# DEAL_MTLS_ENABLED=1
|
||||||
|
# DEAL_MTLS_CA_PEM=/etc/deal/certs/ca.pem
|
||||||
|
# DEAL_MTLS_SERVER_CERT_PFX=/etc/deal/certs/<service>-server.pfx
|
||||||
|
# DEAL_MTLS_SERVER_CERT_PASSWORD=<тот же пароль>
|
||||||
|
# DEAL_MTLS_CLIENT_CERT_PFX=/etc/deal/certs/deal-client.pfx
|
||||||
|
# DEAL_MTLS_CLIENT_CERT_PASSWORD=<тот же пароль>
|
||||||
|
#
|
||||||
|
# Dev-стек (deploy/compose.dev.yml) остаётся plaintext + service-token — DEAL_MTLS_ENABLED не задаётся.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Корень репозитория (каталог скрипта/..).
|
||||||
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
||||||
|
CERT_DIR="$ROOT_DIR/deploy/certs"
|
||||||
|
|
||||||
|
# Пароль PFX: env DEAL_MTLS_CERT_PASSWORD (dev-дефолт — фиксированное значение; PROD — свой).
|
||||||
|
CERT_PASSWORD="${DEAL_MTLS_CERT_PASSWORD:-deal_mtls_dev_password}"
|
||||||
|
|
||||||
|
# Сроки сертификатов (дней): CA — 10 лет, листовые — 825 (как Let's Encrypt).
|
||||||
|
CA_DAYS=3650
|
||||||
|
LEAF_DAYS=825
|
||||||
|
|
||||||
|
# Пространство имён сертификатов: SAN листовых — localhost + имя compose-сервиса + host.docker.internal
|
||||||
|
# (dev-прогон сервиса в контейнере против core на хосте, Ruling 12) + loopback-IP.
|
||||||
|
SERVICES="core telegram-service ai-service ml-service"
|
||||||
|
|
||||||
|
FORCE=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-f|--force) FORCE=1 ;;
|
||||||
|
*)
|
||||||
|
echo "mtls-certs: неизвестный аргумент: $arg (ожидалось -f/--force)" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! command -v openssl >/dev/null 2>&1; then
|
||||||
|
echo "mtls-certs: openssl не найден — установите openssl (например: apt install openssl / brew install openssl)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$CERT_DIR"
|
||||||
|
|
||||||
|
if [ -f "$CERT_DIR/ca.pem" ]; then
|
||||||
|
if [ "$FORCE" -eq 0 ]; then
|
||||||
|
echo "mtls-certs: $CERT_DIR/ca.pem уже существует — повторный запуск не перезаписывает CA" >&2
|
||||||
|
echo "mtls-certs: (смена CA ломает доверие выданных сертификатов). Перегенерировать всё: $0 -f" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Принудительная перегенерация: старые сертификаты удаляются целиком (их доверие утеряно вместе с CA).
|
||||||
|
rm -f "$CERT_DIR/ca.pem" "$CERT_DIR/ca.key" "$CERT_DIR/ca.srl" "$CERT_DIR"/*-server.pfx \
|
||||||
|
"$CERT_DIR/deal-client.pfx" "$CERT_DIR/deal-client.crt" "$CERT_DIR/deal-client.key"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Временный каталог для CSR/конфигов/ключей (всегда чистится, в т.ч. при ошибке).
|
||||||
|
TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/deal-mtls.XXXXXX")
|
||||||
|
trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
|
||||||
|
|
||||||
|
echo "mtls-certs: генерирую dev-CA (CN=Deal mTLS Dev CA, ${CA_DAYS} дн.) в $CERT_DIR ..."
|
||||||
|
cat > "$TMP_DIR/ca.cnf" <<'EOF'
|
||||||
|
[req]
|
||||||
|
distinguished_name = ca_dn
|
||||||
|
prompt = no
|
||||||
|
x509_extensions = ca_ext
|
||||||
|
|
||||||
|
[ca_dn]
|
||||||
|
CN = Deal mTLS Dev CA
|
||||||
|
|
||||||
|
[ca_ext]
|
||||||
|
basicConstraints = critical,CA:TRUE
|
||||||
|
keyUsage = critical,keyCertSign,cRLSign,digitalSignature
|
||||||
|
subjectKeyIdentifier = hash
|
||||||
|
EOF
|
||||||
|
openssl req -x509 -newkey rsa:2048 -sha256 -days "$CA_DAYS" -nodes \
|
||||||
|
-config "$TMP_DIR/ca.cnf" \
|
||||||
|
-keyout "$CERT_DIR/ca.key" \
|
||||||
|
-out "$CERT_DIR/ca.pem"
|
||||||
|
chmod 600 "$CERT_DIR/ca.key"
|
||||||
|
chmod 644 "$CERT_DIR/ca.pem"
|
||||||
|
|
||||||
|
# ── Серверные сертификаты процессов (SAN: localhost + compose-имя + host.docker.internal) ──
|
||||||
|
|
||||||
|
for service in $SERVICES; do
|
||||||
|
echo "mtls-certs: серверный сертификат $service (${LEAF_DAYS} дн., SAN: localhost,$service,host.docker.internal) ..."
|
||||||
|
cat > "$TMP_DIR/server-req.cnf" <<EOF
|
||||||
|
[req]
|
||||||
|
distinguished_name = server_dn
|
||||||
|
prompt = no
|
||||||
|
|
||||||
|
[server_dn]
|
||||||
|
CN = $service
|
||||||
|
EOF
|
||||||
|
cat > "$TMP_DIR/server-ext.cnf" <<EOF
|
||||||
|
[server]
|
||||||
|
basicConstraints = critical,CA:FALSE
|
||||||
|
keyUsage = critical,digitalSignature,keyEncipherment
|
||||||
|
extendedKeyUsage = serverAuth
|
||||||
|
subjectAltName = DNS:localhost,DNS:${service},DNS:host.docker.internal,IP:127.0.0.1
|
||||||
|
EOF
|
||||||
|
openssl req -new -newkey rsa:2048 -nodes \
|
||||||
|
-config "$TMP_DIR/server-req.cnf" \
|
||||||
|
-keyout "$TMP_DIR/server.key" \
|
||||||
|
-out "$TMP_DIR/server.csr"
|
||||||
|
openssl x509 -req \
|
||||||
|
-in "$TMP_DIR/server.csr" \
|
||||||
|
-CA "$CERT_DIR/ca.pem" \
|
||||||
|
-CAkey "$CERT_DIR/ca.key" \
|
||||||
|
-CAcreateserial \
|
||||||
|
-out "$TMP_DIR/server.crt" \
|
||||||
|
-days "$LEAF_DAYS" -sha256 \
|
||||||
|
-extfile "$TMP_DIR/server-ext.cnf" \
|
||||||
|
-extensions server
|
||||||
|
openssl pkcs12 -export \
|
||||||
|
-out "$CERT_DIR/$service-server.pfx" \
|
||||||
|
-inkey "$TMP_DIR/server.key" \
|
||||||
|
-in "$TMP_DIR/server.crt" \
|
||||||
|
-name "$service-server" \
|
||||||
|
-passout "pass:$CERT_PASSWORD"
|
||||||
|
rm -f "$TMP_DIR/server.key" "$TMP_DIR/server.csr" "$TMP_DIR/server.crt"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Общий клиентский сертификат deal-client (исходящие каналы core и telegram-service) ──
|
||||||
|
|
||||||
|
echo "mtls-certs: клиентский сертификат deal-client (${LEAF_DAYS} дн.) ..."
|
||||||
|
cat > "$TMP_DIR/client-req.cnf" <<'EOF'
|
||||||
|
[req]
|
||||||
|
distinguished_name = client_dn
|
||||||
|
prompt = no
|
||||||
|
|
||||||
|
[client_dn]
|
||||||
|
CN = deal-client
|
||||||
|
EOF
|
||||||
|
cat > "$TMP_DIR/client-ext.cnf" <<'EOF'
|
||||||
|
[client]
|
||||||
|
basicConstraints = critical,CA:FALSE
|
||||||
|
keyUsage = critical,digitalSignature
|
||||||
|
extendedKeyUsage = clientAuth
|
||||||
|
EOF
|
||||||
|
openssl req -new -newkey rsa:2048 -nodes \
|
||||||
|
-config "$TMP_DIR/client-req.cnf" \
|
||||||
|
-keyout "$TMP_DIR/client.key" \
|
||||||
|
-out "$TMP_DIR/client.csr"
|
||||||
|
openssl x509 -req \
|
||||||
|
-in "$TMP_DIR/client.csr" \
|
||||||
|
-CA "$CERT_DIR/ca.pem" \
|
||||||
|
-CAkey "$CERT_DIR/ca.key" \
|
||||||
|
-CAcreateserial \
|
||||||
|
-out "$TMP_DIR/client.crt" \
|
||||||
|
-days "$LEAF_DAYS" -sha256 \
|
||||||
|
-extfile "$TMP_DIR/client-ext.cnf" \
|
||||||
|
-extensions client
|
||||||
|
openssl pkcs12 -export \
|
||||||
|
-out "$CERT_DIR/deal-client.pfx" \
|
||||||
|
-inkey "$TMP_DIR/client.key" \
|
||||||
|
-in "$TMP_DIR/client.crt" \
|
||||||
|
-name "deal-client" \
|
||||||
|
-passout "pass:$CERT_PASSWORD"
|
||||||
|
|
||||||
|
# PEM-копии клиентского сертификата для grpc_health_probe compose.prod при mTLS (Task 14): утилита
|
||||||
|
# принимает только PEM и без пароля. Сертификат публичный; приватный ключ — права 600, как ca.key.
|
||||||
|
openssl pkcs12 -in "$CERT_DIR/deal-client.pfx" -clcerts -nokeys -passin "pass:$CERT_PASSWORD" \
|
||||||
|
-out "$CERT_DIR/deal-client.crt"
|
||||||
|
openssl pkcs12 -in "$CERT_DIR/deal-client.pfx" -nocerts -nodes -passin "pass:$CERT_PASSWORD" \
|
||||||
|
-out "$CERT_DIR/deal-client.key"
|
||||||
|
chmod 644 "$CERT_DIR/deal-client.crt"
|
||||||
|
chmod 600 "$CERT_DIR/deal-client.key"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "mtls-certs: готово. Файлы в $CERT_DIR:"
|
||||||
|
ls -1 "$CERT_DIR"
|
||||||
|
echo
|
||||||
|
echo "Пароль PFX (DEAL_MTLS_CERT_PASSWORD): $CERT_PASSWORD"
|
||||||
|
echo "Проверка (SAN/подпись): openssl pkcs12 -in $CERT_DIR/<service>-server.pfx -nokeys | openssl verify -CAfile $CERT_DIR/ca.pem"
|
||||||
|
echo
|
||||||
|
echo "Включение mTLS (dev остаётся plaintext — флаг не задаётся; PROD env передаёт compose-prod, Task 14):"
|
||||||
|
echo " DEAL_MTLS_ENABLED=1"
|
||||||
|
echo " DEAL_MTLS_CA_PEM=$CERT_DIR/ca.pem"
|
||||||
|
echo " DEAL_MTLS_SERVER_CERT_PFX=$CERT_DIR/<service>-server.pfx # для каждого процесса свой"
|
||||||
|
echo " DEAL_MTLS_SERVER_CERT_PASSWORD=$CERT_PASSWORD"
|
||||||
|
echo " DEAL_MTLS_CLIENT_CERT_PFX=$CERT_DIR/deal-client.pfx"
|
||||||
|
echo " DEAL_MTLS_CLIENT_CERT_PASSWORD=$CERT_PASSWORD"
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# restore.sh — восстановление «Дейла» из снапшотов scripts/backup.sh (этап 7 Task 15, Ruling 8).
|
||||||
|
#
|
||||||
|
# ВАЖНО — порядок применения (БД/тома не должны быть заняты; скрипт сервисы НЕ останавливает):
|
||||||
|
# 1) остановите core и сервисы:
|
||||||
|
# dev: docker compose -f deploy/compose.dev.yml stop core telegram-service ml-service
|
||||||
|
# prod: docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml stop
|
||||||
|
# 2) bash scripts/restore.sh [all|pg|minio|data] [TS]
|
||||||
|
# TS — метка времени снапшота вида 20260908-021500 (из имени файла backup-<TS>…);
|
||||||
|
# без TS берётся самый свежий снапшот Postgres, остальные шаги — тот же TS;
|
||||||
|
# 3) поднимите сервисы обратно (docker compose … start core telegram-service ml-service).
|
||||||
|
#
|
||||||
|
# Шаги (зеркально backup.sh, те же env — см. scripts/deal-backup-lib.sh и техдок §13.9):
|
||||||
|
# pg — БД deal целиком (public + tenant_*): docker-режим — dropdb+createdb, затем pg_restore
|
||||||
|
# из $BACKUP_DIR/pg/backup-<TS>.dump (docker exec deal-postgres); при DEAL_PG_HOST —
|
||||||
|
# прямое pg_restore --clean --if-exists поверх существующей БД;
|
||||||
|
# minio — mc mirror $BACKUP_DIR/minio/backup-<TS>/ → бакет deal-files (восстановление = загрузка
|
||||||
|
# В MinIO); DEAL_MINIO_MIRROR_REMOVE=1 добавляет --remove (бакет = снимок 1:1);
|
||||||
|
# data — распаковка $BACKUP_DIR/data/backup-<TS>.tar.gz в DEAL_DATA_DIR; при DEAL_TAR_VOLUMES —
|
||||||
|
# архивы backup-<TS>.<volume>.tar.gz распаковываются в соответствующие docker volume'ы
|
||||||
|
# (busybox-контейнер).
|
||||||
|
#
|
||||||
|
# ⚠ Восстановление — OVERLAY (дописывание поверх текущих данных), лишние объекты НЕ удаляются:
|
||||||
|
# файл/объект, которого нет в снапшоте, останется. Для бакета MinIO строгий снимок 1:1 —
|
||||||
|
# DEAL_MINIO_MIRROR_REMOVE=1 (mc mirror --remove); для data-каталогов/томов при необходимости
|
||||||
|
# очистите целевой каталог/том вручную ПЕРЕД распаковкой.
|
||||||
|
#
|
||||||
|
# Код возврата: 0 — все запрошенные шаги ok; 1 — сбой. Лог — консоль и
|
||||||
|
# $BACKUP_DIR/logs/restore-YYYYMM.log. Секреты в лог не попадают.
|
||||||
|
# Реальный прогон и тест восстановления — ⚠ Manual (нужен поднятый docker-стек).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Общие env-дефолты и хелперы (BACKUP_DIR, resolve_pg_container, select_mc_mode, mc_cmd…)
|
||||||
|
# shellcheck source=deal-backup-lib.sh
|
||||||
|
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/deal-backup-lib.sh"
|
||||||
|
|
||||||
|
# --- Аргументы и валидация -------------------------------------------------------------
|
||||||
|
say_usage() {
|
||||||
|
say "использование: restore.sh [all|pg|minio|data] [TS]"
|
||||||
|
say " TS — метка снапшота YYYYMMDD-HHMMSS (по умолчанию — самый свежий)"
|
||||||
|
}
|
||||||
|
|
||||||
|
STEP=${1:-all}
|
||||||
|
TS_ARG=${2:-""}
|
||||||
|
case "$STEP" in
|
||||||
|
all | pg | minio | data) ;;
|
||||||
|
*) say_usage; die "неизвестный шаг '$STEP'" ;;
|
||||||
|
esac
|
||||||
|
case "$TS_ARG" in
|
||||||
|
'') ;;
|
||||||
|
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) ;;
|
||||||
|
*) say_usage; die "TS должен быть вида YYYYMMDD-HHMMSS (сейчас: '$TS_ARG')" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR/logs"
|
||||||
|
|
||||||
|
latest_pg_ts() { # самая свежая метка среди дампов pg
|
||||||
|
ls -1 "$BACKUP_DIR"/pg/backup-*.dump 2>/dev/null \
|
||||||
|
| sort | tail -n 1 | sed 's#.*/backup-##; s/\.dump$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_pg_ts() { # выбирает TS_ARG: из аргумента или самый свежий pg-снапшот
|
||||||
|
if [ -n "$TS_ARG" ]; then return 0; fi
|
||||||
|
TS_ARG=$(latest_pg_ts)
|
||||||
|
if [ -z "$TS_ARG" ]; then
|
||||||
|
die "в $BACKUP_DIR/pg нет дампов backup-*.dump — сначала выполните scripts/backup.sh"
|
||||||
|
fi
|
||||||
|
log "Выбран снапшот: $TS_ARG"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Шаги --------------------------------------------------------------------------------
|
||||||
|
restore_pg() { # БД deal целиком (public + tenant_*)
|
||||||
|
local dump
|
||||||
|
resolve_pg_ts
|
||||||
|
dump="$BACKUP_DIR/pg/backup-$TS_ARG.dump"
|
||||||
|
[ -f "$dump" ] || die "дамп не найден: $dump"
|
||||||
|
if [ -n "$DEAL_PG_HOST" ]; then
|
||||||
|
have pg_restore || die "задан DEAL_PG_HOST, но pg_restore не найден в PATH"
|
||||||
|
log "Postgres: прямое pg_restore --clean --if-exists → $DEAL_PG_HOST:$DEAL_PG_PORT/$DEAL_PG_DB"
|
||||||
|
PGPASSWORD="$DEAL_PG_PASSWORD" pg_restore --clean --if-exists --no-owner --exit-on-error \
|
||||||
|
-h "$DEAL_PG_HOST" -p "$DEAL_PG_PORT" -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" "$dump" \
|
||||||
|
|| die "pg_restore завершился с ошибкой"
|
||||||
|
else
|
||||||
|
docker_ok || die "Docker недоступен и DEAL_PG_HOST не задан — укажите DEAL_PG_HOST… для прямого pg_restore"
|
||||||
|
resolve_pg_container \
|
||||||
|
|| die "контейнер Postgres не найден (искал deal-postgres и compose-сервис 'postgres'); задайте DEAL_PG_CONTAINER или DEAL_PG_HOST"
|
||||||
|
log "Postgres: docker cp $dump → $PG_CONTAINER, dropdb + createdb + pg_restore"
|
||||||
|
docker cp "$(host_docker_path "$dump")" "$PG_CONTAINER:/tmp/deal-restore.dump" || die "docker cp дампа не удался"
|
||||||
|
# БД пересоздаётся целиком — снимок всех схем консистентен (core/сервисы должны быть остановлены!)
|
||||||
|
docker exec "$PG_CONTAINER" dropdb -U "$DEAL_PG_USER" --if-exists "$DEAL_PG_DB" \
|
||||||
|
|| die "dropdb не удался (есть активные подключения? остановите core/сервисы)"
|
||||||
|
docker exec "$PG_CONTAINER" createdb -U "$DEAL_PG_USER" -O "$DEAL_PG_USER" "$DEAL_PG_DB" \
|
||||||
|
|| die "createdb не удался"
|
||||||
|
docker exec "$PG_CONTAINER" pg_restore -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" --no-owner --exit-on-error \
|
||||||
|
/tmp/deal-restore.dump || die "pg_restore завершился с ошибкой"
|
||||||
|
docker exec "$PG_CONTAINER" rm -f /tmp/deal-restore.dump || true
|
||||||
|
fi
|
||||||
|
log "Postgres: OK (БД $DEAL_PG_DB из $dump)"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_minio() { # mc mirror снапшота → бакет deal-files
|
||||||
|
local snap
|
||||||
|
select_mc_mode
|
||||||
|
[ "$MC_MODE" = "skip" ] && return 0
|
||||||
|
if [ -n "$TS_ARG" ]; then
|
||||||
|
snap="$BACKUP_DIR/minio/backup-$TS_ARG"
|
||||||
|
[ -d "$snap" ] || die "снапшот MinIO не найден: $snap"
|
||||||
|
else
|
||||||
|
snap=$(ls -1d "$BACKUP_DIR"/minio/backup-* 2>/dev/null | sort | tail -n 1)
|
||||||
|
[ -n "$snap" ] || die "в $BACKUP_DIR/minio нет снапшотов backup-*"
|
||||||
|
fi
|
||||||
|
log "MinIO: mc mirror $snap → бакет $DEAL_MINIO_BUCKET (режим mc: $MC_MODE)"
|
||||||
|
if [ "$DEAL_MINIO_MIRROR_REMOVE" = "1" ] || [ "$DEAL_MINIO_MIRROR_REMOVE" = "true" ]; then
|
||||||
|
mc_cmd mirror --overwrite --remove "$snap" "deal/$DEAL_MINIO_BUCKET" \
|
||||||
|
|| die "mc mirror (restore) завершился с ошибкой"
|
||||||
|
else
|
||||||
|
mc_cmd mirror --overwrite "$snap" "deal/$DEAL_MINIO_BUCKET" \
|
||||||
|
|| die "mc mirror (restore) завершился с ошибкой"
|
||||||
|
fi
|
||||||
|
log "MinIO: OK ($snap → $DEAL_MINIO_BUCKET)"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_data() { # распаковка архивов данных: docker volume'ы (DEAL_TAR_VOLUMES) или DEAL_DATA_DIR
|
||||||
|
local vol arc
|
||||||
|
if [ -n "$DEAL_TAR_VOLUMES" ]; then
|
||||||
|
docker_ok || die "задан DEAL_TAR_VOLUMES, но Docker недоступен"
|
||||||
|
for vol in $DEAL_TAR_VOLUMES; do
|
||||||
|
docker volume inspect "$vol" >/dev/null 2>&1 \
|
||||||
|
|| die "docker volume '$vol' не существует (docker volume ls)"
|
||||||
|
if [ -n "$TS_ARG" ]; then
|
||||||
|
arc="$BACKUP_DIR/data/backup-$TS_ARG.$vol.tar.gz"
|
||||||
|
[ -f "$arc" ] || arc=""
|
||||||
|
else
|
||||||
|
arc=$(ls -1 "$BACKUP_DIR"/data/backup-*."$vol".tar.gz 2>/dev/null | sort | tail -n 1)
|
||||||
|
fi
|
||||||
|
if [ -z "$arc" ] || [ ! -f "$arc" ]; then
|
||||||
|
log "Data: WARNING — архив тома '$vol' не найден, пропускаю"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
log "Data: распаковка $(basename "$arc") → docker volume $vol (busybox)"
|
||||||
|
docker run --rm \
|
||||||
|
-v "$(host_docker_path "$BACKUP_DIR/data"):/in:ro" -v "$vol:/data" \
|
||||||
|
busybox tar -xzf "/in/$(basename "$arc")" -C /data \
|
||||||
|
|| die "распаковка в volume '$vol' завершилась с ошибкой"
|
||||||
|
log "Data: OK ($(basename "$arc") → $vol)"
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -d "$DEAL_DATA_DIR" ] || die "DEAL_DATA_DIR='$DEAL_DATA_DIR' не существует (создайте каталог)"
|
||||||
|
if [ -n "$TS_ARG" ]; then
|
||||||
|
arc="$BACKUP_DIR/data/backup-$TS_ARG.tar.gz"
|
||||||
|
else
|
||||||
|
# только host-архивы вида backup-YYYYMMDD-HHMMSS.tar.gz (архивы томов содержат имя volume)
|
||||||
|
arc=$(ls -1 "$BACKUP_DIR"/data/backup-*.tar.gz 2>/dev/null \
|
||||||
|
| grep -E 'backup-[0-9]{8}-[0-9]{6}\.tar\.gz$' | sort | tail -n 1)
|
||||||
|
fi
|
||||||
|
[ -n "$arc" ] && [ -f "$arc" ] || die "архив данных не найден в $BACKUP_DIR/data (backup-<TS>.tar.gz)"
|
||||||
|
log "Data: распаковка $(basename "$arc") → $DEAL_DATA_DIR"
|
||||||
|
tar -xzf "$arc" -C "$DEAL_DATA_DIR" || die "распаковка архива данных завершилась с ошибкой"
|
||||||
|
log "Data: OK ($(basename "$arc") → $DEAL_DATA_DIR)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Основной поток ------------------------------------------------------------------------
|
||||||
|
main() {
|
||||||
|
log "=== Восстановление «Дейла»: старт (шаг: $STEP, BACKUP_DIR=$BACKUP_DIR) ==="
|
||||||
|
case "$STEP" in
|
||||||
|
all)
|
||||||
|
restore_pg || return 1
|
||||||
|
restore_minio || return 1
|
||||||
|
restore_data || return 1
|
||||||
|
;;
|
||||||
|
pg) restore_pg || return 1 ;;
|
||||||
|
minio) restore_minio || return 1 ;;
|
||||||
|
data) restore_data || return 1 ;;
|
||||||
|
esac
|
||||||
|
log "=== Восстановление завершено (шаг: $STEP, TS: ${TS_ARG:-последний}) ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_FILE="$BACKUP_DIR/logs/restore-$(date +%Y%m).log"
|
||||||
|
main "$@" 2>&1 | tee -a "$LOG_FILE"
|
||||||
|
status=$?
|
||||||
|
exit "$status"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# test.sh — тесты всех сервисов Дейла + линтер локализации фронта. CI-шаг «test».
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REPO_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
|
||||||
|
for PROJECT in \
|
||||||
|
src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj \
|
||||||
|
src/telegram-service/Deal.Telegram.Tests/Deal.Telegram.Tests.csproj \
|
||||||
|
src/ai-service/Deal.Ai.Tests/Deal.Ai.Tests.csproj \
|
||||||
|
src/ml-service/Deal.Ml.Tests/Deal.Ml.Tests.csproj \
|
||||||
|
src/storage-service/Deal.Storage.Tests/Deal.Storage.Tests.csproj; do
|
||||||
|
echo "== test: $PROJECT =="
|
||||||
|
(cd "$REPO_DIR" && dotnet test "$PROJECT" --nologo --logger "console;verbosity=minimal")
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== frontend: lint:i18n =="
|
||||||
|
if command -v npm >/dev/null 2>&1; then
|
||||||
|
(cd "$REPO_DIR/src/frontend" && npm run lint:i18n)
|
||||||
|
else
|
||||||
|
echo " пропуск: npm не установлен (линтер локализации не запущен)"
|
||||||
|
fi
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
namespace Deal.Ai;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Имена операций AiService для аудита
|
|
||||||
/// </summary>
|
|
||||||
public static class AiOperations
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Операция ИИ-фильтра входящих сообщений.
|
|
||||||
/// </summary>
|
|
||||||
public const string Filter = "filter";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Операция полного разбора лида.
|
|
||||||
/// </summary>
|
|
||||||
public const string Classify = "classify";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Операция генерации ключевых слов discovery-задачи.
|
|
||||||
/// </summary>
|
|
||||||
public const string GenerateKeywords = "generate_keywords";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Операция оценки соответствия сообщения задаче поиска.
|
|
||||||
/// </summary>
|
|
||||||
public const string EvaluateFit = "evaluate_fit";
|
|
||||||
}
|
|
||||||
@@ -150,7 +150,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
|||||||
}
|
}
|
||||||
catch (LlmCallException callError)
|
catch (LlmCallException callError)
|
||||||
{
|
{
|
||||||
LogAiUnavailable(AiOperations.Filter, tenantId, config, callError);
|
LogAiUnavailable("filter", tenantId, config, callError);
|
||||||
throw ToUnavailable(callError);
|
throw ToUnavailable(callError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
|||||||
}
|
}
|
||||||
catch (LlmCallException callError)
|
catch (LlmCallException callError)
|
||||||
{
|
{
|
||||||
LogAiUnavailable(AiOperations.Classify, tenantId, config, callError);
|
LogAiUnavailable("classify", tenantId, config, callError);
|
||||||
throw ToUnavailable(callError);
|
throw ToUnavailable(callError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
|||||||
}
|
}
|
||||||
catch (LlmCallException callError)
|
catch (LlmCallException callError)
|
||||||
{
|
{
|
||||||
LogAiUnavailable(AiOperations.GenerateKeywords, tenantId, config, callError);
|
LogAiUnavailable("generate_keywords", tenantId, config, callError);
|
||||||
throw ToUnavailable(callError);
|
throw ToUnavailable(callError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,7 +267,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
|||||||
}
|
}
|
||||||
catch (LlmCallException callError)
|
catch (LlmCallException callError)
|
||||||
{
|
{
|
||||||
LogAiUnavailable(AiOperations.EvaluateFit, tenantId, config, callError);
|
LogAiUnavailable("evaluate_fit", tenantId, config, callError);
|
||||||
throw ToUnavailable(callError);
|
throw ToUnavailable(callError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ WORKDIR /repo
|
|||||||
# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников).
|
# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников).
|
||||||
COPY src/contracts/Deal.Proto.csproj src/contracts/
|
COPY src/contracts/Deal.Proto.csproj src/contracts/
|
||||||
COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/
|
COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/
|
||||||
COPY src/core/Deal.SharedKernel/Deal.SharedKernel.csproj src/core/Deal.SharedKernel/
|
|
||||||
COPY src/ai-service/Directory.Build.props src/ai-service/
|
COPY src/ai-service/Directory.Build.props src/ai-service/
|
||||||
COPY src/ai-service/Deal.Ai/Deal.Ai.csproj src/ai-service/Deal.Ai/
|
COPY src/ai-service/Deal.Ai/Deal.Ai.csproj src/ai-service/Deal.Ai/
|
||||||
RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
|
RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
|
||||||
@@ -20,7 +19,6 @@ RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
|
|||||||
# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса.
|
# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса.
|
||||||
COPY src/contracts/ src/contracts/
|
COPY src/contracts/ src/contracts/
|
||||||
COPY src/grpc-hosting/ src/grpc-hosting/
|
COPY src/grpc-hosting/ src/grpc-hosting/
|
||||||
COPY src/core/Deal.SharedKernel/ src/core/Deal.SharedKernel/
|
|
||||||
COPY src/ai-service/Deal.Ai/ src/ai-service/Deal.Ai/
|
COPY src/ai-service/Deal.Ai/ src/ai-service/Deal.Ai/
|
||||||
RUN dotnet publish src/ai-service/Deal.Ai/Deal.Ai.csproj -c Release -o /app/publish
|
RUN dotnet publish src/ai-service/Deal.Ai/Deal.Ai.csproj -c Release -o /app/publish
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace Deal.Ai.Llm;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Роли сообщений чата в wire-формате ИИ-провайдеров
|
|
||||||
/// </summary>
|
|
||||||
public static class LlmChatRoles
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Роль системного сообщения.
|
|
||||||
/// </summary>
|
|
||||||
public const string System = "system";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Роль пользовательского сообщения.
|
|
||||||
/// </summary>
|
|
||||||
public const string User = "user";
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,9 @@ using System.Text.Json.Nodes;
|
|||||||
|
|
||||||
namespace Deal.Ai.Llm;
|
namespace Deal.Ai.Llm;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP-реализация <see cref="IProviderClient"/>
|
||||||
|
/// </summary>
|
||||||
public sealed class LlmHttpClient : IProviderClient
|
public sealed class LlmHttpClient : IProviderClient
|
||||||
{
|
{
|
||||||
// Относительный путь OpenAI-совместимого эндпоинта (база уже без хвостового «/»).
|
// Относительный путь OpenAI-совместимого эндпоинта (база уже без хвостового «/»).
|
||||||
@@ -55,6 +58,13 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
_anthropicCallTimeout = anthropicCallTimeout;
|
_anthropicCallTimeout = anthropicCallTimeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выполняет один вызов модели по выбранной схеме API.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">Конфиг провайдера (стиль — <c>ApiStyle</c>).</param>
|
||||||
|
/// <param name="systemPrompt">Системный промпт.</param>
|
||||||
|
/// <param name="userText">Пользовательское сообщение/контекст.</param>
|
||||||
|
/// <returns>Текст ответа и usage API-ответа (null при его отсутствии).</returns>
|
||||||
async Task<ProviderChatResult> IProviderClient.ChatAsync(
|
async Task<ProviderChatResult> IProviderClient.ChatAsync(
|
||||||
LlmConfig config,
|
LlmConfig config,
|
||||||
string systemPrompt,
|
string systemPrompt,
|
||||||
@@ -100,12 +110,12 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
|
|
||||||
var body = new JsonObject
|
var body = new JsonObject
|
||||||
{
|
{
|
||||||
[LlmWireKeys.Model] = config.Model,
|
["model"] = config.Model,
|
||||||
[LlmWireKeys.Messages] = new JsonArray(
|
["messages"] = new JsonArray(
|
||||||
ChatMessage(LlmChatRoles.System, systemPrompt),
|
ChatMessage("system", systemPrompt),
|
||||||
ChatMessage(LlmChatRoles.User, userText)),
|
ChatMessage("user", userText)),
|
||||||
[LlmWireKeys.Temperature] = Temperature,
|
["temperature"] = Temperature,
|
||||||
[LlmWireKeys.MaxTokens] = MaxResponseTokens,
|
["max_tokens"] = MaxResponseTokens,
|
||||||
};
|
};
|
||||||
|
|
||||||
request.Content = JsonBody(body);
|
request.Content = JsonBody(body);
|
||||||
@@ -123,10 +133,10 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
|
|
||||||
var body = new JsonObject
|
var body = new JsonObject
|
||||||
{
|
{
|
||||||
[LlmWireKeys.Model] = config.Model,
|
["model"] = config.Model,
|
||||||
[LlmWireKeys.MaxTokens] = MaxResponseTokens,
|
["max_tokens"] = MaxResponseTokens,
|
||||||
[LlmWireKeys.System] = systemPrompt,
|
["system"] = systemPrompt,
|
||||||
[LlmWireKeys.Messages] = new JsonArray(ChatMessage(LlmChatRoles.User, userText)),
|
["messages"] = new JsonArray(ChatMessage("user", userText)),
|
||||||
};
|
};
|
||||||
|
|
||||||
request.Content = JsonBody(body);
|
request.Content = JsonBody(body);
|
||||||
@@ -156,7 +166,7 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
private static ProviderChatResult ReadOpenAiBody(string body)
|
private static ProviderChatResult ReadOpenAiBody(string body)
|
||||||
{
|
{
|
||||||
JsonObject? payload = ParseObjectOrThrow(body);
|
JsonObject? payload = ParseObjectOrThrow(body);
|
||||||
JsonArray? choices = payload[LlmWireKeys.Choices] as JsonArray;
|
JsonArray? choices = payload["choices"] as JsonArray;
|
||||||
if (choices is null || choices.Count == 0)
|
if (choices is null || choices.Count == 0)
|
||||||
{
|
{
|
||||||
// Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит).
|
// Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит).
|
||||||
@@ -164,14 +174,14 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
// choices[i] — объект выбора {message, finish_reason, …}; текст — в message.content.
|
// choices[i] — объект выбора {message, finish_reason, …}; текст — в message.content.
|
||||||
JsonObject? message = (choices[0] as JsonObject)?[LlmWireKeys.Message] as JsonObject;
|
JsonObject? message = (choices[0] as JsonObject)?["message"] as JsonObject;
|
||||||
string? content = ReadStringField(message, LlmWireKeys.Content);
|
string? content = ReadStringField(message, "content");
|
||||||
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, LlmWireKeys.ReasoningContent)))
|
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, "reasoning_content")))
|
||||||
{
|
{
|
||||||
throw new LlmHttpException("Модель вернула только reasoning без ответа");
|
throw new LlmHttpException("Модель вернула только reasoning без ответа");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload[LlmWireKeys.Usage]));
|
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ProviderChatResult ReadAnthropicBody(string body)
|
private static ProviderChatResult ReadAnthropicBody(string body)
|
||||||
@@ -179,18 +189,18 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
JsonObject? payload = ParseObjectOrThrow(body);
|
JsonObject? payload = ParseObjectOrThrow(body);
|
||||||
|
|
||||||
var text = new StringBuilder();
|
var text = new StringBuilder();
|
||||||
if (payload[LlmWireKeys.Content] is JsonArray contentBlocks)
|
if (payload["content"] is JsonArray contentBlocks)
|
||||||
{
|
{
|
||||||
foreach (JsonNode? blockNode in contentBlocks)
|
foreach (JsonNode? blockNode in contentBlocks)
|
||||||
{
|
{
|
||||||
if (blockNode is JsonObject block)
|
if (blockNode is JsonObject block)
|
||||||
{
|
{
|
||||||
text.Append(ReadStringField(block, LlmWireKeys.Text));
|
text.Append(ReadStringField(block, "text"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload[LlmWireKeys.Usage]));
|
return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload["usage"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Возвращает usage OpenAI-совместимого ответа (prompt/completion/total_tokens) либо null.
|
// Возвращает usage OpenAI-совместимого ответа (prompt/completion/total_tokens) либо null.
|
||||||
@@ -202,9 +212,9 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
int? promptTokens = ReadIntField(usage, LlmWireKeys.PromptTokens);
|
int? promptTokens = ReadIntField(usage, "prompt_tokens");
|
||||||
int? completionTokens = ReadIntField(usage, LlmWireKeys.CompletionTokens);
|
int? completionTokens = ReadIntField(usage, "completion_tokens");
|
||||||
int? totalTokens = ReadIntField(usage, LlmWireKeys.TotalTokens);
|
int? totalTokens = ReadIntField(usage, "total_tokens");
|
||||||
return promptTokens is null || completionTokens is null || totalTokens is null
|
return promptTokens is null || completionTokens is null || totalTokens is null
|
||||||
? null
|
? null
|
||||||
: new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value);
|
: new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value);
|
||||||
@@ -219,8 +229,8 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
int? inputTokens = ReadIntField(usage, LlmWireKeys.InputTokens);
|
int? inputTokens = ReadIntField(usage, "input_tokens");
|
||||||
int? outputTokens = ReadIntField(usage, LlmWireKeys.OutputTokens);
|
int? outputTokens = ReadIntField(usage, "output_tokens");
|
||||||
return inputTokens is null || outputTokens is null
|
return inputTokens is null || outputTokens is null
|
||||||
? null
|
? null
|
||||||
: new ProviderUsage(inputTokens.Value, outputTokens.Value, inputTokens.Value + outputTokens.Value);
|
: new ProviderUsage(inputTokens.Value, outputTokens.Value, inputTokens.Value + outputTokens.Value);
|
||||||
@@ -254,8 +264,8 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
private static JsonObject ChatMessage(string role, string content)
|
private static JsonObject ChatMessage(string role, string content)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
[LlmWireKeys.Role] = role,
|
["role"] = role,
|
||||||
[LlmWireKeys.Content] = content,
|
["content"] = content,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Создаёт JSON-содержимое запроса (application/json).
|
// Создаёт JSON-содержимое запроса (application/json).
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
namespace Deal.Ai.Llm;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// JSON-ключи wire-формата запросов и ответов ИИ-провайдеров
|
|
||||||
/// </summary>
|
|
||||||
public static class LlmWireKeys
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ имени модели.
|
|
||||||
/// </summary>
|
|
||||||
public const string Model = "model";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ списка сообщений чата.
|
|
||||||
/// </summary>
|
|
||||||
public const string Messages = "messages";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ системного промпта (Anthropic — отдельным полем).
|
|
||||||
/// </summary>
|
|
||||||
public const string System = "system";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ температуры генерации (OpenAI-совместимый формат).
|
|
||||||
/// </summary>
|
|
||||||
public const string Temperature = "temperature";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ лимита токенов ответа.
|
|
||||||
/// </summary>
|
|
||||||
public const string MaxTokens = "max_tokens";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ списка вариантов ответа (OpenAI-совместимый формат).
|
|
||||||
/// </summary>
|
|
||||||
public const string Choices = "choices";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ выбранного варианта ответа (OpenAI-совместимый формат).
|
|
||||||
/// </summary>
|
|
||||||
public const string Message = "message";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ текстового содержимого.
|
|
||||||
/// </summary>
|
|
||||||
public const string Content = "content";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ текста рассуждений модели (reasoning).
|
|
||||||
/// </summary>
|
|
||||||
public const string ReasoningContent = "reasoning_content";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ оценки токенов вызова.
|
|
||||||
/// </summary>
|
|
||||||
public const string Usage = "usage";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ текста блока контента (Anthropic).
|
|
||||||
/// </summary>
|
|
||||||
public const string Text = "text";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ числа токенов запроса (OpenAI-совместимый формат).
|
|
||||||
/// </summary>
|
|
||||||
public const string PromptTokens = "prompt_tokens";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ числа токенов ответа (OpenAI-совместимый формат).
|
|
||||||
/// </summary>
|
|
||||||
public const string CompletionTokens = "completion_tokens";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ суммарного числа токенов (OpenAI-совместимый формат).
|
|
||||||
/// </summary>
|
|
||||||
public const string TotalTokens = "total_tokens";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ числа входных токенов (Anthropic).
|
|
||||||
/// </summary>
|
|
||||||
public const string InputTokens = "input_tokens";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ числа выходных токенов (Anthropic).
|
|
||||||
/// </summary>
|
|
||||||
public const string OutputTokens = "output_tokens";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ключ роли сообщения чата.
|
|
||||||
/// </summary>
|
|
||||||
public const string Role = "role";
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@ WebApplication app = AiServiceHost.Create(
|
|||||||
{
|
{
|
||||||
DealLogging.Configure(builder, aiProcessName);
|
DealLogging.Configure(builder, aiProcessName);
|
||||||
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
|
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
|
||||||
DealTracingHosting.AddDealTracing(builder, aiProcessName);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
DealMetricsHosting.MapDealMetrics(app);
|
DealMetricsHosting.MapDealMetrics(app);
|
||||||
|
|||||||
@@ -42,7 +42,6 @@
|
|||||||
Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент только pre-release-линией; остальные —
|
Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент только pre-release-линией; остальные —
|
||||||
1.17.0 stable). Настройка — Deal.Api/Observability/DealMetricsHosting.cs. -->
|
1.17.0 stable). Настройка — Deal.Api/Observability/DealMetricsHosting.cs. -->
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
|
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user