Compare commits

Author SHA1 Message Date
Rustam Khalimov e62dbfafba Стримить прогресс пакетной переклассификации
CardReclassifier отдаёт IProgress-отчёты, эндпоинт публикует SSE cards_reclassified {progress:true,...} каждые 5 карточек и финальное событие; фронт показывает done/total и перечитывает доску только по финалу. Закрывает BL-RECLASS-SSE.
2026-09-11 17:57:47 +03:00
Rustam Khalimov ea21dc54c6 Сверить счётчики api-map и пометить доки
Исправлен счётчик §3.5 (14→15 после добавления GET /cards/{id}/source), добавлено правило обновления; подтверждены пометки исторических доков. Закрывает TD-APIMAP-COUNT и TD-OLD-DOCS.
2026-09-11 17:53:14 +03:00
Rustam Khalimov 0f79bd4e70 Убрать мёртвые SSE-ветки и двойную перезагрузку доски
Удалены ветки boards_changed/pipeline_stats (core их не публикует); перезагрузка при переклассификации коалесцируется scheduleBoardReload. Закрывает TD-SSE-DEAD и TD-DBL-CLICK.
2026-09-11 17:52:05 +03:00
Rustam Khalimov e15be9de2e Добавить актора в access-лог HTTP
Строка access-лога включает actor (login пользователя/оператора) и tenant из HttpContext.Items. Закрывает BL-LOG-ACTOR.
2026-09-11 17:48:39 +03:00
Rustam Khalimov be457d7286 Добавить единый CI-прогон и workflow
scripts/ci.sh собирает все решения, гоняет тесты всех сервисов, сканирует уязвимые NuGet-зависимости и собирает фронт; build.sh/test.sh расширены на все решения. Плюс .github/workflows/ci.yml. Закрывает BL-CI.
2026-09-11 17:46:03 +03:00
Rustam Khalimov 1d7c9980ba Шардировать пакетную миграцию схем тенантов
Обход реестра идёт страницами (ITenantRepository.ListPageAsync, DefaultPageSize=200) с параллелизмом внутри страницы и изоляцией сбоев — масштаб на 1000+ схем без загрузки всего реестра. Закрывает BL-SCALE-1000.
2026-09-11 17:43:27 +03:00
Rustam Khalimov 38561528ab Ужесточить контейнеры сервисов
Прикладные образы работают non-root (deal, UID 10001, HOME=/tmp); в compose добавлены read_only root FS, tmpfs /tmp, no-new-privileges, cap_drop ALL и лимиты mem/cpus. Логи stateless-сервисов вынесены в /tmp/logs. Проверено compose config (dev/prod/observability).
2026-09-11 17:39:45 +03:00
Rustam Khalimov d2b716bbf4 Добавить метрику расхода ИИ-бюджета
Gauge deal.ai.budget.used.ratio{tenant} (доля израсходованного бюджета периода) собирается RuntimeDepthsCollector и публикуется DealMetricsCollector — основа для алертинга. Закрывает BL-ALERT-BUDGET.
2026-09-11 17:30:26 +03:00
Rustam Khalimov 000d433438 Актуализировать ТЗ под универсальный источник
Уточнены термины «источник», просмотр исходника (в т.ч. догрузка у сервиса-владельца), вкладка «Обработка» и добавлен пункт о сухом прогоне текста в настройках.
2026-09-11 17:30:26 +03:00
Rustam Khalimov db4455a554 Добавить remote-просмотр исходника карточки
RPC TelegramService.ReadSource + ISessionClient.GetMessageAsync, порт ITelegramGateway.ReadSourceAsync и провайдер TelegramSourceContentProvider в ядре; GET /api/cards/{id}/source догружает исходник у сервиса-владельца, в UI — кнопка «Обновить из источника». Медиа-посты пропускаются.
2026-09-11 17:22:06 +03:00
Rustam Khalimov c06ee1cf79 Добавить сухой прогон текста по конвейеру
POST /api/admin/check-message прогоняет текст через стоп-правила, глобальные исключения, ML и ИИ без создания карточки (PipelineWorkerService.DryRunAsync), отдаёт этапы, разбор и целевой контейнер. UI тестера в настройках переведён на новый контракт. Зафиксированы решения по вложениям (медиа-посты пропускаем).
2026-09-11 17:08:34 +03:00
Rustam Khalimov 2b25915790 Зафиксировать вопросы по вложениям источников
Отдельный документ по media→Storage и remote-просмотру исходника: что готово, что требует живого Telegram, предлагаемый план. Ссылки в backlog.
2026-09-11 16:33:26 +03:00
Rustam Khalimov 6d074834a7 Перевести входящий поток источников на generic-контракт
Добавлен sources.proto с PushSource; приём в ядре вынесен в SourceIngressGrpcService с SourceProtoMapper и ISourceIngestObserver, тенант определяется IngressTenantResolver. Из telegram.proto удалён PushMessage, telegram-сервис шлёт generic-записи, превью сохраняет TelegramSourceIngestObserver.
2026-09-11 16:32:49 +03:00
Rustam Khalimov 3327bf48b0 Добавить загрузку исходника карточки по провайдерам
Введены ISourceContentProvider и SourceContentResolver, эндпоинт GET /api/cards/{id}/source отдаёт содержимое источника (провайдер по kind либо сохранённое в карточке).
2026-09-11 15:01:22 +03:00
Rustam Khalimov ebc761d40e Перевести ядро на единый контракт источника
Карточка, очередь и отсев работают с SourceItem (SourceRef + SourceContent); Telegram-поля убраны из домена, персистентности, конвейера и wire, остались только в адаптере приёма. Tenant-миграции пересозданы с нуля. Фронт переведён на generic source/content с просмотрщиком вложений.
2026-09-11 14:58:54 +03:00
Rustam Khalimov 770dba7257 Добавить общий Storage-сервис данных
Новый сервис Deal.Storage (src/storage-service): gRPC Upload/
Download/Stat/Delete, определение типа контент-снифингом, MinIO-бэкенд,
токен-валидация через общий интерцептор. Контракт storage.proto в
Deal.Proto; sln сервиса; подключение в compose.dev и эндпоинт в core;
тесты (снифер + хост/токен).
2026-09-11 14:33:43 +03:00
Rustam Khalimov e1aebd1d78 Ввести единый контракт источника в домен Cards
Добавлены SourceItem/SourceRef/SourceContent/DataRef/ContactRef
(строго типизированный контракт без подтипов вложений; файлы —
ссылки на общий сервис данных). Удалены Telegram-ориентированные
source-интерфейсы из Cards; Card/ICard переведены на SourceRef.
2026-09-11 14:23:33 +03:00
Rustam Khalimov bf4c021ca9 Убрать доки, повторяющие имя/значение члена
summary вида «Ключ «x»» удалены; «Ключ «x»: пояснение» сжаты до
пояснения; summary, дословно равные имени/значению, удалены.
2026-09-11 13:51:31 +03:00
Rustam Khalimov 79c931d88e Почистить комментарии от ссылок на ТЗ и обрывков
Удаление целых //-блоков со ссылками (Task/Ruling/этап/ТЗ/§/
дизайн-док/api-map/python/прототип) вместо построчного вырезания —
без обрывков фраз; снят боилерплейт <param>/<returns>; то же в
.proto.
2026-09-11 13:49:26 +03:00
Rustam Khalimov b053d58335 Почистить комментарии от упоминаний процесса
Удалены <remarks>, <summary> сжаты до короткой фразы, вырезаны
ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками
на процесс удалены; то же в .proto. Правила обновлены в
docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100.
2026-09-11 13:39:39 +03:00
Rustam Khalimov 5f5538d33b Разложить grpc-hosting и UrlSafeToken по назначению
grpc-hosting -> Interceptors/Models/Options/Services; UrlSafeToken
-> SharedKernel/Utilities. Маркеры проектов оставлены в корнях.
namespace/using/FQN мигрированы.
2026-09-11 13:33:19 +03:00
Rustam Khalimov 1e118e9bba Зафиксировать структуру проектов в код-стайле
Правило: namespace = путь папки; состав папок по назначению и
группировка тестовых проектов по областям (Support для общих
хелперов).
2026-09-11 13:31:06 +03:00
Rustam Khalimov e8b9fab860 Убрать неиспользуемые using по код-стайлу
Прогон dotnet format (IDE0005) по 4 решениям: удалены лишние using,
оставшиеся после миграции namespace (676 файлов).
2026-09-11 13:30:57 +03:00
Rustam Khalimov 5bfa92a4ac Сгруппировать тестовые проекты по областям
Deal.Tests.Unit, Deal.Telegram.Tests, Deal.Ai.Tests, Deal.Ml.Tests
разложены по областям (Modules/<X>, Api, Infrastructure, Contracts,
Grpc, ...), общие хелперы -> Support; namespace = папка, using
между областями добавлены итеративно по ошибкам сборки.
2026-09-11 13:28:26 +03:00
Rustam Khalimov e3a2692507 Добить структуру Api, Contracts, SharedKernel и сервисов
Deal.Api/Http -> Services/Models/Extensions; Contracts/Integrations
и SharedKernel/Tenants -> Abstractions/Models; extension-классы
telegram/ml -> Extensions. namespace/using/FQN мигрированы, using
дедуплицированы.
2026-09-11 13:25:18 +03:00
Rustam Khalimov 492950bdd0 Отформатировать списки параметров по код-стайлу
Больше двух параметров — каждый на отдельной строке (закрывающая
скобка в конце последнего); два и меньше — в одну строку. Правило
добавлено в docs/spec/Код-стайл-Дейл.md; применено к 628 сигнатурам
в 253 файлах.
2026-09-11 13:22:56 +03:00
Rustam Khalimov 410194b0cb Разбить Infrastructure и корень Deal.Api по назначению
Integrations -> Abstractions/Exceptions/Extensions/Models/Options/
Services (включая Storage); Persistence-конфигурации -> Configurations;
корень Deal.Api (оркестратор/планировщики/DTO) -> Services/Dtos.
namespace приведён к путям, using добавлены/дедуплицированы, FQN
обновлены.
2026-09-11 13:20:10 +03:00
Rustam Khalimov cd0b3b606b Разбить модули Deal.Modules.* по назначению
Application проектов Discovery, Kanban, Pipeline, Settings, Tenants
разделён на Abstractions/Exceptions/Extensions/Models/Registrars/Services;
namespace приведён к путям, using потребителей мигрированы и
дедуплицированы (169 файлов), cref/FQN обновлены.
2026-09-11 13:18:14 +03:00
Rustam Khalimov 31c434ed93 Разбить Deal.Modules.Cards по назначению
Application разделён на Abstractions (25 интерфейсов), Models (13
доменных типов) и Dtos (CardMoveResultDto); namespace приведён к путям,
using потребителей мигрированы.
2026-09-11 13:16:57 +03:00
Rustam Khalimov 8a7d7f2a7a Добавить дизайн структуры проектов 2026-09-11 13:15:12 +03:00
Rustam Khalimov 413eaac48c Вынести условия-предикаты в extension-методы
HasUser из 13 endpoint-файлов сведён в AuthHelpers.HasUser;
15 приватных предикатов заменены extension-методами с удалением
дублирующих приватных методов: IsCommunicationFailure,
IsTransportFailure, IsPrivateEndpoint, IsConfigured, IsTrue,
IsExpired, IsFailedLogin/IsSuccessfulLogin, HasChanges, HasBudget,
HasAnyTerm, IsCurrencyLetter, IsEmojiCodePoint, ContainsFooterHint,
IsTypeLabel.
2026-09-11 13:08:47 +03:00
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00
1261 changed files with 132981 additions and 125819 deletions
+2 -4
View File
@@ -2,8 +2,7 @@ root = true
[*] [*]
charset = utf-8 charset = utf-8
# LF: пишут инструменты проекта (Python/Node), требуется shell-скриптам на Linux CI end_of_line = crlf
end_of_line = lf
insert_final_newline = true insert_final_newline = true
indent_style = space indent_style = space
indent_size = 4 indent_size = 4
@@ -31,8 +30,7 @@ dotnet_style_qualification_for_method = false:warning
dotnet_style_qualification_for_event = false:warning dotnet_style_qualification_for_event = false:warning
# Члены # Члены
# var — запрещён для встроенных типов (ломает сборку), для очевидных/прочих — silent (§4 код-стайла) csharp_style_var_for_built_in_types = false:silent
csharp_style_var_for_built_in_types = false:warning
csharp_style_var_when_type_is_apparent = false:silent csharp_style_var_when_type_is_apparent = false:silent
csharp_style_var_elsewhere = false:silent csharp_style_var_elsewhere = false:silent
-20
View File
@@ -1,20 +0,0 @@
# Нормализация концов строк: в репозитории и рабочей копии — LF.
# Решение 2026-09-11 (backlog TD-STYLE-ANALYZERS): Python/Node-инструменты проекта пишут LF,
# shell-скрипты на Linux CI не работают с CRLF, фактическое большинство файлов — LF.
* text=auto eol=lf
# Явно бинарные (на всякий случай, auto-детект и так их не трогает)
*.docx binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.zip binary
*.gz binary
*.ttf binary
*.woff binary
*.woff2 binary
*.eot binary
*.pyc binary
-79
View File
@@ -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
+28
View File
@@ -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
-15
View File
@@ -30,18 +30,3 @@ deploy/certs/
# === Рантайм-данные (БД, объектное хранилище, ключи шифрования) === # === Рантайм-данные (БД, объектное хранилище, ключи шифрования) ===
archive/leadradar-legacy/data/ archive/leadradar-legacy/data/
src/core/Deal.Api/data/ src/core/Deal.Api/data/
# Python
__pycache__/
*.pyc
# Gitea runner: секреты и локальное состояние
!deploy/gitea-runner/.env.example
deploy/gitea-runner/.env
deploy/gitea-runner/data/
# Служебные скрипты не входят в репозиторий (правило: в репе только код) — живут только локально
scripts/
# Локальный клон вики
.wiki-clone/
@@ -1,23 +0,0 @@
# Ledger: codestyle-residue (2026-09-11, вечер)
План: `docs/superpowers/plans/2026-09-11-codestyle-остатки.md`
## Итог
- Замер: дубли `<summary>` (текст и имя члена) — 0; var-литералы/касты в src — 0; латинских комментариев — 18
(из них англоязычных 3); членов интерфейсов без дока — 4; TODO — 0; CRLF-файлов — 1029 из 1445
(все 42 .sh — CRLF).
- `.editorconfig`: `csharp_style_var_for_built_in_types = false:warning`; `end_of_line = lf`.
- `dotnet format style --diagnostics IDE0008 --severity warn` по 5 sln — 51 файл (только встроенные типы).
- `.gitattributes` добавлен (`* text=auto eol=lf` + бинарные исключения); 1029 файлов конвертированы в LF;
`git add --renormalize .`.
- `fix_private_docs.py --apply` — 12 блоков; `<summary>` добавлены: `IContainerRules.Keywords/Stack`,
`ITenantContext.TenantId/HasTenant`; переведены 3 комментария (SettingsKeys, OperatorAuthService,
ConversionRecomputerTests); `.pyc` из индекса убраны; STATUS.md — устаревший блок удалён.
- Явные реализации интерфейсов — владельцу на точечное ревью (не автоматизировано сознательно).
## Проверка
- `dotnet build` 5 sln (core, telegram, ai, ml, storage): 0 warnings / 0 errors.
- `sh scripts/test.sh`: все 5 тест-проектов зелёные (счётчики — STATUS.md), `lint:i18n` — зелёный.
- Фронт содержательно не менялся.
@@ -1,29 +0,0 @@
# Ledger: explicit-interfaces (2026-09-11, поздний вечер)
Решение владельца: вариант A — явные реализации по умолчанию, классы напрямую не вызываем
(исключения: DTO, хелперы, экстеншены), тесты через интерфейсы, моки — NSubstitute,
маркерные классы не используем (маркерные интерфейсы).
## Итог
- Codemod `scripts/make_explicit.py` (идемпотентный): таблицы членов интерфейсов (многострочные
сигнатуры), маппинг класс→интерфейсы включая partial-файлы, конвертация `public M(``IFoo.M(`.
Применено: 161 член в 30 прод-файлах.
- Исправления компиляторного цикла: недостающие using'и в partial-файлах (скрипт-фиксер), снят
дефолт параметра в явной реализации `ITenantLimitStore.GetOrCreateAsync`, самовызовы
`WTelegramSessionClient` квалифицированы `((ISessionClient)this)`, мусорный using в `LlmHttpClient`.
- Тесты: 17 файлов перетипизированы с конкретных классов на интерфейсы (поля, tuple-деконструкции,
var/target-typed new); DI-регистрации фейков → регистрация интерфейсных инстансов.
- Маркеры: 10 классов → интерфейсы `IKanbanModule`, `ICardsModule`, `IPipelineModule`,
`IDiscoveryModule`, `ISettingsModule`, `ITelegramModule`, `ITenantsModule`, `IContracts`,
`IInfrastructure`, `ISharedKernel`; тесты на `IsInterface`.
- NSubstitute 6.1.0 добавлен в 5 тест-проектов; `FakePasswordHasher` удалён, вместо него
`Support/TestHashers.New()` (Substitute.For + детерминированная семантика «fake-hash:»).
- Правила владельца зафиксированы в §11 код-стайла; план миграции оставшихся ~30 фейков —
`backlog.md` (TD-TESTS-NSUBSTITUTE).
## Проверка
- `dotnet build` 5 sln: 0 warnings / 0 errors.
- Тесты: core 1340/1340, telegram 130/130, ai 52/52, ml 38/38, storage 9/9.
- Коммиты: ed25c71 (явные реализации), 93e9100 (маркеры), далее — NSubstitute/доки.
+12 -18
View File
@@ -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`.
Служебные скрипты в репозиторий не входят — живут только локально, вне кода.
+32
View File
@@ -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
+24
View File
@@ -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()
+407
View File
@@ -0,0 +1,407 @@
"""DuckDB: единый доступ, схема, стартовые данные.
DuckDB — одна запись в момент времени. Внутри одного процесса мы
сериализуем все операции общим локом (чтение дёшево, объёмы личные),
что полностью соответствует ТЗ (self-hosted, один файл).
"""
from __future__ import annotations
import json
import threading
import time
import uuid
import duckdb
from . import constants as C
from . import config
_LOCK = threading.RLock()
_SCHEMA = """
CREATE TABLE IF NOT EXISTS boards (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
description VARCHAR NOT NULL DEFAULT '',
color VARCHAR NOT NULL DEFAULT '#818cf8',
width VARCHAR NOT NULL DEFAULT 'md',
pos INTEGER NOT NULL DEFAULT 0,
keywords VARCHAR NOT NULL DEFAULT '[]',
prompt VARCHAR NOT NULL DEFAULT '',
visible_fields VARCHAR NOT NULL DEFAULT '["budget","stack","contacts"]',
collapsed BOOLEAN NOT NULL DEFAULT FALSE,
suggested BOOLEAN NOT NULL DEFAULT FALSE,
rules VARCHAR NOT NULL DEFAULT '{}',
note VARCHAR NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS leads (
id VARCHAR PRIMARY KEY,
col VARCHAR NOT NULL,
is_new BOOLEAN NOT NULL DEFAULT TRUE,
is_vacancy BOOLEAN NOT NULL DEFAULT FALSE,
title VARCHAR NOT NULL,
summary VARCHAR NOT NULL DEFAULT '',
stack VARCHAR NOT NULL DEFAULT '[]',
budget_from DOUBLE,
budget_to DOUBLE,
budget_cur VARCHAR NOT NULL DEFAULT '',
conv_from DOUBLE,
conv_to DOUBLE,
conv_cur VARCHAR NOT NULL DEFAULT '',
contact VARCHAR NOT NULL DEFAULT '',
contacts VARCHAR NOT NULL DEFAULT '[]',
ch_name VARCHAR NOT NULL DEFAULT '',
ch_handle VARCHAR NOT NULL DEFAULT '',
ch_hue VARCHAR NOT NULL DEFAULT '#666',
time_label VARCHAR NOT NULL DEFAULT '',
received_at BIGINT NOT NULL,
source_msg VARCHAR NOT NULL DEFAULT '',
prev_col VARCHAR NOT NULL DEFAULT 'inbox',
comments VARCHAR NOT NULL DEFAULT '[]',
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_leads_col ON leads(col);
CREATE TABLE IF NOT EXISTS messages (
id VARCHAR PRIMARY KEY,
dialog_id VARCHAR NOT NULL,
text VARCHAR NOT NULL DEFAULT '',
msg_at BIGINT NOT NULL,
lead_id VARCHAR
);
CREATE INDEX IF NOT EXISTS idx_messages_dialog ON messages(dialog_id, msg_at);
CREATE TABLE IF NOT EXISTS dialogs (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
handle VARCHAR NOT NULL DEFAULT '',
kind VARCHAR NOT NULL DEFAULT 'чат',
hue VARCHAR NOT NULL DEFAULT '#666',
monitor BOOLEAN NOT NULL DEFAULT FALSE,
last_text VARCHAR NOT NULL DEFAULT '',
last_at BIGINT,
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS dedup (
hash VARCHAR PRIMARY KEY,
lead_id VARCHAR,
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS learning_log (
id VARCHAR PRIMARY KEY,
lead_id VARCHAR NOT NULL,
action VARCHAR NOT NULL,
from_col VARCHAR,
to_col VARCHAR,
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS projects (
id VARCHAR PRIMARY KEY,
stage VARCHAR NOT NULL DEFAULT 'planned',
local BOOLEAN NOT NULL DEFAULT FALSE,
lead_id VARCHAR,
title VARCHAR NOT NULL DEFAULT '',
summary VARCHAR NOT NULL DEFAULT '',
stack VARCHAR NOT NULL DEFAULT '[]',
budget_from DOUBLE,
budget_to DOUBLE,
budget_cur VARCHAR NOT NULL DEFAULT '',
contact VARCHAR NOT NULL DEFAULT '',
comments VARCHAR NOT NULL DEFAULT '[]',
links VARCHAR NOT NULL DEFAULT '[]',
files VARCHAR NOT NULL DEFAULT '[]',
tz_text VARCHAR NOT NULL DEFAULT '',
history VARCHAR NOT NULL DEFAULT '[]',
reminder_at BIGINT,
reminder_fired BOOLEAN NOT NULL DEFAULT FALSE,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_projects_stage ON projects(stage);
CREATE TABLE IF NOT EXISTS rates (
id INTEGER PRIMARY KEY,
rates VARCHAR NOT NULL DEFAULT '{}',
source VARCHAR NOT NULL DEFAULT 'cbr',
updated_at BIGINT NOT NULL
);
-- Discovery: задачи поиска Telegram-каналов. keywords — JSON-список, столбцы
-- search_* / found / evaluated / joined / rejected — живой прогресс по задаче.
CREATE TABLE IF NOT EXISTS disc_tasks (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
description VARCHAR NOT NULL DEFAULT '',
keywords VARCHAR NOT NULL DEFAULT '[]',
min_subscribers INTEGER NOT NULL DEFAULT 0,
lang VARCHAR NOT NULL DEFAULT 'ru',
threshold INTEGER NOT NULL DEFAULT 40,
sample_size INTEGER NOT NULL DEFAULT 10,
plan_joins INTEGER NOT NULL DEFAULT 1,
auto_join BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR NOT NULL DEFAULT 'draft', -- draft|running|paused|done|failed
search_idx INTEGER NOT NULL DEFAULT 0,
search_done BOOLEAN NOT NULL DEFAULT FALSE,
found INTEGER NOT NULL DEFAULT 0,
evaluated INTEGER NOT NULL DEFAULT 0,
joined INTEGER NOT NULL DEFAULT 0,
rejected INTEGER NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
-- Discovery: найденные кандидаты. marks/topics — JSON-списки (оценки ИИ).
CREATE TABLE IF NOT EXISTS disc_candidates (
dialog_id VARCHAR PRIMARY KEY,
task_id VARCHAR NOT NULL,
name VARCHAR NOT NULL DEFAULT '',
username VARCHAR NOT NULL DEFAULT '',
kind VARCHAR NOT NULL DEFAULT 'channel', -- channel|group|forum
hue VARCHAR NOT NULL DEFAULT '#666',
participants INTEGER,
lang_ru BOOLEAN,
marks VARCHAR NOT NULL DEFAULT '[]',
topics VARCHAR NOT NULL DEFAULT '[]',
fit_ratio DOUBLE,
status VARCHAR NOT NULL DEFAULT 'new', -- new|review|joined|rejected
auto_joined BOOLEAN NOT NULL DEFAULT FALSE,
join_failures INTEGER NOT NULL DEFAULT 0, -- неудачные авто-вступления подряд (3 → кандидат удаляется)
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_disc_cand_task ON disc_candidates(task_id, status);
-- Discovery: чёрный список (пропускать при поиске).
CREATE TABLE IF NOT EXISTS disc_blacklist (
dialog_id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL DEFAULT '',
reason VARCHAR NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
);
-- Discovery: лог событий по задаче (search|found|skip|eval|review|join_auto|
-- join_manual|leave|reject|flood|error|done).
CREATE TABLE IF NOT EXISTS disc_log (
id VARCHAR PRIMARY KEY,
task_id VARCHAR NOT NULL,
event VARCHAR NOT NULL,
text VARCHAR NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_disc_log_task ON disc_log(task_id, created_at);
CREATE TABLE IF NOT EXISTS settings (
key VARCHAR PRIMARY KEY,
value VARCHAR NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token VARCHAR PRIMARY KEY,
login VARCHAR NOT NULL,
expires BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS creds (
login VARCHAR PRIMARY KEY,
password_hash VARCHAR NOT NULL,
salt VARCHAR NOT NULL
);
-- Outbox обучения ML: действия пользователя всегда пишутся сюда (синхронно,
-- локально), а фоновый воркер отправляет их в автономный ML-сервис.
-- Так ML «обучается всегда» даже если сервис временно недоступен.
CREATE TABLE IF NOT EXISTS ml_outbox (
id VARCHAR PRIMARY KEY,
text VARCHAR NOT NULL,
label VARCHAR NOT NULL,
delta DOUBLE NOT NULL DEFAULT 1.0,
created_at BIGINT NOT NULL
);
-- Очередь входящих: все сообщения из групп попадают сюда и разбираются
-- фоновым воркером. Не прошедшие фильтры удаляются сразу (не копятся).
-- force=TRUE — сообщение возвращено из отсева пользователем: фильтры-отсев
-- для него игнорируются (сообщение уходит на ML/ИИ и создаёт карточку).
CREATE TABLE IF NOT EXISTS pipeline_msg (
id VARCHAR PRIMARY KEY,
dialog_id VARCHAR NOT NULL,
ch_name VARCHAR NOT NULL DEFAULT '',
ch_handle VARCHAR NOT NULL DEFAULT '',
ch_hue VARCHAR NOT NULL DEFAULT '#666',
text VARCHAR NOT NULL,
msg_id BIGINT,
msg_at BIGINT NOT NULL,
status VARCHAR NOT NULL DEFAULT 'new',
force BOOLEAN DEFAULT FALSE,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pipeline_status ON pipeline_msg(status, created_at);
-- Отсев пайплайна (мониторинг «Обработка»): сообщения, не прошедшие этапы
-- (стоп-лист/резюме/тип/без суммы/устарело/ML/ИИ). Хранится причина и «чьё»
-- решение. Автоочистка раз в 3 суток + ручная очистка из UI.
-- При возврате в обработку запись не удаляется: помечается returned с причиной.
CREATE TABLE IF NOT EXISTS rejected_msgs (
id VARCHAR PRIMARY KEY,
dialog_id VARCHAR NOT NULL DEFAULT '',
msg_id BIGINT,
ch_name VARCHAR NOT NULL DEFAULT '',
ch_handle VARCHAR NOT NULL DEFAULT '',
ch_hue VARCHAR NOT NULL DEFAULT '#666',
text VARCHAR NOT NULL,
stage VARCHAR NOT NULL DEFAULT '',
reason VARCHAR NOT NULL DEFAULT '',
kw VARCHAR NOT NULL DEFAULT '',
source VARCHAR NOT NULL DEFAULT 'stop',
msg_at BIGINT,
rejected_at BIGINT NOT NULL,
returned BOOLEAN DEFAULT FALSE,
returned_at BIGINT,
return_reason VARCHAR NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_rejected_rejected_at ON rejected_msgs(rejected_at);
"""
# DuckDB не поддерживает IF NOT EXISTS в ADD COLUMN на старых версиях;
# выполняем в try для совместимости (поле нужно для правила «архив очищается
# через 90 дней после помещения в архив»).
_MIGRATIONS = [
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS archived_at BIGINT",
"ALTER TABLE dialogs ADD COLUMN IF NOT EXISTS backfilled BOOLEAN",
# исходное сообщение: id в Telegram + id диалога (для «открыть исходник»)
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS source_dialog_id VARCHAR DEFAULT ''",
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS source_msg_id BIGINT",
# квалифицированные контакты: JSON-список [{type, value}] (tg/phone/email/linkedin/site)
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS contacts VARCHAR DEFAULT '[]'",
# модель ML переезжает в отдельный контейнер — старые встроенные таблицы не нужны
"DROP TABLE IF EXISTS ml_classes",
"DROP TABLE IF EXISTS ml_terms",
# колонки: ИИ-предложения и правила маршрутизации (DuckDB не умеет
# ADD COLUMN с NOT NULL — значения трактуются как FALSE/{}/'')
"ALTER TABLE boards ADD COLUMN IF NOT EXISTS suggested BOOLEAN",
"ALTER TABLE boards ADD COLUMN IF NOT EXISTS rules VARCHAR",
"ALTER TABLE boards ADD COLUMN IF NOT EXISTS note VARCHAR",
# описание колонки (для пользователя и подсказки ИИ/ML)
"ALTER TABLE boards ADD COLUMN IF NOT EXISTS description VARCHAR DEFAULT ''",
# какие критерии фильтра совпали при попадании карточки в колонку (JSON)
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS match_hits VARCHAR DEFAULT '[]'",
# тип «найм/разовое» подтверждён ИИ по контексту (маркерная эвристика — нет)
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS is_vacancy_known BOOLEAN DEFAULT FALSE",
# возврат из отсева в обработку: force на строке очереди (фильтры игнорируются)
"ALTER TABLE pipeline_msg ADD COLUMN IF NOT EXISTS force BOOLEAN DEFAULT FALSE",
# отсев: исходный диалог/сообщение (для повторного возврата в очередь) и метки возврата
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS dialog_id VARCHAR DEFAULT ''",
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS msg_id BIGINT",
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS ch_hue VARCHAR DEFAULT '#666'",
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS returned BOOLEAN DEFAULT FALSE",
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS returned_at BIGINT",
"ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS return_reason VARCHAR DEFAULT ''",
# discovery: счётчик неудачных авто-вступлений кандидата (лимит ретраев join)
"ALTER TABLE disc_candidates ADD COLUMN IF NOT EXISTS join_failures INTEGER DEFAULT 0",
]
class Store:
"""Синглтон-доступ к DuckDB."""
def __init__(self) -> None:
self._con: duckdb.DuckDBPyConnection | None = None
def init(self) -> None:
config.ensure_dirs()
with _LOCK:
self._con = duckdb.connect(str(config.DB_PATH))
for stmt in _SCHEMA.split(";"):
if stmt.strip():
self._con.execute(stmt)
for stmt in _MIGRATIONS:
try:
self._con.execute(stmt)
except Exception:
pass
self._seed_defaults()
def close(self) -> None:
with _LOCK:
if self._con is not None:
try:
self._con.close()
except Exception:
pass
self._con = None
# ── низкоуровневые примитивы ──────────────────────────────────────────
def execute(self, sql: str, params: dict | list | tuple | None = None) -> None:
with _LOCK:
self._con.execute(sql, params or [])
def query(self, sql: str, params: dict | list | tuple | None = None) -> list[dict]:
with _LOCK:
cur = self._con.execute(sql, params or [])
cols = [d[0] for d in cur.description]
rows = cur.fetchall()
return [dict(zip(cols, row)) for row in rows]
def query_one(self, sql: str, params: dict | list | tuple | None = None) -> dict | None:
rows = self.query(sql, params)
return rows[0] if rows else None
def scalar(self, sql: str, params: dict | list | tuple | None = None):
with _LOCK:
cur = self._con.execute(sql, params or [])
row = cur.fetchone()
return row[0] if row else None
# ── настройки ─────────────────────────────────────────────────────────
def get_setting(self, key: str):
row = self.query_one("SELECT value FROM settings WHERE key = ?", [key])
if row is None:
default = C.DEFAULT_SETTINGS.get(key, None)
return default
return json.loads(row["value"])
def all_settings(self) -> dict:
out = dict(C.DEFAULT_SETTINGS)
for row in self.query("SELECT key, value FROM settings"):
try:
out[row["key"]] = json.loads(row["value"])
except Exception:
pass
return out
def set_setting(self, key: str, value) -> None:
self.execute(
"INSERT INTO settings(key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[key, json.dumps(value, ensure_ascii=False)],
)
# ── стартовые данные ──────────────────────────────────────────────────
def _seed_defaults(self) -> None:
now = time.time_ns() // 1_000_000
# Колонки не создаются по умолчанию: их делает пользователь или
# предлагает ИИ (см. services/suggest.py).
if self.scalar("SELECT count(*) FROM rates") == 0:
self.execute(
"INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, 'mock', ?)",
[json.dumps(C.MOCK_RATES), now],
)
# начальные настройки сохраняем только при отсутствии (значения берутся из DEFAULT_SETTINGS сами)
# ── генераторы ────────────────────────────────────────────────────────
@staticmethod
def uid(prefix: str = "") -> str:
return f"{prefix}{uuid.uuid4().hex[:12]}"
store = Store()
@@ -0,0 +1,216 @@
"""Точка входа FastAPI-приложения LeadRadar.
Запуск: uvicorn app.main:app --host 0.0.0.0 --port 8000
или: python -m app.main
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from . import config
from .auth import ensure_creds
from .db import store
from .routers import (
auth_routes,
dashboard_routes,
discovery_routes,
events_routes,
ml_routes,
processing_routes,
projects_routes,
settings_routes,
tg_routes,
)
from .services import fts as fts_svc
from .services import ml_client
from .services import projects as proj_svc
from .services import rates as rates_svc
from .services.leads import notify_tick_stats, tick_storage
from .services.telegram import register_main_loop, tg
logging.basicConfig(level=config.LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("leadradar")
async def _storage_loop() -> None:
"""Каждые 30 секунд: правила хранения (архив/очистка), напоминания, сердцебиение Telegram."""
while True:
try:
stats = tick_storage()
await notify_tick_stats(stats)
await proj_svc.check_reminders()
await tg.heartbeat()
except Exception: # noqa: BLE001
log.exception("storage loop error")
await asyncio.sleep(30)
async def _fts_loop() -> None:
"""Полнотекстовые индексы пересобираем раз в сутки (FTS — снимок)."""
while True:
await asyncio.sleep(24 * 3600)
try:
fts_svc.rebuild()
except Exception: # noqa: BLE001
log.exception("fts rebuild error")
async def _rates_loop() -> None:
"""Курсы ЦБ РФ: проверяем раз в 30 минут, тянем не чаще 1 раза в 6 часов."""
while True:
try:
if rates_svc.should_fetch():
ok = await rates_svc.refresh_rates()
if not ok:
log.warning("rates fetch failed (повтор через 30 минут)")
except Exception: # noqa: BLE001
log.exception("rates loop error")
await asyncio.sleep(30 * 60)
async def _pipeline_loop() -> None:
"""Фоновый воркер очереди входящих: разбирает сообщения каждые 2 секунды."""
from .services.pipeline import pump_once
while True:
try:
await pump_once()
except Exception: # noqa: BLE001
log.exception("pipeline worker error")
await asyncio.sleep(2)
async def _discovery_loop() -> None:
"""Фоновый воркер Discovery: поиск → оценка → авто-вступление (раз в 5 c)."""
from .services.discovery_worker import tick
while True:
try:
await tick()
except Exception: # noqa: BLE001
log.exception("discovery worker error")
await asyncio.sleep(5)
async def _ml_sync_loop() -> None:
"""Отправка событий обучения в ML-сервис + кэш его статуса (каждые 10 c)."""
while True:
try:
await ml_client.flush_outbox()
await ml_client.refresh_status()
except Exception: # noqa: BLE001
log.exception("ml sync error")
await asyncio.sleep(10)
async def _suggest_loop() -> None:
"""Раз в 3 минуты пробуем предложить колонки по «Неразобранному» (кулдаун 20 мин)."""
from .services.suggest import suggest_from_inbox
while True:
try:
await suggest_from_inbox(force=False)
except Exception: # noqa: BLE001
log.exception("suggest loop error")
await asyncio.sleep(180)
async def _tg_sweep_loop() -> None:
"""Раз в 30 с: страховочная догонялка непрочитанного по включённым каналам
(событие могло потеряться при рестарте/разрыве соединения)."""
from .services.telegram import tg as tg_svc
while True:
await asyncio.sleep(30)
try:
await tg_svc.realtime_sweep()
except Exception: # noqa: BLE001
log.exception("tg realtime sweep error")
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
config.ensure_dirs()
store.init()
ensure_creds()
register_main_loop(asyncio.get_running_loop())
tasks = [
asyncio.create_task(_storage_loop()),
asyncio.create_task(_rates_loop()),
asyncio.create_task(_fts_loop()),
asyncio.create_task(_pipeline_loop()),
asyncio.create_task(_discovery_loop()),
asyncio.create_task(_ml_sync_loop()),
asyncio.create_task(_suggest_loop()),
asyncio.create_task(_tg_sweep_loop()),
asyncio.create_task(tg.auto_resume()),
]
# полнотекстовый поиск: пробуем установить расширение и собрать индекс
if fts_svc.install_extension():
fts_svc.rebuild()
# первичное обновление курсов из ЦБ (если источник cbr)
if store.get_setting("rateSource") == "cbr":
try:
await rates_svc.refresh_rates()
except Exception: # noqa: BLE001
log.warning("initial rates fetch failed; продолжаем с мок-курсами")
try:
yield
finally:
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
await tg.disconnect()
store.close()
app = FastAPI(title="LeadRadar", version="1.2.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # локальный dev-режим; в проде замените на свой origin
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API
for mod in (auth_routes, tg_routes, dashboard_routes, projects_routes, settings_routes, events_routes, discovery_routes, ml_routes, processing_routes):
app.include_router(mod.router)
@app.get("/api/health")
def health() -> dict:
return {"ok": True, "service": "leadradar"}
# Статика фронтенда (собранный dist). Если папки нет — API живёт отдельно.
_DIST = config.FRONTEND_DIST
if _DIST.is_dir():
assets = _DIST / "assets"
if assets.is_dir():
app.mount("/assets", StaticFiles(directory=str(assets)), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def spa(full_path: str):
candidate = (_DIST / full_path).resolve()
if full_path and candidate.is_file() and candidate.is_relative_to(_DIST):
return FileResponse(str(candidate))
index = _DIST / "index.html"
if index.exists():
return FileResponse(str(index))
return JSONResponse({"detail": "фронтенд не собран — используйте npm run dev"}, status_code=200)
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host=config.HOST, port=config.PORT, reload=False)
@@ -0,0 +1 @@
"""Пакет роутеров API."""
@@ -0,0 +1,65 @@
"""Вход в дашборд и сессии (п.4.1 ТЗ)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from ..auth import (
change_password,
create_session,
current_login,
destroy_session,
ensure_creds,
set_session_cookie,
verify_password,
)
from ..config import COOKIE_NAME
router = APIRouter(prefix="/api", tags=["auth"])
class LoginBody(BaseModel):
login: str
password: str
class PasswordBody(BaseModel):
oldPassword: str
newPassword: str
@router.post("/auth/login")
def login(body: LoginBody, response: Response) -> dict:
ensure_creds()
login_name = body.login.strip()
if not verify_password(login_name, body.password):
raise HTTPException(401, "Неверный логин или пароль")
token = create_session(login_name)
set_session_cookie(response, token)
return {"ok": True, "login": login_name}
@router.post("/auth/logout")
def logout(request: Request, response: Response) -> dict:
token = request.cookies.get(COOKIE_NAME)
destroy_session(token)
response.delete_cookie(COOKIE_NAME)
return {"ok": True}
@router.get("/auth/me")
def me(login: str = Depends(current_login)) -> dict:
return {"login": login, "ok": True}
@router.post("/auth/change-password")
def change(body: PasswordBody, request: Request, response: Response, login: str = Depends(current_login)) -> dict:
token = request.cookies.get(COOKIE_NAME)
ok = change_password(login, body.oldPassword, body.newPassword)
if not ok:
raise HTTPException(400, "Текущий пароль неверен")
# старые сессии удалены внутри change_password; выдаём свежую
fresh = create_session(login)
destroy_session(token)
set_session_cookie(response, fresh)
return {"ok": True}
@@ -0,0 +1,409 @@
"""Дашборд: доски, колонки, карточки, поиск, правила хранения."""
from __future__ import annotations
import secrets
import time
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from .. import config
from .. import constants as C
from ..auth import current_login
from ..db import store
from ..sse import broker
from ..services import fts as fts_svc
from ..services import leads as leads_svc
from ..services.ai import normalize_dedup
from ..services.pipeline import _store_lead, lead_to_dict, stage1_plain
from ..services.rates import refresh_rates # noqa: F401 (для админ-тика может пригодиться)
router = APIRouter(prefix="/api", tags=["dashboard"])
class BoardCreate(BaseModel):
name: str
description: str = ""
color: str | None = None
keywords: list[str] | None = None
prompt: str = ""
rules: dict | None = None
class BoardPatch(BaseModel):
name: str | None = None
description: str | None = None
color: str | None = None
width: str | None = None
collapsed: bool | None = None
prompt: str | None = None
keywords: list[str] | None = None
visibleFields: list[str] | None = None
suggested: bool | None = None
rules: dict | None = None
note: str | None = None
class OrderBody(BaseModel):
order: list[str]
class ColStateBody(BaseModel):
collapsed: bool | None = None
width: str | None = None
class MoveBody(BaseModel):
to: str
class CommentBody(BaseModel):
text: str
class ReclassifyBody(BaseModel):
ids: list[str] | None = None
class PumpGateBody(BaseModel):
limit: int | None = None # 0/None — выключить; >0 — остановить воркер после N созданных карточек
class CheckMessageBody(BaseModel):
text: str
# Демо-пул для кнопки «Симулировать лид» (без обращения к ИИ)
_DEMO_POOL = [
("Нужен Middle Python-разработчик на бота для CRM, удалённо, 1 6002 200$.",
{"title": "Middle Python-разработчик на бота для CRM", "summary": "Развитие CRM: бот для менеджеров, интеграция с amoCRM.",
"stack": ["Python", "aiogram", "amoCRM"], "budget": {"from": 1600, "to": 2200, "currency": "USD"},
"contacts": "@crm_head", "is_vacancy": True, "board": None, "is_spam": False}),
("Frontend-разработчик в продуктовую команду, Vue 3, 2 400$.",
{"title": "Frontend-разработчик в продуктовую команду", "summary": "Развитие SPA: компоненты, стейт, производительность.",
"stack": ["Vue 3", "Pinia", "Vitest"], "budget": {"from": 2400, "to": 2400, "currency": "USD"},
"contacts": "@front_hh", "is_vacancy": True, "board": None, "is_spam": False}),
("Ищу подрядчика на бота для такси: клиент заказывает, водитель принимает. Бюджет обсуждаем.",
{"title": "Бот для заказов такси", "summary": "Клиент заказывает, водитель принимает.",
"stack": [], "budget": None, "contacts": "@taxi_owner", "is_vacancy": False, "board": None, "is_spam": False}),
]
def _lead_or_404(lead_id: str) -> dict:
lead = leads_svc.get_lead(lead_id)
if not lead:
raise HTTPException(404, "Карточка не найдена")
return lead
# ─── Доски ────────────────────────────────────────────────────────────────
@router.get("/boards")
def boards(_: str = Depends(current_login)) -> list[dict]:
return leads_svc.list_boards()
@router.post("/boards")
def create_board(body: BoardCreate, _: str = Depends(current_login)) -> dict:
return leads_svc.create_board(
name=body.name,
color=body.color,
keywords=body.keywords,
prompt=body.prompt,
description=body.description,
rules=body.rules,
)
@router.patch("/boards/{board_id}")
def patch_board(board_id: str, body: BoardPatch, _: str = Depends(current_login)) -> dict:
try:
return leads_svc.patch_board(board_id, body.model_dump(exclude_none=True))
except KeyError:
raise HTTPException(404, "Доска не найдена")
@router.delete("/boards/{board_id}")
def delete_board(board_id: str, _: str = Depends(current_login)) -> dict:
moved = leads_svc.delete_board(board_id)
return {"ok": True, "movedToInbox": moved}
@router.post("/boards/reorder")
def reorder(body: OrderBody, _: str = Depends(current_login)) -> dict:
leads_svc.reorder_boards(body.order)
return {"ok": True}
# ─── Состояние колонок ────────────────────────────────────────────────────
@router.get("/columns/state")
def columns_state(_: str = Depends(current_login)) -> dict:
return leads_svc.get_col_state()
@router.patch("/columns/{col_id}/state")
def set_column_state(col_id: str, body: ColStateBody, _: str = Depends(current_login)) -> dict:
state = leads_svc.get_col_state().get(col_id, {})
state.update(body.model_dump(exclude_none=True))
return leads_svc.set_col_state(col_id, state)
# ─── Лиды ─────────────────────────────────────────────────────────────────
@router.get("/leads")
def list_leads(col: str | None = None, _: str = Depends(current_login)) -> dict:
if col and col not in ("inbox", "archive", "trash") and not any(b["id"] == col for b in leads_svc.list_boards()):
raise HTTPException(400, "Неизвестная колонка")
return {"items": leads_svc.list_leads(col)}
@router.get("/leads/counts")
def leads_counts(_: str = Depends(current_login)) -> dict:
return leads_svc.counts()
@router.get("/leads/{lead_id}")
def get_lead(lead_id: str, _: str = Depends(current_login)) -> dict:
return _lead_or_404(lead_id)
@router.post("/leads/{lead_id}/seen")
def seen(lead_id: str, _: str = Depends(current_login)) -> dict:
leads_svc.mark_seen(lead_id=lead_id)
return {"ok": True}
@router.post("/leads/mark-all-seen")
def mark_all_seen(_: str = Depends(current_login)) -> dict:
leads_svc.mark_seen()
return {"ok": True}
class MarkColBody(BaseModel):
col: str
@router.post("/leads/mark-col-seen")
def mark_col_seen(body: MarkColBody, _: str = Depends(current_login)) -> dict:
"""Снять «новое» с колонки (используется при открытии колонки из сайдбара)."""
leads_svc.mark_seen(col=body.col)
return {"ok": True}
@router.post("/leads/{lead_id}/move")
def move(lead_id: str, body: MoveBody, _: str = Depends(current_login)) -> dict:
try:
leads_svc.move_lead(lead_id, body.to)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return _lead_or_404(lead_id)
@router.post("/leads/{lead_id}/trash")
def trash(lead_id: str, _: str = Depends(current_login)) -> dict:
_lead_or_404(lead_id)
leads_svc.trash_lead(lead_id)
return {"ok": True}
@router.post("/leads/{lead_id}/restore")
def restore(lead_id: str, _: str = Depends(current_login)) -> dict:
_lead_or_404(lead_id)
back = leads_svc.restore_lead(lead_id)
return {"ok": True, "col": back}
@router.delete("/leads/{lead_id}")
def delete(lead_id: str, _: str = Depends(current_login)) -> dict:
_lead_or_404(lead_id)
leads_svc.delete_forever(lead_id)
return {"ok": True}
class ClearColBody(BaseModel):
col: str
@router.post("/leads/clear-col")
def clear_col(body: ClearColBody, _: str = Depends(current_login)) -> dict:
"""Ручная полная очистка корзины/архива (безвозвратно)."""
try:
cleared = leads_svc.clear_col(body.col)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": True, "cleared": cleared}
@router.post("/leads/{lead_id}/comments")
def comment(lead_id: str, body: CommentBody, _: str = Depends(current_login)) -> dict:
if not body.text.strip():
raise HTTPException(400, "Пустой комментарий")
return {"comments": leads_svc.add_comment(lead_id, body.text)}
@router.post("/leads/reclassify")
async def reclassify(body: ReclassifyBody | None = None, _: str = Depends(current_login)) -> dict:
"""Переклассификация «Неразобранного»: фоновая задача (одна за раз)."""
ids = body.ids if body else None
return await leads_svc.start_reclassify(ids)
# ─── Поиск ────────────────────────────────────────────────────────────────
@router.get("/search")
def search(q: str = "", _: str = Depends(current_login)) -> dict:
return leads_svc.search(q)
# ─── Админ-тик (правила хранения) ─────────────────────────────────────────
@router.post("/admin/fts/rebuild")
def fts_rebuild(_: str = Depends(current_login)) -> dict:
ok = fts_svc.rebuild()
return {"ok": ok, "ready": fts_svc.is_ready()}
@router.post("/admin/check-message")
async def check_message(body: CheckMessageBody, _: str = Depends(current_login)) -> dict:
"""Проверка фильтра входящих (этап 1 + этап 2) для тестера в настройках."""
from ..services.ai import filter_incoming
r1 = stage1_plain(body.text)
result = {"stage1": {"pass": r1["pass"], "reason": r1["reason"]}}
if not r1["pass"]:
result["stage2"] = {"pass": False, "reason": None, "skipped": True}
result["passed"] = False
return result
try:
r2 = await filter_incoming(body.text)
except Exception as exc: # noqa: BLE001
r2 = {"pass": True, "reason": None, "skipped": True}
result["stage2"] = r2
result["passed"] = bool(r2["pass"])
return result
def _demo_off() -> None:
if not config.DEMO_ENABLED:
raise HTTPException(404, "Демо-режим отключён")
@router.post("/demo/simulate-lead")
async def simulate_lead(_: str = Depends(current_login)) -> dict:
"""Служебный демо-эндпоинт (включён только при LEADRADAR_DEMO=1)."""
_demo_off()
import random
text, raw = random.choice(_DEMO_POOL)
digest = "demo_" + secrets.token_hex(8)
lead = _store_lead(
digest, "demo_channel", "Демо-канал", "demo_channel", "#8b8ff8", text, raw, time.time_ns() // 1_000_000,
)
if lead:
await broker.publish("new_lead", lead)
await broker.publish_toast("Демо: новый лид", "sparkles")
return lead or {}
@router.post("/demo/age-lead")
async def demo_age_lead(_: str = Depends(current_login)) -> dict:
"""Служебный демо-эндпоинт (включён только при LEADRADAR_DEMO=1)."""
_demo_off()
days = int(store.get_setting("archiveAfterDays") or 14)
row = store.query_one(
"SELECT id FROM leads WHERE col IN (SELECT id FROM boards) ORDER BY received_at ASC LIMIT 1"
)
if not row:
raise HTTPException(400, "Нет карточек на досках для демо")
aged = time.time_ns() // 1_000_000 - (days + 1) * C.DAY_MS
store.execute("UPDATE leads SET received_at = ? WHERE id = ?", [aged, row["id"]])
stats = leads_svc.tick_storage()
if stats["archived"]:
await broker.publish_toast(f"Демо: карточка → Архив (старше {days + 1} дн.)", "clock")
return {"ok": True, "stats": stats}
@router.post("/admin/tick")
async def admin_tick(_: str = Depends(current_login)) -> dict:
from ..services import projects as projects_svc
from ..services.pipeline import pump_once, queue_len
stats = leads_svc.tick_storage()
await leads_svc.notify_tick_stats(stats)
due = await projects_svc.check_reminders()
# разгребаем очередь входящих (этап 1 -> правила -> ML/ИИ)
pump = await pump_once()
return {"storage": stats, "reminders": due, "pipeline": pump, "queue": queue_len()}
@router.post("/admin/wipe")
async def admin_wipe(_: str = Depends(current_login)) -> dict:
"""Полный сброс под новый прогон: все карточки, обучение ML, счётчики ИИ/ML.
Удаляет карточки со всех колонок (включая архив/корзину), исходные
сообщения, журнал обучения, очередь обучения и очередь входящих; сбрасывает
модель ML и счётчики реальных действий. Колонки/доски, каналы и настройки
не трогаются.
"""
from ..services import ml_client
from ..services.ml_client import DECISIONS_AI, DECISIONS_ML
n_leads = int(store.scalar("SELECT count(*) FROM leads") or 0)
for tbl in ("leads", "dedup", "messages", "learning_log", "ml_outbox", "pipeline_msg", "rejected_msgs"):
store.execute(f"DELETE FROM {tbl}")
store.set_setting(DECISIONS_ML, 0)
store.set_setting(DECISIONS_AI, 0)
fts_svc.rebuild()
ml = await ml_client.reset_model()
return {"ok": True, "cardsRemoved": n_leads, "ml": ml}
@router.post("/admin/clear-cards")
async def admin_clear_cards(_: str = Depends(current_login)) -> dict:
"""Очистить карточки и очереди для повторного прогона, НЕ трогая ML.
Удаляет карточки (все колонки/архив/корзина), исходники, дедуп, журнал
обучения и очередь входящих. Модель ML, очередь её обучения (ml_outbox)
и счётчики ИИ/ML остаются нетронутыми — нужно для тестов «обучить ML на
прогоне с ИИ, затем прогнать те же сообщения без ИИ».
"""
n_leads = int(store.scalar("SELECT count(*) FROM leads") or 0)
for tbl in ("leads", "dedup", "messages", "learning_log", "pipeline_msg", "rejected_msgs"):
store.execute(f"DELETE FROM {tbl}")
fts_svc.rebuild()
return {"ok": True, "cardsRemoved": n_leads}
@router.post("/admin/pump-gate")
def pump_gate(body: PumpGateBody, _: str = Depends(current_login)) -> dict:
"""Шлагбаум пайплайна: остановить воркер после N созданных карточек.
limit=0 или None — снять ограничение (воркер продолжит разбирать очередь);
limit>0 — обнулить счётчик и останавливаться после N карточек (проверка
результата: «прогон с остановкой после первых N сообщений»).
"""
if body.limit is not None:
v = max(0, int(body.limit))
store.set_setting("pumpGate", v)
store.set_setting("pumpGateDone", 0)
limit = int(store.get_setting("pumpGate") or 0)
done = int(store.get_setting("pumpGateDone") or 0)
return {"ok": True, "limit": limit, "done": done}
@router.post("/ai/suggest-columns")
async def ai_suggest_columns(_: str = Depends(current_login)) -> dict:
"""Ручной запуск анализа «Неразобранного»: ИИ предлагает колонки."""
from ..services.suggest import suggest_from_inbox
result = await suggest_from_inbox(force=True)
return result
@router.post("/ai/suggest-keywords")
async def ai_suggest_keywords(_: str = Depends(current_login)) -> dict:
"""ИИ предлагает общие ключевые слова-маркеры сферы по вашим карточкам."""
from ..services.suggest import suggest_domain_keywords
return await suggest_domain_keywords()
@@ -0,0 +1,285 @@
"""API Discovery (Task 7): задачи поиска каналов, кандидаты, чёрный список, лог.
Prefix /api/discovery, авторизация — current_login (как в соседних роутерах).
Сервис discovery отдаёт наружу camelCase-словари (см. его docstring), поэтому
Pydantic-модели повторяют имена полей API без алиасов (как PreviewBody в tg_routes).
Списки наружу — {"items": [...]}, единичные объекты — как есть (конвенция проекта).
Обработка ошибок контракта: ValueError -> HTTP 400, KeyError -> HTTP 404.
"""
from __future__ import annotations
import logging
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from ..auth import current_login
from ..db import store
from ..services import ai as ai_service
from ..services import discovery
from ..services.telegram import _spawn, tg
log = logging.getLogger("leadradar.discovery_api")
router = APIRouter(prefix="/api/discovery", tags=["discovery"])
# потолок текста описания, уходящего ИИ-генератору ключей
_AI_DESCRIPTION_LIMIT = 4000
# страховочный потолок числа сгенерированных ключей (промпт просит 10–16)
_KEYWORDS_LIMIT = 30
# потолок длины одного ключа (короткие фразы для поиска Telegram)
_KEYWORD_LENGTH_LIMIT = 60
# промпт генерации ключевых слов по описанию задачи (RU+EN, глобальный поиск)
_KEYWORDS_PROMPT = (
"Ты — эксперт по поиску Telegram-каналов и групп. По описанию ниши/задачи "
"составь поисковые ключевые слова, по которым в глобальном поиске Telegram "
"находят подходящие источники. Верни строго JSON вида "
'{"keywords": ["...", "..."]}. Требования к списку:\n'
"- 1016 ключей;\n"
"- примерно поровну русских и английских (английские — популярные в нише термины);\n"
"- короткие фразы 1–4 слова;\n"
"- без #, @, кавычек и лишней пунктуации;\n"
"- конкретные для ниши, включая сленг заказчиков и подрядчиков;\n"
"- без дублей и близких по смыслу повторов."
)
class TaskCreate(BaseModel):
name: str
description: str = ""
keywords: list[str] = []
minSubscribers: int = 0
lang: str = "ru"
threshold: int | None = None
sampleSize: int | None = None
planJoins: int = 1
autoJoin: bool = False
class TaskPatch(BaseModel):
name: str | None = None
description: str | None = None
keywords: list[str] | None = None
minSubscribers: int | None = None
lang: str | None = None
threshold: int | None = None
sampleSize: int | None = None
planJoins: int | None = None
autoJoin: bool | None = None
def _payload(body: BaseModel) -> dict:
"""Поля модели -> payload сервиса, без явных None.
discovery.create_task/patch_task сами подставляют значения по умолчанию
(в т.ч. threshold/sampleSize из настроек), поэтому None-поля пропускаем.
"""
return body.model_dump(exclude_none=True)
def _task_or_404(task_id: str) -> dict:
task = discovery.get_task(task_id)
if task is None:
raise HTTPException(404, "Задача не найдена")
return task
def _candidate_or_404(dialog_id: str) -> dict:
row = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
if row is None:
raise HTTPException(404, "Кандидат не найден")
return row
def _ai_unavailable_reason() -> str | None:
"""Причина недоступности ИИ (None — можно вызывать)."""
if not store.get_setting("aiEnabled"):
return "ИИ выключен в настройках (aiEnabled)"
try:
status = ai_service.provider_status()
except Exception as exc: # noqa: BLE001 — статус не читается = ИИ недоступен
log.debug("generate-keywords: статус ИИ недоступен (%s)", exc)
return "Не удалось прочитать статус ИИ-провайдера"
if not (status.get("local") or status.get("keySet")):
return "Не задан API-ключ ИИ-провайдера"
return None
def _clean_keywords(raw) -> list[str]:
"""Ключи из ответа ИИ: строки без пустых/длинных и повторов (casefold)."""
seen: set[str] = set()
out: list[str] = []
for item in raw or []:
if not isinstance(item, str):
continue
keyword = item.strip()
if not keyword or len(keyword) > _KEYWORD_LENGTH_LIMIT:
continue
key = keyword.casefold()
if key in seen:
continue
seen.add(key)
out.append(keyword)
if len(out) >= _KEYWORDS_LIMIT:
break
return out
async def _backfill_quiet(dialog_id: str) -> None:
"""Догон последних сообщений вступившего источника (фон, best-effort)."""
try:
await tg.backfill_dialog(dialog_id)
except Exception as exc: # noqa: BLE001 — вступление уже состоялось
log.warning("join %s: backfill не удался: %s", dialog_id, exc)
# ─── задачи ────────────────────────────────────────────────────────────────
@router.get("/tasks")
def list_tasks(_: str = Depends(current_login)) -> dict:
return {"items": discovery.list_tasks()}
@router.post("/tasks")
def create_task(body: TaskCreate, _: str = Depends(current_login)) -> dict:
try:
return discovery.create_task(_payload(body))
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.patch("/tasks/{task_id}")
def patch_task(task_id: str, body: TaskPatch, _: str = Depends(current_login)) -> dict:
try:
return discovery.patch_task(task_id, _payload(body))
except KeyError as exc:
raise HTTPException(404, "Задача не найдена") from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.delete("/tasks/{task_id}")
def delete_task(task_id: str, _: str = Depends(current_login)) -> dict:
_task_or_404(task_id)
discovery.delete_task(task_id)
return {"ok": True}
@router.post("/tasks/{task_id}/start")
def start_task(task_id: str, _: str = Depends(current_login)) -> dict:
try:
return discovery.start_task(task_id)
except KeyError as exc:
raise HTTPException(404, "Задача не найдена") from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.post("/tasks/{task_id}/pause")
def pause_task(task_id: str, _: str = Depends(current_login)) -> dict:
try:
return discovery.pause_task(task_id)
except KeyError as exc:
raise HTTPException(404, "Задача не найдена") from exc
@router.post("/tasks/{task_id}/generate-keywords")
async def generate_keywords(task_id: str, _: str = Depends(current_login)) -> dict:
"""ИИ-генерация ключей по описанию задачи: RU+EN, 10–16 строк.
ИИ выключен/не настроен/ответил ошибкой — {"keywords": [], "error": "..."}
с HTTP 200, чтобы UI показал причину, а не падал.
"""
task = _task_or_404(task_id)
reason = _ai_unavailable_reason()
if reason:
return {"keywords": [], "error": reason}
description = str(task.get("description") or "").strip()
if not description:
return {"keywords": [], "error": "У задачи нет описания — по нему генерируются ключи"}
try:
out = await ai_service.chat_json(
_KEYWORDS_PROMPT,
f"Описание ниши/задачи:\n{description[:_AI_DESCRIPTION_LIMIT]}",
)
except Exception as exc: # noqa: BLE001 — сбой провайдера не роняет API
log.warning("generate-keywords задача %s: ИИ не ответил: %s", task_id, exc)
return {"keywords": [], "error": str(exc)}
return {"keywords": _clean_keywords(out.get("keywords", []) if isinstance(out, dict) else [])}
# ─── кандидаты ─────────────────────────────────────────────────────────────
@router.get("/tasks/{task_id}/candidates")
def list_candidates(
task_id: str,
status: Literal["new", "review", "joined", "rejected"] | None = None,
_: str = Depends(current_login),
) -> dict:
_task_or_404(task_id)
return {"items": discovery.list_candidates(task_id, status)}
@router.post("/candidates/{dialog_id}/join")
async def join_candidate(dialog_id: str, _: str = Depends(current_login)) -> dict:
"""Ручное вступление (вне квот и пауз воркера).
tg.discovery_join -> add_dialog_monitored -> backfill_dialog (последние
сообщения, best-effort) -> mark_joined(auto=False); источник снимается с
чёрного списка. Ошибка Telegram -> 400 с текстом причины.
"""
row = _candidate_or_404(dialog_id)
if row["status"] == "joined":
raise HTTPException(400, "Уже вступили в этот источник")
username = str(row.get("username") or "")
try:
await tg.discovery_join(username)
except Exception as exc: # текст ошибки уходит наружу
raise HTTPException(400, f"Не удалось вступить в @{username}: {exc}") from exc
tg.add_dialog_monitored(dialog_id, row.get("name"), username, row.get("kind"), row.get("hue"))
# догон последних сообщений — в фоне: join из UI не должен висеть на
# паузах backfill (10 сообщений × 1.5–3 с); источник уже в мониторинге
_spawn(_backfill_quiet(dialog_id))
discovery.remove_blacklist(dialog_id)
try:
return discovery.mark_joined(dialog_id, auto=False)
except KeyError as exc:
raise HTTPException(404, "Кандидат не найден") from exc
@router.post("/candidates/{dialog_id}/reject")
def reject_candidate(dialog_id: str, _: str = Depends(current_login)) -> dict:
"""Отклонить кандидата (в чёрный список). Уже вступившего — нельзя."""
row = _candidate_or_404(dialog_id)
if row["status"] == "joined":
raise HTTPException(400, "Уже вступили — удалите источник из каналов")
try:
return discovery.mark_rejected(dialog_id, reason="отклонено вручную")
except KeyError as exc:
raise HTTPException(404, "Кандидат не найден") from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
# ─── чёрный список ─────────────────────────────────────────────────────────
@router.get("/blacklist")
def list_blacklist(_: str = Depends(current_login)) -> dict:
return {"items": discovery.list_blacklist()}
@router.delete("/blacklist/{dialog_id}")
def remove_blacklist(dialog_id: str, _: str = Depends(current_login)) -> dict:
discovery.remove_blacklist(dialog_id)
return {"ok": True}
# ─── лог задачи ────────────────────────────────────────────────────────────
@router.get("/tasks/{task_id}/log")
def task_log(task_id: str, _: str = Depends(current_login)) -> dict:
_task_or_404(task_id)
return {"items": discovery.task_log(task_id)}
@@ -0,0 +1,38 @@
"""SSE-события (realtime) для фронтенда."""
from __future__ import annotations
import asyncio
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from ..auth import current_login
from ..sse import broker
router = APIRouter(prefix="/api", tags=["events"])
@router.get("/events")
async def events(request: Request, _: str = Depends(current_login)):
async def stream():
q = await broker.subscribe()
try:
while True:
if await request.is_disconnected():
break
try:
yield await asyncio.wait_for(q.get(), timeout=15)
except asyncio.TimeoutError:
yield ": ping\n\n"
finally:
await broker.unsubscribe(q)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -0,0 +1,171 @@
"""ML-лаборатория: статус, проверка на сообщении, обучение на канале.
Всё обучение уходит в outbox (гарантированно), фоновый цикл отправляет его
в автономный ML-сервис. Использование ML в пайплайне — только по настройке
`mlEnabled`; здесь можно проверить модель и вручную разметить сообщения.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from ..auth import current_login
from ..db import store
from ..services import leads as leads_svc
from ..services import ml_client
from ..services.telegram import tg
router = APIRouter(prefix="/api/ml", tags=["ml"])
class PredictBody(BaseModel):
text: str
class LearnBody(BaseModel):
text: str
label: str
class CandidatesBody(BaseModel):
dialogId: str
limit: int = 10
class ApplyBody(BaseModel):
dialogId: str
msgId: int
action: str # 'spam' | 'board:<id>' | 'skip'
def _msg_text(dialog_id: str, msg_id: int) -> str | None:
"""Текст исходного сообщения (из карточки или из messages)."""
row = store.query_one(
"SELECT source_msg AS text FROM leads WHERE source_dialog_id = ? AND source_msg_id = ? LIMIT 1",
[dialog_id, msg_id],
)
if row and row["text"]:
return row["text"]
m = store.query_one("SELECT text FROM messages WHERE id = ?", [f"m_{dialog_id}_{msg_id}"])
return m["text"] if m else None
def _lead_by_msg(dialog_id: str, msg_id: int) -> dict | None:
row = store.query_one(
"SELECT id FROM leads WHERE source_dialog_id = ? AND source_msg_id = ? LIMIT 1",
[dialog_id, msg_id],
)
if row:
return leads_svc.get_lead(row["id"])
link = store.query_one("SELECT lead_id FROM messages WHERE id = ?", [f"m_{dialog_id}_{msg_id}"])
if link and link["lead_id"]:
return leads_svc.get_lead(link["lead_id"])
return None
@router.get("/status")
async def ml_status(_: str = Depends(current_login)) -> dict:
"""Свежий статус ML-сервиса + локальная статистика (с принудительным refresh)."""
svc = await ml_client.refresh_status()
return {
"enabled": store.get_setting("mlEnabled") is not False,
"service": svc,
"reachable": ml_client._cached["reachable"], # noqa: SLF001
"stats": ml_client.snapshot(),
}
@router.post("/reset")
async def ml_reset(_: str = Depends(current_login)) -> dict:
"""Полный сброс ML-модели (классы/термины) + очистка очереди обучения."""
return await ml_client.reset_model()
@router.post("/predict")
async def ml_predict(body: PredictBody, _: str = Depends(current_login)) -> dict:
text = (body.text or "").strip()
if len(text) < 2:
raise HTTPException(400, "Введите текст")
result = await ml_client.predict(text)
return {"text": text[:200], **result}
@router.post("/learn")
async def ml_learn(body: LearnBody, _: str = Depends(current_login)) -> dict:
"""Ручная разметка: «это сообщение -> сюда». Пишется в outbox (всегда)."""
text = (body.text or "").strip()
label = (body.label or "").strip()
if not text or not label:
raise HTTPException(400, "text и label обязательны")
ml_client.push(text, label)
return {"ok": True, "outbox": ml_client.outbox_len()}
@router.post("/flush")
async def ml_flush(_: str = Depends(current_login)) -> dict:
"""Отправить накопленное обучение в ML-сервис немедленно (обычно — фон раз в 10 c)."""
flushed = await ml_client.flush_outbox()
svc = await ml_client.refresh_status()
return {"ok": True, "flushed": flushed, "outbox": ml_client.outbox_len(), "service": svc}
@router.post("/candidates")
async def ml_candidates(body: CandidatesBody, _: str = Depends(current_login)) -> dict:
"""Последние сообщения канала для разбора/обучения + мнение ML по каждому."""
limit = max(1, min(body.limit, 60))
items = await tg.dialog_messages(body.dialogId, limit)
out = []
for m in items:
text = (m.get("text") or "").strip()
if not text:
continue
pred = await ml_client.predict(text) if ml_client.is_enabled() else {
"take": False, "label": None, "scores": {}, "ready": False}
out.append(
{
"id": m["id"],
"dialogId": body.dialogId,
"text": text[:600],
"time": m.get("time"),
"lead": bool(m.get("lead")),
"pred": {"take": pred.get("take"), "label": pred.get("label"), "scores": pred.get("scores", {})},
}
)
return {"items": out}
@router.post("/apply")
async def ml_apply(body: ApplyBody, _: str = Depends(current_login)) -> dict:
"""Ручное решение по сообщению: учим ML и (если карточка есть) двигаем её."""
text = _msg_text(body.dialogId, body.msgId)
if not text:
raise HTTPException(404, "Исходное сообщение не найдено")
action = body.action
if action == "skip":
return {"ok": True, "learned": False, "moved": None}
lead = _lead_by_msg(body.dialogId, body.msgId)
result: dict = {"ok": True, "learned": False, "moved": None, "leadId": None}
if action == "spam":
ml_client.push(text, "spam")
result["learned"] = True
if lead:
leads_svc.trash_lead(lead["id"], teach=False)
result["moved"] = "trash"
result["leadId"] = lead["id"]
elif action.startswith("board:"):
board_id = action.split(":", 1)[1]
if board_id != "inbox" and store.query_one("SELECT 1 FROM boards WHERE id = ?", [board_id]) is None:
raise HTTPException(400, "Неизвестная доска")
ml_client.push(text, board_id)
result["learned"] = True
if lead:
# повторная разметка карточки, которая уже на доске, — просто обучение
if lead["col"] != board_id:
leads_svc.move_lead(lead["id"], board_id, teach=False)
result["moved"] = board_id
result["leadId"] = lead["id"]
else:
raise HTTPException(400, "Неизвестное действие")
return result
@@ -0,0 +1,74 @@
"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from ..auth import current_login
from ..services import processing as processing_svc
router = APIRouter(prefix="/api/pipeline", tags=["processing"])
class ReturnBody(BaseModel):
reason: str = ""
@router.get("/stats")
def stats(_: str = Depends(current_login)) -> dict:
"""Сводка: размер очереди (new/ai) и число записей в отсеве."""
return processing_svc.stats()
@router.get("/queue")
def queue(limit: int = 100, _: str = Depends(current_login)) -> dict:
"""Сырые сообщения, ожидающие обработки (этап 1 или ИИ)."""
q = processing_svc.queue_counts()
return {
"items": processing_svc.list_queue(limit),
"counts": q,
"rejected": processing_svc.rejected_count(),
}
@router.get("/rejected")
def rejected(
q: str = "",
offset: int = 0,
limit: int = 100,
_: str = Depends(current_login),
) -> dict:
"""Отсев: что и почему не прошло пайплайн (полнотекстовый поиск по q)."""
return processing_svc.list_rejected(q, offset, limit)
@router.post("/rejected/clear")
def rejected_clear(_: str = Depends(current_login)) -> dict:
"""Ручная полная очистка отсева (безвозвратно)."""
cleared = processing_svc.clear_all()
return {"ok": True, "cleared": cleared}
@router.delete("/rejected/{rej_id}")
def rejected_delete(rej_id: str, _: str = Depends(current_login)) -> dict:
processing_svc.delete_one(rej_id)
return {"ok": True}
@router.post("/rejected/{rej_id}/return")
def rejected_return(rej_id: str, body: ReturnBody, _: str = Depends(current_login)) -> dict:
"""Вернуть отсеянное сообщение в обработку.
Для возвращённого сообщения причины отсева (стоп-лист, резюме, тип,
без суммы, устарело, ML/ИИ-спам) игнорируются: оно уходит на ML/ИИ
и создаёт карточку. ML обучается на действии (снятие «спама»), а решение
ИИ по возвращённому сообщению снова учит ML. Запись в отсеве помечается
«возвращено» с указанной пользователем причиной.
"""
try:
out = processing_svc.return_to_queue(rej_id, body.reason)
except KeyError as exc:
raise HTTPException(404, "Запись не найдена") from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return out
@@ -0,0 +1,211 @@
"""«Выбранные»: проектные карточки, стадии, история, напоминания, вложения."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from ..auth import current_login
from ..services import files as files_svc
from ..services import object_store
from ..services import projects as proj_svc
router = APIRouter(prefix="/api/projects", tags=["projects"])
class TakeBody(BaseModel):
leadId: str
class CreateBody(BaseModel):
title: str = ""
summary: str = ""
stack: list[str] | None = None
budget: dict | None = None
contact: str = ""
tzText: str = ""
stage: str | None = None
class PatchBody(BaseModel):
title: str | None = None
summary: str | None = None
stack: list[str] | None = None
budget: dict | None = None
contact: str | None = None
tzText: str | None = None
class StageBody(BaseModel):
stage: str
class CommentBody(BaseModel):
text: str
class LinkBody(BaseModel):
name: str = ""
url: str
class ReminderBody(BaseModel):
at: int # epoch ms
def _card_or_404(card_id: str) -> dict:
card = proj_svc.get_card(card_id)
if not card:
raise HTTPException(404, "Карточка не найдена")
return card
@router.get("")
def list_cards(stage: str | None = None, _: str = Depends(current_login)) -> dict:
return {"items": proj_svc.list_cards(stage)}
@router.get("/reminders")
def reminders(_: str = Depends(current_login)) -> dict:
return {"items": proj_svc.active_reminders()}
@router.get("/{card_id}")
def get_card(card_id: str, _: str = Depends(current_login)) -> dict:
return _card_or_404(card_id)
@router.post("")
def create_card(body: CreateBody, _: str = Depends(current_login)) -> dict:
return proj_svc.create_local_card(body.model_dump())
@router.post("/take")
def take(body: TakeBody, _: str = Depends(current_login)) -> dict:
"""«Взять в работу» — лид безвозвратно уходит с дашборда."""
try:
return proj_svc.take_lead_to_projects(body.leadId)
except KeyError:
raise HTTPException(404, "Лид не найден")
@router.post("/clear-rejected")
def clear_rejected(_: str = Depends(current_login)) -> dict:
"""Полная очистка стадии «Отклонено» (безвозвратно)."""
try:
cleared = proj_svc.clear_stage("rejected")
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": True, "cleared": cleared}
@router.patch("/{card_id}")
def patch(card_id: str, body: PatchBody, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
return proj_svc.patch_card(card_id, body.model_dump(exclude_none=True))
@router.delete("/{card_id}")
def delete(card_id: str, _: str = Depends(current_login)) -> dict:
# По решению удаление проектных карточек не делаем — карточка живёт
# до финального статуса «Выполнено»/«Отклонено».
raise HTTPException(400, "Удаление проектных карточек отключено")
@router.post("/{card_id}/move")
def move(card_id: str, body: StageBody, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
try:
return proj_svc.move_stage(card_id, body.stage)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.post("/{card_id}/comments")
def comment(card_id: str, body: CommentBody, _: str = Depends(current_login)) -> dict:
if not body.text.strip():
raise HTTPException(400, "Пустой комментарий")
return {"comments": proj_svc.add_comment(card_id, body.text)}
# ─── Ссылки ───────────────────────────────────────────────────────────────
@router.post("/{card_id}/links")
def add_link(card_id: str, body: LinkBody, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
url = body.url.strip()
if not url:
raise HTTPException(400, "Пустая ссылка")
if not url.startswith(("http://", "https://")):
url = "https://" + url
card = _card_or_404(card_id)
links = card["links"] + [{"id": f"pl_{card_id[:6]}_{len(card['links'])}", "name": body.name.strip() or url, "url": url}]
return proj_svc.patch_card(card_id, {"links": links})
@router.delete("/{card_id}/links/{link_id}")
def remove_link(card_id: str, link_id: str, _: str = Depends(current_login)) -> dict:
card = _card_or_404(card_id)
links = [l for l in card["links"] if l["id"] != link_id]
return proj_svc.patch_card(card_id, {"links": links})
# ─── Вложения (мета; MinIO позже) ─────────────────────────────────────────
@router.post("/{card_id}/files")
async def upload_files(card_id: str, files: list[UploadFile], _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
added = []
for f in files:
content = await f.read()
added.append(files_svc.add_file(card_id, f.filename or "file", content, f.content_type or ""))
return {"items": added}
@router.get("/{card_id}/files/{file_id}/download")
def download_file(card_id: str, file_id: str, _: str = Depends(current_login)):
entry, _ = files_svc.get_file_entry(card_id, file_id)
if not entry.get("objectKey"):
raise HTTPException(410, "Файл не сохранён в объектном хранилище")
try:
stream = object_store.get(entry["objectKey"])
except Exception as exc: # noqa: BLE001
raise HTTPException(404, "Файл не найден в MinIO") from exc
safe_name = entry["name"].replace('"', "")
return StreamingResponse(
stream,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
)
@router.delete("/{card_id}/files/{file_id}")
def remove_file(card_id: str, file_id: str, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
files_svc.remove_file(card_id, file_id)
return {"ok": True}
# ─── Напоминания об «Отложено» ────────────────────────────────────────────
@router.post("/{card_id}/reminder")
def set_reminder(card_id: str, body: ReminderBody, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
try:
return proj_svc.set_reminder(card_id, body.at)
except PermissionError as exc:
raise HTTPException(400, str(exc)) from exc
@router.delete("/{card_id}/reminder")
def clear_reminder(card_id: str, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
proj_svc.clear_reminder(card_id)
return {"ok": True}
@router.post("/{card_id}/reminder/snooze")
def snooze(card_id: str, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
proj_svc.snooze(card_id)
return {"ok": True}
@@ -0,0 +1,243 @@
"""Настройки, AI-провайдеры, курсы валют (роутер /api)."""
from __future__ import annotations
import asyncio
import json
import httpx
from fastapi import APIRouter, Depends, HTTPException
from .. import constants as C
from ..auth import current_login
from ..crypto import decrypt_text, encrypt_text
from ..db import store
from ..services import ai as ai_svc
from ..services import rates as rates_svc
from ..services.telegram import tg
router = APIRouter(prefix="/api", tags=["settings"])
# Какие настройки видны наружу (без секретов). Значения секретов маскируются.
_PUBLIC_INT = {"archiveAfterDays", "archiveClearDays", "trashClearDays", "minLen", "discJoinLimit", "discJoinDelayMin", "discJoinDelayMax", "discEvalSample", "discEvalThreshold"}
_PUBLIC_BOOL = {"autoArchive", "aiFilterEnabled", "aiEnabled", "conversionOn", "remindersEnabled", "mlEnabled", "blockResumes", "budgetRequiredHire", "budgetRequiredOrder", "autoMonitorNew", "discPaused"}
_PUBLIC_STR = {"targetCurrency", "rateSource", "aiProvider", "aiPrompt", "aiFilterPrompt", "cardPrompt", "domainDescription", "wantedType", "hireLabel", "orderLabel"}
_PUBLIC_LIST = {"stopPhrases", "domainKeywords", "hireMarkers", "levelTerms", "resumeMarkers"}
_PUBLIC_DICT = {"colState"}
def mask(value: str) -> str:
if not value:
return ""
return value if len(value) <= 8 else f"{value[:4]}{value[-4:]}"
def public_settings() -> dict:
out: dict = {}
allset = store.all_settings()
for k in _PUBLIC_INT:
out[k] = int(allset.get(k, 0))
for k in _PUBLIC_BOOL:
out[k] = bool(allset.get(k))
for k in _PUBLIC_STR:
out[k] = allset.get(k, "")
for k in _PUBLIC_LIST:
out[k] = allset.get(k, [])
for k in _PUBLIC_DICT:
out[k] = allset.get(k, {})
out["myPrompts"] = allset.get("myPrompts", [])
tg_keys = store.get_setting("tgKeys") or {}
api_hash = str(tg_keys.get("apiHash", ""))
out["tgKeys"] = {
"apiId": mask(str(tg_keys.get("apiId", ""))),
"apiHashSet": bool(decrypt_text(api_hash)) if api_hash.startswith("enc:") else bool(api_hash),
}
cfg = store.get_setting("aiConfigs") or {}
out["aiConfigs"] = {}
for pid, conf in cfg.items():
raw_key = str(conf.get("apiKey", ""))
plain_key = decrypt_text(raw_key) if raw_key.startswith("enc:") else raw_key
out["aiConfigs"][pid] = {
"baseUrl": conf.get("baseUrl", ""),
"model": conf.get("model", ""),
"keySet": bool(plain_key),
"keyMasked": mask(plain_key),
}
out["providers"] = C.AI_PROVIDERS
return out
@router.get("/settings")
def get_settings(_: str = Depends(current_login)) -> dict:
return public_settings()
@router.patch("/settings")
async def patch_settings(body: dict, _: str = Depends(current_login)) -> dict:
current = store.all_settings()
# инвариант пауз авто-вступлений: если пришли оба конца интервала — клампы
# (5..600) и min <= max (если нет — меняем местами, как делает фронт); если
# пришёл один конец — клампим его относительно сохранённого другого конца
delay_min, delay_max = body.get("discJoinDelayMin"), body.get("discJoinDelayMax")
if delay_min is not None and delay_max is not None:
try:
dmin, dmax = int(delay_min), int(delay_max)
except (TypeError, ValueError):
pass
else:
dmin = max(5, min(600, dmin))
dmax = max(5, min(600, dmax))
if dmin > dmax:
dmin, dmax = dmax, dmin
body["discJoinDelayMin"] = dmin
body["discJoinDelayMax"] = dmax
elif delay_min is not None:
try:
cur_max = int(current.get("discJoinDelayMax") or 0)
value = max(5, min(600, int(delay_min)))
except (TypeError, ValueError):
pass
else:
body["discJoinDelayMin"] = min(value, cur_max) if cur_max >= 5 else value
elif delay_max is not None:
try:
cur_min = int(current.get("discJoinDelayMin") or 0)
value = max(5, min(600, int(delay_max)))
except (TypeError, ValueError):
pass
else:
body["discJoinDelayMax"] = max(value, cur_min) if cur_min <= 600 else value
for key, value in body.items():
if key in _PUBLIC_INT:
try:
value = int(value)
except (TypeError, ValueError):
continue
if key == "archiveAfterDays":
value = max(1, min(30, value))
if key == "minLen":
value = max(10, min(500, value))
if key == "discJoinLimit":
value = max(1, min(200, value))
if key in {"discJoinDelayMin", "discJoinDelayMax"}:
value = max(5, min(600, value))
if key == "discEvalSample":
value = max(3, min(30, value))
if key == "discEvalThreshold":
value = max(1, min(100, value))
store.set_setting(key, value)
elif key in _PUBLIC_BOOL:
store.set_setting(key, bool(value))
elif key in _PUBLIC_STR:
if key in {"targetCurrency"}:
value = str(value).upper()
if key == "aiProvider" and not any(p["id"] == value for p in C.AI_PROVIDERS):
continue
store.set_setting(key, str(value))
elif key in _PUBLIC_LIST:
if isinstance(value, list):
store.set_setting(key, [str(x) for x in value][:200])
elif key in _PUBLIC_DICT:
if isinstance(value, dict):
store.set_setting(key, value)
elif key == "myPrompts" and isinstance(value, list):
clean: list[dict] = []
for item in value[:100]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()[:80]
prompt = str(item.get("prompt") or "").strip()[:8000]
if not name or not prompt:
continue
clean.append(
{
"id": str(item.get("id") or "")[:40] or store.uid("pp_"),
"name": name,
"description": str(item.get("description") or "").strip()[:300],
"prompt": prompt,
}
)
store.set_setting("myPrompts", clean)
elif key == "aiConfigs" and isinstance(value, dict):
cfg = current.get("aiConfigs") or {}
for pid, conf in value.items():
if pid not in cfg:
continue
entry = dict(cfg[pid])
if isinstance(conf, dict):
for fk in ("baseUrl", "model"):
if fk in conf and conf[fk] is not None:
entry[fk] = str(conf[fk])
new_key = conf.get("apiKey")
if new_key and not str(new_key).startswith(("enc:",)) and len(str(new_key)) >= 8:
entry["apiKey"] = encrypt_text(str(new_key))
cfg[pid] = entry
store.set_setting("aiConfigs", cfg)
elif key == "tgKeys" and isinstance(value, dict):
keys = current.get("tgKeys") or {}
if value.get("apiId") is not None:
api = str(value["apiId"]).strip()
if api.isdigit() and 5 < len(api) < 10:
keys["apiId"] = api
new_hash = value.get("apiHash")
if new_hash and len(str(new_hash)) >= 16 and not str(new_hash).startswith("enc:"):
keys["apiHash"] = encrypt_text(str(new_hash).strip())
store.set_setting("tgKeys", keys)
# смена источника курсов — обновляем в фоне; смена целевой валюты/конвертации —
# пересчёт старых карточек (кроме архива/корзины)
if body.get("rateSource"):
asyncio.get_running_loop().create_task(rates_svc.refresh_rates())
if body.get("targetCurrency") is not None or body.get("conversionOn") is not None:
rates_svc.recompute_conversions()
return public_settings()
@router.post("/ai/check")
async def ai_check(_: str = Depends(current_login)) -> dict:
status = ai_svc.provider_status()
provider_id, data = ai_svc._cfg()
meta = data["meta"]
if meta.get("local"):
return {**status, "ok": True, "message": f"Локальный сервер «{meta['name']}» (ping в проде)"}
key = data["cfg"].get("apiKey")
if not key:
return {**status, "ok": False, "message": "Не задан API-ключ"}
# Лёгкая проверка: GET /models (для OpenAI-совместимых) или /v1/models (Anthropic)
base = str(data["cfg"].get("baseUrl") or meta["base"]).rstrip("/")
url = base + ("/v1/models" if meta.get("api_style") == "anthropic" else "/models")
headers = {"Authorization": f"Bearer {key}"} if meta.get("api_style") != "anthropic" else {
"x-api-key": key, "anthropic-version": "2023-06-01"}
try:
async with httpx.AsyncClient(timeout=12) as client:
resp = await client.get(url, headers=headers)
if resp.status_code < 400:
return {**status, "ok": True, "message": "Подключение успешно"}
if resp.status_code in (401, 403):
return {**status, "ok": False, "message": f"Ключ не принят (HTTP {resp.status_code}) — проверьте ключ и доступ к модели"}
return {**status, "ok": False, "message": f"HTTP {resp.status_code} — проверьте Base URL и модель"}
except Exception as exc: # noqa: BLE001
return {**status, "ok": False, "message": f"Ошибка соединения: {exc}"}
# ─── Курсы ────────────────────────────────────────────────────────────────
@router.get("/rates")
def get_rates(_: str = Depends(current_login)) -> dict:
return rates_svc.get_rates()
@router.post("/rates/refresh")
async def refresh_rates(_: str = Depends(current_login)) -> dict:
ok = await rates_svc.refresh_rates()
return {"ok": ok, "rates": rates_svc.get_rates()}
# ─── Методанные для фронта ────────────────────────────────────────────────
@router.get("/meta/constants")
def meta_constants(_: str = Depends(current_login)) -> dict:
return {
"currencies": C.CURRENCIES,
"stages": C.PIPELINE_STAGES,
"palette": C.PALETTE,
}
@@ -0,0 +1,154 @@
"""Telegram: статус, веб-авторизация, диалоги и мониторинг (п.4.2, 4.3 ТЗ)."""
from __future__ import annotations
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel
from ..auth import current_login
from ..services.telegram import tg
router = APIRouter(prefix="/api/tg", tags=["telegram"])
def _qr_svg(url: str) -> str:
"""Реальный QR-код (SVG, без внешних растровых зависимостей)."""
import io
import qrcode
import qrcode.image.svg
qr = qrcode.QRCode(version=None, box_size=12, border=1, error_correction=qrcode.constants.ERROR_CORRECT_M)
qr.add_data(url)
qr.make(fit=True)
buf = io.StringIO()
qr.make_image(image_factory=qrcode.image.svg.SvgPathImage).save(buf)
return buf.getvalue()
@router.get("/qr-image")
def qr_image(_: str = Depends(current_login)) -> Response:
"""SVG-картинка QR для сканирования (фаза входа 'qr')."""
if tg.phase != "qr" or not tg.qr_url:
raise HTTPException(404, "QR не активен — начните вход по QR")
return Response(
_qr_svg(tg.qr_url),
media_type="image/svg+xml",
headers={"Cache-Control": "no-store", "Content-Disposition": "inline"},
)
class PhoneBody(BaseModel):
phone: str
class CodeBody(BaseModel):
code: str
class PasswordBody(BaseModel):
password: str
class MonitorBody(BaseModel):
enabled: bool
class PreviewBody(BaseModel):
dialogId: str
limit: int = 24
@router.get("/status")
def status(_: str = Depends(current_login)) -> dict:
return tg.status()
@router.post("/start-phone")
async def start_phone(body: PhoneBody, _: str = Depends(current_login)) -> dict:
try:
await tg.start_phone(body.phone.strip())
except Exception as exc: # noqa: BLE001
raise HTTPException(400, tg.error or str(exc)) from exc
return {"phase": tg.phase}
@router.post("/start-qr")
async def start_qr(_: str = Depends(current_login)) -> dict:
try:
url = await tg.qr_start()
except Exception as exc: # noqa: BLE001
raise HTTPException(400, tg.error or str(exc)) from exc
return {"phase": tg.phase, "qrUrl": url}
@router.post("/send-code")
async def send_code(body: CodeBody, _: str = Depends(current_login)) -> dict:
try:
await tg.submit_code(body.code.strip())
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(400, tg.error or str(exc)) from exc
return {"phase": tg.phase}
@router.post("/send-password")
async def send_password(body: PasswordBody, _: str = Depends(current_login)) -> dict:
try:
await tg.submit_password(body.password)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"phase": tg.phase}
@router.post("/logout")
async def logout(_: str = Depends(current_login)) -> dict:
await tg.disconnect()
return {"ok": True}
@router.get("/dialogs")
def dialogs(_: str = Depends(current_login)) -> dict:
return {"items": tg.list_dialogs()}
@router.post("/dialogs/refresh")
async def dialogs_refresh(_: str = Depends(current_login)) -> dict:
if not tg.client or not tg.client.is_connected():
return {"ok": False, "reason": "not-connected", "count": 0}
count = await asyncio.wait_for(tg.refresh_dialogs(), timeout=60)
return {"ok": True, "count": count}
@router.post("/dialogs/monitor-all")
def monitor_all(body: MonitorBody, _: str = Depends(current_login)) -> dict:
"""Включить/выключить мониторинг сразу для всех каналов."""
count = tg.set_monitor_all(body.enabled)
return {"ok": True, "count": count, "enabled": body.enabled}
@router.post("/dialogs/backfill-all")
def backfill_all(_: str = Depends(current_login)) -> dict:
"""Перечитать последние 10 сообщений всех включённых каналов (кнопка)."""
count = tg.backfill_monitored()
return {"ok": True, "count": count}
@router.post("/dialogs/{dialog_id}/monitor")
def set_monitor(dialog_id: str, body: MonitorBody, _: str = Depends(current_login)) -> dict:
tg.set_monitor(dialog_id, body.enabled)
return {"ok": True, "enabled": body.enabled}
@router.post("/dialogs/{dialog_id}/backfill")
async def backfill(dialog_id: str, _: str = Depends(current_login)) -> dict:
processed = await tg.backfill_dialog(dialog_id)
return {"ok": True, "processed": processed}
@router.post("/dialogs/preview")
async def preview(body: PreviewBody, _: str = Depends(current_login)) -> dict:
items = await tg.dialog_messages(body.dialogId, min(max(body.limit, 1), 50))
return {"items": items}
@@ -0,0 +1 @@
"""Сервисный слой."""
@@ -0,0 +1,357 @@
"""AI-классификатор поверх выбранного провайдера.
Провайдеров много (облако + локальные OpenAI-совместимые + Anthropic),
активен один. Всё сведено к двум задачам:
* filter_incoming(text) — этап 2 фильтра входящих (промпт aiFilterPrompt);
* classify(text, boards) — разбор лида (промпт aiPrompt + обучающие примеры).
Ответы моделей строго JSON; распознаём и обрезаем markdown-обёртки ```json.
"""
from __future__ import annotations
import json
import logging
import re
import httpx
from .. import constants as C
from ..crypto import decrypt_text
from ..db import store
from .rates import convert_amount
log = logging.getLogger("leadradar.ai")
def _cfg() -> tuple[str, dict]:
provider_id = store.get_setting("aiProvider") or "deepseek"
configs = store.get_setting("aiConfigs") or {}
meta = next((p for p in C.AI_PROVIDERS if p["id"] == provider_id), None) or C.AI_PROVIDERS[0]
raw = configs.get(provider_id, {})
cfg = dict(raw)
if cfg.get("apiKey"):
cfg["apiKey"] = decrypt_text(str(cfg["apiKey"]))
return provider_id, {"meta": meta, "cfg": cfg}
def provider_status() -> dict:
"""Статус для UI: кто активен, есть ли ключ, base, модель (ключ маскируется)."""
provider_id, data = _cfg()
meta = data["meta"]
cfg = data["cfg"]
key = str(cfg.get("apiKey") or "")
return {
"provider": provider_id,
"name": meta["name"],
"base": cfg.get("baseUrl") or meta["base"],
"model": cfg.get("model") or (meta["models"] or [""])[0],
"local": bool(meta.get("local")),
"keySet": bool(key),
"keyMasked": mask_key(key),
}
def mask_key(key: str) -> str:
if not key:
return ""
if len(key) <= 8:
return key[0] + ""
return f"{key[:4]}{key[-4:]}"
# ── «Сфера и ключи»: подстановка в промпты из настроек пользователя ────────
def fill_prompt(prompt: str) -> str:
"""Заменяет в тексте промпта {domain} и {keywords} значениями из настроек.
Настройки задаются в UI (вкладка «Сфера и ключи»), поэтому ИИ-классификатор
работает для любой сферы: разработка, дизайн, недвижимость, стройка и т.п.
"""
domain = str(store.get_setting("domainDescription") or "").strip()
if not domain:
domain = "Универсально: заявка = конкретный запрос на услугу/товар/работу или найм человека."
raw_kws = store.get_setting("domainKeywords") or []
if isinstance(raw_kws, str):
raw_kws = [raw_kws]
kws = [str(k).strip() for k in raw_kws if str(k).strip()]
kws_text = ", ".join(kws[:60]) if kws else "(не заданы — определяй по тексту)"
return (prompt or "").replace("{domain}", domain).replace("{keywords}", kws_text)
async def chat_json(system: str, user: str, max_retries: int = 2, max_tokens: int = 8000) -> dict:
"""Единая точка вызова выбранной модели. Возвращает dict (JSON-ответ).
Модель иногда отвечает HTTP 200 с пустым/не-JSON содержимым (особенно
на больших запросах или при перегрузке API) — делаем несколько попыток
с нарастающей паузой, чтобы не сыпать «ИИ недоступен» из-за разового сбоя.
"""
provider_id, data = _cfg()
meta = data["meta"]
cfg = data["cfg"]
base = str(cfg.get("baseUrl") or meta["base"]).rstrip("/")
model = cfg.get("model") or (meta["models"] or [""])[0]
api_key = str(cfg.get("apiKey") or "")
last_err: Exception | None = None
last_raw = ""
for attempt in range(max_retries + 1):
try:
if meta.get("api_style") == "anthropic":
text = await _call_anthropic(base, api_key, model, system, user, max_tokens)
else:
text = await _call_openai(base, api_key, model, system, user, max_tokens)
last_raw = text
return extract_json(text)
except Exception as exc: # noqa: BLE001 — пробуем ещё раз
last_err = exc
if attempt < max_retries:
await asyncio_sleep(0.8 + 1.2 * attempt) # 0.8с, 2с, 3.2с
log.warning(
"AI call failed (%s, попыток %d): %s | ответ: %r",
meta["name"],
max_retries + 1,
last_err,
last_raw[:200],
)
raise RuntimeError(
f"ИИ ({meta['name']}) не ответил корректно — повторите попытку через несколько секунд"
)
async def asyncio_sleep(secs: float) -> None:
import asyncio
await asyncio.sleep(secs)
async def _call_openai(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str:
url = base + "/chat/completions"
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
body = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.2,
"max_tokens": max_tokens,
}
async with httpx.AsyncClient(timeout=90) as client:
resp = await client.post(url, headers=headers, json=body)
resp.raise_for_status()
payload = resp.json()
try:
msg = payload["choices"][0]["message"]
except (KeyError, IndexError, TypeError):
raise ValueError(f"неожиданный ответ API: {str(payload)[:200]}")
content = msg.get("content")
if not content and msg.get("reasoning_content"):
# модель «подумала», но не дала ответа (переполнение/обрыв) — считаем сбоем и повторим
raise ValueError("модель вернула только reasoning без ответа")
return str(content or "")
async def _call_anthropic(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str:
url = base.rstrip("/") + "/v1/messages"
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
body = {
"model": model,
"max_tokens": max_tokens,
"system": system,
"messages": [{"role": "user", "content": user}],
}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, headers=headers, json=body)
resp.raise_for_status()
payload = resp.json()
return "".join(blk.get("text", "") for blk in payload.get("content", []))
def extract_json(text: str) -> dict:
raw = text.strip()
fence = re.search(r"```(?:json)?\s*(.*?)```", raw, re.S)
if fence:
raw = fence.group(1).strip()
start, end = raw.find("{"), raw.rfind("}")
if start >= 0 and end > start:
raw = raw[start : end + 1]
return json.loads(raw)
# ── Задачи ────────────────────────────────────────────────────────────────
async def filter_incoming(text: str) -> dict:
"""Этап 2 (ИИ-фильтр). Если выключен — считаем пропущенным."""
if not store.get_setting("aiFilterEnabled"):
return {"pass": True, "reason": None, "skipped": True}
prompt = fill_prompt(store.get_setting("aiFilterPrompt"))
out = await chat_json(prompt, f"Сообщение:\n{text[:4000]}")
return {
"pass": bool(out.get("pass", True)),
"reason": out.get("reason"),
"skipped": False,
}
def _learning_examples(limit: int = 8) -> list[dict]:
"""Примеры разметки пользователя для few-shot классификации."""
rows = store.query(
"SELECT l.source_msg AS msg, ll.to_col "
"FROM learning_log ll JOIN leads l ON l.id = ll.lead_id "
"WHERE ll.action IN ('move','restore') AND l.source_msg <> '' "
"ORDER BY ll.created_at DESC LIMIT ?",
[limit],
)
examples = []
for r in rows:
if r["to_col"] in ("trash", "archive"):
continue
examples.append({"text": (r["msg"] or "")[:500], "board": r["to_col"]})
return examples
async def classify(message_text: str) -> dict:
"""Полный разбор лида. Возвращает сырой словарь модели.
Колонка для ИИ — это набор критериев (правила), а не просто название:
в промпт уходят правила колонок, чтобы модель выбирала осмысленно.
"""
from .rules import describe as describe_rules
# в классификации участвуют только принятые колонки (не ИИ-предложения)
boards = store.query(
"SELECT id, name, description, keywords, rules FROM boards WHERE suggested = FALSE ORDER BY pos"
)
lines = []
for b in boards:
rules = json.loads(b["rules"] or "{}") if b["rules"] else {}
has_rules = any(rules.get(k) for k in ("direction", "keywords", "stack", "grade", "budget"))
if has_rules:
suffix = f" (критерии: {describe_rules(rules)})"
else:
kws = json.loads(b["keywords"] or "[]")[:8]
suffix = f" (ключевые слова: {', '.join(kws)})" if kws else ""
line = f"- {b['id']}: {b['name']}{suffix}"
if b.get("description"):
line += f"{str(b['description']).strip()[:160]}"
lines.append(line)
board_map = "\n".join(lines) or "- (колонок пока нет — верните board: null)"
examples = _learning_examples()
user = (
f"Доски: {board_map}\n\n"
+ ("Примеры разметки пользователя:\n" + "\n".join(
f"текст: {e['text']}\n→ колонка: {e['board']}" for e in examples
) + "\n\n" if examples else "")
+ f"Новое сообщение:\n{message_text[:5000]}"
)
prompt = fill_prompt(store.get_setting("aiPrompt"))
# отдельный промпт структуры карточки («О заявке») — задаёт поля контента,
# чтобы все карточки имели одинаковую структуру текста
card = fill_prompt(store.get_setting("cardPrompt") or "")
if card:
prompt = prompt + "\n\n" + card
return await chat_json(prompt, user)
def normalize_dedup(text: str) -> str:
"""Дедупликация по нормализованному тексту (регистр и спецсимволы)."""
import hashlib
import re as _re
norm = _re.sub(r"[^\wа-яё]+", "", text.casefold())
return hashlib.sha1(norm.encode()).hexdigest()
# Синонимы валют из ответов ИИ → коды хранения (согласовано с rules._CUR_*)
_CUR_ALIASES = {
"USD": "USD", "$": "USD", "US$": "USD", "ДОЛЛАР": "USD", "ДОЛЛАРОВ": "USD", "ДОЛЛ": "USD", "БАКС": "USD", "БАКСОВ": "USD",
"EUR": "EUR", "": "EUR", "ЕВРО": "EUR",
"RUB": "RUB", "RUR": "RUB", "": "RUB", "РУБ": "RUB", "РУБЛЕЙ": "RUB", "РУБЛИ": "RUB", "РУБЛЬ": "RUB", "РУБЛЯ": "RUB",
"GBP": "GBP", "£": "GBP", "CNY": "CNY", "¥": "CNY", "USDT": "USDT", "": "USDT",
}
def _norm_currency(raw) -> str | None:
"""Приводит название/символ валюты из ИИ к коду (USD/EUR/RUB/…)."""
s = str(raw or "").strip().upper()
if not s:
return None
if s in _CUR_ALIASES:
return _CUR_ALIASES[s]
letters = re.sub(r"[^A-ZА-Я]", "", s)
if letters in _CUR_ALIASES:
return _CUR_ALIASES[letters]
if len(s) == 3 and s.isalpha():
return s
return None
def _budget_num(v):
"""Число из значения бюджета ИИ: «2к»/«2К» → 2000, «2000₽»/«2000р» → 2000."""
if v is None:
return None
s = str(v).strip().casefold().replace("\u00a0", "").replace(" ", "")
if not s:
return None
mult = 1.0
if s.endswith(("к", "k")):
mult = 1000.0
s = s[:-1]
if s.endswith("руб"):
s = s[:-3]
elif s.endswith(("р", "")):
s = s[:-1]
try:
x = float(s.replace(",", ".")) * mult
except ValueError:
return None
return None if x == 0 else x
def clean_budget(budget) -> dict | None:
"""Нормализация бюджета из ИИ/локального разбора.
Соглашение хранения (и отображения в UI):
одна сумма X → {from: X, to: X}
«до X» → {from: None, to: X}
диапазон «от X до Y» → {from: X, to: Y}
from=0 трактуется как отсутствие нижней границы («от 0 до X» == «до X»).
"""
if not isinstance(budget, dict):
return None
cur = _norm_currency(budget.get("currency") or budget.get("cur"))
if not cur:
return None
def num(v):
return _budget_num(v)
f, t = num(budget.get("from")), num(budget.get("to"))
if f is None and t is None:
return None
if t is None:
t = f # одна сумма или «от X» без верхней границы
return {"from": f, "to": t, "currency": cur}
def budget_to_target(budget: dict | None) -> dict:
"""Пересчёт «один раз при поступлении» в целевую валюту по текущим курсам."""
if not budget or not budget.get("currency"):
return {"convFrom": None, "convTo": None, "convCur": ""}
cur = budget["currency"]
target = store.get_setting("targetCurrency") or "RUB"
if not store.get_setting("conversionOn"):
return {"convFrom": None, "convTo": None, "convCur": ""}
from_, to_ = budget.get("from"), budget.get("to")
c_from = convert_amount(from_, cur, target) if from_ is not None else None
c_to = None
if to_ is not None:
c_to = convert_amount(to_, cur, target)
elif from_ is not None:
c_to = c_from
return {"convFrom": c_from, "convTo": c_to, "convCur": target}
@@ -0,0 +1,80 @@
"""BanGuard: квоты, паузы и защита от flood для авто-вступлений Discovery.
Суточный лимит (discJoinLimit) считается по disc_log (event='join_auto')
за текущие UTC-сутки; между вступлениями выдерживается случайная пауза
(discJoinDelayMin/Max). При FloodWait от Telegram ставится блокировка до
конца суток (discFloodDay), плюс есть ручной стоп-кран (discPaused).
"""
from __future__ import annotations
import asyncio
import random
from datetime import datetime, timezone
from ..db import store
_KEY_JOIN_LIMIT = "discJoinLimit"
_KEY_DELAY_MIN = "discJoinDelayMin"
_KEY_DELAY_MAX = "discJoinDelayMax"
_KEY_FLOOD_DAY = "discFloodDay"
_KEY_PAUSED = "discPaused"
def _start_of_day_ms() -> int:
"""Начало текущих UTC-суток в миллисекундах."""
start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
return int(start.timestamp() * 1000)
def joins_today_auto() -> int:
"""Авто-вступления за текущие UTC-сутки (disc_log, event='join_auto')."""
row = store.scalar(
"SELECT count(*) FROM disc_log WHERE event = 'join_auto' AND created_at >= ?",
[_start_of_day_ms()],
)
return int(row or 0)
def can_auto_join() -> bool:
"""Разрешено ли авто-вступление: лимит не исчерпан, нет flood на сегодня, нет паузы."""
limit = int(store.get_setting(_KEY_JOIN_LIMIT) or 0)
return joins_today_auto() < limit and not flood_today() and not global_paused()
async def wait_join_delay() -> None:
"""Случайная пауза перед авто-вступлением (сек, из настроек).
Защита инварианта min <= max: настройки мог изменить один конец интервала
(single-key PATCH), поэтому при инверсии концы меняются местами.
"""
low = float(store.get_setting(_KEY_DELAY_MIN) or 0)
high = float(store.get_setting(_KEY_DELAY_MAX) or 0)
if low <= 0 and high <= 0:
return # обе настройки не заданы — паузы нет (крайний случай)
if low > high:
low, high = high, low
await asyncio.sleep(random.uniform(low, high))
def note_flood() -> None:
"""Зафиксировать FloodWait: блокировка авто-вступлений до конца суток."""
store.set_setting(_KEY_FLOOD_DAY, _start_of_day_ms())
def flood_today() -> bool:
"""Была ли flood-блокировка в текущие UTC-сутки."""
return int(store.get_setting(_KEY_FLOOD_DAY) or 0) == _start_of_day_ms()
def global_paused() -> bool:
"""Ручной стоп-кран авто-вступлений (setting discPaused)."""
return bool(store.get_setting(_KEY_PAUSED))
def set_global_pause(v: bool) -> None:
store.set_setting(_KEY_PAUSED, bool(v))
def search_pause() -> float:
"""Пауза между поисковыми запросами Telegram (сек)."""
return random.uniform(2.0, 4.0)
@@ -0,0 +1,608 @@
"""Discovery: хранилище задач поиска каналов, кандидатов, чёрного списка и лога.
Единый слой доступа к disc_tasks / disc_candidates / disc_blacklist / disc_log
(Task 1). Потребляется и API (Task 7), и фоновым воркером (Task 6).
Соглашения модуля:
- все обращения к БД только через `store.*` с параметрами (без конкатенации SQL);
- время — миллисекунды (`time.time_ns() // 1_000_000`);
- JSON-поля (keywords/marks/topics) в БД — VARCHAR, наружу всегда список
(`json.dumps(..., ensure_ascii=False)` при записи, `json.loads` при чтении);
- наружные dict-ы — camelCase (конвенция границы API проекта), поля совпадают
с колонками БД: keywords/minSubscribers/sampleSize/planJoins/autoJoin,
searchIdx/searchDone, fitRatio/autoJoined, createdAt/updatedAt, dialogId...
- create/patch принимают ключи и в camelCase, и в snake_case (поле "min_subscribers"
и т.п.) — на входе идёт нормализация к колонкам БД;
- статусы кандидата: new -> review -> joined|rejected. Переводы в joined/rejected
делаются ТОЛЬКО через mark_joined()/mark_rejected() (счётчики, чёрный список,
лог); set_candidate_status() разрешает new/review.
Бюджет авто-вступлений: сумма plan_joins задач со статусом NOT IN ('done','failed')
плюс plan_joins новой/увеличиваемой задачи не должна превышать discJoinLimit.
"""
from __future__ import annotations
import json
import time
from ..db import store
# префиксы id (конвенция: видно, из какой таблицы запись)
_ID_TASK = "dt_"
_ID_LOG = "dl_"
# статусы кандидата, при которых повторное добавление источника запрещено
_ACTIVE_CANDIDATE = {"new", "review", "joined"}
# статусы задачи, которые НЕ занимают бюджет plan_joins
_DONE_TASK = {"done", "failed"}
# алиасы полей задачи: колонка БД -> набор имён в payload/патче
_TASK_ALIASES = {
"min_subscribers": {"minSubscribers", "min_subscribers"},
"sample_size": {"sampleSize", "sample_size"},
"plan_joins": {"planJoins", "plan_joins"},
"auto_join": {"autoJoin", "auto_join"},
# остальные поля называются одинаково: name, description, keywords, lang, threshold
}
# поля кандидата, которые можно менять через set_candidate()
_CANDIDATE_FIELDS = {
"name": "name",
"username": "username",
"kind": "kind",
"hue": "hue",
"participants": "participants",
"lang_ru": "lang_ru",
"langRu": "lang_ru",
"marks": "marks",
"topics": "topics",
"fit_ratio": "fit_ratio",
"fitRatio": "fit_ratio",
"auto_joined": "auto_joined",
"autoJoined": "auto_joined",
}
def _now() -> int:
return time.time_ns() // 1_000_000
def _json(value) -> str:
return json.dumps(value or [], ensure_ascii=False)
def _loads(raw, default: list | None = None) -> list:
try:
return json.loads(raw or "[]")
except (TypeError, ValueError):
return list(default or [])
def _plan_limit() -> int:
"""Верхняя граница plan_joins: текущий суточный лимит discJoinLimit."""
return max(1, int(store.get_setting("discJoinLimit") or 0))
def _active_plan_sum(exclude_id: str | None = None) -> int:
sql = "SELECT coalesce(sum(plan_joins), 0) FROM disc_tasks WHERE status NOT IN ('done', 'failed')"
params: list = []
if exclude_id:
sql += " AND id <> ?"
params.append(exclude_id)
return int(store.scalar(sql, params) or 0)
def _assert_plan(plan_joins: int) -> None:
"""plan_joins >= 1 и не больше суточного лимита (настраивается в UI)."""
if plan_joins < 1:
raise ValueError("plan_joins должен быть не меньше 1")
limit = _plan_limit()
if plan_joins > limit:
raise ValueError(f"plan_joins {plan_joins} больше суточного лимита авто-вступлений ({limit})")
def _assert_budget(plan_joins: int, exclude_id: str | None = None) -> None:
"""Правило бюджета: сумма планов активных задач + новая <= discJoinLimit."""
limit = _plan_limit()
used = _active_plan_sum(exclude_id)
if used + plan_joins > limit:
raise ValueError(
f"Бюджет авто-вступлений исчерпан: задачи уже занимают {used} из {limit} в сутки, "
f"ещё {plan_joins} не влезает"
)
# ─── view-слои (наружу camelCase) ──────────────────────────────────────────
def _task_view(row: dict) -> dict:
return {
"id": row["id"],
"name": row["name"],
"description": row["description"],
"keywords": _loads(row["keywords"]),
"minSubscribers": int(row["min_subscribers"]),
"lang": row["lang"],
"threshold": int(row["threshold"]),
"sampleSize": int(row["sample_size"]),
"planJoins": int(row["plan_joins"]),
"autoJoin": bool(row["auto_join"]),
"status": row["status"],
"searchIdx": int(row["search_idx"]),
"searchDone": bool(row["search_done"]),
"found": int(row["found"]),
"evaluated": int(row["evaluated"]),
"joined": int(row["joined"]),
"rejected": int(row["rejected"]),
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
}
def _candidate_view(row: dict) -> dict:
return {
"dialogId": row["dialog_id"],
"taskId": row["task_id"],
"name": row["name"],
"username": row["username"],
"kind": row["kind"],
"hue": row["hue"],
"participants": row["participants"],
"langRu": row["lang_ru"],
"marks": _loads(row["marks"]),
"topics": _loads(row["topics"]),
"fitRatio": row["fit_ratio"],
"status": row["status"],
"autoJoined": bool(row["auto_joined"]),
"joinFailures": int(row.get("join_failures") or 0),
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
}
def _blacklist_view(row: dict) -> dict:
return {
"dialogId": row["dialog_id"],
"name": row["name"],
"reason": row["reason"],
"createdAt": row["created_at"],
}
def _log_view(row: dict) -> dict:
return {
"id": row["id"],
"taskId": row["task_id"],
"event": row["event"],
"text": row["text"],
"createdAt": row["created_at"],
}
# ─── задачи ────────────────────────────────────────────────────────────────
def _task_or_raise(task_id: str) -> dict:
row = store.query_one("SELECT * FROM disc_tasks WHERE id = ?", [task_id])
if row is None:
raise KeyError(task_id)
return row
def list_tasks() -> list[dict]:
"""Все задачи, старые первыми (воркер берёт самую старую running)."""
return [_task_view(r) for r in store.query("SELECT * FROM disc_tasks ORDER BY created_at ASC")]
def get_task(task_id: str) -> dict | None:
row = store.query_one("SELECT * FROM disc_tasks WHERE id = ?", [task_id])
return _task_view(row) if row else None
def _norm_task_values(patch: dict) -> dict:
"""Нормализация payload/патча задачи (camel/snake алиасы) к колонкам БД."""
out: dict = {}
for key, value in patch.items():
if key in ("name", "description", "keywords", "lang", "threshold"):
col = key
else:
col = next((c for c, aliases in _TASK_ALIASES.items() if key in aliases), None)
if col is None:
continue # неизвестное поле игнорируем
out[col] = value
return out
def _validate_task_values(cols: dict) -> None:
"""Проверка границ значений задачи (после нормализации)."""
if "threshold" in cols:
cols["threshold"] = max(1, min(100, int(cols["threshold"])))
if "sample_size" in cols:
cols["sample_size"] = max(1, int(cols["sample_size"]))
if "min_subscribers" in cols:
cols["min_subscribers"] = max(0, int(cols["min_subscribers"]))
if "lang" in cols and cols["lang"] not in ("ru", "any"):
cols["lang"] = "ru"
if "auto_join" in cols:
cols["auto_join"] = bool(cols["auto_join"])
if "keywords" in cols:
keywords = cols["keywords"] if isinstance(cols["keywords"], list) else [cols["keywords"]]
cols["keywords"] = [str(k).strip() for k in keywords if str(k).strip()]
if "description" in cols:
cols["description"] = str(cols["description"] or "")
if "name" in cols:
cols["name"] = str(cols["name"] or "").strip()
def create_task(payload: dict) -> dict:
"""Создать задачу поиска.
Валидация: name непустое; plan_joins 1..discJoinLimit; правило бюджета
(сумма plan_joins активных задач + новая <= discJoinLimit) — иначе ValueError.
"""
cols = _norm_task_values(payload)
name = str(cols.get("name") or "").strip()
if not name:
raise ValueError("Укажите название задачи")
plan_joins = int(cols.get("plan_joins", 1))
_assert_plan(plan_joins)
_assert_budget(plan_joins)
values = {
"name": name,
"description": str(cols.get("description") or ""),
"keywords": cols.get("keywords", []),
"min_subscribers": max(0, int(cols.get("min_subscribers", 0))),
"lang": cols.get("lang", "ru"),
"threshold": max(1, min(100, int(cols.get("threshold", int(store.get_setting("discEvalThreshold") or 40))))),
"sample_size": max(1, int(cols.get("sample_size", int(store.get_setting("discEvalSample") or 10)))),
"plan_joins": plan_joins,
"auto_join": bool(cols.get("auto_join", False)),
}
_validate_task_values(values)
task_id = store.uid(_ID_TASK)
now = _now()
store.execute(
"INSERT INTO disc_tasks(id, name, description, keywords, min_subscribers, lang, threshold, "
"sample_size, plan_joins, auto_join, status, search_idx, search_done, found, evaluated, "
"joined, rejected, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 0, FALSE, 0, 0, 0, 0, ?, ?)",
[
task_id,
values["name"],
values["description"],
_json(values["keywords"]),
values["min_subscribers"],
values["lang"],
values["threshold"],
values["sample_size"],
values["plan_joins"],
values["auto_join"],
now,
now,
],
)
return get_task(task_id) # type: ignore[return-value]
def patch_task(task_id: str, patch: dict) -> dict:
"""Обновить поля задачи. Увеличение plan_joins — с проверкой бюджета."""
row = _task_or_raise(task_id)
cols = _norm_task_values(patch)
if not cols:
return get_task(task_id) # type: ignore[return-value]
old_plan = int(row["plan_joins"])
if "plan_joins" in cols:
new_plan = int(cols["plan_joins"])
_assert_plan(new_plan)
if new_plan > old_plan:
_assert_budget(new_plan, exclude_id=task_id)
cols["plan_joins"] = new_plan
_validate_task_values(cols)
sets = ["updated_at = ?"]
params: list = [_now()]
for col, value in cols.items():
sets.append(f"{col} = ?")
params.append(_json(value) if col == "keywords" else value)
params.append(task_id)
store.execute(
f"UPDATE disc_tasks SET {', '.join(sets)} WHERE id = ?",
params,
)
return get_task(task_id) # type: ignore[return-value]
def delete_task(task_id: str) -> None:
"""Удалить задачу вместе с её кандидатами и логом (чёрный список общий)."""
store.execute("DELETE FROM disc_tasks WHERE id = ?", [task_id])
store.execute("DELETE FROM disc_candidates WHERE task_id = ?", [task_id])
store.execute("DELETE FROM disc_log WHERE task_id = ?", [task_id])
def start_task(task_id: str) -> dict:
"""Запустить поиск: keywords непустые; status=running.
При повторном запуске завершённой/упавшей задачи прогресс поиска обнуляется
(свежий проход по ключам); при продолжении из paused — сохраняется.
"""
row = _task_or_raise(task_id)
keywords = _loads(row["keywords"])
if not keywords:
raise ValueError("Нет ключевых слов для поиска — добавьте их в задачу")
now = _now()
reset = row["status"] in _DONE_TASK
if reset:
# повторный прогон завершённой/упавшей задачи — свежий проход по ключам
store.execute(
"UPDATE disc_tasks SET status = 'running', search_idx = 0, search_done = FALSE, "
"found = 0, evaluated = 0, joined = 0, rejected = 0, updated_at = ? WHERE id = ?",
[now, task_id],
)
else:
# старт из draft или продолжение из paused — прогресс поиска сохраняется
store.execute(
"UPDATE disc_tasks SET status = 'running', updated_at = ? WHERE id = ?",
[now, task_id],
)
return get_task(task_id) # type: ignore[return-value]
def pause_task(task_id: str) -> dict:
"""Поставить задачу на паузу."""
_task_or_raise(task_id)
store.execute(
"UPDATE disc_tasks SET status = 'paused', updated_at = ? WHERE id = ?",
[_now(), task_id],
)
return get_task(task_id) # type: ignore[return-value]
def bump_counter(task_id: str, field: str, n: int = 1) -> None:
"""Увеличить счётчик задачи: found|evaluated|joined|rejected."""
if field not in ("found", "evaluated", "joined", "rejected"):
raise ValueError(f"Неизвестный счётчик задачи: {field}")
row = _task_or_raise(task_id)
row[field] = int(row[field]) + max(0, int(n))
store.execute(
f"UPDATE disc_tasks SET {field} = ?, updated_at = ? WHERE id = ?",
[row[field], _now(), task_id],
)
def advance_search(task_id: str) -> None:
"""Перейти к следующему ключу; когда search_idx >= len(keywords) — search_done=True."""
row = _task_or_raise(task_id)
keywords = _loads(row["keywords"])
new_idx = int(row["search_idx"]) + 1
done = new_idx >= len(keywords)
store.execute(
"UPDATE disc_tasks SET search_idx = ?, search_done = ?, updated_at = ? WHERE id = ?",
[new_idx, done, _now(), task_id],
)
# ─── кандидаты ─────────────────────────────────────────────────────────────
def list_candidates(task_id: str, status: str | None = None) -> list[dict]:
"""Кандидаты задачи (marks/topics уже списки); status — фильтр."""
if status:
rows = store.query(
"SELECT * FROM disc_candidates WHERE task_id = ? AND status = ? ORDER BY created_at ASC",
[task_id, status],
)
else:
rows = store.query(
"SELECT * FROM disc_candidates WHERE task_id = ? ORDER BY created_at ASC",
[task_id],
)
return [_candidate_view(r) for r in rows]
def _get_candidate(dialog_id: str) -> dict | None:
row = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
return row
def _skip(task_id: str, reason: str) -> None:
add_log(task_id, "skip", reason)
def add_candidate(task_id: str, dialog_id: str, name: str, username: str, kind: str, hue: str) -> dict | None:
"""Добавить найденный источник как кандидата задачи.
None (с логом skip), если источник уже мониторится (есть в dialogs), в
чёрном списке или уже добавлен в статусе new/review/joined. Прежняя запись
со статусом rejected (например, после remove_blacklist) заменяется новой.
"""
_task_or_raise(task_id)
dialog_id = str(dialog_id)
if store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id]):
_skip(task_id, f"пропущен {dialog_id}: источник уже мониторится (мы состоим)")
return None
if store.scalar("SELECT 1 FROM disc_blacklist WHERE dialog_id = ? LIMIT 1", [dialog_id]):
_skip(task_id, f"пропущен {dialog_id}: источник в чёрном списке")
return None
existing = store.query_one(
"SELECT status FROM disc_candidates WHERE dialog_id = ?",
[dialog_id],
)
if existing and existing["status"] in _ACTIVE_CANDIDATE:
_skip(task_id, f"пропущен {dialog_id}: кандидат уже есть (статус {existing['status']})")
return None
if existing:
# устаревшая rejected-запись: перезаписываем как новый кандидат
store.execute("DELETE FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
now = _now()
store.execute(
"INSERT INTO disc_candidates(dialog_id, task_id, name, username, kind, hue, participants, "
"lang_ru, marks, topics, fit_ratio, status, auto_joined, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, '[]', '[]', NULL, 'new', FALSE, ?, ?)",
[
dialog_id,
task_id,
str(name or "").strip() or dialog_id,
str(username or "").strip(),
str(kind or "channel"),
str(hue or "#666"),
now,
now,
],
)
bump_counter(task_id, "found")
row = _get_candidate(dialog_id)
return _candidate_view(row) if row else None
def set_candidate(task_id: str, dialog_id: str, patch: dict) -> dict:
"""Обновить поля кандидата задачи (например, по результатам оценки).
patch — значения в нотации кандидата (camelCase или snake_case):
participants, kind, langRu/lang_ru, marks, topics, fitRatio/fit_ratio,
autoJoined/auto_joined, name, username, hue.
"""
_task_or_raise(task_id)
row = _get_candidate(dialog_id)
if row is None or row["task_id"] != task_id:
raise KeyError(dialog_id)
cols: dict = {}
for key, value in patch.items():
col = _CANDIDATE_FIELDS.get(key)
if col is None:
continue
cols[col] = value
if not cols:
return _candidate_view(row)
if "marks" in cols:
cols["marks"] = _json([str(m) for m in cols["marks"]])
if "topics" in cols:
cols["topics"] = _json(cols["topics"])
for col in ("name", "username", "kind", "hue"):
if col in cols:
cols[col] = str(cols[col] or "").strip() or row[col]
if "participants" in cols and cols["participants"] is not None:
cols["participants"] = int(cols["participants"])
sets = ["updated_at = ?"]
params: list = [_now()]
for col, value in cols.items():
sets.append(f"{col} = ?")
params.append(value)
params.append(dialog_id)
store.execute(f"UPDATE disc_candidates SET {', '.join(sets)} WHERE dialog_id = ?", params)
updated = _get_candidate(dialog_id)
return _candidate_view(updated) if updated else _candidate_view(row)
def set_candidate_status(dialog_id: str, status: str) -> dict:
"""Перевести кандидата в new/review (+ лог review).
joined/rejected меняются только через mark_joined()/mark_rejected() —
там счётчики задачи, чёрный список и лог join/reject.
"""
if status not in ("new", "review"):
raise ValueError(f"Статус {status} выставляется через mark_joined/mark_rejected")
row = _get_candidate(dialog_id)
if row is None:
raise KeyError(dialog_id)
store.execute(
"UPDATE disc_candidates SET status = ?, updated_at = ? WHERE dialog_id = ?",
[status, _now(), dialog_id],
)
if status == "review":
add_log(row["task_id"], "review", f"кандидат {dialog_id} переведён в review")
updated = _get_candidate(dialog_id)
return _candidate_view(updated) if updated else _candidate_view(row)
def delete_candidate(dialog_id: str) -> None:
"""Удалить кандидата (skip-ветки воркера; повторный вызов безопасен)."""
store.execute("DELETE FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
def mark_joined(dialog_id: str, auto: bool) -> dict:
"""Источник вступил: status=joined, счётчик joined задачи, лог join_auto/join_manual."""
row = _get_candidate(dialog_id)
if row is None:
raise KeyError(dialog_id)
if row["status"] == "joined":
return _candidate_view(row) # идемпотентно: повторно не считаем
now = _now()
store.execute(
"UPDATE disc_candidates SET status = 'joined', auto_joined = ?, updated_at = ? WHERE dialog_id = ?",
[bool(auto), now, dialog_id],
)
bump_counter(row["task_id"], "joined")
add_log(row["task_id"], "join_auto" if auto else "join_manual", f"вступили в {dialog_id}")
updated = _get_candidate(dialog_id)
return _candidate_view(updated) if updated else _candidate_view(row)
def mark_rejected(dialog_id: str, reason: str = "") -> dict:
"""Отклонить кандидата: status=rejected, счётчик rejected, лог reject, чёрный список.
Повторный вызов для уже отклонённого — идемпотентен: счётчик/лог/чёрный
список не трогаются (кандидата мог отклонить и человек, и воркер).
"""
row = _get_candidate(dialog_id)
if row is None:
raise KeyError(dialog_id)
if row["status"] == "joined":
raise ValueError("Нельзя отклонить источник, в который уже вступили")
if row["status"] == "rejected":
return _candidate_view(row) # идемпотентно: повторно не считаем и не логируем
now = _now()
store.execute(
"UPDATE disc_candidates SET status = 'rejected', updated_at = ? WHERE dialog_id = ?",
[now, dialog_id],
)
bump_counter(row["task_id"], "rejected")
add_log(row["task_id"], "reject", reason or f"отклонён {dialog_id}")
add_blacklist(dialog_id, row["name"], reason or "")
updated = _get_candidate(dialog_id)
return _candidate_view(updated) if updated else _candidate_view(row)
# ─── чёрный список ─────────────────────────────────────────────────────────
def add_blacklist(dialog_id: str, name: str = "", reason: str = "") -> dict:
"""Пометить источник в чёрном списке (существующая запись обновляется)."""
dialog_id = str(dialog_id)
now = _now()
store.execute(
"INSERT INTO disc_blacklist(dialog_id, name, reason, created_at) VALUES (?, ?, ?, ?) "
"ON CONFLICT(dialog_id) DO UPDATE SET name = excluded.name, reason = excluded.reason",
[dialog_id, str(name or "").strip() or dialog_id, str(reason or ""), now],
)
row = store.query_one("SELECT * FROM disc_blacklist WHERE dialog_id = ?", [dialog_id])
return _blacklist_view(row) if row else {"dialogId": dialog_id, "name": name, "reason": reason, "createdAt": now}
def remove_blacklist(dialog_id: str) -> None:
store.execute("DELETE FROM disc_blacklist WHERE dialog_id = ?", [dialog_id])
def list_blacklist() -> list[dict]:
return [
_blacklist_view(r)
for r in store.query("SELECT * FROM disc_blacklist ORDER BY created_at DESC")
]
# ─── лог ───────────────────────────────────────────────────────────────────
def add_log(task_id: str, event: str, text: str = "") -> None:
store.execute(
"INSERT INTO disc_log(id, task_id, event, text, created_at) VALUES (?, ?, ?, ?, ?)",
[store.uid(_ID_LOG), task_id, str(event), str(text or ""), _now()],
)
def task_log(task_id: str, limit: int = 100) -> list[dict]:
"""Последние события задачи, новые сверху."""
limit = max(1, min(500, int(limit)))
rows = store.query(
"SELECT * FROM disc_log WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
[task_id, limit],
)
return [_log_view(r) for r in rows]
@@ -0,0 +1,237 @@
"""Discovery: оценка контента — язык, темы, fit сообщений под задачу поиска.
Чистая логика оценки (без карточек, очередей и обучения):
* detect_lang_ru — доля кириллических букв среди всех букв выборки;
* group_by_topic — группировка выборки по topic_id (None -> "main")
для форумов: {topic_id, title, messages: [...]};
* evaluate_message — fit одного сообщения под задачу: каскад
короткое -> ML-спам -> ИИ -> эвристика;
* evaluate_sample — агрегат по выборке сообщений кандидата;
* passed — вердикт «источник подходит» (объём выборки + доля fit).
Каскад оценки сообщения (evaluate_message):
1) текст пустой/короче 10 символов -> False «слишком короткое»;
2) ML: ml_client.is_enabled() и прогноз take + label=='spam' -> False;
3) ИИ: если включён (aiEnabled) и доступен ключ/локальный провайдер — один
JSON-вызов ai_service.chat_json({fit, reason}); любая ошибка (нет ключа,
сеть, не-JSON) ловится и оценка продолжается эвристикой;
4) эвристика: любой ключ задачи входит в clean_short(text).casefold().
task — внешний dict в camelCase (конвенция discovery.Task 3): description,
keywords (list[str]), lang, threshold, sampleSize. Иные ключи игнорируются,
поэтому сюда можно передавать и полный view задачи из discovery.get_task.
"""
from __future__ import annotations
import logging
from ..db import store
from . import ai as ai_service
from . import ml_client
from .pipeline import clean_short
log = logging.getLogger("leadradar.discovery_eval")
# пороги доли кириллицы (задача ru-языка): >= hi -> True, <= lo -> False
_RU_RATIO_HI = 0.15
_RU_RATIO_LO = 0.03
# минимальная длина сообщения для содержательной оценки
_MIN_TEXT_LEN = 10
# ограничение текста, уходящего провайдеру (в одном сообщении больше не нужно)
_AI_TEXT_LIMIT = 4000
# потолок причины из ИИ (в UI не нужны простыни)
_AI_REASON_LIMIT = 200
# длина title темы (сниппет первого текста)
_TITLE_LIMIT = 60
# topic_id=None в выборке/группировке -> общая тема ("main")
_MAIN_TOPIC = "main"
# промпт ИИ-оценки (задача: относится ли сообщение к сфере/описанию)
_AI_PROMPT = (
"Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. "
"Ключи: {keywords}. "
"Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}."
)
def _is_cyrillic(ch: str) -> bool:
"""Кириллица ли символ (базовый блок U+0400–U+04FF, включает ё/ў и т.п.)."""
return 0x0400 <= ord(ch) <= 0x04FF
def detect_lang_ru(texts: list[str]) -> bool | None:
"""Доля кириллицы среди всех букв выборки: >=0.15 True, <=0.03 False, иначе None.
None означает «неопределённо» — воркер не отсекает кандидата, а помечает
язык как неподтверждённый. Пустая выборка без букв тоже даёт None.
"""
letters = 0
cyr = 0
for t in texts or []:
for ch in str(t or ""):
if ch.isalpha():
letters += 1
if _is_cyrillic(ch):
cyr += 1
if letters == 0:
return None
ratio = cyr / letters
if ratio >= _RU_RATIO_HI:
return True
if ratio <= _RU_RATIO_LO:
return False
return None
def _topic_title(messages: list[dict]) -> str:
"""Сниппет первого непустого текста темы (<=60 симв., whitespace схлопнут)."""
for m in messages:
text = str(m.get("text") or "").strip()
if text:
text = " ".join(text.split())
return text[:_TITLE_LIMIT]
return ""
def group_by_topic(messages: list[dict]) -> list[dict]:
"""Группирует сообщения по topic_id (None -> "main"), сортирует группы по
числу сообщений (убыв.), порядок сообщений внутри группы — входной.
Возвращает [{"topic_id", "title", "messages": [...]}], где title — сниппет
первого текста темы (первое непустое сообщение во входном порядке).
"""
groups: dict[str, list[dict]] = {}
order: list[str] = []
for m in messages or []:
tid = m.get("topic_id")
key = _MAIN_TOPIC if tid is None else tid
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(m)
out = [
{"topic_id": key, "title": _topic_title(groups[key]), "messages": groups[key]}
for key in order
]
out.sort(key=lambda g: len(g["messages"]), reverse=True)
return out
def _keywords(task: dict) -> list[str]:
kws = task.get("keywords") or []
if isinstance(kws, str):
kws = [kws]
return [str(k).strip() for k in kws if str(k).strip()]
def _ai_usable() -> bool:
"""ИИ-ветка доступна: полный выключатель включён и есть ключ/локальный сервер.
Локальные OpenAI-совместимые (Ollama/LM Studio) работают без ключа, поэтому
для них проверка ключа не требуется. Если статус провайдера прочитать нельзя
(нет БД/настроек) — считаем ИИ недоступным, чтобы не дёргать сеть впустую.
"""
if not store.get_setting("aiEnabled"):
return False
try:
status = ai_service.provider_status()
except Exception as exc: # noqa: BLE001 — отсутствие статуса = ИИ недоступен
log.debug("discovery eval: статус ИИ недоступен (%s) — эвристика", exc)
return False
return bool(status.get("local") or status.get("keySet"))
def _heuristic(task: dict, text: str) -> dict:
"""Эвристика: ключ задачи входит в очищенный текст (без учёта регистра)."""
hay = clean_short(text).casefold()
for kw in _keywords(task):
if kw and kw.casefold() in hay:
return {"fit": True, "reason": f'совпал ключ "{kw}"', "source": "heuristic"}
return {"fit": False, "reason": "нет совпадений с ключами", "source": "heuristic"}
def _ai_prompt(task: dict) -> str:
description = str(task.get("description") or "").strip()
return _AI_PROMPT.format(description=description, keywords=", ".join(_keywords(task)))
def _ai_fit(out: dict) -> bool:
"""fit из JSON-ответа ИИ: 1/true/«да»-подобные -> True, иначе False."""
v = out.get("fit")
if isinstance(v, str):
s = v.strip().casefold()
return bool(s) and s not in {"0", "false", "no", "нет", "null", "none"}
return bool(v)
def _ai_reason(out: dict) -> str:
reason = str(out.get("reason") or "").strip()
if not reason:
reason = "подходит" if _ai_fit(out) else "не подходит"
return reason[:_AI_REASON_LIMIT]
async def evaluate_message(task: dict, text: str) -> dict:
"""Оценка fit одного сообщения. Возвращает {"fit", "reason", "source"}."""
raw = str(text or "")
if len(raw.strip()) < _MIN_TEXT_LEN:
return {"fit": False, "reason": "слишком короткое", "source": "heuristic"}
# ML: уверенный спам отсекаем без обращения к ИИ (когда ML доступен)
if ml_client.is_enabled():
pred = await ml_client.predict(raw)
if pred.get("take") and pred.get("label") == "spam":
return {"fit": False, "reason": "ML: спам", "source": "ml"}
# ИИ: один JSON-вызов; любая ошибка провайдера -> шаг эвристики ниже
if _ai_usable():
try:
out = await ai_service.chat_json(_ai_prompt(task), f"Сообщение:\n{raw[:_AI_TEXT_LIMIT]}")
return {"fit": _ai_fit(out), "reason": _ai_reason(out), "source": "ai"}
except Exception as exc: # noqa: BLE001 — сбой ИИ не роняет оценку
log.debug("discovery eval: ИИ не ответил (%s) — эвристика", exc)
return _heuristic(task, raw)
async def evaluate_sample(task: dict, messages: list[dict]) -> dict:
"""Последовательная оценка всех сообщений выборки кандидата.
Возвращает {"fit_count", "total", "fit_ratio", "per_message": [...]} с
per_message [{"text", "fit", "reason", "topic_id"}] (topic_id None -> "main",
чтобы per_message стыковался с ключами group_by_topic).
"""
per_message = []
fit_count = 0
for m in messages or []:
text = str(m.get("text") or "")
res = await evaluate_message(task, text)
tid = m.get("topic_id")
per_message.append(
{
"text": text,
"fit": bool(res["fit"]),
"reason": str(res["reason"]),
"topic_id": _MAIN_TOPIC if tid is None else tid,
}
)
if res["fit"]:
fit_count += 1
total = len(per_message)
return {
"fit_count": fit_count,
"total": total,
"fit_ratio": (fit_count / total) if total else 0.0,
"per_message": per_message,
}
def passed(ev: dict, task: dict) -> bool:
"""Вердикт «источник подходит»: выборки >=3 сообщений и доля fit >= threshold (%).
Сообщения не обязаны быть «чистыми»: каналы часто разбавляют полезный
контент офтопом, поэтому достаточно доли, а не сплошного соответствия.
"""
total = int(ev.get("total") or 0)
ratio = float(ev.get("fit_ratio") or 0.0)
return total >= 3 and ratio * 100 >= int(task.get("threshold") or 0)
@@ -0,0 +1,484 @@
"""Discovery worker: поиск → оценка → авто-вступление (Task 6).
Фоновый цикл main._discovery_loop вызывает `tick()` каждые ~5 секунд; каждый
вызов выполняет ОДНО действие для самой старой running-задачи и возвращает
{"action": "search"|"review"|"skip"|"join"|"reject"|"flood"|"error"|"done"|"none",
"taskId": ...}. Если работы нет — {"action": "none"}.
Приоритеты внутри tick:
0. ban_guard.global_paused() — ручной стоп-кран: возвращаем none;
1. задача достигла плана вступлений (joined >= planJoins) → status=done
(+ лог done) — занимаемый ею бюджет планов освобождается сразу;
2. шаг поиска: search_done=False → следующий ключ keywords[search_idx],
tg.discovery_search, каждый результат — discovery.add_candidate,
discovery.advance_search; при переходе search_done=True — лог search
«поиск завершён: N кандидатов» (N = счётчик found задачи). Личные чаты/
боты (kind «чат») пропускаются (лог skip). FloodWaitError → note_flood +
лог flood; прочие ошибки поиска → лог error; индекс ключей не двигается
(тик повторит ключ позже), но после 3 ошибок подряд ключ пропускается
(advance_search + лог error «ключ пропущен») — битый ключ не должен
зацикливать поиск навсегда;
2a. флуд-стоп: пока действует flood-блокировка дня (ban_guard.flood_today())
discovery-воркер полностью стоит (никаких сетевых действий поиска/оценки/
вступлений) — это и есть «стоп до конца суток» из лога flood;
3. шаг оценки: первый кандидат status='new' → tg.discovery_info (участники,
kind; форум — если is_forum), фильтр minSubscribers (меньше — delete +
лог skip; не получено — метка), tg.discovery_read: история недоступна →
review с меткой («канал…»/«закрытая группа…», контент не оцениваем),
язык (для ru-задач), evaluate_sample (форумы — по темам через
group_by_topic) → passed → review с fitRatio/topics, иначе
delete_candidate + лог skip «мало подходящих (X из N)»;
4. авто-вступление (отдельный проход, приоритет ниже оценки): у running-
задачи с autoJoin и кандидатом status='review', если
ban_guard.can_auto_join() → повторная проверка «мы не состоим»
(dialogs/disc_blacklist — могли вступить между оценкой и join) → если
уже состоим/в чёрном списке — discovery.mark_rejected + лог;
ban_guard.wait_join_delay() (5070 с — спейсинг авто-вступлений), ПОСЛЕ
паузы кандидат перечитывается — join выполняется, только если запись
ещё есть и в review, задача ещё running с autoJoin и мы не состоим
(иначе — тик выходит без join);
tg.discovery_join(username) → discovery.mark_joined(auto=True) →
tg.add_dialog_monitored(...) → tg.backfill_dialog(dialog_id) →
discovery.remove_blacklist. FloodWaitError → ban_guard.note_flood()
+ лог flood (кандидат остаётся review); прочие ошибки join →
join_failures += 1, лог error, кандидат остаётся review для повтора
(ретраи ограничены: после 3-й неудачи кандидат удаляется, лог skip).
Метки кандидата (marks) — список строк-чипов:
«участники не подтверждены», «язык не подтверждён»,
«канал: история недоступна», «закрытая группа (история скрыта) — вступите сами»,
«мало сообщений».
Темы форума (topics) — список dict (заполняется только для kind='forum'):
{"topicId": str|int, "title": str, "fitCount": int, "total": int,
"fitRatio": float, "passed": bool}
fit каждой темы считается по своей выборке (evaluate_sample + passed);
общий вердикт форума — есть хотя бы одна проходная тема. fitRatio кандидата
— агрегат по всей выборке (fit из N). Для не-форумов topics не заполняется.
"""
from __future__ import annotations
import logging
import time
from contextlib import suppress
from telethon.errors.rpcerrorlist import FloodWaitError
from ..db import store
from . import ban_guard, discovery
from . import discovery_eval as eval_svc
from .telegram import tg
log = logging.getLogger("leadradar.discovery_worker")
_ACTION_NONE = {"action": "none"}
# минимальный объём содержательной выборки для вердикта оценки
_MIN_CONTENT = 3
# ошибки поиска одного ключа подряд, после которых ключ пропускается
_SEARCH_ERRORS_TO_SKIP = 3
# счётчик ошибок поиска по задачам (память процесса; при рестарте сбрасывается)
_search_errors: dict[str, int] = {}
# метки кандидата (marks) — чипы в UI
_MARK_PARTICIPANTS = "участники не подтверждены"
_MARK_LANG = "язык не подтверждён"
_MARK_CHANNEL_NO_HISTORY = "канал: история недоступна"
_MARK_CLOSED_GROUP = "закрытая группа (история скрыта) — вступите сами"
_MARK_FEW_MESSAGES = "мало сообщений"
# kind из Telegram (_kind_of: канал/группа/чат) → код кандидата (channel/group/forum)
_KIND_RU_TO_CODE = {"канал": "channel", "группа": "group", "чат": "group"}
def _now_ms() -> int:
return time.time_ns() // 1_000_000
def _kind_code(kind: str, is_forum: bool = False) -> str:
"""Нормализация kind источника: 'channel'|'group'|'forum' (см. _KIND_RU_TO_CODE)."""
if is_forum:
return "forum"
code = str(kind or "").strip().casefold()
if code in ("channel", "group", "forum"):
return code
return _KIND_RU_TO_CODE.get(code, "group")
def _running_tasks() -> list[dict]:
"""Running-задачи, старые первыми (list_tasks сортирует по created_at)."""
return [t for t in discovery.list_tasks() if t["status"] == "running"]
def _we_are_in(dialog_id: str) -> bool:
"""Уже состоим/отклонили: источник в dialogs или в чёрном списке.
Повторная проверка «мы не состоим» перед авто-вступлением (диалог мог
появиться между оценкой кандидата и join). dialog_id — подписанный peer id,
как в dialogs.id (конвенция discovery_search, Task 4).
"""
in_dialogs = store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id])
in_blacklist = store.scalar("SELECT 1 FROM disc_blacklist WHERE dialog_id = ? LIMIT 1", [dialog_id])
return bool(in_dialogs or in_blacklist)
def _finish_done(task: dict) -> None:
"""Задача выполнила план вступлений: status=done + лог done."""
store.execute(
"UPDATE disc_tasks SET status = 'done', updated_at = ? WHERE id = ?",
[_now_ms(), task["id"]],
)
discovery.add_log(
task["id"],
"done",
f"план выполнен: вступили {task['joined']} из {task['planJoins']}",
)
def _close_search(task_id: str) -> None:
"""Закрыть проход по ключам (пустой список ключей / индекс за границей)."""
discovery.advance_search(task_id)
task = discovery.get_task(task_id)
if task and task["searchDone"]:
discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов")
def _log_search_done(task_id: str) -> None:
"""Лог завершения поиска, если advance_search перевёл задачу в search_done."""
task = discovery.get_task(task_id)
if task and task["searchDone"]:
discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов")
def _finish_review(
task_id: str,
dialog_id: str,
*,
marks: list[str],
lang_ru: bool | None = None,
fit_ratio: float | None = None,
topics: list[dict] | None = None,
) -> None:
"""Перевести кандидата в review с метками/оценкой (статус пишет лог review)."""
patch: dict = {"marks": [m for m in marks if m]}
if lang_ru is not None:
patch["langRu"] = lang_ru
if fit_ratio is not None:
patch["fitRatio"] = fit_ratio
if topics is not None:
patch["topics"] = topics
discovery.set_candidate(task_id, dialog_id, patch)
discovery.set_candidate_status(dialog_id, "review")
# ─── шаги tick ─────────────────────────────────────────────────────────────
async def _search_step(task: dict) -> dict:
"""Шаг поиска: один ключ keywords[searchIdx] → кандидаты + advance_search."""
task_id = task["id"]
keywords = list(task.get("keywords") or [])
idx = int(task.get("searchIdx") or 0)
if not keywords or idx >= len(keywords):
# ключи закончились/пустой список: закрываем проход без сетевого вызова
_close_search(task_id)
return {"action": "search", "taskId": task_id}
keyword = keywords[idx]
try:
results = await tg.discovery_search(keyword)
except FloodWaitError:
# флуд: стоп авто-вступлений до конца суток; ключ не двигаем — повторим позже
ban_guard.note_flood()
discovery.add_log(task_id, "flood", f"поиск «{keyword}»: flood — стоп до конца суток")
return {"action": "flood", "taskId": task_id}
except Exception as exc: # noqa: BLE001 — сбой поиска не двигает индекс ключей
errors = _search_errors.get(task_id, 0) + 1
if errors >= _SEARCH_ERRORS_TO_SKIP:
# 3 ошибки подряд одного ключа: пропускаем (битый ключ не должен
# зацикливать поиск и блокировать оценку/вступления других задач)
_search_errors.pop(task_id, None)
discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc} — ключ пропущен ({errors} ошибки подряд)")
discovery.advance_search(task_id)
_log_search_done(task_id)
return {"action": "error", "taskId": task_id}
_search_errors[task_id] = errors
discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc}")
return {"action": "error", "taskId": task_id}
_search_errors.pop(task_id, None) # успешный поиск — сброс счётчика ошибок ключа
for item in results:
kind_raw = str(item.get("kind") or "").strip().casefold()
name = item.get("name") or ""
if kind_raw == "чат":
# люди/личные чаты и боты глобальным поиском не предлагаются
discovery.add_log(task_id, "skip", f"{name}: личный чат/бот")
continue
discovery.add_candidate(
task_id,
str(item.get("id") or ""),
name,
item.get("username") or "",
_kind_code(kind_raw),
item.get("hue") or "#666",
)
discovery.advance_search(task_id)
_log_search_done(task_id)
return {"action": "search", "taskId": task_id}
async def _eval_step(task: dict, cand: dict) -> dict:
"""Шаг оценки первого кандидата status='new' (все ветки — одно действие)."""
task_id = task["id"]
dialog_id = cand["dialogId"]
marks: list[str] = []
# ── инфо об источнике: kind/forum, участники, имя/username ────────────
info = await tg.discovery_info(dialog_id)
is_forum = bool(info.get("is_forum"))
resolved = info.get("kind") or is_forum
kind = _kind_code(info.get("kind", ""), is_forum) if resolved else (cand["kind"] or "channel")
cand_patch: dict = {
"kind": kind,
"hue": info.get("hue") or "",
"participants": info.get("participants"),
}
if resolved:
# имя/username обновляем только при успешном резолве: при fallback
# discovery_info возвращает name=dialog_id и не должен затирать имя
cand_patch["name"] = info.get("name") or ""
cand_patch["username"] = info.get("username") or ""
discovery.set_candidate(task_id, dialog_id, cand_patch)
participants = info.get("participants")
# ── фильтр minSubscribers ──────────────────────────────────────────────
min_sub = int(task.get("minSubscribers") or 0)
if min_sub > 0:
if participants is None:
marks.append(_MARK_PARTICIPANTS)
elif int(participants) < min_sub:
discovery.delete_candidate(dialog_id)
discovery.add_log(
task_id,
"skip",
f"{dialog_id}: мало участников ({participants} < {min_sub})",
)
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
# ── чтение истории для оценки ──────────────────────────────────────────
read = await tg.discovery_read(dialog_id, int(task.get("sampleSize") or 10))
if not read.get("ok"):
# история недоступна без членства: контент не оцениваем, фильтры помечаем
marks.append(_MARK_CHANNEL_NO_HISTORY if kind == "channel" else _MARK_CLOSED_GROUP)
if task.get("lang") == "ru":
marks.append(_MARK_LANG)
_finish_review(task_id, dialog_id, marks=marks)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
messages = read.get("messages") or []
# ── язык (только для ru-задач) ─────────────────────────────────────────
lang_ru: bool | None = None
if task.get("lang") == "ru":
lang_ru = eval_svc.detect_lang_ru([str(m.get("text") or "") for m in messages])
if lang_ru is False:
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: язык не русский")
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
if lang_ru is None:
marks.append(_MARK_LANG)
# ── объём выборки: меньше 3 содержательных — решает человек ────────────
if len(messages) < _MIN_CONTENT:
marks.append(_MARK_FEW_MESSAGES)
_finish_review(task_id, dialog_id, marks=marks, lang_ru=lang_ru)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
# ── оценка содержания (форумы — по темам) ──────────────────────────────
fit_count, total, fit_ratio, topics, ok = await _evaluate_content(task, kind, messages)
if ok:
_finish_review(
task_id,
dialog_id,
marks=marks,
lang_ru=lang_ru,
fit_ratio=fit_ratio,
topics=topics if kind == "forum" else None,
)
discovery.bump_counter(task_id, "evaluated")
return {"action": "review", "taskId": task_id}
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: мало подходящих ({fit_count} из {total})")
discovery.bump_counter(task_id, "evaluated")
return {"action": "skip", "taskId": task_id}
async def _evaluate_content(task: dict, kind: str, messages: list[dict]) -> tuple[int, int, float, list[dict], bool]:
"""evaluate_sample по выборке кандидата.
Не-форум: один прогон по всем сообщениям, topics пуст, вердикт — passed().
Форум: прогон по каждой теме (group_by_topic), topics заполняется
({topicId, title, fitCount, total, fitRatio, passed}), вердикт — есть хотя
бы одна проходная тема; fitRatio — агрегат fit из N по всей выборке.
"""
if kind == "forum":
topics: list[dict] = []
fit_count = 0
total = 0
any_passed = False
for group in eval_svc.group_by_topic(messages):
ev = await eval_svc.evaluate_sample(task, group["messages"])
t_ok = eval_svc.passed(ev, task)
fit_count += int(ev.get("fit_count") or 0)
total += int(ev.get("total") or 0)
topics.append(
{
"topicId": group["topic_id"],
"title": group["title"] or "",
"fitCount": int(ev.get("fit_count") or 0),
"total": int(ev.get("total") or 0),
"fitRatio": float(ev.get("fit_ratio") or 0.0),
"passed": bool(t_ok),
}
)
any_passed = any_passed or bool(t_ok)
return fit_count, total, (fit_count / total) if total else 0.0, topics, any_passed
ev = await eval_svc.evaluate_sample(task, messages)
return (
int(ev.get("fit_count") or 0),
int(ev.get("total") or 0),
float(ev.get("fit_ratio") or 0.0),
[],
eval_svc.passed(ev, task),
)
async def _join_step(task: dict, cand: dict) -> dict:
"""Шаг авто-вступления одного кандидата status='review'."""
task_id = task["id"]
dialog_id = cand["dialogId"]
# между оценкой и вступлением могли вступить/отклонить источник
if _we_are_in(dialog_id):
already = bool(store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id]))
reason = (
"уже вступили между оценкой и авто-вступлением"
if already
else "источник в чёрном списке (повторная проверка перед авто-вступлением)"
)
with suppress(KeyError, ValueError):
discovery.mark_rejected(dialog_id, reason=reason)
return {"action": "reject", "taskId": task_id}
# спейсинг авто-вступлений (сек из настроек discJoinDelayMin/Max)
await ban_guard.wait_join_delay()
# за время паузы задача/кандидат/состояние BanGuard могли измениться:
# вступаем только если кандидат всё ещё есть и в review, задача ещё running
# с autoJoin, мы не состоим и авто-вступления по-прежнему разрешены (стоп-
# кран/flood/лимит могли включиться во время паузы) — иначе выходим без join
fresh = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
task_now = discovery.get_task(task_id)
if (
fresh is None
or fresh["status"] != "review"
or task_now is None
or task_now["status"] != "running"
or not task_now["autoJoin"]
or _we_are_in(dialog_id)
or not ban_guard.can_auto_join()
):
return _ACTION_NONE
username = str(fresh.get("username") or "").strip().lstrip("@")
try:
await tg.discovery_join(username)
except FloodWaitError:
ban_guard.note_flood() # идемпотентно: discovery_join тоже фиксирует флуд
discovery.add_log(task_id, "flood", f"авто-вступление {dialog_id}: flood — стоп до конца суток")
return {"action": "flood", "taskId": task_id}
except Exception as exc: # noqa: BLE001 — ретраи ограничены счётчиком join_failures
# между паузой и неудачным join кандидата могли отклонить/удалить:
# счётчик и удаление трогаем только у живой записи в статусе review
row_now = store.query_one(
"SELECT status FROM disc_candidates WHERE dialog_id = ?", [dialog_id]
)
if not row_now or row_now["status"] != "review":
return _ACTION_NONE
failures = int(fresh.get("join_failures") or 0) + 1
store.execute(
"UPDATE disc_candidates SET join_failures = ?, updated_at = ? "
"WHERE dialog_id = ? AND status = 'review'",
[failures, _now_ms(), dialog_id],
)
if failures >= 3:
discovery.delete_candidate(dialog_id)
discovery.add_log(task_id, "skip", f"{dialog_id}: не удалось вступить (3 попытки): {exc}")
return {"action": "skip", "taskId": task_id}
discovery.add_log(task_id, "error", f"авто-вступление {dialog_id}: {exc}")
return {"action": "error", "taskId": task_id}
discovery.mark_joined(dialog_id, auto=True)
tg.add_dialog_monitored(
dialog_id,
fresh.get("name"),
username,
fresh.get("kind"),
fresh.get("hue"),
)
# разбор последних сообщений источника (спейсинг/read-ack внутри метода);
# вступление уже состоялось — сбой backfill не роняет шаг
try:
await tg.backfill_dialog(dialog_id)
except Exception as exc: # noqa: BLE001
log.warning("join %s: backfill не удался: %s", dialog_id, exc)
discovery.remove_blacklist(dialog_id)
return {"action": "join", "taskId": task_id}
# ─── tick ──────────────────────────────────────────────────────────────────
async def tick() -> dict:
"""Одно действие discovery-воркера (см. docstring модуля)."""
if ban_guard.global_paused():
return _ACTION_NONE
if ban_guard.flood_today():
# флуд-блокировка дня: никаких сетевых действий (поиск/оценка/join),
# пока действует discFloodDay — воркер просто стоит
return _ACTION_NONE
running = _running_tasks()
if not running:
return _ACTION_NONE
# 1. план достигнут — закрываем задачу (важно до поиска/оценки/join:
# задачу с выполненным планом нельзя продолжать обрабатывать)
for task in running:
if int(task["joined"]) >= int(task["planJoins"]):
_finish_done(task)
return {"action": "done", "taskId": task["id"]}
# 2. поиск: следующая running-задача с незавершённым проходом по ключам
for task in running:
if not task["searchDone"]:
return await _search_step(task)
# 3. оценка: первый кандидат status='new' (самая старая задача — первой)
for task in running:
new_cands = discovery.list_candidates(task["id"], status="new")
if new_cands:
return await _eval_step(task, new_cands[0])
# 4. авто-вступление: отдельный проход, приоритет ниже оценки
for task in running:
if not task["autoJoin"]:
continue
review_cands = discovery.list_candidates(task["id"], status="review")
if review_cands:
if not ban_guard.can_auto_join():
return _ACTION_NONE # суточный лимит/флуд/пауза — join никому нельзя
return await _join_step(task, review_cands[0])
return _ACTION_NONE
@@ -0,0 +1,100 @@
"""Вложения проектных карточек.
По решению: файлы хранятся в MinIO (креды в env), в БД — метаданные и ключ
объекта. Тип определяется автоматически по MIME и расширению.
"""
from __future__ import annotations
import json
import time
from ..db import store
KIND_BY_EXT = {
"image": {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "heic"},
"video": {"mp4", "mov", "avi", "mkv", "webm", "m4v"},
"audio": {"mp3", "wav", "ogg", "m4a", "flac", "aac"},
"archive": {"zip", "rar", "7z", "tar", "gz", "bz2"},
"document": {"pdf", "doc", "docx", "xls", "xlsx", "csv", "txt", "md", "rtf", "ppt", "pptx", "odt", "ods"},
}
KIND_LABELS = {
"image": "Изображение",
"video": "Видео",
"audio": "Аудио",
"archive": "Архив",
"document": "Документ",
"other": "Файл",
}
def detect(name: str, mime: str = "") -> dict:
ext = (name.split(".")[-1] if "." in name else "").lower()
kind = "other"
if mime.startswith("image/"):
kind = "image"
elif mime.startswith("video/"):
kind = "video"
elif mime.startswith("audio/"):
kind = "audio"
else:
for k, exts in KIND_BY_EXT.items():
if ext in exts:
kind = k
break
return {"kind": kind, "label": KIND_LABELS[kind]}
def _files_of(card_id: str) -> list[dict]:
from .projects import patch_card
row = store.query_one("SELECT files FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
return json.loads(row["files"] or "[]")
def add_file(card_id: str, name: str, data: bytes, mime: str = "") -> dict:
"""Сохраняет тело в MinIO и метаданные в карточку."""
from . import object_store
from .projects import patch_card
files = _files_of(card_id)
info = detect(name, mime)
object_key = object_store.put(card_id, name, data, mime)
entry = {
"id": store.uid("pf_"),
"name": name,
"size": len(data),
"kind": info["kind"],
"label": info["label"],
"objectKey": object_key,
}
files.append(entry)
patch_card(card_id, {"files": files})
return entry
def get_file_entry(card_id: str, file_id: str) -> tuple[dict, list[dict]]:
files = _files_of(card_id)
entry = next((f for f in files if f["id"] == file_id), None)
if not entry:
raise KeyError(file_id)
return entry, files
def remove_file(card_id: str, file_id: str) -> None:
from . import object_store
from .projects import patch_card
files = _files_of(card_id)
entry = next((f for f in files if f["id"] == file_id), None)
if entry and entry.get("objectKey"):
object_store.remove(entry["objectKey"])
patch_card(card_id, {"files": [f for f in files if f["id"] != file_id]})
def object_storage_available() -> bool:
from . import object_store
return object_store.configured()
@@ -0,0 +1,103 @@
"""Полнотекстовый поиск DuckDB FTS (по решению — включаем).
DuckDB FTS строит виртуальную таблицу-снимок: индекс пересоздаётся при
старте, раз в сутки по расписанию и вручную (POST /api/admin/fts/rebuild).
Чтобы свежесозданные карточки искались сразу, поиск совмещает FTS с
точечным LIKE-дополнением (см. services/leads.search).
Рабочий синтаксис DuckDB >= 1.0: только PRAGMA create_fts_index
(вариант CREATE VIRTUAL TABLE ... USING fts(...) в этой версии падает).
Поиск — через табличную функцию fts_main_<table>.match_bm25(rowid, query).
"""
from __future__ import annotations
import logging
from ..db import store
log = logging.getLogger("leadradar.fts")
OK_KEY = "ftsOk"
# Источник, колонка-rowid, текстовые колонки (должны совпадать со схемой db.py)
_FTS_TARGETS = [
("leads", ("title", "summary", "source_msg", "contact")),
("messages", ("text",)),
("rejected_msgs", ("text",)),
]
def is_ready() -> bool:
return bool(store.get_setting(OK_KEY))
def _set(ok: bool) -> None:
store.set_setting(OK_KEY, ok)
def install_extension() -> bool:
try:
store.execute("INSTALL fts")
store.execute("LOAD fts")
return True
except Exception as exc: # noqa: BLE001
log.warning("FTS extension unavailable: %s", exc)
return False
def rebuild() -> bool:
"""Пересоздаёт FTS-индексы по leads и messages (overwrite поверх старого).
PRAGMA не поддерживает prepared-параметры, поэтому имена подставляются
напрямую — это внутренние константы из _FTS_TARGETS, не пользовательский ввод.
"""
try:
for table, cols in _FTS_TARGETS:
col_list = ", ".join(f"'{c}'" for c in cols)
store.execute(
f"PRAGMA create_fts_index('{table}', 'id', {col_list}, "
"stemmer='russian', overwrite=1)"
)
_set(True)
log.info("FTS rebuild done")
return True
except Exception as exc: # noqa: BLE001
log.warning("FTS rebuild failed: %s", exc)
_set(False)
return False
def search(query: str, limit: int = 50) -> dict:
"""Возвращает {'leads': [ids], 'messages': [ids], 'rejected': [ids]} по FTS
(пусто при сбое)."""
out: dict = {"leads": [], "messages": [], "rejected": []}
if not is_ready():
return out
q = _sanitize(query)
if not q:
return out
targets = ("leads", "messages", "rejected_msgs")
keys = {"leads": "leads", "messages": "messages", "rejected_msgs": "rejected"}
for table in targets:
try:
# fts_main_<table> создаётся PRAGMA create_fts_index над таблицей
fn = f"fts_main_{table}.match_bm25(id, ?)"
rows = store.query(
f"SELECT id, {fn} AS _s FROM {table} "
f"WHERE {fn} IS NOT NULL ORDER BY _s LIMIT ?",
[q, q, limit * 2],
)
out[keys[table]] = [r["id"] for r in rows]
except Exception as exc: # noqa: BLE001
log.warning("fts %s query failed: %s", table, exc)
return out
def _sanitize(query: str) -> str:
"""Минимальная очистка запроса от служебных символов FTS."""
import re
q = query.strip().lower()
q = re.sub(r'["\'\\()!*+-]', " ", q)
q = " ".join(q.split())
return q[:80]
@@ -0,0 +1,551 @@
"""Доски, колонки и карточки дашборда (п.4.4, 4.5, 4.7 ТЗ).
Сюда же входят правила хранения (автоархив/очистка) и обучающие примеры
(действия пользователя -> learning_log).
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from .. import constants as C
from ..db import store
from ..sse import broker
from . import fts as fts_svc
from . import ml_client
from . import processing as processing_svc
from .pipeline import (
build_contacts,
clean_block,
clean_short,
compose_summary,
lead_to_dict,
normalize_stack,
primary_contact,
qualify_contact,
)
from .rules import board_accepts, extract_amounts, has_active_rules, hits_for_board
log = logging.getLogger("leadradar.leads")
KANBAN_COLS = ("inbox",) # + доски
def _now() -> int:
return time.time_ns() // 1_000_000
def _log_learning(lead_id: str, action: str, from_col: str | None, to_col: str | None) -> None:
store.execute(
"INSERT INTO learning_log(id, lead_id, action, from_col, to_col, created_at) VALUES (?, ?, ?, ?, ?, ?)",
[store.uid("lm_"), lead_id, action, from_col, to_col, _now()],
)
# ─── Доски / колонки ──────────────────────────────────────────────────────
def list_boards() -> list[dict]:
rows = store.query("SELECT * FROM boards ORDER BY suggested, pos")
return [
{
"id": r["id"],
"name": r["name"],
"description": r["description"] or "",
"color": r["color"],
"width": r["width"],
"collapsed": bool(r["collapsed"]),
"keywords": json.loads(r["keywords"] or "[]"),
"prompt": r["prompt"] or "",
"visibleFields": json.loads(r["visible_fields"] or "[]"),
"suggested": bool(r["suggested"]),
"rules": json.loads(r["rules"] or "{}"),
"note": r["note"] or "",
}
for r in rows
]
def board_by_id(board_id: str) -> dict | None:
return next((b for b in list_boards() if b["id"] == board_id), None)
def create_board(
name: str,
color: str | None = None,
keywords: list | None = None,
prompt: str = "",
description: str = "",
suggested: bool = False,
rules: dict | None = None,
note: str = "",
) -> dict:
"""Создаёт колонку. suggested=TRUE — ИИ-предложение, ждёт решения пользователя."""
board_id = store.uid("b_")
pos = int(store.scalar("SELECT COALESCE(MAX(pos), -1) + 1 FROM boards"))
store.execute(
"INSERT INTO boards(id, name, description, color, width, pos, keywords, prompt, visible_fields, collapsed, suggested, rules, note, created_at) "
"VALUES (?, ?, ?, ?, 'md', ?, ?, ?, '[\"budget\",\"stack\",\"contacts\"]', FALSE, ?, ?, ?, ?)",
[
board_id,
name.strip() or "Новая колонка",
(description or "").strip(),
color or C.PALETTE[pos % len(C.PALETTE)],
pos,
json.dumps(keywords or [], ensure_ascii=False),
prompt or "",
bool(suggested),
json.dumps(rules or {}, ensure_ascii=False),
note or "",
_now(),
],
)
return {"id": board_id}
def patch_board(board_id: str, patch: dict) -> dict:
row = store.query_one("SELECT * FROM boards WHERE id = ?", [board_id])
if not row:
raise KeyError(board_id)
allowed = {"name", "description", "color", "width", "collapsed", "prompt", "suggested", "note"}
for key in allowed:
if key in patch and patch[key] is not None:
store.execute(f"UPDATE boards SET {key} = ? WHERE id = ?", [patch[key], board_id])
if "keywords" in patch and patch["keywords"] is not None:
store.execute("UPDATE boards SET keywords = ? WHERE id = ?", [json.dumps(patch["keywords"], ensure_ascii=False), board_id])
if "visibleFields" in patch and patch["visibleFields"] is not None:
store.execute("UPDATE boards SET visible_fields = ? WHERE id = ?", [json.dumps(patch["visibleFields"], ensure_ascii=False), board_id])
if "rules" in patch and patch["rules"] is not None:
store.execute("UPDATE boards SET rules = ? WHERE id = ?", [json.dumps(patch["rules"], ensure_ascii=False), board_id])
return {"id": board_id}
def delete_board(board_id: str) -> int:
"""Карточки доски уходят в «Неразобранное» (с пометкой новых)."""
leads = store.query("SELECT id FROM leads WHERE col = ?", [board_id])
for l in leads:
store.execute("UPDATE leads SET col = 'inbox', is_new = TRUE, prev_col = 'inbox' WHERE id = ?", [l["id"]])
store.execute("DELETE FROM boards WHERE id = ?", [board_id])
return len(leads)
def reorder_boards(order: list[str]) -> None:
for i, board_id in enumerate(order):
store.execute("UPDATE boards SET pos = ? WHERE id = ?", [i, board_id])
def get_col_state() -> dict:
return store.get_setting("colState") or {}
def set_col_state(col_id: str, state: dict) -> dict:
current = store.get_setting("colState") or {}
current[col_id] = state
store.set_setting("colState", current)
return current[col_id]
# ─── Лиды ─────────────────────────────────────────────────────────────────
def list_leads(col: str | None = None) -> list[dict]:
if col:
rows = store.query("SELECT id FROM leads WHERE col = ? ORDER BY received_at DESC", [col])
else:
rows = store.query("SELECT id FROM leads WHERE col NOT IN ('taken') ORDER BY received_at DESC")
return [lead_to_dict(r["id"]) for r in rows]
def get_lead(lead_id: str) -> dict | None:
return lead_to_dict(lead_id) or None
def _move(lead_id: str, to_col: str, action: str = "move") -> None:
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
if not lead or lead["col"] == to_col:
return
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
# при переносе пересчитываем «почему карточка в колонке» (для архив/корзина — пусто)
hits = hits_for_board(to_col, text) if to_col not in ("inbox", "trash", "archive") else []
store.execute(
"UPDATE leads SET col = ?, is_new = FALSE, prev_col = ?, match_hits = ? WHERE id = ?",
[to_col, lead["col"], json.dumps(hits, ensure_ascii=False), lead_id],
)
_log_learning(lead_id, action, lead["col"], to_col)
def move_lead(lead_id: str, to_col: str, teach: bool = True) -> None:
"""Перенос между канбаном (Неразобранное и доски); архив/корзина не цели переноса.
teach=False — «тихое» перемещение без обучения (используется при ручной
разметке в ML-лаборатории, где обучение кладётся явно одним событием).
"""
if to_col not in ("inbox",) and store.query_one("SELECT 1 FROM boards WHERE id = ?", [to_col]) is None:
raise ValueError("Переносить можно только на доски или в «Неразобранное»")
lead = store.query_one("SELECT source_msg, title, col FROM leads WHERE id = ?", [lead_id])
_move(lead_id, to_col)
# ML обучается всегда: текст -> выбранная доска
if teach and lead and to_col != "inbox" and to_col != lead["col"]:
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
if text:
ml_client.push(text, to_col)
def trash_lead(lead_id: str, teach: bool = True) -> None:
lead = store.query_one("SELECT source_msg, title, col FROM leads WHERE id = ?", [lead_id])
_move(lead_id, "trash", action="trash")
# «в корзину» = спам/не то: ML запоминает (обучение всегда)
if teach and lead and lead["col"] != "trash" and lead["col"] != "archive":
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
if text:
ml_client.push(text, "spam")
def restore_lead(lead_id: str) -> str:
"""Возврат из архива/корзины — только на канбан."""
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
if not lead:
raise KeyError(lead_id)
back = lead["prev_col"] if lead["prev_col"] in ("inbox",) or store.query_one("SELECT 1 FROM boards WHERE id = ?", [lead["prev_col"]]) else "inbox"
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
hits = hits_for_board(back, text) if back not in ("inbox", "trash", "archive") else []
store.execute(
"UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox', archived_at = NULL, match_hits = ? WHERE id = ?",
[back, json.dumps(hits, ensure_ascii=False), lead_id],
)
_log_learning(lead_id, "restore", lead["col"], back)
# возврат из корзины = не спам: снимаем метку
if lead["col"] == "trash":
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
if text:
ml_client.push(text, "spam", delta=-1.0)
return back
def _hard_delete(lead_id: str) -> None:
"""Полное удаление карточки: leads + dedup (иначе «сирота» заблокирует
повторное создание той же карточки при перечитывании) + отвязка исходника."""
store.execute("DELETE FROM leads WHERE id = ?", [lead_id])
store.execute("DELETE FROM dedup WHERE lead_id = ?", [lead_id])
store.execute("UPDATE messages SET lead_id = NULL WHERE lead_id = ?", [lead_id])
def delete_forever(lead_id: str) -> None:
_hard_delete(lead_id)
def clear_col(col: str) -> int:
"""Полная ручная очистка служебной колонки (корзина/архив) — безвозвратно."""
if col not in ("trash", "archive"):
raise ValueError("Очищать можно только корзину или архив")
ids = [r["id"] for r in store.query("SELECT id FROM leads WHERE col = ?", [col])]
if not ids:
return 0
store.execute("DELETE FROM leads WHERE col = ?", [col])
for lead_id in ids:
_hard_delete(lead_id)
return len(ids)
def mark_seen(lead_id: str | None = None, col: str | None = None) -> None:
if lead_id:
store.execute("UPDATE leads SET is_new = FALSE WHERE id = ?", [lead_id])
elif col:
store.execute("UPDATE leads SET is_new = FALSE WHERE col = ?", [col])
else:
store.execute("UPDATE leads SET is_new = FALSE")
def add_comment(lead_id: str, text: str) -> list[dict]:
lead = store.query_one("SELECT comments FROM leads WHERE id = ?", [lead_id])
comments = json.loads(lead["comments"] or "[]")
comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"})
store.execute("UPDATE leads SET comments = ? WHERE id = ?", [json.dumps(comments, ensure_ascii=False), lead_id])
_log_learning(lead_id, "comment", None, None)
return comments
def counts() -> dict:
"""Счётчики по колонкам, новые + статистика ML/ИИ (локальная, без HTTP)."""
out = {"new": 0}
rows = store.query("SELECT col, count(*) AS cnt, sum(CASE WHEN is_new THEN 1 ELSE 0 END) AS fresh FROM leads GROUP BY col")
for r in rows:
out[r["col"]] = {"count": r["cnt"], "new": r["fresh"] or 0}
out["new"] = sum((v["new"] for k, v in out.items() if isinstance(v, dict)), 0)
snap = ml_client.snapshot()
out["learning"] = snap["learning"]
out["ml"] = snap["ml"]
out["ai"] = snap["ai"]
return out
# ─── Пакетная переклассификация (Inbox) ──────────────────────────────────
# Фоновая задача переклассификации (одна; повторный вызов возвращает busy)
_reclassify_task: object | None = None
def reclassify_busy() -> bool:
return bool(_reclassify_task and not _reclassify_task.done())
async def reclassify_lead(lead_id: str) -> dict | None:
"""Прогнать карточку «Неразобранного» через полный конвейер ИИ.
Этап 2 (ИИ-фильтр) + классификатор; мусор/спам/служебные сообщения
отправляются в корзину (с обучением ML). Вернувшееся None — карточка
без исходного текста или не найденная.
"""
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
if not lead or not lead["source_msg"]:
return None
from .ai import budget_to_target, classify, clean_budget, filter_incoming
text = lead["source_msg"]
try:
r2 = await filter_incoming(text)
except Exception as exc: # noqa: BLE001
log.warning("reclassify filter fail %s: %s", lead_id, exc)
r2 = {"pass": True, "reason": None, "skipped": True}
if not r2["pass"]:
trash_lead(lead_id, teach=False)
ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT)
return {"status": "trashed", "reason": str(r2.get("reason") or "не прошло ИИ-фильтр")[:120]}
raw = await classify(text)
if raw.get("is_spam"):
trash_lead(lead_id, teach=False)
ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT)
return {"status": "trashed", "reason": "ИИ: не заявка/спам"}
board_id = str(raw.get("board") or "").strip() or None
# страховка: колонку с активными правилами может назначить только текст,
# прошедший эти правила (иначе ручная переклассификация закидывает хлам)
if board_id and not board_accepts(board_id, text):
board_id = None
budget = clean_budget(raw.get("budget"))
contacts = build_contacts(raw.get("contacts"), text)
contact = primary_contact(contacts)[:200]
if not contact:
# старый контакт оставляем только если он валидный (@, телефон, почта…),
# а не мусорная фраза из старого разбора
old = str(lead["contact"] or "").strip()[:200]
contact = old if old and qualify_contact(old) else ""
stack = normalize_stack(raw.get("stack"))
new_title = clean_short(raw.get("title") or "", 140) or clean_short(lead["title"], 140)
new_summary = clean_block(compose_summary(raw, text), 2000) or clean_block(lead["summary"], 2000)
if not budget:
# ИИ не выделил бюджет полем, но сумма с валютой есть в исходнике или
# в структурированной «О заявке» — показываем её на карточке.
for src in (text, new_summary):
amts = extract_amounts(src or "")
if amts:
a = amts[0]
budget = {"from": a["from"], "to": a["to"], "currency": a["cur"]}
break
conv = budget_to_target(budget)
hits = hits_for_board(board_id, text) if board_id else []
store.execute(
"UPDATE leads SET col = ?, title = ?, summary = ?, is_vacancy = ?, is_vacancy_known = TRUE, "
"stack = ?, budget_from = ?, budget_to = ?, budget_cur = ?, "
"conv_from = ?, conv_to = ?, conv_cur = ?, contact = ?, contacts = ?, "
"match_hits = ?, is_new = TRUE "
"WHERE id = ?",
[
board_id or "inbox",
new_title,
new_summary,
bool(raw.get("is_vacancy")),
json.dumps(stack, ensure_ascii=False),
budget.get("from") if budget else None,
budget.get("to") if budget else None,
budget.get("currency", "") if budget else "",
conv["convFrom"], conv["convTo"], conv["convCur"],
contact,
json.dumps(contacts, ensure_ascii=False),
json.dumps(hits, ensure_ascii=False),
lead_id,
],
)
# ИИ-решение при переклассификации — тоже обучающий сигнал для ML.
# Учим только «свободные» колонки (без активных правил, не suggested):
# именно их ML может назначать сама в своём пути.
if board_id:
br = store.query_one("SELECT suggested, rules FROM boards WHERE id = ?", [board_id])
free = False
if br and not bool(br["suggested"]):
try:
br_rules = json.loads(br["rules"] or "{}") if br["rules"] else {}
except Exception: # noqa: BLE001
br_rules = {}
free = not has_active_rules(br_rules)
if free:
ml_client.push(text, board_id, delta=ml_client.AI_WEIGHT)
# тип известен от ИИ — учим ML определять его сам (t:hire / t:order)
if not bool(raw.get("is_spam")):
ml_client.push(
text,
"t:hire" if bool(raw.get("is_vacancy")) else "t:order",
delta=ml_client.AI_WEIGHT,
)
return {"status": "moved" if board_id else "kept"}
async def reclassify_inbox(ids: list[str] | None = None) -> dict:
"""Переклассифицировать «Неразобранное» (все карточки или выбранные)."""
rows = store.query("SELECT id FROM leads WHERE col = 'inbox'")
if ids:
wanted = set(ids)
target = [r["id"] for r in rows if r["id"] in wanted]
else:
target = [r["id"] for r in rows]
res = {"attempted": len(target), "kept": 0, "moved": 0, "trashed": 0}
for lead_id in target:
try:
out = await reclassify_lead(lead_id)
except Exception as exc: # noqa: BLE001
log.warning("reclassify %s failed: %s", lead_id, exc)
continue
if not out:
continue
status = out.get("status")
if status == "trashed":
res["trashed"] += 1
elif status == "moved":
res["moved"] += 1
else:
res["kept"] += 1
await broker.publish("leads_reclassified", res)
return res
async def start_reclassify(ids: list[str] | None = None) -> dict:
"""Запустить переклассификацию в фоне (одна задача за раз)."""
global _reclassify_task
if not store.get_setting("aiEnabled"):
return {"started": False, "busy": False, "attempted": 0, "reason": "ИИ выключен — переклассификация недоступна"}
if reclassify_busy():
return {"started": False, "busy": True}
rows = store.query("SELECT id FROM leads WHERE col = 'inbox'")
if ids:
wanted = set(ids)
target = [r["id"] for r in rows if r["id"] in wanted]
else:
target = [r["id"] for r in rows]
if not target:
return {"started": False, "busy": False, "attempted": 0}
async def _run() -> None:
try:
res = await reclassify_inbox(ids)
await broker.publish_toast(
f"Переклассификация готова: {res['trashed']} в корзину, "
f"{res['moved']} в колонки, {res['kept']} осталось в «Неразобранном»",
"sparkles",
)
except Exception as exc: # noqa: BLE001
log.warning("reclassify task error: %s", exc)
await broker.publish_toast("Переклассификация завершилась с ошибкой", "x")
_reclassify_task = asyncio.create_task(_run())
return {"started": True, "busy": False, "attempted": len(target)}
# ─── Правила хранения (тик раз в 30 секунд) ───────────────────────────────
def tick_storage() -> dict:
auto = bool(store.get_setting("autoArchive"))
after_days = int(store.get_setting("archiveAfterDays") or 14)
archive_clear = int(store.get_setting("archiveClearDays") or 90)
trash_clear = int(store.get_setting("trashClearDays") or 7)
now = _now()
archived = purged_arch = purged_trash = 0
if auto:
rows = store.query(
"SELECT id FROM leads WHERE col IN (SELECT id FROM boards UNION ALL SELECT 'inbox') "
"AND received_at < ?",
[now - after_days * C.DAY_MS],
)
for r in rows:
store.execute(
"UPDATE leads SET col = 'archive', is_new = FALSE, archived_at = ? WHERE id = ?",
[now, r["id"]],
)
archived += 1
old_arch = store.query("SELECT id FROM leads WHERE col = 'archive' AND archived_at IS NOT NULL AND archived_at < ?", [now - archive_clear * C.DAY_MS])
for r in old_arch:
_hard_delete(r["id"])
purged_arch += 1
old_trash = store.query("SELECT id FROM leads WHERE col = 'trash' AND received_at < ?", [now - trash_clear * C.DAY_MS])
for r in old_trash:
_hard_delete(r["id"])
purged_trash += 1
# отсев пайплайна живёт 3 суток, дальше удаляется автоматически
purged_rejected = processing_svc.purge_expired()
return {
"archived": archived,
"purgedArchive": purged_arch,
"purgedTrash": purged_trash,
"purgedRejected": purged_rejected,
}
async def notify_tick_stats(stats: dict) -> None:
if stats["archived"]:
await broker.publish_toast(f"Автоархив: {stats['archived']} карточек", "clock")
if stats["purgedArchive"]:
await broker.publish_toast(f"Архив очищен: {stats['purgedArchive']} (90 дн.)", "trash")
if stats["purgedTrash"]:
await broker.publish_toast(f"Корзина очищена: {stats['purgedTrash']} (7 дн.)", "trash")
if stats.get("purgedRejected"):
await broker.publish_toast(f"Отсев очищен: {stats['purgedRejected']} записей (3 дн.)", "trash")
# ─── Поиск ────────────────────────────────────────────────────────────────
def search(q: str, limit: int = 12) -> dict:
qq = q.strip().lower()
if len(qq) < 2:
return {"leads": [], "messages": []}
pattern = f"%{qq}%"
# FTS-кандидаты (снимок индекса)
fts_ids: list[str] = []
if fts_svc.is_ready():
try:
fts_ids = fts_svc.search(qq, limit=limit)["leads"]
except Exception: # noqa: BLE001
fts_ids = []
# LIKE-дополнение (свежие записи после последнего rebuild)
like_ids = [
r["id"]
for r in store.query(
"SELECT id FROM leads WHERE col != 'taken' AND ("
" lower(title) LIKE ? OR lower(summary) LIKE ? OR lower(contact) LIKE ? OR lower(source_msg) LIKE ?) "
"ORDER BY received_at DESC LIMIT ?",
[pattern, pattern, pattern, pattern, limit * 2],
)
]
merged: list[str] = []
for bid in [*fts_ids, *like_ids]:
if bid not in merged:
merged.append(bid)
leads = []
for lid in merged:
d = lead_to_dict(lid)
if d and d["col"] != "taken":
leads.append(d)
if len(leads) >= limit:
break
msgs = store.query(
"SELECT id, dialog_id, text, msg_at FROM messages WHERE lower(text) LIKE ? ORDER BY msg_at DESC LIMIT 20",
[pattern],
)
return {"leads": leads, "messages": msgs}
@@ -0,0 +1,162 @@
"""Клиент автономного ML-сервиса + локальный outbox обучения.
Основное приложение НИКОГДА не обучает ML напрямую «в своей» базе: каждое
действие пользователя синхронно пишется в таблицу ml_outbox, а фоновый
воркер (main._ml_sync_loop) отправляет накопленное в ML-сервис батчами.
Использование ML в пайплайне включается настройкой `mlEnabled`; обучение
идёт всегда.
"""
from __future__ import annotations
import logging
import time
import httpx
from .. import config
from ..db import store
log = logging.getLogger("leadradar.mlclient")
DECISIONS_ML = "mlDecisions"
DECISIONS_AI = "aiDecisions"
# Веса обучающих сигналов: действия пользователя — истина (1.0), решения ИИ —
# гипотезы (меньше), чтобы реальные действия со временем перевешивали ошибки ИИ.
USER_WEIGHT = 1.0
AI_WEIGHT = 0.4
RULE_WEIGHT = 0.6
# кэш статуса сервиса (обновляется фоновым циклом; живёт не дольше 15 c)
_cached: dict = {"at": 0, "data": None, "reachable": False}
def _now() -> int:
return time.time_ns() // 1_000_000
# ─── Обучение: всегда пишем в outbox ──────────────────────────────────────
def push(text: str, label: str, delta: float = 1.0) -> None:
"""Действие пользователя -> событие обучения (гарантированно, локально)."""
text = (text or "").strip()
label = str(label or "").strip()
if not text or not label:
return
store.execute(
"INSERT INTO ml_outbox(id, text, label, delta, created_at) VALUES (?, ?, ?, ?, ?)",
[store.uid("mle_"), text[:6000], label, delta, _now()],
)
def outbox_len() -> int:
return int(store.scalar("SELECT count(*) FROM ml_outbox") or 0)
async def flush_outbox(batch: int = 100) -> int:
"""Отправляет накопленные события в ML-сервис небольшими порциями.
Один learn-batch из сотен сообщений надолго блокирует ML-сервис
(модель пишет каждый термин отдельным INSERT) и упирается в таймаут;
порции по 20 строк проходят быстро и не роняют сервис.
"""
chunk = 10
total = 0
while total < batch:
rows = store.query(
"SELECT id, text, label, delta FROM ml_outbox ORDER BY created_at LIMIT ?",
[chunk],
)
if not rows:
break
items = [{"text": r["text"], "label": r["label"], "delta": r["delta"]} for r in rows]
try:
await _post("/learn-batch", {"items": items}, timeout=config.ML_TIMEOUT + 10)
except Exception as exc: # noqa: BLE001
log.warning("ml outbox flush failed (%d rows): %s", len(rows), exc)
break
ids = [r["id"] for r in rows]
store.execute(f"DELETE FROM ml_outbox WHERE id IN ({','.join('?' * len(ids))})", ids)
total += len(rows)
log.info("ml outbox flushed: %d", len(rows))
return total
# ─── HTTP ─────────────────────────────────────────────────────────────────
async def _post(path: str, body: dict, timeout: float | None = None) -> dict:
async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client:
resp = await client.post(config.ML_URL + path, json=body)
resp.raise_for_status()
return resp.json()
async def _get(path: str, timeout: float | None = None) -> dict:
async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client:
resp = await client.get(config.ML_URL + path)
resp.raise_for_status()
return resp.json()
async def predict(text: str) -> dict:
"""Предсказание. При сбое/недоступности сервиса — «не уверен» (решит ИИ)."""
try:
return await _post("/predict", {"text": (text or "")[:6000]})
except Exception as exc: # noqa: BLE001
log.debug("ml predict unavailable: %s", exc)
return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": False}
async def reset_model() -> dict:
"""Полный сброс ML-модели + очистка очереди обучения.
Старая модель (в т.ч. «мусорные» классы удалённых колонок) стирается,
события обучения из outbox тоже удаляются — иначе они сразу «переобучат»
модель на старых данных.
"""
try:
await _post("/reset", {}, timeout=30)
except Exception as exc: # noqa: BLE001
log.warning("ml reset failed: %s", exc)
return {"ok": False, "error": str(exc)}
store.execute("DELETE FROM ml_outbox")
await refresh_status()
return {"ok": True}
async def refresh_status() -> dict:
global _cached
try:
data = await _get("/status", timeout=3)
_cached = {"at": _now(), "data": data, "reachable": True}
except Exception as exc: # noqa: BLE001
log.debug("ml status unavailable: %s", exc)
_cached = {"at": _now(), "data": _cached["data"], "reachable": False}
return _cached["data"] or {}
def snapshot() -> dict:
"""Локальная статистика + последний известный статус ML-сервиса (без HTTP)."""
fresh = _cached["data"] or {}
return {
"ml": int(store.get_setting(DECISIONS_ML) or 0),
"ai": int(store.get_setting(DECISIONS_AI) or 0),
"learning": int(store.scalar("SELECT count(*) FROM learning_log") or 0),
"ready": bool(fresh.get("ready")),
"classes": fresh.get("classes", {}),
"learned": int(fresh.get("learned") or 0),
"reachable": _cached["reachable"],
"outbox": outbox_len(),
}
def track_decisions(ml: int = 0, ai: int = 0) -> None:
if ml:
store.set_setting(DECISIONS_ML, int(store.get_setting(DECISIONS_ML) or 0) + ml)
if ai:
store.set_setting(DECISIONS_AI, int(store.get_setting(DECISIONS_AI) or 0) + ai)
def is_enabled() -> bool:
"""Использовать ли ML в пайплайне (настройка UI). Обучение — всегда."""
return store.get_setting("mlEnabled") is not False and _cached["reachable"]
@@ -0,0 +1,108 @@
"""Хранение вложений: MinIO (S3-совместимое), креды в env.
Если MinIO не настроен (локальная разработка без docker-compose), объекты
сохраняются в локальную папку DATA_DIR/attachments — API и карточки при этом
не меняются, в БД хранится только ключ объекта.
"""
from __future__ import annotations
import io
import logging
from pathlib import Path
from .. import config
log = logging.getLogger("leadradar.objectstore")
def configured() -> bool:
return bool(config.MINIO_ENDPOINT and config.MINIO_ACCESS_KEY and config.MINIO_SECRET_KEY)
_client = None
_client_checked = False
def client():
"""MinIO-клиент (создаётся лениво). Бросает ошибку, если не настроен."""
global _client, _client_checked
if _client is not None:
return _client
if not configured():
raise RuntimeError(
"MinIO не настроен: задайте LEADRADAR_MINIO_ENDPOINT / _ACCESS_KEY / _SECRET_KEY / _BUCKET"
)
from minio import Minio
from minio.error import S3Error
_client = Minio(
config.MINIO_ENDPOINT,
access_key=config.MINIO_ACCESS_KEY,
secret_key=config.MINIO_SECRET_KEY,
secure=config.MINIO_SECURE,
)
if not _client_checked:
_client_checked = True
try:
if not _client.bucket_exists(config.MINIO_BUCKET):
_client.make_bucket(config.MINIO_BUCKET)
except S3Error as exc:
log.warning("bucket check failed: %s", exc)
return _client
def _local_path(object_key: str) -> Path:
# object_key вида projects/{card_id}/{ts}_{name}; не даём выйти за пределы FILES_DIR
safe = Path(object_key).name
parent = Path(object_key).parent
return (config.FILES_DIR / parent / safe).resolve()
def put(card_id: str, name: str, data: bytes, content_type: str) -> str:
"""Сохраняет объект (MinIO или локально), возвращает objectKey."""
import time
object_key = f"projects/{card_id}/{int(time.time() * 1000)}_{name}"
if configured():
client().put_object(
config.MINIO_BUCKET,
object_key,
io.BytesIO(data),
length=len(data),
content_type=content_type or "application/octet-stream",
)
else:
path = _local_path(object_key)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
log.info("MinIO не настроен — файл сохранён локально: %s", path)
return object_key
def get(object_key: str):
"""Возвращает поток (бинарный) для скачивания."""
if configured():
try:
return client().get_object(config.MINIO_BUCKET, object_key)
except Exception as exc: # noqa: BLE001
log.warning("minio get failed: %s", exc)
raise
path = _local_path(object_key)
if not path.is_file():
raise FileNotFoundError(object_key)
return io.BytesIO(path.read_bytes())
def remove(object_key: str) -> None:
if configured():
try:
client().remove_object(config.MINIO_BUCKET, object_key)
except Exception as exc: # noqa: BLE001
log.warning("minio remove failed: %s", exc)
return
try:
path = _local_path(object_key)
if path.is_file():
path.unlink()
except Exception as exc: # noqa: BLE001
log.warning("local remove failed: %s", exc)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,320 @@
"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев).
Очередь — сырые сообщения из каналов, ждущие разбора (pipeline_msg).
Отсев — сообщения, отброшенные на любом этапе: стоп-фразы/резюме/тип заявки/
без суммы (source='stop'), устарело ('stale'), ML ('ml'), ИИ ('ai'), повтор
('dup'). Для каждой записи храним этап, причину и конкретное слово/фразу
(kw), если отсев по стоп-списку.
Автоочистка отсева — раз в 3 суток (вызывается из leads.tick_storage),
плюс ручная очистка и удаление отдельных записей из UI.
"""
from __future__ import annotations
import logging
import time
from ..db import store
from . import fts as fts_svc
log = logging.getLogger("leadradar.processing")
# отсев живёт 3 суток, дальше удаляется автоматически
RETENTION_DAYS = 3
# человекочитаемые подписи этапов (для UI; source хранится отдельно)
_STAGE_LABELS = {
"length": "короткое сообщение",
"stop": "стоп-фраза",
"resume": "резюме соискателя",
"type": "тип заявки",
"budget": "нет суммы",
"stale": "устарело",
"spam_ml": "спам (ML)",
"spam_ai": "спам (ИИ)",
"filter_ai": "ИИ-фильтр",
"dup": "повтор",
}
# «чьё» решение: используется в UI как источник метки
_SOURCE_LABELS = {
"stop": "правила",
"ml": "ML",
"ai": "ИИ",
"stale": "система",
"dup": "система",
}
DEFAULT_LIMIT = 100
MAX_LIMIT = 500
def _now() -> int:
return time.time_ns() // 1_000_000
def stage_label(stage: str) -> str:
return _STAGE_LABELS.get(stage, stage or "отсев")
def source_label(source: str) -> str:
return _SOURCE_LABELS.get(source, source or "система")
# ─── Запись отсева ────────────────────────────────────────────────────────
def record(row: dict, source: str, stage: str, reason: str, kw: str = "") -> None:
"""Сохраняет отброшенное сообщение в таблицу отсева.
Ид записи детерминирован по (dialog_id, msg_id): повторное отбрасывание
того же сообщения (перечитывание каналов) обновляет запись, а не копит
дубликаты в списке отсева.
"""
if not row or not (row.get("text") or "").strip():
return
msg_id = row.get("msg_id")
dialog_id = row.get("dialog_id") or ""
rid = f"r_{dialog_id}_{msg_id}" if (msg_id is not None and dialog_id) else store.uid("r_")
now = _now()
store.execute(
"INSERT INTO rejected_msgs(id, dialog_id, msg_id, text, ch_name, ch_handle, ch_hue, stage, reason, kw, source, msg_at, rejected_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET "
"text = excluded.text, ch_name = excluded.ch_name, ch_handle = excluded.ch_handle, "
"ch_hue = excluded.ch_hue, stage = excluded.stage, reason = excluded.reason, kw = excluded.kw, "
"source = excluded.source, msg_at = excluded.msg_at, rejected_at = excluded.rejected_at",
[
rid,
dialog_id,
msg_id,
str(row["text"])[:6000],
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
stage,
str(reason or "")[:500],
str(kw or "")[:200],
source,
row.get("msg_at"),
now,
],
)
def purge_expired(days: int = RETENTION_DAYS) -> int:
"""Автоочистка отсева: записи старше N суток удаляются безвозвратно."""
cut = _now() - days * 24 * 3600 * 1000
rows = store.query(
"SELECT id FROM rejected_msgs WHERE rejected_at < ?", [cut]
)
if not rows:
return 0
ids = [r["id"] for r in rows]
store.execute(
"DELETE FROM rejected_msgs WHERE id IN (" + ",".join(["?"] * len(ids)) + ")",
ids,
)
return len(ids)
def clear_all() -> int:
rows = store.query("SELECT count(*) AS c FROM rejected_msgs")
total = int(rows[0]["c"]) if rows else 0
if total:
store.execute("DELETE FROM rejected_msgs")
return total
def return_to_queue(rej_id: str, reason: str = "") -> dict:
"""Вернуть отсеянное сообщение в обработку (кнопка в «Обработке»).
Строка очереди помечается force: этап 1, устарело, ML-решения и ИИ-отсев
для неё игнорируются — сообщение уходит на классификацию и создаёт карточку.
Запись в отсеве не удаляется, а помечается «возвращено» с причиной (аудит).
Если отсев был по решению «спам» (ML/ИИ) — снимаем у ML вес спама для текста.
"""
row = store.query_one("SELECT * FROM rejected_msgs WHERE id = ?", [rej_id])
if not row:
raise KeyError(rej_id)
if bool(row.get("returned")):
raise ValueError("Сообщение уже возвращено в обработку")
if str(row.get("source") or "") == "dup":
raise ValueError("Повтор: карточка с таким текстом уже есть в системе — возвращать нечего")
dialog_id = str(row.get("dialog_id") or "")
msg_id = row.get("msg_id")
text = str(row.get("text") or "").strip()
if not text:
raise ValueError("В записи нет текста сообщения")
if str(row.get("stage") or "") in ("spam_ml", "spam_ai", "filter_ai"):
from . import ml_client
# реальное действие пользователя: этот текст НЕ спам
ml_client.push(text, "spam", delta=-1.0)
now = _now()
store.execute(
"UPDATE rejected_msgs SET returned = TRUE, returned_at = ?, return_reason = ? WHERE id = ?",
[now, str(reason or "").strip()[:500], rej_id],
)
from .pipeline import enqueue # локальный импорт: pipeline импортирует processing
if dialog_id and msg_id is not None:
enqueue(
dialog_id,
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
msg_id,
text,
row.get("msg_at") or now,
force=True,
)
else:
# старые записи (до сохранения dialog_id/msg_id): текст сохранился,
# возвращаем без ссылки на исходное сообщение (force=True)
store.execute(
"INSERT INTO pipeline_msg(id, dialog_id, ch_name, ch_handle, ch_hue, text, msg_id, msg_at, status, force, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', TRUE, ?, ?)",
[
store.uid("p_"),
dialog_id,
str(row.get("ch_name") or ""),
str(row.get("ch_handle") or ""),
str(row.get("ch_hue") or "#666"),
text[:6000],
msg_id,
row.get("msg_at") or now,
now,
now,
],
)
return {"id": rej_id, "returned": True, "returnedAt": now}
def delete_one(rej_id: str) -> bool:
store.execute("DELETE FROM rejected_msgs WHERE id = ?", [rej_id])
return True
def rejected_count() -> int:
return int(store.scalar("SELECT count(*) FROM rejected_msgs") or 0)
# ─── Очередь (pipeline_msg) ───────────────────────────────────────────────
def queue_counts() -> dict:
out = {"new": 0, "ai": 0}
for r in store.query("SELECT status, count(*) AS c FROM pipeline_msg GROUP BY status"):
if r["status"] == "new":
out["new"] = int(r["c"])
elif r["status"] == "filtered":
out["ai"] = int(r["c"])
out["total"] = out["new"] + out["ai"]
return out
def list_queue(limit: int = DEFAULT_LIMIT) -> list[dict]:
limit = min(max(1, limit), MAX_LIMIT)
rows = store.query(
"SELECT * FROM pipeline_msg ORDER BY created_at LIMIT ?", [limit]
)
items = []
for r in rows:
items.append(
{
"id": r["id"],
"dialogId": r.get("dialog_id") or "",
"msgId": r.get("msg_id"),
"text": r["text"],
"status": r["status"], # new | filtered
"ch": {
"name": r["ch_name"],
"handle": r["ch_handle"],
"hue": r["ch_hue"],
},
"msgAt": r["msg_at"],
"queuedAt": r["created_at"],
}
)
return items
# ─── Отсев (rejected_msgs) ────────────────────────────────────────────────
def list_rejected(q: str = "", offset: int = 0, limit: int = DEFAULT_LIMIT) -> dict:
offset = max(0, offset)
limit = min(max(1, limit), MAX_LIMIT)
qq = (q or "").strip().lower()
ids: list[str] = []
if qq:
# FTS-кандидаты + LIKE-дополнение (свежие записи после последнего rebuild)
if fts_svc.is_ready():
try:
ids = fts_svc.search(qq, limit=limit)["rejected"]
except Exception: # noqa: BLE001
ids = []
pattern = f"%{qq}%"
like = store.query(
"SELECT id FROM rejected_msgs WHERE "
"lower(text) LIKE ? OR lower(reason) LIKE ? OR lower(kw) LIKE ? OR lower(ch_name) LIKE ? "
"ORDER BY rejected_at DESC LIMIT ?",
[pattern, pattern, pattern, pattern, limit * 2],
)
for r in like:
if r["id"] not in ids:
ids.append(r["id"])
total = len(ids) # итог по условию поиска (все кандидаты)
page = ids[offset : offset + limit]
else:
total = rejected_count()
page_rows = store.query(
"SELECT id FROM rejected_msgs ORDER BY rejected_at DESC LIMIT ? OFFSET ?",
[limit, offset],
)
page = [r["id"] for r in page_rows]
by_id: dict[str, dict] = {}
if page:
ph = ",".join(["?"] * len(page))
rows = store.query(
f"SELECT * FROM rejected_msgs WHERE id IN ({ph})", page
)
for r in rows:
by_id[r["id"]] = r
items = []
for rid in page:
r = by_id.get(rid)
if not r:
continue
items.append(
{
"id": r["id"],
"dialogId": r.get("dialog_id") or "",
"msgId": r.get("msg_id"),
"text": r["text"],
"stage": r["stage"],
"stageLabel": stage_label(r["stage"]),
"reason": r["reason"],
"kw": r["kw"],
"source": r["source"],
"sourceLabel": source_label(r["source"]),
"ch": {"name": r["ch_name"], "handle": r["ch_handle"], "hue": r.get("ch_hue") or "#666"},
"msgAt": r["msg_at"],
"rejectedAt": r["rejected_at"],
"returned": bool(r.get("returned")),
"returnedAt": r.get("returned_at"),
"returnReason": r.get("return_reason") or "",
}
)
return {"items": items, "total": total, "offset": offset, "limit": limit}
def stats() -> dict:
q = queue_counts()
return {
"queue": q,
"rejected": rejected_count(),
}
@@ -0,0 +1,282 @@
"""«Выбранные» — проектный канбан (п.4.8 ТЗ).
Карточка живёт только здесь: лид уходит с дашборда безвозвратно (col='taken'),
в архив/корзину проектные карточки не попадают. Есть история движения,
вложения (файлы сейчас мета-мок, MinIO позже), ссылки, ТЗ и напоминания
для стадии «Отложено».
"""
from __future__ import annotations
import json
import logging
import time
from .. import constants as C
from ..db import store
from ..sse import broker
log = logging.getLogger("leadradar.projects")
STAGES = {s["id"] for s in C.PIPELINE_STAGES}
def _now() -> int:
return time.time_ns() // 1_000_000
def _json(value: list, default: str = "[]") -> str:
return json.dumps(value or [], ensure_ascii=False) if value is not None else default
def _row_to_card(row: dict) -> dict:
history = json.loads(row["history"] or "[]")
return {
"id": row["id"],
"stage": row["stage"],
"local": bool(row["local"]),
"leadId": row["lead_id"],
"title": row["title"],
"summary": row["summary"],
"stack": json.loads(row["stack"] or "[]"),
"budget": (
{"from": row["budget_from"], "to": row["budget_to"], "cur": row["budget_cur"]}
if row["budget_cur"]
else None
),
"contact": row["contact"],
"comments": json.loads(row["comments"] or "[]"),
"links": json.loads(row["links"] or "[]"),
"files": json.loads(row["files"] or "[]"),
"tzText": row["tz_text"],
"history": history,
"reminder": {"at": row["reminder_at"]} if row["reminder_at"] else None,
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
}
def list_cards(stage: str | None = None) -> list[dict]:
if stage:
rows = store.query("SELECT * FROM projects WHERE stage = ? ORDER BY updated_at DESC", [stage])
else:
rows = store.query("SELECT * FROM projects ORDER BY updated_at DESC")
return [_row_to_card(r) for r in rows]
def get_card(card_id: str) -> dict | None:
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
return _row_to_card(row) if row else None
def _insert(row_fields: dict) -> dict:
now = _now()
card_id = row_fields["id"]
store.execute(
"INSERT INTO projects(id, stage, local, lead_id, title, summary, stack, "
"budget_from, budget_to, budget_cur, contact, comments, links, files, tz_text, "
"history, reminder_at, reminder_fired, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, FALSE, ?, ?)",
[
card_id,
row_fields.get("stage", "planned"),
bool(row_fields.get("local")),
row_fields.get("lead_id"),
row_fields.get("title", ""),
row_fields.get("summary", ""),
_json(row_fields.get("stack")),
row_fields.get("budget_from"),
row_fields.get("budget_to"),
row_fields.get("budget_cur", ""),
row_fields.get("contact", ""),
_json(row_fields.get("comments")),
_json(row_fields.get("links")),
_json(row_fields.get("files")),
row_fields.get("tz_text", ""),
_json(row_fields.get("history")),
now,
now,
],
)
return get_card(card_id)
def create_local_card(data: dict) -> dict:
"""Ручное создание карточки — «локальная» (без лида)."""
budget = data.get("budget") if isinstance(data.get("budget"), dict) else None
return _insert(
{
"id": store.uid("pr_"),
"stage": data.get("stage") if data.get("stage") in STAGES else "planned",
"local": True,
"title": str(data.get("title", "")).strip(),
"summary": str(data.get("summary", "")),
"stack": data.get("stack") or [],
"budget_from": budget.get("from") if budget else None,
"budget_to": budget.get("to") if budget else None,
"budget_cur": budget.get("cur", "") if budget else "",
"contact": str(data.get("contact", "")),
"comments": data.get("comments") or [],
"links": data.get("links") or [],
"files": data.get("files") or [],
"tz_text": str(data.get("tzText", "")),
"history": [{"id": store.uid("h_"), "at": _now(), "type": "createdLocal"}],
}
)
def take_lead_to_projects(lead_id: str) -> dict:
"""«Взять в работу»: лид уходит с дашборда, создаётся проектная карточка."""
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
if not lead:
raise KeyError(lead_id)
existing = store.query_one("SELECT id FROM projects WHERE lead_id = ?", [lead_id])
if existing:
return get_card(existing["id"])
budget = {"from": lead["budget_from"], "to": lead["budget_to"], "cur": lead["budget_cur"]} if lead["budget_cur"] else None
card = _insert(
{
"id": store.uid("pr_"),
"stage": "planned",
"local": False,
"lead_id": lead_id,
"title": lead["title"],
"summary": lead["summary"],
"stack": json.loads(lead["stack"] or "[]"),
"budget_from": budget["from"] if budget else None,
"budget_to": budget["to"] if budget else None,
"budget_cur": budget["cur"] if budget else "",
"contact": lead["contact"],
"comments": [{"id": store.uid("cm_"), "by": "Вы", "text": "Взял в работу из лида.", "time": "только что"}],
"tz_text": "",
"history": [{"id": store.uid("h_"), "at": _now(), "type": "created"}],
}
)
# эксклюзивность: на дашборде лида больше нет, возврата нет
store.execute("UPDATE leads SET col = 'taken', is_new = FALSE WHERE id = ?", [lead_id])
return card
def patch_card(card_id: str, patch: dict) -> dict:
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
simple = {
"title": "title",
"summary": "summary",
"contact": "contact",
"tz_text": "tzText",
}
for col, key in simple.items():
if key in patch:
store.execute(f"UPDATE projects SET {col} = ? WHERE id = ?", [str(patch[key]), card_id])
if "stack" in patch:
store.execute("UPDATE projects SET stack = ? WHERE id = ?", [_json(patch["stack"]), card_id])
if "budget" in patch:
b = patch["budget"] if isinstance(patch["budget"], dict) else None
store.execute(
"UPDATE projects SET budget_from = ?, budget_to = ?, budget_cur = ? WHERE id = ?",
[b.get("from") if b else None, b.get("to") if b else None, b.get("cur", "") if b else "", card_id],
)
if "comments" in patch:
store.execute("UPDATE projects SET comments = ? WHERE id = ?", [_json(patch["comments"]), card_id])
if "links" in patch:
store.execute("UPDATE projects SET links = ? WHERE id = ?", [_json(patch["links"]), card_id])
if "files" in patch:
store.execute("UPDATE projects SET files = ? WHERE id = ?", [_json(patch["files"]), card_id])
_bump(card_id)
return get_card(card_id)
def _bump(card_id: str) -> None:
store.execute("UPDATE projects SET updated_at = ? WHERE id = ?", [_now(), card_id])
def add_comment(card_id: str, text: str) -> list[dict]:
row = store.query_one("SELECT comments FROM projects WHERE id = ?", [card_id])
comments = json.loads(row["comments"] or "[]")
comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"})
patch_card(card_id, {"comments": comments})
return comments
def move_stage(card_id: str, stage: str) -> dict:
if stage not in STAGES:
raise ValueError("Неизвестная стадия")
row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id])
if not row:
raise KeyError(card_id)
history = json.loads(row["history"] or "[]")
now = _now()
store.execute(
"UPDATE projects SET stage = ?, reminder_at = NULL, reminder_fired = FALSE, updated_at = ? WHERE id = ?",
[stage, now, card_id],
)
history.append({"id": store.uid("h_"), "at": now, "stage": stage})
store.execute("UPDATE projects SET history = ? WHERE id = ?", [_json(history), card_id])
return get_card(card_id)
def remove_card(card_id: str) -> None:
store.execute("DELETE FROM projects WHERE id = ?", [card_id])
def clear_stage(stage: str) -> int:
"""Полная ручная очистка стадии «Отклонено» (терминальные не восстанавливаются)."""
if stage not in ("rejected",):
raise ValueError("Очистка разрешена только для стадии «Отклонено»")
rows = store.query("SELECT id FROM projects WHERE stage = ?", [stage])
if not rows:
return 0
store.execute("DELETE FROM projects WHERE stage = ?", [stage])
return len(rows)
# ─── Напоминания стадии «Отложено» ────────────────────────────────────────
def set_reminder(card_id: str, at: int) -> dict:
if not store.get_setting("remindersEnabled"):
raise PermissionError("Напоминания об отложенных выключены в настройках")
store.execute(
"UPDATE projects SET reminder_at = ?, reminder_fired = FALSE, updated_at = ? WHERE id = ?",
[at, _now(), card_id],
)
return get_card(card_id)
def clear_reminder(card_id: str) -> None:
store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE id = ?", [card_id])
def active_reminders() -> list[dict]:
rows = store.query(
"SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL ORDER BY reminder_at"
)
return [{"id": r["id"], "title": r["title"], "at": r["reminder_at"]} for r in rows]
def snooze(card_id: str, ms: int = C.DAY_MS) -> None:
store.execute(
"UPDATE projects SET reminder_at = ?, reminder_fired = FALSE WHERE id = ?",
[_now() + ms, card_id],
)
async def check_reminders() -> list[dict]:
"""Наступившие напоминания -> события. Если выключено — чистим протухшие."""
if not store.get_setting("remindersEnabled"):
# протухшие напоминания не храним: при включении старые не «выстрелят»
store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE reminder_at IS NOT NULL AND reminder_at <= ?", [_now()])
return []
now = _now()
rows = store.query(
"SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL "
"AND reminder_fired = FALSE AND reminder_at <= ?",
[now],
)
due = []
for r in rows:
store.execute("UPDATE projects SET reminder_fired = TRUE WHERE id = ?", [r["id"]])
due.append({"id": r["id"], "title": r["title"], "stage": r["stage"]})
for item in due:
await broker.publish("reminder_due", item)
return due
@@ -0,0 +1,130 @@
"""Курсы валют: источник ЦБ РФ, 4 запроса в сутки (каждые 6 часов).
По ТЗ до подключения сервиса используются мок-курсы; источник выбирается
в настройках (cbr | mock). Значения хранятся в БД вместе с временем
обновления и выдаются наружу для вкладки «Валюта и курсы».
"""
from __future__ import annotations
import json
import logging
import time
import httpx
from .. import constants as C
from ..db import store
log = logging.getLogger("leadradar.rates")
_FETCH_INTERVAL_MS = 6 * C.HOUR_MS # 4 раза в сутки
def get_rates() -> dict:
row = store.query_one("SELECT rates, source, updated_at FROM rates WHERE id = 1")
rates = json.loads(row["rates"]) if row else dict(C.MOCK_RATES)
return {
"base": "RUB",
"rates": rates,
"source": row["source"] if row else "mock",
"updatedAt": row["updated_at"] if row else None,
}
def save_rates(rates: dict, source: str) -> None:
store.execute(
"INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET rates = excluded.rates, source = excluded.source, "
"updated_at = excluded.updated_at",
[json.dumps(rates), source, time.time_ns() // 1_000_000],
)
async def fetch_cbr() -> dict | None:
"""Запрос к ЦБ РФ (JSON-зеркало daily_json.js). Возвращает rates к RUB."""
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(C.CBR_URL)
resp.raise_for_status()
payload = resp.json()
rates: dict = {"RUB": 1.0}
for code, item in payload.get("Valute", {}).items():
# 1 единица валюты в рублях: Nominal может быть > 1
nominal = int(item.get("Nominal", 1)) or 1
value = float(item.get("Value", 0))
rates[code] = round(value / nominal, 6)
return rates
except Exception as exc: # noqa: BLE001
log.warning("CBR fetch failed: %s", exc)
return None
async def refresh_rates(force_source: str | None = None) -> bool:
"""Ручное/фоновое обновление. Возвращает True при успехе (или при мок-режиме)."""
source = force_source or store.get_setting("rateSource") or "cbr"
if source == "mock":
save_rates(dict(C.MOCK_RATES), "mock")
recompute_conversions()
return True
rates = await fetch_cbr()
if rates is None:
return False
save_rates(rates, "cbr")
recompute_conversions()
return True
def should_fetch() -> bool:
row = store.query_one("SELECT updated_at, source FROM rates WHERE id = 1")
if not row:
return True
if row["source"] == "mock" and store.get_setting("rateSource") == "cbr":
return True
return time.time_ns() // 1_000_000 - row["updated_at"] >= _FETCH_INTERVAL_MS
def _resolve_rate(rates: dict, code: str) -> float | None:
"""USDT приравниваем к USD (у ЦБ нет тикера USDT)."""
if code == "USDT" and "USD" in rates:
return float(rates["USD"])
val = rates.get(code)
return float(val) if val is not None else None
def convert_amount(amount: float | int | None, from_cur: str, to_cur: str) -> float | None:
"""amount в from_cur -> to_cur по актуальным курсам (к рублю)."""
if amount is None:
return None
rates = json.loads(store.query_one("SELECT rates FROM rates WHERE id = 1")["rates"])
rf = _resolve_rate(rates, from_cur)
rt = _resolve_rate(rates, to_cur)
if rf is None or rt is None:
return None
return round(float(amount) * rf / rt, 2)
def recompute_conversions() -> int:
"""Пересчёт старых карточек по актуальному курсу и целевой валюте.
Пересчитываются карточки на канбане и в «Неразобранном»; архивные,
корзинные и ушедшие в «Выбранные» (taken) не трогаем.
"""
if not store.get_setting("conversionOn"):
return 0
target = store.get_setting("targetCurrency") or "RUB"
rows = store.query(
"SELECT id, budget_from, budget_to, budget_cur FROM leads "
"WHERE budget_cur <> '' AND col NOT IN ('archive','trash','taken')",
)
updated = 0
for r in rows:
cf = convert_amount(r["budget_from"], r["budget_cur"], target)
ct = convert_amount(r["budget_to"] if r["budget_to"] is not None else r["budget_from"], r["budget_cur"], target)
if cf is None:
continue
store.execute(
"UPDATE leads SET conv_from = ?, conv_to = ?, conv_cur = ? WHERE id = ?",
[cf, ct, target, r["id"]],
)
updated += 1
return updated
@@ -0,0 +1,390 @@
"""Детерминированные правила колонок: направление, стек, слова, грейд, бюджет.
Колонка — это набор опциональных фильтров, задаёт пользователь (или ИИ при
предложении). Если текст входящего сообщения соответствует правилам — карточка
уходит в эту колонку сразу, без ML/ИИ (ML/ИИ подключаются только когда правила
не сработали). Набор фильтров может меняться: пустая группа не участвует.
"""
from __future__ import annotations
import json
import re
from ..db import store
_CUR_SYMBOLS = {"$": "USD", "": "EUR", "": "RUB", "": "USDT", "£": "GBP", "¥": "CNY"}
_CUR_WORDS = {"usd": "USD", "eur": "EUR", "rub": "RUB", "usdt": "USDT", "gbp": "GBP", "cny": "CNY", "руб": "RUB", "долл": "USD", "бакс": "USD"}
# Грейд/уровень: тег из правил расширяется синонимами, чтобы «middle» находил
# и «mid», и «мидл», а «сеньор» находил senior и т.п.
_GRADE_ALIASES = {
"junior": ["junior", "джун", "джуниор"],
"middle": ["middle", "mid", "мидл"],
"senior": ["senior", "сеньор", "сеньйор"],
"lead": ["lead", "тимлид", "тиэмлид", "team lead", "teamlead", "tech lead", "техлид"],
"architect": ["architect", "архитектор"],
"intern": ["intern", "стажёр", "стажер", "trainee"],
}
def _grade_terms(tags: list[str]) -> list[str]:
out: list[str] = []
for t in tags:
key = str(t).strip().lower()
if not key:
continue
if key in _GRADE_ALIASES:
out.extend(_GRADE_ALIASES[key])
else:
out.append(key)
return out
# Перед матчингом правил вырезаем ссылки и markdown-ссылки: иначе фильтр ловит
# слова из трекерных хвостов/служебных строк URL (например, «desktop» в
# utm_medium=member_desktop) и в колонку попадает мусор, не имеющий отношения
# к содержанию сообщения.
_MD_URL_RE = re.compile(r"\[[^\]]*\]\([^)\s]+\)")
_RAW_URL_RE = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+")
def content_text(text: str) -> str:
s = str(text or "")
s = _MD_URL_RE.sub(" ", s)
return _RAW_URL_RE.sub(" ", s)
# ищем: 1) "от 1 200 до 1 500 $", 2) "1 2001 500 $ / 1 200$", 3) "$1 2001 500"
# суффикс «к/К» разрешён прямо в числе: «2к», «1.5к$» (множитель в _norm_amount)
_AMT = r"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?"
_RANGE = rf"({_AMT})\s*(?:[-–—]\s*({_AMT}))?"
def _norm_amount(raw: str) -> float | None:
s = raw.replace("\u00a0", " ").replace(" ", "").replace(",", ".")
mult = 1.0
# «2к»/«2К»/«1.5к» — тысячи; суффикс убираем до float (иначе парс падает)
if s and s[-1].lower() in ("k", "к"):
mult = 1000.0
s = s[:-1]
try:
v = float(s) * mult
except ValueError:
return None
return v
def _cur_from_tail(text: str, m_start: int) -> str | None:
tail = text[m_start : m_start + 12].lower().strip()
for sym, code in _CUR_SYMBOLS.items():
if tail.startswith(sym):
return code
# слово-валюта сразу после числа (с пробелом)
for word, code in _CUR_WORDS.items():
if tail.startswith(word):
return code
# валюта перед числом ($/€/₽), например "$1 200"
head = text[max(0, m_start - 3) : m_start].strip()
for sym, code in _CUR_SYMBOLS.items():
if head.endswith(sym):
return code
return None
def extract_amounts(text: str) -> list[dict]:
"""Парсер сумм с сохранением смысла:
- диапазон «от A до B» / «A–B» → {'from': A, 'to': B, 'cur': ...};
- «до B» (только верхняя граница) → {'from': None, 'to': B, 'cur': ...};
- одна сумма / «от A» без верхней границы → {'from': A, 'to': A, 'cur': ...}.
Валюту ищем сразу после суммы (или перед ней для «$1 200»); суммы без валюты игнорируются.
"""
out: list[dict] = []
t = text or ""
if not t.strip():
return out
occupied: list[tuple[int, int]] = []
def _free(s: int, e: int) -> bool:
return not any(s < oe and e > os for os, oe in occupied)
def _add(a, b, cur: str | None, s: int, e: int) -> None:
if cur and (a is not None or b is not None) and _free(s, e):
out.append({"from": a, "to": b, "cur": cur})
occupied.append((s, e))
# 1) словесный диапазон «от A до B» (+ валюта после B)
for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")\s+до\s+(" + _AMT + r")", t):
a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2))
cur = _cur_from_tail(t, m.end(2))
if a is not None and b is not None and cur:
_add(a, b, cur, m.start(1), m.end(2))
# 2) «до B» (без пары «от … до») — верхняя граница
for m in re.finditer(r"(?i)\bдо\s+(" + _AMT + r")", t):
b = _norm_amount(m.group(1))
cur = _cur_from_tail(t, m.end(1))
if b is not None and cur:
_add(None, b, cur, m.start(1), m.end(1))
# 3) «от A» без верхней границы — считаем одной суммой
for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")", t):
a = _norm_amount(m.group(1))
cur = _cur_from_tail(t, m.end(1))
if a is not None and cur:
_add(a, a, cur, m.start(1), m.end(1))
# 4) числовые диапазоны «A–B» / «A - B»
for m in re.finditer(r"(?i)(" + _AMT + r")\s*(?:[-–—]|\s+до\s+)\s*(" + _AMT + r")", t):
a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2))
cur = _cur_from_tail(t, m.end(2)) or _cur_from_tail(t, m.end(1))
if a is not None and b is not None and cur:
_add(a, b, cur, m.start(1), m.end(2))
# 5) одиночные суммы с валютой (не вошедшие в конструкции выше)
for m in re.finditer(_AMT, t):
a = _norm_amount(m.group(0))
cur = _cur_from_tail(t, m.end())
if a is not None and cur:
_add(a, a, cur, m.start(), m.end())
return out
def _amount_in_range(amounts: list[dict], budget: dict) -> bool:
from ..services.rates import convert_amount
try:
lo = float(budget.get("from")) if budget.get("from") is not None else None
hi = float(budget.get("to")) if budget.get("to") is not None else None
except (TypeError, ValueError):
return False
if lo is None and hi is None:
return True
target_cur = str(budget.get("cur") or "USD").upper()
for amt in amounts:
raw_val = amt["from"] if amt["from"] is not None else amt.get("to")
if raw_val is None:
continue
try:
val = raw_val if amt["cur"] == target_cur else convert_amount(raw_val, amt["cur"], target_cur)
except Exception: # noqa: BLE001
val = None
if val is None:
continue
if lo is not None and val < lo:
continue
if hi is not None and val > hi:
continue
return True
return False
def match_text(rules: dict, text: str) -> bool:
"""Соответствует ли текст правилам колонки. Возвращает bool.
Колонка — это набор опциональных фильтров: направление, стек, ключевые
слова, грейд/уровень, бюджет. Пустая группа не участвует; режим «все» —
должны совпасть все включённые группы, «любое» — хотя бы одна.
"""
rules = rules or {}
lower = content_text(text).lower()
direction = [str(x).strip().lower() for x in (rules.get("direction") or []) if str(x).strip()]
kw = [str(x).strip().lower() for x in (rules.get("keywords") or []) if str(x).strip()]
stack = [str(x).strip().lower() for x in (rules.get("stack") or []) if str(x).strip()]
grade = [str(x).strip().lower() for x in (rules.get("grade") or []) if str(x).strip()]
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
enabled = [bool(kw), bool(stack), bool(direction), bool(grade), bool(budget)]
if not any(enabled):
return False
grade_terms = _grade_terms(grade)
groups = {
"keywords": any(k in lower for k in kw) if kw else True,
"stack": any(s in lower for s in stack) if stack else True,
"direction": any(d in lower for d in direction) if direction else True,
"grade": any(g in lower for g in grade_terms) if grade else True,
"budget": (_amount_in_range(extract_amounts(text), budget) if budget else True),
}
mode = str(rules.get("mode") or "all").lower()
if mode == "any":
# «любое»: совпасть должна хотя бы одна включённая (непустая) группа.
# Пустые группы равны True и не должны участвовать — иначе колонка
# с одним фильтром (например «стек: WPF») ловит вообще всё.
return any(groups[k] for k, on in zip(groups, enabled) if on and k in groups)
# all: каждая включённая группа обязана совпасть
return all(groups[k] for k, on in zip(groups, enabled) if on and k in groups)
def score_text(rules: dict, text: str) -> int:
"""Число совпавших фильтров (для выбора лучшей ИИ-колонки)."""
if not text:
return 0
lower = content_text(text).lower()
score = 0
for group in ("direction", "keywords", "stack", "grade"):
if group == "grade":
for g in _grade_terms(rules.get(group) or []):
if g in lower:
score += 1
else:
for w in (rules.get(group) or []):
if str(w).lower() in lower:
score += 1
return score
def excluded_terms(rules: dict | None, text: str) -> list[str]:
"""Какие слова-исключения колонки есть в тексте.
Исключения — veto колонки: если в содержании сообщения (без ссылок и
служебных хвостов) встречается любое из них, карточка в колонку не
попадает, даже если все положительные условия совпали.
"""
rules = rules or {}
lower = content_text(text).lower()
out: list[str] = []
for term in rules.get("exclude") or []:
t = str(term).strip()
if t and t.lower() in lower:
out.append(t)
return out
def is_excluded(rules: dict | None, text: str) -> bool:
return bool(excluded_terms(rules, text))
def board_accepts(board_id: str, text: str) -> bool:
"""Пропускает ли колонка этот текст.
Колонка с активными правилами принимает только текст, прошедший её правила
(детерминированно); колонка без правил принимает любой текст — её наполняют
ИИ/ML/пользователь. Нужно как страховка: ИИ или ML не должны класть карточку
в «отфильтрованную» колонку, если текст под правила не подходит. Слова-
исключения работают как veto в любом случае.
"""
row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id])
if not row:
return False
rules = json.loads(row["rules"] or "{}") if row["rules"] else {}
if is_excluded(rules, text):
return False
if not has_active_rules(rules):
return True
return match_text(rules, text)
def hits(rules: dict, text: str) -> list[dict]:
"""Какие именно критерии фильтра совпали с текстом.
Возвращает список совпадений по группам фильтра колонки:
[{"label": "Стек", "term": "WPF"}, {"label": "Грейд", "term": "middle", "word": "mid"}, …].
Нужно, чтобы на карточке показывать «по каким критериям она попала в колонку»
(список совпавших условий фильтра). Ключевое слово ищется по всему тексту
сообщения — включая стек, требования и «будет плюсом», как и наоборот.
"""
rules = rules or {}
lower = content_text(text).lower()
out: list[dict] = []
for group, label in (("direction", "Направление"), ("keywords", "Слова"), ("stack", "Стек")):
for term in rules.get(group) or []:
t = str(term).strip()
if t and t.lower() in lower:
out.append({"label": label, "term": t})
for term in rules.get("grade") or []:
for alias in _grade_terms([term]):
if alias in lower:
out.append({"label": "Грейд/уровень", "term": str(term).strip(), "word": alias})
break
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
if budget and _amount_in_range(extract_amounts(text), budget):
out.append({"label": "Бюджет", "term": _budget_label(budget)})
return out
def _budget_label(budget: dict) -> str:
lo, hi = budget.get("from"), budget.get("to")
cur = str(budget.get("cur") or "").upper()
if lo is not None and hi is not None:
return f"от {lo:g} до {hi:g} {cur}".strip()
if hi is not None:
return f"до {hi:g} {cur}".strip()
if lo is not None:
return f"от {lo:g} {cur}".strip()
return "бюджет"
def hits_for_board(board_id: str, text: str) -> list[dict]:
"""Совпавшие критерии колонки с активными правилами; [] — правил нет."""
row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id])
if not row:
return []
rules = json.loads(row["rules"] or "{}") if row["rules"] else {}
if not has_active_rules(rules):
return []
return hits(rules, text)
def has_active_rules(rules: dict | None) -> bool:
"""Есть ли в правилах хотя бы одна реально работающая группа фильтров.
Нужно для ML: колонка с активными правилами раскладывается только самими
правилами (детерминированно), ML её назначать не должен — иначе в колонку
попадает то, что под правила не подходит.
"""
rules = rules or {}
for key in ("direction", "keywords", "stack", "grade"):
if any(str(x).strip() for x in (rules.get(key) or [])):
return True
budget = rules.get("budget")
if isinstance(budget, dict) and (
budget.get("from") is not None or budget.get("to") is not None
):
return True
return False
def describe(rules: dict) -> str:
"""Человекочитаемое описание правил (для обоснования и промпта)."""
rules = rules or {}
parts = []
direction = rules.get("direction") or []
stack = rules.get("stack") or []
kw = rules.get("keywords") or []
grade = rules.get("grade") or []
if direction:
parts.append("направление: " + ", ".join(str(x) for x in direction[:6]))
if stack:
parts.append("стек: " + ", ".join(str(x) for x in stack[:8]))
if kw:
parts.append("слова: " + ", ".join(str(x) for x in kw[:8]))
if grade:
parts.append("грейд: " + ", ".join(str(x) for x in grade[:8]))
exc = rules.get("exclude") or []
if exc:
parts.append("исключено: " + ", ".join(str(x) for x in exc[:8]))
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
if budget and (budget.get("from") is not None or budget.get("to") is not None):
lo = budget.get("from") if budget.get("from") is not None else ""
hi = budget.get("to") if budget.get("to") is not None else ""
parts.append(f"бюджет: {lo}{hi} {budget.get('cur') or ''}".replace(' ', '').replace(' ', ''))
if not parts:
return "без правил (решает ИИ/ML)"
mode = "все условия" if str(rules.get("mode") or "all").lower() != "any" else "любое из условий"
return mode + " · " + "; ".join(parts)
def route(text: str) -> dict | None:
"""Детерминированная маршрутизация по правилам активных колонок.
Возвращает колонку, если правила однозначно совпали (при нескольких —
ту, где больше совпадений). Предложения ИИ в маршрутизации не участвуют.
"""
rows = store.query(
"SELECT * FROM boards WHERE suggested = FALSE AND rules <> '{}' AND rules <> '' ORDER BY pos"
)
best: dict | None = None
best_score = 0
for r in rows:
rules = json.loads(r["rules"] or "{}")
if not match_text(rules, text):
continue
sc = score_text(rules, text) or 1
if sc > best_score:
best = {"id": r["id"], "name": r["name"], "color": r["color"], "rules": rules}
best_score = sc
return best
@@ -0,0 +1,247 @@
"""ИИ-предложения колонок по анализу «Неразобранного».
Колонка — не «на один скилл», а смысловая тема: направление + набор стека и
ключевых слов (+ бюджет при повторяемости). ИИ группирует карточки и создаёт
КОЛОНКИ-ПРЕДЛОЖЕНИЯ (suggested=TRUE) с обоснованием (note), по каким
критериям собрана. Пользователь открывает предложение, смотрит карточки и
решает: принять (можно переименовать/поправить правила) или удалить.
"""
from __future__ import annotations
import logging
import time
from ..db import store
from ..sse import broker
from . import ai as ai_svc
log = logging.getLogger("leadradar.suggest")
SUGGEST_PROMPT = """Ты — аналитик входящих заявок. Перед тобой пронумерованные сообщения (номера 1..N), которые не подошли ни под одну существующую колонку. Сгруппируй их в 2–4 ОСМЫСЛЕННЫЕ тематические колонки.
Сфера/что считается заявкой:
{domain}
Колонка — это не отдельный предмет и не каждое слово по отдельности, а направление с набором родственных признаков (тип работ/услуг, предметы, технологии, материалы). Пример: «Telegram-боты» — направление: чат-боты/автоматизация; предметы/технологии: Python, aiogram; слова: бот, telegram, автоответчик.
Правила:
- группируй повторяющиеся темы: в каждую колонку бери минимум 2 сообщения;
- не предлагай колонки, похожие на уже существующие;
- 2–4 колонки максимум; если ничего общего нет — верни пустой список.
Для каждой колонки верни:
- name — короткое название (2–4 слова);
- description — короткое описание колонки (1–2 предложения): что за заявки и для кого;
- direction — направление/тип задач (2–5 слов или фраз);
- stack — что фигурирует в заявках: предметы, услуги, технологии, материалы (1–6);
- grade — уровень/грейд, если ярко выражен (например: ["middle"]), иначе пустой список;
- keywords — 3–8 ключевых слов/фраз, по которым узнаётся такая заявка;
- messages — НОМЕРА сообщений из списка, которые относятся к этой колонке (минимум 2, максимум 12);
- reason — обоснование в 1 предложение: что за тема, сколько карточек и что в них общего.
Верни строго JSON:
{ "columns": [ { "name": "", "description": "", "direction": [""], "stack": [""], "grade": [""], "keywords": [""], "messages": [1, 5], "reason": "" } ] }"""
KEYWORDS_PROMPT = """Ты — аналитик входящих сообщений. Ниже — реальные сообщения, которые уже признаны заявками/лидами (и часть — обычный флуд каналов).
Сфера/что считается заявкой:
{domain}
Выдели общие слова-МАРКЕРЫ, по которым сообщение можно отнести к заявкам этой сферы (а не к флуду): типовые предметы/услуги/работы, глаголы-действия («куплю», «нужен», «сниму», «отремонтировать»), статусные слова («срочно», «под ключ», «бюджет»).
Верни строго JSON с плоским списком от 8 до 40 ключевых слов/фраз (слова в нижнем регистре, без знаков препинания):
{ "keywords": ["", ""] }"""
MIN_INBOX = 6 # минимум карточек в «Неразобранном» для анализа
MIN_INBOX_GROUP = 2 # минимум карточек в одной ИИ-колонке
MAX_TEXT = 12 # сколько сообщений берём в анализ (ИИ обрывает длинный JSON)
COOLDOWN_S = 20 * 60 # как часто автоматически переспрашиваем
KEY = "lastSuggestAt"
def _similar_exists(name: str) -> bool:
low = name.casefold()
rows = store.query("SELECT id, name FROM boards")
for r in rows:
n = str(r["name"]).casefold()
if low == n or low in n or n in low:
return True
return False
def _rules_for(item: dict) -> dict:
"""Правила колонки из ИИ-ответа: направление/слова/стек/грейд (любое из условий)."""
return {
"mode": "any",
"direction": [str(x).strip().lower() for x in (item.get("direction") or []) if str(x).strip()][:6],
"keywords": [str(x).strip().lower() for x in (item.get("keywords") or []) if str(x).strip()][:8],
"stack": [str(x).strip().lower() for x in (item.get("stack") or []) if str(x).strip()][:8],
"grade": [str(x).strip().lower() for x in (item.get("grade") or []) if str(x).strip()][:6],
}
async def suggest_from_inbox(force: bool = False) -> dict:
"""Анализ «Неразобранного» и создание колонок-предложений с обоснованием.
ИИ группирует пронумерованные сообщения и для каждой колонки возвращает
номера сообщений (messages). Карточки раскладываются именно по этим
номерам, а не строгим match_text по сгенерированным правилам — иначе
колонка-предложение часто остаётся пустой (правила ИИ приблизительные).
"""
# Предложения колонок — это вызов ИИ: при выключенном ИИ (aiEnabled=false,
# режим «только фильтр + ML») автоцикл не должен дёргать провайдера.
if not force and not store.get_setting("aiEnabled"):
return {"ok": False, "reason": "ИИ выключен — предложения колонок недоступны"}
if not force:
# не плодим предложения, пока пользователь не разобрался со старыми
pending = store.scalar("SELECT count(*) FROM boards WHERE suggested = TRUE")
if pending:
return {"ok": False, "reason": f"сначала решите судьбу {pending} предложенных колонок"}
last = int(store.get_setting(KEY) or 0)
if time.time() - last < COOLDOWN_S:
return {"ok": False, "reason": "недавно предлагали — подождите", "cooldown": True}
inbox = store.query(
"SELECT id, source_msg FROM leads WHERE col = 'inbox' AND source_msg <> '' "
"ORDER BY received_at DESC LIMIT ?",
[MAX_TEXT],
)
if len(inbox) < MIN_INBOX:
return {"ok": False, "reason": f"мало карточек в «Неразобранном» (нужно от {MIN_INBOX})"}
rows = [(str(r["id"]), str(r["source_msg"])) for r in inbox]
texts = [t[:180] for _, t in rows]
existing = [str(r["name"]) for r in store.query("SELECT name FROM boards WHERE suggested = FALSE ORDER BY pos")]
user = (
f"Существующие колонки: {', '.join(existing) if existing else 'нет'}\n\n"
"Сообщения:\n" + "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts, start=1))
)
try:
out = await ai_svc.chat_json(ai_svc.fill_prompt(SUGGEST_PROMPT), user, max_retries=2, max_tokens=16000)
except Exception as exc: # noqa: BLE001
log.warning("suggest failed: %s", exc)
return {"ok": False, "reason": f"ИИ недоступен: {exc}"}
cols = out.get("columns") if isinstance(out, dict) else None
if not isinstance(cols, list) or not cols:
log.info("suggest: ИИ не предложил колонок")
return {"ok": False, "reason": "ИИ не предложил колонок"}
# диагностика: что именно предложил ИИ (имена + число карточек)
log.info(
"suggest: ИИ предложил %d кандидатов: %s",
len(cols),
"; ".join(
f"{str(c.get('name'))[:40]}(msg={c.get('messages')})" for c in cols if isinstance(c, dict)
)[:300],
)
# номера, на которые уже «потратились» предыдущие колонки этого прогона
used_msg: set[int] = set()
created = 0
for item in cols[:4]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if len(name) < 2 or len(name) > 40:
continue
if _similar_exists(name):
continue
nums = [int(x) for x in (item.get("messages") or []) if str(x).isdigit()]
nums = [n for n in nums if 1 <= n <= len(rows) and n not in used_msg]
if len(nums) < MIN_INBOX_GROUP:
continue # колонка без явных карточек не создаётся
used_msg.update(nums)
rules = _rules_for(item)
note = _make_note(item, texts, rules, nums)
description = str(item.get("description") or "").strip()[:300]
board = _store_suggested(name, rules, note, description)
if not board:
continue
assigned = _assign_ids(rows, nums, board["id"])
if not assigned:
# ничего не удалось положить — пустое предложение не нужно
_rollback_suggested(board["id"])
continue
created += 1
if not created:
return {"ok": False, "reason": "похожие колонки уже есть или нечего сгруппировать"}
store.set_setting(KEY, int(time.time()))
await broker.publish("boards_changed", {})
await broker.publish_toast(f"ИИ предложил колонок: {created} — откройте и решите", "sparkles")
return {"ok": True, "created": created}
async def suggest_domain_keywords() -> dict:
"""«Предложить ключи ИИ»: по вашим карточкам выделяет общие слова-маркеры сферы.
Возвращает кандидатов — пользователь смотрит, правит и сохраняет в настройках
«Сфера и ключи» (общие ключи) или использует для правил колонок.
"""
rows = store.query(
"SELECT source_msg FROM leads WHERE col <> 'trash' AND col <> 'archive' AND source_msg <> '' "
"ORDER BY received_at DESC LIMIT 40"
)
texts = [str(r["source_msg"])[:350] for r in rows]
if len(texts) < 3:
return {"ok": False, "reason": "мало карточек — сначала накопите заявки (нужно хотя бы 3)"}
user = "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts))
try:
out = await ai_svc.chat_json(ai_svc.fill_prompt(KEYWORDS_PROMPT), user, max_retries=2)
except Exception as exc: # noqa: BLE001
log.warning("suggest keywords failed: %s", exc)
return {"ok": False, "reason": f"ИИ недоступен: {exc}"}
kws = out.get("keywords") if isinstance(out, dict) else None
if not isinstance(kws, list) or not kws:
return {"ok": False, "reason": "ИИ не смог выделить ключи — попробуйте ещё раз"}
clean: list[str] = []
for k in kws:
s = str(k).strip().casefold().strip(".,;:«»\"'()!#")
if s and len(s) <= 40 and s not in clean:
clean.append(s)
return {"ok": True, "keywords": clean[:60]}
def _make_note(item: dict, texts: list[str], rules: dict, nums: list[int]) -> str:
reason = str(item.get("reason") or "").strip()
direction = " · ".join(str(x) for x in (item.get("direction") or [])[:3])
base = f"Обоснование ИИ: {reason}" if reason else (
f"Обоснование ИИ: направление — {direction}" if direction else
"Обоснование ИИ: повторяющиеся заявки одной темы"
)
# сколько карточек из выборки реально попадёт в колонку (по номерам ИИ)
matched = sum(1 for n in nums if 1 <= n <= len(texts))
if matched:
base += f" · карточек в колонке: {matched}"
return base[:400]
def _store_suggested(name: str, rules: dict, note: str, description: str = "") -> dict | None:
from . import leads as leads_svc
keywords = rules.get("keywords") or []
board = leads_svc.create_board(
name, suggested=True, keywords=keywords, rules=rules, note=note, description=description
)
return leads_svc.board_by_id(board["id"])
def _assign_ids(rows: list[tuple[str, str]], nums: list[int], board_id: str) -> int:
"""Раскладывает в колонку-предложение карточки по номерам, которые назвал ИИ.
rows — список (id, source_msg) в том же порядке, в каком сообщения уходили
в промпт (1..N). Возвращает число реально перенесённых карточек.
"""
assigned = 0
for n in nums:
if not (1 <= n <= len(rows)):
continue
lead_id, _ = rows[n - 1]
still = store.scalar("SELECT 1 FROM leads WHERE id = ? AND col = 'inbox'", [lead_id])
if not still:
continue # карточка уже разобрана другим предложением/пользователем
store.execute(
"UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox' WHERE id = ? AND col = 'inbox'",
[board_id, lead_id],
)
assigned += 1
return assigned
def _rollback_suggested(board_id: str) -> None:
"""Удаляет пустую колонку-предложение (в неё ничего не попало)."""
from . import leads as leads_svc
store.execute("UPDATE leads SET col = 'inbox' WHERE col = ?", [board_id])
store.execute("DELETE FROM boards WHERE id = ?", [board_id])
@@ -0,0 +1,883 @@
"""Telegram-интеграция (п.4.2, 4.3 ТЗ).
api_id/api_hash берутся из настроек (введены в UI, НЕ из env). Сессия
Telethon сохраняется в файловой системе — повторная авторизация не нужна.
Вход: по телефону (+2FA) или по QR-ссылке. Сообщения из каналов с
включённым мониторингом кладутся в очередь pipeline, откуда их разбирает
фоновый воркер (стоп-фразы → ML → ИИ). В БД оседает только прошедшее
фильтры; исходное сообщение карточки хранит msg_id для «открыть исходник».
"""
from __future__ import annotations
import asyncio
import logging
import math
import random
import threading
import time
from telethon import TelegramClient, events, utils
from telethon.errors import SessionPasswordNeededError, PhoneCodeInvalidError, PhoneCodeExpiredError
from telethon.errors.rpcerrorlist import FloodWaitError
from telethon.tl import functions
from .. import config
from ..constants import DIALOG_HUES
from ..crypto import decrypt_text
from ..db import store
from ..sse import broker
from . import ban_guard
from . import pipeline
log = logging.getLogger("leadradar.tg")
BACKFILL_PER_MESSAGE = (1.5, 3.0) # секунды, чтобы не попасть под бан
BACKFILL_PER_DIALOG = (3.0, 6.0)
def _keys() -> dict:
raw = store.get_setting("tgKeys") or {}
api_hash = str(raw.get("apiHash", ""))
return {
"apiId": str(raw.get("apiId", "")).strip(),
"apiHash": (decrypt_text(api_hash) if api_hash.startswith("enc:") else api_hash),
}
# Главный event loop приложения: на нём живут Telethon-клиент и фоновые
# задачи. Синхронные роутеры FastAPI исполняются в threadpool (без running
# loop), поэтому корутины нужно планировать на этот loop через
# call_soon_threadsafe — Telethon не переносит клиент между циклами.
_MAIN_LOOP: asyncio.AbstractEventLoop | None = None
def register_main_loop(loop: asyncio.AbstractEventLoop) -> None:
global _MAIN_LOOP
_MAIN_LOOP = loop
def _spawn(coro) -> None:
"""Запустить корутину из любого контекста: running loop, главный loop или поток.
set_monitor/set_monitor_all вызываются из синхронных роутеров FastAPI, где
running loop отсутствует, поэтому прямой asyncio.create_task падает с
RuntimeError (отсюда Internal Server Error при включении канала).
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = _MAIN_LOOP
if loop is not None and loop.is_running():
loop.call_soon_threadsafe(loop.create_task, coro)
return
threading.Thread(
target=lambda: asyncio.new_event_loop().run_until_complete(coro),
daemon=True,
).start()
else:
loop.create_task(coro)
class TelegramManager:
def __init__(self) -> None:
self.client: TelegramClient | None = None
self.phase = "idle" # idle | phone | code | password | qr | ready
self.phone: str = ""
self._code_hash: str = ""
self._auth_lock = asyncio.Lock()
self._listener_task: asyncio.Task | None = None
self._monitored: set[str] = set()
self.error: str | None = None
self._disconnect_hook = None
# QR
self.qr_url: str = ""
self._qr_task: asyncio.Task | None = None
# сердцебиение статуса
self._last_connected: bool | None = None
# backfill каналов при первом подключении
self._backfilling: set[str] = set()
# ── состояние для UI ──────────────────────────────────────────────────
def status(self) -> dict:
row = store.query_one(
"SELECT count(*) AS d FROM dialogs WHERE monitor = TRUE"
)
acc = store.get_setting("tgAccount") or ""
connected = bool(self.client and self.client.is_connected())
listener_alive = bool(self._listener_task and not self._listener_task.done())
return {
"phase": self.phase,
"connected": connected,
"listener": listener_alive,
"account": acc,
"monitored": int(row["d"]) if row else 0,
"keysSet": bool(_keys().get("apiId") and _keys().get("apiHash")),
"error": self.error,
"qrUrl": self.qr_url if self.phase == "qr" else None,
}
def _client(self) -> TelegramClient:
keys = _keys()
api_id = str(keys.get("apiId") or "").strip()
api_hash = str(keys.get("apiHash") or "").strip()
if not api_id or not api_hash:
raise ValueError("Сначала сохраните Telegram api_id и api_hash в настройках")
if self.client is None:
session = str(config.SESSIONS_DIR / config.SESSION_PREFIX)
self.client = TelegramClient(session, int(api_id), api_hash)
return self.client
# ── веб-авторизация ───────────────────────────────────────────────────
async def start_phone(self, phone: str) -> None:
async with self._auth_lock:
self.phone = phone
self.error = None
try:
client = self._client()
await client.connect()
sent = await client.send_code_request(phone)
self._code_hash = sent.phone_code_hash
self.phase = "code"
except Exception as exc: # noqa: BLE001
self.phase = "idle"
self.error = str(exc)
raise
async def submit_code(self, code: str) -> None:
async with self._auth_lock:
self.error = None
client = self._client()
try:
await client.sign_in(self.phone, code, phone_code_hash=self._code_hash)
await self._finalize()
except SessionPasswordNeededError:
self.phase = "password"
except PhoneCodeInvalidError:
self.error = "Неверный код"
raise ValueError("Неверный код")
except PhoneCodeExpiredError:
self.error = "Код истёк — запросите новый"
raise ValueError("Код истёк — запросите новый")
except Exception as exc: # noqa: BLE001
self.error = str(exc)
raise
async def submit_password(self, password: str) -> None:
async with self._auth_lock:
self.error = None
try:
await self.client.sign_in(password=password)
await self._finalize()
except Exception as exc: # noqa: BLE001
self.error = "Неверный облачный пароль"
raise ValueError("Неверный облачный пароль") from exc
async def _finalize(self, notify: bool = True) -> None:
me = await self.client.get_me()
store.set_setting("tgAccount", f"@{me.username or 'user'}")
self.phase = "ready"
self._start_listener()
await self.refresh_dialogs()
self._schedule_first_backfill()
if notify:
await broker.publish_toast("Telegram подключён, сессия сохранена", "send")
await self._publish_status()
async def disconnect(self) -> None:
async with self._auth_lock:
if self._qr_task:
self._qr_task.cancel()
self._qr_task = None
if self._listener_task:
self._listener_task.cancel()
self._listener_task = None
if self.client:
try:
await self.client.disconnect()
except Exception: # noqa: BLE001
pass
self.phase = "idle"
self._monitored.clear()
self.qr_url = ""
store.set_setting("tgAccount", "")
await broker.publish_toast("Telegram отключён", "logout")
await self._publish_status()
async def auto_resume(self) -> None:
"""При старте сервера: если сессия сохранена — подключиться автоматически."""
try:
keys = _keys()
if not (keys.get("apiId") and keys.get("apiHash")):
return
client = self._client()
await client.connect()
if await client.is_user_authorized():
await self._finalize(notify=False)
except Exception as exc: # noqa: BLE001
log.info("auto_resume skipped: %s", exc)
self.phase = "idle"
# ── прослушивание ─────────────────────────────────────────────────────
def _start_listener(self) -> None:
if self._listener_task and not self._listener_task.done():
return
self._reload_monitored()
self.client.add_event_handler(self._on_message, events.NewMessage())
self._listener_task = asyncio.create_task(self.client.run_until_disconnected())
def _on_done(task: asyncio.Task) -> None:
if task.cancelled():
log.info("listener stopped (cancelled)")
return
exc = task.exception()
if exc:
log.error("listener crashed: %s", exc)
else:
log.info("listener stopped")
self._listener_task.add_done_callback(_on_done)
def _reload_monitored(self) -> None:
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE")
self._monitored = {r["id"] for r in rows}
def _dialog_id(self, message) -> str:
try:
chat_id = message.chat_id
except Exception: # noqa: BLE001
chat_id = message.peer_id
return str(chat_id)
async def _on_message(self, event) -> None:
"""Каждое сообщение мониторящегося диалога кладём в очередь pipeline."""
try:
msg = event.message
if msg is None or msg.text is None or not msg.text.strip():
return
dialog_id = self._dialog_id(event.message)
if dialog_id not in self._monitored:
return
chat = await event.get_chat()
ch_name = getattr(chat, "title", None) or getattr(chat, "first_name", "") or dialog_id
ch_handle = getattr(chat, "username", "") or ""
hue = dialog_hue(dialog_id, ch_name)
now = time.time_ns() // 1_000_000
ts = int(msg.date.timestamp() * 1000) if getattr(msg, "date", None) else now
store.execute(
"UPDATE dialogs SET last_text = ?, last_at = ?, updated_at = ? WHERE id = ?",
[msg.text.strip()[:200], now, now, dialog_id],
)
pipeline.enqueue(dialog_id, ch_name, ch_handle, hue, msg.id, msg.text, ts)
# помечаем сообщение прочитанным в Telegram, чтобы оно не висело
# «новым» в других клиентах/устройствах
try:
await self.client.send_read_acknowledge(chat)
except Exception: # noqa: BLE001
log.debug("read ack failed", exc_info=True)
except Exception as exc: # noqa: BLE001
log.exception("on_message failed: %s", exc)
# ── QR-вход ───────────────────────────────────────────────────────────
async def qr_start(self) -> str:
async with self._auth_lock:
self.error = None
client = self._client()
await client.connect()
if await client.is_user_authorized():
await self._finalize(notify=False)
return ""
if self._qr_task and not self._qr_task.done():
return self.qr_url
qr = await client.qr_login()
self.qr_url = qr.url
self.phase = "qr"
self._qr_task = asyncio.create_task(self._wait_qr(qr))
return self.qr_url
async def _wait_qr(self, qr) -> None:
try:
await qr.wait()
await self._finalize(notify=True)
except Exception as exc: # noqa: BLE001
log.warning("qr wait error: %s", exc)
self.error = str(exc)
self.phase = "idle"
finally:
self._qr_task = None
# ── статус (сердцебиение) ─────────────────────────────────────────────
async def _publish_status(self) -> None:
await broker.publish("system_status", self.status())
async def heartbeat(self) -> None:
"""Периодический вызов из планировщика: уведомляем, если Telegram «уснул»."""
connected = bool(self.client and self.client.is_connected())
if self.phase == "ready" and connected != self._last_connected:
self._last_connected = connected
await self._publish_status()
if not connected:
await broker.publish_toast("Telegram отключён — переподключение при следующей проверке", "bell")
if self.phase == "ready" and connected:
self._last_connected = True
# ── backfill: при первом подключении по 10 последних сообщений ────────
def _schedule_first_backfill(self) -> None:
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE AND backfilled = FALSE")
ids = [r["id"] for r in rows]
if ids:
asyncio.create_task(self._backfill_dialogs(ids))
async def _backfill_dialogs(self, dialog_ids: list[str], force: bool = False) -> None:
for dialog_id in dialog_ids:
if dialog_id in self._backfilling:
continue
try:
processed = await self.backfill_dialog(dialog_id, force=force)
if processed:
log.info("backfill %s: %d сообщений в очередь", dialog_id, processed)
except Exception as exc: # noqa: BLE001
log.warning("backfill %s failed: %s", dialog_id, exc)
await asyncio.sleep(random.uniform(*BACKFILL_PER_DIALOG))
async def backfill_dialog(self, dialog_id: str, force: bool = False) -> int:
"""Последние 10 сообщений канала: разбираем с паузами (анти-бан).
force=True — «Перечитать» по кнопке: даже если канал уже разобран.
Повторы карточек не создаются (защита dedup по нормализованному тексту).
"""
if dialog_id in self._backfilling:
return 0
client = self.client
if not client or not client.is_connected():
return 0
row = store.query_one("SELECT backfilled FROM dialogs WHERE id = ?", [dialog_id])
if not row or (not force and bool(row["backfilled"])):
return 0
self._backfilling.add(dialog_id)
processed = 0
try:
entity = await client.get_entity(int(dialog_id))
chat = await client.get_entity(int(dialog_id))
name = getattr(chat, "title", None) or getattr(chat, "first_name", "") or dialog_id
handle = getattr(chat, "username", "") or ""
hue = dialog_hue(dialog_id, name)
msgs = await client.get_messages(entity, limit=10)
for m in reversed(msgs): # от старых к новым, как реальный поток
if m.text is None or not m.text.strip():
continue
now = time.time_ns() // 1_000_000
ts = int(m.date.timestamp() * 1000) if m.date else now
# в очередь уходит всё; отсев сделает воркер, в БД осядет только
# то, что прошло фильтры
pipeline.enqueue(dialog_id, name, handle, hue, m.id, m.text, ts)
processed += 1
await asyncio.sleep(random.uniform(*BACKFILL_PER_MESSAGE))
# «Перечитать» — вручную вытащили сообщения: снимаем «новое» в Telegram
try:
await client.send_read_acknowledge(entity)
except Exception: # noqa: BLE001
log.debug("backfill read ack failed", exc_info=True)
store.execute("UPDATE dialogs SET backfilled = TRUE WHERE id = ?", [dialog_id])
finally:
self._backfilling.discard(dialog_id)
return processed
async def realtime_sweep(self) -> None:
"""Страховка realtime: если событие потеряно (рестарт/разрыв), раз в 30 с
докачиваем непрочитанные сообщения включённых каналов, кладём в очередь
и помечаем прочитанными."""
client = self.client
if not client or not client.is_connected():
return
self._reload_monitored()
if not self._monitored:
return
try:
dialogs = await client.get_dialogs(limit=500)
except Exception as exc: # noqa: BLE001
log.debug("realtime sweep: get_dialogs fail: %s", exc)
return
# синхронизация списка: новые каналы/группы появляются и включаются
# автоматически, удалённые исчезают (см. _persist_dialogs)
try:
entries = []
for dlg in dialogs:
ent = dlg.entity
name = dlg.name or ""
entries.append(
(
str(dlg.id),
name,
getattr(ent, "username", "") or "",
self._kind_of(ent),
dialog_hue(str(dlg.id), name),
)
)
if entries:
self._persist_dialogs(entries)
except Exception as exc: # noqa: BLE001
log.debug("realtime sweep: sync dialogs fail: %s", exc)
for dlg in dialogs:
did = str(dlg.id)
if did not in self._monitored:
continue
unread = int(getattr(dlg, "unread_count", 0) or 0)
if unread <= 0:
continue
try:
msgs = await client.get_messages(dlg.entity, limit=min(unread + 2, 10))
added = 0
for m in reversed(msgs): # от старых к новым
if m.text is None or not m.text.strip():
continue
if store.scalar(
"SELECT 1 FROM pipeline_msg WHERE dialog_id = ? AND msg_id = ?", [did, m.id]
):
continue
ent = dlg.entity
name = getattr(ent, "title", None) or getattr(ent, "first_name", "") or did
handle = getattr(ent, "username", "") or ""
hue = dialog_hue(did, name)
now = time.time_ns() // 1_000_000
ts = int(m.date.timestamp() * 1000) if getattr(m, "date", None) else now
pipeline.enqueue(did, name, handle, hue, m.id, m.text, ts)
added += 1
if added:
log.info("realtime sweep %s: +%d в очередь (потерянные события)", did, added)
await client.send_read_acknowledge(dlg.entity)
except Exception as exc: # noqa: BLE001
log.debug("realtime sweep dialog %s fail: %s", did, exc)
# ── диалоги/каналы ────────────────────────────────────────────────────
@staticmethod
def _kind_of(entity) -> str:
if getattr(entity, "broadcast", False):
return "канал"
if getattr(entity, "megagroup", False) or getattr(entity, "gigagroup", False) or getattr(entity, "group", False):
return "группа"
return "чат"
def _persist_dialogs(self, entries: list[tuple]) -> int:
"""Синхронизация списка диалогов с Telegram.
- новые чаты/каналы добавляются; авто-мониторинг новых управляется
настройкой autoMonitorNew (вкл — любой появившийся чат мониторится,
выкл — появляется отключённым);
- переименования/смена типа обновляются (monitor пользователя не трогаем);
- диалоги, которых больше нет в Telegram (вышел/удалил), удаляются.
"""
if not entries:
return 0
now = time.time_ns() // 1_000_000
auto_new = bool(store.get_setting("autoMonitorNew"))
seen: set[str] = set()
for dlg_id, name, handle, kind, hue in entries:
seen.add(dlg_id)
store.execute(
"INSERT INTO dialogs(id, name, handle, kind, hue, monitor, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET name = excluded.name, handle = excluded.handle, "
"kind = excluded.kind, hue = excluded.hue, updated_at = excluded.updated_at",
[dlg_id, name, handle, kind, hue, auto_new, now],
)
stale = store.query(
"SELECT id FROM dialogs WHERE id NOT IN (" + ",".join(["?"] * len(seen)) + ")",
list(seen),
)
if stale:
stale_ids = [r["id"] for r in stale]
store.execute(
"DELETE FROM dialogs WHERE id IN (" + ",".join(["?"] * len(stale_ids)) + ")",
stale_ids,
)
log.info("dialogs sync: удалено устаревших источников: %d", len(stale_ids))
self._reload_monitored()
return len(entries)
async def refresh_dialogs(self) -> int:
client = self._client()
if not client.is_connected():
await client.connect()
entries: list[tuple] = []
async for dialog in client.iter_dialogs(limit=500):
entity = dialog.entity
name = dialog.name or ""
handle = getattr(entity, "username", "") or ""
kind = self._kind_of(entity)
hue = dialog_hue(str(dialog.id), name)
entries.append((str(dialog.id), name, handle, kind, hue))
if entries:
self._persist_dialogs(entries)
return len(entries)
def list_dialogs(self) -> list[dict]:
rows = store.query("SELECT * FROM dialogs ORDER BY monitor DESC, name")
return [
{
"id": r["id"],
"name": r["name"],
"handle": r["handle"],
"type": r["kind"],
"hue": r["hue"],
"on": bool(r["monitor"]),
"last": {"text": r["last_text"], "time": r["last_at"]},
}
for r in rows
]
def set_monitor(self, dialog_id: str, enabled: bool) -> None:
store.execute(
"UPDATE dialogs SET monitor = ?, updated_at = ? WHERE id = ?",
[enabled, time.time_ns() // 1_000_000, dialog_id],
)
self._reload_monitored()
if enabled:
row = store.query_one("SELECT backfilled FROM dialogs WHERE id = ?", [dialog_id])
if row and not bool(row["backfilled"]):
# первое включение мониторинга: разбираем последние 10 сообщений с паузами
_spawn(self.backfill_dialog(dialog_id))
def set_monitor_all(self, enabled: bool) -> int:
"""Включить/выключить мониторинг сразу для всех диалогов.
Первое включение каждого канала разбирается последовательно (с паузами),
чтобы не попасть под бан Telegram.
"""
now = time.time_ns() // 1_000_000
rows = store.query("SELECT id, backfilled FROM dialogs")
to_backfill: list[str] = []
for r in rows:
store.execute(
"UPDATE dialogs SET monitor = ?, updated_at = ? WHERE id = ?",
[enabled, now, r["id"]],
)
if enabled and not bool(r["backfilled"]):
to_backfill.append(r["id"])
self._reload_monitored()
if enabled and to_backfill:
_spawn(self._backfill_dialogs(to_backfill))
return len(rows)
def backfill_monitored(self) -> int:
"""Кнопка «Перечитать»: последние 10 сообщений всех включённых каналов.
Разбор идёт в фоне последовательно с паузами (анти-бан), даже если
канал уже разобран (force). Включённые каналы продолжают ловить новые
сообщения в реальном времени — это ручная догонялка.
"""
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE")
ids = [r["id"] for r in rows]
if not ids:
return 0
_spawn(self._backfill_dialogs(ids, force=True))
return len(ids)
async def dialog_messages(self, dialog_id: str, limit: int = 24) -> list[dict]:
"""Последние сообщения диалога: свежие берём из Telegram, старые — из БД."""
out: list[dict] = []
client = self.client
try:
if client and client.is_connected():
entity = await client.get_entity(int(dialog_id))
msgs = await client.get_messages(entity, limit=min(limit, 30))
now = time.time_ns() // 1_000_000
for m in msgs:
if m.text:
row = store.query_one(
"SELECT id FROM messages WHERE id = ?",
[f"m_{dialog_id}_{m.id}"],
)
lead_id = None
if row:
lead_id = row.get("lead_id") or None
if not row:
store.execute(
"INSERT OR IGNORE INTO messages(id, dialog_id, text, msg_at) VALUES (?, ?, ?, ?)",
[f"m_{dialog_id}_{m.id}", dialog_id, m.text[:4000], now],
)
out.append({"id": m.id, "text": m.text, "time": m.date.timestamp() * 1000, "lead": bool(lead_id)})
# вручную вытащили сообщения — снимаем «новое» в Telegram
try:
await client.send_read_acknowledge(entity)
except Exception: # noqa: BLE001
log.debug("dialog_messages read ack failed", exc_info=True)
except Exception as exc: # noqa: BLE001
log.warning("dialog_messages tg fail: %s", exc)
if not out:
rows = store.query(
"SELECT * FROM messages WHERE dialog_id = ? ORDER BY msg_at DESC LIMIT ?",
[dialog_id, limit],
)
out = [{"id": r["id"], "text": r["text"], "time": r["msg_at"], "lead": bool(r["lead_id"])} for r in rows]
return out
# ── discovery: поиск каналов и действия (Task 4) ──────────────────────
async def discovery_search(self, q: str, limit: int = 30) -> list[dict]:
"""Глобальный поиск каналов/групп по ключу (contacts.search).
id возвращается подписанным (как в dialogs: каналы -100…, группы -id,
люди +id). Найденные entity кэшируются в сессию Telethon — тогда
discovery_info/read смогут получить участников/историю по id даже без
вступления (публичные источники).
"""
client = self.client
if not client or not client.is_connected():
raise RuntimeError("Telegram не подключён")
found = await client(functions.contacts.SearchRequest(q=q, limit=limit))
# пауза между поисковыми запросами (анти-бан, BanGuard)
await asyncio.sleep(ban_guard.search_pause())
try:
client.session.process_entities(found)
except Exception: # noqa: BLE001
log.debug("discovery_search: cache entities failed", exc_info=True)
out: list[dict] = []
seen: set[str] = set()
for ent in (*found.chats, *found.users):
try:
dialog_id = str(utils.get_peer_id(ent))
except (TypeError, ValueError):
continue
if dialog_id in seen:
continue
seen.add(dialog_id)
name = utils.get_display_name(ent) or dialog_id
out.append(
{
"id": dialog_id,
"name": name,
"username": getattr(ent, "username", "") or "",
"kind": self._kind_of(ent),
"hue": dialog_hue(dialog_id, name),
}
)
if len(out) >= limit:
break
return out
async def discovery_info(self, dialog_id: str) -> dict:
"""Инфо об источнике: тип, username, участники, признак форума.
participants берётся из full_chat (channels.getFullChannel для
каналов/супергрупп, messages.getFullChat для базовых групп); если
определить не удалось (нет членства/приватный/ошибка) — None,
исключение наружу не бросаем.
"""
result = {
"id": str(dialog_id),
"name": str(dialog_id),
"username": "",
"kind": "",
"hue": dialog_hue(str(dialog_id), str(dialog_id)),
"participants": None,
"is_forum": False,
}
client = self.client
if not client or not client.is_connected():
return result
try:
entity = await client.get_entity(int(dialog_id))
except Exception as exc: # noqa: BLE001
log.warning("discovery_info %s: entity not resolved: %s", dialog_id, exc)
return result
name = utils.get_display_name(entity) or str(dialog_id)
kind = self._kind_of(entity)
result.update(
name=name,
username=getattr(entity, "username", "") or "",
kind=kind,
hue=dialog_hue(str(dialog_id), name),
is_forum=bool(getattr(entity, "forum", False)),
)
try:
if kind in ("канал", "группа"):
full = await client(functions.channels.GetFullChannelRequest(entity))
full_chat = full.full_chat
participants = int(getattr(full_chat, "participants_count", 0) or 0)
result["participants"] = participants or None
elif getattr(entity, "title", None):
# базовая группа (legacy): полный чат приносит список участников
full = await client(functions.messages.GetFullChatRequest(entity.id))
members = getattr(
getattr(full.full_chat, "participants", None), "participants", None
)
if members:
result["participants"] = len(members)
except Exception as exc: # noqa: BLE001
log.debug("discovery_info %s: participants unavailable: %s", dialog_id, exc)
return result
async def discovery_read(self, dialog_id: str, limit: int) -> dict:
"""Последние сообщения источника для оценки (без создания карточек).
Форум (entity.forum): читаем выборку по активным темам
(channels.getForumTopics + по каждой теме get_messages(reply_to=topic_id))
и возвращаем плоский список с topic_id/topic_title. Для обычных
источников topic_id/topic_title = None. История недоступна
(приватный/закрытый источник без членства) — ok=False,
error="no_history". Исключения наружу не бросаем: ошибка в темах —
безопасный fallback на обычное чтение ленты.
"""
client = self.client
limit = max(int(limit or 0), 0)
if limit <= 0:
return {"ok": True, "error": None, "messages": []}
if not client or not client.is_connected():
return {"ok": False, "error": "no_history", "messages": []}
try:
entity = await client.get_entity(int(dialog_id))
except Exception as exc: # noqa: BLE001
log.warning("discovery_read %s: entity not resolved: %s", dialog_id, exc)
return {"ok": False, "error": "no_history", "messages": []}
if getattr(entity, "forum", False):
try:
topic_msgs = await self._read_forum_topics(client, entity, limit)
except Exception as exc: # noqa: BLE001
log.debug("discovery_read %s: forum topics fail, fallback to feed: %s", dialog_id, exc)
topic_msgs = []
if topic_msgs:
return {"ok": True, "error": None, "messages": topic_msgs}
# обычная лента (каналы/группы; для форума — General-тема)
try:
msgs = await client.get_messages(entity, limit=limit)
except Exception as exc: # noqa: BLE001
log.warning("discovery_read %s: history unavailable: %s", dialog_id, exc)
return {"ok": False, "error": "no_history", "messages": []}
now = time.time_ns() // 1_000_000
out: list[dict] = []
for m in msgs:
item = self._discovery_message_item(m, None, None, now)
if item:
out.append(item)
return {"ok": True, "error": None, "messages": out}
async def _read_forum_topics(self, client, entity, limit: int) -> list[dict]:
"""Выборка сообщений по активным темам форума.
Возвращает плоский список {id, text, date_ms, topic_id, topic_title}.
Исключения не бросает: темы, которые не прочитались, пропускаются,
вызывающий решает, делать ли fallback на обычную ленту.
"""
out: list[dict] = []
try:
res = await client(
functions.channels.GetForumTopicsRequest(
channel=entity, offset_date=0, offset_id=0, offset_topic=0, limit=5
)
)
topics = list(getattr(res, "topics", None) or [])
except Exception as exc: # noqa: BLE001
log.debug("discovery_read: getForumTopics fail: %s", exc)
return out
if not topics:
return out
# на тему минимум 3 сообщения (иначе тема почти всегда отсеется как
# «мало подходящих»), cap 10; суммарно выборка может слегка превысить limit
per_topic = min(max(3, math.ceil(limit / len(topics))), 10)
now = time.time_ns() // 1_000_000
for topic in topics:
topic_id = int(getattr(topic, "id", 0) or 0)
topic_title = getattr(topic, "title", "") or ""
if not topic_id:
continue
try:
msgs = await client.get_messages(entity, limit=per_topic, reply_to=topic_id)
except Exception as exc: # noqa: BLE001
log.debug("discovery_read: topic %s read fail: %s", topic_id, exc)
continue
for m in msgs:
item = self._discovery_message_item(m, topic_id, topic_title, now)
if item:
out.append(item)
return out
@staticmethod
def _discovery_message_item(m, topic_id, topic_title, now: int) -> dict | None:
"""Одно сообщение discovery_read: только непустой текст (как в
backfill/dialog_messages), поля {id, text, date_ms, topic_id, topic_title}."""
text = getattr(m, "text", None)
if not text or not text.strip():
return None
date = getattr(m, "date", None)
return {
"id": m.id,
"text": text,
"date_ms": int(date.timestamp() * 1000) if date else now,
"topic_id": topic_id,
"topic_title": topic_title,
}
async def discovery_join(self, username: str) -> None:
"""Вступить в канал/группу по @username (channels.JoinChannelRequest).
Ручной join из API — вне квот, без пауз; паузу перед авто-вступлением
делает воркер (ban_guard.wait_join_delay). FloodWaitError фиксируется
в BanGuard (стоп авто-вступлений до конца суток) и пробрасывается
вызывающему.
"""
username = (username or "").strip().lstrip("@")
if not username:
raise ValueError("Не указан username для вступления")
client = self.client
if not client or not client.is_connected():
raise RuntimeError("Telegram не подключён")
try:
entity = await client.get_entity(username)
await client(functions.channels.JoinChannelRequest(entity))
except FloodWaitError:
ban_guard.note_flood()
log.warning("discovery_join %s: flood — авто-вступления стоп до конца суток", username)
raise
log.info("discovery_join: вступили в @%s", username)
async def discovery_leave(self, dialog_id: str) -> None:
"""Выйти из канала/группы (channels.LeaveChannelRequest)."""
client = self.client
if not client or not client.is_connected():
raise RuntimeError("Telegram не подключён")
entity = await client.get_entity(int(dialog_id))
await client(functions.channels.LeaveChannelRequest(entity))
log.info("discovery_leave: вышли из %s", dialog_id)
def add_dialog_monitored(self, dialog_id, name, username, kind, hue) -> None:
"""Добавить источник в dialogs с monitor=TRUE (после вступления).
INSERT/UPDATE без авто-логики (как set_monitor, но без запуска
backfill): backfilled=FALSE — разбор последних сообщений подхватит
обычный механизм при первом подключении/перечитывании.
"""
now = time.time_ns() // 1_000_000
store.execute(
"INSERT INTO dialogs(id, name, handle, kind, hue, monitor, backfilled, updated_at) "
"VALUES (?, ?, ?, ?, ?, TRUE, FALSE, ?) "
"ON CONFLICT(id) DO UPDATE SET name = excluded.name, handle = excluded.handle, "
"kind = excluded.kind, hue = excluded.hue, monitor = TRUE, backfilled = FALSE, "
"updated_at = excluded.updated_at",
[
str(dialog_id),
name or str(dialog_id),
username or "",
kind or "",
hue or "#666",
now,
],
)
self._reload_monitored()
def dialog_hue(dialog_id: str, name: str = "") -> str:
h = 0
for ch in (dialog_id + name):
h = (h * 31 + ord(ch)) % len(DIALOG_HUES)
return DIALOG_HUES[h]
tg = TelegramManager()
@@ -0,0 +1,51 @@
"""SSE-брокер событий.
Сервер рассылает события подключённым браузерам:
new_lead, lead_updated, toast, reminder_due, project_updated, system_status.
Поток однонаправленный, по ТЗ — Server-Sent Events с автопереподключением.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
class Broker:
def __init__(self) -> None:
self._subscribers: set[asyncio.Queue] = set()
self._lock = asyncio.Lock()
async def subscribe(self) -> asyncio.Queue:
q: asyncio.Queue = asyncio.Queue(maxsize=200)
async with self._lock:
self._subscribers.add(q)
return q
async def unsubscribe(self, q: asyncio.Queue) -> None:
async with self._lock:
self._subscribers.discard(q)
async def publish(self, event_type: str, data: Any) -> None:
payload = f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async with self._lock:
subs = list(self._subscribers)
for q in subs:
try:
q.put_nowait(payload)
except asyncio.QueueFull:
# при переполнении сбрасываем очередь подписчика — браузер переподключится
try:
q.get_nowait()
except Exception:
pass
try:
q.put_nowait(payload)
except Exception:
pass
async def publish_toast(self, text: str, icon: str = "check") -> None:
await self.publish("toast", {"text": text, "icon": icon})
broker = Broker()
@@ -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"]
+353
View File
@@ -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) и хранятся в БД в зашифрованном виде.
+9 -12
View File
@@ -14,11 +14,10 @@
| BL-TG-MULTI | Мультиаккаунтность Telegram (сейчас 1 аккаунт на тенант) | ТЗ §12 | P2 | DEFERRED | | BL-TG-MULTI | Мультиаккаунтность Telegram (сейчас 1 аккаунт на тенант) | ТЗ §12 | P2 | DEFERRED |
| BL-ML-EXP | Экспорт/импорт ML-моделей (перенос «мозгов» между инстансами) | обсуждение этапа 12 | P3 | DEFERRED (решено не делать; вернуться при SaaS-масштабе) | | BL-ML-EXP | Экспорт/импорт ML-моделей (перенос «мозгов» между инстансами) | обсуждение этапа 12 | P3 | DEFERRED (решено не делать; вернуться при SaaS-масштабе) |
| BL-RECLASS-SSE | **Сделано (2026-09-11):** пакетная переклассификация отдаёт промежуточный прогресс через SSE `cards_reclassified` (`{progress:true,done,total,moved,kept,trashed,skipped}`) и финальное событие (`{progress:false,reclassified,moved}`); `CardReclassifier.ReclassifyInboxAsync` принимает `IProgress<ReclassifyProgressDto>`; в UI — индикатор `done/total` в шапке «Неразобранного» | этап 12, D | P3 | DONE | | BL-RECLASS-SSE | **Сделано (2026-09-11):** пакетная переклассификация отдаёт промежуточный прогресс через SSE `cards_reclassified` (`{progress:true,done,total,moved,kept,trashed,skipped}`) и финальное событие (`{progress:false,reclassified,moved}`); `CardReclassifier.ReclassifyInboxAsync` принимает `IProgress<ReclassifyProgressDto>`; в UI — индикатор `done/total` в шапке «Неразобранного» | этап 12, D | P3 | DONE |
| TD-CARD-MERGE | Полное слияние внутренних DTO карточки в единый `CardDto`. **Решение (2026-09-11): DEFERRED.** Наружный контракт единый; внутренние DTO (read/write/DB/patch) намеренно разделены по слоям, слияние — риск без пользы | этап 9/11 | P3 | DEFERRED | | TD-CARD-MERGE | Полное слияние внутренних DTO карточки в единый `CardDto` (наружу уже единый) | этап 9/11 | P3 | TECHDEBT |
| TD-PROTO-COMMENTS | **Сделано (2026-09-11):** из комментариев убраны ссылки на процесс/прототип (`Task/Ruling/этап/python L…/main.py/прототип/LEADRADAR_*`), удалены блоки `<remarks>`, `<summary>` сжаты до короткой фразы; `//`-комментарии со ссылками удалены, в `.proto` — тоже. Строк комментариев 27 210 → ~19 100 | запрос владельца 2026-09-11 | P2 | DONE | | TD-PROTO-COMMENTS | **Сделано (2026-09-11):** из комментариев убраны ссылки на процесс/прототип (`Task/Ruling/этап/python L…/main.py/прототип/LEADRADAR_*`), удалены блоки `<remarks>`, `<summary>` сжаты до короткой фразы; `//`-комментарии со ссылками удалены, в `.proto` — тоже. Строк комментариев 27 210 → ~19 100 | запрос владельца 2026-09-11 | P2 | DONE |
| TD-COMMENTS-IFACE | Привести код к правилам код-стайла (`docs/spec/Код-стайл-Дейл.md`). **Сделано (2026-09-11):** (1) `<summary>` только блочно — 5286 шт.; (2) приватные XML-доки понижены — 2028+12; (3) дедупликация `<summary>``<inheritdoc/>` — дублей нет (сканы); (4) **явные реализации интерфейсов — сделано (2026-09-11, вечер, вариант A)**: 161 член в 30 прод-файлах конвертирован codemod'ом `scripts/make_explicit.py`, потребители перетипизированы на интерфейсы (8 мест в проде, 17 тест-файлов), Card/ICard-семейство оставлено implicit как DTO; попутно маркерные классы заменены маркерными интерфейсами. Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | запрос владельца 2026-09-11 | P2 | DONE | | TD-COMMENTS-IFACE | Привести код к правилам код-стайла (`docs/spec/Код-стайл-Дейл.md`). **Сделано (2026-09-11):** (1) `<summary>` только блочно — исправлено 5286 шт. в 833 файлах; (2) комментарии только на public/protected — понижено 2028 XML-доков с private/internal (359 файлов). **Осталось:** (3) не дублировать `<summary>` интерфейса в реализации (нужен Roslyn-анализ); (4) явная реализация интерфейсов там, где возможно (61 интерфейс, точечный ревью). Скрипты: `scripts/fix_summary_blocks.py`, `scripts/fix_private_docs.py`. Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | запрос владельца 2026-09-11 | P2 | TECHDEBT (1,2 — DONE; 3,4 — BACKLOG) |
| TD-TESTS-NSUBSTITUTE | Миграция тестовых фейков на NSubstitute (решение владельца 2026-09-11: моки — через NSubstitute, новых фейк-классов не заводить). **Сделано (2026-09-11):** NSubstitute 6.1.0 подключён к 5 тест-проектам; эталон миграции — `FakePasswordHasher` → хелпер `TestHashers.New()` (NSubstitute, детерминированная семантика сохранена), фейк удалён. **Осталось (по размеру):** FakeDiscoveryPacer (1 файл), FakeRatesListener (2), FakeTenantProvisioner (5), FakeSecretCipher (8), FakeAiTools (4), FakeRatesSource (1), FakeGlobalSettingsStore (4), FakeTenantRegistry/FakeTenantRepository (3+7), FakeAiClassifier (5), FakeSettingsStore (42), FakeRateLimitCounterStore (5), FakeAuditLogStore (13), FakeTenantStore (10), FakeMlLearningStore (5), FakeMlClient (17), FakeOperatorAuthStore (14), FakeInviteStore (7), FakeFileStorage (9), FakeTokenUsageEventStore (10), FakeAuthStore (15), Recording*/Harness* (gRPC-харнессы — оставить как хелперы), крупные stateful: FakeTelegramGateway (5), FakeDiscoveryGateway (2), FakeTelegramStore (7), FakeTenantLimitStore (15), FakePipelineStore (12), FakeDiscoveryStore (7), FakeKanjStore (21). Для каждого: заменить подставку на `Substitute.For<>()` + `Returns`, семантику состояния воспроизвести в конфигурации, тесты перетипизировать на интерфейс | решение владельца 2026-09-11 | P2 | BACKLOG | | TD-STYLE-ANALYZERS | Остаток мягких правил код-стайла: `var` для встроенных/неочевидных типов (1529, сейчас `silent`), дедупликация `<summary>``<inheritdoc/>` (Roslyn), решение по переводам строк (`.editorconfig` = CRLF, фактически 231 CRLF / 697 LF). Уже закрыто в `.editorconfig` (+build-проверка): запрет `this.` и именование приватных полей (`_camelCase`; `const`/`static readonly` — Pascal). Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | аудит 2026-09-11 | P3 | BACKLOG |
| TD-STYLE-ANALYZERS | Остаток мягких правил код-стайла. **Закрыто (2026-09-11):** (1) `var` — включён ломающий сборку гейт только для встроенных типов (`csharp_style_var_for_built_in_types = false:warning`), остаток выправлен `dotnet format style --diagnostics IDE0008` по 5 sln; режимы «очевидный/прочий тип» — silent осознанно (~1600 субъективных замен); (2) дедупликация `<summary>` — дублей нет (см. TD-COMMENTS-IFACE); (3) переводы строк — **решено: LF** (`.gitattributes` `* text=auto eol=lf`, `.editorconfig` → lf, 1029 файлов нормализовано, `git add --renormalize`; попутно починены 42 CRLF-.sh — до этого первый прогон удалённого CI падал бы). `this.` и именование приватных полей уже закрыты в `.editorconfig` | аудит 2026-09-11 | P3 | DONE |
## 2. Инфраструктура и эксплуатация ## 2. Инфраструктура и эксплуатация
@@ -46,22 +45,22 @@
| BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE | | BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE |
| BL-LOG-ACTOR | **Сделано (2026-09-11):** access-лог HTTP core (`HttpAccessLogMiddleware`) включает `actor` (login пользователя тенанта либо оператора) и `tenant` (id тенанта) — их берут из `HttpContext.Items` (Session/OperatorSession middleware) | этап 12, T6 | P3 | DONE | | BL-LOG-ACTOR | **Сделано (2026-09-11):** access-лог HTTP core (`HttpAccessLogMiddleware`) включает `actor` (login пользователя тенанта либо оператора) и `tenant` (id тенанта) — их берут из `HttpContext.Items` (Session/OperatorSession middleware) | этап 12, T6 | P3 | DONE |
| BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG | | BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG |
| BL-SUSPICIOUS | **Сделано (2026-09-11):** детектор `SuspiciousActivityService` расширен правилом `distinct_logins_per_ip` (перебор разных логинов с одного IP, порог `DistinctLoginsPerIpThreshold`); плюс real-time `SuspiciousActivityReporter` — метрика `deal.security.suspicious{kind}` + warn-лог на 429 rate limiter (`rate_limit`) и блокировке входа (`login_blocked`) | ТЗ §10.5, этап 12 | P3 | DONE | | BL-SUSPICIOUS | Расширение детектора подозрительной активности (правила/пороги по логам безопасности) | ТЗ §10.5, этап 12 | P3 | BACKLOG |
## 5. Технический долг (качество/архитектура) ## 5. Технический долг (качество/архитектура)
| ID | Пункт | Источник | Приоритет | Статус | | ID | Пункт | Источник | Приоритет | Статус |
|---|---|---|---|---| |---|---|---|---|---|
| TD-SETTINGS-UI | Вынос вкладок `SettingsView` в компоненты. **Сделано (2026-09-11):** `SettingsView.vue` — только набор вкладок/QR-опрос, все 10 вкладок — отдельные компоненты (`components/settings/*`) | ревью 2026-09-08 | P3 | DONE | | TD-SETTINGS-UI | Вынос оставшихся вкладок `SettingsView` в отдельные компоненты (частично сделано) | ревью 2026-09-08 | P3 | TECHDEBT |
| TD-VIRT | Полная виртуализация длинных колонок. **Решение (2026-09-11): DEFERRED** — прогрессивный рендер «Показать ещё» покрывает текущие объёмы; виртуализация — при росте списков | ревью, этап 12 | P3 | DEFERRED | | TD-VIRT | Полная виртуализация длинных колонок (сейчас — прогрессивный рендер «Показать ещё») | ревью, этап 12 | P3 | TECHDEBT |
| TD-SSE-DEAD | **Сделано (2026-09-11):** мёртвые SSE-ветки фронта `boards_changed`/`pipeline_stats` удалены из `store/lifecycle.js` (core их не публикует) | этап 12, E | P3 | DONE | | TD-SSE-DEAD | **Сделано (2026-09-11):** мёртвые SSE-ветки фронта `boards_changed`/`pipeline_stats` удалены из `store/lifecycle.js` (core их не публикует) | этап 12, E | P3 | DONE |
| TD-DBL-CLICK | **Сделано (2026-09-11):** перезагрузка доски при batch-переклассификации коалесцируется `scheduleBoardReload()` (ответ + SSE → один запрос) | этап 12, E | P3 | DONE | | TD-DBL-CLICK | **Сделано (2026-09-11):** перезагрузка доски при batch-переклассификации коалесцируется `scheduleBoardReload()` (ответ + SSE → один запрос) | этап 12, E | P3 | DONE |
| TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT | | TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT |
| TD-OLD-DOCS | Исторические доки несут старые термины под пометками. **Проверено (2026-09-11):** `docs/superpowers/plans/*` и старые `docs/architecture/2026-09-0*` имеют шапку «Исторический документ»; переписывать не нужно | docs sweep | P3 | DONE | | TD-OLD-DOCS | Исторические доки несут старые термины под пометками. **Проверено (2026-09-11):** `docs/superpowers/plans/*` и старые `docs/architecture/2026-09-0*` имеют шапку «Исторический документ»; переписывать не нужно | docs sweep | P3 | DONE |
| TD-SOURCE-PROVIDER | Провайдеры содержимого источников. **Сделано (2026-09-11):** `TelegramSourceContentProvider` + `ReadSource` RPC + `GET /api/cards/{id}/source` + UI «Обновить из источника». Осталось: провайдеры прочих источников по мере появления | generic source 2026-09-11 | P2 | TECHDEBT | | TD-SOURCE-PROVIDER | Провайдеры содержимого источников. **Сделано (2026-09-11):** `TelegramSourceContentProvider` + `ReadSource` RPC + `GET /api/cards/{id}/source` + UI «Обновить из источника». Осталось: провайдеры прочих источников по мере появления | generic source 2026-09-11 | P2 | TECHDEBT |
| TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером. **Решение (2026-09-11): медиа-посты Telegram пропускаем** — извлечение/выгрузка не делаются; вложений у прочих источников пока нет — **DEFERRED** (контракт `DataRef` готов, включается при появлении такого источника) | generic source 2026-09-11 | P3 | DEFERRED | | TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером. **Решение (2026-09-11): медиа-посты Telegram пропускаем** — извлечение/выгрузка не делаются. Остались на будущее: вложения прочих источников (файл/диск/таблица) и `ISourceContentProvider` для remote-просмотра | generic source 2026-09-11 | P3 | BACKLOG |
| TD-TG-CORE-SPLIT | Перенос оставшейся Telegram-специфики ядра в telegram-сервис. **Закрыто (2026-09-11): не требуется.** Задача «дашборды/карточки не знают о Telegram» решена generic-контрактом источника; оставшиеся `TelegramStore`/`Dialogs`/`TgMessages`/Discovery — это состояние тенанта (ядро — владелец данных, telegram-service — stateless-шлюз), перенос отдал бы шлюзу доступ к схеме тенанта | generic source 2026-09-11 | — | CLOSED | | TD-TG-CORE-SPLIT | Перенос оставшейся Telegram-специфики ядра в telegram-сервис. **Закрыто (2026-09-11): не требуется.** Задача «дашборды/карточки не знают о Telegram» решена generic-контрактом источника; оставшиеся `TelegramStore`/`Dialogs`/`TgMessages`/Discovery — это состояние тенанта (ядро — владелец данных, telegram-service — stateless-шлюз), перенос отдал бы шлюзу доступ к схеме тенанта | generic source 2026-09-11 | — | CLOSED |
| TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`). **Решение (2026-09-11): DEFERRED** — форматы стабильны, расширяемость под источник добавляется при конкретной потребности | generic source 2026-09-11 | P3 | DEFERRED | | TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`) — вынести в расширяемые правила источников | generic source 2026-09-11 | P3 | BACKLOG |
| TD-APIMAP-COUNT | Ручной подсчёт числа ручек в `api-map`. **Сделано (2026-09-11):** сверил счётчики §3.1–§3.8 с фактическими строками (рассинхрон §3.5 — 14→15 из-за `GET /cards/{id}/source`); в §3 добавлено правило обновлять счётчики | docs sweep | P3 | DONE | | TD-APIMAP-COUNT | Ручной подсчёт числа ручек в `api-map`. **Сделано (2026-09-11):** сверил счётчики §3.1–§3.8 с фактическими строками (рассинхрон §3.5 — 14→15 из-за `GET /cards/{id}/source`); в §3 добавлено правило обновлять счётчики | docs sweep | P3 | DONE |
## 6. Manual-проверки (нужны внешние условия) ## 6. Manual-проверки (нужны внешние условия)
@@ -81,7 +80,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 +89,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` не блокируют разработку; выполняются, когда владелец даёт креды/хост.
-19
View File
@@ -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
}
}
-137
View File
@@ -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
View File
@@ -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:
-14
View File
@@ -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
-34
View File
@@ -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`).
-18
View File
@@ -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
-53
View File
@@ -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), 0100%.",
"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
-40
View File
@@ -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 }})."
-15
View File
@@ -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"]
-28
View File
@@ -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
+10 -9
View File
@@ -136,19 +136,17 @@
| `POST /dialogs/{dialog_id}/backfill` | Догнать сообщения одного диалога | — | `{ok: true, processed: int}` *(фронт не вызывает — только сервер)* | | `POST /dialogs/{dialog_id}/backfill` | Догнать сообщения одного диалога | — | `{ok: true, processed: int}` *(фронт не вызывает — только сервер)* |
| `POST /dialogs/preview` | Последние сообщения диалога (свежие из TG, старые из БД) | `{dialogId, limit?=24 (clamp 1..50)}` | `{items: [§4.11 сообщение]}` | | `POST /dialogs/preview` | Последние сообщения диалога (свежие из TG, старые из БД) | `{dialogId, limit?=24 (clamp 1..50)}` | `{items: [§4.11 сообщение]}` |
### 3.4 Settings / rates / meta (settings_routes.py) — 5 ### 3.4 Settings / rates / meta (settings_routes.py) — 6
| METHOD /api/… | Назначение | Request body | Response | | METHOD /api/… | Назначение | Request body | Response |
|---|---|---|---| |---|---|---|---|
| `GET /settings` | Публичные настройки (секреты замаскированы) | — | §4.6 (полный settings) | | `GET /settings` | Публичные настройки (секреты замаскированы) | — | §4.6 (полный settings) |
| `PATCH /settings` | Частичное обновление (см. §4.6 список ключей). Инварианты: `archiveAfterDays` 1..30, `minLen` 10..500, `discJoinLimit` 1..200, `discJoinDelayMin/Max` 5..600 (min≤max), `discEvalSample` 3..30, `discEvalThreshold` 1..100; `myPrompts` ≤100. ⚠ `tgKeys`, `aiProvider` и `aiConfigs` удалены из настроек тенанта — ключи Telegram и конфигурацию ИИ задаёт оператор глобально. ⚠ Ответ — **весь** public settings (фронт затирает локальное состояние ответом) | произвольный dict из публичных ключей | §4.6 | | `PATCH /settings` | Частичное обновление (см. §4.6 список ключей). Инварианты: `archiveAfterDays` 1..30, `minLen` 10..500, `discJoinLimit` 1..200, `discJoinDelayMin/Max` 5..600 (min≤max), `discEvalSample` 3..30, `discEvalThreshold` 1..100; `aiConfigs` apiKey ≥8 → шифруется; `myPrompts` ≤100. ⚠ `tgKeys` удалён из настроек тенанта — ключи Telegram задаёт оператор глобально. ⚠ Ответ — **весь** public settings (фронт затирает локальное состояние ответом) | произвольный dict из публичных ключей | §4.6 |
| `POST /ai/check` | Проверка подключения AI-провайдера | — | `{ok: bool, message: str}` + поля статуса провайдера |
| `GET /rates` | Курсы валют | — | `{base: "RUB", rates: {CODE: num}, source: "cbr"\|"mock", updatedAt: ms\|null}` | | `GET /rates` | Курсы валют | — | `{base: "RUB", rates: {CODE: num}, source: "cbr"\|"mock", updatedAt: ms\|null}` |
| `POST /rates/refresh` | Принудительно обновить курсы (ЦБ/мок) | — | `{ok: bool, rates: {base, rates, source, updatedAt}}` — ⚠ фронт передаёт `r.rates` в `applyRates` | | `POST /rates/refresh` | Принудительно обновить курсы (ЦБ/мок) | — | `{ok: bool, rates: {base, rates, source, updatedAt}}` — ⚠ фронт передаёт `r.rates` в `applyRates` |
| `GET /meta/constants` | Валюты/стадии/палитра | — | `{currencies: [{code,name,symbol}], stages: [§4.4], palette: ["#…"]}` *(фронт не вызывает — зашиты в data.js)* | | `GET /meta/constants` | Валюты/стадии/палитра | — | `{currencies: [{code,name,symbol}], stages: [§4.4], palette: ["#…"]}` *(фронт не вызывает — зашиты в data.js)* |
`POST /ai/check` удалён: проверка связи с провайдером ИИ теперь операторская —
`POST /api/operator/settings/ai-config/check` (см. `2026-09-10-operator-analytics-contract.md`).
### 3.5 Детальные операции карточки (бывший Projects, projects_routes.py) — 15 ### 3.5 Детальные операции карточки (бывший Projects, projects_routes.py) — 15
Все операции — над ресурсом `/api/cards/{cardId}` (см. §4.1); отдельного `/api/projects` больше нет. Все операции — над ресурсом `/api/cards/{cardId}` (см. §4.1); отдельного `/api/projects` больше нет.
@@ -349,12 +347,14 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/
"discJoinLimit": 50, "discJoinDelayMin": 50, "discJoinDelayMax": 70, "discJoinLimit": 50, "discJoinDelayMin": 50, "discJoinDelayMax": 70,
"discEvalSample": 10, "discEvalThreshold": 40, // (не используется фронтом) "discEvalSample": 10, "discEvalThreshold": 40, // (не используется фронтом)
"discPaused": false, "colState": {}, // colState — то же, что GET /columns/state "discPaused": false, "colState": {}, // colState — то же, что GET /columns/state
"aiProvider": "deepseek",
"aiConfigs": { "deepseek": {"baseUrl": "https://api.deepseek.com", "model": "…", "keySet": true, "keyMasked": "sk-12…3456"} },
"providers": [{"id":"deepseek","name":"DeepSeek","base":"…","local":false,"models":[]}, ]
} }
``` ```
Ключи, которые фронт шлёт в PATCH (по одному/группами): `aiPrompt`, `cardPrompt`, `aiFilterPrompt`, `stopPhrases`, `domainDescription`, `domainKeywords`, `hireMarkers`, `levelTerms`, `resumeMarkers`, `blockResumes`, `myPrompts`, `autoArchive`, `archiveAfterDays`, `aiEnabled`, `aiFilterEnabled`, `minLen`, `conversionOn`, `targetCurrency`, `rateSource`, `remindersEnabled`, `mlEnabled`, `wantedType`, `budgetRequiredHire`, `budgetRequiredOrder`, `hireLabel`, `orderLabel`, `autoMonitorNew`, `discJoinLimit`, `discJoinDelayMin`, `discJoinDelayMax`, `discPaused`. Ключи, которые фронт шлёт в PATCH (по одному/группами): `aiProvider`, `aiConfigs{<id>:{baseUrl,model,apiKey?}}`, `aiPrompt`, `cardPrompt`, `aiFilterPrompt`, `stopPhrases`, `domainDescription`, `domainKeywords`, `hireMarkers`, `levelTerms`, `resumeMarkers`, `blockResumes`, `myPrompts`, `autoArchive`, `archiveAfterDays`, `aiEnabled`, `aiFilterEnabled`, `minLen`, `conversionOn`, `targetCurrency`, `rateSource`, `remindersEnabled`, `mlEnabled`, `wantedType`, `budgetRequiredHire`, `budgetRequiredOrder`, `hireLabel`, `orderLabel`, `autoMonitorNew`, `discJoinLimit`, `discJoinDelayMin`, `discJoinDelayMax`, `discPaused`.
⚠ Ответ PATCH — **полный** settings: `schedulePersist`/`saveDiscQuota` применяют его целиком к локальному state (источник истины после клампов). ⚠ Ответ PATCH — **полный** settings: `schedulePersist`/`saveAiSettings`/`saveDiscQuota` применяют его целиком к локальному state (источник истины после клампов).
**Изменение (решение владельца, вариант A):** ключей Telegram (`api_id`/`api_hash`) в настройках тенанта больше нет — они задаются оператором глобально (ТЗ §4.1/§8.1), см. `docs/architecture/2026-09-10-operator-analytics-contract.md` (раздел «Операторские настройки»). Вкладка Telegram у тенанта остаётся (подключение аккаунта, `GET /api/tg/status`). **Изменение (решение владельца, вариант A):** ключей Telegram (`api_id`/`api_hash`) в настройках тенанта больше нет — они задаются оператором глобально (ТЗ §4.1/§8.1), см. `docs/architecture/2026-09-10-operator-analytics-contract.md` (раздел «Операторские настройки»). Вкладка Telegram у тенанта остаётся (подключение аккаунта, `GET /api/tg/status`).
**Изменение (2026-09-14):** настройки ИИ-провайдера (`aiProvider`, `aiConfigs`, `providers`) из настроек тенанта убраны — провайдера, модель, адрес и ключ задаёт оператор в консоли (раздел «ИИ», `GET/PUT /api/operator/settings/ai-config`); все ИИ-вызовы всех пользователей идут на эту конфигурацию.
### 4.7 Промпты ### 4.7 Промпты
- `aiPrompt`, `aiFilterPrompt`, `cardPrompt` — plain string, редактируются на вкладке ИИ; содержат плейсхолдеры `{domain}`/`{keywords}`. - `aiPrompt`, `aiFilterPrompt`, `cardPrompt` — plain string, редактируются на вкладке ИИ; содержат плейсхолдеры `{domain}`/`{keywords}`.
@@ -381,6 +381,7 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/
`{passed, wouldCreateCard, targetContainer, matchHits, parsed, stages:[{stage, pass, skipped, reason, kw, label}]}`. `{passed, wouldCreateCard, targetContainer, matchHits, parsed, stages:[{stage, pass, skipped, reason, kw, label}]}`.
Коды `stage`: `length|stop|resume|type|exclude|ml|ai|spam_ai|budget`; `skipped=true` — этап выключен Коды `stage`: `length|stop|resume|type|exclude|ml|ai|spam_ai|budget`; `skipped=true` — этап выключен
настройкой. `parsed` — разбор текста (поля карточки) либо null. Запись в систему не производится. настройкой. `parsed` — разбор текста (поля карточки) либо null. Запись в систему не производится.
- **`POST /api/ai/check`**: `{ok: bool, message: string, local?, keySet?}`.
- Комментарии карточки: `{id, by: string, text, time: string}``by` всегда «Вы», `time` «только что». - Комментарии карточки: `{id, by: string, text, time: string}``by` всегда «Вы», `time` «только что».
--- ---
@@ -399,7 +400,7 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/
|---|---:| |---|---:|
| Auth `/api/auth` | 4 | | Auth `/api/auth` | 4 |
| Telegram `/api/tg` | 14 | | Telegram `/api/tg` | 14 |
| Settings/rates/meta (`/api/settings`, `/api/rates`) | 4 | | Settings/rates/meta (`/api/settings`, `/api/ai/check`, `/api/rates`) | 5 |
| Processing `/api/pipeline` (+ `/api/admin/check-message`) | 7 | | Processing `/api/pipeline` (+ `/api/admin/check-message`) | 7 |
| ML `/api/ml` | 5 | | ML `/api/ml` | 5 |
| Discovery `/api/discovery` | 13 | | Discovery `/api/discovery` | 13 |
@@ -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`.
@@ -268,73 +250,3 @@
> Примечание для вкладки Telegram у тенанта: `GET /api/tg/status` остаётся (подключение аккаунта), > Примечание для вкладки Telegram у тенанта: `GET /api/tg/status` остаётся (подключение аккаунта),
> поле `keysSet` отражает глобальные ключи; команды `start-phone`/`start-qr` без ключей отвечают > поле `keysSet` отражает глобальные ключи; команды `start-phone`/`start-qr` без ключей отвечают
> `400 { "detail": "Ключи Telegram не заданы оператором" }`. > `400 { "detail": "Ключи Telegram не заданы оператором" }`.
---
## Операторские настройки: глобальная конфигурация ИИ
Провайдер, модель, адрес API и ключ ИИ задаёт оператор **глобально**, едины для всех тенантов
(в настройках тенанта ключей `aiProvider`/`aiConfigs` больше нет). Хранилище — системная таблица
`public.global_settings` (ключ `aiConfig`), `apiKey` хранится зашифрованным (`enc:`) и наружу
не отдаётся. На эту конфигурацию работают все ИИ-вызовы всех тенантов: классификация, фильтр,
ключи поиска, карточки (`AiProviderConfigBuilder`).
### GET /api/operator/settings/ai-config
Маскированный снимок конфигурации вместе с каталогом провайдеров для выбора.
**200**
```json
{
"providerId": "deepseek",
"baseUrl": "https://api.deepseek.com",
"model": "deepseek-v4-flash",
"keySet": true,
"keyMasked": "sk-o…-123",
"providers": [
{ "id": "deepseek", "name": "DeepSeek", "base": "https://api.deepseek.com", "local": false, "models": ["…"] }
]
}
```
- `providerId` — пусто, если конфигурацию ещё не задавали.
- `baseUrl`/`model` — эффективные значения (адрес каталога, первая модель каталога), если не переопределены.
- `keyMasked`**маска** (пусто / `x…` / `1234…5678`); открытый ключ не возвращается никогда.
- `providers` — каталог `AiProviders` (`id`/`name`/`base`/`local`/`models`); адрес каталогных облачных
провайдеров фиксирован (SSRF-гейт), свой `baseUrl` задаётся только локальным и `custom`.
**Коды**: `200`, `401`.
### PUT /api/operator/settings/ai-config
Сохранение/смена. Поля можно передавать **по отдельности** (частичное обновление): непереданное поле
(`null` или отсутствие в JSON) сохраняет текущее значение. Если конфигурации ещё нет, `providerId` обязателен.
При смене провайдера `baseUrl`/`model`/`apiKey` не переносятся от старого (дефолты каталога); ключ
меняется только при явной передаче (маска не принимается).
**Тело**
```json
{ "providerId": "deepseek", "model": "deepseek-v4-pro", "apiKey": "sk-…" }
```
**200** — маскированный снимок (форма как у GET).
**Ошибки**
- `400 { "detail": "Укажите хотя бы одно поле (providerId, baseUrl, model, apiKey)" }`
- `400 { "detail": "Провайдер не из списка разрешённых" }`
- `400 { "detail": "Конфигурация ИИ ещё не задана — укажите providerId" }`
- `400 { "detail": "Укажите model — у выбранного провайдера нет моделей по умолчанию" }`
- `400 { "detail": "API-ключ должен быть не короче 8 символов, без маски" }`
- `401 { "detail": "Требуется вход оператора" }`
### POST /api/operator/settings/ai-config/check
Проверка связи с сохранённым провайдером (`200` — результат `AiCheckResultDto`). Локальный провайдер
отвечает `ok: true` без HTTP; облачный — запрос к списку моделей (приватные адреса запрещены, SSRF-гейт).
`400 { "detail": "Сначала сохраните конфигурацию ИИ" }` — конфигурации ещё нет.
**Аудит**: событие `ai_config_changed` (актор `operator`, `tenantId: null`, детали
`{providerId, baseUrl, model, keySet}` — без ключа).
+14 -55
View File
@@ -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,43 +221,22 @@
- `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-типы/члены без порта (статика, константы, (`Task ICardStore.GetAsync(...)`), если член не является публичным API класса сам по себе. Если тип
extension-классы), `protected`-члены и DTO/модели. **[изм. 2026-09-13, решение владельца]** реализует член как собственный публичный сервис (нужен в DI/прямых вызовах) — допустима implicit,
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11, но решение осознанное.
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
(напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные
реализации (codemod'ы `scripts/make_explicit.py` и `scripts/strip_implementation_docs.py` — идемпотентны,
`--apply` применяет правки, без флага — dry-run-отчёт).
- Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа. - Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа.
- **Маркерные классы не используются** — если нужен маркер, это маркерный интерфейс
(`IKanbanModule`, `ISharedKernel` и т.п.). **[изм. 2026-09-11]**
- **Тесты: моки — через NSubstitute** (`Substitute.For<IPasswordHasher>()`), тестовые переменные
типизируются интерфейсом. Новые hand-written фейк-классы не заводить; существующие мигрируются
поэтапно (план — `backlog.md`, `TD-TESTS-NSUBSTITUTE`). **[изм. 2026-09-11]**
## 12. Приложение: сводная таблица правил именования ## 12. Приложение: сводная таблица правил именования
@@ -305,8 +263,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`.
@@ -28,35 +28,23 @@
- Тесты: core **1275/1275**, telegram **125/125**, ai **52/52**, ml **38/38** — все пройдены. - Тесты: core **1275/1275**, telegram **125/125**, ai **52/52**, ml **38/38** — все пройдены.
- Повторный прогон renamer: `this.` — 0, полей к переименованию — 0 (идемпотентно). - Повторный прогон renamer: `this.` — 0, полей к переименованию — 0 (идемпотентно).
## 2. Остатки — решения (закрыто 2026-09-11, вечер) ## 2. Осталось — требует решения владельца
1. **`var`закрыто.** В `.editorconfig` включён ломающий сборку гейт `csharp_style_var_for_built_in_types = false:warning` 1. **`var`1529 употреблений.** Правило §4: не использовать для встроенных типов и при неочевидном типе.
(запрет только для встроенных типов — как в §4); режимы «очевидный тип» и «прочие» оставлены `silent` В `.editorconfig` `csharp_style_var_* = false:silent`. Замена требует семантики (вывод типа).
осознанно: правка субъективна и потребовала бы ~1600 механических замен. Остаток встроенных типов **Рекомендация:** включить анализатор (`:warning`) + `dotnet format` с проверкой.
выправлен `dotnet format style --diagnostics IDE0008` по всем 5 решениям (51 файл); сборка 5 sln — 0/0.
2. **Явная реализация интерфейсов (§11) — выполнено (вечер, решение владельца, вариант A).** 161 член
в 30 прод-файлах конвертирован codemod'ом `scripts/make_explicit.py` (частичные классы и многострочные
сигнатуры учтены); потребители конкретных типов перетипизированы на интерфейсы (8 мест в проде,
17 тест-файлов); `Card`/семейство `I*Card` оставлены implicit — это DTO, их члены и есть публичный API.
Правило закреплено в §11 код-стайла: классы напрямую не вызываем (DTO/хелперы/экстеншены — исключения).
3. **Дедупликация `<summary>` через `<inheritdoc/>` — закрыто: дублей нет.** Проверено двумя независимыми
сканами (сопоставление по тексту и по имени члена интерфейса: 39 интерфейсов, 229 задокументированных
членов) — реализаций, дублирующих summary интерфейсного члена, в продакшн-коде нет; случаев
«`<param>` + дублирующий summary» не существует.
4. **Переводы строк — решено: LF.** Обоснование: инструменты проекта (Python/Node-скрипты, codemod'ы) пишут LF;
shell-скрипты с CRLF не работают на Linux CI (`sh scripts/ci.sh` в GitHub Actions); фактическое большинство
файлов уже было LF. Применено: `.gitattributes` (`* text=auto eol=lf` + бинарные исключения),
`.editorconfig``end_of_line = lf`, конвертировано 1029 трекаемых файлов, `git add --renormalize`.
Побочный эффект: починены 42 CRLF-.sh (9 в `scripts/` — до этого первый удалённый прогон CI падал бы).
### Попутно исправлено (2026-09-11, вечер) 2. **Явная реализация интерфейсов (§11).** Субъективное «где возможно» — массовая правка может сломать
DI/прямые вызовы и тесты. **Рекомендация:** точечный ревью по 61 интерфейсу, без автоматизации.
- Повторный прогон `scripts/fix_private_docs.py --apply`: понижено 12 XML-доков на private/internal (extension-файлы). 3. **Дедупликация `<summary>` в реализациях через `/// <inheritdoc/>` (§11).** Надёжно детектируется только
- Добавлены недостающие `<summary>`: `IContainerRules.Keywords`/`Stack`, `ITenantContext.TenantId`/`HasTenant`. по семантической модели (сопоставление интерфейс↔класс). В коде уже 674 `<inheritdoc/>`.
- Переведены на русский англоязычные `//`-комментарии (3 шт. из 18 найденных; остальные — имена **Рекомендация:** Roslyn-анализатор, если нужно добить остаток.
сущностей/заголовки секций тестов, не англоязычный текст).
- Из индекса убраны случайно закоммиченные `archive/**/__pycache__/*.pyc` (2 шт., уже в `.gitignore`). 4. **Переводы строк.** `.editorconfig` требует `end_of_line = crlf`, фактически: **231 файл CRLF / 697 LF**
- STATUS.md: удалён устаревший блок «Осталось (в backlog)» в шапке (пункты закрыты generic-контрактом источника). (смешанно). Правка объёмная. **Рекомендация:** решить — нормализовать под CRLF или зафиксировать LF.
- Дочистка по контрольному скану краткости: удалены 73 очевидных `<param name="ct|cancellationToken">`
(«Токен отмены.» — пересказ сигнатуры, §5) в 17 файлах; ужаты 3 summary (2 многосентенционных, 1 длинное). ## 3. Примечание
Контроль: `<remarks>` — 0, inline-`<summary>` — 0, многосентенционных summary — 0, TODO — 0.
Пункты 2.1, 2.3 можно закрыть анализаторами Roslyn в `Directory.Build.props` — это даст автоматическую
проверку на новом коде. Пункт 2.4 — разовое решение по политике переводов строк.
@@ -189,9 +189,9 @@ Telegram-аккаунт, выбирает каналы/группы для мо
## 8. Настройки тенанта ## 8. Настройки тенанта
- Telegram: ключи приложения (оператор), подключение аккаунта, авто-мониторинг новых. - Telegram: ключи приложения (оператор), подключение аккаунта, авто-мониторинг новых.
- ИИ: провайдер (один; включая локальные), модель и ключ задаёт **оператор** глобально (едины для всех - ИИ: провайдер (один; включая локальные), модель, ключ (хранится зашифрованно),
тенантов; в консоли оператора раздел «ИИ», ключ хранится зашифрованно); пользователю — промпты промпты (базовый + свой), библиотека готовых промптов по сферам + «мои промпты»,
(базовый + свой), библиотека готовых промптов по сферам + «мои промпты», вкл/выкл ИИ, вкл/выкл ИИ-фильтр. вкл/выкл ИИ, вкл/выкл ИИ-фильтр.
- ML: вкл/выкл, обучение на действиях, проверка на сообщении/канале, сброс, самооценка - ML: вкл/выкл, обучение на действиях, проверка на сообщении/канале, сброс, самооценка
(«ML справляется с последними N сообщениями — ИИ можно отключить»). («ML справляется с последними N сообщениями — ИИ можно отключить»).
- Обработка: стоп-фразы, длина, резюме, тип заявки, домен/ключи, маркеры найма/заказа. - Обработка: стоп-фразы, длина, резюме, тип заявки, домен/ключи, маркеры найма/заказа.
@@ -219,8 +219,6 @@ Telegram-аккаунт, выбирает каналы/группы для мо
## 10. Админка оператора ## 10. Админка оператора
- Тенанты: создание, инвайты, статус, лимиты/бюджеты, приостановка. - Тенанты: создание, инвайты, статус, лимиты/бюджеты, приостановка.
- Глобальные настройки сервиса: ключи приложения Telegram и конфигурация ИИ-провайдера
(провайдер/модель/baseUrl/ключ; ключ зашифрован, наружу — маска) с проверкой связи.
- Health всех сервисов и очередей. - Health всех сервисов и очередей.
- Аудит: входы/выходы, инвайты, impersonation, действия оператора и пользователей тенанта - Аудит: входы/выходы, инвайты, impersonation, действия оператора и пользователей тенанта
(создание/перенос/удаление карточек, комментарии, контейнеры, настройки, каналы). (создание/перенос/удаление карточек, комментарии, контейнеры, настройки, каналы).
+4 -33
View File
@@ -17,38 +17,9 @@
> единый CI (`scripts/ci.sh`). Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1326/1326 PASS**, > единый CI (`scripts/ci.sh`). Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1326/1326 PASS**,
> telegram **130/130**, ai **52/52**, ml **38/38**, storage **9/9**, фронт `build` + `lint:i18n` зелёные. > telegram **130/130**, ai **52/52**, ml **38/38**, storage **9/9**, фронт `build` + `lint:i18n` зелёные.
> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`. > Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
> > Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
> **2026-09-11 (вечер) — закрыты остатки код-стайла (TD-COMMENTS-IFACE, TD-STYLE-ANALYZERS).** Дедупликация > telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
> `<summary>`: дублей нет (сканы по тексту и по имени члена — 39 интерфейсов/229 членов). `var`: гейт > (`TelegramStore`, `Dialogs`/`TgMessages`, Discovery) в telegram-сервис.
> `csharp_style_var_for_built_in_types = false:warning` (ломает сборку), остаток выправлен `dotnet format`
> по 5 sln (51 файл), «очевидный/прочий тип» — silent осознанно. Переводы строк: решено LF — `.gitattributes`
> (`* text=auto eol=lf`), `.editorconfig` → lf, нормализовано 1029 файлов; попутно починены 42 CRLF-.sh
> (первый прогон удалённого CI падал бы). Понижено 12 новых private XML-доков; добавлены 4 `<summary>`
> членам интерфейсов; переведены 3 англоязычных комментария; из индекса убраны 2 `__pycache__/*.pyc`;
> STATUS.md — удалён устаревший блок «Осталось (в backlog)» в шапке. Дочистка по контрольному скану
> краткости: удалены 73 очевидных `<param name="ct">` (пересказ сигнатуры) в 17 файлах, ужаты 3 summary
> (многосентенционные/длинные); контроль: `<remarks>` 0, inline-`<summary>` 0, многосентенционных 0, TODO 0. Явные реализации интерфейсов (§11) —
> остались точечным ревью владельца (43 интерфейса с реализациями, массовая правка не автоматизируется).
> Сборка 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.
>
> **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, решение владельца).** Правило
> владельца: классы напрямую не вызываем (исключения — DTO, хелперы, экстеншены), тесты — через
> интерфейсы, моки — NSubstitute, маркерные классы не используем. Сделано: 161 член в 30 прод-файлах
> переведён на явные реализации codemod'ом `scripts/make_explicit.py`; потребители конкретных типов
> перетипизированы на интерфейсы (8 мест в проде, 17 тест-файлов; самовызовы — `((ISessionClient)this)`);
> 10 маркерных классов заменены маркерными интерфейсами (`IKanbanModule`…`ISharedKernel`); NSubstitute 6.1.0
> подключён к 5 тест-проектам, эталон миграции — `FakePasswordHasher` → `TestHashers.New()` (фейк удалён);
> правила зафиксированы в §11 код-стайла. Card/`I*Card` — implicit (DTO). Оставшиеся 30 фейков —
> поэтапная миграция (`backlog.md`, TD-TESTS-NSUBSTITUTE). Build 5 sln 0/0, тесты зелёные.
**Все этапы 0–12 выполнены (100%)** — см. roadmap **Все этапы 0–12 выполнены (100%)** — см. roadmap
> `docs/superpowers/plans/2026-09-05-deal-roadmap.md`, план этапа 10 > `docs/superpowers/plans/2026-09-05-deal-roadmap.md`, план этапа 10
@@ -164,7 +135,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 областях),
@@ -1,35 +0,0 @@
# План: закрытие остатков код-стайла (2026-09-11, вечер)
> Источник: `backlog.md``TD-COMMENTS-IFACE` (п.3, п.4), `TD-STYLE-ANALYZERS`, найденное при проверке
> проекта. Правила — `docs/spec/Код-стайл-Дейл.md`, отчёт — `docs/spec/Код-стайл-аудит-2026-09-11.md` §2.
> Ограничения захода: без поднятия Docker-стека и без внешних кредов.
## Задачи
1. **Замер остатков** (dry-run, без правок): сканами по тексту и по имени члена проверить дубли
`<summary>` реализации ↔ интерфейса; разбивку `var`; латинские комментарии; членов интерфейсов без
дока; TODO; переводы строк по расширениям.
2. **`var` для встроенных типов**: `.editorconfig``csharp_style_var_for_built_in_types = false:warning`
(гейт ломает сборку), остаток выправить `dotnet format style --diagnostics IDE0008` по 5 решениям.
«Очевидный тип» и «прочие» — оставить `silent` (субъективно, ~1600 замен).
3. **Дедупликация `<summary>`→`<inheritdoc/>`**: по результатам замера — либо codemod, либо закрытие «дублей нет».
4. **Переводы строк**: решение политики + нормализация (`.gitattributes`, `.editorconfig`, конверсия файлов,
`git add --renormalize`); проверить, что `.sh` — LF (Linux CI).
5. **Попутные доки/комментарии**: недостающие `<summary>` членам интерфейсов; англоязычные `//`-комментарии;
повторный прогон `fix_private_docs.py`; устаревший блок в `STATUS.md`; трекаемые `.pyc` из индекса.
6. **Приёмка**: build 5 sln 0/0, все тесты зелёные; обновить `backlog.md`/`STATUS.md`.
## Решения
- Явные реализации интерфейсов (§11) — **не автоматизировать**: остаётся точечным ревью владельца
(замер: 54 интерфейса с XML-doc, 43 с реализациями; массовая правка ломает публичную поверхность классов).
- Переводы строк — **LF** (инструменты проекта пишут LF; CRLF-.sh ломают `sh scripts/ci.sh` на Linux CI;
большинство файлов уже LF). Откат — `git revert` нормализации.
- Гейт `var` — только на встроенные типы: правило §4 запрет говорит про встроенные/неочевидные,
«неочевидность» не проверяется машиной.
## Приёмка
- build 5 sln: 0 warnings / 0 errors (гейт IDE0008 проходит).
- Тесты: core / telegram / ai / ml / storage — зелёные, счётчики в `STATUS.md`.
- Фронт не менялся содержательно (только концы строк) — `build`/`lint:i18n` не прогонялись.
@@ -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 + сессии |
@@ -119,10 +119,8 @@ global_settings(key, value, updated_at)
> `token_usage_events` — история расхода токенов (этап 10, T2; подробнее — §13.10). > `token_usage_events` — история расхода токенов (этап 10, T2; подробнее — §13.10).
> `global_settings` — глобальные настройки уровня сервиса; сейчас хранит ключи приложения Telegram > `global_settings` — глобальные настройки уровня сервиса; сейчас хранит ключи приложения Telegram
> (`telegramKeys`: `api_id`/`api_hash`, hash — в `enc:`) и конфигурацию ИИ-провайдера (`aiConfig`: > (`telegramKeys`: `api_id`/`api_hash`, hash — в `enc:`), которые задаёт **оператор** глобально
> `providerId`/`baseUrl`/`model`/`apiKey` — в `enc:`), которые задаёт **оператор** глобально > (ручки `GET/PUT /api/operator/settings/telegram-keys`); тенант ключи не видит/не задаёт.
> (ручки `GET/PUT /api/operator/settings/telegram-keys` и `/ai-config`, проверка связи —
> `POST /api/operator/settings/ai-config/check`); тенант эти настройки не видит и не задаёт.
> Операторские таблицы этапа 7 (`operators`, `operator_sessions`, `tenant_limits`, `audit_log`) и их > Операторские таблицы этапа 7 (`operators`, `operator_sessions`, `tenant_limits`, `audit_log`) и их
> контур описаны в §13.8. С этапа 12 счётчики распределённого rate-limit и попыток входа — > контур описаны в §13.8. С этапа 12 счётчики распределённого rate-limit и попыток входа —
> `public.rate_limit_counters` (см. §10). > `public.rate_limit_counters` (см. §10).
@@ -238,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`).
@@ -255,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` / экран «Аудит» оператор-консоли.
@@ -302,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_*` при включении.
--- ---
@@ -365,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`
@@ -498,8 +462,7 @@ DEAL_MTLS_ENABLED=0|1 DEAL_MTLS_CERT_PASSWORD=... DEAL_DEFAULT_AI_BU
ответ — полный снимок; секреты наружу только масками `keyMasked`/`apiId`; внутренние ключи ответ — полный снимок; секреты наружу только масками `keyMasked`/`apiId`; внутренние ключи
`ratesCache`/`mlDecisions`/`aiDecisions` не публикуются); `ratesCache`/`mlDecisions`/`aiDecisions` не публикуются);
- шифрование секретов AI/Telegram: AES-256-GCM, в БД — `enc:` + Base64 (ключ — env/file, см. §13.4a); - шифрование секретов AI/Telegram: AES-256-GCM, в БД — `enc:` + Base64 (ключ — env/file, см. §13.4a);
- проверка подключения ИИ: `POST /api/operator/settings/ai-config/check` (операторская; локальный провайдер / - проверка подключения ИИ: `POST /api/ai/check` (локальный провайдер / HTTP-проверка облачного);
HTTP-проверка облачного);
- курсы валют: `GET /api/rates`, `POST /api/rates/refresh` (кэш `ratesCache` в settings; `mock`/ЦБ); - курсы валют: `GET /api/rates`, `POST /api/rates/refresh` (кэш `ratesCache` в settings; `mock`/ЦБ);
- ML-панель на детерминированной заглушке: `GET /api/ml/status`, `POST /api/ml/reset|predict` - ML-панель на детерминированной заглушке: `GET /api/ml/status`, `POST /api/ml/reset|predict`
(candidates → `{items:[]}`, apply → 404 — нет telegram-данных до этапа 6); (candidates → `{items:[]}`, apply → 404 — нет telegram-данных до этапа 6);
@@ -777,30 +740,28 @@ health — `GET /api/health` → `{"ok":true,"service":"deal"}`.
### 4a. Шифрование секретов настроек (ключи AI/Telegram) ### 4a. Шифрование секретов настроек (ключи AI/Telegram)
Секреты хранятся шифротекстом `enc:` + Base64(nonce‖ct‖tag), AES-256-GCM (nonce 12 Б, tag 16 Б). Секреты (`aiConfigs[].apiKey`) хранятся в `settings.ValueJson` шифротекстом:
Ключ шифрования — env `DEAL_ENCRYPTION_KEY` (32 байта в urlsafe-Base64); при отсутствии в dev `enc:` + Base64(nonce‖ct‖tag), AES-256-GCM (nonce 12 Б, tag 16 Б). Ключ шифрования — env
берётся/создаётся файл `<ContentRoot>/data/encryption.key` (путь переопределяется env `DEAL_ENCRYPTION_KEY` (32 байта в urlsafe-Base64); при отсутствии в dev берётся/создаётся файл
`DEAL_ENCRYPTION_KEY_FILE`) — при генерации лог-warning. Невалидный env-ключ — ошибка при старте. `<ContentRoot>/data/encryption.key` (путь переопределяется env `DEAL_ENCRYPTION_KEY_FILE`) —
Наружу секреты не отдаются: только маски `keyMasked` (первые 4 + «…» + последние 4, len≤8 — как есть) при генерации лог-warning. Невалидный env-ключ — ошибка при старте. Наружу секреты не отдаются:
в GET/PATCH `/api/settings` только маски `keyMasked` (первые 4 + «…» + последние 4, len≤8 — как есть)
и `keySet`. и `keySet`.
> Исторический раздел (этап 2). С этапа 12 ключей Telegram (`tgKeys`/`apiId`/`apiHash`) в настройках > Исторический раздел (этап 2). С этапа 12 ключей Telegram (`tgKeys`/`apiId`/`apiHash`) в настройках
> тенанта нет — они задаются **оператором** глобально (таблица `public.global_settings`, > тенанта нет — они задаются **оператором** глобально (таблица `public.global_settings`,
> `GET/PUT /api/operator/settings/telegram-keys`; hash шифруется тем же AES-256-GCM). > `GET/PUT /api/operator/settings/telegram-keys`; hash шифруется тем же AES-256-GCM).
>
> С 2026-09-14 там же живёт и конфигурация ИИ-провайдера (ключ `aiConfig` в `public.global_settings`,
> `GET/PUT /api/operator/settings/ai-config`): провайдер, модель, baseUrl и API-ключ задаёт оператор,
> все ИИ-вызовы всех тенантов идут на эту конфигурацию; в настройках тенанта ключей `aiProvider`/
> `aiConfigs` больше нет.
### 4b. Эндпоинты этапа 2 (настройки тенанта; сессия `deal_session` обязательна, иначе 401) ### 4b. Эндпоинты этапа 2 (настройки тенанта; сессия `deal_session` обязательна, иначе 401)
- `GET /api/settings` — публичный снимок дерева настроек: дефолты модуля, перекрытые - `GET /api/settings` — публичный снимок дерева настроек: дефолты модуля, перекрытые
переопределениями из `settings` тенанта; включает `myPrompts` и колонки, но не настройки переопределениями из `settings` тенанта; включает списки `providers`/`aiConfigs`/`tgKeys`/`myPrompts`.
ИИ-провайдера (они операторские).
`PATCH /api/settings` — частичное обновление (невалидное поле мягко пропускается, ответ — полный `PATCH /api/settings` — частичное обновление (невалидное поле мягко пропускается, ответ — полный
снимок). Побочные эффекты: при `rateSource` — фоновый refresh курсов. Внутренние ключи снимок). Побочные эффекты: при `rateSource` — фоновый refresh курсов. Внутренние ключи
(`ratesCache`, `mlDecisions`, `aiDecisions`) в GET/PATCH не участвуют. (`ratesCache`, `mlDecisions`, `aiDecisions`) в GET/PATCH не участвуют.
- `POST /api/ai/check` — проверка подключения активного провайдера (`aiProvider` + `aiConfigs`, ключ
расшифровывается): локальный провайдер → `ok:true` «Локальный сервер…»; облачный — HTTP `GET
{base}/models`; без ключа → «Не задан API-ключ».
- `GET /api/rates` / `POST /api/rates/refresh` — курсы к RUB (`base` = `RUB`); источник по `rateSource` - `GET /api/rates` / `POST /api/rates/refresh` — курсы к RUB (`base` = `RUB`); источник по `rateSource`
(`mock` — константа, `cbr` — ЦБ РФ, ≤4 запроса/сутки, интервал 6 ч; `USDT`=`USD`); кэш — внутренняя (`mock` — константа, `cbr` — ЦБ РФ, ≤4 запроса/сутки, интервал 6 ч; `USDT`=`USD`); кэш — внутренняя
настройка `ratesCache` `{rates, source, updatedAtMs}`. настройка `ratesCache` `{rates, source, updatedAtMs}`.
@@ -1154,9 +1115,8 @@ docker compose -f deploy/compose.dev.yml down # погасить ст
(`PUT /api/operator/settings/telegram-keys`, hash шифруется) → `POST /api/tg/start-qr` → QR-скан → (`PUT /api/operator/settings/telegram-keys`, hash шифруется) → `POST /api/tg/start-qr` → QR-скан →
фаза `ready` («Telegram подключён, сессия сохранена»), затем реальные диалоги/мониторинг/«Перечитать»/ фаза `ready` («Telegram подключён, сессия сохранена»), затем реальные диалоги/мониторинг/«Перечитать»/
discovery-поиск и вступления. В настройках тенанта ключей нет (решение владельца, вариант A). discovery-поиск и вступления. В настройках тенанта ключей нет (решение владельца, вариант A).
- LLM: оператор задаёт провайдера и модель в консоли (`PUT /api/operator/settings/ai-config`, напр. DeepSeek - LLM: `PATCH /api/settings` `aiConfigs`/`aiProvider` (напр. DeepSeek или локальный OpenAI-совместимый) →
или локальный OpenAI-совместимый) → `POST /api/operator/settings/ai-config/check`; реальная `POST /api/ai/check`; реальная классификация/фильтр/генерация ключей при `Services__Ai__UseLocal=false`.
классификация/фильтр/генерация ключей при `Services__Ai__UseLocal=false`.
- Сквозной smoke стека — `scripts/dev-smoke.sh` (одна команда; Docker Desktop должен быть поднят). - Сквозной smoke стека — `scripts/dev-smoke.sh` (одна команда; Docker Desktop должен быть поднят).
### 8. Этап 7 — SaaS-контур (Tasks 114; бэкапы — §13.9; финальные доки — Task 16): оператор/инвайты/лимиты/аудит/rate-limit/mTLS/логи/compose-prod ### 8. Этап 7 — SaaS-контур (Tasks 114; бэкапы — §13.9; финальные доки — Task 16): оператор/инвайты/лимиты/аудит/rate-limit/mTLS/логи/compose-prod
@@ -1192,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.
@@ -1346,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 —
@@ -1429,10 +1387,7 @@ docker compose -f deploy/compose.dev.yml start core telegram-service ml-service
- **`/api/operator/health` (§10.2).** Добавлены `queues:{pipeline,mlOutbox}` и `sessions:{active}` - **`/api/operator/health` (§10.2).** Добавлены `queues:{pipeline,mlOutbox}` и `sessions:{active}`
(общий `RuntimeDepthsCollector`, без дублей SQL). (общий `RuntimeDepthsCollector`, без дублей SQL).
- **Подозрительная активность (§10.5).** `SuspiciousActivityService` + `GET /api/operator/analytics/suspicious` - **Подозрительная активность (§10.5).** `SuspiciousActivityService` + `GET /api/operator/analytics/suspicious`
(всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту, перебор разных (всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту; пороги — константы).
логинов с одного IP `distinct_logins_per_ip`; пороги — константы). Плюс real-time учёт
`SuspiciousActivityReporter`: метрика `deal.security.suspicious{kind}` и предупреждающий лог на 429
rate limiter (`rate_limit`) и блокировке входа (`login_blocked`).
- **«Открыть исходник» на карточке (§6.6)** и **темы оформления (§8.12, §15)** — во фронтенде. - **«Открыть исходник» на карточке (§6.6)** и **темы оформления (§8.12, §15)** — во фронтенде.
Итог: core-тесты **1275/1275**; фронт `npm run build` + `lint:i18n` зелёные. Контракт API — Итог: core-тесты **1275/1275**; фронт `npm run build` + `lint:i18n` зелёные. Контракт API —
@@ -184,13 +184,12 @@
**Настройки → Telegram:** подключение аккаунта, авто-мониторинг новых чатов. **Настройки → Telegram:** подключение аккаунта, авто-мониторинг новых чатов.
**Настройки → ИИ:** **Настройки → ИИ:**
- вкл/выкл ИИ и ИИ-фильтр. Если ML уже уверенно обрабатывает поток — система подскажет, - провайдер и модель (можно выбрать один, включая локальные OpenAI-совместимые);
что ИИ можно отключить; - ключ API (хранится зашифрованно);
- **промпты**: базовый (не меняется) + свой промпт; библиотека готовых промптов по сферам - **промпты**: базовый (не меняется) + свой промпт; библиотека готовых промптов по сферам
с поиском и категориями; сохранённые свои промпты («Мои промпты»). с поиском и категориями; сохранённые свои промпты («Мои промпты»);
- вкл/выкл ИИ и ИИ-фильтр. Если ML уже уверенно обрабатывает поток — система подскажет,
Провайдера, модель и API-ключ задаёт оператор сервиса — они едины для всех пользователей что ИИ можно отключить.
и в кабинете не настраиваются.
**Настройки → ML:** включение, обучение на ваших действиях, проверка модели на сообщении/канале, **Настройки → ML:** включение, обучение на ваших действиях, проверка модели на сообщении/канале,
сброс обучения, показатели самооценки. сброс обучения, показатели самооценки.
@@ -258,9 +257,6 @@
пространствам и лимитам) с фильтрами по типу события, актору, пространству и периоду; есть пагинация. пространствам и лимитам) с фильтрами по типу события, актору, пространству и периоду; есть пагинация.
- **Аналитика** — обзор за период (число пространств, расход токенов, входы/выходы/неудачные входы), - **Аналитика** — обзор за период (число пространств, расход токенов, входы/выходы/неудачные входы),
расход токенов с группировкой по дням/пространствам/провайдерам/моделям и лента действий. расход токенов с группировкой по дням/пространствам/провайдерам/моделям и лента действий.
- **ИИ** — глобальный провайдер ИИ: выбор провайдера из каталога, модель, адрес API (для локальных
и «Другого») и API-ключ, а также проверка связи. Конфигурация единая для всех пользователей;
ключ хранится зашифрованным и показывается только маской.
- **Состояние системы** — доступность ядра, базы данных и сервисов (Telegram, ИИ, ML). - **Состояние системы** — доступность ядра, базы данных и сервисов (Telegram, ИИ, ML).
> **Dev-окружение:** вход в консоль — `operator`/`operator`. В обычной (прод) сборке учётные > **Dev-окружение:** вход в консоль — `operator`/`operator`. В обычной (прод) сборке учётные
Binary file not shown.
+196
View File
@@ -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"
+15
View File
@@ -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
+45
View File
@@ -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 ОК =="
+184
View File
@@ -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
}
+216
View File
@@ -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
+155
View File
@@ -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()
+108
View File
@@ -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()
+54
View File
@@ -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).
+98
View File
@@ -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
+56
View File
@@ -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);
}
+210
View File
@@ -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"

Some files were not shown because too many files have changed in this diff Show More